"""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.util import dt as dt_util from ..const import ( 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_ENABLED, CONF_NOTIFY_DUE_SOON_INTERVAL, CONF_NOTIFY_OVERDUE_ENABLED, CONF_NOTIFY_OVERDUE_INTERVAL, CONF_NOTIFY_SERVICE, CONF_NOTIFY_TRIGGERED_ENABLED, CONF_NOTIFY_TRIGGERED_INTERVAL, CONF_QUIET_HOURS_ENABLED, CONF_QUIET_HOURS_END, CONF_QUIET_HOURS_START, CONF_SNOOZE_DURATION_HOURS, DEFAULT_MAX_NOTIFICATIONS_PER_DAY, DEFAULT_SNOOZE_DURATION_HOURS, DOMAIN, GLOBAL_UNIQUE_ID, MaintenanceStatus, ) from .i18n import normalize_language _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 # --- Notification message translations --- _NOTIFICATION_STRINGS: dict[str, dict[str, str]] = { "de": { "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}", "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}", "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": { "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}", "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}", "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": { "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}", "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}", "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": { "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}", "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}", "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": { "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}", "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}", "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": { "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}", "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}", "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": { "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}", "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}", "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": { "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}", "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}", "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": { "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}", "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}", "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": { "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}", "digest_title": "週間メンテナンスまとめ", "digest_message": "期限切れ {overdue} 件、今週 {due_soon} 件。", "warranty_title": "Warranty expiring soon", "warranty_message": "{count} object(s) with warranty expiring within {days} days: {names}", "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": { "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}", "digest_title": "साप्ताहिक रखरखाव सारांश", "digest_message": "{overdue} अतिदेय, {due_soon} इस सप्ताह।", "warranty_title": "Warranty expiring soon", "warranty_message": "{count} object(s) with warranty expiring within {days} days: {names}", "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": { "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}", "digest_title": "每周维护摘要", "digest_message": "逾期 {overdue} 项,本周 {due_soon} 项。", "warranty_title": "Warranty expiring soon", "warranty_message": "{count} object(s) with warranty expiring within {days} days: {names}", "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": { "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}", "digest_title": "Еженедельная сводка обслуживания", "digest_message": "{overdue} просрочено, {due_soon} на этой неделе.", "warranty_title": "Warranty expiring soon", "warranty_message": "{count} object(s) with warranty expiring within {days} days: {names}", "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": { "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}", "digest_title": "Щотижневий огляд обслуговування", "digest_message": "{overdue} прострочено, {due_soon} цього тижня.", "warranty_title": "Warranty expiring soon", "warranty_message": "{count} object(s) with warranty expiring within {days} days: {names}", "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": { "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}", "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}", "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": { "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}", "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}", "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": { "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}", "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}", "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": { "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}", "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}", "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": { "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}", "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}", "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": { "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}", "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}", "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": { "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}", "digest_title": "주간 유지보수 요약", "digest_message": "기한 초과 {overdue}건, 이번 주 예정 {due_soon}건.", "warranty_title": "보증 기간 만료 임박", "warranty_message": "{days}일 이내에 보증이 만료되는 객체 {count}개: {names}", "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": { "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}", "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}", "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})", }, } 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_`` # where = 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_``, 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 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 _STATUS_ENABLED_KEYS: dict[str, str] = { MaintenanceStatus.DUE_SOON: CONF_NOTIFY_DUE_SOON_ENABLED, MaintenanceStatus.OVERDUE: CONF_NOTIFY_OVERDUE_ENABLED, MaintenanceStatus.TRIGGERED: CONF_NOTIFY_TRIGGERED_ENABLED, } _STATUS_INTERVAL_KEYS: dict[str, tuple[str, int]] = { MaintenanceStatus.DUE_SOON: (CONF_NOTIFY_DUE_SOON_INTERVAL, 24), MaintenanceStatus.OVERDUE: (CONF_NOTIFY_OVERDUE_INTERVAL, 12), MaintenanceStatus.TRIGGERED: (CONF_NOTIFY_TRIGGERED_INTERVAL, 0), } 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 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.""" for entry in self.hass.config_entries.async_entries(DOMAIN): if entry.unique_id == GLOBAL_UNIQUE_ID: return entry.options or entry.data return {} @property def _lang(self) -> str: """Get the HA UI language as a 2-letter table key.""" return normalize_language(self.hass) @property def enabled(self) -> bool: """Check if notifications are globally enabled.""" return bool(self._global_options.get(CONF_NOTIFICATIONS_ENABLED, False)) @property def notify_service(self) -> str: """Get the configured notify service.""" return str(self._global_options.get(CONF_NOTIFY_SERVICE, "")) @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`). """ try: raw = str(self._global_options.get(CONF_NOTIFICATION_TITLE_STYLE, "default")) except AttributeError: return "default" if raw not in ("default", "object_name", "task_name"): return "default" return raw def _configured_service_exists(self, service: str) -> bool: """Return True if the configured notify target exists right now. Either a registered ``notify.`` 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 missing = bool(service) and self.enabled 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.""" key = _STATUS_ENABLED_KEYS.get(status) if key is None: return False return bool(self._global_options.get(key, True)) def _get_interval_hours(self, status: str) -> int: """Get repeat interval for a status. 0 = single notification.""" entry = _STATUS_INTERVAL_KEYS.get(status) if entry is None: return 24 key, default = entry return int(self._global_options.get(key, default)) def _is_quiet_hours(self) -> bool: """Check if current time is in quiet hours.""" options = self._global_options # Quiet hours default: enabled (matches config flow) if not options.get(CONF_QUIET_HOURS_ENABLED, True): return False start_str = options.get(CONF_QUIET_HOURS_START, "22:00") end_str = options.get(CONF_QUIET_HOURS_END, "08:00") 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.""" today = dt_util.now().date() if self._daily_reset_date != today: self._daily_count = 0 self._daily_reset_date = today max_per_day = self._global_options.get(CONF_MAX_NOTIFICATIONS_PER_DAY, DEFAULT_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 _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 snooze_task(self, entry_id: str, task_id: str) -> None: """Snooze all notifications for a task.""" hours = self._global_options.get(CONF_SNOOZE_DURATION_HOURS, DEFAULT_SNOOZE_DURATION_HOURS) until = dt_util.now() + timedelta(hours=hours) # Snooze for all status types for status in (MaintenanceStatus.DUE_SOON, MaintenanceStatus.OVERDUE, MaintenanceStatus.TRIGGERED): key = f"{entry_id}_{task_id}_{status}" self._snoozed_until[key] = until _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, ) -> None: """Handle status change / repeat check and send notification if appropriate.""" # 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 # Check if this status is enabled if not self._is_status_enabled(new_status): return # Check quiet hours if self._is_quiet_hours(): _LOGGER.debug("Skipping notification during quiet hours") return # Vacation mode (v1.2.0): suppress unless task is on the exempt list. # Sensor-triggered notifications use the same path so they're covered. from .vacation import get_vacation_state if get_vacation_state(self.hass).is_silent_for(task_id): _LOGGER.debug("Skipping notification — vacation mode active for %s", task_id) return # Rate limiting / interval key = f"{entry_id}_{task_id}_{new_status}" # Check snooze if self._is_snoozed(key): _LOGGER.debug("Notification snoozed for %s", key) return interval_hours = self._get_interval_hours(new_status) if key in self._last_notified: last = self._last_notified[key] if last == _SENT_ONCE: # interval=0: already sent once, never repeat return elapsed = (dt_util.now() - last).total_seconds() if interval_hours > 0 and elapsed < interval_hours * 3600: return # interval=0 but hasn't been sent yet → allow # Check daily limit if not self._check_daily_limit(): return # Build translated message lang = self._lang title, message = self._build_message(new_status, lang, task_name, object_name, days_until_due, next_due) # Determine target services: user-specific or global target_services = [] 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, ) # Fallback to global service if not target_services and self.notify_service: target_services = [self.notify_service] if not target_services: _LOGGER.warning("No notification services available") return # Send to all target services 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, ): success = True if not success: return # Record send time (only on success) if interval_hours == 0: self._last_notified[key] = _SENT_ONCE else: self._last_notified[key] = dt_util.now() self._daily_count += 1 _LOGGER.debug("Notification sent: %s - %s", title, message) 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, ) -> 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. """ options = self._global_options lang = self._lang # Build action buttons for Companion App actions: list[dict[str, str]] = [] if options.get(CONF_ACTION_COMPLETE_ENABLED, False): actions.append( { "action": f"MS_COMPLETE_{entry_id}_{task_id}", "title": f"\u2705 {_notif_t('action_complete', lang)}", } ) if options.get(CONF_ACTION_SKIP_ENABLED, False): actions.append( { "action": f"MS_SKIP_{entry_id}_{task_id}", "title": f"\u23ed\ufe0f {_notif_t('action_skip', lang)}", } ) if options.get(CONF_ACTION_SNOOZE_ENABLED, False): actions.append( { "action": f"MS_SNOOZE_{entry_id}_{task_id}", "title": f"\U0001f4a4 {_notif_t('action_snooze', lang)}", } ) service_data: dict[str, Any] = {"title": title, "message": message} data: dict[str, Any] = { "tag": f"maintenance_{task_id}", "url": f"/maintenance-supporter?entry_id={entry_id}&task_id={task_id}", "clickAction": f"/maintenance-supporter?entry_id={entry_id}&task_id={task_id}", } if actions: data["actions"] = actions[:3] # Android supports max 3 service_data["data"] = data try: return await async_dispatch_notify(self.hass, service, service_data) 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.notify_service: return if self._is_quiet_hours(): return # Rate-limit bundled notifications (once per hour) bundle_key = f"{entry_id}_bundled" if bundle_key in self._last_notified: last = self._last_notified[bundle_key] if last != _SENT_ONCE: elapsed = (dt_util.now() - last).total_seconds() if elapsed < 3600: return if not self._check_daily_limit(): 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: dict[str, Any] = { "title": title, "message": message, "data": { "tag": f"maintenance_bundled_{entry_id}", "url": f"/maintenance-supporter?entry_id={entry_id}", "clickAction": f"/maintenance-supporter?entry_id={entry_id}", }, } try: if await async_dispatch_notify(self.hass, self.notify_service, service_data): self._last_notified[bundle_key] = dt_util.now() self._daily_count += 1 _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.notify_service: return lang = self._lang service_data: dict[str, Any] = { "title": _notif_t("digest_title", lang), "message": _notif_t("digest_message", lang, overdue=str(overdue), due_soon=str(due_soon)), "data": { "tag": "maintenance_weekly_digest", "url": "/maintenance-supporter", "clickAction": "/maintenance-supporter", }, } try: await async_dispatch_notify(self.hass, self.notify_service, service_data) _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.notify_service or not names: return lang = self._lang service_data: dict[str, Any] = { "title": _notif_t("warranty_title", lang), "message": _notif_t( "warranty_message", lang, count=str(len(names)), days=str(days), names=", ".join(names), ), "data": { "tag": "maintenance_warranty_reminder", "url": "/maintenance-supporter", "clickAction": "/maintenance-supporter", }, } try: await async_dispatch_notify(self.hass, self.notify_service, service_data) _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; the once-per-day tick is the repeat gate, so no ``_last_notified`` bookkeeping is needed. """ self.async_verify_configured_service() if not self.enabled: return if self._is_quiet_hours(): return from .vacation import get_vacation_state if get_vacation_state(self.hass).is_silent_for(task_id): return # An active snooze silences lead reminders too (same key family the # snooze action writes). if self._is_snoozed(f"{entry_id}_{task_id}_{MaintenanceStatus.DUE_SOON}"): return if not self._check_daily_limit(): 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 "?", ) 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 if not target_services and self.notify_service: target_services = [self.notify_service] if not target_services: return 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, ): success = True if success: self._daily_count += 1 _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 = "€", ) -> None: """Send a budget threshold alert notification.""" if not self.enabled or not self.notify_service: return if self._is_quiet_hours(): return # Rate-limit budget alerts (once per 24 hours per period) budget_key = f"_budget_{period}" if budget_key in self._last_notified: last = self._last_notified[budget_key] if last != _SENT_ONCE: elapsed = (dt_util.now() - last).total_seconds() if elapsed < 86400: # 24 hours 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:.2f}{currency_symbol}", budget=f"{budget:.2f}{currency_symbol}", ) service_data: dict[str, Any] = { "title": title, "message": message, "data": { "tag": f"maintenance_budget_{period}", "url": "/maintenance-supporter", "clickAction": "/maintenance-supporter", }, } try: if await async_dispatch_notify(self.hass, self.notify_service, service_data): 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 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 = f"{entry_id}_{task_id}_{status}" 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() def clear_task_state(self, entry_id: str, task_id: str) -> None: """Clear notification state for a task (after completion/reset).""" for status in (MaintenanceStatus.DUE_SOON, MaintenanceStatus.OVERDUE, MaintenanceStatus.TRIGGERED): key = f"{entry_id}_{task_id}_{status}" self._last_notified.pop(key, None) self._snoozed_until.pop(key, None) async def async_dismiss_task_notification(self, task_id: str) -> 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. """ service = self.notify_service domain, _, name = service.partition(".") if not (name and self.hass.services.has_service(domain, name)): return tag = f"maintenance_{task_id}" 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", tag) async def async_unload(self) -> None: """Clean up the notification manager.""" self._last_notified.clear() self._snoozed_until.clear() self._daily_count = 0 self._daily_reset_date = None