wake-alarm/wake_alarm/_sync.py
Krzysztof kuhy Rudnicki 66bc3a9e55
Some checks are pending
pre-commit / pre-commit (push) Waiting to run
Tests / test (3.10) (push) Waiting to run
Tests / test (3.11) (push) Waiting to run
Tests / test (3.12) (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
Read the phone's alarm from Firebase, keeping GitHub as the mirror
The seventh and last app on the GitHub-repo transport. fetch_remote_alarm()
is now `_fetch_firebase_alarm() or _fetch_github_alarm()`, so retiring the
mirror later is deleting one branch and three constants.

Two things made this look harder than it was. The first was the standing
"_sync.py is stdlib-only" rule: crdt_sync pulls in requests, and the service
runs /usr/bin/python rather than a venv. But the package already depends on
gatelock and python-kasa, and _smart_plug.py already has the pattern for
exactly this -- a module-scope import inside try/except ImportError. So the
Firebase import is guarded the same way: a dependency that failed to install
degrades the read to GitHub instead of killing the per-minute trigger. The
GitHub path stays stdlib-only, which is what makes that fallback trustworthy.

The second was the shutdown wrapper, which used to do its own authenticated
GitHub read. It could not have worked against Firebase -- an ID token lasts
about an hour and a shell script at shutdown time has no safe way to refresh
one. It now reads the local alarm cache the trigger already keeps fresh
(deployed to /usr/local/bin/shutdown), so the backend is invisible to it.
install.sh also now refuses to run under sudo, which silently skipped its
later steps.

Verified against the live database, not just in tests: the phone's alarm is
already published at wake-alarm-sync/devices/phone/alarm.json, and Firebase
and GitHub both resolve it to 07:45. `_sync --show --no-cache` reports
"07:45 (remote)" and the trigger dry-run behaves unchanged.

338 tests at 100% branch coverage, no new lint suppressions. The backend
tests moved to test_sync_backends.py to keep both files under the 500-line
cap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017FH32BgGQbQLDstBhR26Xa
2026-08-05 09:09:22 +02:00

434 lines
14 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
from typing import Any
import urllib.error
import urllib.request
from wake_alarm._constants import (
ALARM_CACHE_FILE,
ALARM_CACHE_MAX_AGE_SECONDS,
FALLBACK_ALARM_HOUR,
MAX_HOUR,
MAX_MINUTE,
MIN_HOUR,
MIN_MINUTE,
SYNC_ALARM_PATH,
SYNC_API_BASE,
SYNC_APP_NAME,
SYNC_CONFIG_FILE,
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")
# Keys inside the crdt_sync log the phone writes.
_RECORD_ID = "alarm"
_HOUR_FIELD = "alarm_hour"
_MINUTE_FIELD = "alarm_minute"
@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 _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 _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 _extract_field(log: dict[str, Any], field: str) -> int | None:
"""Pull one integer field out of the phone's crdt_sync log.
The stored shape is ``{"alarm": {"fields": {"<field>": [value, "<hlc>"]}}}``
— each field is a (value, HLC) pair, so the value is element 0.
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.
"""
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)
# bool is an int subclass; an alarm hour of True would be nonsense.
if not isinstance(pair, list) or not pair:
return None
value = pair[0]
if isinstance(value, bool) or not isinstance(value, int):
return None
return value
def _parse_log(raw: str) -> tuple[int, int] | None:
"""Parse the phone's log into an (hour, minute) pair.
Args:
raw: The raw JSON text returned by the Contents API.
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 _fetch_firebase_alarm() -> tuple[int, int] | None:
"""Fetch the alarm the phone last pushed to Firebase.
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)
raw = client.get_file_text(SYNC_ALARM_PATH)
except (ConfigError, FirebaseAuthError, RemoteSyncError, OSError) as exc:
_logger.info("Cannot fetch the alarm from Firebase: %s", exc)
return None
if raw is None:
_logger.info("No alarm published to Firebase yet")
return None
return _parse_log(raw)
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()