mirror of
https://github.com/kuhyx/wake-alarm.git
synced 2026-11-10 18:07:05 +01:00
On 2026-09-19 the phone was powered off after the bathroom tag and nothing on the PC noticed: every finished session was published and nothing read it. Now wake-alarm distils the phone's morning into an HMAC-signed ~/.local/state/wake_alarm/morning_session.json (wake-alarm-session.timer, 07:00-11:00 every 2 min) that screen-locker and leetcode-guard read to defer their locks -- provisionally until min(start + 90 min, 09:30) while the session is live, until 11:00 once it completed inside that cutoff, and not at all for a session finished later (the phone switched on at noon completes the legs then; that must un-lock nothing). The phone publishes the session at start as well as at finish (current_session, cleared with the finish stamp), so the PC can tell "started, not finished" from "never started". A required 1-5 energy tap joins the weigh-in and ships as latest_energy with a PC reader mirroring _weight (the shared half is _field_reader). Also fixes the vmbox harness, red since the 2026-09-11 home-tidy: the guest tree lives at ~/wake-alarm, and the INSTALL-OK grep matched vm's echo of the command line. Takes Flutter 3.47.5. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Tp54ewdXRLaimUuT8LgYyn
229 lines
8.1 KiB
Python
229 lines
8.1 KiB
Python
"""Distil the phone's morning session into the PC's signed carrot file.
|
|
|
|
screen-locker and leetcode-guard defer their locks while the morning is
|
|
being earned, but neither may talk to Firebase: wake-alarm is the one reader.
|
|
``wake-alarm-session.timer`` runs ``--refresh`` every two minutes through the
|
|
morning; each run fetches the phone's logs, picks the newest morning
|
|
(``_session_payload``) and rewrites ``MORNING_SESSION_FILE`` as an HMAC-signed
|
|
entry that always describes **today**:
|
|
|
|
* today's session, with ``exempt_until`` signed in when it is live
|
|
(``min(started_at + 90 min, 09:30)``) or completed (``11:00``);
|
|
* a ``none`` heartbeat when the phone has nothing for today, so a consumer can
|
|
tell "fresh, no session" from "the refresher has not run yet" (a PC booted
|
|
mid-morning) by the date alone.
|
|
|
|
Consumers apply one rule -- HMAC ok, date is today, ``now < exempt_until`` --
|
|
and never re-derive the cutoffs. Dates are the phone's local day throughout;
|
|
``_state._today_str`` is UTC and must not be used here.
|
|
|
|
python -m wake_alarm._session --refresh # fetch and rewrite the file
|
|
python -m wake_alarm._session --show # print the verified entry
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
from datetime import datetime, time
|
|
import json
|
|
import logging
|
|
import sys
|
|
|
|
from gatelock.log_integrity import compute_entry_hmac, verify_entry_hmac
|
|
|
|
from wake_alarm._constants import (
|
|
MORNING_SESSION_FILE,
|
|
SESSION_COMPLETED_UNTIL,
|
|
SESSION_PROVISIONAL_CAP,
|
|
SESSION_PROVISIONAL_GRACE,
|
|
)
|
|
from wake_alarm._logging_setup import configure_logging
|
|
from wake_alarm._session_payload import SessionRecord, newest_session
|
|
from wake_alarm._sync_transport import fetch_firebase_logs
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
NONE_OUTCOME = "none"
|
|
|
|
|
|
def _local(iso: str) -> datetime:
|
|
"""A phone-stamped naive local ISO instant as an aware local datetime."""
|
|
return datetime.fromisoformat(iso).astimezone()
|
|
|
|
|
|
def _at(date: str, clock: tuple[int, int]) -> datetime:
|
|
"""Local wall-clock instant ``clock`` on the phone's session date."""
|
|
return datetime.combine(
|
|
datetime.fromisoformat(date).date(), time(*clock)
|
|
).astimezone()
|
|
|
|
|
|
def _cutoff(record: SessionRecord) -> datetime | None:
|
|
"""The instant a live morning stops being exempt: start + grace, capped."""
|
|
try:
|
|
started = _local(record.started_at)
|
|
except ValueError:
|
|
_logger.info(
|
|
"Session started_at %r unreadable; no exemption", record.started_at
|
|
)
|
|
return None
|
|
return min(
|
|
started + SESSION_PROVISIONAL_GRACE, _at(record.date, SESSION_PROVISIONAL_CAP)
|
|
)
|
|
|
|
|
|
def exempt_until(record: SessionRecord) -> datetime | None:
|
|
"""When the carrot this morning earned runs out; None when it earned none.
|
|
|
|
Live: the start plus one honest morning, capped -- relative so an early
|
|
alarm cannot bank hours by aborting. Completed: the flat end, but only if
|
|
it finished inside that same cutoff -- a phone switched back on at noon
|
|
completes the legs then, and that must not un-lock anything. Failed: none.
|
|
"""
|
|
cutoff = _cutoff(record)
|
|
if cutoff is None or record.outcome == "failed":
|
|
return None
|
|
if record.outcome == "in_progress":
|
|
return cutoff
|
|
try:
|
|
finished = _local(record.finished_at or "")
|
|
except ValueError:
|
|
_logger.info(
|
|
"Session finished_at %r unreadable; no exemption", record.finished_at
|
|
)
|
|
return None
|
|
if finished > cutoff:
|
|
_logger.info(
|
|
"Session finished %s, after the %s cutoff; no exemption", finished, cutoff
|
|
)
|
|
return None
|
|
return _at(record.date, SESSION_COMPLETED_UNTIL)
|
|
|
|
|
|
def to_entry(
|
|
record: SessionRecord | None, *, today: str, refreshed_at: float
|
|
) -> dict[str, object]:
|
|
"""The unsigned file body for ``record`` (today's) or the ``none`` heartbeat."""
|
|
if record is None:
|
|
return {
|
|
"date": today,
|
|
"outcome": NONE_OUTCOME,
|
|
"started_at": None,
|
|
"finished_at": None,
|
|
"energy": None,
|
|
"weight_kg": None,
|
|
"re_rings": 0,
|
|
"device_id": "",
|
|
"published_at": None,
|
|
"refreshed_at": refreshed_at,
|
|
}
|
|
entry: dict[str, object] = {
|
|
"date": record.date,
|
|
"outcome": record.outcome,
|
|
"started_at": record.started_at,
|
|
"finished_at": record.finished_at,
|
|
"energy": record.energy,
|
|
"weight_kg": record.weight_kg,
|
|
"re_rings": record.re_rings,
|
|
"device_id": record.device_id,
|
|
"published_at": record.published_at,
|
|
"refreshed_at": refreshed_at,
|
|
}
|
|
until = exempt_until(record)
|
|
if until is not None:
|
|
entry["exempt_until"] = until.isoformat()
|
|
return entry
|
|
|
|
|
|
def write_entry(entry: dict[str, object]) -> bool:
|
|
"""Sign and write ``entry``; False when the disk refused."""
|
|
signature = compute_entry_hmac(entry)
|
|
if signature is not None:
|
|
entry["hmac"] = signature
|
|
else:
|
|
_logger.warning("HMAC key unavailable — writing unsigned morning session")
|
|
try:
|
|
MORNING_SESSION_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
MORNING_SESSION_FILE.write_text(json.dumps(entry, indent=2))
|
|
except OSError as exc:
|
|
_logger.warning("Could not write %s: %s", MORNING_SESSION_FILE, exc)
|
|
return False
|
|
return True
|
|
|
|
|
|
def read_entry() -> dict[str, object] | None:
|
|
"""The file's entry if it exists and its signature verifies, else None."""
|
|
try:
|
|
entry = json.loads(MORNING_SESSION_FILE.read_text())
|
|
except FileNotFoundError:
|
|
return None
|
|
except (OSError, json.JSONDecodeError) as exc:
|
|
_logger.warning("Cannot read %s: %s", MORNING_SESSION_FILE, exc)
|
|
return None
|
|
if not isinstance(entry, dict):
|
|
return None
|
|
if not verify_entry_hmac(entry):
|
|
_logger.warning("Morning session HMAC verification failed")
|
|
return None
|
|
return entry
|
|
|
|
|
|
def refresh(now: datetime) -> dict[str, object] | None:
|
|
"""Fetch, select, and rewrite the file; None when the backend was unreadable.
|
|
|
|
An unreadable backend leaves the file alone: whatever it says is still
|
|
the newest thing known, and a consumer judges freshness by its date.
|
|
"""
|
|
published = fetch_firebase_logs()
|
|
if published is None:
|
|
_logger.info("Morning session not refreshed: backend unreadable")
|
|
return None
|
|
today = now.date().isoformat()
|
|
record = newest_session(published)
|
|
if record is not None and record.date != today:
|
|
_logger.info("Newest morning is %s, not today; writing none", record.date)
|
|
record = None
|
|
entry = to_entry(record, today=today, refreshed_at=now.timestamp())
|
|
return entry if write_entry(entry) else None
|
|
|
|
|
|
def describe(entry: dict[str, object], now: datetime) -> str:
|
|
"""One line a human can read off ``--show``."""
|
|
until = entry.get("exempt_until")
|
|
if not isinstance(until, str):
|
|
verdict = "no exemption"
|
|
elif now < datetime.fromisoformat(until):
|
|
verdict = f"exempt until {until}"
|
|
else:
|
|
verdict = f"exemption ended {until}"
|
|
return f"{entry.get('date')} {entry.get('outcome')} — {verdict}"
|
|
|
|
|
|
def _parse_args(argv: list[str]) -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(
|
|
description="PC copy of the phone's morning session"
|
|
)
|
|
mode = parser.add_mutually_exclusive_group(required=True)
|
|
mode.add_argument(
|
|
"--refresh", action="store_true", help="fetch and rewrite the file"
|
|
)
|
|
mode.add_argument("--show", action="store_true", help="print the verified file")
|
|
return parser.parse_args(argv)
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
"""Entry point; exit 1 when there is nothing verifiable to report."""
|
|
configure_logging(component="session")
|
|
args = _parse_args(sys.argv[1:] if argv is None else argv)
|
|
now = datetime.now().astimezone()
|
|
entry = refresh(now) if args.refresh else read_entry()
|
|
if entry is None:
|
|
sys.stdout.write("no verified morning session\n")
|
|
return 1
|
|
sys.stdout.write(json.dumps(entry, indent=2) + "\n" + describe(entry, now) + "\n")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|