From a94a24f6f83508642e220fadf2799789dc32a25b Mon Sep 17 00:00:00 2001 From: Andreas Hartl Date: Tue, 5 Feb 2019 16:11:19 +0100 Subject: [PATCH] Added HomeKit fan speed based on speed_list (#19767) Speed_list needs to be in ascending order. --- homeassistant/components/homekit/const.py | 1 + homeassistant/components/homekit/type_fans.py | 44 +++++++++++-- homeassistant/components/homekit/util.py | 54 +++++++++++++-- tests/components/homekit/test_type_fans.py | 49 +++++++++++++- tests/components/homekit/test_util.py | 66 +++++++++++++++++-- 5 files changed, 194 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/homekit/const.py b/homeassistant/components/homekit/const.py index d0e3d52b363..1b2a4dbf05d 100644 --- a/homeassistant/components/homekit/const.py +++ b/homeassistant/components/homekit/const.py @@ -113,6 +113,7 @@ CHAR_OUTLET_IN_USE = 'OutletInUse' CHAR_ON = 'On' CHAR_POSITION_STATE = 'PositionState' CHAR_ROTATION_DIRECTION = 'RotationDirection' +CHAR_ROTATION_SPEED = 'RotationSpeed' CHAR_SATURATION = 'Saturation' CHAR_SERIAL_NUMBER = 'SerialNumber' CHAR_SMOKE_DETECTED = 'SmokeDetected' diff --git a/homeassistant/components/homekit/type_fans.py b/homeassistant/components/homekit/type_fans.py index 2b4e55c4c8d..dcc93b7cf9e 100644 --- a/homeassistant/components/homekit/type_fans.py +++ b/homeassistant/components/homekit/type_fans.py @@ -4,17 +4,20 @@ 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) + ATTR_DIRECTION, ATTR_OSCILLATING, ATTR_SPEED, ATTR_SPEED_LIST, + DIRECTION_FORWARD, DIRECTION_REVERSE, DOMAIN, SERVICE_OSCILLATE, + SERVICE_SET_DIRECTION, SERVICE_SET_SPEED, SUPPORT_DIRECTION, + SUPPORT_OSCILLATE, SUPPORT_SET_SPEED) from homeassistant.const import ( - ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, SERVICE_TURN_OFF, - SERVICE_TURN_ON, STATE_OFF, STATE_ON) + ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, SERVICE_TURN_OFF, SERVICE_TURN_ON, + STATE_OFF, STATE_ON) from . import TYPES -from .accessories import HomeAccessory +from .accessories import debounce, HomeAccessory from .const import ( - CHAR_ACTIVE, CHAR_ROTATION_DIRECTION, CHAR_SWING_MODE, SERV_FANV2) + CHAR_ACTIVE, CHAR_ROTATION_DIRECTION, CHAR_ROTATION_SPEED, CHAR_SWING_MODE, + SERV_FANV2) +from .util import HomeKitSpeedMapping _LOGGER = logging.getLogger(__name__) @@ -41,12 +44,18 @@ class Fan(HomeAccessory): chars.append(CHAR_ROTATION_DIRECTION) if features & SUPPORT_OSCILLATE: chars.append(CHAR_SWING_MODE) + if features & SUPPORT_SET_SPEED: + speed_list = self.hass.states.get(self.entity_id) \ + .attributes.get(ATTR_SPEED_LIST) + self.speed_mapping = HomeKitSpeedMapping(speed_list) + chars.append(CHAR_ROTATION_SPEED) serv_fan = self.add_preload_service(SERV_FANV2, chars) self.char_active = serv_fan.configure_char( CHAR_ACTIVE, value=0, setter_callback=self.set_state) self.char_direction = None + self.char_speed = None self.char_swing = None if CHAR_ROTATION_DIRECTION in chars: @@ -54,6 +63,10 @@ class Fan(HomeAccessory): CHAR_ROTATION_DIRECTION, value=0, setter_callback=self.set_direction) + if CHAR_ROTATION_SPEED in chars: + self.char_speed = serv_fan.configure_char( + CHAR_ROTATION_SPEED, value=0, setter_callback=self.set_speed) + if CHAR_SWING_MODE in chars: self.char_swing = serv_fan.configure_char( CHAR_SWING_MODE, value=0, setter_callback=self.set_oscillating) @@ -83,6 +96,15 @@ class Fan(HomeAccessory): ATTR_OSCILLATING: oscillating} self.call_service(DOMAIN, SERVICE_OSCILLATE, params, oscillating) + @debounce + def set_speed(self, value): + """Set state if call came from HomeKit.""" + _LOGGER.debug('%s: Set speed to %d', self.entity_id, value) + speed = self.speed_mapping.speed_to_states(value) + params = {ATTR_ENTITY_ID: self.entity_id, + ATTR_SPEED: speed} + self.call_service(DOMAIN, SERVICE_SET_SPEED, params, speed) + def update_state(self, new_state): """Update fan after state change.""" # Handle State @@ -104,6 +126,14 @@ class Fan(HomeAccessory): self.char_direction.set_value(hk_direction) self._flag[CHAR_ROTATION_DIRECTION] = False + # Handle Speed + if self.char_speed is not None: + speed = new_state.attributes.get(ATTR_SPEED) + hk_speed_value = self.speed_mapping.speed_to_homekit(speed) + if hk_speed_value is not None and \ + self.char_speed.value != hk_speed_value: + self.char_speed.set_value(hk_speed_value) + # Handle Oscillating if self.char_swing is not None: oscillating = new_state.attributes.get(ATTR_OSCILLATING) diff --git a/homeassistant/components/homekit/util.py b/homeassistant/components/homekit/util.py index 10fdc07e7b4..7ad0cea48e7 100644 --- a/homeassistant/components/homekit/util.py +++ b/homeassistant/components/homekit/util.py @@ -1,17 +1,19 @@ """Collection of useful functions for the HomeKit component.""" +from collections import namedtuple, OrderedDict import logging import voluptuous as vol -from homeassistant.components import media_player -from homeassistant.core import split_entity_id +from homeassistant.components import fan, media_player from homeassistant.const import ( ATTR_CODE, ATTR_SUPPORTED_FEATURES, CONF_NAME, CONF_TYPE, TEMP_CELSIUS) +from homeassistant.core import split_entity_id import homeassistant.helpers.config_validation as cv import homeassistant.util.temperature as temp_util + from .const import ( - CONF_FEATURE, CONF_FEATURE_LIST, HOMEKIT_NOTIFY_ID, FEATURE_ON_OFF, - FEATURE_PLAY_PAUSE, FEATURE_PLAY_STOP, FEATURE_TOGGLE_MUTE, TYPE_FAUCET, + CONF_FEATURE, CONF_FEATURE_LIST, FEATURE_ON_OFF, FEATURE_PLAY_PAUSE, + FEATURE_PLAY_STOP, FEATURE_TOGGLE_MUTE, HOMEKIT_NOTIFY_ID, TYPE_FAUCET, TYPE_OUTLET, TYPE_SHOWER, TYPE_SPRINKLER, TYPE_SWITCH, TYPE_VALVE) _LOGGER = logging.getLogger(__name__) @@ -110,6 +112,50 @@ def validate_media_player_features(state, feature_list): return True +SpeedRange = namedtuple('SpeedRange', ('start', 'target')) +SpeedRange.__doc__ += """ Maps Home Assistant speed \ +values to percentage based HomeKit speeds. +start: Start of the range (inclusive). +target: Percentage to use to determine HomeKit percentages \ +from HomeAssistant speed. +""" + + +class HomeKitSpeedMapping: + """Supports conversion between Home Assistant and HomeKit fan speeds.""" + + def __init__(self, speed_list): + """Initialize a new SpeedMapping object.""" + if speed_list[0] != fan.SPEED_OFF: + _LOGGER.warning("%s does not contain the speed setting " + "%s as its first element. " + "Assuming that %s is equivalent to 'off'.", + speed_list, fan.SPEED_OFF, speed_list[0]) + self.speed_ranges = OrderedDict() + list_size = len(speed_list) + for index, speed in enumerate(speed_list): + # By dividing by list_size -1 the following + # desired attributes hold true: + # * index = 0 => 0%, equal to "off" + # * index = len(speed_list) - 1 => 100 % + # * all other indices are equally distributed + target = index * 100 / (list_size - 1) + start = index * 100 / list_size + self.speed_ranges[speed] = SpeedRange(start, target) + + def speed_to_homekit(self, speed): + """Map Home Assistant speed state to HomeKit speed.""" + speed_range = self.speed_ranges[speed] + return speed_range.target + + def speed_to_states(self, speed): + """Map HomeKit speed to Home Assistant speed state.""" + for state, speed_range in reversed(self.speed_ranges.items()): + if speed_range.start <= speed: + return state + return list(self.speed_ranges.keys())[0] + + def show_setup_message(hass, pincode): """Display persistent notification with setup information.""" pin = pincode.decode() diff --git a/tests/components/homekit/test_type_fans.py b/tests/components/homekit/test_type_fans.py index 27b6cec0790..b620ef50e0f 100644 --- a/tests/components/homekit/test_type_fans.py +++ b/tests/components/homekit/test_type_fans.py @@ -1,14 +1,17 @@ """Test different accessory types: Fans.""" from collections import namedtuple +from unittest.mock import Mock import pytest from homeassistant.components.fan import ( - ATTR_DIRECTION, ATTR_OSCILLATING, DIRECTION_FORWARD, DIRECTION_REVERSE, - DOMAIN, SUPPORT_DIRECTION, SUPPORT_OSCILLATE) + ATTR_DIRECTION, ATTR_OSCILLATING, ATTR_SPEED, ATTR_SPEED_LIST, + DIRECTION_FORWARD, DIRECTION_REVERSE, DOMAIN, SPEED_HIGH, SPEED_LOW, + SPEED_OFF, SUPPORT_DIRECTION, SUPPORT_OSCILLATE, SUPPORT_SET_SPEED) from homeassistant.components.homekit.const import ATTR_VALUE +from homeassistant.components.homekit.util import HomeKitSpeedMapping from homeassistant.const import ( - ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, STATE_ON, STATE_OFF, + ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, STATE_OFF, STATE_ON, STATE_UNKNOWN) from tests.common import async_mock_service @@ -39,6 +42,9 @@ async def test_fan_basic(hass, hk_driver, cls, events): assert acc.category == 3 # Fan assert acc.char_active.value == 0 + # If there are no speed_list values, then HomeKit speed is unsupported + assert acc.char_speed is None + await hass.async_add_job(acc.run) await hass.async_block_till_done() assert acc.char_active.value == 1 @@ -155,3 +161,40 @@ async def test_fan_oscillate(hass, hk_driver, cls, events): assert call_oscillate[1].data[ATTR_OSCILLATING] is True assert len(events) == 2 assert events[-1].data[ATTR_VALUE] is True + + +async def test_fan_speed(hass, hk_driver, cls, events): + """Test fan with speed.""" + entity_id = 'fan.demo' + speed_list = [SPEED_OFF, SPEED_LOW, SPEED_HIGH] + + hass.states.async_set(entity_id, STATE_ON, { + ATTR_SUPPORTED_FEATURES: SUPPORT_SET_SPEED, ATTR_SPEED: SPEED_OFF, + ATTR_SPEED_LIST: speed_list}) + await hass.async_block_till_done() + acc = cls.fan(hass, hk_driver, 'Fan', entity_id, 2, None) + assert acc.char_speed.value == 0 + + await hass.async_add_job(acc.run) + assert acc.speed_mapping.speed_ranges == \ + HomeKitSpeedMapping(speed_list).speed_ranges + + acc.speed_mapping.speed_to_homekit = Mock(return_value=42) + acc.speed_mapping.speed_to_states = Mock(return_value='ludicrous') + + hass.states.async_set(entity_id, STATE_ON, {ATTR_SPEED: SPEED_HIGH}) + await hass.async_block_till_done() + acc.speed_mapping.speed_to_homekit.assert_called_with(SPEED_HIGH) + assert acc.char_speed.value == 42 + + # Set from HomeKit + call_set_speed = async_mock_service(hass, DOMAIN, 'set_speed') + + await hass.async_add_job(acc.char_speed.client_update_value, 42) + await hass.async_block_till_done() + acc.speed_mapping.speed_to_states.assert_called_with(42) + assert call_set_speed[0] + assert call_set_speed[0].data[ATTR_ENTITY_ID] == entity_id + assert call_set_speed[0].data[ATTR_SPEED] == 'ludicrous' + assert len(events) == 1 + assert events[-1].data[ATTR_VALUE] == 'ludicrous' diff --git a/tests/components/homekit/test_util.py b/tests/components/homekit/test_util.py index a2849a77396..c86b1353c48 100644 --- a/tests/components/homekit/test_util.py +++ b/tests/components/homekit/test_util.py @@ -3,15 +3,14 @@ import pytest import voluptuous as vol from homeassistant.components.homekit.const import ( - CONF_FEATURE, CONF_FEATURE_LIST, HOMEKIT_NOTIFY_ID, FEATURE_ON_OFF, - FEATURE_PLAY_PAUSE, TYPE_FAUCET, TYPE_OUTLET, TYPE_SHOWER, TYPE_SPRINKLER, + CONF_FEATURE, CONF_FEATURE_LIST, FEATURE_ON_OFF, FEATURE_PLAY_PAUSE, + HOMEKIT_NOTIFY_ID, TYPE_FAUCET, TYPE_OUTLET, TYPE_SHOWER, TYPE_SPRINKLER, TYPE_SWITCH, TYPE_VALVE) from homeassistant.components.homekit.util import ( - convert_to_float, density_to_air_quality, dismiss_setup_message, - show_setup_message, temperature_to_homekit, temperature_to_states, + HomeKitSpeedMapping, SpeedRange, convert_to_float, density_to_air_quality, + dismiss_setup_message, show_setup_message, temperature_to_homekit, + temperature_to_states, validate_entity_config as vec, validate_media_player_features) -from homeassistant.components.homekit.util import validate_entity_config \ - as vec from homeassistant.components.persistent_notification import ( ATTR_MESSAGE, ATTR_NOTIFICATION_ID, DOMAIN) from homeassistant.const import ( @@ -144,3 +143,58 @@ async def test_dismiss_setup_msg(hass): assert call_dismiss_notification assert call_dismiss_notification[0].data[ATTR_NOTIFICATION_ID] == \ HOMEKIT_NOTIFY_ID + + +def test_homekit_speed_mapping(): + """Test if the SpeedRanges from a speed_list are as expected.""" + # A standard 2-speed fan + speed_mapping = HomeKitSpeedMapping(['off', 'low', 'high']) + assert speed_mapping.speed_ranges == { + 'off': SpeedRange(0, 0), + 'low': SpeedRange(100 / 3, 50), + 'high': SpeedRange(200 / 3, 100), + } + + # A standard 3-speed fan + speed_mapping = HomeKitSpeedMapping(['off', 'low', 'medium', 'high']) + assert speed_mapping.speed_ranges == { + 'off': SpeedRange(0, 0), + 'low': SpeedRange(100 / 4, 100 / 3), + 'medium': SpeedRange(200 / 4, 200 / 3), + 'high': SpeedRange(300 / 4, 100), + } + + # a Dyson-like fan with 10 speeds + speed_mapping = HomeKitSpeedMapping([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]) + assert speed_mapping.speed_ranges == { + 0: SpeedRange(0, 0), + 1: SpeedRange(10, 100 / 9), + 2: SpeedRange(20, 200 / 9), + 3: SpeedRange(30, 300 / 9), + 4: SpeedRange(40, 400 / 9), + 5: SpeedRange(50, 500 / 9), + 6: SpeedRange(60, 600 / 9), + 7: SpeedRange(70, 700 / 9), + 8: SpeedRange(80, 800 / 9), + 9: SpeedRange(90, 100), + } + + +def test_speed_to_homekit(): + """Test speed conversion from HA to Homekit.""" + speed_mapping = HomeKitSpeedMapping(['off', 'low', 'high']) + assert speed_mapping.speed_to_homekit('off') == 0 + assert speed_mapping.speed_to_homekit('low') == 50 + assert speed_mapping.speed_to_homekit('high') == 100 + + +def test_speed_to_states(): + """Test speed conversion from Homekit to HA.""" + speed_mapping = HomeKitSpeedMapping(['off', 'low', 'high']) + assert speed_mapping.speed_to_states(0) == 'off' + assert speed_mapping.speed_to_states(33) == 'off' + assert speed_mapping.speed_to_states(34) == 'low' + assert speed_mapping.speed_to_states(50) == 'low' + assert speed_mapping.speed_to_states(66) == 'low' + assert speed_mapping.speed_to_states(67) == 'high' + assert speed_mapping.speed_to_states(100) == 'high'