This commit is contained in:
Home Assistant Version Control
2026-08-02 15:33:12 +00:00
parent 60dd13b52c
commit 2ce8792155
39 changed files with 1992 additions and 157 deletions
@@ -158,6 +158,7 @@ class NotificationCoordinator:
recipients_fired: list[str] = []
message = self._render_template(meta, context)
nav_url = self._resolve_nav_url(cfg)
# Multi-parent routing (#687): thin the PARENT recipients down per the
# configured policy. Child routes are never touched — a reminder for a
@@ -179,7 +180,7 @@ class NotificationCoordinator:
notify_service = self._resolve_notify_service(recipient_id)
if not notify_service:
continue
await self._send_to(notify_service, message, meta, context)
await self._send_to(notify_service, message, meta, context, nav_url)
recipients_fired.append(recipient_id)
# NOTE: deliberately no unconditional persistent_notification here.
@@ -283,6 +284,7 @@ class NotificationCoordinator:
}
message = "[TEST] " + self._render_template(meta, ctx)
cfg = self.storage.get_notification_config(type_id)
nav_url = self._resolve_nav_url(cfg)
sent: list[str] = []
for recipient_id, route in cfg.routes.items():
if not route.enabled:
@@ -290,7 +292,7 @@ class NotificationCoordinator:
notify_service = self._resolve_notify_service(recipient_id)
if not notify_service:
continue
await self._send_to(notify_service, message, meta, ctx)
await self._send_to(notify_service, message, meta, ctx, nav_url)
sent.append(recipient_id)
await self._fire_persistent_notification(type_id, message)
return sent
@@ -308,6 +310,13 @@ class NotificationCoordinator:
child.quiet_hours_start, child.quiet_hours_end, dt_util.now()
)
def _resolve_nav_url(self, cfg) -> str:
"""Tap target for this notification: per-type override, else global default."""
per_type = (getattr(cfg, "nav_url", "") or "").strip()
if per_type:
return per_type
return str(self.storage.get_setting("notification_nav_url", "/taskmate") or "").strip()
def _resolve_notify_service(self, recipient_id: str) -> str:
if recipient_id.startswith("child:"):
child_id = recipient_id.split(":", 1)[1]
@@ -349,7 +358,7 @@ class NotificationCoordinator:
async def _send_to(
self, notify_service: str, message: str,
meta: "NotificationTypeMeta", context: dict[str, Any],
meta: "NotificationTypeMeta", context: dict[str, Any], nav_url: str = "",
) -> None:
domain, service = (
notify_service.split(".", 1) if "." in notify_service
@@ -395,6 +404,13 @@ class NotificationCoordinator:
push["image"] = photo_url
push["attachment"] = {"url": photo_url, "content-type": "jpeg"}
# Tap target (#734): open a chosen place when the notification body is
# tapped. clickAction=Android, url=iOS — the same value works on both.
# Only the mobile app honours these; other backends ignore data, so skip.
if nav_url and service.startswith("mobile_app"):
push["clickAction"] = nav_url
push["url"] = nav_url
if push:
data["data"] = push
@@ -693,6 +709,15 @@ class NotificationCoordinator:
await self.storage.async_save()
await self.async_setup_schedules()
async def set_nav_url(self, type_id: str | None, nav_url: str) -> None:
"""Set the tap target — global (type_id falsy) or per notification type."""
nav_url = (nav_url or "").strip()
if type_id:
self.storage.set_notification_nav_url(type_id, nav_url)
else:
self.storage.set_setting("notification_nav_url", nav_url)
await self.storage.async_save()
def _has_outstanding_chores_today(self, child_id: str) -> bool:
"""Returns True if the child has at least one chore assigned today
that has no approved/pending completion yet."""
+1 -1
View File
@@ -17,5 +17,5 @@
"iot_class": "calculated",
"issue_tracker": "https://github.com/tempus2016/taskmate/issues",
"requirements": [],
"version": "5.0.1"
"version": "5.0.2"
}
+6 -1
View File
@@ -1194,6 +1194,7 @@ class NotificationConfig:
type_id: str
master_enabled: bool = False
routes: dict[str, NotificationRoute] = field(default_factory=dict)
nav_url: str = "" # tap target; "" = inherit global default
@classmethod
def from_dict(cls, data: dict[str, Any]) -> NotificationConfig:
@@ -1205,14 +1206,18 @@ class NotificationConfig:
rid: NotificationRoute.from_dict(rdata)
for rid, rdata in raw_routes.items()
},
nav_url=data.get("nav_url", "") or "",
)
def to_dict(self) -> dict[str, Any]:
return {
d: dict[str, Any] = {
"type_id": self.type_id,
"master_enabled": self.master_enabled,
"routes": {rid: r.to_dict() for rid, r in self.routes.items()},
}
if self.nav_url:
d["nav_url"] = self.nav_url
return d
@dataclass
+5
View File
@@ -728,6 +728,11 @@ class TaskMateStorage:
cfg.master_enabled = enabled
self._data.setdefault("notification_config", {})[type_id] = cfg.to_dict()
def set_notification_nav_url(self, type_id: str, nav_url: str) -> None:
cfg = self.get_notification_config(type_id)
cfg.nav_url = nav_url
self._data.setdefault("notification_config", {})[type_id] = cfg.to_dict()
def set_notification_route(
self, type_id: str, recipient_id: str, route: NotificationRoute
) -> None:
+15 -1
View File
@@ -154,6 +154,7 @@ WS_NOTIF_LIST_NOTIFY: Final = "taskmate/notifications/list_notify_service
WS_NOTIF_SET_STREAK_CUTOFF: Final = "taskmate/notifications/set_streak_cutoff"
WS_NOTIF_SET_ESCALATION: Final = "taskmate/notifications/set_escalation"
WS_NOTIF_SEND_TEST: Final = "taskmate/notifications/send_test"
WS_NOTIF_SET_NAV_URL: Final = "taskmate/notifications/set_nav_url"
# Calendar ICS feed (FEAT-10)
WS_CAL_GET_URL: Final = "taskmate/calendar/get_ics_url"
@@ -1909,6 +1910,7 @@ async def ws_notif_get_state(hass, connection, msg, coordinator):
"streak_at_risk_cutoff_time": c.storage.get_streak_at_risk_cutoff(),
"mandatory_escalation_reminder_minutes": c.storage.get_escalation_reminder_minutes(),
"mandatory_escalation_parent_minutes": c.storage.get_escalation_parent_minutes(),
"notification_nav_url": c.storage.get_setting("notification_nav_url", "/taskmate"),
},
}
connection.send_result(msg["id"], state)
@@ -2135,6 +2137,18 @@ async def ws_notif_send_test(hass, connection, msg, coordinator):
connection.send_result(msg["id"], {"sent": sent})
@websocket_api.websocket_command({
vol.Required("type"): WS_NOTIF_SET_NAV_URL,
vol.Optional("type_id"): vol.Any(str, None),
vol.Required("nav_url"): vol.All(str, vol.Length(max=200)),
})
@websocket_api.async_response
@_admin_only
async def ws_notif_set_nav_url(hass, connection, msg, coordinator):
await coordinator.notifications.set_nav_url(msg.get("type_id"), msg["nav_url"])
connection.send_result(msg["id"], {"ok": True})
# ---------------------------------------------------------------------------
# Calendar ICS feed (FEAT-10)
# ---------------------------------------------------------------------------
@@ -2323,7 +2337,7 @@ _COMMANDS = (
ws_notif_upsert_parent, ws_notif_delete_parent,
ws_notif_upsert_custom, ws_notif_delete_custom,
ws_notif_list_notify, ws_notif_set_streak_cutoff, ws_notif_send_test,
ws_notif_set_escalation,
ws_notif_set_escalation, ws_notif_set_nav_url,
ws_cal_get_url, ws_cal_regen_token,
)
@@ -866,6 +866,9 @@
"panel.notif_section_recipients": "Empfänger",
"panel.notif_section_recipients_desc": "Weise jedem Kind und Elternteil einen Home Assistant Benachrichtigungsdienst zu. Leer lassen, um Benachrichtigungen für diese Person zu überspringen.",
"panel.notif_streak_cutoff_label": "Frist",
"panel.notif_nav_url_global_label": "Beim Tippen öffnen",
"panel.notif_nav_url_global_hint": "Wohin eine angetippte Benachrichtigung führt: ein Pfad wie /taskmate oder /lovelace/parents, eine vollständige URL oder noAction. Gilt für alle Benachrichtigungen, sofern unten nicht pro Typ überschrieben.",
"panel.notif_nav_url_row_placeholder": "Nutzt globalen Standard",
"panel.notif_tab_title": "Benachrichtigungen",
"panel.template_assignment_mode_label": "Zuweisungsmodus",
"panel.template_builtin": "Integriert",
@@ -916,6 +916,9 @@
"panel.notif_section_recipients": "Recipients",
"panel.notif_section_recipients_desc": "Map each child and parent to a Home Assistant notify service. Leave blank to skip notifications for that person.",
"panel.notif_streak_cutoff_label": "Cutoff",
"panel.notif_nav_url_global_label": "When tapped, open",
"panel.notif_nav_url_global_hint": "Where a tapped notification opens: a path like /taskmate or /lovelace/parents, a full URL, or noAction. Applies to all notifications unless overridden per type below.",
"panel.notif_nav_url_row_placeholder": "Uses global default",
"panel.notif_send_test": "Send test",
"panel.notif_test_sent": "Test sent to {count} recipient(s) + persistent notification",
"panel.notif_tab_title": "Notifications",
@@ -916,6 +916,9 @@
"panel.notif_section_recipients": "Recipients",
"panel.notif_section_recipients_desc": "Map each child and parent to a Home Assistant notify service. Leave blank to skip notifications for that person.",
"panel.notif_streak_cutoff_label": "Cutoff",
"panel.notif_nav_url_global_label": "When tapped, open",
"panel.notif_nav_url_global_hint": "Where a tapped notification opens: a path like /taskmate or /lovelace/parents, a full URL, or noAction. Applies to all notifications unless overridden per type below.",
"panel.notif_nav_url_row_placeholder": "Uses global default",
"panel.notif_send_test": "Send test",
"panel.notif_test_sent": "Test sent to {count} recipient(s) + persistent notification",
"panel.notif_tab_title": "Notifications",
@@ -866,6 +866,9 @@
"panel.notif_section_recipients": "Destinataires",
"panel.notif_section_recipients_desc": "Associez chaque enfant et parent à un service de notification Home Assistant. Laisser vide pour ignorer les notifications pour cette personne.",
"panel.notif_streak_cutoff_label": "Limite",
"panel.notif_nav_url_global_label": "Au toucher, ouvrir",
"panel.notif_nav_url_global_hint": "Où mène une notification touchée : un chemin comme /taskmate ou /lovelace/parents, une URL complète ou noAction. S'applique à toutes les notifications sauf remplacement par type ci-dessous.",
"panel.notif_nav_url_row_placeholder": "Utilise la valeur globale",
"panel.notif_tab_title": "Notifications",
"panel.template_assignment_mode_label": "Mode d'assignation",
"panel.template_builtin": "Intégré",
@@ -866,6 +866,9 @@
"panel.notif_section_recipients": "Mottakere",
"panel.notif_section_recipients_desc": "Koble hvert barn og foresatt til en Home Assistant-varslingstjeneste. La stå tomt for å hoppe over varsler for den personen.",
"panel.notif_streak_cutoff_label": "Grense",
"panel.notif_nav_url_global_label": "Ved trykk, åpne",
"panel.notif_nav_url_global_hint": "Hvor et trykk på et varsel åpner: en sti som /taskmate eller /lovelace/parents, en full URL, eller noAction. Gjelder alle varsler med mindre det overstyres per type nedenfor.",
"panel.notif_nav_url_row_placeholder": "Bruker global standard",
"panel.notif_tab_title": "Varsler",
"panel.template_assignment_mode_label": "Tildelingsmodus",
"panel.template_builtin": "Innebygd",
@@ -866,6 +866,9 @@
"panel.notif_section_recipients": "Mottakarar",
"panel.notif_section_recipients_desc": "Kople kvart born og kvar føresett til ein Home Assistant-varslingsteneste. La stå tomt for å hoppa over varsel for den personen.",
"panel.notif_streak_cutoff_label": "Grense",
"panel.notif_nav_url_global_label": "Ved trykk, opne",
"panel.notif_nav_url_global_hint": "Kvar eit trykk på eit varsel opnar: ein sti som /taskmate eller /lovelace/parents, ein full URL, eller noAction. Gjeld alle varsel med mindre det vert overstyrt per type nedanfor.",
"panel.notif_nav_url_row_placeholder": "Brukar global standard",
"panel.notif_tab_title": "Varsel",
"panel.template_assignment_mode_label": "Tildelingsmodus",
"panel.template_builtin": "Innebygd",
@@ -866,6 +866,9 @@
"panel.notif_section_recipients": "Destinatários",
"panel.notif_section_recipients_desc": "Associe cada criança e responsável a um serviço de notificação do Home Assistant. Deixe em branco para pular notificações para essa pessoa.",
"panel.notif_streak_cutoff_label": "Limite",
"panel.notif_nav_url_global_label": "Ao tocar, abrir",
"panel.notif_nav_url_global_hint": "Para onde uma notificação tocada abre: um caminho como /taskmate ou /lovelace/parents, uma URL completa ou noAction. Aplica-se a todas as notificações, exceto se substituída por tipo abaixo.",
"panel.notif_nav_url_row_placeholder": "Usa o padrão global",
"panel.notif_tab_title": "Notificações",
"panel.template_assignment_mode_label": "Modo de atribuição",
"panel.template_builtin": "Integrado",
@@ -871,6 +871,9 @@
"panel.notif_section_recipients": "Destinatários",
"panel.notif_section_recipients_desc": "Associe cada criança e encarregado a um serviço de notificação do Home Assistant. Deixe em branco para ignorar notificações para essa pessoa.",
"panel.notif_streak_cutoff_label": "Limite",
"panel.notif_nav_url_global_label": "Ao tocar, abrir",
"panel.notif_nav_url_global_hint": "Para onde uma notificação tocada abre: um caminho como /taskmate ou /lovelace/parents, um URL completo ou noAction. Aplica-se a todas as notificações, salvo substituição por tipo abaixo.",
"panel.notif_nav_url_row_placeholder": "Usa o padrão global",
"panel.notif_tab_title": "Notificações",
"panel.template_assignment_mode_label": "Modo de atribuição",
"panel.template_builtin": "Incorporado",
@@ -676,6 +676,8 @@ class TaskMatePanel extends HTMLElement {
if (act === "notif-set-parent-notify"){ /* handled in _onChange */ return; }
if (act === "notif-rename-parent") { /* handled in _onChange */ return; }
if (act === "notif-set-streak-cutoff"){ /* handled in _onChange */ return; }
if (act === "notif-set-nav-url-global"){ /* handled in _onChange */ return; }
if (act === "notif-set-nav-url-type") { /* handled in _onChange */ return; }
if (act === "notif-add-parent") { this._notifAddParent(); return; }
if (act === "notif-delete-parent") { this._notifDeleteParent(t.dataset.parentId); return; }
if (act === "notif-add-custom") { this._notifAddCustom(); return; }
@@ -915,6 +917,14 @@ class TaskMatePanel extends HTMLElement {
this._notifSetStreakCutoff(t.value);
return;
}
if (t.dataset.act === "notif-set-nav-url-global") {
this._notifSetNavUrl(null, t.value);
return;
}
if (t.dataset.act === "notif-set-nav-url-type") {
this._notifSetNavUrl(t.dataset.typeId, t.value);
return;
}
if (t.dataset.act === "notif-set-escalation") {
this._notifSetEscalation(t.dataset.escField, t.value);
return;
@@ -2100,6 +2110,13 @@ class TaskMatePanel extends HTMLElement {
await this._fetchState();
}
async _notifSetNavUrl(typeId, navUrl) {
const payload = { type: "taskmate/notifications/set_nav_url", nav_url: navUrl || "" };
if (typeId) payload.type_id = typeId;
await this._callWS(payload);
await this._fetchState();
}
async _notifSetEscalation(field, value) {
const s = (this._notifState && this._notifState.settings) || {};
const cur = {
@@ -4578,6 +4595,13 @@ class TaskMatePanel extends HTMLElement {
<div class="tm-card" style="margin-bottom:16px">
<h3>${this._t("panel.notif_section_matrix")}</h3>
<p class="tm-meta">${this._t("panel.notif_section_matrix_desc")}</p>
<div class="tm-meta" style="margin:4px 0 12px;display:flex;flex-wrap:wrap;align-items:center;gap:8px">
<label style="font-weight:500">${this._t("panel.notif_nav_url_global_label")}</label>
<input type="text" class="tm-input" style="flex:1;min-width:180px"
value="${this._esc((ns.settings && ns.settings.notification_nav_url) || "")}"
data-act="notif-set-nav-url-global" placeholder="/taskmate">
<div class="tm-meta" style="flex-basis:100%">${this._t("panel.notif_nav_url_global_hint")}</div>
</div>
<div class="tm-table-wrap">
<table class="tm-table">
<thead>
@@ -4630,6 +4654,12 @@ class TaskMatePanel extends HTMLElement {
<ha-icon icon="mdi:send" style="--mdc-icon-size:14px"></ha-icon> ${this._t("panel.notif_send_test")}
</button>
</div>
<div style="margin-top:6px;display:flex;align-items:center;gap:8px">
<input type="text" class="tm-notif-time-input" style="width:150px"
value="${this._esc(c.nav_url || "")}"
data-act="notif-set-nav-url-type" data-type-id="${this._esc(t.id)}"
placeholder="${this._t("panel.notif_nav_url_row_placeholder")}">
</div>
</div>
</div>
</td>