bringup and order sensors

This commit is contained in:
Thaddeus Hughes
2026-04-22 18:31:31 -05:00
parent a775999c87
commit 3774cde506
55 changed files with 184854 additions and 38 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

168
bringup/bringup.py Normal file
View File

@@ -0,0 +1,168 @@
#!/usr/bin/env python3
"""
SC-F001 Bring-up Tool
Flashes the firmware (optional) and then walks the operator through the
bring-up procedure documented in docs/SC-F001/BRINGUP.md.
Usage:
bringup.py --port <COMx | /dev/ttyUSB0> [options]
Options:
--port <p> Serial port (required)
--baud <n> Baud rate for UART protocol (default: 115200)
--out <basename> Transcript basename (default: dated)
Flashing (optional):
--flash Flash the firmware before running tests
--build-dir <p> Build directory containing flasher_args.json
(default: ../build/ relative to this script)
--flash-baud <n> Baud for esptool (default: 460800)
--erase `esptool erase_flash` before writing (slow)
--flash-only Flash and exit (no bring-up tests)
Testing:
--skip-relays Skip the live relay pulse stage
--no-calibrate Skip battery-voltage calibration prompt
--no-transcript Do not write a .txt transcript file
"""
from __future__ import annotations
import argparse
import sys
import time
from datetime import datetime
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from protocol import Link # noqa: E402
from stages import all_stages, Tally # noqa: E402
import flash as flasher # noqa: E402
def _default_basename() -> str:
return "BRINGUP_" + datetime.now().strftime("%d%b%Y_%H%M").upper()
def _do_flash(args, log_fn) -> None:
build_dir = Path(args.build_dir) if args.build_dir else None
try:
flasher.flash(
port=args.port,
build_dir=build_dir,
baud=args.flash_baud,
erase_all=args.erase,
log=log_fn,
)
except flasher.FlashError as e:
log_fn(f"FLASH FAILED: {e}")
raise SystemExit(2)
def main() -> int:
ap = argparse.ArgumentParser(description="SC-F001 bring-up tool (flash + test)")
ap.add_argument("--port", required=True, help="serial port (COM5, /dev/ttyUSB0, ...)")
ap.add_argument("--baud", type=int, default=115200)
ap.add_argument("--out", default=None, help="transcript basename")
ap.add_argument("--flash", action="store_true",
help="flash firmware before tests")
ap.add_argument("--build-dir", default=None,
help="build dir with flasher_args.json (default: ../build)")
ap.add_argument("--flash-baud", type=int, default=460800)
ap.add_argument("--erase", action="store_true",
help="erase_flash before writing")
ap.add_argument("--flash-only", action="store_true",
help="flash and exit; skip tests")
ap.add_argument("--skip-relays", action="store_true")
ap.add_argument("--no-calibrate", action="store_true")
ap.add_argument("--no-transcript", action="store_true")
args = ap.parse_args()
basename = args.out or _default_basename()
transcript_path = None if args.no_transcript else Path(basename + ".txt")
transcript_file = None
transcript_cb = None
if transcript_path:
transcript_file = transcript_path.open("w", encoding="utf-8")
def _tx(line: str) -> None:
ts = datetime.now().strftime("%H:%M:%S.%f")[:-3]
transcript_file.write(f"{ts} {line}\n")
transcript_file.flush()
transcript_cb = _tx
print(f"Transcript → {transcript_path}")
def _log(msg: str) -> None:
print(msg)
if transcript_cb:
transcript_cb(msg)
# Phase 1: optional flash
if args.flash or args.flash_only:
_log(f"== Flashing {args.port} ==")
_do_flash(args, _log)
_log("== Flash complete ==")
if args.flash_only:
if transcript_file:
transcript_file.close()
return 0
# Give the chip a moment to finish hard_reset before we open the port
time.sleep(1.5)
# Phase 2: connect and walk bring-up stages
_log(f"Connecting to {args.port} @ {args.baud} ...")
link = Link(args.port, baud=args.baud, transcript=transcript_cb)
link.ser.reset_input_buffer()
tally = Tally()
stages = all_stages(skip_relays=args.skip_relays,
no_calibrate=args.no_calibrate)
def _snapshot(t: Tally) -> tuple[int, int, int, int]:
return (t.passed, t.failed, t.warnings, t.skipped)
def _restore(t: Tally, snap: tuple[int, int, int, int]) -> None:
t.passed, t.failed, t.warnings, t.skipped = snap
try:
for stage in stages:
while True:
snap = _snapshot(tally)
try:
stage(link, tally)
except TimeoutError as e:
print(f" TIMEOUT: {e}")
tally.note_fail()
except Exception as e:
print(f" EXCEPTION in stage: {e!r}")
tally.note_fail()
if tally.failed > snap[1]:
ans = input(" Stage had FAILs — retry? [y/n]: ").strip().lower()
if ans.startswith("y"):
_restore(tally, snap)
continue
break
except KeyboardInterrupt:
print("\nAborted by operator")
try:
link.send("BU.END")
except Exception:
pass
print("\n==== Bring-up summary ====")
print(f" pass={tally.passed} fail={tally.failed} "
f"warn={tally.warnings} skip={tally.skipped}")
link.close()
if transcript_file:
transcript_file.close()
return 0 if tally.failed == 0 else 1
if __name__ == "__main__":
raise SystemExit(main())

