diff --git a/homeassistant/components/counter/trigger.py b/homeassistant/components/counter/trigger.py index a81122fef737..8913b703b901 100644 --- a/homeassistant/components/counter/trigger.py +++ b/homeassistant/components/counter/trigger.py @@ -8,6 +8,7 @@ from homeassistant.helpers.automation import DomainSpec from homeassistant.helpers.trigger import ( ENTITY_STATE_TRIGGER_SCHEMA, EntityTriggerBase, + NotTriggeredReasonReporter, Trigger, ) @@ -30,7 +31,11 @@ class CounterBaseIntegerTrigger(EntityTriggerBase): _schema = ENTITY_STATE_TRIGGER_SCHEMA @override - def is_valid_state(self, state: State) -> bool: + def is_valid_state( + self, + state: State, + report_not_triggered: NotTriggeredReasonReporter, + ) -> bool: """Check if the new state is valid.""" return _is_integer_state(state) @@ -63,7 +68,11 @@ class CounterMaxReachedTrigger(CounterValueBaseTrigger): """Trigger for when a counter reaches its maximum value.""" @override - def is_valid_state(self, state: State) -> bool: + def is_valid_state( + self, + state: State, + report_not_triggered: NotTriggeredReasonReporter, + ) -> bool: """Check if the new state matches the expected state(s).""" if (max_value := state.attributes.get(CONF_MAXIMUM)) is None: return False @@ -74,7 +83,11 @@ class CounterMinReachedTrigger(CounterValueBaseTrigger): """Trigger for when a counter reaches its minimum value.""" @override - def is_valid_state(self, state: State) -> bool: + def is_valid_state( + self, + state: State, + report_not_triggered: NotTriggeredReasonReporter, + ) -> bool: """Check if the new state matches the expected state(s).""" if (min_value := state.attributes.get(CONF_MINIMUM)) is None: return False @@ -85,7 +98,11 @@ class CounterResetTrigger(CounterValueBaseTrigger): """Trigger for reset of counter entities.""" @override - def is_valid_state(self, state: State) -> bool: + def is_valid_state( + self, + state: State, + report_not_triggered: NotTriggeredReasonReporter, + ) -> bool: """Check if the new state matches the expected state(s).""" if (init_state := state.attributes.get(CONF_INITIAL)) is None: return False diff --git a/homeassistant/components/cover/trigger.py b/homeassistant/components/cover/trigger.py index 76460552810e..5c74e6ee7b12 100644 --- a/homeassistant/components/cover/trigger.py +++ b/homeassistant/components/cover/trigger.py @@ -5,7 +5,11 @@ from typing import override from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant, State -from homeassistant.helpers.trigger import EntityTriggerBase, Trigger +from homeassistant.helpers.trigger import ( + EntityTriggerBase, + NotTriggeredReasonReporter, + Trigger, +) from .const import ATTR_IS_CLOSED, DOMAIN, CoverDeviceClass from .models import CoverDomainSpec @@ -24,7 +28,11 @@ class CoverTriggerBase(EntityTriggerBase): return state.state @override - def is_valid_state(self, state: State) -> bool: + def is_valid_state( + self, + state: State, + report_not_triggered: NotTriggeredReasonReporter, + ) -> bool: """Check if the state matches the target cover state.""" domain_spec = self._domain_specs[state.domain] return self._get_value(state) == domain_spec.target_value diff --git a/homeassistant/components/doorbell/trigger.py b/homeassistant/components/doorbell/trigger.py index a7bc0b3164d4..6d68ddb8889b 100644 --- a/homeassistant/components/doorbell/trigger.py +++ b/homeassistant/components/doorbell/trigger.py @@ -10,7 +10,11 @@ from homeassistant.components.event import ( ) from homeassistant.core import HomeAssistant, State from homeassistant.helpers.automation import DomainSpec -from homeassistant.helpers.trigger import StatelessEntityTriggerBase, Trigger +from homeassistant.helpers.trigger import ( + NotTriggeredReasonReporter, + StatelessEntityTriggerBase, + Trigger, +) class DoorbellRangTrigger(StatelessEntityTriggerBase): @@ -19,7 +23,11 @@ class DoorbellRangTrigger(StatelessEntityTriggerBase): _domain_specs = {EVENT_DOMAIN: DomainSpec(device_class=EventDeviceClass.DOORBELL)} @override - def is_valid_state(self, state: State) -> bool: + def is_valid_state( + self, + state: State, + report_not_triggered: NotTriggeredReasonReporter, + ) -> bool: """Check if the event type is ring.""" return state.attributes.get(ATTR_EVENT_TYPE) == DoorbellEventType.RING diff --git a/homeassistant/components/event/trigger.py b/homeassistant/components/event/trigger.py index 54cfbd59d3d4..8c348ea5eff5 100644 --- a/homeassistant/components/event/trigger.py +++ b/homeassistant/components/event/trigger.py @@ -10,6 +10,7 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.automation import DomainSpec from homeassistant.helpers.trigger import ( ENTITY_STATE_TRIGGER_SCHEMA, + NotTriggeredReasonReporter, StatelessEntityTriggerBase, Trigger, TriggerConfig, @@ -42,7 +43,11 @@ class EventReceivedTrigger(StatelessEntityTriggerBase): self._event_types = set(self._options[CONF_EVENT_TYPE]) @override - def is_valid_state(self, state: State) -> bool: + def is_valid_state( + self, + state: State, + report_not_triggered: NotTriggeredReasonReporter, + ) -> bool: """Check if the event type matches one of the configured types.""" return state.attributes.get(ATTR_EVENT_TYPE) in self._event_types diff --git a/homeassistant/components/media_player/trigger.py b/homeassistant/components/media_player/trigger.py index 59e8e5bbf709..36c9e1c936ff 100644 --- a/homeassistant/components/media_player/trigger.py +++ b/homeassistant/components/media_player/trigger.py @@ -9,6 +9,7 @@ from homeassistant.helpers.trigger import ( EntityNumericalStateCrossedThresholdTriggerBase, EntityNumericalStateTriggerBase, EntityTriggerBase, + NotTriggeredReasonReporter, Trigger, make_entity_transition_trigger, ) @@ -60,7 +61,11 @@ class _MediaPlayerMutedStateTriggerBase(EntityTriggerBase): return self.is_muted(from_state) != self.is_muted(to_state) @override - def is_valid_state(self, state: State) -> bool: + def is_valid_state( + self, + state: State, + report_not_triggered: NotTriggeredReasonReporter, + ) -> bool: """Check if the new state matches the expected state.""" if not self._has_volume_attributes(state): return False diff --git a/homeassistant/components/zone/trigger.py b/homeassistant/components/zone/trigger.py index 70102b4c9c7a..f9fa1f9aea8b 100644 --- a/homeassistant/components/zone/trigger.py +++ b/homeassistant/components/zone/trigger.py @@ -36,6 +36,7 @@ from homeassistant.helpers.event import async_track_state_change_event from homeassistant.helpers.trigger import ( ENTITY_STATE_TRIGGER_SCHEMA_WITH_BEHAVIOR, EntityTriggerBase, + NotTriggeredReasonReporter, Trigger, TriggerActionRunner, TriggerConfig, @@ -211,7 +212,11 @@ class EnteredZoneTrigger(ZoneTriggerBase): return not self._in_target_zone(from_state) @override - def is_valid_state(self, state: State) -> bool: + def is_valid_state( + self, + state: State, + report_not_triggered: NotTriggeredReasonReporter, + ) -> bool: """Check that the entity is now in the selected zone.""" return self._in_target_zone(state) @@ -225,7 +230,11 @@ class LeftZoneTrigger(ZoneTriggerBase): return self._in_target_zone(from_state) @override - def is_valid_state(self, state: State) -> bool: + def is_valid_state( + self, + state: State, + report_not_triggered: NotTriggeredReasonReporter, + ) -> bool: """Check that the entity is no longer in the selected zone.""" return not self._in_target_zone(state) @@ -279,7 +288,11 @@ class OccupancyDetectedTrigger(_ZoneOccupancyTriggerBase): """Trigger when a zone transitions to an occupied state.""" @override - def is_valid_state(self, state: State) -> bool: + def is_valid_state( + self, + state: State, + report_not_triggered: NotTriggeredReasonReporter, + ) -> bool: """Check that the zone is occupied.""" return self._is_occupied(state) @@ -293,7 +306,11 @@ class OccupancyClearedTrigger(_ZoneOccupancyTriggerBase): """Trigger when a zone transitions from occupied to unoccupied.""" @override - def is_valid_state(self, state: State) -> bool: + def is_valid_state( + self, + state: State, + report_not_triggered: NotTriggeredReasonReporter, + ) -> bool: """Check that the zone is empty (count == 0).""" return self._occupancy_count(state) == 0 diff --git a/homeassistant/helpers/trigger.py b/homeassistant/helpers/trigger.py index 2059575b8b1e..a541242de992 100644 --- a/homeassistant/helpers/trigger.py +++ b/homeassistant/helpers/trigger.py @@ -374,6 +374,10 @@ ENTITY_STATE_TRIGGER_SCHEMA_WITH_BEHAVIOR = ENTITY_STATE_TRIGGER_SCHEMA.extend( ) +def _report_not_triggered_noop(reason: str, /, **data: Any) -> None: + """Swallow a not-triggered report; used when diagnostics are not wanted.""" + + class EntityTriggerBase(Trigger): """Trigger for entity state changes.""" @@ -431,7 +435,11 @@ class EntityTriggerBase(Trigger): """ return from_state.state != to_state.state - def is_valid_state(self, state: State) -> bool: + def is_valid_state( + self, + state: State, + report_not_triggered: NotTriggeredReasonReporter, + ) -> bool: """Check if the state is a target state for the trigger. Called only after `state.state` has been filtered against @@ -439,6 +447,12 @@ class EntityTriggerBase(Trigger): check. Default: any non-excluded state is a target. Override to restrict (specific to_states, value within a threshold, etc.). + + When the state cannot fire the trigger, subclasses may use + `report_not_triggered` to record an interesting reason - e.g. a + non-numeric value or an unsupported unit - in the automation trace. + Callers that don't collect diagnostics (e.g. `count_matches`) pass + `_report_not_triggered_noop`. """ return True @@ -481,7 +495,7 @@ class EntityTriggerBase(Trigger): if state is None or not self._should_include(state): continue included += 1 - if self.is_valid_state(state): + if self.is_valid_state(state, _report_not_triggered_noop): matches += 1 return matches, included @@ -509,7 +523,7 @@ class EntityTriggerBase(Trigger): if ( to_state is None or to_state.state in self._excluded_states - or not self.is_valid_state(to_state) + or not self.is_valid_state(to_state, _report_not_triggered_noop) ): pending_timers.pop(entity_id)() return @@ -593,11 +607,19 @@ class EntityTriggerBase(Trigger): if not from_state or not to_state: return - # The trigger should never fire if the new state is excluded - # or not a target state. - if to_state.state in self._excluded_states or not self.is_valid_state( - to_state - ): + if to_state.state in self._excluded_states: + return + + @callback + def report_not_triggered(reason: str, /, **data: Any) -> None: + """Report why this evaluated change did not fire the trigger.""" + if did_not_trigger is None: + return + did_not_trigger( + NotTriggeredInfo(reason=reason, data=data), event.context + ) + + if not self.is_valid_state(to_state, report_not_triggered): return # The trigger should never fire if the origin state is excluded @@ -708,7 +730,11 @@ class EntityTargetStateTriggerBase(EntityTriggerBase): ) @override - def is_valid_state(self, state: State) -> bool: + def is_valid_state( + self, + state: State, + report_not_triggered: NotTriggeredReasonReporter, + ) -> bool: """Check if the new state matches the expected state.""" return self._get_tracked_value(state) in self._to_states @@ -729,7 +755,11 @@ class EntityTransitionTriggerBase(EntityTriggerBase): ) @override - def is_valid_state(self, state: State) -> bool: + def is_valid_state( + self, + state: State, + report_not_triggered: NotTriggeredReasonReporter, + ) -> bool: """Check if the new state matches the expected states.""" return self._get_tracked_value(state) in self._to_states @@ -748,7 +778,11 @@ class EntityOriginStateTriggerBase(EntityTriggerBase): ) @override - def is_valid_state(self, state: State) -> bool: + def is_valid_state( + self, + state: State, + report_not_triggered: NotTriggeredReasonReporter, + ) -> bool: """Check that the new state is different from the origin state.""" return bool(self._get_tracked_value(state) != self._from_state) @@ -804,7 +838,11 @@ class EntityNumericalStateTriggerBase(EntityTriggerBase): return True return unit == self._valid_unit - def _get_threshold_value(self, threshold: ThresholdConfig | None) -> float | None: + def _get_threshold_value( + self, + threshold: ThresholdConfig | None, + report_not_triggered: NotTriggeredReasonReporter, + ) -> float | None: """Get threshold value from float or entity state.""" if threshold is None: return None @@ -813,14 +851,29 @@ class EntityNumericalStateTriggerBase(EntityTriggerBase): if not (state := self._hass.states.get(threshold.entity)): # type: ignore[arg-type] # Entity not found + report_not_triggered( + "threshold_entity_not_found", + entity_id=threshold.entity, + ) return None - if not self._is_valid_unit(state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)): + unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + if not self._is_valid_unit(unit): # Entity unit does not match the expected unit + report_not_triggered( + "threshold_unit_not_supported", + entity_id=threshold.entity, + unit=unit, + ) return None try: return float(state.state) except TypeError, ValueError: # Entity state is not a valid number + report_not_triggered( + "threshold_value_not_numeric", + entity_id=threshold.entity, + value=state.state, + ) return None @override @@ -841,11 +894,46 @@ class EntityNumericalStateTriggerBase(EntityTriggerBase): # Entity state is not a valid number return None + def _report_tracked_value_problem( + self, state: State, report_not_triggered: NotTriggeredReasonReporter + ) -> None: + """Report why `_get_tracked_value` rejected this state. + + Called only when the tracked value is invalid. It mirrors the failure + modes of `_get_tracked_value` - which integrations override, so the + reason is derived here rather than reported inline: a state-sourced + value with an unsupported unit, otherwise a value that is not a number. + """ + domain_spec = self._domain_specs[state.domain] + raw_value: Any + if domain_spec.value_source is None: + unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + if not self._is_valid_unit(unit): + report_not_triggered( + "entity_unit_not_supported", + entity_id=state.entity_id, + unit=unit, + ) + return + raw_value = state.state + else: + raw_value = state.attributes.get(domain_spec.value_source) + report_not_triggered( + "entity_value_not_numeric", + entity_id=state.entity_id, + value=raw_value, + ) + @override - def is_valid_state(self, state: State) -> bool: + def is_valid_state( + self, + state: State, + report_not_triggered: NotTriggeredReasonReporter, + ) -> bool: """Check if the new state or state attribute matches the expected one.""" # Handle missing or None value case first to avoid expensive exceptions if (current_value := self._get_tracked_value(state)) is None: + self._report_tracked_value_problem(state, report_not_triggered) return False if self._threshold_type == NumericThresholdType.ANY: @@ -854,20 +942,32 @@ class EntityNumericalStateTriggerBase(EntityTriggerBase): return True if self._threshold_type == NumericThresholdType.ABOVE: - if (limit := self._get_threshold_value(self.threshold)) is None: + if ( + limit := self._get_threshold_value(self.threshold, report_not_triggered) + ) is None: # Entity not found or invalid number, don't trigger return False return current_value > limit if self._threshold_type == NumericThresholdType.BELOW: - if (limit := self._get_threshold_value(self.threshold)) is None: + if ( + limit := self._get_threshold_value(self.threshold, report_not_triggered) + ) is None: # Entity not found or invalid number, don't trigger return False return current_value < limit - # Mode is BETWEEN or OUTSIDE - lower_limit = self._get_threshold_value(self.lower_threshold) - upper_limit = self._get_threshold_value(self.upper_threshold) - if lower_limit is None or upper_limit is None: + # Mode is BETWEEN or OUTSIDE. Evaluate the lower limit first so at most + # one not-triggered reason is reported per change. + lower_limit = self._get_threshold_value( + self.lower_threshold, report_not_triggered + ) + if lower_limit is None: + # Entity not found or invalid number, don't trigger + return False + upper_limit = self._get_threshold_value( + self.upper_threshold, report_not_triggered + ) + if upper_limit is None: # Entity not found or invalid number, don't trigger return False between = lower_limit <= current_value <= upper_limit @@ -887,7 +987,41 @@ class EntityNumericalStateTriggerWithUnitBase(EntityNumericalStateTriggerBase): return state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) @override - def _get_threshold_value(self, threshold: ThresholdConfig | None) -> float | None: + def _report_tracked_value_problem( + self, state: State, report_not_triggered: NotTriggeredReasonReporter + ) -> None: + """Report why `_get_tracked_value` rejected this state. + + Mirrors the with-unit failure modes: a value that is not a number, + otherwise a unit that cannot be converted to the base unit. + """ + domain_spec = self._domain_specs[state.domain] + raw_value: Any + if domain_spec.value_source is None: + raw_value = state.state + else: + raw_value = state.attributes.get(domain_spec.value_source) + try: + float(raw_value) + except TypeError, ValueError: + report_not_triggered( + "entity_value_not_numeric", + entity_id=state.entity_id, + value=raw_value, + ) + return + report_not_triggered( + "entity_unit_not_supported", + entity_id=state.entity_id, + unit=self._get_entity_unit(state), + ) + + @override + def _get_threshold_value( + self, + threshold: ThresholdConfig | None, + report_not_triggered: NotTriggeredReasonReporter, + ) -> float | None: """Get threshold value from float or entity state.""" if threshold is None: return None @@ -900,19 +1034,32 @@ class EntityNumericalStateTriggerWithUnitBase(EntityNumericalStateTriggerBase): if not (state := self._hass.states.get(threshold.entity)): # type: ignore[arg-type] # Entity not found + report_not_triggered( + "threshold_entity_not_found", + entity_id=threshold.entity, + ) return None try: value = float(state.state) except TypeError, ValueError: # Entity state is not a valid number + report_not_triggered( + "threshold_value_not_numeric", + entity_id=threshold.entity, + value=state.state, + ) return None + unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) try: - return self._unit_converter.convert( - value, state.attributes.get(ATTR_UNIT_OF_MEASUREMENT), self._base_unit - ) + return self._unit_converter.convert(value, unit, self._base_unit) except HomeAssistantError: # Unit conversion failed (i.e. incompatible units), treat as invalid number + report_not_triggered( + "threshold_unit_not_supported", + entity_id=threshold.entity, + unit=unit, + ) return None @override @@ -1009,7 +1156,7 @@ class EntityNumericalStateCrossedThresholdTriggerBase(EntityNumericalStateTrigge @override def is_valid_transition(self, from_state: State, to_state: State) -> bool: """Check that the tracked value crossed into the threshold range.""" - return not self.is_valid_state(from_state) + return not self.is_valid_state(from_state, _report_not_triggered_noop) def _make_numerical_state_crossed_threshold_with_unit_schema( @@ -1289,6 +1436,13 @@ class TriggerNotTriggeredReporter(Protocol): """Report that the trigger did not fire.""" +class NotTriggeredReasonReporter(Protocol): + """Reports why an evaluated change did not fire an entity trigger.""" + + def __call__(self, reason: str, /, **data: Any) -> None: + """Report, with diagnostic data, why the change did not fire.""" + + class TriggerNotTriggeredAction(Protocol): """Protocol type for the did_not_trigger consumer callback. diff --git a/tests/helpers/test_trigger.py b/tests/helpers/test_trigger.py index 351ad9dc4761..3704dbdef4ba 100644 --- a/tests/helpers/test_trigger.py +++ b/tests/helpers/test_trigger.py @@ -74,6 +74,7 @@ from homeassistant.helpers.trigger import ( EntityNumericalStateCrossedThresholdTriggerWithUnitBase, EntityTriggerBase, NotTriggeredInfo, + NotTriggeredReasonReporter, PluggableAction, StatelessEntityTriggerBase, Trigger, @@ -81,9 +82,11 @@ from homeassistant.helpers.trigger import ( TriggerConfig, TriggerNotTriggeredReporter, _async_get_trigger_platform, + _report_not_triggered_noop, async_initialize_triggers, async_validate_trigger_config, make_entity_numerical_state_changed_trigger, + make_entity_numerical_state_changed_with_unit_trigger, make_entity_numerical_state_crossed_threshold_trigger, make_entity_origin_state_trigger, make_entity_target_state_trigger, @@ -105,6 +108,13 @@ from tests.common import ( ) +def _reported_reasons( + did_not_trigger_reports: list[NotTriggeredInfo], +) -> list[tuple[str, Any]]: + """Return the (reason, data) pair of each recorded did-not-trigger report.""" + return [(report.reason, report.data) for report in did_not_trigger_reports] + + async def _arm_numerical_trigger( hass: HomeAssistant, trigger_cls: type[Trigger], @@ -1837,14 +1847,23 @@ async def test_numerical_state_attribute_changed_error_handling( hass.states.async_set("test.test_entity", "on", {}) await hass.async_block_till_done() assert calls == [] - assert did_not_trigger_reports == [] + assert _reported_reasons(did_not_trigger_reports) == [ + ("entity_value_not_numeric", {"entity_id": "test.test_entity", "value": None}) + ] + did_not_trigger_reports.clear() # Test the trigger does not fire when the attribute value is invalid for value in ("cat", None): hass.states.async_set("test.test_entity", "on", {"test_attribute": value}) await hass.async_block_till_done() assert calls == [] - assert did_not_trigger_reports == [] + assert _reported_reasons(did_not_trigger_reports) == [ + ( + "entity_value_not_numeric", + {"entity_id": "test.test_entity", "value": value}, + ) + ] + did_not_trigger_reports.clear() # Test the trigger does not fire when the above sensor does not exist hass.states.async_remove("sensor.above") @@ -1852,7 +1871,10 @@ async def test_numerical_state_attribute_changed_error_handling( hass.states.async_set("test.test_entity", "on", {"test_attribute": 50}) await hass.async_block_till_done() assert calls == [] - assert did_not_trigger_reports == [] + assert _reported_reasons(did_not_trigger_reports) == [ + ("threshold_entity_not_found", {"entity_id": "sensor.above"}) + ] + did_not_trigger_reports.clear() # Test the trigger does not fire when the above sensor state is not numeric for invalid_value in ("cat", None): @@ -1861,7 +1883,17 @@ async def test_numerical_state_attribute_changed_error_handling( hass.states.async_set("test.test_entity", "on", {"test_attribute": 50}) await hass.async_block_till_done() assert calls == [] - assert did_not_trigger_reports == [] + assert _reported_reasons(did_not_trigger_reports) == [ + ( + "entity_value_not_numeric", + {"entity_id": "test.test_entity", "value": None}, + ), + ( + "threshold_value_not_numeric", + {"entity_id": "sensor.above", "value": str(invalid_value)}, + ), + ] + did_not_trigger_reports.clear() # Reset the above sensor state to a valid numeric value hass.states.async_set("sensor.above", "10") @@ -1872,7 +1904,11 @@ async def test_numerical_state_attribute_changed_error_handling( hass.states.async_set("test.test_entity", "on", {"test_attribute": 50}) await hass.async_block_till_done() assert calls == [] - assert did_not_trigger_reports == [] + assert _reported_reasons(did_not_trigger_reports) == [ + ("entity_value_not_numeric", {"entity_id": "test.test_entity", "value": None}), + ("threshold_entity_not_found", {"entity_id": "sensor.below"}), + ] + did_not_trigger_reports.clear() # Test the trigger does not fire when the below sensor state is not numeric for invalid_value in ("cat", None): @@ -1881,7 +1917,17 @@ async def test_numerical_state_attribute_changed_error_handling( hass.states.async_set("test.test_entity", "on", {"test_attribute": 50}) await hass.async_block_till_done() assert calls == [] - assert did_not_trigger_reports == [] + assert _reported_reasons(did_not_trigger_reports) == [ + ( + "entity_value_not_numeric", + {"entity_id": "test.test_entity", "value": None}, + ), + ( + "threshold_value_not_numeric", + {"entity_id": "sensor.below", "value": str(invalid_value)}, + ), + ] + did_not_trigger_reports.clear() unsub() @@ -2288,6 +2334,11 @@ async def test_numerical_state_attribute_changed_with_unit_error_handling( entity_did_not_trigger_reports, ) ) + # Both triggers report a non-numeric tracked value identically. + entity_not_numeric = ( + "entity_value_not_numeric", + {"entity_id": "test.test_entity", "value": None}, + ) # 77°F = 25°C, within range (above 20, below 30) - should trigger numerical # Entity automation won't trigger because sensor.above/below don't exist yet @@ -2302,8 +2353,13 @@ async def test_numerical_state_attribute_changed_with_unit_error_handling( await hass.async_block_till_done() assert len(numeric_calls) == 1 assert entity_calls == [] - assert numeric_did_not_trigger_reports == entity_did_not_trigger_reports == [] + # The entity-threshold trigger can't resolve its limits yet (sensors absent) + assert numeric_did_not_trigger_reports == [] + assert _reported_reasons(entity_did_not_trigger_reports) == [ + ("threshold_entity_not_found", {"entity_id": "sensor.above"}) + ] numeric_calls.clear() + entity_did_not_trigger_reports.clear() # 59°F = 15°C, below 20°C - should NOT trigger hass.states.async_set( @@ -2316,7 +2372,11 @@ async def test_numerical_state_attribute_changed_with_unit_error_handling( ) await hass.async_block_till_done() assert numeric_calls == entity_calls == [] - assert numeric_did_not_trigger_reports == entity_did_not_trigger_reports == [] + assert numeric_did_not_trigger_reports == [] + assert _reported_reasons(entity_did_not_trigger_reports) == [ + ("threshold_entity_not_found", {"entity_id": "sensor.above"}) + ] + entity_did_not_trigger_reports.clear() # 95°F = 35°C, above 30°C - should NOT trigger hass.states.async_set( @@ -2329,7 +2389,11 @@ async def test_numerical_state_attribute_changed_with_unit_error_handling( ) await hass.async_block_till_done() assert numeric_calls == entity_calls == [] - assert numeric_did_not_trigger_reports == entity_did_not_trigger_reports == [] + assert numeric_did_not_trigger_reports == [] + assert _reported_reasons(entity_did_not_trigger_reports) == [ + ("threshold_entity_not_found", {"entity_id": "sensor.above"}) + ] + entity_did_not_trigger_reports.clear() # Set up entity limits referencing sensors that report in °F hass.states.async_set( @@ -2363,7 +2427,10 @@ async def test_numerical_state_attribute_changed_with_unit_error_handling( hass.states.async_set("test.test_entity", "on", {}) await hass.async_block_till_done() assert numeric_calls == entity_calls == [] - assert numeric_did_not_trigger_reports == entity_did_not_trigger_reports == [] + assert _reported_reasons(numeric_did_not_trigger_reports) == [entity_not_numeric] + assert _reported_reasons(entity_did_not_trigger_reports) == [entity_not_numeric] + numeric_did_not_trigger_reports.clear() + entity_did_not_trigger_reports.clear() # Test the trigger does not fire when the attribute value is invalid for value in ("cat", None): @@ -2377,7 +2444,20 @@ async def test_numerical_state_attribute_changed_with_unit_error_handling( ) await hass.async_block_till_done() assert numeric_calls == entity_calls == [] - assert numeric_did_not_trigger_reports == entity_did_not_trigger_reports == [] + assert _reported_reasons(numeric_did_not_trigger_reports) == [ + ( + "entity_value_not_numeric", + {"entity_id": "test.test_entity", "value": value}, + ) + ] + assert _reported_reasons(entity_did_not_trigger_reports) == [ + ( + "entity_value_not_numeric", + {"entity_id": "test.test_entity", "value": value}, + ) + ] + numeric_did_not_trigger_reports.clear() + entity_did_not_trigger_reports.clear() # Test the trigger does not fire when the unit is incompatible hass.states.async_set( @@ -2390,7 +2470,20 @@ async def test_numerical_state_attribute_changed_with_unit_error_handling( ) await hass.async_block_till_done() assert numeric_calls == entity_calls == [] - assert numeric_did_not_trigger_reports == entity_did_not_trigger_reports == [] + assert _reported_reasons(numeric_did_not_trigger_reports) == [ + ( + "entity_unit_not_supported", + {"entity_id": "test.test_entity", "unit": "invalid_unit"}, + ) + ] + assert _reported_reasons(entity_did_not_trigger_reports) == [ + ( + "entity_unit_not_supported", + {"entity_id": "test.test_entity", "unit": "invalid_unit"}, + ) + ] + numeric_did_not_trigger_reports.clear() + entity_did_not_trigger_reports.clear() # Test the trigger does not fire when the above sensor does not exist hass.states.async_remove("sensor.above") @@ -2409,7 +2502,15 @@ async def test_numerical_state_attribute_changed_with_unit_error_handling( ) await hass.async_block_till_done() assert numeric_calls == entity_calls == [] - assert numeric_did_not_trigger_reports == entity_did_not_trigger_reports == [] + # The intermediate None reports a non-numeric value on both triggers; the + # missing threshold entity is reported only by the entity-threshold trigger. + assert _reported_reasons(numeric_did_not_trigger_reports) == [entity_not_numeric] + assert _reported_reasons(entity_did_not_trigger_reports) == [ + entity_not_numeric, + ("threshold_entity_not_found", {"entity_id": "sensor.above"}), + ] + numeric_did_not_trigger_reports.clear() + entity_did_not_trigger_reports.clear() # Test the trigger does not fire when the above sensor state is not numeric for invalid_value in ("cat", None): @@ -2436,7 +2537,18 @@ async def test_numerical_state_attribute_changed_with_unit_error_handling( ) await hass.async_block_till_done() assert numeric_calls == entity_calls == [] - assert numeric_did_not_trigger_reports == entity_did_not_trigger_reports == [] + assert _reported_reasons(numeric_did_not_trigger_reports) == [ + entity_not_numeric + ] + assert _reported_reasons(entity_did_not_trigger_reports) == [ + entity_not_numeric, + ( + "threshold_value_not_numeric", + {"entity_id": "sensor.above", "value": str(invalid_value)}, + ), + ] + numeric_did_not_trigger_reports.clear() + entity_did_not_trigger_reports.clear() # Test the trigger does not fire when the above sensor's unit is incompatible hass.states.async_set( @@ -2459,7 +2571,16 @@ async def test_numerical_state_attribute_changed_with_unit_error_handling( ) await hass.async_block_till_done() assert numeric_calls == entity_calls == [] - assert numeric_did_not_trigger_reports == entity_did_not_trigger_reports == [] + assert _reported_reasons(numeric_did_not_trigger_reports) == [entity_not_numeric] + assert _reported_reasons(entity_did_not_trigger_reports) == [ + entity_not_numeric, + ( + "threshold_unit_not_supported", + {"entity_id": "sensor.above", "unit": "invalid_unit"}, + ), + ] + numeric_did_not_trigger_reports.clear() + entity_did_not_trigger_reports.clear() # Reset the above sensor state to a valid numeric value hass.states.async_set( @@ -2485,7 +2606,13 @@ async def test_numerical_state_attribute_changed_with_unit_error_handling( ) await hass.async_block_till_done() assert numeric_calls == entity_calls == [] - assert numeric_did_not_trigger_reports == entity_did_not_trigger_reports == [] + assert _reported_reasons(numeric_did_not_trigger_reports) == [entity_not_numeric] + assert _reported_reasons(entity_did_not_trigger_reports) == [ + entity_not_numeric, + ("threshold_entity_not_found", {"entity_id": "sensor.below"}), + ] + numeric_did_not_trigger_reports.clear() + entity_did_not_trigger_reports.clear() # Test the trigger does not fire when the below sensor state is not numeric for invalid_value in ("cat", None): @@ -2508,7 +2635,18 @@ async def test_numerical_state_attribute_changed_with_unit_error_handling( ) await hass.async_block_till_done() assert numeric_calls == entity_calls == [] - assert numeric_did_not_trigger_reports == entity_did_not_trigger_reports == [] + assert _reported_reasons(numeric_did_not_trigger_reports) == [ + entity_not_numeric + ] + assert _reported_reasons(entity_did_not_trigger_reports) == [ + entity_not_numeric, + ( + "threshold_value_not_numeric", + {"entity_id": "sensor.below", "value": str(invalid_value)}, + ), + ] + numeric_did_not_trigger_reports.clear() + entity_did_not_trigger_reports.clear() # Test the trigger does not fire when the below sensor's unit is incompatible hass.states.async_set( @@ -2531,12 +2669,242 @@ async def test_numerical_state_attribute_changed_with_unit_error_handling( ) await hass.async_block_till_done() assert numeric_calls == entity_calls == [] - assert numeric_did_not_trigger_reports == entity_did_not_trigger_reports == [] + assert _reported_reasons(numeric_did_not_trigger_reports) == [entity_not_numeric] + assert _reported_reasons(entity_did_not_trigger_reports) == [ + entity_not_numeric, + ( + "threshold_unit_not_supported", + {"entity_id": "sensor.below", "unit": "invalid_unit"}, + ), + ] + numeric_did_not_trigger_reports.clear() + entity_did_not_trigger_reports.clear() for unsub in unsubs: unsub() +# State-sourced numerical triggers: brightness-style (percentage) and +# temperature-style (with unit conversion to a base unit). +_PERCENT_CHANGED_TRIGGER = make_entity_numerical_state_changed_trigger( + {"test": DomainSpec()}, "%" +) +_TEMPERATURE_CHANGED_TRIGGER = make_entity_numerical_state_changed_with_unit_trigger( + {"test": DomainSpec()}, UnitOfTemperature.CELSIUS, TemperatureConverter +) + + +@pytest.mark.parametrize( + ( + "trigger_cls", + "good_unit", + "bad_state", + "bad_unit", + "expected_reason", + "expected_data", + ), + [ + pytest.param( + _PERCENT_CHANGED_TRIGGER, + "%", + "cat", + "%", + "entity_value_not_numeric", + {"entity_id": "test.test_entity", "value": "cat"}, + id="non-numeric", + ), + pytest.param( + _PERCENT_CHANGED_TRIGGER, + "%", + "50", + "kg", + "entity_unit_not_supported", + {"entity_id": "test.test_entity", "unit": "kg"}, + id="unsupported-unit", + ), + pytest.param( + _TEMPERATURE_CHANGED_TRIGGER, + "°C", + "cat", + "°C", + "entity_value_not_numeric", + {"entity_id": "test.test_entity", "value": "cat"}, + id="with-unit-non-numeric", + ), + pytest.param( + _TEMPERATURE_CHANGED_TRIGGER, + "°C", + "50", + "kg", + "entity_unit_not_supported", + {"entity_id": "test.test_entity", "unit": "kg"}, + id="with-unit-incompatible-unit", + ), + ], +) +async def test_numerical_trigger_reports_invalid_tracked_value( + hass: HomeAssistant, + trigger_cls: type[Trigger], + good_unit: str, + bad_state: str, + bad_unit: str, + expected_reason: str, + expected_data: dict[str, Any], +) -> None: + """Report a non-numeric value or unsupported unit on the tracked entity.""" + calls: list[dict[str, Any]] = [] + did_not_trigger_reports: list[NotTriggeredInfo] = [] + hass.states.async_set( + "test.test_entity", "10", {ATTR_UNIT_OF_MEASUREMENT: good_unit} + ) + await hass.async_block_till_done() + + unsub = await _arm_numerical_trigger( + hass, + trigger_cls, + {"threshold": {"type": "any"}}, + calls, + did_not_trigger_reports, + ) + + hass.states.async_set( + "test.test_entity", bad_state, {ATTR_UNIT_OF_MEASUREMENT: bad_unit} + ) + await hass.async_block_till_done() + + assert calls == [] + assert _reported_reasons(did_not_trigger_reports) == [ + (expected_reason, expected_data) + ] + + unsub() + + +@pytest.mark.parametrize( + ( + "trigger_cls", + "good_unit", + "threshold_state", + "threshold_unit", + "expected_reason", + "expected_data", + ), + [ + pytest.param( + _PERCENT_CHANGED_TRIGGER, + "%", + "cat", + "%", + "threshold_value_not_numeric", + {"entity_id": "sensor.limit", "value": "cat"}, + id="non-numeric", + ), + pytest.param( + _PERCENT_CHANGED_TRIGGER, + "%", + "30", + "kg", + "threshold_unit_not_supported", + {"entity_id": "sensor.limit", "unit": "kg"}, + id="unsupported-unit", + ), + pytest.param( + _TEMPERATURE_CHANGED_TRIGGER, + "°C", + "cat", + "°C", + "threshold_value_not_numeric", + {"entity_id": "sensor.limit", "value": "cat"}, + id="with-unit-non-numeric", + ), + pytest.param( + _TEMPERATURE_CHANGED_TRIGGER, + "°C", + "30", + "kg", + "threshold_unit_not_supported", + {"entity_id": "sensor.limit", "unit": "kg"}, + id="with-unit-incompatible-unit", + ), + ], +) +async def test_numerical_trigger_reports_invalid_threshold_entity( + hass: HomeAssistant, + trigger_cls: type[Trigger], + good_unit: str, + threshold_state: str, + threshold_unit: str, + expected_reason: str, + expected_data: dict[str, Any], +) -> None: + """Report a non-numeric value or unsupported unit on a threshold entity.""" + calls: list[dict[str, Any]] = [] + did_not_trigger_reports: list[NotTriggeredInfo] = [] + hass.states.async_set( + "sensor.limit", threshold_state, {ATTR_UNIT_OF_MEASUREMENT: threshold_unit} + ) + hass.states.async_set( + "test.test_entity", "10", {ATTR_UNIT_OF_MEASUREMENT: good_unit} + ) + await hass.async_block_till_done() + + unsub = await _arm_numerical_trigger( + hass, + trigger_cls, + {"threshold": {"type": "above", "value": {"entity": "sensor.limit"}}}, + calls, + did_not_trigger_reports, + ) + + hass.states.async_set( + "test.test_entity", "20", {ATTR_UNIT_OF_MEASUREMENT: good_unit} + ) + await hass.async_block_till_done() + + assert calls == [] + assert _reported_reasons(did_not_trigger_reports) == [ + (expected_reason, expected_data) + ] + + unsub() + + +async def test_numerical_trigger_reports_single_reason_for_between( + hass: HomeAssistant, +) -> None: + """Two invalid between-thresholds yield a single diagnostic for the lower one.""" + calls: list[dict[str, Any]] = [] + did_not_trigger_reports: list[NotTriggeredInfo] = [] + hass.states.async_set("sensor.low", "cat", {ATTR_UNIT_OF_MEASUREMENT: "%"}) + hass.states.async_set("sensor.high", "dog", {ATTR_UNIT_OF_MEASUREMENT: "%"}) + hass.states.async_set("test.test_entity", "10", {ATTR_UNIT_OF_MEASUREMENT: "%"}) + await hass.async_block_till_done() + + unsub = await _arm_numerical_trigger( + hass, + _PERCENT_CHANGED_TRIGGER, + { + "threshold": { + "type": "between", + "value_min": {"entity": "sensor.low"}, + "value_max": {"entity": "sensor.high"}, + } + }, + calls, + did_not_trigger_reports, + ) + + hass.states.async_set("test.test_entity", "20", {ATTR_UNIT_OF_MEASUREMENT: "%"}) + await hass.async_block_till_done() + + assert calls == [] + assert _reported_reasons(did_not_trigger_reports) == [ + ("threshold_value_not_numeric", {"entity_id": "sensor.low", "value": "cat"}) + ] + + unsub() + + @pytest.mark.parametrize( ("trigger_options", "expected_result"), [ @@ -2979,8 +3347,11 @@ async def test_numerical_state_attribute_crossed_threshold_error_handling( hass.states.async_set("test.test_entity", "on", {"test_attribute": 50}) await hass.async_block_till_done() assert len(calls) == 1 - assert did_not_trigger_reports == [] + assert _reported_reasons(did_not_trigger_reports) == [ + ("entity_value_not_numeric", {"entity_id": "test.test_entity", "value": None}) + ] calls.clear() + did_not_trigger_reports.clear() # Test the trigger does not fire when the attribute value is outside the limits for value in (5, 95): @@ -2993,14 +3364,23 @@ async def test_numerical_state_attribute_crossed_threshold_error_handling( hass.states.async_set("test.test_entity", "on", {}) await hass.async_block_till_done() assert calls == [] - assert did_not_trigger_reports == [] + assert _reported_reasons(did_not_trigger_reports) == [ + ("entity_value_not_numeric", {"entity_id": "test.test_entity", "value": None}) + ] + did_not_trigger_reports.clear() # Test the trigger does not fire when the attribute value is invalid for value in ("cat", None): hass.states.async_set("test.test_entity", "on", {"test_attribute": value}) await hass.async_block_till_done() assert calls == [] - assert did_not_trigger_reports == [] + assert _reported_reasons(did_not_trigger_reports) == [ + ( + "entity_value_not_numeric", + {"entity_id": "test.test_entity", "value": value}, + ) + ] + did_not_trigger_reports.clear() # Test the trigger does not fire when the lower sensor does not exist hass.states.async_remove("sensor.lower") @@ -3008,7 +3388,10 @@ async def test_numerical_state_attribute_crossed_threshold_error_handling( hass.states.async_set("test.test_entity", "on", {"test_attribute": 50}) await hass.async_block_till_done() assert calls == [] - assert did_not_trigger_reports == [] + assert _reported_reasons(did_not_trigger_reports) == [ + ("threshold_entity_not_found", {"entity_id": "sensor.lower"}) + ] + did_not_trigger_reports.clear() # Test the trigger does not fire when the lower sensor state is not numeric for invalid_value in ("cat", None): @@ -3017,7 +3400,17 @@ async def test_numerical_state_attribute_crossed_threshold_error_handling( hass.states.async_set("test.test_entity", "on", {"test_attribute": 50}) await hass.async_block_till_done() assert calls == [] - assert did_not_trigger_reports == [] + assert _reported_reasons(did_not_trigger_reports) == [ + ( + "entity_value_not_numeric", + {"entity_id": "test.test_entity", "value": None}, + ), + ( + "threshold_value_not_numeric", + {"entity_id": "sensor.lower", "value": str(invalid_value)}, + ), + ] + did_not_trigger_reports.clear() # Reset the lower sensor state to a valid numeric value hass.states.async_set("sensor.lower", "10") @@ -3028,7 +3421,11 @@ async def test_numerical_state_attribute_crossed_threshold_error_handling( hass.states.async_set("test.test_entity", "on", {"test_attribute": 50}) await hass.async_block_till_done() assert calls == [] - assert did_not_trigger_reports == [] + assert _reported_reasons(did_not_trigger_reports) == [ + ("entity_value_not_numeric", {"entity_id": "test.test_entity", "value": None}), + ("threshold_entity_not_found", {"entity_id": "sensor.upper"}), + ] + did_not_trigger_reports.clear() # Test the trigger does not fire when the upper sensor state is not numeric for invalid_value in ("cat", None): @@ -3037,7 +3434,17 @@ async def test_numerical_state_attribute_crossed_threshold_error_handling( hass.states.async_set("test.test_entity", "on", {"test_attribute": 50}) await hass.async_block_till_done() assert calls == [] - assert did_not_trigger_reports == [] + assert _reported_reasons(did_not_trigger_reports) == [ + ( + "entity_value_not_numeric", + {"entity_id": "test.test_entity", "value": None}, + ), + ( + "threshold_value_not_numeric", + {"entity_id": "sensor.upper", "value": str(invalid_value)}, + ), + ] + did_not_trigger_reports.clear() unsub() @@ -3418,7 +3825,13 @@ async def test_numerical_state_attribute_crossed_threshold_with_unit_error_handl ) await hass.async_block_till_done() assert calls == [] - assert did_not_trigger_reports == [] + assert _reported_reasons(did_not_trigger_reports) == [ + ( + "entity_unit_not_supported", + {"entity_id": "test.test_entity", "unit": "invalid_unit"}, + ) + ] + did_not_trigger_reports.clear() unsub() @@ -3437,7 +3850,11 @@ def _make_trigger( """Accept any transition.""" return True - def is_valid_state(self, state: State) -> bool: + def is_valid_state( + self, + state: State, + report_not_triggered: NotTriggeredReasonReporter, + ) -> bool: """Accept any state.""" return True @@ -3582,13 +3999,13 @@ async def test_make_entity_target_state_trigger( # Value changed to target — valid assert trig.is_valid_transition(from_state, to_state) - assert trig.is_valid_state(to_state) + assert trig.is_valid_state(to_state, _report_not_triggered_noop) # Value did not change — not a valid transition assert not trig.is_valid_transition(from_state, from_state) # Value not in to_states — not valid - assert not trig.is_valid_state(wrong_value_state) + assert not trig.is_valid_state(wrong_value_state, _report_not_triggered_noop) @pytest.mark.parametrize( @@ -3646,13 +4063,13 @@ async def test_make_entity_transition_trigger( # Valid transition assert trig.is_valid_transition(from_state, to_state) - assert trig.is_valid_state(to_state) + assert trig.is_valid_state(to_state, _report_not_triggered_noop) # Wrong origin (not in from_states) assert not trig.is_valid_transition(wrong_from, to_state) # Wrong target (not in to_states) - assert not trig.is_valid_state(wrong_to) + assert not trig.is_valid_state(wrong_to, _report_not_triggered_noop) # No change in tracked value — not a valid transition assert not trig.is_valid_transition(from_state, from_state) @@ -3697,7 +4114,7 @@ async def test_make_entity_origin_state_trigger( # Valid: changed from expected origin to something else assert trig.is_valid_transition(from_state, to_state) - assert trig.is_valid_state(to_state) + assert trig.is_valid_state(to_state, _report_not_triggered_noop) # Wrong origin (not the expected from_state) assert not trig.is_valid_transition(wrong_from, to_state) @@ -3706,7 +4123,7 @@ async def test_make_entity_origin_state_trigger( assert not trig.is_valid_transition(from_state, from_state) # To-state still matches from_state — not valid - assert not trig.is_valid_state(from_state) + assert not trig.is_valid_state(from_state, _report_not_triggered_noop) class _ActivatedTrigger(StatelessEntityTriggerBase): @@ -3802,7 +4219,11 @@ class _OffToOnTrigger(EntityTriggerBase): return False return from_state.state != STATE_ON - def is_valid_state(self, state: State) -> bool: + def is_valid_state( + self, + state: State, + report_not_triggered: NotTriggeredReasonReporter, + ) -> bool: """Valid if the state is 'on'.""" return state.state == STATE_ON