Homekit valve duration characteristics (#149698)

Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
Fabian Leutgeb 2025-08-01 03:21:48 +02:00 committed by GitHub
parent c72c600de4
commit 61396d92a5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 398 additions and 4 deletions

View File

@ -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,

View File

@ -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"

View File

@ -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):

View File

@ -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)

View File

@ -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

View File

@ -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: