278 files

This commit is contained in:
Home Assistant Version Control
2026-09-13 20:38:58 +00:00
parent d7a0a08372
commit 1b4f5f68c6
278 changed files with 36892 additions and 7150 deletions
+130 -19
View File
@@ -68,9 +68,11 @@ from .const import (
MAX_TIME_PERIODS,
SCHEDULE_MODES,
TIME_CATEGORY_ICONS,
is_valid_completion_sound,
)
from .coordinator import TaskMateCoordinator
from .models import BonusSubTask, Reward
from .sounds import MAX_NAME_LEN as MAX_SOUND_NAME_LEN
_LOGGER = logging.getLogger(__name__)
@@ -125,6 +127,10 @@ 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_ADD_CUSTOM_SOUND: Final = "taskmate/add_custom_sound"
WS_RENAME_CUSTOM_SOUND: Final = "taskmate/rename_custom_sound"
WS_REMOVE_CUSTOM_SOUND: Final = "taskmate/remove_custom_sound"
WS_UPDATE_SETTINGS: Final = "taskmate/update_settings"
# Operational
@@ -368,6 +374,7 @@ def _build_state_snapshot(coordinator: TaskMateCoordinator) -> dict[str, Any]:
"points_transactions": transactions[-100:], # most recent 100 for audit log
"badges": list(data.get("badges", [])),
"awarded_badges": list(data.get("awarded_badges", [])),
"custom_sounds": coordinator.custom_sounds_state(), # uploaded completion sounds (#856)
"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)
@@ -521,6 +528,20 @@ async def _ws_list_ha_users(hass, connection, msg, coordinator):
# ---------------------------------------------------------------------------
def _completion_sound(value):
"""Accept a built-in sound name or a ``custom:<file>`` reference (#856).
This field was previously an unvalidated ``str``, so a typo'd or stale name
persisted happily and then played nothing. Like ``_image_url_or_blank``
below, it raises rather than returning False — voluptuous treats a callable
as a coercer, so a falsy return would be stored as the value.
"""
text = str(value or "")
if is_valid_completion_sound(text):
return text
raise vol.Invalid(f"Unknown completion sound: {text}")
def _image_url_or_blank(value):
"""Accept a blank string (clears the picture) or one of our image URLs.
@@ -607,7 +628,7 @@ def _chore_payload_schema(*, require_name: 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("completion_sound"): _completion_sound,
vol.Optional("icon"): str,
vol.Optional("image_url"): _image_url_or_blank,
vol.Optional("difficulty"): vol.In(DIFFICULTY_TIERS),
@@ -938,6 +959,22 @@ async def _ws_scheduled_remove(hass, connection, msg, coordinator):
# Rewards
# ---------------------------------------------------------------------------
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}"
_REWARD_FIELDS = {
"name",
"cost",
@@ -953,6 +990,10 @@ _REWARD_FIELDS = {
"restock_period",
"unlock_entity",
"unlock_minutes",
"time_lock_enabled",
"available_days",
"available_from",
"available_until",
}
@@ -974,6 +1015,10 @@ def _reward_payload_schema(*, require_name: bool):
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)),
vol.Optional("time_lock_enabled"): bool,
vol.Optional("available_days"): [vol.All(int, vol.Range(min=0, max=6))],
vol.Optional("available_from"): _validate_hhmm_or_empty,
vol.Optional("available_until"): _validate_hhmm_or_empty,
}
@@ -1014,6 +1059,10 @@ async def _ws_add_reward(hass, connection, msg, coordinator):
restock_period=msg.get("restock_period", "weekly"),
unlock_entity=unlock_entity,
unlock_minutes=unlock_minutes,
time_lock_enabled=msg.get("time_lock_enabled", False),
available_days=sorted(set(msg.get("available_days", []) or [])),
available_from=msg.get("available_from", "") or "",
available_until=msg.get("available_until", "") or "",
)
coordinator.storage.add_reward(reward)
await coordinator.storage.async_save()
@@ -1051,6 +1100,8 @@ async def _ws_update_reward(hass, connection, msg, coordinator):
value = value.strip()
if field == "expires_at":
value = value or None
if field == "available_days":
value = sorted(set(value or []))
if isinstance(value, list):
value = list(value)
setattr(existing, field, value)
@@ -1517,6 +1568,69 @@ async def _ws_remove_task_group(hass, connection, msg, coordinator):
connection.send_result(msg["id"], {"id": msg["group_id"]})
# ---------------------------------------------------------------------------
# Custom completion sounds (#856)
#
# The audio file is uploaded separately to the admin-only HTTP view in
# http_sounds.py, which returns its generated filename. These commands manage
# the registry entry that gives that file a display name and makes it
# selectable. All three are admin-only, matching the upload view.
# ---------------------------------------------------------------------------
@websocket_api.websocket_command(
{
vol.Required("type"): WS_ADD_CUSTOM_SOUND,
vol.Required("file"): str,
vol.Required("name"): vol.All(str, vol.Length(min=1, max=MAX_SOUND_NAME_LEN)),
}
)
@websocket_api.async_response
@_admin_only
async def _ws_add_custom_sound(hass, connection, msg, coordinator):
try:
sound = await coordinator.async_add_custom_sound(msg["file"], msg["name"])
except ValueError as err:
connection.send_error(msg["id"], "invalid_sound", str(err))
return
connection.send_result(msg["id"], {"file": sound.file, "name": sound.name})
@websocket_api.websocket_command(
{
vol.Required("type"): WS_RENAME_CUSTOM_SOUND,
vol.Required("file"): str,
vol.Required("name"): vol.All(str, vol.Length(min=1, max=MAX_SOUND_NAME_LEN)),
}
)
@websocket_api.async_response
@_admin_only
async def _ws_rename_custom_sound(hass, connection, msg, coordinator):
try:
sound = await coordinator.async_rename_custom_sound(msg["file"], msg["name"])
except ValueError as err:
connection.send_error(msg["id"], "not_found", str(err))
return
connection.send_result(msg["id"], {"file": sound.file, "name": sound.name})
@websocket_api.websocket_command(
{
vol.Required("type"): WS_REMOVE_CUSTOM_SOUND,
vol.Required("file"): str,
}
)
@websocket_api.async_response
@_admin_only
async def _ws_remove_custom_sound(hass, connection, msg, coordinator):
try:
reset = await coordinator.async_remove_custom_sound(msg["file"])
except ValueError as err:
connection.send_error(msg["id"], "not_found", str(err))
return
connection.send_result(msg["id"], {"file": msg["file"], "chores_reset": reset})
# ---------------------------------------------------------------------------
# Settings — partial update of currency + the "settings" subkey
# ---------------------------------------------------------------------------
@@ -1527,6 +1641,7 @@ _TOP_LEVEL_SETTINGS = {"points_name", "points_icon"}
_ALLOWED_CARD_DESIGNS = {"classic", "playroom", "console", "cleanpro", "accessible"}
# Settings stored under storage._data["settings"][key]
_SUBKEY_SETTINGS = {
"require_linked_child",
"history_days",
"streak_reset_mode",
"card_design",
@@ -1734,6 +1849,7 @@ _UPDATE_SETTINGS_SCHEMA = {
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("require_linked_child"): bool,
vol.Optional("parent_routing"): vol.In(["all", "home", "round_robin"]),
vol.Optional("read_aloud_media_player"): str,
vol.Optional("read_aloud_tts_entity"): str,
@@ -1980,7 +2096,7 @@ async def _ws_set_global_chore_order(hass, connection, msg, coordinator):
vol.Optional("schedule_mode"): vol.In(SCHEDULE_MODES),
vol.Optional("due_days"): [str],
vol.Optional("daily_limit"): vol.All(int, vol.Range(min=1)),
vol.Optional("completion_sound"): str,
vol.Optional("completion_sound"): _completion_sound,
}
)
@websocket_api.async_response
@@ -2044,7 +2160,7 @@ async def _ws_templates_get(hass, connection, msg, coordinator):
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("completion_sound"): _completion_sound,
vol.Optional("schedule_mode"): vol.In(SCHEDULE_MODES),
vol.Optional("due_days"): [str],
vol.Optional("recurrence"): str,
@@ -2248,21 +2364,6 @@ async def ws_notif_set_child_notify(hass, connection, msg, coordinator):
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,
@@ -2505,7 +2606,14 @@ async def ws_cal_regen_token(hass, connection, msg, coordinator):
@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()})
connection.send_result(
msg["id"],
{
"entries": coordinator.storage.get_audit_log(),
# Non-zero means older entries have aged out of the capped log.
"dropped": coordinator.storage.get_audit_dropped_count(),
},
)
@websocket_api.websocket_command({vol.Required("type"): WS_AUDIT_CLEAR})
@@ -2693,6 +2801,9 @@ _COMMANDS = (
_ws_add_task_group,
_ws_update_task_group,
_ws_remove_task_group,
_ws_add_custom_sound,
_ws_rename_custom_sound,
_ws_remove_custom_sound,
_ws_update_settings,
_ws_complete_bonus_subtask,
_ws_approve_chore,