348 files

This commit is contained in:
Home Assistant Version Control
2026-07-23 19:02:12 +00:00
parent 0ed1687503
commit 016af2e013
348 changed files with 12368 additions and 11689 deletions
+246 -6
View File
@@ -77,6 +77,14 @@ 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"
@@ -127,6 +135,9 @@ 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
@@ -177,8 +188,9 @@ WS_CONFIG_IMPORT: Final = "taskmate/config/import"
# 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_AUDIT_LIST, WS_AUDIT_CLEAR,
WS_CONFIG_EXPORT,
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,
}
@@ -294,6 +306,7 @@ def _build_state_snapshot(coordinator: TaskMateCoordinator) -> dict[str, Any]:
"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", [])),
@@ -377,6 +390,8 @@ async def _ws_add_child(hass, connection, msg, coordinator):
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
@@ -399,6 +414,19 @@ async def _ws_update_child(hass, connection, msg, coordinator):
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})
@@ -448,10 +476,14 @@ async def _ws_list_ha_users(hass, connection, msg, coordinator):
_CHORE_EDITABLE_FIELDS = {
"name", "description", "points", "assigned_to", "depends_on", "requires_approval",
"time_category", "claim_allowance_minutes", "daily_limit", "completion_sound",
"difficulty",
"icon", "difficulty",
"schedule_mode", "due_days", "recurrence", "recurrence_day",
"recurrence_start", "first_occurrence_mode", "visibility_entity",
"visibility_state", "visibility_operator", "enabled", "expires_on",
"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",
@@ -474,6 +506,7 @@ def _chore_payload_schema(*, require_name: bool):
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],
@@ -484,6 +517,14 @@ def _chore_payload_schema(*, require_name: bool):
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,
@@ -602,13 +643,168 @@ async def _ws_remove_chore(hass, connection, msg, coordinator):
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"}
"restock_enabled", "restock_amount", "restock_period",
"unlock_entity", "unlock_minutes"}
def _reward_payload_schema(*, require_name: bool):
@@ -627,6 +823,8 @@ def _reward_payload_schema(*, require_name: bool):
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)),
}
@@ -637,6 +835,16 @@ def _reward_payload_schema(*, require_name: bool):
@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(
@@ -652,6 +860,8 @@ async def _ws_add_reward(hass, connection, msg, coordinator):
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()
@@ -671,6 +881,15 @@ async def _ws_update_reward(hass, connection, msg, coordinator):
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]
@@ -1105,7 +1324,7 @@ async def _ws_remove_task_group(hass, connection, msg, coordinator):
# 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"}
_ALLOWED_CARD_DESIGNS = {"classic", "playroom", "console", "cleanpro", "accessible"}
# Settings stored under storage._data["settings"][key]
_SUBKEY_SETTINGS = {
"history_days", "streak_reset_mode", "card_design",
@@ -1113,6 +1332,10 @@ _SUBKEY_SETTINGS = {
"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",
@@ -1272,6 +1495,17 @@ _UPDATE_SETTINGS_SCHEMA = {
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)),
@@ -1770,6 +2004,7 @@ async def ws_notif_set_child_quiet(hass, connection, msg, coordinator):
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
@@ -1787,6 +2022,7 @@ async def ws_notif_upsert_parent(hass, connection, msg, coordinator):
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:
@@ -1794,6 +2030,7 @@ async def ws_notif_upsert_parent(hass, connection, msg, coordinator):
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())
@@ -2060,6 +2297,9 @@ _COMMANDS = (
_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,