393 files

This commit is contained in:
Home Assistant Version Control
2026-09-27 17:16:54 +00:00
parent b05b7a897e
commit 39d97ac2db
404 changed files with 54006 additions and 11034 deletions
@@ -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