mirror of
https://github.com/home-assistant/core.git
synced 2026-06-25 08:05:21 +02:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1db30030aa |
@@ -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
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user