39
bringup/calibrate.py Normal file
View File

@@ -0,0 +1,39 @@
"""Battery voltage calibration math.
Firmware model (power_mgmt.c:278):
V_bat = mV * V_SENS_K + V_SENS_OFFSET
"""
from __future__ import annotations
from dataclasses import dataclass
@dataclass
class CalResult:
k: float
offset: float
note: str
def single_point_offset(bat_mv: float, v_true: float, k: float) -> CalResult:
"""Fix K, solve for OFFSET so that V_bat(bat_mv) == v_true."""
offset = v_true - bat_mv * k
return CalResult(k=k, offset=offset, note="single-point offset")
def two_point(
bat_mv_1: float, v_true_1: float,
bat_mv_2: float, v_true_2: float,
) -> CalResult:
"""Solve for K and OFFSET from two (mV, V) pairs."""
dmv = bat_mv_2 - bat_mv_1
if abs(dmv) < 1e-3:
raise ValueError("Two calibration points are too close — pick wider V")
k = (v_true_2 - v_true_1) / dmv
offset = v_true_1 - bat_mv_1 * k
return CalResult(k=k, offset=offset, note="two-point")
def verify(bat_mv: float, cal: CalResult) -> float:
return bat_mv * cal.k + cal.offset

108
bringup/flash.py Normal file
View File

