Updated apps
This commit is contained in:
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.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user