wake-alarm/wake_alarm/_rtc.py
Krzysztof kuhy Rudnicki 5d0f06b19e PC: sick days stand the alarm down only when it is not kept
All PC enforcement (fallback ring, RTC self-wake, trigger, workday penalty
write/retract, audit) now asks sickdays.stands_down(LOCKER_ALARM, day) via
_sick.alarm_stands_down instead of is_sick_day, so a sick day that keeps the
alarm behaves exactly as a normal stick day. Bump sickdays pin to v0.2.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PAhbNszs5JKdtnD4uBi3xp
2026-10-16 15:13:23 +02:00

192 lines
6.8 KiB
Python

"""Arm the RTC so the PC is up before a stick-day alarm.
``wake-alarm-rtc.timer`` runs ``--arm`` every 15 min, all day, and once
after boot; a calendar timer that elapsed while the PC slept catches up on
resume, so a PC hibernated at 16:00 on Monday is armed for Tuesday the
moment it next runs at all. The target is the first morning whose wake instant
(``A - RTC_WAKE_LEAD``, ``A`` clamped to 08:00) is still ahead -- so a
02:00 tick on a Tuesday targets Tuesday, not Wednesday. Only a stick day
(``freedays.is_workday``: Tue/Wed/Thu) that is neither a free day nor a sick
day (``_sick``) is armed, via
``sudo -n /usr/sbin/rtcwake -m no -t <epoch>``. The alarm lives in CMOS, so
it survives however the PC goes down.
When the next morning is not one to wake for (a carrot day, or a stick day
that became free or sick), an alarm *we* armed is cleared (``rtc_disarmed``); one
someone else set is left alone (``_rtc_hw``). A changed alarm time needs no
disarm: arming overwrites the single RTC alarm.
``rtc_armed`` / ``rtc_skip`` are logged only when they change; the audit
matches ``rtc_armed`` on its ``target_date``.
``--arm`` pulls the sick-day pool before judging a stick-day target (a day
marked sick on the phone at 23:00 must disarm before the PC goes down);
``--dry-run`` / ``--show`` read the local pool and write nothing. ``--arm``
also retracts a workday penalty issued on a day since marked sick
(``_workday_penalty.cancel_for_sick_day``): this is the one wake_alarm job
that runs all day, and a day can be marked sick until midnight.
python -m wake_alarm._rtc --arm # what the timer runs
python -m wake_alarm._rtc --dry-run # the plan, nothing armed
python -m wake_alarm._rtc --show # the plan + the RTC's current alarm
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import UTC, date, datetime, timedelta
import sys
from typing import TYPE_CHECKING
import freedays
from wake_alarm._cli_modes import mode_parser, parse_with_now
from wake_alarm._constants import RTC_WAKE_LEAD
from wake_alarm._events import emit, emit_on_change
from wake_alarm._fallback_policy import alarm_instant
from wake_alarm._logging_setup import configure_logging
from wake_alarm._pc_wake_mode import read_mode
from wake_alarm._rtc_hw import arm, current_alarm, disarm_ours, read_record
from wake_alarm._sick import alarm_stands_down, alarm_stands_down_synced
from wake_alarm._sync import Alarm, get_alarm
from wake_alarm._workday_penalty import cancel_for_sick_day
if TYPE_CHECKING:
from collections.abc import Callable
@dataclass(frozen=True)
class Plan:
"""Arm for ``target`` at ``wake_at``, or skip it with a reason."""
target: date
wake_at: datetime
arm: bool
reason: str
@property
def epoch(self) -> int:
"""``wake_at`` as whole epoch seconds."""
return int(self.wake_at.timestamp())
def _now() -> datetime:
"""Aware local now; the seam tests patch."""
return datetime.now(tz=UTC).astimezone()
def plan(
now: datetime, alarm: Alarm, *, sick: Callable[[date], bool] = alarm_stands_down
) -> Plan:
"""Which morning is next, and whether it is one to wake the PC for.
``sick`` is asked last, so only a stick-day target that is not free ever
pays for the synced variant ``--arm`` passes.
"""
target = now.date()
wake_at = alarm_instant(alarm, target) - RTC_WAKE_LEAD
if wake_at <= now:
target += timedelta(days=1)
wake_at = alarm_instant(alarm, target) - RTC_WAKE_LEAD
if not freedays.is_workday(target):
return Plan(
target, wake_at, arm=False, reason=f"{target:%a} is not a stick day"
)
if freedays.is_free_day(target):
return Plan(target, wake_at, arm=False, reason="free day")
if sick(target):
return Plan(target, wake_at, arm=False, reason="sick day")
return Plan(target, wake_at, arm=True, reason="stick day")
def release(chosen: Plan) -> None:
"""No morning to wake for: clear the alarm we armed, if still pending."""
record, disarmed, detail = disarm_ours()
if record is None or detail == "no longer pending":
return
if disarmed:
emit(
"rtc_disarmed",
f"cleared {record.target_date} ({chosen.reason})",
epoch=record.epoch,
target_date=record.target_date,
reason=chosen.reason,
)
return
emit_on_change(
"rtc_skip",
f"{chosen.target}|disarm_failed",
f"rtcwake -m disable failed: {detail}",
epoch=record.epoch,
target_date=record.target_date,
)
def apply(chosen: Plan) -> bool:
"""Arm or skip, logging ``rtc_armed`` / ``rtc_skip`` on change."""
fields = {
"target_date": chosen.target.isoformat(),
"wake_at": chosen.wake_at.isoformat(),
"epoch": chosen.epoch,
"mode": read_mode(),
}
if not chosen.arm:
emit_on_change(
"rtc_skip", f"{chosen.target}|{chosen.reason}", chosen.reason, **fields
)
release(chosen)
return False
ok, detail = arm(chosen.epoch, chosen.target.isoformat())
if not ok:
emit_on_change(
"rtc_skip", f"{chosen.target}|failed", f"rtcwake failed: {detail}", **fields
)
return False
emit_on_change(
"rtc_armed", str(chosen.epoch), f"wake at {chosen.wake_at:%a %H:%M}", **fields
)
return True
def describe(chosen: Plan) -> str:
"""One human line for ``--dry-run`` / ``--show``."""
verdict = "ARM" if chosen.arm else "skip"
return (
f"{verdict} {chosen.target} ({chosen.reason}): wake at "
f"{chosen.wake_at:%Y-%m-%d %H:%M %Z} epoch {chosen.epoch}\n"
)
def main(argv: list[str] | None = None) -> int:
"""Entry point."""
parser = mode_parser("Arm the RTC for a stick day", "arm", "arm (the timer)")
args, now = parse_with_now(parser, argv, _now)
if args.arm:
configure_logging(component="rtc")
cancel_for_sick_day()
chosen = plan(now, get_alarm(), sick=alarm_stands_down_synced)
return 0 if apply(chosen) or not chosen.arm else 1
chosen = plan(now, get_alarm())
sys.stdout.write(describe(chosen))
record = read_record()
if not chosen.arm and record is not None and current_alarm() == record.epoch:
sys.stdout.write(
f"would disarm ours: {record.epoch} for {record.target_date}\n"
)
if args.show:
pending = current_alarm()
when = (
datetime.fromtimestamp(pending, tz=UTC).astimezone().isoformat()
if pending
else "none"
)
ours = "none" if record is None else f"{record.epoch} for {record.target_date}"
sys.stdout.write(
f"rtc wakealarm: {when}\narmed by us: {ours}\nmode: {read_mode()}\n"
)
return 0
if __name__ == "__main__":
sys.exit(main())