"""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 /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))