wake-alarm/wake_alarm/_session_payload.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

172 lines
5.8 KiB
Python

"""Parse the phone's morning session out of the ``crdt_sync`` log.
Pure, like ``_alarm_payload``: JSON in, one ``SessionRecord`` out, no clock,
no disk. The phone (``session_sync.dart``) publishes two fields on the same
``alarm.json`` record the alarm time rides on:
* ``morning_sessions`` -- the newest finished sessions, each the phone's own
session JSON with ``outcome`` ``completed`` or ``failed``;
* ``current_session`` -- the live session while one is running (``outcome``
``active``), ``{}`` once it has finished.
Selection is by content, never by comparing the two fields' HLC stamps: for
one date a finished record beats the in-progress one whatever the stamps say,
so a phone that lost the network at finish and never cleared
``current_session`` still resolves to the finished morning.
"""
from __future__ import annotations
from dataclasses import dataclass
import json
import logging
from typing import Any
from wake_alarm._alarm_payload import extract_hlc, hlc_instant
from wake_alarm._constants import SESSION_CURRENT_FIELD, SESSIONS_FIELD
_logger = logging.getLogger(__name__)
# The phone's outcome names, mapped to the PC's. ``active`` is the phone's
# word for a live session; the PC file says ``in_progress`` so a reader never
# has to know the Dart enum.
_OUTCOMES: dict[str, str] = {
"active": "in_progress",
"completed": "completed",
"failed": "failed",
}
_FINISHED_PHASE = 1
_LIVE_PHASE = 0
@dataclass(frozen=True)
class SessionRecord:
"""One morning as the phone described it."""
date: str
outcome: str
started_at: str
finished_at: str | None
energy: int | None
weight_kg: float | None
re_rings: int
device_id: str
published_at: float | None
@property
def finished(self) -> bool:
"""Whether the phone considers this morning over."""
return self.outcome != "in_progress"
def _int_or_none(value: object) -> int | None:
"""An int, refusing bools (an int subclass) and anything else."""
if isinstance(value, bool) or not isinstance(value, int):
return None
return value
def _float_or_none(value: object) -> float | None:
"""A float from an int or float, refusing bools."""
if isinstance(value, bool) or not isinstance(value, (int, float)):
return None
return float(value)
def record_from_json(
value: object,
*,
device_id: str,
published_at: float | None,
) -> SessionRecord | None:
"""Build a record from one phone session JSON, or None if malformed."""
if not isinstance(value, dict):
return None
date, started, outcome = (
value.get("date"),
value.get("started_at"),
value.get("outcome"),
)
if not isinstance(date, str) or not isinstance(started, str):
return None
if not isinstance(outcome, str) or outcome not in _OUTCOMES:
return None
mapped = _OUTCOMES[outcome]
updated = value.get("updated_at")
finished_at = (
updated if mapped != "in_progress" and isinstance(updated, str) else None
)
return SessionRecord(
date=date,
outcome=mapped,
started_at=started,
finished_at=finished_at,
energy=_int_or_none(value.get("energy")),
weight_kg=_float_or_none(value.get("weight_kg")),
re_rings=_int_or_none(value.get("re_rings")) or 0,
device_id=device_id,
published_at=published_at,
)
def _field_value(log: dict[str, Any], field: str) -> object:
"""The value half of one ``(value, HLC)`` pair, or None."""
record = log.get("alarm")
fields = record.get("fields") if isinstance(record, dict) else None
pair = fields.get(field) if isinstance(fields, dict) else None
return pair[0] if isinstance(pair, list) and pair else None
def _rank_key(record: SessionRecord, hlc: str) -> tuple[str, int, str, str]:
"""Newest date first; a finished morning beats a live one on the same date."""
phase = _FINISHED_PHASE if record.finished else _LIVE_PHASE
return (record.date, phase, hlc, record.device_id)
def sessions_on_log(
log: dict[str, Any], device_id: str
) -> list[tuple[tuple[str, int, str, str], SessionRecord]]:
"""Every usable session one device published, with its ranking key."""
ranked: list[tuple[tuple[str, int, str, str], SessionRecord]] = []
history = _field_value(log, SESSIONS_FIELD)
history_hlc = extract_hlc(log, SESSIONS_FIELD)
history_at = hlc_instant(history_hlc)
for entry in history if isinstance(history, list) else []:
record = record_from_json(entry, device_id=device_id, published_at=history_at)
if record is not None and record.finished:
ranked.append((_rank_key(record, history_hlc), record))
live_hlc = extract_hlc(log, SESSION_CURRENT_FIELD)
live = record_from_json(
_field_value(log, SESSION_CURRENT_FIELD),
device_id=device_id,
published_at=hlc_instant(live_hlc),
)
if live is not None and not live.finished:
ranked.append((_rank_key(live, live_hlc), live))
return ranked
def newest_session(published: list[tuple[str, str]]) -> SessionRecord | None:
"""The newest morning across every device's log, or None.
Args:
published: ``(device_id, raw_json)`` per device, in any order.
Returns:
The record with the newest date -- finished before live on that date,
then by HLC, then by ``device_id`` so two installs never alternate.
"""
ranked: list[tuple[tuple[str, int, str, str], SessionRecord]] = []
for device_id, raw in published:
try:
log = json.loads(raw)
except json.JSONDecodeError:
_logger.info("Ignoring unparsable session log from %r", device_id)
continue
if not isinstance(log, dict):
continue
ranked.extend(sessions_on_log(log, device_id))
if not ranked:
return None
return max(ranked, key=lambda entry: entry[0])[1]