361 files

This commit is contained in:
Home Assistant Version Control committed 2026-10-06 17:11:43 +00:00
1 parent d2c5af43e3
commit bf886c9347
362 files changed
+22060 -1285

No files matched your search

@@ -139,11 +139,15 @@ def _rewrite_action(action: dict[str, Any], old_id: str, new_id: str) -> tuple[d
def rewrite_object(obj: dict[str, Any], old_id: str, new_id: str) -> tuple[dict[str, Any], bool]:
"""Rewrite the battery fleet's manual include / exclude lists (#135) —
a renamed battery sensor fell out of the fleet (include) or came back
into it (exclude) after an entity rename (bug audit 2026-09-27)."""
from ..const import BATTERY_FLEET_EXCLUDED, BATTERY_FLEET_INCLUDED
into it (exclude) after an entity rename (bug audit 2026-09-27) — and
the object's place (2026-10): a renamed zone keeps its objects."""
from ..const import BATTERY_FLEET_EXCLUDED, BATTERY_FLEET_INCLUDED, CONF_OBJECT_PLACE
new_obj = dict(obj)
changed = False
if new_obj.get(CONF_OBJECT_PLACE) == old_id and new_id.startswith("zone."):
new_obj[CONF_OBJECT_PLACE] = new_id
changed = True
for key in (BATTERY_FLEET_INCLUDED, BATTERY_FLEET_EXCLUDED):
ids = new_obj.get(key)
if isinstance(ids, list) and old_id in ids:
@@ -52,6 +52,7 @@ from .notification_ids import (
budget_tag,
bundle_tag,
panel_url,
place_tag,
task_tag,
)
from .notify_hooks import (
@@ -59,6 +60,7 @@ from .notify_hooks import (
KIND_BUNDLE,
KIND_DIGEST,
KIND_LEAD_TIME,
KIND_PLACE_ARRIVAL,
KIND_QUIET_END,
KIND_STATUS,
KIND_WARRANTY,
@@ -116,6 +118,8 @@ _NOTIFICATION_STRINGS: dict[str, dict[str, str]] = {
"action_snooze": "Später",
"bundled_title": "Wartung: {count} Aufgaben",
"bundled_message": "{object}: {task_list}",
"place_arrival_title": "Wartung vor Ort: {place}",
"place_arrival_message": "Hier fällig: {task_list}",
"quiet_end_title": "{count} Erinnerungen aus der Ruhezeit",
"digest_title": "Wöchentliche Wartungsübersicht",
"digest_message": "{overdue} überfällig, {due_soon} diese Woche fällig.",
@@ -156,6 +160,8 @@ _NOTIFICATION_STRINGS: dict[str, dict[str, str]] = {
"action_snooze": "Later",
"bundled_title": "Onderhoud: {count} taken",
"bundled_message": "{object}: {task_list}",
"place_arrival_title": "Onderhoud ter plaatse: {place}",
"place_arrival_message": "Hier te doen: {task_list}",
"quiet_end_title": "{count} herinneringen uit de stille uren",
"digest_title": "Wekelijks onderhoudsoverzicht",
"digest_message": "{overdue} achterstallig, {due_soon} deze week.",
@@ -196,6 +202,8 @@ _NOTIFICATION_STRINGS: dict[str, dict[str, str]] = {
"action_snooze": "Reporter",
"bundled_title": "Maintenance : {count} tâches",
"bundled_message": "{object} : {task_list}",
"place_arrival_title": "Entretien sur place : {place}",
"place_arrival_message": "À faire ici : {task_list}",
"quiet_end_title": "{count} rappels des heures calmes",
"digest_title": "Récapitulatif hebdomadaire d'entretien",
"digest_message": "{overdue} en retard, {due_soon} cette semaine.",
@@ -236,6 +244,8 @@ _NOTIFICATION_STRINGS: dict[str, dict[str, str]] = {
"action_snooze": "Posticipa",
"bundled_title": "Manutenzione: {count} attività",
"bundled_message": "{object}: {task_list}",
"place_arrival_title": "Manutenzione sul posto: {place}",
"place_arrival_message": "Da fare qui: {task_list}",
"quiet_end_title": "{count} promemoria dalle ore di silenzio",
"digest_title": "Riepilogo settimanale manutenzione",
"digest_message": "{overdue} scadute, {due_soon} questa settimana.",
@@ -276,6 +286,8 @@ _NOTIFICATION_STRINGS: dict[str, dict[str, str]] = {
"action_snooze": "Posponer",
"bundled_title": "Mantenimiento: {count} tareas",
"bundled_message": "{object}: {task_list}",
"place_arrival_title": "Mantenimiento en el lugar: {place}",
"place_arrival_message": "Pendiente aquí: {task_list}",
"quiet_end_title": "{count} recordatorios de las horas de silencio",
"digest_title": "Resumen semanal de mantenimiento",
"digest_message": "{overdue} vencidas, {due_soon} esta semana.",
@@ -316,6 +328,8 @@ _NOTIFICATION_STRINGS: dict[str, dict[str, str]] = {
"action_snooze": "Snooze",
"bundled_title": "Maintenance: {count} tasks",
"bundled_message": "{object}: {task_list}",
"place_arrival_title": "Maintenance at {place}",
"place_arrival_message": "Due here: {task_list}",
"quiet_end_title": "{count} reminders held during quiet hours",
"digest_title": "Weekly maintenance digest",
"digest_message": "{overdue} overdue, {due_soon} due this week.",
@@ -356,6 +370,8 @@ _NOTIFICATION_STRINGS: dict[str, dict[str, str]] = {
"action_snooze": "Senere",
"bundled_title": "Vedligeholdelse: {count} opgaver",
"bundled_message": "{object}: {task_list}",
"place_arrival_title": "Vedligeholdelse på stedet: {place}",
"place_arrival_message": "Skal gøres her: {task_list}",
"quiet_end_title": "{count} påmindelser fra de stille timer",
"digest_title": "Ugentlig vedligeholdelsesoversigt",
"digest_message": "{overdue} forfaldne, {due_soon} denne uge.",
@@ -396,6 +412,8 @@ _NOTIFICATION_STRINGS: dict[str, dict[str, str]] = {
"action_snooze": "Myöhemmin",
"bundled_title": "Huolto: {count} tehtävää",
"bundled_message": "{object}: {task_list}",
"place_arrival_title": "Huolto paikan päällä: {place}",
"place_arrival_message": "Tehtävänä täällä: {task_list}",
"quiet_end_title": "{count} muistutusta hiljaisilta tunneilta",
"digest_title": "Viikoittainen huoltokooste",
"digest_message": "{overdue} myöhässä, {due_soon} tällä viikolla.",
@@ -436,6 +454,8 @@ _NOTIFICATION_STRINGS: dict[str, dict[str, str]] = {
"action_snooze": "Senere",
"bundled_title": "Vedlikehold: {count} oppgaver",
"bundled_message": "{object}: {task_list}",
"place_arrival_title": "Vedlikehold på stedet: {place}",
"place_arrival_message": "Skal gjøres her: {task_list}",
"quiet_end_title": "{count} påminnelser fra de stille timene",
"digest_title": "Ukentlig vedlikeholdsoversikt",
"digest_message": "{overdue} forfalt, {due_soon} denne uken.",
@@ -476,6 +496,8 @@ _NOTIFICATION_STRINGS: dict[str, dict[str, str]] = {
"action_snooze": "後で",
"bundled_title": "メンテナンス: {count} 件のタスク",
"bundled_message": "{object}: {task_list}",
"place_arrival_title": "現地のメンテナンス: {place}",
"place_arrival_message": "ここでの作業: {task_list}",
"quiet_end_title": "静音時間中の{count}件のリマインダー",
"digest_title": "週間メンテナンスまとめ",
"digest_message": "期限切れ {overdue} 件、今週 {due_soon} 件。",
@@ -516,6 +538,8 @@ _NOTIFICATION_STRINGS: dict[str, dict[str, str]] = {
"action_snooze": "बाद में",
"bundled_title": "रखरखाव: {count} कार्य",
"bundled_message": "{object}: {task_list}",
"place_arrival_title": "मौके पर रखरखाव: {place}",
"place_arrival_message": "यहाँ करने के काम: {task_list}",
"quiet_end_title": "शांत घंटों के {count} अनुस्मारक",
"digest_title": "साप्ताहिक रखरखाव सारांश",
"digest_message": "{overdue} अतिदेय, {due_soon} इस सप्ताह।",
@@ -556,6 +580,8 @@ _NOTIFICATION_STRINGS: dict[str, dict[str, str]] = {
"action_snooze": "稍后提醒",
"bundled_title": "维护:共有 {count} 项任务",
"bundled_message": "{object}: {task_list}",
"place_arrival_title": "现场维护:{place}",
"place_arrival_message": "此处待办:{task_list}",
"quiet_end_title": "静音时段积累的 {count} 条提醒",
"digest_title": "每周维护摘要",
"digest_message": "逾期 {overdue} 项,本周 {due_soon} 项。",
@@ -596,6 +622,8 @@ _NOTIFICATION_STRINGS: dict[str, dict[str, str]] = {
"action_snooze": "Отложить",
"bundled_title": "Обслуживание: {count} задач",
"bundled_message": "{object}: {task_list}",
"place_arrival_title": "Обслуживание на месте: {place}",
"place_arrival_message": "Здесь нужно сделать: {task_list}",
"quiet_end_title": "{count} напоминаний за тихие часы",
"digest_title": "Еженедельная сводка обслуживания",
"digest_message": "{overdue} просрочено, {due_soon} на этой неделе.",
@@ -636,6 +664,8 @@ _NOTIFICATION_STRINGS: dict[str, dict[str, str]] = {
"action_snooze": "Відкласти",
"bundled_title": "Обслуговування: {count} завдань",
"bundled_message": "{object}: {task_list}",
"place_arrival_title": "Обслуговування на місці: {place}",
"place_arrival_message": "Тут потрібно зробити: {task_list}",
"quiet_end_title": "{count} нагадувань за тихі години",
"digest_title": "Щотижневий огляд обслуговування",
"digest_message": "{overdue} прострочено, {due_soon} цього тижня.",
@@ -676,6 +706,8 @@ _NOTIFICATION_STRINGS: dict[str, dict[str, str]] = {
"action_snooze": "Adiar",
"bundled_title": "Manutenção: {count} tarefas",
"bundled_message": "{object}: {task_list}",
"place_arrival_title": "Manutenção no local: {place}",
"place_arrival_message": "A fazer aqui: {task_list}",
"quiet_end_title": "{count} lembretes das horas de silêncio",
"digest_title": "Resumo semanal de manutenção",
"digest_message": "{overdue} atrasadas, {due_soon} esta semana.",
@@ -718,6 +750,8 @@ _NOTIFICATION_STRINGS: dict[str, dict[str, str]] = {
"action_snooze": "Drzemka",
"bundled_title": "Konserwacja: {count} zadań",
"bundled_message": "{object}: {task_list}",
"place_arrival_title": "Konserwacja na miejscu: {place}",
"place_arrival_message": "Do zrobienia tutaj: {task_list}",
"quiet_end_title": "{count} przypomnień z godzin ciszy",
"digest_title": "Cotygodniowe podsumowanie konserwacji",
"digest_message": "{overdue} zaległych, {due_soon} w tym tygodniu.",
@@ -760,6 +794,8 @@ _NOTIFICATION_STRINGS: dict[str, dict[str, str]] = {
"action_snooze": "Odložit",
"bundled_title": "Údržba: {count} úkolů",
"bundled_message": "{object}: {task_list}",
"place_arrival_title": "Údržba na místě: {place}",
"place_arrival_message": "Zde je potřeba udělat: {task_list}",
"quiet_end_title": "{count} připomínek z tichých hodin",
"digest_title": "Týdenní přehled údržby",
"digest_message": "{overdue} po termínu, {due_soon} tento týden.",
@@ -801,6 +837,8 @@ _NOTIFICATION_STRINGS: dict[str, dict[str, str]] = {
"action_snooze": "Snooza",
"bundled_title": "Underhåll: {count} uppgifter",
"bundled_message": "{object}: {task_list}",
"place_arrival_title": "Underhåll på plats: {place}",
"place_arrival_message": "Att göra här: {task_list}",
"quiet_end_title": "{count} påminnelser från de tysta timmarna",
"digest_title": "Veckovis underhållssammanfattning",
"digest_message": "{overdue} försenade, {due_soon} denna vecka.",
@@ -841,6 +879,8 @@ _NOTIFICATION_STRINGS: dict[str, dict[str, str]] = {
"action_snooze": "Adiar",
"bundled_title": "Manutenção: {count} tarefas",
"bundled_message": "{object}: {task_list}",
"place_arrival_title": "Manutenção no local: {place}",
"place_arrival_message": "Pendente aqui: {task_list}",
"quiet_end_title": "{count} lembretes das horas de silêncio",
"digest_title": "Resumo semanal de manutenção",
"digest_message": "{overdue} atrasadas, {due_soon} vencem nesta semana.",
@@ -881,6 +921,8 @@ _NOTIFICATION_STRINGS: dict[str, dict[str, str]] = {
"action_snooze": "Halasztás",
"bundled_title": "Karbantartás: {count} feladat",
"bundled_message": "{object}: {task_list}",
"place_arrival_title": "Karbantartás a helyszínen: {place}",
"place_arrival_message": "Itt esedékes: {task_list}",
"quiet_end_title": "{count} emlékeztető a csendes órákból",
"digest_title": "Heti karbantartási összefoglaló",
"digest_message": "{overdue} lejárt, {due_soon} esedékes ezen a héten.",
@@ -921,6 +963,8 @@ _NOTIFICATION_STRINGS: dict[str, dict[str, str]] = {
"action_snooze": "미루기",
"bundled_title": "유지보수: 작업 {count}개",
"bundled_message": "{object}: {task_list}",
"place_arrival_title": "현장 유지보수: {place}",
"place_arrival_message": "여기서 할 일: {task_list}",
"quiet_end_title": "방해 금지 시간 동안 쌓인 알림 {count}건",
"digest_title": "주간 유지보수 요약",
"digest_message": "기한 초과 {overdue}건, 이번 주 예정 {due_soon}건.",
@@ -961,6 +1005,8 @@ _NOTIFICATION_STRINGS: dict[str, dict[str, str]] = {
"action_snooze": "Ertele",
"bundled_title": "Bakım: {count} görev",
"bundled_message": "{object}: {task_list}",
"place_arrival_title": "Yerinde bakım: {place}",
"place_arrival_message": "Burada yapılacaklar: {task_list}",
"quiet_end_title": "Sessiz saatlerden {count} hatırlatma",
"digest_title": "Haftalık bakım özeti",
"digest_message": "{overdue} gecikmiş, {due_soon} bu hafta yapılacak.",
@@ -2062,6 +2108,82 @@ class NotificationManager:
_LOGGER.exception("Failed to send notification to %s", service)
return False
async def async_place_arrival(
self,
*,
user_id: str,
zone_id: str,
place_name: str,
tasks: list[dict[str, Any]],
) -> bool:
"""2026-10 places: what is due at ``place_name``, to the person who
just arrived there (helpers/places.PlaceArrivals). Only that person's
own phones, never the household service: nobody else is there. Not
held through quiet hours (the place's reminders follow once they
end). Each task passes the kind's per-task gates (mute, snooze).
True when it went out."""
self.async_verify_configured_service()
if not self.enabled or self._is_quiet_hours():
return False
due = [
t
for t in tasks
if task_may_notify(
self.hass, t["entry_id"], t["task_id"], str(t["status"]), t.get("task_data") or {}, kind=KIND_PLACE_ARRIVAL, manager=self
)
]
if not due:
return False
targets: list[str] = await get_user_notify_services(self.hass, user_id)
if not targets:
if not self.event_only:
_LOGGER.debug("Place arrival at %s: user %s has no phone to tell", zone_id, user_id)
return False
targets = [""] # #173: the event IS the delivery
lang = self._lang
by_object: dict[str, list[str]] = {}
for t in due:
by_object.setdefault(str(t["object_name"]), []).append(_bundled_line(t["status"], lang, str(t["task_name"])))
task_list = "; ".join(f"{name}: {', '.join(lines)}" for name, lines in by_object.items())
entry_ids = sorted({str(t["entry_id"]) for t in due})
single = entry_ids[0] if len(entry_ids) == 1 else None
service_data = _service_payload(
_notif_t("place_arrival_title", lang, place=place_name),
_notif_t("place_arrival_message", lang, task_list=task_list),
tag=place_tag(zone_id),
url=panel_url(entry_id=single),
)
context = notification_context(
self.hass,
KIND_PLACE_ARRIVAL,
entry_id=single,
responsible_user_id=user_id,
tasks=[
{"entry_id": t["entry_id"], "task_id": t["task_id"], "task_name": t["task_name"], "status": str(t["status"])}
for t in due
],
place=zone_id,
place_name=place_name,
)
sent = False
for target in targets:
try:
if await async_emit_and_dispatch(self.hass, target, service_data, context):
sent = True
except Exception: # noqa: BLE001 — one phone failing must not stop the others
_LOGGER.warning("Place arrival message to %s failed", target or "the event", exc_info=True)
if sent:
from .places import reminds_on_site
# For an object whose reminders wait for somebody on site this
# message IS the visit's reminder: unstamped, the held reminders
# followed on the next refresh, to everybody (at home too).
for t in due:
if reminds_on_site(self.hass, t["entry_id"]):
status = str(t["status"])
self._stamp_status_sent(notification_key(t["entry_id"], t["task_id"], status), self._get_interval_hours(status))
return sent
async def async_send_bundled(
self,
entry_id: str,
@@ -209,7 +209,11 @@ def stash_task_config_for_readopt(hass: HomeAssistant, task: dict[str, Any]) ->
tc = task.get("trigger_config")
if not isinstance(tc, dict) or not tc.get("auto_complete_on_recovery"):
return
entity_ids = tc.get("entity_ids") or []
from ..entity.triggers import normalize_entity_ids
# Either stored shape: a task with only the legacy ``entity_id`` lost its
# notes on un-adopt (same-class audit 2026-10-04).
entity_ids = normalize_entity_ids(tc)
config = {k: task[k] for k in _STASHED_TASK_FIELDS if task.get(k) and (not isinstance(task[k], str) or task[k].strip())}
# "normal" priority is the default — not worth resurrecting on its own.
if config.get("priority") == "normal":
@@ -80,6 +80,7 @@ _OBJECT_STR_LIMITS: dict[str, int] = {
"model": MAX_META_LENGTH,
"serial_number": MAX_META_LENGTH,
"area_id": MAX_META_LENGTH,
"place": MAX_META_LENGTH, # 2026-10: an HA zone (helpers/places.py)
"installation_date": MAX_DATE_LENGTH,
"warranty_expiry": MAX_DATE_LENGTH, # (#67)
"documentation_url": MAX_URL_LENGTH, # v1.4.0 #43
@@ -543,9 +544,26 @@ def cap_object_fields(obj_data: dict[str, Any]) -> dict[str, Any]:
"""Truncate user-controllable strings on an object dict in-place."""
_cap_strings(obj_data, _OBJECT_STR_LIMITS)
_drop_unsafe_url(obj_data)
_normalize_place(obj_data)
return obj_data
def _normalize_place(data: dict[str, Any]) -> None:
"""2026-10 places, on every create, edit and import path: a zone's entity
id or nothing (home — also for ``zone.home`` and anything that is no
zone), and ``remind_on_site`` only as a real True next to a place."""
from .places import normalize_place
if "place" in data:
place = normalize_place(data.get("place"))
if place is None:
data.pop("place")
else:
data["place"] = place
if "remind_on_site" in data and not (data.get("remind_on_site") is True and data.get("place")):
data.pop("remind_on_site")
def _drop_unsafe_url(data: dict[str, Any]) -> None:
"""Drop a ``documentation_url`` that is not http(s)/path-relative —
every create and edit path runs through the cap helpers, so this is the
@@ -40,6 +40,7 @@ from ..const import (
CONF_ADVANCED_COMPLETION_ACTIONS,
CONF_ADVANCED_ENVIRONMENTAL,
CONF_ADVANCED_GROUPS,
CONF_ADVANCED_PLACES,
CONF_ADVANCED_SCHEDULE_TIME,
CONF_ADVANCED_SEASONAL,
CONF_ARCHIVE_ONEOFF_DAYS,
@@ -171,6 +172,7 @@ SETTING_SPECS: tuple[SettingSpec, ...] = (
SettingSpec(CONF_ADVANCED_CHECKLISTS, bool, False),
SettingSpec(CONF_ADVANCED_SCHEDULE_TIME, bool, False),
SettingSpec(CONF_ADVANCED_COMPLETION_ACTIONS, bool, False),
SettingSpec(CONF_ADVANCED_PLACES, bool, False),
# Governance (list elements sanitised by a bespoke rule in the handler)
SettingSpec(CONF_ADMIN_PANEL_USER_IDS, list, []),
SettingSpec(CONF_OPERATOR_WRITE_ENABLED, bool, False),
@@ -49,6 +49,30 @@ def _aggregate(per_entity: list[bool], entity_logic: str) -> bool:
return all(per_entity) if entity_logic == "all" else any(per_entity)
def _aggregate_known(per_entity: list[bool | None], entity_logic: str) -> bool | None:
"""The verdict of entities that may lack a reading (``None``).
Three-valued, so a sensor that dropped out never decides: "any" is True
as soon as one entity reached, False only when every entity reported and
none reached; "all" is False as soon as one entity reported not reaching,
True only when every entity reported and all reached. Anything else is
None, no verdict: the event-driven latch stays as it is (base_trigger
keeps an entity's last state through an outage). A counter that dropped
out read as "not reached" ended an episode its sensor never left: the
task flipped to OK and back on every refresh, and the next restart
announced the same activation again (live installation 2026-10-04).
"""
known = [verdict for verdict in per_entity if verdict is not None]
complete = bool(per_entity) and len(known) == len(per_entity)
if entity_logic == "all":
if False in known:
return False
return True if complete else None
if True in known:
return True
return False if complete else None
def threshold_exceeds(
value: float,
*,
@@ -118,28 +142,28 @@ def evaluate_threshold(
if not entity_ids:
return FallbackResult(current_value=None, active=False)
per_entity: list[bool] = []
per_entity: list[bool | None] = []
last_value: float | None = None
for eid in entity_ids:
value = _numeric_entity_value(get_state, eid, attribute)
if value is None:
per_entity.append(False)
per_entity.append(None) # no reading: no verdict for this entity
continue
last_value = value
per_entity.append(threshold_exceeds(value, above=above, below=below, equals=equals, not_equals=not_equals))
aggregated = _aggregate(per_entity, entity_logic) if per_entity else False
# Only READINGS decide. base_trigger deliberately keeps the latch through
# unavailable blips ("unavailable carries no measurement"); this sweep
# used to overrule it: a 90 s sensor dropout inside the 5-min window
# flipped a latched task OK and back, firing state automations twice
# (bug audit 2026-08-22). Per entity since 2026-10-04: one dropped-out
# entity of several no longer counts as "not exceeded" either.
aggregated = _aggregate_known(per_entity, entity_logic)
active: bool | None
if for_minutes == 0:
# Only assert a verdict when at least one entity produced a READING.
# base_trigger deliberately keeps the latch through unavailable blips
# ("unavailable carries no measurement"); this sweep used to overrule
# it — a 90 s sensor dropout inside the 5-min window flipped a latched
# task OK and back, firing state automations twice (bug audit
# 2026-08-22). Mirrors the guard the for_minutes>0 branch always had.
active = aggregated if last_value is not None else None
elif not aggregated and last_value is not None:
active = aggregated
elif aggregated is False:
# Back in the normal range — safe to deactivate even with for_minutes.
active = False
else:
@@ -221,12 +245,14 @@ def evaluate_counter(
target = trigger_config.get("trigger_target_value", 0) # pragma: no mutate (WS-required; default only guards hand-edited data)
delta_mode = trigger_config.get("trigger_delta_mode", False)
per_entity: list[bool] = []
per_entity: list[bool | None] = []
last_value: float | None = None
for eid in entity_ids:
value = _numeric_entity_value(get_state, eid, attribute)
if value is None:
per_entity.append(False)
# No reading, no verdict: a dropped-out counter is not "below
# target" (live installation 2026-10-04, see _aggregate_known).
per_entity.append(None)
continue
last_value = value
if delta_mode:
@@ -240,8 +266,7 @@ def evaluate_counter(
if reading is not None:
last_value = reading
active = _aggregate(per_entity, entity_logic) if per_entity else None
return FallbackResult(current_value=last_value, active=active)
return FallbackResult(current_value=last_value, active=_aggregate_known(per_entity, entity_logic))
def evaluate_state_change(
@@ -298,22 +323,20 @@ def evaluate_due_date(
entity_logic = trigger_config.get("entity_logic", "any")
days_before = float(trigger_config.get("trigger_days_before") or 0)
now = now or dt_util.utcnow()
per_entity: list[bool] = []
per_entity: list[bool | None] = []
soonest: float | None = None
for eid in entity_ids:
state = get_state(eid)
if state is None or state.state in UNAVAILABLE_STATES:
continue
due = due_instant(state.attributes.get(attribute) if attribute else state.state)
due = None if state is None or state.state in UNAVAILABLE_STATES else due_instant(state.attributes.get(attribute) if attribute else state.state)
if due is None:
per_entity.append(None) # no readable date: no verdict for this entity
continue
left = days_until(due, now)
soonest = left if soonest is None else min(soonest, left)
per_entity.append(left <= days_before)
if not per_entity:
# No readable date — keep the event-driven latch as it is.
return FallbackResult(current_value=None, active=None)
return FallbackResult(current_value=soonest, active=_aggregate(per_entity, entity_logic))
# No readable date at all, or too few to decide: the event-driven latch
# stays as it is (_aggregate_known).
return FallbackResult(current_value=soonest, active=_aggregate_known(per_entity, entity_logic))
def evaluate_runtime(