Updated apps

This commit is contained in:
2026-07-20 22:52:35 -04:00
parent 28a8cb98f6
commit a0c3271743
1164 changed files with 94781 additions and 6892 deletions
@@ -44,7 +44,12 @@ class CounterTrigger(BaseTrigger):
# 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:
# The Store baseline wins even over an explicit trigger_baseline_value
# from the config: the config value is only the INITIAL baseline, while
# the Store holds the LIVING one — after a completion re-baselines to
# e.g. 80, falling back to the config's 0 on restart would re-fire the
# just-completed task (issue #102 family).
if self._delta_mode:
saved = self.config.get("_trigger_state", {}).get(self.entity_id, {}).get("baseline_value")
if saved is not None:
self._baseline_value = saved
@@ -154,6 +159,13 @@ class CounterTrigger(BaseTrigger):
entity_id=self.entity_id,
immediate=True,
)
# Surface the moved baseline in the coordinator read-model right
# away. Without this, a freshly adopted delta task shows no delta
# for up to a full update interval and the panel's progress bar
# falls back to the RAW counter value — a 27,000 km odometer reads
# as "27000/15000, overdue" (issue #102). Debounced upstream, and
# the periodic refresh never writes baselines, so no loop.
await self._coordinator.async_request_refresh()
def reset(self) -> None:
"""Reset trigger and baseline."""
@@ -3,6 +3,12 @@
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.
When ``attribute`` is configured, the tracked value is that ATTRIBUTE of the
entity instead of its state — a climate entity's ``hvac_action`` says whether
the unit is actually conditioning (cooling/heating/fan), while its state only
reports the standby MODE. State-change events fire on attribute changes too,
so the same listener covers both.
"""
from __future__ import annotations
@@ -11,7 +17,7 @@ import logging
from datetime import datetime, timedelta
from typing import TYPE_CHECKING, Any
from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback
from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, State, callback
from homeassistant.helpers.event import (
EventStateChangedData,
async_track_state_change_event,
@@ -87,7 +93,7 @@ class RuntimeTrigger(BaseTrigger):
return
# Entity exists — check if currently ON
if self._is_on(state.state):
if self._is_on(self._tracked_value(state)):
if self._on_since_dt is None:
# No restored timestamp — start tracking from now
now = dt_util.utcnow()
@@ -154,7 +160,9 @@ class RuntimeTrigger(BaseTrigger):
if new_state is None:
return
new_val = new_state.state
new_val = self._tracked_value(new_state)
# Availability is judged on the raw STATE even in attribute mode.
raw_state = new_state.state
# Entity appeared for the first time
if old_state is None:
@@ -172,10 +180,10 @@ class RuntimeTrigger(BaseTrigger):
self._update_evaluation()
return
old_val = old_state.state
old_val = self._tracked_value(old_state)
# Handle unavailable/unknown — pause accumulation
if new_val in ("unavailable", "unknown"):
if raw_state in ("unavailable", "unknown"):
if self._on_since_dt is not None:
self._accumulate_elapsed()
self._on_since_dt = None
@@ -185,7 +193,7 @@ class RuntimeTrigger(BaseTrigger):
_LOGGER.warning(
"Runtime trigger entity %s became %s (runtime paused)",
self.entity_id,
new_val,
raw_state,
)
self._logged_unavailable = True
return
@@ -236,6 +244,12 @@ class RuntimeTrigger(BaseTrigger):
"""Check if state represents 'on'."""
return state_value.lower() in self._on_states
def _tracked_value(self, state: State) -> str:
"""The tracked string: the configured attribute if set, else the state."""
if self.attribute:
return str(state.attributes.get(self.attribute, "") or "")
return str(state.state)
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:
@@ -19,6 +19,11 @@ from .base_trigger import BaseTrigger
_LOGGER = logging.getLogger(__name__)
def _norm_state(value: str | None) -> str | None:
"""Case/whitespace-insensitive state form for from/to comparisons."""
return value.strip().casefold() if isinstance(value, str) else value
class StateChangeTrigger(BaseTrigger):
"""Trigger that activates after counting state transitions.
@@ -35,8 +40,13 @@ class StateChangeTrigger(BaseTrigger):
"""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")
# Case-insensitive matching, like RuntimeTrigger's on_states: the
# options flow lowercases these on save while the panel keeps the
# user's casing, and HA states themselves can be capitalized
# (input_select "Home") — normalizing BOTH sides at compare time is
# the only variant that works for every surface combination.
self._from_state: str | None = _norm_state(trigger_config.get("trigger_from_state"))
self._to_state: str | None = _norm_state(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)
@@ -64,12 +74,27 @@ class StateChangeTrigger(BaseTrigger):
# 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,
)
# Latch reconciliation: a single-shot state alarm (target_changes
# == 1) that already left its alert state while we were down is no
# longer active. Clear it quietly — the recovery transition was
# never observed, so we must NOT auto-complete for it here (that
# path only runs on a live off event, guarded against double-count).
if (
self._to_state is not None
and self._target_changes == 1
and _norm_state(state.state) != self._to_state
):
self._change_count = 0
self._current_value = 0.0
if self.hass.is_running:
self.hass.async_create_task(self._persist_change_count())
else:
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)
@@ -137,9 +162,9 @@ class StateChangeTrigger(BaseTrigger):
# Check if transition matches pattern
matches = True
if self._from_state is not None and effective_old != self._from_state:
if self._from_state is not None and _norm_state(effective_old) != self._from_state:
matches = False
if self._to_state is not None and new_val != self._to_state:
if self._to_state is not None and _norm_state(new_val) != self._to_state:
matches = False
if matches and effective_old != new_val:
@@ -166,6 +191,25 @@ class StateChangeTrigger(BaseTrigger):
elif not is_triggered and was_triggered:
self._on_trigger_deactivated(float(self._change_count))
# Latch recovery: a single-shot state alarm (target_changes == 1 — an
# adopted problem sensor or an appliance event) clears when the entity
# leaves its alert state. Reset the counter so the next occurrence can
# fire again, and run the deactivation path — which auto-completes on
# recovery when opted in. Multi-count triggers keep accumulating and
# only reset on manual completion, so they are untouched here.
elif (
self._to_state is not None
and self._target_changes == 1
and self._triggered
and _norm_state(new_val) != self._to_state
):
self._change_count = 0
self._current_value = 0.0
if self.hass.is_running:
self.hass.async_create_task(self._persist_change_count())
self._triggered = False
self._on_trigger_deactivated(0.0)
self._last_state = new_val
def evaluate(self, value: float) -> bool: