456 files

This commit is contained in:
Home Assistant Version Control
2026-10-01 05:38:21 +00:00
parent 4192ecbaae
commit 5d6d845ebf
456 changed files with 64951 additions and 3828 deletions
+438 -13
View File
@@ -3,7 +3,8 @@
from __future__ import annotations
import logging
from datetime import date
from datetime import date, timedelta
from types import SimpleNamespace
from homeassistant.components.sensor import (
SensorEntity,
@@ -17,11 +18,13 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util import dt as dt_util
from . import images
from .const import DOMAIN
from .chore_undo import child_undo_metadata, undo_window_seconds
from .const import BOUNTY_RECENT_HOURS, DOMAIN, WISH_MAX_OPEN_PER_CHILD
from .coord_rewards import reward_is_time_locked
from .coordinator import TaskMateCoordinator
from .entity import taskmate_device_info
from .models import Child
from .watch import DEFAULT_STATE_STRINGS, watch_state, watch_summary
_LOGGER = logging.getLogger(__name__)
@@ -79,6 +82,11 @@ def _compute_common(coordinator: TaskMateCoordinator) -> dict:
child_lookup = {c.id: c for c in children}
chore_lookup = {c.id: c for c in chores}
reward_lookup = {r.id: r for r in rewards}
# Wishlist redemptions (#932) are reward claims with no Reward behind them.
wish_lookup = {w.id: w for w in coordinator.storage.get_wishes()}
# A bounty's completion (#931) carries the bounty id as its chore_id, so
# every "which chore is this?" lookup below falls back to this one.
bounty_lookup = {b.id: b for b in data.get("bounties", []) or []}
# Current-month leaderboard points per child (FEAT-2).
season_points = coordinator.storage.get_season_points(dt_util.now().strftime("%Y-%m"))
@@ -86,7 +94,7 @@ def _compute_common(coordinator: TaskMateCoordinator) -> dict:
# Pending chore points per child (awaiting approval -> points to be earned).
pending_points_by_child: dict[str, int] = {}
for comp in pending_completions:
chore = chore_lookup.get(comp.chore_id)
chore = chore_lookup.get(comp.chore_id) or bounty_lookup.get(comp.chore_id)
if chore:
pending_points_by_child[comp.child_id] = pending_points_by_child.get(comp.child_id, 0) + chore.points
@@ -120,8 +128,11 @@ def _compute_common(coordinator: TaskMateCoordinator) -> dict:
"child_lookup": child_lookup,
"chore_lookup": chore_lookup,
"reward_lookup": reward_lookup,
"wish_lookup": wish_lookup,
"bounty_lookup": bounty_lookup,
"season_points": season_points,
"all_completions": all_completions,
"chore_undo_seconds": undo_window_seconds(coordinator.storage),
"pending_completions": pending_completions,
"pending_reward_claim_objs": pending_reward_claim_objs,
"pool_alloc_objs": pool_alloc_objs,
@@ -130,11 +141,22 @@ def _compute_common(coordinator: TaskMateCoordinator) -> dict:
"pool_by_child_reward": pool_by_child_reward,
"pool_total_by_reward": pool_total_by_reward,
"total_allocated_by_child": total_allocated_by_child,
# Sent back to redo by an inspection (#981): not "done" on the card.
"inspection_redo_ids": _inspection_redo_ids(coordinator),
}
setattr(coordinator, _COMMON_CACHE_ATTR, {"data_id": data_id, "common": common})
return common
def _inspection_redo_ids(coordinator) -> set[str]:
getter = getattr(coordinator, "inspection_redo_completion_ids", None)
try:
return set(getter()) if callable(getter) else set()
except Exception: # noqa: BLE001
_LOGGER.debug("inspection redo ids unavailable", exc_info=True)
return set()
def _build_children_summary(coordinator: TaskMateCoordinator, common: dict) -> list[dict]:
"""Build compact per-child summary used on the overview sensor."""
children = common["children"]
@@ -142,6 +164,7 @@ def _build_children_summary(coordinator: TaskMateCoordinator, common: dict) -> l
committed = common["committed_points_by_child"]
allocated = common["total_allocated_by_child"]
season = common["season_points"]
freezes_on = _safe_int(((common.get("data") or {}).get("settings") or {}).get("streak_freeze_max"), 2) > 0
summary = []
for c in children:
committed_amount = committed.get(c.id, 0)
@@ -196,7 +219,12 @@ def _build_children_summary(coordinator: TaskMateCoordinator, common: dict) -> l
"last_completion_date": getattr(c, "last_completion_date", None),
"streak_paused": getattr(c, "streak_paused", False),
"on_vacation": coordinator._is_child_on_vacation(c),
# Birthday mode (#924): only on the day, so it costs nothing otherwise.
**({"birthday": bday} if (bday := coordinator.birthday_summary(c)) else {}),
"streak_milestones_achieved": getattr(c, "streak_milestones_achieved", None) or [],
# Streak freeze tokens (#925): only while the feature is on, so
# the cards can tell "none left" apart from "not in use".
**({"streak_freezes": getattr(c, "streak_freezes", 0) or 0} if freezes_on else {}),
"awarded_perfect_weeks": getattr(c, "awarded_perfect_weeks", None) or [],
"career_score": getattr(c, "career_score", 0) or 0,
"total_penalties_received": getattr(c, "total_penalties_received", 0) or 0,
@@ -204,6 +232,11 @@ def _build_children_summary(coordinator: TaskMateCoordinator, common: dict) -> l
"avatar_options": coordinator.avatar_options_for_child(c),
"challenges": coordinator.challenge_progress_for_child(c.id),
**({"weekly_chore_progress": weekly_progress} if weekly_progress else {}),
# Reject reasons (#976): why a chore / claim was just sent back,
# only while there's one to show.
**({"rejections": rej} if (rej := coordinator.rejections_for_child(c.id)) else {}),
# Surprise inspections (#981): today's banner(s), only when any.
**({"inspections": insp} if (insp := coordinator.inspections_for_child(c.id)) else {}),
}
)
return summary
@@ -223,6 +256,9 @@ def _build_chores_list(coordinator: TaskMateCoordinator, common: dict) -> list[d
"""
chores = common["chores"]
chores_list = []
today = dt_util.as_local(dt_util.now()).date()
# Calendar moves older than the calendar card's week view are history only.
moved_floor = (today - timedelta(days=7)).isoformat()
for c in chores:
assigned_to = c.assigned_to if isinstance(c.assigned_to, list) else []
depends_on = c.depends_on if isinstance(getattr(c, "depends_on", None), list) else []
@@ -242,9 +278,22 @@ def _build_chores_list(coordinator: TaskMateCoordinator, common: dict) -> list[d
difficulty = getattr(c, "difficulty", "medium") or "medium"
if difficulty != "medium":
record["difficulty"] = difficulty
effective_points = coordinator.effective_chore_points(c)
# A teamwork chore (#928) pays each participant their share, so that
# is the figure the cards quote as "+N".
team_size = coordinator.teamwork_size(c) if int(getattr(c, "team_size", 0) or 0) >= 2 else 0
effective_points = coordinator.team_share_points(c) if team_size else coordinator.effective_chore_points(c)
if effective_points != c.points:
record["effective_points"] = effective_points
# Teamwork (#928): only on teamwork chores, and only the non-default
# parts — the joined list is the day's progress the card draws.
if team_size:
team: dict = {"size": team_size}
joined = coordinator.team_joined_ids(c)
if joined:
team["joined"] = joined
if getattr(c, "team_points_mode", "each") == "split":
team["split"] = True
record["team"] = team
# Optional fields — emit only when non-default to save bytes.
description = getattr(c, "description", "") or ""
if description:
@@ -263,6 +312,35 @@ def _build_chores_list(coordinator: TaskMateCoordinator, common: dict) -> list[d
due_days = getattr(c, "due_days", []) or []
if due_days:
record["due_days"] = due_days
# Occurrences moved or removed from the HA calendar (#977). Only chores
# that have one carry these: `occ_today` settles today for the cards'
# weekday filters, the map lets the calendar card place other days.
moved = getattr(c, "moved_occurrences", None) or {}
if moved:
override = coordinator.occurrence_override(c, today)
if override is not None:
record["occ_today"] = override
recent = {src: dst for src, dst in moved.items() if src >= moved_floor or (dst and dst >= moved_floor)}
if recent:
record["moved_occurrences"] = recent
# A one-off belongs to its own date only (#992) — a calendar-added one
# for later in the week, or yesterday's before the midnight sweep. Same
# flag, so every card's today filter drops it as the backend does.
if record["schedule_mode"] == "one_shot" and record["enabled"] and _one_off_not_today(c, today):
record["occ_today"] = False
# Won at auction (#982). Only chores with a won occurrence this week
# carry these: `auction` settles today for the cards (the winner alone,
# at the winning price, which is also what the chore pays today); the
# map lets the calendar card place the other days.
wins = coordinator.auction_wins_for_chore(c, today, 7)
if isinstance(wins, dict) and wins:
record["auction_wins"] = {day: w["child_id"] for day, w in wins.items()}
# What each won day pays, so a future one shows it too (#998).
record["auction_prices"] = {day: w["points"] for day, w in wins.items()}
today_win = wins.get(today.isoformat())
if today_win:
record["auction"] = today_win
record["effective_points"] = today_win["points"]
requires_approval = getattr(c, "requires_approval", True)
if not requires_approval:
record["requires_approval"] = False
@@ -351,6 +429,21 @@ def _build_chores_list(coordinator: TaskMateCoordinator, common: dict) -> list[d
return chores_list
def _one_off_not_today(chore, today: date) -> bool:
"""True when a one-off chore is dated for a day other than ``today``.
Mirrors the one_shot branch of is_chore_available_for_child: no date, or
one that doesn't parse, counts as today.
"""
created_date = getattr(chore, "created_date", "") or ""
if not created_date:
return False
try:
return date.fromisoformat(created_date) != today
except ValueError:
return False
def _build_chore_availability(coordinator: TaskMateCoordinator, common: dict) -> dict:
"""Build the chore availability matrix: {chore_id: {child_id: bool}}.
@@ -390,14 +483,25 @@ def _completion_award(completion, recalculated: int) -> int:
return recalculated
def _bounty_title(common: dict, comp) -> str:
"""The bounty's title for a bounty completion (#931), else ""."""
bounty = common.get("bounty_lookup", {}).get(getattr(comp, "bounty_id", "") or "")
return bounty.title if bounty else ""
def _build_todays_completions(common: dict) -> list[dict]:
"""Build today's completions (both approved and pending)."""
now = dt_util.now()
today = now.date()
child_lookup = common["child_lookup"]
chore_lookup = common["chore_lookup"]
redo_ids = common.get("inspection_redo_ids") or set()
out = []
for comp in common["all_completions"]:
# An inspection sent this one back to redo (#981): the chore is owed
# again today, so the card must not see it as done.
if comp.id in redo_ids:
continue
comp_dt = comp.completed_at
if hasattr(comp_dt, "astimezone"):
comp_dt = dt_util.as_local(comp_dt)
@@ -411,7 +515,7 @@ def _build_todays_completions(common: dict) -> list[dict]:
display_name = f"{matched_chore.name} › {subtask.name}" if subtask else matched_chore.name
display_points = subtask.points if subtask else 0
else:
display_name = matched_chore.name if matched_chore else ""
display_name = matched_chore.name if matched_chore else _bounty_title(common, comp)
display_points = matched_chore.points if matched_chore else 0
timed_secs = getattr(comp, "timed_duration_seconds", 0) or 0
if timed_secs > 0 and matched_chore and getattr(matched_chore, "task_type", "") == "timed":
@@ -435,6 +539,10 @@ def _build_todays_completions(common: dict) -> list[dict]:
}
if timed_secs > 0:
rec["timed_duration_seconds"] = timed_secs
# Child undo (#918): child_undo_pending / child_undo_until, present only
# on a completion the child may still take back, so the attribute
# stays small (and empty whenever the window is 0).
rec.update(child_undo_metadata(comp, common["all_completions"], common.get("chore_undo_seconds", 0)))
# Emit the bare (unsigned) photo path. A card's <img> carries no bearer
# token, so the card signs each path per-viewer via auth/sign_path before
# rendering — this keeps a self-authenticating URL out of this
@@ -544,6 +652,9 @@ def _build_rewards_list(common: dict) -> list[dict]:
# Time lock (#857): only carried for rewards that actually use it. This
# attribute slice is capped at the recorder's 16 KB limit, so unused
# feature fields must not cost every reward bytes.
# Streak freeze (#925): flag only the rewards that are one.
if getattr(r, "streak_freeze", False):
out[-1]["streak_freeze"] = True
if getattr(r, "time_lock_enabled", False):
out[-1]["time_lock"] = {
"days": list(getattr(r, "available_days", []) or []),
@@ -555,13 +666,25 @@ def _build_rewards_list(common: dict) -> list[dict]:
return out
def _wish_as_reward(wish) -> SimpleNamespace | None:
"""Present a wish's redemption claim (#932) the way a reward claim reads.
Approval cards and the activity feed look a claim's reward up by id; a
wish has no Reward record, so they get its name, a heart and its target.
"""
if wish is None:
return None
return SimpleNamespace(id=wish.id, name=wish.name, icon="mdi:heart", cost=wish.target)
def _build_pending_reward_claims(common: dict) -> list[dict]:
"""Build enriched pending reward claims list."""
reward_lookup = common["reward_lookup"]
child_lookup = common["child_lookup"]
out = []
wish_lookup = common.get("wish_lookup", {})
for rc in common["pending_reward_claim_objs"]:
reward = reward_lookup.get(rc.reward_id)
reward = reward_lookup.get(rc.reward_id) or _wish_as_reward(wish_lookup.get(rc.wish_id))
child = child_lookup.get(rc.child_id)
if not reward or not child:
continue
@@ -607,12 +730,14 @@ def _build_recent_completions(common: dict, limit: int = 35) -> list[dict]:
"child_name": "Parent"
if comp.child_id == "__parent__"
else (child_lookup[comp.child_id].name if comp.child_id in child_lookup else ""),
"chore_name": matched_chore.name if matched_chore else "",
"chore_name": matched_chore.name if matched_chore else _bounty_title(common, comp),
"points": _completion_award(comp, display_points),
"approved": comp.approved,
"completed_at": comp.completed_at.isoformat()
if hasattr(comp.completed_at, "isoformat")
else str(comp.completed_at),
# Star rating the parent gave at approval (#927) — only when rated.
**({"rating": comp.quality_rating} if getattr(comp, "quality_rating", 0) else {}),
}
)
return out
@@ -636,7 +761,7 @@ def _build_photo_gallery(common: dict, limit: int = 40) -> list[dict]:
{
"completion_id": comp.id,
"child_name": child_lookup[comp.child_id].name if comp.child_id in child_lookup else "",
"chore_name": chore.name if chore else "",
"chore_name": chore.name if chore else _bounty_title(common, comp),
"approved": comp.approved,
"completed_at": comp.completed_at.isoformat()
if hasattr(comp.completed_at, "isoformat")
@@ -647,7 +772,16 @@ def _build_photo_gallery(common: dict, limit: int = 40) -> list[dict]:
return out
def _build_recent_transactions(common: dict, limit: int = 20) -> list[dict]:
_FEED_REJECTIONS_MAX = 8
_FEED_INSPECTIONS_MAX = 8
def _build_recent_transactions(
common: dict,
limit: int = 20,
rejections: list[dict] | None = None,
inspections: list[dict] | None = None,
) -> list[dict]:
"""Unified activity feed of manual point adjustments and reward claims.
Capped at 20 so the combined activity slice (completions + transactions)
@@ -676,9 +810,10 @@ def _build_recent_transactions(common: dict, limit: int = 20) -> list[dict]:
}
)
wish_lookup = common.get("wish_lookup", {})
for rc in all_reward_claims:
child = child_lookup.get(rc.child_id)
reward = reward_lookup.get(rc.reward_id)
reward = reward_lookup.get(rc.reward_id) or _wish_as_reward(wish_lookup.get(getattr(rc, "wish_id", "")))
if not child or not reward:
continue
event_type = "reward_approved" if rc.approved else "reward_claimed"
@@ -700,6 +835,37 @@ def _build_recent_transactions(common: dict, limit: int = 20) -> list[dict]:
}
)
# Reasoned rejections (#976): the completion / claim itself is gone, so
# these come from the rejection log rather than the records above. A
# reason can run to 200 characters, so only the newest few make the feed
# (the 16 KB cap).
for r in (rejections or [])[-_FEED_REJECTIONS_MAX:]:
child = child_lookup.get(r.get("child_id"))
if not child:
continue
kind = "reward" if r.get("kind") == "reward" else "chore"
events.append(
{
"transaction_id": r.get("id", ""),
"type": f"{kind}_rejected",
"child_id": child.id,
"child_name": child.name,
f"{kind}_id": r.get("item_id", ""),
f"{kind}_name": r.get("item_name", ""),
"reason": r.get("reason", ""),
"created_at": str(r.get("rejected_at", "")),
}
)
# Surprise inspections (#981): started / didn't pass / sent back (a pass
# with a bonus is already here as its "Inspection passed:" transaction).
for e in (inspections or [])[-_FEED_INSPECTIONS_MAX:]:
child = child_lookup.get(e.get("child_id"))
if not child:
continue
row = {k: v for k, v in e.items() if k != "id"}
events.append({**row, "transaction_id": e.get("id", ""), "child_name": child.name})
events.sort(key=lambda e: e["created_at"], reverse=True)
return events[:limit]
@@ -754,12 +920,16 @@ async def async_setup_entry(
entities.append(TaskMateRewardsSensor(coordinator, entry))
entities.append(TaskMateActivitySensor(coordinator, entry))
entities.append(TaskMateIncentivesSensor(coordinator, entry))
entities.append(TaskMateBountiesSensor(coordinator, entry))
entities.append(TaskMateAuctionsSensor(coordinator, entry))
# Add sensors for each child
for child in coordinator.data.get("children", []):
entities.append(ChildPointsSensor(coordinator, entry, child))
entities.append(ChildStatsSensor(coordinator, entry, child))
entities.append(ChildBadgesSensor(coordinator, entry, child))
entities.append(ChildWishlistSensor(coordinator, entry, child))
entities.append(ChildWatchSensor(coordinator, entry, child))
tracked_child_ids.add(child.id)
# Add pending approvals sensor
@@ -778,6 +948,8 @@ async def async_setup_entry(
new_entities.append(ChildPointsSensor(coordinator, entry, child))
new_entities.append(ChildStatsSensor(coordinator, entry, child))
new_entities.append(ChildBadgesSensor(coordinator, entry, child))
new_entities.append(ChildWishlistSensor(coordinator, entry, child))
new_entities.append(ChildWatchSensor(coordinator, entry, child))
tracked_child_ids.add(child.id)
if new_entities:
@@ -897,6 +1069,8 @@ class TaskMateOverallStatsSensor(_CachedAttrsSensor):
"perfect_week_enabled": settings.get("perfect_week_enabled", "true") == "true",
"perfect_week_bonus": _safe_int(settings.get("perfect_week_bonus"), 50),
"streak_requires_all_chores": settings.get("streak_requires_all_chores", "false") in (True, "true"),
"streak_freeze_max": max(0, _safe_int(settings.get("streak_freeze_max"), 2)),
"streak_freeze_earn_every": max(0, _safe_int(settings.get("streak_freeze_earn_every"), 7)),
"perfect_week_requires_all_chores": settings.get("perfect_week_requires_all_chores", "false")
in (True, "true"),
"total_children": len(children),
@@ -1094,7 +1268,11 @@ class TaskMateActivitySensor(_CachedAttrsSensor):
career_history[c.id] = self.coordinator.storage.get_career_score_history(c.id)
return {
"recent_completions": _build_recent_completions(common),
"recent_transactions": _build_recent_transactions(common),
"recent_transactions": _build_recent_transactions(
common,
rejections=self.coordinator.recent_rejections(),
inspections=self.coordinator.recent_inspection_events(),
),
"career_score_history": career_history,
"photo_gallery": _build_photo_gallery(common),
}
@@ -1131,6 +1309,120 @@ class TaskMateIncentivesSensor(_CachedAttrsSensor):
}
def _build_bounties_list(coordinator: TaskMateCoordinator, common: dict) -> list[dict]:
"""The bounty board for the cards (#931): live bounties plus the last day's
completed ones. Expired and older history stays in storage for the panel.
Compact: optional fields only appear when they carry something, so a board
of a handful of jobs costs a few hundred bytes.
"""
now = dt_util.now()
recent_cutoff = now - timedelta(hours=BOUNTY_RECENT_HOURS)
completions = {c.id: c for c in common["all_completions"]}
window = common.get("chore_undo_seconds", 0)
out = []
for b in common["data"].get("bounties", []) or []:
if b.status == "expired":
continue
if b.status == "completed" and (b.closed_at is None or b.closed_at < recent_cutoff):
continue
rec = {
"id": b.id,
"title": b.title,
"points": b.points,
"icon": b.icon,
"status": b.status,
"claim_hours": b.claim_hours,
}
if b.description:
rec["description"] = b.description
if b.eligible_child_ids:
rec["eligible"] = list(b.eligible_child_ids)
if b.expires_at is not None:
rec["expires_at"] = b.expires_at.isoformat()
if b.require_photo:
rec["require_photo"] = True
if b.status in ("claimed", "pending", "completed") and b.claimed_by:
rec["claimed_by"] = b.claimed_by
if b.status == "claimed":
if b.claimed_at is not None:
rec["claimed_at"] = b.claimed_at.isoformat()
if b.claim_until is not None:
rec["claim_until"] = b.claim_until.isoformat()
if b.status == "pending" and b.completion_id:
rec["completion_id"] = b.completion_id
comp = completions.get(b.completion_id)
if comp is not None and child_undo_metadata(comp, common["all_completions"], window).get(
"child_undo_pending"
):
rec["undo"] = True
if b.status == "completed":
rec["points_awarded"] = b.points_awarded
rec["completed_at"] = b.closed_at.isoformat()
out.append(rec)
return out
class TaskMateBountiesSensor(_CachedAttrsSensor):
"""The bounty board (#931): open bounties as the state, the board as an
attribute for the bounty card."""
_unrecorded_attributes = frozenset({"bounties"})
def __init__(
self,
coordinator: TaskMateCoordinator,
entry: ConfigEntry,
) -> None:
super().__init__(coordinator, entry)
self._attr_unique_id = f"{entry.entry_id}_bounties"
self._attr_name = "TaskMate Bounties"
@property
def native_value(self) -> int:
return sum(1 for b in self.coordinator.data.get("bounties", []) or [] if b.status == "open")
@property
def icon(self) -> str:
return "mdi:flag-checkered"
def _build_attributes(self) -> dict:
common = _compute_common(self.coordinator)
return {"bounties": _build_bounties_list(self.coordinator, common)}
class TaskMateAuctionsSensor(_CachedAttrsSensor):
"""Chore auctions (#982): live auctions as the state, and a public digest
the auction card watches to know when to re-fetch its view.
Bids are secret, so the digest carries only how many are in — never who
bid or how much. The card fetches its own bid over the
``taskmate/auctions/list`` WebSocket command, which enforces that.
"""
_unrecorded_attributes = frozenset({"auctions"})
def __init__(
self,
coordinator: TaskMateCoordinator,
entry: ConfigEntry,
) -> None:
super().__init__(coordinator, entry)
self._attr_unique_id = f"{entry.entry_id}_auctions"
self._attr_name = "TaskMate Auctions"
@property
def native_value(self) -> int:
return sum(1 for a in self.coordinator.storage.get_auctions() if a.status == "open")
@property
def icon(self) -> str:
return "mdi:gavel"
def _build_attributes(self) -> dict:
return {"auctions": self.coordinator.auctions_public_state()}
class ChildPointsSensor(TaskMateBaseSensor):
"""Sensor for a child's points."""
@@ -1232,6 +1524,10 @@ class ChildStatsSensor(TaskMateBaseSensor):
def _included(c):
if not (child.id in c.assigned_to or not c.assigned_to):
return False
# Won at auction (#982): today's occurrence is the winner's alone.
winner = self.coordinator.auction_winner(c)
if isinstance(winner, str) and winner:
return winner == child.id
mode = getattr(c, "assignment_mode", "everyone")
if mode not in ("everyone", "first_come") and getattr(c, "assignment_current_child_id", "") != child.id:
return False
@@ -1260,6 +1556,123 @@ class ChildStatsSensor(TaskMateBaseSensor):
}
class ChildWishlistSensor(TaskMateBaseSensor):
"""A child's open wishes (#932), for the wishlist card.
One per child rather than one for the family: a wish can carry a name,
a link, a picture and a row of pledge chips, and a family's worth of
those would not fit under the recorder's 16 KB attribute limit. The card
finds this sensor by ``wishlist_child_id``. The redeemed history is left
to the admin panel.
"""
_attr_icon = "mdi:heart-outline"
_unrecorded_attributes = frozenset({"wishes"})
def __init__(
self,
coordinator: TaskMateCoordinator,
entry: ConfigEntry,
child: Child,
) -> None:
super().__init__(coordinator, entry)
self.child_id = child.id
self._attr_unique_id = f"{entry.entry_id}_{child.id}_wishlist"
self._attr_name = f"{child.name} Wishlist"
@property
def native_value(self) -> int:
"""Wishes on the go: waiting, saving, or waiting to be handed over."""
return sum(
1
for w in self.coordinator.storage.get_wishes()
if w.child_id == self.child_id and w.status in ("pending", "active", "redeem_requested")
)
@property
def extra_state_attributes(self) -> dict:
return {
"wishlist_child_id": self.child_id,
"wishes": self.coordinator.wishlist_sensor_rows(self.child_id),
"wish_max_open": WISH_MAX_OPEN_PER_CHILD,
}
class ChildWatchSensor(TaskMateBaseSensor):
"""A child's day in a few characters, for watch complications (#978).
The state is a short, already-translated phrase ("3 left", "All done")
because the companion apps render complication templates server-side and
show the raw state — the frontend's state translation never reaches the
wrist. The attributes are a handful of scalars so templates can build
gauges and rings (``progress`` is 0–1) without touching the heavy sensors.
"""
_attr_icon = "mdi:watch"
_attr_translation_key = "child_watch"
def __init__(
self,
coordinator: TaskMateCoordinator,
entry: ConfigEntry,
child: Child,
) -> None:
super().__init__(coordinator, entry)
self.child_id = child.id
self._attr_unique_id = f"{entry.entry_id}_{child.id}_watch"
self._attr_name = f"{child.name} Watch"
self._state_strings: dict[str, str] = dict(DEFAULT_STATE_STRINGS)
self._summary_key: tuple | None = None
self._summary: dict | None = None
def _current_summary(self) -> dict | None:
"""The summary once per coordinator update — state and attributes share it."""
key = (
id(self.coordinator.data),
getattr(self.coordinator, "external_state_version", 0),
dt_util.as_local(dt_util.now()).date(),
)
if key != self._summary_key:
self._summary = watch_summary(self.coordinator, self.child_id)
self._summary_key = key
return self._summary
async def async_added_to_hass(self) -> None:
"""Load the state phrases in the server's language."""
await super().async_added_to_hass()
self._state_strings = await _async_watch_state_strings(self.hass)
@property
def native_value(self) -> str | None:
return watch_state(self._current_summary(), self._state_strings)
@property
def extra_state_attributes(self) -> dict:
return self._current_summary() or {}
async def _async_watch_state_strings(hass: HomeAssistant) -> dict[str, str]:
"""The watch sensor's state phrases from TaskMate's entity translations.
Falls back to English per phrase, so a missing or broken translation
never leaves the sensor without a state.
"""
strings = dict(DEFAULT_STATE_STRINGS)
try:
from homeassistant.helpers.translation import async_get_translations
translations = await async_get_translations(hass, hass.config.language, "entity", {DOMAIN})
except Exception: # noqa: BLE001 - translations are cosmetic; never block the sensor
_LOGGER.debug("Could not load TaskMate watch translations", exc_info=True)
return strings
prefix = f"component.{DOMAIN}.entity.sensor.child_watch.state."
for key in strings:
value = translations.get(prefix + key) if isinstance(translations, dict) else None
if isinstance(value, str) and value:
strings[key] = value
return strings
class ChildBadgesSensor(TaskMateBaseSensor):
"""Sensor exposing a child's earned and available badges."""
@@ -1390,7 +1803,8 @@ class PendingApprovalsSensor(TaskMateBaseSensor):
completion_details = []
for comp in pending_completions:
child = self.coordinator.get_child(comp.child_id)
chore = self.coordinator.get_chore(comp.chore_id)
# A bounty's completion (#931) is approved in the same queue.
chore = self.coordinator.get_chore(comp.chore_id) or self.coordinator.bounty_completion_chore(comp)
if child and chore:
# Bonus sub-task completions carry the sub-task's own name and
# points; mirror _build_todays_completions so cards reading this
@@ -1422,6 +1836,8 @@ class PendingApprovalsSensor(TaskMateBaseSensor):
}
if timed_secs > 0:
detail["timed_duration_seconds"] = timed_secs
if getattr(comp, "bounty_id", ""):
detail["bounty_id"] = comp.bounty_id
# Bare (unsigned) path; the card signs per-viewer via
# auth/sign_path so no self-authenticating URL lands in this
# world-readable attribute.
@@ -1443,6 +1859,8 @@ class PendingApprovalsSensor(TaskMateBaseSensor):
for claim in pending_rewards:
child = self.coordinator.get_child(claim.child_id)
reward = self.coordinator.get_reward(claim.reward_id)
if reward is None and getattr(claim, "wish_id", ""):
reward = _wish_as_reward(self.coordinator.get_wish(claim.wish_id))
if child and reward:
reward_details.append(
{
@@ -1458,7 +1876,7 @@ class PendingApprovalsSensor(TaskMateBaseSensor):
)
mandatory_misses = self.coordinator.mandatory_misses_state()
return {
attrs = {
"pending_chore_completions": len(pending_completions),
"pending_reward_claims": len(pending_rewards),
"pending_mandatory_misses": len(mandatory_misses),
@@ -1466,3 +1884,10 @@ class PendingApprovalsSensor(TaskMateBaseSensor):
"reward_claims": reward_details,
"mandatory_misses": mandatory_misses,
}
# Quality rating (#927): tells approval cards to draw the star picker,
# and what each star pays. Absent entirely while the feature is off.
if self.coordinator.quality_rating_enabled():
attrs["quality_rating"] = {
"multipliers": [round(m, 3) for _, m in sorted(self.coordinator.quality_rating_multipliers().items())]
}
return attrs