Skip to content

hardware in the loop testing - #376

Merged
mullinmax merged 24 commits into
mainfrom
claude/hil-testing-design-s564ln
Aug 26, 2026
Merged

hardware in the loop testing#376
mullinmax merged 24 commits into
mainfrom
claude/hil-testing-design-s564ln

Conversation

@mullinmax

@mullinmax mullinmax commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Description

Stands up hardware-in-the-loop testing on a self-hosted runner, and pins the supply chain that feeds it.

Area What landed
dev/hil/DESIGN.md Architecture, threat model, gating, test matrices, rollout
dev/hil/setup-runner.sh Provisions the bench Pi as a runner (idempotent, credentials via env)
dev/hil/flash_and_check.py Identifies, builds, flashes and health-checks every board
.github/workflows/hil-smoke.yml Proves the runner and bench wiring
.github/workflows/hil-flash-check.yml Drives the flash + health check
pinning All actions to commit SHAs; all dev/requirements.txt deps to versions

Covers design goals G1 (boots and runs) and G2 (API reachable) from DESIGN.md, on real hardware, both transports. G3 (config matrix) and G4 (upgrade matrix) are designed but not built.

Related Issues

None yet. Two firmware findings below deserve their own.

Motivation and Context

Host-side CI is good, but nothing proved the firmware boots and runs on hardware. The gaps that matter: a config that passes schema validation but exhausts RAM on load, an update that bricks a board upgrading from an older release, an API route that regresses on one transport but not the other.

The repo is public, so the design has to make running untrusted code on hardware safe rather than avoid it. The central property: PR code is built on GitHub-hosted runners, and only build artifacts cross onto the bench. The harness executes from a trusted ref via workflow_run, which GitHub runs from the default branch regardless of what the triggering PR contains — so a PR cannot modify the workflow, the harness, conftest.py, or the dependency set the Pi executes.

That answers "separate repo?" too: a separate repo would not have been safer on its own. One that still checked out the PR and ran pytest on the Pi would be equally compromised. Trusted-ref execution is the property that matters, not the repo boundary.

Board identification

Nothing on a board reports what hardware it is. systemConfig.vectorSystem is a build-time constant baked into whatever was last flashed, and machine.unique_id() is the RP2040 chip id — stable per board, silent about the system. Autodetection can only report what a board is running, which is exactly wrong after a mis-flash.

This was not hypothetical: all three bench boards were running sys11 firmware, including the Data East board. Flashing from self-report would have re-flashed it as sys11 and reported success. So VECTOR_HIL_BOARD_MAP (chip id → target) is authoritative when set, and without it the harness refuses to flash when two boards claim the same system. --identify blinks each board so the map can be built once.

mpy-cross pinning

It emits the .mpy bytecode the boards must import, and the header carries a version the board's MicroPython will refuse if it doesn't recognise it. Unpinned, CI resolved 1.28.0.post2 while the shipped UF2s carry MicroPython v1.24.1 (System 11/9) and v1.26.0-preview (WPC, Data East), with nothing asserting the pairing is valid. Pinned to what CI already resolved, so the pin freezes current behaviour. What it should be is a hardware decision — open question 1 in the design.

Testing

Runs on the actual bench. Latest flash + health check is green across all three boards:

  ok    /dev/ttyACM0     sys11        192.168.2.6
  ok    /dev/ttyACM1     wpc          192.168.2.175
  ok    /dev/ttyACM2     data_east    192.168.2.10

all 3 board(s) flashed and healthy

Per board: built, wiped, flashed with bench config, reset, waited on the console for Server: Loop Forever, then checked over USB (version matches the built source, faults, game status, config list, active config is the one flashed, leaders, auth challenge) and over HTTP (index page plus 12 read-only routes, auth enforced, challenges single-use, version and config count agreeing between transports).

Measured timings, which retire the build-caching concern the design raised:

Stage sys11 wpc data_east
build 14.6s 21.7s 14.7s
flash 26.3s 29.7s 24.8s
boot + full health check 29.3s 32.6s 23.1s

