Files
keyrelay/tools/hw_probe.sh
T
ducoterra 121491bab4 phase: 01_verification_gate
All green. Report:

**Implemented / verified (phase 01 final pass)**
- `.agents/validate.sh` re-verified as the gate: ①`tests/run_tests.sh` ②`SANITIZE=1` run ③`uv … platformio run -e bridge -e console` ④page byte count ⑤hygiene, then the informational `tools/hw_probe.sh`. Added an explicit `== repo hygiene ==` header + `repo hygiene: OK (57 tracked files…)` verdict line so every check prints one (previously hygiene passed in silence).
- `.agents/HW_VERIFICATION.md`: baseline (bridge 26.7 %/87 592 B, 36.1 %/1 135 293 B; console 16.8 %/33.6 %; 3406 checks; page 48 598 B) re-confirmed; served-page row extended with this pass's one-attempt measurements.

**Tests / gate (100 % green)**
- `bash .agents/validate.sh` → **exit 0** (also from `/tmp`): `RESULT: all suites passed` twice (3406 = 3162 C++ + 160 relayctl + 84 page), `2 succeeded`, `page bytes: 48598`, `repo hygiene: OK`, `validation OK`.
- Faults re-demonstrated then reverted: (a) inverted `test_keystate` assert → cold-copy gate `RESULT: FAILED`, **GATE_EXIT=1**; (b) markup id `pArmed`→`pArmedZ` → page suite rc 1; (c) type error in `src/main.cpp` → `bridge FAILED / console FAILED`, rc 1; (d) hygiene regex matches `certs/test.pem` (git index commands are forbidden in this pass — task 01's report holds the original live `git add` demo).
- Lint: no linter is configured for this repo; compiler is the linter — `-Wall -Wextra` host suites + both firmware builds: **0 warnings**; `bash -n` clean; `py_compile` clean. `ruff check` (this machine's broad ruleset) reports 21 pre-existing *preview-rule* style nits in untouched `tools/*.py` + `tests/test_relayctl.py` (SIM/UP/ISC/EXE/RUF/FURB), zero correctness findings — not churned: `watch_target.py`/`relayctl.py` are hardware-verified artifacts outside this phase. `shellcheck` is absent here and not in the local container image (fetching it would violate the gate's no-network rule). Coverage: n/a (shell + docs; no new Python).

**Completion criteria** — all met: exit 0 with per-check output ✓; SANITIZE in-gate ✓; no `-t upload`/device curl/network fetch (grep: only in comments; `run_tests.sh` checks `podman image exists` first, so never pulls) ✓; faults bite ✓; HW_VERIFICATION.md names command+expected observation for every 02–05 device claim + one-attempt rule ✓; full suite green, `git status` = phase artifacts + mandated doc edits, nothing stray ✓.

**Documentation** — README inconsistencies found & fixed: `test_proto` 193→**2327** (table now sums to the headline 3406); duplicate stale `test_page 47` row deleted (element-id clause merged into the 84 row); "last six checks"/"fails 3 checks" claims replaced by measurement (5 fail-safe source pins; `linked()` gate → 2, accept-stamp → 1, both → 3); console line formats corrected to what the code prints (`mouse interface=present target=report protocol buttons=0x00 …`, `queue 0 keys pending…`, `target=BOOT protocol (refused)`); status-JSON sample gained `typed/typing/mouse/mbtn/mframes`; footprint 87 400/35.5 %→87 592/36.1 %; page delivery `Content-Length: 35164`→**48 598** (re-measured live, one capped curl: 200/48 598, 404 on `/nope`, 401 on `/status`); stale Backlog item "Mouse." removed and renumbered; "~35 KB"→"~48 KB"; page-button names matched to the real markup; `validate.sh`/`HW_VERIFICATION.md`/`hw_probe.sh` added to Architecture, Tests and End-to-end. AGENTS.md: only stale statements fixed — "one commit, `Initial import`… predates the mouse and `output.h`" and the pure-module list missing `mouse.h`/`output.h`; not expanded.

**Deviations** — Did not run the keystroke/mouse device rows: the board's target is this very machine, so injecting input during an unattended pass is the side effect the README's "hazard nobody warns you about" forbids; those rows stay blank per the contract (read-only GETs only). No live infra changed.

**Next pending phase:** `02_tls_control_channel` (tasks `01_tls_identity_module` … `06_tls_docs_rollback`).
2026-11-09 00:04:45 -05:00

161 lines
7.6 KiB
Bash
Executable File

#!/usr/bin/env bash
# tools/hw_probe.sh — what device context exists RIGHT NOW. It informs; it does
# not gate. Always exits 0, and .agents/validate.sh deliberately does NOT fold
# its exit status into the gate's rc.
#
# Why this exists (AGENTS.md "Verification standard", PLAN.md "No unit blocks on
# hardware"): device checks are the half of this roadmap's verification the host
# gate cannot see. They are best effort — one attempt, wrapped in a timeout, the
# outcome recorded in .agents/HW_VERIFICATION.md. This probe is the cheap way to
# find out whether attempting one is even plausible, so a unit running unattended
# never discovers the absence of a board by hanging on it.
#
# Rules this script obeys — the same rules the hardware contract states:
# * No side effects. It writes nothing and NEVER opens a serial port: opening
# /dev/ttyACM* asserts DTR/RTS and resets the board. `test -e`/`test -r` and
# read-only sysfs metadata only. HTTP(S) probes are idempotent GETs.
# * Never blocks. Every probe that could wait is capped with `timeout` or curl
# -m. getent CAN hang when no mDNS responder answers, so it runs only under
# `timeout` — if `timeout` itself is missing, the lookup is skipped with a
# stated reason rather than risked.
# * Always exits 0. Every fact is printed; none of them is a verdict. The
# gate result comes from .agents/validate.sh; device verdicts come from the
# outcome table in .agents/HW_VERIFICATION.md, filled in by whoever ran the
# actual command — never inferred from these lines.
#
# One line per fact, prefix "hw-probe", so the output stays greppable:
# serial nodes / keyrelay.local resolution / https / http(+redirect) /
# uv / node / watch_target.py-capable evdev nodes.
set -uo pipefail
have() { command -v "$1" >/dev/null 2>&1; }
# to <secs> <cmd...> — run capped; rc 124 = the cap fired, 125 = cannot cap, so
# the caller must NOT run the command (an uncapped blocking probe is exactly
# what this file's rule forbids).
to() {
local secs="$1"
shift
if have timeout; then
timeout "$secs" "$@"
else
return 125
fi
}
# --- 1. serial nodes (existence and readability ONLY — never opened) ----------
serials=()
for candidate in /dev/ttyACM* /dev/ttyUSB*; do
[[ -e "$candidate" ]] && serials+=("$candidate")
done
if ((${#serials[@]} > 0)); then
for s in "${serials[@]}"; do
if [[ -r "$s" ]]; then
echo "hw-probe serial $s present, readable (NOT opened: opening toggles DTR/RTS and resets the board)"
else
echo "hw-probe serial $s present, NOT readable (serial group membership? console.py would fail)"
fi
done
else
echo "hw-probe serial no /dev/ttyACM* or /dev/ttyUSB* here — no board on a serial port"
fi
# --- 2. keyrelay.local (mDNS via the resolver; may legitimately be slow) ------
mdns_rc=0
mdns_out="$(to 4 getent hosts keyrelay.local 2>/dev/null)" || mdns_rc=$?
if ! have getent && ((mdns_rc != 0)); then
echo "hw-probe mdns unknown — getent not installed; resolution state not checkable here"
elif ((mdns_rc == 0)); then
echo "hw-probe mdns keyrelay.local resolves -> ${mdns_out%%[[:space:]]*}"
elif ((mdns_rc == 124)); then
echo "hw-probe mdns keyrelay.local did not resolve within 4 s (no responder? glibc does not cache mDNS — a retry may still work; the IP is the fallback)"
elif ((mdns_rc == 125)); then
echo "hw-probe mdns unknown — 'timeout' is not installed and an uncapped resolver lookup can hang; skipped on principle"
else
echo "hw-probe mdns keyrelay.local did not resolve"
fi
# --- 3./4. reachability of the two listeners -----------------------------------
# Expectations shift when phase 02 lands: until then :80 serves the page and
# :443 is closed; after it, :443 serves TLS and :80 is redirect-only. Both
# halves print what IS, and name the expectation, so neither state reads as a
# failure — this probe cannot fail, only inform.
if have curl; then
https_rc=0
https_code="$(curl -sk -m 2 -o /dev/null -w '%{http_code}' https://keyrelay.local/ 2>/dev/null)" || https_rc=$?
if [[ "$https_rc" == 0 && "$https_code" != 000 ]]; then
echo "hw-probe https https://keyrelay.local/ -> HTTP $https_code (TLS listener is up)"
else
echo "hw-probe https https://keyrelay.local/ unreachable (curl rc $https_rc, code ${https_code:-000}) — expected until phase 02 (TLS) lands"
fi
http_rc=0
http_fact="$(curl -s -m 2 -o /dev/null -w '%{http_code} %{redirect_url}' http://keyrelay.local/ 2>/dev/null)" || http_rc=$?
http_code="${http_fact%% *}"
http_redirect="${http_fact#* }"
if [[ "$http_rc" == 0 && "$http_code" == 3* && -n "$http_redirect" ]]; then
echo "hw-probe http http://keyrelay.local/ -> HTTP $http_code, redirects to $http_redirect (redirect-only: the post-phase-02 shape)"
elif [[ "$http_rc" == 0 && "$http_code" != 000 ]]; then
echo "hw-probe http http://keyrelay.local/ -> HTTP $http_code, no redirect (plaintext page still served: the pre-phase-02 shape)"
else
echo "hw-probe http http://keyrelay.local/ unreachable (curl rc $http_rc) — board off, asleep, or this host is not on its network"
fi
else
echo "hw-probe http skipped — curl not installed; https/http reachability unknown"
fi
# --- 5./6. the two tools the gate and the suites pin ---------------------------
if have uv; then
echo "hw-probe uv $(command -v uv) — firmware builds available"
else
echo "hw-probe uv MISSING — the gate's firmware check will FAIL (AGENTS.md: pio on PATH is broken; fallback probe: find / -maxdepth 6 -name platformio -type d -path '*site-packages*')"
fi
if have node; then
echo "hw-probe node $(command -v node) — test_page.js runs"
else
echo "hw-probe node MISSING — tests/run_tests.sh would skip the page suite and the gate pins it; the gate will FAIL"
fi
# --- 7. watch_target.py-capable evdev nodes (informational, read-only) ---------
# Same discovery rule watch_target.py uses: USB devices with the board's ids
# (Logitech G815 identity, Espressif default accepted), nodes classified by the
# kernel's capabilities/ev mask, never by node order — the same reasoning that
# keeps the verifier honest. sysfs reads only; no /dev/input node is opened.
kb=""
ms=""
board_ids=""
for dev in /sys/bus/usb/devices/*/; do
[[ -r "${dev}idVendor" && -r "${dev}idProduct" ]] || continue
vid="$(<"${dev}idVendor")"
pid="$(<"${dev}idProduct")"
case "$vid:$pid" in
046d:c33f | 303a:1001) : ;;
*) continue ;;
esac
for ev in "$dev"*/input/input*/event* "$dev"*/*/input/input*/event*; do
[[ -e "$ev" ]] || continue
caps_file="${ev%/*}/capabilities/ev" # built-in, not `dirname` — works with any PATH
[[ -r "$caps_file" ]] || continue
mask_hex="$(<"$caps_file")"
[[ "$mask_hex" =~ ^[0-9a-fA-F]+$ ]] || continue
mask=$((16#$mask_hex))
node="/dev/input/${ev##*/}"
readable="readable"
[[ -r "$node" ]] || readable="NOT readable (input group?)"
if ((mask & 4)); then # EV_REL — the pointer interface
[[ -z "$ms" ]] && ms="$node ($readable)"
elif ((mask & 1)); then # EV_KEY — the keyboard interface
[[ -z "$kb" ]] && kb="$node ($readable)"
fi
board_ids="$vid:$pid"
done
done
if [[ -n "$kb$ms" ]]; then
echo "hw-probe evdev board $board_ids visible: keyboard=${kb:-none} mouse=${ms:-none} — python3 tools/watch_target.py runs ON THIS machine (the target)"
else
echo "hw-probe evdev no USB device with the board's ids (046d:c33f, 303a:1001) here — target-side claims are 'skipped (no device)' unless the board is plugged into another machine"
fi
# Deliberate, unconditional: this script informs and must never decide anything.
exit 0