wake-alarm/wake_alarm/_session.py
Krzysztof kuhy Rudnicki c3c129b068 Make the morning session the carrot: earn 11:00 by finishing it in time
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
2026-09-19 16:40:51 +02:00

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())