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
@@ -34,16 +34,26 @@ from ..const import (
CONF_TASKS,
DEFAULT_BUDGET_CURRENCY,
DEFAULT_CURRENCY_DECIMALS,
DEFAULT_TASK_PRIORITY,
DOMAIN,
NOTIFIABLE_STATUSES,
NOTIFICATION_TITLE_STYLES,
MaintenanceStatus,
TaskPriority,
)
from .global_options import get_global_options
from .i18n import normalize_language
from .aggregate import priority_rank
from .global_options import get_global_options, global_option
from .i18n import format_text, normalize_language
from .notification_gates import STATUS_ENABLED_KEYS, status_reminder_enabled, task_may_notify
from .notification_ids import (
DIGEST_TAG,
PANEL_PATH,
QUIET_END_TAG,
WARRANTY_TAG,
action_id,
budget_tag,
bundle_tag,
panel_url,
task_tag,
)
from .notify_hooks import (
KIND_BUDGET,
KIND_BUNDLE,
@@ -75,6 +85,14 @@ _NOTIFY_SERVICE_MISSING_ISSUE_ID = "notify_service_missing"
# adds noise; the sentinel is a singleton, not a real timestamp.
_SENT_ONCE = datetime.max # noqa: DTZ901 - intentional naive sentinel, see comment above
# A "notify once" status re-arms when the task leaves it — a threshold that
# recovered without a completion, an overdue task that was postponed — so the
# next time it gets there it is announced again (bug audit 2026-09-26: the
# freezer alarm fired once, ever). The stamp then holds the re-arm time and
# a new push waits at least this long: a sensor flapping around its limit
# cannot turn "once" into a stream.
_REARM_MIN_SECONDS = 3600
# The manager's bookkeeping survives a restart (2026-09-13): what was sent when
# (incl. the "once" marks), snoozes, the daily counter, the lead dedup, the
# fairness sets and the reminders quiet hours are holding. Without it every
@@ -973,7 +991,19 @@ _NOTIFICATION_STRINGS: dict[str, dict[str, str]] = {
}
PRIORITY_RANK: dict[str, int] = {TaskPriority.HIGH: 0, TaskPriority.NORMAL: 1, TaskPriority.LOW: 2}
# The line of one task inside a bundled / held-reminder summary, per status
# (unknown statuses read as due soon). The bundle and the quiet-hours summary
# each spelled this map (DRY audit 2026-09-26 B).
_BUNDLED_LINE_KEYS: dict[str, str] = {
MaintenanceStatus.OVERDUE: "bundled_overdue",
MaintenanceStatus.DUE_SOON: "bundled_due_soon",
MaintenanceStatus.TRIGGERED: "bundled_triggered",
}
def _bundled_line(status: Any, lang: str, task_name: str) -> str:
"""One task's line in a bundle / quiet-hours summary."""
return _notif_t(_BUNDLED_LINE_KEYS.get(str(status or ""), "bundled_due_soon"), lang, task=task_name)
def task_key_of(entry_id: str, task_id: str) -> str:
@@ -981,7 +1011,7 @@ def task_key_of(entry_id: str, task_id: str) -> str:
return f"{entry_id}_{task_id}"
def _service_payload(title: str, message: str, *, tag: str, url: str = "/maintenance-supporter") -> dict[str, Any]:
def _service_payload(title: str, message: str, *, tag: str, url: str = PANEL_PATH) -> dict[str, Any]:
"""Notify payload with the deep link doubled into ``url`` (iOS) and
``clickAction`` (Android) — five hand-built copies each repeated the
link string twice."""
@@ -1016,7 +1046,7 @@ def build_action_buttons(
is_test = entry_id is None or task_id is None
def _id(verb: str) -> str:
return f"MS_TEST_{verb}" if is_test else f"MS_{verb}_{entry_id}_{task_id}"
return action_id(verb, None, None) if is_test else action_id(verb, entry_id, task_id)
actions: list[dict[str, str]] = []
if options.get(CONF_ACTION_COMPLETE_ENABLED, setting_default(CONF_ACTION_COMPLETE_ENABLED)):
@@ -1029,13 +1059,10 @@ def build_action_buttons(
def _notif_t(key: str, lang: str, **kwargs: str) -> str:
"""Get notification translation string."""
strings = _NOTIFICATION_STRINGS.get(lang, _NOTIFICATION_STRINGS["en"])
text = strings.get(key, _NOTIFICATION_STRINGS["en"].get(key, key))
if kwargs:
safe_kwargs = {k: str(v).replace("{", "{{").replace("}", "}}") for k, v in kwargs.items()}
text = text.format(**safe_kwargs)
return text
"""Get notification translation string (the shared i18n.format_text —
values go in verbatim; the brace doubling this copy did put "{{A}}" on
phones, bug audit 2026-09-26)."""
return format_text(_NOTIFICATION_STRINGS, lang, key, **kwargs)
async def get_user_notify_services(hass: HomeAssistant, user_id: str) -> list[str]:
@@ -1201,13 +1228,37 @@ class NotificationManager:
def _status_due(self, key: str, interval_hours: int) -> bool:
"""Whether a status notification for ``key`` is due now: never sent,
or the repeat interval has elapsed. A "notify once" status (interval
0) that was sent is never due again until the state is cleared."""
0) that was sent is not due again while the task stays in it; once
the task left the status (``rearm_left_statuses``) it is due again
after ``_REARM_MIN_SECONDS``."""
last = self._last_notified.get(key)
if last is None:
return True
if last == _SENT_ONCE:
return False
return not (interval_hours > 0 and (dt_util.now() - last).total_seconds() < interval_hours * 3600)
min_seconds = interval_hours * 3600 if interval_hours > 0 else _REARM_MIN_SECONDS
return (dt_util.now() - last).total_seconds() >= min_seconds
def rearm_left_statuses(self, entry_id: str, task_id: str, current_status: str | None) -> None:
"""Re-arm the "notify once" stamps of the statuses the task is no
longer in. Checked on every refresh (not on the transition), so a
status left while Home Assistant was down re-arms too."""
changed = False
for status in NOTIFIABLE_STATUSES:
if status == current_status:
continue
key = notification_key(entry_id, task_id, status)
if self._last_notified.get(key) == _SENT_ONCE:
self._last_notified[key] = dt_util.now()
changed = True
# A reminder held for the quiet-hours summary is stale once the
# task left that status (postponed, completed from elsewhere,
# the sensor recovered): the morning summary announced "overdue"
# for a task that no longer was (bug audit 2026-09-27).
if self._quiet_held.pop(key, None) is not None:
changed = True
if changed:
self._dirty()
def _stamp_status_sent(self, key: str, interval_hours: int) -> None:
self._last_notified[key] = _SENT_ONCE if interval_hours == 0 else dt_util.now()
@@ -1302,7 +1353,7 @@ class NotificationManager:
def _opt(self, key: str) -> Any:
"""A global setting, or its registry default when unset."""
return self._global_options.get(key, setting_default(key))
return global_option(self.hass, key)
@property
def enabled(self) -> bool:
@@ -1556,18 +1607,13 @@ class NotificationManager:
return False
held = list(self._quiet_held.values())
lang = self._lang
status_key_map: dict[str, str] = {
MaintenanceStatus.OVERDUE: "bundled_overdue",
MaintenanceStatus.DUE_SOON: "bundled_due_soon",
MaintenanceStatus.TRIGGERED: "bundled_triggered",
}
parts: list[str] = []
for f in held:
line = _notif_t(status_key_map.get(str(f.get("status") or ""), "bundled_due_soon"), lang, task=str(f.get("task_name") or ""))
line = _bundled_line(f.get("status"), lang, str(f.get("task_name") or ""))
parts.append(f"{f.get('object_name') or ''}: {line}" if f.get("object_name") else line)
title = _notif_t("quiet_end_title", lang, count=str(len(held)))
message = "; ".join(parts)
service_data = _service_payload(title, message, tag="maintenance_quiet_end", url="/maintenance-supporter?tab=today")
service_data = _service_payload(title, message, tag=QUIET_END_TAG, url=panel_url(tab="today"))
context = notification_context(
self.hass,
KIND_QUIET_END,
@@ -1604,7 +1650,7 @@ class NotificationManager:
@staticmethod
def _priority_rank(task_data: Mapping[str, Any] | None) -> int:
"""0 = high, 1 = normal, 2 = low (unknown reads as normal)."""
return PRIORITY_RANK.get(str((task_data or {}).get(CONF_TASK_PRIORITY) or DEFAULT_TASK_PRIORITY), 1)
return priority_rank((task_data or {}).get(CONF_TASK_PRIORITY))
def _admit(self, keys: list[str], rank: int) -> bool:
"""The daily-limit decision for a task (or a bundle's member tasks).
@@ -1843,13 +1889,8 @@ class NotificationManager:
if actor_name:
who = _notif_t("completed_by", lang, name=actor_name)
reason = _notif_t(f"reason_{src}" if f"reason_{src}" in _NOTIFICATION_STRINGS["en"] else "reason_unknown", lang)
title = _notif_t("completed_title", lang)
title = self._styled_title(_notif_t("completed_title", lang), task_name, object_name)
message = _notif_t("completed_message", lang, task=task_name, object=object_name, reason=reason, who=who)
style = self.title_style
if style == "object_name" and object_name:
title = object_name
elif style == "task_name" and task_name:
title = task_name
context = notification_context(
self.hass,
KIND_COMPLETED,
@@ -1865,7 +1906,7 @@ class NotificationManager:
# Same payload shape as every other kind: the per-task tag (the
# completion replaces the task's still-standing reminder on the phone)
# and the deep link the context already carries.
service_data = _service_payload(title, message, tag=f"maintenance_{task_id}", url=str(context["url"]))
service_data = _service_payload(title, message, tag=task_tag(task_id), url=str(context["url"]))
sent = await async_emit_and_dispatch(self.hass, self.notify_service, service_data, context)
if sent:
self._daily_count += 1
@@ -1913,15 +1954,18 @@ class NotificationManager:
title = "Maintenance"
message = f"{task_name} ({object_name})"
# v1.4.0 (#44): override title with object/task name so phone
# notification stacks remain distinguishable at a glance.
return self._styled_title(title, task_name, object_name), message
def _styled_title(self, title: str, task_name: str, object_name: str) -> str:
"""v1.4.0 (#44): the configured title style — the object or task name
instead of the generic title, so phone notification stacks stay
distinguishable at a glance. One place for every per-task push."""
style = self.title_style
if style == "object_name" and object_name:
title = object_name
elif style == "task_name" and task_name:
title = task_name
return title, message
return object_name
if style == "task_name" and task_name:
return task_name
return title
async def _async_send_notification_to_service(
self,
@@ -1957,8 +2001,8 @@ class NotificationManager:
service_data = _service_payload(
title,
message,
tag=f"maintenance_{task_id}",
url=f"/maintenance-supporter?entry_id={entry_id}&task_id={task_id}",
tag=task_tag(task_id),
url=panel_url(entry_id=entry_id, task_id=task_id),
)
if actions:
service_data["data"]["actions"] = actions
@@ -2027,16 +2071,7 @@ class NotificationManager:
return
lang = self._lang
status_key_map = {
MaintenanceStatus.OVERDUE: "bundled_overdue",
MaintenanceStatus.DUE_SOON: "bundled_due_soon",
MaintenanceStatus.TRIGGERED: "bundled_triggered",
}
task_parts: list[str] = []
for t in tasks:
key = status_key_map.get(t["status"], "bundled_due_soon")
task_parts.append(_notif_t(key, lang, task=t["task_name"]))
task_parts = [_bundled_line(t["status"], lang, t["task_name"]) for t in tasks]
title = _notif_t("bundled_title", lang, count=str(len(tasks)))
message = _notif_t("bundled_message", lang, object=object_name, task_list=", ".join(task_parts))
@@ -2050,8 +2085,8 @@ class NotificationManager:
service_data = _service_payload(
title,
message,
tag=f"maintenance_bundled_{entry_id}",
url=f"/maintenance-supporter?entry_id={entry_id}",
tag=bundle_tag(entry_id),
url=panel_url(entry_id=entry_id),
)
context = notification_context(
@@ -2091,7 +2126,7 @@ class NotificationManager:
service_data = _service_payload(
_notif_t("digest_title", lang),
_notif_t("digest_message", lang, overdue=str(overdue), due_soon=str(due_soon)),
tag="maintenance_weekly_digest",
tag=DIGEST_TAG,
)
context = notification_context(self.hass, KIND_DIGEST, overdue=overdue, due_soon=due_soon)
try:
@@ -2118,7 +2153,7 @@ class NotificationManager:
days=str(days),
names=", ".join(names),
),
tag="maintenance_warranty_reminder",
tag=WARRANTY_TAG,
)
context = notification_context(self.hass, KIND_WARRANTY, names=list(names), days=days)
try:
@@ -2174,16 +2209,11 @@ class NotificationManager:
if not self._admit([lead_task_key], self._priority_rank(self._task_config(entry_id, task_id))):
return
lang = self._lang
title = _notif_t("due_soon_title", lang)
message = _notif_t(
"due_soon_message",
lang,
task=task_name,
object=object_name,
days=str(days),
due=next_due if next_due is not None else "?",
)
# The due-soon message of the status path, #44 title style included
# — this copy built its own and always titled "due soon", so lead
# reminders were the one push a stack could not tell apart (bug
# audit 2026-09-26).
title, message = self._build_message(MaintenanceStatus.DUE_SOON, self._lang, task_name, object_name, days, next_due)
context = notification_context(
self.hass,
@@ -2248,7 +2278,7 @@ class NotificationManager:
budget=f"{budget:.{decimals}f}{currency_symbol}",
)
service_data = _service_payload(title, message, tag=f"maintenance_budget_{period}")
service_data = _service_payload(title, message, tag=budget_tag(period))
context = notification_context(self.hass, KIND_BUDGET, period=period, spent=spent, budget=budget, percent=pct)
try:
@@ -2296,6 +2326,35 @@ class NotificationManager:
self._last_notified.pop(key, None)
self._snoozed_until.pop(key, None)
self._quiet_held.pop(key, None)
# The daily-limit waiting lists too: a completed (or deleted) task is
# not waiting for a slot any more — left in, it kept reserving the
# "starved first" budget for a reminder that would never be sent
# (bug audit 2026-09-27).
tkey = task_key_of(entry_id, task_id)
self._deferred_today.discard(tkey)
self._starved.discard(tkey)
self._dirty()
def purge_entry(self, entry_id: str) -> None:
"""Forget every piece of bookkeeping about a deleted object's tasks —
the stamps, snoozes, held quiet-hours reminders, daily-limit lists and
lead-reminder dedup were persisted forever and a held reminder of a
deleted object still went out in the next summary (bug audit
2026-09-27). Keys all start with ``<entry_id>_``."""
prefix = f"{entry_id}_"
mappings: tuple[dict[str, Any], ...] = (
self._last_notified,
self._snoozed_until,
self._quiet_held,
self._delivered_at,
self._lead_sent,
)
for mapping in mappings:
for key in [k for k in mapping if k.startswith(prefix)]:
del mapping[key]
for keys in (self._served_today, self._deferred_today, self._starved):
keys.difference_update({k for k in keys if k.startswith(prefix)})
self._seeded_entries.discard(entry_id)
self._dirty()
async def async_dismiss_task_notification(self, task_id: str, responsible_user_id: str | None = None) -> None:
@@ -2313,7 +2372,7 @@ class NotificationManager:
services.extend(await get_user_notify_services(self.hass, responsible_user_id))
except Exception: # noqa: BLE001 - a lookup failure must not keep the action from finishing
_LOGGER.debug("User notify services unavailable for %s", responsible_user_id, exc_info=True)
tag = f"maintenance_{task_id}"
tag = task_tag(task_id)
for service in dict.fromkeys(s for s in services if s):
domain, _, name = service.partition(".")
if not (name and self.hass.services.has_service(domain, name)):