New apps Added
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
"""Entity module for the Maintenance Supporter integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .entity_base import MaintenanceEntity
|
||||
|
||||
__all__ = ["MaintenanceEntity"]
|
||||
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -0,0 +1,89 @@
|
||||
"""Base entity for the Maintenance Supporter integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from ..const import CONF_OBJECT, DOMAIN, GLOBAL_UNIQUE_ID
|
||||
from ..coordinator import MaintenanceCoordinator
|
||||
|
||||
|
||||
class MaintenanceEntity(CoordinatorEntity[MaintenanceCoordinator]):
|
||||
"""Base class for Maintenance Supporter entities."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: MaintenanceCoordinator,
|
||||
task_id: str,
|
||||
) -> None:
|
||||
"""Initialize the base entity."""
|
||||
super().__init__(coordinator)
|
||||
self._task_id = task_id
|
||||
self._object_data = coordinator.entry.data.get(CONF_OBJECT, {})
|
||||
|
||||
@property
|
||||
def device_info(self) -> DeviceInfo:
|
||||
"""Return device info for this maintenance object.
|
||||
|
||||
Three shapes (2.19, device attachment):
|
||||
|
||||
* ``ha_device_id`` set → attach to that EXISTING device: return only
|
||||
its identifiers/connections so the registry merges us onto it — no
|
||||
name/manufacturer, which would overwrite the owning integration's
|
||||
metadata (the obj→device forward-sync skips linked objects too).
|
||||
* ``parent_entry_id`` set → our own device, nested under the parent
|
||||
object's device via ``via_device``.
|
||||
* neither → our own stand-alone device (the historical shape).
|
||||
"""
|
||||
obj = self._object_data
|
||||
hass = self.coordinator.hass
|
||||
|
||||
if device_id := obj.get("ha_device_id"):
|
||||
device = dr.async_get(hass).async_get(device_id)
|
||||
if device is not None and (device.identifiers or device.connections):
|
||||
info = DeviceInfo()
|
||||
if device.identifiers:
|
||||
info["identifiers"] = device.identifiers
|
||||
if device.connections:
|
||||
info["connections"] = device.connections
|
||||
return info
|
||||
# Linked device vanished → fall through to an own device so the
|
||||
# entities never end up device-less.
|
||||
|
||||
identifiers = {(DOMAIN, self.coordinator.entry.unique_id or "")}
|
||||
|
||||
device_info = DeviceInfo(
|
||||
identifiers=identifiers,
|
||||
name=obj.get("name", "Unknown"),
|
||||
)
|
||||
|
||||
if obj.get("manufacturer"):
|
||||
device_info["manufacturer"] = obj["manufacturer"]
|
||||
if obj.get("model"):
|
||||
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"]
|
||||
|
||||
if parent_entry_id := obj.get("parent_entry_id"):
|
||||
parent = hass.config_entries.async_get_entry(parent_entry_id)
|
||||
if parent is not None and parent.domain == DOMAIN and parent.unique_id and parent.unique_id != GLOBAL_UNIQUE_ID:
|
||||
device_info["via_device"] = (DOMAIN, parent.unique_id)
|
||||
|
||||
return device_info
|
||||
|
||||
@property
|
||||
def _task_data(self) -> dict[str, Any]:
|
||||
"""Return the current task data from coordinator."""
|
||||
if self.coordinator.data is None:
|
||||
return {}
|
||||
tasks: dict[str, Any] = self.coordinator.data.get("tasks", {})
|
||||
result: dict[str, Any] = tasks.get(self._task_id, {})
|
||||
return result
|
||||
@@ -0,0 +1,103 @@
|
||||
"""Aggregate summary coordinator for the global Maintenance Supporter entry.
|
||||
|
||||
Holds the cross-object status counts that back the global summary sensors.
|
||||
It does not poll: it recomputes whenever any object coordinator updates, a new
|
||||
object entry appears, or a sensor trigger flips — mirroring the live-update
|
||||
pattern used by the ``maintenance_supporter/subscribe`` WebSocket endpoint.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
|
||||
from ..const import (
|
||||
EVENT_TRIGGER_ACTIVATED,
|
||||
EVENT_TRIGGER_DEACTIVATED,
|
||||
SIGNAL_NEW_OBJECT_ENTRY,
|
||||
SIGNAL_OBJECT_ENTRY_REMOVED,
|
||||
)
|
||||
from ..helpers.aggregate import (
|
||||
compute_status_counts,
|
||||
get_object_entries,
|
||||
get_runtime_data,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MaintenanceSummaryCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
"""Event-driven aggregator of task status counts across all objects."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant) -> None:
|
||||
"""Initialize the summary coordinator (no polling)."""
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
name="Maintenance Supporter summary",
|
||||
update_interval=None,
|
||||
)
|
||||
self._unsubs: list[CALLBACK_TYPE] = []
|
||||
self._attached: dict[str, CALLBACK_TYPE] = {}
|
||||
|
||||
async def _async_update_data(self) -> dict[str, Any]:
|
||||
"""Recompute counts from the shared aggregator (single source)."""
|
||||
return compute_status_counts(self.hass)
|
||||
|
||||
@callback
|
||||
def async_setup_listeners(self) -> None:
|
||||
"""Attach to every object coordinator + new-entry/trigger signals."""
|
||||
for entry in get_object_entries(self.hass):
|
||||
self._attach_entry(entry.entry_id)
|
||||
|
||||
self._unsubs.append(async_dispatcher_connect(self.hass, SIGNAL_NEW_OBJECT_ENTRY, self._on_new_entry))
|
||||
self._unsubs.append(async_dispatcher_connect(self.hass, SIGNAL_OBJECT_ENTRY_REMOVED, self._on_removed))
|
||||
# Trigger activation/deactivation updates a task's _status in place but
|
||||
# does NOT notify coordinator listeners, so listen for it explicitly.
|
||||
self._unsubs.append(self.hass.bus.async_listen(EVENT_TRIGGER_ACTIVATED, self._on_event))
|
||||
self._unsubs.append(self.hass.bus.async_listen(EVENT_TRIGGER_DEACTIVATED, self._on_event))
|
||||
|
||||
def _attach_entry(self, entry_id: str) -> None:
|
||||
"""Register a listener on a single object coordinator (once)."""
|
||||
if entry_id in self._attached:
|
||||
return
|
||||
rd = get_runtime_data(self.hass, entry_id)
|
||||
if rd and rd.coordinator:
|
||||
self._attached[entry_id] = rd.coordinator.async_add_listener(self._schedule)
|
||||
|
||||
@callback
|
||||
def _on_new_entry(self, entry_id: str) -> None:
|
||||
"""A new object entry was set up — attach and recompute."""
|
||||
self._attach_entry(entry_id)
|
||||
self._schedule()
|
||||
|
||||
@callback
|
||||
def _on_removed(self, entry_id: str) -> None:
|
||||
"""An object entry was deleted — detach its listener and recompute."""
|
||||
unsub = self._attached.pop(entry_id, None)
|
||||
if unsub is not None:
|
||||
unsub()
|
||||
self._schedule()
|
||||
|
||||
@callback
|
||||
def _on_event(self, _event: Event) -> None:
|
||||
self._schedule()
|
||||
|
||||
@callback
|
||||
def _schedule(self) -> None:
|
||||
"""Request a (debounced) recompute from any sync callback."""
|
||||
self.hass.async_create_task(self.async_request_refresh())
|
||||
|
||||
@callback
|
||||
def async_teardown_listeners(self) -> None:
|
||||
"""Detach all listeners (called on global entry unload)."""
|
||||
for unsub in self._unsubs:
|
||||
unsub()
|
||||
for unsub in self._attached.values():
|
||||
unsub()
|
||||
self._unsubs.clear()
|
||||
self._attached.clear()
|
||||
@@ -0,0 +1,189 @@
|
||||
"""Trigger system for sensor-based maintenance tasks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from ...const import TriggerType
|
||||
from .base_trigger import BaseTrigger
|
||||
from .compound import CompoundTrigger
|
||||
from .counter import CounterTrigger
|
||||
from .runtime import RuntimeTrigger
|
||||
from .state_change import StateChangeTrigger
|
||||
from .threshold import ThresholdTrigger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from ...sensor import MaintenanceSensor
|
||||
|
||||
|
||||
def normalize_entity_ids(trigger_config: dict[str, Any]) -> list[str]:
|
||||
"""Return all entity IDs referenced by a trigger config.
|
||||
|
||||
For non-compound triggers, reads the new ``entity_ids`` list or legacy
|
||||
``entity_id`` string from the root.
|
||||
|
||||
For compound triggers (``type == "compound"``), recursively collects
|
||||
entity IDs from all conditions. A condition may have ``entity_id`` /
|
||||
``entity_ids`` at top level OR nested inside a ``trigger_config`` sub-dict.
|
||||
Order is preserved, duplicates are removed.
|
||||
|
||||
Always returns a list (possibly empty). Recursion depth is bounded since
|
||||
nested compound triggers are rejected by validation.
|
||||
"""
|
||||
if trigger_config.get("type") == "compound":
|
||||
seen: set[str] = set()
|
||||
result: list[str] = []
|
||||
for cond in trigger_config.get("conditions", []):
|
||||
cond_ids = normalize_entity_ids(cond)
|
||||
if not cond_ids:
|
||||
cond_ids = normalize_entity_ids(cond.get("trigger_config", {}))
|
||||
for cond_eid in cond_ids:
|
||||
if cond_eid not in seen:
|
||||
seen.add(cond_eid)
|
||||
result.append(cond_eid)
|
||||
return result
|
||||
|
||||
ids = trigger_config.get("entity_ids")
|
||||
if isinstance(ids, list) and ids:
|
||||
return list(ids)
|
||||
flat_eid = trigger_config.get("entity_id")
|
||||
if flat_eid:
|
||||
return [flat_eid]
|
||||
return []
|
||||
|
||||
|
||||
def _migrate_flat_to_per_entity(config: dict[str, Any], first_entity_id: str) -> dict[str, dict[str, Any]]:
|
||||
"""Create ``_trigger_state`` from legacy flat keys on first load.
|
||||
|
||||
Returns a dict keyed by entity_id with per-entity state. Only creates
|
||||
an entry for *first_entity_id* since legacy configs only had one entity.
|
||||
"""
|
||||
trigger_type = config.get("type", TriggerType.THRESHOLD)
|
||||
state: dict[str, Any] = {}
|
||||
|
||||
if trigger_type == TriggerType.COUNTER:
|
||||
bv = config.get("trigger_baseline_value")
|
||||
if bv is not None:
|
||||
state["baseline_value"] = bv
|
||||
elif trigger_type == TriggerType.RUNTIME:
|
||||
acc = config.get("trigger_accumulated_seconds")
|
||||
if acc is not None:
|
||||
state["accumulated_seconds"] = acc
|
||||
on_since = config.get("trigger_on_since")
|
||||
if on_since is not None:
|
||||
state["on_since"] = on_since
|
||||
elif trigger_type == TriggerType.STATE_CHANGE:
|
||||
cc = config.get("trigger_change_count")
|
||||
if cc is not None:
|
||||
state["change_count"] = cc
|
||||
|
||||
if state:
|
||||
return {first_entity_id: state}
|
||||
return {}
|
||||
|
||||
|
||||
def _inject_per_entity_state(config: dict[str, Any], entity_state: dict[str, Any]) -> None:
|
||||
"""Inject per-entity persisted state into a trigger config for construction.
|
||||
|
||||
Overwrites the flat keys that each trigger reads in ``__init__`` so that
|
||||
the trigger instance picks up the correct per-entity values.
|
||||
"""
|
||||
trigger_type = config.get("type", TriggerType.THRESHOLD)
|
||||
|
||||
if trigger_type == TriggerType.COUNTER:
|
||||
if "baseline_value" in entity_state:
|
||||
config["trigger_baseline_value"] = entity_state["baseline_value"]
|
||||
elif trigger_type == TriggerType.RUNTIME:
|
||||
if "accumulated_seconds" in entity_state:
|
||||
config["trigger_accumulated_seconds"] = entity_state["accumulated_seconds"]
|
||||
if "on_since" in entity_state:
|
||||
config["trigger_on_since"] = entity_state["on_since"]
|
||||
elif trigger_type == TriggerType.STATE_CHANGE:
|
||||
if "change_count" in entity_state:
|
||||
config["trigger_change_count"] = entity_state["change_count"]
|
||||
elif trigger_type == TriggerType.THRESHOLD:
|
||||
tes = entity_state.get("threshold_exceeded_since")
|
||||
if tes:
|
||||
config["trigger_threshold_exceeded_since"] = tes
|
||||
|
||||
|
||||
def create_trigger(
|
||||
hass: HomeAssistant,
|
||||
entity: MaintenanceSensor,
|
||||
trigger_config: dict[str, Any],
|
||||
) -> BaseTrigger:
|
||||
"""Create a trigger instance based on trigger type."""
|
||||
trigger_type = trigger_config.get("type", TriggerType.THRESHOLD)
|
||||
|
||||
if trigger_type == TriggerType.THRESHOLD:
|
||||
return ThresholdTrigger(hass, entity, trigger_config)
|
||||
if trigger_type == TriggerType.COUNTER:
|
||||
return CounterTrigger(hass, entity, trigger_config)
|
||||
if trigger_type == TriggerType.STATE_CHANGE:
|
||||
return StateChangeTrigger(hass, entity, trigger_config)
|
||||
if trigger_type == TriggerType.RUNTIME:
|
||||
return RuntimeTrigger(hass, entity, trigger_config)
|
||||
if trigger_type == TriggerType.COMPOUND:
|
||||
return CompoundTrigger(hass, entity, trigger_config)
|
||||
|
||||
raise ValueError(f"Unknown trigger type: {trigger_type}")
|
||||
|
||||
|
||||
def create_triggers(
|
||||
hass: HomeAssistant,
|
||||
entity: MaintenanceSensor,
|
||||
trigger_config: dict[str, Any],
|
||||
) -> list[BaseTrigger]:
|
||||
"""Create trigger instances for all entity_ids in the config.
|
||||
|
||||
Creates one trigger per entity_id for all trigger types that support
|
||||
multi-entity. For counter/runtime/state_change, per-entity persisted
|
||||
state is injected from ``_trigger_state`` before construction.
|
||||
"""
|
||||
# Compound triggers manage their own sub-triggers; always return one.
|
||||
trigger_type = trigger_config.get("type", TriggerType.THRESHOLD)
|
||||
if trigger_type == TriggerType.COMPOUND:
|
||||
return [CompoundTrigger(hass, entity, trigger_config)]
|
||||
|
||||
entity_ids = normalize_entity_ids(trigger_config)
|
||||
if not entity_ids:
|
||||
raise ValueError("No entity_id(s) in trigger_config")
|
||||
|
||||
if len(entity_ids) == 1:
|
||||
single_config = dict(trigger_config)
|
||||
single_config["entity_id"] = entity_ids[0]
|
||||
# Inject per-entity persisted state for single entity too
|
||||
entity_state = trigger_config.get("_trigger_state", {}).get(entity_ids[0], {})
|
||||
if entity_state:
|
||||
_inject_per_entity_state(single_config, entity_state)
|
||||
return [create_trigger(hass, entity, single_config)]
|
||||
|
||||
# Ensure _trigger_state exists (migrate from flat keys if needed)
|
||||
if "_trigger_state" not in trigger_config:
|
||||
trigger_config["_trigger_state"] = _migrate_flat_to_per_entity(trigger_config, entity_ids[0])
|
||||
|
||||
# Multi-entity: one trigger per entity_id
|
||||
triggers: list[BaseTrigger] = []
|
||||
for eid in entity_ids:
|
||||
per_entity_config = dict(trigger_config)
|
||||
per_entity_config["entity_id"] = eid
|
||||
# Inject per-entity persisted state
|
||||
entity_state = trigger_config.get("_trigger_state", {}).get(eid, {})
|
||||
_inject_per_entity_state(per_entity_config, entity_state)
|
||||
triggers.append(create_trigger(hass, entity, per_entity_config))
|
||||
return triggers
|
||||
|
||||
|
||||
__all__ = [
|
||||
"BaseTrigger",
|
||||
"CompoundTrigger",
|
||||
"CounterTrigger",
|
||||
"RuntimeTrigger",
|
||||
"StateChangeTrigger",
|
||||
"ThresholdTrigger",
|
||||
"create_trigger",
|
||||
"create_triggers",
|
||||
"normalize_entity_ids",
|
||||
]
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -0,0 +1,309 @@
|
||||
"""Base trigger class for sensor-based maintenance triggers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
from abc import ABC, abstractmethod
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, State, callback
|
||||
from homeassistant.helpers.event import (
|
||||
EventStateChangedData,
|
||||
async_call_later,
|
||||
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,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BaseTrigger(ABC):
|
||||
"""Base class for all maintenance triggers."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
entity: MaintenanceSensor,
|
||||
trigger_config: dict[str, Any],
|
||||
) -> None:
|
||||
"""Initialize the trigger."""
|
||||
self.hass = hass
|
||||
self.entity = entity
|
||||
self.config = trigger_config
|
||||
self.entity_id = trigger_config.get("entity_id", "")
|
||||
self.attribute = trigger_config.get("attribute")
|
||||
|
||||
self._triggered = False
|
||||
self._current_value: float | None = None
|
||||
self._unsub_listener: CALLBACK_TYPE | None = None
|
||||
self._unsub_retry: CALLBACK_TYPE | None = None
|
||||
self._logged_unavailable = False # Log-once pattern for unavailable
|
||||
|
||||
@property
|
||||
def _coordinator(self) -> MaintenanceCoordinator:
|
||||
"""Get the coordinator from the entity."""
|
||||
return self.entity.coordinator
|
||||
|
||||
@property
|
||||
def _task_id(self) -> str:
|
||||
"""Get the task ID from the entity."""
|
||||
return self.entity._task_id
|
||||
|
||||
async def async_setup(self) -> None:
|
||||
"""Set up the trigger: validate entity and register listener.
|
||||
|
||||
IMPORTANT: The listener is ALWAYS registered, even when the entity does
|
||||
not exist yet. HA fires a state_change event when an entity first
|
||||
appears (old_state=None), so the trigger will self-heal automatically.
|
||||
"""
|
||||
state = self.hass.states.get(self.entity_id)
|
||||
if state is None:
|
||||
_LOGGER.info(
|
||||
"Trigger entity %s not yet available — listener registered, waiting for entity to appear",
|
||||
self.entity_id,
|
||||
)
|
||||
# Register listener anyway so we catch the entity appearing
|
||||
self._unsub_listener = async_track_state_change_event(self.hass, [self.entity_id], self._handle_state_change_event)
|
||||
return
|
||||
|
||||
# Register state change listener
|
||||
self._unsub_listener = async_track_state_change_event(self.hass, [self.entity_id], self._handle_state_change_event)
|
||||
|
||||
# If state is unknown/unavailable, schedule a retry
|
||||
if state.state in ("unavailable", "unknown"):
|
||||
_LOGGER.info(
|
||||
"Trigger entity %s is '%s' — will retry evaluation in 30s",
|
||||
self.entity_id,
|
||||
state.state,
|
||||
)
|
||||
self._schedule_retry()
|
||||
return
|
||||
|
||||
# Validate that we can get a value
|
||||
value = self._get_numeric_value(state)
|
||||
if value is not None:
|
||||
self._current_value = value
|
||||
|
||||
# Initial evaluation
|
||||
if value is not None:
|
||||
self._evaluate_and_update(value)
|
||||
|
||||
_LOGGER.debug(
|
||||
"Trigger setup complete: %s monitoring %s (attribute=%s, value=%s)",
|
||||
type(self).__name__,
|
||||
self.entity_id,
|
||||
self.attribute,
|
||||
self._current_value,
|
||||
)
|
||||
|
||||
def _schedule_retry(self) -> None:
|
||||
"""Schedule a retry of the initial evaluation after 30 seconds."""
|
||||
self._cancel_retry()
|
||||
|
||||
@callback
|
||||
def _retry_initial_evaluation(_now: datetime) -> None:
|
||||
"""Re-check entity state after a delay."""
|
||||
self._unsub_retry = None
|
||||
state = self.hass.states.get(self.entity_id)
|
||||
if state is None or state.state in ("unavailable", "unknown"):
|
||||
_LOGGER.debug(
|
||||
"Trigger entity %s still %s after retry",
|
||||
self.entity_id,
|
||||
state.state if state else "missing",
|
||||
)
|
||||
return
|
||||
value = self._get_numeric_value(state)
|
||||
if value is not None:
|
||||
self._current_value = value
|
||||
self._evaluate_and_update(value)
|
||||
_LOGGER.info(
|
||||
"Trigger entity %s recovered after retry (value=%s)",
|
||||
self.entity_id,
|
||||
value,
|
||||
)
|
||||
|
||||
self._unsub_retry = async_call_later(self.hass, 30, _retry_initial_evaluation)
|
||||
|
||||
def _cancel_retry(self) -> None:
|
||||
"""Cancel pending retry timer."""
|
||||
if self._unsub_retry is not None:
|
||||
self._unsub_retry()
|
||||
self._unsub_retry = None
|
||||
|
||||
async def async_teardown(self) -> None:
|
||||
"""Remove the trigger listener."""
|
||||
self._cancel_retry()
|
||||
if self._unsub_listener is not None:
|
||||
self._unsub_listener()
|
||||
self._unsub_listener = None
|
||||
_LOGGER.debug("Trigger teardown: %s", self.entity_id)
|
||||
|
||||
@callback
|
||||
def _handle_state_change_event(self, event: Event[EventStateChangedData]) -> None:
|
||||
"""Handle state change event from the monitored entity."""
|
||||
old_state = event.data.get("old_state")
|
||||
new_state = event.data.get("new_state")
|
||||
|
||||
if new_state is None:
|
||||
# Entity removed from state machine
|
||||
return
|
||||
|
||||
# Entity appeared for the first time (old_state=None)
|
||||
if old_state is None:
|
||||
_LOGGER.info(
|
||||
"Trigger entity %s appeared in state machine (state=%s)",
|
||||
self.entity_id,
|
||||
new_state.state,
|
||||
)
|
||||
self._logged_unavailable = False
|
||||
|
||||
# Handle unavailable/unknown with log-once pattern.
|
||||
# A transient unavailable/unknown carries no measurement, so we treat
|
||||
# it as "no new data" and do NOT deactivate an active trigger here.
|
||||
# Deactivating on a blip dropped real (alarm) triggers and, combined
|
||||
# with the threshold for-minutes debounce latch, left tasks stuck at
|
||||
# OK once the value returned below threshold. A genuinely missing
|
||||
# trigger entity is surfaced via the missing_trigger_entity repair
|
||||
# flow instead. (Numeric-only triggers: any non-numeric value below
|
||||
# is likewise ignored via the _get_numeric_value None check.)
|
||||
if new_state.state in ("unavailable", "unknown"):
|
||||
if not self._logged_unavailable:
|
||||
_LOGGER.warning(
|
||||
"Trigger entity %s became %s — keeping last trigger state",
|
||||
self.entity_id,
|
||||
new_state.state,
|
||||
)
|
||||
self._logged_unavailable = True
|
||||
return
|
||||
|
||||
# Entity is back to a valid state
|
||||
if self._logged_unavailable:
|
||||
_LOGGER.info(
|
||||
"Trigger entity %s is available again (state=%s)",
|
||||
self.entity_id,
|
||||
new_state.state,
|
||||
)
|
||||
self._logged_unavailable = False
|
||||
|
||||
value = self._get_numeric_value(new_state)
|
||||
if value is not None:
|
||||
self._current_value = value
|
||||
self._evaluate_and_update(value)
|
||||
|
||||
def _evaluate_and_update(self, value: float) -> None:
|
||||
"""Evaluate trigger condition and update state if changed."""
|
||||
was_triggered = self._triggered
|
||||
is_triggered = self.evaluate(value)
|
||||
self._triggered = is_triggered
|
||||
|
||||
if is_triggered and not was_triggered:
|
||||
self._on_trigger_activated(value)
|
||||
elif not is_triggered and was_triggered:
|
||||
self._on_trigger_deactivated(value)
|
||||
|
||||
@abstractmethod
|
||||
def evaluate(self, value: float) -> bool:
|
||||
"""Evaluate whether the trigger condition is met.
|
||||
|
||||
Must be implemented by subclasses.
|
||||
Returns True if trigger should be active.
|
||||
"""
|
||||
|
||||
def _on_trigger_activated(self, value: float) -> None:
|
||||
"""Handle trigger activation."""
|
||||
_LOGGER.info(
|
||||
"Maintenance trigger activated: %s = %s (entity: %s)",
|
||||
self.entity.entity_id,
|
||||
value,
|
||||
self.entity_id,
|
||||
)
|
||||
|
||||
# Update entity state
|
||||
self.entity.async_update_trigger_state(
|
||||
is_triggered=True,
|
||||
current_value=value,
|
||||
trigger_entity_id=self.entity_id,
|
||||
)
|
||||
|
||||
# Add history entry for the trigger activation
|
||||
self.hass.async_create_task(self._coordinator.async_add_trigger_history_entry(self._task_id, trigger_value=value))
|
||||
|
||||
# Fire event
|
||||
self.hass.bus.async_fire(
|
||||
EVENT_TRIGGER_ACTIVATED,
|
||||
{
|
||||
"entity_id": self.entity.entity_id,
|
||||
"trigger_entity": self.entity_id,
|
||||
"trigger_attribute": self.attribute,
|
||||
"trigger_value": value,
|
||||
"trigger_type": self.config.get("type"),
|
||||
},
|
||||
)
|
||||
|
||||
def _on_trigger_deactivated(self, value: float) -> None:
|
||||
"""Handle trigger deactivation."""
|
||||
_LOGGER.info(
|
||||
"Maintenance trigger deactivated: %s = %s (entity: %s)",
|
||||
self.entity.entity_id,
|
||||
value,
|
||||
self.entity_id,
|
||||
)
|
||||
|
||||
# Update entity state
|
||||
self.entity.async_update_trigger_state(
|
||||
is_triggered=False,
|
||||
current_value=value,
|
||||
trigger_entity_id=self.entity_id,
|
||||
)
|
||||
|
||||
# Fire event
|
||||
self.hass.bus.async_fire(
|
||||
EVENT_TRIGGER_DEACTIVATED,
|
||||
{
|
||||
"entity_id": self.entity.entity_id,
|
||||
"trigger_entity": self.entity_id,
|
||||
"trigger_attribute": self.attribute,
|
||||
"trigger_value": value,
|
||||
"trigger_type": self.config.get("type"),
|
||||
},
|
||||
)
|
||||
|
||||
# Opt-in (#53): the sensor recovering IS the maintenance being done
|
||||
# (salt refilled, filter swapped) — record the completion so
|
||||
# last_performed and the time-between-services statistics stay real.
|
||||
# This hook only fires on the evaluate path: a manual complete/skip
|
||||
# resets the trigger via reset(), which never lands here, so the
|
||||
# manual flow cannot double-record.
|
||||
if self.config.get("auto_complete_on_recovery"):
|
||||
self.hass.async_create_task(self._coordinator.async_auto_complete_on_recovery(self._task_id, value))
|
||||
|
||||
def _get_numeric_value(self, state: State) -> float | None:
|
||||
"""Extract numeric value from state or attribute."""
|
||||
try:
|
||||
if self.attribute:
|
||||
raw = state.attributes.get(self.attribute)
|
||||
else:
|
||||
raw = state.state
|
||||
|
||||
if raw is None:
|
||||
return None
|
||||
val = float(raw)
|
||||
if not math.isfinite(val):
|
||||
return None
|
||||
return val
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Reset the trigger (called after maintenance completion)."""
|
||||
self._triggered = False
|
||||
@@ -0,0 +1,282 @@
|
||||
"""Compound trigger — combines multiple trigger conditions with AND/OR logic."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ...coordinator import MaintenanceCoordinator
|
||||
from ...sensor import MaintenanceSensor
|
||||
|
||||
from ...const import (
|
||||
CONF_COMPOUND_CONDITIONS,
|
||||
CONF_COMPOUND_LOGIC,
|
||||
EVENT_TRIGGER_ACTIVATED,
|
||||
EVENT_TRIGGER_DEACTIVATED,
|
||||
)
|
||||
from .base_trigger import BaseTrigger
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CompoundSubEntity:
|
||||
"""Proxy entity for a single compound condition.
|
||||
|
||||
Each condition creates its own sub-triggers that call back into this
|
||||
proxy. The proxy aggregates per-entity states within the condition
|
||||
(using the condition's ``entity_logic``), then notifies the parent
|
||||
``CompoundTrigger`` of the condition-level result.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: CompoundTrigger,
|
||||
condition_idx: int,
|
||||
condition_config: dict[str, Any],
|
||||
) -> None:
|
||||
"""Initialize the sub-entity proxy."""
|
||||
self._parent = parent
|
||||
self._condition_idx = condition_idx
|
||||
self._condition_config = condition_config
|
||||
# Pre-seed a MULTI-entity condition with every entity at False, so
|
||||
# `entity_logic == "all"` quantifies over EVERY entity, not just the ones
|
||||
# that have already transitioned (an entity that never toggles must keep
|
||||
# the AND from firing). Single-entity conditions stay empty and use the
|
||||
# is_triggered fallback in async_update_trigger_state. (H1)
|
||||
_eids = condition_config.get("entity_ids") or []
|
||||
self._per_entity_states: dict[str, bool] = {eid: False for eid in _eids} if len(_eids) > 1 else {}
|
||||
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
|
||||
|
||||
@callback
|
||||
def async_update_trigger_state(
|
||||
self,
|
||||
is_triggered: bool,
|
||||
current_value: float | None = None,
|
||||
trigger_entity_id: str | None = None,
|
||||
) -> None:
|
||||
"""Receive trigger state from a sub-trigger."""
|
||||
if trigger_entity_id is not None:
|
||||
self._per_entity_states[trigger_entity_id] = is_triggered
|
||||
if current_value is not None:
|
||||
self._per_entity_values[trigger_entity_id] = current_value
|
||||
|
||||
# Aggregate within this condition
|
||||
if not self._per_entity_states:
|
||||
aggregated = is_triggered
|
||||
elif self._entity_logic == "all":
|
||||
aggregated = bool(self._per_entity_states) and all(self._per_entity_states.values())
|
||||
else: # "any"
|
||||
aggregated = any(self._per_entity_states.values())
|
||||
|
||||
self._parent._on_condition_changed(self._condition_idx, aggregated)
|
||||
|
||||
@callback
|
||||
def async_write_ha_state(self) -> None:
|
||||
"""No-op — the compound trigger manages state through the real entity."""
|
||||
|
||||
|
||||
class _CompoundCoordinatorProxy:
|
||||
"""Proxy coordinator that routes persistence to the correct condition index.
|
||||
|
||||
Wraps the real coordinator so that ``async_persist_trigger_runtime``
|
||||
stores data under ``_trigger_state.conditions[idx][entity_id]``.
|
||||
"""
|
||||
|
||||
def __init__(self, real_coordinator: MaintenanceCoordinator, condition_idx: int) -> None:
|
||||
"""Initialize the proxy."""
|
||||
self._real = real_coordinator
|
||||
self._condition_idx = condition_idx
|
||||
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
"""Delegate all other attributes to the real coordinator."""
|
||||
return getattr(self._real, name)
|
||||
|
||||
async def async_persist_trigger_runtime(
|
||||
self,
|
||||
task_id: str,
|
||||
runtime_data: dict[str, Any],
|
||||
entity_id: str | None = None,
|
||||
*,
|
||||
immediate: bool = False,
|
||||
) -> 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.
|
||||
"""
|
||||
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()
|
||||
|
||||
|
||||
class CompoundTrigger(BaseTrigger):
|
||||
"""Compound trigger that combines multiple conditions with AND/OR logic.
|
||||
|
||||
Two-level aggregation:
|
||||
1. Within each condition: multi-entity ``entity_logic`` (any/all)
|
||||
2. Across conditions: ``compound_logic`` (AND/OR)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
entity: MaintenanceSensor,
|
||||
trigger_config: dict[str, Any],
|
||||
) -> None:
|
||||
"""Initialize the compound trigger."""
|
||||
# BaseTrigger expects entity_id; compound has no single monitored entity
|
||||
config_with_id = dict(trigger_config)
|
||||
config_with_id.setdefault("entity_id", "")
|
||||
super().__init__(hass, entity, config_with_id)
|
||||
|
||||
# `or "AND"` (not just the .get default) so a present-but-null value in
|
||||
# hand-edited config doesn't crash on .upper().
|
||||
self._compound_logic: str = (trigger_config.get(CONF_COMPOUND_LOGIC) or "AND").upper()
|
||||
self._conditions: list[dict[str, Any]] = trigger_config.get(CONF_COMPOUND_CONDITIONS) or []
|
||||
self._condition_states: list[bool] = [False] * len(self._conditions)
|
||||
self._sub_triggers: list[list[BaseTrigger]] = []
|
||||
self._sub_entities: list[CompoundSubEntity] = []
|
||||
|
||||
@property
|
||||
def condition_states(self) -> list[bool]:
|
||||
"""Return the current state of each condition."""
|
||||
return list(self._condition_states)
|
||||
|
||||
async def async_setup(self) -> None:
|
||||
"""Set up all sub-triggers for each condition."""
|
||||
from . import create_triggers
|
||||
|
||||
trigger_state = self.config.get("_trigger_state", {})
|
||||
conditions_state = trigger_state.get("conditions", [])
|
||||
|
||||
for idx, condition in enumerate(self._conditions):
|
||||
sub_entity = CompoundSubEntity(self, idx, condition)
|
||||
self._sub_entities.append(sub_entity)
|
||||
|
||||
# Build per-condition config with its persisted state
|
||||
cond_config = dict(condition)
|
||||
if idx < len(conditions_state):
|
||||
cond_state = conditions_state[idx]
|
||||
if cond_state:
|
||||
cond_config["_trigger_state"] = cond_state
|
||||
|
||||
# Wrap the real coordinator with a proxy for persistence
|
||||
proxy_coordinator = _CompoundCoordinatorProxy(self._coordinator, idx)
|
||||
sub_entity.coordinator = proxy_coordinator # type: ignore[assignment]
|
||||
|
||||
sub_triggers = create_triggers(self.hass, sub_entity, cond_config) # type: ignore[arg-type]
|
||||
self._sub_triggers.append(sub_triggers)
|
||||
|
||||
for trigger in sub_triggers:
|
||||
await trigger.async_setup()
|
||||
|
||||
_LOGGER.debug(
|
||||
"Compound trigger setup: %d conditions with %s logic for %s",
|
||||
len(self._conditions),
|
||||
self._compound_logic,
|
||||
self.entity.entity_id,
|
||||
)
|
||||
|
||||
async def async_teardown(self) -> None:
|
||||
"""Tear down all sub-triggers."""
|
||||
for trigger_list in self._sub_triggers:
|
||||
for trigger in trigger_list:
|
||||
await trigger.async_teardown()
|
||||
self._sub_triggers = []
|
||||
self._sub_entities = []
|
||||
self._condition_states = [False] * len(self._conditions)
|
||||
await super().async_teardown()
|
||||
|
||||
def evaluate(self, value: float) -> bool:
|
||||
"""Evaluate compound condition (aggregation of condition states)."""
|
||||
if self._compound_logic == "AND":
|
||||
return bool(self._condition_states) and all(self._condition_states)
|
||||
return any(self._condition_states) # OR
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Reset all sub-triggers."""
|
||||
super().reset()
|
||||
self._condition_states = [False] * len(self._conditions)
|
||||
for trigger_list in self._sub_triggers:
|
||||
for trigger in trigger_list:
|
||||
trigger.reset()
|
||||
|
||||
@callback
|
||||
def _on_condition_changed(self, condition_idx: int, is_triggered: bool) -> None:
|
||||
"""Handle a condition state change and re-aggregate."""
|
||||
self._condition_states[condition_idx] = is_triggered
|
||||
|
||||
was_triggered = self._triggered
|
||||
if self._compound_logic == "AND":
|
||||
now_triggered = bool(self._condition_states) and all(self._condition_states)
|
||||
else: # OR
|
||||
now_triggered = any(self._condition_states)
|
||||
|
||||
self._triggered = now_triggered
|
||||
|
||||
if now_triggered and not was_triggered:
|
||||
self._on_trigger_activated(0.0)
|
||||
elif not now_triggered and was_triggered:
|
||||
self._on_trigger_deactivated(0.0)
|
||||
|
||||
def _on_trigger_activated(self, value: float) -> None:
|
||||
"""Handle compound trigger activation."""
|
||||
_LOGGER.info(
|
||||
"Compound trigger activated: %s (conditions: %s, logic: %s)",
|
||||
self.entity.entity_id,
|
||||
self._condition_states,
|
||||
self._compound_logic,
|
||||
)
|
||||
self.entity.async_update_trigger_state(
|
||||
is_triggered=True,
|
||||
current_value=None,
|
||||
trigger_entity_id=None,
|
||||
)
|
||||
self.hass.async_create_task(self._coordinator.async_add_trigger_history_entry(self._task_id, trigger_value=None))
|
||||
self.hass.bus.async_fire(
|
||||
EVENT_TRIGGER_ACTIVATED,
|
||||
{
|
||||
"entity_id": self.entity.entity_id,
|
||||
"trigger_type": "compound",
|
||||
"compound_logic": self._compound_logic,
|
||||
"condition_states": list(self._condition_states),
|
||||
},
|
||||
)
|
||||
|
||||
def _on_trigger_deactivated(self, value: float) -> None:
|
||||
"""Handle compound trigger deactivation."""
|
||||
_LOGGER.info(
|
||||
"Compound trigger deactivated: %s (conditions: %s, logic: %s)",
|
||||
self.entity.entity_id,
|
||||
self._condition_states,
|
||||
self._compound_logic,
|
||||
)
|
||||
self.entity.async_update_trigger_state(
|
||||
is_triggered=False,
|
||||
current_value=None,
|
||||
trigger_entity_id=None,
|
||||
)
|
||||
self.hass.bus.async_fire(
|
||||
EVENT_TRIGGER_DEACTIVATED,
|
||||
{
|
||||
"entity_id": self.entity.entity_id,
|
||||
"trigger_type": "compound",
|
||||
"compound_logic": self._compound_logic,
|
||||
"condition_states": list(self._condition_states),
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,161 @@
|
||||
"""Counter trigger for maintenance tasks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ...sensor import MaintenanceSensor
|
||||
|
||||
from .base_trigger import BaseTrigger
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CounterTrigger(BaseTrigger):
|
||||
"""Trigger that activates when a counter reaches a target value.
|
||||
|
||||
Supports:
|
||||
- Absolute mode: triggers when value >= target
|
||||
- Delta mode: triggers when (value - baseline) >= target
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
entity: MaintenanceSensor,
|
||||
trigger_config: dict[str, Any],
|
||||
) -> None:
|
||||
"""Initialize counter trigger."""
|
||||
super().__init__(hass, entity, trigger_config)
|
||||
|
||||
self._target_value: float = trigger_config.get("trigger_target_value", 0)
|
||||
self._delta_mode: bool = trigger_config.get("trigger_delta_mode", False)
|
||||
self._baseline_value: float | None = trigger_config.get("trigger_baseline_value")
|
||||
# Set when reset_baseline is called while the source is unavailable, so
|
||||
# the re-baseline is applied against the next real value (M4).
|
||||
self._reset_pending: bool = False
|
||||
|
||||
async def async_setup(self) -> None:
|
||||
"""Set up counter trigger with baseline initialization."""
|
||||
# Restore persisted baseline BEFORE super().async_setup() which calls
|
||||
# evaluate(). Without this, evaluate() sets _baseline_value to the
|
||||
# current sensor value, discarding the saved baseline from the Store.
|
||||
if self._delta_mode and self._baseline_value is None:
|
||||
saved = self.config.get("_trigger_state", {}).get(self.entity_id, {}).get("baseline_value")
|
||||
if saved is not None:
|
||||
self._baseline_value = saved
|
||||
_LOGGER.debug(
|
||||
"Counter baseline restored from store: %s = %s",
|
||||
self.entity_id,
|
||||
self._baseline_value,
|
||||
)
|
||||
|
||||
await super().async_setup()
|
||||
|
||||
# Set initial baseline if in delta mode and no baseline exists
|
||||
if self._delta_mode and self._baseline_value is None and self._current_value is not None:
|
||||
self._baseline_value = self._current_value
|
||||
await self._persist_baseline()
|
||||
_LOGGER.debug(
|
||||
"Counter baseline initialized: %s = %s",
|
||||
self.entity_id,
|
||||
self._baseline_value,
|
||||
)
|
||||
|
||||
def _evaluate_and_update(self, value: float) -> None:
|
||||
"""Initialize/re-baseline before evaluation, then persist."""
|
||||
if self._delta_mode and self._reset_pending:
|
||||
# Apply the deferred post-completion re-baseline now that a real
|
||||
# value is available (the source was unavailable at completion). (M4)
|
||||
self._baseline_value = value
|
||||
self._reset_pending = False
|
||||
self.hass.async_create_task(
|
||||
self._persist_baseline(),
|
||||
eager_start=False,
|
||||
)
|
||||
elif self._delta_mode and self._baseline_value is None:
|
||||
self._baseline_value = value
|
||||
self.hass.async_create_task(
|
||||
self._persist_baseline(),
|
||||
eager_start=False,
|
||||
)
|
||||
_LOGGER.debug(
|
||||
"Counter baseline initialized on state change: %s = %s",
|
||||
self.entity_id,
|
||||
self._baseline_value,
|
||||
)
|
||||
super()._evaluate_and_update(value)
|
||||
|
||||
def evaluate(self, value: float) -> bool:
|
||||
"""Evaluate counter condition."""
|
||||
if self._delta_mode:
|
||||
if self._baseline_value is None:
|
||||
# Fallback — should be caught by _evaluate_and_update
|
||||
self._baseline_value = value
|
||||
return False
|
||||
if value < self._baseline_value:
|
||||
# Source counter reset / rolled over (device reboot, meter
|
||||
# rollover) — re-baseline to the new lower value so we don't
|
||||
# miss the next interval waiting for it to climb back past the
|
||||
# old baseline. (M3)
|
||||
self._baseline_value = value
|
||||
self.hass.async_create_task(
|
||||
self._persist_baseline(),
|
||||
eager_start=False,
|
||||
)
|
||||
return False
|
||||
delta = value - self._baseline_value
|
||||
return delta >= self._target_value
|
||||
|
||||
# Absolute mode
|
||||
return value >= self._target_value
|
||||
|
||||
@property
|
||||
def current_delta(self) -> float | None:
|
||||
"""Return the current delta from baseline."""
|
||||
if not self._delta_mode or self._baseline_value is None:
|
||||
return None
|
||||
if self._current_value is None:
|
||||
return None
|
||||
return self._current_value - self._baseline_value
|
||||
|
||||
def reset_baseline(self) -> None:
|
||||
"""Reset the baseline to current value (after maintenance)."""
|
||||
if self._current_value is not None:
|
||||
self._baseline_value = self._current_value
|
||||
self._reset_pending = False
|
||||
self.hass.async_create_task(self._persist_baseline())
|
||||
_LOGGER.debug(
|
||||
"Counter baseline reset: %s = %s",
|
||||
self.entity_id,
|
||||
self._baseline_value,
|
||||
)
|
||||
elif self._delta_mode:
|
||||
# Source unavailable at completion — defer the re-baseline to the
|
||||
# next real value so a stale baseline can't immediately re-trigger
|
||||
# the just-completed task when the entity returns. (M4; delta-only,
|
||||
# absolute mode has no baseline to defer.)
|
||||
self._reset_pending = True
|
||||
_LOGGER.debug(
|
||||
"Counter baseline reset deferred (source unavailable): %s",
|
||||
self.entity_id,
|
||||
)
|
||||
|
||||
async def _persist_baseline(self) -> None:
|
||||
"""Persist baseline value to the Store for survival across restarts."""
|
||||
if self._baseline_value is not None:
|
||||
await self._coordinator.async_persist_trigger_runtime(
|
||||
self._task_id,
|
||||
{"baseline_value": self._baseline_value},
|
||||
entity_id=self.entity_id,
|
||||
immediate=True,
|
||||
)
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Reset trigger and baseline."""
|
||||
super().reset()
|
||||
self.reset_baseline()
|
||||
@@ -0,0 +1,324 @@
|
||||
"""Runtime trigger for maintenance tasks.
|
||||
|
||||
Tracks accumulated 'on' time of a binary entity (input_boolean, switch,
|
||||
binary_sensor, etc.) and triggers when the total runtime reaches a
|
||||
configured threshold in hours.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback
|
||||
from homeassistant.helpers.event import (
|
||||
EventStateChangedData,
|
||||
async_track_state_change_event,
|
||||
async_track_time_interval,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ...sensor import MaintenanceSensor
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .base_trigger import BaseTrigger
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
_DEFAULT_ON_STATES = frozenset({"on", "1", "true"})
|
||||
|
||||
# Persist accumulated runtime every 5 minutes to minimise data loss on crash
|
||||
_PERSIST_INTERVAL = timedelta(minutes=5)
|
||||
|
||||
|
||||
class RuntimeTrigger(BaseTrigger):
|
||||
"""Trigger that activates when accumulated runtime reaches target hours.
|
||||
|
||||
Monitors a binary entity (on/off) and accumulates time spent in the 'on'
|
||||
state. Triggers when accumulated hours >= configured threshold.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
entity: MaintenanceSensor,
|
||||
trigger_config: dict[str, Any],
|
||||
) -> None:
|
||||
"""Initialize runtime trigger."""
|
||||
super().__init__(hass, entity, trigger_config)
|
||||
|
||||
self._target_hours: float = trigger_config.get("trigger_runtime_hours", 100.0)
|
||||
self._accumulated_seconds: float = trigger_config.get("trigger_accumulated_seconds", 0.0)
|
||||
|
||||
# Restore on_since timestamp for restart recovery
|
||||
on_since_str = trigger_config.get("trigger_on_since")
|
||||
self._on_since: str | None = None # ISO string stored for persistence
|
||||
self._on_since_dt: datetime | None = None # parsed datetime for calculation
|
||||
if on_since_str:
|
||||
parsed = dt_util.parse_datetime(on_since_str)
|
||||
if parsed:
|
||||
self._on_since = on_since_str
|
||||
self._on_since_dt = parsed
|
||||
|
||||
# Custom ON states (default: on, 1, true)
|
||||
custom_on = trigger_config.get("trigger_on_states")
|
||||
if custom_on and isinstance(custom_on, list):
|
||||
self._on_states: frozenset[str] = frozenset(s.lower().strip() for s in custom_on if isinstance(s, str) and s.strip())
|
||||
else:
|
||||
self._on_states = _DEFAULT_ON_STATES
|
||||
|
||||
self._unsub_periodic: CALLBACK_TYPE | None = None
|
||||
|
||||
async def async_setup(self) -> None:
|
||||
"""Set up runtime trigger with state restoration."""
|
||||
state = self.hass.states.get(self.entity_id)
|
||||
if state is None:
|
||||
_LOGGER.info(
|
||||
"Runtime trigger entity %s not yet available — listener registered, waiting for entity to appear",
|
||||
self.entity_id,
|
||||
)
|
||||
self._unsub_listener = async_track_state_change_event(
|
||||
self.hass,
|
||||
[self.entity_id],
|
||||
self._handle_runtime_state_change,
|
||||
)
|
||||
self._start_periodic_timer()
|
||||
return
|
||||
|
||||
# Entity exists — check if currently ON
|
||||
if self._is_on(state.state):
|
||||
if self._on_since_dt is None:
|
||||
# No restored timestamp — start tracking from now
|
||||
now = dt_util.utcnow()
|
||||
self._on_since_dt = now
|
||||
self._on_since = now.isoformat()
|
||||
await self._persist_runtime()
|
||||
_LOGGER.debug(
|
||||
"Runtime trigger: entity %s is ON, started tracking from now",
|
||||
self.entity_id,
|
||||
)
|
||||
else:
|
||||
# Entity is OFF — clear any stale on_since
|
||||
if self._on_since_dt is not None:
|
||||
# Was ON before restart but now OFF — accumulate the gap
|
||||
self._accumulate_elapsed()
|
||||
self._on_since_dt = None
|
||||
self._on_since = None
|
||||
await self._persist_runtime()
|
||||
|
||||
# Register state change listener
|
||||
self._unsub_listener = async_track_state_change_event(
|
||||
self.hass,
|
||||
[self.entity_id],
|
||||
self._handle_runtime_state_change,
|
||||
)
|
||||
|
||||
# Start periodic persistence timer
|
||||
self._start_periodic_timer()
|
||||
|
||||
# Initial evaluation
|
||||
current_hours = self._get_current_runtime_hours()
|
||||
self._current_value = current_hours
|
||||
self._evaluate_and_update(current_hours)
|
||||
|
||||
_LOGGER.debug(
|
||||
"Runtime trigger setup: %s (target=%.1fh, accumulated=%.2fh, on_since=%s)",
|
||||
self.entity_id,
|
||||
self._target_hours,
|
||||
self._accumulated_seconds / 3600.0,
|
||||
self._on_since,
|
||||
)
|
||||
|
||||
def _start_periodic_timer(self) -> None:
|
||||
"""Start the periodic persistence timer."""
|
||||
self._unsub_periodic = async_track_time_interval(
|
||||
self.hass,
|
||||
self._periodic_callback,
|
||||
_PERSIST_INTERVAL,
|
||||
)
|
||||
|
||||
async def async_teardown(self) -> None:
|
||||
"""Remove listeners and periodic timer."""
|
||||
if self._unsub_periodic is not None:
|
||||
self._unsub_periodic()
|
||||
self._unsub_periodic = None
|
||||
await super().async_teardown()
|
||||
|
||||
@callback
|
||||
def _handle_runtime_state_change(self, event: Event[EventStateChangedData]) -> None:
|
||||
"""Handle state changes for runtime accumulation."""
|
||||
old_state = event.data.get("old_state")
|
||||
new_state = event.data.get("new_state")
|
||||
|
||||
if new_state is None:
|
||||
return
|
||||
|
||||
new_val = new_state.state
|
||||
|
||||
# Entity appeared for the first time
|
||||
if old_state is None:
|
||||
_LOGGER.info(
|
||||
"Runtime trigger entity %s appeared (state=%s)",
|
||||
self.entity_id,
|
||||
new_val,
|
||||
)
|
||||
self._logged_unavailable = False
|
||||
if self._is_on(new_val) and self._on_since_dt is None:
|
||||
now = dt_util.utcnow()
|
||||
self._on_since_dt = now
|
||||
self._on_since = now.isoformat()
|
||||
self.hass.async_create_task(self._persist_runtime())
|
||||
self._update_evaluation()
|
||||
return
|
||||
|
||||
old_val = old_state.state
|
||||
|
||||
# Handle unavailable/unknown — pause accumulation
|
||||
if new_val in ("unavailable", "unknown"):
|
||||
if self._on_since_dt is not None:
|
||||
self._accumulate_elapsed()
|
||||
self._on_since_dt = None
|
||||
self._on_since = None
|
||||
self.hass.async_create_task(self._persist_runtime())
|
||||
if not self._logged_unavailable:
|
||||
_LOGGER.warning(
|
||||
"Runtime trigger entity %s became %s (runtime paused)",
|
||||
self.entity_id,
|
||||
new_val,
|
||||
)
|
||||
self._logged_unavailable = True
|
||||
return
|
||||
|
||||
# Entity back to valid state
|
||||
if self._logged_unavailable:
|
||||
_LOGGER.info(
|
||||
"Runtime trigger entity %s available again (state=%s)",
|
||||
self.entity_id,
|
||||
new_val,
|
||||
)
|
||||
self._logged_unavailable = False
|
||||
|
||||
was_on = self._is_on(old_val)
|
||||
now_on = self._is_on(new_val)
|
||||
|
||||
if was_on and not now_on:
|
||||
# Turned OFF — accumulate elapsed time
|
||||
self._accumulate_elapsed()
|
||||
self._on_since_dt = None
|
||||
self._on_since = None
|
||||
self.hass.async_create_task(self._persist_runtime())
|
||||
_LOGGER.debug(
|
||||
"Runtime trigger: %s turned OFF (accumulated=%.2fh)",
|
||||
self.entity_id,
|
||||
self._accumulated_seconds / 3600.0,
|
||||
)
|
||||
elif not was_on and now_on:
|
||||
# Turned ON — start tracking
|
||||
now = dt_util.utcnow()
|
||||
self._on_since_dt = now
|
||||
self._on_since = now.isoformat()
|
||||
self.hass.async_create_task(self._persist_runtime())
|
||||
_LOGGER.debug(
|
||||
"Runtime trigger: %s turned ON (tracking started)",
|
||||
self.entity_id,
|
||||
)
|
||||
|
||||
self._update_evaluation()
|
||||
|
||||
def _update_evaluation(self) -> None:
|
||||
"""Evaluate trigger condition with current runtime."""
|
||||
current_hours = self._get_current_runtime_hours()
|
||||
self._current_value = current_hours
|
||||
self._evaluate_and_update(current_hours)
|
||||
|
||||
def _is_on(self, state_value: str) -> bool:
|
||||
"""Check if state represents 'on'."""
|
||||
return state_value.lower() in self._on_states
|
||||
|
||||
def _accumulate_elapsed(self, now: datetime | None = None) -> None:
|
||||
"""Add elapsed time since _on_since to accumulated total."""
|
||||
if self._on_since_dt is None:
|
||||
return
|
||||
now = now or dt_util.utcnow()
|
||||
elapsed = (now - self._on_since_dt).total_seconds()
|
||||
if elapsed > 0:
|
||||
self._accumulated_seconds += elapsed
|
||||
|
||||
def _get_current_runtime_hours(self) -> float:
|
||||
"""Get current runtime in hours (accumulated + ongoing if ON)."""
|
||||
total_seconds = self._accumulated_seconds
|
||||
if self._on_since_dt is not None:
|
||||
elapsed = (dt_util.utcnow() - self._on_since_dt).total_seconds()
|
||||
if elapsed > 0:
|
||||
total_seconds += elapsed
|
||||
return total_seconds / 3600.0
|
||||
|
||||
def evaluate(self, value: float) -> bool:
|
||||
"""Evaluate whether runtime threshold is met."""
|
||||
return value >= self._target_hours
|
||||
|
||||
@callback
|
||||
def _periodic_callback(self, _now: datetime) -> None:
|
||||
"""Periodic callback to persist runtime every 5 minutes."""
|
||||
if self._on_since_dt is None:
|
||||
return
|
||||
# Accumulate elapsed, reset window, persist
|
||||
now = dt_util.utcnow()
|
||||
self._accumulate_elapsed(now)
|
||||
self._on_since_dt = now
|
||||
self._on_since = now.isoformat()
|
||||
self.hass.async_create_task(self._persist_runtime())
|
||||
|
||||
# Re-evaluate (runtime may have crossed threshold)
|
||||
self._update_evaluation()
|
||||
|
||||
_LOGGER.debug(
|
||||
"Runtime trigger periodic persist: %s (accumulated=%.2fh)",
|
||||
self.entity_id,
|
||||
self._accumulated_seconds / 3600.0,
|
||||
)
|
||||
|
||||
async def _persist_runtime(self) -> None:
|
||||
"""Persist accumulated runtime and on_since to the Store."""
|
||||
data: dict[str, Any] = {
|
||||
"accumulated_seconds": self._accumulated_seconds,
|
||||
"on_since": self._on_since,
|
||||
}
|
||||
await self._coordinator.async_persist_trigger_runtime(
|
||||
self._task_id,
|
||||
data,
|
||||
entity_id=self.entity_id,
|
||||
)
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Reset accumulated runtime (called after maintenance completion)."""
|
||||
super().reset()
|
||||
self._accumulated_seconds = 0.0
|
||||
# If entity is currently ON, keep tracking from now (fresh start)
|
||||
if self._on_since_dt is not None:
|
||||
now = dt_util.utcnow()
|
||||
self._on_since_dt = now
|
||||
self._on_since = now.isoformat()
|
||||
self.hass.async_create_task(self._persist_runtime())
|
||||
_LOGGER.debug(
|
||||
"Runtime trigger reset: %s (accumulated hours cleared)",
|
||||
self.entity_id,
|
||||
)
|
||||
|
||||
# --- Properties for sensor attributes ---
|
||||
|
||||
@property
|
||||
def accumulated_hours(self) -> float:
|
||||
"""Return persisted accumulated hours (not including ongoing session)."""
|
||||
return self._accumulated_seconds / 3600.0
|
||||
|
||||
@property
|
||||
def current_runtime_hours(self) -> float:
|
||||
"""Return total runtime including ongoing session."""
|
||||
return self._get_current_runtime_hours()
|
||||
|
||||
@property
|
||||
def remaining_hours(self) -> float:
|
||||
"""Return hours remaining until trigger fires."""
|
||||
return max(0.0, self._target_hours - self._get_current_runtime_hours())
|
||||
@@ -0,0 +1,200 @@
|
||||
"""State change trigger for maintenance tasks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from homeassistant.core import Event, HomeAssistant, callback
|
||||
from homeassistant.helpers.event import (
|
||||
EventStateChangedData,
|
||||
async_track_state_change_event,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ...sensor import MaintenanceSensor
|
||||
|
||||
from .base_trigger import BaseTrigger
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class StateChangeTrigger(BaseTrigger):
|
||||
"""Trigger that activates after counting state transitions.
|
||||
|
||||
Counts transitions matching from_state -> to_state pattern.
|
||||
Triggers when count reaches target_changes.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
entity: MaintenanceSensor,
|
||||
trigger_config: dict[str, Any],
|
||||
) -> None:
|
||||
"""Initialize state change trigger."""
|
||||
super().__init__(hass, entity, trigger_config)
|
||||
|
||||
self._from_state: str | None = trigger_config.get("trigger_from_state")
|
||||
self._to_state: str | None = trigger_config.get("trigger_to_state")
|
||||
self._target_changes: int = trigger_config.get("trigger_target_changes", 1)
|
||||
# Restore persisted change count from config, default to 0
|
||||
self._change_count: int = trigger_config.get("trigger_change_count", 0)
|
||||
self._current_value = float(self._change_count)
|
||||
self._last_state: str | None = None
|
||||
|
||||
async def async_setup(self) -> None:
|
||||
"""Set up state change trigger.
|
||||
|
||||
IMPORTANT: The listener is ALWAYS registered, even when the entity does
|
||||
not exist yet. HA fires a state_change event when an entity first
|
||||
appears (old_state=None), so the trigger will self-heal automatically.
|
||||
"""
|
||||
state = self.hass.states.get(self.entity_id)
|
||||
if state is None:
|
||||
_LOGGER.info(
|
||||
"Trigger entity %s not yet available — listener registered, waiting for entity to appear",
|
||||
self.entity_id,
|
||||
)
|
||||
# Register listener anyway so we catch the entity appearing
|
||||
self._unsub_listener = async_track_state_change_event(self.hass, [self.entity_id], self._handle_state_transition)
|
||||
return
|
||||
|
||||
self._last_state = state.state
|
||||
|
||||
# Restore triggered state from persisted change count
|
||||
if self._change_count >= self._target_changes:
|
||||
self._triggered = True
|
||||
self.entity.async_update_trigger_state(
|
||||
is_triggered=True,
|
||||
current_value=float(self._change_count),
|
||||
trigger_entity_id=self.entity_id,
|
||||
)
|
||||
|
||||
# Register state change listener (override base: we handle events differently)
|
||||
self._unsub_listener = async_track_state_change_event(self.hass, [self.entity_id], self._handle_state_transition)
|
||||
|
||||
_LOGGER.debug(
|
||||
"State change trigger setup: %s (target=%d, count=%d, from=%s, to=%s)",
|
||||
self.entity_id,
|
||||
self._target_changes,
|
||||
self._change_count,
|
||||
self._from_state,
|
||||
self._to_state,
|
||||
)
|
||||
|
||||
@callback
|
||||
def _handle_state_transition(self, event: Event[EventStateChangedData]) -> None:
|
||||
"""Handle state transition and count matching changes."""
|
||||
old_state = event.data.get("old_state")
|
||||
new_state = event.data.get("new_state")
|
||||
|
||||
if new_state is None:
|
||||
# Entity removed from state machine
|
||||
return
|
||||
|
||||
new_val = new_state.state
|
||||
|
||||
# Entity appeared for the first time (old_state=None)
|
||||
if old_state is None:
|
||||
_LOGGER.info(
|
||||
"Trigger entity %s appeared in state machine (state=%s)",
|
||||
self.entity_id,
|
||||
new_val,
|
||||
)
|
||||
self._logged_unavailable = False
|
||||
# Capture initial state but don't count as a transition
|
||||
if new_val not in ("unavailable", "unknown"):
|
||||
self._last_state = new_val
|
||||
return
|
||||
|
||||
old_val = old_state.state
|
||||
|
||||
# Handle unavailable/unknown with log-once pattern
|
||||
if new_val in ("unavailable", "unknown"):
|
||||
if not self._logged_unavailable:
|
||||
_LOGGER.warning(
|
||||
"Trigger entity %s became %s",
|
||||
self.entity_id,
|
||||
new_val,
|
||||
)
|
||||
self._logged_unavailable = True
|
||||
return
|
||||
|
||||
# Entity is back to a valid state
|
||||
if self._logged_unavailable:
|
||||
_LOGGER.info(
|
||||
"Trigger entity %s is available again (state=%s)",
|
||||
self.entity_id,
|
||||
new_val,
|
||||
)
|
||||
self._logged_unavailable = False
|
||||
|
||||
# Use _last_state as fallback when old_val is unavailable/unknown
|
||||
effective_old = old_val
|
||||
if old_val in ("unavailable", "unknown") and self._last_state is not None:
|
||||
effective_old = self._last_state
|
||||
|
||||
# Check if transition matches pattern
|
||||
matches = True
|
||||
if self._from_state is not None and effective_old != self._from_state:
|
||||
matches = False
|
||||
if self._to_state is not None and new_val != self._to_state:
|
||||
matches = False
|
||||
|
||||
if matches and effective_old != new_val:
|
||||
self._change_count += 1
|
||||
self._current_value = float(self._change_count)
|
||||
# Persist change count to survive restarts
|
||||
if self.hass.is_running:
|
||||
self.hass.async_create_task(self._persist_change_count())
|
||||
_LOGGER.debug(
|
||||
"State change counted: %s (%s -> %s) count=%d/%d",
|
||||
self.entity_id,
|
||||
old_val,
|
||||
new_val,
|
||||
self._change_count,
|
||||
self._target_changes,
|
||||
)
|
||||
|
||||
was_triggered = self._triggered
|
||||
is_triggered = self._change_count >= self._target_changes
|
||||
self._triggered = is_triggered
|
||||
|
||||
if is_triggered and not was_triggered:
|
||||
self._on_trigger_activated(float(self._change_count))
|
||||
elif not is_triggered and was_triggered:
|
||||
self._on_trigger_deactivated(float(self._change_count))
|
||||
|
||||
self._last_state = new_val
|
||||
|
||||
def evaluate(self, value: float) -> bool:
|
||||
"""Evaluate is handled by _handle_state_transition directly."""
|
||||
# State change triggers use event-driven evaluation only
|
||||
return self._triggered
|
||||
|
||||
@property
|
||||
def change_count(self) -> int:
|
||||
"""Return the current change count."""
|
||||
return self._change_count
|
||||
|
||||
def reset_count(self) -> None:
|
||||
"""Reset the change counter (after maintenance)."""
|
||||
self._change_count = 0
|
||||
self._current_value = 0.0
|
||||
if self.hass.is_running:
|
||||
self.hass.async_create_task(self._persist_change_count())
|
||||
_LOGGER.debug("State change counter reset: %s", self.entity_id)
|
||||
|
||||
async def _persist_change_count(self) -> None:
|
||||
"""Persist change count to the Store for survival across restarts."""
|
||||
await self._coordinator.async_persist_trigger_runtime(
|
||||
self._task_id,
|
||||
{"change_count": self._change_count},
|
||||
entity_id=self.entity_id,
|
||||
)
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Reset trigger and counter."""
|
||||
super().reset()
|
||||
self.reset_count()
|
||||
@@ -0,0 +1,176 @@
|
||||
"""Threshold trigger for maintenance tasks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
|
||||
from homeassistant.helpers.event import async_call_later
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ...sensor import MaintenanceSensor
|
||||
|
||||
from .base_trigger import BaseTrigger
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ThresholdTrigger(BaseTrigger):
|
||||
"""Trigger that activates when a sensor value exceeds thresholds.
|
||||
|
||||
Supports:
|
||||
- Above threshold (value > above)
|
||||
- Below threshold (value < below)
|
||||
- Duration requirement (value must exceed for X minutes)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
entity: MaintenanceSensor,
|
||||
trigger_config: dict[str, Any],
|
||||
) -> None:
|
||||
"""Initialize threshold trigger."""
|
||||
super().__init__(hass, entity, trigger_config)
|
||||
|
||||
self._above: float | None = trigger_config.get("trigger_above")
|
||||
self._below: float | None = trigger_config.get("trigger_below")
|
||||
self._for_minutes: int = trigger_config.get("trigger_for_minutes", 0)
|
||||
|
||||
self._threshold_exceeded = False
|
||||
self._timer_cancel: CALLBACK_TYPE | None = None
|
||||
|
||||
# Restore persisted exceeded-since timestamp (survives HA restarts)
|
||||
exceeded_since = trigger_config.get("trigger_threshold_exceeded_since")
|
||||
self._exceeded_since: str | None = None
|
||||
self._exceeded_since_dt: datetime | None = None
|
||||
if exceeded_since:
|
||||
try:
|
||||
parsed = datetime.fromisoformat(exceeded_since)
|
||||
# Older payloads may be naive — assume UTC since live writes
|
||||
# use dt_util.utcnow().isoformat() (TZ-aware).
|
||||
if parsed.tzinfo is None:
|
||||
from datetime import UTC
|
||||
|
||||
parsed = parsed.replace(tzinfo=UTC)
|
||||
self._exceeded_since_dt = parsed
|
||||
self._exceeded_since = exceeded_since
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
def _value_exceeds_threshold(self, value: float) -> bool:
|
||||
"""Check if the value exceeds configured thresholds."""
|
||||
if self._above is not None and value > self._above:
|
||||
return True
|
||||
if self._below is not None and value < self._below:
|
||||
return True
|
||||
return False
|
||||
|
||||
def evaluate(self, value: float) -> bool:
|
||||
"""Evaluate threshold condition."""
|
||||
exceeds = self._value_exceeds_threshold(value)
|
||||
|
||||
if exceeds:
|
||||
if self._for_minutes > 0:
|
||||
if not self._threshold_exceeded:
|
||||
self._threshold_exceeded = True
|
||||
|
||||
# Restart recovery: check persisted exceeded_since
|
||||
if self._exceeded_since_dt is not None:
|
||||
elapsed = (dt_util.utcnow() - self._exceeded_since_dt).total_seconds()
|
||||
if elapsed >= self._for_minutes * 60:
|
||||
_LOGGER.debug(
|
||||
"Threshold recovery: elapsed %.0fs >= %ds, triggering immediately: %s",
|
||||
elapsed,
|
||||
self._for_minutes * 60,
|
||||
self.entity_id,
|
||||
)
|
||||
self._triggered = True
|
||||
return True
|
||||
remaining = max(self._for_minutes * 60 - elapsed, 0)
|
||||
_LOGGER.debug(
|
||||
"Threshold recovery: %.0fs remaining: %s",
|
||||
remaining,
|
||||
self.entity_id,
|
||||
)
|
||||
self._exceeded_since_dt = None # consumed
|
||||
self._start_for_timer(remaining_seconds=remaining)
|
||||
return False
|
||||
|
||||
# Fresh start: persist timestamp and start full timer
|
||||
self._exceeded_since = dt_util.utcnow().isoformat()
|
||||
self._exceeded_since_dt = None
|
||||
if self.hass.is_running:
|
||||
self.hass.async_create_task(self._persist_exceeded_since())
|
||||
self._start_for_timer()
|
||||
return False
|
||||
# Timer running or already triggered
|
||||
return self._triggered
|
||||
return True
|
||||
|
||||
# Value back in normal range
|
||||
if self._threshold_exceeded and self._exceeded_since is not None:
|
||||
self._exceeded_since = None
|
||||
self._exceeded_since_dt = None
|
||||
if self.hass.is_running:
|
||||
self.hass.async_create_task(self._persist_exceeded_since())
|
||||
self._threshold_exceeded = False
|
||||
self._cancel_timer()
|
||||
return False
|
||||
|
||||
def _start_for_timer(self, remaining_seconds: float | None = None) -> None:
|
||||
"""Start the for-duration timer.
|
||||
|
||||
If *remaining_seconds* is provided (e.g. after a restart recovery),
|
||||
the timer uses that value instead of the full ``for_minutes`` duration.
|
||||
The exceeded-since timestamp is persisted so the timer survives HA
|
||||
restarts.
|
||||
"""
|
||||
self._cancel_timer()
|
||||
duration = remaining_seconds if remaining_seconds is not None else self._for_minutes * 60
|
||||
|
||||
@callback
|
||||
def _timer_fired(_now: datetime) -> None:
|
||||
"""Handle timer completion."""
|
||||
if self._threshold_exceeded:
|
||||
_LOGGER.debug(
|
||||
"Threshold for-timer fired: %s (%d min)",
|
||||
self.entity_id,
|
||||
self._for_minutes,
|
||||
)
|
||||
self._triggered = True
|
||||
self._on_trigger_activated(self._current_value or 0.0)
|
||||
|
||||
self._timer_cancel = async_call_later(self.hass, duration, _timer_fired)
|
||||
|
||||
def _cancel_timer(self) -> None:
|
||||
"""Cancel the for-duration timer."""
|
||||
if self._timer_cancel is not None:
|
||||
self._timer_cancel()
|
||||
self._timer_cancel = None
|
||||
|
||||
async def _persist_exceeded_since(self) -> None:
|
||||
"""Persist exceeded-since timestamp for survival across restarts."""
|
||||
await self._coordinator.async_persist_trigger_runtime(
|
||||
self._task_id,
|
||||
{"threshold_exceeded_since": self._exceeded_since},
|
||||
entity_id=self.entity_id,
|
||||
)
|
||||
|
||||
async def async_teardown(self) -> None:
|
||||
"""Clean up timer on teardown."""
|
||||
self._cancel_timer()
|
||||
await super().async_teardown()
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Reset trigger state."""
|
||||
super().reset()
|
||||
self._threshold_exceeded = False
|
||||
self._exceeded_since = None
|
||||
self._exceeded_since_dt = None
|
||||
if self.hass.is_running:
|
||||
self.hass.async_create_task(self._persist_exceeded_since())
|
||||
self._cancel_timer()
|
||||
Reference in New Issue
Block a user