Add HomeKit support for fans (#14351)

This commit is contained in:
Matt Schmitt 2018-05-16 07:15:59 -04:00 committed by cdce8p
parent e20f88c143
commit 25dcddfeef
9 changed files with 294 additions and 15 deletions

View file

@ -115,6 +115,9 @@ def get_accessory(hass, state, aid, config):
elif features & (SUPPORT_OPEN | SUPPORT_CLOSE):
a_type = 'WindowCoveringBasic'
elif state.domain == 'fan':
a_type = 'Fan'
elif state.domain == 'light':
a_type = 'Light'
@ -202,8 +205,9 @@ class HomeKit():
# pylint: disable=unused-variable
from . import ( # noqa F401
type_covers, type_lights, type_locks, type_security_systems,
type_sensors, type_switches, type_thermostats)
type_covers, type_fans, type_lights, type_locks,
type_security_systems, type_sensors, type_switches,
type_thermostats)
for state in self.hass.states.all():
self.add_bridge_accessory(state)

View file

@ -29,6 +29,7 @@ SERV_AIR_QUALITY_SENSOR = 'AirQualitySensor'
SERV_CARBON_DIOXIDE_SENSOR = 'CarbonDioxideSensor'
SERV_CARBON_MONOXIDE_SENSOR = 'CarbonMonoxideSensor'
SERV_CONTACT_SENSOR = 'ContactSensor'
SERV_FANV2 = 'Fanv2'
SERV_GARAGE_DOOR_OPENER = 'GarageDoorOpener'
SERV_HUMIDITY_SENSOR = 'HumiditySensor' # CurrentRelativeHumidity
SERV_LEAK_SENSOR = 'LeakSensor'
@ -46,6 +47,7 @@ SERV_WINDOW_COVERING = 'WindowCovering'
# CurrentPosition, TargetPosition, PositionState
# #### Characteristics ####
CHAR_ACTIVE = 'Active'
CHAR_AIR_PARTICULATE_DENSITY = 'AirParticulateDensity'
CHAR_AIR_QUALITY = 'AirQuality'
CHAR_BRIGHTNESS = 'Brightness' # Int | [0, 100]
@ -77,9 +79,11 @@ CHAR_NAME = 'Name'
CHAR_OCCUPANCY_DETECTED = 'OccupancyDetected'
CHAR_ON = 'On' # boolean
CHAR_POSITION_STATE = 'PositionState'
CHAR_ROTATION_DIRECTION = 'RotationDirection'
CHAR_SATURATION = 'Saturation' # percent
CHAR_SERIAL_NUMBER = 'SerialNumber'
CHAR_SMOKE_DETECTED = 'SmokeDetected'
CHAR_SWING_MODE = 'SwingMode'
CHAR_TARGET_DOOR_STATE = 'TargetDoorState'
CHAR_TARGET_HEATING_COOLING = 'TargetHeatingCoolingState'
CHAR_TARGET_POSITION = 'TargetPosition' # Int | [0, 100]
@ -88,6 +92,9 @@ CHAR_TARGET_TEMPERATURE = 'TargetTemperature'
CHAR_TEMP_DISPLAY_UNITS = 'TemperatureDisplayUnits'
# #### Properties ####
PROP_MAX_VALUE = 'maxValue'
PROP_MIN_VALUE = 'minValue'
PROP_CELSIUS = {'minValue': -273, 'maxValue': 999}
# #### Device Class ####

View file

