393 files
This commit is contained in:
@@ -12,7 +12,7 @@ from homeassistant.components.sensor import (
|
||||
SensorEntity,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.const import UnitOfInformation, UnitOfTime
|
||||
from homeassistant.const import EntityCategory, UnitOfInformation, UnitOfTime
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
|
||||
@@ -39,8 +39,11 @@ from .coordinator import MaintenanceCoordinator
|
||||
from .entity.entity_base import MaintenanceEntity
|
||||
from .entity.summary_coordinator import MaintenanceSummaryCoordinator
|
||||
from .entity.triggers import BaseTrigger, create_triggers, normalize_entity_ids
|
||||
from .helpers.aggregate import object_slug as aggregate_object_slug
|
||||
from .helpers.dates import parse_hhmm
|
||||
from .helpers.global_options import is_schedule_time_enabled
|
||||
from .helpers.pause import is_task_inert
|
||||
from .helpers.phases import current_phase_summary
|
||||
from .helpers.schedule import read_legacy_fields
|
||||
from .helpers.status import compute_status_from_task_dict
|
||||
|
||||
@@ -62,16 +65,6 @@ if TYPE_CHECKING:
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _current_phase_summary_for_sensor(task: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"""Resolved current phase for entity attributes (#139)."""
|
||||
from .helpers.phases import current_phase
|
||||
|
||||
phase = current_phase(task)
|
||||
if phase is None:
|
||||
return None
|
||||
return {"id": phase["id"], "name": phase["name"], "index": phase["index"], "count": phase["count"]}
|
||||
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
@@ -157,7 +150,7 @@ class MaintenanceSensor(MaintenanceEntity, SensorEntity):
|
||||
obj_data = coordinator.entry.data.get(CONF_OBJECT, {})
|
||||
task_data = coordinator.entry.data.get(CONF_TASKS, {}).get(task_id, {})
|
||||
|
||||
object_slug = slugify_object_name(obj_data.get("name", "unknown"))
|
||||
object_slug = aggregate_object_slug(obj_data)
|
||||
self._attr_unique_id = task_unique_id(object_slug, task_id)
|
||||
|
||||
# Use custom entity_slug as the friendly name if provided
|
||||
@@ -255,10 +248,16 @@ class MaintenanceSensor(MaintenanceEntity, SensorEntity):
|
||||
|
||||
attrs.update(last_reading_attributes(task.get("history")))
|
||||
|
||||
# #189: the calendar events behind the next due date ("Residual
|
||||
# waste", "Paper") — stable until the next occurrence, only present
|
||||
# for a calendar-driven task that has them.
|
||||
if titles := task.get("_next_event_titles"):
|
||||
attrs["next_event_titles"] = list(titles)
|
||||
|
||||
# Task phases (#139): which cycle step is due — stable (changes once
|
||||
# per completion), so it belongs on the entity for automations
|
||||
# ("announce 'replace blades' only when THAT phase comes up").
|
||||
phase = _current_phase_summary_for_sensor(task)
|
||||
phase = current_phase_summary(task)
|
||||
if phase is not None:
|
||||
attrs["current_phase"] = phase["name"]
|
||||
attrs["current_phase_id"] = phase["id"]
|
||||
@@ -348,15 +347,14 @@ class MaintenanceSensor(MaintenanceEntity, SensorEntity):
|
||||
signal = SIGNAL_TASK_RESET.format(entry_id=self.coordinator.entry.entry_id, task_id=self._task_id)
|
||||
self.async_on_remove(async_dispatcher_connect(self.hass, signal, self._handle_task_reset))
|
||||
|
||||
# v2.10.0: an archived task is inert — keep the status listener above
|
||||
# (so unarchive repaints immediately) but never wire up its triggers.
|
||||
if task_data.get("archived_at") is not None:
|
||||
return
|
||||
|
||||
# v2.20 (N3): same for a seasonally paused object — the pause/resume
|
||||
# WS commands reload the entry, so triggers wire up again on resume.
|
||||
obj_data = self.coordinator.entry.data.get(CONF_OBJECT, {})
|
||||
if obj_data.get("paused_at") is not None:
|
||||
# An inert task — archived (v2.10.0), disabled, or its object
|
||||
# seasonally paused (v2.20, N3) — keeps the status listener above (so
|
||||
# unarchive repaints immediately) but never wires up its triggers:
|
||||
# every lifecycle change reloads the entry, which wires them again.
|
||||
# A DISABLED task used to be wired, and a sensor crossing its limit
|
||||
# wrote a TRIGGERED history entry and fired the activation event for
|
||||
# a task that reads OK (bug audit 2026-09-26).
|
||||
if is_task_inert(task_data, self.coordinator.entry.data.get(CONF_OBJECT, {})):
|
||||
return
|
||||
|
||||
if not trigger_config:
|
||||
@@ -381,8 +379,7 @@ class MaintenanceSensor(MaintenanceEntity, SensorEntity):
|
||||
# all({one: True}) pass and falsely activated the task while the
|
||||
# sibling sensors were still fine (bug audit 2026-08-22). Setup
|
||||
# below overwrites entries for entities that restore triggered.
|
||||
if len(self._triggers) > 1:
|
||||
self._trigger_states = {t.entity_id: False for t in self._triggers}
|
||||
self._seed_trigger_states()
|
||||
for trigger in self._triggers:
|
||||
await trigger.async_setup()
|
||||
_LOGGER.debug(
|
||||
@@ -402,14 +399,28 @@ class MaintenanceSensor(MaintenanceEntity, SensorEntity):
|
||||
self._trigger_values = {}
|
||||
await super().async_will_remove_from_hass()
|
||||
|
||||
def _seed_trigger_states(self) -> None:
|
||||
"""Every entity of a multi-entity trigger starts at False, so
|
||||
``entity_logic == "all"`` quantifies over ALL of them (single-entity
|
||||
triggers use the direct assignment and need no map)."""
|
||||
self._trigger_states = {t.entity_id: False for t in self._triggers} if len(self._triggers) > 1 else {}
|
||||
|
||||
@callback
|
||||
def _handle_task_reset(self) -> None:
|
||||
"""Reset all trigger instances after task completion/skip/reset."""
|
||||
for trigger in self._triggers:
|
||||
trigger.reset()
|
||||
self._trigger_states = {}
|
||||
# Re-seed, not clear: an empty map let the FIRST entity crossing its
|
||||
# limit after a completion satisfy all({one: True}) — the "all" task
|
||||
# re-triggered (history entry + event) while its sibling sensors were
|
||||
# fine (bug audit 2026-09-26, SCH-5).
|
||||
self._seed_trigger_states()
|
||||
self._trigger_values = {}
|
||||
|
||||
# Tell the coordinator too: a refresh already under way restored the
|
||||
# old latch before this reset and would publish it (bug audit
|
||||
# 2026-09-27, see MaintenanceCoordinator.note_live_trigger_state).
|
||||
self.coordinator.note_live_trigger_state(self._task_id, False, None)
|
||||
if self.coordinator.data is not None:
|
||||
tasks = self.coordinator.data.get(CONF_TASKS, {})
|
||||
task = tasks.get(self._task_id, {})
|
||||
@@ -427,43 +438,64 @@ class MaintenanceSensor(MaintenanceEntity, SensorEntity):
|
||||
is_triggered: bool,
|
||||
current_value: float | None = None,
|
||||
trigger_entity_id: str | None = None,
|
||||
) -> None:
|
||||
) -> bool:
|
||||
"""Update trigger state from trigger callback.
|
||||
|
||||
For multi-entity triggers, aggregates per-entity states using the
|
||||
configured entity_logic ("any" or "all").
|
||||
|
||||
Returns True when the TASK-level trigger state went from active to
|
||||
inactive — the recovery an ``auto_complete_on_recovery`` task may
|
||||
complete on. One entity of several recovering is not that: with
|
||||
"any" a sibling still holds the task, with "all" the task may never
|
||||
have been triggered (bug audit 2026-09-26, SCH-5).
|
||||
"""
|
||||
if self.coordinator.data is None:
|
||||
return
|
||||
# No read model yet: nothing to aggregate against — a deactivation
|
||||
# keeps the historical per-entity meaning.
|
||||
return not is_triggered
|
||||
|
||||
tasks = self.coordinator.data.get(CONF_TASKS, {})
|
||||
task = tasks.get(self._task_id, {})
|
||||
prev_active = task.get("_trigger_active", False)
|
||||
|
||||
# Track per-entity state
|
||||
if trigger_entity_id is not None:
|
||||
self._trigger_states[trigger_entity_id] = is_triggered
|
||||
if current_value is not None:
|
||||
self._trigger_values[trigger_entity_id] = current_value
|
||||
|
||||
# Aggregate trigger states
|
||||
if len(self._triggers) > 1:
|
||||
trigger_config = self.coordinator.entry.data.get(CONF_TASKS, {}).get(self._task_id, {}).get("trigger_config", {})
|
||||
entity_logic = trigger_config.get("entity_logic", DEFAULT_ENTITY_LOGIC)
|
||||
|
||||
if entity_logic == "all":
|
||||
aggregated = bool(self._trigger_states) and all(self._trigger_states.values())
|
||||
else: # "any"
|
||||
aggregated = any(self._trigger_states.values())
|
||||
|
||||
was_aggregated = self._aggregate(entity_logic)
|
||||
# Track per-entity state
|
||||
if trigger_entity_id is not None:
|
||||
self._trigger_states[trigger_entity_id] = is_triggered
|
||||
if current_value is not None:
|
||||
self._trigger_values[trigger_entity_id] = current_value
|
||||
aggregated = self._aggregate(entity_logic)
|
||||
task["_trigger_active"] = aggregated
|
||||
episode_closed = was_aggregated and not aggregated
|
||||
else:
|
||||
# Single trigger: direct assignment
|
||||
# Single trigger: direct assignment. It only reports False when
|
||||
# it was latched (a deactivation, or a persisted latch the live
|
||||
# state no longer supports).
|
||||
if trigger_entity_id is not None:
|
||||
self._trigger_states[trigger_entity_id] = is_triggered
|
||||
if current_value is not None:
|
||||
self._trigger_values[trigger_entity_id] = current_value
|
||||
task["_trigger_active"] = is_triggered
|
||||
episode_closed = not is_triggered
|
||||
|
||||
if current_value is not None:
|
||||
task["_trigger_current_value"] = current_value
|
||||
|
||||
# The coordinator keeps the live latch (a refresh under way must not
|
||||
# publish the state it restored before this flip — bug audit
|
||||
# 2026-09-27) and the end of the trigger episode: without a record of
|
||||
# the recovery, a NEW episode that began while Home Assistant was
|
||||
# down read as the old one and was only repainted on the next start
|
||||
# (R SCH-10).
|
||||
self.coordinator.note_live_trigger_state(self._task_id, bool(task["_trigger_active"]), task.get("_trigger_current_value"))
|
||||
if episode_closed:
|
||||
self.coordinator.note_trigger_cleared(self._task_id)
|
||||
|
||||
# Recompute _status immediately so native_value reflects the change
|
||||
old_status = task.get("_status")
|
||||
new_status = self._compute_live_status(task)
|
||||
@@ -476,6 +508,13 @@ class MaintenanceSensor(MaintenanceEntity, SensorEntity):
|
||||
# trigger_active attribute must repaint regardless.
|
||||
if new_status != old_status or task.get("_trigger_active", False) != prev_active:
|
||||
self.async_write_ha_state()
|
||||
return bool(prev_active) and not task.get("_trigger_active", False)
|
||||
|
||||
def _aggregate(self, entity_logic: str) -> bool:
|
||||
"""The task-level latch of a multi-entity trigger ("all" / "any")."""
|
||||
if entity_logic == "all":
|
||||
return bool(self._trigger_states) and all(self._trigger_states.values())
|
||||
return any(self._trigger_states.values())
|
||||
|
||||
@staticmethod
|
||||
def _compute_live_status(task: dict[str, Any]) -> str:
|
||||
@@ -510,7 +549,7 @@ class MaintenanceNextDueSensor(MaintenanceEntity, SensorEntity):
|
||||
super().__init__(coordinator, task_id)
|
||||
obj_data = coordinator.entry.data.get(CONF_OBJECT, {})
|
||||
task_data = coordinator.entry.data.get(CONF_TASKS, {}).get(task_id, {})
|
||||
object_slug = slugify_object_name(obj_data.get("name", "unknown"))
|
||||
object_slug = aggregate_object_slug(obj_data)
|
||||
self._attr_unique_id = task_unique_id(object_slug, task_id, "next_due")
|
||||
self._attr_translation_placeholders = {"task_name": task_data.get("name", "")}
|
||||
|
||||
@@ -575,7 +614,7 @@ class MaintenanceDaysUntilDueSensor(MaintenanceEntity, SensorEntity):
|
||||
super().__init__(coordinator, task_id)
|
||||
obj_data = coordinator.entry.data.get(CONF_OBJECT, {})
|
||||
task_data = coordinator.entry.data.get(CONF_TASKS, {}).get(task_id, {})
|
||||
object_slug = slugify_object_name(obj_data.get("name", "unknown"))
|
||||
object_slug = aggregate_object_slug(obj_data)
|
||||
self._attr_unique_id = task_unique_id(object_slug, task_id, "days_until_due")
|
||||
self._attr_translation_placeholders = {"task_name": task_data.get("name", "")}
|
||||
# Pin the documented, language-independent entity_id. Only NEW entities
|
||||
@@ -664,7 +703,7 @@ class PartStockSensor(MaintenanceEntity, SensorEntity):
|
||||
super().__init__(coordinator, part_id)
|
||||
self._part_id = part_id
|
||||
obj_data = coordinator.entry.data.get(CONF_OBJECT, {})
|
||||
object_slug = slugify_object_name(obj_data.get("name", "unknown"))
|
||||
object_slug = aggregate_object_slug(obj_data)
|
||||
self._attr_unique_id = f"maintenance_supporter_{object_slug}_part_{part_id}"
|
||||
part = self._part
|
||||
self._attr_name = f"{part.get('name', 'Part')} stock"
|
||||
@@ -921,6 +960,9 @@ class DocumentStorageSensor(SensorEntity):
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_translation_key = "document_storage"
|
||||
# Quality scale entity-category: storage footprint is system information
|
||||
# about the integration itself, not maintenance data.
|
||||
_attr_entity_category = EntityCategory.DIAGNOSTIC
|
||||
_attr_device_class = SensorDeviceClass.DATA_SIZE
|
||||
_attr_native_unit_of_measurement = UnitOfInformation.BYTES
|
||||
_attr_state_class = SensorStateClass.MEASUREMENT
|
||||
|
||||
Reference in New Issue
Block a user