bringup and order sensors
This commit is contained in:
BIN
bringup/__pycache__/calibrate.cpython-311.pyc
Normal file
BIN
bringup/__pycache__/calibrate.cpython-311.pyc
Normal file
Binary file not shown.
BIN
bringup/__pycache__/flash.cpython-311.pyc
Normal file
BIN
bringup/__pycache__/flash.cpython-311.pyc
Normal file
Binary file not shown.
BIN
bringup/__pycache__/flash.cpython-313.pyc
Normal file
BIN
bringup/__pycache__/flash.cpython-313.pyc
Normal file
Binary file not shown.
BIN
bringup/__pycache__/protocol.cpython-311.pyc
Normal file
BIN
bringup/__pycache__/protocol.cpython-311.pyc
Normal file
Binary file not shown.
BIN
bringup/__pycache__/protocol.cpython-313.pyc
Normal file
BIN
bringup/__pycache__/protocol.cpython-313.pyc
Normal file
Binary file not shown.
BIN
bringup/__pycache__/stages.cpython-311.pyc
Normal file
BIN
bringup/__pycache__/stages.cpython-311.pyc
Normal file
Binary file not shown.
BIN
bringup/__pycache__/stages.cpython-313.pyc
Normal file
BIN
bringup/__pycache__/stages.cpython-313.pyc
Normal file
Binary file not shown.
168
bringup/bringup.py
Normal file
168
bringup/bringup.py
Normal 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
39
bringup/calibrate.py
Normal 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
108
bringup/flash.py
Normal 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
209
bringup/protocol.py
Normal 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
2
bringup/requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
pyserial>=3.5
|
||||
esptool>=4.6
|
||||
474
bringup/stages.py
Normal file
474
bringup/stages.py
Normal 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 330–660 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
|
||||
Reference in New Issue
Block a user