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_POSITION_STATE = 'PositionState'
CHAR_ROTATION_DIRECTION = 'RotationDirection'
CHAR_ROTATION_SPEED = 'RotationSpeed'
CHAR_SATURATION = 'Saturation'
CHAR_SERIAL_NUMBER = 'SerialNumber'
CHAR_SMOKE_DETECTED = 'SmokeDetected'

View file

@ -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)

View file

@ -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()

View file

@ -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'

View file

@ -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'