283 lines
12 KiB
Python
283 lines
12 KiB
Python
"""Timed task operations mixin for TaskMateCoordinator."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from datetime import datetime
|
|
from typing import TYPE_CHECKING
|
|
|
|
from homeassistant.util import dt as dt_util
|
|
|
|
from .models import ChoreCompletion, TimedSession
|
|
|
|
if TYPE_CHECKING:
|
|
pass
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
|
|
class TimedMixin:
|
|
"""Mixin providing timed task start/pause/stop and session management."""
|
|
|
|
async def async_start_timed_task(self, chore_id: str, child_id: str) -> None:
|
|
"""Start or resume a timed task session."""
|
|
chore = self.get_chore(chore_id)
|
|
if not chore:
|
|
raise ValueError(f"Chore {chore_id} not found")
|
|
if chore.task_type != "timed":
|
|
raise ValueError(f"Chore '{chore.name}' is not a timed task")
|
|
|
|
child = self.get_child(child_id)
|
|
if not child:
|
|
raise ValueError(f"Child {child_id} not found")
|
|
|
|
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":
|
|
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:
|
|
# 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,
|
|
state="running",
|
|
segments=[{"start": now.isoformat(), "end": None}],
|
|
total_seconds_today=0,
|
|
session_date=today,
|
|
)
|
|
self.storage.save_timed_session(session)
|
|
|
|
await self.storage.async_save()
|
|
await self.async_refresh()
|
|
|
|
async def async_pause_timed_task(self, chore_id: str, child_id: str) -> None:
|
|
"""Pause a running timed task session."""
|
|
session = self.storage.get_active_timed_session(chore_id, child_id)
|
|
if not session or session.state != "running":
|
|
raise ValueError("No running timer to pause")
|
|
|
|
now = dt_util.now()
|
|
if session.segments and session.segments[-1].get("end") is None:
|
|
session.segments[-1]["end"] = now.isoformat()
|
|
|
|
session.total_seconds_today = self._calc_session_seconds(session)
|
|
session.state = "paused"
|
|
self.storage.save_timed_session(session)
|
|
await self.storage.async_save()
|
|
await self.async_refresh()
|
|
|
|
async def async_stop_timed_task(self, chore_id: str, child_id: str) -> None:
|
|
"""Stop a timed task session and create a completion."""
|
|
session = self.storage.get_active_timed_session(chore_id, child_id)
|
|
if not session:
|
|
raise ValueError("No active timer to stop")
|
|
|
|
chore = self.get_chore(chore_id)
|
|
if not chore:
|
|
raise ValueError(f"Chore {chore_id} not found")
|
|
|
|
child = self.get_child(child_id)
|
|
if not child:
|
|
raise ValueError(f"Child {child_id} not found")
|
|
|
|
now = dt_util.now()
|
|
|
|
# Close running segment
|
|
if session.state == "running" and session.segments and session.segments[-1].get("end") is None:
|
|
session.segments[-1]["end"] = now.isoformat()
|
|
|
|
total_seconds = self._calc_session_seconds(session)
|
|
|
|
# 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:
|
|
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.
|
|
rate_seconds = chore.timed_rate_minutes * 60
|
|
pts = (total_seconds // rate_seconds) * chore.timed_rate_points if rate_seconds > 0 else 0
|
|
|
|
completion = ChoreCompletion(
|
|
chore_id=chore_id,
|
|
child_id=child_id,
|
|
completed_at=now,
|
|
approved=not chore.requires_approval,
|
|
points_awarded=pts if not chore.requires_approval else 0,
|
|
submitted_points=pts,
|
|
timed_duration_seconds=total_seconds,
|
|
)
|
|
|
|
if not chore.requires_approval:
|
|
total_awarded = await self._award_points(child, pts)
|
|
completion.approved = True
|
|
completion.approved_at = dt_util.now()
|
|
completion.points_awarded = total_awarded
|
|
|
|
self.storage.add_completion(completion)
|
|
self.storage.set_last_completed(chore_id, child_id, now.isoformat())
|
|
self.storage.remove_timed_session(session.id)
|
|
|
|
await self.storage.async_save()
|
|
|
|
if chore.requires_approval:
|
|
await self._async_notify_pending_approval(
|
|
child.name,
|
|
chore.name,
|
|
pts,
|
|
completion_id=completion.id,
|
|
)
|
|
|
|
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
|
|
for seg in session.segments:
|
|
start_str = seg.get("start")
|
|
end_str = seg.get("end")
|
|
if not start_str:
|
|
continue
|
|
try:
|
|
start_dt = datetime.fromisoformat(start_str)
|
|
end_dt = datetime.fromisoformat(end_str) if end_str else dt_util.now()
|
|
diff = (end_dt - start_dt).total_seconds()
|
|
if diff > 0:
|
|
total += int(diff)
|
|
except (ValueError, TypeError):
|
|
continue
|
|
return total
|
|
|
|
async def _async_stop_stale_timed_sessions(self) -> None:
|
|
"""Auto-stop any timed sessions from a previous day (midnight cleanup)."""
|
|
today = dt_util.as_local(dt_util.now()).date().isoformat()
|
|
sessions = self.storage.get_timed_sessions()
|
|
stale = [s for s in sessions if s.session_date != today and s.state in ("running", "paused")]
|
|
|
|
for session in stale:
|
|
chore = self.get_chore(session.chore_id)
|
|
child = self.get_child(session.child_id)
|
|
if not chore or not child:
|
|
self.storage.remove_timed_session(session.id)
|
|
continue
|
|
|
|
# Close any open segment at midnight (tz-aware so it can be
|
|
# subtracted from the tz-aware segment start)
|
|
if session.segments and session.segments[-1].get("end") is None:
|
|
midnight = dt_util.start_of_local_day()
|
|
session.segments[-1]["end"] = midnight.isoformat()
|
|
|
|
total_seconds = self._calc_session_seconds(session)
|
|
if chore.timed_max_daily_minutes > 0:
|
|
total_seconds = min(total_seconds, chore.timed_max_daily_minutes * 60)
|
|
|
|
rate_seconds = chore.timed_rate_minutes * 60
|
|
pts = (total_seconds // rate_seconds) * chore.timed_rate_points if rate_seconds > 0 else 0
|
|
|
|
if total_seconds > 0:
|
|
completion = ChoreCompletion(
|
|
chore_id=session.chore_id,
|
|
child_id=session.child_id,
|
|
completed_at=dt_util.now(),
|
|
approved=not chore.requires_approval,
|
|
points_awarded=pts if not chore.requires_approval else 0,
|
|
submitted_points=pts,
|
|
timed_duration_seconds=total_seconds,
|
|
)
|
|
if not chore.requires_approval:
|
|
total_awarded = await self._award_points(child, pts)
|
|
completion.approved = True
|
|
completion.approved_at = dt_util.now()
|
|
completion.points_awarded = total_awarded
|
|
self.storage.add_completion(completion)
|
|
|
|
self.storage.remove_timed_session(session.id)
|
|
|
|
if stale:
|
|
await self.storage.async_save()
|
|
await self.async_refresh()
|
|
|
|
async def _async_auto_stop_capped_sessions(self) -> None:
|
|
"""Check running sessions against daily cap and auto-stop if exceeded."""
|
|
sessions = self.storage.get_timed_sessions()
|
|
for session in sessions:
|
|
if session.state != "running":
|
|
continue
|
|
chore = self.get_chore(session.chore_id)
|
|
if not chore or chore.timed_max_daily_minutes <= 0:
|
|
continue
|
|
elapsed = self._calc_session_seconds(session)
|
|
if elapsed >= chore.timed_max_daily_minutes * 60:
|
|
await self.async_stop_timed_task(session.chore_id, session.child_id)
|