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 <cursoragent@cursor.com>
This commit is contained in:
2026-05-18 23:36:28 +02:00
co-authored by Cursor
parent a8ae65d9dc
commit d24b0cb5c3
17 changed files with 958 additions and 106 deletions
+133
View File
@@ -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 18 (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])
}
+29
View File
@@ -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")
}
}
+79
View File
@@ -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)
}
+281
View File
@@ -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
}
+69
View File
@@ -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
}