257 files

This commit is contained in:
Home Assistant Version Control
2026-10-02 16:06:24 +00:00
parent 7c55603942
commit c29f5cc088
258 changed files with 24621 additions and 3136 deletions
@@ -4,6 +4,7 @@ from __future__ import annotations
from typing import Any
from homeassistant.helpers import area_registry as ar
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -11,6 +12,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity
from ..const import CONF_OBJECT, DOMAIN
from ..coordinator import MaintenanceCoordinator
from ..helpers import device_link
from ..helpers.coordinator_data import has_coordinator_data
class MaintenanceEntity(CoordinatorEntity[MaintenanceCoordinator]):
@@ -106,15 +108,19 @@ class MaintenanceEntity(CoordinatorEntity[MaintenanceCoordinator]):
device_info["model"] = obj["model"]
if obj.get("serial_number"):
device_info["serial_number"] = obj["serial_number"]
if obj.get("area_id"):
device_info["suggested_area"] = obj["area_id"]
# suggested_area is looked up by NAME: the area id passed here made
# Home Assistant create a second area called e.g. "living_room" next
# to "Living Room" and put the device in it (bug audit 2026-09-29).
# An id no area has any more suggests nothing.
if isinstance(obj.get("area_id"), str) and (area := ar.async_get(self.hass).async_get_area(obj["area_id"])):
device_info["suggested_area"] = area.name
return device_info
@property
def _task_data(self) -> dict[str, Any]:
"""Return the current task data from coordinator."""
if self.coordinator.data is None:
if not has_coordinator_data(self.coordinator):
return {}
tasks: dict[str, Any] = self.coordinator.data.get("tasks", {})
result: dict[str, Any] = tasks.get(self._task_id, {})
@@ -8,6 +8,7 @@ from ...const import TriggerType
from .base_trigger import BaseTrigger
from .compound import CompoundTrigger
from .counter import CounterTrigger
from .due_date import DueDateTrigger
from .runtime import RuntimeTrigger
from .state_change import StateChangeTrigger
from .threshold import ThresholdTrigger
@@ -15,7 +16,7 @@ from .threshold import ThresholdTrigger
if TYPE_CHECKING:
from homeassistant.core import HomeAssistant
from ...sensor import MaintenanceSensor
from .base_trigger import TriggerHost
def normalize_entity_ids(trigger_config: dict[str, Any]) -> list[str]:
@@ -137,7 +138,7 @@ def _inject_per_entity_state(config: dict[str, Any], entity_state: dict[str, Any
def create_trigger(
hass: HomeAssistant,
entity: MaintenanceSensor,
entity: TriggerHost,
trigger_config: dict[str, Any],
) -> BaseTrigger:
"""Create a trigger instance based on trigger type."""
@@ -151,6 +152,8 @@ def create_trigger(
return StateChangeTrigger(hass, entity, trigger_config)
if trigger_type == TriggerType.RUNTIME:
return RuntimeTrigger(hass, entity, trigger_config)
if trigger_type == TriggerType.DUE_DATE:
return DueDateTrigger(hass, entity, trigger_config)
if trigger_type == TriggerType.COMPOUND:
return CompoundTrigger(hass, entity, trigger_config)
@@ -159,7 +162,7 @@ def create_trigger(
def create_triggers(
hass: HomeAssistant,
entity: MaintenanceSensor,
entity: TriggerHost,
trigger_config: dict[str, Any],
) -> list[BaseTrigger]:
"""Create trigger instances for all entity_ids in the config.
@@ -206,6 +209,7 @@ __all__ = [
"BaseTrigger",
"CompoundTrigger",
"CounterTrigger",
"DueDateTrigger",
"RuntimeTrigger",
"StateChangeTrigger",
"ThresholdTrigger",
@@ -7,7 +7,7 @@ import math
from abc import ABC, abstractmethod
from collections.abc import Coroutine
from datetime import datetime
from typing import TYPE_CHECKING, Any
from typing import Any, Protocol
from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, State, callback
from homeassistant.helpers.event import (
@@ -15,10 +15,6 @@ from homeassistant.helpers.event import (
async_track_state_change_event,
)
if TYPE_CHECKING:
from ...coordinator import MaintenanceCoordinator
from ...sensor import MaintenanceSensor
from ...const import (
EVENT_TRIGGER_ACTIVATED,
EVENT_TRIGGER_DEACTIVATED,
@@ -28,6 +24,49 @@ from ...helpers.managed_timer import ManagedTimer
_LOGGER = logging.getLogger(__name__)
class TriggerCoordinator(Protocol):
"""What a trigger needs from its coordinator: the object's, or a compound
condition's proxy that routes these per condition."""
async def async_add_trigger_history_entry(self, task_id: str, trigger_value: float | None = None) -> None: ...
async def async_auto_complete_on_recovery(self, task_id: str, trigger_value: float) -> None: ...
async def async_persist_trigger_runtime(
self,
task_id: str,
runtime_data: dict[str, Any],
entity_id: str | None = None,
*,
immediate: bool = False,
) -> None: ...
async def async_request_refresh(self) -> None: ...
def note_trigger_edge(self, task_id: str, *, recovered: bool = True) -> None: ...
class TriggerHost(Protocol):
"""What a trigger needs from the entity it drives: the task's sensor, or
a compound condition's proxy entity."""
@property
def entity_id(self) -> str: ...
@property
def _task_id(self) -> str: ...
@property
def coordinator(self) -> TriggerCoordinator: ...
def async_update_trigger_state(
self,
is_triggered: bool,
current_value: float | None = None,
trigger_entity_id: str | None = None,
) -> bool | None: ...
# The initial-evaluation retry: an entity that is unknown/unavailable at
# setup is re-checked this often, this many times, before the state-change
# listener alone is trusted to catch its recovery (issue #1 family).
@@ -45,7 +84,7 @@ class BaseTrigger(ABC):
def __init__(
self,
hass: HomeAssistant,
entity: MaintenanceSensor,
entity: TriggerHost,
trigger_config: dict[str, Any],
) -> None:
"""Initialize the trigger."""
@@ -75,7 +114,7 @@ class BaseTrigger(ABC):
self._logged_unavailable = False # Log-once pattern for unavailable
@property
def _coordinator(self) -> MaintenanceCoordinator:
def _coordinator(self) -> TriggerCoordinator:
"""Get the coordinator from the entity."""
return self.entity.coordinator
@@ -84,6 +123,14 @@ class BaseTrigger(ABC):
"""Get the task ID from the entity."""
return self.entity._task_id
def _event_ids(self) -> dict[str, Any]:
"""The task's ids for the trigger events — EXAMPLES.md tells
automations to deep-link with them (`?entry_id=…&task_id=…`), but
only the completed / skipped / reset events carried them (audit
2026-09-28)."""
entry = getattr(self._coordinator, "entry", None)
return {"entry_id": getattr(entry, "entry_id", None), "task_id": self._task_id}
async def async_setup(self) -> None:
"""Set up the trigger: validate entity and register listener.
@@ -307,6 +354,12 @@ class BaseTrigger(ABC):
"""
@property
def _is_compound_condition(self) -> bool:
"""This trigger watches one condition of a compound (its entity is
the condition's proxy), not the task itself."""
return getattr(self.entity, "is_compound_condition", False) is True
def _request_coordinator_refresh(self) -> None:
"""#175: a trigger flip must reach the coordinator now, not on the
next 5-minute tick. Notifications (and the ``maintenance_supporter_
@@ -332,6 +385,12 @@ class BaseTrigger(ABC):
current_value=value,
trigger_entity_id=self.entity_id,
)
if self._is_compound_condition:
# One condition of a compound: the compound decides whether the
# TASK activated and announces it. Firing here sent
# maintenance_supporter_trigger_activated with the task's ids when
# one half of an AND flipped (bug audit 2026-09-29).
return
# Add history entry for the trigger activation
self._track(self._coordinator.async_add_trigger_history_entry(self._task_id, trigger_value=value))
@@ -343,6 +402,7 @@ class BaseTrigger(ABC):
EVENT_TRIGGER_ACTIVATED,
{
"entity_id": self.entity.entity_id,
**self._event_ids(),
"trigger_entity": self.entity_id,
"trigger_attribute": self.attribute,
"trigger_value": value,
@@ -367,6 +427,8 @@ class BaseTrigger(ABC):
current_value=value,
trigger_entity_id=self.entity_id,
)
if self._is_compound_condition:
return # the compound announces the task's recovery (see activation)
self._request_coordinator_refresh()
@@ -375,6 +437,7 @@ class BaseTrigger(ABC):
EVENT_TRIGGER_DEACTIVATED,
{
"entity_id": self.entity.entity_id,
**self._event_ids(),
"trigger_entity": self.entity_id,
"trigger_attribute": self.attribute,
"trigger_value": value,
@@ -8,8 +8,7 @@ from typing import TYPE_CHECKING, Any
from homeassistant.core import HomeAssistant, callback
if TYPE_CHECKING:
from ...coordinator import MaintenanceCoordinator
from ...sensor import MaintenanceSensor
from .base_trigger import TriggerCoordinator, TriggerHost
from ...const import (
CONF_COMPOUND_CONDITIONS,
@@ -32,6 +31,11 @@ class CompoundSubEntity:
``CompoundTrigger`` of the condition-level result.
"""
# A condition's sub-trigger only reports to this proxy: the task-level
# side effects (the trigger events, the cooldown edge, the refresh) belong
# to the compound — BaseTrigger checks this flag.
is_compound_condition = True
def __init__(
self,
parent: CompoundTrigger,
@@ -52,9 +56,10 @@ class CompoundSubEntity:
self._per_entity_values: dict[str, float | None] = {}
self._entity_logic = condition_config.get("entity_logic", "any")
# Mirror attributes the real entity exposes for trigger access
self.entity_id = parent.entity.entity_id
self._task_id = parent.entity._task_id
self.coordinator = parent.entity.coordinator
self.entity_id: str = parent.entity.entity_id
self._task_id: str = parent.entity._task_id
# Replaced by the condition's _CompoundCoordinatorProxy right after.
self.coordinator: TriggerCoordinator = parent.entity.coordinator
@callback
def async_update_trigger_state(
@@ -91,7 +96,7 @@ class _CompoundCoordinatorProxy:
stores data under ``_trigger_state.conditions[idx][entity_id]``.
"""
def __init__(self, real_coordinator: MaintenanceCoordinator, condition_idx: int) -> None:
def __init__(self, real_coordinator: TriggerCoordinator, condition_idx: int) -> None:
"""Initialize the proxy."""
self._real = real_coordinator
self._condition_idx = condition_idx
@@ -110,19 +115,14 @@ class _CompoundCoordinatorProxy:
) -> None:
"""Persist under trigger_runtime as a per-condition compound key.
The real coordinator always has a Store; merge_task_data reshapes these
``_compound_<idx>[_<entity_id>]`` keys back into
``_trigger_state["conditions"][idx]`` on read.
The coordinator stores under whatever key it is given;
merge_task_data reshapes these ``_compound_<idx>[_<entity_id>]`` keys
back into ``_trigger_state["conditions"][idx]`` on read.
"""
store = self._real._store
compound_key = f"_compound_{self._condition_idx}"
if entity_id is not None:
compound_key = f"_compound_{self._condition_idx}_{entity_id}"
store.set_trigger_runtime(task_id, compound_key, runtime_data)
if immediate:
await store.async_save()
else:
store.async_delay_save()
await self._real.async_persist_trigger_runtime(task_id, runtime_data, compound_key, immediate=immediate)
# A condition's sub-trigger is not the task's trigger: only the compound
# decides when the TASK activates. Delegated to the real coordinator,
@@ -137,6 +137,14 @@ class _CompoundCoordinatorProxy:
def note_trigger_edge(self, task_id: str, *, recovered: bool = True) -> None:
"""No-op for a condition — the compound reports its own edge."""
async def async_request_refresh(self) -> None:
"""Delegate to the object's coordinator."""
await self._real.async_request_refresh()
async def async_auto_complete_on_recovery(self, task_id: str, trigger_value: float) -> None:
"""Delegate to the object's coordinator."""
await self._real.async_auto_complete_on_recovery(task_id, trigger_value)
class CompoundTrigger(BaseTrigger):
"""Compound trigger that combines multiple conditions with AND/OR logic.
@@ -154,7 +162,7 @@ class CompoundTrigger(BaseTrigger):
def __init__(
self,
hass: HomeAssistant,
entity: MaintenanceSensor,
entity: TriggerHost,
trigger_config: dict[str, Any],
) -> None:
"""Initialize the compound trigger."""
@@ -222,9 +230,9 @@ class CompoundTrigger(BaseTrigger):
# Wrap the real coordinator with a proxy for persistence
proxy_coordinator = _CompoundCoordinatorProxy(self._coordinator, idx)
sub_entity.coordinator = proxy_coordinator # type: ignore[assignment]
sub_entity.coordinator = proxy_coordinator
sub_triggers = create_triggers(self.hass, sub_entity, cond_config) # type: ignore[arg-type]
sub_triggers = create_triggers(self.hass, sub_entity, cond_config)
self._sub_triggers.append(sub_triggers)
for trigger in sub_triggers:
@@ -326,6 +334,7 @@ class CompoundTrigger(BaseTrigger):
EVENT_TRIGGER_ACTIVATED,
{
"entity_id": self.entity.entity_id,
**self._event_ids(),
"trigger_type": "compound",
"compound_logic": self._compound_logic,
"condition_states": list(self._condition_states),
@@ -350,6 +359,7 @@ class CompoundTrigger(BaseTrigger):
EVENT_TRIGGER_DEACTIVATED,
{
"entity_id": self.entity.entity_id,
**self._event_ids(),
"trigger_type": "compound",
"compound_logic": self._compound_logic,
"condition_states": list(self._condition_states),
@@ -8,7 +8,7 @@ from typing import TYPE_CHECKING, Any
from homeassistant.core import HomeAssistant
if TYPE_CHECKING:
from ...sensor import MaintenanceSensor
from .base_trigger import TriggerHost
from ...helpers.trigger_fallback import counter_baseline
from .base_trigger import BaseTrigger
@@ -27,7 +27,7 @@ class CounterTrigger(BaseTrigger):
def __init__(
self,
hass: HomeAssistant,
entity: MaintenanceSensor,
entity: TriggerHost,
trigger_config: dict[str, Any],
) -> None:
"""Initialize counter trigger."""
@@ -27,7 +27,7 @@ from ...const import UNAVAILABLE_STATES
from ...helpers.managed_timer import ManagedTimer
if TYPE_CHECKING:
from ...sensor import MaintenanceSensor
from .base_trigger import TriggerHost
from homeassistant.util import dt as dt_util
from .base_trigger import BaseTrigger
@@ -50,7 +50,7 @@ class RuntimeTrigger(BaseTrigger):
def __init__(
self,
hass: HomeAssistant,
entity: MaintenanceSensor,
entity: TriggerHost,
trigger_config: dict[str, Any],
) -> None:
"""Initialize runtime trigger."""
@@ -17,7 +17,7 @@ from ...const import UNAVAILABLE_STATES
from ...helpers.managed_timer import ManagedTimer
if TYPE_CHECKING:
from ...sensor import MaintenanceSensor
from .base_trigger import TriggerHost
from .base_trigger import BaseTrigger
@@ -48,7 +48,7 @@ class StateChangeTrigger(BaseTrigger):
def __init__(
self,
hass: HomeAssistant,
entity: MaintenanceSensor,
entity: TriggerHost,
trigger_config: dict[str, Any],
) -> None:
"""Initialize state change trigger."""
@@ -10,7 +10,7 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.util import dt as dt_util
if TYPE_CHECKING:
from ...sensor import MaintenanceSensor
from .base_trigger import TriggerHost
from ...helpers.managed_timer import ManagedTimer
from ...helpers.trigger_fallback import threshold_exceeds
@@ -32,7 +32,7 @@ class ThresholdTrigger(BaseTrigger):
def __init__(
self,
hass: HomeAssistant,
entity: MaintenanceSensor,
entity: TriggerHost,
trigger_config: dict[str, Any],
) -> None:
"""Initialize threshold trigger."""