Add goTool clients subcommand for CLIENT_INFO query.

Refactor into version/clients subcommands with shared serial framing
to list registered slaves from the master over UART.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
simon 2026-05-18 22:26:42 +02:00
parent 92e146e2ed
commit 16bfbd1091
5 changed files with 188 additions and 65 deletions

View File

@ -9,7 +9,8 @@ Full system documentation (roles, ESP-NOW, framing, protobuf): [`../main/README.
```bash
cd goTool
go mod tidy
go run . -port /dev/ttyUSB0
go run . -port /dev/ttyUSB0 version
go run . -port /dev/ttyUSB0 clients
```
| Flag | Default | Description |
@ -17,7 +18,21 @@ go run . -port /dev/ttyUSB0
| `-port` | (required) | Serial port on master UART (GPIO2/3 adapter) |
| `-baud` | `921600` | Must match firmware `UART_BAUD_RATE` |
Implements the VERSION command (`MessageType` = 3): sends framed `03`, decodes protobuf `UartMessage.version_response`.
### Commands
| Command | UART payload | Description |
|---------|--------------|-------------|
| `version` | `0x03` | Prints `version` and `git_hash` from firmware |
| `clients` | `0x04` | Lists slaves registered on the master via ESP-NOW |
`clients` requires slaves to have responded to master discover broadcasts first.
Example output:
```
clients (2):
[0] id=42 mac=aabbccddeeff ver=1 available=true used=false last_ping=12345 last_success_ping=12345
```
## Regenerate protobuf

46
goTool/cmd_clients.go Normal file
View File

@ -0,0 +1,46 @@
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")
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
}
fmt.Printf("clients (%d):\n", len(clients))
for i, c := range clients {
mac := hex.EncodeToString(c.GetMac())
fmt.Printf(" [%d] id=%d mac=%s ver=%d available=%v used=%v last_ping=%d last_success_ping=%d\n",
i, c.GetId(), mac, c.GetVersion(), c.GetAvailable(), c.GetUsed(),
c.GetLastPing(), c.GetLastSuccessPing())
}
return nil
}

34
goTool/cmd_version.go Normal file
View File

@ -0,0 +1,34 @@
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")
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
}

View File

@ -5,86 +5,49 @@ import (
"fmt"
"log"
"os"
"time"
"go.bug.st/serial"
"google.golang.org/protobuf/proto"
"powerpod/gotool/pb"
uartframe "powerpod/gotool/uart"
)
const (
defaultBaud = 921600
versionCmdID = byte(pb.MessageType_VERSION)
readTimeout = 3 * time.Second
)
const defaultBaud = 921600
func usage() {
fmt.Fprintf(os.Stderr, "usage: gotool -port /dev/ttyUSB0 <command>\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\n")
flag.PrintDefaults()
}
func main() {
portName := flag.String("port", "", "serial port (e.g. /dev/ttyUSB0)")
baud := flag.Int("baud", defaultBaud, "UART baud rate")
flag.Parse()
if *portName == "" {
fmt.Fprintln(os.Stderr, "usage: gotool -port /dev/ttyUSB0")
flag.PrintDefaults()
if *portName == "" || flag.NArg() < 1 {
usage()
os.Exit(2)
}
mode := &serial.Mode{
BaudRate: *baud,
DataBits: 8,
Parity: serial.NoParity,
StopBits: serial.OneStopBit,
}
cmd := flag.Arg(0)
port, err := serial.Open(*portName, mode)
sp, err := openSerial(*portName, *baud)
if err != nil {
log.Fatalf("open serial: %v", err)
}
defer port.Close()
defer sp.Close()
if err := port.SetReadTimeout(readTimeout); err != nil {
log.Fatalf("set read timeout: %v", err)
var runErr error
switch cmd {
case "version":
runErr = runVersion(sp)
case "clients", "client-info":
runErr = runClients(sp)
default:
fmt.Fprintf(os.Stderr, "unknown command %q\n\n", cmd)
usage()
os.Exit(2)
}
frame, err := uartframe.EncodeFrame([]byte{versionCmdID})
if err != nil {
log.Fatalf("encode frame: %v", err)
if runErr != nil {
log.Fatal(runErr)
}
log.Printf("sending VERSION command (%d bytes): % x", len(frame), frame)
if _, err := port.Write(frame); err != nil {
log.Fatalf("write: %v", err)
}
payload, err := uartframe.ReadFrame(port, nil)
if err != nil {
log.Fatalf("read response: %v", err)
}
log.Printf("response payload (%d bytes): % x", len(payload), payload)
if len(payload) == 0 {
log.Fatal("empty response payload")
}
if payload[0] != versionCmdID {
log.Fatalf("unexpected command id 0x%02x (want 0x%02x)", payload[0], versionCmdID)
}
var msg pb.UartMessage
if err := proto.Unmarshal(payload[1:], &msg); err != nil {
log.Fatalf("decode protobuf: %v", err)
}
if msg.GetType() != pb.MessageType_VERSION {
log.Fatalf("unexpected message type %v", msg.GetType())
}
ver := msg.GetVersionResponse()
if ver == nil {
log.Fatal("response missing version_response")
}
fmt.Printf("version: %d\n", ver.GetVersion())
fmt.Printf("git_hash: %s\n", ver.GetGitHash())
}

65
goTool/serialport.go Normal file
View File

@ -0,0 +1,65 @@
package main
import (
"fmt"
"log"
"time"
"go.bug.st/serial"
uartframe "powerpod/gotool/uart"
)
const readTimeout = 3 * time.Second
type serialPort struct {
port serial.Port
}
func openSerial(portName string, baud int) (*serialPort, error) {
mode := &serial.Mode{
BaudRate: baud,
DataBits: 8,
Parity: serial.NoParity,
StopBits: serial.OneStopBit,
}
port, err := serial.Open(portName, mode)
if err != nil {
return nil, err
}
if err := port.SetReadTimeout(readTimeout); err != nil {
port.Close()
return nil, err
}
return &serialPort{port: port}, nil
}
func (s *serialPort) Close() error {
return s.port.Close()
}
func (s *serialPort) exchange(cmdID byte, cmdName string) ([]byte, error) {
frame, err := uartframe.EncodeFrame([]byte{cmdID})
if err != nil {
return nil, fmt.Errorf("encode frame: %w", err)
}
log.Printf("sending %s command (%d bytes): % x", cmdName, len(frame), frame)
if _, err := s.port.Write(frame); err != nil {
return nil, fmt.Errorf("write: %w", err)
}
payload, err := uartframe.ReadFrame(s.port, nil)
if err != nil {
return nil, fmt.Errorf("read response: %w", err)
}
log.Printf("response payload (%d bytes): % x", len(payload), payload)
if len(payload) == 0 {
return nil, fmt.Errorf("empty response payload")
}
if payload[0] != cmdID {
return nil, fmt.Errorf("unexpected command id 0x%02x (want 0x%02x)", payload[0], cmdID)
}
return payload, nil
}