Files
HomeAssistantVS/custom_components/maintenance_supporter/helpers/notification_manager.py
T

2343 lines
114 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Notification manager for maintenance reminders."""
from __future__ import annotations
import logging
from collections.abc import Mapping
from datetime import date, datetime, time, timedelta
from typing import Any
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import issue_registry as ir
from homeassistant.helpers.storage import Store
from homeassistant.util import dt as dt_util
from ..const import (
BUDGET_CURRENCIES,
CONF_ACTION_COMPLETE_ENABLED,
CONF_ACTION_SKIP_ENABLED,
CONF_ACTION_SNOOZE_ENABLED,
CONF_MAX_NOTIFICATIONS_PER_DAY,
CONF_NOTIFICATION_TITLE_STYLE,
CONF_NOTIFICATIONS_ENABLED,
CONF_NOTIFY_DUE_SOON_INTERVAL,
CONF_NOTIFY_EVENT_ONLY,
CONF_NOTIFY_OVERDUE_INTERVAL,
CONF_NOTIFY_SERVICE,
CONF_NOTIFY_TRIGGERED_INTERVAL,
CONF_QUIET_HOURS_ENABLED,
CONF_QUIET_HOURS_END,
CONF_QUIET_HOURS_START,
CONF_SNOOZE_DURATION_HOURS,
CONF_TASK_PRIORITY,
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 .notification_gates import STATUS_ENABLED_KEYS, status_reminder_enabled, task_may_notify
from .notify_hooks import (
KIND_BUDGET,
KIND_BUNDLE,
KIND_DIGEST,
KIND_LEAD_TIME,
KIND_QUIET_END,
KIND_STATUS,
KIND_WARRANTY,
async_emit_and_dispatch,
notification_context,
)
from .settings_registry import setting_default
_LOGGER = logging.getLogger(__name__)
# Repair-issue id raised when the *configured* global notify service does not
# exist (e.g. the mobile app or notify group it points at was removed), so
# notifications would silently fail. Scope is the top-level service only — a
# broken member *inside* a notify group is invisible here (HA dispatches to the
# working members and only logs the bad one), so we never flag that case.
_NOTIFY_SERVICE_MISSING_ISSUE_ID = "notify_service_missing"
# Sentinel value: interval=0 means "notify once, never repeat".
# A naive datetime.max is intentionally NOT comparable with the timezone-aware
# values stored elsewhere in self._last_notified — every code path that touches
# this sentinel guards on equality (`last == _SENT_ONCE` / `last != _SENT_ONCE`)
# BEFORE attempting any subtraction, so the naive/aware mix never reaches an
# arithmetic operation. Replacing with a tz-aware version would still work but
# adds noise; the sentinel is a singleton, not a real timestamp.
_SENT_ONCE = datetime.max # noqa: DTZ901 - intentional naive sentinel, see comment above
# 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
# restart re-announced everything once the trigger entities came back.
STATE_STORE_KEY = f"{DOMAIN}.notification_state"
STATE_STORE_VERSION = 1
_STATE_SAVE_DELAY = 5
# --- Notification message translations ---
_NOTIFICATION_STRINGS: dict[str, dict[str, str]] = {
"de": {
"open_task_link": "Aufgabe öffnen",
"due_soon_title": "Wartung bald fällig",
"due_soon_message": "{task} für {object} ist in {days} Tag(en) fällig (Fällig: {due}).",
"overdue_title": "Wartung überfällig!",
"overdue_message": "{task} für {object} ist {days} Tag(e) überfällig!",
"triggered_title": "Wartung ausgelöst",
"triggered_message": "{task} für {object} wurde durch Sensordaten ausgelöst.",
"action_complete": "Erledigt",
"action_skip": "Überspringen",
"action_snooze": "Später",
"bundled_title": "Wartung: {count} Aufgaben",
"bundled_message": "{object}: {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.",
"warranty_title": "Garantie läuft bald ab",
"warranty_message": "{count} Objekt(e) mit Garantie-Ablauf in {days} Tagen: {names}",
"completed_title": 'Aufgabe erledigt',
"completed_message": '{task} für {object}: {reason}{who}',
"completed_by": ' — von {name}',
"reason_panel": 'im Panel erledigt',
"reason_qr": 'per QR-Scan erledigt',
"reason_nfc": 'per NFC-Tag erledigt',
"reason_button": 'über die Button-Entität erledigt',
"reason_todo": 'in der To-do-Liste abgehakt',
"reason_voice": 'per Sprache erledigt',
"reason_notification_action": 'aus der Benachrichtigung erledigt',
"reason_shopping_list": 'über die Einkaufsliste nachgefüllt',
"reason_todo_mirror": 'in einer gespiegelten To-do-Liste erledigt',
"reason_service": 'von einer Automation erledigt',
"reason_auto_recovery": 'automatisch erledigt, der Sensor hat sich erholt',
"reason_unknown": 'erledigt',
"bundled_overdue": "{task} (überfällig)",
"bundled_due_soon": "{task} (bald fällig)",
"bundled_triggered": "{task} (ausgelöst)",
"budget_alert_title": "Wartungsbudget-Warnung",
"budget_alert_monthly": "Monatsbudget zu {pct}% ausgeschöpft ({spent} von {budget})",
"budget_alert_yearly": "Jahresbudget zu {pct}% ausgeschöpft ({spent} von {budget})",
},
"nl": {
"open_task_link": "Taak openen",
"due_soon_title": "Onderhoud binnenkort",
"due_soon_message": "{task} voor {object} is over {days} dag(en) te doen (Vervaldatum: {due}).",
"overdue_title": "Onderhoud achterstallig!",
"overdue_message": "{task} voor {object} is {days} dag(en) achterstallig!",
"triggered_title": "Onderhoud geactiveerd",
"triggered_message": "{task} voor {object} is geactiveerd door sensordata.",
"action_complete": "Voltooid",
"action_skip": "Overslaan",
"action_snooze": "Later",
"bundled_title": "Onderhoud: {count} taken",
"bundled_message": "{object}: {task_list}",
"quiet_end_title": "{count} herinneringen uit de stille uren",
"digest_title": "Wekelijks onderhoudsoverzicht",
"digest_message": "{overdue} achterstallig, {due_soon} deze week.",
"warranty_title": "Warranty expiring soon",
"warranty_message": "{count} object(s) with warranty expiring within {days} days: {names}",
"completed_title": 'Taak voltooid',
"completed_message": '{task} voor {object}: {reason}{who}',
"completed_by": ' — door {name}',
"reason_panel": 'voltooid in het paneel',
"reason_qr": 'voltooid via QR-scan',
"reason_nfc": 'voltooid via NFC-tag',
"reason_button": 'voltooid via de knop-entiteit',
"reason_todo": 'afgevinkt in de takenlijst',
"reason_voice": 'voltooid via spraak',
"reason_notification_action": 'voltooid vanuit de notificatie',
"reason_shopping_list": 'aangevuld via de boodschappenlijst',
"reason_todo_mirror": 'afgevinkt in een gespiegelde takenlijst',
"reason_service": 'voltooid door een automatisering',
"reason_auto_recovery": 'automatisch voltooid, de sensor is hersteld',
"reason_unknown": 'voltooid',
"bundled_overdue": "{task} (achterstallig)",
"bundled_due_soon": "{task} (binnenkort)",
"bundled_triggered": "{task} (geactiveerd)",
"budget_alert_title": "Onderhoudsbudget waarschuwing",
"budget_alert_monthly": "Maandbudget op {pct}% ({spent} van {budget})",
"budget_alert_yearly": "Jaarbudget op {pct}% ({spent} van {budget})",
},
"fr": {
"open_task_link": "Ouvrir la tâche",
"due_soon_title": "Maintenance bientôt due",
"due_soon_message": "{task} pour {object} est dû dans {days} jour(s) (Échéance : {due}).",
"overdue_title": "Maintenance en retard !",
"overdue_message": "{task} pour {object} est en retard de {days} jour(s) !",
"triggered_title": "Maintenance déclenchée",
"triggered_message": "{task} pour {object} a été déclenchée par les données du capteur.",
"action_complete": "Terminé",
"action_skip": "Ignorer",
"action_snooze": "Reporter",
"bundled_title": "Maintenance : {count} tâches",
"bundled_message": "{object} : {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.",
"warranty_title": "Warranty expiring soon",
"warranty_message": "{count} object(s) with warranty expiring within {days} days: {names}",
"completed_title": 'Tâche terminée',
"completed_message": '{task} pour {object} : {reason}{who}',
"completed_by": ' — par {name}',
"reason_panel": 'terminée dans le panneau',
"reason_qr": 'terminée par scan QR',
"reason_nfc": 'terminée par tag NFC',
"reason_button": "terminée via l'entité bouton",
"reason_todo": 'cochée dans la liste de tâches',
"reason_voice": 'terminée à la voix',
"reason_notification_action": 'terminée depuis la notification',
"reason_shopping_list": 'réapprovisionnée via la liste de courses',
"reason_todo_mirror": 'cochée dans une liste de tâches miroir',
"reason_service": 'terminée par une automatisation',
"reason_auto_recovery": "terminée automatiquement, le capteur s'est rétabli",
"reason_unknown": 'terminée',
"bundled_overdue": "{task} (en retard)",
"bundled_due_soon": "{task} (bientôt dû)",
"bundled_triggered": "{task} (déclenché)",
"budget_alert_title": "Alerte budget maintenance",
"budget_alert_monthly": "Budget mensuel à {pct}% ({spent} sur {budget})",
"budget_alert_yearly": "Budget annuel à {pct}% ({spent} sur {budget})",
},
"it": {
"open_task_link": "Apri l'attività",
"due_soon_title": "Manutenzione in scadenza",
"due_soon_message": "{task} per {object} è in scadenza tra {days} giorno/i (Scadenza: {due}).",
"overdue_title": "Manutenzione scaduta!",
"overdue_message": "{task} per {object} è scaduta da {days} giorno/i!",
"triggered_title": "Manutenzione attivata",
"triggered_message": "{task} per {object} è stata attivata dai dati del sensore.",
"action_complete": "Completato",
"action_skip": "Salta",
"action_snooze": "Posticipa",
"bundled_title": "Manutenzione: {count} attività",
"bundled_message": "{object}: {task_list}",
"quiet_end_title": "{count} promemoria dalle ore di silenzio",
"digest_title": "Riepilogo settimanale manutenzione",
"digest_message": "{overdue} scadute, {due_soon} questa settimana.",
"warranty_title": "Warranty expiring soon",
"warranty_message": "{count} object(s) with warranty expiring within {days} days: {names}",
"completed_title": 'Attività completata',
"completed_message": '{task} per {object}: {reason}{who}',
"completed_by": ' — da {name}',
"reason_panel": 'completata nel pannello',
"reason_qr": 'completata con scansione QR',
"reason_nfc": 'completata con tag NFC',
"reason_button": "completata tramite l'entità pulsante",
"reason_todo": 'spuntata nella lista delle cose da fare',
"reason_voice": 'completata a voce',
"reason_notification_action": 'completata dalla notifica',
"reason_shopping_list": 'rifornita dalla lista della spesa',
"reason_todo_mirror": 'spuntata in una lista di cose da fare specchiata',
"reason_service": "completata da un'automazione",
"reason_auto_recovery": 'completata automaticamente, il sensore si è ripristinato',
"reason_unknown": 'completata',
"bundled_overdue": "{task} (scaduta)",
"bundled_due_soon": "{task} (in scadenza)",
"bundled_triggered": "{task} (attivata)",
"budget_alert_title": "Avviso budget manutenzione",
"budget_alert_monthly": "Budget mensile al {pct}% ({spent} di {budget})",
"budget_alert_yearly": "Budget annuale al {pct}% ({spent} di {budget})",
},
"es": {
"open_task_link": "Abrir la tarea",
"due_soon_title": "Mantenimiento próximo",
"due_soon_message": "{task} para {object} vence en {days} día(s) (Vencimiento: {due}).",
"overdue_title": "¡Mantenimiento vencido!",
"overdue_message": "¡{task} para {object} está vencido por {days} día(s)!",
"triggered_title": "Mantenimiento activado",
"triggered_message": "{task} para {object} ha sido activado por datos del sensor.",
"action_complete": "Completar",
"action_skip": "Omitir",
"action_snooze": "Posponer",
"bundled_title": "Mantenimiento: {count} tareas",
"bundled_message": "{object}: {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.",
"warranty_title": "Warranty expiring soon",
"warranty_message": "{count} object(s) with warranty expiring within {days} days: {names}",
"completed_title": 'Tarea completada',
"completed_message": '{task} para {object}: {reason}{who}',
"completed_by": ' — por {name}',
"reason_panel": 'completada en el panel',
"reason_qr": 'completada por escaneo QR',
"reason_nfc": 'completada por etiqueta NFC',
"reason_button": 'completada mediante la entidad botón',
"reason_todo": 'marcada en la lista de tareas',
"reason_voice": 'completada por voz',
"reason_notification_action": 'completada desde la notificación',
"reason_shopping_list": 'repuesta desde la lista de la compra',
"reason_todo_mirror": 'marcada en una lista de tareas reflejada',
"reason_service": 'completada por una automatización',
"reason_auto_recovery": 'completada automáticamente, el sensor se recuperó',
"reason_unknown": 'completada',
"bundled_overdue": "{task} (vencido)",
"bundled_due_soon": "{task} (próximo)",
"bundled_triggered": "{task} (activado)",
"budget_alert_title": "Alerta de presupuesto de mantenimiento",
"budget_alert_monthly": "Presupuesto mensual al {pct}% ({spent} de {budget})",
"budget_alert_yearly": "Presupuesto anual al {pct}% ({spent} de {budget})",
},
"en": {
"open_task_link": "Open task",
"due_soon_title": "Maintenance Due Soon",
"due_soon_message": "{task} for {object} is due in {days} day(s) (Due: {due}).",
"overdue_title": "Maintenance Overdue!",
"overdue_message": "{task} for {object} is {days} day(s) overdue!",
"triggered_title": "Maintenance Triggered",
"triggered_message": "{task} for {object} has been triggered by sensor data.",
"action_complete": "Complete",
"action_skip": "Skip",
"action_snooze": "Snooze",
"bundled_title": "Maintenance: {count} tasks",
"bundled_message": "{object}: {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.",
"warranty_title": "Warranty expiring soon",
"warranty_message": "{count} object(s) with warranty expiring within {days} days: {names}",
"completed_title": 'Task completed',
"completed_message": '{task} for {object}: {reason}{who}',
"completed_by": ' — by {name}',
"reason_panel": 'completed in the panel',
"reason_qr": 'completed by QR scan',
"reason_nfc": 'completed by NFC tag',
"reason_button": 'completed via the button entity',
"reason_todo": 'ticked off in the to-do list',
"reason_voice": 'completed by voice',
"reason_notification_action": 'completed from the notification',
"reason_shopping_list": 'restocked from the shopping list',
"reason_todo_mirror": 'checked off in a mirrored to-do list',
"reason_service": 'completed by an automation',
"reason_auto_recovery": 'auto-completed, the sensor recovered',
"reason_unknown": 'completed',
"bundled_overdue": "{task} (overdue)",
"bundled_due_soon": "{task} (due soon)",
"bundled_triggered": "{task} (triggered)",
"budget_alert_title": "Maintenance Budget Warning",
"budget_alert_monthly": "Monthly budget at {pct}% ({spent} of {budget})",
"budget_alert_yearly": "Yearly budget at {pct}% ({spent} of {budget})",
},
"da": {
"open_task_link": "Åbn opgaven",
"due_soon_title": "Vedligeholdelse snart forfalden",
"due_soon_message": "{task} for {object} forfalder om {days} dag(e) (Forfalder: {due}).",
"overdue_title": "Vedligeholdelse forfalden!",
"overdue_message": "{task} for {object} er {days} dag(e) forsinket!",
"triggered_title": "Vedligeholdelse udløst",
"triggered_message": "{task} for {object} blev udløst af sensordata.",
"action_complete": "Udført",
"action_skip": "Spring over",
"action_snooze": "Senere",
"bundled_title": "Vedligeholdelse: {count} opgaver",
"bundled_message": "{object}: {task_list}",
"quiet_end_title": "{count} påmindelser fra de stille timer",
"digest_title": "Ugentlig vedligeholdelsesoversigt",
"digest_message": "{overdue} forfaldne, {due_soon} denne uge.",
"warranty_title": "Warranty expiring soon",
"warranty_message": "{count} object(s) with warranty expiring within {days} days: {names}",
"completed_title": 'Opgave fuldført',
"completed_message": '{task} for {object}: {reason}{who}',
"completed_by": ' — af {name}',
"reason_panel": 'fuldført i panelet',
"reason_qr": 'fuldført via QR-scanning',
"reason_nfc": 'fuldført via NFC-tag',
"reason_button": 'fuldført via knap-entiteten',
"reason_todo": 'afkrydset på to-do-listen',
"reason_voice": 'fuldført med stemmen',
"reason_notification_action": 'fuldført fra notifikationen',
"reason_shopping_list": 'genopfyldt fra indkøbslisten',
"reason_todo_mirror": 'afkrydset på en spejlet to-do-liste',
"reason_service": 'fuldført af en automatisering',
"reason_auto_recovery": 'fuldført automatisk, sensoren kom sig',
"reason_unknown": 'fuldført',
"bundled_overdue": "{task} (forfalden)",
"bundled_due_soon": "{task} (snart forfalden)",
"bundled_triggered": "{task} (udløst)",
"budget_alert_title": "Advarsel om vedligeholdelsesbudget",
"budget_alert_monthly": "Månedsbudget på {pct}% ({spent} af {budget})",
"budget_alert_yearly": "Årsbudget på {pct}% ({spent} af {budget})",
},
"fi": {
"open_task_link": "Avaa tehtävä",
"due_soon_title": "Huolto erääntyy pian",
"due_soon_message": "{task} kohteelle {object} erääntyy {days} päivän kuluttua (Eräpäivä: {due}).",
"overdue_title": "Huolto myöhässä!",
"overdue_message": "{task} kohteelle {object} on {days} päivää myöhässä!",
"triggered_title": "Huolto käynnistetty",
"triggered_message": "{task} kohteelle {object} käynnistyi anturitietojen perusteella.",
"action_complete": "Valmis",
"action_skip": "Ohita",
"action_snooze": "Myöhemmin",
"bundled_title": "Huolto: {count} tehtävää",
"bundled_message": "{object}: {task_list}",
"quiet_end_title": "{count} muistutusta hiljaisilta tunneilta",
"digest_title": "Viikoittainen huoltokooste",
"digest_message": "{overdue} myöhässä, {due_soon} tällä viikolla.",
"warranty_title": "Warranty expiring soon",
"warranty_message": "{count} object(s) with warranty expiring within {days} days: {names}",
"completed_title": 'Tehtävä valmis',
"completed_message": '{task} kohteelle {object}: {reason}{who}',
"completed_by": ' — tekijä {name}',
"reason_panel": 'suoritettu paneelissa',
"reason_qr": 'suoritettu QR-skannauksella',
"reason_nfc": 'suoritettu NFC-tunnisteella',
"reason_button": 'suoritettu painike-entiteetillä',
"reason_todo": 'kuitattu tehtävälistalla',
"reason_voice": 'suoritettu puheella',
"reason_notification_action": 'suoritettu ilmoituksesta',
"reason_shopping_list": 'täydennetty ostoslistalta',
"reason_todo_mirror": 'kuitattu peilatussa tehtävälistassa',
"reason_service": 'suoritettu automaatiolla',
"reason_auto_recovery": 'suoritettu automaattisesti, anturi palautui',
"reason_unknown": 'suoritettu',
"bundled_overdue": "{task} (myöhässä)",
"bundled_due_soon": "{task} (pian)",
"bundled_triggered": "{task} (käynnistetty)",
"budget_alert_title": "Huoltobudjetin varoitus",
"budget_alert_monthly": "Kuukausibudjetti {pct}% ({spent} / {budget})",
"budget_alert_yearly": "Vuosibudjetti {pct}% ({spent} / {budget})",
},
"nb": {
"open_task_link": "Åpne oppgaven",
"due_soon_title": "Vedlikehold forfaller snart",
"due_soon_message": "{task} for {object} forfaller om {days} dag(er) (Forfaller: {due}).",
"overdue_title": "Vedlikehold forfalt!",
"overdue_message": "{task} for {object} er {days} dag(er) forsinket!",
"triggered_title": "Vedlikehold utløst",
"triggered_message": "{task} for {object} ble utløst av sensordata.",
"action_complete": "Fullført",
"action_skip": "Hopp over",
"action_snooze": "Senere",
"bundled_title": "Vedlikehold: {count} oppgaver",
"bundled_message": "{object}: {task_list}",
"quiet_end_title": "{count} påminnelser fra de stille timene",
"digest_title": "Ukentlig vedlikeholdsoversikt",
"digest_message": "{overdue} forfalt, {due_soon} denne uken.",
"warranty_title": "Warranty expiring soon",
"warranty_message": "{count} object(s) with warranty expiring within {days} days: {names}",
"completed_title": 'Oppgave fullført',
"completed_message": '{task} for {object}: {reason}{who}',
"completed_by": ' — av {name}',
"reason_panel": 'fullført i panelet',
"reason_qr": 'fullført via QR-skanning',
"reason_nfc": 'fullført via NFC-tagg',
"reason_button": 'fullført via knapp-entiteten',
"reason_todo": 'huket av i gjøremålslisten',
"reason_voice": 'fullført med stemme',
"reason_notification_action": 'fullført fra varselet',
"reason_shopping_list": 'etterfylt fra handlelisten',
"reason_todo_mirror": 'krysset av i en speilet gjøremålsliste',
"reason_service": 'fullført av en automasjon',
"reason_auto_recovery": 'fullført automatisk, sensoren kom seg',
"reason_unknown": 'fullført',
"bundled_overdue": "{task} (forfalt)",
"bundled_due_soon": "{task} (snart)",
"bundled_triggered": "{task} (utløst)",
"budget_alert_title": "Varsel om vedlikeholdsbudsjett",
"budget_alert_monthly": "Månedsbudsjett på {pct}% ({spent} av {budget})",
"budget_alert_yearly": "Årsbudsjett på {pct}% ({spent} av {budget})",
},
"ja": {
"open_task_link": "タスクを開く",
"due_soon_title": "メンテナンス期限間近",
"due_soon_message": "{object} の {task} はあと {days} 日で期限です(期限: {due})。",
"overdue_title": "メンテナンス期限超過!",
"overdue_message": "{object} の {task} は {days} 日超過しています!",
"triggered_title": "メンテナンス起動",
"triggered_message": "{object} の {task} がセンサーデータにより起動されました。",
"action_complete": "完了",
"action_skip": "スキップ",
"action_snooze": "後で",
"bundled_title": "メンテナンス: {count} 件のタスク",
"bundled_message": "{object}: {task_list}",
"quiet_end_title": "静音時間中の{count}件のリマインダー",
"digest_title": "週間メンテナンスまとめ",
"digest_message": "期限切れ {overdue} 件、今週 {due_soon} 件。",
"warranty_title": "Warranty expiring soon",
"warranty_message": "{count} object(s) with warranty expiring within {days} days: {names}",
"completed_title": 'タスク完了',
"completed_message": '{object} の {task}:{reason}{who}',
"completed_by": '({name})',
"reason_panel": 'パネルで完了',
"reason_qr": 'QR スキャンで完了',
"reason_nfc": 'NFC タグで完了',
"reason_button": 'ボタンエンティティで完了',
"reason_todo": 'To-do リストでチェック',
"reason_voice": '音声で完了',
"reason_notification_action": '通知から完了',
"reason_shopping_list": '買い物リストから補充',
"reason_todo_mirror": 'ミラーされたToDoリストで完了',
"reason_service": 'オートメーションで完了',
"reason_auto_recovery": 'センサー回復により自動完了',
"reason_unknown": '完了',
"bundled_overdue": "{task}(超過)",
"bundled_due_soon": "{task}(間近)",
"bundled_triggered": "{task}(起動)",
"budget_alert_title": "メンテナンス予算の警告",
"budget_alert_monthly": "月間予算が {pct}%({budget} 中 {spent})",
"budget_alert_yearly": "年間予算が {pct}%({budget} 中 {spent})",
},
"hi": {
"open_task_link": "कार्य खोलें",
"due_soon_title": "रखरखाव जल्द देय",
"due_soon_message": "{object} के लिए {task} {days} दिन में देय है (देय: {due})।",
"overdue_title": "रखरखाव अतिदेय!",
"overdue_message": "{object} के लिए {task} {days} दिन अतिदेय है!",
"triggered_title": "रखरखाव ट्रिगर हुआ",
"triggered_message": "{object} के लिए {task} सेंसर डेटा द्वारा ट्रिगर हुआ।",
"action_complete": "पूर्ण",
"action_skip": "छोड़ें",
"action_snooze": "बाद में",
"bundled_title": "रखरखाव: {count} कार्य",
"bundled_message": "{object}: {task_list}",
"quiet_end_title": "शांत घंटों के {count} अनुस्मारक",
"digest_title": "साप्ताहिक रखरखाव सारांश",
"digest_message": "{overdue} अतिदेय, {due_soon} इस सप्ताह।",
"warranty_title": "Warranty expiring soon",
"warranty_message": "{count} object(s) with warranty expiring within {days} days: {names}",
"completed_title": 'कार्य पूरा हुआ',
"completed_message": '{object} के लिए {task}: {reason}{who}',
"completed_by": ' — {name} द्वारा',
"reason_panel": 'पैनल में पूरा किया',
"reason_qr": 'QR स्कैन से पूरा किया',
"reason_nfc": 'NFC टैग से पूरा किया',
"reason_button": 'बटन एंटिटी से पूरा किया',
"reason_todo": 'टू-डू सूची में टिक किया',
"reason_voice": 'आवाज़ से पूरा किया',
"reason_notification_action": 'सूचना से पूरा किया',
"reason_shopping_list": 'खरीदारी सूची से पुनः भरा',
"reason_todo_mirror": 'मिरर की गई टू-डू सूची में पूरा किया गया',
"reason_service": 'ऑटोमेशन द्वारा पूरा किया',
"reason_auto_recovery": 'स्वतः पूरा, सेंसर ठीक हो गया',
"reason_unknown": 'पूरा किया',
"bundled_overdue": "{task} (अतिदेय)",
"bundled_due_soon": "{task} (जल्द देय)",
"bundled_triggered": "{task} (ट्रिगर)",
"budget_alert_title": "रखरखाव बजट चेतावनी",
"budget_alert_monthly": "मासिक बजट {pct}% पर ({budget} में से {spent})",
"budget_alert_yearly": "वार्षिक बजट {pct}% पर ({budget} में से {spent})",
},
"zh": {
"open_task_link": "打开任务",
"due_soon_title": "维护即将到期",
"due_soon_message": "{object} 的 {task} 将在 {days} 天后到期(到期日期:{due})。",
"overdue_title": "维护已超期!",
"overdue_message": "{object} 的 {task} 已超期 {days} 天!",
"triggered_title": "维护已触发",
"triggered_message": "传感器数据已触发 {object} 的 {task}。",
"action_complete": "完成",
"action_skip": "跳过",
"action_snooze": "稍后提醒",
"bundled_title": "维护:共有 {count} 项任务",
"bundled_message": "{object}: {task_list}",
"quiet_end_title": "静音时段积累的 {count} 条提醒",
"digest_title": "每周维护摘要",
"digest_message": "逾期 {overdue} 项,本周 {due_soon} 项。",
"warranty_title": "Warranty expiring soon",
"warranty_message": "{count} object(s) with warranty expiring within {days} days: {names}",
"completed_title": '任务已完成',
"completed_message": '{object} 的 {task}:{reason}{who}',
"completed_by": '({name})',
"reason_panel": '在面板中完成',
"reason_qr": '通过二维码扫描完成',
"reason_nfc": '通过 NFC 标签完成',
"reason_button": '通过按钮实体完成',
"reason_todo": '在待办清单中勾选',
"reason_voice": '通过语音完成',
"reason_notification_action": '从通知中完成',
"reason_shopping_list": '从购物清单补货',
"reason_todo_mirror": '在镜像的待办清单中勾选完成',
"reason_service": '由自动化完成',
"reason_auto_recovery": '传感器恢复,自动完成',
"reason_unknown": '已完成',
"bundled_overdue": "{task}(超期)",
"bundled_due_soon": "{task}(即将到期)",
"bundled_triggered": "{task}(已触发)",
"budget_alert_title": "维护预算警报",
"budget_alert_monthly": "月度预算已达 {pct}%(已支出 {spent} / 总预算 {budget})",
"budget_alert_yearly": "年度预算已达 {pct}%(已支出 {spent} / 总预算 {budget})",
},
"ru": {
"open_task_link": "Открыть задачу",
"due_soon_title": "Обслуживание скоро требуется",
"due_soon_message": "{task} для {object} требуется через {days} дн. (Срок: {due}).",
"overdue_title": "Обслуживание просрочено!",
"overdue_message": "{task} для {object} просрочено на {days} дн.!",
"triggered_title": "Обслуживание сработало",
"triggered_message": "{task} для {object} было запущено по данным датчика.",
"action_complete": "Выполнить",
"action_skip": "Пропустить",
"action_snooze": "Отложить",
"bundled_title": "Обслуживание: {count} задач",
"bundled_message": "{object}: {task_list}",
"quiet_end_title": "{count} напоминаний за тихие часы",
"digest_title": "Еженедельная сводка обслуживания",
"digest_message": "{overdue} просрочено, {due_soon} на этой неделе.",
"warranty_title": "Warranty expiring soon",
"warranty_message": "{count} object(s) with warranty expiring within {days} days: {names}",
"completed_title": 'Задача выполнена',
"completed_message": '{task} для {object}: {reason}{who}',
"completed_by": ' — {name}',
"reason_panel": 'выполнено в панели',
"reason_qr": 'выполнено по QR-коду',
"reason_nfc": 'выполнено по NFC-метке',
"reason_button": 'выполнено через кнопку',
"reason_todo": 'отмечено в списке дел',
"reason_voice": 'выполнено голосом',
"reason_notification_action": 'выполнено из уведомления',
"reason_shopping_list": 'пополнено из списка покупок',
"reason_todo_mirror": 'отмечено в зеркальном списке дел',
"reason_service": 'выполнено автоматизацией',
"reason_auto_recovery": 'выполнено автоматически: датчик восстановился',
"reason_unknown": 'выполнено',
"bundled_overdue": "{task} (просрочено)",
"bundled_due_soon": "{task} (скоро)",
"bundled_triggered": "{task} (сработало)",
"budget_alert_title": "Предупреждение о бюджете обслуживания",
"budget_alert_monthly": "Месячный бюджет: {pct}% ({spent} из {budget})",
"budget_alert_yearly": "Годовой бюджет: {pct}% ({spent} из {budget})",
},
"uk": {
"open_task_link": "Відкрити завдання",
"due_soon_title": "Незабаром термін обслуговування",
"due_soon_message": "{task} для {object} через {days} день(днів) (Термін: {due}).",
"overdue_title": "Обслуговування прострочено!",
"overdue_message": "{task} для {object} прострочено на {days} день(днів)!",
"triggered_title": "Обслуговування спрацювало",
"triggered_message": "{task} для {object} спрацювало за даними сенсора.",
"action_complete": "Виконати",
"action_skip": "Пропустити",
"action_snooze": "Відкласти",
"bundled_title": "Обслуговування: {count} завдань",
"bundled_message": "{object}: {task_list}",
"quiet_end_title": "{count} нагадувань за тихі години",
"digest_title": "Щотижневий огляд обслуговування",
"digest_message": "{overdue} прострочено, {due_soon} цього тижня.",
"warranty_title": "Warranty expiring soon",
"warranty_message": "{count} object(s) with warranty expiring within {days} days: {names}",
"completed_title": 'Завдання виконано',
"completed_message": '{task} для {object}: {reason}{who}',
"completed_by": ' — {name}',
"reason_panel": 'виконано в панелі',
"reason_qr": 'виконано за QR-кодом',
"reason_nfc": 'виконано за NFC-міткою',
"reason_button": 'виконано через кнопку',
"reason_todo": 'позначено в списку справ',
"reason_voice": 'виконано голосом',
"reason_notification_action": 'виконано зі сповіщення',
"reason_shopping_list": 'поповнено зі списку покупок',
"reason_todo_mirror": 'відмічено у дзеркальному списку справ',
"reason_service": 'виконано автоматизацією',
"reason_auto_recovery": 'виконано автоматично: датчик відновився',
"reason_unknown": 'виконано',
"bundled_overdue": "{task} (прострочено)",
"bundled_due_soon": "{task} (незабаром)",
"bundled_triggered": "{task} (спрацювало)",
"budget_alert_title": "Попередження про бюджет обслуговування",
"budget_alert_monthly": "Щомісячний бюджет використано на {pct}% ({spent} з {budget})",
"budget_alert_yearly": "Щорічний бюджет використано на {pct}% ({spent} з {budget})",
},
"pt": {
"open_task_link": "Abrir a tarefa",
"due_soon_title": "Manutenção em breve",
"due_soon_message": "{task} para {object} é necessário em {days} dia(s) (Prazo: {due}).",
"overdue_title": "Manutenção atrasada!",
"overdue_message": "{task} para {object} está atrasado em {days} dia(s)!",
"triggered_title": "Manutenção acionada",
"triggered_message": "{task} para {object} foi acionado por dados do sensor.",
"action_complete": "Concluir",
"action_skip": "Ignorar",
"action_snooze": "Adiar",
"bundled_title": "Manutenção: {count} tarefas",
"bundled_message": "{object}: {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.",
"warranty_title": "Warranty expiring soon",
"warranty_message": "{count} object(s) with warranty expiring within {days} days: {names}",
"completed_title": 'Tarefa concluída',
"completed_message": '{task} para {object}: {reason}{who}',
"completed_by": ' — por {name}',
"reason_panel": 'concluída no painel',
"reason_qr": 'concluída por leitura QR',
"reason_nfc": 'concluída por etiqueta NFC',
"reason_button": 'concluída através da entidade botão',
"reason_todo": 'assinalada na lista de tarefas',
"reason_voice": 'concluída por voz',
"reason_notification_action": 'concluída a partir da notificação',
"reason_shopping_list": 'reposta a partir da lista de compras',
"reason_todo_mirror": 'concluída numa lista de tarefas espelhada',
"reason_service": 'concluída por uma automação',
"reason_auto_recovery": 'concluída automaticamente, o sensor recuperou',
"reason_unknown": 'concluída',
"bundled_overdue": "{task} (atrasado)",
"bundled_due_soon": "{task} (em breve)",
"bundled_triggered": "{task} (acionado)",
"budget_alert_title": "Alerta de orçamento de manutenção",
"budget_alert_monthly": "Orçamento mensal em {pct}% ({spent} de {budget})",
"budget_alert_yearly": "Orçamento anual em {pct}% ({spent} de {budget})",
},
# v1.4.2: Polish notifications (panel + config-flow have been pl since v1.3.3
# but phone notifications still went out in English).
"pl": {
"open_task_link": "Otwórz zadanie",
"due_soon_title": "Wkrótce wymagana konserwacja",
"due_soon_message": "{task} dla {object} wymagane za {days} dni (Termin: {due}).",
"overdue_title": "Konserwacja przeterminowana!",
"overdue_message": "{task} dla {object} jest przeterminowane o {days} dni!",
"triggered_title": "Konserwacja wyzwolona",
"triggered_message": "{task} dla {object} zostało wyzwolone przez dane z czujnika.",
"action_complete": "Zakończ",
"action_skip": "Pomiń",
"action_snooze": "Drzemka",
"bundled_title": "Konserwacja: {count} zadań",
"bundled_message": "{object}: {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.",
"warranty_title": "Warranty expiring soon",
"warranty_message": "{count} object(s) with warranty expiring within {days} days: {names}",
"completed_title": 'Zadanie ukończone',
"completed_message": '{task} dla {object}: {reason}{who}',
"completed_by": ' — przez {name}',
"reason_panel": 'ukończone w panelu',
"reason_qr": 'ukończone przez skan QR',
"reason_nfc": 'ukończone przez tag NFC',
"reason_button": 'ukończone przez encję przycisku',
"reason_todo": 'odhaczone na liście zadań',
"reason_voice": 'ukończone głosowo',
"reason_notification_action": 'ukończone z powiadomienia',
"reason_shopping_list": 'uzupełnione z listy zakupów',
"reason_todo_mirror": 'odhaczone na lustrzanej liście zadań',
"reason_service": 'ukończone przez automatyzację',
"reason_auto_recovery": 'ukończone automatycznie, czujnik wrócił do normy',
"reason_unknown": 'ukończone',
"bundled_overdue": "{task} (przeterminowane)",
"bundled_due_soon": "{task} (wkrótce)",
"bundled_triggered": "{task} (wyzwolone)",
"budget_alert_title": "Ostrzeżenie o budżecie konserwacji",
"budget_alert_monthly": "Budżet miesięczny na poziomie {pct}% ({spent} z {budget})",
"budget_alert_yearly": "Budżet roczny na poziomie {pct}% ({spent} z {budget})",
},
# v1.4.2: Czech notifications (panel UI is cs since v1.0.41; closing the
# config-flow + notification gap together).
"cs": {
"open_task_link": "Otevřít úkol",
"due_soon_title": "Údržba se blíží",
"due_soon_message": "{task} pro {object} je třeba za {days} dní (Termín: {due}).",
"overdue_title": "Údržba po termínu!",
"overdue_message": "{task} pro {object} je {days} dní po termínu!",
"triggered_title": "Údržba spuštěna",
"triggered_message": "{task} pro {object} bylo spuštěno daty ze senzoru.",
"action_complete": "Hotovo",
"action_skip": "Přeskočit",
"action_snooze": "Odložit",
"bundled_title": "Údržba: {count} úkolů",
"bundled_message": "{object}: {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.",
"warranty_title": "Warranty expiring soon",
"warranty_message": "{count} object(s) with warranty expiring within {days} days: {names}",
"completed_title": 'Úkol dokončen',
"completed_message": '{task} pro {object}: {reason}{who}',
"completed_by": ' — {name}',
"reason_panel": 'dokončeno v panelu',
"reason_qr": 'dokončeno skenem QR',
"reason_nfc": 'dokončeno NFC tagem',
"reason_button": 'dokončeno přes entitu tlačítka',
"reason_todo": 'odškrtnuto v seznamu úkolů',
"reason_voice": 'dokončeno hlasem',
"reason_notification_action": 'dokončeno z oznámení',
"reason_shopping_list": 'doplněno z nákupního seznamu',
"reason_todo_mirror": 'odškrtnuto v zrcadleném seznamu úkolů',
"reason_service": 'dokončeno automatizací',
"reason_auto_recovery": 'dokončeno automaticky, senzor se zotavil',
"reason_unknown": 'dokončeno',
"bundled_overdue": "{task} (po termínu)",
"bundled_due_soon": "{task} (brzy)",
"bundled_triggered": "{task} (spuštěno)",
"budget_alert_title": "Upozornění na rozpočet údržby",
"budget_alert_monthly": "Měsíční rozpočet na {pct}% ({spent} z {budget})",
"budget_alert_yearly": "Roční rozpočet na {pct}% ({spent} z {budget})",
},
# v1.4.2: Swedish notifications.
"sv": {
"open_task_link": "Öppna uppgiften",
"due_soon_title": "Underhåll snart",
"due_soon_message": "{task} för {object} förfaller om {days} dag(ar) (Förfaller: {due}).",
"overdue_title": "Underhåll försenat!",
"overdue_message": "{task} för {object} är {days} dag(ar) försenat!",
"triggered_title": "Underhåll utlöst",
"triggered_message": "{task} för {object} har utlösts av sensordata.",
"action_complete": "Slutför",
"action_skip": "Hoppa över",
"action_snooze": "Snooza",
"bundled_title": "Underhåll: {count} uppgifter",
"bundled_message": "{object}: {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.",
"warranty_title": "Warranty expiring soon",
"warranty_message": "{count} object(s) with warranty expiring within {days} days: {names}",
"completed_title": 'Uppgift slutförd',
"completed_message": '{task} för {object}: {reason}{who}',
"completed_by": ' — av {name}',
"reason_panel": 'slutförd i panelen',
"reason_qr": 'slutförd via QR-skanning',
"reason_nfc": 'slutförd via NFC-tagg',
"reason_button": 'slutförd via knappentiteten',
"reason_todo": 'avbockad i att-göra-listan',
"reason_voice": 'slutförd med rösten',
"reason_notification_action": 'slutförd från aviseringen',
"reason_shopping_list": 'påfylld via inköpslistan',
"reason_todo_mirror": 'avbockad i en speglad att-göra-lista',
"reason_service": 'slutförd av en automation',
"reason_auto_recovery": 'slutförd automatiskt, sensorn återhämtade sig',
"reason_unknown": 'slutförd',
"bundled_overdue": "{task} (försenat)",
"bundled_due_soon": "{task} (snart)",
"bundled_triggered": "{task} (utlöst)",
"budget_alert_title": "Varning för underhållsbudget",
"budget_alert_monthly": "Månadsbudget på {pct}% ({spent} av {budget})",
"budget_alert_yearly": "Årsbudget på {pct}% ({spent} av {budget})",
},
"pt-br": {
"open_task_link": "Abrir a tarefa",
"due_soon_title": "Manutenção em breve",
"due_soon_message": "{task} de {object} vence em {days} dia(s) (vencimento: {due}).",
"overdue_title": "Manutenção atrasada!",
"overdue_message": "{task} de {object} está {days} dia(s) atrasada!",
"triggered_title": "Manutenção acionada",
"triggered_message": "{task} de {object} foi acionada por dados de sensor.",
"action_complete": "Concluir",
"action_skip": "Pular",
"action_snooze": "Adiar",
"bundled_title": "Manutenção: {count} tarefas",
"bundled_message": "{object}: {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.",
"warranty_title": "Garantia expirando em breve",
"warranty_message": "{count} objeto(s) com garantia expirando em {days} dias: {names}",
"completed_title": 'Tarefa concluída',
"completed_message": '{task} para {object}: {reason}{who}',
"completed_by": ' — por {name}',
"reason_panel": 'concluída no painel',
"reason_qr": 'concluída por leitura de QR',
"reason_nfc": 'concluída por tag NFC',
"reason_button": 'concluída pela entidade de botão',
"reason_todo": 'marcada na lista de tarefas',
"reason_voice": 'concluída por voz',
"reason_notification_action": 'concluída pela notificação',
"reason_shopping_list": 'reposta pela lista de compras',
"reason_todo_mirror": 'concluída em uma lista de tarefas espelhada',
"reason_service": 'concluída por uma automação',
"reason_auto_recovery": 'concluída automaticamente, o sensor se recuperou',
"reason_unknown": 'concluída',
"bundled_overdue": "{task} (atrasada)",
"bundled_due_soon": "{task} (em breve)",
"bundled_triggered": "{task} (acionada)",
"budget_alert_title": "Alerta de orçamento de manutenção",
"budget_alert_monthly": "Orçamento mensal em {pct}% ({spent} de {budget})",
"budget_alert_yearly": "Orçamento anual em {pct}% ({spent} de {budget})",
},
"hu": {
"open_task_link": "Feladat megnyitása",
"due_soon_title": "Karbantartás hamarosan esedékes",
"due_soon_message": "{object} – {task} {days} nap múlva esedékes (határidő: {due}).",
"overdue_title": "Karbantartás lejárt!",
"overdue_message": "{object} – {task} {days} napja esedékes!",
"triggered_title": "Karbantartás aktiválódott",
"triggered_message": "{object} – {task} feladatot érzékelőadatok aktiválták.",
"action_complete": "Kész",
"action_skip": "Kihagyás",
"action_snooze": "Halasztás",
"bundled_title": "Karbantartás: {count} feladat",
"bundled_message": "{object}: {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.",
"warranty_title": "Hamarosan lejáró garancia",
"warranty_message": "{count} objektum garanciája jár le {days} napon belül: {names}",
"completed_title": 'Feladat elvégezve',
"completed_message": '{task} ({object}): {reason}{who}',
"completed_by": ' — {name}',
"reason_panel": 'a panelen elvégezve',
"reason_qr": 'QR-beolvasással elvégezve',
"reason_nfc": 'NFC-címkével elvégezve',
"reason_button": 'a gomb-entitáson keresztül elvégezve',
"reason_todo": 'kipipálva a teendőlistán',
"reason_voice": 'hanggal elvégezve',
"reason_notification_action": 'az értesítésből elvégezve',
"reason_shopping_list": 'a bevásárlólistáról feltöltve',
"reason_todo_mirror": 'kipipálva egy tükrözött teendőlistán',
"reason_service": 'automatizálás végezte el',
"reason_auto_recovery": 'automatikusan elvégezve, az érzékelő helyreállt',
"reason_unknown": 'elvégezve',
"bundled_overdue": "{task} (lejárt)",
"bundled_due_soon": "{task} (hamarosan)",
"bundled_triggered": "{task} (aktiválva)",
"budget_alert_title": "Karbantartási keret figyelmeztetés",
"budget_alert_monthly": "Havi keret {pct}%-on ({spent} / {budget})",
"budget_alert_yearly": "Éves keret {pct}%-on ({spent} / {budget})",
},
"ko": {
"open_task_link": "작업 열기",
"due_soon_title": "곧 예정된 유지보수",
"due_soon_message": "{object}의 {task}이(가) {days}일 후 예정입니다 (기한: {due}).",
"overdue_title": "유지보수 기한 초과!",
"overdue_message": "{object}의 {task}이(가) {days}일 지났습니다!",
"triggered_title": "유지보수 트리거됨",
"triggered_message": "{object}의 {task}이(가) 센서 데이터로 트리거되었습니다.",
"action_complete": "완료",
"action_skip": "건너뛰기",
"action_snooze": "미루기",
"bundled_title": "유지보수: 작업 {count}개",
"bundled_message": "{object}: {task_list}",
"quiet_end_title": "방해 금지 시간 동안 쌓인 알림 {count}건",
"digest_title": "주간 유지보수 요약",
"digest_message": "기한 초과 {overdue}건, 이번 주 예정 {due_soon}건.",
"warranty_title": "보증 기간 만료 임박",
"warranty_message": "{days}일 이내에 보증이 만료되는 객체 {count}개: {names}",
"completed_title": '작업 완료',
"completed_message": '{object}의 {task}: {reason}{who}',
"completed_by": ' — {name}',
"reason_panel": '패널에서 완료',
"reason_qr": 'QR 스캔으로 완료',
"reason_nfc": 'NFC 태그로 완료',
"reason_button": '버튼 엔티티로 완료',
"reason_todo": '할 일 목록에서 체크',
"reason_voice": '음성으로 완료',
"reason_notification_action": '알림에서 완료',
"reason_shopping_list": '쇼핑 목록에서 보충',
"reason_todo_mirror": '미러링된 할 일 목록에서 완료',
"reason_service": '자동화가 완료',
"reason_auto_recovery": '센서 복구로 자동 완료',
"reason_unknown": '완료',
"bundled_overdue": "{task} (기한 초과)",
"bundled_due_soon": "{task} (곧 예정)",
"bundled_triggered": "{task} (트리거됨)",
"budget_alert_title": "유지보수 예산 경고",
"budget_alert_monthly": "월 예산 {pct}% 사용 ({budget} 중 {spent})",
"budget_alert_yearly": "연 예산 {pct}% 사용 ({budget} 중 {spent})",
},
"tr": {
"open_task_link": "Görevi aç",
"due_soon_title": "Bakım zamanı yaklaşıyor",
"due_soon_message": "{object} için {task}, {days} gün içinde yapılmalı (Tarih: {due}).",
"overdue_title": "Bakım gecikti!",
"overdue_message": "{object} için {task}, {days} gün gecikti!",
"triggered_title": "Bakım tetiklendi",
"triggered_message": "{object} için {task}, sensör verileriyle tetiklendi.",
"action_complete": "Tamamla",
"action_skip": "Atla",
"action_snooze": "Ertele",
"bundled_title": "Bakım: {count} görev",
"bundled_message": "{object}: {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.",
"warranty_title": "Garanti yakında sona eriyor",
"warranty_message": "{days} gün içinde garantisi sona erecek {count} nesne: {names}",
"completed_title": 'Görev tamamlandı',
"completed_message": '{object} için {task}: {reason}{who}',
"completed_by": ' — {name} tarafından',
"reason_panel": 'panelde tamamlandı',
"reason_qr": 'QR taramayla tamamlandı',
"reason_nfc": 'NFC etiketiyle tamamlandı',
"reason_button": 'düğme varlığıyla tamamlandı',
"reason_todo": 'yapılacaklar listesinde işaretlendi',
"reason_voice": 'sesle tamamlandı',
"reason_notification_action": 'bildirimden tamamlandı',
"reason_shopping_list": 'alışveriş listesinden yenilendi',
"reason_todo_mirror": 'yansıtılmış bir yapılacaklar listesinde tamamlandı',
"reason_service": 'bir otomasyon tarafından tamamlandı',
"reason_auto_recovery": 'otomatik tamamlandı, sensör toparlandı',
"reason_unknown": 'tamamlandı',
"bundled_overdue": "{task} (gecikmiş)",
"bundled_due_soon": "{task} (yaklaşıyor)",
"bundled_triggered": "{task} (tetiklendi)",
"budget_alert_title": "Bakım bütçesi uyarısı",
"budget_alert_monthly": "Aylık bütçe %{pct} ({spent} / {budget})",
"budget_alert_yearly": "Yıllık bütçe %{pct} ({spent} / {budget})",
},
}
PRIORITY_RANK: dict[str, int] = {TaskPriority.HIGH: 0, TaskPriority.NORMAL: 1, TaskPriority.LOW: 2}
def task_key_of(entry_id: str, task_id: str) -> str:
"""The per-task key of the daily-limit bookkeeping (status-agnostic)."""
return f"{entry_id}_{task_id}"
def _service_payload(title: str, message: str, *, tag: str, url: str = "/maintenance-supporter") -> 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."""
return {
"title": title,
"message": message,
"data": {"tag": tag, "url": url, "clickAction": url},
}
def build_action_buttons(
hass: HomeAssistant,
options: Mapping[str, Any],
lang: str,
*,
entry_id: str | None,
task_id: str | None,
skip_allowed: bool,
) -> list[dict[str, str]]:
"""The Companion-app action buttons a reminder carries, per the three
action toggles — translated labels, at most three (Android's cap).
The one builder behind the status reminder AND the Settings test send
(which hand-built the same list with hard-coded English labels and
ignored the per-task skip lock). ``entry_id``/``task_id`` ``None`` = the
test send: its ids are ``MS_TEST_<VERB>``, which the action listener
ignores — a tap on a test button must not complete anything. ``hass`` is
unused today (the caller resolves ``lang``); it keeps the signature in
step with ``notification_context`` for a future lookup.
"""
del hass
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}"
actions: list[dict[str, str]] = []
if options.get(CONF_ACTION_COMPLETE_ENABLED, setting_default(CONF_ACTION_COMPLETE_ENABLED)):
actions.append({"action": _id("COMPLETE"), "title": f"✅ {_notif_t('action_complete', lang)}"})
if options.get(CONF_ACTION_SKIP_ENABLED, setting_default(CONF_ACTION_SKIP_ENABLED)) and skip_allowed:
actions.append({"action": _id("SKIP"), "title": f"⏭️ {_notif_t('action_skip', lang)}"})
if options.get(CONF_ACTION_SNOOZE_ENABLED, setting_default(CONF_ACTION_SNOOZE_ENABLED)):
actions.append({"action": _id("SNOOZE"), "title": f"\U0001f4a4 {_notif_t('action_snooze', lang)}"})
return actions[:3]
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
async def get_user_notify_services(hass: HomeAssistant, user_id: str) -> list[str]:
"""Find all notify services for a user via mobile_app config entries.
Discovery strategy:
1. Find mobile_app config entries whose data contains matching user_id
2. Look up associated devices in the device registry
3. Find corresponding notify.mobile_app_* services
4. Return list of service names
"""
from homeassistant.helpers import device_registry as dr
from homeassistant.util import slugify
device_reg = dr.async_get(hass)
services: list[str] = []
seen: set[str] = set()
# Find mobile_app config entries for this user
for entry in hass.config_entries.async_entries("mobile_app"):
if entry.data.get("user_id") != user_id:
continue
# mobile_app registers its notify service as ``notify.mobile_app_<slug>``
# where <slug> = slugify(device name): the legacy notify platform
# slugifies each target, and mobile_app's target is
# ``entry.data[ATTR_DEVICE_NAME]`` ("device_name"). The previous code
# used the device IDENTIFIER (a webhook UUID) instead, so the lookup
# never matched and user notifications silently fell back to the global
# service (#75). Use the entry's device_name (authoritative), plus the
# device-registry names as a safety net (older entries without
# device_name, or a device the user renamed).
candidate_names: set[str] = {entry.data.get("device_name") or ""}
for device in dr.async_entries_for_config_entry(device_reg, entry.entry_id):
candidate_names.add(device.name_by_user or "")
candidate_names.add(device.name or "")
for name in candidate_names:
if not name:
continue
service = f"mobile_app_{slugify(name)}"
if service in seen or not hass.services.has_service("notify", service):
continue
seen.add(service)
services.append(f"notify.{service}")
_LOGGER.debug("Found notify service notify.%s for user %s", service, user_id)
return services
async def async_dispatch_notify(
hass: HomeAssistant,
target: str,
service_data: dict[str, Any],
*,
blocking: bool = False,
) -> bool:
"""Send ``service_data`` to a notify target, handling both notify models.
- A legacy notify *service* (``notify.mobile_app_<slug>``, a notify group, …)
is called directly and carries the full payload — action buttons, tag, url.
- A notify *entity* (the newer model — many single devices live only here)
has no callable per-name service; it's reached via ``notify.send_message``
with ``entity_id``, which carries only ``message`` + ``title`` (the entity
model can't take ``data``, so action buttons / tag / url are dropped).
Returns True if a target was dispatched, False if neither a service nor an
entity by that name exists. Exceptions are left for the caller to handle.
"""
domain, _, name = target.partition(".")
if not name:
_LOGGER.warning("Invalid notify target: %s", target)
return False
# #159: a persistent notification renders Markdown but never surfaces
# data.url (the payload's deep link) — append it as a tappable line.
if name == "persistent_notification":
url = (service_data.get("data") or {}).get("url")
msg = service_data.get("message", "")
if url and "](" not in msg:
link_text = _notif_t("open_task_link", normalize_language(hass))
service_data = {**service_data, "message": f"{msg}\n\n[{link_text}]({url})"}
if hass.services.has_service(domain, name):
await hass.services.async_call(domain, name, service_data, blocking=blocking)
return True
if hass.states.get(target) is not None:
await hass.services.async_call(
"notify",
"send_message",
{
"entity_id": target,
"title": service_data.get("title", ""),
"message": service_data.get("message", ""),
},
blocking=blocking,
)
return True
_LOGGER.warning("Notify target not found (no service or entity): %s", target)
return False
# Per-status config mapping
# The per-status repeat interval (hours; 0 = notify once) — defaults live in
# the settings registry. The enabled toggles are STATUS_ENABLED_KEYS
# (notification_gates), shared with the per-task gate.
_STATUS_INTERVAL_KEYS: dict[str, str] = {
MaintenanceStatus.DUE_SOON: CONF_NOTIFY_DUE_SOON_INTERVAL,
MaintenanceStatus.OVERDUE: CONF_NOTIFY_OVERDUE_INTERVAL,
MaintenanceStatus.TRIGGERED: CONF_NOTIFY_TRIGGERED_INTERVAL,
}
def notification_key(entry_id: str, task_id: str, status: str) -> str:
"""The manager's bookkeeping key for one task's status reminder — the
``_last_notified`` stamp and the snooze both live under it (was spelled
out as an f-string at eight call sites)."""
return f"{entry_id}_{task_id}_{status}"
class NotificationManager:
"""Manages maintenance notifications and reminders."""
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize the notification manager."""
self.hass = hass
self._last_notified: dict[str, datetime] = {}
self._snoozed_until: dict[str, datetime] = {}
self._daily_count: int = 0
# Daily-limit fairness (2026-09-13): which tasks got a message today,
# which were turned away today (limit / reserve / hold), and which
# were turned away YESTERDAY and never served — those go first today.
# Keyed by "<entry_id>_<task_id>"; a bundle marks every member.
self._served_today: set[str] = set()
self._deferred_today: set[str] = set()
self._starved: set[str] = set()
self._delivered_at: dict[str, datetime] = {}
# Reminders that fell into quiet hours: key → the facts needed for the
# one summary that goes out when the quiet hours end.
self._quiet_held: dict[str, dict[str, Any]] = {}
# The quiet-hours skip is logged once per quiet period, not per refresh.
self._quiet_logged_for: str | None = None
self._store: Store[dict[str, Any]] = Store(hass, STATE_STORE_VERSION, STATE_STORE_KEY)
# Bug audit 2026-09-12: lead reminders run from the 08:00 tick AND a
# noon retry (for quiet windows ending after 08:00) - this is the
# per-day dedup the retry relies on: (entry, task, lead) -> ISO day.
self._lead_sent: dict[str, str] = {}
# Entries whose startup seed already ran this process. The manager
# outlives every object-entry reload (each task edit reloads its
# entry and hands the coordinator an empty _previous_statuses), and
# re-seeding on every reload marked never-sent notifications as sent.
self._seeded_entries: set[str] = set()
self._daily_reset_date: date | None = None
# Tracks the last-known state of the "configured notify service missing"
# repair issue. None = not yet reconciled this process; the first
# ``async_verify_configured_service`` call forces a registry sync so a
# stale issue persisted from a previous run is cleared on restart.
self._notify_issue_active: bool | None = None
@property
def _global_options(self) -> Mapping[str, Any]:
"""Get global options from the global config entry."""
return get_global_options(self.hass)
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."""
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)
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()
self._dirty()
def _rate_limited(self, key: str, min_seconds: float) -> bool:
"""True when ``key`` fired less than ``min_seconds`` ago.
The _SENT_ONCE sentinel never blocks here — only the status-change
path stores it, with its own interval-hours semantics. Extracted from
the bundle/budget copies of the elapsed check."""
last = self._last_notified.get(key)
if last is None or last == _SENT_ONCE:
return False
return (dt_util.now() - last).total_seconds() < min_seconds
async def _resolve_and_send(
self,
responsible_user_id: str | None,
*,
title: str,
message: str,
entry_id: str,
task_id: str,
context: Mapping[str, Any],
) -> bool:
"""Resolve targets (per-user services, else the global service) and
fan the notification out; True when at least one send succeeded.
The status-change and lead-reminder paths carried drifting copies of
this block — one logged the per-user fallback, the other was silent.
"""
target_services: list[str] = []
if responsible_user_id:
user_services = await get_user_notify_services(self.hass, responsible_user_id)
if user_services:
target_services = user_services
_LOGGER.debug(
"Sending notification to user %s services: %s",
responsible_user_id,
user_services,
)
else:
_LOGGER.debug(
"User %s has no notification services, falling back to global",
responsible_user_id,
)
if not target_services and self.notify_service:
target_services = [self.notify_service]
if not target_services:
if self.event_only:
# #173: "only fire the event" needs no notify service — the
# automation IS the delivery. One event, empty target.
target_services = [""]
else:
_LOGGER.warning("No notification services available")
return False
success = False
for service in target_services:
if await self._async_send_notification_to_service(
service=service,
title=title,
message=message,
entry_id=entry_id,
task_id=task_id,
context=context,
):
success = True
return success
@property
def _lang(self) -> str:
"""Get the HA UI language as a 2-letter table key."""
return normalize_language(self.hass)
def _skip_allowed(self, entry_id: str, task_id: str) -> bool:
"""#150: a skip-locked task gets no "Skip" action button — the tap
could only fail in the action handler (bug review 2026-09-04).
Unknown entries/tasks keep the button (nothing to check against)."""
return self._task_config(entry_id, task_id).get("allow_skip") is not False
def _task_config(self, entry_id: str, task_id: str) -> Mapping[str, Any]:
"""The task's static config dict (``entry.data``) — carries the flags
the computed coordinator payload drops (``notify_enabled``,
``allow_skip``). Empty for an unknown entry/task."""
entry = self.hass.config_entries.async_get_entry(entry_id)
if entry is None:
return {}
task: Mapping[str, Any] = (entry.data.get(CONF_TASKS) or {}).get(task_id) or {}
return task
def _opt(self, key: str) -> Any:
"""A global setting, or its registry default when unset."""
return self._global_options.get(key, setting_default(key))
@property
def enabled(self) -> bool:
"""Check if notifications are globally enabled."""
return bool(self._opt(CONF_NOTIFICATIONS_ENABLED))
@property
def notify_service(self) -> str:
"""Get the configured notify service."""
return str(self._opt(CONF_NOTIFY_SERVICE))
@property
def _has_target(self) -> bool:
"""A send has somewhere to go: a notify service, or event-only mode
(#173), where the event itself is the delivery."""
return bool(self.notify_service) or self.event_only
@property
def event_only(self) -> bool:
"""#165/#173: the user routes every notification through the event."""
return bool(self._opt(CONF_NOTIFY_EVENT_ONLY))
@property
def title_style(self) -> str:
"""v1.4.0 (#44): how to format the notification title.
Returns one of "default" / "object_name" / "task_name". Defaults to
"default" so existing installs see no behaviour change. Defensive
against a partially-initialised manager (some unit tests construct
NotificationManager via __new__ without setting `hass`).
"""
fallback = str(setting_default(CONF_NOTIFICATION_TITLE_STYLE))
try:
raw = str(self._opt(CONF_NOTIFICATION_TITLE_STYLE))
except AttributeError:
return fallback
if raw not in NOTIFICATION_TITLE_STYLES:
return fallback
return raw
def _configured_service_exists(self, service: str) -> bool:
"""Return True if the configured notify target exists right now.
Either a registered ``notify.<name>`` service (legacy: mobile_app,
notify groups) OR a notify *entity* (newer model, sent via
``notify.send_message``). Otherwise the repair issue would false-fire on
a perfectly valid entity target.
"""
domain, _, name = service.partition(".")
if not name:
return False
if self.hass.services.has_service(domain, name):
return True
return self.hass.states.get(service) is not None
@callback
def async_verify_configured_service(self) -> None:
"""Raise/clear a repair issue when the configured notify service is gone.
Notifications are sent with ``blocking=False``, so a configured service
that no longer exists (renamed mobile app, removed notify group, …) fails
*silently*. This surfaces that as a Home Assistant repair issue instead.
Transition-gated and idempotent: cheap to call on every send attempt, on
global-options changes, and once HA has started. Scope is deliberately the
**top-level** configured service only — a broken member *inside* a notify
group is invisible from here (HA dispatches to the working members and only
logs the bad one in its own system log), so we never flag, nor false-alarm
on, that case.
"""
service = self.notify_service
# Event-only delivery never calls the service, so a stale name is
# not a fault to repair (bug audit 2026-09-12).
missing = bool(service) and self.enabled and not self.event_only and not self._configured_service_exists(service)
# None (first call) never equals a bool, so the registry is reconciled
# once at startup — clearing any issue persisted from a previous run.
if missing == self._notify_issue_active:
return
self._notify_issue_active = missing
if missing:
_LOGGER.warning(
"Configured notify service '%s' is not available — notifications will silently fail; raising a repair issue",
service,
)
ir.async_create_issue(
self.hass,
DOMAIN,
_NOTIFY_SERVICE_MISSING_ISSUE_ID,
is_fixable=False,
severity=ir.IssueSeverity.WARNING,
translation_key="notify_service_missing",
translation_placeholders={"service": service},
)
else:
ir.async_delete_issue(self.hass, DOMAIN, _NOTIFY_SERVICE_MISSING_ISSUE_ID)
def _is_status_enabled(self, status: str) -> bool:
"""Check if notifications for this specific status are enabled."""
return status_reminder_enabled(self._global_options, status)
def _get_interval_hours(self, status: str) -> int:
"""Get repeat interval for a status. 0 = single notification."""
key = _STATUS_INTERVAL_KEYS.get(status, CONF_NOTIFY_DUE_SOON_INTERVAL)
return int(self._opt(key))
def _is_quiet_hours(self) -> bool:
"""Check if current time is in quiet hours."""
# Quiet hours default: enabled (matches config flow)
if not self._opt(CONF_QUIET_HOURS_ENABLED):
return False
start_str = self._opt(CONF_QUIET_HOURS_START)
end_str = self._opt(CONF_QUIET_HOURS_END)
try:
start = time.fromisoformat(start_str)
end = time.fromisoformat(end_str)
except (ValueError, TypeError):
return False
now = dt_util.now().time()
if start <= end:
return start <= now <= end
# Overnight quiet hours (e.g., 22:00 - 08:00)
return now >= start or now <= end
def _check_daily_limit(self) -> bool:
"""Check if daily notification limit has been reached."""
self._roll_day()
max_per_day = self._opt(CONF_MAX_NOTIFICATIONS_PER_DAY)
if max_per_day > 0 and self._daily_count >= max_per_day:
_LOGGER.debug("Daily notification limit reached (%s/%s)", self._daily_count, max_per_day)
return False
return True
def _roll_day(self) -> None:
"""Reset the daily counter at the first check of a new day; whoever was
turned away yesterday and never served becomes today's ``_starved``."""
today = dt_util.now().date()
if self._daily_reset_date == today:
return
self._starved = {k for k in self._deferred_today if k not in self._served_today}
self._deferred_today.clear()
self._served_today.clear()
self._daily_count = 0
self._daily_reset_date = today
self._dirty()
# ── persistence ──────────────────────────────────────────────────────
async def async_load(self) -> None:
"""Restore the bookkeeping saved by the previous process."""
raw = await self._store.async_load()
if not isinstance(raw, dict):
return
def _dt(value: Any) -> datetime | None:
if value == "once":
return _SENT_ONCE
parsed = dt_util.parse_datetime(value) if isinstance(value, str) else None
return parsed
for key, value in (raw.get("last_notified") or {}).items():
parsed = _dt(value)
if parsed is not None:
self._last_notified[str(key)] = parsed
for key, value in (raw.get("snoozed_until") or {}).items():
parsed = _dt(value)
if parsed is not None and parsed is not _SENT_ONCE:
self._snoozed_until[str(key)] = parsed
for key, value in (raw.get("delivered_at") or {}).items():
parsed = _dt(value)
if parsed is not None and parsed is not _SENT_ONCE:
self._delivered_at[str(key)] = parsed
lead = raw.get("lead_sent")
if isinstance(lead, dict):
self._lead_sent = {str(k): str(v) for k, v in lead.items()}
try:
self._daily_count = max(0, int(raw.get("daily_count") or 0))
except (TypeError, ValueError):
self._daily_count = 0
reset = raw.get("daily_reset_date")
self._daily_reset_date = date.fromisoformat(reset) if isinstance(reset, str) else None
for name in ("served_today", "deferred_today", "starved"):
values = raw.get(name)
if isinstance(values, list):
setattr(self, f"_{name}", {str(v) for v in values})
held = raw.get("quiet_held")
if isinstance(held, dict):
self._quiet_held = {str(k): dict(v) for k, v in held.items() if isinstance(v, dict)}
def _snapshot(self) -> dict[str, Any]:
def _ser(value: datetime) -> str:
return "once" if value == _SENT_ONCE else value.isoformat()
return {
"last_notified": {k: _ser(v) for k, v in self._last_notified.items()},
"snoozed_until": {k: v.isoformat() for k, v in self._snoozed_until.items()},
"delivered_at": {k: v.isoformat() for k, v in self._delivered_at.items()},
"lead_sent": dict(self._lead_sent),
"daily_count": self._daily_count,
"daily_reset_date": self._daily_reset_date.isoformat() if self._daily_reset_date else None,
"served_today": sorted(self._served_today),
"deferred_today": sorted(self._deferred_today),
"starved": sorted(self._starved),
"quiet_held": dict(self._quiet_held),
}
def _dirty(self) -> None:
"""Schedule a save of the bookkeeping (debounced; nothing is lost on a
clean shutdown because HA flushes delayed saves at stop)."""
self._store.async_delay_save(self._snapshot, _STATE_SAVE_DELAY)
# ── quiet hours: hold, summarise, log once ───────────────────────────
def _quiet_period_id(self) -> str:
"""Identifies the current quiet period (the day it started + start
time) — the unit the skip is logged per."""
start = str(self._opt(CONF_QUIET_HOURS_START))
now = dt_util.now()
try:
start_t = time.fromisoformat(start)
except (TypeError, ValueError):
start_t = time(0, 0)
day = now.date() if now.time() >= start_t else (now - timedelta(days=1)).date()
return f"{day.isoformat()}T{start}"
def _note_quiet_skip(self, what: str) -> None:
"""Log the quiet-hours skip ONCE per quiet period (it used to log on
every refresh — thousands of lines a night)."""
period = self._quiet_period_id()
if self._quiet_logged_for == period:
return
self._quiet_logged_for = period
_LOGGER.debug("Quiet hours (%s): holding %s — held reminders go out as one summary at the end", period, what)
def _hold_for_quiet_end(self, key: str, facts: dict[str, Any]) -> None:
self._quiet_held[key] = facts
self._note_quiet_skip("a reminder")
self._dirty()
async def async_flush_quiet_held(self) -> bool:
"""Deliver what quiet hours held back as ONE household summary. Called
at the first send attempt after quiet hours; True when a summary went
out. Every member is stamped as sent, so the per-task reminders that
follow in the same refresh wave stay silent."""
if not self._quiet_held or self._is_quiet_hours():
return False
if not self.enabled or not self._has_target or not self._check_daily_limit():
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 ""))
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")
context = notification_context(
self.hass,
KIND_QUIET_END,
tasks=[
{
"entry_id": f.get("entry_id"),
"task_id": f.get("task_id"),
"task_name": f.get("task_name"),
"object_name": f.get("object_name"),
"status": f.get("status"),
"days_until_due": f.get("days_until_due"),
"next_due": f.get("next_due"),
}
for f in held
],
)
try:
sent = await async_emit_and_dispatch(self.hass, self.notify_service, service_data, context)
except (HomeAssistantError, ValueError, TypeError):
_LOGGER.exception("Failed to send the quiet-hours summary")
return False
if not sent:
return False
keys: list[str] = []
for key, f in self._quiet_held.items():
self._stamp_status_sent(key, self._get_interval_hours(str(f.get("status"))))
if f.get("entry_id") and f.get("task_id"):
keys.append(task_key_of(str(f["entry_id"]), str(f["task_id"])))
self._quiet_held.clear()
self._mark_delivered(keys)
_LOGGER.debug("Quiet-hours summary sent: %s", title)
return True
@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)
def _admit(self, keys: list[str], rank: int) -> bool:
"""The daily-limit decision for a task (or a bundle's member tasks).
Without a limit every send passes. With a limit the last slots are
not first-come-first-served any more:
* **Priority** — the last 10 % of the limit (rounded down) are kept
for high-priority tasks; low-priority tasks stop at 20 %. Below a
limit of 10 those reserves are 0 and priority only orders a refresh.
* **Starved first** — a task turned away yesterday and never served
is guaranteed today: while such tasks are still waiting and the
remaining budget would not cover them, other normal/low tasks wait.
* **First-timers before repeats** — a task that already got a message
today yields its repeat while a task that was turned away today is
still waiting.
A refused task is not stamped, so the next refresh offers it again —
that is how the held budget reaches the waiting tasks.
"""
self._roll_day()
max_per_day = int(self._opt(CONF_MAX_NOTIFICATIONS_PER_DAY) or 0)
if max_per_day <= 0:
return True
remaining = max_per_day - self._daily_count
reason: str | None = None
if remaining <= 0:
reason = "daily limit reached"
elif rank == 2 and remaining <= max_per_day // 5:
reason = "last 20 % kept for normal/high priority"
elif rank == 1 and remaining <= max_per_day // 10:
reason = "last 10 % kept for high priority"
elif rank != 0:
waiting_starved = [k for k in self._starved if k not in self._served_today]
if waiting_starved and not any(k in self._starved for k in keys) and remaining <= len(waiting_starved):
reason = f"{len(waiting_starved)} task(s) starved yesterday go first"
elif all(k in self._served_today for k in keys) and any(k not in self._served_today for k in self._deferred_today):
reason = "repeat yields to tasks still waiting today"
if reason is not None:
self._deferred_today.update(keys)
self._dirty()
_LOGGER.debug("Holding notification for %s (%s/%s sent): %s", keys, self._daily_count, max_per_day, reason)
return False
return True
def _mark_delivered(self, keys: list[str]) -> None:
"""Bookkeeping after a successful send: counts, served set, starvation."""
self._daily_count += 1
now = dt_util.now()
for k in keys:
self._served_today.add(k)
self._deferred_today.discard(k)
self._starved.discard(k)
self._delivered_at[k] = now
self._dirty()
def _is_snoozed(self, key: str) -> bool:
"""Check if a notification key is snoozed."""
until = self._snoozed_until.get(key)
if until is None:
return False
if dt_util.now() >= until:
# Snooze expired
del self._snoozed_until[key]
return False
return True
def is_snoozed(self, entry_id: str, task_id: str, status: str) -> bool:
"""Is the task's ``status`` reminder under an active snooze? (The
per-task gate's view of the snooze state — notification_gates.)"""
return self._is_snoozed(notification_key(entry_id, task_id, status))
def snooze_task(self, entry_id: str, task_id: str) -> None:
"""Snooze all notifications for a task."""
hours = self._opt(CONF_SNOOZE_DURATION_HOURS)
until = dt_util.now() + timedelta(hours=hours)
# Snooze for all status types
for status in NOTIFIABLE_STATUSES:
self._snoozed_until[notification_key(entry_id, task_id, status)] = until
self._dirty()
_LOGGER.debug("Snoozed task %s for %s hours (until %s)", task_id, hours, until)
async def async_task_status_changed(
self,
entry_id: str,
task_id: str,
task_name: str,
object_name: str,
new_status: str,
days_until_due: int | None = None,
next_due: str | None = None,
responsible_user_id: str | None = None,
task_data: Mapping[str, Any] | None = None,
) -> None:
"""Handle status change / repeat check and send notification if appropriate.
``task_data`` is the task's config dict for the per-task gates (mute,
saved-view scope); looked up from the entry when not given.
"""
# Keep the "configured notify service missing" repair issue in sync with
# reality on every attempt (cheap; transition-gated internally).
self.async_verify_configured_service()
if not self.enabled:
return
# Only notify for certain statuses
if new_status not in STATUS_ENABLED_KEYS:
return
# The per-task gates the status kind declares (status toggle, mute,
# scope, vacation, snooze) - one place, notification_gates.
gate = task_may_notify(
self.hass,
entry_id,
task_id,
new_status,
task_data if task_data is not None else self._task_config(entry_id, task_id),
kind=KIND_STATUS,
manager=self,
)
if not gate:
_LOGGER.debug("Skipping %s notification for %s (%s)", new_status, task_id, gate.blocked_by)
return
# Rate limiting / interval
key = notification_key(entry_id, task_id, new_status)
interval_hours = self._get_interval_hours(new_status)
if not self._status_due(key, interval_hours):
return
# Quiet hours: hold the reminder — it goes out in the one summary at
# the end (not as a burst of single pushes on the first refresh after).
if self._is_quiet_hours():
self._hold_for_quiet_end(
key,
{
"entry_id": entry_id,
"task_id": task_id,
"task_name": task_name,
"object_name": object_name,
"status": new_status,
"days_until_due": days_until_due,
"next_due": next_due,
"responsible_user_id": responsible_user_id,
},
)
return
if await self.async_flush_quiet_held() and not self._status_due(key, interval_hours):
return # this task was part of the summary
# Daily limit with priority + fairness (a refusal is not stamped, so
# the next refresh offers the task again).
task_key = task_key_of(entry_id, task_id)
if not self._admit([task_key], self._priority_rank(task_data if task_data is not None else self._task_config(entry_id, task_id))):
return
# Build translated message
lang = self._lang
title, message = self._build_message(new_status, lang, task_name, object_name, days_until_due, next_due)
context = notification_context(
self.hass,
KIND_STATUS,
status=new_status,
entry_id=entry_id,
task_id=task_id,
task_name=task_name,
object_name=object_name,
days_until_due=days_until_due,
next_due=next_due,
responsible_user_id=responsible_user_id,
)
if not await self._resolve_and_send(
responsible_user_id,
title=title,
message=message,
entry_id=entry_id,
task_id=task_id,
context=context,
):
return
self._stamp_status_sent(key, interval_hours)
self._mark_delivered([task_key])
_LOGGER.debug("Notification sent: %s - %s", title, message)
async def async_task_completed(
self,
*,
entry_id: str,
task_id: str,
task_name: str,
object_name: str,
source: str | None,
completed_by: str | None = None,
completed_at: str | None = None,
task_data: Mapping[str, Any] | None = None,
) -> bool:
"""#173 follow-up: "activity" notification for a completion — household
news, nothing to do. Off by default; ``automatic`` announces only
completions no person made on the spot (sensor recovery, shopping-list
sync, an automation's service call), ``all`` every one. Gates: the
per-task mute, the saved-view scope, quiet hours and the daily cap —
no repeat, no snooze (see the notification matrix). Routed to the
household service only: a completion is shared news, and the person
who just pressed Complete needs no push about it.
"""
from ..const import COMPLETION_SOURCES_AUTOMATIC, CONF_NOTIFY_COMPLETED
from .notify_hooks import KIND_COMPLETED, async_emit_and_dispatch, notification_context
mode = str(self._opt(CONF_NOTIFY_COMPLETED))
if mode == "off" or not self.enabled or not self._has_target:
return False
src = source or "unknown"
if mode == "automatic" and src not in COMPLETION_SOURCES_AUTOMATIC:
return False
# The per-task gates the completed kind declares (mute, scope) — only
# with a task dict to judge (a caller without one gets no mute/scope).
if task_data is not None and not task_may_notify(
self.hass, entry_id, task_id, MaintenanceStatus.DUE_SOON, task_data, kind=KIND_COMPLETED, manager=self
):
return False
if self._is_quiet_hours():
self._note_quiet_skip("a completion notification")
return False
if not self._check_daily_limit():
return False
lang = self._lang
who = ""
actor_name: str | None = None
if completed_by:
user = await self.hass.auth.async_get_user(completed_by)
actor_name = user.name if user else None
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)
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,
entry_id=entry_id,
task_id=task_id,
task_name=task_name,
object_name=object_name,
reason=src,
completed_by=completed_by,
completed_by_name=actor_name,
completed_at=completed_at,
)
# 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"]))
sent = await async_emit_and_dispatch(self.hass, self.notify_service, service_data, context)
if sent:
self._daily_count += 1
return sent
def _build_message(
self,
status: str,
lang: str,
task_name: str,
object_name: str,
days_until_due: int | None,
next_due: str | None,
) -> tuple[str, str]:
"""Build translated notification title and message."""
if status == MaintenanceStatus.DUE_SOON:
title = _notif_t("due_soon_title", lang)
message = _notif_t(
"due_soon_message",
lang,
task=task_name,
object=object_name,
days=str(days_until_due) if days_until_due is not None else "?",
due=next_due if next_due is not None else "?",
)
elif status == MaintenanceStatus.OVERDUE:
title = _notif_t("overdue_title", lang)
days_overdue = str(abs(days_until_due)) if days_until_due is not None else "?"
message = _notif_t(
"overdue_message",
lang,
task=task_name,
object=object_name,
days=days_overdue,
)
elif status == MaintenanceStatus.TRIGGERED:
title = _notif_t("triggered_title", lang)
message = _notif_t(
"triggered_message",
lang,
task=task_name,
object=object_name,
)
else:
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.
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
async def _async_send_notification_to_service(
self,
service: str,
title: str,
message: str,
entry_id: str,
task_id: str,
context: Mapping[str, Any] | None = None,
) -> bool:
"""Send notification via specific service, optionally with action buttons.
Args:
service: Full service name like "notify.mobile_app_device"
title: Notification title
message: Notification message
entry_id: Config entry ID for action buttons
task_id: Task ID for action buttons
Returns:
True if the notification was sent successfully, False otherwise.
"""
# Build action buttons for Companion App
actions = build_action_buttons(
self.hass,
self._global_options,
self._lang,
entry_id=entry_id,
task_id=task_id,
skip_allowed=self._skip_allowed(entry_id, task_id),
)
service_data = _service_payload(
title,
message,
tag=f"maintenance_{task_id}",
url=f"/maintenance-supporter?entry_id={entry_id}&task_id={task_id}",
)
if actions:
service_data["data"]["actions"] = actions
if context is None:
context = notification_context(self.hass, KIND_STATUS, entry_id=entry_id, task_id=task_id)
try:
return await async_emit_and_dispatch(self.hass, service, service_data, context)
except (HomeAssistantError, ValueError, TypeError):
_LOGGER.exception("Failed to send notification to %s", service)
return False
async def async_send_bundled(
self,
entry_id: str,
object_name: str,
tasks: list[dict[str, Any]],
) -> None:
"""Send a single bundled notification summarising multiple tasks."""
self.async_verify_configured_service()
if not self.enabled or not self._has_target:
return
if self._is_quiet_hours():
for t in tasks:
if not t.get("task_id"):
continue
key = notification_key(entry_id, t["task_id"], t["status"])
if self._status_due(key, self._get_interval_hours(t["status"])):
self._hold_for_quiet_end(key, {"entry_id": entry_id, "task_id": t["task_id"], "task_name": t.get("task_name"), "object_name": object_name, "status": t["status"], "days_until_due": t.get("days_until_due")})
return
await self.async_flush_quiet_held()
# Rate-limit bundled notifications (once per hour)
bundle_key = f"{entry_id}_bundled"
if self._rate_limited(bundle_key, 3600):
return
# The per-task gates the bundle kind declares (mute, scope, vacation,
# snooze) are enforced HERE, per member, so a bundle honours them even
# without the coordinator's pre-filter (DRY review 2026-09-12).
# Bug audit 2026-09-12: a bundle used to repeat every hour for as long
# as N tasks were pending, ignoring the per-status repeat intervals
# and the "notify once" statuses. It now rides the same bookkeeping as
# the per-task path: only tasks whose own status reminder is due are
# announced, and every announced task is stamped, so nothing is
# re-announced when the bundle later dissolves.
due = [
t
for t in tasks
if not t.get("task_id")
or (
task_may_notify(
self.hass, entry_id, t["task_id"], t["status"], self._task_config(entry_id, t["task_id"]), kind=KIND_BUNDLE, manager=self
)
and self._status_due(notification_key(entry_id, t["task_id"], t["status"]), self._get_interval_hours(t["status"]))
)
]
if not due:
return
tasks = due
member_keys = [task_key_of(entry_id, t["task_id"]) for t in tasks if t.get("task_id")]
best_rank = min((self._priority_rank(self._task_config(entry_id, t["task_id"])) for t in tasks if t.get("task_id")), default=1)
if not self._admit(member_keys, best_rank):
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"]))
title = _notif_t("bundled_title", lang, count=str(len(tasks)))
message = _notif_t("bundled_message", lang, object=object_name, task_list=", ".join(task_parts))
# v1.4.0 (#44): for bundled notifications, "object_name" style still
# prefers the object as the title; "task_name" doesn't map cleanly
# for multi-task bundles, so we leave the count-based default.
if self.title_style == "object_name" and object_name:
title = object_name
service_data = _service_payload(
title,
message,
tag=f"maintenance_bundled_{entry_id}",
url=f"/maintenance-supporter?entry_id={entry_id}",
)
context = notification_context(
self.hass,
KIND_BUNDLE,
entry_id=entry_id,
object_name=object_name,
tasks=[
{"task_id": t.get("task_id"), "task_name": t.get("task_name"), "status": t.get("status")}
for t in tasks
],
)
try:
if await async_emit_and_dispatch(self.hass, self.notify_service, service_data, context):
self._last_notified[bundle_key] = dt_util.now()
self._dirty()
for t in tasks:
if t.get("task_id"):
self._stamp_status_sent(notification_key(entry_id, t["task_id"], t["status"]), self._get_interval_hours(t["status"]))
self._mark_delivered(member_keys)
_LOGGER.debug("Bundled notification sent: %s - %s", title, message)
except (HomeAssistantError, ValueError, TypeError):
_LOGGER.exception("Failed to send bundled notification")
async def async_send_weekly_digest(self, overdue: int, due_soon: int) -> None:
"""Send the opt-in weekly summary notification.
One message with the cross-object counts. Unlike the reactive per-task
notifications this is a scheduled once-a-week send, so it deliberately
skips the rate-limit / quiet-hours gating (it fires at a fixed morning
hour the user chose by enabling the digest).
"""
self.async_verify_configured_service()
if not self.enabled or not self._has_target:
return
lang = self._lang
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",
)
context = notification_context(self.hass, KIND_DIGEST, overdue=overdue, due_soon=due_soon)
try:
await async_emit_and_dispatch(self.hass, self.notify_service, service_data, context)
_LOGGER.debug("Weekly digest sent: %s overdue, %s due soon", overdue, due_soon)
except (HomeAssistantError, ValueError, TypeError):
_LOGGER.exception("Failed to send weekly digest")
async def async_send_warranty_reminder(self, names: list[str], days: int) -> None:
"""Send the opt-in warranty-expiry reminder — one message listing the
objects whose warranty expires within ``days`` days. Like the digest,
a scheduled once-a-day send that skips rate-limit / quiet-hours gating.
"""
self.async_verify_configured_service()
if not self.enabled or not self._has_target or not names:
return
lang = self._lang
service_data = _service_payload(
_notif_t("warranty_title", lang),
_notif_t(
"warranty_message",
lang,
count=str(len(names)),
days=str(days),
names=", ".join(names),
),
tag="maintenance_warranty_reminder",
)
context = notification_context(self.hass, KIND_WARRANTY, names=list(names), days=days)
try:
await async_emit_and_dispatch(self.hass, self.notify_service, service_data, context)
_LOGGER.debug("Warranty reminder sent: %s object(s)", len(names))
except (HomeAssistantError, ValueError, TypeError):
_LOGGER.exception("Failed to send warranty reminder")
async def async_send_lead_reminder(
self,
entry_id: str,
task_id: str,
task_name: str,
object_name: str,
days: int,
next_due: str | None = None,
responsible_user_id: str | None = None,
) -> None:
"""Send one lead-time reminder (task is due in exactly ``days`` days).
Fired by the daily lead-reminder tick when a task's days-until-due
matches one of the configured ``reminder_lead_days``. Reuses the
due-soon strings (same message shape) and the per-user routing of the
status-change path. Honours quiet hours, vacation mode, snooze, and
the daily limit. Sent at most once per task, lead and day: the 08:00
tick and the noon retry both call this, and only a delivered reminder
is stamped, so a quiet-hours skip at 08:00 still goes out at noon.
"""
self.async_verify_configured_service()
if not self.enabled:
return
lead_key = f"{entry_id}_{task_id}_{days}"
today = dt_util.now().date().isoformat()
if self._lead_sent.get(lead_key) == today:
return
if self._is_quiet_hours():
self._note_quiet_skip("a lead-time reminder")
return
await self.async_flush_quiet_held()
# The per-task gates the lead-time kind declares (mute, vacation,
# snooze — an active snooze on the due-soon key silences leads too).
if not task_may_notify(
self.hass,
entry_id,
task_id,
MaintenanceStatus.DUE_SOON,
self._task_config(entry_id, task_id),
kind=KIND_LEAD_TIME,
manager=self,
):
return
lead_task_key = task_key_of(entry_id, task_id)
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 "?",
)
context = notification_context(
self.hass,
KIND_LEAD_TIME,
status="due_soon",
entry_id=entry_id,
task_id=task_id,
task_name=task_name,
object_name=object_name,
days_until_due=days,
next_due=next_due,
responsible_user_id=responsible_user_id,
)
if await self._resolve_and_send(
responsible_user_id,
title=title,
message=message,
entry_id=entry_id,
task_id=task_id,
context=context,
):
self._mark_delivered([lead_task_key])
# Keep only today's stamps - yesterday's are dead weight.
self._lead_sent = {k: d for k, d in self._lead_sent.items() if d == today}
self._lead_sent[lead_key] = today
self._dirty()
_LOGGER.debug("Lead reminder sent: %s due in %s day(s)", task_name, days)
async def async_budget_alert(
self,
period: str,
spent: float,
budget: float,
currency_symbol: str = BUDGET_CURRENCIES[DEFAULT_BUDGET_CURRENCY],
decimals: int = DEFAULT_CURRENCY_DECIMALS,
) -> None:
"""Send a budget threshold alert notification."""
if not self.enabled or not self._has_target:
return
if self._is_quiet_hours():
self._note_quiet_skip("a budget alert")
return
# Rate-limit budget alerts (once per 24 hours per period)
budget_key = f"_budget_{period}"
if self._rate_limited(budget_key, 86400):
return
if not self._check_daily_limit():
return
pct = round(spent / budget * 100) if budget > 0 else 0
lang = self._lang
title = _notif_t("budget_alert_title", lang)
key = f"budget_alert_{period}"
message = _notif_t(
key,
lang,
pct=str(pct),
spent=f"{spent:.{decimals}f}{currency_symbol}",
budget=f"{budget:.{decimals}f}{currency_symbol}",
)
service_data = _service_payload(title, message, tag=f"maintenance_budget_{period}")
context = notification_context(self.hass, KIND_BUDGET, period=period, spent=spent, budget=budget, percent=pct)
try:
if await async_emit_and_dispatch(self.hass, self.notify_service, service_data, context):
self._last_notified[budget_key] = dt_util.now()
self._daily_count += 1
_LOGGER.debug("Budget alert sent: %s - %s", title, message)
except (HomeAssistantError, ValueError, TypeError):
_LOGGER.exception("Failed to send budget alert")
def begin_startup_seed(self, entry_id: str) -> bool:
"""True the FIRST time an entry's coordinator refreshes in this process
- only then may it seed ``_last_notified`` for the tasks that are
already notifiable. Every later coordinator instance (the entry is
reloaded on each task edit) inherits the manager's live state: stamps
of what was sent survive, and a notification that was still pending
(quiet hours, daily cap) is not silently marked as sent (bug audit
2026-09-12)."""
if entry_id in self._seeded_entries:
return False
self._seeded_entries.add(entry_id)
return True
def seed_startup_state(self, entry_id: str, task_id: str, status: str) -> None:
"""Seed notification state for a task that is already notifiable at startup.
Called once on first coordinator refresh to prevent a burst of stale
notifications. Sets the ``_last_notified`` timestamp so the repeat
interval starts *now* rather than firing immediately.
"""
key = notification_key(entry_id, task_id, status)
if key in self._last_notified:
return # persisted from the previous process — keep the real stamp
interval_hours = self._get_interval_hours(status)
if interval_hours == 0:
self._last_notified[key] = _SENT_ONCE
else:
self._last_notified[key] = dt_util.now()
self._dirty()
def clear_task_state(self, entry_id: str, task_id: str) -> None:
"""Clear notification state for a task (after completion/reset)."""
for status in NOTIFIABLE_STATUSES:
key = notification_key(entry_id, task_id, status)
self._last_notified.pop(key, None)
self._snoozed_until.pop(key, None)
self._quiet_held.pop(key, None)
self._dirty()
async def async_dismiss_task_notification(self, task_id: str, responsible_user_id: str | None = None) -> None:
"""Dismiss a task notification on Companion App devices.
``clear_notification`` is a legacy mobile_app service feature; the notify
*entity* model (notify.send_message) has no equivalent, so an entity-only
target can't be dismissed and is simply skipped. Reminders for a task
with a responsible person land on THAT person's devices (see
_resolve_and_send), so those are cleared too (bug audit 2026-09-12).
"""
services = [self.notify_service]
if responsible_user_id:
try:
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}"
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)):
continue
try:
await self.hass.services.async_call(
domain,
name,
{"message": "clear_notification", "data": {"tag": tag}},
blocking=False,
)
except (HomeAssistantError, ValueError, TypeError):
_LOGGER.debug("Failed to dismiss notification for tag %s on %s", tag, service)
async def async_unload(self) -> None:
"""Clean up the notification manager (the bookkeeping is saved first —
the next manager instance loads it)."""
await self._store.async_save(self._snapshot())
self._last_notified.clear()
self._snoozed_until.clear()
self._daily_count = 0
self._daily_reset_date = None
self._served_today.clear()
self._deferred_today.clear()
self._starved.clear()
self._delivered_at.clear()