Off-hardware verification: full dev/build.py under the pinned dependency set produces 41 .mpy files at bytecode version 6, identical to what mpy-cross 1.23.0 emits, confirming the pin is behaviour-preserving. setup-runner.sh exercised against a stubbed environment (missing credentials, missing token, full install, tokenless re-run, dirty-clone refusal, newline-in-credential refusal). HTTP checks tested against a stub reproducing the board's gzip, 401 and nonce behaviour. Boot-marker detection tested over a pseudo-terminal, including that the earlier > starting web server on port 80 line is correctly not treated as ready.

No firmware or src/ changes, so no version bump required.

Types of Changes

  • Bug fix (non-breaking change to resolve an issue)
  • New feature (non-breaking change to add functionality)
  • Breaking change (alters existing functionality)
  • Documentation update required

Checklist

  • My code follows the project's style guidelines.
  • I have updated documentation as needed.
  • I have read the CONTRIBUTING.md document.
  • I have added or updated tests.
  • All new and existing tests pass.

Additional Notes

Firmware findings, each worth its own issue — none block this PR:

  1. HDWR02: No Bus Activity is unreachable. adr_activity_ok() is defined and never called, in both src/common/main.py:69 and src/data_east/main.py:82. That fault is exactly the diagnostic a customer with a dead bus needs, and nothing can currently raise it. Found because the design predicted it would always fire on a bare bench and it never did.
  2. usb_comms line buffering is fragile. It accumulates stdin into a module-level buffer until a newline (usb_comms.py:132), so any unterminated bytes silently prefix the next request and the board answers 404 for a route that exists. The harness now flushes with a newline first, but a firmware-side guard would make the USB transport robust for every client.
  3. /api/game/name returns a bare string labelled Content-Type: application/json. The route is documented as plain text, so the body is right and the header is wrong.
  4. detect_boards.py swallows OSError into an empty result, so "mpremote is missing" is indistinguishable from "no boards attached". It cost a debug cycle here. dev/sync.py shells out to a bare python the same way.

Before merging: the push: triggers on both HIL workflows are branch-scoped for pre-merge testing and go dead on merge, at which point workflow_dispatch starts working from the Actions UI. Removing them is tidiness, not correctness — happy either way.

trench-coat has the same floating-tag exposure — 5 unpinned actions across its 2 workflows, plus unpinned pre-commit and pytest. Left alone to keep this scoped; happy to do a companion PR.

Suggested follow-up: a CI check that greps for uses:.*@v[0-9] so floating tags can't creep back in.

Design for a self-hosted Actions runner driving real Vector boards from
this public repo, covering boot smoke tests, API contract tests, the full
game-config matrix, and the previous-5-versions upgrade matrix.

The security model is the load-bearing part: PR code is built on
GitHub-hosted runners and only artifacts cross onto the bench, with the
harness itself always executing from the default branch via workflow_run.

Design only -- no implementation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WPbnmZNF6DFnLQBtpQpcW4
@github-actions

Copy link
Copy Markdown
Contributor

Developer build links:
Sys11

https://raw.githubusercontent.com/warped-pinball/vector/pr-update-artifacts/pr-artifacts/pr-376/sys11-update.json

Sys11 (Tiny)

https://raw.githubusercontent.com/warped-pinball/vector/pr-update-artifacts/pr-artifacts/pr-376/sys11-tiny-update.json

WPC

https://raw.githubusercontent.com/warped-pinball/vector/pr-update-artifacts/pr-artifacts/pr-376/wpc-update.json

EM

https://raw.githubusercontent.com/warped-pinball/vector/pr-update-artifacts/pr-artifacts/pr-376/em-update.json

WhiteStar

https://raw.githubusercontent.com/warped-pinball/vector/pr-update-artifacts/pr-artifacts/pr-376/whitestar-update.json

DataEast

https://raw.githubusercontent.com/warped-pinball/vector/pr-update-artifacts/pr-artifacts/pr-376/data-east-update.json

Classic

https://raw.githubusercontent.com/warped-pinball/vector/pr-update-artifacts/pr-artifacts/pr-376/classic-update.json

