Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions custom_components/taskmate/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -334,3 +334,11 @@

# Chore keys (sound-related)
CONF_CHORE_COMPLETION_SOUND: Final = "completion_sound"

# --- Notification type IDs (v3.9.0) ---
NOTIF_TYPE_BEDTIME_REMINDER: Final = "bedtime_reminder"
NOTIF_TYPE_STREAK_AT_RISK: Final = "streak_at_risk"
NOTIF_TYPE_ALL_CHORES_DONE: Final = "all_chores_done"
NOTIF_TYPE_BADGE_EARNED: Final = "badge_earned"
NOTIF_TYPE_PENDING_CHORE_APPROVAL: Final = "pending_chore_approval"
NOTIF_TYPE_PENDING_REWARD_CLAIM: Final = "pending_reward_claim"
164 changes: 164 additions & 0 deletions custom_components/taskmate/coord_notifications.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
"""Central notification dispatcher and scheduler.

All TaskMate notifications flow through this module. It owns:
* The static NOTIFICATION_TYPES registry (built-in metadata)
* `fire(type_id, context)` — the public dispatch entry point
* Scheduled callbacks for time-gated types (bedtime, streak-at-risk, custom)
* The mobile_app_notification_action listener for tap-to-approve

Other coordinators MUST NOT call notify.* / persistent_notification directly
once this module is in place. They call self.notifications.fire(...).
"""
from __future__ import annotations

import logging
from dataclasses import dataclass
from typing import Any

from homeassistant.core import HomeAssistant

from .const import (
NOTIF_TYPE_ALL_CHORES_DONE,
NOTIF_TYPE_BADGE_EARNED,
NOTIF_TYPE_BEDTIME_REMINDER,
NOTIF_TYPE_PENDING_CHORE_APPROVAL,
NOTIF_TYPE_PENDING_REWARD_CLAIM,
NOTIF_TYPE_STREAK_AT_RISK,
)
from .models import NotificationRoute

_LOGGER = logging.getLogger(__name__)


@dataclass(frozen=True)
class NotificationTypeMeta:
id: str
audience: str # "child" | "parent" | "both"
time_gated: bool # has its own scheduled callback
per_recipient_time: bool # if True, route.time controls the schedule per recipient
actionable: bool # carries Approve/Reject mobile actions
default_enabled: bool # default master_enabled state at install


NOTIFICATION_TYPES: list[NotificationTypeMeta] = [
NotificationTypeMeta(NOTIF_TYPE_BEDTIME_REMINDER, "child", True, True, False, False),
NotificationTypeMeta(NOTIF_TYPE_STREAK_AT_RISK, "child", True, False, False, False),
NotificationTypeMeta(NOTIF_TYPE_ALL_CHORES_DONE, "both", False, False, False, False),
NotificationTypeMeta(NOTIF_TYPE_BADGE_EARNED, "both", False, False, False, True),
NotificationTypeMeta(NOTIF_TYPE_PENDING_CHORE_APPROVAL, "parent", False, False, True, True),
NotificationTypeMeta(NOTIF_TYPE_PENDING_REWARD_CLAIM, "parent", False, False, True, True),
]

NOTIFICATION_TYPES_BY_ID: dict[str, NotificationTypeMeta] = {
t.id: t for t in NOTIFICATION_TYPES
}


class _SafeDict(dict):
"""str.format_map dict that leaves missing keys as `{key}` literal."""
def __missing__(self, key: str) -> str:
return "{" + key + "}"


class NotificationCoordinator:
"""Single dispatcher for all TaskMate notifications."""

def __init__(self, hass: HomeAssistant, storage) -> None:
self.hass = hass
self.storage = storage
self._scheduled_unsubs: list = [] # cancellation handles for time triggers

async def fire(self, type_id: str, context: dict[str, Any]) -> None:
"""Dispatch a notification of the given type with the given context."""
meta = NOTIFICATION_TYPES_BY_ID.get(type_id)
if meta is None:
_LOGGER.warning("Unknown notification type %s", type_id)
return

cfg = self.storage.get_notification_config(type_id)
if not cfg.master_enabled:
self._fire_bus_event(type_id, context, recipients=[])
return

