mirror of
https://github.com/kuhyx/wake-alarm.git
synced 2026-11-11 12:47:31 +01:00
The PC read one hardcoded path, wake-alarm-sync/devices/phone/alarm.json, and never parsed the HLC. The phone's device id is migrating from the fixed constant "phone" to a per-install uuid (a role constant collides as soon as a second install takes the same role, and a reinstall inherits the previous install's CRDT identity). A reader pinned to the old path would have stopped seeing the alarm the moment the phone migrated, fallen through to the cache and then to FALLBACK_ALARM_HOUR -- i.e. woken at the wrong time, silently. So the PC half ships first, and this commit is that half plus the phone change it makes safe: - _fetch_firebase_alarm() lists SYNC_DEVICES_PREFIX, reads every device's alarm.json, and picks the newest by the hour field's HLC rather than by listing order. device_id breaks an exact tie so two devices stamped in the same millisecond don't alternate between ticks; a device whose payload doesn't parse is skipped rather than allowed to win with a bad value. - SYNC_ALARM_PATH stays as a last-resort read for a backend that lists nothing, so an un-migrated install keeps working. The GitHub mirror still reads the single fixed path. - The phone persists a uuid under SharedPreferences 'crdt.nodeId' (the key todo and home_inventory already use) and pushes under it. deviceId is now an instance field, injectable so tests don't touch real prefs. Parsing and ranking moved to _alarm_payload.py: _sync.py was over the repo's 500-line cap, and the wire format deserves testing without network or disk -- a misparse here doesn't raise, it just resolves to the wrong wake-up time. Verified against the live RTDB, not just mocks: seeded a uuid-named device directory and confirmed the reader discovered it, read it and ranked it -- losing to the phone with an old stamp, winning with a new one (03:15 vs the real 07:45). Test device deleted; --show returns 07:45 and the cache is intact. 354 tests, 100% branch coverage. Phone app: 10 tests, analyzer clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GZH89WEqAjuJT8WZdqc7AP
174 lines
5.7 KiB
Python
174 lines
5.7 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
|
|
|
|
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 newest_alarm(published: list[tuple[str, str]]) -> tuple[int, int] | 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) pair, or None if no device published a
|
|
usable one.
|
|
"""
|
|
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
|