Pin every GitHub Action to a commit SHA with the version in a trailing
comment. deploy_docs, docs-on-pr, specialfeatures-on-pr,
validate-json-configs and version-bump-guard were on floating @v4/@v5
tags; build_release was pinned but to older revisions. All six now pin to
the latest patch of the major version already in use, so this is a
supply-chain change with no behaviour change.

Pin every dependency in dev/requirements.txt. mpy-cross was the notable
gap: it emits the .mpy bytecode the boards must import, and leaving it
unpinned meant a future release could silently change the bytecode
version and produce builds no deployed board can load. Pinned to
1.28.0.post2, the version CI was already resolving. Verified a full sys11
build succeeds and emits 41 .mpy files at bytecode version 6, matching
what 1.23.0 produces.

Design doc updates: the upgrade matrix now runs on every PR rather than
nightly, with the combined per-PR wall-clock cost quantified and the
levers to reduce it listed; runner host section reflects that the Pi is a
Zero 2 W; new open question about which mpy-cross version the build
should actually target, since the shipped UF2s carry MicroPython v1.24.1
for System 11/9 and v1.26.0-preview for WPC and Data East.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WPbnmZNF6DFnLQBtpQpcW4
@mullinmax mullinmax changed the title docs: hardware-in-the-loop testing design proposal ci: pin actions and Python deps + hardware-in-the-loop testing design Aug 15, 2026
claude added 8 commits August 16, 2026 16:41
Step-by-step setup for the Pi Zero 2 W bench runner: memory headroom,
unprivileged runner user, pinned Python environment, stable board device
names via udev, network isolation verification, bench manifest, Actions
runner install with verified checksums, job hooks, and an end-to-end
smoke workflow.

Each phase ends with a verification step. Hook scripts and YAML blocks
are syntax-checked and the pre-job health check is behaviour-tested for
both the healthy and missing-board paths.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WPbnmZNF6DFnLQBtpQpcW4
Board detection and flashing already work through the repo's dev pipeline
(detect_boards.py identifies boards by querying systemConfig.vectorSystem,
so port ordering does not matter), which removes the need for udev rules,
a bench manifest, and job hooks at setup time.

Drops the swap tuning, dedicated user, udev rules, dnsmasq, bench manifest,
secrets file, job hooks, and logrotate config down to a short list of
commands. The most likely failure modes are kept as one-line fixes at the
bottom rather than pre-emptive configuration.

The removed detail remains in git history if any of it turns out to be
needed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WPbnmZNF6DFnLQBtpQpcW4
Drop the download checksum step and the arch detection (arm64 confirmed
in both kernel and userland). Remove the dialout group step from the main
flow since the default login user already has it; keep it in the
troubleshooting list.

Boards share the VLAN with the Pi, so there is no network configuration
in this doc.

Bench wifi credentials go in the runner's .env, which is read at service
start and exported to every job.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WPbnmZNF6DFnLQBtpQpcW4
Converts the manual command list into a POSIX sh script artefact plus an
otamaker manifest, so the bench Pi can be provisioned remotely instead of
by hand.

The artefact contract shapes the design: the script runs as root, so it
drops to the runner user via runuser for everything that should not be
root (the Actions runner refuses to configure as root); output goes to
the journal, so each phase logs a line; and exit codes follow the 0/1/2
success/failure/reboot convention.

Made re-runnable, since a redeploy is the natural way to refresh
credentials or pull a newer harness: an existing clone is fetched rather
than re-cloned, an existing runner download and registration are left
alone, and .env is rewritten in place touching only the VECTOR_HIL_ lines
it owns. Board detection is non-fatal so a transient USB state does not
fail the whole deployment. Placeholder credentials are rejected up front.

Verified against a stubbed root environment: fresh install, re-run
idempotency, .env non-duplication and preservation of unmanaged vars, and
failure propagation for apt, registration, and a service that starts but
does not stay active.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WPbnmZNF6DFnLQBtpQpcW4
Adds the built .tar.zst plus its SHA-256 so Raspberry Pi Connect can be
pointed at a URL in this repo rather than at a locally built file.

Committing the artefact required moving credentials out of it first.
Baking the registration token and wifi password into the script before
packaging would have published the bench wifi password to a public repo.
The script now sources /etc/vector-hil.conf on the device instead, which
never enters the repo, and refuses to run if that file is group- or
world-writable or not owned by root.

That also removes the rebuild-per-deploy step: the artefact carries no
per-deployment state, so redeploying is pointing Connect at the same URL
again. The registration token is only needed for the first deploy, since
the script skips registration once the runner is registered.

build.sh produces the archive reproducibly - fixed mtimes and ownership,
sorted entries - so an unchanged source rebuilds byte-identically and a
changed checksum in a diff means the contents really changed. It also
syntax-checks the script and refuses to package one containing literal
credentials.

Marks *.tar.zst as binary in .gitattributes; the repo sets `* text=auto`,
and any normalization of the artefact would invalidate the checksum the
device verifies against.

Verified: reproducible rebuild, credential guard, and the config-file
paths (missing, bad permissions, missing token when unregistered, and
tokenless redeploy when already registered).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WPbnmZNF6DFnLQBtpQpcW4
The documented raw.githubusercontent URL points at main, which does not
resolve until this merges. Adds the branch URL so the artefact can be
deployed and tested first, plus a note that GitHub does not render binary
blobs in the Files changed view, so the committed artefact is easy to
mistake for missing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WPbnmZNF6DFnLQBtpQpcW4
Raspberry Pi Connect's remote update requires A/B image support, which
only Pi 4 and 5 have, so the Zero 2 W cannot be provisioned that way.
Removes the artefact, its manifest, the reproducible build script, the
committed tarball and checksum, and the device-side config file.

Replaces them with dev/hil/setup-runner.sh, run directly on the Pi as the
login user with sudo where needed. Credentials come from environment
variables rather than a file, so nothing sensitive lands in the repo or
on disk.

Keeps the properties that were worth having: idempotent re-runs, non-fatal
board detection, and a check that the service actually stayed up rather
than reporting success for one that immediately died.

Verified against stubs: missing credentials, missing token while
unregistered, full install, and a tokenless re-run preserving unmanaged
.env entries.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WPbnmZNF6DFnLQBtpQpcW4
Nothing in the repo targeted the self-hosted runner yet, so there was no
way to validate the bench. This checks that the runner picks up jobs, the
bench environment reaches them, the serial devices are present and
accessible, and every detected board answers over mpremote. It fails if no
boards are found or if a detected board does not respond.

Triggers on push to this branch as well as workflow_dispatch, because a
workflow_dispatch workflow is not dispatchable until it exists on the
default branch - without the push trigger the bench could not be validated
before merging. Drop the push trigger once this is on main.

No pull_request trigger: this targets a self-hosted runner on a private
network, and pull_request would let any fork PR run against it. Fork
gating is designed in DESIGN.md but not built.

The job does no checkout, matching the trust model - the bench runs code
from the clone it already has, not from a PR.

Parses only the last line of detect_boards.py output, since it prints
progress text before the JSON.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WPbnmZNF6DFnLQBtpQpcW4
@mullinmax
mullinmax marked this pull request as ready for review August 17, 2026 19:12
Copilot AI lite review requested due to automatic review settings August 17, 2026 19:12
First bench run failed at board detection with an empty result while
/dev/ttyACM0-2 were present and the runner user was in dialout.

dev/detect_boards.py shells out to a bare `mpremote` resolved from PATH,
and catches OSError - which covers FileNotFoundError - returning an empty
list. Invoking the venv's python by absolute path does not put the venv's
bin on PATH, so mpremote was missing and "tool not installed" surfaced as
"no boards attached".

Adds the venv to GITHUB_PATH and invokes python and mpremote by name so
subprocesses resolve the same interpreter and tools.

Also splits out an explicit mpremote check that prints `mpremote devs`
directly, because detect_boards.py collapses "mpremote missing",
"mpremote failed" and "no boards attached" into the same empty result -
which is what made the first failure ambiguous.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WPbnmZNF6DFnLQBtpQpcW4

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR strengthens CI supply-chain hygiene by pinning GitHub Actions and Python dev-tooling dependencies, and it introduces an initial hardware-in-the-loop (HIL) bench plan (plus bench runner setup/smoke workflow) under dev/hil/ to validate the self-hosted runner wiring before the full harness exists.

