From 10084c8c0c943fc30d04ea493d02782638694900 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 6 May 2026 05:54:49 +0200 Subject: [PATCH 1/9] Add trigger timer.time_remaining (#169763) --- homeassistant/components/timer/icons.json | 3 + homeassistant/components/timer/strings.json | 9 + homeassistant/components/timer/trigger.py | 159 +++++- homeassistant/components/timer/triggers.yaml | 10 + tests/components/timer/test_trigger.py | 511 ++++++++++++++++++- 5 files changed, 687 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/timer/icons.json b/homeassistant/components/timer/icons.json index 5641020f8318ff..769fdfd10f88b9 100644 --- a/homeassistant/components/timer/icons.json +++ b/homeassistant/components/timer/icons.json @@ -45,6 +45,9 @@ }, "started": { "trigger": "mdi:timer-play" + }, + "time_remaining": { + "trigger": "mdi:timer-alert-outline" } } } diff --git a/homeassistant/components/timer/strings.json b/homeassistant/components/timer/strings.json index 9e411128105078..ddc6e9db44f65e 100644 --- a/homeassistant/components/timer/strings.json +++ b/homeassistant/components/timer/strings.json @@ -183,6 +183,15 @@ } }, "name": "Timer started" + }, + "time_remaining": { + "description": "Triggers when one or more timers reach a specific remaining time.", + "fields": { + "remaining": { + "name": "Time remaining" + } + }, + "name": "Timer time remaining" } } } diff --git a/homeassistant/components/timer/trigger.py b/homeassistant/components/timer/trigger.py index 394c325065228c..776c9118573143 100644 --- a/homeassistant/components/timer/trigger.py +++ b/homeassistant/components/timer/trigger.py @@ -1,10 +1,160 @@ """Provides triggers for timers.""" -from homeassistant.core import HomeAssistant -from homeassistant.helpers.automation import DomainSpec -from homeassistant.helpers.trigger import Trigger, make_entity_target_state_trigger +from datetime import datetime, timedelta +from typing import cast, override + +import voluptuous as vol + +from homeassistant.const import ATTR_ENTITY_ID, CONF_OPTIONS +from homeassistant.core import CALLBACK_TYPE, Context, HomeAssistant, State, callback +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.automation import DomainSpec, filter_by_domain_specs +from homeassistant.helpers.event import async_track_point_in_utc_time +from homeassistant.helpers.target import ( + TargetStateChangedData, + async_track_target_selector_state_change_event, +) +from homeassistant.helpers.trigger import ( + ENTITY_STATE_TRIGGER_SCHEMA, + Trigger, + TriggerActionRunner, + TriggerConfig, + make_entity_target_state_trigger, +) +from homeassistant.helpers.typing import ConfigType +from homeassistant.util import dt as dt_util + +from . import ATTR_FINISHES_AT, ATTR_LAST_TRANSITION, DOMAIN, STATUS_ACTIVE + +CONF_REMAINING = "remaining" + +TIME_REMAINING_TRIGGER_SCHEMA = ENTITY_STATE_TRIGGER_SCHEMA.extend( + { + vol.Required(CONF_OPTIONS): { + vol.Required(CONF_REMAINING): cv.positive_time_period_dict, + }, + } +) + + +class TimeRemainingTrigger(Trigger): + """Trigger when a timer has a specific amount of time remaining.""" + + _domain_specs: dict[str, DomainSpec] = {DOMAIN: DomainSpec()} + _schema = TIME_REMAINING_TRIGGER_SCHEMA + + @override + @classmethod + async def async_validate_config( + cls, hass: HomeAssistant, config: ConfigType + ) -> ConfigType: + """Validate config.""" + return cast(ConfigType, cls._schema(config)) + + def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None: + """Initialize the time remaining trigger.""" + super().__init__(hass, config) + assert config.target is not None + self._target = config.target + options = config.options or {} + self._remaining: timedelta = options[CONF_REMAINING] + + def entity_filter(self, entities: set[str]) -> set[str]: + """Filter entities to timer domain.""" + return filter_by_domain_specs(self._hass, self._domain_specs, entities) + + @override + async def async_attach_runner( + self, run_action: TriggerActionRunner + ) -> CALLBACK_TYPE: + """Attach the trigger to an action runner.""" + scheduled: dict[str, CALLBACK_TYPE] = {} + + @callback + def schedule_for_state( + entity_id: str, + to_state: State | None, + context: Context | None, + ) -> None: + """Schedule a fire for an active timer state, if applicable.""" + if to_state is None: + return + if to_state.state != STATUS_ACTIVE: + return + + finishes_at_str = to_state.attributes.get(ATTR_FINISHES_AT) + if finishes_at_str is None: + return + + finishes_at = dt_util.parse_datetime(finishes_at_str) + if finishes_at is None: + return + + fire_at = finishes_at - self._remaining + if fire_at <= dt_util.utcnow(): + return + + @callback + def fire_trigger(now: datetime) -> None: + """Fire the trigger.""" + scheduled.pop(entity_id, None) + run_action( + { + ATTR_ENTITY_ID: entity_id, + "to_state": to_state, + "remaining": self._remaining, + }, + f"time remaining of {entity_id}", + context, + ) + + scheduled[entity_id] = async_track_point_in_utc_time( + self._hass, fire_trigger, fire_at + ) + + @callback + def state_change_listener( + target_state_change_data: TargetStateChangedData, + ) -> None: + """Listen for state changes and schedule trigger.""" + event = target_state_change_data.state_change_event + entity_id: str = event.data["entity_id"] + to_state = event.data["new_state"] + + # Cancel any previously scheduled callback for this entity + if entity_id in scheduled: + scheduled.pop(entity_id)() + + schedule_for_state(entity_id, to_state, event.context) + + @callback + def on_entities_update(added: set[str], removed: set[str]) -> None: + """Handle changes to the tracked entity set.""" + for entity_id in removed: + if entity_id in scheduled: + scheduled.pop(entity_id)() + for entity_id in added: + state = self._hass.states.get(entity_id) + schedule_for_state(entity_id, state, state.context if state else None) + + unsub = async_track_target_selector_state_change_event( + self._hass, + self._target, + state_change_listener, + self.entity_filter, + on_entities_update, + ) + + @callback + def async_remove() -> None: + """Remove state listeners.""" + unsub() + for cancel in scheduled.values(): + cancel() + scheduled.clear() + + return async_remove -from . import ATTR_LAST_TRANSITION, DOMAIN TRIGGERS: dict[str, type[Trigger]] = { "cancelled": make_entity_target_state_trigger( @@ -22,6 +172,7 @@ "started": make_entity_target_state_trigger( {DOMAIN: DomainSpec(value_source=ATTR_LAST_TRANSITION)}, "started" ), + "time_remaining": TimeRemainingTrigger, } diff --git a/homeassistant/components/timer/triggers.yaml b/homeassistant/components/timer/triggers.yaml index 110acc4f5b8fda..a94482202b898d 100644 --- a/homeassistant/components/timer/triggers.yaml +++ b/homeassistant/components/timer/triggers.yaml @@ -20,3 +20,13 @@ finished: *trigger_common paused: *trigger_common restarted: *trigger_common started: *trigger_common + +time_remaining: + target: + entity: + domain: timer + fields: + remaining: + required: true + selector: + duration: diff --git a/tests/components/timer/test_trigger.py b/tests/components/timer/test_trigger.py index ddc58f9564f42d..d8d09460959275 100644 --- a/tests/components/timer/test_trigger.py +++ b/tests/components/timer/test_trigger.py @@ -1,18 +1,38 @@ """Test timer triggers.""" +from datetime import timedelta +import logging from typing import Any +from freezegun.api import FrozenDateTimeFactory import pytest +import voluptuous as vol from homeassistant.components.timer import ( + ATTR_FINISHES_AT, ATTR_LAST_TRANSITION, DOMAIN, STATUS_ACTIVE, STATUS_IDLE, STATUS_PAUSED, ) -from homeassistant.core import HomeAssistant +from homeassistant.const import ( + ATTR_LABEL_ID, + CONF_ENTITY_ID, + CONF_OPTIONS, + CONF_PLATFORM, + CONF_TARGET, +) +from homeassistant.core import Context, HomeAssistant, callback +from homeassistant.helpers import entity_registry as er, label_registry as lr +from homeassistant.helpers.trigger import ( + async_initialize_triggers, + async_validate_trigger_config, +) +from homeassistant.helpers.typing import TemplateVarsType +from homeassistant.util import dt as dt_util +from tests.common import async_fire_time_changed from tests.components.common import ( TriggerStateDescription, assert_trigger_behavior_any, @@ -40,6 +60,7 @@ async def target_timers(hass: HomeAssistant) -> dict[str, list[str]]: "timer.paused", "timer.restarted", "timer.started", + "timer.time_remaining", ], ) async def test_timer_triggers_gated_by_labs_flag( @@ -58,6 +79,7 @@ async def test_timer_triggers_gated_by_labs_flag( ("timer.paused", {}, True, True), ("timer.restarted", {}, True, True), ("timer.started", {}, True, True), + ("timer.time_remaining", {"remaining": {"hours": 1}}, False, False), ], ) async def test_timer_trigger_options_validation( @@ -249,3 +271,490 @@ async def test_timer_trigger_behavior_last( trigger_options=trigger_options, states=states, ) + + +# --- time_remaining trigger tests --- + + +async def _arm_time_remaining_trigger( + hass: HomeAssistant, + entity_id: str, + remaining: dict[str, int], + calls: list[dict[str, Any]], + *, + target: dict[str, Any] | None = None, +) -> None: + """Arm the time_remaining trigger.""" + trigger_config = await async_validate_trigger_config( + hass, + [ + { + CONF_PLATFORM: "timer.time_remaining", + CONF_TARGET: target or {CONF_ENTITY_ID: entity_id}, + CONF_OPTIONS: {"remaining": remaining}, + } + ], + ) + + @callback + def action(run_variables: TemplateVarsType, context: Context | None = None) -> None: + calls.append(run_variables["trigger"]) + + logger = logging.getLogger(__name__) + + def log_cb(level: int, msg: str, **kwargs: Any) -> None: + logger._log(level, "%s", msg, **kwargs) + + await async_initialize_triggers( + hass, + trigger_config, + action, + domain="test", + name="test_trigger", + log_cb=log_cb, + ) + + +@pytest.mark.usefixtures("enable_labs_preview_features") +async def test_time_remaining_trigger_validation(hass: HomeAssistant) -> None: + """Test time_remaining trigger config validation.""" + # Valid config + await async_validate_trigger_config( + hass, + [ + { + CONF_PLATFORM: "timer.time_remaining", + CONF_TARGET: {CONF_ENTITY_ID: "timer.test"}, + CONF_OPTIONS: {"remaining": {"seconds": 30}}, + } + ], + ) + + # Missing remaining option + with pytest.raises(vol.Invalid): + await async_validate_trigger_config( + hass, + [ + { + CONF_PLATFORM: "timer.time_remaining", + CONF_TARGET: {CONF_ENTITY_ID: "timer.test"}, + CONF_OPTIONS: {}, + } + ], + ) + + +@pytest.mark.usefixtures("enable_labs_preview_features") +async def test_time_remaining_trigger_fires( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: + """Test time_remaining trigger fires at the right time.""" + now = dt_util.utcnow() + calls: list[dict[str, Any]] = [] + + hass.states.async_set("timer.test", STATUS_IDLE, {ATTR_LAST_TRANSITION: None}) + await hass.async_block_till_done() + + await _arm_time_remaining_trigger(hass, "timer.test", {"seconds": 30}, calls) + + # Start timer with 60 second duration + finishes_at = now + timedelta(seconds=60) + hass.states.async_set( + "timer.test", + STATUS_ACTIVE, + {ATTR_LAST_TRANSITION: "started", ATTR_FINISHES_AT: finishes_at.isoformat()}, + ) + await hass.async_block_till_done() + assert len(calls) == 0 + + # Advance to 25 seconds - 35 seconds remaining, should not fire + freezer.move_to(now + timedelta(seconds=25)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert len(calls) == 0 + + # Advance to 30 seconds - 30 seconds remaining, should fire + freezer.move_to(now + timedelta(seconds=30)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert len(calls) == 1 + assert calls[0]["entity_id"] == "timer.test" + assert calls[0]["remaining"] == timedelta(seconds=30) + + +@pytest.mark.usefixtures("enable_labs_preview_features") +async def test_time_remaining_trigger_paused_before_threshold( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: + """Test time_remaining trigger does not fire when timer is paused before threshold.""" + now = dt_util.utcnow() + calls: list[dict[str, Any]] = [] + + hass.states.async_set("timer.test", STATUS_IDLE, {ATTR_LAST_TRANSITION: None}) + await hass.async_block_till_done() + + await _arm_time_remaining_trigger(hass, "timer.test", {"seconds": 30}, calls) + + # Start timer with 60 second duration + finishes_at = now + timedelta(seconds=60) + hass.states.async_set( + "timer.test", + STATUS_ACTIVE, + {ATTR_LAST_TRANSITION: "started", ATTR_FINISHES_AT: finishes_at.isoformat()}, + ) + await hass.async_block_till_done() + + # Pause timer at 10 seconds (before the 30-second threshold) + freezer.move_to(now + timedelta(seconds=10)) + hass.states.async_set( + "timer.test", + STATUS_PAUSED, + {ATTR_LAST_TRANSITION: "paused"}, + ) + await hass.async_block_till_done() + + # Advance past the original fire time - should not fire since paused + freezer.move_to(now + timedelta(seconds=35)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert len(calls) == 0 + + +@pytest.mark.usefixtures("enable_labs_preview_features") +async def test_time_remaining_trigger_cancelled_before_threshold( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: + """Test time_remaining trigger does not fire when timer is cancelled before threshold.""" + now = dt_util.utcnow() + calls: list[dict[str, Any]] = [] + + hass.states.async_set("timer.test", STATUS_IDLE, {ATTR_LAST_TRANSITION: None}) + await hass.async_block_till_done() + + await _arm_time_remaining_trigger(hass, "timer.test", {"seconds": 30}, calls) + + # Start timer with 60 second duration + finishes_at = now + timedelta(seconds=60) + hass.states.async_set( + "timer.test", + STATUS_ACTIVE, + {ATTR_LAST_TRANSITION: "started", ATTR_FINISHES_AT: finishes_at.isoformat()}, + ) + await hass.async_block_till_done() + + # Cancel timer at 10 seconds + freezer.move_to(now + timedelta(seconds=10)) + hass.states.async_set( + "timer.test", + STATUS_IDLE, + {ATTR_LAST_TRANSITION: "cancelled"}, + ) + await hass.async_block_till_done() + + # Advance past the original fire time - should not fire since cancelled + freezer.move_to(now + timedelta(seconds=35)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert len(calls) == 0 + + +@pytest.mark.usefixtures("enable_labs_preview_features") +async def test_time_remaining_trigger_restarted( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: + """Test time_remaining trigger reschedules when timer is restarted.""" + now = dt_util.utcnow() + calls: list[dict[str, Any]] = [] + + hass.states.async_set("timer.test", STATUS_IDLE, {ATTR_LAST_TRANSITION: None}) + await hass.async_block_till_done() + + await _arm_time_remaining_trigger(hass, "timer.test", {"seconds": 30}, calls) + + # Start timer with 60 second duration + finishes_at = now + timedelta(seconds=60) + hass.states.async_set( + "timer.test", + STATUS_ACTIVE, + {ATTR_LAST_TRANSITION: "started", ATTR_FINISHES_AT: finishes_at.isoformat()}, + ) + await hass.async_block_till_done() + + # Restart timer at 10 seconds with a new 60-second duration + freezer.move_to(now + timedelta(seconds=10)) + new_finishes_at = now + timedelta(seconds=70) # 10s elapsed + 60s new + hass.states.async_set( + "timer.test", + STATUS_ACTIVE, + { + ATTR_LAST_TRANSITION: "restarted", + ATTR_FINISHES_AT: new_finishes_at.isoformat(), + }, + ) + await hass.async_block_till_done() + assert len(calls) == 0 + + # Original fire time (30s) should not fire since rescheduled + freezer.move_to(now + timedelta(seconds=30)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert len(calls) == 0 + + # New fire time: new_finishes_at - 30s = now + 40s + freezer.move_to(now + timedelta(seconds=40)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert len(calls) == 1 + + +@pytest.mark.usefixtures("enable_labs_preview_features") +async def test_time_remaining_trigger_short_timer( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: + """Test time_remaining trigger does not fire when timer duration is shorter than remaining threshold.""" + now = dt_util.utcnow() + calls: list[dict[str, Any]] = [] + + hass.states.async_set("timer.test", STATUS_IDLE, {ATTR_LAST_TRANSITION: None}) + await hass.async_block_till_done() + + await _arm_time_remaining_trigger(hass, "timer.test", {"seconds": 30}, calls) + + # Start timer with only 20 second duration (less than 30s threshold) + finishes_at = now + timedelta(seconds=20) + hass.states.async_set( + "timer.test", + STATUS_ACTIVE, + {ATTR_LAST_TRANSITION: "started", ATTR_FINISHES_AT: finishes_at.isoformat()}, + ) + await hass.async_block_till_done() + + # fire_at = now + 20 - 30 = now - 10 (in the past), should not schedule + # Advance past the timer's end time + freezer.move_to(now + timedelta(seconds=25)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert len(calls) == 0 + + +@pytest.mark.usefixtures("enable_labs_preview_features") +async def test_time_remaining_trigger_already_active_at_attach( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: + """Test trigger schedules for timers already active when the trigger attaches.""" + now = dt_util.utcnow() + calls: list[dict[str, Any]] = [] + + # Timer is already active before the trigger is armed + finishes_at = now + timedelta(seconds=60) + hass.states.async_set( + "timer.test", + STATUS_ACTIVE, + {ATTR_LAST_TRANSITION: "started", ATTR_FINISHES_AT: finishes_at.isoformat()}, + ) + await hass.async_block_till_done() + + await _arm_time_remaining_trigger(hass, "timer.test", {"seconds": 30}, calls) + + # No fire yet + assert len(calls) == 0 + + # Before fire_at (finishes_at - 30s = now + 30s) — should not fire + freezer.move_to(now + timedelta(seconds=25)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert len(calls) == 0 + + # At fire_at — should fire even though no state change occurred + freezer.move_to(now + timedelta(seconds=30)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert len(calls) == 1 + assert calls[0]["entity_id"] == "timer.test" + assert calls[0]["remaining"] == timedelta(seconds=30) + + +@pytest.mark.usefixtures("enable_labs_preview_features") +async def test_time_remaining_trigger_already_active_past_threshold_at_attach( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: + """Test trigger does not schedule for timers already past the fire point at attach.""" + now = dt_util.utcnow() + calls: list[dict[str, Any]] = [] + + # Timer is active but only 20 seconds remain — past the 30s threshold already + finishes_at = now + timedelta(seconds=20) + hass.states.async_set( + "timer.test", + STATUS_ACTIVE, + {ATTR_LAST_TRANSITION: "started", ATTR_FINISHES_AT: finishes_at.isoformat()}, + ) + await hass.async_block_till_done() + + await _arm_time_remaining_trigger(hass, "timer.test", {"seconds": 30}, calls) + + # Advance past the timer's finishing time — should never fire + freezer.move_to(now + timedelta(seconds=25)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert len(calls) == 0 + + +@pytest.mark.usefixtures("enable_labs_preview_features") +async def test_time_remaining_trigger_idle_at_attach( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: + """Test trigger does not schedule for non-active timers at attach time.""" + now = dt_util.utcnow() + calls: list[dict[str, Any]] = [] + + hass.states.async_set("timer.test", STATUS_IDLE, {ATTR_LAST_TRANSITION: None}) + await hass.async_block_till_done() + + await _arm_time_remaining_trigger(hass, "timer.test", {"seconds": 30}, calls) + + # Even far in the future, no fire because timer never started + freezer.move_to(now + timedelta(seconds=120)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert len(calls) == 0 + + +@pytest.mark.usefixtures("enable_labs_preview_features") +async def test_time_remaining_trigger_active_on_first_state_event( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: + """Test trigger schedules when first observed state event has no from_state. + + This simulates a timer entity that is created/restored after the trigger + is attached and appears directly in active state (e.g., RestoreEntity on + restart), where the initial state-change event has from_state=None. + """ + now = dt_util.utcnow() + calls: list[dict[str, Any]] = [] + + await _arm_time_remaining_trigger(hass, "timer.test", {"seconds": 30}, calls) + + # First state event for the entity has no old_state + finishes_at = now + timedelta(seconds=60) + hass.states.async_set( + "timer.test", + STATUS_ACTIVE, + {ATTR_LAST_TRANSITION: "started", ATTR_FINISHES_AT: finishes_at.isoformat()}, + ) + await hass.async_block_till_done() + assert len(calls) == 0 + + # Advance to fire time — should still fire even though from_state was None + freezer.move_to(now + timedelta(seconds=30)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert len(calls) == 1 + assert calls[0]["entity_id"] == "timer.test" + assert calls[0]["remaining"] == timedelta(seconds=30) + + +@pytest.mark.usefixtures("enable_labs_preview_features") +async def test_time_remaining_trigger_entity_removed_from_target( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + entity_registry: er.EntityRegistry, +) -> None: + """Test trigger cancels scheduled fire when entity is removed from the target.""" + now = dt_util.utcnow() + calls: list[dict[str, Any]] = [] + + label_reg = lr.async_get(hass) + label = label_reg.async_create("Test Time Remaining") + + entry = entity_registry.async_get_or_create( + domain=DOMAIN, platform="test", unique_id="time_remaining_remove" + ) + entity_registry.async_update_entity(entry.entity_id, labels={label.label_id}) + + hass.states.async_set(entry.entity_id, STATUS_IDLE, {ATTR_LAST_TRANSITION: None}) + await hass.async_block_till_done() + + await _arm_time_remaining_trigger( + hass, + entry.entity_id, + {"seconds": 30}, + calls, + target={ATTR_LABEL_ID: label.label_id}, + ) + + # Start the timer — this schedules a fire via the state-change path + finishes_at = now + timedelta(seconds=60) + hass.states.async_set( + entry.entity_id, + STATUS_ACTIVE, + {ATTR_LAST_TRANSITION: "started", ATTR_FINISHES_AT: finishes_at.isoformat()}, + ) + await hass.async_block_till_done() + + # Remove the entity from the target by stripping its label + freezer.move_to(now + timedelta(seconds=10)) + entity_registry.async_update_entity(entry.entity_id, labels=set()) + await hass.async_block_till_done() + + # Advance past the original fire time — should not fire since cancelled + freezer.move_to(now + timedelta(seconds=35)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert len(calls) == 0 + + +@pytest.mark.usefixtures("enable_labs_preview_features") +async def test_time_remaining_trigger_entity_added_to_target( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + entity_registry: er.EntityRegistry, +) -> None: + """Test trigger schedules a fire for an active timer added to the target later.""" + now = dt_util.utcnow() + calls: list[dict[str, Any]] = [] + + label_reg = lr.async_get(hass) + label = label_reg.async_create("Test Time Remaining Add") + + entry = entity_registry.async_get_or_create( + domain=DOMAIN, platform="test", unique_id="time_remaining_add" + ) + + # Timer is active, but not in the target yet + finishes_at = now + timedelta(seconds=60) + hass.states.async_set( + entry.entity_id, + STATUS_ACTIVE, + {ATTR_LAST_TRANSITION: "started", ATTR_FINISHES_AT: finishes_at.isoformat()}, + ) + await hass.async_block_till_done() + + await _arm_time_remaining_trigger( + hass, + entry.entity_id, + {"seconds": 30}, + calls, + target={ATTR_LABEL_ID: label.label_id}, + ) + + # Now label the entity so it joins the target + entity_registry.async_update_entity(entry.entity_id, labels={label.label_id}) + await hass.async_block_till_done() + + # Advance to the fire time — should fire even though no state change occurred + freezer.move_to(now + timedelta(seconds=30)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert len(calls) == 1 + assert calls[0]["entity_id"] == entry.entity_id + assert calls[0]["remaining"] == timedelta(seconds=30) From 2af9405750426fd749c8b2b46055dd1ab21385a4 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 6 May 2026 08:42:05 +0200 Subject: [PATCH 2/9] Cleanup unused code in Tuya util (#169883) --- homeassistant/components/tuya/util.py | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/homeassistant/components/tuya/util.py b/homeassistant/components/tuya/util.py index 0210ea4c0e2ae7..824ff6bc91d74c 100644 --- a/homeassistant/components/tuya/util.py +++ b/homeassistant/components/tuya/util.py @@ -7,27 +7,6 @@ from .const import DOMAIN, DPCode -def get_dpcode( - device: CustomerDevice, dpcodes: str | tuple[str, ...] | None -) -> str | None: - """Get the first matching DPCode from the device or return None.""" - if dpcodes is None: - return None - - if not isinstance(dpcodes, tuple): - dpcodes = (dpcodes,) - - for dpcode in dpcodes: - if ( - dpcode in device.function - or dpcode in device.status - or dpcode in device.status_range - ): - return dpcode - - return None - - class ActionDPCodeNotFoundError(ServiceValidationError): """Custom exception for action DP code not found errors.""" From 073b20c4b26d2426256850ca5abb862bf8803663 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 6 May 2026 09:09:24 +0200 Subject: [PATCH 3/9] Fix Zinvolt select options (#169886) --- homeassistant/components/zinvolt/select.py | 5 ++--- homeassistant/components/zinvolt/strings.json | 3 +-- tests/components/zinvolt/snapshots/test_select.ambr | 8 +++----- 3 files changed, 6 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/zinvolt/select.py b/homeassistant/components/zinvolt/select.py index ae68470c146759..efa2bcbba1f76e 100644 --- a/homeassistant/components/zinvolt/select.py +++ b/homeassistant/components/zinvolt/select.py @@ -13,9 +13,8 @@ SmartMode.DYNAMIC: "dynamic", SmartMode.SELF_USE: "self_use", SmartMode.PERFORMANCE: "fast_discharge", - SmartMode.CHARGED: "charged", - SmartMode.DEFAULT: "idle", - SmartMode.FEED: "fast_charge", + SmartMode.CHARGED: "fast_charge", + SmartMode.FEED: "connected_solar_panels", } HA_TO_MODE = {v: k for k, v in MODE_MAP.items()} diff --git a/homeassistant/components/zinvolt/strings.json b/homeassistant/components/zinvolt/strings.json index f0ecb751af21ba..4612949a7a959f 100644 --- a/homeassistant/components/zinvolt/strings.json +++ b/homeassistant/components/zinvolt/strings.json @@ -66,11 +66,10 @@ "battery_mode": { "name": "Mode", "state": { - "charged": "Charged", + "connected_solar_panels": "Connected solar panels", "dynamic": "Dynamic", "fast_charge": "Fast charge", "fast_discharge": "Fast discharge", - "idle": "[%key:common::state::idle%]", "self_use": "Self-use" } } diff --git a/tests/components/zinvolt/snapshots/test_select.ambr b/tests/components/zinvolt/snapshots/test_select.ambr index 941a346b4f7e61..b0cb60b8f62abb 100644 --- a/tests/components/zinvolt/snapshots/test_select.ambr +++ b/tests/components/zinvolt/snapshots/test_select.ambr @@ -10,9 +10,8 @@ 'dynamic', 'self_use', 'fast_discharge', - 'charged', - 'idle', 'fast_charge', + 'connected_solar_panels', ]), }), 'config_entry_id': , @@ -53,9 +52,8 @@ 'dynamic', 'self_use', 'fast_discharge', - 'charged', - 'idle', 'fast_charge', + 'connected_solar_panels', ]), }), 'context': , @@ -63,6 +61,6 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'charged', + 'state': 'fast_charge', }) # --- From d89bcd83d9078e9ba0dc2c5a16004217459ae3c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zolt=C3=A1n=20Farkasdi?= <93778865+farkasdi@users.noreply.github.com> Date: Wed, 6 May 2026 09:16:22 +0200 Subject: [PATCH 4/9] netatmo: bump pyatmo v9.4.0 (#169735) Co-authored-by: Erik Montnemery --- homeassistant/components/netatmo/button.py | 5 ++--- homeassistant/components/netatmo/camera.py | 5 ++++- homeassistant/components/netatmo/climate.py | 5 ++++- homeassistant/components/netatmo/cover.py | 5 ++++- homeassistant/components/netatmo/fan.py | 5 ++++- homeassistant/components/netatmo/helper.py | 10 ++++++++++ homeassistant/components/netatmo/manifest.json | 2 +- homeassistant/components/netatmo/switch.py | 5 ++++- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/netatmo/snapshots/test_camera.ambr | 2 +- .../components/netatmo/snapshots/test_diagnostics.ambr | 3 +++ 12 files changed, 39 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/netatmo/button.py b/homeassistant/components/netatmo/button.py index cf06cbe366a21d..72a6b1056425fc 100644 --- a/homeassistant/components/netatmo/button.py +++ b/homeassistant/components/netatmo/button.py @@ -12,6 +12,7 @@ from .const import CONF_URL_CONTROL, NETATMO_CREATE_BUTTON from .data_handler import HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoDevice from .entity import NetatmoModuleEntity +from .helper import device_type_to_str _LOGGER = logging.getLogger(__name__) @@ -55,9 +56,7 @@ def __init__(self, netatmo_device: NetatmoDevice) -> None: }, ] ) - self._attr_unique_id = ( - f"{self.device.entity_id}-{self.device_type}-preferred_position" - ) + self._attr_unique_id = f"{self.device.entity_id}-{device_type_to_str(self.device_type)}-preferred_position" @callback def async_update_callback(self) -> None: diff --git a/homeassistant/components/netatmo/camera.py b/homeassistant/components/netatmo/camera.py index 9c2ff7fb7ca97b..6e19d674346fcc 100644 --- a/homeassistant/components/netatmo/camera.py +++ b/homeassistant/components/netatmo/camera.py @@ -42,6 +42,7 @@ ) from .data_handler import EVENT, HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoDevice from .entity import NetatmoModuleEntity +from .helper import device_type_to_str _LOGGER = logging.getLogger(__name__) @@ -102,7 +103,9 @@ def __init__( Camera.__init__(self) super().__init__(netatmo_device) - self._attr_unique_id = f"{netatmo_device.device.entity_id}-{self.device_type}" + self._attr_unique_id = ( + f"{netatmo_device.device.entity_id}-{device_type_to_str(self.device_type)}" + ) self._light_state = None self._publishers.extend( diff --git a/homeassistant/components/netatmo/climate.py b/homeassistant/components/netatmo/climate.py index f89d7f1b79f95d..f15c5515f5da13 100644 --- a/homeassistant/components/netatmo/climate.py +++ b/homeassistant/components/netatmo/climate.py @@ -54,6 +54,7 @@ ) from .data_handler import HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoRoom from .entity import NetatmoRoomEntity +from .helper import device_type_to_str _LOGGER = logging.getLogger(__name__) @@ -219,7 +220,9 @@ def __init__(self, room: NetatmoRoom) -> None: if self.device_type is NA_THERM: self._attr_hvac_modes.append(HVACMode.OFF) - self._attr_unique_id = f"{self.device.entity_id}-{self.device_type}" + self._attr_unique_id = ( + f"{self.device.entity_id}-{device_type_to_str(self.device_type)}" + ) async def async_added_to_hass(self) -> None: """Entity created.""" diff --git a/homeassistant/components/netatmo/cover.py b/homeassistant/components/netatmo/cover.py index 7d4ee1ed535f86..35d55edcef317d 100644 --- a/homeassistant/components/netatmo/cover.py +++ b/homeassistant/components/netatmo/cover.py @@ -18,6 +18,7 @@ from .const import CONF_URL_CONTROL, NETATMO_CREATE_COVER from .data_handler import HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoDevice from .entity import NetatmoModuleEntity +from .helper import device_type_to_str _LOGGER = logging.getLogger(__name__) @@ -70,7 +71,9 @@ def __init__(self, netatmo_device: NetatmoDevice) -> None: }, ] ) - self._attr_unique_id = f"{self.device.entity_id}-{self.device_type}" + self._attr_unique_id = ( + f"{self.device.entity_id}-{device_type_to_str(self.device_type)}" + ) async def async_close_cover(self, **kwargs: Any) -> None: """Close the cover.""" diff --git a/homeassistant/components/netatmo/fan.py b/homeassistant/components/netatmo/fan.py index 4075af13c2b43b..6c9665ca475fe4 100644 --- a/homeassistant/components/netatmo/fan.py +++ b/homeassistant/components/netatmo/fan.py @@ -13,6 +13,7 @@ from .const import CONF_URL_CONTROL, NETATMO_CREATE_FAN from .data_handler import HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoDevice from .entity import NetatmoModuleEntity +from .helper import device_type_to_str _LOGGER = logging.getLogger(__name__) @@ -62,7 +63,9 @@ def __init__(self, netatmo_device: NetatmoDevice) -> None: ] ) - self._attr_unique_id = f"{self.device.entity_id}-{self.device_type}" + self._attr_unique_id = ( + f"{self.device.entity_id}-{device_type_to_str(self.device_type)}" + ) async def async_set_preset_mode(self, preset_mode: str) -> None: """Set the preset mode of the fan.""" diff --git a/homeassistant/components/netatmo/helper.py b/homeassistant/components/netatmo/helper.py index d3a07cd6b3dbe1..b1b305e6709cfd 100644 --- a/homeassistant/components/netatmo/helper.py +++ b/homeassistant/components/netatmo/helper.py @@ -3,6 +3,16 @@ from dataclasses import dataclass from uuid import UUID, uuid4 +from pyatmo.modules.device_types import DeviceType as NetatmoDeviceType + + +def device_type_to_str(device_type: NetatmoDeviceType) -> str: + """Convert a device type to a string. + + Used to generate backwards compatible unique ids. + """ + return f"{type(device_type).__name__}.{device_type}" + @dataclass class NetatmoArea: diff --git a/homeassistant/components/netatmo/manifest.json b/homeassistant/components/netatmo/manifest.json index aeb4ffa0c55ee4..6d6aea230f1870 100644 --- a/homeassistant/components/netatmo/manifest.json +++ b/homeassistant/components/netatmo/manifest.json @@ -12,5 +12,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["pyatmo"], - "requirements": ["pyatmo==9.2.3"] + "requirements": ["pyatmo==9.4.0"] } diff --git a/homeassistant/components/netatmo/switch.py b/homeassistant/components/netatmo/switch.py index 31102211b769d2..0c0a1570a7fdd6 100644 --- a/homeassistant/components/netatmo/switch.py +++ b/homeassistant/components/netatmo/switch.py @@ -13,6 +13,7 @@ from .const import CONF_URL_CONTROL, NETATMO_CREATE_SWITCH from .data_handler import HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoDevice from .entity import NetatmoModuleEntity +from .helper import device_type_to_str _LOGGER = logging.getLogger(__name__) @@ -58,7 +59,9 @@ def __init__( }, ] ) - self._attr_unique_id = f"{self.device.entity_id}-{self.device_type}" + self._attr_unique_id = ( + f"{self.device.entity_id}-{device_type_to_str(self.device_type)}" + ) self._attr_is_on = self.device.on @callback diff --git a/requirements_all.txt b/requirements_all.txt index 54765b415d46a7..5bd38f7ebb1f87 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1990,7 +1990,7 @@ pyaprilaire==0.9.1 pyatag==0.3.5.3 # homeassistant.components.netatmo -pyatmo==9.2.3 +pyatmo==9.4.0 # homeassistant.components.apple_tv pyatv==0.17.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 261f0c6d11b9b9..7c25e24afe574f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1727,7 +1727,7 @@ pyaprilaire==0.9.1 pyatag==0.3.5.3 # homeassistant.components.netatmo -pyatmo==9.2.3 +pyatmo==9.4.0 # homeassistant.components.apple_tv pyatv==0.17.0 diff --git a/tests/components/netatmo/snapshots/test_camera.ambr b/tests/components/netatmo/snapshots/test_camera.ambr index 72fb74ea051b76..b6d06ddfdea10f 100644 --- a/tests/components/netatmo/snapshots/test_camera.ambr +++ b/tests/components/netatmo/snapshots/test_camera.ambr @@ -174,7 +174,7 @@ 'entity_picture': '/api/camera_proxy/camera.netatmo_doorbell?token=1caab5c3b3', 'friendly_name': 'Netatmo-Doorbell', 'id': '12:34:56:10:f1:66', - 'is_local': None, + 'is_local': False, 'light_state': None, 'local_url': None, 'monitoring': True, diff --git a/tests/components/netatmo/snapshots/test_diagnostics.ambr b/tests/components/netatmo/snapshots/test_diagnostics.ambr index 3a66aa84c4142e..829ffc342915c8 100644 --- a/tests/components/netatmo/snapshots/test_diagnostics.ambr +++ b/tests/components/netatmo/snapshots/test_diagnostics.ambr @@ -581,6 +581,7 @@ 'access_camera', 'access_doorbell', 'access_presence', + 'access_camerapro', 'read_bubendorff', 'read_bfi', 'read_camera', @@ -595,6 +596,7 @@ 'read_smokedetector', 'read_station', 'read_thermostat', + 'read_camerapro', 'write_bubendorff', 'write_bfi', 'write_camera', @@ -604,6 +606,7 @@ 'write_presence', 'write_smarther', 'write_thermostat', + 'write_camerapro', ]), 'type': 'Bearer', }), From 97b5f1cf64345a2d56a5a60301a9291713055dc5 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 6 May 2026 09:49:22 +0200 Subject: [PATCH 5/9] Add method _should_include to EntityConditionBase (#169884) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/climate/condition.py | 21 +++++-- homeassistant/helpers/condition.py | 17 +++++- tests/components/climate/test_condition.py | 2 + tests/components/common.py | 59 +++++++++++++++++++ 4 files changed, 92 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/climate/condition.py b/homeassistant/components/climate/condition.py index 0d1b5803b599d7..51a5bfcf6d11fa 100644 --- a/homeassistant/components/climate/condition.py +++ b/homeassistant/components/climate/condition.py @@ -13,8 +13,8 @@ Condition, ConditionConfig, EntityConditionBase, + EntityNumericalConditionBase, EntityNumericalConditionWithUnitBase, - make_entity_numerical_condition, make_entity_state_condition, ) from homeassistant.util.unit_conversion import TemperatureConverter @@ -65,6 +65,20 @@ def _get_entity_unit(self, entity_state: State) -> str | None: return self._hass.config.units.temperature_unit +class ClimateTargetHumidityCondition(EntityNumericalConditionBase): + """Condition for climate target humidity.""" + + _domain_specs = {DOMAIN: DomainSpec(value_source=ATTR_HUMIDITY)} + _valid_unit = "%" + + def _should_include(self, state: State) -> bool: + """Skip climate entities that do not expose a target humidity.""" + return ( + super()._should_include(state) + and state.attributes.get(ATTR_HUMIDITY) is not None + ) + + CONDITIONS: dict[str, type[Condition]] = { "is_hvac_mode": ClimateHVACModeCondition, "is_off": make_entity_state_condition(DOMAIN, HVACMode.OFF), @@ -88,10 +102,7 @@ def _get_entity_unit(self, entity_state: State) -> str | None: "is_heating": make_entity_state_condition( {DOMAIN: DomainSpec(value_source=ATTR_HVAC_ACTION)}, HVACAction.HEATING ), - "target_humidity": make_entity_numerical_condition( - {DOMAIN: DomainSpec(value_source=ATTR_HUMIDITY)}, - valid_unit="%", - ), + "target_humidity": ClimateTargetHumidityCondition, "target_temperature": ClimateTargetTemperatureCondition, } diff --git a/homeassistant/helpers/condition.py b/homeassistant/helpers/condition.py index 29b794b64f0f5f..d33a793d72524b 100644 --- a/homeassistant/helpers/condition.py +++ b/homeassistant/helpers/condition.py @@ -437,6 +437,9 @@ class EntityConditionBase(Condition): """Base class for entity conditions.""" _domain_specs: Mapping[str, DomainSpec] + _excluded_states: Final[frozenset[str]] = frozenset( + {STATE_UNAVAILABLE, STATE_UNKNOWN} + ) _schema: vol.Schema = ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL # When True, indirect target expansion (via device/area/floor) skips # entities with an entity_category. @@ -501,7 +504,7 @@ def _update_valid_since(self, entity_id: str, _state: State | None) -> None: """ if ( _state is not None - and _state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN) + and self._should_include(_state) and self.is_valid_state(_state) ): # Only record the time if not already tracked, to avoid @@ -566,6 +569,16 @@ def _get_tracked_value(self, entity_state: State) -> Any: return entity_state.state return entity_state.attributes.get(domain_spec.value_source) + def _should_include(self, _state: State) -> bool: + """Check if an entity should participate in any/all checks. + + The default implementation excludes only entities whose state.state + is in `_excluded_states` (unavailable / unknown). Subclasses can + override to also exclude entities that lack the optional capability + the condition relies on. + """ + return _state.state not in self._excluded_states + @abc.abstractmethod def is_valid_state(self, entity_state: State) -> bool: """Check if the state matches the expected state(s).""" @@ -622,7 +635,7 @@ def _async_check(self, **kwargs: Unpack[ConditionCheckParams]) -> bool: _state for entity_id in filtered_entity_ids if (_state := self._hass.states.get(entity_id)) - and _state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN) + and self._should_include(_state) ] return self._matcher(entity_states) diff --git a/tests/components/climate/test_condition.py b/tests/components/climate/test_condition.py index 7da1595edc4684..32abb574ac36f5 100644 --- a/tests/components/climate/test_condition.py +++ b/tests/components/climate/test_condition.py @@ -332,6 +332,7 @@ async def test_climate_attribute_condition_behavior_all( "climate.target_humidity", HVACMode.AUTO, ATTR_HUMIDITY, + attribute_required=True, ), *parametrize_numerical_attribute_condition_above_below_any( "climate.target_temperature", @@ -376,6 +377,7 @@ async def test_climate_numerical_condition_behavior_any( "climate.target_humidity", HVACMode.AUTO, ATTR_HUMIDITY, + attribute_required=True, ), *parametrize_numerical_attribute_condition_above_below_all( "climate.target_temperature", diff --git a/tests/components/common.py b/tests/components/common.py index 5903941decaf27..e26ae70e3cfd19 100644 --- a/tests/components/common.py +++ b/tests/components/common.py @@ -246,6 +246,7 @@ def _parametrize_condition_states( condition_options: dict[str, Any] | None = None, target_states: list[str | None | tuple[str | None, dict]], other_states: list[str | None | tuple[str | None, dict]], + extra_excluded_states: list[str | None | tuple[str | None, dict]] | None = None, required_filter_attributes: dict | None, condition_true_if_invalid: bool, excluded_entities_from_other_domain: bool, @@ -261,6 +262,7 @@ def _parametrize_condition_states( required_filter_attributes = required_filter_attributes or {} condition_options = condition_options or {} + extra_excluded_states = extra_excluded_states or [] add_excluded_state = excluded_entities_from_other_domain or bool( required_filter_attributes ) @@ -314,6 +316,18 @@ def state_with_attributes( STATE_UNKNOWN, condition_true_if_invalid, True ), ), + # `extra_excluded_states` are filtered by the condition's + # `_should_include` override exactly like + # missing/unavailable/unknown, so they share the + # `condition_true_if_invalid` expectation: vacuous True + # under behavior=all (every entity filtered → all-check + # vacuous), vacuous False under behavior=any. + ( + state_with_attributes( + extra_excluded_state, condition_true_if_invalid, True + ) + for extra_excluded_state in extra_excluded_states + ), ( state_with_attributes(other_state, False, False) for other_state in other_states @@ -342,6 +356,7 @@ def parametrize_condition_states_any( condition_options: dict[str, Any] | None = None, target_states: list[str | None | tuple[str | None, dict]], other_states: list[str | None | tuple[str | None, dict]], + extra_excluded_states: list[str | None | tuple[str | None, dict]] | None = None, required_filter_attributes: dict | None = None, excluded_entities_from_other_domain: bool = False, ) -> list[tuple[str, dict[str, Any], list[ConditionStateDescription]]]: @@ -364,6 +379,13 @@ def parametrize_condition_states_any( other_states: States the condition is expected to evaluate False for. Same accepted shapes as `target_states`. With behavior=any, an entity in such a state does not satisfy the condition. + extra_excluded_states: *Additional* states (on top of the always- + excluded missing/unavailable/unknown states) that the + condition's `_should_include` override is expected to filter out. + Under behavior=any, every targeted entity sitting in a filtered + state yields `any([]) → False`, so these share the built-in + invalid states' expectation. Set this for conditions whose + `_should_include` skips entities lacking the tracked attribute. required_filter_attributes: Attributes that must be present on the entity for the condition's domain filter to accept it. The helper merges these into every generated state so the entity @@ -380,6 +402,7 @@ def parametrize_condition_states_any( condition_options=condition_options, target_states=target_states, other_states=other_states, + extra_excluded_states=extra_excluded_states, required_filter_attributes=required_filter_attributes, condition_true_if_invalid=False, excluded_entities_from_other_domain=excluded_entities_from_other_domain, @@ -392,6 +415,7 @@ def parametrize_condition_states_all( condition_options: dict[str, Any] | None = None, target_states: list[str | None | tuple[str | None, dict]], other_states: list[str | None | tuple[str | None, dict]], + extra_excluded_states: list[str | None | tuple[str | None, dict]] | None = None, required_filter_attributes: dict | None = None, excluded_entities_from_other_domain: bool = False, ) -> list[tuple[str, dict[str, Any], list[ConditionStateDescription]]]: @@ -416,6 +440,14 @@ def parametrize_condition_states_all( for. Same accepted shapes as `target_states`. Under behavior=all, an entity in such a state blocks the all-check (counts toward the check but is not a match). + extra_excluded_states: *Additional* states (on top of the always- + excluded/filtered-out missing/unavailable/unknown states) that + the condition's `_should_include` override is expected to filter + out. Under behavior=all, every targeted entity sitting in a + filtered state yields `all([]) → True` (vacuous), so these share + the built-in invalid states' expectation. Set this for + conditions whose `_should_include` skips entities lacking the + tracked attribute. required_filter_attributes: Attributes that must be present on the entity for the condition's domain filter to accept it. The helper merges these into every generated state so the entity @@ -432,6 +464,7 @@ def parametrize_condition_states_all( condition_options=condition_options, target_states=target_states, other_states=other_states, + extra_excluded_states=extra_excluded_states, required_filter_attributes=required_filter_attributes, condition_true_if_invalid=True, excluded_entities_from_other_domain=excluded_entities_from_other_domain, @@ -2095,6 +2128,7 @@ def parametrize_numerical_attribute_condition_above_below_any( required_filter_attributes: dict | None = None, threshold_unit: str | None | UndefinedType = UNDEFINED, unit_attributes: dict | None = None, + attribute_required: bool = False, ) -> list[tuple[str, dict[str, Any], list[ConditionStateDescription]]]: """Parametrize above/below/between threshold cases for attribute-based numerical conditions under behavior=any. @@ -2134,9 +2168,18 @@ def parametrize_numerical_attribute_condition_above_below_any( `{ATTR_UNIT_OF_MEASUREMENT: ...}`) merged into every generated state, so the entity carries a unit alongside its tracked attribute. + attribute_required: When True, `(state, {attribute: None})` is + classified as an *excluded* state (filtered out of the all/any + check by the condition's `_should_include` override) rather + than treated as just-missing. Set this for conditions whose + `_should_include` skips entities lacking the tracked + attribute. """ condition_options = condition_options or {} unit_attributes = unit_attributes or {} + extra_excluded_states = ( + [(state, {attribute: None} | unit_attributes)] if attribute_required else None + ) return [ *parametrize_condition_states_any( @@ -2158,6 +2201,7 @@ def parametrize_numerical_attribute_condition_above_below_any( (state, {attribute: 10} | unit_attributes), (state, {attribute: 20} | unit_attributes), ], + extra_excluded_states=extra_excluded_states, required_filter_attributes=required_filter_attributes, ), *parametrize_condition_states_any( @@ -2179,6 +2223,7 @@ def parametrize_numerical_attribute_condition_above_below_any( (state, {attribute: 90} | unit_attributes), (state, {attribute: 100} | unit_attributes), ], + extra_excluded_states=extra_excluded_states, required_filter_attributes=required_filter_attributes, ), *parametrize_condition_states_any( @@ -2205,6 +2250,7 @@ def parametrize_numerical_attribute_condition_above_below_any( (state, {attribute: 80} | unit_attributes), (state, {attribute: 100} | unit_attributes), ], + extra_excluded_states=extra_excluded_states, required_filter_attributes=required_filter_attributes, ), ] @@ -2219,6 +2265,7 @@ def parametrize_numerical_attribute_condition_above_below_all( required_filter_attributes: dict | None = None, threshold_unit: str | None | UndefinedType = UNDEFINED, unit_attributes: dict | None = None, + attribute_required: bool = False, ) -> list[tuple[str, dict[str, Any], list[ConditionStateDescription]]]: """Parametrize above/below/between threshold cases for attribute-based numerical conditions under behavior=all. @@ -2256,9 +2303,18 @@ def parametrize_numerical_attribute_condition_above_below_all( `{ATTR_UNIT_OF_MEASUREMENT: ...}`) merged into every generated state, so the entity carries a unit alongside its tracked attribute. + attribute_required: When True, `(state, {attribute: None})` is + classified as an *excluded* state (filtered out of the all/any + check by the condition's `_should_include` override) rather + than treated as just-missing. Set this for conditions whose + `_should_include` skips entities lacking the tracked + attribute. """ condition_options = condition_options or {} unit_attributes = unit_attributes or {} + extra_excluded_states = ( + [(state, {attribute: None} | unit_attributes)] if attribute_required else None + ) return [ *parametrize_condition_states_all( @@ -2280,6 +2336,7 @@ def parametrize_numerical_attribute_condition_above_below_all( (state, {attribute: 10} | unit_attributes), (state, {attribute: 20} | unit_attributes), ], + extra_excluded_states=extra_excluded_states, required_filter_attributes=required_filter_attributes, ), *parametrize_condition_states_all( @@ -2301,6 +2358,7 @@ def parametrize_numerical_attribute_condition_above_below_all( (state, {attribute: 90} | unit_attributes), (state, {attribute: 100} | unit_attributes), ], + extra_excluded_states=extra_excluded_states, required_filter_attributes=required_filter_attributes, ), *parametrize_condition_states_all( @@ -2327,6 +2385,7 @@ def parametrize_numerical_attribute_condition_above_below_all( (state, {attribute: 80} | unit_attributes), (state, {attribute: 100} | unit_attributes), ], + extra_excluded_states=extra_excluded_states, required_filter_attributes=required_filter_attributes, ), ] From 5d091d25d5e59919533a2abaa754259652ea6872 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 6 May 2026 09:50:18 +0200 Subject: [PATCH 6/9] Bump j178/prek-action from 2.0.2 to 2.0.3 (#169882) --- .github/workflows/ci.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index ef15440bb2532f..53f81800a42df5 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -281,7 +281,7 @@ jobs: echo "::add-matcher::.github/workflows/matchers/check-executables-have-shebangs.json" echo "::add-matcher::.github/workflows/matchers/codespell.json" - name: Run prek - uses: j178/prek-action@cbc2f23eb5539cf20d82d1aabd0d0ecbcc56f4e3 # v2.0.2 + uses: j178/prek-action@6ad80277337ad479fe43bd70701c3f7f8aa74db3 # v2.0.3 env: PREK_SKIP: no-commit-to-branch,mypy,pylint,gen_requirements_all,hassfest,hassfest-metadata,hassfest-mypy-config,zizmor RUFF_OUTPUT_FORMAT: github @@ -302,7 +302,7 @@ jobs: with: persist-credentials: false - name: Run zizmor - uses: j178/prek-action@cbc2f23eb5539cf20d82d1aabd0d0ecbcc56f4e3 # v2.0.2 + uses: j178/prek-action@6ad80277337ad479fe43bd70701c3f7f8aa74db3 # v2.0.3 with: extra-args: --all-files zizmor From ee84d625cd2e59819735c6bdcb203557db1b9199 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Wed, 6 May 2026 10:16:31 +0200 Subject: [PATCH 7/9] Expose SET_SPEED for all fans via PercentSetting in Matter (#169696) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Ludovic BOUÉ <132135057+lboue@users.noreply.github.com> --- homeassistant/components/matter/fan.py | 10 ++++-- .../components/matter/snapshots/test_fan.ambr | 18 +++++++---- tests/components/matter/test_fan.py | 32 +++++++++++++++++++ 3 files changed, 52 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/matter/fan.py b/homeassistant/components/matter/fan.py index 969d32432a08da..2a15fc29b477de 100644 --- a/homeassistant/components/matter/fan.py +++ b/homeassistant/components/matter/fan.py @@ -251,8 +251,10 @@ def _calculate_features( return self._feature_map = feature_map self._attr_supported_features = FanEntityFeature(0) + # Reset to default so a featuremap change from MultiSpeed -> non-MultiSpeed + # does not leave a stale speed_count / percentage_step. + self._attr_speed_count = 100 if feature_map & FanControlFeature.kMultiSpeed: - self._attr_supported_features |= FanEntityFeature.SET_SPEED self._attr_speed_count = int( self.get_matter_attribute_value(clusters.FanControl.Attributes.SpeedMax) ) @@ -302,8 +304,12 @@ def _calculate_features( if feature_map & FanControlFeature.kAirflowDirection: self._attr_supported_features |= FanEntityFeature.DIRECTION + # PercentSetting is always a mandatory attribute of the FanControl cluster, + # so percentage-based speed control is always available. self._attr_supported_features |= ( - FanEntityFeature.TURN_OFF | FanEntityFeature.TURN_ON + FanEntityFeature.SET_SPEED + | FanEntityFeature.TURN_OFF + | FanEntityFeature.TURN_ON ) diff --git a/tests/components/matter/snapshots/test_fan.ambr b/tests/components/matter/snapshots/test_fan.ambr index cf5c8c568ffe06..df0b19220c15b0 100644 --- a/tests/components/matter/snapshots/test_fan.ambr +++ b/tests/components/matter/snapshots/test_fan.ambr @@ -37,7 +37,7 @@ 'platform': 'matter', 'previous_unique_id': None, 'suggested_object_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': 'fan', 'unique_id': '00000000000004D2-0000000000000004-MatterNodeDevice-1-MatterFan-514-0', 'unit_of_measurement': None, @@ -47,6 +47,8 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Longan link HVAC', + 'percentage': 0, + 'percentage_step': 1.0, 'preset_mode': None, 'preset_modes': list([ 'low', @@ -54,7 +56,7 @@ 'high', 'auto', ]), - 'supported_features': , + 'supported_features': , }), 'context': , 'entity_id': 'fan.longan_link_hvac', @@ -174,7 +176,7 @@ 'platform': 'matter', 'previous_unique_id': None, 'suggested_object_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': 'fan', 'unique_id': '00000000000004D2-0000000000000049-MatterNodeDevice-1-MatterFan-514-0', 'unit_of_measurement': None, @@ -184,13 +186,15 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Mock Extractor hood', + 'percentage': 0, + 'percentage_step': 1.0, 'preset_mode': None, 'preset_modes': list([ 'low', 'medium', 'high', ]), - 'supported_features': , + 'supported_features': , }), 'context': , 'entity_id': 'fan.mock_extractor_hood', @@ -450,7 +454,7 @@ 'platform': 'matter', 'previous_unique_id': None, 'suggested_object_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': 'fan', 'unique_id': '00000000000004D2-0000000000000072-MatterNodeDevice-1-MatterFan-514-0', 'unit_of_measurement': None, @@ -460,13 +464,15 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'SL-RangeHood', + 'percentage': 0, + 'percentage_step': 1.0, 'preset_mode': None, 'preset_modes': list([ 'low', 'medium', 'high', ]), - 'supported_features': , + 'supported_features': , }), 'context': , 'entity_id': 'fan.sl_rangehood', diff --git a/tests/components/matter/test_fan.py b/tests/components/matter/test_fan.py index 6da7d3e86f0fe8..b9bba9d218d5b5 100644 --- a/tests/components/matter/test_fan.py +++ b/tests/components/matter/test_fan.py @@ -16,6 +16,7 @@ DOMAIN as FAN_DOMAIN, SERVICE_OSCILLATE, SERVICE_SET_DIRECTION, + SERVICE_SET_PERCENTAGE, FanEntityFeature, ) from homeassistant.const import ( @@ -441,3 +442,34 @@ async def test_fan_features( state = hass.states.get(entity_id) assert state assert state.attributes["preset_modes"] == preset_modes + + +@pytest.mark.parametrize("node_fixture", ["silabs_range_hood"]) +async def test_fan_set_percentage_without_multispeed( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test percentage control on a fan without the MultiSpeed feature. + + PercentSetting is mandatory in the FanControl cluster regardless of features, + so SET_SPEED must be available and write to PercentSetting (attribute 0x0002). + """ + entity_id = "fan.sl_rangehood" + state = hass.states.get(entity_id) + assert state + assert state.attributes["supported_features"] & FanEntityFeature.SET_SPEED + assert state.attributes["percentage_step"] == 1.0 + + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_SET_PERCENTAGE, + {ATTR_ENTITY_ID: entity_id, ATTR_PERCENTAGE: 75}, + blocking=True, + ) + assert matter_client.write_attribute.call_count == 1 + assert matter_client.write_attribute.call_args == call( + node_id=matter_node.node_id, + attribute_path="1/514/2", + value=75, + ) From 267d52491a31be86302235b843b48e09158e5dff Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 6 May 2026 10:48:10 +0200 Subject: [PATCH 8/9] Add media_player volume triggers (#169885) --- .../components/media_player/icons.json | 6 + .../components/media_player/strings.json | 27 ++- .../components/media_player/trigger.py | 46 ++++ .../components/media_player/triggers.yaml | 44 +++- tests/components/common.py | 62 ++++-- tests/components/light/test_trigger.py | 160 +++----------- tests/components/media_player/test_trigger.py | 199 ++++++++++++++++++ 7 files changed, 386 insertions(+), 158 deletions(-) diff --git a/homeassistant/components/media_player/icons.json b/homeassistant/components/media_player/icons.json index 068a0910c3c7d1..c6c1b066b09a47 100644 --- a/homeassistant/components/media_player/icons.json +++ b/homeassistant/components/media_player/icons.json @@ -143,6 +143,12 @@ }, "unmuted": { "trigger": "mdi:volume-high" + }, + "volume_changed": { + "trigger": "mdi:volume-medium" + }, + "volume_crossed_threshold": { + "trigger": "mdi:volume-medium" } } } diff --git a/homeassistant/components/media_player/strings.json b/homeassistant/components/media_player/strings.json index 02e055ea01cb6f..346e76ccc074d3 100644 --- a/homeassistant/components/media_player/strings.json +++ b/homeassistant/components/media_player/strings.json @@ -3,7 +3,8 @@ "condition_behavior_name": "Condition passes if", "condition_for_name": "For at least", "trigger_behavior_name": "Trigger when", - "trigger_for_name": "For at least" + "trigger_for_name": "For at least", + "trigger_threshold_name": "Threshold" }, "conditions": { "is_not_playing": { @@ -520,6 +521,30 @@ } }, "name": "Media player unmuted" + }, + "volume_changed": { + "description": "Triggers after the volume of one or more media players changes.", + "fields": { + "threshold": { + "name": "[%key:component::media_player::common::trigger_threshold_name%]" + } + }, + "name": "Media player volume changed" + }, + "volume_crossed_threshold": { + "description": "Triggers after the volume of one or more media players crosses a threshold.", + "fields": { + "behavior": { + "name": "[%key:component::media_player::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::media_player::common::trigger_for_name%]" + }, + "threshold": { + "name": "[%key:component::media_player::common::trigger_threshold_name%]" + } + }, + "name": "Media player volume crossed threshold" } } } diff --git a/homeassistant/components/media_player/trigger.py b/homeassistant/components/media_player/trigger.py index c3ef068b41adcf..9bdb20460d031f 100644 --- a/homeassistant/components/media_player/trigger.py +++ b/homeassistant/components/media_player/trigger.py @@ -4,6 +4,9 @@ from homeassistant.core import HomeAssistant, State from homeassistant.helpers.automation import DomainSpec from homeassistant.helpers.trigger import ( + EntityNumericalStateChangedTriggerBase, + EntityNumericalStateCrossedThresholdTriggerBase, + EntityNumericalStateTriggerBase, EntityTriggerBase, Trigger, make_entity_transition_trigger, @@ -12,6 +15,10 @@ from . import ATTR_MEDIA_VOLUME_LEVEL, ATTR_MEDIA_VOLUME_MUTED, MediaPlayerState from .const import DOMAIN +VOLUME_DOMAIN_SPECS = { + DOMAIN: DomainSpec(value_source=ATTR_MEDIA_VOLUME_LEVEL), +} + class _MediaPlayerMutedStateTriggerBase(EntityTriggerBase): """Base class for media player muted/unmuted triggers.""" @@ -71,9 +78,48 @@ class MediaPlayerUnmutedTrigger(_MediaPlayerMutedStateTriggerBase): _target_muted = False +class VolumeTriggerMixin(EntityNumericalStateTriggerBase): + """Mixin for volume triggers.""" + + _domain_specs = VOLUME_DOMAIN_SPECS + _valid_unit = "%" + + def _get_tracked_value(self, state: State) -> float | None: + """Get tracked volume as a percentage.""" + value = super()._get_tracked_value(state) + if value is None: + return None + # Convert 0.0-1.0 range to percentage (0-100) + return value * 100.0 + + def _should_include(self, state: State) -> bool: + """Check if an entity should participate in all/count checks. + + Entities without a volume level cannot have their volume tracked, + so they are excluded - otherwise an "all" check would never pass + when there are media players without volume support. + """ + return ( + super()._should_include(state) + and state.attributes.get(ATTR_MEDIA_VOLUME_LEVEL) is not None + ) + + +class VolumeChangedTrigger(EntityNumericalStateChangedTriggerBase, VolumeTriggerMixin): + """Trigger for media player volume changes.""" + + +class VolumeCrossedThresholdTrigger( + EntityNumericalStateCrossedThresholdTriggerBase, VolumeTriggerMixin +): + """Trigger for media player volume crossing a threshold.""" + + TRIGGERS: dict[str, type[Trigger]] = { "muted": MediaPlayerMutedTrigger, "unmuted": MediaPlayerUnmutedTrigger, + "volume_changed": VolumeChangedTrigger, + "volume_crossed_threshold": VolumeCrossedThresholdTrigger, "paused_playing": make_entity_transition_trigger( DOMAIN, from_states={ diff --git a/homeassistant/components/media_player/triggers.yaml b/homeassistant/components/media_player/triggers.yaml index d719c61d10fe67..fa6def22a3abeb 100644 --- a/homeassistant/components/media_player/triggers.yaml +++ b/homeassistant/components/media_player/triggers.yaml @@ -1,20 +1,34 @@ .trigger_common: &trigger_common - target: + target: &trigger_media_player_target entity: domain: media_player fields: - behavior: + behavior: &trigger_behavior required: true default: any selector: automation_behavior: mode: trigger - for: + for: &trigger_for required: true default: 00:00:00 selector: duration: +.volume_threshold_entity: &volume_threshold_entity + - domain: input_number + unit_of_measurement: "%" + - domain: number + unit_of_measurement: "%" + - domain: sensor + unit_of_measurement: "%" + +.volume_threshold_number: &volume_threshold_number + min: 0 + max: 100 + mode: box + unit_of_measurement: "%" + muted: *trigger_common unmuted: *trigger_common paused_playing: *trigger_common @@ -22,3 +36,27 @@ started_playing: *trigger_common stopped_playing: *trigger_common turned_off: *trigger_common turned_on: *trigger_common + +volume_changed: + target: *trigger_media_player_target + fields: + threshold: + required: true + selector: + numeric_threshold: + entity: *volume_threshold_entity + mode: changed + number: *volume_threshold_number + +volume_crossed_threshold: + target: *trigger_media_player_target + fields: + behavior: *trigger_behavior + for: *trigger_for + threshold: + required: true + selector: + numeric_threshold: + entity: *volume_threshold_entity + mode: crossed + number: *volume_threshold_number diff --git a/tests/components/common.py b/tests/components/common.py index e26ae70e3cfd19..b97348847c3f00 100644 --- a/tests/components/common.py +++ b/tests/components/common.py @@ -827,6 +827,7 @@ def parametrize_numerical_attribute_changed_trigger_states( trigger_options: dict[str, Any] | None = None, required_filter_attributes: dict | None = None, unit_attributes: dict | None = None, + attribute_value_scale: float = 1.0, attribute_required: bool = False, ) -> list[tuple[str, dict[str, Any], list[TriggerStateDescription]]]: """Parametrize states and expected service call counts for numerical-changed triggers. @@ -860,6 +861,12 @@ def parametrize_numerical_attribute_changed_trigger_states( unit_attributes: Attributes (typically `{ATTR_UNIT_OF_MEASUREMENT: ...}`) merged into every generated state, so the entity carries a unit alongside its tracked attribute. + attribute_value_scale: Multiplier applied to the helper's fixed + attribute values before they are written to the state. Use + this when the trigger stores its tracked value on a different + scale than the threshold — e.g. `media_player` volume is + stored as 0.0–1.0 but the threshold is in percent, so pass + `attribute_value_scale=0.01`. attribute_required: When True, `(state, {attribute: None})` is classified as an *excluded* state (filtered out of the all/count check by the trigger's `_should_include` override) instead of an @@ -876,6 +883,7 @@ def parametrize_numerical_attribute_changed_trigger_states( # the all/count check), giving us a proper "other" state. Mirrors how # `parametrize_numerical_state_value_changed_trigger_states` uses the # literal string "none" as a non-numeric state value. + s = attribute_value_scale if attribute_required: extra_excluded_states = [(state, {attribute: None} | unit_attributes)] other_invalid_attr = (state, {attribute: "none"} | unit_attributes) @@ -896,9 +904,9 @@ def parametrize_numerical_attribute_changed_trigger_states( threshold_unit, ), target_states=[ - (state, {attribute: 0} | unit_attributes), - (state, {attribute: 50} | unit_attributes), - (state, {attribute: 100} | unit_attributes), + (state, {attribute: 0 * s} | unit_attributes), + (state, {attribute: 50 * s} | unit_attributes), + (state, {attribute: 100 * s} | unit_attributes), ], other_states=[other_invalid_attr], extra_excluded_states=extra_excluded_states, @@ -918,12 +926,12 @@ def parametrize_numerical_attribute_changed_trigger_states( threshold_unit, ), target_states=[ - (state, {attribute: 50} | unit_attributes), - (state, {attribute: 100} | unit_attributes), + (state, {attribute: 50 * s} | unit_attributes), + (state, {attribute: 100 * s} | unit_attributes), ], other_states=[ other_invalid_attr, - (state, {attribute: 0} | unit_attributes), + (state, {attribute: 0 * s} | unit_attributes), ], extra_excluded_states=extra_excluded_states, required_filter_attributes=required_filter_attributes, @@ -942,12 +950,12 @@ def parametrize_numerical_attribute_changed_trigger_states( threshold_unit, ), target_states=[ - (state, {attribute: 0} | unit_attributes), - (state, {attribute: 50} | unit_attributes), + (state, {attribute: 0 * s} | unit_attributes), + (state, {attribute: 50 * s} | unit_attributes), ], other_states=[ other_invalid_attr, - (state, {attribute: 100} | unit_attributes), + (state, {attribute: 100 * s} | unit_attributes), ], extra_excluded_states=extra_excluded_states, required_filter_attributes=required_filter_attributes, @@ -965,6 +973,7 @@ def parametrize_numerical_attribute_crossed_threshold_trigger_states( trigger_options: dict[str, Any] | None = None, required_filter_attributes: dict | None = None, unit_attributes: dict | None = None, + attribute_value_scale: float = 1.0, attribute_required: bool = False, ) -> list[tuple[str, dict[str, Any], list[TriggerStateDescription]]]: """Parametrize states and expected service call counts for numerical crossed-threshold triggers. @@ -1000,6 +1009,12 @@ def parametrize_numerical_attribute_crossed_threshold_trigger_states( unit_attributes: Attributes (typically `{ATTR_UNIT_OF_MEASUREMENT: ...}`) merged into every generated state, so the entity carries a unit alongside its tracked attribute. + attribute_value_scale: Multiplier applied to the helper's fixed + attribute values before they are written to the state. Use + this when the trigger stores its tracked value on a different + scale than the threshold — e.g. `media_player` volume is + stored as 0.0–1.0 but the threshold is in percent, so pass + `attribute_value_scale=0.01`. attribute_required: When True, `(state, {attribute: None})` is classified as an *excluded* state (filtered out of the all/count check by the trigger's `_should_include` override) instead of an @@ -1013,6 +1028,7 @@ def parametrize_numerical_attribute_crossed_threshold_trigger_states( # when `attribute_required=True`: the override would filter `None` # out of the all/count check, so we use a value that fails # `is_valid_state` but is still included. + s = attribute_value_scale if attribute_required: extra_excluded_states = [(state, {attribute: None} | unit_attributes)] other_invalid_attr = (state, {attribute: "none"} | unit_attributes) @@ -1035,13 +1051,13 @@ def parametrize_numerical_attribute_crossed_threshold_trigger_states( threshold_unit, ), target_states=[ - (state, {attribute: 50} | unit_attributes), - (state, {attribute: 60} | unit_attributes), + (state, {attribute: 50 * s} | unit_attributes), + (state, {attribute: 60 * s} | unit_attributes), ], other_states=[ other_invalid_attr, - (state, {attribute: 0} | unit_attributes), - (state, {attribute: 100} | unit_attributes), + (state, {attribute: 0 * s} | unit_attributes), + (state, {attribute: 100 * s} | unit_attributes), ], extra_excluded_states=extra_excluded_states, required_filter_attributes=required_filter_attributes, @@ -1060,13 +1076,13 @@ def parametrize_numerical_attribute_crossed_threshold_trigger_states( threshold_unit, ), target_states=[ - (state, {attribute: 0} | unit_attributes), - (state, {attribute: 100} | unit_attributes), + (state, {attribute: 0 * s} | unit_attributes), + (state, {attribute: 100 * s} | unit_attributes), ], other_states=[ other_invalid_attr, - (state, {attribute: 50} | unit_attributes), - (state, {attribute: 60} | unit_attributes), + (state, {attribute: 50 * s} | unit_attributes), + (state, {attribute: 60 * s} | unit_attributes), ], extra_excluded_states=extra_excluded_states, required_filter_attributes=required_filter_attributes, @@ -1084,12 +1100,12 @@ def parametrize_numerical_attribute_crossed_threshold_trigger_states( threshold_unit, ), target_states=[ - (state, {attribute: 50} | unit_attributes), - (state, {attribute: 100} | unit_attributes), + (state, {attribute: 50 * s} | unit_attributes), + (state, {attribute: 100 * s} | unit_attributes), ], other_states=[ other_invalid_attr, - (state, {attribute: 0} | unit_attributes), + (state, {attribute: 0 * s} | unit_attributes), ], extra_excluded_states=extra_excluded_states, required_filter_attributes=required_filter_attributes, @@ -1107,12 +1123,12 @@ def parametrize_numerical_attribute_crossed_threshold_trigger_states( threshold_unit, ), target_states=[ - (state, {attribute: 0} | unit_attributes), - (state, {attribute: 50} | unit_attributes), + (state, {attribute: 0 * s} | unit_attributes), + (state, {attribute: 50 * s} | unit_attributes), ], other_states=[ other_invalid_attr, - (state, {attribute: 100} | unit_attributes), + (state, {attribute: 100 * s} | unit_attributes), ], extra_excluded_states=extra_excluded_states, required_filter_attributes=required_filter_attributes, diff --git a/tests/components/light/test_trigger.py b/tests/components/light/test_trigger.py index 431494183e2078..4f27bd3f16d1e1 100644 --- a/tests/components/light/test_trigger.py +++ b/tests/components/light/test_trigger.py @@ -16,11 +16,20 @@ assert_trigger_gated_by_labs_flag, assert_trigger_ignores_limit_entities_with_wrong_unit, assert_trigger_options_supported, + parametrize_numerical_attribute_changed_trigger_states, + parametrize_numerical_attribute_crossed_threshold_trigger_states, parametrize_target_entities, parametrize_trigger_states, target_entities, ) +# Brightness is stored as a uint8 (0-255) but the trigger threshold is in +# percent (0-100). The generic numerical-attribute helpers feed values in +# the threshold's percent space and scale them by `attribute_value_scale` +# to land on the entity's storage values; for brightness that's +# 255/100 = 2.55 (so 0/50/60/100 -> 0/127.5/153/255). +_BRIGHTNESS_VALUE_SCALE = 255 / 100 + @pytest.fixture async def target_lights(hass: HomeAssistant) -> dict[str, list[str]]: @@ -28,129 +37,6 @@ async def target_lights(hass: HomeAssistant) -> dict[str, list[str]]: return await target_entities(hass, "light") -def parametrize_brightness_changed_trigger_states( - trigger: str, state: str, attribute: str -) -> list[tuple[str, dict[str, Any], list[TriggerStateDescription]]]: - """Parametrize states and expected service call counts for brightness changed triggers. - - Note: The brightness in the trigger configuration is in percentage (0-100) scale, - the underlying attribute in the state is in uint8 (0-255) scale. - """ - return [ - *parametrize_trigger_states( - trigger=trigger, - trigger_options={"threshold": {"type": "any"}}, - target_states=[ - (state, {attribute: 0}), - (state, {attribute: 128}), - (state, {attribute: 255}), - ], - other_states=[(state, {attribute: None})], - retrigger_on_target_state=True, - ), - *parametrize_trigger_states( - trigger=trigger, - trigger_options={"threshold": {"type": "above", "value": {"number": 10}}}, - target_states=[ - (state, {attribute: 128}), - (state, {attribute: 255}), - ], - other_states=[ - (state, {attribute: None}), - (state, {attribute: 0}), - ], - retrigger_on_target_state=True, - ), - *parametrize_trigger_states( - trigger=trigger, - trigger_options={"threshold": {"type": "below", "value": {"number": 90}}}, - target_states=[ - (state, {attribute: 0}), - (state, {attribute: 128}), - ], - other_states=[ - (state, {attribute: None}), - (state, {attribute: 255}), - ], - retrigger_on_target_state=True, - ), - ] - - -def parametrize_brightness_crossed_threshold_trigger_states( - trigger: str, state: str, attribute: str -) -> list[tuple[str, dict[str, Any], list[TriggerStateDescription]]]: - """Parametrize states and expected service call counts for brightness crossed threshold triggers. - - Note: The brightness in the trigger configuration is in percentage (0-100) scale, - the underlying attribute in the state is in uint8 (0-255) scale. - """ - return [ - *parametrize_trigger_states( - trigger=trigger, - trigger_options={ - "threshold": { - "type": "between", - "value_min": {"number": 10}, - "value_max": {"number": 90}, - } - }, - target_states=[ - (state, {attribute: 128}), - (state, {attribute: 153}), - ], - other_states=[ - (state, {attribute: None}), - (state, {attribute: 0}), - (state, {attribute: 255}), - ], - ), - *parametrize_trigger_states( - trigger=trigger, - trigger_options={ - "threshold": { - "type": "outside", - "value_min": {"number": 10}, - "value_max": {"number": 90}, - } - }, - target_states=[ - (state, {attribute: 0}), - (state, {attribute: 255}), - ], - other_states=[ - (state, {attribute: None}), - (state, {attribute: 128}), - (state, {attribute: 153}), - ], - ), - *parametrize_trigger_states( - trigger=trigger, - trigger_options={"threshold": {"type": "above", "value": {"number": 10}}}, - target_states=[ - (state, {attribute: 128}), - (state, {attribute: 255}), - ], - other_states=[ - (state, {attribute: None}), - (state, {attribute: 0}), - ], - ), - *parametrize_trigger_states( - trigger=trigger, - trigger_options={"threshold": {"type": "below", "value": {"number": 90}}}, - target_states=[ - (state, {attribute: 0}), - (state, {attribute: 128}), - ], - other_states=[ - (state, {attribute: None}), - (state, {attribute: 255}), - ], - ), - ] - - @pytest.mark.parametrize( "trigger_key", [ @@ -243,11 +129,17 @@ async def test_light_state_trigger_behavior_any( @pytest.mark.parametrize( ("trigger", "trigger_options", "states"), [ - *parametrize_brightness_changed_trigger_states( - "light.brightness_changed", STATE_ON, ATTR_BRIGHTNESS + *parametrize_numerical_attribute_changed_trigger_states( + "light.brightness_changed", + STATE_ON, + ATTR_BRIGHTNESS, + attribute_value_scale=_BRIGHTNESS_VALUE_SCALE, ), - *parametrize_brightness_crossed_threshold_trigger_states( - "light.brightness_crossed_threshold", STATE_ON, ATTR_BRIGHTNESS + *parametrize_numerical_attribute_crossed_threshold_trigger_states( + "light.brightness_crossed_threshold", + STATE_ON, + ATTR_BRIGHTNESS, + attribute_value_scale=_BRIGHTNESS_VALUE_SCALE, ), ], ) @@ -325,8 +217,11 @@ async def test_light_state_trigger_behavior_first( @pytest.mark.parametrize( ("trigger", "trigger_options", "states"), [ - *parametrize_brightness_crossed_threshold_trigger_states( - "light.brightness_crossed_threshold", STATE_ON, ATTR_BRIGHTNESS + *parametrize_numerical_attribute_crossed_threshold_trigger_states( + "light.brightness_crossed_threshold", + STATE_ON, + ATTR_BRIGHTNESS, + attribute_value_scale=_BRIGHTNESS_VALUE_SCALE, ), ], ) @@ -404,8 +299,11 @@ async def test_light_state_trigger_behavior_last( @pytest.mark.parametrize( ("trigger", "trigger_options", "states"), [ - *parametrize_brightness_crossed_threshold_trigger_states( - "light.brightness_crossed_threshold", STATE_ON, ATTR_BRIGHTNESS + *parametrize_numerical_attribute_crossed_threshold_trigger_states( + "light.brightness_crossed_threshold", + STATE_ON, + ATTR_BRIGHTNESS, + attribute_value_scale=_BRIGHTNESS_VALUE_SCALE, ), ], ) diff --git a/tests/components/media_player/test_trigger.py b/tests/components/media_player/test_trigger.py index b824cf4249c3ef..9c8494dc0de4d0 100644 --- a/tests/components/media_player/test_trigger.py +++ b/tests/components/media_player/test_trigger.py @@ -19,12 +19,18 @@ assert_trigger_behavior_first, assert_trigger_behavior_last, assert_trigger_gated_by_labs_flag, + assert_trigger_ignores_limit_entities_with_wrong_unit, assert_trigger_options_supported, + parametrize_numerical_attribute_changed_trigger_states, + parametrize_numerical_attribute_crossed_threshold_trigger_states, parametrize_target_entities, parametrize_trigger_states, target_entities, ) +_VOLUME_CHANGED_THRESHOLD = {"threshold": {"type": "any"}} +_VOLUME_CROSSED_THRESHOLD = {"threshold": {"type": "above", "value": {"number": 50}}} + @pytest.fixture async def target_media_players(hass: HomeAssistant) -> dict[str, list[str]]: @@ -37,6 +43,8 @@ async def target_media_players(hass: HomeAssistant) -> dict[str, list[str]]: [ "media_player.muted", "media_player.unmuted", + "media_player.volume_changed", + "media_player.volume_crossed_threshold", "media_player.paused_playing", "media_player.started_playing", "media_player.stopped_playing", @@ -118,6 +126,13 @@ def parametrize_muted_trigger_states( ("media_player.stopped_playing", {}, True, True), ("media_player.turned_off", {}, True, True), ("media_player.turned_on", {}, True, True), + ("media_player.volume_changed", _VOLUME_CHANGED_THRESHOLD, False, False), + ( + "media_player.volume_crossed_threshold", + _VOLUME_CROSSED_THRESHOLD, + True, + True, + ), ], ) async def test_media_player_trigger_options_validation( @@ -330,6 +345,190 @@ async def test_media_player_state_trigger_behavior_last( ) +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("trigger_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("media_player"), +) +@pytest.mark.parametrize( + ("trigger", "trigger_options", "states"), + [ + *parametrize_numerical_attribute_changed_trigger_states( + "media_player.volume_changed", + MediaPlayerState.PLAYING, + ATTR_MEDIA_VOLUME_LEVEL, + attribute_value_scale=0.01, + attribute_required=True, + ), + *parametrize_numerical_attribute_crossed_threshold_trigger_states( + "media_player.volume_crossed_threshold", + MediaPlayerState.PLAYING, + ATTR_MEDIA_VOLUME_LEVEL, + attribute_value_scale=0.01, + attribute_required=True, + ), + ], +) +async def test_media_player_volume_trigger_behavior_any( + hass: HomeAssistant, + target_media_players: dict[str, list[str]], + trigger_target_config: dict, + entity_id: str, + entities_in_target: int, + trigger: str, + trigger_options: dict[str, Any], + states: list[TriggerStateDescription], +) -> None: + """Test the media_player volume triggers fire when any entity matches.""" + await assert_trigger_behavior_any( + hass, + target_entities=target_media_players, + trigger_target_config=trigger_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + trigger=trigger, + trigger_options=trigger_options, + states=states, + ) + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("trigger_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("media_player"), +) +@pytest.mark.parametrize( + ("trigger", "trigger_options", "states"), + [ + *parametrize_numerical_attribute_crossed_threshold_trigger_states( + "media_player.volume_crossed_threshold", + MediaPlayerState.PLAYING, + ATTR_MEDIA_VOLUME_LEVEL, + attribute_value_scale=0.01, + attribute_required=True, + ), + ], +) +async def test_media_player_volume_trigger_behavior_first( + hass: HomeAssistant, + target_media_players: dict[str, list[str]], + trigger_target_config: dict, + entity_id: str, + entities_in_target: int, + trigger: str, + trigger_options: dict[str, Any], + states: list[TriggerStateDescription], +) -> None: + """Test the media_player volume crossed threshold trigger fires for the first matching entity.""" + await assert_trigger_behavior_first( + hass, + target_entities=target_media_players, + trigger_target_config=trigger_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + trigger=trigger, + trigger_options=trigger_options, + states=states, + ) + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("trigger_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("media_player"), +) +@pytest.mark.parametrize( + ("trigger", "trigger_options", "states"), + [ + *parametrize_numerical_attribute_crossed_threshold_trigger_states( + "media_player.volume_crossed_threshold", + MediaPlayerState.PLAYING, + ATTR_MEDIA_VOLUME_LEVEL, + attribute_value_scale=0.01, + attribute_required=True, + ), + ], +) +async def test_media_player_volume_trigger_behavior_last( + hass: HomeAssistant, + target_media_players: dict[str, list[str]], + trigger_target_config: dict, + entity_id: str, + entities_in_target: int, + trigger: str, + trigger_options: dict[str, Any], + states: list[TriggerStateDescription], +) -> None: + """Test the media_player volume crossed threshold trigger fires for the last matching entity.""" + await assert_trigger_behavior_last( + hass, + target_entities=target_media_players, + trigger_target_config=trigger_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + trigger=trigger, + trigger_options=trigger_options, + states=states, + ) + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("trigger", "trigger_options", "limit_entities"), + [ + ( + "media_player.volume_changed", + { + "threshold": { + "type": "between", + "value_min": {"entity": "sensor.volume_above"}, + "value_max": {"entity": "sensor.volume_below"}, + }, + }, + ["sensor.volume_above", "sensor.volume_below"], + ), + ( + "media_player.volume_crossed_threshold", + { + "threshold": { + "type": "between", + "value_min": {"entity": "sensor.volume_lower"}, + "value_max": {"entity": "sensor.volume_upper"}, + }, + }, + ["sensor.volume_lower", "sensor.volume_upper"], + ), + ], +) +async def test_media_player_trigger_ignores_limit_entity_with_wrong_unit( + hass: HomeAssistant, + trigger: str, + trigger_options: dict[str, Any], + limit_entities: list[str], +) -> None: + """Test numerical triggers do not fire if limit entities have the wrong unit.""" + await assert_trigger_ignores_limit_entities_with_wrong_unit( + hass, + trigger=trigger, + trigger_options=trigger_options, + entity_id="media_player.test_player", + reset_state={ + "state": MediaPlayerState.PLAYING, + "attributes": {ATTR_MEDIA_VOLUME_LEVEL: 0.0}, + }, + trigger_state={ + "state": MediaPlayerState.PLAYING, + "attributes": {ATTR_MEDIA_VOLUME_LEVEL: 0.5}, + }, + limit_entities=[ + (limit_entities[0], "10"), + (limit_entities[1], "90"), + ], + correct_unit="%", + wrong_unit="lx", + ) + + @pytest.mark.usefixtures("enable_labs_preview_features") async def test_muted_trigger_ignores_entities_without_volume_attributes( hass: HomeAssistant, From 80f6f8ee313d362f81ff544e07de0d962e72e30d Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 6 May 2026 10:48:36 +0200 Subject: [PATCH 9/9] Improve entity trigger tests (#169881) --- tests/components/common.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/tests/components/common.py b/tests/components/common.py index b97348847c3f00..946c1a750bf256 100644 --- a/tests/components/common.py +++ b/tests/components/common.py @@ -774,12 +774,15 @@ def state_with_attributes( # trigger should still fire when the entity under test alone transitions # other -> target. # Sequence per (target, other, excluded): - # entity_id: other -> target (1) - # others: other -> excluded - # i.e. step 0 sets all entities to `other`; step 1 transitions the - # entity under test to `target` while the others go to `excluded` (via - # `others_state`). The all/count check filters the others out, so a - # single matching entity is enough to fire `behavior=last`. + # step 0: all entities at `other`. + # step 1: entity_id stays at `other`, peers transition to `excluded`. + # This positions peers in their filtered state *before* the + # entity under test transitions, so all three behaviors + # (any/first/last) evaluate the firing transition with peers + # already filtered. count = 0. + # step 2: entity_id transitions to `target`, peers stay at `excluded`. + # The all/count check filters the peers out, so a single + # matching entity is enough to fire. count = 1. tests.append( ( trigger, @@ -788,6 +791,9 @@ def state_with_attributes( itertools.chain.from_iterable( ( state_with_attributes(other_state, 0), + state_with_attributes( + other_state, 0, others_state=excluded_state + ), state_with_attributes( target_state, 1, others_state=excluded_state ),