Make the morning session the carrot: earn 11:00 by finishing it in time

On 2026-09-19 the phone was powered off after the bathroom tag and nothing
on the PC noticed: every finished session was published and nothing read
it. Now wake-alarm distils the phone's morning into an HMAC-signed
~/.local/state/wake_alarm/morning_session.json (wake-alarm-session.timer,
07:00-11:00 every 2 min) that screen-locker and leetcode-guard read to
defer their locks -- provisionally until min(start + 90 min, 09:30) while
the session is live, until 11:00 once it completed inside that cutoff,
and not at all for a session finished later (the phone switched on at
noon completes the legs then; that must un-lock nothing).

The phone publishes the session at start as well as at finish
(current_session, cleared with the finish stamp), so the PC can tell
"started, not finished" from "never started". A required 1-5 energy tap
joins the weigh-in and ships as latest_energy with a PC reader mirroring
_weight (the shared half is _field_reader).

Also fixes the vmbox harness, red since the 2026-09-11 home-tidy: the
guest tree lives at ~/wake-alarm, and the INSTALL-OK grep matched vm's
echo of the command line. Takes Flutter 3.47.5.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tp54ewdXRLaimUuT8LgYyn
This commit is contained in:
Krzysztof Rudnicki 2026-09-19 16:40:51 +02:00
parent af293413c0
commit c3c129b068
47 changed files with 1874 additions and 247 deletions

2
.fvmrc
View File

@ -1,3 +1,3 @@
{
"flutter": "3.47.4"
"flutter": "3.47.5"
}

View File

@ -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

View File

@ -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

View File

@ -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`.

View File

@ -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
```

View File

@ -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

View File

@ -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`

View File

@ -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<int> onSelect;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
Text('Energy', style: Theme.of(context).textTheme.labelLarge),
const SizedBox(height: 4),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
for (var level = minEnergy; level <= maxEnergy; level++)
ChoiceChip(
label: Text('$level'),
selected: selected == level,
selectedColor: scheme.secondaryContainer,
showCheckmark: false,
onSelected: (_) => onSelect(level),
),
],
),
],
);
}
}

View File

@ -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<HomeHistoryCard> {
}
}
/// 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';
}

View File

@ -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<int> 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) ...<Widget>[
EnergyPicker(selected: energy, onSelect: onSelectEnergy),
const SizedBox(height: 12),
TextField(
controller: controller,
keyboardType: const TextInputType.numberWithOptions(

View File

@ -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();
}

View File

@ -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';
}
}

View File

@ -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<String> Function()? publish;
@override
@ -60,12 +61,11 @@ class _SessionScreenState extends State<SessionScreen> {
late final Future<String> 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<String>? _startPublish;
bool _finishing = false;
String _status = '';
@ -104,10 +104,10 @@ class _SessionScreenState extends State<SessionScreen> {
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<SessionScreen> {
'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<void> _onTag(String tagId) async {
@ -127,24 +138,15 @@ class _SessionScreenState extends State<SessionScreen> {
await _commit(detail);
}
Future<void> _submitWeight() async {
Future<void> _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<SessionScreen> {
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<String>.value(''));
final message = await _publish();
sessionLog(
'finished ${session.outcome.name} re-rings=${session.reRings}: $message',
@ -222,7 +227,7 @@ class _SessionScreenState extends State<SessionScreen> {
void dispose() {
_timer?.cancel();
_scheduler.clearNfcListener();
_input.dispose();
_checkIn.dispose();
super.dispose();
}
@ -235,9 +240,11 @@ class _SessionScreenState extends State<SessionScreen> {
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()),
);
}
}

View File

@ -80,6 +80,7 @@ class MorningSession {
Map<SessionLeg, LegPass>? passedHow,
Set<SessionLeg>? 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;

View File

@ -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,

View File

@ -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);

View File

@ -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<void> 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 <String, Object?>{},
configured: true,
);
}
await stageField(
service,
sessionsField,
@ -57,17 +92,26 @@ Future<void> stageSessionData(
configured: true,
);
final weighed = history.where((s) => s.weightKg != null).firstOrNull;
if (weighed == null) return;
if (weighed != null) {
await stageField(service, latestWeightField, stamp, <String, Object?>{
'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, <String, Object?>{
'date': rated.date,
'level': rated.energy,
}, configured: true);
}
/// Writes log values newer than the local copy into local storage.
///
/// 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<void> applySyncedSessionData(
AlarmSyncService service, {
SessionStore? store,

View File

@ -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.

View File

@ -0,0 +1 @@
"""Emulator drivers for the morning session (see ``emu_morning.py``)."""

View File

@ -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")

View File

@ -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("<II", data[:8])
# Header is 12 bytes (w, h, format) or 16 (plus colour space) by API.
header = len(data) - width * height * 4
if header not in (12, 16):
msg = f"unexpected screencap layout: {len(data)} bytes"
raise SystemExit(msg)
return width, height, data[header:]
def find_button_y(serial: str) -> 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

View File

@ -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("<II", data[:8])
# Header is 12 bytes (w, h, format) or 16 (plus colour space) by API.
header = len(data) - width * height * 4
if header not in (12, 16):
msg = f"unexpected screencap layout: {len(data)} bytes"
raise SystemExit(msg)
return width, height, data[header:]
def find_button_y(serial: str) -> 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)

View File

@ -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 {

View File

@ -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);
});

View File

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

View File

@ -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);
});
});

View File

@ -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<String?> read() async => text;
@override
Future<void> write(String value) async => text = value;
}
Future<AlarmSyncService> _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(<String, Object>{}));
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<Map<Object?, Object?>>());
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(<String, Object?>{
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, <String, Object?>{
'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);
});
});
}

View File

@ -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<String> publishes;
late List<bool> storeHadSession;
Completer<String>? holdFirst;
void mockChannel() {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(
const MethodChannel(AlarmScheduler.channelName),
(call) async => call.method == 'hasNfc' ? true : null,
);
}
Future<void> 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 = <String>[];
storeHadSession = <bool>[];
holdFirst = null;
SharedPreferences.setMockInitialValues(<String, Object>{});
mockChannel();
await SessionTagSettings.replaceAll(<String>['bath', 'ward', 'desk'], '');
});
Future<void> pumpSession(WidgetTester tester) async {
await tester.pumpWidget(
MaterialApp(
routes: <String, WidgetBuilder>{
'/': (_) => 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<void> 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, <String>['start']);
expect(storeHadSession, <bool>[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(<String>['', '', ''], '');
holdFirst = Completer<String>();
await pumpSession(tester);
await tester.tap(find.widgetWithText(ChoiceChip, '2'));
await tester.pump();
await submit(tester, '72');
await tester.pump();
expect(publishes, <String>['start (held)']);
holdFirst!.complete('late');
await tester.pump();
await tester.pump();
expect(publishes, <String>['start (held)', 'finish']);
});
}

View File

@ -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);

View File

@ -42,7 +42,6 @@ void main() {
}
List<String> methods() => calls.map((c) => c.method).toList();
Future<void> 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<void> 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, <String, int?>{'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, <String, Object?>{
'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 <String>['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);
});
}

View File

@ -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

View File

@ -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)"
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

View File

@ -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 ~/<basename> 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"
}

View File

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

View File

@ -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

16
wake-alarm-session.timer Normal file
View File

@ -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

View File

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

68
wake_alarm/_energy.py Normal file
View File

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

View File

@ -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

228
wake_alarm/_session.py Normal file
View File

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

View File

@ -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]

View File

@ -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:

View File

@ -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.

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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}