Changes:

  • Pin GitHub Actions uses: references to specific commit SHAs across the touched workflows.
  • Pin previously-floating Python tooling dependencies in dev/requirements.txt (notably mpy-cross) to fixed versions.
  • Add HIL bench documentation and bootstrap artifacts (dev/hil/DESIGN.md, runner setup script/docs) plus a self-hosted runner smoke workflow.

Reviewed changes

Copilot reviewed 12 out of 12 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
dev/requirements.txt Pins dev pipeline Python dependencies (incl. mpy-cross) and documents the bytecode-compat rationale.
dev/hil/setup-runner.sh Adds a Pi bootstrap script to provision a self-hosted runner, venv, and bench env vars.
dev/hil/RUNNER_SETUP.md Documents how to run the runner setup and how to validate the bench.
dev/hil/DESIGN.md Design proposal for HIL architecture, threat model, gating, and test matrix strategy.
.github/workflows/hil-smoke.yml Adds a self-hosted runner smoke workflow to validate bench wiring and board responsiveness.
.github/workflows/build_release.yml Updates pinned SHAs for key actions used in the build/release workflow.
.github/workflows/deploy_docs.yml Pins checkout/setup-python actions by SHA.
.github/workflows/docs-on-pr.yml Pins checkout/setup-python actions by SHA.
.github/workflows/specialfeatures-on-pr.yml Pins checkout/setup-python actions by SHA.
.github/workflows/validate-json-configs.yml Pins checkout/setup-python actions by SHA.
.github/workflows/version-bump-guard.yml Pins checkout/setup-python actions by SHA (plus EOF newline normalization).
.gitattributes Forces LF for dev/hil/*.sh to avoid CRLF shebang breakage on the Pi.
Suppressed comments (2)

dev/hil/setup-runner.sh:145

  • The .env file is written with unescaped SSID/password values. If either contains spaces, quotes, or backslashes (common for SSIDs), systemd’s EnvironmentFile parsing can break and the runner won’t export the intended values. Quote and escape these values when writing .env.
{
    echo "VECTOR_HIL_WIFI_SSID=$VECTOR_HIL_WIFI_SSID"
    echo "VECTOR_HIL_WIFI_PASSWORD=$VECTOR_HIL_WIFI_PASSWORD"
    echo "VECTOR_HIL_VENV=$VENV_DIR"
    echo "VECTOR_HIL_REPO=$REPO_DIR"

dev/hil/setup-runner.sh:127

  • The design doc’s hardening checklist calls out using an ephemeral runner (--ephemeral) to reduce persistence between jobs, but the setup script registers a persistent runner service (config.sh without --ephemeral). This leaves a larger post-compromise surface (state in _work/, caches, etc.) than the threat model intends. Recommend reconciling the setup script with the checklist (either implement an ephemeral registration flow + cleanup hooks, or document why persistence is acceptable for now).
    ( cd "$RUNNER_DIR" && ./config.sh \
        --url "$REPO_URL" \
        --token "$RUNNER_TOKEN" \
        --labels "$RUNNER_LABELS" \
        --unattended --replace >/dev/null ) \

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread dev/hil/setup-runner.sh
Comment thread .github/workflows/hil-smoke.yml
Review feedback on #376.

The setup script fetched but never checked anything out, so an existing
clone stayed on whatever was last checked out. That contradicted both the
trusted-checkout model and this repo's own docs, which claim re-running is
how you pull a newer harness - it was not. Now checks out
origin/$REPO_BRANCH (default main) detached after fetch, and refuses to
run against a clone with uncommitted changes rather than discarding them.

Rejects newlines in the wifi credentials. The runner parses .env by
splitting each line on the first '=' and taking the rest verbatim
(Runner.Listener/Program.cs) - there is no EnvironmentFile= in the
generated unit and runsvc.sh does not source it - so spaces, quotes and
backslashes are already safe and must not be escaped. A newline is the
only value that cannot be represented.

Documents why the runner is persistent rather than ephemeral, in both the
design checklist and the setup guide, instead of leaving the two in silent
disagreement.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WPbnmZNF6DFnLQBtpQpcW4
@mullinmax mullinmax changed the title ci: pin actions and Python deps + hardware-in-the-loop testing design ci: pin actions and deps, HIL bench runner setup + smoke workflow Aug 17, 2026

Copy link
Copy Markdown
Contributor Author

Thanks — three of the four are addressed in cad0383, and one is incorrect and worth recording so nobody "fixes" it later.

Stale checkout after git fetch — correct, and a real bug. The script fetched but never checked anything out, so an existing clone stayed on whatever was last checked out. That contradicted the trusted-checkout model and this repo's own docs, which claimed re-running is how you pull a newer harness — it wasn't. Now checks out origin/$REPO_BRANCH (default main) detached after fetch, and refuses to run against a clone with uncommitted changes rather than silently discarding them.

PR description said "design only" — correct. That was stale from an earlier revision. Description rewritten to describe what's actually here, including the smoke workflow and the bench run.

Ephemeral runner — correct that the docs disagreed with the script. The deviation is deliberate but was undocumented after a rewrite dropped the explanation. Now recorded in both DESIGN.md §9 and RUNNER_SETUP.md: ephemeral runners deregister after every job, so something must mint a fresh registration token each time, which means a PAT with administration: write sitting on the Pi readable by the same user that runs job code. That's a strictly more valuable secret than the runner's own credentials, which only let you receive jobs. It's an acceptable trade only because workflow_run means the Pi never executes PR-authored code — noted as the condition that would force ephemeral if it ever changes.

.env quoting — this one is wrong, and applying it would break credentials. The runner's .env is not parsed by systemd. The generated unit has no EnvironmentFile= and runsvc.sh doesn't source it. It's parsed by the runner itself, Runner.Listener/Program.cs#L179-L197:

var envContents = File.ReadAllLines(envFile);
var separatorIndex = env.IndexOf('=');
envValue = env.Substring(separatorIndex + 1);   // rest of the line, verbatim
Environment.SetEnvironmentVariable(envKey, envValue);

Split on the first =, remainder taken literally. No shell parsing, no quote handling, no escape processing. So spaces, quotes, backslashes and = in a password are already safe — and quoting would store literal " characters in the password, breaking a case that currently works. Verified with VECTOR_HIL_WIFI_PASSWORD='p@ss "w/ quotes"', which round-trips byte-for-byte.

There is one real failure mode nearby: a newline can't be represented in that format at all. The script now rejects it up front instead of writing a corrupt file.


Generated by Claude Code

claude added 10 commits August 17, 2026 19:32
Adds dev/hil/flash_and_check.py plus a workflow to drive it: inventory,
resolve target per board, build, flash with bench wifi config, then
health-check the API over USB and HTTP once the board boots.

The identification problem is the interesting part. Nothing on a board
reports what hardware it is - systemConfig.vectorSystem is a build-time
constant baked into whatever was last flashed, and machine.unique_id() is
the RP2040 chip id, stable per board but silent about which system it is
wired for. The last smoke run had all three boards reporting sys11, so
flashing from self-report would have perpetuated a mis-flash.

So an explicit chip-id map (VECTOR_HIL_BOARD_MAP) is authoritative when
set, and without one the harness falls back to self-report but refuses to
flash when two boards claim the same system, printing the chip ids needed
to build the map. --inventory-only prints them without touching anything.

Health checks: version matches the built source, faults are only the
HDWR02 expected on a bare bench, game status and config list are sane, the
flashed config is the active one, leaders and the auth challenge route
answer, then the same board over HTTP for version, index page and faults.
HDWR01 warns rather than fails and suppresses the active-config assertion,
since it means the board took the safe_mode path.

The workflow uses actions/checkout, which is safe only because every
trigger is repo-internal; noted inline that a fork-reachable trigger would
have to move back to the runner's pinned clone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WPbnmZNF6DFnLQBtpQpcW4
The first flash run correctly refused to proceed: all three bench boards
report sys11, so autodetection would have flashed sys11 firmware onto the
WPC and Data East hardware. Resolving that needs a chip-id map, which
needs a way to tell the physical boards apart.

--identify blinks each board in turn, using the Pico W onboard LED (which
works from the REPL regardless of what firmware is loaded) plus the Vector
board's WS2812 in blue when the flashed firmware provides the driver. One
pass over the bench identifies every board, rather than unplugging them
one at a time and diffing the inventory.

Documents the whole flow in RUNNER_SETUP.md, including why detection
cannot identify hardware and where the map lives.

Skipping CI on this commit: without the map set, a triggered run would
fail at the same resolve step and add nothing. Identification is a
hands-on task to run directly on the Pi while watching the bench.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WPbnmZNF6DFnLQBtpQpcW4
Running the harness from a login shell failed with FileNotFoundError on
mpremote. VECTOR_HIL_VENV is exported by the runner service into Actions
jobs, not by an interactive shell, so the documented command prefixed PATH
with an empty path and fell through to the system interpreter.

The harness now locates the bench venv itself - VECTOR_HIL_VENV, then the
directory of the running interpreter, then <repo>/.venv - prepends its bin
to PATH, and uses its python for the build and flash subprocesses. That
matters beyond our own calls: dev/build.py shells out to a bare mpy-cross
and dev/flash.py to a bare mpremote, and the system interpreter would not
have the build dependencies either.

If mpremote still cannot be found it now says so and gives a working
command, rather than raising a traceback from subprocess.

Corrects the same bad command in RUNNER_SETUP.md and the module docstring.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WPbnmZNF6DFnLQBtpQpcW4
Build cost on the Zero 2 W is unmeasured - three targets of mpy-cross plus
web minification, against a 45 minute job timeout. Each stage now reports
its duration and the run ends with a breakdown, so if the timeout gets
tight we know which stage to attack rather than guessing.

Also triggers the first flash run now that VECTOR_HIL_BOARD_MAP is set on
the bench.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WPbnmZNF6DFnLQBtpQpcW4
The first full flash run flashed all three boards, booted them, joined
wifi and passed every USB API check - then failed on my own HTTP
assertions.

The board serves web assets pre-gzipped with Content-Encoding: gzip
(backend.py:183) and urllib does not decompress automatically, so the
index-page check was looking for "<html" inside gzip bytes. The board's
own log confirmed it had served GET / 200 correctly. Now decompresses, and
accepts <!doctype as well as <html.

The wpc board separately failed with IncompleteRead(0 bytes read, 2359
more expected). phew is single-threaded on a microcontroller that is also
fielding discovery broadcasts, so an occasional dropped body is not worth
failing a bench run over; HTTP gets three attempts before giving up, and
reports the underlying error when it does.

Also logs the whole active-config object rather than a "name" key it does
not have, which made a passing check print "None".

Verified both paths against a stub server that serves gzip and simulates
a truncated body.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WPbnmZNF6DFnLQBtpQpcW4
…[skip ci]

The design predicted HDWR02 would always fire on a bare bench and that
HDWR01 might fire nondeterministically and silently invalidate the config
coverage. Both were wrong, and the first full flash run measured it: three
boards freshly flashed for their real targets all booted with no faults at
all.

HDWR01 simply did not appear, so the floating-pin concern does not need a
resistor pack and the config assertions are not vacuous.

HDWR02 cannot appear: adr_activity_ok() is defined but never called, in
either src/common/main.py or src/data_east/main.py. That is a firmware
finding rather than a bench one - "No Bus Activity" is precisely the
diagnostic a customer with a dead bus needs, and nothing can currently
raise it. Worth its own issue: reconnect the check or drop the code, but a
dead diagnostic in faults.py is the worst of both.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WPbnmZNF6DFnLQBtpQpcW4
The flash/health-check harness now reports per-stage timings, so the
design can carry real figures instead of guesses: builds are 14-22s per
target, flashing 22-28s per board, and boot to a fully health-checked API
10-15s. A full flash-and-verify of all three boards is about 3.5 minutes.

Build cost was called out as a risk against the 45 minute job timeout and
a candidate for caching. It is not - that concern is retired.

The per-config reboot loop that drives the config matrix is still an
estimate and is now labelled as one, since only flash-and-verify has been
measured.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WPbnmZNF6DFnLQBtpQpcW4
Boot is slow and variable, so the harness now watches the serial console
for the firmware announcing readiness instead of polling an API that is
not listening yet. Polling told us nothing about why a board was late and
burned the whole timeout when one failed to boot; reading the console
gives an exact ready signal and, on failure, the boot log that explains
it. The transcript is now printed whenever a health check fails.

The marker is "Server: Loop Forever", printed immediately before
loop.run_forever() (phew/server.py:381). Deliberately not the earlier
"> starting web server on port 80" line - that is printed before
start_server is even scheduled, so matching it returns while the socket is
still closed. A pty-based test covers exactly that distinction. Since the
marker still precedes run_forever(), a short settle follows it, with
http_get's retries covering the remainder. backend.go() runs
connect_to_wifi() before either line, so the marker covers both transports.

HTTP coverage grows from three requests to the index page plus twelve
read-only routes, all required to return JSON, and adds checks only this
transport can make:

- authentication is enforced (password_check must 401 without credentials)
  - USB bypasses auth by design (backend.py:280), so HTTP is the only place
    the gate can be proven
- challenges are single-use, not a repeated nonce
- version and config count agree between USB and HTTP, since the two
  bridges share a route table but not their plumbing

Boot timeout raised to 150s. Verified against a stub server reproducing
the board's gzip, 401 and nonce behaviour, including the mismatch and
unenforced-auth failure paths.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WPbnmZNF6DFnLQBtpQpcW4
The console watch timed out on all three boards while they were in fact
healthy - the boot log it dumped showed steady-state runtime chatter
(RESOURCE, DISCOVERY PING/PONG between the boards' own IPs, FRAM cycles),
not boot output.

The marker prints exactly once per boot. dev/flash.py resets at the end of
flashing, but flashing loops over every board before any health check
starts, so the first board had booted 50-75 seconds earlier and its marker
was long past. The previous polling approach was immune to this because it
did not care when the board became ready; watching a one-shot line is not.

So the harness now issues the reset itself at the start of each health
check, immediately before opening the console. The wait becomes
deterministic and the reported boot time actually measures a boot. A
failed reset is now its own clear error rather than a 150s timeout.

Worth noting the boot-log dump added in the same series is what made this
diagnosable at a glance - without it this looked like three boards failing
to boot.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WPbnmZNF6DFnLQBtpQpcW4
The reset fix worked - all three boards reported their ready marker
(12.5s, 13.9s, 23.6s) - which exposed two further problems.

sys11 failed on my own over-strict assertion. /api/game/name is documented
as "Plain-text game name" (backend.py:451) and returns a bare string, so
requiring JSON from every route was wrong. Routes now carry their expected
body kind; text routes are checked for a non-empty body instead. Worth
noting separately that route_wrapper still labels that response
application/json, which is a small inconsistency in the firmware rather
than in the test.

wpc and data_east answered 404 to the very first USB request for
/api/version. The route table cannot be incomplete - the server only
starts if backend.py imports fully - so the board must have parsed a
different path. usb_comms accumulates stdin into a module-level buffer
until it sees a newline (usb_comms.py:132), so any untermined bytes left
over from the raw-REPL session that issued the reset prefix the next
request and break the _routes lookup. The harness now sends a lone newline
to flush that buffer, waits one scheduler turn for it to be consumed, and
clears both ends of the line first.

If a USB request still fails, the error now includes the board's own
narration. usb_comms prints "USB REQ: route not found: <path>" but
UsbApiClient discards every line that is not a response, which is why the
last run could not say which path the board actually saw.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WPbnmZNF6DFnLQBtpQpcW4
@mullinmax mullinmax changed the title ci: pin actions and deps, HIL bench runner setup + smoke workflow hardware in the loop testing Aug 18, 2026
@mullinmax
mullinmax merged commit a94729f into main Aug 26, 2026
4 checks passed
@mullinmax
mullinmax deleted the claude/hil-testing-design-s564ln branch August 26, 2026 02:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants