#!/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 — 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