@ -0,0 +1,116 @@
"""Class to hold all light accessories."""
import logging
from pyhap.const import CATEGORY_FAN
from homeassistant.components.fan import (
ATTR_DIRECTION, ATTR_OSCILLATING,
DIRECTION_FORWARD, DIRECTION_REVERSE, DOMAIN, SERVICE_OSCILLATE,
SERVICE_SET_DIRECTION, SUPPORT_DIRECTION, SUPPORT_OSCILLATE)
from homeassistant.const import (
ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, STATE_OFF, STATE_ON,
SERVICE_TURN_OFF, SERVICE_TURN_ON)
from . import TYPES
from .accessories import HomeAccessory
from .const import (
CHAR_ACTIVE, CHAR_ROTATION_DIRECTION, CHAR_SWING_MODE, SERV_FANV2)
_LOGGER = logging.getLogger(__name__)
@TYPES.register('Fan')
class Fan(HomeAccessory):
"""Generate a Fan accessory for a fan entity.
Currently supports: state, speed, oscillate, direction.
"""
def __init__(self, *args):
"""Initialize a new Light accessory object."""
super().__init__(*args, category=CATEGORY_FAN)
self._flag = {CHAR_ACTIVE: False,
CHAR_ROTATION_DIRECTION: False,
CHAR_SWING_MODE: False}
self._state = 0
self.chars = []
features = self.hass.states.get(self.entity_id) \
.attributes.get(ATTR_SUPPORTED_FEATURES)
if features & SUPPORT_DIRECTION:
self.chars.append(CHAR_ROTATION_DIRECTION)
if features & SUPPORT_OSCILLATE:
self.chars.append(CHAR_SWING_MODE)
serv_fan = self.add_preload_service(SERV_FANV2, self.chars)
self.char_active = serv_fan.configure_char(
CHAR_ACTIVE, value=0, setter_callback=self.set_state)
if CHAR_ROTATION_DIRECTION in self.chars:
self.char_direction = serv_fan.configure_char(
CHAR_ROTATION_DIRECTION, value=0,
setter_callback=self.set_direction)
if CHAR_SWING_MODE in self.chars:
self.char_swing = serv_fan.configure_char(
CHAR_SWING_MODE, value=0, setter_callback=self.set_oscillating)
def set_state(self, value):
"""Set state if call came from HomeKit."""
if self._state == value:
return
_LOGGER.debug('%s: Set state to %d', self.entity_id, value)
self._flag[CHAR_ACTIVE] = True
service = SERVICE_TURN_ON if value == 1 else SERVICE_TURN_OFF
params = {ATTR_ENTITY_ID: self.entity_id}
self.hass.services.call(DOMAIN, service, params)
def set_direction(self, value):
"""Set state if call came from HomeKit."""
_LOGGER.debug('%s: Set direction to %d', self.entity_id, value)
self._flag[CHAR_ROTATION_DIRECTION] = True
direction = DIRECTION_REVERSE if value == 1 else DIRECTION_FORWARD
params = {ATTR_ENTITY_ID: self.entity_id,
ATTR_DIRECTION: direction}
self.hass.services.call(DOMAIN, SERVICE_SET_DIRECTION, params)
def set_oscillating(self, value):
"""Set state if call came from HomeKit."""
_LOGGER.debug('%s: Set oscillating to %d', self.entity_id, value)
self._flag[CHAR_SWING_MODE] = True
oscillating = True if value == 1 else False
params = {ATTR_ENTITY_ID: self.entity_id,
ATTR_OSCILLATING: oscillating}
self.hass.services.call(DOMAIN, SERVICE_OSCILLATE, params)
def update_state(self, new_state):
"""Update fan after state change."""
# Handle State
state = new_state.state
if state in (STATE_ON, STATE_OFF):
self._state = 1 if state == STATE_ON else 0
if not self._flag[CHAR_ACTIVE] and \
self.char_active.value != self._state:
self.char_active.set_value(self._state)
self._flag[CHAR_ACTIVE] = False
# Handle Direction
if CHAR_ROTATION_DIRECTION in self.chars:
direction = new_state.attributes.get(ATTR_DIRECTION)
if not self._flag[CHAR_ROTATION_DIRECTION] and \
direction in (DIRECTION_FORWARD, DIRECTION_REVERSE):
hk_direction = 1 if direction == DIRECTION_REVERSE else 0
if self.char_direction.value != hk_direction:
self.char_direction.set_value(hk_direction)
self._flag[CHAR_ROTATION_DIRECTION] = False
# Handle Oscillating
if CHAR_SWING_MODE in self.chars:
oscillating = new_state.attributes.get(ATTR_OSCILLATING)
if not self._flag[CHAR_SWING_MODE] and \
oscillating in (True, False):
hk_oscillating = 1 if oscillating else 0
if self.char_swing.value != hk_oscillating:
self.char_swing.set_value(hk_oscillating)
self._flag[CHAR_SWING_MODE] = False

View file

