From 95b121118035e772f2549e8a96cfbb6e4467da81 Mon Sep 17 00:00:00 2001 From: louwii17 Date: Wed, 9 Sep 2026 05:03:06 -0700 Subject: [PATCH] Support number entities for HomeKit valve durations (#177410) Co-authored-by: Markus Tuominen <3738613+Markus98@users.noreply.github.com> --- .../components/homekit/type_switches.py | 4 +- homeassistant/components/homekit/util.py | 9 ++- .../components/homekit/test_type_switches.py | 70 +++++++++++++++---- tests/components/homekit/test_util.py | 41 +++++++++-- 4 files changed, 104 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/homekit/type_switches.py b/homeassistant/components/homekit/type_switches.py index 58e42b03fc48..9637b1b638ea 100644 --- a/homeassistant/components/homekit/type_switches.py +++ b/homeassistant/components/homekit/type_switches.py @@ -20,7 +20,6 @@ from homeassistant.components.input_number import ( CONF_MAX as INPUT_NUMBER_CONF_MAX, CONF_MIN as INPUT_NUMBER_CONF_MIN, CONF_STEP as INPUT_NUMBER_CONF_STEP, - DOMAIN as INPUT_NUMBER_DOMAIN, SERVICE_SET_VALUE as INPUT_NUMBER_SERVICE_SET_VALUE, ) from homeassistant.components.input_select import SERVICE_SELECT_OPTION @@ -404,8 +403,9 @@ class ValveBase(HomeAccessory): 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) + assert self.linked_duration_entity self.async_call_service( - INPUT_NUMBER_DOMAIN, + split_entity_id(self.linked_duration_entity)[0], INPUT_NUMBER_SERVICE_SET_VALUE, { ATTR_ENTITY_ID: self.linked_duration_entity, diff --git a/homeassistant/components/homekit/util.py b/homeassistant/components/homekit/util.py index aa2cb347ce07..2d2dd801496f 100644 --- a/homeassistant/components/homekit/util.py +++ b/homeassistant/components/homekit/util.py @@ -31,6 +31,7 @@ from homeassistant.components.media_player import ( MediaPlayerDeviceClass, MediaPlayerEntityFeature, ) +from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN from homeassistant.components.remote import DOMAIN as REMOTE_DOMAIN, RemoteEntityFeature from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN @@ -290,7 +291,9 @@ SWITCH_TYPE_SCHEMA = BASIC_INFO_SCHEMA.extend( ) ), ), - vol.Optional(CONF_LINKED_VALVE_DURATION): cv.entity_domain(INPUT_NUMBER_DOMAIN), + vol.Optional(CONF_LINKED_VALVE_DURATION): cv.entity_domain( + [INPUT_NUMBER_DOMAIN, NUMBER_DOMAIN] + ), vol.Optional(CONF_LINKED_VALVE_END_TIME): cv.entity_domain(SENSOR_DOMAIN), } ) @@ -308,7 +311,9 @@ VALVE_SCHEMA = BASIC_INFO_SCHEMA.extend( cv.string, vol.In((TYPE_FAUCET, TYPE_SHOWER, TYPE_SPRINKLER, TYPE_VALVE)), ), - vol.Optional(CONF_LINKED_VALVE_DURATION): cv.entity_domain(INPUT_NUMBER_DOMAIN), + vol.Optional(CONF_LINKED_VALVE_DURATION): cv.entity_domain( + [INPUT_NUMBER_DOMAIN, NUMBER_DOMAIN] + ), vol.Optional(CONF_LINKED_VALVE_END_TIME): cv.entity_domain(SENSOR_DOMAIN), } ) diff --git a/tests/components/homekit/test_type_switches.py b/tests/components/homekit/test_type_switches.py index 66cb16e80d1e..ccd6a7f8ec77 100644 --- a/tests/components/homekit/test_type_switches.py +++ b/tests/components/homekit/test_type_switches.py @@ -36,6 +36,7 @@ from homeassistant.components.lawn_mower import ( LawnMowerActivity, LawnMowerEntityFeature, ) +from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN from homeassistant.components.select import ATTR_OPTIONS from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.components.vacuum import ( @@ -935,33 +936,78 @@ async def test_valve_switch_with_duration_characteristics( assert acc.get_duration() == 0 +@pytest.mark.parametrize( + ("entity_id", "state", "accessory_class", "config"), + [ + pytest.param( + "switch.sprinkler", + STATE_OFF, + ValveSwitch, + { + "type": "sprinkler", + "linked_valve_duration": "number.valve_duration", + "linked_valve_end_time": "sensor.valve_end_time", + }, + id="switch", + ), + pytest.param( + "valve.sprinkler", + STATE_CLOSED, + Valve, + { + "linked_valve_duration": "number.valve_duration", + "linked_valve_end_time": "sensor.valve_end_time", + }, + id="valve", + ), + ], +) async def test_valve_with_duration_characteristics( - hass: HomeAssistant, hk_driver, events: list[Event] + hass: HomeAssistant, + hk_driver: HomeDriver, + events: list[Event], + entity_id: str, + state: str, + accessory_class: type[Valve | ValveSwitch], + config: dict[str, str], ) -> 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(entity_id, state) + hass.states.async_set( + "number.valve_duration", + "900", + {"min": 60, "max": 10800, "step": 60}, + ) 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( + acc = accessory_class( hass, hk_driver, - "Valve", + "Sprinkler", entity_id, 5, - { - "linked_valve_duration": "input_number.valve_duration", - "linked_valve_end_time": "sensor.valve_end_time", - }, + config, ) acc.run() await hass.async_block_till_done() + assert acc.char_set_duration.value == 900 + assert acc.char_set_duration.properties["minValue"] == 60 + assert acc.char_set_duration.properties["maxValue"] == 10800 + assert acc.char_set_duration.properties["minStep"] == 60 + + call_set_value = async_mock_service( + hass, 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[0].data == { + "entity_id": "number.valve_duration", + "value": 300, + } + with freeze_time(dt_util.utcnow()): hass.states.async_set( "sensor.valve_end_time", diff --git a/tests/components/homekit/test_util.py b/tests/components/homekit/test_util.py index 04e125806348..ac58a7987515 100644 --- a/tests/components/homekit/test_util.py +++ b/tests/components/homekit/test_util.py @@ -135,20 +135,29 @@ def test_validate_entity_config() -> None: { "switch.test": { CONF_TYPE: "sprinkler", - # Must be input_number entity - CONF_LINKED_VALVE_DURATION: "number.valve_duration", + # Must be input_number or number entity + CONF_LINKED_VALVE_DURATION: "sensor.valve_duration", + } + }, + { + "switch.test": { + CONF_TYPE: "sprinkler", # Must be sensor (timestamp) entity CONF_LINKED_VALVE_END_TIME: "datetime.valve_end_time", } }, {"fan.test": {CONF_TYPE: "invalid_type"}}, {"climate.test": {CONF_TYPE: "invalid_type"}}, + { + "valve.test": { + # Must be input_number or number entity + CONF_LINKED_VALVE_DURATION: "sensor.valve_duration", + } + }, { "valve.test": { # Must be sensor (timestamp) entity CONF_LINKED_VALVE_END_TIME: "datetime.valve_end_time", - # Must be input_number - CONF_LINKED_VALVE_DURATION: "number.valve_duration", } }, {"valve.test": {CONF_TYPE: "invalid_type"}}, @@ -254,6 +263,17 @@ def test_validate_entity_config() -> None: CONF_LOW_BATTERY_THRESHOLD: DEFAULT_LOW_BATTERY_THRESHOLD, } } + config = { + CONF_LINKED_VALVE_DURATION: "number.valve_duration", + CONF_LINKED_VALVE_END_TIME: "sensor.valve_end_time", + } + assert vec({"valve.sprinkler": config}) == { + "valve.sprinkler": { + CONF_LINKED_VALVE_DURATION: "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} } @@ -299,6 +319,19 @@ def test_validate_entity_config() -> None: CONF_LOW_BATTERY_THRESHOLD: DEFAULT_LOW_BATTERY_THRESHOLD, } } + config = { + CONF_TYPE: TYPE_SPRINKLER, + CONF_LINKED_VALVE_DURATION: "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: "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: