wake-alarm/wake_alarm/_sync.py
Krzysztof kuhy Rudnicki 03392e7ea5 Discover alarm device directories instead of reading a fixed phone path
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
2026-08-09 17:06:43 +02:00

372 lines
13 KiB
Python

"""Read the alarm time the phone pushed, with an on-disk cache and fallbacks.
The phone app (``phone_app/``) is the sole writer: it stores the chosen alarm
in a ``crdt_sync`` ``LogStore`` and pushes that log to a fixed path in the
private ``kuhyx/syncs`` repo. This module is the PC-side reader used by the
per-minute trigger (see ``_trigger.py``).
``shutdown-wrapper.sh`` no longer reads the remote file at all — it reads the
local cache this module writes, so there is only one remote reader to keep in
step (see ``CLAUDE.md``).
**Backends.** Firebase is preferred and GitHub is the cutover fallback, the
same order every other app in the fleet uses. Firebase needs ``crdt_sync``
(hence ``requests``), which is a real dependency declared in
``pyproject.toml``; the GitHub path stays stdlib-only (``urllib.request`` +
``json``) so that if the import is somehow missing on the production
interpreter, the alarm still resolves rather than crashing the trigger. That
is why the import below is guarded rather than top-level: this module runs
under ``/usr/bin/python``, not a venv, and a wake-up is not worth an
ImportError.
"""
from __future__ import annotations
import argparse
from dataclasses import dataclass
import json
import logging
import sys
import time
import urllib.error
import urllib.request
from wake_alarm._alarm_payload import in_range, newest_alarm, parse_log
from wake_alarm._constants import (
ALARM_CACHE_FILE,
ALARM_CACHE_MAX_AGE_SECONDS,
FALLBACK_ALARM_HOUR,
SYNC_ALARM_FILENAME,
SYNC_ALARM_PATH,
SYNC_API_BASE,
SYNC_APP_NAME,
SYNC_CONFIG_FILE,
SYNC_DEVICES_PREFIX,
SYNC_HTTP_TIMEOUT_SECONDS,
SYNC_REPO_NAME,
SYNC_REPO_OWNER,
SYNC_TOKEN_FALLBACK_FILES,
)
from wake_alarm._logging_setup import configure_logging
_logger = logging.getLogger(__name__)
# Optional at runtime, exactly like python-kasa in _smart_plug.py: the trigger
# runs every minute under /usr/bin/python, so a dependency that failed to
# install must degrade this read to the GitHub path, not raise.
try:
from crdt_sync import ConfigError, firebase_client_for
from crdt_sync._config import CONFIG_FILE as FIREBASE_CONFIG_FILE
from crdt_sync._firebase_auth import FirebaseAuthError
from crdt_sync._remote import RemoteSyncError
_FIREBASE_AVAILABLE = True
except ImportError:
_FIREBASE_AVAILABLE = False
_logger.info("crdt_sync is not installed; reading the alarm from GitHub")
@dataclass(frozen=True)
class Alarm:
"""A wall-clock alarm time, in the phone's (and the PC's) local timezone.
Attributes:
hour: Hour of day, 0-23.
minute: Minute of hour, 0-59.
source: Where the value came from ("remote", "cache", "stale-cache"
or "fallback"). Purely informational — used for logging and the
``--show`` output so a wrong alarm is diagnosable at a glance.
"""
hour: int
minute: int
source: str
def __str__(self) -> str:
"""Render as ``HH:MM (source)``."""
return f"{self.hour:02d}:{self.minute:02d} ({self.source})"
def _read_token_config() -> str | None:
"""Read the token from this package's own JSON config.
Returns:
The token, or None if the file is missing, unreadable, malformed, or
holds no usable token.
"""
try:
with SYNC_CONFIG_FILE.open() as handle:
config = json.load(handle)
except (OSError, json.JSONDecodeError):
return None
if not isinstance(config, dict):
return None
token = config.get("token")
if not isinstance(token, str) or not token:
return None
return token
def _read_token_fallback() -> str | None:
"""Read the token from a sibling app's raw single-line token file.
Returns:
The first usable token found, or None if none of the fallback files
exists or holds one.
"""
for path in SYNC_TOKEN_FALLBACK_FILES:
try:
token = path.read_text().strip()
except OSError:
continue
if token:
_logger.debug("Using sync token from %s", path)
return token
return None
def _read_token() -> str | None:
"""Read the GitHub token, preferring this package's own config.
Falls back to the sibling companion apps' token files so one repo-scoped
PAT covers every app syncing through ``kuhyx/syncs``.
Returns:
The token, or None if no configured source holds one.
"""
# No warning on a miss here: the caller reports "no token" once. Logging
# twice for one unconfigured file makes every tick noisy in the journal.
return _read_token_config() or _read_token_fallback()
def _fetch_firebase_alarm() -> tuple[int, int] | None:
"""Fetch the newest alarm any device has pushed to Firebase.
Discovers device directories rather than reading one fixed path: the
phone's device id is migrating from the constant "phone" to a per-install
uuid, and a reader pinned to the old path would stop seeing the alarm the
moment the phone migrated — falling back to FALLBACK_ALARM_HOUR, i.e.
waking the user at the wrong time. Listing covers both ids, and
SYNC_ALARM_PATH remains as a last resort for a backend that cannot list.
Returns None — never raises — for every failure mode, including
``crdt_sync`` not being importable on this interpreter, so the caller can
fall back to GitHub and then to the cache.
Returns:
The (hour, minute) pair, or None if unconfigured or unreadable.
"""
if not _FIREBASE_AVAILABLE:
return None
# Checked before constructing anything, so an unconfigured machine never
# touches the network (and a socket-blocking test suite stays quiet).
if not FIREBASE_CONFIG_FILE.is_file():
_logger.info("Firebase not configured at %s", FIREBASE_CONFIG_FILE)
return None
try:
client = firebase_client_for(SYNC_APP_NAME)
published: list[tuple[str, str]] = []
for device_id in client.list_directory(SYNC_DEVICES_PREFIX):
raw = client.get_file_text(
f"{SYNC_DEVICES_PREFIX}/{device_id}/{SYNC_ALARM_FILENAME}"
)
if raw is not None:
published.append((device_id, raw))
if not published:
# No directories listed (or none held a file): still try the
# legacy fixed path, so a phone that has not migrated is read.
raw = client.get_file_text(SYNC_ALARM_PATH)
if raw is None:
_logger.info("No alarm published to Firebase yet")
return None
published.append(("phone", raw))
except (ConfigError, FirebaseAuthError, RemoteSyncError, OSError) as exc:
_logger.info("Cannot fetch the alarm from Firebase: %s", exc)
return None
return newest_alarm(published)
def _fetch_github_alarm() -> tuple[int, int] | None:
"""Fetch the alarm the phone last pushed to GitHub.
Uses the raw media type so the Contents API returns the file body directly
rather than base64-wrapped JSON.
Returns:
The (hour, minute) pair, or None if unconfigured, unreachable, or
malformed. Never raises — a failed read must degrade to the cache,
not crash the trigger.
"""
token = _read_token()
if token is None:
# INFO, not WARNING: get_alarm() runs on every one-minute trigger tick,
# so a persistent condition logged loudly here is 1440 identical
# journal lines a day. The trigger warns once, at the moment it
# actually fires on a degraded source — that is the actionable event.
_logger.info("No sync token configured at %s", SYNC_CONFIG_FILE)
return None
url = (
f"{SYNC_API_BASE}/repos/{SYNC_REPO_OWNER}/{SYNC_REPO_NAME}"
f"/contents/{SYNC_ALARM_PATH}"
)
request = urllib.request.Request(
url,
headers={
"Authorization": f"token {token}",
"Accept": "application/vnd.github.raw",
},
)
# The URL is built from module constants, never from remote input, but
# assert the scheme anyway: this call carries a credential, and an http://
# or file:// target would leak it. Belt and braces for a bad edit above.
if request.type != "https":
_logger.error("Refusing to send the sync token over %s", request.type)
return None
try:
with urllib.request.urlopen(
request, timeout=SYNC_HTTP_TIMEOUT_SECONDS
) as response:
raw = response.read().decode("utf-8")
except (urllib.error.URLError, OSError, UnicodeDecodeError) as exc:
# INFO for the same reason as the no-token path: a persistent failure
# (404 because the phone never pushed, an expired token, no network)
# would otherwise be one WARNING per cache expiry, forever.
_logger.info("Cannot fetch synced alarm: %s", exc)
return None
return parse_log(raw)
def fetch_remote_alarm() -> tuple[int, int] | None:
"""Fetch the alarm the phone last pushed, Firebase first.
GitHub is tried only when Firebase yields nothing, which covers both the
not-yet-migrated machine and a Firebase outage. Once every device has
moved, deleting the GitHub branch is the whole retirement.
Returns:
The (hour, minute) pair, or None if no backend has a usable answer.
"""
return _fetch_firebase_alarm() or _fetch_github_alarm()
def _read_cache() -> tuple[int, int, float] | None:
"""Read the cached alarm.
Returns:
An (hour, minute, fetched_at) triple, or None if there is no usable
cache.
"""
try:
with ALARM_CACHE_FILE.open() as handle:
cached = json.load(handle)
except (OSError, json.JSONDecodeError):
return None
if not isinstance(cached, dict):
return None
hour = cached.get("hour")
minute = cached.get("minute")
fetched_at = cached.get("fetched_at")
if not isinstance(hour, int) or not isinstance(minute, int):
return None
if not isinstance(fetched_at, (int, float)):
return None
if not in_range(hour, minute):
return None
return hour, minute, float(fetched_at)
def _write_cache(hour: int, minute: int) -> None:
"""Persist a freshly fetched alarm so later reads survive a network loss.
Failures are logged and swallowed: an unwritable cache degrades the next
read to a live fetch, which is not worth failing a trigger tick over.
Args:
hour: Hour of day, 0-23.
minute: Minute of hour, 0-59.
"""
entry = {"hour": hour, "minute": minute, "fetched_at": time.time()}
try:
ALARM_CACHE_FILE.parent.mkdir(parents=True, exist_ok=True)
with ALARM_CACHE_FILE.open("w") as handle:
json.dump(entry, handle, indent=2)
except OSError as exc:
_logger.warning("Cannot write alarm cache: %s", exc)
def get_alarm(*, max_cache_age_seconds: float = ALARM_CACHE_MAX_AGE_SECONDS) -> Alarm:
"""Resolve the alarm to fire today, degrading gracefully at every step.
Resolution order:
1. A cache entry younger than *max_cache_age_seconds* (no network).
2. A live fetch from GitHub (cached on success).
3. Any cache entry, however stale — better an old alarm than none.
4. ``FALLBACK_ALARM_HOUR:00``, if the alarm has never been read at all.
Args:
max_cache_age_seconds: How long a cached read is served without
re-fetching. Injectable so tests and ``--show`` can force a live
read.
Returns:
The resolved alarm. Always succeeds.
"""
cached = _read_cache()
if cached is not None:
hour, minute, fetched_at = cached
if time.time() - fetched_at < max_cache_age_seconds:
return Alarm(hour, minute, "cache")
remote = fetch_remote_alarm()
if remote is not None:
_write_cache(*remote)
return Alarm(remote[0], remote[1], "remote")
# Both degraded paths log at INFO for the same per-minute-tick reason as
# _read_token above; _trigger warns when it fires on a degraded source.
if cached is not None:
_logger.info("Using stale cached alarm — sync read failed")
return Alarm(cached[0], cached[1], "stale-cache")
_logger.info(
"No synced or cached alarm — falling back to %02d:00",
FALLBACK_ALARM_HOUR,
)
return Alarm(FALLBACK_ALARM_HOUR, 0, "fallback")
def _parse_args(argv: list[str]) -> argparse.Namespace:
"""Parse CLI arguments for the sync reader."""
parser = argparse.ArgumentParser(
description="Show the alarm time the phone last synced."
)
parser.add_argument(
"--show",
action="store_true",
help="Print the resolved alarm and where it came from.",
)
parser.add_argument(
"--no-cache",
action="store_true",
help="Ignore cache freshness and force a live fetch.",
)
return parser.parse_args(argv)
def main() -> None:
"""Entry point: print the resolved alarm (``--show`` is the only mode)."""
configure_logging()
args = _parse_args(sys.argv[1:])
max_age = 0.0 if args.no_cache else ALARM_CACHE_MAX_AGE_SECONDS
sys.stdout.write(f"{get_alarm(max_cache_age_seconds=max_age)}\n")
if __name__ == "__main__":
main()