187 files

This commit is contained in:
Home Assistant Version Control
2026-10-09 11:57:31 +00:00
parent 28fa34d78a
commit 1e1b12d52b
187 changed files with 40899 additions and 32654 deletions
@@ -15,13 +15,13 @@ from __future__ import annotations
import contextlib
import logging
from dataclasses import dataclass
from datetime import datetime, timedelta
from datetime import date, datetime, timedelta
from typing import Any
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.event import async_track_state_change_event, async_track_time_change
from . import authz
from . import authz, notify_strings
from .const import (
DEFAULT_NOTIFICATION_GROUP,
DEFAULT_NOTIFICATION_NAV_URL,
@@ -64,24 +64,24 @@ _LOGGER = logging.getLogger(__name__)
DEFAULT_MORNING_NOTIFY_TIME = "08:00"
# Appended to actionable notifications sent to non-mobile_app backends, which
# silently ignore tap actions. Gives those recipients a way to act.
_APPROVE_IN_PANEL_HINT = "Open the TaskMate panel to approve or reject."
# Mobile action id prefix for "approve with an N-star quality rating" (#927):
# TASKMATE_RATE_<n>_<completion id>. Distinct from TASKMATE_APPROVE_ so the
# original Approve/Reject ids keep working unchanged when ratings are off.
_RATE_ACTION_PREFIX = "TASKMATE_RATE_"
# Stand-in names for a test push, translated like the rest (notify.sample_*).
_SAMPLE_KEYS = ("chore", "reward", "badge", "goal", "goal_reward", "wish", "pledger", "bounty", "reason", "note")
def _reject_action(entry_id: str) -> dict[str, str]:
def _reject_action(hass: HomeAssistant, entry_id: str) -> dict[str, str]:
"""The Reject button of an approval push, with a reply box for the reason (#976)."""
reject = notify_strings.text(hass, "action_reject")
return {
"action": f"TASKMATE_REJECT_{entry_id}",
"title": "Reject",
"title": reject,
"behavior": "textInput",
"textInputButtonTitle": "Reject",
"textInputPlaceholder": "Reason (optional)",
"textInputButtonTitle": reject,
"textInputPlaceholder": notify_strings.text(hass, "action_reject_placeholder"),
}
@@ -255,11 +255,7 @@ _parse_hhmm = parse_hhmm
_is_within_quiet_hours = is_within_window
class _SafeDict(dict):
"""str.format_map dict that leaves missing keys as `{key}` literal."""
def __missing__(self, key: str) -> str:
return "{" + key + "}"
_SafeDict = notify_strings.SafeDict
class NotificationCoordinator:
@@ -300,6 +296,7 @@ class NotificationCoordinator:
return
recipients_fired: list[str] = []
await notify_strings.async_load(self.hass)
message = self._render_template(meta, context)
nav_url = self._resolve_nav_url(cfg)
group = self._resolve_group(cfg)
@@ -416,38 +413,54 @@ class NotificationCoordinator:
meta = NOTIFICATION_TYPES_BY_ID.get(type_id)
if meta is None:
raise ValueError(f"Unknown notification type {type_id}")
await notify_strings.async_load(self.hass)
points_name = self.storage.get_points_name()
sample_month = date(2026, 1, 1)
sample = {key: notify_strings.text(self.hass, f"sample_{key}") for key in _SAMPLE_KEYS}
ctx = {
"child_name": "Alex",
"chore_name": "Tidy room",
"reward_name": "Movie night",
"badge_name": "Star Helper",
"chore_name": sample["chore"],
"reward_name": sample["reward"],
"badge_name": sample["badge"],
"points": 10,
"cost": 50,
"streak": 7,
"days": 7,
"level": 5,
"tier": 3,
"message": "Alex reached level 5!",
"summary": "• Alex: 5 chores, 50 Stars earned",
"month": "January 2026",
"goal_name": "Movie night fund",
"goal_reward": "a family movie night",
"message": notify_strings.render(self.hass, "celebrate_level_up", {"child_name": "Alex", "level": 5}),
"summary": notify_strings.render(
self.hass,
"weekly_digest_line",
{"child_name": "Alex", "chores": 5, "points": 50, "points_name": points_name},
),
"month": notify_strings.month_year(self.hass, sample_month),
"goal_name": sample["goal"],
"goal_reward": sample["goal_reward"],
"multiplier": "2",
"count": 3,
"wish_name": "Lego set",
"freezes_left": 2,
"wish_name": sample["wish"],
"target": 600,
"pledger": "Grandma",
"bounty_name": "Wash the car",
"pledger": sample["pledger"],
"bounty_name": sample["bounty"],
"minutes": 15,
"period": "January",
"item_name": "Tidy room",
"reason": "Not finished",
"reason_text": ": Not finished",
"period": notify_strings.month_name(self.hass, sample_month.month),
"item_name": sample["chore"],
"reason": sample["reason"],
"reason_text": f": {sample['reason']}",
"date": notify_strings.short_date(self.hass, sample_month),
"max_points": 20,
"until": "18:20",
"bonus": 10,
"note_text": ": Brilliant job!",
"points_name": self.storage.get_points_name(),
"note_text": f": {sample['note']}",
"points_name": points_name,
}
ctx["result_text"] = notify_strings.render(
self.hass,
"auction_won",
{**ctx, "points": 12},
)
message = "[TEST] " + self._render_template(meta, ctx)
cfg = self.storage.get_notification_config(type_id)
nav_url = self._resolve_nav_url(cfg)
@@ -506,47 +519,17 @@ class NotificationCoordinator:
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.",
NOTIF_TYPE_STREAK_MILESTONE: "{child_name} hit a {days}-day streak — +{points} {points_name}!",
NOTIF_TYPE_LEVEL_UP: "{child_name} reached level {level}! 🎉",
NOTIF_TYPE_WEEKLY_DIGEST: "TaskMate weekly digest:\n{summary}",
NOTIF_TYPE_CELEBRATION: "🎉 {message}",
NOTIF_TYPE_MANDATORY_REMINDER: "{child_name}, you still need to do '{chore_name}'.",
NOTIF_TYPE_MANDATORY_PARENT_ALERT: "{child_name} still hasn't done the mandatory chore '{chore_name}'.",
NOTIF_TYPE_MONTHLY_REPORT: "TaskMate {month} report:\n{summary}",
NOTIF_TYPE_SEASON_CHAMPION: "🏆 {child_name} won the {month} leaderboard with {points} {points_name}!",
NOTIF_TYPE_FAMILY_GOAL_REACHED: "🎉 Family goal reached: {goal_name}! Time for {goal_reward}.",
NOTIF_TYPE_BIRTHDAY: "🎂 Happy birthday, {child_name}! Every chore pays {multiplier}× today.",
NOTIF_TYPE_STREAK_FREEZE_USED: "❄️ A streak freeze saved {child_name}'s {streak}-day streak ({freezes_left} left).",
NOTIF_TYPE_PRESENCE_ARRIVAL: "🏠 You're home, {child_name} — {count} chores left today.",
NOTIF_TYPE_WISH_REQUESTED: "{child_name} wished for '{wish_name}' ({target} {points_name}) — waiting for your approval.",
NOTIF_TYPE_WISH_PLEDGED: "💝 {pledger} added {points} {points_name} to your wish '{wish_name}'!",
NOTIF_TYPE_BOUNTY_POSTED: "🏁 New bounty: {bounty_name} — {points} {points_name}. First to claim it gets it!",
NOTIF_TYPE_BOUNTY_CLAIM_LAPSING: "⏳ {child_name}, {minutes} minutes left to finish '{bounty_name}' before it goes back on the board.",
NOTIF_TYPE_RECAP_READY: "✨ Your {period} recap is ready, {child_name}! Tap to watch it.",
NOTIF_TYPE_ITEM_REJECTED: "↩️ {child_name}, '{item_name}' was sent back{reason_text}",
NOTIF_TYPE_AUCTION_OPENED: "🔨 New auction: {chore_name} on {date}, up to {max_points} {points_name}. Lowest bid wins!",
NOTIF_TYPE_AUCTION_CLOSING: "⏳ {child_name}, bidding on '{chore_name}' closes in {minutes} minutes.",
NOTIF_TYPE_AUCTION_RESULT: "🔨 Auction closed: {result_text}",
NOTIF_TYPE_INSPECTION_STARTED: "🔍 {child_name}, a grown-up is coming to inspect '{chore_name}' before {until}. Keep it looking great for +{bonus} {points_name}!",
NOTIF_TYPE_INSPECTION_PASSED: "🌟 Inspection passed, {child_name}! '{chore_name}' looked great: +{bonus} {points_name}{note_text}",
NOTIF_TYPE_INSPECTION_REMINDER: "🔍 {child_name}'s '{chore_name}' inspection closes in {minutes} minutes — pass or fail it before {until}.",
}
tpl = context.get("message_template") or templates.get(meta.id, "")
# Built-in types read their wording from the locale catalogue in HA's
# language (#1037); a custom message_template is the user's own text.
custom = context.get("message_template")
if not custom:
return notify_strings.render(self.hass, meta.id, context)
try:
return tpl.format_map(_SafeDict(context))
return custom.format_map(_SafeDict(context))
except (ValueError, IndexError, KeyError):
# Malformed user template (e.g. stray '{') — fall back to raw text
_LOGGER.warning("Malformed notification template %r, sending raw", tpl)
return tpl
_LOGGER.warning("Malformed notification template %r, sending raw", custom)
return custom
async def _send_to(
self,
@@ -587,7 +570,7 @@ class NotificationCoordinator:
push["tag"] = _approval_tag(entry_id)
push["actions"] = self._approval_actions(meta.id, entry_id)
else:
data["message"] = f"{message} {_APPROVE_IN_PANEL_HINT}"
data["message"] = f"{message} {notify_strings.text(self.hass, 'approve_in_panel_hint')}"
if attach_photo:
# "image" renders inline on Android; iOS needs it under
@@ -647,11 +630,11 @@ class NotificationCoordinator:
if rated:
return [
*({"action": f"{_RATE_ACTION_PREFIX}{n}_{entry_id}", "title": "★" * n} for n in QUALITY_RATINGS),
_reject_action(entry_id),
_reject_action(self.hass, entry_id),
]
return [
{"action": f"TASKMATE_APPROVE_{entry_id}", "title": "Approve"},
_reject_action(entry_id),
{"action": f"TASKMATE_APPROVE_{entry_id}", "title": notify_strings.text(self.hass, "action_approve")},
_reject_action(self.hass, entry_id),
]
async def clear_approval(self, type_id: str, entry_id: str) -> None: