Files
Home-Assistant/custom_components/taskmate/coord_timed.py
T
2026-07-08 10:43:39 -04:00

225 lines
9.1 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()
existing = self.storage.get_active_timed_session(chore_id, child_id)
if existing and existing.state == "running":
raise ValueError("Timer is already running")
if existing and existing.state == "paused":
# Check daily cap before resuming
if chore.timed_max_daily_minutes > 0:
if existing.total_seconds_today >= chore.timed_max_daily_minutes * 60:
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)"
)
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 daily cap
if chore.timed_max_daily_minutes > 0:
max_seconds = chore.timed_max_daily_minutes * 60
total_seconds = min(total_seconds, max_seconds)
# 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,
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 _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,
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)