From 61396d92a504eac53e231ca16cbd381a5bcc3378 Mon Sep 17 00:00:00 2001 From: Fabian Leutgeb Date: Fri, 1 Aug 2025 03:21:48 +0200 Subject: [PATCH] Homekit valve duration characteristics (#149698) Co-authored-by: J. Nick Koston --- .../components/homekit/accessories.py | 4 +- homeassistant/components/homekit/const.py | 4 + .../components/homekit/type_switches.py | 108 ++++++++- homeassistant/components/homekit/util.py | 16 +- .../components/homekit/test_type_switches.py | 226 ++++++++++++++++++ tests/components/homekit/test_util.py | 44 ++++ 6 files changed, 398 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/homekit/accessories.py b/homeassistant/components/homekit/accessories.py index 95842d56094..681ebcbbef7 100644 --- a/homeassistant/components/homekit/accessories.py +++ b/homeassistant/components/homekit/accessories.py @@ -628,12 +628,12 @@ class HomeAccessory(Accessory): # type: ignore[misc] self, domain: str, service: str, - service_data: dict[str, Any] | None, + service_data: dict[str, Any], value: Any | None = None, ) -> None: """Fire event and call service for changes from HomeKit.""" event_data = { - ATTR_ENTITY_ID: self.entity_id, + ATTR_ENTITY_ID: service_data.get(ATTR_ENTITY_ID, self.entity_id), ATTR_DISPLAY_NAME: self.display_name, ATTR_SERVICE: service, ATTR_VALUE: value, diff --git a/homeassistant/components/homekit/const.py b/homeassistant/components/homekit/const.py index 44f18c30099..2d4e2b03079 100644 --- a/homeassistant/components/homekit/const.py +++ b/homeassistant/components/homekit/const.py @@ -57,6 +57,8 @@ CONF_LINKED_HUMIDITY_SENSOR = "linked_humidity_sensor" CONF_LINKED_OBSTRUCTION_SENSOR = "linked_obstruction_sensor" CONF_LINKED_PM25_SENSOR = "linked_pm25_sensor" CONF_LINKED_TEMPERATURE_SENSOR = "linked_temperature_sensor" +CONF_LINKED_VALVE_DURATION = "linked_valve_duration" +CONF_LINKED_VALVE_END_TIME = "linked_valve_end_time" CONF_LOW_BATTERY_THRESHOLD = "low_battery_threshold" CONF_MAX_FPS = "max_fps" CONF_MAX_HEIGHT = "max_height" @@ -229,10 +231,12 @@ CHAR_ON = "On" CHAR_OUTLET_IN_USE = "OutletInUse" CHAR_POSITION_STATE = "PositionState" CHAR_PROGRAMMABLE_SWITCH_EVENT = "ProgrammableSwitchEvent" +CHAR_REMAINING_DURATION = "RemainingDuration" CHAR_REMOTE_KEY = "RemoteKey" CHAR_ROTATION_DIRECTION = "RotationDirection" CHAR_ROTATION_SPEED = "RotationSpeed" CHAR_SATURATION = "Saturation" +CHAR_SET_DURATION = "SetDuration" CHAR_SERIAL_NUMBER = "SerialNumber" CHAR_SERVICE_LABEL_INDEX = "ServiceLabelIndex" CHAR_SERVICE_LABEL_NAMESPACE = "ServiceLabelNamespace" diff --git a/homeassistant/components/homekit/type_switches.py b/homeassistant/components/homekit/type_switches.py index 18150c820c3..c011b8cd327 100644 --- a/homeassistant/components/homekit/type_switches.py +++ b/homeassistant/components/homekit/type_switches.py @@ -15,6 +15,11 @@ from pyhap.const import ( ) from homeassistant.components import button, input_button +from homeassistant.components.input_number import ( + ATTR_VALUE as INPUT_NUMBER_ATTR_VALUE, + DOMAIN as INPUT_NUMBER_DOMAIN, + SERVICE_SET_VALUE as INPUT_NUMBER_SERVICE_SET_VALUE, +) from homeassistant.components.input_select import ATTR_OPTIONS, SERVICE_SELECT_OPTION from homeassistant.components.lawn_mower import ( DOMAIN as LAWN_MOWER_DOMAIN, @@ -45,6 +50,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, State, callback, split_entity_id from homeassistant.helpers.event import async_call_later +from homeassistant.util import dt as dt_util from .accessories import TYPES, HomeAccessory, HomeDriver from .const import ( @@ -54,7 +60,11 @@ from .const import ( CHAR_NAME, CHAR_ON, CHAR_OUTLET_IN_USE, + CHAR_REMAINING_DURATION, + CHAR_SET_DURATION, CHAR_VALVE_TYPE, + CONF_LINKED_VALVE_DURATION, + CONF_LINKED_VALVE_END_TIME, SERV_OUTLET, SERV_SWITCH, SERV_VALVE, @@ -271,7 +281,21 @@ class ValveBase(HomeAccessory): self.on_service = on_service self.off_service = off_service - serv_valve = self.add_preload_service(SERV_VALVE) + self.chars = [] + + self.linked_duration_entity: str | None = self.config.get( + CONF_LINKED_VALVE_DURATION + ) + self.linked_end_time_entity: str | None = self.config.get( + CONF_LINKED_VALVE_END_TIME + ) + + if self.linked_duration_entity: + self.chars.append(CHAR_SET_DURATION) + if self.linked_end_time_entity: + self.chars.append(CHAR_REMAINING_DURATION) + + serv_valve = self.add_preload_service(SERV_VALVE, self.chars) self.char_active = serv_valve.configure_char( CHAR_ACTIVE, value=False, setter_callback=self.set_state ) @@ -279,6 +303,25 @@ class ValveBase(HomeAccessory): self.char_valve_type = serv_valve.configure_char( CHAR_VALVE_TYPE, value=VALVE_TYPE[valve_type].valve_type ) + + if CHAR_SET_DURATION in self.chars: + _LOGGER.debug( + "%s: Add characteristic %s", self.entity_id, CHAR_SET_DURATION + ) + self.char_set_duration = serv_valve.configure_char( + CHAR_SET_DURATION, + value=self.get_duration(), + setter_callback=self.set_duration, + ) + + if CHAR_REMAINING_DURATION in self.chars: + _LOGGER.debug( + "%s: Add characteristic %s", self.entity_id, CHAR_REMAINING_DURATION + ) + self.char_remaining_duration = serv_valve.configure_char( + CHAR_REMAINING_DURATION, getter_callback=self.get_remaining_duration + ) + # Set the state so it is in sync on initial # GET to avoid an event storm after homekit startup self.async_update_state(state) @@ -294,12 +337,75 @@ class ValveBase(HomeAccessory): @callback def async_update_state(self, new_state: State) -> None: """Update switch state after state changed.""" + self._update_duration_chars() 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) + def _update_duration_chars(self) -> None: + """Update valve duration related properties if characteristics are available.""" + if CHAR_SET_DURATION in self.chars: + self.char_set_duration.set_value(self.get_duration()) + if CHAR_REMAINING_DURATION in self.chars: + self.char_remaining_duration.set_value(self.get_remaining_duration()) + + def set_duration(self, value: int) -> None: + """Set default duration for how long the valve should remain open.""" + _LOGGER.debug("%s: Set default run time to %s", self.entity_id, value) + self.async_call_service( + INPUT_NUMBER_DOMAIN, + INPUT_NUMBER_SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: self.linked_duration_entity, + INPUT_NUMBER_ATTR_VALUE: value, + }, + value, + ) + + def get_duration(self) -> int: + """Get the default duration from Home Assistant.""" + duration_state = self._get_entity_state(self.linked_duration_entity) + if duration_state is None: + _LOGGER.debug( + "%s: No linked duration entity state available", self.entity_id + ) + return 0 + + try: + duration = float(duration_state) + return max(int(duration), 0) + except ValueError: + _LOGGER.debug("%s: Cannot parse linked duration entity", self.entity_id) + return 0 + + def get_remaining_duration(self) -> int: + """Calculate the remaining duration based on end time in Home Assistant.""" + end_time_state = self._get_entity_state(self.linked_end_time_entity) + if end_time_state is None: + _LOGGER.debug( + "%s: No linked end time entity state available", self.entity_id + ) + return self.get_duration() + + end_time = dt_util.parse_datetime(end_time_state) + if end_time is None: + _LOGGER.debug("%s: Cannot parse linked end time entity", self.entity_id) + return self.get_duration() + + remaining_time = (end_time - dt_util.utcnow()).total_seconds() + return max(int(remaining_time), 0) + + def _get_entity_state(self, entity_id: str | None) -> str | None: + """Fetch the state of a linked entity.""" + if entity_id is None: + return None + state = self.hass.states.get(entity_id) + if state is None: + return None + return state.state + @TYPES.register("ValveSwitch") class ValveSwitch(ValveBase): diff --git a/homeassistant/components/homekit/util.py b/homeassistant/components/homekit/util.py index 85207e09626..ea67e30a3c1 100644 --- a/homeassistant/components/homekit/util.py +++ b/homeassistant/components/homekit/util.py @@ -17,6 +17,7 @@ import voluptuous as vol from homeassistant.components import ( binary_sensor, + input_number, media_player, persistent_notification, sensor, @@ -69,6 +70,8 @@ from .const import ( CONF_LINKED_OBSTRUCTION_SENSOR, CONF_LINKED_PM25_SENSOR, CONF_LINKED_TEMPERATURE_SENSOR, + CONF_LINKED_VALVE_DURATION, + CONF_LINKED_VALVE_END_TIME, CONF_LOW_BATTERY_THRESHOLD, CONF_MAX_FPS, CONF_MAX_HEIGHT, @@ -266,7 +269,9 @@ SWITCH_TYPE_SCHEMA = BASIC_INFO_SCHEMA.extend( TYPE_VALVE, ) ), - ) + ), + vol.Optional(CONF_LINKED_VALVE_DURATION): cv.entity_domain(input_number.DOMAIN), + vol.Optional(CONF_LINKED_VALVE_END_TIME): cv.entity_domain(sensor.DOMAIN), } ) @@ -277,6 +282,12 @@ SENSOR_SCHEMA = BASIC_INFO_SCHEMA.extend( } ) +VALVE_SCHEMA = BASIC_INFO_SCHEMA.extend( + { + vol.Optional(CONF_LINKED_VALVE_DURATION): cv.entity_domain(input_number.DOMAIN), + vol.Optional(CONF_LINKED_VALVE_END_TIME): cv.entity_domain(sensor.DOMAIN), + } +) HOMEKIT_CHAR_TRANSLATIONS = { 0: " ", # nul @@ -360,6 +371,9 @@ def validate_entity_config(values: dict) -> dict[str, dict]: elif domain == "sensor": config = SENSOR_SCHEMA(config) + elif domain == "valve": + config = VALVE_SCHEMA(config) + else: config = BASIC_INFO_SCHEMA(config) diff --git a/tests/components/homekit/test_type_switches.py b/tests/components/homekit/test_type_switches.py index 3f0f0a3c22b..47a9c398d16 100644 --- a/tests/components/homekit/test_type_switches.py +++ b/tests/components/homekit/test_type_switches.py @@ -2,6 +2,7 @@ from datetime import timedelta +from freezegun import freeze_time import pytest from homeassistant.components.homekit.const import ( @@ -22,6 +23,10 @@ from homeassistant.components.homekit.type_switches import ( Valve, ValveSwitch, ) +from homeassistant.components.input_number import ( + DOMAIN as INPUT_NUMBER_DOMAIN, + SERVICE_SET_VALUE as INPUT_NUMBER_SERVICE_SET_VALUE, +) from homeassistant.components.lawn_mower import ( DOMAIN as LAWN_MOWER_DOMAIN, SERVICE_DOCK, @@ -30,6 +35,7 @@ from homeassistant.components.lawn_mower import ( LawnMowerEntityFeature, ) from homeassistant.components.select import ATTR_OPTIONS +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.components.vacuum import ( DOMAIN as VACUUM_DOMAIN, SERVICE_RETURN_TO_BASE, @@ -658,3 +664,223 @@ async def test_button_switch( await hass.async_block_till_done() assert acc.char_on.value is False assert len(events) == 1 + + +async def test_valve_switch_with_set_duration_characteristic( + hass: HomeAssistant, hk_driver, events: list[Event] +) -> None: + """Test valve switch with set duration characteristic.""" + entity_id = "switch.sprinkler" + + hass.states.async_set(entity_id, STATE_OFF) + hass.states.async_set("input_number.valve_duration", "0") + await hass.async_block_till_done() + + # Mock switch services to prevent errors + async_mock_service(hass, SWITCH_DOMAIN, SERVICE_TURN_ON) + async_mock_service(hass, SWITCH_DOMAIN, SERVICE_TURN_OFF) + + acc = ValveSwitch( + hass, + hk_driver, + "Sprinkler", + entity_id, + 5, + {"type": "sprinkler", "linked_valve_duration": "input_number.valve_duration"}, + ) + acc.run() + await hass.async_block_till_done() + + # Assert initial state is synced + assert acc.get_duration() == 0 + + # Simulate setting duration from HomeKit + call_set_value = async_mock_service( + hass, INPUT_NUMBER_DOMAIN, INPUT_NUMBER_SERVICE_SET_VALUE + ) + acc.char_set_duration.client_update_value(300) + await hass.async_block_till_done() + assert call_set_value + assert call_set_value[0].data == { + "entity_id": "input_number.valve_duration", + "value": 300, + } + + # Assert state change in Home Assistant is synced to HomeKit + hass.states.async_set("input_number.valve_duration", "600") + await hass.async_block_till_done() + assert acc.get_duration() == 600 + + # Test fallback if no state is set + hass.states.async_remove("input_number.valve_duration") + await hass.async_block_till_done() + assert acc.get_duration() == 0 + + # Test remaining duration fallback if no end time is linked + assert acc.get_remaining_duration() == 0 + + +async def test_valve_switch_with_remaining_duration_characteristic( + hass: HomeAssistant, hk_driver, events: list[Event] +) -> None: + """Test valve switch with remaining duration characteristic.""" + entity_id = "switch.sprinkler" + + hass.states.async_set(entity_id, STATE_OFF) + hass.states.async_set("sensor.valve_end_time", dt_util.utcnow().isoformat()) + await hass.async_block_till_done() + + # Mock switch services to prevent errors + async_mock_service(hass, SWITCH_DOMAIN, SERVICE_TURN_ON) + async_mock_service(hass, SWITCH_DOMAIN, SERVICE_TURN_OFF) + + acc = ValveSwitch( + hass, + hk_driver, + "Sprinkler", + entity_id, + 5, + {"type": "sprinkler", "linked_valve_end_time": "sensor.valve_end_time"}, + ) + acc.run() + await hass.async_block_till_done() + + # Assert initial state is synced + assert acc.get_remaining_duration() == 0 + + # Simulate remaining duration update from Home Assistant + with freeze_time(dt_util.utcnow()): + hass.states.async_set( + "sensor.valve_end_time", + (dt_util.utcnow() + timedelta(seconds=90)).isoformat(), + ) + await hass.async_block_till_done() + + # Assert remaining duration is calculated correctly based on end time + assert acc.get_remaining_duration() == 90 + + # Test fallback if no state is set + hass.states.async_remove("sensor.valve_end_time") + await hass.async_block_till_done() + assert acc.get_remaining_duration() == 0 + + # Test get duration fallback if no duration is linked + assert acc.get_duration() == 0 + + +async def test_valve_switch_with_duration_characteristics( + hass: HomeAssistant, hk_driver, events: list[Event] +) -> None: + """Test valve switch with set duration and remaining duration characteristics.""" + entity_id = "switch.sprinkler" + + # Test with duration and end time entities linked + hass.states.async_set(entity_id, STATE_OFF) + hass.states.async_set("input_number.valve_duration", "300") + hass.states.async_set("sensor.valve_end_time", dt_util.utcnow().isoformat()) + await hass.async_block_till_done() + + # Mock switch services to prevent errors + async_mock_service(hass, SWITCH_DOMAIN, SERVICE_TURN_ON) + async_mock_service(hass, SWITCH_DOMAIN, SERVICE_TURN_OFF) + # Mock input_number service for set_duration calls + call_set_value = async_mock_service( + hass, INPUT_NUMBER_DOMAIN, INPUT_NUMBER_SERVICE_SET_VALUE + ) + + acc = ValveSwitch( + hass, + hk_driver, + "Sprinkler", + entity_id, + 5, + { + "type": "sprinkler", + "linked_valve_duration": "input_number.valve_duration", + "linked_valve_end_time": "sensor.valve_end_time", + }, + ) + acc.run() + await hass.async_block_till_done() + + # Test update_duration_chars with both characteristics + with freeze_time(dt_util.utcnow()): + hass.states.async_set( + "sensor.valve_end_time", + (dt_util.utcnow() + timedelta(seconds=60)).isoformat(), + ) + hass.states.async_set(entity_id, STATE_OFF) + await hass.async_block_till_done() + assert acc.char_set_duration.value == 300 + assert acc.get_remaining_duration() == 60 + + # Test get_duration fallback with invalid state + hass.states.async_set("input_number.valve_duration", "invalid") + await hass.async_block_till_done() + assert acc.get_duration() == 0 + + # Test get_remaining_duration fallback with invalid state + hass.states.async_set("sensor.valve_end_time", "invalid") + await hass.async_block_till_done() + assert acc.get_remaining_duration() == 0 + + # Test get_remaining_duration with end time in the past + hass.states.async_set( + "sensor.valve_end_time", + (dt_util.utcnow() - timedelta(seconds=10)).isoformat(), + ) + await hass.async_block_till_done() + assert acc.get_remaining_duration() == 0 + + # Test set_duration with negative value + acc.set_duration(-10) + await hass.async_block_till_done() + assert acc.get_duration() == 0 + # Verify the service was called with correct parameters + assert len(call_set_value) == 1 + assert call_set_value[0].data == { + "entity_id": "input_number.valve_duration", + "value": -10, + } + + # Test set_duration with negative state + hass.states.async_set("sensor.valve_duration", -10) + await hass.async_block_till_done() + assert acc.get_duration() == 0 + + +async def test_valve_with_duration_characteristics( + hass: HomeAssistant, hk_driver, events: list[Event] +) -> None: + """Test valve with set duration and remaining duration characteristics.""" + entity_id = "switch.sprinkler" + + # Test with duration and end time entities linked + hass.states.async_set(entity_id, STATE_OFF) + hass.states.async_set("input_number.valve_duration", "900") + hass.states.async_set("sensor.valve_end_time", dt_util.utcnow().isoformat()) + await hass.async_block_till_done() + + # Using Valve instead of ValveSwitch + acc = Valve( + hass, + hk_driver, + "Valve", + entity_id, + 5, + { + "linked_valve_duration": "input_number.valve_duration", + "linked_valve_end_time": "sensor.valve_end_time", + }, + ) + acc.run() + await hass.async_block_till_done() + + with freeze_time(dt_util.utcnow()): + hass.states.async_set( + "sensor.valve_end_time", + (dt_util.utcnow() + timedelta(seconds=600)).isoformat(), + ) + await hass.async_block_till_done() + assert acc.get_duration() == 900 + assert acc.get_remaining_duration() == 600 diff --git a/tests/components/homekit/test_util.py b/tests/components/homekit/test_util.py index 66906c72266..4cb8eb41489 100644 --- a/tests/components/homekit/test_util.py +++ b/tests/components/homekit/test_util.py @@ -15,6 +15,8 @@ from homeassistant.components.homekit.const import ( CONF_LINKED_BATTERY_SENSOR, CONF_LINKED_DOORBELL_SENSOR, CONF_LINKED_MOTION_SENSOR, + CONF_LINKED_VALVE_DURATION, + CONF_LINKED_VALVE_END_TIME, CONF_LOW_BATTERY_THRESHOLD, CONF_MAX_FPS, CONF_MAX_HEIGHT, @@ -128,7 +130,25 @@ def test_validate_entity_config() -> None: } }, {"switch.test": {CONF_TYPE: "invalid_type"}}, + { + "switch.test": { + CONF_TYPE: "sprinkler", + CONF_LINKED_VALVE_DURATION: "number.valve_duration", # Must be input_number entity + CONF_LINKED_VALVE_END_TIME: "datetime.valve_end_time", # Must be sensor (timestamp) entity + } + }, {"fan.test": {CONF_TYPE: "invalid_type"}}, + { + "valve.test": { + CONF_LINKED_VALVE_END_TIME: "datetime.valve_end_time", # Must be sensor (timestamp) entity + CONF_LINKED_VALVE_DURATION: "number.valve_duration", # Must be input_number + } + }, + { + "valve.test": { + CONF_TYPE: "sprinkler", # Extra keys not allowed + } + }, ] for conf in configs: @@ -212,6 +232,19 @@ def test_validate_entity_config() -> None: assert vec({"switch.demo": {CONF_TYPE: TYPE_VALVE}}) == { "switch.demo": {CONF_TYPE: TYPE_VALVE, CONF_LOW_BATTERY_THRESHOLD: 20} } + config = { + CONF_TYPE: TYPE_SPRINKLER, + CONF_LINKED_VALVE_DURATION: "input_number.valve_duration", + CONF_LINKED_VALVE_END_TIME: "sensor.valve_end_time", + } + assert vec({"switch.sprinkler": config}) == { + "switch.sprinkler": { + CONF_TYPE: TYPE_SPRINKLER, + CONF_LINKED_VALVE_DURATION: "input_number.valve_duration", + CONF_LINKED_VALVE_END_TIME: "sensor.valve_end_time", + CONF_LOW_BATTERY_THRESHOLD: DEFAULT_LOW_BATTERY_THRESHOLD, + } + } assert vec({"sensor.co": {CONF_THRESHOLD_CO: 500}}) == { "sensor.co": {CONF_THRESHOLD_CO: 500, CONF_LOW_BATTERY_THRESHOLD: 20} } @@ -244,6 +277,17 @@ def test_validate_entity_config() -> None: CONF_LOW_BATTERY_THRESHOLD: DEFAULT_LOW_BATTERY_THRESHOLD, } } + config = { + CONF_LINKED_VALVE_DURATION: "input_number.valve_duration", + CONF_LINKED_VALVE_END_TIME: "sensor.valve_end_time", + } + assert vec({"valve.demo": config}) == { + "valve.demo": { + CONF_LINKED_VALVE_DURATION: "input_number.valve_duration", + CONF_LINKED_VALVE_END_TIME: "sensor.valve_end_time", + CONF_LOW_BATTERY_THRESHOLD: DEFAULT_LOW_BATTERY_THRESHOLD, + } + } def test_validate_media_player_features() -> None: