Files

2340 lines
94 KiB
Python

"""WebSocket API for the TaskMate admin panel.
The panel speaks to the integration via these commands rather than via HA
services — services are intended for automation/templating consumers and
would clutter the service registry with two dozen panel-only entries.
Read:
taskmate/get_state — full snapshot
Children:
taskmate/add_child — name, avatar?, availability_entity?
taskmate/update_child — child_id + any of the above
taskmate/remove_child — child_id
Chores:
taskmate/add_chore — name + many optional fields
taskmate/update_chore — chore_id + any of the above
taskmate/remove_chore — chore_id
Rewards:
taskmate/add_reward — name + cost + many optional fields
taskmate/update_reward — reward_id + any of the above
taskmate/remove_reward — reward_id
Penalties / Bonuses (same shape):
taskmate/add_penalty — name + points + optional
taskmate/update_penalty — penalty_id + any of the above
taskmate/remove_penalty — penalty_id
taskmate/apply_penalty — penalty_id + child_id (operational, exposed for convenience)
(and the bonus equivalents)
Task groups:
taskmate/add_task_group — name, policy, chore_ids?
taskmate/update_task_group — group_id + any of the above
taskmate/remove_task_group — group_id
Ordering:
taskmate/set_chore_order — child_id + chore_order (per-child)
taskmate/set_global_chore_order — chore_order (admin panel display order)
Settings:
taskmate/update_settings — partial dict of {points_name, points_icon, history_days, streak_reset_mode, ...}
All commands require admin. Mutations write through coordinator methods so
TaskMate's existing business logic (refunds, cleanup, recompute) runs.
"""
from __future__ import annotations
import logging
import re
from datetime import date
from functools import wraps
from typing import Any, Final
import voluptuous as vol
from homeassistant.components import websocket_api
from homeassistant.core import HomeAssistant
from . import photos
from .const import DEFAULT_TIME_PERIODS, DOMAIN, MAX_TIME_PERIODS, TIME_CATEGORY_ICONS
from .coordinator import TaskMateCoordinator
from .models import BonusSubTask, Reward
_LOGGER = logging.getLogger(__name__)
WS_REGISTERED: Final = "ws_registered"
# --- command names ---------------------------------------------------------
WS_GET_STATE: Final = "taskmate/get_state"
WS_ADD_CHILD: Final = "taskmate/add_child"
WS_UPDATE_CHILD: Final = "taskmate/update_child"
WS_REMOVE_CHILD: Final = "taskmate/remove_child"
WS_LIST_HA_USERS: Final = "taskmate/list_ha_users"
WS_ADD_CHORE: Final = "taskmate/add_chore"
WS_UPDATE_CHORE: Final = "taskmate/update_chore"
WS_REMOVE_CHORE: Final = "taskmate/remove_chore"
WS_REPORT_FAIRNESS: Final = "taskmate/reports/fairness"
WS_REPORT_FRICTION: Final = "taskmate/reports/friction"
WS_REPORT_PROJECTION: Final = "taskmate/reports/projection"
WS_REPORT_HEALTH: Final = "taskmate/reports/health"
WS_SCHEDULED_LIST: Final = "taskmate/scheduled/list"
WS_SCHEDULED_ADD: Final = "taskmate/scheduled/add"
WS_SCHEDULED_REMOVE: Final = "taskmate/scheduled/remove"
WS_ADD_REWARD: Final = "taskmate/add_reward"
WS_UPDATE_REWARD: Final = "taskmate/update_reward"
WS_REMOVE_REWARD: Final = "taskmate/remove_reward"
WS_ADD_PENALTY: Final = "taskmate/add_penalty"
WS_UPDATE_PENALTY: Final = "taskmate/update_penalty"
WS_REMOVE_PENALTY: Final = "taskmate/remove_penalty"
WS_APPLY_PENALTY: Final = "taskmate/apply_penalty"
WS_ADD_BONUS: Final = "taskmate/add_bonus"
WS_UPDATE_BONUS: Final = "taskmate/update_bonus"
WS_REMOVE_BONUS: Final = "taskmate/remove_bonus"
WS_APPLY_BONUS: Final = "taskmate/apply_bonus"
WS_CREATE_QUEST: Final = "taskmate/create_quest"
WS_UPDATE_QUEST: Final = "taskmate/update_quest"
WS_DELETE_QUEST: Final = "taskmate/delete_quest"
WS_UPDATE_AVATAR_CATALOG: Final = "taskmate/update_avatar_catalog"
WS_SET_CHILD_AVATAR: Final = "taskmate/set_child_avatar"
WS_CREATE_CHALLENGE: Final = "taskmate/create_challenge"
WS_UPDATE_CHALLENGE: Final = "taskmate/update_challenge"
WS_DELETE_CHALLENGE: Final = "taskmate/delete_challenge"
WS_ADD_TASK_GROUP: Final = "taskmate/add_task_group"
WS_UPDATE_TASK_GROUP: Final = "taskmate/update_task_group"
WS_REMOVE_TASK_GROUP: Final = "taskmate/remove_task_group"
WS_UPDATE_SETTINGS: Final = "taskmate/update_settings"
# Operational
WS_COMPLETE_BONUS_SUBTASK: Final = "taskmate/complete_bonus_subtask"
WS_APPROVE_CHORE: Final = "taskmate/approve_chore"
WS_APPROVE_ALL_CHORES: Final = "taskmate/approve_all_chores"
WS_REJECT_CHORE: Final = "taskmate/reject_chore"
WS_APPROVE_REWARD: Final = "taskmate/approve_reward"
WS_REJECT_REWARD: Final = "taskmate/reject_reward"
WS_SET_CHORE_ORDER: Final = "taskmate/set_chore_order"
WS_SET_GLOBAL_CHORE_ORDER: Final = "taskmate/set_global_chore_order"
WS_ADD_CHORES_BULK: Final = "taskmate/add_chores_bulk"
WS_PARENT_COMPLETE_CHORE: Final = "taskmate/parent_complete_chore"
# Templates
WS_TEMPLATES_LIST: Final = "taskmate/templates/list"
WS_TEMPLATES_GET: Final = "taskmate/templates/get"
WS_TEMPLATES_APPLY: Final = "taskmate/templates/apply"
WS_TEMPLATES_SAVE_FROM: Final = "taskmate/templates/save_from_chores"
WS_TEMPLATES_CREATE: Final = "taskmate/templates/create"
WS_TEMPLATES_UPDATE: Final = "taskmate/templates/update"
WS_TEMPLATES_EXPORT: Final = "taskmate/templates/export"
WS_TEMPLATES_IMPORT: Final = "taskmate/templates/import"
WS_PRINT_CHART: Final = "taskmate/print/weekly_chart"
WS_TEMPLATES_DELETE: Final = "taskmate/templates/delete"
# Notifications
WS_NOTIF_GET_STATE: Final = "taskmate/notifications/get_state"
WS_NOTIF_SET_MASTER: Final = "taskmate/notifications/set_master_enabled"
WS_NOTIF_SET_ROUTE: Final = "taskmate/notifications/set_route"
WS_NOTIF_SET_CHILD_NOTIFY: Final = "taskmate/notifications/set_child_notify"
WS_NOTIF_SET_CHILD_QUIET: Final = "taskmate/notifications/set_child_quiet"
WS_NOTIF_UPSERT_PARENT: Final = "taskmate/notifications/upsert_parent"
WS_NOTIF_DELETE_PARENT: Final = "taskmate/notifications/delete_parent"
WS_NOTIF_UPSERT_CUSTOM: Final = "taskmate/notifications/upsert_custom"
WS_NOTIF_DELETE_CUSTOM: Final = "taskmate/notifications/delete_custom"
WS_NOTIF_LIST_NOTIFY: Final = "taskmate/notifications/list_notify_services"
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"
# Calendar ICS feed (FEAT-10)
WS_CAL_GET_URL: Final = "taskmate/calendar/get_ics_url"
WS_CAL_REGEN_TOKEN: Final = "taskmate/calendar/regenerate_ics_token"
# Admin audit log
WS_AUDIT_LIST: Final = "taskmate/audit/list"
WS_AUDIT_CLEAR: Final = "taskmate/audit/clear"
# Undo / retract
WS_UNDO_TRANSACTION: Final = "taskmate/undo_transaction"
# Clone / duplicate
WS_CLONE_CHORE: Final = "taskmate/clone_chore"
# Bulk chore operations
WS_BULK_CHORE_ACTION: Final = "taskmate/bulk_chore_action"
# Inter-child gifting
WS_GIFT_POINTS: Final = "taskmate/gift_points"
# Sibling chore swaps
WS_REQUEST_SWAP: Final = "taskmate/request_swap"
WS_APPROVE_SWAP: Final = "taskmate/approve_swap"
WS_REJECT_SWAP: Final = "taskmate/reject_swap"
# Backup / restore
WS_CONFIG_EXPORT: Final = "taskmate/config/export"
WS_CONFIG_IMPORT: Final = "taskmate/config/import"
# Read-only / audit-management commands that should NOT themselves be audited.
# Everything else routed through @_admin_only mutates state and is logged.
_AUDIT_EXCLUDE: Final = {
WS_GET_STATE, WS_NOTIF_GET_STATE, WS_NOTIF_LIST_NOTIFY,
WS_TEMPLATES_LIST, WS_TEMPLATES_GET, WS_TEMPLATES_EXPORT, WS_PRINT_CHART,
WS_AUDIT_LIST, WS_AUDIT_CLEAR,
WS_CONFIG_EXPORT, WS_SCHEDULED_LIST, WS_REPORT_FAIRNESS, WS_REPORT_FRICTION, WS_REPORT_PROJECTION, WS_REPORT_HEALTH,
}
def _audit_target(coordinator, msg: dict) -> str:
"""Best-effort human-readable target for an admin action from its payload."""
name = msg.get("name")
if isinstance(name, str) and name.strip():
return name.strip()
for key, getter in (
("chore_id", coordinator.get_chore),
("child_id", coordinator.get_child),
("reward_id", coordinator.get_reward),
):
val = msg.get(key)
if val:
obj = getter(val)
return getattr(obj, "name", None) or str(val)
for key in (
"penalty_id", "bonus_id", "badge_id", "group_id", "template_id",
"completion_id", "claim_id", "parent_id", "custom_id",
"awarded_badge_id", "type_id", "transaction_id",
):
if msg.get(key):
return str(msg[key])
return ""
def _get_coordinator(hass: HomeAssistant) -> TaskMateCoordinator | None:
for value in hass.data.get(DOMAIN, {}).values():
if isinstance(value, TaskMateCoordinator):
return value
return None
def _admin_only(handler):
"""Enforce admin + coordinator availability + uniform error reporting."""
@wraps(handler)
async def wrapper(hass, connection, msg):
if not connection.user.is_admin:
connection.send_error(msg["id"], websocket_api.const.ERR_UNAUTHORIZED, "Admin only")
return
coordinator = _get_coordinator(hass)
if not coordinator:
connection.send_error(msg["id"], "no_coordinator", "TaskMate not initialised")
return
try:
await handler(hass, connection, msg, coordinator)
except vol.Invalid as err:
connection.send_error(msg["id"], "invalid_args", str(err))
except ValueError as err:
connection.send_error(msg["id"], "invalid", str(err))
except Exception as err: # noqa: BLE001
_LOGGER.exception("WS handler %s failed", msg.get("type"))
connection.send_error(msg["id"], "handler_failed", str(err))
else:
# Record successful mutating commands in the admin audit log.
mtype = msg.get("type", "")
if mtype not in _AUDIT_EXCLUDE:
try:
user = connection.user
await coordinator.async_record_audit(
getattr(user, "id", ""),
getattr(user, "name", "") or "",
mtype.split("taskmate/", 1)[-1],
_audit_target(coordinator, msg),
)
except Exception: # noqa: BLE001
_LOGGER.debug("audit record failed for %s", msg.get("type"), exc_info=True)
return wrapper
# ---------------------------------------------------------------------------
# Validators / coercers
# ---------------------------------------------------------------------------
def _opt_str(v: Any) -> str:
"""Coerce optional string field to stripped str (or empty)."""
if v is None:
return ""
return str(v).strip()
# ---------------------------------------------------------------------------
# State snapshot
# ---------------------------------------------------------------------------
def _build_state_snapshot(coordinator: TaskMateCoordinator) -> dict[str, Any]:
data = coordinator.storage.data
completions = list(data.get("completions", []))
# Sign evidence-photo URLs so the admin panel can render thumbnails and
# open/save the full image — a plain <img>/navigation request carries no
# bearer token, so the auth-gated serve view would 401. Copy each dict so
# the expiring signed URL is never written back into storage.
completions = [
{**c, "photo_url": photos.sign_photo_url(coordinator.hass, c["photo_url"])}
if c.get("photo_url") else c
for c in completions
]
reward_claims = list(data.get("reward_claims", []))
transactions = list(data.get("points_transactions", []))
parent_completable = {}
for chore_dict in data.get("chores", []):
chore_id = chore_dict.get("id")
if not chore_id or not chore_dict.get("enabled", True):
continue
if chore_dict.get("schedule_mode", "specific_days") == "one_shot":
continue
parent_completable[chore_id] = True
return {
"version": "2",
"children": list(data.get("children", [])),
"chores": list(data.get("chores", [])),
"chore_display_order": list(data.get("chore_display_order", [])),
"scheduled_changes": list(data.get("scheduled_changes", [])),
"rewards": list(data.get("rewards", [])),
"penalties": list(data.get("penalties", [])),
"bonuses": list(data.get("bonuses", [])),
"task_groups": list(data.get("task_groups", [])),
"quests": list(data.get("quests", [])),
"quest_progress": dict(data.get("quest_progress", {}) or {}),
"avatar_catalog": coordinator.avatar_catalog(),
"challenges": list(data.get("challenges", [])),
"pool_allocations": list(data.get("pool_allocations", [])),
"timed_sessions": list(data.get("timed_sessions", [])),
"templates": coordinator.get_all_templates(),
# Operational state — used by the panel's Activity tab + approval banner
"completions": completions, # all (panel slices for display)
"pending_completions": [c for c in completions if not c.get("approved")],
"reward_claims": reward_claims,
"pending_reward_claims": [c for c in reward_claims if not c.get("approved")],
"mandatory_misses": coordinator.mandatory_misses_state(), # missed mandatory chores awaiting review (#532)
"points_transactions": transactions[-100:], # most recent 100 for audit log
"badges": list(data.get("badges", [])),
"awarded_badges": list(data.get("awarded_badges", [])),
"audit_log": coordinator.storage.get_audit_log()[:100], # newest 100 for the panel
"swap_requests": [r for r in coordinator.storage.get_swap_requests() if r.get("status") == "pending"],
"allowance_payouts": list(reversed(coordinator.storage.get_allowance_payouts()))[:50], # newest first (FEAT-3)
"settings": {
"points_name": data.get("points_name", "Stars"),
"points_icon": data.get("points_icon", "mdi:star"),
"card_design": "classic",
# Difficulty multiplier defaults; overridden by stored values below.
"difficulty_multiplier_easy": 0.5,
"difficulty_multiplier_medium": 1.0,
"difficulty_multiplier_hard": 2.0,
**(data.get("settings", {}) or {}),
},
"parent_completable": parent_completable,
}
@websocket_api.websocket_command({vol.Required("type"): WS_GET_STATE})
@websocket_api.async_response
@_admin_only
async def _ws_get_state(hass, connection, msg, coordinator):
connection.send_result(msg["id"], _build_state_snapshot(coordinator))
# ---------------------------------------------------------------------------
# Children
# ---------------------------------------------------------------------------
@websocket_api.websocket_command({
vol.Required("type"): WS_ADD_CHILD,
vol.Required("name"): vol.All(str, vol.Length(min=1, max=120)),
vol.Optional("avatar", default="mdi:account-circle"): str,
vol.Optional("availability_entity", default=""): str,
vol.Optional("availability_inverted", default=False): bool,
vol.Optional("unavailability_entity", default=""): str,
vol.Optional("pause_streak_when_unavailable", default=False): bool,
vol.Optional("linked_user_id", default=""): str,
})
@websocket_api.async_response
@_admin_only
async def _ws_add_child(hass, connection, msg, coordinator):
child = await coordinator.async_add_child(
name=msg["name"].strip(),
avatar=msg.get("avatar") or "mdi:account-circle",
availability_entity=_opt_str(msg.get("availability_entity")),
availability_inverted=bool(msg.get("availability_inverted", False)),
unavailability_entity=_opt_str(msg.get("unavailability_entity")),
pause_streak_when_unavailable=bool(msg.get("pause_streak_when_unavailable", False)),
linked_user_id=_opt_str(msg.get("linked_user_id")),
)
connection.send_result(msg["id"], {"id": child.id})
@websocket_api.websocket_command({
vol.Required("type"): WS_UPDATE_CHILD,
vol.Required("child_id"): str,
vol.Optional("name"): vol.All(str, vol.Length(min=1, max=120)),
vol.Optional("avatar"): str,
vol.Optional("availability_entity"): str,
vol.Optional("availability_inverted"): bool,
vol.Optional("unavailability_entity"): str,
vol.Optional("pause_streak_when_unavailable"): bool,
vol.Optional("linked_user_id"): str,
vol.Optional("is_guest"): bool,
vol.Optional("guest_expires_on"): str,
})
@websocket_api.async_response
@_admin_only
async def _ws_update_child(hass, connection, msg, coordinator):
existing = coordinator.storage.get_child(msg["child_id"])
if not existing:
connection.send_error(msg["id"], "not_found", f"Child {msg['child_id']} not found")
return
if "name" in msg:
existing.name = msg["name"].strip()
if "avatar" in msg:
existing.avatar = msg["avatar"] or "mdi:account-circle"
if "availability_entity" in msg:
existing.availability_entity = _opt_str(msg["availability_entity"])
if "availability_inverted" in msg:
existing.availability_inverted = bool(msg["availability_inverted"])
if "unavailability_entity" in msg:
existing.unavailability_entity = _opt_str(msg["unavailability_entity"])
if "pause_streak_when_unavailable" in msg:
existing.pause_streak_when_unavailable = bool(msg["pause_streak_when_unavailable"])
if "linked_user_id" in msg:
existing.linked_user_id = _opt_str(msg["linked_user_id"])
if "is_guest" in msg or "guest_expires_on" in msg:
# Routed through the coordinator so the expiry is validated and an
# archived guest is un-archived when promoted to a family member.
try:
await coordinator.async_set_guest(
existing.id,
bool(msg.get("is_guest", existing.is_guest)),
_opt_str(msg.get("guest_expires_on", existing.guest_expires_on)),
)
except ValueError as err:
connection.send_error(msg["id"], "invalid_format", str(err))
return
existing = coordinator.storage.get_child(msg["child_id"])
await coordinator.async_update_child(existing)
connection.send_result(msg["id"], {"id": existing.id})
@websocket_api.websocket_command({
vol.Required("type"): WS_REMOVE_CHILD,
vol.Required("child_id"): str,
})
@websocket_api.async_response
@_admin_only
async def _ws_remove_child(hass, connection, msg, coordinator):
if not coordinator.storage.get_child(msg["child_id"]):
connection.send_error(msg["id"], "not_found", f"Child {msg['child_id']} not found")
return
await coordinator.async_remove_child(msg["child_id"])
connection.send_result(msg["id"], {"id": msg["child_id"]})
@websocket_api.websocket_command({
vol.Required("type"): WS_LIST_HA_USERS,
})
@websocket_api.async_response
@_admin_only
async def _ws_list_ha_users(hass, connection, msg, coordinator):
"""Return selectable HA users for linking a child to an account.
Admin-only — it exposes account names. Excludes system-generated accounts
(e.g. Supervisor, Home Assistant Content) and inactive users.
"""
users = await hass.auth.async_get_users()
result = [
{"id": u.id, "name": u.name or "(unnamed user)", "is_admin": bool(u.is_admin)}
for u in users
if u.is_active and not u.system_generated
]
result.sort(key=lambda x: x["name"].lower())
connection.send_result(msg["id"], {"users": result})
# ---------------------------------------------------------------------------
# Chores
# ---------------------------------------------------------------------------
# Fields the panel is allowed to set directly. Anything else (skip_date,
# assignment_current_child_id, publish_calendar_published_dates, etc.) is
# coordinator-managed runtime state and intentionally not exposed.
_CHORE_EDITABLE_FIELDS = {
"name", "description", "points", "assigned_to", "depends_on", "requires_approval",
"time_category", "claim_allowance_minutes", "daily_limit", "completion_sound",
"icon", "difficulty",
"schedule_mode", "due_days", "recurrence", "recurrence_day",
"recurrence_start", "first_occurrence_mode", "visibility_entity",
"visibility_state", "visibility_operator",
"weather_entity", "weather_block_conditions", "weather_temp_min",
"weather_temp_max", "weather_wind_max",
"deadline_at", "speed_bonus_points",
"enabled", "expires_on",
"due_time", "early_bonus", "late_penalty", "require_photo",
"mandatory", "mandatory_penalty_points",
"assignment_mode", "assignment_rotation_anchor", "require_availability",
"publish_calendar_entities", "bonus_subtasks",
"task_type", "timed_rate_points", "timed_rate_minutes", "timed_max_daily_minutes",
}
def _chore_payload_schema(*, require_name: bool):
"""Build a vol.Schema for add/update chore. require_name=True for add."""
name_field = vol.Required("name") if require_name else vol.Optional("name")
return {
name_field: vol.All(str, vol.Length(min=1, max=200)),
vol.Optional("description"): str,
vol.Optional("points"): vol.All(int, vol.Range(min=0)),
vol.Optional("assigned_to"): [str],
vol.Optional("depends_on"): [str],
vol.Optional("requires_approval"): bool,
vol.Optional("time_category"): str,
vol.Optional("claim_allowance_minutes"): vol.All(int, vol.Range(min=0)),
vol.Optional("daily_limit"): vol.All(int, vol.Range(min=1)),
vol.Optional("completion_sound"): str,
vol.Optional("icon"): str,
vol.Optional("difficulty"): vol.In(["easy", "medium", "hard"]),
vol.Optional("schedule_mode"): vol.In(["specific_days", "recurring", "one_shot"]),
vol.Optional("due_days"): [str],
vol.Optional("recurrence"): str,
vol.Optional("recurrence_day"): str,
vol.Optional("recurrence_start"): str,
vol.Optional("first_occurrence_mode"): str,
vol.Optional("visibility_entity"): str,
vol.Optional("visibility_state"): str,
vol.Optional("visibility_operator"): str,
vol.Optional("weather_entity"): str,
vol.Optional("weather_block_conditions"): [str],
# None clears the limit — 0 is a real threshold, so it can't double as "off".
vol.Optional("weather_temp_min"): vol.Any(None, vol.Coerce(float)),
vol.Optional("weather_temp_max"): vol.Any(None, vol.Coerce(float)),
vol.Optional("weather_wind_max"): vol.Any(None, vol.All(vol.Coerce(float), vol.Range(min=0))),
vol.Optional("deadline_at"): str,
vol.Optional("speed_bonus_points"): vol.All(int, vol.Range(min=0)),
vol.Optional("enabled"): bool,
vol.Optional("expires_on"): str,
vol.Optional("due_time"): str,
vol.Optional("early_bonus"): vol.All(int, vol.Range(min=0)),
vol.Optional("late_penalty"): vol.All(int, vol.Range(min=0)),
vol.Optional("mandatory"): bool,
vol.Optional("mandatory_penalty_points"): vol.All(int, vol.Range(min=0)),
vol.Optional("require_photo"): bool,
vol.Optional("assignment_mode"): vol.In(["everyone", "alternating", "random", "balanced", "first_come", "unassigned"]),
vol.Optional("assignment_rotation_anchor"): str,
vol.Optional("require_availability"): bool,
vol.Optional("publish_calendar_entities"): [str],
vol.Optional("bonus_subtasks"): [{
vol.Required("name"): vol.All(str, vol.Length(min=1, max=200)),
vol.Optional("points"): vol.All(int, vol.Range(min=0)),
vol.Optional("description"): str,
vol.Optional("id"): str,
}],
vol.Optional("task_type"): vol.In(["standard", "timed"]),
vol.Optional("timed_rate_points"): vol.All(int, vol.Range(min=1)),
vol.Optional("timed_rate_minutes"): vol.All(int, vol.Range(min=1)),
vol.Optional("timed_max_daily_minutes"): vol.All(int, vol.Range(min=0)),
}
async def _maybe_apply_manual_start(coordinator, chore_id: str, child_id: str | None) -> None:
"""If a manual rotation start was supplied, apply it via the coordinator.
The coordinator handles the "alternating reorders the pool / random+balanced
is ephemeral" semantics; we just pass through. Silently ignored for
'everyone' mode chores (the coordinator raises ValueError).
"""
if not child_id:
return
try:
await coordinator.async_set_chore_manual_start(chore_id, child_id)
except ValueError as err:
_LOGGER.debug("manual start ignored for %s: %s", chore_id, err)
@websocket_api.websocket_command({
vol.Required("type"): WS_ADD_CHORE,
vol.Optional("manual_start_child_id"): vol.Any(str, None),
**_chore_payload_schema(require_name=True),
})
@websocket_api.async_response
@_admin_only
async def _ws_add_chore(hass, connection, msg, coordinator):
chore = await coordinator.async_add_chore(
name=msg["name"].strip(),
points=msg.get("points", 10),
description=msg.get("description", ""),
assigned_to=list(msg.get("assigned_to", []) or []),
requires_approval=msg.get("requires_approval", True),
time_category=msg.get("time_category", "anytime"),
claim_allowance_minutes=msg.get("claim_allowance_minutes", 0),
daily_limit=msg.get("daily_limit", 1),
completion_sound=msg.get("completion_sound", "coin"),
schedule_mode=msg.get("schedule_mode", "specific_days"),
)
extra_fields = (set(msg.keys()) & _CHORE_EDITABLE_FIELDS) - {
"name", "points", "description", "assigned_to", "requires_approval",
"time_category", "claim_allowance_minutes", "daily_limit",
"completion_sound", "schedule_mode",
}
if extra_fields:
for f in extra_fields:
if f == "bonus_subtasks":
setattr(chore, f, [BonusSubTask.from_dict(b) for b in (msg[f] or [])])
else:
setattr(chore, f, msg[f] if not isinstance(msg[f], list) else list(msg[f]))
await coordinator.async_update_chore(chore)
await _maybe_apply_manual_start(coordinator, chore.id, msg.get("manual_start_child_id"))
connection.send_result(msg["id"], {"id": chore.id})
@websocket_api.websocket_command({
vol.Required("type"): WS_UPDATE_CHORE,
vol.Required("chore_id"): str,
vol.Optional("manual_start_child_id"): vol.Any(str, None),
**_chore_payload_schema(require_name=False),
})
@websocket_api.async_response
@_admin_only
async def _ws_update_chore(hass, connection, msg, coordinator):
existing = coordinator.storage.get_chore(msg["chore_id"])
if not existing:
connection.send_error(msg["id"], "not_found", f"Chore {msg['chore_id']} not found")
return
for field in _CHORE_EDITABLE_FIELDS:
if field in msg:
value = msg[field]
if field == "bonus_subtasks":
value = [BonusSubTask.from_dict(b) for b in (value or [])]
elif isinstance(value, list):
value = list(value)
elif field == "name":
value = value.strip()
setattr(existing, field, value)
await coordinator.async_update_chore(existing)
await _maybe_apply_manual_start(coordinator, existing.id, msg.get("manual_start_child_id"))
connection.send_result(msg["id"], {"id": existing.id})
@websocket_api.websocket_command({
vol.Required("type"): WS_REMOVE_CHORE,
vol.Required("chore_id"): str,
})
@websocket_api.async_response
@_admin_only
async def _ws_remove_chore(hass, connection, msg, coordinator):
if not coordinator.storage.get_chore(msg["chore_id"]):
connection.send_error(msg["id"], "not_found", f"Chore {msg['chore_id']} not found")
return
await coordinator.async_remove_chore(msg["chore_id"])
connection.send_result(msg["id"], {"id": msg["chore_id"]})
# ---------------------------------------------------------------------------
# Insight reports (#679)
# ---------------------------------------------------------------------------
@websocket_api.websocket_command({
vol.Required("type"): WS_REPORT_FAIRNESS,
vol.Optional("days"): vol.All(int, vol.Range(min=1, max=90)),
})
@websocket_api.async_response
@_admin_only
async def _ws_report_fairness(hass, connection, msg, coordinator):
connection.send_result(msg["id"], coordinator.fairness_report(msg.get("days")))
@websocket_api.websocket_command({
vol.Required("type"): WS_REPORT_FRICTION,
vol.Optional("days"): vol.All(int, vol.Range(min=1, max=90)),
})
@websocket_api.async_response
@_admin_only
async def _ws_report_friction(hass, connection, msg, coordinator):
connection.send_result(msg["id"], coordinator.friction_report(msg.get("days")))
@websocket_api.websocket_command({
vol.Required("type"): WS_REPORT_PROJECTION,
vol.Optional("days"): vol.All(int, vol.Range(min=1, max=28)),
})
@websocket_api.async_response
@_admin_only
async def _ws_report_projection(hass, connection, msg, coordinator):
connection.send_result(msg["id"], coordinator.projection_report(msg.get("days")))
@websocket_api.websocket_command({
vol.Required("type"): WS_TEMPLATES_EXPORT,
vol.Optional("template_ids"): [str],
})
@websocket_api.async_response
@_admin_only
async def _ws_templates_export(hass, connection, msg, coordinator):
connection.send_result(msg["id"], coordinator.export_templates(msg.get("template_ids")))
@websocket_api.websocket_command({
vol.Required("type"): WS_TEMPLATES_IMPORT,
vol.Required("pack"): dict,
})
@websocket_api.async_response
@_admin_only
async def _ws_templates_import(hass, connection, msg, coordinator):
try:
result = await coordinator.async_import_pack(msg["pack"])
except ValueError as err:
connection.send_error(msg["id"], "invalid_format", str(err))
return
connection.send_result(msg["id"], result)
@websocket_api.websocket_command({
vol.Required("type"): WS_PRINT_CHART,
vol.Optional("orientation", default="portrait"): vol.In(["portrait", "landscape"]),
vol.Optional("week_start"): str,
vol.Optional("title"): vol.All(str, vol.Length(max=80)),
})
@websocket_api.async_response
@_admin_only
async def _ws_print_chart(hass, connection, msg, coordinator):
from datetime import date as _date
from homeassistant.util import dt as dt_util
from . import printable
raw = msg.get("week_start", "")
try:
anchor = _date.fromisoformat(raw) if raw else dt_util.as_local(dt_util.now()).date()
except (TypeError, ValueError):
connection.send_error(msg["id"], "invalid_format", "week_start must be an ISO date")
return
data = coordinator.storage.data
html = printable.build_chart(
children=list(data.get("children", [])),
chores=list(data.get("chores", [])),
start=printable.week_start(anchor),
orientation=msg.get("orientation", "portrait"),
title=msg.get("title") or "This week",
points_name=coordinator.storage.get_points_name(),
)
connection.send_result(msg["id"], {"html": html})
@websocket_api.websocket_command({vol.Required("type"): WS_REPORT_HEALTH})
@websocket_api.async_response
@_admin_only
async def _ws_report_health(hass, connection, msg, coordinator):
connection.send_result(msg["id"], coordinator.health_report())
# ---------------------------------------------------------------------------
# Scheduled config changes (#675)
# ---------------------------------------------------------------------------
@websocket_api.websocket_command({
vol.Required("type"): WS_SCHEDULED_LIST,
vol.Optional("chore_id"): str,
})
@websocket_api.async_response
@_admin_only
async def _ws_scheduled_list(hass, connection, msg, coordinator):
changes = coordinator.get_scheduled_changes(msg.get("chore_id", ""))
connection.send_result(msg["id"], {"changes": [c.to_dict() for c in changes]})
@websocket_api.websocket_command({
vol.Required("type"): WS_SCHEDULED_ADD,
vol.Required("chore_id"): str,
vol.Required("apply_on"): str,
vol.Required("changes"): dict,
vol.Optional("note", default=""): vol.All(str, vol.Length(max=200)),
})
@websocket_api.async_response
@_admin_only
async def _ws_scheduled_add(hass, connection, msg, coordinator):
try:
change = await coordinator.async_add_scheduled_change(
chore_id=msg["chore_id"],
apply_on=msg["apply_on"],
changes=msg["changes"],
note=msg.get("note", ""),
)
except ValueError as err:
connection.send_error(msg["id"], "invalid_format", str(err))
return
connection.send_result(msg["id"], {"id": change.id})
@websocket_api.websocket_command({
vol.Required("type"): WS_SCHEDULED_REMOVE,
vol.Required("change_id"): str,
})
@websocket_api.async_response
@_admin_only
async def _ws_scheduled_remove(hass, connection, msg, coordinator):
try:
await coordinator.async_remove_scheduled_change(msg["change_id"])
except ValueError as err:
connection.send_error(msg["id"], "not_found", str(err))
return
connection.send_result(msg["id"], {"success": True})
# ---------------------------------------------------------------------------
# Rewards
# ---------------------------------------------------------------------------
_REWARD_FIELDS = {"name", "cost", "description", "icon", "assigned_to",
"is_jackpot", "pool_enabled", "quantity", "expires_at",
"restock_enabled", "restock_amount", "restock_period",
"unlock_entity", "unlock_minutes"}
def _reward_payload_schema(*, require_name: bool):
name_field = vol.Required("name") if require_name else vol.Optional("name")
cost_field = vol.Required("cost") if require_name else vol.Optional("cost")
return {
name_field: vol.All(str, vol.Length(min=1, max=200)),
cost_field: vol.All(int, vol.Range(min=0)),
vol.Optional("description"): str,
vol.Optional("icon"): str,
vol.Optional("assigned_to"): [str],
vol.Optional("is_jackpot"): bool,
vol.Optional("pool_enabled"): bool,
vol.Optional("quantity"): vol.Any(None, vol.All(int, vol.Range(min=0))),
vol.Optional("expires_at"): vol.Any(None, str),
vol.Optional("restock_enabled"): bool,
vol.Optional("restock_amount"): vol.All(int, vol.Range(min=0, max=10000)),
vol.Optional("restock_period"): vol.In(["daily", "weekly", "monthly"]),
vol.Optional("unlock_entity"): str,
vol.Optional("unlock_minutes"): vol.All(int, vol.Range(min=0, max=1440)),
}
@websocket_api.websocket_command({
vol.Required("type"): WS_ADD_REWARD,
**_reward_payload_schema(require_name=True),
})
@websocket_api.async_response
@_admin_only
async def _ws_add_reward(hass, connection, msg, coordinator):
# Timed unlock (#678): refuse an entity that isn't on the parent's
# allowlist, at save time, with a message the panel can show.
try:
unlock_entity, unlock_minutes = coordinator.validate_unlock(
msg.get("unlock_entity", ""), msg.get("unlock_minutes", 0),
)
except ValueError as err:
connection.send_error(msg["id"], "invalid_format", str(err))
return
# coordinator.async_add_reward accepts a subset; build a Reward dataclass
# to populate every editable field uniformly.
reward = Reward(
name=msg["name"].strip(),
cost=msg["cost"],
description=msg.get("description", ""),
icon=msg.get("icon", "mdi:gift"),
assigned_to=list(msg.get("assigned_to", []) or []),
is_jackpot=msg.get("is_jackpot", False),
pool_enabled=msg.get("pool_enabled", False),
quantity=msg.get("quantity"),
expires_at=msg.get("expires_at") or None,
restock_enabled=msg.get("restock_enabled", False),
restock_amount=msg.get("restock_amount", 0),
restock_period=msg.get("restock_period", "weekly"),
unlock_entity=unlock_entity,
unlock_minutes=unlock_minutes,
)
coordinator.storage.add_reward(reward)
await coordinator.storage.async_save()
await coordinator.async_refresh()
connection.send_result(msg["id"], {"id": reward.id})
@websocket_api.websocket_command({
vol.Required("type"): WS_UPDATE_REWARD,
vol.Required("reward_id"): str,
**_reward_payload_schema(require_name=False),
})
@websocket_api.async_response
@_admin_only
async def _ws_update_reward(hass, connection, msg, coordinator):
existing = coordinator.storage.get_reward(msg["reward_id"])
if not existing:
connection.send_error(msg["id"], "not_found", f"Reward {msg['reward_id']} not found")
return
if "unlock_entity" in msg or "unlock_minutes" in msg:
try:
coordinator.validate_unlock(
msg.get("unlock_entity", existing.unlock_entity),
msg.get("unlock_minutes", existing.unlock_minutes),
)
except ValueError as err:
connection.send_error(msg["id"], "invalid_format", str(err))
return
for field in _REWARD_FIELDS:
if field in msg:
value = msg[field]
if field == "name" and value:
value = value.strip()
if field == "expires_at":
value = value or None
if isinstance(value, list):
value = list(value)
setattr(existing, field, value)
await coordinator.async_update_reward(existing)
connection.send_result(msg["id"], {"id": existing.id})
@websocket_api.websocket_command({
vol.Required("type"): WS_REMOVE_REWARD,
vol.Required("reward_id"): str,
})
@websocket_api.async_response
@_admin_only
async def _ws_remove_reward(hass, connection, msg, coordinator):
if not coordinator.storage.get_reward(msg["reward_id"]):
connection.send_error(msg["id"], "not_found", f"Reward {msg['reward_id']} not found")
return
await coordinator.async_remove_reward(msg["reward_id"])
connection.send_result(msg["id"], {"id": msg["reward_id"]})
# ---------------------------------------------------------------------------
# Quests (chore chains)
# ---------------------------------------------------------------------------
@websocket_api.websocket_command({
vol.Required("type"): WS_CREATE_QUEST,
vol.Required("name"): vol.All(str, vol.Length(min=1, max=200)),
vol.Optional("description", default=""): str,
vol.Optional("icon", default="mdi:map-marker-path"): str,
vol.Required("steps"): vol.All([str], vol.Length(min=1)),
vol.Optional("bonus_points", default=25): vol.All(int, vol.Range(min=0, max=1000000)),
vol.Optional("assigned_to", default=[]): [str],
vol.Optional("repeatable", default=False): bool,
vol.Optional("active", default=True): bool,
})
@websocket_api.async_response
@_admin_only
async def _ws_create_quest(hass, connection, msg, coordinator):
quest_id = await coordinator.async_create_quest(
name=msg["name"].strip(),
description=msg.get("description", ""),
icon=msg.get("icon", "mdi:map-marker-path"),
steps=list(msg["steps"]),
bonus_points=msg.get("bonus_points", 25),
assigned_to=list(msg.get("assigned_to", []) or []),
repeatable=msg.get("repeatable", False),
active=msg.get("active", True),
)
connection.send_result(msg["id"], {"id": quest_id})
@websocket_api.websocket_command({
vol.Required("type"): WS_UPDATE_QUEST,
vol.Required("quest_id"): str,
vol.Optional("name"): vol.All(str, vol.Length(min=1, max=200)),
vol.Optional("description"): str,
vol.Optional("icon"): str,
vol.Optional("steps"): vol.All([str], vol.Length(min=1)),
vol.Optional("bonus_points"): vol.All(int, vol.Range(min=0, max=1000000)),
vol.Optional("assigned_to"): [str],
vol.Optional("repeatable"): bool,
vol.Optional("active"): bool,
})
@websocket_api.async_response
@_admin_only
async def _ws_update_quest(hass, connection, msg, coordinator):
fields = {k: v for k, v in msg.items() if k not in ("id", "type", "quest_id")}
if "name" in fields and fields["name"]:
fields["name"] = fields["name"].strip()
try:
await coordinator.async_update_quest(msg["quest_id"], **fields)
except ValueError as err:
connection.send_error(msg["id"], "not_found", str(err))
return
connection.send_result(msg["id"], {"id": msg["quest_id"]})
@websocket_api.websocket_command({
vol.Required("type"): WS_DELETE_QUEST,
vol.Required("quest_id"): str,
})
@websocket_api.async_response
@_admin_only
async def _ws_delete_quest(hass, connection, msg, coordinator):
try:
await coordinator.async_delete_quest(msg["quest_id"])
except ValueError as err:
connection.send_error(msg["id"], "not_found", str(err))
return
connection.send_result(msg["id"], {"id": msg["quest_id"]})
# ---------------------------------------------------------------------------
# Avatar unlockables
# ---------------------------------------------------------------------------
@websocket_api.websocket_command({
vol.Required("type"): WS_UPDATE_AVATAR_CATALOG,
vol.Required("catalog"): [{
vol.Optional("id"): str,
vol.Optional("label"): str,
vol.Required("icon"): str,
vol.Optional("unlock_type"): vol.In(["free", "level", "points", "streak"]),
vol.Optional("unlock_value"): vol.All(vol.Coerce(int), vol.Range(min=0, max=1000000)),
}],
})
@websocket_api.async_response
@_admin_only
async def _ws_update_avatar_catalog(hass, connection, msg, coordinator):
await coordinator.async_update_avatar_catalog(list(msg["catalog"]))
connection.send_result(msg["id"], {"count": len(msg["catalog"])})
@websocket_api.websocket_command({
vol.Required("type"): WS_SET_CHILD_AVATAR,
vol.Required("child_id"): str,
vol.Required("icon"): str,
})
@websocket_api.async_response
@_admin_only
async def _ws_set_child_avatar(hass, connection, msg, coordinator):
# Admin (panel) may set any catalogue avatar; unlock gate is for children.
try:
await coordinator.async_set_avatar(msg["child_id"], msg["icon"], enforce_unlock=False)
except ValueError as err:
connection.send_error(msg["id"], "invalid", str(err))
return
connection.send_result(msg["id"], {"id": msg["child_id"]})
# ---------------------------------------------------------------------------
# Challenges (daily / weekly)
# ---------------------------------------------------------------------------
@websocket_api.websocket_command({
vol.Required("type"): WS_CREATE_CHALLENGE,
vol.Required("name"): vol.All(str, vol.Length(min=1, max=200)),
vol.Optional("description", default=""): str,
vol.Optional("icon", default="mdi:trophy-outline"): str,
vol.Optional("scope", default="daily"): vol.In(["daily", "weekly"]),
vol.Optional("metric", default="chores"): vol.In(["chores", "points"]),
vol.Required("target"): vol.All(int, vol.Range(min=1, max=1000000)),
vol.Optional("bonus_points", default=15): vol.All(int, vol.Range(min=0, max=1000000)),
vol.Optional("assigned_to", default=[]): [str],
vol.Optional("active", default=True): bool,
})
@websocket_api.async_response
@_admin_only
async def _ws_create_challenge(hass, connection, msg, coordinator):
challenge_id = await coordinator.async_create_challenge(
name=msg["name"].strip(),
description=msg.get("description", ""),
icon=msg.get("icon", "mdi:trophy-outline"),
scope=msg.get("scope", "daily"),
metric=msg.get("metric", "chores"),
target=msg["target"],
bonus_points=msg.get("bonus_points", 15),
assigned_to=list(msg.get("assigned_to", []) or []),
active=msg.get("active", True),
)
connection.send_result(msg["id"], {"id": challenge_id})
@websocket_api.websocket_command({
vol.Required("type"): WS_UPDATE_CHALLENGE,
vol.Required("challenge_id"): str,
vol.Optional("name"): vol.All(str, vol.Length(min=1, max=200)),
vol.Optional("description"): str,
vol.Optional("icon"): str,
vol.Optional("scope"): vol.In(["daily", "weekly"]),
vol.Optional("metric"): vol.In(["chores", "points"]),
vol.Optional("target"): vol.All(int, vol.Range(min=1, max=1000000)),
vol.Optional("bonus_points"): vol.All(int, vol.Range(min=0, max=1000000)),
vol.Optional("assigned_to"): [str],
vol.Optional("active"): bool,
})
@websocket_api.async_response
@_admin_only
async def _ws_update_challenge(hass, connection, msg, coordinator):
fields = {k: v for k, v in msg.items() if k not in ("id", "type", "challenge_id")}
if "name" in fields and fields["name"]:
fields["name"] = fields["name"].strip()
try:
await coordinator.async_update_challenge(msg["challenge_id"], **fields)
except ValueError as err:
connection.send_error(msg["id"], "not_found", str(err))
return
connection.send_result(msg["id"], {"id": msg["challenge_id"]})
@websocket_api.websocket_command({
vol.Required("type"): WS_DELETE_CHALLENGE,
vol.Required("challenge_id"): str,
})
@websocket_api.async_response
@_admin_only
async def _ws_delete_challenge(hass, connection, msg, coordinator):
try:
await coordinator.async_delete_challenge(msg["challenge_id"])
except ValueError as err:
connection.send_error(msg["id"], "not_found", str(err))
return
connection.send_result(msg["id"], {"id": msg["challenge_id"]})
# ---------------------------------------------------------------------------
# Penalties
# ---------------------------------------------------------------------------
@websocket_api.websocket_command({
vol.Required("type"): WS_ADD_PENALTY,
vol.Required("name"): vol.All(str, vol.Length(min=1, max=200)),
vol.Required("points"): vol.All(int, vol.Range(min=1)),
vol.Optional("description", default=""): str,
vol.Optional("icon", default="mdi:alert-circle-outline"): str,
vol.Optional("assigned_to", default=[]): [str],
})
@websocket_api.async_response
@_admin_only
async def _ws_add_penalty(hass, connection, msg, coordinator):
pen = await coordinator.async_add_penalty(
name=msg["name"].strip(),
points=msg["points"],
description=msg.get("description", ""),
icon=msg.get("icon", "mdi:alert-circle-outline"),
assigned_to=list(msg.get("assigned_to", []) or []),
)
connection.send_result(msg["id"], {"id": pen.id})
@websocket_api.websocket_command({
vol.Required("type"): WS_UPDATE_PENALTY,
vol.Required("penalty_id"): str,
vol.Optional("name"): vol.All(str, vol.Length(min=1, max=200)),
vol.Optional("points"): vol.All(int, vol.Range(min=1)),
vol.Optional("description"): str,
vol.Optional("icon"): str,
vol.Optional("assigned_to"): [str],
})
@websocket_api.async_response
@_admin_only
async def _ws_update_penalty(hass, connection, msg, coordinator):
existing = coordinator.storage.get_penalty(msg["penalty_id"])
if not existing:
connection.send_error(msg["id"], "not_found", f"Penalty {msg['penalty_id']} not found")
return
for k in ("name", "points", "description", "icon", "assigned_to"):
if k in msg:
val = msg[k]
if k == "name" and val:
val = val.strip()
if k == "assigned_to":
val = list(val)
setattr(existing, k, val)
await coordinator.async_update_penalty(existing)
connection.send_result(msg["id"], {"id": existing.id})
@websocket_api.websocket_command({
vol.Required("type"): WS_REMOVE_PENALTY,
vol.Required("penalty_id"): str,
})
@websocket_api.async_response
@_admin_only
async def _ws_remove_penalty(hass, connection, msg, coordinator):
if not coordinator.storage.get_penalty(msg["penalty_id"]):
connection.send_error(msg["id"], "not_found", f"Penalty {msg['penalty_id']} not found")
return
await coordinator.async_remove_penalty(msg["penalty_id"])
connection.send_result(msg["id"], {"id": msg["penalty_id"]})
@websocket_api.websocket_command({
vol.Required("type"): WS_APPLY_PENALTY,
vol.Required("penalty_id"): str,
vol.Required("child_id"): str,
})
@websocket_api.async_response
@_admin_only
async def _ws_apply_penalty(hass, connection, msg, coordinator):
await coordinator.async_apply_penalty(
penalty_id=msg["penalty_id"], child_id=msg["child_id"]
)
connection.send_result(msg["id"], {"penalty_id": msg["penalty_id"], "child_id": msg["child_id"]})
# ---------------------------------------------------------------------------
# Bonuses (mirror of penalties)
# ---------------------------------------------------------------------------
@websocket_api.websocket_command({
vol.Required("type"): WS_ADD_BONUS,
vol.Required("name"): vol.All(str, vol.Length(min=1, max=200)),
vol.Required("points"): vol.All(int, vol.Range(min=1)),
vol.Optional("description", default=""): str,
vol.Optional("icon", default="mdi:star-circle-outline"): str,
vol.Optional("assigned_to", default=[]): [str],
})
@websocket_api.async_response
@_admin_only
async def _ws_add_bonus(hass, connection, msg, coordinator):
b = await coordinator.async_add_bonus(
name=msg["name"].strip(),
points=msg["points"],
description=msg.get("description", ""),
icon=msg.get("icon", "mdi:star-circle-outline"),
assigned_to=list(msg.get("assigned_to", []) or []),
)
connection.send_result(msg["id"], {"id": b.id})
@websocket_api.websocket_command({
vol.Required("type"): WS_UPDATE_BONUS,
vol.Required("bonus_id"): str,
vol.Optional("name"): vol.All(str, vol.Length(min=1, max=200)),
vol.Optional("points"): vol.All(int, vol.Range(min=1)),
vol.Optional("description"): str,
vol.Optional("icon"): str,
vol.Optional("assigned_to"): [str],
})
@websocket_api.async_response
@_admin_only
async def _ws_update_bonus(hass, connection, msg, coordinator):
existing = coordinator.storage.get_bonus(msg["bonus_id"])
if not existing:
connection.send_error(msg["id"], "not_found", f"Bonus {msg['bonus_id']} not found")
return
for k in ("name", "points", "description", "icon", "assigned_to"):
if k in msg:
val = msg[k]
if k == "name" and val:
val = val.strip()
if k == "assigned_to":
val = list(val)
setattr(existing, k, val)
await coordinator.async_update_bonus(existing)
connection.send_result(msg["id"], {"id": existing.id})
@websocket_api.websocket_command({
vol.Required("type"): WS_REMOVE_BONUS,
vol.Required("bonus_id"): str,
})
@websocket_api.async_response
@_admin_only
async def _ws_remove_bonus(hass, connection, msg, coordinator):
if not coordinator.storage.get_bonus(msg["bonus_id"]):
connection.send_error(msg["id"], "not_found", f"Bonus {msg['bonus_id']} not found")
return
await coordinator.async_remove_bonus(msg["bonus_id"])
connection.send_result(msg["id"], {"id": msg["bonus_id"]})
@websocket_api.websocket_command({
vol.Required("type"): WS_APPLY_BONUS,
vol.Required("bonus_id"): str,
vol.Required("child_id"): str,
})
@websocket_api.async_response
@_admin_only
async def _ws_apply_bonus(hass, connection, msg, coordinator):
await coordinator.async_apply_bonus(bonus_id=msg["bonus_id"], child_id=msg["child_id"])
connection.send_result(msg["id"], {"bonus_id": msg["bonus_id"], "child_id": msg["child_id"]})
# ---------------------------------------------------------------------------
# Task groups
# ---------------------------------------------------------------------------
@websocket_api.websocket_command({
vol.Required("type"): WS_ADD_TASK_GROUP,
vol.Required("name"): vol.All(str, vol.Length(min=1, max=200)),
vol.Required("policy"): vol.In(["sticky", "spread"]),
vol.Optional("chore_ids", default=[]): [str],
})
@websocket_api.async_response
@_admin_only
async def _ws_add_task_group(hass, connection, msg, coordinator):
g = await coordinator.async_add_task_group(
name=msg["name"].strip(),
policy=msg["policy"],
chore_ids=list(msg.get("chore_ids", []) or []),
)
connection.send_result(msg["id"], {"id": g.id})
@websocket_api.websocket_command({
vol.Required("type"): WS_UPDATE_TASK_GROUP,
vol.Required("group_id"): str,
vol.Optional("name"): vol.All(str, vol.Length(min=1, max=200)),
vol.Optional("policy"): vol.In(["sticky", "spread"]),
vol.Optional("chore_ids"): [str],
})
@websocket_api.async_response
@_admin_only
async def _ws_update_task_group(hass, connection, msg, coordinator):
g = await coordinator.async_update_task_group(
group_id=msg["group_id"],
name=msg["name"].strip() if "name" in msg else None,
policy=msg.get("policy"),
chore_ids=list(msg["chore_ids"]) if "chore_ids" in msg else None,
)
connection.send_result(msg["id"], {"id": g.id if g else msg["group_id"]})
@websocket_api.websocket_command({
vol.Required("type"): WS_REMOVE_TASK_GROUP,
vol.Required("group_id"): str,
})
@websocket_api.async_response
@_admin_only
async def _ws_remove_task_group(hass, connection, msg, coordinator):
if not coordinator.storage.get_task_group(msg["group_id"]):
connection.send_error(msg["id"], "not_found", f"Group {msg['group_id']} not found")
return
await coordinator.async_remove_task_group(msg["group_id"])
connection.send_result(msg["id"], {"id": msg["group_id"]})
# ---------------------------------------------------------------------------
# Settings — partial update of currency + the "settings" subkey
# ---------------------------------------------------------------------------
# Top-level fields stored at storage._data root
_TOP_LEVEL_SETTINGS = {"points_name", "points_icon"}
# Allowed values for the global default card-design style (per-card design styles).
_ALLOWED_CARD_DESIGNS = {"classic", "playroom", "console", "cleanpro", "accessible"}
# Settings stored under storage._data["settings"][key]
_SUBKEY_SETTINGS = {
"history_days", "streak_reset_mode", "card_design",
"weekend_multiplier", "streak_milestones_enabled", "perfect_week_enabled",
"perfect_week_bonus", "streak_milestones",
"streak_requires_all_chores", "perfect_week_requires_all_chores",
"difficulty_multiplier_easy", "difficulty_multiplier_medium", "difficulty_multiplier_hard",
"unlock_allowlist", "parent_routing",
"read_aloud_media_player", "read_aloud_tts_entity", "read_aloud_template",
"read_aloud_one_template", "read_aloud_done_template", "read_aloud_joiner",
"roulette_enabled", "roulette_multiplier", "roulette_daily_spins",
"surprise_bonus_enabled", "surprise_bonus_chance", "surprise_bonus_min", "surprise_bonus_max",
"points_decay_enabled", "points_decay_period", "points_decay_percent",
"level_xp_step",
"spend_cap_enabled", "spend_cap_period", "spend_cap_amount",
"interest_enabled", "interest_period", "interest_percent",
"celebration_notify", "celebration_notify_min_tier",
"allow_negative_balance",
"allowance_enabled", "allowance_rate", "allowance_currency",
"family_goal_enabled", "family_goal_name", "family_goal_target", "family_goal_reward",
"notify_service", "calendar_projection_days", "skip_confirmation_enabled",
"vacation_calendar",
"time_morning_start", "time_morning_end",
"time_afternoon_start", "time_afternoon_end",
"time_evening_start", "time_evening_end",
"time_night_start", "time_night_end",
}
def _slugify_period_id(label: str, taken: set[str]) -> str:
"""Derive a stable, unique slug id from a period label."""
base = re.sub(r"[^a-z0-9]+", "_", label.lower()).strip("_") or "period"
if base == "anytime":
base = "period"
slug = base
n = 2
while slug in taken:
slug = f"{base}_{n}"
n += 1
return slug
def _validate_time_periods(raw: list, coordinator) -> tuple[list[dict] | None, str | None]:
"""Normalize and validate a time_periods payload.
Returns (periods, None) on success or (None, error_message) on failure.
Enforces: HH:MM times, start < end, non-empty labels for custom periods,
unique ids, non-overlapping when sorted by start, and block-on-delete for
periods still referenced by chores.
"""
if not isinstance(raw, list) or not raw:
return None, "time_periods must be a non-empty list"
if len(raw) > MAX_TIME_PERIODS:
return None, f"too many periods (max {MAX_TIME_PERIODS})"
builtin_ids = {p["id"] for p in DEFAULT_TIME_PERIODS}
time_re = re.compile(r"^([01]\d|2[0-3]):[0-5]\d$")
periods: list[dict] = []
ids: set[str] = {"anytime"}
for entry in raw:
if not isinstance(entry, dict):
return None, "each period must be an object"
label = str(entry.get("label") or "").strip()[:60]
pid = str(entry.get("id") or "").strip()
if pid and pid != "anytime" and pid in ids:
return None, f"duplicate period id: {pid}"
if not pid or pid == "anytime":
pid = _slugify_period_id(label, ids)
if not label and pid not in builtin_ids:
return None, "every custom period needs a name"
start = str(entry.get("start") or "")
end = str(entry.get("end") or "")
if not time_re.match(start) or not time_re.match(end):
return None, f"invalid time for period '{label or pid}' (use HH:MM)"
if start >= end:
return None, f"period '{label or pid}' must start before it ends"
ids.add(pid)
periods.append({
"id": pid,
"label": label,
"start": start,
"end": end,
"icon": str(entry.get("icon") or "").strip()[:120]
or TIME_CATEGORY_ICONS.get(pid, "mdi:clock-outline"),
})
periods.sort(key=lambda p: p["start"])
for prev, cur in zip(periods, periods[1:], strict=False):
if cur["start"] < prev["end"]:
return None, (
f"'{cur['label'] or cur['id']}' overlaps "
f"'{prev['label'] or prev['id']}' — periods cannot overlap"
)
removed = {p["id"] for p in coordinator.get_time_periods()} - {p["id"] for p in periods}
if removed:
in_use = sorted({
chore.name or chore.id
for chore in coordinator.storage.get_chores()
if chore.time_category in removed
})
if in_use:
return None, (
"cannot delete a period still used by chores: "
+ ", ".join(in_use[:10])
+ ("…" if len(in_use) > 10 else "")
)
return periods, None
MAX_VACATION_PERIODS: Final = 50
def _validate_vacation_periods(raw: list) -> tuple[list[dict] | None, str | None]:
"""Normalise and validate a vacation_periods payload.
Returns (periods, None) on success or (None, error_message) on failure.
An empty list is valid (clears all vacations). Each entry needs valid ISO
start/end dates; start/end are swapped if reversed; ids are generated when
missing. Returns periods sorted by start.
"""
if not isinstance(raw, list):
return None, "vacation_periods must be a list"
if len(raw) > MAX_VACATION_PERIODS:
return None, f"too many vacation periods (max {MAX_VACATION_PERIODS})"
periods: list[dict] = []
taken: set[str] = set()
for entry in raw:
if not isinstance(entry, dict):
return None, "each vacation period must be an object"
try:
start = date.fromisoformat(str(entry.get("start")))
end = date.fromisoformat(str(entry.get("end")))
except (TypeError, ValueError):
return None, "each vacation period needs valid start and end dates (YYYY-MM-DD)"
if end < start:
start, end = end, start
pid = str(entry.get("id") or "").strip()
if not pid or pid in taken:
pid = f"{start.isoformat()}_{len(periods)}"
taken.add(pid)
periods.append({
"id": pid,
"name": str(entry.get("name") or "").strip(),
"start": start.isoformat(),
"end": end.isoformat(),
})
return sorted(periods, key=lambda p: p["start"]), None
# Extracted to a module constant so the accepted settings keys can be unit-tested
# (the websocket_command decorator does not expose the compiled schema). Every key
# accepted here must also be routed in _ws_update_settings below.
_UPDATE_SETTINGS_SCHEMA = {
vol.Required("type"): WS_UPDATE_SETTINGS,
vol.Optional("points_name"): vol.All(str, vol.Length(min=1, max=120)),
vol.Optional("points_icon"): str,
vol.Optional("card_design"): vol.In(_ALLOWED_CARD_DESIGNS),
vol.Optional("history_days"): vol.All(int, vol.Range(min=30, max=365)),
vol.Optional("streak_reset_mode"): vol.In(["reset", "pause"]),
vol.Optional("weekend_multiplier"): vol.All(vol.Coerce(float), vol.Range(min=1.0, max=5.0)),
vol.Optional("streak_milestones_enabled"): bool,
vol.Optional("perfect_week_enabled"): bool,
vol.Optional("perfect_week_bonus"): vol.All(int, vol.Range(min=0)),
vol.Optional("streak_requires_all_chores"): bool,
vol.Optional("perfect_week_requires_all_chores"): bool,
vol.Optional("difficulty_multiplier_easy"): vol.All(vol.Coerce(float), vol.Range(min=0.0, max=10.0)),
vol.Optional("difficulty_multiplier_medium"): vol.All(vol.Coerce(float), vol.Range(min=0.0, max=10.0)),
vol.Optional("difficulty_multiplier_hard"): vol.All(vol.Coerce(float), vol.Range(min=0.0, max=10.0)),
vol.Optional("unlock_allowlist"): [str],
vol.Optional("parent_routing"): vol.In(["all", "home", "round_robin"]),
vol.Optional("read_aloud_media_player"): str,
vol.Optional("read_aloud_tts_entity"): str,
vol.Optional("read_aloud_template"): vol.All(str, vol.Length(max=300)),
vol.Optional("read_aloud_one_template"): vol.All(str, vol.Length(max=300)),
vol.Optional("read_aloud_done_template"): vol.All(str, vol.Length(max=300)),
vol.Optional("read_aloud_joiner"): vol.All(str, vol.Length(max=20)),
vol.Optional("roulette_enabled"): bool,
vol.Optional("roulette_multiplier"): vol.All(vol.Coerce(float), vol.Range(min=1.0, max=5.0)),
vol.Optional("roulette_daily_spins"): vol.All(int, vol.Range(min=1, max=10)),
vol.Optional("surprise_bonus_enabled"): bool,
vol.Optional("surprise_bonus_chance"): vol.All(vol.Coerce(float), vol.Range(min=0.0, max=100.0)),
vol.Optional("surprise_bonus_min"): vol.All(int, vol.Range(min=0, max=10000)),
vol.Optional("surprise_bonus_max"): vol.All(int, vol.Range(min=0, max=10000)),
vol.Optional("points_decay_enabled"): bool,
vol.Optional("points_decay_period"): vol.In(["weekly", "monthly"]),
vol.Optional("points_decay_percent"): vol.All(vol.Coerce(float), vol.Range(min=0.0, max=100.0)),
vol.Optional("level_xp_step"): vol.All(int, vol.Range(min=1, max=100000)),
vol.Optional("spend_cap_enabled"): bool,
vol.Optional("spend_cap_period"): vol.In(["weekly", "monthly"]),
vol.Optional("spend_cap_amount"): vol.All(int, vol.Range(min=0, max=1000000)),
vol.Optional("interest_enabled"): bool,
vol.Optional("interest_period"): vol.In(["weekly", "monthly"]),
vol.Optional("interest_percent"): vol.All(vol.Coerce(float), vol.Range(min=0.0, max=100.0)),
vol.Optional("celebration_notify"): bool,
vol.Optional("celebration_notify_min_tier"): vol.All(vol.Coerce(int), vol.Range(min=1, max=3)),
vol.Optional("skip_confirmation_enabled"): bool,
vol.Optional("allow_negative_balance"): bool,
vol.Optional("allowance_enabled"): bool,
vol.Optional("allowance_rate"): vol.All(vol.Coerce(int), vol.Range(min=1, max=100000)),
vol.Optional("allowance_currency"): vol.All(str, vol.Length(max=8)),
vol.Optional("family_goal_enabled"): bool,
vol.Optional("family_goal_name"): vol.All(str, vol.Length(max=120)),
vol.Optional("family_goal_target"): vol.All(vol.Coerce(int), vol.Range(min=1, max=10000000)),
vol.Optional("family_goal_reward"): vol.All(str, vol.Length(max=200)),
vol.Optional("streak_milestones"): str,
vol.Optional("notify_service"): str,
vol.Optional("calendar_projection_days"): vol.All(int, vol.Range(min=1, max=90)),
vol.Optional("vacation_calendar"): str,
vol.Optional("time_morning_start"): vol.Match(r"^\d{2}:\d{2}$"),
vol.Optional("time_morning_end"): vol.Match(r"^\d{2}:\d{2}$"),
vol.Optional("time_afternoon_start"): vol.Match(r"^\d{2}:\d{2}$"),
vol.Optional("time_afternoon_end"): vol.Match(r"^\d{2}:\d{2}$"),
vol.Optional("time_evening_start"): vol.Match(r"^\d{2}:\d{2}$"),
vol.Optional("time_evening_end"): vol.Match(r"^\d{2}:\d{2}$"),
vol.Optional("time_night_start"): vol.Match(r"^\d{2}:\d{2}$"),
vol.Optional("time_night_end"): vol.Match(r"^\d{2}:\d{2}$"),
vol.Optional("time_periods"): list,
vol.Optional("vacation_periods"): list,
vol.Optional("parent_user_ids"): [str],
}
@websocket_api.websocket_command(_UPDATE_SETTINGS_SCHEMA)
@websocket_api.async_response
@_admin_only
async def _ws_update_settings(hass, connection, msg, coordinator):
storage = coordinator.storage
changed = []
if "time_periods" in msg:
periods, err = _validate_time_periods(msg["time_periods"], coordinator)
if err:
connection.send_error(msg["id"], "invalid_time_periods", err)
return
storage.set_setting("time_periods", periods)
changed.append("time_periods")
if "vacation_periods" in msg:
vacations, verr = _validate_vacation_periods(msg["vacation_periods"])
if verr:
connection.send_error(msg["id"], "invalid_vacation_periods", verr)
return
storage.set_setting("vacation_periods", vacations)
changed.append("vacation_periods")
if "parent_user_ids" in msg:
# Non-admin parent role (#661). Admin-gated write (this whole handler is
# @_admin_only) so a parent can never grant/escalate the role.
storage.set_parent_user_ids(list(msg["parent_user_ids"]))
changed.append("parent_user_ids")
for k, v in msg.items():
if k in {"id", "type", "time_periods", "vacation_periods", "parent_user_ids"}:
continue
if k == "points_name":
storage.set_points_name(v.strip())
changed.append(k)
elif k == "points_icon":
storage.set_points_icon(v or "mdi:star")
changed.append(k)
elif k in _SUBKEY_SETTINGS:
if k == "card_design" and v not in _ALLOWED_CARD_DESIGNS:
continue
storage.set_setting(k, v)
changed.append(k)
# Re-arm a family goal when its target/enabled changes (FEAT-4).
if "family_goal_target" in changed or "family_goal_enabled" in changed:
storage.set_setting("family_goal_achieved", False)
if changed:
await storage.async_save()
# Period boundaries moved → re-arm the mandatory-chore end-of-period checks (#532)
if "time_periods" in changed:
await coordinator.async_rearm_mandatory_schedules()
await coordinator.async_refresh()
connection.send_result(msg["id"], {"updated": changed})
# ---------------------------------------------------------------------------
# Operational — approval / rejection / reorder / bulk add
# ---------------------------------------------------------------------------
@websocket_api.websocket_command({
vol.Required("type"): WS_COMPLETE_BONUS_SUBTASK,
vol.Required("chore_id"): str,
vol.Required("bonus_subtask_id"): str,
vol.Required("child_id"): str,
})
@websocket_api.async_response
@_admin_only
async def _ws_complete_bonus_subtask(hass, connection, msg, coordinator):
completion = await coordinator.async_complete_bonus_subtask(
msg["chore_id"], msg["bonus_subtask_id"], msg["child_id"]
)
connection.send_result(msg["id"], {"id": completion.id})
@websocket_api.websocket_command({
vol.Required("type"): WS_APPROVE_CHORE,
vol.Required("completion_id"): str,
})
@websocket_api.async_response
@_admin_only
async def _ws_approve_chore(hass, connection, msg, coordinator):
await coordinator.async_approve_chore(msg["completion_id"])
connection.send_result(msg["id"], {"completion_id": msg["completion_id"]})
@websocket_api.websocket_command({
vol.Required("type"): WS_APPROVE_ALL_CHORES,
vol.Optional("completion_ids"): [str],
})
@websocket_api.async_response
@_admin_only
async def _ws_approve_all_chores(hass, connection, msg, coordinator):
count = await coordinator.async_approve_chores_bulk(msg.get("completion_ids"))
connection.send_result(msg["id"], {"count": count})
@websocket_api.websocket_command({
vol.Required("type"): WS_REJECT_CHORE,
vol.Required("completion_id"): str,
})
@websocket_api.async_response
@_admin_only
async def _ws_reject_chore(hass, connection, msg, coordinator):
await coordinator.async_reject_chore(msg["completion_id"])
connection.send_result(msg["id"], {"completion_id": msg["completion_id"]})
@websocket_api.websocket_command({
vol.Required("type"): WS_APPROVE_REWARD,
vol.Required("claim_id"): str,
})
@websocket_api.async_response
@_admin_only
async def _ws_approve_reward(hass, connection, msg, coordinator):
await coordinator.async_approve_reward(msg["claim_id"])
connection.send_result(msg["id"], {"claim_id": msg["claim_id"]})
@websocket_api.websocket_command({
vol.Required("type"): WS_REJECT_REWARD,
vol.Required("claim_id"): str,
})
@websocket_api.async_response
@_admin_only
async def _ws_reject_reward(hass, connection, msg, coordinator):
await coordinator.async_reject_reward(msg["claim_id"])
connection.send_result(msg["id"], {"claim_id": msg["claim_id"]})
@websocket_api.websocket_command({
vol.Required("type"): WS_PARENT_COMPLETE_CHORE,
vol.Required("chore_id"): str,
})
@websocket_api.async_response
@_admin_only
async def _ws_parent_complete_chore(hass, connection, msg, coordinator):
await coordinator.async_parent_complete_chore(msg["chore_id"])
connection.send_result(msg["id"], {"ok": True})
@websocket_api.websocket_command({
vol.Required("type"): WS_SET_CHORE_ORDER,
vol.Required("child_id"): str,
vol.Required("chore_order"): [str],
})
@websocket_api.async_response
@_admin_only
async def _ws_set_chore_order(hass, connection, msg, coordinator):
await coordinator.async_set_chore_order(msg["child_id"], list(msg["chore_order"]))
connection.send_result(msg["id"], {"child_id": msg["child_id"]})
@websocket_api.websocket_command({
vol.Required("type"): WS_SET_GLOBAL_CHORE_ORDER,
vol.Required("chore_order"): [str],
})
@websocket_api.async_response
@_admin_only
async def _ws_set_global_chore_order(hass, connection, msg, coordinator):
await coordinator.async_set_global_chore_order(list(msg["chore_order"]))
connection.send_result(msg["id"], {"ok": True})
@websocket_api.websocket_command({
vol.Required("type"): WS_ADD_CHORES_BULK,
vol.Required("chore_names"): [str],
vol.Optional("points"): vol.All(int, vol.Range(min=0)),
vol.Optional("assigned_to"): [str],
vol.Optional("requires_approval"): bool,
vol.Optional("time_category"): str,
vol.Optional("schedule_mode"): vol.In(["specific_days", "recurring", "one_shot"]),
vol.Optional("due_days"): [str],
vol.Optional("daily_limit"): vol.All(int, vol.Range(min=1)),
vol.Optional("completion_sound"): str,
})
@websocket_api.async_response
@_admin_only
async def _ws_add_chores_bulk(hass, connection, msg, coordinator):
chore_names = [n.strip() for n in msg.get("chore_names", []) if n and n.strip()]
if not chore_names:
connection.send_error(msg["id"], "no_names", "At least one chore name is required")
return
chores = await coordinator.async_add_chores_bulk(
chore_names=chore_names,
points=msg.get("points", 10),
due_days=list(msg.get("due_days", []) or []),
assigned_to=list(msg.get("assigned_to", []) or []),
requires_approval=msg.get("requires_approval", True),
time_category=msg.get("time_category", "anytime"),
daily_limit=msg.get("daily_limit", 1),
schedule_mode=msg.get("schedule_mode", "specific_days"),
completion_sound=msg.get("completion_sound", "coin"),
)
connection.send_result(msg["id"], {"created": [c.id for c in chores], "count": len(chores)})
# ---------------------------------------------------------------------------
# Templates
# ---------------------------------------------------------------------------
@websocket_api.websocket_command({vol.Required("type"): WS_TEMPLATES_LIST})
@websocket_api.async_response
@_admin_only
async def _ws_templates_list(hass, connection, msg, coordinator):
connection.send_result(msg["id"], {"templates": coordinator.get_all_templates()})
@websocket_api.websocket_command({
vol.Required("type"): WS_TEMPLATES_GET,
vol.Required("template_id"): str,
})
@websocket_api.async_response
@_admin_only
async def _ws_templates_get(hass, connection, msg, coordinator):
tpl = coordinator.get_template(msg["template_id"])
if tpl is None:
connection.send_error(msg["id"], "not_found", f"Template {msg['template_id']} not found")
return
connection.send_result(msg["id"], tpl)
@websocket_api.websocket_command({
vol.Required("type"): WS_TEMPLATES_APPLY,
vol.Required("chores"): [{
vol.Required("name"): vol.All(str, vol.Length(min=1, max=200)),
vol.Optional("points"): vol.All(int, vol.Range(min=0)),
vol.Optional("description"): str,
vol.Optional("assigned_to"): [str],
vol.Optional("requires_approval"): bool,
vol.Optional("time_category"): str,
vol.Optional("daily_limit"): vol.All(int, vol.Range(min=1)),
vol.Optional("completion_sound"): str,
vol.Optional("schedule_mode"): vol.In(["specific_days", "recurring", "one_shot"]),
vol.Optional("due_days"): [str],
vol.Optional("recurrence"): str,
vol.Optional("recurrence_day"): str,
vol.Optional("recurrence_start"): str,
vol.Optional("first_occurrence_mode"): str,
vol.Optional("assignment_mode"): vol.In(["everyone", "alternating", "random", "balanced", "first_come", "unassigned"]),
vol.Optional("require_availability"): bool,
vol.Optional("visibility_entity"): str,
vol.Optional("visibility_state"): str,
vol.Optional("visibility_operator"): str,
vol.Optional("task_type"): vol.In(["standard", "timed"]),
vol.Optional("timed_rate_points"): vol.All(int, vol.Range(min=1)),
vol.Optional("timed_rate_minutes"): vol.All(int, vol.Range(min=1)),
vol.Optional("timed_max_daily_minutes"): vol.All(int, vol.Range(min=0)),
}],
})
@websocket_api.async_response
@_admin_only
async def _ws_templates_apply(hass, connection, msg, coordinator):
created_ids = await coordinator.async_apply_template(list(msg["chores"]))
connection.send_result(msg["id"], {"created_ids": created_ids})
@websocket_api.websocket_command({
vol.Required("type"): WS_TEMPLATES_SAVE_FROM,
vol.Required("chore_ids"): vol.All([str], vol.Length(min=1)),
vol.Required("name"): vol.All(str, vol.Length(min=1, max=200)),
vol.Optional("icon", default="mdi:clipboard-list"): str,
})
@websocket_api.async_response
@_admin_only
async def _ws_templates_save_from(hass, connection, msg, coordinator):
tpl_id = await coordinator.async_save_template_from_chores(
chore_ids=list(msg["chore_ids"]),
name=msg["name"],
icon=msg.get("icon", "mdi:clipboard-list"),
)
connection.send_result(msg["id"], {"template_id": tpl_id})
@websocket_api.websocket_command({
vol.Required("type"): WS_TEMPLATES_CREATE,
vol.Required("name"): vol.All(str, vol.Length(min=1, max=200)),
vol.Optional("icon", default="mdi:clipboard-list"): str,
vol.Required("chores"): vol.All(list, vol.Length(min=1)),
})
@websocket_api.async_response
@_admin_only
async def _ws_templates_create(hass, connection, msg, coordinator):
tpl_id = await coordinator.async_create_template(
name=msg["name"],
icon=msg.get("icon", "mdi:clipboard-list"),
chores=list(msg["chores"]),
)
connection.send_result(msg["id"], {"template_id": tpl_id})
@websocket_api.websocket_command({
vol.Required("type"): WS_TEMPLATES_UPDATE,
vol.Required("template_id"): str,
vol.Optional("name"): vol.All(str, vol.Length(min=1, max=200)),
vol.Optional("icon"): str,
vol.Optional("chores"): vol.All(list, vol.Length(min=1)),
})
@websocket_api.async_response
@_admin_only
async def _ws_templates_update(hass, connection, msg, coordinator):
await coordinator.async_update_template(
msg["template_id"],
name=msg.get("name"),
icon=msg.get("icon"),
chores=msg.get("chores"),
)
connection.send_result(msg["id"], {"success": True})
@websocket_api.websocket_command({
vol.Required("type"): WS_TEMPLATES_DELETE,
vol.Required("template_id"): str,
})
@websocket_api.async_response
@_admin_only
async def _ws_templates_delete(hass, connection, msg, coordinator):
await coordinator.async_delete_template(msg["template_id"])
connection.send_result(msg["id"], {"success": True})
# ---------------------------------------------------------------------------
# Notifications
# ---------------------------------------------------------------------------
@websocket_api.websocket_command({vol.Required("type"): WS_NOTIF_GET_STATE})
@websocket_api.async_response
@_admin_only
async def ws_notif_get_state(hass, connection, msg, coordinator):
from .coord_notifications import NOTIFICATION_TYPES
c = coordinator
state = {
"recipients": {
"children": [
{
"id": f"child:{ch.id}",
"name": ch.name,
"notify_service": ch.notify_service,
"quiet_hours_start": ch.quiet_hours_start,
"quiet_hours_end": ch.quiet_hours_end,
}
for ch in c.storage.get_children()
],
"parents": [p.to_dict() for p in c.storage.get_parent_recipients()],
},
"types": [
{
"id": t.id,
"audience": t.audience,
"time_gated": t.time_gated,
"per_recipient_time": t.per_recipient_time,
"actionable": t.actionable,
"default_enabled": t.default_enabled,
}
for t in NOTIFICATION_TYPES
],
"config": {
tid: cfg.to_dict()
for tid, cfg in c.storage.get_all_notification_configs().items()
},
"custom": [n.to_dict() for n in c.storage.get_custom_notifications()],
"settings": {
"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(),
},
}
connection.send_result(msg["id"], state)
@websocket_api.websocket_command({
vol.Required("type"): WS_NOTIF_SET_MASTER,
vol.Required("type_id"): str,
vol.Required("enabled"): bool,
})
@websocket_api.async_response
@_admin_only
async def ws_notif_set_master(hass, connection, msg, coordinator):
await coordinator.notifications.set_master_enabled(msg["type_id"], msg["enabled"])
connection.send_result(msg["id"], {"ok": True})
@websocket_api.websocket_command({
vol.Required("type"): WS_NOTIF_SET_ROUTE,
vol.Required("type_id"): str,
vol.Required("recipient_id"): str,
vol.Required("enabled"): bool,
vol.Optional("time"): vol.Any(str, None),
})
@websocket_api.async_response
@_admin_only
async def ws_notif_set_route(hass, connection, msg, coordinator):
from .models import NotificationRoute
route = NotificationRoute(enabled=msg["enabled"], time=msg.get("time"))
await coordinator.notifications.set_route(msg["type_id"], msg["recipient_id"], route)
connection.send_result(msg["id"], {"ok": True})
@websocket_api.websocket_command({
vol.Required("type"): WS_NOTIF_SET_CHILD_NOTIFY,
vol.Required("child_id"): str,
vol.Required("notify_service"): vol.Any(str, None),
})
@websocket_api.async_response
@_admin_only
async def ws_notif_set_child_notify(hass, connection, msg, coordinator):
c = coordinator
child = c.storage.get_child(msg["child_id"])
if child is None:
connection.send_error(msg["id"], "not_found", "Child not found")
return
child.notify_service = msg["notify_service"] or None
c.storage.update_child(child)
await c.storage.async_save()
await c.notifications.async_setup_schedules()
connection.send_result(msg["id"], {"ok": True})
def _validate_hhmm_or_empty(value):
"""voluptuous validator: '' (disabled) or a valid 'HH:MM' string."""
if value in (None, ""):
return ""
if not isinstance(value, str):
raise vol.Invalid("must be a string")
parts = value.split(":")
if len(parts) != 2 or not (parts[0].isdigit() and parts[1].isdigit()):
raise vol.Invalid("must be HH:MM")
hour, minute = int(parts[0]), int(parts[1])
if not (0 <= hour <= 23 and 0 <= minute <= 59):
raise vol.Invalid("HH:MM out of range")
return f"{hour:02d}:{minute:02d}"
@websocket_api.websocket_command({
vol.Required("type"): WS_NOTIF_SET_CHILD_QUIET,
vol.Required("child_id"): str,
vol.Required("quiet_hours_start"): _validate_hhmm_or_empty,
vol.Required("quiet_hours_end"): _validate_hhmm_or_empty,
})
@websocket_api.async_response
@_admin_only
async def ws_notif_set_child_quiet(hass, connection, msg, coordinator):
c = coordinator
child = c.storage.get_child(msg["child_id"])
if child is None:
connection.send_error(msg["id"], "not_found", "Child not found")
return
child.quiet_hours_start = msg["quiet_hours_start"]
child.quiet_hours_end = msg["quiet_hours_end"]
c.storage.update_child(child)
await c.storage.async_save()
connection.send_result(msg["id"], {"ok": True})
@websocket_api.websocket_command({
vol.Required("type"): WS_NOTIF_UPSERT_PARENT,
vol.Optional("parent_id"): str,
vol.Required("name"): str,
vol.Required("notify_service"): str,
vol.Optional("enabled", default=True): bool,
vol.Optional("presence_entity", default=""): str,
})
@websocket_api.async_response
@_admin_only
async def ws_notif_upsert_parent(hass, connection, msg, coordinator):
from .models import ParentRecipient
c = coordinator
p_id = msg.get("parent_id")
if p_id:
existing = next(
(p for p in c.storage.get_parent_recipients() if p.id == p_id), None
)
if existing is None:
connection.send_error(msg["id"], "not_found", "Parent not found")
return
existing.name = msg["name"]
existing.notify_service = msg["notify_service"]
existing.enabled = msg["enabled"]
existing.presence_entity = msg.get("presence_entity", "")
await c.notifications.upsert_parent(existing)
connection.send_result(msg["id"], existing.to_dict())
else:
p = ParentRecipient(
name=msg["name"],
notify_service=msg["notify_service"],
enabled=msg["enabled"],
presence_entity=msg.get("presence_entity", ""),
)
await c.notifications.upsert_parent(p)
connection.send_result(msg["id"], p.to_dict())
@websocket_api.websocket_command({
vol.Required("type"): WS_NOTIF_DELETE_PARENT,
vol.Required("parent_id"): str,
})
@websocket_api.async_response
@_admin_only
async def ws_notif_delete_parent(hass, connection, msg, coordinator):
await coordinator.notifications.delete_parent(msg["parent_id"])
connection.send_result(msg["id"], {"ok": True})
@websocket_api.websocket_command({
vol.Required("type"): WS_NOTIF_UPSERT_CUSTOM,
vol.Optional("custom_id"): str,
vol.Required("name"): str,
vol.Required("message_template"): str,
vol.Required("time"): str,
vol.Optional("day_mask", default=0b1111111): int,
vol.Optional("recipient_ids", default=list): list,
vol.Optional("enabled", default=True): bool,
})
@websocket_api.async_response
@_admin_only
async def ws_notif_upsert_custom(hass, connection, msg, coordinator):
from .models import CustomNotification
c = coordinator
n = CustomNotification.from_dict({
"id": msg.get("custom_id"),
"name": msg["name"],
"message_template": msg["message_template"],
"time": msg["time"],
"day_mask": int(msg["day_mask"]),
"recipient_ids": list(msg["recipient_ids"]),
"enabled": bool(msg["enabled"]),
})
await c.notifications.upsert_custom(n)
connection.send_result(msg["id"], n.to_dict())
@websocket_api.websocket_command({
vol.Required("type"): WS_NOTIF_DELETE_CUSTOM,
vol.Required("custom_id"): str,
})
@websocket_api.async_response
@_admin_only
async def ws_notif_delete_custom(hass, connection, msg, coordinator):
await coordinator.notifications.delete_custom(msg["custom_id"])
connection.send_result(msg["id"], {"ok": True})
@websocket_api.websocket_command({vol.Required("type"): WS_NOTIF_LIST_NOTIFY})
@websocket_api.async_response
@_admin_only
async def ws_notif_list_notify(hass, connection, msg, coordinator):
services = [
f"notify.{name}"
for name in hass.services.async_services().get("notify", {})
]
services.sort()
connection.send_result(msg["id"], services)
@websocket_api.websocket_command({
vol.Required("type"): WS_NOTIF_SET_STREAK_CUTOFF,
vol.Required("time"): str,
})
@websocket_api.async_response
@_admin_only
async def ws_notif_set_streak_cutoff(hass, connection, msg, coordinator):
await coordinator.notifications.set_streak_cutoff(msg["time"])
connection.send_result(msg["id"], {"ok": True})
@websocket_api.websocket_command({
vol.Required("type"): WS_NOTIF_SET_ESCALATION,
vol.Required("reminder_minutes"): vol.All(vol.Coerce(int), vol.Range(min=1, max=1440)),
vol.Required("parent_minutes"): vol.All(vol.Coerce(int), vol.Range(min=1, max=1440)),
})
@websocket_api.async_response
@_admin_only
async def ws_notif_set_escalation(hass, connection, msg, coordinator):
coordinator.storage.set_escalation_minutes(
msg["reminder_minutes"], msg["parent_minutes"]
)
await coordinator.storage.async_save()
connection.send_result(msg["id"], {"ok": True})
@websocket_api.websocket_command({
vol.Required("type"): WS_NOTIF_SEND_TEST,
vol.Required("type_id"): str,
})
@websocket_api.async_response
@_admin_only
async def ws_notif_send_test(hass, connection, msg, coordinator):
sent = await coordinator.notifications.send_test(msg["type_id"])
connection.send_result(msg["id"], {"sent": sent})
# ---------------------------------------------------------------------------
# Calendar ICS feed (FEAT-10)
# ---------------------------------------------------------------------------
def _build_ics_url(hass, token: str) -> str:
from .http_calendar import CALENDAR_URL
base = ""
try:
from homeassistant.helpers.network import get_url
base = get_url(hass, prefer_external=True)
except Exception: # noqa: BLE001 - no configured URL yet
base = ""
return f"{base}{CALENDAR_URL}?token={token}"
@websocket_api.websocket_command({vol.Required("type"): WS_CAL_GET_URL})
@websocket_api.async_response
@_admin_only
async def ws_cal_get_url(hass, connection, msg, coordinator):
token = await coordinator.async_get_or_create_ics_token()
connection.send_result(msg["id"], {"token": token, "url": _build_ics_url(hass, token)})
@websocket_api.websocket_command({vol.Required("type"): WS_CAL_REGEN_TOKEN})
@websocket_api.async_response
@_admin_only
async def ws_cal_regen_token(hass, connection, msg, coordinator):
token = await coordinator.async_regenerate_ics_token()
connection.send_result(msg["id"], {"token": token, "url": _build_ics_url(hass, token)})
# ---------------------------------------------------------------------------
# Admin audit log
# ---------------------------------------------------------------------------
@websocket_api.websocket_command({vol.Required("type"): WS_AUDIT_LIST})
@websocket_api.async_response
@_admin_only
async def _ws_audit_list(hass, connection, msg, coordinator):
connection.send_result(msg["id"], {"entries": coordinator.storage.get_audit_log()})
@websocket_api.websocket_command({vol.Required("type"): WS_AUDIT_CLEAR})
@websocket_api.async_response
@_admin_only
async def _ws_audit_clear(hass, connection, msg, coordinator):
coordinator.storage.clear_audit_log()
await coordinator.storage.async_save()
connection.send_result(msg["id"], {"cleared": True})
@websocket_api.websocket_command({
vol.Required("type"): WS_UNDO_TRANSACTION,
vol.Required("transaction_id"): str,
})
@websocket_api.async_response
@_admin_only
async def _ws_undo_transaction(hass, connection, msg, coordinator):
await coordinator.async_undo_transaction(msg["transaction_id"])
connection.send_result(msg["id"], {"undone": msg["transaction_id"]})
@websocket_api.websocket_command({
vol.Required("type"): WS_CLONE_CHORE,
vol.Required("chore_id"): str,
})
@websocket_api.async_response
@_admin_only
async def _ws_clone_chore(hass, connection, msg, coordinator):
clone = await coordinator.async_clone_chore(msg["chore_id"])
connection.send_result(msg["id"], {"id": clone.id})
@websocket_api.websocket_command({
vol.Required("type"): WS_BULK_CHORE_ACTION,
vol.Required("action"): vol.In(["delete", "enable", "disable", "reassign"]),
vol.Required("chore_ids"): [str],
vol.Optional("assigned_to"): [str],
})
@websocket_api.async_response
@_admin_only
async def _ws_bulk_chore_action(hass, connection, msg, coordinator):
count = await coordinator.async_bulk_chore_action(
msg["action"], msg["chore_ids"], msg.get("assigned_to"),
)
connection.send_result(msg["id"], {"count": count})
@websocket_api.websocket_command({
vol.Required("type"): WS_GIFT_POINTS,
vol.Required("from_child_id"): str,
vol.Required("to_child_id"): str,
vol.Required("points"): vol.All(int, vol.Range(min=1)),
})
@websocket_api.async_response
@_admin_only
async def _ws_gift_points(hass, connection, msg, coordinator):
await coordinator.async_gift_points(msg["from_child_id"], msg["to_child_id"], msg["points"])
connection.send_result(msg["id"], {"ok": True})
@websocket_api.websocket_command({
vol.Required("type"): WS_REQUEST_SWAP,
vol.Required("chore_id"): str,
vol.Required("requester_id"): str,
})
@websocket_api.async_response
@_admin_only
async def _ws_request_swap(hass, connection, msg, coordinator):
rid = await coordinator.async_request_swap(msg["chore_id"], msg["requester_id"])
connection.send_result(msg["id"], {"id": rid})
@websocket_api.websocket_command({
vol.Required("type"): WS_APPROVE_SWAP,
vol.Required("request_id"): str,
})
@websocket_api.async_response
@_admin_only
async def _ws_approve_swap(hass, connection, msg, coordinator):
await coordinator.async_approve_swap(msg["request_id"])
connection.send_result(msg["id"], {"ok": True})
@websocket_api.websocket_command({
vol.Required("type"): WS_REJECT_SWAP,
vol.Required("request_id"): str,
})
@websocket_api.async_response
@_admin_only
async def _ws_reject_swap(hass, connection, msg, coordinator):
await coordinator.async_reject_swap(msg["request_id"])
connection.send_result(msg["id"], {"ok": True})
@websocket_api.websocket_command({vol.Required("type"): WS_CONFIG_EXPORT})
@websocket_api.async_response
@_admin_only
async def _ws_config_export(hass, connection, msg, coordinator):
connection.send_result(msg["id"], coordinator.export_config())
@websocket_api.websocket_command({
vol.Required("type"): WS_CONFIG_IMPORT,
vol.Required("payload"): dict,
})
@websocket_api.async_response
@_admin_only
async def _ws_config_import(hass, connection, msg, coordinator):
await coordinator.async_import_config(msg["payload"])
connection.send_result(msg["id"], {"imported": True})
# ---------------------------------------------------------------------------
# Registration
# ---------------------------------------------------------------------------
_COMMANDS = (
_ws_get_state,
_ws_audit_list, _ws_audit_clear, _ws_undo_transaction,
_ws_add_child, _ws_update_child, _ws_remove_child, _ws_list_ha_users,
_ws_add_chore, _ws_update_chore, _ws_remove_chore, _ws_clone_chore,
_ws_scheduled_list, _ws_scheduled_add, _ws_scheduled_remove,
_ws_report_fairness, _ws_report_friction, _ws_report_projection, _ws_report_health,
_ws_templates_export, _ws_templates_import, _ws_print_chart,
_ws_bulk_chore_action, _ws_gift_points,
_ws_request_swap, _ws_approve_swap, _ws_reject_swap,
_ws_config_export, _ws_config_import,
_ws_add_reward, _ws_update_reward, _ws_remove_reward,
_ws_create_quest, _ws_update_quest, _ws_delete_quest,
_ws_update_avatar_catalog, _ws_set_child_avatar,
_ws_create_challenge, _ws_update_challenge, _ws_delete_challenge,
_ws_add_penalty, _ws_update_penalty, _ws_remove_penalty, _ws_apply_penalty,
_ws_add_bonus, _ws_update_bonus, _ws_remove_bonus, _ws_apply_bonus,
_ws_add_task_group, _ws_update_task_group, _ws_remove_task_group,
_ws_update_settings,
_ws_complete_bonus_subtask,
_ws_approve_chore, _ws_approve_all_chores, _ws_reject_chore, _ws_approve_reward, _ws_reject_reward,
_ws_parent_complete_chore,
_ws_set_chore_order, _ws_set_global_chore_order, _ws_add_chores_bulk,
_ws_templates_list, _ws_templates_get, _ws_templates_apply,
_ws_templates_save_from, _ws_templates_create, _ws_templates_update,
_ws_templates_delete,
ws_notif_get_state, ws_notif_set_master, ws_notif_set_route,
ws_notif_set_child_notify, ws_notif_set_child_quiet,
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_cal_get_url, ws_cal_regen_token,
)
def async_register_websocket_commands(hass: HomeAssistant) -> None:
"""Register all TaskMate WebSocket commands. Idempotent."""
if hass.data.get(DOMAIN, {}).get(WS_REGISTERED):
_LOGGER.debug("TaskMate WS commands already registered, skipping")
return
for cmd in _COMMANDS:
websocket_api.async_register_command(hass, cmd)
hass.data.setdefault(DOMAIN, {})[WS_REGISTERED] = True
_LOGGER.info("Registered %d TaskMate WebSocket commands", len(_COMMANDS))