257 files
This commit is contained in:
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user