recipients_fired: list[str] = []
message = self._render_template(meta, context)

for recipient_id, route in cfg.routes.items():
if not route.enabled:
continue
notify_service = self._resolve_notify_service(recipient_id)
if not notify_service:
continue
await self._send_to(notify_service, message, meta, context)
recipients_fired.append(recipient_id)

await self._fire_persistent_notification(type_id, message)
self._fire_bus_event(type_id, context, recipients_fired)

def _resolve_notify_service(self, recipient_id: str) -> str:
if recipient_id.startswith("child:"):
child_id = recipient_id.split(":", 1)[1]
child = self.storage.get_child(child_id)
return child.notify_service or "" if child else ""
if recipient_id.startswith("parent:"):
for p in self.storage.get_parent_recipients():
if p.id == recipient_id and p.enabled:
return p.notify_service
return ""

def _render_template(self, meta: "NotificationTypeMeta", context: dict[str, Any]) -> str:
# Built-in types use a baked-in default; will be replaced by translations
# in a later task. For now use a safe English fallback so dispatch works.
templates = {
NOTIF_TYPE_BEDTIME_REMINDER: "{child_name}, you still have chores to do before bedtime.",
NOTIF_TYPE_STREAK_AT_RISK: "{child_name}, complete a chore today to keep your {streak}-day streak!",
NOTIF_TYPE_ALL_CHORES_DONE: "{child_name} finished every chore today!",
NOTIF_TYPE_BADGE_EARNED: "{child_name} earned the {badge_name} badge!",
NOTIF_TYPE_PENDING_CHORE_APPROVAL: "{child_name} completed '{chore_name}' (+{points} {points_name}) — awaiting approval.",
NOTIF_TYPE_PENDING_REWARD_CLAIM: "{child_name} claimed '{reward_name}' ({cost} {points_name}) — awaiting approval.",
}
tpl = context.get("message_template") or templates.get(meta.id, "")
return tpl.format_map(_SafeDict(context))

async def _send_to(
self, notify_service: str, message: str,
meta: "NotificationTypeMeta", context: dict[str, Any],
) -> None:
domain, service = (
notify_service.split(".", 1) if "." in notify_service
else ("notify", notify_service)
)
if domain != "notify":
_LOGGER.warning("notify_service must be notify.*, got %s", notify_service)
return

data: dict[str, Any] = {"title": "TaskMate", "message": message}
if meta.actionable:
entry_id = context.get("entry_id")
if entry_id:
data["data"] = {
"actions": [
{"action": f"TASKMATE_APPROVE_{entry_id}", "title": "Approve"},
{"action": f"TASKMATE_REJECT_{entry_id}", "title": "Reject"},
]
}
try:
await self.hass.services.async_call(domain, service, data, blocking=False)
except Exception as err: # noqa: BLE001
_LOGGER.warning("notify call failed for %s: %s", notify_service, err)

async def _fire_persistent_notification(self, type_id: str, message: str) -> None:
await self.hass.services.async_call(
"persistent_notification", "create",
{
"title": "TaskMate",
"message": message,
"notification_id": f"taskmate_{type_id}",
},
blocking=False,
)

def _fire_bus_event(self, type_id: str, context: dict[str, Any], recipients: list[str]) -> None:
payload = dict(context)
payload["recipients"] = recipients
self.hass.bus.async_fire(f"taskmate_{type_id}", payload)
2 changes: 2 additions & 0 deletions custom_components/taskmate/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from .const import DOMAIN
from .coord_assignments import AssignmentsMixin
from .coord_badges import BadgeCoordinator
from .coord_notifications import NotificationCoordinator
from .coord_calendar import CalendarMixin
from .coord_chores import ChoresMixin
from .coord_points import PointsMixin
Expand Down Expand Up @@ -48,6 +49,7 @@ def __init__(self, hass: HomeAssistant, entry_id: str) -> None:
)
self.storage = TaskMateStorage(hass, entry_id)
self.badges = BadgeCoordinator(hass, self.storage, self)
self.notifications = NotificationCoordinator(hass, self.storage)
self.entry_id = entry_id
self._unsub_midnight: Callable[[], None] | None = None
self._unsub_prune: Callable[[], None] | None = None
Expand Down
116 changes: 116 additions & 0 deletions custom_components/taskmate/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ class Child:
unavailability_entity: str = "" # Second HA entity; _AVAILABLE_STATES = child is busy
career_score: int = 0
total_penalties_received: int = 0
notify_service: str | None = None
id: str = field(default_factory=generate_id)

