mirror of
https://github.com/kuhyx/wake-alarm.git
synced 2026-11-10 20:27:02 +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
372 lines
13 KiB
Python
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()
|