361 files
This commit is contained in:
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(
|
||||
|
||||
Reference in new issue
Block a user