This commit is contained in:
Home Assistant Version Control
2026-08-31 14:54:46 +00:00
parent 38964507bd
commit 1770e33ddc
28 changed files with 692 additions and 232 deletions
+67 -10
View File
@@ -33,24 +33,31 @@ class TimedMixin:
now = dt_util.now()
today = dt_util.as_local(now).date().isoformat()
cap_seconds = chore.timed_max_daily_minutes * 60 if chore.timed_max_daily_minutes > 0 else 0
existing = self.storage.get_active_timed_session(chore_id, child_id)
if existing and existing.state == "running":
raise ValueError("Timer is already running")
# Seconds already credited today survive a stop (which removes the live
# session); count them so the cap is a true daily budget rather than a
# per-session one that resets on every stop.
credited_today = self._timed_seconds_credited_today(chore_id, child_id)
if existing and existing.state == "paused":
# Check daily cap before resuming
if chore.timed_max_daily_minutes > 0 and existing.total_seconds_today >= chore.timed_max_daily_minutes * 60:
if cap_seconds and (credited_today + existing.total_seconds_today) >= cap_seconds:
raise ValueError(f"Daily cap reached ({chore.timed_max_daily_minutes} min)")
existing.state = "running"
existing.segments.append({"start": now.isoformat(), "end": None})
self.storage.save_timed_session(existing)
else:
# Check daily cap before starting fresh
if chore.timed_max_daily_minutes > 0:
old_session = self.storage.get_timed_session(chore_id, child_id, today)
if old_session and old_session.total_seconds_today >= chore.timed_max_daily_minutes * 60:
raise ValueError(f"Daily cap reached ({chore.timed_max_daily_minutes} min)")
# Fresh start: enforce the same assignment/enabled/schedule
# eligibility the child card uses, so a disabled, off-day, or
# not-yours timed chore can't be farmed via a crafted start call.
if not self._timed_start_allowed(chore, child_id):
raise ValueError(f"'{chore.name}' is not available for {child.name} right now")
if cap_seconds and credited_today >= cap_seconds:
raise ValueError(f"Daily cap reached ({chore.timed_max_daily_minutes} min)")
session = TimedSession(
chore_id=chore_id,
child_id=child_id,
@@ -102,10 +109,13 @@ class TimedMixin:
total_seconds = self._calc_session_seconds(session)
# Clamp to daily cap
# Clamp to the remaining daily budget (cap minus what was already
# credited today), so repeated start/stop cycles can't exceed the cap.
if chore.timed_max_daily_minutes > 0:
max_seconds = chore.timed_max_daily_minutes * 60
total_seconds = min(total_seconds, max_seconds)
remaining = max(
0, chore.timed_max_daily_minutes * 60 - self._timed_seconds_credited_today(chore_id, child_id)
)
total_seconds = min(total_seconds, remaining)
# Calculate points. Guard against a mis-configured zero rate, which
# would otherwise raise ZeroDivisionError and wedge the session.
@@ -143,6 +153,53 @@ class TimedMixin:
await self.async_refresh()
def _timed_start_allowed(self, chore, child_id: str) -> bool:
"""Assignment/enabled/schedule eligibility for starting a timed task.
A lightweight, self-contained gate (no live-state lookups): the chore
must be enabled, not per-child disabled, assigned to this child if it
has an assignee list, and — for a specific_days schedule — due today.
"""
if not getattr(chore, "enabled", True):
return False
if child_id in (getattr(chore, "disabled_for", []) or []):
return False
assigned = getattr(chore, "assigned_to", []) or []
if assigned and child_id not in assigned:
return False
if getattr(chore, "schedule_mode", "specific_days") == "specific_days":
due_days = getattr(chore, "due_days", []) or []
if due_days:
today = dt_util.as_local(dt_util.now()).date()
dow = ("monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday")[today.weekday()]
if dow not in due_days:
return False
return True
def _timed_seconds_credited_today(self, chore_id: str, child_id: str) -> int:
"""Seconds already credited today for this timed chore + child.
Sums ``timed_duration_seconds`` across today's completions (approved or
pending). Stop removes the live session, so the daily cap is enforced
against these persisted completions instead of a session that vanishes.
"""
today = dt_util.as_local(dt_util.now()).date()
total = 0
for c in self.storage.get_completions():
if c.chore_id != chore_id or c.child_id != child_id:
continue
if getattr(c, "bonus_subtask_id", ""):
continue
secs = getattr(c, "timed_duration_seconds", 0) or 0
if secs <= 0:
continue
try:
if dt_util.as_local(c.completed_at).date() == today:
total += int(secs)
except (AttributeError, TypeError, ValueError):
continue
return total
def _calc_session_seconds(self, session: TimedSession) -> int:
"""Calculate total elapsed seconds from session segments."""
total = 0