diff --git a/.fvmrc b/.fvmrc index 5ec4649..827b556 100644 --- a/.fvmrc +++ b/.fvmrc @@ -1,3 +1,3 @@ { - "flutter": "3.47.4" + "flutter": "3.47.5" } diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index bdea19d..bb46833 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -50,7 +50,7 @@ jobs: - uses: subosito/flutter-action@v2 with: - flutter-version: 3.47.4 + flutter-version: 3.47.5 channel: stable cache: true diff --git a/.github/workflows/release-apk.yml b/.github/workflows/release-apk.yml index 0dde952..a99dab1 100644 --- a/.github/workflows/release-apk.yml +++ b/.github/workflows/release-apk.yml @@ -77,7 +77,7 @@ jobs: - uses: subosito/flutter-action@v2 with: - flutter-version: 3.47.4 + flutter-version: 3.47.5 channel: stable cache: true diff --git a/AGENTS.md b/AGENTS.md index a1387a2..94e737c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -139,10 +139,13 @@ systemd-sleep hook: a suspended or just-booted machine still fires. `FALLBACK_ALARM_HOUR` *is* the phone's baseline. An **undated** record is trusted, not expired: the GitHub mirror has no per-field HLC, so treating "cannot tell" as "too old" would break that backend entirely. -- **Degraded reads log at INFO, not WARNING.** `get_alarm()` runs every minute, - so a persistent condition logged loudly is 1440 identical journal lines a day. - `_trigger` emits the single WARNING that matters: it is firing *now* on a - non-phone alarm time. +- **Degraded reads log at INFO, not WARNING.** `get_alarm()` runs every minute; + a persistent condition logged loudly is 1440 journal lines a day. `_trigger` + emits the single WARNING that matters: firing *now* on a non-phone time. +- **The morning session is the carrot** (`_session.py`, `wake-alarm-session.timer`, + **`DOCS-morning-session-pc.md`**): a completed-in-time morning defers the + workout and LeetCode locks to 11:00 via one signed file both consumers read. + Phone-local dates only — never `_state._today_str()` (UTC). ## The morning_routine orchestrator lives elsewhere @@ -212,10 +215,9 @@ another repo with no error message anywhere. - Run tests: `python -m pytest wake_alarm/tests/ --cov=wake_alarm --cov-branch --cov-fail-under=100` - Phone tests: `cd phone_app && flutter test && flutter analyze` -- Walk the whole morning on the emulator: `phone_app/scripts/emu_morning.py` - against `lib/main_session_probe.dart` (see `DOCS-morning-session.md`) +- Walk the morning on the emulator: `phone_app/scripts/emu_morning.py` (`DOCS-morning-session.md`) - Lint: `pre-commit run --all-files` -- Show the resolved alarm: `python -m wake_alarm._sync --show` (`--no-cache` forces a live read) +- Show the resolved alarm / the morning: `python -m wake_alarm._sync --show` (`--no-cache` live), `python -m wake_alarm._session --show` - Show what the next tick would do: `python -m wake_alarm._trigger --dry-run` - Watch it live: `journalctl --user -fu wake-alarm-trigger.service` - Install: `bash install.sh` — Uninstall: `bash uninstall.sh` @@ -232,19 +234,17 @@ The dev venv lacks `gatelock`, so anything importing `_state` (including - Don't add a dependency without the production install-path check above. `_sync.py`'s GitHub path is stdlib-only for that reason — don't "improve" it - to `requests`, and don't make the `crdt_sync` import unconditional. -- Don't forget the orchestrator when changing this package's module paths. -- Don't point the trigger at anything but `morning-routine.service`, or the - workout lock never runs. -- Don't remove the trigger's once-per-day latch. -- Don't raise the log level of `_sync`'s degraded-read paths. + to `requests`, and don't make the `crdt_sync` import unconditional. Don't + forget the orchestrator when changing this package's module paths. +- Don't point the trigger at anything but `morning-routine.service` (or the + workout lock never runs), don't remove its once-per-day latch, and don't + raise the log level of `_sync`'s degraded-read paths. - Don't let the bypass skip the walk, and don't add a challenge kind that hides its own prompt. - Don't close the ring screen on dismiss when a session should follow, don't add a re-ring cancel beyond the session's own `finishSession`, and don't restore a re-ring instant that has passed (BOOT_COMPLETED replays on launch). -- Don't make `HomeScreen.initState` concurrent again, and don't collapse - `publish()`'s two failure messages back into one. -- Don't date a record by when the PC fetched it. -- Don't delete `/etc/sudoers.d/wake-alarm`. -- Don't commit `wake_state.json`. +- Don't make `HomeScreen.initState` concurrent again; keep `publish()`'s two failure messages. +- Don't date a record by when the PC fetched it, and don't let a consumer of + `morning_session.json` re-derive the 09:30/11:00 cutoffs or fetch its own copy. +- Don't delete `/etc/sudoers.d/wake-alarm`. Don't commit `wake_state.json`. diff --git a/DOCS-morning-session-pc.md b/DOCS-morning-session-pc.md new file mode 100644 index 0000000..6b49a99 --- /dev/null +++ b/DOCS-morning-session-pc.md @@ -0,0 +1,95 @@ +# The morning session on the PC — the carrot, and what it costs to skip + +Companion to `DOCS-morning-session.md` (the phone side). This is how the +phone's morning turns into "no workout lock, no LeetCode lock" on the PC, and +why an aborted morning turns into neither. + +## Why + +On 2026-09-19 the alarm rang, the full dismiss ran, the session reached the +bathroom tag — and the phone was powered off. Nothing on the PC noticed: the +phone was publishing every finished session and nothing read it, the +workout-lock "carrot" was a hardcoded 05:00–08:30 window in screen-locker +with no link to getting up, and leetcode-guard locked at login regardless. So +finishing the morning was worth at most 30 minutes, and aborting it cost +nothing. Powering the phone off cannot be prevented (device-owner +provisioning, and a 10 s hardware off still wins); it can be *noticed*. + +## The rule + +A morning earns the carrot **only by being completed in time**: + +| the phone says | `exempt_until` | meaning | +|---|---|---| +| started, not finished (`in_progress`) | `min(started_at + 90 min, 09:30)` | provisional — the legs take ~40 min; relative to the start because an alarm earlier than 08:00 is legal and a fixed 09:30 would hand an aborted 06:15 session three hours | +| `completed`, finished inside that same cutoff | **11:00** | the carrot | +| `completed` *after* the cutoff | none | a phone switched back on at noon completes the legs then; that un-locks nothing | +| `failed`, or nothing started today | none | today's ordinary ladder (screen-locker's 05:00–08:30 window, leetcode-guard at login/09:00) | + +Fail closed: a sync outage, a refresher that is not running, a bad signature +— all read as "no carrot". Nothing here can make a lock *earlier* than today. + +## The plumbing + +**wake-alarm is the only Firebase reader.** `wake-alarm-session.timer` +(`*-*-* 07..10:00/2:00` and `11:00:00`, `Persistent=true`, ~121 reads/day) +runs `python -m wake_alarm._session --refresh`: + +1. `_sync_transport.fetch_firebase_logs()` — every device's `alarm.json`. +2. `_session_payload.newest_session()` — the finished entries of + `morning_sessions` plus `current_session` when it is live, ranked by + `(date, finished-before-live, HLC, device_id)`. **By content, never by + comparing the two fields' stamps**: a phone that lost the network at + finish and never cleared `current_session` still resolves to the finished + morning. The phone's `active` becomes `in_progress`. +3. `_session.to_entry()` + `write_entry()` — an HMAC-signed + `~/.local/state/wake_alarm/morning_session.json` that **always describes + today** (PC-local date): today's session with `exempt_until` signed in, or + a `none` heartbeat when the phone has nothing for today. The heartbeat is + what lets a consumer tell "fresh, no session" from "the refresher has not + run yet" by the date alone. + +Dates are the phone's local day throughout. `wake_alarm._state._today_str` +is UTC and must not be used here (screen-locker compares against its local +`today_str`; a UTC date would miss by a day every evening). + +**Consumers apply one predicate**: signature verifies ∧ `date == local today` +∧ `exempt_until` present ∧ `now < exempt_until`. Neither re-derives the +cutoffs, neither imports `crdt_sync`, neither talks to Firebase: + +- screen-locker `screen_locker/_morning_session.py` → the `wake_alarm_skip` + rung of `_auto_upgrade._check_today_state_exits`, in its old position + (after `workout_logged_today`, before `early_bird_banked`). The decision + record carries `exempt_until` and `outcome`. +- leetcode-guard `leetcode_guard/_morning_session.py` → `_cli._run_lock` + returns `EXIT_OK` with `deferred: …` before `build_client()`. A run + deferral, never ledger state (`decide()` stays pure; a deleted file cannot + mint an unlocked day). `leetcode-guard.timer` gained 09:30 and 11:00 slots + so a deferred run is re-decided at both boundaries. `--check` prints a + `morning` line. + +**A PC booted mid-morning** has a missing or yesterday's file until the +timer's `Persistent=true` catch-up lands, and that needs the network. Only +the enforce path, only inside 07:00–11:00, and only when the file is not +today's, both consumers retry for ≤ 30 s (5 s steps) and then proceed as +normal with a WARNING. Status paths never wait; tests pin the retry to 0. + +## Known limits (v1, deliberate) + +- A lock already on screen is not lifted by a later publish: a session + that completes at 09:45 after the 09:30 lock stays locked. The publish + would have to arrive before the boundary. +- A session finishing inside ~2 min before 09:30 may be seen as + `in_progress` at the 09:30 tick and lock; bounded by the timer cadence. +- No extra stick for a `failed` session beyond losing the carrot. + +## Commands + +```sh +python -m wake_alarm._session --refresh # fetch, select, rewrite the file +python -m wake_alarm._session --show # the verified file + a verdict line +python -m wake_alarm._energy # {"date", "level"} or exit 1 +systemctl --user list-timers | grep wake-alarm-session +journalctl --user -u wake-alarm-session.service -n 20 +leetcode-guard --check # the "morning" line +``` diff --git a/DOCS-morning-session.md b/DOCS-morning-session.md index 45da1a7..578330b 100644 --- a/DOCS-morning-session.md +++ b/DOCS-morning-session.md @@ -30,9 +30,12 @@ Legs latch like the ring's stages. A leg whose tag is not registered is is configured: with no session tags at all it is a weigh-in and nothing else. Away from home (`RingSkip.away`) there is no session. -**Weight.** Typed, 30–250 kg; more than 3 kg from the last confirmed value -must be typed twice (`weight_entry.dart`). Without a bathroom tag the typed -value passes the first leg by itself — there is nothing to walk to. +**Weight and energy.** The weight is typed, 30–250 kg; more than 3 kg from +the last confirmed value must be typed twice (`weight_entry.dart`). The +energy is one of five chips, 1–5, and is required before the weight submits +(`session_checkin.dart`): not proof of anything, just the one number worth a +tap while standing at the scale. Without a bathroom tag the check-in passes +the first leg by itself — there is nothing to walk to. ## What rings, and how @@ -106,13 +109,23 @@ over: Finished sessions go to `session.history` (30 kept). The home screen shows the last seven (`home_history_card.dart`) with weight deltas. The record rides on the synced `alarm.json` like the ring tags do -(`session_sync.dart`): `session_tag_ids`, `morning_sessions` (14) and -`latest_weight_kg` (`{date, kg}`). The PC reads it with -`python -m wake_alarm._weight` (`wake_alarm/_weight.py`, Firebase only, same -device discovery as the alarm); diet-guard's `sync` imports that lazily to -refresh the weight behind its protein target. The PC alarm parser ignores -every field it does not name. Sessions merge per date, newest `updatedAt` wins, so a -reinstall gets its history back. +(`session_sync.dart`): `session_tag_ids`, `morning_sessions` (14), +`latest_weight_kg` (`{date, kg}`), `latest_energy` (`{date, level}`) and +`current_session` — the live session while one runs, `{}` once it is over. +The PC reads the two numbers with `python -m wake_alarm._weight` / +`_energy` (Firebase only, same device discovery as the alarm); diet-guard's +`sync` imports the weight lazily for its protein target. The PC alarm parser +ignores every field it does not name. Sessions merge per date, newest +`updatedAt` wins, so a reinstall gets its history back; `current_session` is +never applied from a peer — a live session in history would stop +`SessionHandoff.afterDismiss` starting the morning it belongs to. + +**The session is published twice**: once when it starts (`SessionScreen._init`, +strictly after the session is saved and on screen — a publish racing the load +stages nothing, and the PC would then lock an honest morning) and once when +it finishes. That is what lets the PC tell "started, not finished" from +"never started" and defer the workout and LeetCode locks only while the +morning is actually being earned: **`DOCS-morning-session-pc.md`**. ## Gotchas — each one cost a real run @@ -148,6 +161,7 @@ reinstall gets its history back. ```sh flutter run -d emulator-5554 --debug -t lib/main_session_probe.dart > run.log scripts/emu_morning.py --log run.log dismiss # ring1, 30 steps, ring2, answers +scripts/emu_morning.py energy 3 # one of the five chips scripts/emu_morning.py weight 72.4 scripts/emu_morning.py tag bath # then wait 8 min, tag bath again scripts/emu_morning.py home; scripts/emu_morning.py launch # back on the leg diff --git a/install.sh b/install.sh index 66cd598..667c4de 100755 --- a/install.sh +++ b/install.sh @@ -39,6 +39,8 @@ readonly SCRIPT_DIR readonly REPO_DIR="$SCRIPT_DIR" readonly TRIGGER_SERVICE_SRC="$SCRIPT_DIR/wake-alarm-trigger.service" readonly TRIGGER_TIMER_SRC="$SCRIPT_DIR/wake-alarm-trigger.timer" +readonly SESSION_SERVICE_SRC="$SCRIPT_DIR/wake-alarm-session.service" +readonly SESSION_TIMER_SRC="$SCRIPT_DIR/wake-alarm-session.timer" readonly SYSTEMD_USER_DIR="$HOME/.config/systemd/user" echo "=== Wake Alarm Trigger Installer ===" @@ -54,10 +56,13 @@ echo " wake_alarm and gatelock import under /usr/bin/python." echo "[2/3] Installing systemd user units..." mkdir -p "$SYSTEMD_USER_DIR" -cp "$TRIGGER_SERVICE_SRC" "$TRIGGER_TIMER_SRC" "$SYSTEMD_USER_DIR/" +cp "$TRIGGER_SERVICE_SRC" "$TRIGGER_TIMER_SRC" \ + "$SESSION_SERVICE_SRC" "$SESSION_TIMER_SRC" "$SYSTEMD_USER_DIR/" systemctl --user daemon-reload systemctl --user enable --now wake-alarm-trigger.timer echo " Trigger timer enabled and started (ticks once a minute)." +systemctl --user enable --now wake-alarm-session.timer +echo " Session timer enabled (07:00-11:00, every 2 min)." echo "[3/3] Removing retired waking machinery from earlier installs..." # Opportunistic, and deliberately NOT fatal. This script runs under `set -e` diff --git a/phone_app/lib/screens/energy_picker.dart b/phone_app/lib/screens/energy_picker.dart new file mode 100644 index 0000000..939a56f --- /dev/null +++ b/phone_app/lib/screens/energy_picker.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; +import 'package:wake_alarm_sync/services/weight_entry.dart'; + +/// Five chips, 1 to 5: the morning's energy, tapped next to the weigh-in. +/// +/// Chips rather than a field because a rating is a tap, not a number to +/// type. The selected colour is the theme's chip container (a raised ink), +/// deliberately not the gold `primaryContainer`: `scripts/emu_morning.py` +/// finds the submit button by its gold pixels, and a gold chip sat above it +/// was found first -- and the label vanished into it. +class EnergyPicker extends StatelessWidget { + /// Creates the picker; [selected] is null until a chip is tapped. + const EnergyPicker({ + required this.selected, + required this.onSelect, + super.key, + }); + + /// The chosen level, or null. + final int? selected; + + /// Called with the tapped level. + final ValueChanged onSelect; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text('Energy', style: Theme.of(context).textTheme.labelLarge), + const SizedBox(height: 4), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + for (var level = minEnergy; level <= maxEnergy; level++) + ChoiceChip( + label: Text('$level'), + selected: selected == level, + selectedColor: scheme.secondaryContainer, + showCheckmark: false, + onSelected: (_) => onSelect(level), + ), + ], + ), + ], + ); + } +} diff --git a/phone_app/lib/screens/home_history_card.dart b/phone_app/lib/screens/home_history_card.dart index 96767e9..f05cbd5 100644 --- a/phone_app/lib/screens/home_history_card.dart +++ b/phone_app/lib/screens/home_history_card.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:wake_alarm_sync/services/morning_session.dart'; import 'package:wake_alarm_sync/services/session_store.dart'; +import 'package:wake_alarm_sync/services/weight_entry.dart'; /// How many mornings the home screen shows. const int kHistoryShown = 7; @@ -69,7 +70,7 @@ class _HomeHistoryCardState extends State { } } -/// One line for [session]: `09-16 ✓ 0 re-rings · 72.4 kg (−0.3)`. +/// One line for [session]: `09-16 ✓ 0 re-rings · 72.4 kg (−0.3) · energy 3/5`. String describeMorning(MorningSession session, {double? previousWeight}) { final mark = switch (session.outcome) { SessionOutcome.completed => '✓', @@ -87,5 +88,8 @@ String describeMorning(MorningSession session, {double? previousWeight}) { weight += ' ($sign${delta.abs().toStringAsFixed(1)})'; } } - return '${session.date.substring(5)} $mark $rings$weight'; + final energy = session.energy == null + ? '' + : ' · energy ${session.energy}/$maxEnergy'; + return '${session.date.substring(5)} $mark $rings$weight$energy'; } diff --git a/phone_app/lib/screens/session_body.dart b/phone_app/lib/screens/session_body.dart index 85ddcd5..23dd16b 100644 --- a/phone_app/lib/screens/session_body.dart +++ b/phone_app/lib/screens/session_body.dart @@ -1,8 +1,9 @@ import 'package:flutter/material.dart'; +import 'package:wake_alarm_sync/screens/energy_picker.dart'; import 'package:wake_alarm_sync/screens/session_checklist.dart'; import 'package:wake_alarm_sync/services/morning_session.dart'; -/// The session screen's body: the checklist, the weigh-in field, the clocks. +/// The session screen's body: the checklist, the check-in, the clocks. /// /// Stateless, taking plain values — the screen keeps the state and the /// ordering, which is where the ring's bugs lived. @@ -13,6 +14,8 @@ class SessionBody extends StatelessWidget { required this.now, required this.controller, required this.status, + required this.energy, + required this.onSelectEnergy, required this.onSubmitWeight, super.key, }); @@ -29,6 +32,12 @@ class SessionBody extends StatelessWidget { /// The status line. final String status; + /// The tapped energy level, or null. + final int? energy; + + /// Called when an energy chip is tapped. + final ValueChanged onSelectEnergy; + /// Called when the user submits a weight. final VoidCallback onSubmitWeight; @@ -60,6 +69,8 @@ class SessionBody extends StatelessWidget { SessionChecklist(session: session, now: now), const SizedBox(height: 24), if (_awaitingWeight) ...[ + EnergyPicker(selected: energy, onSelect: onSelectEnergy), + const SizedBox(height: 12), TextField( controller: controller, keyboardType: const TextInputType.numberWithOptions( diff --git a/phone_app/lib/screens/session_checkin.dart b/phone_app/lib/screens/session_checkin.dart new file mode 100644 index 0000000..f37bd9a --- /dev/null +++ b/phone_app/lib/screens/session_checkin.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; +import 'package:wake_alarm_sync/services/morning_session.dart'; +import 'package:wake_alarm_sync/services/session_rules.dart'; +import 'package:wake_alarm_sync/services/weight_entry.dart'; + +/// What a check-in attempt came to: the status line, and whether it stuck. +typedef CheckInResult = ({String detail, bool recorded}); + +/// The weigh-in field's state and the energy tap, kept out of the screen. +/// +/// Holds what used to be four fields on `_SessionScreenState`: the text +/// controller, the last weight (for the jump check), the value awaiting a +/// retyped confirmation, and now the energy level. [submit] applies the +/// rules in order — energy first, since it is the cheaper thing to fix — +/// and only touches the session when everything passed. +class SessionCheckIn { + /// The weight field's controller; disposed by [dispose]. + final TextEditingController input = TextEditingController(); + + /// The previous morning's weight, for the plausibility jump check. + double? lastWeight; + + /// A weight awaiting a retyped confirmation, if the jump was large. + double? confirming; + + /// The tapped energy level, or null. + int? energy; + + /// Validates and, if everything passes, records the check-in on [session]. + CheckInResult submit(MorningSession session, DateTime now) { + final level = energy; + if (level == null) { + return ( + detail: 'Tap your energy ($minEnergy-$maxEnergy) first', + recorded: false, + ); + } + final check = checkWeight( + input.text, + last: lastWeight, + confirming: confirming, + ); + if (!check.accepted) { + confirming = check.verdict == WeightVerdict.needsConfirm + ? check.kg + : null; + return (detail: check.detail, recorded: false); + } + confirming = null; + input.clear(); + return ( + detail: session.enterCheckIn(check.kg!, level, now), + recorded: true, + ); + } + + /// Releases the controller. + void dispose() => input.dispose(); +} diff --git a/phone_app/lib/screens/session_checklist.dart b/phone_app/lib/screens/session_checklist.dart index 0b23b9a..26c65c8 100644 --- a/phone_app/lib/screens/session_checklist.dart +++ b/phone_app/lib/screens/session_checklist.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:wake_alarm_sync/services/morning_session.dart'; +import 'package:wake_alarm_sync/services/weight_entry.dart'; /// The session's progress list: the weigh-in, then every leg with a tag. /// @@ -75,6 +76,10 @@ class SessionChecklist extends StatelessWidget { static String _kg(MorningSession session) { final kg = session.weightKg ?? session.pendingWeightKg; - return kg == null ? '' : ' — $kg kg'; + if (kg == null) return ''; + final energy = session.energy; + return energy == null + ? ' — $kg kg' + : ' — $kg kg · energy $energy/$maxEnergy'; } } diff --git a/phone_app/lib/screens/session_screen.dart b/phone_app/lib/screens/session_screen.dart index e7d1bbd..b83d9f9 100644 --- a/phone_app/lib/screens/session_screen.dart +++ b/phone_app/lib/screens/session_screen.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:wake_alarm_sync/main.dart'; import 'package:wake_alarm_sync/screens/session_body.dart'; +import 'package:wake_alarm_sync/screens/session_checkin.dart'; import 'package:wake_alarm_sync/services/alarm_scheduler.dart'; import 'package:wake_alarm_sync/services/morning_session.dart'; import 'package:wake_alarm_sync/services/session_notice.dart'; @@ -10,7 +11,6 @@ import 'package:wake_alarm_sync/services/session_publisher.dart'; import 'package:wake_alarm_sync/services/session_rules.dart'; import 'package:wake_alarm_sync/services/session_store.dart'; import 'package:wake_alarm_sync/services/session_tag_settings.dart'; -import 'package:wake_alarm_sync/services/weight_entry.dart'; /// How often the clocks advance and the countdown redraws. const Duration kSessionTick = Duration(seconds: 1); @@ -46,7 +46,8 @@ class SessionScreen extends StatefulWidget { /// Wall clock; injectable because the legs are minutes apart. final DateTime Function()? clock; - /// Pushes the finished session; defaults to [SessionPublisher.publish]. + /// Pushes the session at start and at finish; defaults to + /// [SessionPublisher.publish]. final Future Function()? publish; @override @@ -60,12 +61,11 @@ class _SessionScreenState extends State { late final Future Function() _publish = widget.publish ?? SessionPublisher().publish; - final TextEditingController _input = TextEditingController(); + final SessionCheckIn _checkIn = SessionCheckIn(); MorningSession? _session; SessionTagSettings _tags = const SessionTagSettings(); - double? _lastWeight; - double? _confirming; Timer? _timer; + Future? _startPublish; bool _finishing = false; String _status = ''; @@ -104,10 +104,10 @@ class _SessionScreenState extends State { final last = await _store.lastWeightKg(); await pointNativeAtLeg(_scheduler, session); if (!mounted) return; + _checkIn.lastWeight = last; setState(() { _tags = tags; _session = session; - _lastWeight = last; }); _scheduler.onNfcTag(_onTag); sessionLog( @@ -115,7 +115,18 @@ class _SessionScreenState extends State { 'deadline=${session.nextDeadline()}', ); _timer = Timer.periodic(kSessionTick, (_) => unawaited(_onTick())); - if (!session.isActive) await _finish(); + if (!session.isActive) { + await _finish(); + return; + } + // Tell the PC the morning has started -- strictly after the session is + // saved and on screen, never racing the load (a publish that ran early + // would stage an empty session and the PC would lock an honest morning). + // Unawaited: a backend that is down must not hold the screen. + _startPublish = _publish().then((message) { + sessionLog('start published: $message'); + return message; + }); } Future _onTag(String tagId) async { @@ -127,24 +138,15 @@ class _SessionScreenState extends State { await _commit(detail); } - Future _submitWeight() async { + Future _submitCheckIn() async { final session = _session; if (session == null) return; - final check = checkWeight( - _input.text, - last: _lastWeight, - confirming: _confirming, - ); - if (!check.accepted) { - _confirming = check.verdict == WeightVerdict.needsConfirm - ? check.kg - : null; - setState(() => _status = check.detail); + final result = _checkIn.submit(session, _now()); + if (!result.recorded) { + setState(() => _status = result.detail); return; } - _confirming = null; - _input.clear(); - await _commit(session.enterWeight(check.kg!, _now())); + await _commit(result.detail); } /// Persists, re-points the deadline, and ends the session if it is over. @@ -210,6 +212,9 @@ class _SessionScreenState extends State { final session = _session!; await _scheduler.finishSession(); await _store.archive(session); + // The start publish may still be in flight; two pushes on one local log + // must not overlap. + await (_startPublish ?? Future.value('')); final message = await _publish(); sessionLog( 'finished ${session.outcome.name} re-rings=${session.reRings}: $message', @@ -222,7 +227,7 @@ class _SessionScreenState extends State { void dispose() { _timer?.cancel(); _scheduler.clearNfcListener(); - _input.dispose(); + _checkIn.dispose(); super.dispose(); } @@ -235,9 +240,11 @@ class _SessionScreenState extends State { return SessionBody( session: session, now: _now(), - controller: _input, + controller: _checkIn.input, status: _status, - onSubmitWeight: () => unawaited(_submitWeight()), + energy: _checkIn.energy, + onSelectEnergy: (level) => setState(() => _checkIn.energy = level), + onSubmitWeight: () => unawaited(_submitCheckIn()), ); } } diff --git a/phone_app/lib/services/morning_session.dart b/phone_app/lib/services/morning_session.dart index d1c24e4..0a7bcd9 100644 --- a/phone_app/lib/services/morning_session.dart +++ b/phone_app/lib/services/morning_session.dart @@ -80,6 +80,7 @@ class MorningSession { Map? passedHow, Set? deadlineRung, this.weightKg, + this.energy, this.pendingWeightKg, this.pendingWeightAt, this.reRings = 0, @@ -135,6 +136,9 @@ class MorningSession { /// The confirmed weigh-in, kg. double? weightKg; + /// The 1-5 energy rating tapped with the weigh-in; null until then. + int? energy; + /// A typed weight the bathroom tag has not confirmed yet. double? pendingWeightKg; diff --git a/phone_app/lib/services/session_json.dart b/phone_app/lib/services/session_json.dart index ac9a7d8..f775c71 100644 --- a/phone_app/lib/services/session_json.dart +++ b/phone_app/lib/services/session_json.dart @@ -18,6 +18,7 @@ extension SessionJson on MorningSession { }, 'deadline_rung': deadlineRung.map((l) => l.name).toList(), 'weight_kg': weightKg, + 'energy': energy, 'pending_weight_kg': pendingWeightKg, 'pending_weight_at': pendingWeightAt?.toIso8601String(), 're_rings': reRings, @@ -60,6 +61,7 @@ MorningSession? sessionFromJson(Object? raw) { for (final name in rung) ?SessionLeg.values.asNameMap()[name], }, weightKg: (raw['weight_kg'] as num?)?.toDouble(), + energy: (raw['energy'] as num?)?.toInt(), pendingWeightKg: (raw['pending_weight_kg'] as num?)?.toDouble(), pendingWeightAt: DateTime.tryParse('${raw['pending_weight_at']}'), reRings: (raw['re_rings'] as num?)?.toInt() ?? 0, diff --git a/phone_app/lib/services/session_rules.dart b/phone_app/lib/services/session_rules.dart index 3e582ba..b04f36c 100644 --- a/phone_app/lib/services/session_rules.dart +++ b/phone_app/lib/services/session_rules.dart @@ -56,13 +56,19 @@ extension SessionRules on MorningSession { /// `yyyy-mm-dd` of [now], local. static String dateKey(DateTime now) => now.toIso8601String().substring(0, 10); - /// Records a typed, already-validated weight. + /// Records the check-in: a typed, already-validated weight and the + /// energy tap. /// - /// Pending until the bathroom tag confirms it; without a bathroom tag it - /// is the whole first leg. - String enterWeight(double kg, DateTime now) { + /// The weight is pending until the bathroom tag confirms it; without a + /// bathroom tag it is the whole first leg. The energy rating is not proof + /// of anything — it is the one number worth a tap while standing there. + String enterCheckIn(double kg, int energyLevel, DateTime now) { if (currentLeg != SessionLeg.bathroomIn) return 'Already weighed in'; + if (energyLevel < minEnergy || energyLevel > maxEnergy) { + return 'Pick your energy ($minEnergy-$maxEnergy)'; + } updatedAt = now; + energy = energyLevel; if (!bathroomTagRequired) { weightKg = kg; _pass(SessionLeg.bathroomIn, now, LegPass.tag); diff --git a/phone_app/lib/services/session_sync.dart b/phone_app/lib/services/session_sync.dart index e95e3b7..b3f32a1 100644 --- a/phone_app/lib/services/session_sync.dart +++ b/phone_app/lib/services/session_sync.dart @@ -27,6 +27,19 @@ const sessionsField = 'morning_sessions'; /// number (diet-guard's budget) should not have to know the session shape. const latestWeightField = 'latest_weight_kg'; +/// Field holding `{date, level}` of the newest energy tap, for the same +/// reason as [latestWeightField]. +const latestEnergyField = 'latest_energy'; + +/// Field holding the live session while one runs, `{}` once it is over. +/// +/// This is how the PC tells "started, not finished" from "never started" +/// and defers the workout lock only while the morning is actually being +/// earned. Kept apart from [sessionsField] because the receiving side +/// merges that one into history, and a live session in history would make +/// `SessionHandoff.afterDismiss` refuse to start the morning it belongs to. +const currentSessionField = 'current_session'; + /// How many finished sessions ride on the record. const sessionsCarried = 14; @@ -43,12 +56,34 @@ Future stageSessionData( tags.tagIds, configured: tags.isConfigured, ); - final history = await (store ?? SessionStore()).history(); + final sessions = store ?? SessionStore(); + final current = await sessions.loadCurrent(); + final history = await sessions.history(); + if (current != null) { + await stageField( + service, + currentSessionField, + current.updatedAt.toUtc().toIso8601String(), + current.toJson(), + configured: true, + ); + } if (history.isEmpty) return; final newest = history .map((s) => s.updatedAt) .reduce((a, b) => a.isAfter(b) ? a : b); final stamp = newest.toUtc().toIso8601String(); + if (current == null) { + // Cleared with the finish stamp, which is strictly newer than any + // stamp the live session was staged under (stageField skips on >=). + await stageField( + service, + currentSessionField, + stamp, + const {}, + configured: true, + ); + } await stageField( service, sessionsField, @@ -57,10 +92,17 @@ Future stageSessionData( configured: true, ); final weighed = history.where((s) => s.weightKg != null).firstOrNull; - if (weighed == null) return; - await stageField(service, latestWeightField, stamp, { - 'date': weighed.date, - 'kg': weighed.weightKg, + if (weighed != null) { + await stageField(service, latestWeightField, stamp, { + 'date': weighed.date, + 'kg': weighed.weightKg, + }, configured: true); + } + final rated = history.where((s) => s.energy != null).firstOrNull; + if (rated == null) return; + await stageField(service, latestEnergyField, stamp, { + 'date': rated.date, + 'level': rated.energy, }, configured: true); } @@ -68,6 +110,8 @@ Future stageSessionData( /// /// Sessions merge per date rather than replace: two installs' mornings end /// up in one list, and a peer's shorter list cannot truncate ours. +/// [currentSessionField] is deliberately not applied: a live session is +/// never restored from a peer (see the field's doc). Future applySyncedSessionData( AlarmSyncService service, { SessionStore? store, diff --git a/phone_app/lib/services/weight_entry.dart b/phone_app/lib/services/weight_entry.dart index 279eb4c..aae2721 100644 --- a/phone_app/lib/services/weight_entry.dart +++ b/phone_app/lib/services/weight_entry.dart @@ -1,4 +1,4 @@ -/// Validation for the morning weigh-in. +/// Validation for the morning check-in: the weigh-in and the energy tap. /// /// A typed number, not a Bluetooth scale — the ring path takes no plugins. /// Two gates stand in for the hardware: the value has to be plausible, and @@ -18,6 +18,13 @@ const double maxWeightJumpKg = 3; /// How long a typed weight stays valid before the bathroom tag confirms it. const Duration confirmWindow = Duration(minutes: 2); +/// Lowest energy rating; five taps, no typing, so nothing to validate beyond +/// the range. +const int minEnergy = 1; + +/// Highest energy rating. +const int maxEnergy = 5; + /// What a weigh-in attempt came to. enum WeightVerdict { /// Accepted. diff --git a/phone_app/scripts/__init__.py b/phone_app/scripts/__init__.py new file mode 100644 index 0000000..a9b6db2 --- /dev/null +++ b/phone_app/scripts/__init__.py @@ -0,0 +1 @@ +"""Emulator drivers for the morning session (see ``emu_morning.py``).""" diff --git a/phone_app/scripts/emu_adb.py b/phone_app/scripts/emu_adb.py new file mode 100644 index 0000000..8a2260b --- /dev/null +++ b/phone_app/scripts/emu_adb.py @@ -0,0 +1,20 @@ +"""The one adb call the emulator drivers share.""" + +from __future__ import annotations + +import shutil +import subprocess + +ADB = shutil.which("adb") or "/usr/bin/adb" + + +def adb(serial: str, *args: str) -> bytes: + """Runs one adb command and returns its raw stdout.""" + return subprocess.run( + [ADB, "-s", serial, *args], capture_output=True, check=False + ).stdout + + +def adb_text(serial: str, *args: str) -> str: + """Runs one adb command and returns its stdout as text.""" + return adb(serial, *args).decode(errors="replace") diff --git a/phone_app/scripts/emu_morning.py b/phone_app/scripts/emu_morning.py index bdc2f10..c6f6054 100755 --- a/phone_app/scripts/emu_morning.py +++ b/phone_app/scripts/emu_morning.py @@ -9,6 +9,7 @@ and types the challenge answers it reads back from logcat. Everything else scripts/emu_morning.py dismiss # tag 1, walk, tag 2, 3 answers scripts/emu_morning.py tag bath # one session tag scripts/emu_morning.py rering # fire the deadline alarm now + scripts/emu_morning.py energy 3 # tap energy chip 1-5 scripts/emu_morning.py weight 72.4 # type a weight and submit scripts/emu_morning.py home # leave the app (home gesture) scripts/emu_morning.py launch # tap the launcher icon @@ -24,40 +25,15 @@ from __future__ import annotations import argparse from pathlib import Path import re -import shutil -import struct -import subprocess import sys import time +from emu_adb import adb, adb_text +from emu_screen import tap_energy, type_and_submit + PACKAGE = "com.kuhy.wake_alarm_sync" FILES = f"/sdcard/Android/data/{PACKAGE}/files" -KEY_ENTER = "66" -KEY_DEL = "67" -KEY_CTRL_A = ("113", "29") ANSWER_RE = re.compile(r"answer=(\S+)") -# The FilledButton's gold, as (min red, green range, max blue). -GOLD_RED_MIN = 170 -GOLD_GREEN = (120, 170) -GOLD_BLUE_MAX = 90 -# Columns sampled per row, and gold samples that make a row "button". -SAMPLE_STEP = 4 -GOLD_HITS_MIN = 20 -# The text field sits this fraction of the screen height above the button. -FIELD_ABOVE_BUTTON = 0.079 -ADB = shutil.which("adb") or "/usr/bin/adb" - - -def adb(serial: str, *args: str) -> bytes: - """Runs one adb command and returns its raw stdout.""" - return subprocess.run( - [ADB, "-s", serial, *args], capture_output=True, check=False - ).stdout - - -def adb_text(serial: str, *args: str) -> str: - """Runs one adb command and returns its stdout as text.""" - return adb(serial, *args).decode(errors="replace") def write_file(serial: str, name: str, content: str) -> None: @@ -106,66 +82,6 @@ def screenshot(serial: str, path: Path) -> None: path.write_bytes(adb(serial, "exec-out", "screencap", "-p")) -def raw_screen(serial: str) -> tuple[int, int, bytes]: - """The screen as (width, height, RGBA bytes), from screencap's raw dump.""" - data = adb(serial, "exec-out", "screencap") - width, height = struct.unpack(" tuple[int, int] | None: - """(row, screen height) of the gold FilledButton, or None when absent.""" - width, height, pixels = raw_screen(serial) - rows = [] - for y in range(height): - hits = 0 - for x in range(0, width, SAMPLE_STEP): - i = (y * width + x) * 4 - red, green, blue = pixels[i], pixels[i + 1], pixels[i + 2] - if ( - red > GOLD_RED_MIN - and GOLD_GREEN[0] < green < GOLD_GREEN[1] - and blue < GOLD_BLUE_MAX - ): - hits += 1 - if hits > GOLD_HITS_MIN: - rows.append(y) - if not rows: - return None - return (rows[0] + rows[-1]) // 2, height - - -def focus_field(serial: str) -> bool: - """Taps the text field sitting above the submit button.""" - found = find_button_y(serial) - if found is None: - return False - y, height = found - width = raw_screen(serial)[0] - field_y = int(y - height * FIELD_ABOVE_BUTTON) - adb(serial, "shell", "input", "tap", str(width // 2), str(field_y)) - time.sleep(1) - return True - - -def type_and_submit(serial: str, text: str) -> None: - """Focuses the field, replaces its content with [text], presses Enter.""" - if not focus_field(serial): - msg = "no submit button on screen" - raise SystemExit(msg) - adb(serial, "shell", "input", "keycombination", *KEY_CTRL_A) - adb(serial, "shell", "input", "keyevent", KEY_DEL) - adb(serial, "shell", "input", "text", text) - time.sleep(0.5) - adb(serial, "shell", "input", "keyevent", KEY_ENTER) - time.sleep(2) - - def log_text(serial: str, log: Path | None) -> str: """The flutter run log, or the device's flutter logcat.""" return log.read_text() if log else adb_text(serial, "logcat", "-d", "-s", "flutter") @@ -214,6 +130,7 @@ def main(argv: list[str]) -> int: sub.add_parser("home") sub.add_parser("launch") sub.add_parser("weight").add_argument("kg") + sub.add_parser("energy").add_argument("level", type=int, choices=range(1, 6)) sub.add_parser("shot").add_argument("path", type=Path) args = parser.parse_args(argv) if args.cmd == "dismiss": @@ -228,6 +145,8 @@ def main(argv: list[str]) -> int: launch(args.serial) elif args.cmd == "weight": type_and_submit(args.serial, args.kg) + elif args.cmd == "energy": + tap_energy(args.serial, args.level) elif args.cmd == "shot": screenshot(args.serial, args.path) return 0 diff --git a/phone_app/scripts/emu_screen.py b/phone_app/scripts/emu_screen.py new file mode 100644 index 0000000..e420a41 --- /dev/null +++ b/phone_app/scripts/emu_screen.py @@ -0,0 +1,106 @@ +"""Pixel-hunting on the emulator screen for ``emu_morning.py``. + +Finds the check-in's submit button in ``screencap``'s raw RGBA dump by its +gold colour and taps relative to it: the weight field above it, the energy +chips above that. No image library. Split out of ``emu_morning.py`` to keep +both under the shared 250-line cap. +""" + +from __future__ import annotations + +import struct +import time + +from emu_adb import adb + +KEY_ENTER = "66" +KEY_DEL = "67" +KEY_CTRL_A = ("113", "29") +# The FilledButton's gold, as (min red, green range, max blue). +GOLD_RED_MIN = 170 +GOLD_GREEN = (120, 170) +GOLD_BLUE_MAX = 90 +# Columns sampled per row, and gold samples that make a row "button". +SAMPLE_STEP = 4 +GOLD_HITS_MIN = 20 +# The text field sits this fraction of the screen height above the button. +FIELD_ABOVE_BUTTON = 0.079 +# The energy chip row sits above the field; chips are spread edge to edge +# inside the 24 dp page padding, so chip n's centre is a fixed fraction of +# the width. Both tuned on the Pixel 6a AVD like FIELD_ABOVE_BUTTON. +ENERGY_ABOVE_BUTTON = 0.135 +ENERGY_CHIP_X = (0.10, 0.30, 0.50, 0.70, 0.90) + + +def raw_screen(serial: str) -> tuple[int, int, bytes]: + """The screen as (width, height, RGBA bytes), from screencap's raw dump.""" + data = adb(serial, "exec-out", "screencap") + width, height = struct.unpack(" tuple[int, int] | None: + """(row, screen height) of the gold FilledButton, or None when absent.""" + width, height, pixels = raw_screen(serial) + rows = [] + for y in range(height): + hits = 0 + for x in range(0, width, SAMPLE_STEP): + i = (y * width + x) * 4 + red, green, blue = pixels[i], pixels[i + 1], pixels[i + 2] + if ( + red > GOLD_RED_MIN + and GOLD_GREEN[0] < green < GOLD_GREEN[1] + and blue < GOLD_BLUE_MAX + ): + hits += 1 + if hits > GOLD_HITS_MIN: + rows.append(y) + if not rows: + return None + return (rows[0] + rows[-1]) // 2, height + + +def focus_field(serial: str) -> bool: + """Taps the text field sitting above the submit button.""" + found = find_button_y(serial) + if found is None: + return False + y, height = found + width = raw_screen(serial)[0] + field_y = int(y - height * FIELD_ABOVE_BUTTON) + adb(serial, "shell", "input", "tap", str(width // 2), str(field_y)) + time.sleep(1) + return True + + +def tap_energy(serial: str, level: int) -> None: + """Taps energy chip [level] (1-5) in the row above the weight field.""" + found = find_button_y(serial) + if found is None: + msg = "no submit button on screen" + raise SystemExit(msg) + y, height = found + width = raw_screen(serial)[0] + chip_x = int(width * ENERGY_CHIP_X[level - 1]) + chip_y = int(y - height * ENERGY_ABOVE_BUTTON) + adb(serial, "shell", "input", "tap", str(chip_x), str(chip_y)) + time.sleep(1) + + +def type_and_submit(serial: str, text: str) -> None: + """Focuses the field, replaces its content with [text], presses Enter.""" + if not focus_field(serial): + msg = "no submit button on screen" + raise SystemExit(msg) + adb(serial, "shell", "input", "keycombination", *KEY_CTRL_A) + adb(serial, "shell", "input", "keyevent", KEY_DEL) + adb(serial, "shell", "input", "text", text) + time.sleep(0.5) + adb(serial, "shell", "input", "keyevent", KEY_ENTER) + time.sleep(2) diff --git a/phone_app/test/home_history_card_test.dart b/phone_app/test/home_history_card_test.dart index adcdaef..7317c6a 100644 --- a/phone_app/test/home_history_card_test.dart +++ b/phone_app/test/home_history_card_test.dart @@ -57,6 +57,10 @@ void main() { ), '09-14 … 0 re-rings', ); + expect( + describeMorning(_session(DateTime(2026, 9, 13, 8), kg: 72)..energy = 4), + '09-13 ✓ 0 re-rings · 72.0 kg · energy 4/5', + ); }); testWidgets('is absent until a morning has finished', (tester) async { diff --git a/phone_app/test/ring_session_handoff_test.dart b/phone_app/test/ring_session_handoff_test.dart index 005ffc8..5d3be05 100644 --- a/phone_app/test/ring_session_handoff_test.dart +++ b/phone_app/test/ring_session_handoff_test.dart @@ -108,12 +108,14 @@ void main() { // Pixel showed a session that never heard a single tag (2026-09-16). final answers = await pumpApp(tester); await dismiss(tester, answers); + await tester.tap(find.widgetWithText(ChoiceChip, '4')); + await tester.pump(); await tester.enterText(find.byType(TextField), '72'); await tester.tap(find.text('Log weight')); await tester.pump(); await tapTag(tester, 'bath'); await tester.pump(); - expect(find.text('Weigh in — 72.0 kg'), findsOneWidget); + expect(find.text('Weigh in — 72.0 kg · energy 4/5'), findsOneWidget); expect(find.textContaining('tag counts in'), findsOneWidget); }); diff --git a/phone_app/test/services/session_notice_test.dart b/phone_app/test/services/session_notice_test.dart index 15c5d82..ded2441 100644 --- a/phone_app/test/services/session_notice_test.dart +++ b/phone_app/test/services/session_notice_test.dart @@ -42,7 +42,7 @@ void main() { test('a finished session drops the deadline and posts no notice', () async { final s = fresh() - ..enterWeight(72, t0) + ..enterCheckIn(72, 3, t0) ..onTag(SessionTagSlot.bathroom, t0) ..onTag(SessionTagSlot.bathroom, t0.add(const Duration(minutes: 9))) ..onTag(SessionTagSlot.wardrobe, t0) diff --git a/phone_app/test/services/session_rules_test.dart b/phone_app/test/services/session_rules_test.dart index 1dcfaa6..f8b9958 100644 --- a/phone_app/test/services/session_rules_test.dart +++ b/phone_app/test/services/session_rules_test.dart @@ -14,7 +14,7 @@ MorningSession fresh({SessionTagSettings tags = allTags, bool nfc = true}) => /// Weighs in and taps the bathroom tag, leaving the shower leg live. MorningSession inBathroom() => fresh() - ..enterWeight(72, t0) + ..enterCheckIn(72, 3, t0) ..onTag(SessionTagSlot.bathroom, t0.add(const Duration(seconds: 30))); void main() { @@ -39,7 +39,7 @@ void main() { final s = fresh(nfc: false); expect(s.bathroomTagRequired, isFalse); expect(s.currentLeg, SessionLeg.bathroomIn); - s.enterWeight(70, t0); + s.enterCheckIn(70, 3, t0); expect(s.outcome, SessionOutcome.completed); expect(s.weightKg, 70); }); @@ -48,7 +48,7 @@ void main() { group('weigh-in', () { test('stays pending until the bathroom tag confirms it', () { final s = fresh(); - expect(s.enterWeight(72, t0), contains('tap the bathroom tag')); + expect(s.enterCheckIn(72, 3, t0), contains('tap the bathroom tag')); expect(s.weightKg, isNull); expect(s.pendingWeightKg, 72); s.onTag(SessionTagSlot.bathroom, t0.add(const Duration(minutes: 1))); @@ -64,7 +64,7 @@ void main() { }); test('a late tag discards the weight', () { - final s = fresh()..enterWeight(72, t0); + final s = fresh()..enterCheckIn(72, 3, t0); final late = t0.add(const Duration(minutes: 2, seconds: 1)); expect(s.onTag(SessionTagSlot.bathroom, late), contains('expired')); expect(s.pendingWeightKg, isNull); @@ -72,7 +72,7 @@ void main() { }); test('the tick expires a pending weight too', () { - final s = fresh()..enterWeight(72, t0); + final s = fresh()..enterCheckIn(72, 3, t0); final tick = s.tick(t0.add(const Duration(minutes: 3))); expect(tick.changed, isTrue); expect(tick.note, contains('expired')); @@ -81,7 +81,7 @@ void main() { test('is refused once the leg has passed', () { final s = inBathroom(); - expect(s.enterWeight(80, t0), 'Already weighed in'); + expect(s.enterCheckIn(80, 3, t0), 'Already weighed in'); expect(s.weightKg, 72); }); }); diff --git a/phone_app/test/services/session_sync_live_test.dart b/phone_app/test/services/session_sync_live_test.dart new file mode 100644 index 0000000..2dbc057 --- /dev/null +++ b/phone_app/test/services/session_sync_live_test.dart @@ -0,0 +1,101 @@ +import 'package:crdt_sync/crdt_sync.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:wake_alarm_sync/services/alarm_sync_service.dart'; +import 'package:wake_alarm_sync/services/morning_session.dart'; +import 'package:wake_alarm_sync/services/session_json.dart'; +import 'package:wake_alarm_sync/services/session_rules.dart'; +import 'package:wake_alarm_sync/services/session_store.dart'; +import 'package:wake_alarm_sync/services/session_sync.dart'; +import 'package:wake_alarm_sync/services/session_tag_settings.dart'; + +/// The live-session and energy halves of `session_sync.dart`: what the PC +/// reads to tell "started, not finished" from "never started". +class _MemPersistence implements LogPersistence { + String? text; + + @override + Future read() async => text; + + @override + Future write(String value) async => text = value; +} + +Future _open() => + AlarmSyncService.open(persistence: _MemPersistence(), deviceId: 'me'); + +MorningSession _session(DateTime at, {int? energy}) => SessionRules.start( + now: at, + tags: const SessionTagSettings(), + nfcAvailable: false, +)..energy = energy; + +void main() { + setUp(() => SharedPreferences.setMockInitialValues({})); + + group('current_session', () { + test('a live session is staged even with no history yet', () async { + final store = SessionStore(); + await store.save(_session(DateTime(2026, 9, 19, 8, 1))); + final service = await _open(); + await stageSessionData(service); + final live = service.field(currentSessionField)?.$1; + expect(live, isA>()); + expect((live! as Map)['outcome'], 'active'); + expect(service.field(sessionsField), isNull); + }); + + test('is cleared with a newer stamp once archived', () async { + final store = SessionStore(); + final session = _session(DateTime(2026, 9, 19, 8, 1)); + await store.save(session); + final service = await _open(); + await stageSessionData(service); + final liveStamp = service.field(currentSessionField)!.$2; + session + ..outcome = SessionOutcome.completed + ..updatedAt = DateTime(2026, 9, 19, 8, 40); + await store.archive(session); + await stageSessionData(service); + final cleared = service.field(currentSessionField)!; + expect(cleared.$1, isEmpty); + expect(cleared.$2.wallTimeMs, greaterThan(liveStamp.wallTimeMs)); + }); + + test('is never applied from a peer', () async { + final service = await _open(); + await service.setFields({ + currentSessionField: _session(DateTime(2026, 9, 19, 8, 1)).toJson(), + }, Hlc.newTick('peer')); + await applySyncedSessionData(service); + expect(await SessionStore().loadCurrent(), isNull); + }); + }); + + group('latest_energy', () { + test('ships the newest rated morning', () async { + final store = SessionStore(); + await store.archive(_session(DateTime(2026, 9, 18, 8), energy: 2)); + await store.archive(_session(DateTime(2026, 9, 19, 8))); + final service = await _open(); + await stageSessionData(service); + expect(service.field(latestEnergyField)?.$1, { + 'date': '2026-09-18', + 'level': 2, + }); + }); + + test('is absent until a morning was rated', () async { + await SessionStore().archive(_session(DateTime(2026, 9, 19, 8))); + final service = await _open(); + await stageSessionData(service); + expect(service.field(latestEnergyField), isNull); + }); + + test('survives the JSON round trip', () { + final json = _session(DateTime(2026, 9, 19, 8), energy: 4).toJson(); + expect(json['energy'], 4); + expect(sessionFromJson(json)?.energy, 4); + }); + }); +} diff --git a/phone_app/test/session_screen_checkin_test.dart b/phone_app/test/session_screen_checkin_test.dart new file mode 100644 index 0000000..aebbb52 --- /dev/null +++ b/phone_app/test/session_screen_checkin_test.dart @@ -0,0 +1,143 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:wake_alarm_sync/screens/session_screen.dart'; +import 'package:wake_alarm_sync/services/alarm_scheduler.dart'; +import 'package:wake_alarm_sync/services/session_store.dart'; +import 'package:wake_alarm_sync/services/session_tag_settings.dart'; + +/// The check-in half of the session screen (energy + weight) and the two +/// publishes: split from `session_screen_test.dart` at the 250-line cap. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late DateTime now; + late List publishes; + late List storeHadSession; + Completer? holdFirst; + + void mockChannel() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel(AlarmScheduler.channelName), + (call) async => call.method == 'hasNfc' ? true : null, + ); + } + + Future tap(String tagId) async { + const codec = StandardMethodCodec(); + await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .handlePlatformMessage( + AlarmScheduler.channelName, + codec.encodeMethodCall(MethodCall('onNfcTag', tagId)), + (_) {}, + ); + } + + setUp(() async { + now = DateTime(2026, 9, 19, 8, 4); + publishes = []; + storeHadSession = []; + holdFirst = null; + SharedPreferences.setMockInitialValues({}); + mockChannel(); + await SessionTagSettings.replaceAll(['bath', 'ward', 'desk'], ''); + }); + + Future pumpSession(WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + routes: { + '/': (_) => SessionScreen( + clock: () => now, + publish: () async { + // What the real publisher stages: is the session on disk yet? + storeHadSession.add(await SessionStore().loadCurrent() != null); + final hold = holdFirst; + if (publishes.isEmpty && hold != null) { + publishes.add('start (held)'); + return await hold.future; + } + publishes.add(publishes.isEmpty ? 'start' : 'finish'); + return 'pushed'; + }, + ), + '/ring': (_) => const Scaffold(body: Text('RING')), + }, + ), + ); + await tester.pump(); + await tester.pump(); + } + + Future submit(WidgetTester tester, String kg) async { + await tester.enterText(find.byType(TextField), kg); + await tester.tap(find.text('Log weight')); + await tester.pump(); + } + + testWidgets('the start publish runs after the session is stored', ( + tester, + ) async { + await pumpSession(tester); + expect(publishes, ['start']); + expect(storeHadSession, [true]); + }); + + testWidgets('no energy tapped: the weight is refused and nothing sticks', ( + tester, + ) async { + await pumpSession(tester); + await submit(tester, '72'); + expect(find.textContaining('Tap your energy'), findsOneWidget); + expect(find.byType(TextField), findsOneWidget); + expect((await SessionStore().loadCurrent())?.pendingWeightKg, isNull); + }); + + testWidgets('energy then weight records both on the session', (tester) async { + await pumpSession(tester); + await tester.tap(find.widgetWithText(ChoiceChip, '5')); + await tester.pump(); + await submit(tester, '72'); + expect(find.textContaining('now tap the bathroom tag'), findsOneWidget); + final session = await SessionStore().loadCurrent(); + expect(session?.energy, 5); + expect(session?.pendingWeightKg, 72); + }); + + testWidgets('an unknown tag and bad weights are reported', (tester) async { + await pumpSession(tester); + await tester.tap(find.widgetWithText(ChoiceChip, '3')); + await tester.pump(); + await submit(tester, 'x'); + expect(find.textContaining('Enter your weight'), findsOneWidget); + await submit(tester, '1'); + expect(find.textContaining('not a body weight'), findsOneWidget); + expect(find.byType(TextField), findsOneWidget); + await tap('mystery'); + await tester.pump(); + expect(find.text('Unknown tag'), findsOneWidget); + }); + + testWidgets('the finish publish waits for a start publish in flight', ( + tester, + ) async { + // No session tags: the check-in is the whole morning, so it finishes + // seconds after it started, while the start publish may still be out. + await SessionTagSettings.replaceAll(['', '', ''], ''); + holdFirst = Completer(); + await pumpSession(tester); + await tester.tap(find.widgetWithText(ChoiceChip, '2')); + await tester.pump(); + await submit(tester, '72'); + await tester.pump(); + expect(publishes, ['start (held)']); + holdFirst!.complete('late'); + await tester.pump(); + await tester.pump(); + expect(publishes, ['start (held)', 'finish']); + }); +} diff --git a/phone_app/test/session_screen_relaunch_test.dart b/phone_app/test/session_screen_relaunch_test.dart index acf5b56..522f8bc 100644 --- a/phone_app/test/session_screen_relaunch_test.dart +++ b/phone_app/test/session_screen_relaunch_test.dart @@ -48,11 +48,11 @@ void main() { tags: await SessionTagSettings.load(), nfcAvailable: true, ) - ..enterWeight(72, at) + ..enterCheckIn(72, 3, at) ..onTag(SessionTagSlot.bathroom, at); await SessionStore().save(session); await pumpCold(tester); - expect(find.text('Weigh in — 72.0 kg'), findsOneWidget); + expect(find.text('Weigh in — 72.0 kg · energy 3/5'), findsOneWidget); expect(find.textContaining('tag counts in'), findsOneWidget); final notice = calls.lastWhere((c) => c.method == 'updateSessionNotice'); expect( @@ -69,7 +69,7 @@ void main() { now: now.subtract(const Duration(minutes: 30)), tags: const SessionTagSettings(), nfcAvailable: false, - )..enterWeight(72, now.subtract(const Duration(minutes: 29))); + )..enterCheckIn(72, 3, now.subtract(const Duration(minutes: 29))); expect(done.outcome, SessionOutcome.completed); await SessionStore().archive(done); await pumpCold(tester); diff --git a/phone_app/test/session_screen_test.dart b/phone_app/test/session_screen_test.dart index 8c4bddc..c69ff43 100644 --- a/phone_app/test/session_screen_test.dart +++ b/phone_app/test/session_screen_test.dart @@ -42,7 +42,6 @@ void main() { } List methods() => calls.map((c) => c.method).toList(); - Future tick(WidgetTester tester, [int seconds = 1]) async { for (var i = 0; i < seconds; i++) { await tester.pump(kSessionTick); @@ -79,7 +78,10 @@ void main() { await tester.pump(); } + /// Taps energy 3 (required since the check-in) and submits [kg]. Future logWeight(WidgetTester tester, String kg) async { + await tester.tap(find.widgetWithText(ChoiceChip, '3')); + await tester.pump(); await tester.enterText(find.byType(TextField), kg); await tester.tap(find.text('Log weight')); await tester.pump(); @@ -95,8 +97,7 @@ void main() { final deadline = now.add(MorningSession.legWindow).millisecondsSinceEpoch; final arm = calls.firstWhere((c) => c.method == 'armReRing'); expect(arm.arguments, {'atMillis': deadline}); - // The native record behind the launcher redirect and the notification - // follows the deadline in the same breath. + // The launcher-redirect record follows the deadline in the same breath. final notice = calls.firstWhere((c) => c.method == 'updateSessionNotice'); expect(notice.arguments, { 'date': '2026-09-17', @@ -131,7 +132,7 @@ void main() { expect(find.text('Not the bathroom tag'), findsOneWidget); await tap('bath'); await tester.pump(); - expect(find.text('Weigh in — 72.4 kg'), findsOneWidget); + expect(find.text('Weigh in — 72.4 kg · energy 3/5'), findsOneWidget); expect(find.textContaining('tag counts in 08:00'), findsOneWidget); await tap('bath'); await tester.pump(); @@ -140,14 +141,14 @@ void main() { now = now.add(const Duration(minutes: 9)); await tick(tester); expect(find.textContaining('Alarm returns in'), findsOneWidget); - await tap('bath'); - await tap('ward'); - await tap('desk'); + for (final id in ['bath', 'ward', 'desk']) { + await tap(id); + } await tester.pump(); await tester.pump(); expect(methods(), contains('finishSession')); expect(methods().last, 'closeRingScreen'); - expect(published, 1); + expect(published, 2); // once at start, once at finish final store = SessionStore(); expect(await store.loadCurrent(), isNull); final done = (await store.history()).single; @@ -235,16 +236,4 @@ void main() { expect(find.textContaining('now tap the bathroom tag'), findsOneWidget); expect(find.byType(TextField), findsNothing); }); - - testWidgets('an unknown tag and bad weights are reported', (tester) async { - await pumpSession(tester); - await logWeight(tester, 'x'); - expect(find.textContaining('Enter your weight'), findsOneWidget); - await logWeight(tester, '1'); - expect(find.textContaining('not a body weight'), findsOneWidget); - expect(find.byType(TextField), findsOneWidget); - await tap('mystery'); - await tester.pump(); - expect(find.text('Unknown tag'), findsOneWidget); - }); } diff --git a/pyproject.toml b/pyproject.toml index 0e50e39..2756bf2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -103,8 +103,10 @@ exclude = [".venv/"] # linted on its own. pylint otherwise infers the path from the files it is # given, so a script resolved its package imports only when a package file # happened to be in the same batch -- passing or failing by what else was -# staged. -init-hook = "import sys; sys.path.insert(0, '.')" +# staged. phone_app/scripts is there too: the emulator drivers import each +# other as siblings (`from emu_adb import adb`), which is how a script run +# by path resolves them, and pylint has no other way to see that directory. +init-hook = "import sys; sys.path.insert(0, '.'); sys.path.insert(0, 'phone_app/scripts')" analyse-fallback-blocks = true persistent = true jobs = 0 diff --git a/scripts/vmbox/guest_cases.sh b/scripts/vmbox/guest_cases.sh index a360436..c925c48 100755 --- a/scripts/vmbox/guest_cases.sh +++ b/scripts/vmbox/guest_cases.sh @@ -42,13 +42,30 @@ case_imports() { # install.sh writing the unit file is not the same as systemd running it, and # a timer that is present but disabled fails silently every morning. case_units() { - local name state - name="trigger-timer-enabled" - state="$(systemctl --user is-enabled wake-alarm-trigger.timer 2>&1)" - if [[ "$state" == "enabled" ]]; then + local timer state + for timer in wake-alarm-trigger.timer wake-alarm-session.timer; do + state="$(systemctl --user is-enabled "$timer" 2>&1)" + if [[ "$state" == "enabled" ]]; then + pass "${timer%.timer}-enabled" + else + fail "${timer%.timer}-enabled" "expected enabled, got '$state'" + fi + done +} + +# --- Case: the session refresh runs without a backend ------------------------ +# The guest has no Firebase config, so --refresh must degrade to "no verified +# morning session" (exit 1) rather than crash: that is what an unconfigured +# machine's timer does every two minutes. +case_session_refresh() { + local name="session-refresh-degrades" output + # Exit 1 is the expected verdict here ("nothing verifiable"), so capture + # first: piped straight into grep, pipefail would fail the case on it. + output="$("$PY" -m wake_alarm._session --refresh 2>/dev/null || true)" + if grep -q "no verified" <<<"$output"; then pass "$name" else - fail "$name" "expected enabled, got '$state'" + fail "$name" "--refresh did not degrade cleanly" fi } @@ -68,6 +85,7 @@ main() { case_imports case_units case_trigger_dry_run + case_session_refresh ;; *) echo "usage: $0 [all]" >&2 diff --git a/scripts/vmbox/harness.sh b/scripts/vmbox/harness.sh index 3eb6d80..8434ca6 100755 --- a/scripts/vmbox/harness.sh +++ b/scripts/vmbox/harness.sh @@ -113,10 +113,15 @@ run_install() { # so this must confirm success from the OUTPUT. Trusting the exit code let # a guest ImportError through as a pass, and the three cases downstream # then failed with no hint that the install was the cause. + # `vm repo` lands the tree at ~/ in the guest, whatever the + # host layout is: the 2026-09-11 home-tidy re-pointed this to ~/src and + # the gate went red for a week without anyone noticing, because the + # sentinel grep below matched vm's echo of the command line itself. + # Hence -x: only a line that IS the sentinel counts. vm run "$VM_NAME" \ - 'cd ~/src/wake-alarm && bash install.sh && echo INSTALL-OK' \ + 'cd ~/wake-alarm && bash install.sh && echo INSTALL-OK' \ >/tmp/wa-install.log 2>&1 || true - if ! grep -q 'INSTALL-OK' /tmp/wa-install.log; then + if ! grep -qx 'INSTALL-OK' /tmp/wa-install.log; then tail -20 /tmp/wa-install.log >&2 die "install.sh failed in the guest (full log: /tmp/wa-install.log)" fi @@ -130,7 +135,7 @@ run_cases() { step "Running the guest cases" local output output="$(vm run "$VM_NAME" \ - 'bash ~/src/wake-alarm/scripts/vmbox/guest_cases.sh all' 2>&1)" + 'bash ~/wake-alarm/scripts/vmbox/guest_cases.sh all' 2>&1)" echo "$output" grep -q "$GUEST_OK_SENTINEL" <<<"$output" } diff --git a/uninstall.sh b/uninstall.sh index 9883d8b..c635a53 100755 --- a/uninstall.sh +++ b/uninstall.sh @@ -75,8 +75,11 @@ remove_retired() { remove_trigger() { echo " Disabling the trigger timer" systemctl --user disable --now wake-alarm-trigger.timer >/dev/null 2>&1 || true + systemctl --user disable --now wake-alarm-session.timer >/dev/null 2>&1 || true rm -f "$SYSTEMD_USER_DIR/wake-alarm-trigger.service" \ - "$SYSTEMD_USER_DIR/wake-alarm-trigger.timer" + "$SYSTEMD_USER_DIR/wake-alarm-trigger.timer" \ + "$SYSTEMD_USER_DIR/wake-alarm-session.service" \ + "$SYSTEMD_USER_DIR/wake-alarm-session.timer" } main() { diff --git a/wake-alarm-session.service b/wake-alarm-session.service new file mode 100644 index 0000000..133acfc --- /dev/null +++ b/wake-alarm-session.service @@ -0,0 +1,11 @@ +[Unit] +Description=Wake Alarm morning-session refresh (the PC's copy of the phone's morning) +Documentation=https://github.com/kuhyx/wake-alarm + +[Service] +Type=oneshot +# Fetches the phone's morning session and rewrites the HMAC-signed +# ~/.local/state/wake_alarm/morning_session.json that screen-locker and +# leetcode-guard read to defer their locks while the morning is being earned. +# wake-alarm is the only Firebase reader; the consumers never touch the network. +ExecStart=/usr/bin/python -m wake_alarm._session --refresh diff --git a/wake-alarm-session.timer b/wake-alarm-session.timer new file mode 100644 index 0000000..78db91b --- /dev/null +++ b/wake-alarm-session.timer @@ -0,0 +1,16 @@ +[Unit] +Description=Wake Alarm morning-session refresh, every 2 min through the morning +Documentation=https://github.com/kuhyx/wake-alarm + +[Timer] +# Every two minutes from 07:00 to 10:58, then once at 11:00 (the carrot's end) +# so the last rewrite lands on the boundary. ~121 Firebase reads/day. +OnCalendar=*-*-* 07..10:00/2:00 +OnCalendar=*-*-* 11:00:00 +AccuracySec=1s +# A PC booted mid-morning refreshes at once; the consumers wait up to 30 s +# for exactly this catch-up before treating the file as stale. +Persistent=true + +[Install] +WantedBy=timers.target diff --git a/wake_alarm/_constants.py b/wake_alarm/_constants.py index 7274505..10a1055 100644 --- a/wake_alarm/_constants.py +++ b/wake_alarm/_constants.py @@ -7,6 +7,7 @@ say, and has the morning routine already started today. from __future__ import annotations +from datetime import timedelta from pathlib import Path # Days the wake alarm is active (Python weekday(): Mon=0 ... Sun=6) @@ -129,3 +130,23 @@ TRIGGER_CATCHUP_MINUTES: int = 60 # wake-alarm.service directly: the orchestrator runs the alarm first and the # workout lock after, so the two never fight for the fullscreen. TRIGGER_TARGET_UNIT: str = "morning-routine.service" + + +# --- Morning session (phone -> PC carrot) ---------------------------------- +# The phone publishes its morning session on the same alarm.json record; the +# PC distils it into one HMAC-signed file that screen-locker and leetcode-guard +# read with stdlib only. Both consumers hardcode this path -- keep in step. +MORNING_SESSION_FILE: Path = LOG_STATE_DIR / "morning_session.json" + +# crdt_sync field names on alarm.json (session_sync.dart owns the writer side). +SESSIONS_FIELD: str = "morning_sessions" +SESSION_CURRENT_FIELD: str = "current_session" +ENERGY_FIELD: str = "latest_energy" + +# A session that has started but not finished is exempt only for as long as an +# honest morning takes, and never past the cap: relative to the start, because +# an alarm earlier than 08:00 is legal and a fixed 09:30 would hand an aborted +# 06:15 session three hours of no lock. A completed session earns the flat end. +SESSION_PROVISIONAL_GRACE: timedelta = timedelta(minutes=90) +SESSION_PROVISIONAL_CAP: tuple[int, int] = (9, 30) +SESSION_COMPLETED_UNTIL: tuple[int, int] = (11, 0) diff --git a/wake_alarm/_energy.py b/wake_alarm/_energy.py new file mode 100644 index 0000000..0e5bdde --- /dev/null +++ b/wake_alarm/_energy.py @@ -0,0 +1,68 @@ +"""The phone's newest morning energy rating, read from the synced alarm record. + +The morning check-in (``phone_app``, ``DOCS-morning-session.md``) publishes +``latest_energy`` -- ``{"date": "yyyy-mm-dd", "level": 3}``, 1 to 5 -- on the +same ``alarm.json`` the alarm time and ``latest_weight_kg`` ride on. This is +the PC-side reader, the twin of ``_weight.py``: one number, no session shape. + +Firebase only, for the same reason as the weight: the GitHub mirror has no +per-field stamps and is on its way out. + + python -m wake_alarm._energy # prints {"date": ..., "level": ...} or nothing +""" + +from __future__ import annotations + +from dataclasses import dataclass +import json +import sys + +from wake_alarm._constants import ENERGY_FIELD +from wake_alarm._field_reader import latest_field, newest_field + +MIN_LEVEL = 1 +MAX_LEVEL = 5 + + +@dataclass(frozen=True) +class Energy: + """One rating: the local date it was given and the 1-5 level tapped.""" + + date: str + level: int + device_id: str + + +def _parse_energy(value: object, device_id: str) -> Energy | None: + """The rating out of one device's ``latest_energy`` value, or None.""" + if not isinstance(value, dict): + return None + date, level = value.get("date"), value.get("level") + if not isinstance(date, str) or isinstance(level, bool): + return None + if not isinstance(level, int) or not MIN_LEVEL <= level <= MAX_LEVEL: + return None + return Energy(date=date, level=level, device_id=device_id) + + +def newest_energy(published: list[tuple[str, str]]) -> Energy | None: + """The most recently stamped rating across every device's log.""" + return newest_field(published, ENERGY_FIELD, _parse_energy) + + +def latest_energy() -> Energy | None: + """The phone's newest rating from Firebase, or None when unreadable.""" + return latest_field(ENERGY_FIELD, _parse_energy, what="energy rating") + + +def main() -> int: + """Print the newest rating as JSON; exit 1 when there is none.""" + energy = latest_energy() + if energy is None: + return 1 + sys.stdout.write(json.dumps({"date": energy.date, "level": energy.level}) + "\n") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/wake_alarm/_field_reader.py b/wake_alarm/_field_reader.py new file mode 100644 index 0000000..495dbb9 --- /dev/null +++ b/wake_alarm/_field_reader.py @@ -0,0 +1,73 @@ +"""Pick one field's newest value out of every device's synced log. + +``_weight`` and ``_energy`` each read a single ``{date, value}`` pair the +phone publishes on ``alarm.json``; everything but the parse is the same, and +pylint rightly flagged the copy. This is the shared half: JSON in per device, +one winner out, ranked by the field's HLC with ``device_id`` breaking exact +ties so two installs never alternate between reads. +""" + +from __future__ import annotations + +import json +import logging +from typing import TYPE_CHECKING, Any + +from wake_alarm._alarm_payload import extract_hlc +from wake_alarm._sync_transport import fetch_firebase_logs + +if TYPE_CHECKING: + from collections.abc import Callable + +_logger = logging.getLogger(__name__) + + +def field_value(log: dict[str, Any], field: str) -> object: + """The value half of one ``(value, HLC)`` pair on the alarm record, 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 newest_field[T]( + published: list[tuple[str, str]], + field: str, + parse: Callable[[object, str], T | None], +) -> T | None: + """The most recently stamped ``field`` across every device's log. + + Args: + published: ``(device_id, raw_json)`` per device, in any order. + field: The crdt_sync field name. + parse: Turns the raw value plus its device id into the result, or + None when the shape is not usable -- an unusable device never + outranks a usable one. + """ + ranked: list[tuple[str, str, T]] = [] + for device_id, raw in published: + try: + log = json.loads(raw) + except json.JSONDecodeError: + continue + if not isinstance(log, dict): + continue + parsed = parse(field_value(log, field), device_id) + if parsed is not None: + ranked.append((extract_hlc(log, field), device_id, parsed)) + if not ranked: + return None + return max(ranked, key=lambda entry: (entry[0], entry[1]))[2] + + +def latest_field[T]( + field: str, parse: Callable[[object, str], T | None], *, what: str +) -> T | None: + """``newest_field`` over a live Firebase read; None when unreadable.""" + published = fetch_firebase_logs() + if published is None: + return None + value = newest_field(published, field, parse) + if value is None: + _logger.info("No %s published yet", what) + return value diff --git a/wake_alarm/_session.py b/wake_alarm/_session.py new file mode 100644 index 0000000..f68bfb7 --- /dev/null +++ b/wake_alarm/_session.py @@ -0,0 +1,228 @@ +"""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()) diff --git a/wake_alarm/_session_payload.py b/wake_alarm/_session_payload.py new file mode 100644 index 0000000..0d0d8a9 --- /dev/null +++ b/wake_alarm/_session_payload.py @@ -0,0 +1,171 @@ +"""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] diff --git a/wake_alarm/_weight.py b/wake_alarm/_weight.py index 71d2a16..57bc1c1 100644 --- a/wake_alarm/_weight.py +++ b/wake_alarm/_weight.py @@ -16,14 +16,9 @@ from __future__ import annotations from dataclasses import dataclass import json -import logging import sys -from typing import Any -from wake_alarm._alarm_payload import extract_hlc -from wake_alarm._sync_transport import fetch_firebase_logs - -_logger = logging.getLogger(__name__) +from wake_alarm._field_reader import latest_field, newest_field WEIGHT_FIELD = "latest_weight_kg" @@ -37,12 +32,8 @@ class Weight: device_id: str -def _parse_weight(log: dict[str, Any], device_id: str) -> Weight | None: - """The weigh-in on one device's log, or None if absent or malformed.""" - record = log.get("alarm") - fields = record.get("fields") if isinstance(record, dict) else None - pair = fields.get(WEIGHT_FIELD) if isinstance(fields, dict) else None - value = pair[0] if isinstance(pair, list) and pair else None +def _parse_weight(value: object, device_id: str) -> Weight | None: + """The weigh-in out of one device's ``latest_weight_kg`` value, or None.""" if not isinstance(value, dict): return None date, kg = value.get("date"), value.get("kg") @@ -54,42 +45,13 @@ def _parse_weight(log: dict[str, Any], device_id: str) -> Weight | None: def newest_weight(published: list[tuple[str, str]]) -> Weight | None: - """Pick the most recently stamped weigh-in out of every device's log. - - Ranked by the field's HLC like the alarm is, with ``device_id`` breaking - exact ties, so two installs never alternate between reads. - - Args: - published: ``(device_id, raw_json)`` per device, in any order. - - Returns: - The newest weigh-in, or None if no device published a usable one. - """ - ranked: list[tuple[str, str, Weight]] = [] - for device_id, raw in published: - try: - log = json.loads(raw) - except json.JSONDecodeError: - continue - if not isinstance(log, dict): - continue - weight = _parse_weight(log, device_id) - if weight is not None: - ranked.append((extract_hlc(log, WEIGHT_FIELD), device_id, weight)) - if not ranked: - return None - return max(ranked, key=lambda entry: (entry[0], entry[1]))[2] + """The most recently stamped weigh-in across every device's log.""" + return newest_field(published, WEIGHT_FIELD, _parse_weight) def latest_weight() -> Weight | None: """The phone's newest weigh-in from Firebase, or None when unreadable.""" - published = fetch_firebase_logs() - if published is None: - return None - weight = newest_weight(published) - if weight is None: - _logger.info("No weigh-in published yet") - return weight + return latest_field(WEIGHT_FIELD, _parse_weight, what="weigh-in") def main() -> int: diff --git a/wake_alarm/tests/conftest.py b/wake_alarm/tests/conftest.py index af0b8b7..18122ee 100644 --- a/wake_alarm/tests/conftest.py +++ b/wake_alarm/tests/conftest.py @@ -37,6 +37,10 @@ def _hermetic(tmp_path: Path) -> Generator[None]: with ( patch("gatelock.log_integrity.DEFAULT_HMAC_KEY_FILE", key_file), patch("wake_alarm._state.WAKE_STATE_FILE", tmp_path / "wake_state.json"), + patch( + "wake_alarm._session.MORNING_SESSION_FILE", + tmp_path / "morning_session.json", + ), # The shared free-day pool lives under ~/.local/share, outside every # path above. Without this, a real free day on the developer's # machine would silence the alarm in every trigger test here. diff --git a/wake_alarm/tests/test_energy.py b/wake_alarm/tests/test_energy.py new file mode 100644 index 0000000..e76e6b1 --- /dev/null +++ b/wake_alarm/tests/test_energy.py @@ -0,0 +1,83 @@ +"""The PC-side reader for the phone's morning energy tap.""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING +from unittest.mock import patch + +from wake_alarm._energy import Energy, latest_energy, main, newest_energy + +if TYPE_CHECKING: + import pytest + + +def _log( + level: object, hlc: str = "2026-09-19T06:20:00.000Z-0000-phone", **extra: object +) -> str: + value = {"date": "2026-09-19", "level": level, **extra} + return json.dumps({"alarm": {"fields": {"latest_energy": [value, hlc]}}}) + + +class TestNewestEnergy: + """Ranking and parsing across devices.""" + + def test_picks_the_newest_stamp(self) -> None: + published = [ + ("old", _log(2, "2026-09-10T06:20:00.000Z-0000-old")), + ("new", _log(4, "2026-09-19T06:20:00.000Z-0000-new")), + ] + assert newest_energy(published) == Energy("2026-09-19", 4, "new") + assert newest_energy(list(reversed(published))) == Energy( + "2026-09-19", 4, "new" + ) + + def test_device_id_breaks_an_exact_tie(self) -> None: + published = [("b", _log(3)), ("a", _log(1))] + assert newest_energy(published) == Energy("2026-09-19", 3, "b") + + def test_skips_every_malformed_shape(self) -> None: + """Only a 1-5 int with a date string counts; never a bool or 0/6.""" + bad = [ + ("json", "not json"), + ("list", "[1]"), + ("no-alarm", "{}"), + ("no-fields", json.dumps({"alarm": {}})), + ("no-pair", json.dumps({"alarm": {"fields": {}}})), + ("empty-pair", json.dumps({"alarm": {"fields": {"latest_energy": []}}})), + ("not-dict", json.dumps({"alarm": {"fields": {"latest_energy": [3]}}})), + ( + "no-date", + json.dumps({"alarm": {"fields": {"latest_energy": [{"level": 3}]}}}), + ), + ("bool", _log(level=True)), + ("float", _log(3.5)), + ("low", _log(0)), + ("high", _log(6)), + ] + assert newest_energy(bad) is None + assert newest_energy([*bad, ("ok", _log(5))]) == Energy("2026-09-19", 5, "ok") + + +class TestLatestEnergy: + """The fetch wrapper and the CLI.""" + + def test_unreadable_backend_is_none(self) -> None: + with patch("wake_alarm._field_reader.fetch_firebase_logs", return_value=None): + assert latest_energy() is None + + def test_no_rating_yet_is_none(self) -> None: + with patch("wake_alarm._field_reader.fetch_firebase_logs", return_value=[]): + assert latest_energy() is None + + def test_main_prints_json(self, capsys: pytest.CaptureFixture[str]) -> None: + with patch( + "wake_alarm._field_reader.fetch_firebase_logs", + return_value=[("p", _log(3))], + ): + assert main() == 0 + assert json.loads(capsys.readouterr().out) == {"date": "2026-09-19", "level": 3} + + def test_main_exits_1_with_nothing(self) -> None: + with patch("wake_alarm._field_reader.fetch_firebase_logs", return_value=None): + assert main() == 1 diff --git a/wake_alarm/tests/test_session.py b/wake_alarm/tests/test_session.py new file mode 100644 index 0000000..396af5f --- /dev/null +++ b/wake_alarm/tests/test_session.py @@ -0,0 +1,221 @@ +"""The signed morning-session file the lock consumers read.""" + +from __future__ import annotations + +from datetime import datetime, timedelta +import json +from pathlib import Path +from typing import TYPE_CHECKING +from unittest.mock import patch + +from wake_alarm import _session +from wake_alarm._session import ( + describe, + exempt_until, + main, + read_entry, + refresh, + to_entry, + write_entry, +) +from wake_alarm._session_payload import SessionRecord + +if TYPE_CHECKING: + import pytest + +NOW = datetime(2026, 9, 19, 8, 30).astimezone() + + +def _record( + outcome: str = "completed", + started: str = "2026-09-19T08:01:00.000", + finished: str | None = "2026-09-19T08:40:00.000", + date: str = "2026-09-19", +) -> SessionRecord: + return SessionRecord( + date=date, + outcome=outcome, + started_at=started, + finished_at=finished, + energy=3, + weight_kg=73.8, + re_rings=0, + device_id="p", + published_at=1.0, + ) + + +def _local(iso: str) -> datetime: + return datetime.fromisoformat(iso).astimezone() + + +class TestExemptUntil: + """The policy, signed in once so no consumer re-derives it.""" + + def test_completed_in_time_earns_eleven(self) -> None: + assert exempt_until(_record()) == _local("2026-09-19T11:00:00") + + def test_live_is_start_plus_grace_capped(self) -> None: + assert exempt_until(_record("in_progress", finished=None)) == _local( + "2026-09-19T09:30:00" + ) + early = _record("in_progress", started="2026-09-19T06:00:00.000", finished=None) + assert exempt_until(early) == _local("2026-09-19T07:30:00") + + def test_completed_after_the_cutoff_earns_nothing(self) -> None: + """A phone switched on at noon completes the legs then; no carrot.""" + assert exempt_until(_record(finished="2026-09-19T12:33:00.000")) is None + # An aborted 06:00 alarm "completed" at 10:00 locks no later than today. + late_early = _record( + started="2026-09-19T06:05:00.000", finished="2026-09-19T10:00:00.000" + ) + assert exempt_until(late_early) is None + + def test_failed_and_unreadable_instants_earn_nothing(self) -> None: + assert exempt_until(_record("failed")) is None + assert exempt_until(_record(started="when?")) is None + assert exempt_until(_record(finished=None)) is None + assert exempt_until(_record(finished="later")) is None + + +class TestEntryFile: + """Writing, signing, reading back.""" + + def test_round_trip_is_verified(self) -> None: + entry = to_entry(_record(), today="2026-09-19", refreshed_at=5.0) + assert entry["exempt_until"] == _local("2026-09-19T11:00:00").isoformat() + assert write_entry(entry) + assert read_entry() == entry + assert "hmac" in entry + + def test_none_heartbeat_and_failed_have_no_exemption(self) -> None: + entry = to_entry(None, today="2026-09-19", refreshed_at=5.0) + assert entry["outcome"] == "none" + assert "exempt_until" not in entry + failed = to_entry(_record("failed"), today="2026-09-19", refreshed_at=5.0) + assert failed["outcome"] == "failed" + assert "exempt_until" not in failed + + def test_tampering_is_rejected(self, tmp_path: Path) -> None: + write_entry(to_entry(_record(), today="2026-09-19", refreshed_at=5.0)) + path = _session.MORNING_SESSION_FILE + tampered = json.loads(path.read_text()) + tampered["exempt_until"] = _local("2026-09-19T23:00:00").isoformat() + path.write_text(json.dumps(tampered)) + assert read_entry() is None + path.write_text("[]") + assert read_entry() is None + path.write_text("{") + assert read_entry() is None + path.unlink() + assert read_entry() is None + assert (tmp_path / "morning_session.json") == path + + def test_unsigned_when_the_key_is_missing(self) -> None: + with patch("wake_alarm._session.compute_entry_hmac", return_value=None): + entry = to_entry(None, today="2026-09-19", refreshed_at=5.0) + assert write_entry(entry) + assert "hmac" not in entry + + def test_write_failure_is_false(self) -> None: + with patch.object(Path, "write_text", side_effect=OSError("ro")): + assert not write_entry(to_entry(None, today="2026-09-19", refreshed_at=5.0)) + + +class TestRefresh: + """Fetch, select, write.""" + + def _logs(self, record_json: dict[str, object]) -> list[tuple[str, str]]: + raw = json.dumps( + { + "alarm": { + "fields": { + "morning_sessions": [ + [record_json], + "2026-09-19T06:20:00.000Z-0000-p", + ] + } + } + } + ) + return [("p", raw)] + + def test_todays_session_is_written(self) -> None: + session = { + "date": "2026-09-19", + "started_at": "2026-09-19T08:01:00.000", + "updated_at": "2026-09-19T08:40:00.000", + "outcome": "completed", + } + with patch( + "wake_alarm._session.fetch_firebase_logs", return_value=self._logs(session) + ): + entry = refresh(NOW) + assert entry is not None + assert entry["outcome"] == "completed" + assert read_entry() == entry + + def test_yesterdays_session_becomes_none(self) -> None: + session = { + "date": "2026-09-18", + "started_at": "2026-09-18T08:01:00.000", + "outcome": "completed", + } + with patch( + "wake_alarm._session.fetch_firebase_logs", return_value=self._logs(session) + ): + entry = refresh(NOW) + assert entry is not None + assert (entry["date"], entry["outcome"]) == ("2026-09-19", "none") + + def test_unreadable_backend_leaves_the_file_alone(self) -> None: + write_entry(to_entry(_record(), today="2026-09-19", refreshed_at=5.0)) + with patch("wake_alarm._session.fetch_firebase_logs", return_value=None): + assert refresh(NOW) is None + assert read_entry() is not None + + def test_write_failure_is_none(self) -> None: + with ( + patch("wake_alarm._session.fetch_firebase_logs", return_value=[]), + patch("wake_alarm._session.write_entry", return_value=False), + ): + assert refresh(NOW) is None + + +class TestCli: + """--show and --refresh, and the verdict line.""" + + def test_describe_covers_every_verdict(self) -> None: + entry = to_entry(_record(), today="2026-09-19", refreshed_at=5.0) + assert "exempt until" in describe(entry, NOW) + assert "exemption ended" in describe(entry, NOW + timedelta(hours=4)) + assert "no exemption" in describe( + to_entry(None, today="x", refreshed_at=5.0), NOW + ) + + def test_show_prints_the_verified_entry( + self, capsys: pytest.CaptureFixture[str] + ) -> None: + write_entry(to_entry(_record(), today="2026-09-19", refreshed_at=5.0)) + assert main(["--show"]) == 0 + out = capsys.readouterr().out + assert '"outcome": "completed"' in out + assert "2026-09-19 completed" in out + + def test_show_without_a_file_exits_1( + self, capsys: pytest.CaptureFixture[str] + ) -> None: + assert main(["--show"]) == 1 + assert "no verified" in capsys.readouterr().out + + def test_refresh_uses_the_backend(self) -> None: + with patch("wake_alarm._session.fetch_firebase_logs", return_value=[]) as fetch: + assert main(["--refresh"]) == 0 + fetch.assert_called_once() + entry = read_entry() + assert entry is not None + assert entry["outcome"] == "none" + + def test_argv_defaults_to_sys_argv(self) -> None: + with patch("wake_alarm._session.sys.argv", ["prog", "--show"]): + assert main() == 1 diff --git a/wake_alarm/tests/test_session_payload.py b/wake_alarm/tests/test_session_payload.py new file mode 100644 index 0000000..5dabc19 --- /dev/null +++ b/wake_alarm/tests/test_session_payload.py @@ -0,0 +1,143 @@ +"""Selecting the phone's newest morning out of the synced logs.""" + +from __future__ import annotations + +import json + +from wake_alarm._session_payload import SessionRecord, newest_session, record_from_json + +HLC = "2026-09-19T06:20:00.000Z-0000-phone" + + +def _session( + date: str = "2026-09-19", outcome: str = "completed", **extra: object +) -> dict: + return { + "date": date, + "started_at": f"{date}T08:01:00.000", + "updated_at": f"{date}T08:40:00.000", + "outcome": outcome, + "weight_kg": 73.8, + "energy": 3, + "re_rings": 1, + **extra, + } + + +def _log( + history: list[dict] | None = None, + live: object = None, + hlc: str = HLC, + live_hlc: str = HLC, +) -> str: + fields: dict[str, object] = {} + if history is not None: + fields["morning_sessions"] = [history, hlc] + if live is not None: + fields["current_session"] = [live, live_hlc] + return json.dumps({"alarm": {"fields": fields}}) + + +class TestRecordFromJson: + """One phone session JSON -> one record.""" + + def test_maps_the_phone_outcome_and_dates_a_finished_one(self) -> None: + record = record_from_json(_session(), device_id="p", published_at=1.0) + assert record == SessionRecord( + date="2026-09-19", + outcome="completed", + started_at="2026-09-19T08:01:00.000", + finished_at="2026-09-19T08:40:00.000", + energy=3, + weight_kg=73.8, + re_rings=1, + device_id="p", + published_at=1.0, + ) + + def test_a_live_session_has_no_finish_and_maps_to_in_progress(self) -> None: + record = record_from_json( + _session(outcome="active"), device_id="p", published_at=None + ) + assert record is not None + assert record.outcome == "in_progress" + assert record.finished_at is None + assert not record.finished + + def test_rejects_every_malformed_shape(self) -> None: + bad = [ + "text", + {}, + _session(date=1), + _session(started_at=None), + _session(outcome="odd"), + _session(outcome=3), + ] + for value in bad: + assert record_from_json(value, device_id="p", published_at=None) is None + + def test_optional_numbers_refuse_bools_and_strings(self) -> None: + record = record_from_json( + _session(energy=True, weight_kg="73", re_rings=False, updated_at=5), + device_id="p", + published_at=None, + ) + assert record is not None + assert record.energy is None + assert record.weight_kg is None + assert record.re_rings == 0 + assert record.finished_at is None + + +class TestNewestSession: + """Ranking across fields and devices.""" + + def test_finished_beats_live_on_the_same_date_whatever_the_stamps(self) -> None: + raw = _log( + history=[_session()], + live=_session(outcome="active"), + hlc="2026-09-19T06:00:00.000Z-0000-phone", + live_hlc="2026-09-19T09:00:00.000Z-0000-phone", + ) + record = newest_session([("p", raw)]) + assert record is not None + assert record.outcome == "completed" + + def test_a_newer_live_date_beats_older_finished_ones(self) -> None: + raw = _log( + history=[_session(date="2026-09-18")], live=_session(outcome="active") + ) + record = newest_session([("p", raw)]) + assert record is not None + assert (record.date, record.outcome) == ("2026-09-19", "in_progress") + + def test_hlc_then_device_id_break_ties(self) -> None: + older = _log(history=[_session()], hlc="2026-09-19T06:00:00.000Z-0000-a") + newer = _log(history=[_session()], hlc="2026-09-19T07:00:00.000Z-0000-b") + assert newest_session([("a", older), ("b", newer)]).device_id == "b" + same = _log(history=[_session()]) + assert newest_session([("a", same), ("b", same)]).device_id == "b" + + def test_skips_unusable_logs_and_entries(self) -> None: + published = [ + ("json", "nope"), + ("list", "[]"), + ("empty", _log()), + ("cleared", _log(live={})), + ("bad-history", _log(history=[{"date": "2026-09-19"}])), + ( + "history-not-list", + json.dumps({"alarm": {"fields": {"morning_sessions": [3, HLC]}}}), + ), + ("finished-in-live-slot", _log(live=_session())), + ("live-in-history", _log(history=[_session(outcome="active")])), + ] + assert newest_session(published) is None + assert ( + newest_session([*published, ("ok", _log(history=[_session()]))]) is not None + ) + + def test_carries_the_stamp_instant(self) -> None: + record = newest_session([("p", _log(history=[_session()]))]) + assert record is not None + assert record.published_at is not None diff --git a/wake_alarm/tests/test_weight.py b/wake_alarm/tests/test_weight.py index debc283..1735353 100644 --- a/wake_alarm/tests/test_weight.py +++ b/wake_alarm/tests/test_weight.py @@ -82,20 +82,21 @@ class TestLatestWeight: def test_unreadable_backend_is_none(self) -> None: """No Firebase means no weight, not an error.""" - with patch("wake_alarm._weight.fetch_firebase_logs", return_value=None): + with patch("wake_alarm._field_reader.fetch_firebase_logs", return_value=None): assert latest_weight() is None assert main() == 1 def test_no_weigh_in_yet_is_none(self) -> None: """A phone that has not run a session publishes no weight.""" logs = [("phone", json.dumps({"alarm": {"fields": {"alarm_hour": [8, "h"]}}}))] - with patch("wake_alarm._weight.fetch_firebase_logs", return_value=logs): + with patch("wake_alarm._field_reader.fetch_firebase_logs", return_value=logs): assert latest_weight() is None def test_main_prints_json(self, capsys: pytest.CaptureFixture[str]) -> None: """The CLI emits one JSON line diet-guard can consume.""" with patch( - "wake_alarm._weight.fetch_firebase_logs", return_value=[("p", _log(72.4))] + "wake_alarm._field_reader.fetch_firebase_logs", + return_value=[("p", _log(72.4))], ): assert main() == 0 assert json.loads(capsys.readouterr().out) == {"date": "2026-09-17", "kg": 72.4}