@@ -0,0 +1,108 @@
"""Wrap esptool to flash the SC-F001 firmware from a build directory.
Reads `build/flasher_args.json` (produced by `idf.py build`) to get the
authoritative list of offsets + binaries, then invokes esptool once with all
of them — no hardcoded offsets here.
Requires esptool reachable either as a Python module (`pip install esptool`)
or as `esptool.py` on PATH (e.g., from an ESP-IDF activation).
"""
from __future__ import annotations
import importlib.util
import json
import shutil
import subprocess
import sys
from pathlib import Path
class FlashError(RuntimeError):
pass
def _default_build_dir() -> Path:
# bringup dir sits at SC-F001/bringup; build sits at SC-F001/build
return Path(__file__).resolve().parent.parent / "build"
def _resolve_esptool_invocation() -> list[str]:
"""Return the command prefix to run esptool, preferring the installed
module in the current interpreter, falling back to esptool.py on PATH.
Raises FlashError with an actionable message if neither is available.
"""
if importlib.util.find_spec("esptool") is not None:
return [sys.executable, "-m", "esptool"]
fallback = shutil.which("esptool.py") or shutil.which("esptool")
if fallback:
return [fallback]
raise FlashError(
"esptool is not installed in this Python and not on PATH.\n"
" Install with: "
f"{sys.executable} -m pip install -r "
f"{Path(__file__).parent / 'requirements.txt'}\n"
" Or activate an ESP-IDF shell that provides esptool.py."
)
def _load_manifest(build_dir: Path) -> dict:
manifest = build_dir / "flasher_args.json"
if not manifest.exists():
raise FlashError(
f"Build manifest not found: {manifest}\n"
f"Run `idf.py build` from SC-F001/ first, or pass --build-dir."
)
return json.loads(manifest.read_text())
def _resolve_flash_args(build_dir: Path, manifest: dict) -> list[str]:
"""Expand manifest into a (offset, abs-path) list suitable for esptool."""
args: list[str] = []
# flasher_args.json's flash_files is {offset_hex: relpath}.
for offset_hex, rel in manifest["flash_files"].items():
p = (build_dir / rel).resolve()
if not p.exists():
raise FlashError(f"Missing firmware binary: {p}")
args.append(offset_hex)
args.append(str(p))
return args
def flash(
port: str,
build_dir: Path | None = None,
baud: int = 460800,
erase_all: bool = False,
log: callable = print,
) -> None:
build_dir = (build_dir or _default_build_dir()).resolve()
manifest = _load_manifest(build_dir)
chip = manifest.get("extra_esptool_args", {}).get("chip", "esp32")
before = manifest.get("extra_esptool_args", {}).get("before", "default_reset")
after = manifest.get("extra_esptool_args", {}).get("after", "hard_reset")
esptool_cmd = _resolve_esptool_invocation()
base_cmd = esptool_cmd + [
"--chip", chip,
"--port", port,
"--baud", str(baud),
"--before", before,
"--after", after,
]
if erase_all:
log(f" erase_flash @ {port}")
subprocess.check_call(base_cmd + ["erase_flash"])
write_args = manifest.get("write_flash_args", [])
cmd = base_cmd + ["write_flash"] + write_args + _resolve_flash_args(build_dir, manifest)
log(f" flashing from {build_dir}")
log(f" files: {list(manifest['flash_files'].items())}")
try:
subprocess.check_call(cmd)
except subprocess.CalledProcessError as e:
raise FlashError(f"esptool failed (exit {e.returncode})") from e

209
bringup/protocol.py Normal file
View File

@@ -0,0 +1,209 @@
"""Line-oriented protocol over UART for the SC-F001 bring-up procedure.
The firmware side is specified in docs/SC-F001/BRINGUP.md §3.
Commands are prefixed `BU.`; responses are `BU.OK`, `BU.ERR`, `BU.SKIP`,
or streamed `BU.EVENT` lines.
"""
from __future__ import annotations
import re
import time
from dataclasses import dataclass, field
from typing import Callable, Iterator
import serial
LINE_TIMEOUT_S = 2.0
@dataclass
class Response:
status: str # "OK" | "ERR" | "SKIP"
cmd: str
fields: dict[str, str] = field(default_factory=dict)
raw: str = ""
def get(self, key: str, default: str | None = None) -> str | None:
return self.fields.get(key, default)
def getf(self, key: str, default: float = float("nan")) -> float:
v = self.fields.get(key)
if v is None:
return default
try:
return float(v)
except ValueError:
return default
def geti(self, key: str, default: int = 0) -> int:
v = self.fields.get(key)
if v is None:
return default
try:
return int(v, 0)
except ValueError:
return default
@dataclass
class Event:
cmd: str
fields: dict[str, str] = field(default_factory=dict)
raw: str = ""
_KV_RE = re.compile(r'(\w[\w.]*)=("[^"]*"|\S+)')
def _parse_kv(rest: str) -> dict[str, str]:
out: dict[str, str] = {}
for m in _KV_RE.finditer(rest):
k = m.group(1)
v = m.group(2)
if v.startswith('"') and v.endswith('"'):
v = v[1:-1]
out[k] = v
return out
def parse_line(line: str) -> Response | Event | None:
"""Returns None for lines that aren't bring-up protocol (boot chatter etc.)."""
line = line.rstrip("\r\n")
if not line.startswith("BU."):
return None
tokens = line.split(None, 2)
tag = tokens[0] # BU.OK | BU.ERR | BU.EVENT | BU.SKIP
if len(tokens) < 2:
return None
cmd = tokens[1]
rest = tokens[2] if len(tokens) >= 3 else ""
fields = _parse_kv(rest)
if tag == "BU.EVENT":
return Event(cmd=cmd, fields=fields, raw=line)
status = tag.removeprefix("BU.")
if status in ("OK", "ERR", "SKIP"):
return Response(status=status, cmd=cmd, fields=fields, raw=line)
return None
class Link:
"""Wraps a serial.Serial with line I/O + protocol parsing."""
def __init__(self, port: str, baud: int = 115200, transcript: Callable[[str], None] | None = None):
# Don't let pyserial auto-assert DTR/RTS on open. ESP32 dev boards
# tie those into the BOOT/EN transistor pair — default-asserted lines
# hold the chip in reset for as long as the port is open, which
# silently blocks every command we send.
self.ser = serial.Serial()
self.ser.port = port
self.ser.baudrate = baud
self.ser.timeout = LINE_TIMEOUT_S
self.ser.dtr = False
self.ser.rts = False
self.ser.open()
# After open, re-assert False (some platforms override on open).
self.ser.dtr = False
self.ser.rts = False
self.transcript = transcript or (lambda _s: None)
self._buf = b""
def close(self) -> None:
try:
self.ser.close()
except Exception:
pass
def _readline(self, deadline: float) -> str | None:
while True:
remaining = deadline - time.monotonic()
if remaining <= 0:
return None
if b"\n" in self._buf:
line, _, self._buf = self._buf.partition(b"\n")
s = line.decode("utf-8", errors="replace")
self.transcript(f"<- {s}")
return s
self.ser.timeout = min(remaining, 0.5)
chunk = self.ser.read(256)
if chunk:
self._buf += chunk
def send(self, cmd: str) -> None:
if not cmd.endswith("\n"):
cmd = cmd + "\n"
self.transcript(f"-> {cmd.rstrip()}")
self.ser.write(cmd.encode("utf-8"))
self.ser.flush()
def request(self, cmd: str, overall_timeout_s: float = 5.0) -> Response:
"""Send a command and collect lines until a terminating OK/ERR/SKIP."""
self.send(cmd)
deadline = time.monotonic() + overall_timeout_s
while True:
line = self._readline(deadline)
if line is None:
raise TimeoutError(f"No terminating response to {cmd!r}")
parsed = parse_line(line)
if isinstance(parsed, Response):
return parsed
# Events during a non-streaming command are unexpected but not fatal
# — swallow them and keep reading.
def wait_ready(self, cmd: str = "BU.BEGIN",
per_attempt_s: float = 1.5,
overall_timeout_s: float = 30.0,
show_boot_chatter: bool = True) -> "Response":
"""Send `cmd` repeatedly until we get a Response back.
Used once at the start of a session to ride out the boot/init time
before uart_comms installs the UART driver — bytes sent earlier are
dropped by the hardware FIFO and never reach the firmware.
When `show_boot_chatter` is True (default), non-protocol lines
(ESP_LOG output, boot banner) are printed to stdout so the operator
can see what the device is actually doing while we wait.
"""
deadline = time.monotonic() + overall_timeout_s
last_err: Exception | None = None
attempt = 0
while time.monotonic() < deadline:
attempt += 1
remaining = deadline - time.monotonic()
print(f" [wait_ready] attempt {attempt}, {remaining:.0f}s left")
self.send(cmd)
per_deadline = time.monotonic() + per_attempt_s
while True:
line = self._readline(per_deadline)
if line is None:
last_err = TimeoutError(f"no response to {cmd!r}")
break
parsed = parse_line(line)
if isinstance(parsed, Response):
return parsed
if parsed is None and show_boot_chatter:
stripped = line.rstrip("\r\n")
if stripped:
print(f" [uart] {stripped}")
raise TimeoutError(
f"Device never answered {cmd!r} within {overall_timeout_s:.0f}s "
f"(last: {last_err})"
)
def request_stream(
self, cmd: str, overall_timeout_s: float
) -> Iterator[Event | Response]:
"""Yield each Event, then the terminating Response."""
self.send(cmd)
deadline = time.monotonic() + overall_timeout_s
while True:
line = self._readline(deadline)
if line is None:
raise TimeoutError(f"Timed out during streaming {cmd!r}")
parsed = parse_line(line)
if parsed is None:
continue
yield parsed
if isinstance(parsed, Response):
return

