New apps Added

This commit is contained in:
2026-07-08 10:43:39 -04:00
parent 3b1f4bbd75
commit fefc2c8b5c
1114 changed files with 406637 additions and 154 deletions
@@ -0,0 +1,7 @@
"""Entity module for the Maintenance Supporter integration."""
from __future__ import annotations
from .entity_base import MaintenanceEntity
__all__ = ["MaintenanceEntity"]
@@ -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",
]
@@ -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()