mirror of
https://github.com/kuhyx/wake-alarm.git
synced 2026-11-10 19:07:03 +01:00
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
This commit is contained in:
parent
3b8cd21969
commit
8696f2d0bf
@ -106,8 +106,9 @@ repos:
|
||||
# the manifest becomes an unresolvable hook environment rather
|
||||
# than merely an old one.
|
||||
- gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.9.0#subdirectory=gatelock
|
||||
- crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.1#subdirectory=crdt-sync
|
||||
- freedays @ git+https://github.com/kuhyx/utils@freedays-v0.3.0#subdirectory=freedays
|
||||
- crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.2#subdirectory=crdt-sync
|
||||
- freedays @ git+https://github.com/kuhyx/utils@freedays-v0.4.0#subdirectory=freedays
|
||||
- sickdays @ git+https://github.com/kuhyx/utils@sickdays-v0.1.0#subdirectory=sickdays
|
||||
# Test suites intentionally use patterns (protected-access, magic-value
|
||||
# comparisons) that don't belong in the source-code 10/10 gate.
|
||||
exclude: ^(\.venv/)|(^|/)(tests/|conftest\.py)
|
||||
|
||||
@ -71,7 +71,8 @@ clock, and everything that is a hard crash or a silent no-ring:
|
||||
**Free days.** On a day in the shared pool (`~/src/utils/freedays`) the phone
|
||||
does not ring at all: `AlarmReceiver` checks a native cache Dart rewrites
|
||||
after every mark/release/sync. Picker, cache and its fail-closed contract:
|
||||
**`DOCS-free-days.md`**.
|
||||
**`DOCS-free-days.md`**. **Sick days** (`~/src/utils/sickdays`, never a free
|
||||
day) stand the PC down too, via `_sick.py`: **`DOCS-wake-enforcement.md`**.
|
||||
|
||||
## Scheduling — a per-minute timer
|
||||
|
||||
|
||||
@ -21,7 +21,7 @@ lever is something that bites *today*. Two holes made skipping cheap:
|
||||
### PC fallback alarm (every ringing day)
|
||||
|
||||
Let `A` be today's alarm (`_sync.get_alarm()`, clamped ≤ 08:00 like the
|
||||
trigger). Not on a free day. Per-minute tick, `A-5min .. A+45min`:
|
||||
trigger). Not on a free day, not on a sick day (below). Per-minute tick, `A-5min .. A+45min`:
|
||||
|
||||
| condition (first match wins) | action |
|
||||
|---|---|
|
||||
@ -76,6 +76,50 @@ During a workday lockdown focus-owner applies its **lockdown allowlist**
|
||||
(calls, SMS, Signal, maps, transit/ticket, banking + authenticator,
|
||||
wake-alarm, todo) — narrower than the night list.
|
||||
|
||||
### Sick days (PC side, 2026-10-16)
|
||||
|
||||
A sick day is a date in the shared `sickdays` pool (`~/src/utils/sickdays`):
|
||||
uncapped, no reason, today or later, and **not a free day** -- its own pool,
|
||||
its own reason strings (`sick_day` / `sick day`). On a sick day `D` (the
|
||||
alarm's day, so the RTC asks about *tomorrow* the evening before):
|
||||
|
||||
| piece | on a sick day |
|
||||
|---|---|
|
||||
| fallback ring | never starts; a ring in progress stops (`stop_reason` `sick_day`) |
|
||||
| RTC self-wake | not armed for `D`; an alarm we armed for `D` is disarmed (`rtc_skip` / `rtc_disarmed`, reason `sick day`) |
|
||||
| trigger | `morning-routine.service` is not started (`sick day`), like a free day |
|
||||
| workday penalty | never written for `D`; one *issued* on `D` before `D` was marked is deleted (`penalty_cancelled`, `cause: sick_day`) by the next `_session --refresh` or `_rtc --arm` |
|
||||
| `morning_session.json` | unchanged: it only *grants* exemptions, and every locker stands down on a sick day by its own check |
|
||||
| audit | RTC / up-by-A / ring / lockdown checks are n/a; a penalty for `D` on disk or a `lockdown_sent` for a miss on `D` is flagged |
|
||||
|
||||
All of it lives behind `wake_alarm/_sick.py`. **Fail closed:** an
|
||||
unreadable pool, or `sickdays` not importable under `/usr/bin/python`, is
|
||||
"not sick" -- enforcement runs.
|
||||
|
||||
**Sync.** The decisions read the local pool, and the act modes pull it from
|
||||
Firebase first (`sickdays.sync_quietly()`), because the sickdays sync timer
|
||||
alone cannot be trusted at the moment that matters: it is a monotonic
|
||||
15-minute timer, paused during hibernate and with no `Persistent` catch-up,
|
||||
so right after the RTC resume the pool can be an interval stale. Pulled:
|
||||
`_rtc --arm` when the target is a stick day that is not free; the fallback
|
||||
tick whenever it already goes to the network (`A-5 .. A+45`, and every poll
|
||||
of a ring in progress, so a day marked sick mid-ring stops it); the day's
|
||||
first penalty write. The trigger reads only the local file -- the fallback
|
||||
has pulled from `A-5`. `--dry-run` / `--show` never pull (a pull rewrites
|
||||
the pool file).
|
||||
|
||||
**Other readers of `workday_penalty.json`** (screen-locker, leetcode-guard)
|
||||
must treat the file as void when **either** `issued_date` **or**
|
||||
`penalty_date` is a sick day (`sickdays.is_sick_day(date.fromisoformat(...))`).
|
||||
`issued_date` covers a miss at 08:15 marked sick at 14:00 -- the deletion
|
||||
runs on the next RTC tick (<= 15 min) or the next morning's refresh, and a
|
||||
reader must not depend on that; `penalty_date` is a sick day on which every
|
||||
locker stands down anyway.
|
||||
|
||||
**Phone, assumed name:** the phone's `alarm_fired` on a sick day carries
|
||||
`data.reason = "sick_day"` (by analogy with `free_day`); the audit treats
|
||||
it as not the morning's ring.
|
||||
|
||||
### Escape hatch (until 2026-10-15 inclusive)
|
||||
|
||||
Home screen button "That was a bug". Needs a typed reason ≥ 40 characters.
|
||||
@ -113,13 +157,14 @@ shown comes from Dart constants mirrored against the Python ones by
|
||||
- `events.jsonl` — every PC event (`_log_sink`), `event` key names below.
|
||||
- `fallback_ring.json` — today's latch: `{"date", "started_at", "stopped_at",
|
||||
"reason", "stop_reason"}`.
|
||||
- `workday_penalty.json` — unchanged shape; not written for an escaped day,
|
||||
and removed when an escape arrives for the day it was issued.
|
||||
- `workday_penalty.json` — unchanged shape; not written for an escaped day
|
||||
or a sick day, and removed when an escape arrives for the day it was
|
||||
issued or that day is marked sick.
|
||||
|
||||
### Event names
|
||||
|
||||
Phone (`phone_events.kind`): `alarm_fired` (data: `reason` =
|
||||
`daily`|`rering`|`free_day` — only `daily` is the morning's ring),
|
||||
`daily`|`rering`|`free_day`|`sick_day` — only `daily` is the morning's ring),
|
||||
`ring_screen_opened`, `ring_started_published` (data: `date`, `source` =
|
||||
`alarm`|`ring_screen`), `dismissed`, `stick_check` (data: `trigger` =
|
||||
`exact`|`boot`|`watchdog`, `date` judged, `verdict`), `lockdown_sent` (data:
|
||||
@ -135,7 +180,7 @@ system's next alarm. Boot/watchdog catch up on up to 5 unchecked past days.
|
||||
PC (`events.jsonl` `event`): `fallback_tick` (only on decision change),
|
||||
`fallback_ring_start`, `fallback_ring_stop`, `ring_backend`, `rtc_armed`,
|
||||
`rtc_skip`, `firebase_unreachable`, `escape_seen`, `penalty_written`,
|
||||
`penalty_cancelled`, `audit_written`.
|
||||
`penalty_cancelled` (field `cause`: `escape` | `sick_day`), `audit_written`.
|
||||
|
||||
## Verification status (2026-10-01)
|
||||
|
||||
|
||||
@ -12,8 +12,11 @@ dependencies = [
|
||||
# Firebase transport for the alarm read. _sync.py imports it lazily and
|
||||
# falls back to its stdlib GitHub path if the import fails, so a broken
|
||||
# install degrades the read instead of killing the per-minute trigger.
|
||||
"crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.1#subdirectory=crdt-sync",
|
||||
"freedays @ git+https://github.com/kuhyx/utils@freedays-v0.3.0#subdirectory=freedays",
|
||||
"crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.2#subdirectory=crdt-sync",
|
||||
"freedays @ git+https://github.com/kuhyx/utils@freedays-v0.4.0#subdirectory=freedays",
|
||||
# Sick days stand the PC's enforcement down (_sick.py). Imported behind
|
||||
# a guard: missing means "not sick", never a crashed tick.
|
||||
"sickdays @ git+https://github.com/kuhyx/utils@sickdays-v0.1.0#subdirectory=sickdays",
|
||||
]
|
||||
|
||||
# Package discovery must be explicit: phone_app/ is a Flutter app, not a Python
|
||||
|
||||
@ -5,15 +5,16 @@
|
||||
bandit==1.9.4
|
||||
codespell==2.4.3
|
||||
coverage==7.16.2
|
||||
crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.1#subdirectory=crdt-sync
|
||||
freedays @ git+https://github.com/kuhyx/utils@freedays-v0.3.0#subdirectory=freedays
|
||||
crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.2#subdirectory=crdt-sync
|
||||
freedays @ git+https://github.com/kuhyx/utils@freedays-v0.4.0#subdirectory=freedays
|
||||
gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.9.0#subdirectory=gatelock
|
||||
mypy==2.4.0
|
||||
pre-commit==4.6.2
|
||||
pylint==4.1.2
|
||||
pre-commit==4.7.0
|
||||
pylint==4.1.3
|
||||
pytest==9.1.1
|
||||
pytest-cov==7.1.0
|
||||
pytest-randomly==5.0.0
|
||||
pytest-sugar==1.1.1
|
||||
pytest-xdist==3.8.0
|
||||
ruff==0.16.10
|
||||
ruff==0.17.1
|
||||
sickdays @ git+https://github.com/kuhyx/utils@sickdays-v0.1.0#subdirectory=sickdays
|
||||
|
||||
@ -36,6 +36,7 @@ from wake_alarm._enforcement_records import parse_instant
|
||||
from wake_alarm._events import emit
|
||||
from wake_alarm._fallback_policy import alarm_instant
|
||||
from wake_alarm._logging_setup import configure_logging
|
||||
from wake_alarm._sick import is_sick
|
||||
from wake_alarm._sync import get_alarm
|
||||
from wake_alarm._sync_transport import fetch_firebase_logs
|
||||
from wake_alarm._workday_penalty import issued_date
|
||||
@ -64,6 +65,7 @@ def gather(day: date) -> tuple[Evidence, bool]:
|
||||
day=day,
|
||||
stick_day=freedays.is_workday(day),
|
||||
free_day=freedays.is_free_day(day),
|
||||
sick_day=is_sick(day),
|
||||
alarm_at=alarm_at,
|
||||
alarm_source=source,
|
||||
pc_side=PcSide(
|
||||
@ -117,7 +119,15 @@ def _timeline(ev: Evidence) -> list[str]:
|
||||
|
||||
def render(ev: Evidence, checks: list[Check], *, reachable: bool) -> str:
|
||||
"""The whole report."""
|
||||
kind = "free day" if ev.free_day else "stick day" if ev.stick_day else "carrot day"
|
||||
kind = (
|
||||
"sick day"
|
||||
if ev.sick_day
|
||||
else "free day"
|
||||
if ev.free_day
|
||||
else "stick day"
|
||||
if ev.stick_day
|
||||
else "carrot day"
|
||||
)
|
||||
lines = [f"# Wake audit {ev.day} ({ev.day:%a}, {kind})", "", "## Flags", ""]
|
||||
flagged = flags(ev, checks)
|
||||
if not reachable:
|
||||
|
||||
@ -29,11 +29,12 @@ if TYPE_CHECKING:
|
||||
|
||||
from wake_alarm._audit_sources import PcEvent
|
||||
|
||||
_FINAL_STOPS = frozenset({"escape", "session_started", "cap", "free_day"})
|
||||
_FINAL_STOPS = frozenset({"escape", "session_started", "cap", "free_day", "sick_day"})
|
||||
_RING_KINDS = frozenset({"ring_screen_opened", "ring_started_published"})
|
||||
# alarm_fired.data.reason: only the daily ring is the morning's ring -- a
|
||||
# free_day firing rings nothing, and a rering presupposes an earlier ring.
|
||||
_NOT_THE_MORNING_RING = frozenset({"free_day", "rering"})
|
||||
# free_day / sick_day firing rings nothing, and a rering presupposes an
|
||||
# earlier ring.
|
||||
_NOT_THE_MORNING_RING = frozenset({"free_day", "sick_day", "rering"})
|
||||
|
||||
|
||||
def alarm_from_events(pc: list[PcEvent], day: date) -> datetime | None:
|
||||
@ -58,6 +59,8 @@ def check_rtc(ev: Evidence) -> Check:
|
||||
"""On a stick day the RTC was armed the night before."""
|
||||
if not ev.stick_day or ev.free_day:
|
||||
return na("RTC armed the night before", "not a stick day")
|
||||
if ev.sick_day:
|
||||
return na("RTC armed the night before", "sick day")
|
||||
armed = [
|
||||
e
|
||||
for e in ev.pc
|
||||
@ -75,6 +78,8 @@ def check_up_by_alarm(ev: Evidence) -> Check:
|
||||
"""On a stick day the PC was up by ``A``."""
|
||||
if not ev.stick_day or ev.free_day or ev.alarm_at is None:
|
||||
return na("PC up by A", "not a stick day or no alarm")
|
||||
if ev.sick_day:
|
||||
return na("PC up by A", "sick day")
|
||||
alarm = ev.alarm_at
|
||||
lead = alarm - RTC_WAKE_LEAD - timedelta(minutes=5)
|
||||
ups = [u for u in ev.ups if u.at.date() == ev.day and u.at <= alarm]
|
||||
@ -90,6 +95,8 @@ def check_ring(ev: Evidence) -> Check:
|
||||
"""Something rang: the phone's ring screen, or the PC fallback."""
|
||||
if ev.free_day:
|
||||
return na("something rang", "free day")
|
||||
if ev.sick_day:
|
||||
return na("something rang", "sick day")
|
||||
fallback = pc_on_day(ev, "fallback_ring_start")
|
||||
phone = ev.ring_started or any(
|
||||
e.kind in _RING_KINDS
|
||||
|
||||
@ -51,6 +51,7 @@ class Evidence:
|
||||
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]:
|
||||
|
||||
@ -74,10 +74,11 @@ def check_penalty(ev: Evidence) -> Check:
|
||||
"""The PC penalty agrees with the outcome and any escape."""
|
||||
name = "penalty consistent"
|
||||
escaped = _escaped(ev)
|
||||
if escaped and ev.penalty_issued == ev.day.isoformat():
|
||||
return bad(name, "escaped, but the penalty is on disk")
|
||||
if escaped:
|
||||
return ok(name, "escaped: no penalty")
|
||||
if escaped or ev.sick_day:
|
||||
why = "escaped" if escaped else "sick day"
|
||||
if ev.penalty_issued == ev.day.isoformat():
|
||||
return bad(name, f"{why}, but the penalty is on disk")
|
||||
return ok(name, f"{why}: no penalty")
|
||||
if not ev.stick_day or ev.free_day:
|
||||
return na(name, "not a stick day")
|
||||
written = pc_on_day(ev, "penalty_written")
|
||||
@ -119,6 +120,14 @@ def _lockdown_for_miss(ev: Evidence, judged: date) -> Check:
|
||||
return ok(name, f"until {until}")
|
||||
|
||||
|
||||
def _sick_lockdown(ev: Evidence, judged: date | None) -> Check:
|
||||
"""A sick day expects no lockdown; one sent for its miss is flagged."""
|
||||
name = "lockdown consistent"
|
||||
if judged is not None and _sent_on(ev, judged):
|
||||
return bad(name, "sick day, but lockdown_sent")
|
||||
return na(name, "sick day")
|
||||
|
||||
|
||||
def check_lockdown(ev: Evidence) -> Check:
|
||||
"""``lockdown_sent`` agrees with the verdict, and escapes lift it."""
|
||||
name = "lockdown consistent"
|
||||
@ -128,6 +137,8 @@ def check_lockdown(ev: Evidence) -> Check:
|
||||
if "lift_sent" not in phone_kinds(ev):
|
||||
return bad(name, "escaped, but no lift_sent")
|
||||
return ok(name, "lifted after the escape")
|
||||
if ev.sick_day:
|
||||
return _sick_lockdown(ev, judged)
|
||||
if judged is not None:
|
||||
return _lockdown_for_miss(ev, judged)
|
||||
if sent_today and not _escaped(ev):
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
"""The PC fallback alarm: one per-minute tick, 04:00-09:59.
|
||||
|
||||
``wake-alarm-fallback.timer`` runs ``--tick`` every minute. Outside
|
||||
``A - 5 min .. A + 45 min`` (or on a free day) a tick decides from local
|
||||
``A - 5 min .. A + 45 min`` (or on a free or sick day) a tick decides from local
|
||||
state alone and never touches the network. Inside, it reads every device's
|
||||
``alarm.json`` once, decides (``_fallback_policy.decide``) and, when the
|
||||
decision is to ring, rings *in this process* until a stop condition holds
|
||||
@ -44,6 +44,7 @@ from wake_alarm._fallback_policy import (
|
||||
from wake_alarm._fallback_ring import ring
|
||||
from wake_alarm._logging_setup import configure_logging
|
||||
from wake_alarm._ring_wav import SYSTEM_WAV_DIR, USER_WAV_DIR, ensure_pair
|
||||
from wake_alarm._sick import is_sick
|
||||
from wake_alarm._sync import get_alarm
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@ -71,7 +72,7 @@ def _close(latch: Latch, reason: str) -> None:
|
||||
|
||||
def _poll(day: str) -> str | None:
|
||||
"""Re-observe while ringing; the reason to stop, or None."""
|
||||
seen = observe(_now())
|
||||
seen = observe(_now(), sync=True)
|
||||
record(seen)
|
||||
reason = stop_reason(replace(seen.facts, latch=read_latch(day)))
|
||||
if reason is not None:
|
||||
@ -105,7 +106,7 @@ def ring_now(facts: Facts, reason: str) -> str:
|
||||
|
||||
def tick(now: datetime) -> Decision:
|
||||
"""One timer tick: observe, log a changed decision, ring if due."""
|
||||
seen = observe(now)
|
||||
seen = observe(now, sync=True)
|
||||
record(seen)
|
||||
facts, day = seen.facts, now.date().isoformat()
|
||||
decision = decide(facts)
|
||||
@ -134,6 +135,7 @@ def describe(seen: Observation, decision: Decision) -> str:
|
||||
return (
|
||||
f"{facts.now:%Y-%m-%d %H:%M} — {verdict}: {decision.reason}\n"
|
||||
f" alarm A={facts.alarm_at:%H:%M} free_day={facts.free_day} "
|
||||
f"sick_day={facts.sick_day} "
|
||||
f"firebase={'ok' if seen.reachable else 'UNREACHABLE'}\n"
|
||||
f" escaped={facts.escaped} session_started={facts.session_started} "
|
||||
f"ring_started={facts.ring_started} settled={facts.settled}\n"
|
||||
@ -147,7 +149,8 @@ def show(now: datetime) -> str:
|
||||
alarm_at = alarm_instant(get_alarm(), now.date())
|
||||
return (
|
||||
f"today {day}: A={alarm_at:%H:%M} "
|
||||
f"free_day={freedays.is_free_day(now.date())}\n"
|
||||
f"free_day={freedays.is_free_day(now.date())} "
|
||||
f"sick_day={is_sick(now.date())}\n"
|
||||
f" latch={read_latch(day)}\n"
|
||||
f" last fallback_tick={last_value('fallback_tick')}\n"
|
||||
f" settled={settled_reason(day, read_latch(day))}\n"
|
||||
@ -166,7 +169,7 @@ def main(argv: list[str] | None = None) -> int:
|
||||
if args.show:
|
||||
sys.stdout.write(show(now))
|
||||
elif args.dry_run:
|
||||
seen = observe(now)
|
||||
seen = observe(now, sync=False)
|
||||
sys.stdout.write(describe(seen, decide(seen.facts)))
|
||||
else:
|
||||
configure_logging(component="fallback")
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
"""One look at the world for the fallback alarm, and its bookkeeping.
|
||||
|
||||
Split from ``_fallback`` (tick, ring lifecycle, CLI): this is "what is true
|
||||
right now" -- today's alarm, the latch, the free-day pool and, only when the
|
||||
decision needs it, every device's ``alarm.json``.
|
||||
right now" -- today's alarm, the latch, the free-day and sick-day pools
|
||||
and, only when the decision needs it, every device's ``alarm.json``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@ -23,6 +23,7 @@ from wake_alarm._fallback_policy import (
|
||||
needs_facts,
|
||||
)
|
||||
from wake_alarm._session_payload import newest_session
|
||||
from wake_alarm._sick import is_sick, is_sick_synced
|
||||
from wake_alarm._sync import get_alarm
|
||||
from wake_alarm._sync_transport import fetch_firebase_logs
|
||||
|
||||
@ -49,18 +50,37 @@ def settled_reason(day: str, latch: Latch | None) -> str | None:
|
||||
return remembered[2] if remembered[0] == day and remembered[2] else None
|
||||
|
||||
|
||||
def observe(now: datetime) -> Observation:
|
||||
"""Gather the facts; the network only when the decision needs it."""
|
||||
def _sick_today(now: datetime, *, online: bool, sync: bool) -> bool:
|
||||
"""Whether today is a sick day; pulls the pool first when acting online.
|
||||
|
||||
Synced only on the ticks that already go to the network (the window, or
|
||||
a ring in progress), so a day marked sick on the phone mid-ring stops
|
||||
the ring at the next poll, and an off-window tick stays local.
|
||||
"""
|
||||
if online and sync:
|
||||
return is_sick_synced(now.date())
|
||||
return is_sick(now.date())
|
||||
|
||||
|
||||
def observe(now: datetime, *, sync: bool) -> Observation:
|
||||
"""Gather the facts; the network only when the decision needs it.
|
||||
|
||||
``sync`` is False for ``--dry-run``: pulling the sick-day pool rewrites
|
||||
its local file, and a dry run writes nothing.
|
||||
"""
|
||||
day = now.date().isoformat()
|
||||
latch = read_latch(day)
|
||||
alarm_at = alarm_instant(get_alarm(), now.date())
|
||||
online = needs_facts(now, alarm_at, latch)
|
||||
facts = Facts(
|
||||
now=now,
|
||||
alarm_at=alarm_instant(get_alarm(), now.date()),
|
||||
alarm_at=alarm_at,
|
||||
free_day=freedays.is_free_day(now.date()),
|
||||
sick_day=_sick_today(now, online=online, sync=sync),
|
||||
latch=latch,
|
||||
settled=settled_reason(day, latch),
|
||||
)
|
||||
if facts.free_day or not needs_facts(now, facts.alarm_at, latch):
|
||||
if facts.free_day or facts.sick_day or not online:
|
||||
return Observation(facts)
|
||||
published = fetch_firebase_logs()
|
||||
if published is None:
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
"""The PC fallback alarm's rule, as one pure function.
|
||||
|
||||
``DOCS-wake-enforcement.md``'s table, first match wins, inside
|
||||
``A - 5 min .. A + 45 min`` on a day that is not free:
|
||||
``A - 5 min .. A + 45 min`` on a day that is neither free nor sick:
|
||||
|
||||
========================================================= ==================
|
||||
escape recorded for today, or a session started today do not ring / stop
|
||||
@ -45,6 +45,7 @@ FALLBACK_POLL_SECONDS: float = 20.0
|
||||
|
||||
# Reasons, as they appear in events.jsonl and fallback_ring.json.
|
||||
FREE_DAY = "free_day"
|
||||
SICK_DAY = "sick_day"
|
||||
OUTSIDE_WINDOW = "outside_window"
|
||||
ESCAPE = "escape"
|
||||
SESSION_STARTED = "session_started"
|
||||
@ -69,6 +70,7 @@ class Facts:
|
||||
now: datetime
|
||||
alarm_at: datetime
|
||||
free_day: bool
|
||||
sick_day: bool = False
|
||||
escaped: bool = False
|
||||
session_started: bool = False
|
||||
ring_started: bool = False
|
||||
@ -121,15 +123,26 @@ def is_capped(facts: Facts) -> bool:
|
||||
return started is not None and facts.now >= started + FALLBACK_RING_CAP
|
||||
|
||||
|
||||
def day_off(facts: Facts) -> str | None:
|
||||
"""``free_day`` / ``sick_day`` when today rings nothing at all."""
|
||||
if facts.free_day:
|
||||
return FREE_DAY
|
||||
if facts.sick_day:
|
||||
return SICK_DAY
|
||||
return None
|
||||
|
||||
|
||||
def stop_reason(facts: Facts) -> str | None:
|
||||
"""Why a ring in progress must stop now, or None to keep ringing.
|
||||
|
||||
Only the contract's three stop conditions (plus a day that turned free):
|
||||
Only the contract's three stop conditions (plus a day that turned free
|
||||
or sick):
|
||||
a phone that comes back on mid-ring does not silence the PC -- getting up
|
||||
does.
|
||||
"""
|
||||
if facts.free_day:
|
||||
return FREE_DAY
|
||||
off = day_off(facts)
|
||||
if off is not None:
|
||||
return off
|
||||
if facts.settled in FINAL_REASONS:
|
||||
return facts.settled
|
||||
if facts.escaped:
|
||||
@ -143,8 +156,9 @@ def stop_reason(facts: Facts) -> str | None:
|
||||
|
||||
def decide(facts: Facts) -> Decision:
|
||||
"""The table above; first match wins."""
|
||||
if facts.free_day:
|
||||
return Decision(ring=False, reason=FREE_DAY)
|
||||
off = day_off(facts)
|
||||
if off is not None:
|
||||
return Decision(ring=False, reason=off)
|
||||
if not needs_facts(facts.now, facts.alarm_at, facts.latch):
|
||||
return Decision(ring=False, reason=OUTSIDE_WINDOW)
|
||||
stop = stop_reason(facts)
|
||||
|
||||
@ -6,18 +6,26 @@ 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 not a free day is armed, via
|
||||
(``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), an alarm *we* armed is cleared (``rtc_disarmed``); one
|
||||
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
|
||||
@ -28,6 +36,7 @@ from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, date, datetime, timedelta
|
||||
import sys
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import freedays
|
||||
|
||||
@ -38,7 +47,12 @@ 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 is_sick, is_sick_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)
|
||||
@ -61,8 +75,14 @@ def _now() -> datetime:
|
||||
return datetime.now(tz=UTC).astimezone()
|
||||
|
||||
|
||||
def plan(now: datetime, alarm: Alarm) -> Plan:
|
||||
"""Which morning is next, and whether it is one to wake the PC for."""
|
||||
def plan(
|
||||
now: datetime, alarm: Alarm, *, sick: Callable[[date], bool] = is_sick
|
||||
) -> 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:
|
||||
@ -74,6 +94,8 @@ def plan(now: datetime, alarm: Alarm) -> Plan:
|
||||
)
|
||||
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")
|
||||
|
||||
|
||||
@ -139,10 +161,12 @@ 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)
|
||||
chosen = plan(now, get_alarm())
|
||||
if args.arm:
|
||||
configure_logging(component="rtc")
|
||||
cancel_for_sick_day()
|
||||
chosen = plan(now, get_alarm(), sick=is_sick_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:
|
||||
|
||||
@ -42,6 +42,7 @@ from wake_alarm._session_payload import newest_session
|
||||
from wake_alarm._session_policy import to_entry
|
||||
from wake_alarm._sync import get_alarm
|
||||
from wake_alarm._sync_transport import fetch_firebase_logs
|
||||
from wake_alarm._workday_penalty import cancel_for_sick_day as _cancel_for_sick_day
|
||||
from wake_alarm._workday_penalty import maybe_apply as _maybe_apply_workday_penalty
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
@ -84,8 +85,11 @@ def refresh(now: datetime) -> dict[str, object] | None:
|
||||
"""Fetch, select, and rewrite the file; None when the backend was unreadable.
|
||||
|
||||
An unreadable backend leaves the file alone: whatever it says is still
|
||||
the newest thing known, and a consumer judges freshness by its date.
|
||||
the newest thing known, and a consumer judges freshness by its date. A
|
||||
penalty issued on a day since marked sick is retracted first, from the
|
||||
local pool alone, so it does not wait on Firebase.
|
||||
"""
|
||||
_cancel_for_sick_day()
|
||||
published = fetch_firebase_logs()
|
||||
if published is None:
|
||||
_logger.info("Morning session not refreshed: backend unreadable")
|
||||
|
||||
54
wake_alarm/_sick.py
Normal file
54
wake_alarm/_sick.py
Normal file
@ -0,0 +1,54 @@
|
||||
"""Sick days: the one place wake_alarm asks the shared ``sickdays`` pool.
|
||||
|
||||
A sick day (``~/src/utils/sickdays``) is not a free day -- a separate pool,
|
||||
no cap, no reason -- but on the PC it stands down the same things: no
|
||||
fallback ring, no RTC self-wake, no workday penalty, no morning routine.
|
||||
|
||||
Two rules this module exists to keep in one spot:
|
||||
|
||||
* **Fail closed.** ``sickdays.is_sick_day`` never raises and reads a corrupt
|
||||
pool as "not sick". The import is guarded as well: these jobs run under
|
||||
``/usr/bin/python``, and a package that failed to install must leave
|
||||
enforcement *on* (not sick), never crash the per-minute tick -- a crashed
|
||||
tick rings nothing.
|
||||
* **Sync only where something is about to happen, never in a read-only
|
||||
mode.** ``sync_quietly`` rewrites the local pool, so ``--dry-run`` and
|
||||
``--show`` never call it. The sickdays timer alone is not enough: it is a
|
||||
monotonic 15-minute timer, which pauses during hibernate and has no
|
||||
``Persistent`` catch-up, so right after the RTC resume the pool can be a
|
||||
whole interval stale -- exactly when a day marked sick from bed matters.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
import sickdays
|
||||
|
||||
_AVAILABLE = True
|
||||
except ImportError:
|
||||
_AVAILABLE = False
|
||||
_logger.warning("sickdays is not installed; no day counts as sick")
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from datetime import date
|
||||
|
||||
|
||||
def is_sick(day: date) -> bool:
|
||||
"""Whether ``day`` is a sick day; local file only, never raises."""
|
||||
return _AVAILABLE and sickdays.is_sick_day(day)
|
||||
|
||||
|
||||
def sync() -> bool:
|
||||
"""Pull the pool from Firebase now; False when it could not (silently)."""
|
||||
return _AVAILABLE and sickdays.sync_quietly()
|
||||
|
||||
|
||||
def is_sick_synced(day: date) -> bool:
|
||||
""":func:`is_sick` after a fresh :func:`sync` -- for act modes only."""
|
||||
sync()
|
||||
return is_sick(day)
|
||||
@ -35,6 +35,7 @@ from wake_alarm._constants import (
|
||||
TRIGGER_TARGET_UNIT,
|
||||
)
|
||||
from wake_alarm._logging_setup import configure_logging
|
||||
from wake_alarm._sick import is_sick
|
||||
from wake_alarm._state import (
|
||||
save_trigger_fired,
|
||||
was_trigger_fired_today,
|
||||
@ -84,12 +85,13 @@ def _skip_reason(now: datetime) -> str | None:
|
||||
"""Return why the alarm must not fire right now, or None if it may.
|
||||
|
||||
Checks only the gates that are independent of the clock: the shared
|
||||
free-day pool, the day gate and the once-per-day latch.
|
||||
free-day and sick-day pools, the day gate and the once-per-day latch.
|
||||
|
||||
A free day means the alarm does not ring at all -- the orchestrator is
|
||||
never started, so neither the alarm nor the workout lock behind it
|
||||
appears. The pool is one local file read, never the network, so this
|
||||
stays a millisecond-scale tick.
|
||||
appears. A sick day is the same. Each pool is one local file read, never
|
||||
the network, so this stays a millisecond-scale tick; the fallback tick
|
||||
pulls the sick-day pool from ``A - 5 min``, so it is fresh by the fire.
|
||||
|
||||
Args:
|
||||
now: Current local time.
|
||||
@ -99,6 +101,8 @@ def _skip_reason(now: datetime) -> str | None:
|
||||
"""
|
||||
if freedays.is_free_day(now.date()):
|
||||
return "free day"
|
||||
if is_sick(now.date()):
|
||||
return "sick day"
|
||||
if now.weekday() not in ALARM_DAYS:
|
||||
return "not an alarm day"
|
||||
if was_trigger_fired_today():
|
||||
|
||||
@ -15,7 +15,11 @@ today == penalty_date means "no leniency today," the same shape as
|
||||
``morning_session.json``'s own ``exempt_until`` predicate.
|
||||
|
||||
An escape (``DOCS-wake-enforcement.md``) for a morning means no penalty is
|
||||
written for it, and one already issued that day is deleted.
|
||||
written for it, and one already issued that day is deleted. A sick day
|
||||
(``_sick``) is the same, for its own reason: nothing is written on a sick
|
||||
day, and a penalty *issued* on a day marked sick afterwards (a miss at 08:15,
|
||||
"sick" at 14:00) is deleted by the next ``--refresh`` or RTC tick. Other
|
||||
readers must not wait for that: see ``DOCS-wake-enforcement.md``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@ -30,6 +34,7 @@ from gatelock.log_integrity import compute_entry_hmac
|
||||
from wake_alarm._constants import WORKDAY_PENALTY_FILE
|
||||
from wake_alarm._events import emit
|
||||
from wake_alarm._session_policy import NONE_OUTCOME
|
||||
from wake_alarm._sick import is_sick, is_sick_synced
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
@ -95,6 +100,22 @@ def issued_date() -> str | None:
|
||||
return issued if isinstance(issued, str) else None
|
||||
|
||||
|
||||
def _cancel(issued: str, cause: str) -> bool:
|
||||
"""Delete the penalty issued on ``issued``; log ``penalty_cancelled``."""
|
||||
try:
|
||||
WORKDAY_PENALTY_FILE.unlink()
|
||||
except OSError as exc:
|
||||
_logger.warning("Could not cancel %s: %s", WORKDAY_PENALTY_FILE, exc)
|
||||
return False
|
||||
emit(
|
||||
"penalty_cancelled",
|
||||
f"{cause.replace('_', ' ')} for {issued}",
|
||||
issued_date=issued,
|
||||
cause=cause,
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def cancel_for_escapes(escaped_dates: frozenset[str]) -> bool:
|
||||
"""Delete the penalty when an escape excuses the morning it was issued.
|
||||
|
||||
@ -104,13 +125,47 @@ def cancel_for_escapes(escaped_dates: frozenset[str]) -> bool:
|
||||
issued = issued_date()
|
||||
if issued is None or issued not in escaped_dates:
|
||||
return False
|
||||
try:
|
||||
WORKDAY_PENALTY_FILE.unlink()
|
||||
except OSError as exc:
|
||||
_logger.warning("Could not cancel %s: %s", WORKDAY_PENALTY_FILE, exc)
|
||||
return _cancel(issued, "escape")
|
||||
|
||||
|
||||
def cancel_for_sick_day() -> bool:
|
||||
"""Delete the penalty when the morning it was issued is now a sick day.
|
||||
|
||||
Local pool only: callers on a timer keep it fresh. Fail closed -- an
|
||||
unreadable pool or date keeps the penalty.
|
||||
|
||||
Returns:
|
||||
True when a penalty was removed.
|
||||
"""
|
||||
issued = issued_date()
|
||||
if issued is None:
|
||||
return False
|
||||
emit("penalty_cancelled", f"escape for {issued}", issued_date=issued)
|
||||
return True
|
||||
try:
|
||||
day = date.fromisoformat(issued)
|
||||
except ValueError:
|
||||
return False
|
||||
return is_sick(day) and _cancel(issued, "sick_day")
|
||||
|
||||
|
||||
def skip_reason(
|
||||
entry: dict[str, object],
|
||||
now: datetime,
|
||||
*,
|
||||
escaped_dates: frozenset[str] = frozenset(),
|
||||
) -> str | None:
|
||||
"""Why today earns no penalty, or None when it does. Pure and local."""
|
||||
today = now.date()
|
||||
if today.isoformat() in escaped_dates:
|
||||
return "escaped"
|
||||
if freedays.is_free_day(today):
|
||||
return "free day"
|
||||
if not is_stick_day(today):
|
||||
return "not a stick day"
|
||||
if is_sick(today):
|
||||
return "sick day"
|
||||
if not is_workday_miss(entry, now):
|
||||
return "no miss"
|
||||
return None
|
||||
|
||||
|
||||
def maybe_apply(
|
||||
@ -126,19 +181,19 @@ def maybe_apply(
|
||||
would otherwise have rung the alarm -- the pool is fail-closed the other
|
||||
direction (a corrupt read is "not free"), which is exactly what is
|
||||
wanted here too: an unreadable pool means no penalty, never a spurious
|
||||
one. An escaped morning is never penalised either.
|
||||
one. An escaped morning or a sick day is never penalised either; the
|
||||
sick-day pool is pulled once more before the day's *first* write, so a
|
||||
day marked sick on the phone minutes ago still counts.
|
||||
|
||||
``penalty_written`` is logged once per issued day, not on every
|
||||
two-minute refresh that rewrites the same entry.
|
||||
"""
|
||||
if skip_reason(entry, now, escaped_dates=escaped_dates) is not None:
|
||||
return False
|
||||
today = now.date()
|
||||
if today.isoformat() in escaped_dates:
|
||||
return False
|
||||
if freedays.is_free_day(today) or not is_stick_day(today):
|
||||
return False
|
||||
if not is_workday_miss(entry, now):
|
||||
return False
|
||||
previously = issued_date()
|
||||
if previously != today.isoformat() and is_sick_synced(today):
|
||||
return False
|
||||
entry_body = penalty_entry(today=today)
|
||||
if not write_penalty(entry_body):
|
||||
return False
|
||||
|
||||
@ -19,6 +19,8 @@ an inhibitor:
|
||||
``~/.local/state/wake_alarm/events.jsonl``. Unredirected, every suite run
|
||||
appended fixture warnings ("Synced alarm out of range: 24:0") to the log a
|
||||
failed wake-up is reconstructed from.
|
||||
* The free-day and sick-day pools live under ``~/.local/share``; both are
|
||||
redirected, and the sick-day sync is stubbed offline.
|
||||
* Wake enforcement v2 writes more state (the fallback latch, the event
|
||||
state, incident reports) and reaches real hardware (``pw-play``,
|
||||
``sudo systemctl``, ``rtcwake``, ``journalctl``). Every path is redirected
|
||||
@ -34,6 +36,7 @@ from unittest.mock import patch
|
||||
|
||||
import freedays
|
||||
import pytest
|
||||
import sickdays
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Generator
|
||||
@ -100,6 +103,15 @@ def _hermetic(tmp_path: Path) -> Generator[None]:
|
||||
"freedays._api.resolve_paths",
|
||||
lambda paths: paths or freedays.Paths.under(tmp_path / "freedays"),
|
||||
),
|
||||
# Same for the sick-day pool. Its sync is stubbed to "offline": the
|
||||
# real ~/.config/crdt-sync credential exists on this machine, so an
|
||||
# unstubbed act-mode tick would talk to live Firebase and rewrite the
|
||||
# real pool.
|
||||
patch(
|
||||
"sickdays._api.resolve_paths",
|
||||
lambda paths: paths or sickdays.Paths.under(tmp_path / "sickdays"),
|
||||
),
|
||||
patch("sickdays.sync_quietly", lambda **_: False),
|
||||
]
|
||||
with ExitStack() as stack:
|
||||
for redirect in patches:
|
||||
|
||||
@ -45,7 +45,7 @@ def _alarm() -> Generator[None]:
|
||||
def test_outside_the_window_never_fetches() -> None:
|
||||
fetch = MagicMock()
|
||||
with patch.object(_fallback_observe, "fetch_firebase_logs", fetch):
|
||||
seen = observe(datetime(2026, 10, 1, 5, 0).astimezone())
|
||||
seen = observe(datetime(2026, 10, 1, 5, 0).astimezone(), sync=True)
|
||||
fetch.assert_not_called()
|
||||
assert seen.reachable
|
||||
assert seen.view is None
|
||||
@ -57,13 +57,13 @@ def test_a_free_day_never_fetches() -> None:
|
||||
patch.object(_fallback_observe, "fetch_firebase_logs", fetch),
|
||||
patch.object(_fallback_observe.freedays, "is_free_day", return_value=True),
|
||||
):
|
||||
assert observe(NOW).facts.free_day
|
||||
assert observe(NOW, sync=False).facts.free_day
|
||||
fetch.assert_not_called()
|
||||
|
||||
|
||||
def test_unreachable_firebase_is_no_ping_no_session() -> None:
|
||||
with patch.object(_fallback_observe, "fetch_firebase_logs", return_value=None):
|
||||
seen = observe(NOW)
|
||||
seen = observe(NOW, sync=False)
|
||||
assert not seen.reachable
|
||||
facts = seen.facts
|
||||
assert not (facts.escaped)
|
||||
@ -81,7 +81,7 @@ def test_facts_from_the_phone() -> None:
|
||||
with patch.object(
|
||||
_fallback_observe, "fetch_firebase_logs", return_value=[("p", raw)]
|
||||
):
|
||||
seen = observe(NOW)
|
||||
seen = observe(NOW, sync=False)
|
||||
assert seen.facts.session_started
|
||||
assert seen.facts.ring_started
|
||||
assert seen.facts.escaped
|
||||
@ -95,7 +95,7 @@ def test_yesterdays_session_does_not_count() -> None:
|
||||
"fetch_firebase_logs",
|
||||
return_value=[("p", _log(morning_sessions=[session]))],
|
||||
):
|
||||
assert not observe(NOW).facts.session_started
|
||||
assert not observe(NOW, sync=False).facts.session_started
|
||||
|
||||
|
||||
def test_settled_from_latch_or_event_state() -> None:
|
||||
@ -123,12 +123,12 @@ def test_record_logs_unreachable_once_and_resets(
|
||||
patch.object(_fallback_observe, "fetch_firebase_logs", return_value=None),
|
||||
caplog.at_level(logging.INFO),
|
||||
):
|
||||
seen = observe(NOW)
|
||||
seen = observe(NOW, sync=False)
|
||||
record(seen)
|
||||
record(seen)
|
||||
assert caplog.text.count("firebase_unreachable:") == 1
|
||||
with patch.object(_fallback_observe, "fetch_firebase_logs", return_value=[]):
|
||||
record(observe(NOW))
|
||||
record(observe(NOW, sync=False))
|
||||
assert last_value("firebase_unreachable") is None
|
||||
|
||||
|
||||
@ -137,5 +137,5 @@ def test_record_notes_escapes() -> None:
|
||||
with patch.object(
|
||||
_fallback_observe, "fetch_firebase_logs", return_value=[("p", raw)]
|
||||
):
|
||||
record(observe(NOW))
|
||||
record(observe(NOW, sync=False))
|
||||
assert last_value("escape_seen") == "e"
|
||||
|
||||
159
wake_alarm/tests/test_sick_days.py
Normal file
159
wake_alarm/tests/test_sick_days.py
Normal file
@ -0,0 +1,159 @@
|
||||
"""Sick days stand the PC down: no fallback ring, no RTC wake, no trigger.
|
||||
|
||||
The pool is the real ``sickdays`` package writing into ``tmp_path``
|
||||
(``conftest`` redirects it); only the network sync is stubbed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, timedelta
|
||||
import importlib
|
||||
import sys
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
import sickdays
|
||||
|
||||
from wake_alarm import _fallback, _fallback_observe, _rtc, _sick, _trigger
|
||||
from wake_alarm._fallback_latch import mark_started
|
||||
from wake_alarm._fallback_policy import SICK_DAY, Decision, Facts, decide, stop_reason
|
||||
from wake_alarm._sync import Alarm
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Generator
|
||||
|
||||
TUESDAY = date(2026, 10, 6)
|
||||
A = datetime(2026, 10, 6, 7, 0).astimezone()
|
||||
MONDAY_EVENING = datetime(2026, 10, 5, 22, 0).astimezone()
|
||||
|
||||
|
||||
def mark_sick(day: date) -> None:
|
||||
"""Put ``day`` in the (redirected) pool; may be in the past."""
|
||||
sickdays.import_legacy([day])
|
||||
|
||||
|
||||
def pull_marks_tuesday() -> bool:
|
||||
"""A sync that brings TUESDAY in from another device."""
|
||||
mark_sick(TUESDAY)
|
||||
return True
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sync() -> Generator[MagicMock]:
|
||||
"""The network pull, observed; reports success."""
|
||||
with patch("sickdays.sync_quietly", return_value=True) as pulled:
|
||||
yield pulled
|
||||
|
||||
|
||||
class TestSickModule:
|
||||
def test_reads_the_pool(self) -> None:
|
||||
assert not _sick.is_sick(TUESDAY)
|
||||
mark_sick(TUESDAY)
|
||||
assert _sick.is_sick(TUESDAY)
|
||||
|
||||
def test_synced_pulls_first(self, sync: MagicMock) -> None:
|
||||
sync.side_effect = pull_marks_tuesday
|
||||
assert _sick.is_sick_synced(TUESDAY)
|
||||
sync.assert_called_once_with()
|
||||
|
||||
def test_missing_package_is_never_sick(self) -> None:
|
||||
try:
|
||||
with patch.dict(sys.modules, {"sickdays": None}):
|
||||
reloaded = importlib.reload(_sick)
|
||||
assert reloaded._AVAILABLE is False
|
||||
assert not reloaded.is_sick(TUESDAY)
|
||||
assert not reloaded.sync()
|
||||
finally:
|
||||
importlib.reload(_sick)
|
||||
assert _sick._AVAILABLE is True
|
||||
|
||||
|
||||
class TestFallbackPolicy:
|
||||
def test_a_sick_day_never_rings(self) -> None:
|
||||
facts = Facts(now=A + timedelta(minutes=20), alarm_at=A, free_day=False)
|
||||
assert decide(facts).ring
|
||||
sick = Facts(
|
||||
now=A + timedelta(minutes=20), alarm_at=A, free_day=False, sick_day=True
|
||||
)
|
||||
assert decide(sick) == Decision(ring=False, reason=SICK_DAY)
|
||||
assert stop_reason(sick) == SICK_DAY
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def alarm_seven() -> Generator[None]:
|
||||
with (
|
||||
patch.object(_fallback_observe, "get_alarm", return_value=Alarm(7, 0, "c")),
|
||||
patch.object(_fallback, "get_alarm", return_value=Alarm(7, 0, "c")),
|
||||
):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("alarm_seven")
|
||||
class TestFallback:
|
||||
def test_in_window_act_pulls_and_stays_offline(self, sync: MagicMock) -> None:
|
||||
sync.side_effect = pull_marks_tuesday
|
||||
fetch = MagicMock()
|
||||
with patch.object(_fallback_observe, "fetch_firebase_logs", fetch):
|
||||
seen = _fallback_observe.observe(A + timedelta(minutes=3), sync=True)
|
||||
assert seen.facts.sick_day
|
||||
sync.assert_called_once_with()
|
||||
fetch.assert_not_called()
|
||||
|
||||
def test_dry_run_never_pulls(self, sync: MagicMock) -> None:
|
||||
mark_sick(TUESDAY)
|
||||
seen = _fallback_observe.observe(A + timedelta(minutes=3), sync=False)
|
||||
assert seen.facts.sick_day
|
||||
sync.assert_not_called()
|
||||
|
||||
def test_off_window_tick_never_pulls(self, sync: MagicMock) -> None:
|
||||
_fallback_observe.observe(A - timedelta(hours=2), sync=True)
|
||||
sync.assert_not_called()
|
||||
|
||||
def test_a_ring_in_progress_stops(self) -> None:
|
||||
mark_sick(TUESDAY)
|
||||
mark_started(None, day=TUESDAY.isoformat(), now=A, reason="phone_silent")
|
||||
with patch.object(_fallback, "ring_now") as ring_now:
|
||||
decision = _fallback.tick(A + timedelta(minutes=5))
|
||||
assert decision == Decision(ring=False, reason=SICK_DAY)
|
||||
ring_now.assert_not_called()
|
||||
latch = _fallback.read_latch(TUESDAY.isoformat())
|
||||
assert latch is not None
|
||||
assert latch.stop_reason == SICK_DAY
|
||||
|
||||
def test_reports_say_sick(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
mark_sick(TUESDAY)
|
||||
_fallback.main(["--dry-run", "--at", "2026-10-06T07:05:00"])
|
||||
assert "quiet: sick_day" in capsys.readouterr().out
|
||||
with patch.object(_fallback, "_now", return_value=A):
|
||||
_fallback.main(["--show"])
|
||||
assert "sick_day=True" in capsys.readouterr().out
|
||||
|
||||
|
||||
class TestRtc:
|
||||
def test_a_sick_stick_day_is_skipped(self) -> None:
|
||||
assert _rtc.plan(MONDAY_EVENING, Alarm(7, 0, "c")).arm
|
||||
mark_sick(TUESDAY)
|
||||
chosen = _rtc.plan(MONDAY_EVENING, Alarm(7, 0, "c"))
|
||||
assert (chosen.arm, chosen.reason) == (False, "sick day")
|
||||
|
||||
def test_arm_pulls_dry_run_does_not(self, sync: MagicMock) -> None:
|
||||
sync.side_effect = pull_marks_tuesday
|
||||
with (
|
||||
patch.object(_rtc, "get_alarm", return_value=Alarm(7, 0, "c")),
|
||||
patch.object(_rtc, "_now", return_value=MONDAY_EVENING),
|
||||
patch.object(_rtc, "configure_logging"),
|
||||
patch.object(_rtc, "cancel_for_sick_day") as retract,
|
||||
):
|
||||
assert _rtc.main(["--dry-run"]) == 0
|
||||
sync.assert_not_called()
|
||||
retract.assert_not_called()
|
||||
assert _rtc.main(["--arm"]) == 0 # a skip is not a failure
|
||||
sync.assert_called_once_with()
|
||||
retract.assert_called_once_with()
|
||||
|
||||
|
||||
def test_the_trigger_skips_a_sick_day() -> None:
|
||||
assert _trigger._skip_reason(A) is None
|
||||
mark_sick(TUESDAY)
|
||||
assert _trigger._skip_reason(A) == "sick day"
|
||||
175
wake_alarm/tests/test_sick_days_penalty.py
Normal file
175
wake_alarm/tests/test_sick_days_penalty.py
Normal file
@ -0,0 +1,175 @@
|
||||
"""Sick days and the workday penalty, the session refresh and the audit."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
from datetime import date, datetime
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
import sickdays
|
||||
|
||||
from wake_alarm import _audit, _session, _workday_penalty
|
||||
from wake_alarm._audit_checks import (
|
||||
check_lockdown,
|
||||
check_penalty,
|
||||
check_ring,
|
||||
check_rtc,
|
||||
check_up_by_alarm,
|
||||
)
|
||||
from wake_alarm._audit_evidence import Evidence, PcSide, PhoneSide
|
||||
from wake_alarm._enforcement_records import PhoneEvent
|
||||
from wake_alarm._sync import Alarm
|
||||
from wake_alarm._workday_penalty import (
|
||||
cancel_for_sick_day,
|
||||
issued_date,
|
||||
maybe_apply,
|
||||
penalty_entry,
|
||||
skip_reason,
|
||||
write_penalty,
|
||||
)
|
||||
|
||||
TUESDAY = date(2026, 9, 22)
|
||||
AFTER_GRACE = datetime(2026, 9, 22, 9, 0).astimezone()
|
||||
MISS: dict[str, object] = {
|
||||
"outcome": "none",
|
||||
"exempt_until": datetime(2026, 9, 22, 8, 15).astimezone().isoformat(),
|
||||
}
|
||||
|
||||
|
||||
def mark_sick(day: date) -> None:
|
||||
"""Put ``day`` in the (redirected) pool; may be in the past."""
|
||||
sickdays.import_legacy([day])
|
||||
|
||||
|
||||
def pull_marks_tuesday() -> bool:
|
||||
"""A sync that brings TUESDAY in from another device."""
|
||||
mark_sick(TUESDAY)
|
||||
return True
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def issued() -> None:
|
||||
"""A penalty issued on TUESDAY, on disk."""
|
||||
assert write_penalty(penalty_entry(today=TUESDAY))
|
||||
|
||||
|
||||
class TestWriting:
|
||||
def test_a_sick_day_is_never_penalised(self) -> None:
|
||||
assert skip_reason(MISS, AFTER_GRACE) is None
|
||||
mark_sick(TUESDAY)
|
||||
assert skip_reason(MISS, AFTER_GRACE) == "sick day"
|
||||
assert not maybe_apply(MISS, AFTER_GRACE)
|
||||
assert issued_date() is None
|
||||
|
||||
def test_the_first_write_pulls_the_pool(self) -> None:
|
||||
with patch("sickdays.sync_quietly", side_effect=pull_marks_tuesday) as pulled:
|
||||
assert not maybe_apply(MISS, AFTER_GRACE)
|
||||
pulled.assert_called_once_with()
|
||||
assert issued_date() is None
|
||||
|
||||
def test_a_rewrite_does_not_pull_again(self) -> None:
|
||||
assert maybe_apply(MISS, AFTER_GRACE)
|
||||
with patch("sickdays.sync_quietly") as pulled:
|
||||
assert maybe_apply(MISS, AFTER_GRACE)
|
||||
pulled.assert_not_called()
|
||||
|
||||
def test_other_skip_reasons(self) -> None:
|
||||
assert skip_reason(MISS, AFTER_GRACE, escaped_dates=frozenset({"2026-09-22"}))
|
||||
monday = datetime(2026, 9, 21, 9, 0).astimezone()
|
||||
assert skip_reason(MISS, monday) == "not a stick day"
|
||||
assert skip_reason({"outcome": "completed"}, AFTER_GRACE) == "no miss"
|
||||
with patch.object(_workday_penalty.freedays, "is_free_day", return_value=True):
|
||||
assert skip_reason(MISS, AFTER_GRACE) == "free day"
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("issued")
|
||||
class TestRetraction:
|
||||
def test_kept_while_not_sick(self) -> None:
|
||||
assert not cancel_for_sick_day()
|
||||
assert issued_date() == TUESDAY.isoformat()
|
||||
|
||||
def test_retracted_once_the_issuing_day_is_sick(
|
||||
self, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
mark_sick(TUESDAY)
|
||||
with caplog.at_level(logging.INFO):
|
||||
assert cancel_for_sick_day()
|
||||
assert issued_date() is None
|
||||
assert "penalty_cancelled: sick day for 2026-09-22" in caplog.text
|
||||
|
||||
def test_a_sick_penalty_date_alone_does_not_retract(self) -> None:
|
||||
mark_sick(date(2026, 9, 23))
|
||||
assert not cancel_for_sick_day()
|
||||
|
||||
def test_unlink_failure_keeps_it(self) -> None:
|
||||
mark_sick(TUESDAY)
|
||||
with patch.object(Path, "unlink", side_effect=OSError("ro")):
|
||||
assert not cancel_for_sick_day()
|
||||
|
||||
def test_garbage_issued_date_is_kept(self) -> None:
|
||||
_workday_penalty.WORKDAY_PENALTY_FILE.write_text(
|
||||
json.dumps({"issued_date": "soon"})
|
||||
)
|
||||
assert not cancel_for_sick_day()
|
||||
|
||||
def test_refresh_retracts_even_offline(self) -> None:
|
||||
mark_sick(TUESDAY)
|
||||
with patch.object(_session, "fetch_firebase_logs", return_value=None):
|
||||
assert _session.refresh(AFTER_GRACE) is None
|
||||
assert issued_date() is None
|
||||
|
||||
|
||||
def test_no_penalty_file_nothing_to_retract() -> None:
|
||||
assert not cancel_for_sick_day()
|
||||
|
||||
|
||||
def _ev(**overrides: object) -> Evidence:
|
||||
pc = PcSide(penalty_issued=overrides.pop("penalty_issued", None))
|
||||
phone = PhoneSide(events=overrides.pop("phone", []))
|
||||
base = Evidence(
|
||||
day=TUESDAY,
|
||||
stick_day=True,
|
||||
free_day=False,
|
||||
alarm_at=AFTER_GRACE,
|
||||
alarm_source="t",
|
||||
pc_side=pc,
|
||||
phone_side=phone,
|
||||
sick_day=True,
|
||||
)
|
||||
return replace(base, **overrides)
|
||||
|
||||
|
||||
class TestAudit:
|
||||
def test_nothing_is_expected(self) -> None:
|
||||
for check in (check_rtc, check_up_by_alarm, check_ring, check_lockdown):
|
||||
result = check(_ev())
|
||||
assert (result.ok, result.detail) == (None, "sick day")
|
||||
|
||||
def test_penalty(self) -> None:
|
||||
assert check_penalty(_ev()).ok is True
|
||||
assert check_penalty(_ev(penalty_issued="2026-09-22")).ok is False
|
||||
|
||||
def test_a_lockdown_for_a_sick_miss_is_flagged(self) -> None:
|
||||
at = AFTER_GRACE.isoformat()
|
||||
phone = [
|
||||
PhoneEvent(at, "stick_check", {"date": "2026-09-22", "verdict": "miss"}),
|
||||
PhoneEvent(at, "lockdown_sent", {"until": "2026-09-23"}),
|
||||
]
|
||||
assert check_lockdown(_ev(phone=phone)).ok is False
|
||||
assert check_lockdown(_ev(phone=phone[:1])).ok is None
|
||||
|
||||
def test_the_report_names_the_day(self) -> None:
|
||||
mark_sick(TUESDAY)
|
||||
with (
|
||||
patch.object(_audit, "fetch_firebase_logs", return_value=[]),
|
||||
patch.object(_audit, "get_alarm", return_value=Alarm(7, 0, "cache")),
|
||||
patch.object(_audit, "boots", return_value=[]),
|
||||
patch.object(_audit, "resumes", return_value=[]),
|
||||
):
|
||||
evidence, _ = _audit.gather(TUESDAY)
|
||||
assert evidence.sick_day
|
||||
assert "(Tue, sick day)" in _audit.render(evidence, [], reachable=True)
|
||||
Loading…
Reference in New Issue
Block a user