348 files
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user