wake-alarm/wake_alarm/_alarm_payload.py
Krzysztof kuhy Rudnicki 6be5420fcf Stop trusting an alarm the phone published months ago
Belt-and-braces for the publishing race fixed in the previous commit. That bug
is gone, but the shape of the failure is worth closing on this side too: a
publish that never happens is indistinguishable from one that has not changed,
so the last successful publish stayed authoritative forever. One record stamped
2026-08-09 had the PC starting the morning routine at 07:00 for three weeks.

get_alarm now skips any record the phone published longer ago than
PUBLISHED_ALARM_MAX_AGE_SECONDS (14 days) and falls back. That is safe
precisely because FALLBACK_ALARM_HOUR is the phone's own baseline, so a lapsed
record degrades to the right answer rather than to a guess -- and an override
is one-shot anyway, so reverting to the baseline is what the phone would say.

The check is on when the PHONE stamped it, never on when we fetched, and that
distinction is the whole design. Checking the fetch would be trivially
defeated: the cache is rewritten from the same stale record every few minutes,
so `fetched_at` is always fresh no matter how old the record behind it is.
So the winning HLC's instant is now carried out of _alarm_payload, through
_sync_transport, and persisted in the cache as `published_at`. Nothing else
reads that cache any more -- the shutdown wrapper that used to went with the
PC's waking role -- so widening its shape is free.

An undated record is trusted rather than expired. The GitHub mirror reads a
single fixed path with no per-field HLC, so treating "cannot tell how old this
is" as "too old" would break that whole backend, and refusing an alarm we
merely failed to date is a worse failure than honouring it.

test_sync.py and test_sync_backends.py crossed the 250-line cap, so the new
lapse cases and the backend-ordering cases move to test_sync_lapsed.py and
test_sync_dispatch.py.

168 tests at 100% branch coverage. Verified against the real backend: the
cache now carries published_at, and `_sync --show` still resolves 08:00 from
the record the phone stamped minutes earlier.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PcdngP688mW6rJye3LRkSN
2026-08-30 16:37:13 +02:00

203 lines
6.8 KiB
Python

"""Parse the alarm out of the ``crdt_sync`` log the phone publishes.
Split out of ``_sync.py`` so that module keeps to fetching, caching and
fallback: everything here is pure, takes text or already-parsed JSON, and
touches neither the network nor the disk. That makes the wire format testable
on its own, which matters because a misparse here does not raise — it
silently resolves to the wrong wake-up time.
The published shape is one ``crdt_sync`` record::
{"alarm": {"fields": {"alarm_hour": [7, "<hlc>"], "alarm_minute": [30, "<hlc>"]}}}
Every field is a ``(value, HLC)`` pair. Ranking across devices uses the HLC
rather than the value, so the alarm that wins is the one *set* most recently.
"""
from __future__ import annotations
from datetime import UTC, datetime
import json
import logging
from typing import Any
from wake_alarm._constants import MAX_HOUR, MAX_MINUTE, MIN_HOUR, MIN_MINUTE
_logger = logging.getLogger(__name__)
# Keys inside the crdt_sync log the phone writes.
RECORD_ID = "alarm"
HOUR_FIELD = "alarm_hour"
MINUTE_FIELD = "alarm_minute"
# A crdt_sync field is a (value, HLC) pair.
_HLC_PAIR_LENGTH = 2
def in_range(hour: int, minute: int) -> bool:
"""Check that an (hour, minute) pair is a real wall-clock time.
Guards both readers: a malformed sync payload and a corrupted cache must
never become a fire time.
Args:
hour: Candidate hour.
minute: Candidate minute.
Returns:
True if both values are within a 24-hour clock.
"""
return MIN_HOUR <= hour <= MAX_HOUR and MIN_MINUTE <= minute <= MAX_MINUTE
def _field_pair(log: dict[str, Any], field: str) -> list[Any] | None:
"""Return the raw ``(value, HLC)`` pair for [field], if well-shaped.
Args:
log: The parsed log JSON.
field: Field name to look up.
Returns:
The pair as a list, or None if any level of the shape is unexpected.
"""
record = log.get(RECORD_ID)
if not isinstance(record, dict):
return None
fields = record.get("fields")
if not isinstance(fields, dict):
return None
pair = fields.get(field)
return pair if isinstance(pair, list) and pair else None
def extract_field(log: dict[str, Any], field: str) -> int | None:
"""Pull one integer field out of the phone's crdt_sync log.
Args:
log: The parsed log JSON.
field: Field name to extract (e.g. ``alarm_hour``).
Returns:
The integer value, or None if anything about the shape is unexpected.
"""
pair = _field_pair(log, field)
if pair is None:
return None
value = pair[0]
# bool is an int subclass; an alarm hour of True would be nonsense.
if isinstance(value, bool) or not isinstance(value, int):
return None
return value
def extract_hlc(log: dict[str, Any], field: str) -> str:
"""Pull one field's HLC stamp out of the phone's crdt_sync log.
HLC strings are ``<iso>-<counter>-<deviceId>`` and sort lexicographically
in timestamp order, which is what lets [newest_alarm] rank two devices'
publications against each other.
Args:
log: The parsed log JSON.
field: Field name whose stamp to read.
Returns:
The HLC string, or ``""`` if absent or the wrong shape -- which sorts
below every real stamp, so a device publishing a malformed pair never
outranks one publishing a good pair.
"""
pair = _field_pair(log, field)
if pair is None or len(pair) < _HLC_PAIR_LENGTH:
return ""
hlc = pair[1]
return hlc if isinstance(hlc, str) else ""
def parse_log(raw: str) -> tuple[int, int] | None:
"""Parse one device's published log into an (hour, minute) pair.
Args:
raw: The raw JSON text returned by the backend.
Returns:
The (hour, minute) pair, or None if the payload is malformed or the
values are out of range.
"""
try:
log = json.loads(raw)
except json.JSONDecodeError:
_logger.warning("Synced alarm is not valid JSON")
return None
if not isinstance(log, dict):
return None
hour = extract_field(log, HOUR_FIELD)
minute = extract_field(log, MINUTE_FIELD)
if hour is None or minute is None:
_logger.warning("Synced alarm is missing hour/minute fields")
return None
if not in_range(hour, minute):
_logger.warning("Synced alarm out of range: %d:%d", hour, minute)
return None
return hour, minute
def hlc_instant(hlc: str) -> float | None:
"""Wall-clock instant an HLC stamp was minted, as epoch seconds.
HLC strings are ``<iso>-<counter>-<deviceId>``, and the ISO part ends at
its ``Z`` -- which is what makes this splittable at all, since the ISO
date itself contains the same ``-`` the fields are joined with.
Args:
hlc: The stamp, or ``""`` when the field carried none.
Returns:
Epoch seconds, or None when the stamp is absent or unparsable. None
means "cannot tell how old this is", and callers treat that as
trustworthy rather than expired -- refusing to act on an alarm we
merely failed to date would be a worse failure than acting on it.
"""
iso, separator, _ = hlc.partition("Z")
if not separator:
return None
try:
return datetime.fromisoformat(iso).replace(tzinfo=UTC).timestamp()
except ValueError:
return None
def newest_alarm(
published: list[tuple[str, str]],
) -> tuple[int, int, float | None] | None:
"""Pick the most recently published alarm out of every device's log.
Ranked by the hour field's HLC stamp, which orders by wall time and then
by counter -- so the winner is the device that set the alarm last, not
whichever directory the backend happened to list first. A device whose
payload does not parse is skipped rather than being allowed to win with a
malformed value.
Args:
published: ``(device_id, raw_json)`` for every device that has
published, in any order.
Returns:
The newest ``(hour, minute, published_at)``, where ``published_at`` is
epoch seconds from the winning HLC (or None if it carried no readable
stamp). None if no device published a usable alarm.
"""
ranked: list[tuple[str, str, tuple[int, int]]] = []
for device_id, raw in published:
parsed = parse_log(raw)
if parsed is None:
_logger.info("Ignoring unusable alarm published by %r", device_id)
continue
ranked.append((extract_hlc(json.loads(raw), HOUR_FIELD), device_id, parsed))
if not ranked:
return None
# device_id breaks an exact HLC tie deterministically, so two devices
# stamped in the same millisecond don't alternate between ticks.
hlc, winner, alarm = max(ranked, key=lambda entry: (entry[0], entry[1]))
_logger.info("Using the alarm published by %r (hlc %s)", winner, hlc or "none")
return (*alarm, hlc_instant(hlc))