@classmethod
Expand All @@ -167,6 +168,7 @@ def from_dict(cls, data: dict[str, Any]) -> Child:
unavailability_entity=data.get("unavailability_entity", ""),
career_score=data.get("career_score", 0),
total_penalties_received=data.get("total_penalties_received", 0),
notify_service=data.get("notify_service", None),
id=data.get("id", generate_id()),
)

Expand All @@ -191,6 +193,7 @@ def to_dict(self) -> dict[str, Any]:
"unavailability_entity": self.unavailability_entity,
"career_score": self.career_score,
"total_penalties_received": self.total_penalties_received,
"notify_service": self.notify_service,
"id": self.id,
}

Expand Down Expand Up @@ -758,3 +761,116 @@ def to_dict(self) -> dict[str, Any]:
"chore_ids": self.chore_ids,
"id": self.id,
}


@dataclass
class ParentRecipient:
"""A configured parent notification target."""

name: str
notify_service: str
enabled: bool = True
id: str = field(default_factory=lambda: f"parent:{generate_id()}")

@classmethod
def from_dict(cls, data: dict[str, Any]) -> ParentRecipient:
rid = data.get("id") or f"parent:{generate_id()}"
return cls(
name=data.get("name", ""),
notify_service=data.get("notify_service", ""),
enabled=bool(data.get("enabled", True)),
id=rid,
)

def to_dict(self) -> dict[str, Any]:
return {
"id": self.id,
"name": self.name,
"notify_service": self.notify_service,
"enabled": self.enabled,
}


@dataclass
class NotificationRoute:
"""Per-recipient notification settings inside a NotificationConfig."""

enabled: bool = False
time: str | None = None # "HH:MM" — only meaningful for per-recipient-time types

@classmethod
def from_dict(cls, data: dict[str, Any]) -> NotificationRoute:
return cls(
enabled=bool(data.get("enabled", False)),
time=data.get("time"),
)

def to_dict(self) -> dict[str, Any]:
d: dict[str, Any] = {"enabled": self.enabled}
if self.time is not None:
d["time"] = self.time
return d


@dataclass
class NotificationConfig:
"""Stored configuration for one notification type."""

type_id: str
master_enabled: bool = False
routes: dict[str, NotificationRoute] = field(default_factory=dict)

@classmethod
def from_dict(cls, data: dict[str, Any]) -> NotificationConfig:
raw_routes = data.get("routes", {}) or {}
return cls(
type_id=data.get("type_id", ""),
master_enabled=bool(data.get("master_enabled", False)),
routes={
rid: NotificationRoute.from_dict(rdata)
for rid, rdata in raw_routes.items()
},
)

def to_dict(self) -> dict[str, Any]:
return {
"type_id": self.type_id,
"master_enabled": self.master_enabled,
"routes": {rid: r.to_dict() for rid, r in self.routes.items()},
}


@dataclass
class CustomNotification:
"""Parent-defined scheduled reminder."""

name: str
message_template: str
time: str # "HH:MM"
day_mask: int = 0b1111111 # bit0=Mon … bit6=Sun
recipient_ids: list[str] = field(default_factory=list)
enabled: bool = True
id: str = field(default_factory=generate_id)

@classmethod
def from_dict(cls, data: dict[str, Any]) -> CustomNotification:
return cls(
name=data.get("name", ""),
message_template=data.get("message_template", ""),
time=data.get("time", "20:00"),
day_mask=int(data.get("day_mask", 0b1111111)),
recipient_ids=list(data.get("recipient_ids", [])),
enabled=bool(data.get("enabled", True)),
id=data.get("id") or generate_id(),
)

def to_dict(self) -> dict[str, Any]:
return {
"id": self.id,
"name": self.name,
"message_template": self.message_template,
"time": self.time,
"day_mask": self.day_mask,
"recipient_ids": list(self.recipient_ids),
"enabled": self.enabled,
}
Loading