@ -12,7 +12,8 @@ from . import TYPES
from .accessories import HomeAccessory, debounce
from .const import (
SERV_LIGHTBULB, CHAR_COLOR_TEMPERATURE,
CHAR_BRIGHTNESS, CHAR_HUE, CHAR_ON, CHAR_SATURATION)
CHAR_BRIGHTNESS, CHAR_HUE, CHAR_ON, CHAR_SATURATION,
PROP_MAX_VALUE, PROP_MIN_VALUE)
_LOGGER = logging.getLogger(__name__)
@ -61,7 +62,8 @@ class Light(HomeAccessory):
.attributes.get(ATTR_MAX_MIREDS, 500)
self.char_color_temperature = serv_light.configure_char(
CHAR_COLOR_TEMPERATURE, value=min_mireds,
properties={'minValue': min_mireds, 'maxValue': max_mireds},
properties={PROP_MIN_VALUE: min_mireds,
PROP_MAX_VALUE: max_mireds},
setter_callback=self.set_color_temperature)
if CHAR_HUE in self.chars:
self.char_hue = serv_light.configure_char(

View file

@ -37,6 +37,7 @@ def test_customize_options(config, name):
@pytest.mark.parametrize('type_name, entity_id, state, attrs, config', [
('Fan', 'fan.test', 'on', {}, {}),
('Light', 'light.test', 'on', {}, {}),
('Lock', 'lock.test', 'locked', {}, {}),

View file

@ -14,18 +14,18 @@ from tests.components.homekit.test_accessories import patch_debounce
@pytest.fixture(scope='module')
def cls(request):
def cls():
"""Patch debounce decorator during import of type_covers."""
patcher = patch_debounce()
patcher.start()
_import = __import__('homeassistant.components.homekit.type_covers',
fromlist=['GarageDoorOpener', 'WindowCovering,',
'WindowCoveringBasic'])
request.addfinalizer(patcher.stop)
patcher_tuple = namedtuple('Cls', ['window', 'window_basic', 'garage'])
return patcher_tuple(window=_import.WindowCovering,
window_basic=_import.WindowCoveringBasic,
garage=_import.GarageDoorOpener)
yield patcher_tuple(window=_import.WindowCovering,
window_basic=_import.WindowCoveringBasic,
garage=_import.GarageDoorOpener)
patcher.stop()
async def test_garage_door_open_close(hass, cls):

View file

@ -0,0 +1,149 @@
"""Test different accessory types: Fans."""
from collections import namedtuple
import pytest
from homeassistant.components.fan import (
ATTR_DIRECTION, ATTR_OSCILLATING,
DIRECTION_FORWARD, DIRECTION_REVERSE, DOMAIN, SERVICE_OSCILLATE,
SERVICE_SET_DIRECTION, SUPPORT_DIRECTION, SUPPORT_OSCILLATE)
from homeassistant.const import (
ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES,
STATE_ON, STATE_OFF, STATE_UNKNOWN, SERVICE_TURN_ON, SERVICE_TURN_OFF)
from tests.common import async_mock_service
from tests.components.homekit.test_accessories import patch_debounce
@pytest.fixture(scope='module')
def cls():
"""Patch debounce decorator during import of type_fans."""
patcher = patch_debounce()
patcher.start()
_import = __import__('homeassistant.components.homekit.type_fans',
fromlist=['Fan'])
patcher_tuple = namedtuple('Cls', ['fan'])
yield patcher_tuple(fan=_import.Fan)
patcher.stop()
async def test_fan_basic(hass, cls):
"""Test fan with char state."""
entity_id = 'fan.demo'
hass.states.async_set(entity_id, STATE_ON,
{ATTR_SUPPORTED_FEATURES: 0})
await hass.async_block_till_done()
acc = cls.fan(hass, 'Fan', entity_id, 2, None)
assert acc.aid == 2
assert acc.category == 3 # Fan
assert acc.char_active.value == 0
await hass.async_add_job(acc.run)
await hass.async_block_till_done()
assert acc.char_active.value == 1
hass.states.async_set(entity_id, STATE_OFF,
{ATTR_SUPPORTED_FEATURES: 0})
await hass.async_block_till_done()
assert acc.char_active.value == 0
hass.states.async_set(entity_id, STATE_UNKNOWN)
await hass.async_block_till_done()
assert acc.char_active.value == 0
hass.states.async_remove(entity_id)
await hass.async_block_till_done()
assert acc.char_active.value == 0
# Set from HomeKit
call_turn_on = async_mock_service(hass, DOMAIN, SERVICE_TURN_ON)
call_turn_off = async_mock_service(hass, DOMAIN, SERVICE_TURN_OFF)
await hass.async_add_job(acc.char_active.client_update_value, 1)
await hass.async_block_till_done()
assert call_turn_on
assert call_turn_on[0].data[ATTR_ENTITY_ID] == entity_id
hass.states.async_set(entity_id, STATE_ON)
await hass.async_block_till_done()
await hass.async_add_job(acc.char_active.client_update_value, 0)
await hass.async_block_till_done()
assert call_turn_off
assert call_turn_off[0].data[ATTR_ENTITY_ID] == entity_id
async def test_fan_direction(hass, cls):
"""Test fan with direction."""
entity_id = 'fan.demo'
hass.states.async_set(entity_id, STATE_ON, {
ATTR_SUPPORTED_FEATURES: SUPPORT_DIRECTION,
ATTR_DIRECTION: DIRECTION_FORWARD})
await hass.async_block_till_done()
acc = cls.fan(hass, 'Fan', entity_id, 2, None)
assert acc.char_direction.value == 0
await hass.async_add_job(acc.run)
await hass.async_block_till_done()
assert acc.char_direction.value == 0
hass.states.async_set(entity_id, STATE_ON,
{ATTR_DIRECTION: DIRECTION_REVERSE})
await hass.async_block_till_done()
assert acc.char_direction.value == 1
# Set from HomeKit
call_set_direction = async_mock_service(hass, DOMAIN,
SERVICE_SET_DIRECTION)
await hass.async_add_job(acc.char_direction.client_update_value, 0)
await hass.async_block_till_done()
assert call_set_direction[0]
assert call_set_direction[0].data[ATTR_ENTITY_ID] == entity_id
assert call_set_direction[0].data[ATTR_DIRECTION] == DIRECTION_FORWARD
await hass.async_add_job(acc.char_direction.client_update_value, 1)
await hass.async_block_till_done()
assert call_set_direction[1]
assert call_set_direction[1].data[ATTR_ENTITY_ID] == entity_id
assert call_set_direction[1].data[ATTR_DIRECTION] == DIRECTION_REVERSE
async def test_fan_oscillate(hass, cls):
"""Test fan with oscillate."""
entity_id = 'fan.demo'
hass.states.async_set(entity_id, STATE_ON, {
ATTR_SUPPORTED_FEATURES: SUPPORT_OSCILLATE, ATTR_OSCILLATING: False})
await hass.async_block_till_done()
acc = cls.fan(hass, 'Fan', entity_id, 2, None)
assert acc.char_swing.value == 0
await hass.async_add_job(acc.run)
await hass.async_block_till_done()
assert acc.char_swing.value == 0
hass.states.async_set(entity_id, STATE_ON,
{ATTR_OSCILLATING: True})
await hass.async_block_till_done()
assert acc.char_swing.value == 1
# Set from HomeKit
call_oscillate = async_mock_service(hass, DOMAIN, SERVICE_OSCILLATE)
await hass.async_add_job(acc.char_swing.client_update_value, 0)
await hass.async_block_till_done()
assert call_oscillate[0]
assert call_oscillate[0].data[ATTR_ENTITY_ID] == entity_id
assert call_oscillate[0].data[ATTR_OSCILLATING] is False
await hass.async_add_job(acc.char_swing.client_update_value, 1)
await hass.async_block_till_done()
assert call_oscillate[1]
assert call_oscillate[1].data[ATTR_ENTITY_ID] == entity_id
assert call_oscillate[1].data[ATTR_OSCILLATING] is True

View file

@ -15,15 +15,15 @@ from tests.components.homekit.test_accessories import patch_debounce
@pytest.fixture(scope='module')
def cls(request):
def cls():
"""Patch debounce decorator during import of type_lights."""
patcher = patch_debounce()
patcher.start()
_import = __import__('homeassistant.components.homekit.type_lights',
fromlist=['Light'])
request.addfinalizer(patcher.stop)
patcher_tuple = namedtuple('Cls', ['light'])
return patcher_tuple(light=_import.Light)
yield patcher_tuple(light=_import.Light)
patcher.stop()
async def test_light_basic(hass, cls):

View file

@ -16,15 +16,15 @@ from tests.components.homekit.test_accessories import patch_debounce
@pytest.fixture(scope='module')
def cls(request):
def cls():
"""Patch debounce decorator during import of type_thermostats."""
patcher = patch_debounce()
patcher.start()
_import = __import__('homeassistant.components.homekit.type_thermostats',
fromlist=['Thermostat'])
request.addfinalizer(patcher.stop)
patcher_tuple = namedtuple('Cls', ['thermostat'])
return patcher_tuple(thermostat=_import.Thermostat)
yield patcher_tuple(thermostat=_import.Thermostat)
patcher.stop()
async def test_default_thermostat(hass, cls):