393 files
This commit is contained in:
@@ -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)):
|
||||
|
||||
Reference in New Issue
Block a user