348 files

This commit is contained in:
Home Assistant Version Control
2026-07-23 19:02:12 +00:00
parent 0ed1687503
commit 016af2e013
348 changed files with 12368 additions and 11689 deletions
+139
View File
@@ -68,6 +68,21 @@ def format_datetime(dt: datetime | None) -> str | None:
return utc_dt.isoformat().replace("+00:00", "Z")
def optional_float(value: Any) -> float | None:
"""Coerce a stored/user value to a float, or None when it isn't set.
Used for limits where 0 is a meaningful value (a 0 °C threshold is real),
so the usual "0 means off" sentinel can't be used. Empty strings and
unparseable values both read as "no limit".
"""
if value is None or value == "":
return None
try:
return float(value)
except (TypeError, ValueError):
return None
@dataclass
class TimedSession:
"""Tracks an active or paused timed-task session."""
@@ -159,6 +174,10 @@ class Child:
quiet_hours_start: str = "" # "HH:MM" — start of do-not-disturb window; empty = no quiet hours
quiet_hours_end: str = "" # "HH:MM" — end of do-not-disturb window; start>end means overnight
level: int = 1 # cached XP level (derived from total_points_earned)
# Guest profiles (#690): a visiting cousin gets a temporary child that
# expires on its own and stays out of the family leaderboard.
is_guest: bool = False
guest_expires_on: str = "" # ISO date; profile auto-archives the day after
id: str = field(default_factory=generate_id)
@classmethod
@@ -178,6 +197,8 @@ class Child:
streak_paused=data.get("streak_paused", False),
streak_milestones_achieved=list(data.get("streak_milestones_achieved", [])),
awarded_perfect_weeks=list(data.get("awarded_perfect_weeks", [])),
is_guest=bool(data.get("is_guest", False)),
guest_expires_on=str(data.get("guest_expires_on", "") or ""),
availability_entity=data.get("availability_entity", ""),
availability_inverted=data.get("availability_inverted", False),
unavailability_entity=data.get("unavailability_entity", ""),
@@ -208,6 +229,8 @@ class Child:
"streak_paused": self.streak_paused,
"streak_milestones_achieved": self.streak_milestones_achieved,
"awarded_perfect_weeks": self.awarded_perfect_weeks,
"is_guest": self.is_guest,
"guest_expires_on": self.guest_expires_on,
"availability_entity": self.availability_entity,
"availability_inverted": self.availability_inverted,
"unavailability_entity": self.unavailability_entity,
@@ -236,6 +259,9 @@ class Chore:
claim_allowance_minutes: int = 0 # Grace minutes past period end during which the chore stays claimable; 0 = no grace. Night chores still cap at midnight.
daily_limit: int = 1
completion_sound: str = "coin" # Sound to play on completion
# Optional picture for the chore. Text-free pre-reader mode (#683) needs
# one per chore; everything else falls back to the time-of-day icon.
icon: str = ""
difficulty: str = "medium" # easy | medium | hard — scales awarded points by the tier multiplier (medium = ×1.0 baseline)
# Scheduling
# schedule_mode: "specific_days" = show on selected days of week (Mode A)
@@ -251,12 +277,28 @@ class Chore:
visibility_entity: str = "" # optional: entity_id to check for visibility
visibility_state: str = "on" # state/value that makes chore visible (e.g. "on", "true", "123")
visibility_operator: str = "equals" # equals, gte, lte, gt, lt, not_equals
# Weather-aware chores (#673): hide an outdoor chore while the chosen
# weather.* entity reports unsuitable conditions. Every limit is optional
# and evaluation is fail-open — a missing or unavailable entity never hides
# a chore. Temperature/wind limits are read from the entity's attributes in
# whatever unit HA reports them (native_temperature/wind_speed_unit).
weather_entity: str = ""
weather_block_conditions: list[str] = field(default_factory=list) # e.g. ["rainy", "pouring"]
weather_temp_min: float | None = None # block below this temperature
weather_temp_max: float | None = None # block above this temperature
weather_wind_max: float | None = None # block above this wind speed
# One-shot chore fields
enabled: bool = True # False = soft-disabled (completed or expired)
disabled_for: list[str] = field(default_factory=list) # Child IDs this chore is disabled for
depends_on: list[str] = field(default_factory=list) # Chore IDs that must be approved-completed today before this is available
created_date: str = "" # ISO date for one-shot expiry, e.g. "2026-04-16"
expires_on: str = "" # optional ISO end date; chore auto-disables the day after
# Reactive chores (#674): a short-lived chore raised by an automation, e.g.
# "the washing machine finished — empty it within 30 minutes". deadline_at
# is an ISO datetime after which the chore is unavailable and gets
# soft-disabled; beat it and speed_bonus_points is added to the award.
deadline_at: str = ""
speed_bonus_points: int = 0
# Time-of-day incentive: when due_time (HH:MM) is set, a completion at/before
# it earns +early_bonus, after it loses late_penalty (applied to the award).
due_time: str = ""
@@ -311,6 +353,7 @@ class Chore:
claim_allowance_minutes=max(0, int(data.get("claim_allowance_minutes", 0) or 0)),
daily_limit=data.get("daily_limit", 1),
completion_sound=data.get("completion_sound", "coin"),
icon=str(data.get("icon", "") or ""),
difficulty=data.get("difficulty", "medium"),
schedule_mode=schedule_mode,
due_days=list(data.get("due_days", [])),
@@ -321,6 +364,13 @@ class Chore:
visibility_entity=data.get("visibility_entity", ""),
visibility_state=data.get("visibility_state", "on"),
visibility_operator=data.get("visibility_operator", "equals"),
weather_entity=data.get("weather_entity", ""),
weather_block_conditions=list(data.get("weather_block_conditions", [])),
weather_temp_min=optional_float(data.get("weather_temp_min")),
weather_temp_max=optional_float(data.get("weather_temp_max")),
weather_wind_max=optional_float(data.get("weather_wind_max")),
deadline_at=data.get("deadline_at", ""),
speed_bonus_points=int(data.get("speed_bonus_points", 0) or 0),
enabled=data.get("enabled", True),
disabled_for=list(data.get("disabled_for", [])),
depends_on=list(data.get("depends_on", []) or []),
@@ -366,6 +416,7 @@ class Chore:
"claim_allowance_minutes": self.claim_allowance_minutes,
"daily_limit": self.daily_limit,
"completion_sound": self.completion_sound,
"icon": self.icon,
"difficulty": self.difficulty,
"schedule_mode": self.schedule_mode,
"due_days": self.due_days,
@@ -376,6 +427,13 @@ class Chore:
"visibility_entity": self.visibility_entity,
"visibility_state": self.visibility_state,
"visibility_operator": self.visibility_operator,
"weather_entity": self.weather_entity,
"weather_block_conditions": self.weather_block_conditions,
"weather_temp_min": self.weather_temp_min,
"weather_temp_max": self.weather_temp_max,
"weather_wind_max": self.weather_wind_max,
"deadline_at": self.deadline_at,
"speed_bonus_points": self.speed_bonus_points,
"enabled": self.enabled,
"disabled_for": self.disabled_for,
"depends_on": self.depends_on,
@@ -422,6 +480,11 @@ class Reward:
restock_amount: int = 0
restock_period: str = "weekly" # daily | weekly (Mon) | monthly (1st)
restock_last: str = "" # ISO date this reward was last restocked
# Timed unlock (#678): on approval, turn this entity on and turn it back
# off after unlock_minutes. The entity must be on the parent's allowlist,
# checked both at save time and again when it fires.
unlock_entity: str = ""
unlock_minutes: int = 0
id: str = field(default_factory=generate_id)
def __post_init__(self) -> None:
@@ -451,6 +514,8 @@ class Reward:
expires_at=expires_at,
restock_enabled=data.get("restock_enabled", False),
restock_amount=int(data.get("restock_amount", 0) or 0),
unlock_entity=str(data.get("unlock_entity", "") or ""),
unlock_minutes=int(data.get("unlock_minutes", 0) or 0),
restock_period=data.get("restock_period", "weekly"),
restock_last=data.get("restock_last", ""),
id=data.get("id", generate_id()),
@@ -472,6 +537,8 @@ class Reward:
"restock_amount": self.restock_amount,
"restock_period": self.restock_period,
"restock_last": self.restock_last,
"unlock_entity": self.unlock_entity,
"unlock_minutes": self.unlock_minutes,
"id": self.id,
}
@@ -999,6 +1066,73 @@ class TaskGroup:
}
@dataclass
class ScheduledChange:
"""A chore edit queued to take effect on a future date (#675).
"From 1 September this chore is worth 20 points" / "from Monday it's
Ella's". ``changes`` maps chore field -> new value; only the fields in
``SCHEDULED_CHANGE_FIELDS`` may be set, so a queued change can't rewrite
runtime state like rotation anchors or publish history.
Applied changes are kept (not deleted) so the panel can show what happened
and when — a silent config change is worse than no config change.
"""
chore_id: str
apply_on: str # ISO date, e.g. "2026-09-01"
changes: dict[str, Any] = field(default_factory=dict)
note: str = ""
applied: bool = False
applied_at: str = ""
created_at: str = field(default_factory=dt_util_now_iso)
id: str = field(default_factory=generate_id)
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "ScheduledChange":
return cls(
chore_id=data.get("chore_id", ""),
apply_on=data.get("apply_on", ""),
changes=dict(data.get("changes", {}) or {}),
note=data.get("note", ""),
applied=bool(data.get("applied", False)),
applied_at=data.get("applied_at", ""),
created_at=data.get("created_at", "") or dt_util_now_iso(),
id=data.get("id", generate_id()),
)
def to_dict(self) -> dict[str, Any]:
return {
"chore_id": self.chore_id,
"apply_on": self.apply_on,
"changes": self.changes,
"note": self.note,
"applied": self.applied,
"applied_at": self.applied_at,
"created_at": self.created_at,
"id": self.id,
}
# Chore fields a scheduled change is allowed to set. Deliberately excludes
# runtime state (assignment_current_child_id, skip_date, publish history) and
# anything that would need extra coordination to change safely.
SCHEDULED_CHANGE_FIELDS: dict[str, type | tuple[type, ...]] = {
"points": int,
"assigned_to": list,
"enabled": bool,
"time_category": str,
"difficulty": str,
"requires_approval": bool,
"daily_limit": int,
"due_days": list,
"mandatory": bool,
"mandatory_penalty_points": int,
"description": str,
"expires_on": str,
}
@dataclass
class ParentRecipient:
"""A configured parent notification target."""
@@ -1006,6 +1140,9 @@ class ParentRecipient:
name: str
notify_service: str
enabled: bool = True
# Presence entity for "whoever is home" routing (#687). Empty means this
# parent is always considered available.
presence_entity: str = ""
id: str = field(default_factory=lambda: f"parent:{generate_id()}")
@classmethod
@@ -1015,6 +1152,7 @@ class ParentRecipient:
name=data.get("name", ""),
notify_service=data.get("notify_service", ""),
enabled=bool(data.get("enabled", True)),
presence_entity=str(data.get("presence_entity", "") or ""),
id=rid,
)
@@ -1022,6 +1160,7 @@ class ParentRecipient:
return {
"id": self.id,
"name": self.name,
"presence_entity": self.presence_entity,
"notify_service": self.notify_service,
"enabled": self.enabled,
}