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