From 25dcddfeefc6ad98a93ea78d3ab37713a5c00051 Mon Sep 17 00:00:00 2001 From: Matt Schmitt Date: Wed, 16 May 2018 07:15:59 -0400 Subject: [PATCH] Add HomeKit support for fans (#14351) --- homeassistant/components/homekit/__init__.py | 8 +- homeassistant/components/homekit/const.py | 7 + homeassistant/components/homekit/type_fans.py | 116 ++++++++++++++ .../components/homekit/type_lights.py | 6 +- .../homekit/test_get_accessories.py | 1 + tests/components/homekit/test_type_covers.py | 10 +- tests/components/homekit/test_type_fans.py | 149 ++++++++++++++++++ tests/components/homekit/test_type_lights.py | 6 +- .../homekit/test_type_thermostats.py | 6 +- 9 files changed, 294 insertions(+), 15 deletions(-) create mode 100644 homeassistant/components/homekit/type_fans.py create mode 100644 tests/components/homekit/test_type_fans.py diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py index 028155593fb..41b0791a352 100644 --- a/homeassistant/components/homekit/__init__.py +++ b/homeassistant/components/homekit/__init__.py @@ -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) diff --git a/homeassistant/components/homekit/const.py b/homeassistant/components/homekit/const.py index ce46e84a2ef..adde13cc030 100644 --- a/homeassistant/components/homekit/const.py +++ b/homeassistant/components/homekit/const.py @@ -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 #### diff --git a/homeassistant/components/homekit/type_fans.py b/homeassistant/components/homekit/type_fans.py new file mode 100644 index 00000000000..a3ea027c07e --- /dev/null +++ b/homeassistant/components/homekit/type_fans.py @@ -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 diff --git a/homeassistant/components/homekit/type_lights.py b/homeassistant/components/homekit/type_lights.py index d8a205d7026..dae3579a97a 100644 --- a/homeassistant/components/homekit/type_lights.py +++ b/homeassistant/components/homekit/type_lights.py @@ -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( diff --git a/tests/components/homekit/test_get_accessories.py b/tests/components/homekit/test_get_accessories.py index cdfb858b727..a72f50f6c6f 100644 --- a/tests/components/homekit/test_get_accessories.py +++ b/tests/components/homekit/test_get_accessories.py @@ -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', {}, {}), diff --git a/tests/components/homekit/test_type_covers.py b/tests/components/homekit/test_type_covers.py index dc4caeb35a6..7260ae40c1a 100644 --- a/tests/components/homekit/test_type_covers.py +++ b/tests/components/homekit/test_type_covers.py @@ -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): diff --git a/tests/components/homekit/test_type_fans.py b/tests/components/homekit/test_type_fans.py new file mode 100644 index 00000000000..fc504cc6cbd --- /dev/null +++ b/tests/components/homekit/test_type_fans.py @@ -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 diff --git a/tests/components/homekit/test_type_lights.py b/tests/components/homekit/test_type_lights.py index d9602a6e41f..65a526edcc3 100644 --- a/tests/components/homekit/test_type_lights.py +++ b/tests/components/homekit/test_type_lights.py @@ -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): diff --git a/tests/components/homekit/test_type_thermostats.py b/tests/components/homekit/test_type_thermostats.py index ea592bd63dd..bc5b3219cdf 100644 --- a/tests/components/homekit/test_type_thermostats.py +++ b/tests/components/homekit/test_type_thermostats.py @@ -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):