Create hil-test-suite.yml for HIL tests - #930
Conversation
Add initial content for HIL test suite configuration.
Replaces the stub with a HIL workflow that, after the build job, joins Tailscale to the usbip-hil-controller and compares a release build vs this PR's build on a real QT Py ESP32-S3: it flashes each, boots against a per-session protomq broker with secrets, then injects a v1 ws.signal.pixelWrite to an uninitialised strand (pin D0, colour 200) and asserts the release CRASHES+reboots while the #927 fix logs and continues. Contract: low=release, high=this PR (else custom run). - Only board(s) the controller reports available run (GET /v1/targets); the rest of the bench is skipped + listed in the PR comment (none go red). - Firmware delivered via the controller's upload/url endpoints; serial.log / protomq.log / flash.log captured and attached as the hil-assets artifact and summarised in a PR comment. - hil-pixelwrite-run.sh holds the A/B driver (upload -> submit -> poll -> assert). Needs repo secrets TAILSCALE_AUTHKEY_TYETH + HIL_API_TOKEN (+ HIL_IO_*/HIL_WIFI_*) and optional vars HIL_API_BASE / HIL_LOW_REF / HIL_TARGETS. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
🔌 HIL test suite added (pixelWrite / uninitialised-strand regression)Replaced the What it checks — a v1
How it runs
Contract: low = a published release, high = this PR's build. Any other combination routes to a custom script run. Triggering — Required — repo secrets
🤖 Generated with Claude Code |
…oller The controller supplies the broker host:port (write_secrets_msc fills it from the launched protomq), so CI passes only IO + Wi-Fi creds. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ld_run_id The HIL job DEPENDS ON the build (workflow_run after 'WipperSnapper Build CI' succeeds) unless manually triggered (workflow_dispatch). Manual runs take a build_run_id input for the HIGH artifacts; documented the workflow_run default-branch caveat (auto on merge, dispatch from the PR branch). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
workflow_run/workflow_dispatch are read only from the default branch, so neither fires for a workflow file that lives only on a feature branch — the last PR commit triggered nothing. pull_request is the only event that runs the branch's own copy of the workflow. Add on: pull_request and resolve BUILD_RUN_ID per trigger in a new step: for a PR it waits (≤20 min) for "WipperSnapper Build CI" on the head SHA to succeed and flashes that run's artifacts as HIGH, preserving the depends-on-build semantics without needing the file on main. Comment step now posts on PR and workflow_run paths (skips manual dispatch). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The WipperSnapper build matrix can take 30+ min (up to ~1.5 h). Extend the PR-path build-wait from 20 min (60x20s) to ~95 min (190x30s) and add a 150-min job timeout so a stuck wait doesn't hold a runner for the 6 h default. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Three fixes found by the first live PR run: 1. HIGH firmware fetch was wrong. The build matrix uploads per-platform build-files-<plat>-zip artifacts, then a merge-job combines them into ONE 'build-files' artifact and deletes the per-platform ones. The old code downloaded a non-existent build-files-<target>-zip, so HIGH was always missing and every target reported "firmware missing" (nothing flashed). Now: download the single build-files once, then dig out wippersnapper.<target>.fatfs.<ver>.zip for each target. (LOW release fetch unchanged; qtpy_esp32s3_n4r2 is a real matrix entry, so the bin is present.) 2. PR result comment is now sticky: update our marker comment (<!-- hil-test-suite -->) in place instead of posting a new comment each run. 3. Cosmetic: the "firmware missing" row printed "ok<path>"; print ok/missing. Secrets confirmed sufficient: jobreq defaults to bench creds (hil/hil, free4all/password) when HIL_IO_*/HIL_WIFI_* are unset, and TAILSCALE/HIL_API keys are already set (Tailscale joined, /v1/targets authorized). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
🔌 HIL test resultsDriven by usbip-hil-controller over Tailscale; only bench-available targets run (others skipped + listed with reason). Per-test proof (serial.log / protomq.log / flash.log) attached as the ✅ Check-in smoke testFlash this PR's build → write secrets → power-cycle → assert the device checks in to the broker.
Expected: 🧪 pixelWrite uninitialised-strand regression (#926)v1
Expected: release |
The PR pixelWrite regression (#926/#927) is validated on the bench but parked as the default CI gate for now. Add hil-checkin-run.sh — flash THIS PR's build → write secrets → power-cycle → verify_checkin (CHECKIN_VERDICT ok=true), single build, no injection — and switch the workflow to run it; the A/B step is left commented out with a re-enable note. Also fold in the proven firmware-bench stage order into the (parked) pixelwrite driver: a power_cycle MUST precede write_secrets_msc (the MSC volume only enumerates once the app boots), and run_side now treats error/timeout as terminal states so it doesn't spin. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The run_target/run_side wait loops used a fixed 120-iteration count, but firmware-bench floods serial-stream events so each /wait returns instantly — the loop burned out in ~4min, before the ~6-8min flash→secrets→checkin completes, and reported ok=unknown even though the controller job checked in fine (CHECKIN_VERDICT ok=true, job finished/pass). Poll on a ~15min wall-clock deadline instead and break the moment the verdict line appears. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…926) Both tests now run every PR and report individually. Each driver appends its own section (+ per-test assets) to a shared hil-out/comment.md; the workflow owns the sticky-comment marker/header and writes it once (Init PR summary step). Tests run with if:always() so one failing test still lets the others run and report, while the job still fails if any test fails. - check-in smoke test = the default gate (flash → secrets → checkin). - pixelWrite uninitialised-strand regression named with the issue (#926, fixed by #927); A/B LOW(release)=crash vs HIGH(PR)=graceful. - A/B hold window cut 10→1 min so the LOW job frees the device before HIGH. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Per feedback: - Post a NEW PR comment each run (drop the sticky in-place update — runs were impossible to track). Header carries run number + commit + link to the run and its hil-assets artifact. - Fix missing proof logs: the drivers downloaded assets right after the verdict, but firmware-bench registers serial/protomq/flash logs at TEARDOWN (after the hold), so only the firmware bin existed yet. Now poll until the job is TERMINAL before downloading, and download ONLY kind='log' assets (skip the 3MB bin). - Inline an excerpt: each test section gets a <details> with the tail of the captured serial.log (falling back to flash.log) as in-comment proof. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
🔌 HIL test results — run #8commit ✅ Check-in smoke testFlash this PR's build → write secrets → power-cycle → assert the device checks in to the broker.
📜 `qtpy_esp32s3_n4r2` checkin — qtpy_esp32s3_n4r2-checkin-serial.log (tail)Expected: 🧪 pixelWrite uninitialised-strand regression (#926)v1
Expected: release |
Half 2 of the controller's HANDOFF §1. A HIL job runs several firmware-bench jobs in sequence; a dwc_otg wedge can auto-reboot the DUT host (~3–5 min) mid-suite. Previously the remaining tests submitted regardless and errored instantly with an SSH "no route to host" / "device unavailable" signature, reporting `unknown` (run #8) — a transient outage, not a logic bug. New shared helper hil-lib.sh: * wait_for_target_available <target> — re-polls GET /v1/targets before each job; on a temporary outage sleeps until the controller-advertised retry_after (+margin), re-polls, bounded by HIL_WAIT_BUDGET_S (~6 min); permanent → skip; past budget → fail-with-reason. The controller now sets retry_after = now + HIL_HOST_REBOOT_ETA_S on a wedge/auto-reboot. * is_host_offline_failure — classifies a job that errored with a host-offline signature (vs a real verdict) so the driver re-submits that test ONCE after the host returns — even the test that triggered the wedge gets a real verdict. Both drivers (checkin, pixelWrite A/B) wait before submitting + reactively retry, and note "host rebooted — retried" in the PR comment. run_target/run_side now set verdict+state via globals (the verdict was lost to the $(...) subshell otherwise). hil-lib.test.sh unit-tests the helpers (available / temp-then-available / permanent / budget-exceeded + the classifier) with stubbed curl/date/sleep; wired as a fast hardware-free gate before the ~95 min build-wait. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
✅ Check-in smoke testFlash this PR's build → write secrets → power-cycle → assert the device checks in to the broker.
Expected: 🧪 pixelWrite uninitialised-strand regression (#926)v1
Expected: release |
The test cd's into a tmp dir then sourced hil-lib.sh via a path derived from
BASH_SOURCE — but CI invokes it as `bash .github/workflows/hil-lib.test.sh`
(relative), so after the cd the relative source resolved to nothing and every
check failed with 127 ("No such file or directory"). Resolve the script dir to
an absolute path before cd'ing away. (Passed locally only because it was run
with an absolute path.)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
🔌 HIL test results — run #10commit ✅ Check-in smoke testFlash this PR's build → write secrets → power-cycle → assert the device checks in to the broker.
📜 `qtpy_esp32s3_n4r2` checkin — qtpy_esp32s3_n4r2-checkin-serial.log (tail)Expected: 🧪 pixelWrite uninitialised-strand regression (#926)v1
Expected: release |
…gnature) Run #10: the check-in test rode through a wedge and retried to ok=true, but pixelWrite LOW/HIGH errored and were reported `unknown` with no retry. Two driver bugs: * The wait loop broke on the state→terminal event before the error-reason event arrived, so the events log kept only the firmware-link line — HIGH's "No route to host" reason was lost, LOW's was empty. The old retry classifier (is_host_offline_failure) greps that events log, so it matched nothing → no retry. Fixes: * Drain trailing events after a terminal state (one more short /wait) so the reason lands in the events log + artifact, and a verdict in the final batch is caught. * Replace the events-log signature match with is_infra_error(state): retry once whenever a job ends error/timeout/failed (or no state) with no real verdict — a real firmware verdict comes out as state=finished, so an infra error is always retry-eligible regardless of whether/where a reason was captured. A wait_for_target_available still precedes the re-submit (and the controller now flags an unreachable host temporary+retry_after, so the wait actually waits). * Drop the now-unused is_host_offline_failure; hil-lib.test.sh updated (14 checks). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
🔌 HIL test results — run #11commit ✅ Check-in smoke testFlash this PR's build → write secrets → power-cycle → assert the device checks in to the broker.
📜 `qtpy_esp32s3_n4r2` checkin — qtpy_esp32s3_n4r2-checkin-serial.log (tail)Expected: 🧪 pixelWrite uninitialised-strand regression (#926)v1
Expected: release |
🔌 HIL test results — run #11commit ✅ Check-in smoke testFlash this PR's build → write secrets → power-cycle → assert the device checks in to the broker.
Expected: 🧪 pixelWrite uninitialised-strand regression (#926)v1
Expected: release |
🔌 HIL test results — run #11commit ✅ Check-in smoke testFlash this PR's build → write secrets → power-cycle → assert the device checks in to the broker.
📜 `qtpy_esp32s3_n4r2` checkin — qtpy_esp32s3_n4r2-checkin-serial.log (tail)Expected: 🧪 pixelWrite uninitialised-strand regression (#926)v1
📜 `qtpy_esp32s3_n4r2` high — qtpy_esp32s3_n4r2-high-serial.log (tail)Expected: release |
Final run showed the single reactive retry isn't enough when the DUT host reboot-LOOPS faster than a test completes: pixelWrite LOW's one retry waited out the reboot (312s) but its re-run ALSO overlapped the next reboot → unknown, while HIGH happened to land in a stable gap and passed. Make the reactive retry a bounded loop (HIL_TEST_ATTEMPTS, default 4 total) — keep waiting+retrying on an infra error until a real verdict appears or attempts run out, so a test rides through repeated host reboots instead of giving up after one. Both drivers (checkin, pixelWrite A/B). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
🔌 HIL test results — run #12commit ✅ Check-in smoke testFlash this PR's build → write secrets → power-cycle → assert the device checks in to the broker.
📜 `qtpy_esp32s3_n4r2` checkin — qtpy_esp32s3_n4r2-checkin-serial.log (tail)Expected: 🧪 pixelWrite uninitialised-strand regression (#926)v1
Expected: release |
With a 2nd qtpy_esp32s3_n4r2 DUT now on rpi-hil006, /v1/targets returns two entries for the build_target. _hil_fetch_target_rec took head -1, so it could grab the (maintenance-down) rpi-displays one and never try the available rpi-hil006 board. Select the first AVAILABLE matching device, falling back to the first of any (so a real outage still reports a skip). Lets either DUT serve the chip — one host can go down for maintenance while the other takes the job. New multi-dut test. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
🔌 HIL test results — run #13commit ✅ Check-in smoke testFlash this PR's build → write secrets → power-cycle → assert the device checks in to the broker.
📜 `qtpy_esp32s3_n4r2` checkin — qtpy_esp32s3_n4r2-checkin-flash.log (tail)Expected: 🧪 pixelWrite uninitialised-strand regression (#926)v1
📜 `qtpy_esp32s3_n4r2` low — qtpy_esp32s3_n4r2-low-flash.log (tail)📜 `qtpy_esp32s3_n4r2` high — qtpy_esp32s3_n4r2-high-flash.log (tail)Expected: release |
The old proof blind-tailed serial.log (or flash.log) — useless: it didn't show the data the verdict rests on, never showed protomq, and on rpi-hil006 serial.log was absent so it tailed esptool's flash.log. Redesign (shared append_proof in hil-lib.sh): * SEPARATE collapsible sections for serial.log AND protomq.log per (target,test), each WINDOWED around the test phrase via proof_window — the lines up to and just after the last match of a per-test evidence regex (check-in: broker connect / registration; pixelWrite: reset banner / Pixel-strand / pixel signal). Falls back to a tail (clearly labelled) if the phrase isn't found; a log that wasn't captured is called out, not silently dropped. * Per-log artifacts: upload serial/protomq/flash/events each as its own artifact (hil-serial-logs etc.) so each is an individual download entry; files named <target>-<test>-<type>.log. Full hil-assets bundle kept as a fallback. The comment links the run's Artifacts. hil-lib.test.sh: proof_window matched-window + tail-fallback tests (19 checks). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
🔌 HIL test results — run #14commit ✅ Check-in smoke testFlash this PR's build → write secrets → power-cycle → assert the device checks in to the broker.
📜 `qtpy_esp32s3_n4r2` checkin · protomq.log (✓ around the detected test phrase)logs for Expected: 🧪 pixelWrite uninitialised-strand regression (#926)v1
📜 `qtpy_esp32s3_n4r2` low · protomq.log (✓ around the detected test phrase)logs for
📜 `qtpy_esp32s3_n4r2` high · protomq.log (✓ around the detected test phrase)logs for Expected: release |
🔌 HIL test results — run #14commit ✅ Check-in smoke testFlash this PR's build → write secrets → power-cycle → assert the device checks in to the broker.
📜 `qtpy_esp32s3_n4r2` checkin · serial.log (✓ around the detected test phrase)📜 `qtpy_esp32s3_n4r2` checkin · protomq.log (✓ around the detected test phrase)logs for Expected: 🧪 pixelWrite uninitialised-strand regression (#926)v1
📜 `qtpy_esp32s3_n4r2` low · serial.log (✓ around the detected test phrase)📜 `qtpy_esp32s3_n4r2` low · protomq.log (✓ around the detected test phrase)logs for 📜 `qtpy_esp32s3_n4r2` high · serial.log (✓ around the detected test phrase)📜 `qtpy_esp32s3_n4r2` high · protomq.log (✓ around the detected test phrase)logs for Expected: release |
… TCP connect The check-in protomq window quoted the bare `connected (io-…)` — connectivity, not proof of check-in. Anchor it on the actual registration handshake instead: RegistrationComplete / CreateDescriptionResponse / "Auto-Responding to checkin" / totalGpioPins (broker checkinResponse with pin counts + reference voltage), plus the device-side serial markers. The window now shows version → checkinResponse(pins) → RegistrationComplete — the real check-in evidence. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
🔌 HIL test results — run #15commit ✅ Check-in smoke testFlash this PR's build → write secrets → power-cycle → assert the device checks in to the broker.
📜 `qtpy_esp32s3_n4r2` checkin · serial.log (✓ around the detected test phrase)📜 `qtpy_esp32s3_n4r2` checkin · protomq.log (✓ around the detected test phrase)logs for Expected: 🧪 pixelWrite uninitialised-strand regression (#926)v1
📜 `qtpy_esp32s3_n4r2` low · serial.log (✓ around the detected test phrase)📜 `qtpy_esp32s3_n4r2` low · protomq.log (✓ around the detected test phrase)logs for 📜 `qtpy_esp32s3_n4r2` high · serial.log (✓ around the detected test phrase)📜 `qtpy_esp32s3_n4r2` high · protomq.log (✓ around the detected test phrase)logs for Expected: release |
There was a problem hiding this comment.
Looks relatively good. Should surface all the ENV VARs like Lines Before and After the test phrases / conditions in logs provided in the PR summaries. Either as workflow options (with defaults) and/or at top of file. Possibly a readme. Possibly move script elsewhere (lib subfolder?). Tests could move too. The WS Python HIL tests will alter things a bit and maybe bring more clarity on reusable fixtures (like a dual library set of mqtt protobuf scenarios in pytest and then individual lib / serial expectations)
final step:
we probably want serial since boot in the quotes by default for these specific tests (if the lines-before proof summary env var is -1), and the timestamps of the two quoted log sections for the last test run show different time periods by a minute or so which shows they don't line up and the protomq log quote is therefore useless (missed event) especially for comparison with the serial log
…to serial window The proof sections were windowed independently by a fixed LINE count, but the broker (protomq) log is far chattier than the device serial log, so N lines spans a very different wall-clock duration in each — the two quotes drifted to different minutes and the protomq quote missed the events corresponding to the serial test phrase (useless for side-by-side comparison). - HIL_PROOF_BEFORE now defaults to -1 = 'from boot': the serial quote shows the whole boot->check-in story up to just after the test phrase, not a fixed slice. - proof_window records its window's UTC-ms time span (PW_TS_START/PW_TS_END). - new time_window() selects protomq lines within the SAME wall-clock span as the serial quote (logs share one clock; fixed-width ISO-8601 UTC sorts lexically = chronologically), so the broker handshake lines up with the serial events. - +4 tests (since-boot window, recorded time span, time_window selection). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
🔌 HIL test results — run #16commit ✅ Check-in smoke testFlash this PR's build → write secrets → power-cycle → assert the device checks in to the broker.
🧪 pixelWrite uninitialised-strand regression (#926)v1
|
…der set -u) proof_window runs in a command-substitution subshell (win=$(proof_window ...)), so the PW_TS_START/PW_TS_END globals it set never reached the caller — referencing them under 'set -u' crashed the test script AFTER the verdict (check-in showed ok=true but the step still failed, and no proof sections were emitted). Derive the window's time span from the CAPTURED window text instead (first/last stamped line). +1 regression test that runs append_proof under set -u and asserts the protomq quote is time-aligned. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
🔌 HIL test results — run #17commit ✅ Check-in smoke testFlash this PR's build → write secrets → power-cycle → assert the device checks in to the broker.
📜 `qtpy_esp32s3_n4r2` checkin · serial.log (✓ from boot to just after the detected test phrase)📜 `qtpy_esp32s3_n4r2` checkin · protomq.log (⏱ aligned to the serial window (2026-06-16T01:13:19.312+00:00 … 2026-06-16T01:14:04.677+00:00))logs for Expected: 🧪 pixelWrite uninitialised-strand regression (#926)v1
📜 `qtpy_esp32s3_n4r2` low · serial.log (✓ from boot to just after the detected test phrase)📜 `qtpy_esp32s3_n4r2` low · protomq.log (⏱ aligned to the serial window (2026-06-16T01:16:47.130+00:00 … 2026-06-16T01:17:26.421+00:00))logs for 📜 `qtpy_esp32s3_n4r2` high · serial.log (✓ from boot to just after the detected test phrase)📜 `qtpy_esp32s3_n4r2` high · protomq.log (⏱ aligned to the serial window (2026-06-16T01:19:32.689+00:00 … 2026-06-16T01:20:16.395+00:00))logs for Expected: release |
🔌 HIL test results — run #17commit ✅ Check-in smoke testFlash this PR's build → write secrets → power-cycle → assert the device checks in to the broker.
📜 `qtpy_esp32s3_n4r2` checkin · serial.log (✓ from boot to just after the detected test phrase)📜 `qtpy_esp32s3_n4r2` checkin · protomq.log (⏱ aligned to the serial window (2026-06-16T11:08:28.917+00:00 … 2026-06-16T11:09:12.234+00:00))logs for Expected: 🧪 pixelWrite uninitialised-strand regression (#926)v1
📜 `qtpy_esp32s3_n4r2` low · serial.log (✓ from boot to just after the detected test phrase)📜 `qtpy_esp32s3_n4r2` low · protomq.log (⏱ aligned to the serial window (2026-06-16T11:11:50.048+00:00 … 2026-06-16T11:12:22.735+00:00))logs for 📜 `qtpy_esp32s3_n4r2` high · serial.log (✓ from boot to just after the detected test phrase)📜 `qtpy_esp32s3_n4r2` high · protomq.log (⏱ aligned to the serial window (2026-06-16T11:14:32.719+00:00 … 2026-06-16T11:15:16.374+00:00))logs for Expected: release |
🔌 HIL test results — run #17commit ✅ Check-in smoke testFlash this PR's build → write secrets → power-cycle → assert the device checks in to the broker.
📜 `qtpy_esp32s3_n4r2` checkin · serial.log (✓ from boot to just after the detected test phrase)📜 `qtpy_esp32s3_n4r2` checkin · protomq.log (⏱ aligned to the serial window (2026-06-16T11:21:50.423+00:00 … 2026-06-16T11:22:33.037+00:00))logs for Expected: 🧪 pixelWrite uninitialised-strand regression (#926)v1
📜 `qtpy_esp32s3_n4r2` low · serial.log (✓ from boot to just after the detected test phrase)📜 `qtpy_esp32s3_n4r2` low · protomq.log (⏱ aligned to the serial window (2026-06-16T11:25:11.305+00:00 … 2026-06-16T11:25:45.149+00:00))logs for 📜 `qtpy_esp32s3_n4r2` high · serial.log (✓ from boot to just after the detected test phrase)📜 `qtpy_esp32s3_n4r2` high · protomq.log (⏱ aligned to the serial window (2026-06-16T11:27:43.375+00:00 … 2026-06-16T11:28:25.882+00:00))logs for Expected: release |
|
Note supposedly when testing bossa/bossac on SAMD51, that pyportal titano didn't like v128, but did like v78, so bisect/investigate using HIL suite (it's on rpi-displays host). Think it was on an alpha debug copy of airlift so probably hated semver or saw unexpected serial msgs at boot etc. v78 + 128 + 130 fine after airlift firmware change to latest. |
…proof Replace the single-purpose hil_lilygo_display.py with hil_display_test.py: a generic, spec-driven firmware-bench driver. The test sequence is data (see .github/scripts/specs/lilygo_t_display_s3.json) — display_add / display_write / component_add|inject / capture steps map to controller stages, so future test writers can drive any board/display or a non-display flow (e.g. add an I2C sensor between display writes) by writing a spec, not editing the driver. Logging conforms to the hil-test-suite.yml convention (PR adafruit#930 hil-lib.sh): poll to terminal so firmware-bench's auto-registered serial/protomq/flash logs and the capture harvest, download them as <target>-<label>-<type>.log, and assemble hil-out/comment.md with a result row, the visual proof, a serial proof window (boot→matched phrase) and a protomq window time-aligned to it (shared UTC-ms clock, ported from append_proof), plus an invocation/stages block. The workflow uploads per-type log artifacts and posts comment.md per run. Adds a firmware-repo skill (.claude/skills/hil-display-arduino) documenting the path. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add initial content for HIL test suite configuration. This is a placeholder PR to experiment with in PR feedback and runners etc.