mirror of
https://github.com/kuhyx/wake-alarm.git
synced 2026-11-10 18:07:05 +01:00
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:
parent
af293413c0
commit
c3c129b068
2
.github/workflows/python-tests.yml
vendored
2
.github/workflows/python-tests.yml
vendored
@ -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
|
||||
|
||||
|
||||
2
.github/workflows/release-apk.yml
vendored
2
.github/workflows/release-apk.yml
vendored
@ -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
|
||||
|
||||
|
||||
36
AGENTS.md
36
AGENTS.md
@ -139,10 +139,13 @@ systemd-sleep hook: a suspended or just-booted machine still fires.
|
||||
`FALLBACK_ALARM_HOUR` *is* the phone's baseline. An **undated** record is
|
||||
trusted, not expired: the GitHub mirror has no per-field HLC, so treating
|
||||
"cannot tell" as "too old" would break that backend entirely.
|
||||
- **Degraded reads log at INFO, not WARNING.** `get_alarm()` runs every minute,
|
||||
so a persistent condition logged loudly is 1440 identical journal lines a day.
|
||||
`_trigger` emits the single WARNING that matters: it is firing *now* on a
|
||||
non-phone alarm time.
|
||||
- **Degraded reads log at INFO, not WARNING.** `get_alarm()` runs every minute;
|
||||
a persistent condition logged loudly is 1440 journal lines a day. `_trigger`
|
||||
emits the single WARNING that matters: firing *now* on a non-phone time.
|
||||
- **The morning session is the carrot** (`_session.py`, `wake-alarm-session.timer`,
|
||||
**`DOCS-morning-session-pc.md`**): a completed-in-time morning defers the
|
||||
workout and LeetCode locks to 11:00 via one signed file both consumers read.
|
||||
Phone-local dates only — never `_state._today_str()` (UTC).
|
||||
|
||||
## The morning_routine orchestrator lives elsewhere
|
||||
|
||||
@ -212,10 +215,9 @@ another repo with no error message anywhere.
|
||||
|
||||
- Run tests: `python -m pytest wake_alarm/tests/ --cov=wake_alarm --cov-branch --cov-fail-under=100`
|
||||
- Phone tests: `cd phone_app && flutter test && flutter analyze`
|
||||
- Walk the whole morning on the emulator: `phone_app/scripts/emu_morning.py`
|
||||
against `lib/main_session_probe.dart` (see `DOCS-morning-session.md`)
|
||||
- Walk the morning on the emulator: `phone_app/scripts/emu_morning.py` (`DOCS-morning-session.md`)
|
||||
- Lint: `pre-commit run --all-files`
|
||||
- Show the resolved alarm: `python -m wake_alarm._sync --show` (`--no-cache` forces a live read)
|
||||
- Show the resolved alarm / the morning: `python -m wake_alarm._sync --show` (`--no-cache` live), `python -m wake_alarm._session --show`
|
||||
- Show what the next tick would do: `python -m wake_alarm._trigger --dry-run`
|
||||
- Watch it live: `journalctl --user -fu wake-alarm-trigger.service`
|
||||
- Install: `bash install.sh` — Uninstall: `bash uninstall.sh`
|
||||
@ -232,19 +234,17 @@ The dev venv lacks `gatelock`, so anything importing `_state` (including
|
||||
|
||||
- Don't add a dependency without the production install-path check above.
|
||||
`_sync.py`'s GitHub path is stdlib-only for that reason — don't "improve" it
|
||||
to `requests`, and don't make the `crdt_sync` import unconditional.
|
||||
- Don't forget the orchestrator when changing this package's module paths.
|
||||
- Don't point the trigger at anything but `morning-routine.service`, or the
|
||||
workout lock never runs.
|
||||
- Don't remove the trigger's once-per-day latch.
|
||||
- Don't raise the log level of `_sync`'s degraded-read paths.
|
||||
to `requests`, and don't make the `crdt_sync` import unconditional. Don't
|
||||
forget the orchestrator when changing this package's module paths.
|
||||
- Don't point the trigger at anything but `morning-routine.service` (or the
|
||||
workout lock never runs), don't remove its once-per-day latch, and don't
|
||||
raise the log level of `_sync`'s degraded-read paths.
|
||||
- Don't let the bypass skip the walk, and don't add a challenge kind that
|
||||
hides its own prompt.
|
||||
- Don't close the ring screen on dismiss when a session should follow, don't
|
||||
add a re-ring cancel beyond the session's own `finishSession`, and don't
|
||||
restore a re-ring instant that has passed (BOOT_COMPLETED replays on launch).
|
||||
- Don't make `HomeScreen.initState` concurrent again, and don't collapse
|
||||
`publish()`'s two failure messages back into one.
|
||||
- Don't date a record by when the PC fetched it.
|
||||
- Don't delete `/etc/sudoers.d/wake-alarm`.
|
||||
- Don't commit `wake_state.json`.
|
||||
- Don't make `HomeScreen.initState` concurrent again; keep `publish()`'s two failure messages.
|
||||
- Don't date a record by when the PC fetched it, and don't let a consumer of
|
||||
`morning_session.json` re-derive the 09:30/11:00 cutoffs or fetch its own copy.
|
||||
- Don't delete `/etc/sudoers.d/wake-alarm`. Don't commit `wake_state.json`.
|
||||
|
||||
95
DOCS-morning-session-pc.md
Normal file
95
DOCS-morning-session-pc.md
Normal 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
|
||||
```
|
||||
@ -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
|
||||
|
||||
@ -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`
|
||||
|
||||
49
phone_app/lib/screens/energy_picker.dart
Normal file
49
phone_app/lib/screens/energy_picker.dart
Normal 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),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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';
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
59
phone_app/lib/screens/session_checkin.dart
Normal file
59
phone_app/lib/screens/session_checkin.dart
Normal 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();
|
||||
}
|
||||
@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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,10 +92,17 @@ Future<void> stageSessionData(
|
||||
configured: true,
|
||||
);
|
||||
final weighed = history.where((s) => s.weightKg != null).firstOrNull;
|
||||
if (weighed == null) return;
|
||||
await stageField(service, latestWeightField, stamp, <String, Object?>{
|
||||
'date': weighed.date,
|
||||
'kg': weighed.weightKg,
|
||||
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);
|
||||
}
|
||||
|
||||
@ -68,6 +110,8 @@ Future<void> stageSessionData(
|
||||
///
|
||||
/// Sessions merge per date rather than replace: two installs' mornings end
|
||||
/// up in one list, and a peer's shorter list cannot truncate ours.
|
||||
/// [currentSessionField] is deliberately not applied: a live session is
|
||||
/// never restored from a peer (see the field's doc).
|
||||
Future<void> applySyncedSessionData(
|
||||
AlarmSyncService service, {
|
||||
SessionStore? store,
|
||||
|
||||
@ -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.
|
||||
|
||||
1
phone_app/scripts/__init__.py
Normal file
1
phone_app/scripts/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Emulator drivers for the morning session (see ``emu_morning.py``)."""
|
||||
20
phone_app/scripts/emu_adb.py
Normal file
20
phone_app/scripts/emu_adb.py
Normal 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")
|
||||
@ -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
|
||||
|
||||
106
phone_app/scripts/emu_screen.py
Normal file
106
phone_app/scripts/emu_screen.py
Normal 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)
|
||||
@ -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 {
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
101
phone_app/test/services/session_sync_live_test.dart
Normal file
101
phone_app/test/services/session_sync_live_test.dart
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
143
phone_app/test/session_screen_checkin_test.dart
Normal file
143
phone_app/test/session_screen_checkin_test.dart
Normal 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']);
|
||||
});
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -42,13 +42,30 @@ case_imports() {
|
||||
# install.sh writing the unit file is not the same as systemd running it, and
|
||||
# a timer that is present but disabled fails silently every morning.
|
||||
case_units() {
|
||||
local name state
|
||||
name="trigger-timer-enabled"
|
||||
state="$(systemctl --user is-enabled wake-alarm-trigger.timer 2>&1)"
|
||||
if [[ "$state" == "enabled" ]]; then
|
||||
local timer state
|
||||
for timer in wake-alarm-trigger.timer wake-alarm-session.timer; do
|
||||
state="$(systemctl --user is-enabled "$timer" 2>&1)"
|
||||
if [[ "$state" == "enabled" ]]; then
|
||||
pass "${timer%.timer}-enabled"
|
||||
else
|
||||
fail "${timer%.timer}-enabled" "expected enabled, got '$state'"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# --- Case: the session refresh runs without a backend ------------------------
|
||||
# The guest has no Firebase config, so --refresh must degrade to "no verified
|
||||
# morning session" (exit 1) rather than crash: that is what an unconfigured
|
||||
# machine's timer does every two minutes.
|
||||
case_session_refresh() {
|
||||
local name="session-refresh-degrades" output
|
||||
# Exit 1 is the expected verdict here ("nothing verifiable"), so capture
|
||||
# first: piped straight into grep, pipefail would fail the case on it.
|
||||
output="$("$PY" -m wake_alarm._session --refresh 2>/dev/null || true)"
|
||||
if grep -q "no verified" <<<"$output"; then
|
||||
pass "$name"
|
||||
else
|
||||
fail "$name" "expected enabled, got '$state'"
|
||||
fail "$name" "--refresh did not degrade cleanly"
|
||||
fi
|
||||
}
|
||||
|
||||
@ -68,6 +85,7 @@ main() {
|
||||
case_imports
|
||||
case_units
|
||||
case_trigger_dry_run
|
||||
case_session_refresh
|
||||
;;
|
||||
*)
|
||||
echo "usage: $0 [all]" >&2
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
@ -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() {
|
||||
|
||||
11
wake-alarm-session.service
Normal file
11
wake-alarm-session.service
Normal 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
16
wake-alarm-session.timer
Normal 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
|
||||
@ -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
68
wake_alarm/_energy.py
Normal 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())
|
||||
73
wake_alarm/_field_reader.py
Normal file
73
wake_alarm/_field_reader.py
Normal 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
228
wake_alarm/_session.py
Normal 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())
|
||||
171
wake_alarm/_session_payload.py
Normal file
171
wake_alarm/_session_payload.py
Normal 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]
|
||||
@ -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:
|
||||
|
||||
@ -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.
|
||||
|
||||
83
wake_alarm/tests/test_energy.py
Normal file
83
wake_alarm/tests/test_energy.py
Normal 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
|
||||
221
wake_alarm/tests/test_session.py
Normal file
221
wake_alarm/tests/test_session.py
Normal 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
|
||||
143
wake_alarm/tests/test_session_payload.py
Normal file
143
wake_alarm/tests/test_session_payload.py
Normal 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
|
||||
@ -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}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user