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