Added HomeKit fan speed based on speed_list (#19767)

Speed_list needs to be in ascending order.
This commit is contained in:
Andreas Hartl 2019-02-05 16:11:19 +01:00 committed by cdce8p
parent 208ea6eae4
commit a94a24f6f8
5 changed files with 194 additions and 20 deletions

View file

@ -113,6 +113,7 @@ CHAR_OUTLET_IN_USE = 'OutletInUse'
CHAR_ON = 'On' CHAR_ON = 'On'
CHAR_POSITION_STATE = 'PositionState' CHAR_POSITION_STATE = 'PositionState'
CHAR_ROTATION_DIRECTION = 'RotationDirection' CHAR_ROTATION_DIRECTION = 'RotationDirection'
CHAR_ROTATION_SPEED = 'RotationSpeed'
CHAR_SATURATION = 'Saturation' CHAR_SATURATION = 'Saturation'
CHAR_SERIAL_NUMBER = 'SerialNumber' CHAR_SERIAL_NUMBER = 'SerialNumber'
CHAR_SMOKE_DETECTED = 'SmokeDetected' CHAR_SMOKE_DETECTED = 'SmokeDetected'

View file

@ -4,17 +4,20 @@ import logging
from pyhap.const import CATEGORY_FAN from pyhap.const import CATEGORY_FAN
from homeassistant.components.fan import ( from homeassistant.components.fan import (
ATTR_DIRECTION, ATTR_OSCILLATING, DIRECTION_FORWARD, DIRECTION_REVERSE, ATTR_DIRECTION, ATTR_OSCILLATING, ATTR_SPEED, ATTR_SPEED_LIST,
DOMAIN, SERVICE_OSCILLATE, SERVICE_SET_DIRECTION, SUPPORT_DIRECTION, DIRECTION_FORWARD, DIRECTION_REVERSE, DOMAIN, SERVICE_OSCILLATE,
SUPPORT_OSCILLATE) SERVICE_SET_DIRECTION, SERVICE_SET_SPEED, SUPPORT_DIRECTION,
SUPPORT_OSCILLATE, SUPPORT_SET_SPEED)
from homeassistant.const import ( from homeassistant.const import (
ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, SERVICE_TURN_OFF, ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, SERVICE_TURN_OFF, SERVICE_TURN_ON,
SERVICE_TURN_ON, STATE_OFF, STATE_ON) STATE_OFF, STATE_ON)
from . import TYPES from . import TYPES
from .accessories import HomeAccessory from .accessories import debounce, HomeAccessory
from .const import ( 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__) _LOGGER = logging.getLogger(__name__)
@ -41,12 +44,18 @@ class Fan(HomeAccessory):
chars.append(CHAR_ROTATION_DIRECTION) chars.append(CHAR_ROTATION_DIRECTION)
if features & SUPPORT_OSCILLATE: if features & SUPPORT_OSCILLATE:
chars.append(CHAR_SWING_MODE) 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) serv_fan = self.add_preload_service(SERV_FANV2, chars)
self.char_active = serv_fan.configure_char( self.char_active = serv_fan.configure_char(
CHAR_ACTIVE, value=0, setter_callback=self.set_state) CHAR_ACTIVE, value=0, setter_callback=self.set_state)
self.char_direction = None self.char_direction = None
self.char_speed = None
self.char_swing = None self.char_swing = None
if CHAR_ROTATION_DIRECTION in chars: if CHAR_ROTATION_DIRECTION in chars:
@ -54,6 +63,10 @@ class Fan(HomeAccessory):
CHAR_ROTATION_DIRECTION, value=0, CHAR_ROTATION_DIRECTION, value=0,
setter_callback=self.set_direction) 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: if CHAR_SWING_MODE in chars:
self.char_swing = serv_fan.configure_char( self.char_swing = serv_fan.configure_char(
CHAR_SWING_MODE, value=0, setter_callback=self.set_oscillating) CHAR_SWING_MODE, value=0, setter_callback=self.set_oscillating)
@ -83,6 +96,15 @@ class Fan(HomeAccessory):
ATTR_OSCILLATING: oscillating} ATTR_OSCILLATING: oscillating}
self.call_service(DOMAIN, SERVICE_OSCILLATE, params, 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): def update_state(self, new_state):
"""Update fan after state change.""" """Update fan after state change."""
# Handle State # Handle State
@ -104,6 +126,14 @@ class Fan(HomeAccessory):
self.char_direction.set_value(hk_direction) self.char_direction.set_value(hk_direction)
self._flag[CHAR_ROTATION_DIRECTION] = False 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 # Handle Oscillating
if self.char_swing is not None: if self.char_swing is not None:
oscillating = new_state.attributes.get(ATTR_OSCILLATING) oscillating = new_state.attributes.get(ATTR_OSCILLATING)

View file

