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
+219 -5
View File
@@ -19,6 +19,7 @@ from .models import (
Chore,
ChoreCompletion,
CustomNotification,
CustomSound,
MandatoryMiss,
NotificationConfig,
NotificationRoute,
@@ -32,10 +33,30 @@ from .models import (
ScheduledChange,
TaskGroup,
TimedSession,
parse_datetime,
)
_LOGGER = logging.getLogger(__name__)
def _finite_number(value: Any, default: int | float) -> int | float:
"""Coerce an imported value to a real, finite number.
Returns ``default`` for anything that isn't usable — a string that doesn't
parse, a bool, a dict, or inf/NaN. Booleans are excluded deliberately:
``float(True)`` is 1.0, which would silently turn a flag into a score.
"""
if isinstance(value, bool) or isinstance(value, (dict, list)):
return default
try:
num = float(value)
except (TypeError, ValueError):
return default
if num != num or num in (float("inf"), float("-inf")):
return default
return int(num) if float(num).is_integer() else num
STORAGE_VERSION = 1
STORAGE_KEY = f"{DOMAIN}.storage"
@@ -117,6 +138,10 @@ class TaskMateStorage:
if "chore_display_order" not in self._data:
self._data["chore_display_order"] = []
# Ensure custom_sounds store exists (migration for custom sounds, #856)
if "custom_sounds" not in self._data:
self._data["custom_sounds"] = []
# Ensure scheduled_changes store exists (#675)
if "scheduled_changes" not in self._data:
self._data["scheduled_changes"] = []
@@ -558,6 +583,48 @@ class TaskMateStorage:
"""Remove a bonus."""
self._data["bonuses"] = [b for b in self._data.get("bonuses", []) if b.get("id") != bonus_id]
# Custom sounds management (#856)
def get_custom_sounds(self) -> list[CustomSound]:
"""Get all uploaded custom completion sounds."""
return [CustomSound.from_dict(s) for s in self._data.get("custom_sounds", [])]
def get_custom_sound(self, file: str) -> CustomSound | None:
"""Get a custom sound by its stored filename."""
for s in self._data.get("custom_sounds", []):
if s.get("file") == file:
return CustomSound.from_dict(s)
return None
def add_custom_sound(self, sound: CustomSound) -> None:
"""Register an uploaded custom sound."""
self._data.setdefault("custom_sounds", []).append(sound.to_dict())
def rename_custom_sound(self, file: str, name: str) -> bool:
"""Rename a custom sound. Returns False if it isn't registered."""
for s in self._data.get("custom_sounds", []):
if s.get("file") == file:
s["name"] = name
return True
return False
def remove_custom_sound(self, file: str) -> None:
"""Deregister a custom sound (the file itself is deleted by the caller)."""
self._data["custom_sounds"] = [s for s in self._data.get("custom_sounds", []) if s.get("file") != file]
def reset_chores_using_sound(self, sound_value: str, fallback: str = "coin") -> int:
"""Point any chore using ``sound_value`` back at ``fallback``.
Called when a custom sound is deleted. Without this the chore keeps a
dangling ``custom:`` reference and silently plays nothing on completion.
Returns the number of chores rewritten.
"""
changed = 0
for c in self._data.get("chores", []):
if c.get("completion_sound") == sound_value:
c["completion_sound"] = fallback
changed += 1
return changed
# Badges management
def get_badges(self) -> list[Badge]:
"""Get all badges."""
@@ -774,6 +841,17 @@ class TaskMateStorage:
return []
return [x for x in raw if isinstance(x, str) and x]
def get_require_linked_child(self) -> bool:
"""True when acting *as* a child requires that child to be linked.
Off by default: the shared-tablet/kiosk setup (nobody linked, one
household account drives every child) is the common deployment and
must keep working on upgrade. Households where each child has their
own Home Assistant login can switch this on to stop one child acting
through another's profile.
"""
return bool((self._data.get("settings", {}) or {}).get("require_linked_child", False))
def set_parent_user_ids(self, ids: list[str]) -> None:
"""Replace the parent role list (deduped, order-preserving, strings only)."""
seen: list[str] = []
@@ -956,15 +1034,30 @@ class TaskMateStorage:
return list(reversed(self._data.get("audit_log", [])))
def add_audit_entry(self, entry: dict) -> None:
"""Append an admin audit entry, capping the log at 500 (oldest dropped)."""
"""Append an audit entry, capping the log at 500 (oldest dropped).
Truncation is counted, not silent: without a marker, generating 500
routine entries quietly pushes an earlier one out of the log and the
reader has no way to tell that anything is missing.
"""
log = self._data.setdefault("audit_log", [])
log.append(entry)
if len(log) > 500:
dropped = len(log) - 500
del log[:-500]
self._data["audit_log_dropped"] = int(self._data.get("audit_log_dropped", 0) or 0) + dropped
def get_audit_dropped_count(self) -> int:
"""How many audit entries have aged out of the capped log."""
try:
return int(self._data.get("audit_log_dropped", 0) or 0)
except (TypeError, ValueError):
return 0
def clear_audit_log(self) -> None:
"""Remove all audit entries."""
self._data["audit_log"] = []
self._data["audit_log_dropped"] = 0
# ── Chore swap requests ──────────────────────────────────────────────
def get_swap_requests(self) -> list[dict]:
@@ -1185,6 +1278,48 @@ class TaskMateStorage:
_STREAK_MODES = ("reset", "pause")
_CARD_DESIGNS = ("classic", "playroom", "console", "cleanpro", "accessible")
# Per-record numeric fields. The WebSocket schemas coerce these on every
# normal write; import bypasses the schemas entirely, so a crafted
# backup could leave a string (or inf/NaN) where the rest of the code —
# and the panel's number inputs — expect a number.
_NUMERIC_RECORD_FIELDS = {
# NB: streak_milestones_achieved is a list[int], not a scalar — it
# must not be coerced here.
"children": (
"points",
"total_points_earned",
"total_chores_completed",
"current_streak",
"best_streak",
"career_score",
"total_penalties_received",
),
"chores": (
"points",
"claim_allowance_minutes",
"daily_limit",
"mandatory_penalty_points",
"speed_bonus_points",
"skip_count",
"timed_rate_points",
"timed_rate_minutes",
"timed_max_daily_minutes",
),
"rewards": ("cost", "restock_amount", "unlock_minutes"),
"penalties": ("points",),
"bonuses": ("points",),
"completions": ("points_awarded", "timed_duration_seconds"),
"points_transactions": ("points",),
"mandatory_misses": ("penalty_points", "postpone_count", "escalation_stage"),
"pool_allocations": ("allocated_points",),
}
_DATETIME_RECORD_FIELDS = {
"completions": ("completed_at", "approved_at"),
"reward_claims": ("claimed_at", "approved_at"),
"points_transactions": ("created_at",),
"mandatory_misses": ("created_at",),
"allowance_payouts": ("created_at",),
}
_NUMERIC_SETTINGS = (
"history_days",
"weekend_multiplier",
@@ -1204,6 +1339,45 @@ class TaskMateStorage:
"perfect_week_bonus",
)
for bucket, keys in _NUMERIC_RECORD_FIELDS.items():
for record in self._data.get(bucket, []) or []:
if not isinstance(record, dict):
continue
for key in keys:
if key in record:
record[key] = _finite_number(record[key], 0)
for sub in record.get("bonus_subtasks", []) or []:
if isinstance(sub, dict) and "points" in sub:
sub["points"] = _finite_number(sub["points"], 0)
# quantity is "unlimited" when absent/None, so it can't share the loop
# above — but a string or NaN there breaks the sold-out comparison.
for reward in self._data.get("rewards", []) or []:
if isinstance(reward, dict) and reward.get("quantity") is not None:
reward["quantity"] = _finite_number(reward["quantity"], 0)
for badge in self._data.get("badges", []) or []:
if not isinstance(badge, dict):
continue
if "points" in badge:
badge["points"] = _finite_number(badge["points"], 0)
for crit in badge.get("criteria", []) or []:
if isinstance(crit, dict) and "value" in crit:
crit["value"] = _finite_number(crit["value"], 0)
# A timestamp that doesn't parse is dropped: models already turn it into
# None on load, and the panel renders a blank as an em dash rather than
# echoing whatever the string happened to be.
for bucket, keys in _DATETIME_RECORD_FIELDS.items():
for record in self._data.get(bucket, []) or []:
if not isinstance(record, dict):
continue
for key in keys:
raw = record.get(key)
if raw and parse_datetime(raw) is None:
_LOGGER.warning("Import: dropped unparseable %s on a %s record", key, bucket)
record[key] = ""
for comp in self._data.get("completions", []):
if not isinstance(comp, dict):
continue
@@ -1230,6 +1404,34 @@ class TaskMateStorage:
)
chore["image_url"] = ""
# Custom sounds: `file` is concatenated into a path and handed to HA's
# URL signer, and `name` is rendered in the panel and the chore
# dropdown. Neither goes through the upload validation on an import.
from .sounds import FILENAME_RE as _SOUND_FILENAME_RE
from .sounds import clean_sound_name
sounds = self._data.get("custom_sounds")
if isinstance(sounds, list):
kept = []
for snd in sounds:
if not isinstance(snd, dict):
continue
if not _SOUND_FILENAME_RE.match(str(snd.get("file", "") or "")):
_LOGGER.warning("Import: dropped custom sound with an invalid file name")
continue
snd["name"] = clean_sound_name(snd.get("name"))
kept.append(snd)
self._data["custom_sounds"] = kept
# A chore's completion_sound is an enum or a reference to one of those
# sounds; anything else would be rendered into the panel as-is.
from .const import is_valid_completion_sound
for chore in self._data.get("chores", []) or []:
if isinstance(chore, dict) and "completion_sound" in chore:
if not is_valid_completion_sound(str(chore.get("completion_sound") or "")):
chore["completion_sound"] = "coin"
for grp in self._data.get("task_groups", []):
if isinstance(grp, dict) and grp.get("policy") not in TASK_GROUP_POLICIES:
grp["policy"] = "sticky"
@@ -1253,11 +1455,23 @@ class TaskMateStorage:
if key not in settings:
continue
val = settings[key]
if isinstance(val, bool) or not isinstance(val, (int, float)):
try:
settings[key] = float(val)
except (TypeError, ValueError):
if (
isinstance(val, bool)
or not isinstance(val, (int, float))
or val != val
or val
in (
float("inf"),
float("-inf"),
)
):
# inf/NaN parse happily via float() but blow up later in
# arithmetic (e.g. a multiplier), so drop them to the default.
coerced = _finite_number(val, None)
if coerced is None:
settings.pop(key, None)
else:
settings[key] = coerced
def replace_completions(self, completions: list[ChoreCompletion]) -> None:
"""Replace all completions with the given list."""