2
bringup/requirements.txt Normal file
View File

@@ -0,0 +1,2 @@
pyserial>=3.5
esptool>=4.6

474
bringup/stages.py Normal file
View File

@@ -0,0 +1,474 @@
"""One function per bring-up stage. Each is explicit and independently
runnable from the operator prompt — no implicit sequencing between them."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Callable
from protocol import Link, Response, Event
@dataclass
class Tally:
passed: int = 0
failed: int = 0
skipped: int = 0
warnings: int = 0
def note_pass(self) -> None: self.passed += 1
def note_fail(self) -> None: self.failed += 1
def note_skip(self) -> None: self.skipped += 1
def note_warn(self) -> None: self.warnings += 1
def _prompt(msg: str, accept_skip: bool = True) -> str:
"""Block for operator input. Returns 'run', 'skip', or 'quit'."""
hint = " [Enter=run" + (", s=skip" if accept_skip else "") + ", q=quit]"
ans = input(msg + hint + ": ").strip().lower()
if ans in ("", "y", "yes", "r", "run"):
return "run"
if ans in ("s", "skip") and accept_skip:
return "skip"
if ans in ("q", "quit", "exit"):
return "quit"
# anything else — treat as run, operator can always quit with Ctrl-C
return "run"
def _show_response(label: str, r: Response) -> None:
bag = " ".join(f"{k}={v}" for k, v in r.fields.items())
print(f" [{r.status}] {label} {bag}")
# ---------------------------------------------------------------------------
# Stage 0 — Begin + identify
# ---------------------------------------------------------------------------
def stage_begin(link: Link, t: Tally) -> None:
print("\n== Stage 0 — Begin ==")
print(" Waiting for device to finish booting ...")
r = link.wait_ready("BU.BEGIN", per_attempt_s=1.5, overall_timeout_s=30.0)
_show_response("begin", r)
if r.status != "OK":
t.note_fail(); raise SystemExit("Device did not enter bring-up mode")
r = link.request("BU.INFO")
_show_response("info", r)
(t.note_pass if r.status == "OK" else t.note_fail)()
print(f" -> fw={r.get('fw')} board={r.get('board', '?')} reset={r.get('reset')} "
f"heap={r.get('heap')}")
# ---------------------------------------------------------------------------
# Stage 1 — Flash & persistence
# ---------------------------------------------------------------------------
def stage_flash(link: Link, t: Tally) -> None:
print("\n== Stage 1 — Flash & storage ==")
if _prompt(" Run flash roundtrip + log head/tail check") != "run":
t.note_skip(); return
r = link.request("BU.FLASH", overall_timeout_s=10)
_show_response("flash", r)
(t.note_pass if r.status == "OK" else t.note_fail)()
# ---------------------------------------------------------------------------
# Stage 2 — I2C + LEDs
# ---------------------------------------------------------------------------
def stage_i2c_led(link: Link, t: Tally) -> None:
import threading
import time
print("\n== Stage 2 — I2C / TCA9555 / LEDs ==")
if _prompt(" Probe TCA9555 and run LED waterfall") != "run":
t.note_skip(); return
r = link.request("BU.I2C")
_show_response("i2c", r)
if r.status != "OK":
t.note_fail(); return
t.note_pass()
# Waterfall: 000 → 001 → 011 → 111 → 110 → 100 → 000, looped.
pattern = [0b000, 0b001, 0b011, 0b111, 0b110, 0b100]
step_s = 0.25
stop = threading.Event()
def driver() -> None:
i = 0
while not stop.is_set():
try:
link.request(f"BU.LED {pattern[i % len(pattern)]}",
overall_timeout_s=2.0)
except Exception as e:
print(f" [led] {e!r}")
break
i += 1
# Sleep in small chunks so we can stop promptly.
for _ in range(int(step_s * 20)):
if stop.is_set():
return
time.sleep(0.05)
th = threading.Thread(target=driver, daemon=True)
th.start()
print(" LED waterfall running — watch the board.")
try:
while True:
ans = input(" Did the waterfall look correct? [y/n]: ").strip().lower()
if ans.startswith("y"):
verdict = "pass"; break
if ans.startswith("n"):
verdict = "fail"; break
finally:
stop.set()
th.join(timeout=3)
try:
link.request("BU.LED 0", overall_timeout_s=2.0)
except Exception:
pass
if verdict == "pass":
t.note_pass()
else:
print(" LED visual check FAILED")
t.note_fail()
# ---------------------------------------------------------------------------
# Stage 3 — ADC + battery calibration
# ---------------------------------------------------------------------------
def stage_adc(link: Link, t: Tally, calibrate: bool = True) -> None:
print("\n== Stage 3 — Analog front-end ==")
if _prompt(" Read ADC snapshot (battery / current / FAULT / VOC)") != "run":
t.note_skip(); return
r = link.request("BU.ADC")
_show_response("adc", r)
if r.status != "OK":
t.note_fail(); return
t.note_pass()
bat_V = r.getf("bat_V", 0.0)
print(f" -> battery reports {bat_V:.3f} V")
# V5-specific checks — INFORMATIONAL only on the current V5 boards,
# because both VOC and FAULT are wired straight to the ESP32 with no
# external resistors. Without a pull-down on VOC the pin floats at VDD
# (IC has a ~10 µA internal current source toward VDD, needs an
# external RL_VOC to GND to set the OC threshold). Without a pull-up
# on FAULT (open-drain, active-low) the line is undefined. Neither
# reading is actionable in firmware until the board is respun —
# GPIO36/39 are input-only on ESP32 and don't have internal pulls.
# See docs/SC-F001/README.md "V5 hardware caveats".
if "voc_mv" in r.fields:
voc_mv = r.geti("voc_mv")
in_range = 330 <= voc_mv <= 660
print(f" INFO: VOC={voc_mv} mV "
f"({'in' if in_range else 'out of'} datasheet linear range 330660 mV)")
if "fault" in r.fields:
if r.geti("fault") == 1:
print(" INFO: FAULT pin reads LOW — expected on V5 boards without "
"an external pull-up on the FAULT trace")
if not calibrate:
return
_run_battery_cal(link, t)
def _run_battery_cal(link: Link, t: Tally) -> None:
from calibrate import single_point_offset, verify
print("\n-- Battery voltage calibration --")
while True:
ans = input(" Run calibration now? [y/n]: ").strip().lower()
if ans.startswith("n"):
t.note_skip(); return
if ans.startswith("y"):
break
# Read current K and raw mV.
k_r = link.request("BU.PARAM GET V_SENS_K")
if k_r.status != "OK":
print(" Could not read V_SENS_K"); t.note_fail(); return
k = k_r.getf("value")
adc_r = link.request("BU.ADC")
bat_mv = adc_r.getf("bat_mv")
if bat_mv == 0.0:
print(" ADC read looks bogus (mv=0)"); t.note_fail(); return
raw_ans = input(" Measure the battery at the board terminals with a DMM.\n"
" Enter true voltage (V): ").strip()
try:
v_true = float(raw_ans)
except ValueError:
print(" Not a number — skipping cal"); t.note_skip(); return
cal = single_point_offset(bat_mv, v_true, k)
predicted = verify(bat_mv, cal)
print(f" bat_mv={bat_mv:.0f} K={k:.10f} new OFFSET={cal.offset:+.6f} V")
print(f" predicted V_bat after cal = {predicted:.3f} (true = {v_true:.3f})")
if input(" Write this to the device? [y/n]: ").strip().lower().startswith("y"):
wr = link.request(f"BU.PARAM SET V_SENS_OFFSET {cal.offset:.6f}")
_show_response("param.set", wr)
if wr.status != "OK":
t.note_fail(); return
# Verify by re-reading the ADC
check = link.request("BU.ADC")
new_V = check.getf("bat_V")
err = new_V - v_true
print(f" Post-cal bat_V = {new_V:.3f} (err {err*1000:+.1f} mV)")
if abs(err) < 0.05:
t.note_pass()
else:
print(" WARN: residual error > 50 mV")
t.note_warn()
else:
print(" Calibration not written (operator declined)")
t.note_skip()
# ---------------------------------------------------------------------------
# Stage 4 — Discrete sensors (mandatory edges)
# ---------------------------------------------------------------------------
SENSOR_NAMES = ["SAFETY"] # JACK and DRIVE are checked via the relay pulse stage.
def stage_sensors(link: Link, t: Tally) -> None:
"""Live-print safety-sensor edges until operator presses Enter.
Drive and jack sensors are encoder-style and only trip while the motor
runs — they're verified as a side effect of Stage 5 relay pulses.
"""
import threading
print("\n== Stage 4 — Sensor live view ==")
print(" Live state of all 4 sensor pins is printed below when any one")
print(" changes. Per-edge events also print as they arrive.")
print(" Poke each sensor by hand / magnet / jumper to verify it responds.")
print(" SAFETY must show both break and make to pass; the others are")
print(" diagnostic only (drive/jack are properly tested in Stage 5).")
print(" Press Enter when you're satisfied.")
state = {"make": False, "break": False}
last_state_line = {"v": ""}
def reader() -> None:
try:
for item in link.request_stream("BU.SENSORS.WATCH 0",
overall_timeout_s=3600):
if isinstance(item, Event):
if item.cmd == "sensor":
name = item.fields.get("name")
edge = item.fields.get("edge")
if name == "SAFETY" and edge in state:
state[edge] = True
print(f" [{name}] {edge}")
elif item.cmd == "state":
# Live snapshot of all four sensors. Only print when
# the level line changes, so steady state doesn't spam.
f = item.fields
line = (f"SAFETY={f.get('SAFETY','?')} "
f"DRIVE={f.get('DRIVE','?')} "
f"JACK={f.get('JACK','?')} "
f"AUX={f.get('AUX','?')} "
f"isr=(s={f.get('isr_s','?')} "
f"d={f.get('isr_d','?')} "
f"j={f.get('isr_j','?')} "
f"a={f.get('isr_a','?')})")
if line != last_state_line["v"]:
print(f" [state] {line}")
last_state_line["v"] = line
elif isinstance(item, Response):
# terminating OK after we aborted
return
except Exception as e: # pragma: no cover — defensive
print(f" [reader] {e!r}")
th = threading.Thread(target=reader, daemon=True)
th.start()
input(" Press Enter when SAFETY has been actuated: ")
# Kick the firmware out of its watch loop: any byte aborts.
link.send("") # just the \n
th.join(timeout=3)
if state["make"] and state["break"]:
print(" SAFETY: PASS (saw break and make)")
t.note_pass()
else:
missing = [k for k, v in state.items() if not v]
print(f" SAFETY: FAIL — missed {missing}")
t.note_fail()
# ---------------------------------------------------------------------------
# Stage 5 — Relay bridges
# ---------------------------------------------------------------------------
# (bridge, dir, ms, (dI_min, dI_max), check_edges)
# check_edges → bridge has an encoder-style sensor; pulse must produce
# at least one edge on it.
RELAY_TESTS = [
("SENSORS", "ON", 200, (0.0, 0.0), False),
("DRIVE", "FWD", 1000, (0.5, 25.0), True),
("DRIVE", "REV", 1000, (0.5, 25.0), True),
("JACK", "UP", 500, (0.2, 25.0), True),
("JACK", "DOWN", 500, (0.2, 25.0), True),
("AUX", "FWD", 150, (0.1, 25.0), False),
]
def stage_relays(link: Link, t: Tally) -> None:
print("\n== Stage 5 — Relay bridges ==")
print(" PRECONDITIONS:")
print(" - Battery connected, fuse in place")
print(" - Drive wheels off ground / disengaged")
print(" - Safety interlock asserted (SAFETY sensor HIGH)")
if _prompt(" Proceed with live relay tests", accept_skip=True) != "run":
print(" Relay stage SKIPPED"); t.note_skip(); return
for bridge, direction, ms, (lo, hi), check_edges in RELAY_TESTS:
prompt = f" Pulse {bridge} {direction} for {ms} ms"
if _prompt(prompt) != "run":
t.note_skip(); continue
r = link.request(f"BU.RELAY {bridge} {direction} {ms}",
overall_timeout_s=ms / 1000.0 + 5.0)
_show_response(f"{bridge}/{direction}", r)
if r.status == "SKIP":
print(" Device refused (safety?)"); t.note_skip(); continue
if r.status != "OK":
t.note_fail(); continue
if bridge == "SENSORS":
t.note_pass(); continue
i_before = r.getf("I_before")
i_mid = r.getf("I_mid")
delta = abs(i_mid - i_before)
tripped = r.geti("tripped") == 1
edges = r.geti("edges")
edge_str = f" edges={edges}" if check_edges else ""
print(f" |ΔI| = {delta:.2f} A (expected {lo}-{hi}) "
f"tripped={tripped}{edge_str}")
if tripped:
print(" FAIL: efuse tripped"); t.note_fail(); continue
if check_edges and edges <= 0:
print(f" FAIL: {bridge} sensor saw no edges — motor not turning?")
t.note_fail(); continue
if lo <= delta <= hi:
t.note_pass()
else:
print(f" WARN: ΔI outside nominal")
t.note_warn()
# ---------------------------------------------------------------------------
# Stage 6 — Radio & connectivity
# ---------------------------------------------------------------------------
def stage_rf(link: Link, t: Tally) -> None:
import threading
print("\n== Stage 6a — RF 433 MHz ==")
if _prompt(" Watch for RF remote codes") != "run":
t.note_skip(); return
print(" Press buttons on the RF remote. Codes will print live.")
print(" Press Enter to stop.")
count = {"seen": 0}
def reader() -> None:
try:
for item in link.request_stream("BU.RF.WATCH 0",
overall_timeout_s=3600):
if isinstance(item, Event) and item.cmd == "rf":
code = item.fields.get("code", "?")
print(f" rf code={code}")
count["seen"] += 1
elif isinstance(item, Response):
return
except Exception as e: # pragma: no cover
print(f" [reader] {e!r}")
th = threading.Thread(target=reader, daemon=True)
th.start()
input(" Press Enter when done: ")
link.send("") # abort the watch
th.join(timeout=3)
print(f" -> {count['seen']} code(s) captured")
(t.note_pass if count["seen"] > 0 else t.note_warn)()
def stage_wifi(link: Link, t: Tally) -> None:
print("\n== Stage 6b — WiFi + web UI ==")
if _prompt(" Start SoftAP and wait for a client to load the web UI") != "run":
t.note_skip(); return
r = link.request("BU.WIFI.START", overall_timeout_s=20)
_show_response("wifi.start", r)
if r.status != "OK":
t.note_fail(); return
ssid = r.get("ssid", "?")
print(f"\n Connect a device to WiFi SSID `{ssid}` and open http://192.168.4.1/")
print(" Waiting for a client to associate and load the page... (Ctrl+C to abort)")
try:
for item in link.request_stream("BU.WIFI.WAIT", overall_timeout_s=3600):
if isinstance(item, Event):
print(f" {item.cmd} {' '.join(f'{k}={v}' for k,v in item.fields.items())}")
elif isinstance(item, Response):
_show_response("wifi.wait", item)
(t.note_pass if item.status == "OK" else t.note_fail)()
break
except KeyboardInterrupt:
print(" WiFi wait aborted by operator"); t.note_skip()
# ---------------------------------------------------------------------------
# End
# ---------------------------------------------------------------------------
def stage_end(link: Link, t: Tally) -> None:
print("\n== Stage — End ==")
if _prompt(" Exit bring-up mode (device will reboot)") != "run":
print(" Leaving device in bring-up mode — reset manually to resume normal firmware.")
return
r = link.request("BU.END", overall_timeout_s=5)
_show_response("end", r)
# Device reboots; no further response expected.
# ---------------------------------------------------------------------------
# Top-level driver
# ---------------------------------------------------------------------------
Stage = Callable[[Link, Tally], None]
def all_stages(skip_relays: bool = False, no_calibrate: bool = False) -> list[Stage]:
stages: list[Stage] = [
stage_begin,
stage_flash,
stage_i2c_led,
lambda link, t: stage_adc(link, t, calibrate=not no_calibrate),
stage_sensors,
]
if not skip_relays:
stages.append(stage_relays)
stages.extend([
stage_rf,
stage_wifi,
stage_end,
])
return stages