wake-alarm/wake_alarm/_correlation.py
Krzysztof kuhy Rudnicki e8f50828e6 Phase 5: the power button no longer ends the alarm (item 2)
Two defences, because one press and one held press are different problems.

**Prevention — _inhibit.py.** A logind block-mode inhibitor on
handle-power-key:shutdown:idle, taken while the alarm is audible and released
in on_close(). Confirmed on the host before writing it that
PowerKeyIgnoreInhibited=no is in force, so no logind.conf drop-in is needed --
and none is added: a drop-in changes the machine permanently, an inhibitor is
scoped to the alarm.

Verified live: the inhibitor appears in `systemd-inhibit --list` as
`wake-alarm ... shutdown:idle:handle-power-key ... block` and disappears on
release.

The child runs `sleep 7200`, never `sleep infinity`. An orphaned block-mode
inhibitor would hold the power button down until reboot -- worse than the bug
being fixed -- so it self-terminates, and a stale one is cleared at startup.

That cleanup was wrong on the first attempt and the host run caught it:
`pkill -f "--who=wake-alarm"` matched the *shell that had just typed the
pattern* and killed it, leaving the real inhibitor alive (`killed 0`, one
inhibitor still listed). Substring matching is inherently self-matching. Now
it lists `pgrep -x systemd-inhibit` and confirms each pid via
/proc/<pid>/cmdline. Re-verified: orphan reaped, exactly one live inhibitor,
zero remaining after release.

**Consequence — _alarm_session.py.** The real gap was that nothing on disk
ever said "ringing, not yet dismissed": wake_state.json is written only on
dismissal, so a hard power-off left no trace and the catch-up window closed
while the machine was down. A signed record is now written at fire time, and
_trigger re-fires on one still marked ringing, ignoring the window.

Deliberately no date field. _state.py keys by UTC and
screen_locker/_wake_state.py mirrors that helper to read the very same file
from another repo, so the UTC basis there is load-bearing. The plan's
_today_str() flip is therefore NOT done -- it would have made any dismissal
between 00:00-02:00 local read as yesterday's in screen-locker and silently
lose an earned workout skip. This record carries only an aware fired_at and
compares elapsed time, so the midnight question never arises.

Age-bounded at 5400 s: an alarm that dies before dismissal leaves a record
nothing ever clears, and unbounded this would restart the alarm every minute
forever.

Two bugs the unit tests could not see, both found by running it:

- A future-stamped record returned None, i.e. "not ringing". But a backward
  clock jump is routine on a machine that hibernates nightly (NTP correcting
  after resume), so that failed *open* on exactly the escape it closes: ring
  on a fast clock, clock corrects, power-off works again. Now clamped to zero
  within a 900 s tolerance and logged.
- _alarm.py derived the correlation id from the alarm's *start* instant. On a
  catch-up or re-fire the PC would write wake-...-0735 while the phone wrote
  wake-...-0700 and the two log streams stop joining. Now
  correlation_id_for_today() derives it from the resolved alarm time, which is
  what _correlation.py always specified.

Every trigger test patched should_refire, so _decide against a real record was
untested -- which is why only the manual run found the first one. There is now
an end-to-end test that writes a real session and asserts the decision.

Splits carried: test_trigger.py 294 -> 197 + test_trigger_main.py,
test_alarm_part2.py 266 -> 170 + test_alarm_timers_flash.py. The inhibitor
handle moved into _AlarmHardware, which also settles pylint's attribute count
and puts it beside the other thing on_close() gives back.

No new suppressions: the one inline pylint disable was redundant, since
consider-using-with is already disabled repo-wide in pyproject.toml.

452 tests pass, 100% branch coverage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EckWmyfetYq3Mi8MAnUudh
2026-08-27 20:26:37 +02:00

68 lines
2.4 KiB
Python

"""Derive the identifier that joins one morning's PC and phone log lines.
The id is **derived, never generated**: both halves compute it independently
from the resolved alarm instant, so no handshake, no uuid exchange and no
network round-trip is involved. That matters because the two devices must
still agree when sync is down -- which is exactly when the logs are worth
reading.
Local time, matching ``_trigger``'s local-time basis (``_state.py`` keys by
UTC, which is a separate known mismatch tracked in the hardening plan).
"""
from __future__ import annotations
from datetime import datetime
from wake_alarm._sync import get_alarm
# ``wake-YYYYMMDD-HHMM``. Stable across processes and across devices.
_CORRELATION_FORMAT = "wake-%Y%m%d-%H%M"
def correlation_id(fire_time: datetime) -> str:
"""Build the correlation id for the alarm firing at *fire_time*.
Args:
fire_time: The resolved local fire instant for today's alarm.
Returns:
A string of the form ``wake-20260826-0700``.
"""
return fire_time.strftime(_CORRELATION_FORMAT)
def correlation_id_for(hour: int, minute: int, *, now: datetime) -> str:
"""Build the correlation id from an ``(hour, minute)`` alarm on *now*'s date.
Convenience for callers that hold the synced alarm rather than a resolved
datetime. The date comes from *now*, so a caller must pass the date the
alarm belongs to, not merely the current instant.
Args:
hour: Alarm hour, 0-23.
minute: Alarm minute, 0-59.
now: Any datetime on the alarm's local date.
Returns:
The correlation id string.
"""
return correlation_id(now.replace(hour=hour, minute=minute))
def correlation_id_for_today() -> str:
"""Derive this morning's correlation id from the *resolved alarm time*.
Not from "now": the alarm can start up to a full catch-up window late, and
a re-fire after a power-off can start hours later still. The phone derives
its id from the alarm time it was set to, with no handshake between the
two, so deriving the PC's from the start instant would silently stop the
two log streams joining -- ``wake-20260827-0735`` against
``wake-20260827-0700``.
Returns:
The correlation id both halves compute independently.
"""
alarm = get_alarm()
return correlation_id_for(alarm.hour, alarm.minute, now=datetime.now().astimezone())