Compare commits

...

1 Commits

Author SHA1 Message Date
Erik 1db30030aa Tell bots that we like small try-clauses 2026-06-24 16:15:39 +02:00
4 changed files with 581 additions and 39 deletions
@@ -16,6 +16,7 @@ description: Everything you need to know to build, test and review Home Assistan
- When validation guarantees a key is present, prefer direct dictionary indexing (`data["key"]`) over `.get("key")` so invalid assumptions fail fast.
- Integrations should be thin wrappers. Protocol parsing, device state machines, or other domain logic belong in a separate PyPI library, not in the integration itself. If unsure, ask before inlining.
- Integrations should not implement fixes or workarounds for limitations in libraries. Instead, the library should be updated to fix the issue.
- When catching exceptions, try-clauses should be as small as possible, i.e. avoid wrapping large blocks of code in a try-clause, and avoid catching exceptions from functions that are not expected to raise them.
The following platforms have extra guidelines:
- **Diagnostics**: [`platform-diagnostics.md`](platform-diagnostics.md) for diagnostic data collection
@@ -19,6 +19,7 @@ excludeAgent: "cloud-agent"
- When validation guarantees a key is present, prefer direct dictionary indexing (`data["key"]`) over `.get("key")` so invalid assumptions fail fast.
- Integrations should be thin wrappers. Protocol parsing, device state machines, or other domain logic belong in a separate PyPI library, not in the integration itself. If unsure, ask before inlining.
- Integrations should not implement fixes or workarounds for limitations in libraries. Instead, the library should be updated to fix the issue.
- When catching exceptions, try-clauses should be as small as possible, i.e. avoid wrapping large blocks of code in a try-clause, and avoid catching exceptions from functions that are not expected to raise them.
The following platforms have extra guidelines:
- **Diagnostics**: [`platform-diagnostics.md`](platform-diagnostics.md) for diagnostic data collection
+174 -18
View File
@@ -441,6 +441,19 @@ class EntityTriggerBase(Trigger):
"""
return True
def _is_valid_to_state(
self, to_state: State, report_not_triggered: _NotTriggeredReasonReporter
) -> bool:
"""Check if to_state can fire the trigger, reporting why if it cannot.
Firing-path wrapper around `is_valid_state` for the changed entity.
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.
The base implementation never reports.
"""
return self.is_valid_state(to_state)
def _should_include(self, state: State) -> bool:
"""Check if an entity should participate in all/count checks.
@@ -592,11 +605,23 @@ 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
):
@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
)
# The trigger should never fire if the new state is excluded.
if to_state.state in self._excluded_states:
return
# The trigger should never fire if the new state is not a target
# state. Interesting reasons (e.g. a non-numeric value or an
# unsupported unit) are reported for the trace.
if not self._is_valid_to_state(to_state, report_not_triggered):
return
# The trigger should never fire if the origin state is excluded
@@ -803,7 +828,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 | None = None,
) -> float | None:
"""Get threshold value from float or entity state."""
if threshold is None:
return None
@@ -813,13 +842,26 @@ class EntityNumericalStateTriggerBase(EntityTriggerBase):
if not (state := self._hass.states.get(threshold.entity)): # type: ignore[arg-type]
# Entity not found
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
if report_not_triggered is not None:
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
if report_not_triggered is not None:
report_not_triggered(
"threshold_value_not_numeric",
entity_id=threshold.entity,
value=state.state,
)
return None
@override
@@ -841,10 +883,53 @@ class EntityNumericalStateTriggerBase(EntityTriggerBase):
return None
@override
def is_valid_state(self, state: State) -> bool:
def _is_valid_to_state(
self, to_state: State, report_not_triggered: _NotTriggeredReasonReporter
) -> bool:
"""Check if to_state can fire, reporting non-numeric / unit reasons."""
return self.is_valid_state(to_state, report_not_triggered)
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,
report_not_triggered: _NotTriggeredReasonReporter | None = None,
) -> 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:
if report_not_triggered is not None:
self._report_tracked_value_problem(state, report_not_triggered)
return False
if self._threshold_type == NumericThresholdType.ANY:
@@ -853,20 +938,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
@@ -886,7 +983,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 | None = None,
) -> float | None:
"""Get threshold value from float or entity state."""
if threshold is None:
return None
@@ -904,14 +1035,25 @@ class EntityNumericalStateTriggerWithUnitBase(EntityNumericalStateTriggerBase):
value = float(state.state)
except TypeError, ValueError:
# Entity state is not a valid number
if report_not_triggered is not None:
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
if report_not_triggered is not None:
report_not_triggered(
"threshold_unit_not_supported",
entity_id=threshold.entity,
unit=unit,
)
return None
@override
@@ -1288,6 +1430,20 @@ class TriggerNotTriggeredReporter(Protocol):
"""Report that the trigger did not fire."""
class _NotTriggeredReasonReporter(Protocol):
"""Reports why an evaluated change did not fire an entity trigger.
Threaded down the firing-path checks for the changed entity so the leaf
that detects an interesting, user-actionable problem - a non-numeric value
or an unsupported unit - can record it in the automation trace. It wraps
the trigger's ``did_not_trigger`` reporter, building the
``NotTriggeredInfo`` from ``reason`` and the keyword ``data``.
"""
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.
+405 -21
View File
@@ -84,6 +84,7 @@ from homeassistant.helpers.trigger import (
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 +106,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 +1845,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")
@@ -1861,7 +1878,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 +1899,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) == [
("entity_value_not_numeric", {"entity_id": "test.test_entity", "value": None})
]
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 +1911,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 +2328,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
@@ -2363,7 +2408,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 +2425,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 +2451,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 +2483,12 @@ 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 itself is not reported.
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 above sensor state is not numeric
for invalid_value in ("cat", None):
@@ -2436,7 +2515,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 +2549,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 +2584,10 @@ 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]
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 +2610,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 +2644,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 +3322,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 +3339,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")
@@ -3017,7 +3372,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 +3393,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) == [
("entity_value_not_numeric", {"entity_id": "test.test_entity", "value": None})
]
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 +3405,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 +3796,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()