"""Sensor platform for TaskMate integration.""" from __future__ import annotations import logging from datetime import date from homeassistant.components.sensor import ( SensorEntity, SensorStateClass, ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import dt as dt_util from . import photos from .const import DOMAIN from .coordinator import TaskMateCoordinator from .models import Child _LOGGER = logging.getLogger(__name__) def _safe_float(value, default: float) -> float: """Convert value to float, returning default on failure.""" try: return float(value) if value is not None else default except (ValueError, TypeError): return default def _safe_int(value, default: int) -> int: """Convert value to int, returning default on failure.""" try: return int(value) if value is not None else default except (ValueError, TypeError): return default # --------------------------------------------------------------------------- # Shared attribute builders # # The overview sensor used to pack every data slice into one 16KB+ attribute # payload, which Home Assistant's recorder refuses to store. The data is now # split across five sensors (overview / chores / rewards / activity / # incentives); the helpers below build each slice once per coordinator update # and share intermediate lookups so all sensors stay cheap to render. # --------------------------------------------------------------------------- _COMMON_CACHE_ATTR = "_taskmate_sensor_common_cache" def _compute_common(coordinator: TaskMateCoordinator) -> dict: """Build the per-update context shared by all TaskMate sensors. Cached on the coordinator so each sensor re-uses a single pass over completions / transactions / pool allocations. """ data = coordinator.data data_id = id(data) cached = getattr(coordinator, _COMMON_CACHE_ATTR, None) if cached and cached.get("data_id") == data_id: return cached["common"] children = data.get("children", []) chores = data.get("chores", []) rewards = data.get("rewards", []) all_completions = data.get("completions", []) pending_completions = data.get("pending_completions", []) pending_reward_claim_objs = data.get("pending_reward_claims", []) pool_alloc_objs = data.get("pool_allocations", []) 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} # Current-month leaderboard points per child (FEAT-2). season_points = coordinator.storage.get_season_points(dt_util.now().strftime("%Y-%m")) # 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) if chore: pending_points_by_child[comp.child_id] = ( pending_points_by_child.get(comp.child_id, 0) + chore.points ) # Committed points per child (reward claims awaiting approval = points reserved). # Pool-mode pending claims are skipped because their cost was already deducted # from child.points at allocation time. committed_points_by_child: dict[str, int] = {} for rc in pending_reward_claim_objs: if coordinator.is_pool_mode_claim(rc): continue reward = reward_lookup.get(rc.reward_id) if reward: committed_points_by_child[rc.child_id] = ( committed_points_by_child.get(rc.child_id, 0) + reward.cost ) # Pool allocation lookups for v3.0 pool mode. pool_by_child_reward: dict[str, dict[str, int]] = {} pool_total_by_reward: dict[str, int] = {} total_allocated_by_child: dict[str, int] = {} for pa in pool_alloc_objs: pool_by_child_reward.setdefault(pa.child_id, {})[pa.reward_id] = pa.allocated_points pool_total_by_reward[pa.reward_id] = ( pool_total_by_reward.get(pa.reward_id, 0) + pa.allocated_points ) total_allocated_by_child[pa.child_id] = ( total_allocated_by_child.get(pa.child_id, 0) + pa.allocated_points ) common = { "data_id": data_id, "hass": coordinator.hass, "data": data, "children": children, "chores": chores, "rewards": rewards, "child_lookup": child_lookup, "chore_lookup": chore_lookup, "reward_lookup": reward_lookup, "season_points": season_points, "all_completions": all_completions, "pending_completions": pending_completions, "pending_reward_claim_objs": pending_reward_claim_objs, "pool_alloc_objs": pool_alloc_objs, "pending_points_by_child": pending_points_by_child, "committed_points_by_child": committed_points_by_child, "pool_by_child_reward": pool_by_child_reward, "pool_total_by_reward": pool_total_by_reward, "total_allocated_by_child": total_allocated_by_child, } setattr(coordinator, _COMMON_CACHE_ATTR, {"data_id": data_id, "common": common}) return common def _build_children_summary(coordinator: TaskMateCoordinator, common: dict) -> list[dict]: """Build compact per-child summary used on the overview sensor.""" children = common["children"] pending = common["pending_points_by_child"] committed = common["committed_points_by_child"] allocated = common["total_allocated_by_child"] season = common["season_points"] summary = [] for c in children: committed_amount = committed.get(c.id, 0) lvl = coordinator.level_info(c) summary.append({ "level": lvl["level"], "level_progress": lvl["progress"], "level_target": lvl["target"], "id": c.id, "name": c.name, "points": c.points, "pending_points": pending.get(c.id, 0), # Guest profiles (#690): cards filter these out of competitive views. **({"is_guest": True, "guest_expires_on": getattr(c, "guest_expires_on", "")} if getattr(c, "is_guest", False) else {}), # Chore roulette (#677): today's pick + spins left, so the card can # show the result and disable the button once the allowance is used. **( { "roulette": { **(coordinator.roulette_selection(c.id) or {}), "spins_left": coordinator.roulette_spins_left(c.id), } } if coordinator.roulette_enabled() else {} ), "committed_points": committed_amount, "allocated_points": allocated.get(c.id, 0), # Allocations were deducted from child.points already, so spendable # only needs to account for pending-claim commitments. "spendable_balance": max(0, c.points - committed_amount), "chore_order": c.chore_order, "current_streak": getattr(c, 'current_streak', 0) or 0, "best_streak": getattr(c, 'best_streak', 0) or 0, "season_points": int(season.get(c.id, 0)), "total_points_earned": getattr(c, 'total_points_earned', 0) or 0, "total_chores_completed": getattr(c, 'total_chores_completed', 0) or 0, "avatar": getattr(c, 'avatar', 'mdi:account-circle') or 'mdi:account-circle', "last_completion_date": getattr(c, 'last_completion_date', None), "streak_paused": getattr(c, 'streak_paused', False), "on_vacation": coordinator._is_child_on_vacation(c), "streak_milestones_achieved": getattr(c, 'streak_milestones_achieved', None) or [], "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, "quests": coordinator.quest_progress_for_child(c.id), "avatar_options": coordinator.avatar_options_for_child(c), "challenges": coordinator.challenge_progress_for_child(c.id), }) return summary def _build_chores_list(coordinator: TaskMateCoordinator, common: dict) -> list[dict]: """Build the chores list, omitting rarely-used / empty fields. Fields dropped entirely (no frontend consumer): - publish_calendar_entities, last_completed_at, first_occurrence_mode, assignment_rotation_anchor. Optional fields are only emitted when they hold a non-default value so small / simple chores produce compact records and the slice stays under the 16KB recorder limit. Per-child availability is split into its own attribute (`chore_availability`) on the chore-availability sensor to keep this record compact. """ chores = common["chores"] chores_list = [] 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 [] record: dict = { "id": c.id, "name": c.name, "points": c.points, "time_category": c.time_category, "assigned_to": assigned_to, "depends_on": depends_on, "schedule_mode": getattr(c, 'schedule_mode', 'specific_days'), "enabled": getattr(c, 'enabled', True), "assignment_mode": getattr(c, 'assignment_mode', 'everyone'), } # Difficulty tier + the points it actually awards. Emitted only when # non-default (medium / ×1.0) so simple chores stay compact. difficulty = getattr(c, 'difficulty', 'medium') or 'medium' if difficulty != 'medium': record["difficulty"] = difficulty effective_points = coordinator.effective_chore_points(c) if effective_points != c.points: record["effective_points"] = effective_points # Optional fields — emit only when non-default to save bytes. description = getattr(c, 'description', '') or '' if description: record["description"] = description daily_limit = getattr(c, 'daily_limit', 1) if daily_limit != 1: record["daily_limit"] = daily_limit claim_allowance_minutes = getattr(c, 'claim_allowance_minutes', 0) or 0 if claim_allowance_minutes: record["claim_allowance_minutes"] = claim_allowance_minutes due_days = getattr(c, 'due_days', []) or [] if due_days: record["due_days"] = due_days requires_approval = getattr(c, 'requires_approval', True) if not requires_approval: record["requires_approval"] = False # Mandatory chores (#532): emit only when set so the child card can # show the mandatory styling/badge. penalty rides along when non-zero. if getattr(c, 'mandatory', False): record["mandatory"] = True penalty = getattr(c, 'mandatory_penalty_points', 0) or 0 if penalty: record["mandatory_penalty_points"] = penalty if getattr(c, 'require_photo', False): record["require_photo"] = True recurrence = getattr(c, 'recurrence', 'weekly') if recurrence != 'weekly': record["recurrence"] = recurrence recurrence_day = getattr(c, 'recurrence_day', '') if recurrence_day: record["recurrence_day"] = recurrence_day recurrence_start = getattr(c, 'recurrence_start', '') if recurrence_start: record["recurrence_start"] = recurrence_start visibility_entity = getattr(c, 'visibility_entity', '') if visibility_entity: record["visibility_entity"] = visibility_entity record["visibility_operator"] = getattr(c, 'visibility_operator', 'equals') record["visibility_state"] = getattr(c, 'visibility_state', 'on') weather_entity = getattr(c, 'weather_entity', '') if weather_entity: record["weather_entity"] = weather_entity record["weather_block_conditions"] = list(getattr(c, 'weather_block_conditions', []) or []) for limit in ("weather_temp_min", "weather_temp_max", "weather_wind_max"): value = getattr(c, limit, None) if value is not None: record[limit] = value # Why it's hidden, so the panel can say "rained off" instead of # just dropping the chore out of the list. reason = coordinator.weather_block_reason(c) if reason: record["weather_blocked"] = reason deadline_at = getattr(c, 'deadline_at', '') if deadline_at: record["deadline_at"] = deadline_at speed_bonus = getattr(c, 'speed_bonus_points', 0) if speed_bonus: record["speed_bonus_points"] = speed_bonus disabled_for = getattr(c, 'disabled_for', []) if disabled_for: record["disabled_for"] = disabled_for created_date = getattr(c, 'created_date', '') if created_date: record["created_date"] = created_date assignment_current_child_id = getattr(c, 'assignment_current_child_id', '') if assignment_current_child_id: record["assignment_current_child_id"] = assignment_current_child_id icon = getattr(c, 'icon', '') if icon: record["icon"] = icon completion_sound = getattr(c, 'completion_sound', 'coin') if completion_sound and completion_sound != 'coin': record["completion_sound"] = completion_sound task_type = getattr(c, 'task_type', 'standard') if task_type == "timed": record["task_type"] = "timed" record["timed_rate_points"] = getattr(c, 'timed_rate_points', 10) record["timed_rate_minutes"] = getattr(c, 'timed_rate_minutes', 5) record["timed_max_daily_minutes"] = getattr(c, 'timed_max_daily_minutes', 0) bonus_subtasks = getattr(c, 'bonus_subtasks', []) if bonus_subtasks: record["bonus_subtasks"] = [ {"id": b.id, "name": b.name, "points": b.points} for b in bonus_subtasks ] chores_list.append(record) return chores_list def _build_chore_availability(coordinator: TaskMateCoordinator, common: dict) -> dict: """Build the chore availability matrix: {chore_id: {child_id: bool}}. Extracted from the chores sensor so `sensor.taskmate_chores` carries only definitions and stays compact even for families with many chores/kids. """ children = common["children"] chores = common["chores"] availability: dict[str, dict[str, bool]] = {} with coordinator.availability_build_scope(): for c in chores: per_child = {} for child in children: per_child[child.id] = coordinator.is_chore_available_for_child(c, child.id) availability[c.id] = per_child return availability 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"] out = [] for comp in common["all_completions"]: comp_dt = comp.completed_at if hasattr(comp_dt, 'astimezone'): comp_dt = dt_util.as_local(comp_dt) comp_date = comp_dt.date() if hasattr(comp_dt, 'date') else comp_dt if comp_date != today: continue matched_chore = chore_lookup.get(comp.chore_id) bonus_subtask_id = getattr(comp, "bonus_subtask_id", "") or "" if bonus_subtask_id and matched_chore: subtask = next((b for b in matched_chore.bonus_subtasks if b.id == bonus_subtask_id), None) 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_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": rate_seconds = matched_chore.timed_rate_minutes * 60 if rate_seconds > 0: display_points = (timed_secs // rate_seconds) * matched_chore.timed_rate_points rec = { "completion_id": comp.id, "chore_id": comp.chore_id, "child_id": comp.child_id, "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": display_name, "points": display_points, "approved": comp.approved, "completed_at": comp.completed_at.isoformat() if hasattr(comp.completed_at, 'isoformat') else str(comp.completed_at), "bonus_subtask_id": bonus_subtask_id, } if timed_secs > 0: rec["timed_duration_seconds"] = timed_secs # Sign the evidence photo so a card's (which carries no bearer # token) can load it from the auth-gated serve view. photo = getattr(comp, "photo_url", "") or "" if photo: rec["photo_url"] = photos.sign_photo_url(common["hass"], photo) out.append(rec) return out def _build_active_timed_sessions(coordinator: TaskMateCoordinator) -> list[dict]: """Build active timed sessions for frontend consumption.""" sessions = coordinator.data.get("timed_sessions", []) out = [] for s in sessions: if hasattr(s, 'state'): state = s.state if state not in ("running", "paused"): continue segments = s.segments or [] current_segment_start = "" if state == "running" and segments: last_seg = segments[-1] if isinstance(last_seg, dict) and last_seg.get("end") is None: current_segment_start = last_seg.get("start", "") out.append({ "chore_id": s.chore_id, "child_id": s.child_id, "state": state, "total_seconds_today": s.total_seconds_today, "current_segment_start": current_segment_start, }) else: state = s.get("state", "stopped") if isinstance(s, dict) else "stopped" if state not in ("running", "paused"): continue segments = s.get("segments", []) if isinstance(s, dict) else [] current_segment_start = "" if state == "running" and segments: last_seg = segments[-1] if isinstance(last_seg, dict) and last_seg.get("end") is None: current_segment_start = last_seg.get("start", "") out.append({ "chore_id": s.get("chore_id", "") if isinstance(s, dict) else "", "child_id": s.get("child_id", "") if isinstance(s, dict) else "", "state": state, "total_seconds_today": s.get("total_seconds_today", 0) if isinstance(s, dict) else 0, "current_segment_start": current_segment_start, }) return out def _build_rewards_list(common: dict) -> list[dict]: """Build the rewards list with calculated_costs and pool allocations.""" rewards = common["rewards"] children = common["children"] pool_by_child_reward = common["pool_by_child_reward"] pool_total_by_reward = common["pool_total_by_reward"] today = dt_util.now().date() out = [] for r in rewards: assigned = ( r.assigned_to if isinstance(r.assigned_to, list) and r.assigned_to else [c.id for c in children] ) calculated_costs = {child_id: r.cost for child_id in assigned} reward_pool_allocations = { cid: pool_by_child_reward.get(cid, {}).get(r.id, 0) for cid in assigned } jackpot_pool_total = ( pool_total_by_reward.get(r.id, 0) if getattr(r, 'is_jackpot', False) else None ) quantity = getattr(r, 'quantity', None) expires_at = getattr(r, 'expires_at', None) is_sold_out = quantity is not None and quantity <= 0 is_expired = False days_until_expiry: int | None = None if expires_at: try: deadline = date.fromisoformat(expires_at) is_expired = deadline <= today days_until_expiry = (deadline - today).days except (TypeError, ValueError): pass out.append({ "id": r.id, "name": r.name, "cost": r.cost, "description": getattr(r, 'description', ''), "icon": r.icon, "assigned_to": r.assigned_to if isinstance(r.assigned_to, list) else [], "is_jackpot": getattr(r, 'is_jackpot', False), "pool_enabled": getattr(r, 'pool_enabled', False), "calculated_costs": calculated_costs, "pool_allocations": reward_pool_allocations, "jackpot_pool_total": jackpot_pool_total, "quantity": quantity, "expires_at": expires_at, "is_sold_out": is_sold_out, "is_expired": is_expired, "is_available": not (is_sold_out or is_expired), "days_until_expiry": days_until_expiry, }) return out 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 = [] for rc in common["pending_reward_claim_objs"]: reward = reward_lookup.get(rc.reward_id) child = child_lookup.get(rc.child_id) if not reward or not child: continue out.append({ "claim_id": rc.id, "reward_id": rc.reward_id, "child_id": rc.child_id, "child_name": child.name, "child_avatar": getattr(child, 'avatar', 'mdi:account-circle') or 'mdi:account-circle', "reward_name": reward.name, "reward_icon": reward.icon or 'mdi:gift', "cost": reward.cost, "claimed_at": rc.claimed_at.isoformat() if hasattr(rc.claimed_at, 'isoformat') else str(rc.claimed_at), }) return out def _build_recent_completions(common: dict, limit: int = 35) -> list[dict]: """Return the most recent completions, capped to `limit`. Lowered from 200 to 35 so the activity sensor's attribute payload stays under the 16KB recorder limit even for families with heavy chore activity. """ child_lookup = common["child_lookup"] chore_lookup = common["chore_lookup"] recent = sorted(common["all_completions"], key=lambda c: c.completed_at, reverse=True)[:limit] out = [] for comp in recent: matched_chore = chore_lookup.get(comp.chore_id) 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": rate_seconds = matched_chore.timed_rate_minutes * 60 if rate_seconds > 0: display_points = (timed_secs // rate_seconds) * matched_chore.timed_rate_points out.append({ "completion_id": comp.id, "chore_id": comp.chore_id, "child_id": comp.child_id, "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 "", "points": display_points, "approved": comp.approved, "completed_at": comp.completed_at.isoformat() if hasattr(comp.completed_at, 'isoformat') else str(comp.completed_at), }) return out def _build_photo_gallery(common: dict, limit: int = 40) -> list[dict]: """Recent completions that carry an evidence photo, newest first (FEAT-13). Returns the raw (unsigned) ``/api/taskmate/photo/`` path; the gallery card signs each via ``auth/sign_path`` before rendering (a card's can't send a bearer token). """ child_lookup = common["child_lookup"] chore_lookup = common["chore_lookup"] with_photos = [ c for c in common["all_completions"] if getattr(c, "photo_url", "") ] recent = sorted(with_photos, key=lambda c: c.completed_at, reverse=True)[:limit] out = [] for comp in recent: chore = chore_lookup.get(comp.chore_id) out.append({ "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 "", "approved": comp.approved, "completed_at": comp.completed_at.isoformat() if hasattr(comp.completed_at, "isoformat") else str(comp.completed_at), "photo_url": comp.photo_url, }) return out def _build_recent_transactions(common: dict, limit: int = 20) -> list[dict]: """Unified activity feed of manual point adjustments and reward claims. Capped at 20 so the combined activity slice (completions + transactions) stays under the 16KB recorder limit. """ data = common["data"] child_lookup = common["child_lookup"] reward_lookup = common["reward_lookup"] points_transactions = data.get("points_transactions", []) all_reward_claims = data.get("reward_claims", []) events = [] for t in points_transactions: child = child_lookup.get(t.child_id) if not child: continue events.append({ "transaction_id": t.id, "type": "points_added" if t.points > 0 else "points_removed", "child_id": t.child_id, "child_name": child.name, "points": t.points, "reason": t.reason or "", "created_at": t.created_at.isoformat() if hasattr(t.created_at, 'isoformat') else str(t.created_at), }) for rc in all_reward_claims: child = child_lookup.get(rc.child_id) reward = reward_lookup.get(rc.reward_id) if not child or not reward: continue event_type = "reward_approved" if rc.approved else "reward_claimed" timestamp = rc.approved_at if rc.approved and rc.approved_at else rc.claimed_at events.append({ "transaction_id": rc.id, "type": event_type, "child_id": rc.child_id, "child_name": child.name, "reward_id": rc.reward_id, "reward_name": reward.name, "reward_icon": reward.icon or "mdi:gift", "points": -reward.cost, "approved": rc.approved, "created_at": timestamp.isoformat() if hasattr(timestamp, 'isoformat') else str(timestamp), }) events.sort(key=lambda e: e["created_at"], reverse=True) return events[:limit] def _build_penalties_list(common: dict) -> list[dict]: return [{ "id": p.id, "name": p.name, "points": p.points, "description": p.description, "icon": p.icon, "assigned_to": p.assigned_to or [], } for p in common["data"].get("penalties", [])] def _build_bonuses_list(common: dict) -> list[dict]: return [{ "id": b.id, "name": b.name, "points": b.points, "description": b.description, "icon": b.icon, "assigned_to": b.assigned_to or [], } for b in common["data"].get("bonuses", [])] async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up TaskMate sensors.""" coordinator: TaskMateCoordinator = hass.data[DOMAIN][entry.entry_id] entities: list[SensorEntity] = [] # Track child IDs that have sensors created tracked_child_ids: set[str] = set() # Add global sensors. The overview sensor is kept for backward compatibility # with existing Lovelace dashboards; its heavy attributes have been split # across four companion sensors to stay below the 16KB recorder limit. entities.append(TaskMateOverallStatsSensor(coordinator, entry)) entities.append(TaskMateChoresSensor(coordinator, entry)) entities.append(TaskMateChoreAvailabilitySensor(coordinator, entry)) entities.append(TaskMateRewardsSensor(coordinator, entry)) entities.append(TaskMateActivitySensor(coordinator, entry)) entities.append(TaskMateIncentivesSensor(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)) tracked_child_ids.add(child.id) # Add pending approvals sensor entities.append(PendingApprovalsSensor(coordinator, entry)) async_add_entities(entities) # Set up listener for new children @callback def async_add_child_sensors() -> None: """Add sensors for newly added children.""" new_entities: list[SensorEntity] = [] for child in coordinator.data.get("children", []): if child.id not in tracked_child_ids: new_entities.append(ChildPointsSensor(coordinator, entry, child)) new_entities.append(ChildStatsSensor(coordinator, entry, child)) new_entities.append(ChildBadgesSensor(coordinator, entry, child)) tracked_child_ids.add(child.id) if new_entities: async_add_entities(new_entities) coordinator.async_add_listener(async_add_child_sensors) class TaskMateBaseSensor(CoordinatorEntity, SensorEntity): """Base class for TaskMate sensors.""" def __init__( self, coordinator: TaskMateCoordinator, entry: ConfigEntry, ) -> None: """Initialize the sensor.""" super().__init__(coordinator) self._entry = entry @property def device_info(self) -> DeviceInfo: """Return device info.""" return DeviceInfo( identifiers={(DOMAIN, self._entry.entry_id)}, name="TaskMate", manufacturer="TaskMate", model="Family Chore Manager", ) class _CachedAttrsSensor(TaskMateBaseSensor): """Base for sensors whose attributes are expensive to build.""" def __init__(self, coordinator: TaskMateCoordinator, entry: ConfigEntry) -> None: super().__init__(coordinator, entry) self._cached_attrs: dict | None = None self._cached_key: tuple[int, int] | None = None @property def extra_state_attributes(self) -> dict: # The coordinator reuses one snapshot object while storage.data_version # is unchanged, so id(data) alone would pin these attributes forever # when only an external entity moved — stranding the visibility and # weather gates on stale state. external_state_version covers that. key = (id(self.coordinator.data), getattr(self.coordinator, "external_state_version", 0)) if self._cached_key == key and self._cached_attrs is not None: return self._cached_attrs attrs = self._build_attributes() self._cached_attrs = attrs self._cached_key = key return attrs def _build_attributes(self) -> dict: # pragma: no cover - abstract raise NotImplementedError class TaskMateOverallStatsSensor(_CachedAttrsSensor): """Overview sensor — scalars plus the compact per-child summary. Heavy lists (chores/rewards/activity/incentives) live on companion sensors so this entity stays well under the 16KB recorder limit. """ def __init__( self, coordinator: TaskMateCoordinator, entry: ConfigEntry, ) -> None: super().__init__(coordinator, entry) self._attr_unique_id = f"{entry.entry_id}_overall_stats" self._attr_name = "TaskMate Overview" @property def native_value(self) -> int: """Return the total number of children.""" return len(self.coordinator.data.get("children", [])) @property def icon(self) -> str: return "mdi:clipboard-check-multiple" def _build_attributes(self) -> dict: common = _compute_common(self.coordinator) data = common["data"] children = common["children"] chores = common["chores"] rewards = common["rewards"] total_points = sum(c.points for c in children) total_chores_completed = sum(c.total_chores_completed for c in children) today_dow = dt_util.now().strftime("%A").lower() settings = data.get("settings", {}) time_periods = self.coordinator.get_time_periods() period_by_id = {p["id"]: p for p in time_periods} active_vacation = self.coordinator.active_vacation() # Legacy shape kept for cards that still read the four fixed keys. legacy_defaults = { "morning": ("06:00", "12:00"), "afternoon": ("12:00", "17:00"), "evening": ("17:00", "21:00"), "night": ("21:00", "23:59"), } time_boundaries = {} for cat, (def_start, def_end) in legacy_defaults.items(): period = period_by_id.get(cat) time_boundaries[f"{cat}_start"] = period["start"] if period else def_start time_boundaries[f"{cat}_end"] = period["end"] if period else def_end return { "today_day_of_week": today_dow, "streak_reset_mode": settings.get("streak_reset_mode", "reset"), "weekend_multiplier": _safe_float(settings.get("weekend_multiplier"), 2.0), "streak_milestones_enabled": settings.get("streak_milestones_enabled", "true") == "true", "streak_milestones": settings.get("streak_milestones", "3:5, 7:10, 14:20, 30:50, 60:100, 100:200"), "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"), "perfect_week_requires_all_chores": settings.get("perfect_week_requires_all_chores", "false") in (True, "true"), "total_children": len(children), "total_chores": len(chores), "total_rewards": len(rewards), "total_points_available": total_points, "total_chores_completed": total_chores_completed, "total_completions_all_time": len(common["all_completions"]), "total_pending_completions": len(common["pending_completions"]), "points_name": data.get("points_name", "Stars"), "points_icon": data.get("points_icon", "mdi:star"), # Global default card-design style; cards read this when no per-card override (#design). "card_design": settings.get("card_design", "classic"), # Non-admin parent role (#661): cards unlock parent controls for these HA users. "parent_user_ids": self.coordinator.storage.get_parent_user_ids(), "children": _build_children_summary(self.coordinator, common), "time_boundaries": time_boundaries, "time_periods": time_periods, "vacation_active": active_vacation is not None, "vacation_name": active_vacation.get("name", "") if active_vacation else "", "vacation_end": active_vacation.get("end", "") if active_vacation else "", "vacation_periods": self.coordinator.get_vacation_periods(), # Leaderboard seasons (FEAT-2): current-month standings + champion history. "season_month": dt_util.now().strftime("%Y-%m"), "season_champions": list(reversed(self.coordinator.storage.get_season_champions()))[:12], # Family co-op goal (FEAT-4). "family_goal": { "enabled": settings.get("family_goal_enabled", False) in (True, "true"), "name": settings.get("family_goal_name", "") or "", "target": _safe_int(settings.get("family_goal_target"), 0), "reward": settings.get("family_goal_reward", "") or "", "progress": sum(c.points for c in children), "achieved": settings.get("family_goal_achieved", False) in (True, "true"), }, } class TaskMateChoresSensor(_CachedAttrsSensor): """Chores catalog + today's completions.""" def __init__( self, coordinator: TaskMateCoordinator, entry: ConfigEntry, ) -> None: super().__init__(coordinator, entry) self._attr_unique_id = f"{entry.entry_id}_chores" self._attr_name = "TaskMate Chores" @property def native_value(self) -> int: return len(self.coordinator.data.get("chores", [])) @property def icon(self) -> str: return "mdi:format-list-checks" def _build_attributes(self) -> dict: common = _compute_common(self.coordinator) task_groups = [ { "id": g.id, "name": g.name, "policy": g.policy, "chore_ids": list(g.chore_ids or []), } for g in self.coordinator.storage.get_task_groups() ] active_vacation = self.coordinator.active_vacation() return { "chores": _build_chores_list(self.coordinator, common), "todays_completions": _build_todays_completions(common), "task_groups": task_groups, "active_timed_sessions": _build_active_timed_sessions(self.coordinator), "vacation_active": active_vacation is not None, "vacation_name": active_vacation.get("name", "") if active_vacation else "", "vacation_end": active_vacation.get("end", "") if active_vacation else "", } class TaskMateChoreAvailabilitySensor(_CachedAttrsSensor): """Per-chore × per-child availability matrix. Split off from the chores sensor so `sensor.taskmate_chores` can stay under the 16 KB recorder limit even for families with lots of chores. The map is `{chore_id: {child_id: bool}}`. """ def __init__( self, coordinator: TaskMateCoordinator, entry: ConfigEntry, ) -> None: super().__init__(coordinator, entry) self._attr_unique_id = f"{entry.entry_id}_chore_availability" self._attr_name = "TaskMate Chore Availability" @property def native_value(self) -> int: common = _compute_common(self.coordinator) total = 0 availability = _build_chore_availability(self.coordinator, common) for per_child in availability.values(): total += sum(1 for v in per_child.values() if v) return total @property def icon(self) -> str: return "mdi:calendar-check" def _build_attributes(self) -> dict: common = _compute_common(self.coordinator) return { "chore_availability": _build_chore_availability(self.coordinator, common), } class TaskMateRewardsSensor(_CachedAttrsSensor): """Rewards catalog + pending claims + pool allocations.""" def __init__( self, coordinator: TaskMateCoordinator, entry: ConfigEntry, ) -> None: super().__init__(coordinator, entry) self._attr_unique_id = f"{entry.entry_id}_rewards" self._attr_name = "TaskMate Rewards" @property def native_value(self) -> int: return len(self.coordinator.data.get("rewards", [])) @property def icon(self) -> str: return "mdi:gift-outline" def _build_attributes(self) -> dict: common = _compute_common(self.coordinator) return { "rewards": _build_rewards_list(common), "pending_reward_claims": _build_pending_reward_claims(common), "pool_allocations": [pa.to_dict() for pa in common["pool_alloc_objs"]], } class TaskMateActivitySensor(_CachedAttrsSensor): """Recent completions + recent points/reward transactions.""" def __init__( self, coordinator: TaskMateCoordinator, entry: ConfigEntry, ) -> None: super().__init__(coordinator, entry) self._attr_unique_id = f"{entry.entry_id}_activity" self._attr_name = "TaskMate Activity" @property def native_value(self) -> int: return len(self.coordinator.data.get("completions", [])) @property def icon(self) -> str: return "mdi:history" def _build_attributes(self) -> dict: common = _compute_common(self.coordinator) children = common["children"] career_history = {} for c in children: 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), "career_score_history": career_history, "photo_gallery": _build_photo_gallery(common), } class TaskMateIncentivesSensor(_CachedAttrsSensor): """Penalties + bonuses catalogue.""" def __init__( self, coordinator: TaskMateCoordinator, entry: ConfigEntry, ) -> None: super().__init__(coordinator, entry) self._attr_unique_id = f"{entry.entry_id}_incentives" self._attr_name = "TaskMate Incentives" @property def native_value(self) -> int: data = self.coordinator.data return len(data.get("penalties", [])) + len(data.get("bonuses", [])) @property def icon(self) -> str: return "mdi:scale-balance" def _build_attributes(self) -> dict: common = _compute_common(self.coordinator) return { "penalties": _build_penalties_list(common), "bonuses": _build_bonuses_list(common), } class ChildPointsSensor(TaskMateBaseSensor): """Sensor for a child's points.""" def __init__( self, coordinator: TaskMateCoordinator, entry: ConfigEntry, child: Child, ) -> None: """Initialize the sensor.""" super().__init__(coordinator, entry) self.child_id = child.id self._attr_unique_id = f"{entry.entry_id}_{child.id}_points" self._attr_name = f"{child.name} Points" self._attr_state_class = SensorStateClass.TOTAL @property def native_value(self) -> int: """Return the child's current points.""" child = self.coordinator.get_child(self.child_id) return child.points if child else 0 @property def native_unit_of_measurement(self) -> str: """Return the unit of measurement.""" return self.coordinator.data.get("points_name", "Stars") @property def icon(self) -> str: """Return the icon.""" return self.coordinator.data.get("points_icon", "mdi:star") @property def extra_state_attributes(self) -> dict: """Return additional attributes.""" child = self.coordinator.get_child(self.child_id) if not child: return {} return { "child_id": child.id, "child_name": child.name, "avatar": child.avatar, "total_points_earned": child.total_points_earned, "total_chores_completed": child.total_chores_completed, "current_streak": child.current_streak, "best_streak": child.best_streak, "career_score": child.career_score, "total_penalties_received": child.total_penalties_received, } class ChildStatsSensor(TaskMateBaseSensor): """Sensor for a child's statistics.""" _attr_translation_key = "child_stats" def __init__( self, coordinator: TaskMateCoordinator, entry: ConfigEntry, child: Child, ) -> None: """Initialize the sensor.""" super().__init__(coordinator, entry) self.child_id = child.id self._attr_unique_id = f"{entry.entry_id}_{child.id}_stats" self._attr_name = f"{child.name} Stats" self._attr_state_class = SensorStateClass.TOTAL @property def native_value(self) -> int: """Return the child's total chores completed.""" child = self.coordinator.get_child(self.child_id) return child.total_chores_completed if child else 0 @property def icon(self) -> str: """Return the icon.""" child = self.coordinator.get_child(self.child_id) return child.avatar if child else "mdi:account-circle" @property def extra_state_attributes(self) -> dict: """Return additional attributes.""" child = self.coordinator.get_child(self.child_id) if not child: return {} # Get chores assigned to this child. For alternating/random/balanced # assignment, only include a chore when this child is the active one # today. first_come is competitive: the whole pool sees it (no single # active child) until one member fills the shared quota. Either way, # drop it once any pool member has completed it today (so a parent # crediting the off-rotation child clears the chore for everyone). chores = self.coordinator.data.get("chores", []) def _included(c): if not (child.id in c.assigned_to or not c.assigned_to): return False mode = getattr(c, "assignment_mode", "everyone") if mode not in ("everyone", "first_come"): if getattr(c, "assignment_current_child_id", "") != child.id: return False if mode != "everyone": if self.coordinator._is_rotation_done_today(c): return False return True assigned_chores = [c for c in chores if _included(c)] return { "child_id": child.id, "child_name": child.name, "avatar": child.avatar, "points": child.points, "total_points_earned": child.total_points_earned, "total_chores_completed": child.total_chores_completed, "current_streak": child.current_streak, "best_streak": child.best_streak, "career_score": child.career_score, "total_penalties_received": child.total_penalties_received, "assigned_chores": [{"id": c.id, "name": c.name, "points": c.points, "time_category": c.time_category} for c in assigned_chores], "chore_order": child.chore_order, } class ChildBadgesSensor(TaskMateBaseSensor): """Sensor exposing a child's earned and available badges.""" _attr_icon = "mdi:trophy-award" _attr_translation_key = "child_badges" 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}_badges" self._attr_name = f"{child.name} Badges" @property def native_value(self) -> int: """Number of badges earned by this child.""" return len( self.coordinator.storage.get_awarded_badges_for_child(self.child_id) ) @property def extra_state_attributes(self) -> dict: from .coord_badges import resolve_metric storage = self.coordinator.storage child = storage.get_child(self.child_id) if child is None: return {"earned": [], "available": [], "total_badges": 0} all_badges = [b for b in storage.get_badges() if b.enabled] applicable = [ b for b in all_badges if not b.assigned_to or self.child_id in b.assigned_to ] awarded_records = storage.get_awarded_badges_for_child(self.child_id) record_by_id = {a.badge_id: a for a in awarded_records} earned: list[dict] = [] available: list[dict] = [] for b in applicable: if b.id in record_by_id: rec = record_by_id[b.id] earned.append({ "badge_id": b.id, "name": b.name, "icon": b.icon, "tier": b.tier, "earned_at": rec.earned_at.isoformat() if rec.earned_at else None, "manually_awarded": rec.manually_awarded, "silent": rec.silent, }) else: if not b.criteria: progress_pct = 0 else: pcts = [] for c in b.criteria: cur = resolve_metric(c.metric, child, storage) target = max(c.value, 1) pcts.append(min(100, int(100 * cur / target))) progress_pct = min(pcts) if pcts else 0 available.append({ "badge_id": b.id, "name": b.name, "icon": b.icon, "tier": b.tier, "progress_pct": progress_pct, "criteria_summary": ", ".join( f"{c.metric} >= {c.value}" for c in b.criteria ), }) earned.sort(key=lambda e: e.get("earned_at") or "", reverse=True) return { "earned": earned, "available": available, "total_badges": len(applicable), } class PendingApprovalsSensor(TaskMateBaseSensor): """Sensor for pending approvals.""" def __init__( self, coordinator: TaskMateCoordinator, entry: ConfigEntry, ) -> None: """Initialize the sensor.""" super().__init__(coordinator, entry) self._attr_unique_id = f"{entry.entry_id}_pending_approvals" self._attr_name = "Pending Approvals" self._attr_state_class = SensorStateClass.MEASUREMENT @property def native_value(self) -> int: """Return the number of pending approvals.""" pending_completions = self.coordinator.data.get("pending_completions", []) pending_rewards = self.coordinator.data.get("pending_reward_claims", []) return len(pending_completions) + len(pending_rewards) @property def icon(self) -> str: """Return the icon.""" return "mdi:clipboard-clock" @property def extra_state_attributes(self) -> dict: """Return additional attributes.""" pending_completions = self.coordinator.data.get("pending_completions", []) pending_rewards = self.coordinator.data.get("pending_reward_claims", []) completion_details = [] for comp in pending_completions: child = self.coordinator.get_child(comp.child_id) chore = self.coordinator.get_chore(comp.chore_id) 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 # list render them identically. bonus_subtask_id = getattr(comp, "bonus_subtask_id", "") or "" if bonus_subtask_id: subtask = next( (b for b in chore.bonus_subtasks if b.id == bonus_subtask_id), None ) chore_name = f"{chore.name} › {subtask.name}" if subtask else chore.name pts = subtask.points if subtask else 0 else: chore_name = chore.name pts = chore.points timed_secs = getattr(comp, "timed_duration_seconds", 0) or 0 if timed_secs > 0 and getattr(chore, "task_type", "") == "timed": rate_seconds = chore.timed_rate_minutes * 60 if rate_seconds > 0: pts = (timed_secs // rate_seconds) * chore.timed_rate_points detail = { "completion_id": comp.id, "type": "chore", "child_name": child.name, "child_id": child.id, "chore_name": chore_name, "chore_id": chore.id, "points": pts, "time_category": chore.time_category, "completed_at": comp.completed_at.isoformat(), "bonus_subtask_id": bonus_subtask_id, } if timed_secs > 0: detail["timed_duration_seconds"] = timed_secs photo = getattr(comp, "photo_url", "") or "" if photo: detail["photo_url"] = photos.sign_photo_url( self.coordinator.hass, photo ) completion_details.append(detail) reward_details = [] for claim in pending_rewards: child = self.coordinator.get_child(claim.child_id) reward = self.coordinator.get_reward(claim.reward_id) if child and reward: reward_details.append({ "claim_id": claim.id, "type": "reward", "child_name": child.name, "child_id": child.id, "reward_name": reward.name, "reward_id": reward.id, "cost": reward.cost, "claimed_at": claim.claimed_at.isoformat(), }) mandatory_misses = self.coordinator.mandatory_misses_state() return { "pending_chore_completions": len(pending_completions), "pending_reward_claims": len(pending_rewards), "pending_mandatory_misses": len(mandatory_misses), "chore_completions": completion_details, "reward_claims": reward_details, "mandatory_misses": mandatory_misses, }