wake-alarm/wake_alarm/_audit_evidence.py
Krzysztof kuhy Rudnicki 8696f2d0bf wake_alarm: sick days stand the PC down (fallback ring, RTC wake, trigger, workday penalty)
A date in the new shared sickdays pool (not a free day) now means, on the PC:
no fallback ring (a ring in progress stops, reason sick_day), no RTC
self-wake for that morning (ours is disarmed), no morning-routine trigger,
and no workday_penalty.json; a penalty issued on a day later marked sick is
retracted (penalty_cancelled, cause sick_day) by the next session refresh or
RTC tick. The audit treats the day as one where nothing is expected.

All pool access goes through wake_alarm/_sick.py: fail closed (unreadable
pool or missing package = not sick), and act modes pull the pool from
Firebase first (RTC --arm on a stick-day target, in-window fallback ticks
and ring polls, the day's first penalty write) because the sickdays timer
pauses through hibernate. Dry runs never pull.

Pins: sickdays-v0.1.0, freedays-v0.4.0, crdt-sync-v0.9.2 (needed by both);
pre-commit 4.7.0, pylint 4.1.3, ruff 0.17.1 (freshness gate).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xfj1sxKH9VBPqpcAni8Jry
2026-10-16 08:45:29 +02:00

139 lines
3.9 KiB
Python

"""What the daily audit knows about one morning, as plain data.
Split from ``_audit_checks`` (the rules) so the evidence shape can be read on
its own: the PC's side (named events, when anything was logged, boots and
resumes, the penalty file) and the phone's side (its event log, escapes, the
ring screen and the stick streak), plus the day and its alarm.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import TYPE_CHECKING
from wake_alarm._enforcement_records import parse_instant
if TYPE_CHECKING:
from datetime import date, datetime
from wake_alarm._audit_sources import PcEvent, Up
from wake_alarm._enforcement_records import Escape, PhoneEvent, StickState
@dataclass(frozen=True)
class PcSide:
"""The PC's own record of the morning."""
events: list[PcEvent] = field(default_factory=list)
seen: list[datetime] = field(default_factory=list)
ups: list[Up] = field(default_factory=list)
penalty_issued: str | None = None
@dataclass(frozen=True)
class PhoneSide:
"""What the phone published about the morning."""
events: list[PhoneEvent] = field(default_factory=list)
escapes: list[Escape] = field(default_factory=list)
ring_started: bool = False
stick_state: StickState | None = None
@dataclass(frozen=True)
class Evidence:
"""Everything the audit gathered about ``day``."""
day: date
stick_day: bool
free_day: bool
alarm_at: datetime | None
alarm_source: str
pc_side: PcSide = field(default_factory=PcSide)
phone_side: PhoneSide = field(default_factory=PhoneSide)
sick_day: bool = False
@property
def pc(self) -> list[PcEvent]:
"""Named PC events."""
return self.pc_side.events
@property
def pc_seen(self) -> list[datetime]:
"""When the PC logged anything at all."""
return self.pc_side.seen
@property
def ups(self) -> list[Up]:
"""Boots and resumes."""
return self.pc_side.ups
@property
def penalty_issued(self) -> str | None:
"""``issued_date`` of the penalty file, if one is on disk."""
return self.pc_side.penalty_issued
@property
def phone(self) -> list[PhoneEvent]:
"""The phone's own events."""
return self.phone_side.events
@property
def escapes(self) -> list[Escape]:
"""Escapes excusing this day (honoured or not)."""
return self.phone_side.escapes
@property
def ring_started(self) -> bool:
"""Whether the phone's ring screen opened this day."""
return self.phone_side.ring_started
@property
def stick_state(self) -> StickState | None:
"""The phone's streak, as published now."""
return self.phone_side.stick_state
@dataclass(frozen=True)
class Check:
"""One expectation and what was observed."""
name: str
ok: bool | None
detail: str
def ok(name: str, detail: str) -> Check:
"""An expectation that held."""
return Check(name, ok=True, detail=detail)
def bad(name: str, detail: str) -> Check:
"""A mismatch: lands under ``## Flags``."""
return Check(name, ok=False, detail=detail)
def na(name: str, detail: str) -> Check:
"""Not applicable to this morning."""
return Check(name, ok=None, detail=detail)
def pc_on_day(ev: Evidence, name: str) -> list[PcEvent]:
"""PC events called ``name`` on the audited day itself."""
return [e for e in ev.pc if e.event == name and e.at.date() == ev.day]
def phone_on_day(ev: Evidence) -> list[PhoneEvent]:
"""Phone events stamped on the audited day itself."""
on_day: list[PhoneEvent] = []
for event in ev.phone:
at = parse_instant(event.at)
if at is not None and at.date() == ev.day:
on_day.append(event)
return on_day
def phone_kinds(ev: Evidence) -> set[str]:
"""Phone event kinds recorded on the audited day."""
return {event.kind for event in phone_on_day(ev)}