From 0299ba44fd0e55b66b760692486e48a2e334cd73 Mon Sep 17 00:00:00 2001 From: simon Date: Mon, 18 May 2026 23:44:58 +0200 Subject: [PATCH] Add bench UART ports and cold-start reset to goTool autotest. Bench configs define command and console serial paths; scenarios can reset nodes via esptool before tests. Smoke resets all nodes then waits for ESP-NOW join. Co-authored-by: Cursor --- goTool/README.md | 11 +-- goTool/autotest/config.go | 19 +++++ goTool/autotest/config_test.go | 3 + goTool/autotest/reset.go | 103 +++++++++++++++++++++++ goTool/autotest/runner.go | 31 +++++++ goTool/cmd_autotest.go | 27 +++++- goTool/main.go | 42 +++++---- goTool/testdata/README.md | 45 +++++++--- goTool/testdata/configs/example-lab.json | 10 ++- goTool/testdata/scenarios/smoke.json | 22 +++-- 10 files changed, 268 insertions(+), 45 deletions(-) create mode 100644 goTool/autotest/reset.go diff --git a/goTool/README.md b/goTool/README.md index 2839e2f..f1c64bd 100644 --- a/goTool/README.md +++ b/goTool/README.md @@ -31,15 +31,16 @@ go run . -port /dev/ttyUSB0 clients ### Automated tests -Bench **configs** (`testdata/configs/`) list master/slave MACs. **Scenarios** (`testdata/scenarios/`) run a sequence of the same UART commands with `input` / `expect` checks. +Bench **configs** (`testdata/configs/`) list network, MACs, and serial ports (`uart.master` for commands, `*_console` for esptool reset). **Scenarios** run UART commands plus optional `reset` steps. ```bash -go run . -port /dev/ttyUSB0 test -list-configs -go run . -port /dev/ttyUSB0 test -list-scenarios -go run . -port /dev/ttyUSB0 test -config example-lab -scenario smoke -go run . -port /dev/ttyUSB0 test -config testdata/configs/my-lab.json -scenario smoke -v +go run . test -list-configs +go run . test -config example-lab -scenario smoke +go run . test -config my-lab -scenario smoke -port /dev/ttyUSB1 -v ``` +With a complete bench config, `-port` is optional for `test` (uses `uart.master` from JSON). + See [`testdata/README.md`](testdata/README.md) for the JSON schema. ```bash diff --git a/goTool/autotest/config.go b/goTool/autotest/config.go index 9f27b31..99fda9e 100644 --- a/goTool/autotest/config.go +++ b/goTool/autotest/config.go @@ -7,12 +7,23 @@ import ( "strings" ) +// UARTConfig holds serial paths for gotool commands and per-node reset (USB console). +type UARTConfig struct { + // Baud for uart.master (default 921600). + Baud uint `json:"baud,omitempty"` + // Master command UART (external adapter on GPIO2/3), e.g. /dev/ttyUSB0. + Master string `json:"master"` + // Master USB console / JTAG serial for esptool reset, e.g. /dev/ttyACM0. + MasterConsole string `json:"master_console,omitempty"` +} + // Config describes the bench: ESP-NOW network, master MAC, and known slaves. type Config struct { ID string `json:"id"` Description string `json:"description,omitempty"` Network uint `json:"network"` MasterMAC string `json:"master_mac"` + UART UARTConfig `json:"uart"` Slaves []SlaveNode `json:"slaves"` } @@ -20,6 +31,8 @@ type SlaveNode struct { ID string `json:"id"` MAC string `json:"mac"` ClientID *uint `json:"client_id,omitempty"` + // USB console port for esptool reset (optional), e.g. /dev/ttyACM1. + Console string `json:"console,omitempty"` } type Bench struct { @@ -50,6 +63,12 @@ func NewBench(cfg Config) (*Bench, error) { if cfg.Network < 1 || cfg.Network > 8 { return nil, fmt.Errorf("config %q: network must be 1–8 (DIP / IO expander)", cfg.ID) } + if cfg.UART.Master == "" { + return nil, fmt.Errorf("config %q: uart.master is required (gotool command port)", cfg.ID) + } + if cfg.UART.Baud == 0 { + cfg.UART.Baud = 921600 + } b := &Bench{Config: cfg, slaveByID: make(map[string]*SlaveNode)} for i := range cfg.Slaves { diff --git a/goTool/autotest/config_test.go b/goTool/autotest/config_test.go index ba6c645..2c41f6e 100644 --- a/goTool/autotest/config_test.go +++ b/goTool/autotest/config_test.go @@ -7,6 +7,9 @@ func TestNewBenchClientIDFromMAC(t *testing.T) { ID: "t", Network: 1, MasterMAC: "aa:bb:cc:dd:ee:ff", + UART: UARTConfig{ + Master: "/dev/ttyUSB0", + }, Slaves: []SlaveNode{{ ID: "pod", MAC: "50:78:7d:18:01:10", diff --git a/goTool/autotest/reset.go b/goTool/autotest/reset.go new file mode 100644 index 0000000..b2d94f9 --- /dev/null +++ b/goTool/autotest/reset.go @@ -0,0 +1,103 @@ +package autotest + +import ( + "fmt" + "os" + "os/exec" + "time" +) + +const defaultResetWaitMS = 2000 + +// ResetESP32 toggles EN via esptool (USB-JTAG / ttyACM console port). +func ResetESP32(port string) error { + if port == "" { + return fmt.Errorf("empty console port") + } + + tries := [][]string{ + {"python", "-m", "esptool", "--chip", "esp32s3", "-p", port, + "--before", "default_reset", "--after", "hard_reset", "chip_id"}, + {"esptool.py", "--chip", "esp32s3", "-p", port, + "--before", "default_reset", "--after", "hard_reset", "chip_id"}, + {"esptool", "--chip", "esp32s3", "-p", port, + "--before", "default_reset", "--after", "hard_reset", "chip_id"}, + } + + var lastErr error + for _, argv := range tries { + if _, err := exec.LookPath(argv[0]); err != nil { + lastErr = err + continue + } + cmd := exec.Command(argv[0], argv[1:]...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + lastErr = err + continue + } + return nil + } + if lastErr != nil { + return fmt.Errorf("esptool reset on %s: %w (install ESP-IDF esptool or python -m esptool)", port, lastErr) + } + return fmt.Errorf("esptool not found; cannot reset %s", port) +} + +func (b *Bench) resetNode(target string) (string, error) { + switch target { + case "master", "": + if b.UART.MasterConsole == "" { + return "", fmt.Errorf("uart.master_console not set in config %q", b.ID) + } + return b.UART.MasterConsole, nil + default: + s, err := b.Slave(target) + if err != nil { + return "", err + } + if s.Console == "" { + return "", fmt.Errorf("slave %q has no console port in config", target) + } + return s.Console, nil + } +} + +func (b *Bench) ResetTargets(targets []string, waitMS int) error { + if waitMS <= 0 { + waitMS = defaultResetWaitMS + } + if len(targets) == 0 { + targets = []string{"master"} + } + + for _, t := range targets { + port, err := b.resetNode(t) + if err != nil { + return err + } + if err := ResetESP32(port); err != nil { + return err + } + time.Sleep(time.Duration(waitMS) * time.Millisecond) + } + return nil +} + +func (b *Bench) ResetAll(waitMS int) error { + // Slaves first, master last — master discover runs after slaves are booting. + var targets []string + for i := range b.Slaves { + if b.Slaves[i].Console != "" { + targets = append(targets, b.Slaves[i].ID) + } + } + if b.UART.MasterConsole != "" { + targets = append(targets, "master") + } + if len(targets) == 0 { + return fmt.Errorf("config %q: no console ports defined for reset", b.ID) + } + return b.ResetTargets(targets, waitMS) +} diff --git a/goTool/autotest/runner.go b/goTool/autotest/runner.go index 772657b..25c4f8d 100644 --- a/goTool/autotest/runner.go +++ b/goTool/autotest/runner.go @@ -92,11 +92,42 @@ func runStep(bench *Bench, step Step, client MasterClient) error { return checkDeadzone(bench, step, client) case "unicast_test", "unicast": return checkUnicastTest(bench, step, client) + case "reset", "reboot": + return checkReset(bench, step) default: return fmt.Errorf("unknown command %q", step.Command) } } +type resetInput struct { + Target string `json:"target"` + Slave string `json:"slave"` + All bool `json:"all"` + WaitMS int `json:"wait_ms"` +} + +func checkReset(bench *Bench, step Step) error { + var in resetInput + if len(step.Input) > 0 { + if err := json.Unmarshal(step.Input, &in); err != nil { + return fmt.Errorf("input: %w", err) + } + } + + if in.All { + return bench.ResetAll(in.WaitMS) + } + + target := in.Target + if in.Slave != "" { + target = in.Slave + } + if target == "" { + target = "master" + } + return bench.ResetTargets([]string{target}, in.WaitMS) +} + func checkVersion(step Step, client MasterClient) error { ver, err := client.GetVersion() if err != nil { diff --git a/goTool/cmd_autotest.go b/goTool/cmd_autotest.go index 4f5e055..8aea123 100644 --- a/goTool/cmd_autotest.go +++ b/goTool/cmd_autotest.go @@ -9,7 +9,7 @@ import ( "powerpod/gotool/autotest" ) -func runTest(sp *serialPort, args []string) error { +func runTest(portOverride string, baudOverride int, args []string) error { fs := flag.NewFlagSet("test", flag.ExitOnError) configName := fs.String("config", "", "bench config id or path (testdata/configs/)") scenarioName := fs.String("scenario", "", "scenario id or path (testdata/scenarios/)") @@ -49,14 +49,37 @@ func runTest(sp *serialPort, args []string) error { return fmt.Errorf("load scenario: %w", err) } + port := portOverride + if port == "" { + port = bench.UART.Master + } + baud := baudOverride + if baud <= 0 { + baud = int(bench.UART.Baud) + } + + sp, err := openSerial(port, baud) + if err != nil { + return fmt.Errorf("open %s: %w", port, err) + } + defer sp.Close() + if !*verbose { log.SetOutput(io.Discard) } fmt.Printf("bench config %q (network %d, master %s)\n", bench.ID, bench.Network, bench.MasterMAC) + fmt.Printf(" uart.master %s (baud %d)\n", bench.UART.Master, bench.UART.Baud) + if bench.UART.MasterConsole != "" { + fmt.Printf(" uart.master_console %s\n", bench.UART.MasterConsole) + } for _, s := range bench.Slaves { - fmt.Printf(" slave %q mac=%s client_id=%d\n", s.ID, s.MAC, *s.ClientID) + line := fmt.Sprintf(" slave %q mac=%s client_id=%d", s.ID, s.MAC, *s.ClientID) + if s.Console != "" { + line += fmt.Sprintf(" console=%s", s.Console) + } + fmt.Println(line) } fmt.Printf("scenario %q (%d steps)\n\n", sc.ID, len(sc.Steps)) diff --git a/goTool/main.go b/goTool/main.go index 0ef4da2..81d20b7 100644 --- a/goTool/main.go +++ b/goTool/main.go @@ -10,7 +10,8 @@ import ( const defaultBaud = 921600 func usage() { - fmt.Fprintf(os.Stderr, "usage: gotool -port /dev/ttyUSB0 \n\n") + fmt.Fprintf(os.Stderr, "usage: gotool [-port /dev/ttyUSB0] \n") + fmt.Fprintf(os.Stderr, " test uses uart.master from bench config when -port is omitted\n\n") fmt.Fprintf(os.Stderr, "commands:\n") fmt.Fprintf(os.Stderr, " version firmware version and git hash\n") fmt.Fprintf(os.Stderr, " clients registered ESP-NOW slaves on the master\n") @@ -25,31 +26,38 @@ func main() { baud := flag.Int("baud", defaultBaud, "UART baud rate") flag.Parse() - if *portName == "" || flag.NArg() < 1 { + if flag.NArg() < 1 { usage() os.Exit(2) } cmd := flag.Arg(0) - sp, err := openSerial(*portName, *baud) - if err != nil { - log.Fatalf("open serial: %v", err) - } - defer sp.Close() - var runErr error switch cmd { - case "version": - runErr = runVersion(sp) - case "clients", "client-info": - runErr = runClients(sp) - case "deadzone", "accel-deadzone": - runErr = runDeadzone(sp, flag.Args()[1:]) - case "unicast-test", "unicast_test": - runErr = runUnicastTest(sp, flag.Args()[1:]) case "test", "autotest": - runErr = runTest(sp, flag.Args()[1:]) + runErr = runTest(*portName, *baud, flag.Args()[1:]) + case "version", "clients", "client-info", "deadzone", "accel-deadzone", "unicast-test", "unicast_test": + if *portName == "" { + fmt.Fprintf(os.Stderr, "command %q requires -port\n\n", cmd) + usage() + os.Exit(2) + } + sp, err := openSerial(*portName, *baud) + if err != nil { + log.Fatalf("open serial: %v", err) + } + defer sp.Close() + switch cmd { + case "version": + runErr = runVersion(sp) + case "clients", "client-info": + runErr = runClients(sp) + case "deadzone", "accel-deadzone": + runErr = runDeadzone(sp, flag.Args()[1:]) + case "unicast-test", "unicast_test": + runErr = runUnicastTest(sp, flag.Args()[1:]) + } default: fmt.Fprintf(os.Stderr, "unknown command %q\n\n", cmd) usage() diff --git a/goTool/testdata/README.md b/goTool/testdata/README.md index 721c397..b887b26 100644 --- a/goTool/testdata/README.md +++ b/goTool/testdata/README.md @@ -2,29 +2,35 @@ ## Bench config (`configs/*.json`) -Describes your hardware bench (for documentation and slave lookup in tests). +Describes your hardware bench: network, MACs, and serial ports. | Field | Meaning | |-------|---------| | `id` | Config name; referenced by scenarios | -| `network` | ESP-NOW network **1–8** (must match DIP/IO expander on all nodes) | -| `master_mac` | Expected WiFi MAC of the master ESP (reference) | -| `slaves[].id` | Short name used in scenario `input.slave` / `expect.slave` | -| `slaves[].mac` | Full slave STA MAC (must match `gotool clients`) | +| `network` | ESP-NOW network **1–8** (DIP / IO expander on all nodes) | +| `master_mac` | Master WiFi STA MAC (reference) | +| `uart.baud` | Command UART baud (default **921600**) | +| `uart.master` | **gotool** port — external UART adapter on master GPIO2/3 (e.g. `/dev/ttyUSB0`) | +| `uart.master_console` | Master USB-JTAG/console for **reset** via esptool (e.g. `/dev/ttyACM0`) | +| `slaves[].id` | Short name for scenarios (`input.slave`, `expect.slave`) | +| `slaves[].mac` | Slave STA MAC (must match `gotool clients`) | | `slaves[].client_id` | Optional; default = last MAC byte | +| `slaves[].console` | Slave USB console for **reset** (optional, e.g. `/dev/ttyACM1`) | -Copy `example-lab.json` to e.g. `my-lab.json` and set real MACs. +Copy `example-lab.json` → `my-lab.json` and set real paths (`ls /dev/ttyUSB* /dev/ttyACM*`). + +`gotool test` uses `uart.master` when `-port` is omitted. Override with `-port /dev/…`. ## Scenario (`scenarios/*.json`) -Ordered steps: UART commands with `input` and `expect`. +Ordered steps: UART commands, delays, or esptool reset. | Step field | Meaning | |------------|---------| | `delay_ms` | Sleep only (no command) | -| `command` | `version`, `clients`, `deadzone`, `unicast_test` | -| `input` | Command arguments (see below) | -| `expect` | Assertions on the response | +| `command` | See below | +| `input` | Command arguments | +| `expect` | Assertions (UART commands only) | ### Commands @@ -38,10 +44,25 @@ Ordered steps: UART commands with `input` and `expect`. **unicast_test** — `input`: `slave` or `client_id`, `seq` `expect`: `success`, `seq` +**reset** — esptool hard-reset via console port from bench config (no `expect`). +`input`: `target` (`master`) or `slave` (`pod-1`), or `all: true`; optional `wait_ms` after each reset (default 2000). + +Example reset steps: + +```json +{ "name": "reset master", "command": "reset", "input": { "target": "master", "wait_ms": 3000 } }, +{ "name": "reset all", "command": "reset", "input": { "all": true, "wait_ms": 2500 } } +``` + +Requires `python -m esptool` or `esptool.py` on PATH (ESP-IDF). + +The `smoke` scenario resets every node with a configured `console` port, waits **10 s** for boot and ESP-NOW join, then runs the UART checks. + ### Example ```bash cd goTool -go run . -port /dev/ttyUSB0 test -config my-lab -scenario smoke -go run . -port /dev/ttyUSB0 test -list-configs +go run . test -config example-lab -scenario smoke +go run . test -config my-lab -scenario smoke -port /dev/ttyUSB1 +go run . test -list-configs ``` diff --git a/goTool/testdata/configs/example-lab.json b/goTool/testdata/configs/example-lab.json index ae6cb91..52cd528 100644 --- a/goTool/testdata/configs/example-lab.json +++ b/goTool/testdata/configs/example-lab.json @@ -1,13 +1,19 @@ { "id": "example-lab", - "description": "Example bench — replace MACs and network with your hardware", + "description": "Example bench — replace MACs, network, and serial paths", "network": 1, "master_mac": "50:78:7d:18:00:10", + "uart": { + "baud": 921600, + "master": "/dev/ttyUSB0", + "master_console": "/dev/ttyACM0" + }, "slaves": [ { "id": "pod-1", "mac": "50:78:7d:18:01:10", - "client_id": 16 + "client_id": 16, + "console": "/dev/ttyACM1" } ] } diff --git a/goTool/testdata/scenarios/smoke.json b/goTool/testdata/scenarios/smoke.json index 75886f6..1c1019c 100644 --- a/goTool/testdata/scenarios/smoke.json +++ b/goTool/testdata/scenarios/smoke.json @@ -1,21 +1,29 @@ { "id": "smoke", - "description": "Basic master UART checks (no slaves required for version)", + "description": "Cold-start smoke: reset all nodes, wait for join, then UART checks", "config": "example-lab", "steps": [ { - "name": "firmware version", + "name": "reset all nodes", + "command": "reset", + "input": { + "all": true, + "wait_ms": 2500 + } + }, + { + "name": "boot and ESP-NOW join", + "delay_ms": 10000 + }, + { + "name": "master UART up", "command": "version", "expect": { "version_min": 1 } }, { - "name": "wait for ESP-NOW join", - "delay_ms": 5000 - }, - { - "name": "slave visible", + "name": "slave registered", "command": "clients", "expect": { "min_clients": 1,