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,6 +12,7 @@ from __future__ import annotations
import logging
from collections.abc import Mapping
from datetime import UTC, datetime
from typing import Any
from homeassistant.core import HomeAssistant
@@ -71,8 +72,11 @@ def legacy_runtime_to_trigger_state(trigger_config: Mapping[str, Any], legacy: M
if legacy and all(isinstance(v, Mapping) for v in legacy.values()):
# Already per-entity (defensive: nothing writes this shape today).
return dict(legacy)
entity_ids = trigger_config.get("entity_ids") or [trigger_config.get("entity_id")]
first = next((eid for eid in entity_ids if eid), None)
from .entity.triggers import primary_entity_id
# The shared "which entity does this trigger watch" rule (DRY audit
# 2026-09-26 B) — a compound trigger has none and gets nothing.
first = primary_entity_id(dict(trigger_config))
per_entity = {
_LEGACY_TO_PER_ENTITY_KEY[k]: v for k, v in legacy.items() if k in _LEGACY_TO_PER_ENTITY_KEY and v is not None
}
@@ -158,6 +162,14 @@ class MaintenanceStore:
if len(kept) != len(history):
_LOGGER.warning("Dropping %d malformed history entries for task %s", len(history) - len(kept), tid)
state["history"] = kept
# A non-string timestamp (an epoch number from an imported
# backup) broke every "latest entry" comparison — completing
# the task raised TypeError (bug audit 2026-09-27). Epoch
# numbers become ISO stamps, anything else is dropped.
fixed = [_coerce_history_timestamp(h) for h in state["history"]]
if any(a is not b for a, b in zip(fixed, state["history"], strict=True)):
_LOGGER.warning("Repaired malformed history timestamps for task %s", tid)
state["history"] = fixed
if "trigger_runtime" in state:
runtime = state["trigger_runtime"]
kept_rt = {eid: rt for eid, rt in runtime.items() if isinstance(rt, dict)}
@@ -493,6 +505,23 @@ class MaintenanceStore:
# ------------------------------------------------------------------
def _coerce_history_timestamp(entry: dict[str, Any]) -> dict[str, Any]:
"""``entry`` unchanged when its timestamp is a string (or absent); a copy
with an epoch number turned into a UTC ISO stamp, or without the key when
the value is anything else."""
ts = entry.get("timestamp")
if ts is None or isinstance(ts, str):
return entry
fixed = dict(entry)
fixed.pop("timestamp")
if isinstance(ts, (int, float)) and not isinstance(ts, bool):
try:
fixed["timestamp"] = datetime.fromtimestamp(ts, tz=UTC).isoformat()
except (OverflowError, OSError, ValueError):
pass
return fixed
def reanchor_from_history(store: MaintenanceStore, task_id: str, history: list[dict[str, Any]]) -> str | None:
"""Re-derive a task's ``last_performed`` from its history and write it.
@@ -504,7 +533,16 @@ def reanchor_from_history(store: MaintenanceStore, task_id: str, history: list[d
editing a note on the last completion keeps a postponed occurrence,
deleting that completion abandons it. Returns the new anchor.
"""
latest = max((h.get("timestamp") or "" for h in history if h.get("type") in LIFECYCLE_HISTORY_TYPES), default="")
# String stamps only: an imported epoch number made max() compare int
# with str and the history edit / delete raised (bug audit 2026-09-27).
latest = max(
(
stamp
for h in history
if isinstance(h, dict) and h.get("type") in LIFECYCLE_HISTORY_TYPES and isinstance(stamp := h.get("timestamp"), str)
),
default="",
)
anchor = local_date_from_iso(latest) if latest else None # the stamp's local calendar day
store.set_anchor(task_id, anchor, clear_modifiers=anchor != store.get_last_performed(task_id))
return anchor