From d24b0cb5c332791508bf6b0a02b5a6f51712049b Mon Sep 17 00:00:00 2001 From: simon Date: Mon, 18 May 2026 23:36:28 +0200 Subject: [PATCH] Add goTool autotest with bench configs and UART scenarios. JSON configs describe network and node MACs; scenarios run command sequences with expect checks. Share UART client API across CLI and tests. Co-authored-by: Cursor --- Makefile | 15 +- goTool/README.md | 14 ++ goTool/autotest/config.go | 133 +++++++++++ goTool/autotest/config_test.go | 29 +++ goTool/autotest/paths.go | 79 +++++++ goTool/autotest/runner.go | 281 +++++++++++++++++++++++ goTool/autotest/scenario.go | 69 ++++++ goTool/client_api.go | 114 +++++++++ goTool/cmd_autotest.go | 102 ++++++++ goTool/cmd_clients.go | 22 +- goTool/cmd_deadzone.go | 37 +-- goTool/cmd_unicast.go | 32 +-- goTool/cmd_version.go | 21 +- goTool/main.go | 5 +- goTool/testdata/README.md | 47 ++++ goTool/testdata/configs/example-lab.json | 13 ++ goTool/testdata/scenarios/smoke.json | 51 ++++ 17 files changed, 958 insertions(+), 106 deletions(-) create mode 100644 goTool/autotest/config.go create mode 100644 goTool/autotest/config_test.go create mode 100644 goTool/autotest/paths.go create mode 100644 goTool/autotest/runner.go create mode 100644 goTool/autotest/scenario.go create mode 100644 goTool/client_api.go create mode 100644 goTool/cmd_autotest.go create mode 100644 goTool/testdata/README.md create mode 100644 goTool/testdata/configs/example-lab.json create mode 100644 goTool/testdata/scenarios/smoke.json diff --git a/Makefile b/Makefile index aaf22cd..b1670df 100644 --- a/Makefile +++ b/Makefile @@ -6,8 +6,12 @@ GOTOOL := $(GOTOOL_DIR)/gotool GOTOOL_RUN := cd $(GOTOOL_DIR) && go run . -port $(PORT) .PHONY: default proto_generate proto_generate_uart proto_generate_espnow \ - gotool-build gotool-proto gotool-tidy \ - gotool-version gotool-clients gotool-unicast-test gotool-deadzone-get gotool-deadzone-set + gotool-build gotool-proto gotool-tidy gotool-test-units \ + gotool-version gotool-clients gotool-unicast-test gotool-deadzone-get gotool-deadzone-set \ + gotool-test + +TEST_CONFIG ?= example-lab +TEST_SCENARIO ?= smoke default: @echo "Targets: proto_generate gotool-build gotool-clients gotool-version …" @@ -51,4 +55,11 @@ gotool-deadzone-set: $(GOTOOL) @test -n "$(DEADZONE)" || (echo "Usage: make gotool-deadzone-set DEADZONE=100 [CLIENT=16]"; exit 1) $(GOTOOL) -port $(PORT) deadzone -set -value $(DEADZONE) -client $(or $(CLIENT),0) +gotool-test-units: + cd $(GOTOOL_DIR) && go test ./... + +# CONFIG=example-lab SCENARIO=smoke +gotool-test: $(GOTOOL) + $(GOTOOL) -port $(PORT) test -config $(TEST_CONFIG) -scenario $(TEST_SCENARIO) + $(GOTOOL): gotool-build diff --git a/goTool/README.md b/goTool/README.md index 9921a60..2839e2f 100644 --- a/goTool/README.md +++ b/goTool/README.md @@ -25,9 +25,23 @@ go run . -port /dev/ttyUSB0 clients | `version` | `0x03` | Prints `version` and `git_hash` from firmware | | `clients` | `0x04` | Lists slaves registered on the master via ESP-NOW | | `unicast-test` | `0x07` | Sends ESP-NOW unicast test to one slave (`-client`, `-seq`) | +| `test` | — | Run an automated scenario (JSON configs under `testdata/`) | `clients` requires slaves to have responded to master discover broadcasts first. +### 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. + +```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 +``` + +See [`testdata/README.md`](testdata/README.md) for the JSON schema. + ```bash go run . -port /dev/ttyUSB0 unicast-test -client 16 -seq 42 ``` diff --git a/goTool/autotest/config.go b/goTool/autotest/config.go new file mode 100644 index 0000000..9f27b31 --- /dev/null +++ b/goTool/autotest/config.go @@ -0,0 +1,133 @@ +package autotest + +import ( + "encoding/json" + "fmt" + "os" + "strings" +) + +// 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"` + Slaves []SlaveNode `json:"slaves"` +} + +type SlaveNode struct { + ID string `json:"id"` + MAC string `json:"mac"` + ClientID *uint `json:"client_id,omitempty"` +} + +type Bench struct { + Config + slaveByID map[string]*SlaveNode +} + +func LoadConfig(path string) (*Bench, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var cfg Config + if err := json.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("parse config: %w", err) + } + return NewBench(cfg) +} + +func NewBench(cfg Config) (*Bench, error) { + cfg.MasterMAC = normalizeMAC(cfg.MasterMAC) + if cfg.ID == "" { + return nil, fmt.Errorf("config: id is required") + } + if cfg.MasterMAC == "" { + return nil, fmt.Errorf("config %q: master_mac is required", cfg.ID) + } + if cfg.Network < 1 || cfg.Network > 8 { + return nil, fmt.Errorf("config %q: network must be 1–8 (DIP / IO expander)", cfg.ID) + } + + b := &Bench{Config: cfg, slaveByID: make(map[string]*SlaveNode)} + for i := range cfg.Slaves { + s := &cfg.Slaves[i] + if s.ID == "" { + return nil, fmt.Errorf("config %q: slave missing id", cfg.ID) + } + if _, dup := b.slaveByID[s.ID]; dup { + return nil, fmt.Errorf("config %q: duplicate slave id %q", cfg.ID, s.ID) + } + mac, err := parseMAC(s.MAC) + if err != nil { + return nil, fmt.Errorf("config %q slave %q: %w", cfg.ID, s.ID, err) + } + s.MAC = mac + if s.ClientID == nil { + parts := strings.Split(mac, ":") + var last byte + fmt.Sscanf(parts[5], "%02x", &last) + id := uint(last) + s.ClientID = &id + } + b.slaveByID[s.ID] = s + } + return b, nil +} + +func (b *Bench) Slave(id string) (*SlaveNode, error) { + s, ok := b.slaveByID[id] + if !ok { + return nil, fmt.Errorf("unknown slave %q (config %q)", id, b.ID) + } + return s, nil +} + +func (b *Bench) ResolveClientID(slaveID string) (uint32, error) { + if slaveID == "" || slaveID == "master" || slaveID == "local" { + return 0, nil + } + s, err := b.Slave(slaveID) + if err != nil { + return 0, err + } + return uint32(*s.ClientID), nil +} + +func normalizeMAC(s string) string { + s = strings.TrimSpace(strings.ToLower(s)) + s = strings.ReplaceAll(s, "-", ":") + return s +} + +func parseMAC(s string) (string, error) { + s = normalizeMAC(s) + parts := strings.Split(s, ":") + if len(parts) != 6 { + return "", fmt.Errorf("invalid mac %q", s) + } + out := make([]byte, 6) + for i, p := range parts { + var b byte + if _, err := fmt.Sscanf(p, "%02x", &b); err != nil { + return "", fmt.Errorf("invalid mac byte %q", p) + } + out[i] = b + } + return fmt.Sprintf("%02x:%02x:%02x:%02x:%02x:%02x", + out[0], out[1], out[2], out[3], out[4], out[5]), nil +} + +func MACEqual(a, b string) bool { + return normalizeMAC(a) == normalizeMAC(b) +} + +func MACFromProto(mac []byte) string { + if len(mac) != 6 { + return "" + } + return fmt.Sprintf("%02x:%02x:%02x:%02x:%02x:%02x", + mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]) +} diff --git a/goTool/autotest/config_test.go b/goTool/autotest/config_test.go new file mode 100644 index 0000000..ba6c645 --- /dev/null +++ b/goTool/autotest/config_test.go @@ -0,0 +1,29 @@ +package autotest + +import "testing" + +func TestNewBenchClientIDFromMAC(t *testing.T) { + cfg := Config{ + ID: "t", + Network: 1, + MasterMAC: "aa:bb:cc:dd:ee:ff", + Slaves: []SlaveNode{{ + ID: "pod", + MAC: "50:78:7d:18:01:10", + }}, + } + b, err := NewBench(cfg) + if err != nil { + t.Fatal(err) + } + s, _ := b.Slave("pod") + if *s.ClientID != 16 { + t.Fatalf("client_id=%d want 16", *s.ClientID) + } +} + +func TestMACEqual(t *testing.T) { + if !MACEqual("50:78:7D:18:01:10", "50-78-7d-18-01-10") { + t.Fatal("MACEqual failed") + } +} diff --git a/goTool/autotest/paths.go b/goTool/autotest/paths.go new file mode 100644 index 0000000..1500918 --- /dev/null +++ b/goTool/autotest/paths.go @@ -0,0 +1,79 @@ +package autotest + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +const ( + ConfigDir = "testdata/configs" + ScenarioDir = "testdata/scenarios" +) + +func ResolveConfigPath(name string) (string, error) { + return resolveJSON(name, ConfigDir) +} + +func ResolveScenarioPath(name string) (string, error) { + return resolveJSON(name, ScenarioDir) +} + +func resolveJSON(name, dir string) (string, error) { + if name == "" { + return "", fmt.Errorf("empty name") + } + if filepath.Ext(name) == ".json" { + if fileExists(name) { + return name, nil + } + } + base := name + if filepath.Ext(base) != ".json" { + base += ".json" + } + for _, root := range configRoots() { + p := filepath.Join(root, dir, base) + if fileExists(p) { + return p, nil + } + } + return "", fmt.Errorf("not found: %s (searched %s under gotool)", base, dir) +} + +func configRoots() []string { + var roots []string + if wd, err := os.Getwd(); err == nil { + roots = append(roots, wd) + } + // When run from repo root via make. + roots = append(roots, "goTool", filepath.Join("..", "goTool")) + return roots +} + +func fileExists(path string) bool { + st, err := os.Stat(path) + return err == nil && !st.IsDir() +} + +func ListJSONFiles(dir string) ([]string, error) { + for _, root := range configRoots() { + p := filepath.Join(root, dir) + entries, err := os.ReadDir(p) + if err != nil { + continue + } + var names []string + for _, e := range entries { + if e.IsDir() || filepath.Ext(e.Name()) != ".json" { + continue + } + names = append(names, strings.TrimSuffix(e.Name(), ".json")) + } + if len(names) > 0 { + return names, nil + } + } + return nil, fmt.Errorf("directory %s not found", dir) +} diff --git a/goTool/autotest/runner.go b/goTool/autotest/runner.go new file mode 100644 index 0000000..772657b --- /dev/null +++ b/goTool/autotest/runner.go @@ -0,0 +1,281 @@ +package autotest + +import ( + "encoding/json" + "fmt" + "strings" + "time" + + "powerpod/gotool/pb" +) + +// MasterClient talks to the powerpod master over UART (implemented by main.serialPort). +type MasterClient interface { + GetVersion() (*pb.VersionResponse, error) + ListClients() ([]*pb.ClientInfo, error) + AccelDeadzone(req *pb.AccelDeadzoneRequest) (*pb.AccelDeadzoneResponse, error) + EspnowUnicastTest(clientID, seq uint32) (*pb.EspNowUnicastTestResponse, error) +} + +type StepResult struct { + Index int + Name string + Command string + Pass bool + Detail string +} + +type RunResult struct { + ConfigID string + ScenarioID string + Steps []StepResult + Passed int + Failed int +} + +func Run(bench *Bench, sc *Scenario, client MasterClient) (*RunResult, error) { + if sc.ConfigID != bench.ID { + return nil, fmt.Errorf("scenario %q expects config %q, got %q", sc.ID, sc.ConfigID, bench.ID) + } + + res := &RunResult{ConfigID: bench.ID, ScenarioID: sc.ID} + for i, step := range sc.Steps { + sr := StepResult{Index: i + 1, Name: step.Name, Command: step.Command} + if step.Name == "" { + sr.Name = fmt.Sprintf("step %d", i+1) + } + + if step.DelayMS > 0 && step.Command == "" { + time.Sleep(time.Duration(step.DelayMS) * time.Millisecond) + sr.Pass = true + sr.Detail = fmt.Sprintf("delay %d ms", step.DelayMS) + res.Steps = append(res.Steps, sr) + res.Passed++ + continue + } + + if step.Command == "" { + sr.Pass = false + sr.Detail = "step needs command or delay_ms" + res.Steps = append(res.Steps, sr) + res.Failed++ + continue + } + + if step.DelayMS > 0 { + time.Sleep(time.Duration(step.DelayMS) * time.Millisecond) + } + + err := runStep(bench, step, client) + if err != nil { + sr.Pass = false + sr.Detail = err.Error() + res.Failed++ + } else { + sr.Pass = true + sr.Detail = "ok" + res.Passed++ + } + res.Steps = append(res.Steps, sr) + } + return res, nil +} + +func runStep(bench *Bench, step Step, client MasterClient) error { + cmd := strings.ToLower(strings.ReplaceAll(step.Command, "-", "_")) + switch cmd { + case "version": + return checkVersion(step, client) + case "clients", "client_info": + return checkClients(bench, step, client) + case "deadzone", "accel_deadzone": + return checkDeadzone(bench, step, client) + case "unicast_test", "unicast": + return checkUnicastTest(bench, step, client) + default: + return fmt.Errorf("unknown command %q", step.Command) + } +} + +func checkVersion(step Step, client MasterClient) error { + ver, err := client.GetVersion() + if err != nil { + return err + } + e := step.Expect + if e.Version != nil && ver.GetVersion() != *e.Version { + return fmt.Errorf("version=%d want %d", ver.GetVersion(), *e.Version) + } + if e.VersionMin != nil && ver.GetVersion() < *e.VersionMin { + return fmt.Errorf("version=%d want >= %d", ver.GetVersion(), *e.VersionMin) + } + if e.GitHash != "" && ver.GetGitHash() != e.GitHash { + return fmt.Errorf("git_hash=%q want %q", ver.GetGitHash(), e.GitHash) + } + return nil +} + +func checkClients(bench *Bench, step Step, client MasterClient) error { + clients, err := client.ListClients() + if err != nil { + return err + } + e := step.Expect + n := len(clients) + + if e.ClientCount != nil && n != *e.ClientCount { + return fmt.Errorf("client_count=%d want %d", n, *e.ClientCount) + } + if e.MinClients != nil && n < *e.MinClients { + return fmt.Errorf("client_count=%d want >= %d", n, *e.MinClients) + } + if e.MaxClients != nil && n > *e.MaxClients { + return fmt.Errorf("client_count=%d want <= %d", n, *e.MaxClients) + } + + slaveNames := e.Slaves + if e.Slave != "" { + slaveNames = append(slaveNames, e.Slave) + } + for _, name := range slaveNames { + want, err := bench.Slave(name) + if err != nil { + return err + } + var found *pb.ClientInfo + for _, c := range clients { + if MACEqual(MACFromProto(c.GetMac()), want.MAC) { + found = c + break + } + } + if found == nil { + return fmt.Errorf("slave %q mac %s not in client list", name, want.MAC) + } + if uint32(*want.ClientID) != found.GetId() { + return fmt.Errorf("slave %q id=%d want client_id=%d", name, found.GetId(), *want.ClientID) + } + if e.Available != nil && found.GetAvailable() != *e.Available { + return fmt.Errorf("slave %q available=%v want %v", name, found.GetAvailable(), *e.Available) + } + if e.MAC != "" && !MACEqual(MACFromProto(found.GetMac()), e.MAC) { + return fmt.Errorf("slave %q mac=%s want %s", name, MACFromProto(found.GetMac()), e.MAC) + } + } + return nil +} + +type deadzoneInput struct { + Write bool `json:"write"` + Value uint `json:"value"` + Deadzone uint `json:"deadzone"` + ClientID *uint `json:"client_id"` + Client uint `json:"client"` + Slave string `json:"slave"` + AllClients bool `json:"all_clients"` +} + +func checkDeadzone(bench *Bench, step Step, client MasterClient) error { + var in deadzoneInput + if len(step.Input) > 0 { + if err := json.Unmarshal(step.Input, &in); err != nil { + return fmt.Errorf("input: %w", err) + } + } + dz := in.Value + if dz == 0 { + dz = in.Deadzone + } + + var clientID uint32 + switch { + case in.AllClients: + // client_id 0 with all_clients set + case in.Slave != "": + id, err := bench.ResolveClientID(in.Slave) + if err != nil { + return err + } + clientID = id + case in.ClientID != nil: + clientID = uint32(*in.ClientID) + default: + clientID = uint32(in.Client) + } + + req := &pb.AccelDeadzoneRequest{ + Write: in.Write, + Deadzone: uint32(dz), + ClientId: clientID, + AllClients: in.AllClients, + } + resp, err := client.AccelDeadzone(req) + if err != nil { + return err + } + + e := step.Expect + if e.Deadzone != nil && resp.GetDeadzone() != *e.Deadzone { + return fmt.Errorf("deadzone=%d want %d", resp.GetDeadzone(), *e.Deadzone) + } + if e.Success != nil && resp.GetSuccess() != *e.Success { + return fmt.Errorf("success=%v want %v", resp.GetSuccess(), *e.Success) + } + if e.SlavesUpdated != nil && resp.GetSlavesUpdated() != *e.SlavesUpdated { + return fmt.Errorf("slaves_updated=%d want %d", resp.GetSlavesUpdated(), *e.SlavesUpdated) + } + if e.SlavesUpdatedMin != nil && resp.GetSlavesUpdated() < *e.SlavesUpdatedMin { + return fmt.Errorf("slaves_updated=%d want >= %d", resp.GetSlavesUpdated(), *e.SlavesUpdatedMin) + } + return nil +} + +type unicastInput struct { + Seq uint `json:"seq"` + ClientID *uint `json:"client_id"` + Client uint `json:"client"` + Slave string `json:"slave"` +} + +func checkUnicastTest(bench *Bench, step Step, client MasterClient) error { + var in unicastInput + if len(step.Input) > 0 { + if err := json.Unmarshal(step.Input, &in); err != nil { + return fmt.Errorf("input: %w", err) + } + } + if in.Seq == 0 { + in.Seq = 1 + } + + var clientID uint32 + switch { + case in.Slave != "": + id, err := bench.ResolveClientID(in.Slave) + if err != nil { + return err + } + clientID = id + case in.ClientID != nil: + clientID = uint32(*in.ClientID) + default: + clientID = uint32(in.Client) + } + if clientID == 0 { + return fmt.Errorf("unicast_test: client_id or slave required") + } + + resp, err := client.EspnowUnicastTest(clientID, uint32(in.Seq)) + if err != nil { + return err + } + + e := step.Expect + if e.Success != nil && resp.GetSuccess() != *e.Success { + return fmt.Errorf("success=%v want %v", resp.GetSuccess(), *e.Success) + } + if e.Seq != nil && resp.GetSeq() != *e.Seq { + return fmt.Errorf("seq=%d want %d", resp.GetSeq(), *e.Seq) + } + return nil +} diff --git a/goTool/autotest/scenario.go b/goTool/autotest/scenario.go new file mode 100644 index 0000000..ef38268 --- /dev/null +++ b/goTool/autotest/scenario.go @@ -0,0 +1,69 @@ +package autotest + +import ( + "encoding/json" + "fmt" + "os" +) + +// Scenario is an ordered list of steps run against a bench Config. +type Scenario struct { + ID string `json:"id"` + Description string `json:"description,omitempty"` + ConfigID string `json:"config"` + Steps []Step `json:"steps"` +} + +type Step struct { + Name string `json:"name,omitempty"` + Command string `json:"command,omitempty"` + DelayMS int `json:"delay_ms,omitempty"` + Input json.RawMessage `json:"input,omitempty"` + Expect Expect `json:"expect,omitempty"` +} + +type Expect struct { + // version + Version *uint32 `json:"version,omitempty"` + VersionMin *uint32 `json:"version_min,omitempty"` + GitHash string `json:"git_hash,omitempty"` + + // clients + MinClients *int `json:"min_clients,omitempty"` + MaxClients *int `json:"max_clients,omitempty"` + ClientCount *int `json:"client_count,omitempty"` + Slave string `json:"slave,omitempty"` + Slaves []string `json:"slaves,omitempty"` + Available *bool `json:"available,omitempty"` + MAC string `json:"mac,omitempty"` + + // deadzone + Deadzone *uint32 `json:"deadzone,omitempty"` + Success *bool `json:"success,omitempty"` + SlavesUpdated *uint32 `json:"slaves_updated,omitempty"` + SlavesUpdatedMin *uint32 `json:"slaves_updated_min,omitempty"` + + // unicast_test + Seq *uint32 `json:"seq,omitempty"` +} + +func LoadScenario(path string) (*Scenario, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var sc Scenario + if err := json.Unmarshal(data, &sc); err != nil { + return nil, fmt.Errorf("parse scenario: %w", err) + } + if sc.ID == "" { + return nil, fmt.Errorf("scenario: id is required") + } + if sc.ConfigID == "" { + return nil, fmt.Errorf("scenario %q: config is required", sc.ID) + } + if len(sc.Steps) == 0 { + return nil, fmt.Errorf("scenario %q: no steps", sc.ID) + } + return &sc, nil +} diff --git a/goTool/client_api.go b/goTool/client_api.go new file mode 100644 index 0000000..d39ce68 --- /dev/null +++ b/goTool/client_api.go @@ -0,0 +1,114 @@ +package main + +import ( + "fmt" + + "google.golang.org/protobuf/proto" + + "powerpod/gotool/pb" +) + +func (s *serialPort) getVersion() (*pb.VersionResponse, error) { + payload, err := s.exchange(byte(pb.MessageType_VERSION), "VERSION") + if err != nil { + return nil, err + } + var msg pb.UartMessage + if err := proto.Unmarshal(payload[1:], &msg); err != nil { + return nil, fmt.Errorf("decode: %w", err) + } + if msg.GetType() != pb.MessageType_VERSION { + return nil, fmt.Errorf("unexpected type %v", msg.GetType()) + } + ver := msg.GetVersionResponse() + if ver == nil { + return nil, fmt.Errorf("missing version_response") + } + return ver, nil +} + +func (s *serialPort) listClients() ([]*pb.ClientInfo, error) { + payload, err := s.exchange(byte(pb.MessageType_CLIENT_INFO), "CLIENT_INFO") + if err != nil { + return nil, err + } + var msg pb.UartMessage + if err := proto.Unmarshal(payload[1:], &msg); err != nil { + return nil, fmt.Errorf("decode: %w", err) + } + if msg.GetType() != pb.MessageType_CLIENT_INFO { + return nil, fmt.Errorf("unexpected type %v", msg.GetType()) + } + info := msg.GetClientInfoResponse() + if info == nil { + return nil, fmt.Errorf("missing client_info_response") + } + return info.GetClients(), nil +} + +func (s *serialPort) accelDeadzone(req *pb.AccelDeadzoneRequest) (*pb.AccelDeadzoneResponse, error) { + msg := &pb.UartMessage{ + Type: pb.MessageType_ACCEL_DEADZONE, + Payload: &pb.UartMessage_AccelDeadzoneRequest{ + AccelDeadzoneRequest: req, + }, + } + body, err := proto.Marshal(msg) + if err != nil { + return nil, fmt.Errorf("encode: %w", err) + } + payload := append([]byte{byte(pb.MessageType_ACCEL_DEADZONE)}, body...) + respPayload, err := s.exchangePayload(payload, "ACCEL_DEADZONE") + if err != nil { + return nil, err + } + var respMsg pb.UartMessage + if err := proto.Unmarshal(respPayload[1:], &respMsg); err != nil { + return nil, fmt.Errorf("decode: %w", err) + } + r := respMsg.GetAccelDeadzoneResponse() + if r == nil { + return nil, fmt.Errorf("missing accel_deadzone_response") + } + return r, nil +} + +func (s *serialPort) espnowUnicastTest(clientID, seq uint32) (*pb.EspNowUnicastTestResponse, error) { + req := &pb.EspNowUnicastTestRequest{ClientId: clientID, Seq: seq} + msg := &pb.UartMessage{ + Type: pb.MessageType_ESPNOW_UNICAST_TEST, + Payload: &pb.UartMessage_EspnowUnicastTestRequest{ + EspnowUnicastTestRequest: req, + }, + } + body, err := proto.Marshal(msg) + if err != nil { + return nil, fmt.Errorf("encode: %w", err) + } + payload := append([]byte{byte(pb.MessageType_ESPNOW_UNICAST_TEST)}, body...) + respPayload, err := s.exchangePayload(payload, "ESPNOW_UNICAST_TEST") + if err != nil { + return nil, err + } + var respMsg pb.UartMessage + if err := proto.Unmarshal(respPayload[1:], &respMsg); err != nil { + return nil, fmt.Errorf("decode: %w", err) + } + r := respMsg.GetEspnowUnicastTestResponse() + if r == nil { + return nil, fmt.Errorf("missing espnow_unicast_test_response") + } + return r, nil +} + +func (s *serialPort) GetVersion() (*pb.VersionResponse, error) { return s.getVersion() } + +func (s *serialPort) ListClients() ([]*pb.ClientInfo, error) { return s.listClients() } + +func (s *serialPort) AccelDeadzone(req *pb.AccelDeadzoneRequest) (*pb.AccelDeadzoneResponse, error) { + return s.accelDeadzone(req) +} + +func (s *serialPort) EspnowUnicastTest(clientID, seq uint32) (*pb.EspNowUnicastTestResponse, error) { + return s.espnowUnicastTest(clientID, seq) +} diff --git a/goTool/cmd_autotest.go b/goTool/cmd_autotest.go new file mode 100644 index 0000000..4f5e055 --- /dev/null +++ b/goTool/cmd_autotest.go @@ -0,0 +1,102 @@ +package main + +import ( + "flag" + "fmt" + "io" + "log" + + "powerpod/gotool/autotest" +) + +func runTest(sp *serialPort, 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/)") + listConfigs := fs.Bool("list-configs", false, "list available bench configs") + listScenarios := fs.Bool("list-scenarios", false, "list available scenarios") + verbose := fs.Bool("v", false, "verbose (log UART traffic)") + if err := fs.Parse(args); err != nil { + return err + } + + if *listConfigs { + return printList("configs", autotest.ConfigDir) + } + if *listScenarios { + return printList("scenarios", autotest.ScenarioDir) + } + + if *configName == "" || *scenarioName == "" { + return fmt.Errorf("need -config and -scenario (or -list-configs / -list-scenarios)") + } + + configPath, err := autotest.ResolveConfigPath(*configName) + if err != nil { + return err + } + scenarioPath, err := autotest.ResolveScenarioPath(*scenarioName) + if err != nil { + return err + } + + bench, err := autotest.LoadConfig(configPath) + if err != nil { + return fmt.Errorf("load config: %w", err) + } + sc, err := autotest.LoadScenario(scenarioPath) + if err != nil { + return fmt.Errorf("load scenario: %w", err) + } + + if !*verbose { + log.SetOutput(io.Discard) + } + + fmt.Printf("bench config %q (network %d, master %s)\n", + bench.ID, bench.Network, bench.MasterMAC) + for _, s := range bench.Slaves { + fmt.Printf(" slave %q mac=%s client_id=%d\n", s.ID, s.MAC, *s.ClientID) + } + fmt.Printf("scenario %q (%d steps)\n\n", sc.ID, len(sc.Steps)) + + result, err := autotest.Run(bench, sc, sp) + if err != nil { + return err + } + + failed := false + for _, sr := range result.Steps { + mark := "PASS" + if !sr.Pass { + mark = "FAIL" + failed = true + } + cmd := sr.Command + if cmd == "" { + cmd = "delay" + } + fmt.Printf("[%s] %s (%s) — %s\n", mark, sr.Name, cmd, sr.Detail) + } + fmt.Printf("\n%d passed, %d failed\n", result.Passed, result.Failed) + if failed { + return fmt.Errorf("scenario %q failed", sc.ID) + } + return nil +} + +func printList(kind, dir string) error { + names, err := autotest.ListJSONFiles(dir) + if err != nil { + return err + } + if len(names) == 0 { + fmt.Printf("no %s found under %s\n", kind, dir) + return nil + } + fmt.Printf("%s:\n", kind) + for _, n := range names { + fmt.Printf(" %s\n", n) + } + return nil +} diff --git a/goTool/cmd_clients.go b/goTool/cmd_clients.go index b0c4e97..4c4af00 100644 --- a/goTool/cmd_clients.go +++ b/goTool/cmd_clients.go @@ -3,33 +3,13 @@ package main import ( "encoding/hex" "fmt" - - "google.golang.org/protobuf/proto" - - "powerpod/gotool/pb" ) func runClients(sp *serialPort) error { - payload, err := sp.exchange(byte(pb.MessageType_CLIENT_INFO), "CLIENT_INFO") + clients, err := sp.listClients() if err != nil { return err } - - var msg pb.UartMessage - if err := proto.Unmarshal(payload[1:], &msg); err != nil { - return fmt.Errorf("decode protobuf: %w", err) - } - - if msg.GetType() != pb.MessageType_CLIENT_INFO { - return fmt.Errorf("unexpected message type %v", msg.GetType()) - } - - info := msg.GetClientInfoResponse() - if info == nil { - return fmt.Errorf("response missing client_info_response") - } - - clients := info.GetClients() if len(clients) == 0 { fmt.Println("no clients registered") return nil diff --git a/goTool/cmd_deadzone.go b/goTool/cmd_deadzone.go index 39d2007..b53b001 100644 --- a/goTool/cmd_deadzone.go +++ b/goTool/cmd_deadzone.go @@ -4,8 +4,6 @@ import ( "flag" "fmt" - "google.golang.org/protobuf/proto" - "powerpod/gotool/pb" ) @@ -19,39 +17,16 @@ func runDeadzone(sp *serialPort, args []string) error { return err } - req := &pb.AccelDeadzoneRequest{ - Write: *write, - Deadzone: uint32(*deadzone), - ClientId: uint32(*clientID), - AllClients: *all, - } - msg := &pb.UartMessage{ - Type: pb.MessageType_ACCEL_DEADZONE, - Payload: &pb.UartMessage_AccelDeadzoneRequest{ - AccelDeadzoneRequest: req, - }, - } - body, err := proto.Marshal(msg) - if err != nil { - return fmt.Errorf("encode request: %w", err) - } - - payload := append([]byte{byte(pb.MessageType_ACCEL_DEADZONE)}, body...) - respPayload, err := sp.exchangePayload(payload, "ACCEL_DEADZONE") + r, err := sp.accelDeadzone(&pb.AccelDeadzoneRequest{ + Write: *write, + Deadzone: uint32(*deadzone), + ClientId: uint32(*clientID), + AllClients: *all, + }) if err != nil { return err } - var respMsg pb.UartMessage - if err := proto.Unmarshal(respPayload[1:], &respMsg); err != nil { - return fmt.Errorf("decode response: %w", err) - } - - r := respMsg.GetAccelDeadzoneResponse() - if r == nil { - return fmt.Errorf("response missing accel_deadzone_response") - } - fmt.Printf("deadzone=%d client_id=%d success=%v slaves_updated=%d\n", r.GetDeadzone(), r.GetClientId(), r.GetSuccess(), r.GetSlavesUpdated()) return nil diff --git a/goTool/cmd_unicast.go b/goTool/cmd_unicast.go index 11489cd..e5f8857 100644 --- a/goTool/cmd_unicast.go +++ b/goTool/cmd_unicast.go @@ -3,10 +3,6 @@ package main import ( "flag" "fmt" - - "google.golang.org/protobuf/proto" - - "powerpod/gotool/pb" ) func runUnicastTest(sp *serialPort, args []string) error { @@ -20,37 +16,11 @@ func runUnicastTest(sp *serialPort, args []string) error { return fmt.Errorf("client id required (see `gotool clients`)") } - req := &pb.EspNowUnicastTestRequest{ - ClientId: uint32(*clientID), - Seq: uint32(*seq), - } - msg := &pb.UartMessage{ - Type: pb.MessageType_ESPNOW_UNICAST_TEST, - Payload: &pb.UartMessage_EspnowUnicastTestRequest{ - EspnowUnicastTestRequest: req, - }, - } - body, err := proto.Marshal(msg) - if err != nil { - return fmt.Errorf("encode request: %w", err) - } - - payload := append([]byte{byte(pb.MessageType_ESPNOW_UNICAST_TEST)}, body...) - respPayload, err := sp.exchangePayload(payload, "ESPNOW_UNICAST_TEST") + r, err := sp.espnowUnicastTest(uint32(*clientID), uint32(*seq)) if err != nil { return err } - var respMsg pb.UartMessage - if err := proto.Unmarshal(respPayload[1:], &respMsg); err != nil { - return fmt.Errorf("decode response: %w", err) - } - - r := respMsg.GetEspnowUnicastTestResponse() - if r == nil { - return fmt.Errorf("response missing espnow_unicast_test_response") - } - fmt.Printf("unicast test sent: success=%v seq=%d\n", r.GetSuccess(), r.GetSeq()) return nil } diff --git a/goTool/cmd_version.go b/goTool/cmd_version.go index 45576a6..457de3e 100644 --- a/goTool/cmd_version.go +++ b/goTool/cmd_version.go @@ -2,32 +2,13 @@ package main import ( "fmt" - - "google.golang.org/protobuf/proto" - - "powerpod/gotool/pb" ) func runVersion(sp *serialPort) error { - payload, err := sp.exchange(byte(pb.MessageType_VERSION), "VERSION") + ver, err := sp.getVersion() if err != nil { return err } - - var msg pb.UartMessage - if err := proto.Unmarshal(payload[1:], &msg); err != nil { - return fmt.Errorf("decode protobuf: %w", err) - } - - if msg.GetType() != pb.MessageType_VERSION { - return fmt.Errorf("unexpected message type %v", msg.GetType()) - } - - ver := msg.GetVersionResponse() - if ver == nil { - return fmt.Errorf("response missing version_response") - } - fmt.Printf("version: %d\n", ver.GetVersion()) fmt.Printf("git_hash: %s\n", ver.GetGitHash()) return nil diff --git a/goTool/main.go b/goTool/main.go index c1c64a4..0ef4da2 100644 --- a/goTool/main.go +++ b/goTool/main.go @@ -15,7 +15,8 @@ func usage() { fmt.Fprintf(os.Stderr, " version firmware version and git hash\n") fmt.Fprintf(os.Stderr, " clients registered ESP-NOW slaves on the master\n") fmt.Fprintf(os.Stderr, " deadzone get/set accelerometer deadzone (LSB)\n") - fmt.Fprintf(os.Stderr, " unicast-test send ESP-NOW unicast test to one slave\n\n") + fmt.Fprintf(os.Stderr, " unicast-test send ESP-NOW unicast test to one slave\n") + fmt.Fprintf(os.Stderr, " test run automated scenario (see testdata/)\n\n") flag.PrintDefaults() } @@ -47,6 +48,8 @@ func main() { 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:]) default: fmt.Fprintf(os.Stderr, "unknown command %q\n\n", cmd) usage() diff --git a/goTool/testdata/README.md b/goTool/testdata/README.md new file mode 100644 index 0000000..721c397 --- /dev/null +++ b/goTool/testdata/README.md @@ -0,0 +1,47 @@ +# goTool autotest fixtures + +## Bench config (`configs/*.json`) + +Describes your hardware bench (for documentation and slave lookup in tests). + +| 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`) | +| `slaves[].client_id` | Optional; default = last MAC byte | + +Copy `example-lab.json` to e.g. `my-lab.json` and set real MACs. + +## Scenario (`scenarios/*.json`) + +Ordered steps: UART commands with `input` and `expect`. + +| 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 | + +### Commands + +**version** — `expect`: `version`, `version_min`, `git_hash` + +**clients** — `expect`: `min_clients`, `max_clients`, `client_count`, `slave` / `slaves`, `available` + +**deadzone** — `input`: `write`, `value`/`deadzone`, `slave` or `client`/`client_id`, `all_clients` +`expect`: `deadzone`, `success`, `slaves_updated`, `slaves_updated_min` + +**unicast_test** — `input`: `slave` or `client_id`, `seq` +`expect`: `success`, `seq` + +### Example + +```bash +cd goTool +go run . -port /dev/ttyUSB0 test -config my-lab -scenario smoke +go run . -port /dev/ttyUSB0 test -list-configs +``` diff --git a/goTool/testdata/configs/example-lab.json b/goTool/testdata/configs/example-lab.json new file mode 100644 index 0000000..ae6cb91 --- /dev/null +++ b/goTool/testdata/configs/example-lab.json @@ -0,0 +1,13 @@ +{ + "id": "example-lab", + "description": "Example bench — replace MACs and network with your hardware", + "network": 1, + "master_mac": "50:78:7d:18:00:10", + "slaves": [ + { + "id": "pod-1", + "mac": "50:78:7d:18:01:10", + "client_id": 16 + } + ] +} diff --git a/goTool/testdata/scenarios/smoke.json b/goTool/testdata/scenarios/smoke.json new file mode 100644 index 0000000..75886f6 --- /dev/null +++ b/goTool/testdata/scenarios/smoke.json @@ -0,0 +1,51 @@ +{ + "id": "smoke", + "description": "Basic master UART checks (no slaves required for version)", + "config": "example-lab", + "steps": [ + { + "name": "firmware version", + "command": "version", + "expect": { + "version_min": 1 + } + }, + { + "name": "wait for ESP-NOW join", + "delay_ms": 5000 + }, + { + "name": "slave visible", + "command": "clients", + "expect": { + "min_clients": 1, + "slave": "pod-1", + "available": true + } + }, + { + "name": "unicast path", + "command": "unicast_test", + "input": { + "slave": "pod-1", + "seq": 42 + }, + "expect": { + "success": true, + "seq": 42 + } + }, + { + "name": "read local deadzone", + "command": "deadzone", + "input": { + "write": false, + "client": 0 + }, + "expect": { + "success": true, + "deadzone": 100 + } + } + ] +}