diff --git a/homeassistant/components/homekit/accessories.py b/homeassistant/components/homekit/accessories.py index 40e86efe6a9..8d10387e239 100644 --- a/homeassistant/components/homekit/accessories.py +++ b/homeassistant/components/homekit/accessories.py @@ -104,12 +104,12 @@ from .util import ( _LOGGER = logging.getLogger(__name__) SWITCH_TYPES = { - TYPE_FAUCET: "Valve", + TYPE_FAUCET: "ValveSwitch", TYPE_OUTLET: "Outlet", - TYPE_SHOWER: "Valve", - TYPE_SPRINKLER: "Valve", + TYPE_SHOWER: "ValveSwitch", + TYPE_SPRINKLER: "ValveSwitch", TYPE_SWITCH: "Switch", - TYPE_VALVE: "Valve", + TYPE_VALVE: "ValveSwitch", } TYPES: Registry[str, type[HomeAccessory]] = Registry() @@ -244,6 +244,9 @@ def get_accessory( # noqa: C901 else: a_type = "Switch" + elif state.domain == "valve": + a_type = "Valve" + elif state.domain == "vacuum": a_type = "Vacuum" @@ -289,7 +292,7 @@ class HomeAccessory(Accessory): # type: ignore[misc] name: str, entity_id: str, aid: int, - config: dict, + config: dict[str, Any], *args: Any, category: int = CATEGORY_OTHER, device_id: str | None = None, diff --git a/homeassistant/components/homekit/config_flow.py b/homeassistant/components/homekit/config_flow.py index 30fb80cbdfc..78979f73490 100644 --- a/homeassistant/components/homekit/config_flow.py +++ b/homeassistant/components/homekit/config_flow.py @@ -17,6 +17,7 @@ from homeassistant.components.camera import DOMAIN as CAMERA_DOMAIN from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER_DOMAIN from homeassistant.components.remote import DOMAIN as REMOTE_DOMAIN +from homeassistant.components.valve import DOMAIN as VALVE_DOMAIN from homeassistant.config_entries import ( SOURCE_IMPORT, ConfigEntry, @@ -105,6 +106,7 @@ SUPPORTED_DOMAINS = [ "switch", "vacuum", "water_heater", + VALVE_DOMAIN, ] DEFAULT_DOMAINS = [ diff --git a/homeassistant/components/homekit/type_switches.py b/homeassistant/components/homekit/type_switches.py index 86861417bdb..45a823882f7 100644 --- a/homeassistant/components/homekit/type_switches.py +++ b/homeassistant/components/homekit/type_switches.py @@ -3,7 +3,7 @@ from __future__ import annotations import logging -from typing import Any, NamedTuple +from typing import Any, Final, NamedTuple from pyhap.characteristic import Characteristic from pyhap.const import ( @@ -28,14 +28,19 @@ from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, CONF_TYPE, + SERVICE_CLOSE_VALVE, + SERVICE_OPEN_VALVE, SERVICE_TURN_OFF, SERVICE_TURN_ON, + STATE_CLOSING, STATE_ON, + STATE_OPEN, + STATE_OPENING, ) -from homeassistant.core import State, callback, split_entity_id +from homeassistant.core import HomeAssistant, State, callback, split_entity_id from homeassistant.helpers.event import async_call_later -from .accessories import TYPES, HomeAccessory +from .accessories import TYPES, HomeAccessory, HomeDriver from .const import ( CHAR_ACTIVE, CHAR_IN_USE, @@ -55,6 +60,8 @@ from .util import cleanup_name_for_homekit _LOGGER = logging.getLogger(__name__) +VALVE_OPEN_STATES: Final = {STATE_OPEN, STATE_OPENING, STATE_CLOSING} + class ValveInfo(NamedTuple): """Category and type information for valve.""" @@ -211,18 +218,28 @@ class Vacuum(Switch): self.char_on.set_value(current_state) -@TYPES.register("Valve") -class Valve(HomeAccessory): - """Generate a Valve accessory.""" +class ValveBase(HomeAccessory): + """Valve base class.""" - def __init__(self, *args: Any) -> None: + def __init__( + self, + valve_type: str, + open_states: set[str], + on_service: str, + off_service: str, + *args: Any, + **kwargs: Any, + ) -> None: """Initialize a Valve accessory object.""" - super().__init__(*args) + super().__init__(*args, **kwargs) + self.domain = split_entity_id(self.entity_id)[0] state = self.hass.states.get(self.entity_id) assert state - valve_type = self.config[CONF_TYPE] self.category = VALVE_TYPE[valve_type].category + self.open_states = open_states + self.on_service = on_service + self.off_service = off_service serv_valve = self.add_preload_service(SERV_VALVE) self.char_active = serv_valve.configure_char( @@ -241,19 +258,64 @@ class Valve(HomeAccessory): _LOGGER.debug("%s: Set switch state to %s", self.entity_id, value) self.char_in_use.set_value(value) params = {ATTR_ENTITY_ID: self.entity_id} - service = SERVICE_TURN_ON if value else SERVICE_TURN_OFF - self.async_call_service(DOMAIN, service, params) + service = self.on_service if value else self.off_service + self.async_call_service(self.domain, service, params) @callback def async_update_state(self, new_state: State) -> None: """Update switch state after state changed.""" - current_state = 1 if new_state.state == STATE_ON else 0 + current_state = 1 if new_state.state in self.open_states else 0 _LOGGER.debug("%s: Set active state to %s", self.entity_id, current_state) 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("ValveSwitch") +class ValveSwitch(ValveBase): + """Generate a Valve accessory from a HomeAssistant switch.""" + + def __init__( + self, + hass: HomeAssistant, + driver: HomeDriver, + name: str, + entity_id: str, + aid: int, + config: dict[str, Any], + *args: Any, + ) -> None: + """Initialize a Valve accessory object.""" + super().__init__( + config[CONF_TYPE], + {STATE_ON}, + SERVICE_TURN_ON, + SERVICE_TURN_OFF, + hass, + driver, + name, + entity_id, + aid, + config, + *args, + ) + + +@TYPES.register("Valve") +class Valve(ValveBase): + """Generate a Valve accessory from a HomeAssistant valve.""" + + def __init__(self, *args: Any) -> None: + """Initialize a Valve accessory object.""" + super().__init__( + TYPE_VALVE, + VALVE_OPEN_STATES, + SERVICE_OPEN_VALVE, + SERVICE_CLOSE_VALVE, + *args, + ) + + @TYPES.register("SelectSwitch") class SelectSwitch(HomeAccessory): """Generate a Switch accessory that contains multiple switches.""" diff --git a/tests/components/homekit/test_get_accessories.py b/tests/components/homekit/test_get_accessories.py index 02a39ed9258..c4b1cbe98d8 100644 --- a/tests/components/homekit/test_get_accessories.py +++ b/tests/components/homekit/test_get_accessories.py @@ -335,10 +335,10 @@ def test_type_sensors(type_name, entity_id, state, attrs) -> None: ("SelectSwitch", "select.test", "option1", {}, {}), ("Switch", "switch.test", "on", {}, {}), ("Switch", "switch.test", "on", {}, {CONF_TYPE: TYPE_SWITCH}), - ("Valve", "switch.test", "on", {}, {CONF_TYPE: TYPE_FAUCET}), - ("Valve", "switch.test", "on", {}, {CONF_TYPE: TYPE_VALVE}), - ("Valve", "switch.test", "on", {}, {CONF_TYPE: TYPE_SHOWER}), - ("Valve", "switch.test", "on", {}, {CONF_TYPE: TYPE_SPRINKLER}), + ("ValveSwitch", "switch.test", "on", {}, {CONF_TYPE: TYPE_FAUCET}), + ("ValveSwitch", "switch.test", "on", {}, {CONF_TYPE: TYPE_VALVE}), + ("ValveSwitch", "switch.test", "on", {}, {CONF_TYPE: TYPE_SHOWER}), + ("ValveSwitch", "switch.test", "on", {}, {CONF_TYPE: TYPE_SPRINKLER}), ], ) def test_type_switches(type_name, entity_id, state, attrs, config) -> None: @@ -350,6 +350,21 @@ def test_type_switches(type_name, entity_id, state, attrs, config) -> None: assert mock_type.called +@pytest.mark.parametrize( + ("type_name", "entity_id", "state", "attrs"), + [ + ("Valve", "valve.test", "on", {}), + ], +) +def test_type_valve(type_name, entity_id, state, attrs) -> None: + """Test if valve 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, {}) + assert mock_type.called + + @pytest.mark.parametrize( ("type_name", "entity_id", "state", "attrs"), [ diff --git a/tests/components/homekit/test_type_switches.py b/tests/components/homekit/test_type_switches.py index 27937babc57..a2c88d7e1ab 100644 --- a/tests/components/homekit/test_type_switches.py +++ b/tests/components/homekit/test_type_switches.py @@ -17,6 +17,7 @@ from homeassistant.components.homekit.type_switches import ( Switch, Vacuum, Valve, + ValveSwitch, ) from homeassistant.components.select import ATTR_OPTIONS from homeassistant.components.vacuum import ( @@ -33,9 +34,13 @@ from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, CONF_TYPE, + SERVICE_CLOSE_VALVE, + SERVICE_OPEN_VALVE, SERVICE_SELECT_OPTION, + STATE_CLOSED, STATE_OFF, STATE_ON, + STATE_OPEN, ) from homeassistant.core import HomeAssistant, split_entity_id import homeassistant.util.dt as dt_util @@ -140,32 +145,34 @@ async def test_switch_set_state( assert events[-1].data[ATTR_VALUE] is None -async def test_valve_set_state(hass: HomeAssistant, hk_driver, events) -> None: +async def test_valve_switch_set_state(hass: HomeAssistant, hk_driver, events) -> None: """Test if Valve accessory and HA are updated accordingly.""" entity_id = "switch.valve_test" hass.states.async_set(entity_id, None) await hass.async_block_till_done() - acc = Valve(hass, hk_driver, "Valve", entity_id, 2, {CONF_TYPE: TYPE_FAUCET}) + acc = ValveSwitch(hass, hk_driver, "Valve", entity_id, 2, {CONF_TYPE: TYPE_FAUCET}) acc.run() await hass.async_block_till_done() assert acc.category == 29 # Faucet assert acc.char_valve_type.value == 3 # Water faucet - acc = Valve(hass, hk_driver, "Valve", entity_id, 3, {CONF_TYPE: TYPE_SHOWER}) + acc = ValveSwitch(hass, hk_driver, "Valve", entity_id, 3, {CONF_TYPE: TYPE_SHOWER}) acc.run() await hass.async_block_till_done() assert acc.category == 30 # Shower assert acc.char_valve_type.value == 2 # Shower head - acc = Valve(hass, hk_driver, "Valve", entity_id, 4, {CONF_TYPE: TYPE_SPRINKLER}) + acc = ValveSwitch( + hass, hk_driver, "Valve", entity_id, 4, {CONF_TYPE: TYPE_SPRINKLER} + ) acc.run() await hass.async_block_till_done() assert acc.category == 28 # Sprinkler assert acc.char_valve_type.value == 1 # Irrigation - acc = Valve(hass, hk_driver, "Valve", entity_id, 5, {CONF_TYPE: TYPE_VALVE}) + acc = ValveSwitch(hass, hk_driver, "Valve", entity_id, 5, {CONF_TYPE: TYPE_VALVE}) acc.run() await hass.async_block_till_done() @@ -187,8 +194,57 @@ async def test_valve_set_state(hass: HomeAssistant, hk_driver, events) -> None: assert acc.char_in_use.value == 0 # Set from HomeKit - call_turn_on = async_mock_service(hass, "switch", "turn_on") - call_turn_off = async_mock_service(hass, "switch", "turn_off") + call_turn_on = async_mock_service(hass, "switch", SERVICE_TURN_ON) + call_turn_off = async_mock_service(hass, "switch", SERVICE_TURN_OFF) + + acc.char_active.client_update_value(1) + await hass.async_block_till_done() + assert acc.char_in_use.value == 1 + assert call_turn_on + assert call_turn_on[0].data[ATTR_ENTITY_ID] == entity_id + assert len(events) == 1 + assert events[-1].data[ATTR_VALUE] is None + + acc.char_active.client_update_value(0) + await hass.async_block_till_done() + assert acc.char_in_use.value == 0 + assert call_turn_off + assert call_turn_off[0].data[ATTR_ENTITY_ID] == entity_id + assert len(events) == 2 + assert events[-1].data[ATTR_VALUE] is None + + +async def test_valve_set_state(hass: HomeAssistant, hk_driver, events) -> None: + """Test if Valve accessory and HA are updated accordingly.""" + entity_id = "valve.valve_test" + + hass.states.async_set(entity_id, None) + await hass.async_block_till_done() + + acc = Valve(hass, hk_driver, "Valve", entity_id, 5, {CONF_TYPE: TYPE_VALVE}) + acc.run() + await hass.async_block_till_done() + + assert acc.aid == 5 + assert acc.category == 29 # Faucet + + assert acc.char_active.value == 0 + assert acc.char_in_use.value == 0 + assert acc.char_valve_type.value == 0 # Generic Valve + + hass.states.async_set(entity_id, STATE_OPEN) + await hass.async_block_till_done() + assert acc.char_active.value == 1 + assert acc.char_in_use.value == 1 + + hass.states.async_set(entity_id, STATE_CLOSED) + await hass.async_block_till_done() + assert acc.char_active.value == 0 + assert acc.char_in_use.value == 0 + + # Set from HomeKit + call_turn_on = async_mock_service(hass, "valve", SERVICE_OPEN_VALVE) + call_turn_off = async_mock_service(hass, "valve", SERVICE_CLOSE_VALVE) acc.char_active.client_update_value(1) await hass.async_block_till_done()