From 433775cf4b0f1de0c004bd8601a6854cde5c4a74 Mon Sep 17 00:00:00 2001 From: ha0y <30557072+ha0y@users.noreply.github.com> Date: Mon, 30 Aug 2021 13:28:26 -0700 Subject: [PATCH] Add input_select and select domain support for HomeKit (#54760) Co-authored-by: J. Nick Koston --- .../components/homekit/accessories.py | 3 + .../components/homekit/config_flow.py | 2 + .../components/homekit/type_switches.py | 47 ++++++++++++++ .../homekit/test_get_accessories.py | 2 + .../components/homekit/test_type_switches.py | 64 ++++++++++++++++++- 5 files changed, 117 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/homekit/accessories.py b/homeassistant/components/homekit/accessories.py index 8298cdd9c83..8aa7878bfba 100644 --- a/homeassistant/components/homekit/accessories.py +++ b/homeassistant/components/homekit/accessories.py @@ -198,6 +198,9 @@ def get_accessory(hass, driver, state, aid, config): # noqa: C901 elif state.domain in ("automation", "input_boolean", "remote", "scene", "script"): a_type = "Switch" + elif state.domain in ("input_select", "select"): + a_type = "SelectSwitch" + elif state.domain == "water_heater": a_type = "WaterHeater" diff --git a/homeassistant/components/homekit/config_flow.py b/homeassistant/components/homekit/config_flow.py index 03df55a9026..6f0e9d9ba5f 100644 --- a/homeassistant/components/homekit/config_flow.py +++ b/homeassistant/components/homekit/config_flow.py @@ -84,6 +84,7 @@ SUPPORTED_DOMAINS = [ "fan", "humidifier", "input_boolean", + "input_select", "light", "lock", MEDIA_PLAYER_DOMAIN, @@ -91,6 +92,7 @@ SUPPORTED_DOMAINS = [ REMOTE_DOMAIN, "scene", "script", + "select", "sensor", "switch", "vacuum", diff --git a/homeassistant/components/homekit/type_switches.py b/homeassistant/components/homekit/type_switches.py index 3bb496a2abc..4e76b0369fe 100644 --- a/homeassistant/components/homekit/type_switches.py +++ b/homeassistant/components/homekit/type_switches.py @@ -9,6 +9,7 @@ from pyhap.const import ( CATEGORY_SWITCH, ) +from homeassistant.components.input_select import ATTR_OPTIONS, SERVICE_SELECT_OPTION from homeassistant.components.switch import DOMAIN from homeassistant.components.vacuum import ( DOMAIN as VACUUM_DOMAIN, @@ -33,9 +34,11 @@ from .accessories import TYPES, HomeAccessory from .const import ( CHAR_ACTIVE, CHAR_IN_USE, + CHAR_NAME, CHAR_ON, CHAR_OUTLET_IN_USE, CHAR_VALVE_TYPE, + MAX_NAME_LENGTH, SERV_OUTLET, SERV_SWITCH, SERV_VALVE, @@ -226,3 +229,47 @@ class Valve(HomeAccessory): self.char_active.set_value(current_state) _LOGGER.debug("%s: Set in_use state to %s", self.entity_id, current_state) self.char_in_use.set_value(current_state) + + +@TYPES.register("SelectSwitch") +class SelectSwitch(HomeAccessory): + """Generate a Switch accessory that contains multiple switches.""" + + def __init__(self, *args): + """Initialize a Switch accessory object.""" + super().__init__(*args, category=CATEGORY_SWITCH) + self.domain = split_entity_id(self.entity_id)[0] + state = self.hass.states.get(self.entity_id) + self.select_chars = {} + options = state.attributes[ATTR_OPTIONS] + for option in options: + serv_option = self.add_preload_service( + SERV_OUTLET, [CHAR_NAME, CHAR_IN_USE] + ) + serv_option.configure_char( + CHAR_NAME, + value=f"{option}"[:MAX_NAME_LENGTH], + ) + serv_option.configure_char(CHAR_IN_USE, value=False) + self.select_chars[option] = serv_option.configure_char( + CHAR_ON, + value=False, + setter_callback=lambda value, option=option: self.select_option(option), + ) + self.set_primary_service(self.select_chars[options[0]]) + # Set the state so it is in sync on initial + # GET to avoid an event storm after homekit startup + self.async_update_state(state) + + def select_option(self, option): + """Set option from HomeKit.""" + _LOGGER.debug("%s: Set option to %s", self.entity_id, option) + params = {ATTR_ENTITY_ID: self.entity_id, "option": option} + self.async_call_service(self.domain, SERVICE_SELECT_OPTION, params) + + @callback + def async_update_state(self, new_state): + """Update switch state after state changed.""" + current_option = new_state.state + for option, char in self.select_chars.items(): + char.set_value(option == current_option) diff --git a/tests/components/homekit/test_get_accessories.py b/tests/components/homekit/test_get_accessories.py index af98f6a45f9..be2429c79cf 100644 --- a/tests/components/homekit/test_get_accessories.py +++ b/tests/components/homekit/test_get_accessories.py @@ -274,6 +274,8 @@ def test_type_sensors(type_name, entity_id, state, attrs): ("Switch", "remote.test", "on", {}, {}), ("Switch", "scene.test", "on", {}, {}), ("Switch", "script.test", "on", {}, {}), + ("SelectSwitch", "input_select.test", "option1", {}, {}), + ("SelectSwitch", "select.test", "option1", {}, {}), ("Switch", "switch.test", "on", {}, {}), ("Switch", "switch.test", "on", {}, {CONF_TYPE: TYPE_SWITCH}), ("Valve", "switch.test", "on", {}, {CONF_TYPE: TYPE_FAUCET}), diff --git a/tests/components/homekit/test_type_switches.py b/tests/components/homekit/test_type_switches.py index 6df1f0182ed..c13f7ea2538 100644 --- a/tests/components/homekit/test_type_switches.py +++ b/tests/components/homekit/test_type_switches.py @@ -10,7 +10,14 @@ from homeassistant.components.homekit.const import ( TYPE_SPRINKLER, TYPE_VALVE, ) -from homeassistant.components.homekit.type_switches import Outlet, Switch, Vacuum, Valve +from homeassistant.components.homekit.type_switches import ( + Outlet, + SelectSwitch, + Switch, + Vacuum, + Valve, +) +from homeassistant.components.select.const import ATTR_OPTIONS from homeassistant.components.vacuum import ( DOMAIN as VACUUM_DOMAIN, SERVICE_RETURN_TO_BASE, @@ -26,6 +33,7 @@ from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, CONF_TYPE, + SERVICE_SELECT_OPTION, STATE_OFF, STATE_ON, ) @@ -387,3 +395,57 @@ async def test_script_switch(hass, hk_driver, events): await hass.async_block_till_done() assert acc.char_on.value is False assert len(events) == 1 + + +@pytest.mark.parametrize( + "domain", + ["input_select", "select"], +) +async def test_input_select_switch(hass, hk_driver, events, domain): + """Test if select switch accessory is handled correctly.""" + entity_id = f"{domain}.test" + + hass.states.async_set( + entity_id, "option1", {ATTR_OPTIONS: ["option1", "option2", "option3"]} + ) + await hass.async_block_till_done() + acc = SelectSwitch(hass, hk_driver, "SelectSwitch", entity_id, 2, None) + await acc.run() + await hass.async_block_till_done() + + assert acc.select_chars["option1"].value is True + assert acc.select_chars["option2"].value is False + assert acc.select_chars["option3"].value is False + + call_select_option = async_mock_service(hass, domain, SERVICE_SELECT_OPTION) + acc.select_chars["option2"].client_update_value(True) + await hass.async_block_till_done() + + assert call_select_option + assert call_select_option[0].data == {"entity_id": entity_id, "option": "option2"} + assert len(events) == 1 + assert events[-1].data[ATTR_VALUE] is None + + hass.states.async_set( + entity_id, "option2", {ATTR_OPTIONS: ["option1", "option2", "option3"]} + ) + await hass.async_block_till_done() + assert acc.select_chars["option1"].value is False + assert acc.select_chars["option2"].value is True + assert acc.select_chars["option3"].value is False + + hass.states.async_set( + entity_id, "option3", {ATTR_OPTIONS: ["option1", "option2", "option3"]} + ) + await hass.async_block_till_done() + assert acc.select_chars["option1"].value is False + assert acc.select_chars["option2"].value is False + assert acc.select_chars["option3"].value is True + + hass.states.async_set( + entity_id, "invalid", {ATTR_OPTIONS: ["option1", "option2", "option3"]} + ) + await hass.async_block_till_done() + assert acc.select_chars["option1"].value is False + assert acc.select_chars["option2"].value is False + assert acc.select_chars["option3"].value is False