@ -1,17 +1,19 @@
"""Collection of useful functions for the HomeKit component.""" """Collection of useful functions for the HomeKit component."""
from collections import namedtuple, OrderedDict
import logging import logging
import voluptuous as vol import voluptuous as vol
from homeassistant.components import media_player from homeassistant.components import fan, media_player
from homeassistant.core import split_entity_id
from homeassistant.const import ( from homeassistant.const import (
ATTR_CODE, ATTR_SUPPORTED_FEATURES, CONF_NAME, CONF_TYPE, TEMP_CELSIUS) 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.helpers.config_validation as cv
import homeassistant.util.temperature as temp_util import homeassistant.util.temperature as temp_util
from .const import ( from .const import (
CONF_FEATURE, CONF_FEATURE_LIST, HOMEKIT_NOTIFY_ID, FEATURE_ON_OFF, CONF_FEATURE, CONF_FEATURE_LIST, FEATURE_ON_OFF, FEATURE_PLAY_PAUSE,
FEATURE_PLAY_PAUSE, FEATURE_PLAY_STOP, FEATURE_TOGGLE_MUTE, TYPE_FAUCET, FEATURE_PLAY_STOP, FEATURE_TOGGLE_MUTE, HOMEKIT_NOTIFY_ID, TYPE_FAUCET,
TYPE_OUTLET, TYPE_SHOWER, TYPE_SPRINKLER, TYPE_SWITCH, TYPE_VALVE) TYPE_OUTLET, TYPE_SHOWER, TYPE_SPRINKLER, TYPE_SWITCH, TYPE_VALVE)
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -110,6 +112,50 @@ def validate_media_player_features(state, feature_list):
return True 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): def show_setup_message(hass, pincode):
"""Display persistent notification with setup information.""" """Display persistent notification with setup information."""
pin = pincode.decode() pin = pincode.decode()

View file

@ -1,14 +1,17 @@
"""Test different accessory types: Fans.""" """Test different accessory types: Fans."""
from collections import namedtuple from collections import namedtuple
from unittest.mock import Mock
import pytest import pytest
from homeassistant.components.fan import ( from homeassistant.components.fan import (
ATTR_DIRECTION, ATTR_OSCILLATING, DIRECTION_FORWARD, DIRECTION_REVERSE, ATTR_DIRECTION, ATTR_OSCILLATING, ATTR_SPEED, ATTR_SPEED_LIST,
DOMAIN, SUPPORT_DIRECTION, SUPPORT_OSCILLATE) 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.const import ATTR_VALUE
from homeassistant.components.homekit.util import HomeKitSpeedMapping
from homeassistant.const import ( 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) STATE_UNKNOWN)
from tests.common import async_mock_service 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.category == 3 # Fan
assert acc.char_active.value == 0 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_add_job(acc.run)
await hass.async_block_till_done() await hass.async_block_till_done()
assert acc.char_active.value == 1 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 call_oscillate[1].data[ATTR_OSCILLATING] is True
assert len(events) == 2 assert len(events) == 2
assert events[-1].data[ATTR_VALUE] is True 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'

View file

@ -3,15 +3,14 @@ import pytest
import voluptuous as vol import voluptuous as vol
from homeassistant.components.homekit.const import ( from homeassistant.components.homekit.const import (
CONF_FEATURE, CONF_FEATURE_LIST, HOMEKIT_NOTIFY_ID, FEATURE_ON_OFF, CONF_FEATURE, CONF_FEATURE_LIST, FEATURE_ON_OFF, FEATURE_PLAY_PAUSE,
FEATURE_PLAY_PAUSE, TYPE_FAUCET, TYPE_OUTLET, TYPE_SHOWER, TYPE_SPRINKLER, HOMEKIT_NOTIFY_ID, TYPE_FAUCET, TYPE_OUTLET, TYPE_SHOWER, TYPE_SPRINKLER,
TYPE_SWITCH, TYPE_VALVE) TYPE_SWITCH, TYPE_VALVE)
from homeassistant.components.homekit.util import ( from homeassistant.components.homekit.util import (
convert_to_float, density_to_air_quality, dismiss_setup_message, HomeKitSpeedMapping, SpeedRange, convert_to_float, density_to_air_quality,
show_setup_message, temperature_to_homekit, temperature_to_states, dismiss_setup_message, show_setup_message, temperature_to_homekit,
temperature_to_states, validate_entity_config as vec,
validate_media_player_features) validate_media_player_features)
from homeassistant.components.homekit.util import validate_entity_config \
as vec
from homeassistant.components.persistent_notification import ( from homeassistant.components.persistent_notification import (
ATTR_MESSAGE, ATTR_NOTIFICATION_ID, DOMAIN) ATTR_MESSAGE, ATTR_NOTIFICATION_ID, DOMAIN)
from homeassistant.const import ( from homeassistant.const import (
@ -144,3 +143,58 @@ async def test_dismiss_setup_msg(hass):
assert call_dismiss_notification assert call_dismiss_notification
assert call_dismiss_notification[0].data[ATTR_NOTIFICATION_ID] == \ assert call_dismiss_notification[0].data[ATTR_NOTIFICATION_ID] == \
HOMEKIT_NOTIFY_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'