From f5d74e07d51b0992d37105e1eb0fbbeca99a32ef Mon Sep 17 00:00:00 2001 From: Matt Schmitt <schmittx@users.noreply.github.com> Date: Fri, 1 Jun 2018 12:04:54 -0400 Subject: [PATCH] Add support for outlets in HomeKit (#14628) --- homeassistant/components/homekit/__init__.py | 13 ++++-- homeassistant/components/homekit/const.py | 6 +++ .../components/homekit/type_switches.py | 39 ++++++++++++++++- homeassistant/components/homekit/util.py | 13 +++++- .../homekit/test_get_accessories.py | 23 +++++----- .../components/homekit/test_type_switches.py | 42 ++++++++++++++++++- tests/components/homekit/test_util.py | 9 ++-- 7 files changed, 123 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py index ce3b79e6c72..34372b8b6a8 100644 --- a/homeassistant/components/homekit/__init__.py +++ b/homeassistant/components/homekit/__init__.py @@ -12,7 +12,7 @@ import voluptuous as vol import homeassistant.components.cover as cover from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_SUPPORTED_FEATURES, ATTR_UNIT_OF_MEASUREMENT, - CONF_IP_ADDRESS, CONF_NAME, CONF_PORT, DEVICE_CLASS_HUMIDITY, + CONF_IP_ADDRESS, CONF_NAME, CONF_PORT, CONF_TYPE, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_ILLUMINANCE, DEVICE_CLASS_TEMPERATURE, EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, TEMP_CELSIUS, TEMP_FAHRENHEIT) @@ -23,7 +23,7 @@ from homeassistant.util.decorator import Registry from .const import ( CONF_AUTO_START, CONF_ENTITY_CONFIG, CONF_FEATURE_LIST, CONF_FILTER, DEFAULT_AUTO_START, DEFAULT_PORT, DEVICE_CLASS_CO2, DEVICE_CLASS_PM25, - DOMAIN, HOMEKIT_FILE, SERVICE_HOMEKIT_START) + DOMAIN, HOMEKIT_FILE, SERVICE_HOMEKIT_START, TYPE_OUTLET, TYPE_SWITCH) from .util import ( show_setup_message, validate_entity_config, validate_media_player_features) @@ -38,6 +38,8 @@ STATUS_RUNNING = 1 STATUS_STOPPED = 2 STATUS_WAIT = 3 +SWITCH_TYPES = {TYPE_OUTLET: 'Outlet', + TYPE_SWITCH: 'Switch'} CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.All({ @@ -149,8 +151,11 @@ def get_accessory(hass, driver, state, aid, config): elif device_class == DEVICE_CLASS_ILLUMINANCE or unit in ('lm', 'lx'): a_type = 'LightSensor' - elif state.domain in ('automation', 'input_boolean', 'remote', 'script', - 'switch'): + elif state.domain == 'switch': + switch_type = config.get(CONF_TYPE, TYPE_SWITCH) + a_type = SWITCH_TYPES[switch_type] + + elif state.domain in ('automation', 'input_boolean', 'remote', 'script'): a_type = 'Switch' if a_type is None: diff --git a/homeassistant/components/homekit/const.py b/homeassistant/components/homekit/const.py index 6d49c806e0f..dec6353850e 100644 --- a/homeassistant/components/homekit/const.py +++ b/homeassistant/components/homekit/const.py @@ -31,6 +31,10 @@ BRIDGE_NAME = 'Home Assistant Bridge' BRIDGE_SERIAL_NUMBER = 'homekit.bridge' MANUFACTURER = 'Home Assistant' +# #### Switch Types #### +TYPE_OUTLET = 'outlet' +TYPE_SWITCH = 'switch' + # #### Services #### SERV_ACCESSORY_INFO = 'AccessoryInformation' SERV_AIR_QUALITY_SENSOR = 'AirQualitySensor' @@ -46,6 +50,7 @@ SERV_LIGHTBULB = 'Lightbulb' SERV_LOCK = 'LockMechanism' SERV_MOTION_SENSOR = 'MotionSensor' SERV_OCCUPANCY_SENSOR = 'OccupancySensor' +SERV_OUTLET = 'Outlet' SERV_SECURITY_SYSTEM = 'SecuritySystem' SERV_SMOKE_SENSOR = 'SmokeSensor' SERV_SWITCH = 'Switch' @@ -84,6 +89,7 @@ CHAR_MODEL = 'Model' CHAR_MOTION_DETECTED = 'MotionDetected' CHAR_NAME = 'Name' CHAR_OCCUPANCY_DETECTED = 'OccupancyDetected' +CHAR_OUTLET_IN_USE = 'OutletInUse' CHAR_ON = 'On' CHAR_POSITION_STATE = 'PositionState' CHAR_ROTATION_DIRECTION = 'RotationDirection' diff --git a/homeassistant/components/homekit/type_switches.py b/homeassistant/components/homekit/type_switches.py index 69f14821bd6..c8bf8c7ad7c 100644 --- a/homeassistant/components/homekit/type_switches.py +++ b/homeassistant/components/homekit/type_switches.py @@ -1,19 +1,54 @@ """Class to hold all switch accessories.""" import logging -from pyhap.const import CATEGORY_SWITCH +from pyhap.const import CATEGORY_OUTLET, CATEGORY_SWITCH +from homeassistant.components.switch import DOMAIN as SWITCH from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_TURN_ON, SERVICE_TURN_OFF, STATE_ON) from homeassistant.core import split_entity_id from . import TYPES from .accessories import HomeAccessory -from .const import SERV_SWITCH, CHAR_ON +from .const import CHAR_ON, CHAR_OUTLET_IN_USE, SERV_OUTLET, SERV_SWITCH _LOGGER = logging.getLogger(__name__) +@TYPES.register('Outlet') +class Outlet(HomeAccessory): + """Generate an Outlet accessory.""" + + def __init__(self, *args): + """Initialize an Outlet accessory object.""" + super().__init__(*args, category=CATEGORY_OUTLET) + self.flag_target_state = False + + serv_outlet = self.add_preload_service(SERV_OUTLET) + self.char_on = serv_outlet.configure_char( + CHAR_ON, value=False, setter_callback=self.set_state) + self.char_outlet_in_use = serv_outlet.configure_char( + CHAR_OUTLET_IN_USE, value=True) + + def set_state(self, value): + """Move switch state to value if call came from HomeKit.""" + _LOGGER.debug('%s: Set switch state to %s', + self.entity_id, value) + self.flag_target_state = True + params = {ATTR_ENTITY_ID: self.entity_id} + service = SERVICE_TURN_ON if value else SERVICE_TURN_OFF + self.hass.services.call(SWITCH, service, params) + + def update_state(self, new_state): + """Update switch state after state changed.""" + current_state = (new_state.state == STATE_ON) + if not self.flag_target_state: + _LOGGER.debug('%s: Set current state to %s', + self.entity_id, current_state) + self.char_on.set_value(current_state) + self.flag_target_state = False + + @TYPES.register('Switch') class Switch(HomeAccessory): """Generate a Switch accessory.""" diff --git a/homeassistant/components/homekit/util.py b/homeassistant/components/homekit/util.py index 50095844757..6a43a0c6228 100644 --- a/homeassistant/components/homekit/util.py +++ b/homeassistant/components/homekit/util.py @@ -6,12 +6,13 @@ import voluptuous as vol import homeassistant.components.media_player as media_player from homeassistant.core import split_entity_id from homeassistant.const import ( - ATTR_CODE, ATTR_SUPPORTED_FEATURES, CONF_NAME, TEMP_CELSIUS) + ATTR_CODE, ATTR_SUPPORTED_FEATURES, CONF_NAME, CONF_TYPE, TEMP_CELSIUS) 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) + FEATURE_PLAY_PAUSE, FEATURE_PLAY_STOP, FEATURE_TOGGLE_MUTE, TYPE_OUTLET, + TYPE_SWITCH) _LOGGER = logging.getLogger(__name__) @@ -35,6 +36,11 @@ MEDIA_PLAYER_SCHEMA = vol.Schema({ FEATURE_PLAY_STOP, FEATURE_TOGGLE_MUTE))), }) +SWITCH_TYPE_SCHEMA = BASIC_INFO_SCHEMA.extend({ + vol.Optional(CONF_TYPE, default=TYPE_SWITCH): vol.All( + cv.string, vol.In((TYPE_OUTLET, TYPE_SWITCH))), +}) + def validate_entity_config(values): """Validate config entry for CONF_ENTITY.""" @@ -62,6 +68,9 @@ def validate_entity_config(values): feature_list[key] = params config[CONF_FEATURE_LIST] = feature_list + elif domain == 'switch': + config = SWITCH_TYPE_SCHEMA(config) + else: config = BASIC_INFO_SCHEMA(config) diff --git a/tests/components/homekit/test_get_accessories.py b/tests/components/homekit/test_get_accessories.py index 3b7f307fce7..4de68057084 100644 --- a/tests/components/homekit/test_get_accessories.py +++ b/tests/components/homekit/test_get_accessories.py @@ -9,10 +9,11 @@ import homeassistant.components.climate as climate import homeassistant.components.media_player as media_player from homeassistant.components.homekit import get_accessory, TYPES from homeassistant.components.homekit.const import ( - CONF_FEATURE_LIST, FEATURE_ON_OFF) + CONF_FEATURE_LIST, FEATURE_ON_OFF, TYPE_OUTLET, TYPE_SWITCH) from homeassistant.const import ( ATTR_CODE, ATTR_DEVICE_CLASS, ATTR_SUPPORTED_FEATURES, - ATTR_UNIT_OF_MEASUREMENT, CONF_NAME, TEMP_CELSIUS, TEMP_FAHRENHEIT) + ATTR_UNIT_OF_MEASUREMENT, CONF_NAME, CONF_TYPE, TEMP_CELSIUS, + TEMP_FAHRENHEIT) def test_not_supported(caplog): @@ -129,17 +130,19 @@ def test_type_sensors(type_name, entity_id, state, attrs): assert mock_type.called -@pytest.mark.parametrize('type_name, entity_id, state, attrs', [ - ('Switch', 'automation.test', 'on', {}), - ('Switch', 'input_boolean.test', 'on', {}), - ('Switch', 'remote.test', 'on', {}), - ('Switch', 'script.test', 'on', {}), - ('Switch', 'switch.test', 'on', {}), +@pytest.mark.parametrize('type_name, entity_id, state, attrs, config', [ + ('Outlet', 'switch.test', 'on', {}, {CONF_TYPE: TYPE_OUTLET}), + ('Switch', 'automation.test', 'on', {}, {}), + ('Switch', 'input_boolean.test', 'on', {}, {}), + ('Switch', 'remote.test', 'on', {}, {}), + ('Switch', 'script.test', 'on', {}, {}), + ('Switch', 'switch.test', 'on', {}, {}), + ('Switch', 'switch.test', 'on', {}, {CONF_TYPE: TYPE_SWITCH}), ]) -def test_type_switches(type_name, entity_id, state, attrs): +def test_type_switches(type_name, entity_id, state, attrs, config): """Test if switch types are associated correctly.""" mock_type = Mock() with patch.dict(TYPES, {type_name: mock_type}): entity_state = State(entity_id, state, attrs) - get_accessory(None, None, entity_state, 2, {}) + get_accessory(None, None, entity_state, 2, config) assert mock_type.called diff --git a/tests/components/homekit/test_type_switches.py b/tests/components/homekit/test_type_switches.py index b1830d1926f..3a09d2715d1 100644 --- a/tests/components/homekit/test_type_switches.py +++ b/tests/components/homekit/test_type_switches.py @@ -2,12 +2,51 @@ import pytest from homeassistant.core import split_entity_id -from homeassistant.components.homekit.type_switches import Switch +from homeassistant.components.homekit.type_switches import Outlet, Switch from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON from tests.common import async_mock_service +async def test_outlet_set_state(hass, hk_driver): + """Test if Outlet accessory and HA are updated accordingly.""" + entity_id = 'switch.outlet_test' + + hass.states.async_set(entity_id, None) + await hass.async_block_till_done() + acc = Outlet(hass, hk_driver, 'Outlet', entity_id, 2, None) + await hass.async_add_job(acc.run) + await hass.async_block_till_done() + + assert acc.aid == 2 + assert acc.category == 7 # Outlet + + assert acc.char_on.value is False + assert acc.char_outlet_in_use.value is True + + hass.states.async_set(entity_id, STATE_ON) + await hass.async_block_till_done() + assert acc.char_on.value is True + + hass.states.async_set(entity_id, STATE_OFF) + await hass.async_block_till_done() + assert acc.char_on.value is False + + # Set from HomeKit + call_turn_on = async_mock_service(hass, 'switch', 'turn_on') + call_turn_off = async_mock_service(hass, 'switch', 'turn_off') + + await hass.async_add_job(acc.char_on.client_update_value, True) + await hass.async_block_till_done() + assert call_turn_on + assert call_turn_on[0].data[ATTR_ENTITY_ID] == entity_id + + await hass.async_add_job(acc.char_on.client_update_value, False) + await hass.async_block_till_done() + assert call_turn_off + assert call_turn_off[0].data[ATTR_ENTITY_ID] == entity_id + + @pytest.mark.parametrize('entity_id', [ 'automation.test', 'input_boolean.test', @@ -23,6 +62,7 @@ async def test_switch_set_state(hass, hk_driver, entity_id): await hass.async_block_till_done() acc = Switch(hass, hk_driver, 'Switch', entity_id, 2, None) await hass.async_add_job(acc.run) + await hass.async_block_till_done() assert acc.aid == 2 assert acc.category == 8 # Switch diff --git a/tests/components/homekit/test_util.py b/tests/components/homekit/test_util.py index 0bc1eb96841..fa9fddee5fc 100644 --- a/tests/components/homekit/test_util.py +++ b/tests/components/homekit/test_util.py @@ -5,7 +5,7 @@ import voluptuous as vol from homeassistant.core import State from homeassistant.components.homekit.const import ( CONF_FEATURE, CONF_FEATURE_LIST, HOMEKIT_NOTIFY_ID, FEATURE_ON_OFF, - FEATURE_PLAY_PAUSE) + FEATURE_PLAY_PAUSE, TYPE_OUTLET) 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, @@ -15,7 +15,7 @@ from homeassistant.components.homekit.util import validate_entity_config \ from homeassistant.components.persistent_notification import ( ATTR_MESSAGE, ATTR_NOTIFICATION_ID, DOMAIN) from homeassistant.const import ( - ATTR_CODE, ATTR_SUPPORTED_FEATURES, CONF_NAME, STATE_UNKNOWN, + ATTR_CODE, ATTR_SUPPORTED_FEATURES, CONF_NAME, CONF_TYPE, STATE_UNKNOWN, TEMP_CELSIUS, TEMP_FAHRENHEIT) from tests.common import async_mock_service @@ -30,7 +30,8 @@ def test_validate_entity_config(): {CONF_FEATURE: 'invalid_feature'}]}}, {'media_player.test': {CONF_FEATURE_LIST: [ {CONF_FEATURE: FEATURE_ON_OFF}, - {CONF_FEATURE: FEATURE_ON_OFF}]}}, ] + {CONF_FEATURE: FEATURE_ON_OFF}]}}, + {'switch.test': {CONF_TYPE: 'invalid_type'}}] for conf in configs: with pytest.raises(vol.Invalid): @@ -56,6 +57,8 @@ def test_validate_entity_config(): assert vec({'media_player.demo': config}) == \ {'media_player.demo': {CONF_FEATURE_LIST: {FEATURE_ON_OFF: {}, FEATURE_PLAY_PAUSE: {}}}} + assert vec({'switch.demo': {CONF_TYPE: TYPE_OUTLET}}) == \ + {'switch.demo': {CONF_TYPE: TYPE_OUTLET}} def test_validate_media_player_features(): -- GitLab