Four review passes with fixes between them (flare-edge's flow-framework
review, 2026-09-09). qmp.py: drive() split out of a 330-line dispatcher,
every verb guarded so a raising verb records a fatal row instead of ending
the run, the shot path sanitised, the rs485 and wait verbs judged through
shared helpers; imgtools.py: a bench subcommand for phash/structural
timings and a colour probe that samples instead of scanning the frame;
ui-drive.sh: the boot poll no longer walks every pixel per tick and the
simulator's control socket path is passed as one word. Offline tests: 8.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N3G6m9Aw5RyVY4ZowtKzEj
ui-drive.sh --rs485-devices fixed the roster for the whole boot, so no
script could show the guest noticing a device go quiet. mbsim.py now
serves a control socket (flare-edge --control); ui-drive.sh opens it next
to the pty and hands its path to qmp.py drive (--rs485-control), whose new
verb `rs485 silence|restore ADDR` sends one command and judges the reply.
A run without a simulated bus records the step as fatal rather than a
silent pass. Rig: unit 5 silenced reads online=false after 65 s in the
status json, restored reads online=true after 64 s (flare-edge #184).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N3G6m9Aw5RyVY4ZowtKzEj
A hit or region check fired once, so a check one second after `nav`
raced a page still laying itself out (flare-edge #175). wait_hit X Y CLASS
TIMEOUT_S [box=...] [TEXT] and wait_region NAME TOLERANCE TIMEOUT_S run
the same judgement every 0.5 s until it holds or the deadline passes, and
report how long they waited. The assert_hit judgement moves into
judge_hit so both verbs mean the same thing by a match; a missing
reference or foreign tolerance stays fatal on the first answer, since
waiting cannot fix either. Offline tests cover both verbs and the timeout.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N3G6m9Aw5RyVY4ZowtKzEj
scroll X Y DY and home are judged by the channel's own ack, like nav and
wake: the deterministic stand-in for a swipe that only brings a control
into view (flare-edge #174), and the fresh-boot state a live panel needs
before each flow (flare-edge #173).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N3G6m9Aw5RyVY4ZowtKzEj
imgtools.structural() marked a cell occupied when its grey exceeded an
absolute 10/255, and the WardenOS page background is grey 16: every cell
of every region read occupied and no structural check could ever fail
(#19). Occupancy is now grey deviating from the crop's own median by more
than DEVIATION_THRESHOLD, or edge energy above EDGE_THRESHOLD. Measured on
real captures: a switch knob left/right differs in 240 of 256 cells (was
0), a dark card reads its icon and text and nothing else. Every committed
reference is recaptured with flare-edge tools/flow-run-all.sh --capture.
qmp.py to_axis() truncated the pixel-to-axis conversion and LVGL's
evdev calibration truncates on the way back, so many pixels landed one
short (130 -> 5924 -> 129) and a tap could miss the control hit had just
confirmed at that pixel (#21). It now rounds up to the smallest axis value
that truncates to the requested pixel; test_qmp_drive.py asserts the round
trip for every pixel at three panel sizes.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N3G6m9Aw5RyVY4ZowtKzEj
- tests/qmp.py: `wake` is a judged verb (the reply must be the channel's
own ack) and `assert_stat` knows `idle` (0 awake, 1 dimmed, 2 asleep).
warden-ui's sleep.c swallows the touch that wakes a dimmed or blanked
panel, so every compiled flow script now opens with wake (flare-edge
#148); tests cover both.
- run.sh: vt.global_cursor_default=0. fbcon shares the virtio-gpu
framebuffer with warden-ui and its cursor blinked an 8x2 block onto the
top-left corner in some boots and not others, which made a structural
pixel reference fail by one cell (#18). The panel has no fbcon on its
display, so this only makes the rig match it.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013aHKWzT5EF86RFKRMtAv9n
- rootfs/sbin/init: the control bridge answers `@cat PATH` locally so the
driver can read /tmp/warden-web-status.json out of the guest. That file
has no trailing newline; the bridge adds one so the sentinel stays on
its own line and the line-based reader never blocks.
- tests/qmp.py: wait_json/assert_json (dotted paths, eq/ne/contains/
len_eq/len_ge/gt/lt), assert_stat off the FIFO's stats reply,
capture_region, assert_region NAME [TOLERANCE] and assert_ocr. A
tolerance other than the captured one, a reference box that does not
fit the screendump, a missing reference or a missing tesseract is FATAL
for that step and the run continues (flare-edge #147).
- tests/imgtools.py: P6 reader, crop, perceptual and structural hashes,
compare, with a self-test.
- tests/test_qmp_drive.py: drive() with QMP and the control channel
faked, pinning the per-step ok/fail/fatal contract.
- mkimage.sh SEED_DIR and ui-drive.sh --seed/--refs: settings fixtures
staged into userdata before warden-ui starts, and a reference store
handed to the driver.
- ci: the driver tests and the imgtools self-test run in qemu-tools.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013aHKWzT5EF86RFKRMtAv9n