simon 755bdd92d7 Refactor ESP-NOW air protocol to nanopb protobuf.
Add esp_now_messages.proto with EspNowMessage types, encode/decode helpers,
and Makefile targets to regenerate firmware and UART schemas together.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-18 22:44:57 +02:00
..
2026-05-05 17:36:29 +02:00
2026-05-05 17:36:29 +02:00
2026-05-05 17:36:29 +02:00

Powerpod firmware

ESP32-S3 firmware for Powerpod nodes. Master and slave devices run the same binary; role and ESP-NOW network are selected at boot via DIP switches and an I2C IO expander.

System overview

                    ┌─────────────┐
                    │     PC      │
                    │  (goTool)   │
                    └──────┬──────┘
                           │ UART1 (921600)
                           │ framed commands + protobuf
                    ┌──────▼──────┐
                    │   MASTER    │
                    │   ESP32     │
                    └──────┬──────┘
                           │ ESP-NOW (WiFi channel = network ID)
              ┌────────────┼────────────┐
              │            │            │
       ┌──────▼──────┐ ┌───▼────┐ ┌─────▼─────┐
       │   SLAVE     │ │ SLAVE  │ │   SLAVE   │
       └─────────────┘ └────────┘ └───────────┘
Role UART to PC ESP-NOW
Master Yes — command handler, protobuf replies Broadcasts discover; collects slave info
Slave No Responds to discover with slave info

Master keeps a client registry (client_registry.c) of slaves seen via ESP-NOW. The PC can query it with the CLIENT_INFO UART command.

Boot configuration

Read in app_main() before subsystems start. Stored in app_config_t (app_config.h).

Setting Source Notes
master GPIO DIP_MASTER (pin 4) Low = master, high = slave
network I2C IO expander 0x20, port bits (nibble reversed) Value 18 → ESP-NOW WiFi channel 18
running_partition OTA API Active partition label

Pins (powerpod.h):

Signal GPIO
DIP master 4
I2C SCL 5
I2C SDA 6
UART TX 3
UART RX 2
LED ring 7

Startup order:

  1. Read DIP + IO expander → app_config
  2. esp_now_comm_init(&app_config) — WiFi + ESP-NOW
  3. led_ring_init()
  4. Master only: command queue, UART, registered commands (e.g. VERSION)

ESP-NOW discovery

Implementation: esp_now_comm.c / esp_now_comm.h.

WiFi is brought up in STA mode (no AP association). Channel = app_config.network (clamped to 113).

Air protocol (nanopb)

Schema: proto/esp_now_messages.proto. Encode/decode: esp_now_proto.c. The ESP-NOW payload is a single encoded EspNowMessage (no extra framing).

EspNowMessageType Direction oneof payload
ESPNOW_DISCOVER Master → broadcast FF:FF:FF:FF:FF:FF EspNowDiscover (network)
ESPNOW_SLAVE_INFO Slave → master EspNowSlavePresence
ESPNOW_HEARTBEAT Slave → master EspNowSlavePresence (same fields)

EspNowSlavePresence: network, mac (6 bytes), version, slave_id, available, used.

Master: task espnow_disc sends DISCOVER every 500 ms on the configured network. Logs slave joined id=… mac=… ver=… when a new slave is seen (up to 16 entries). Task espnow_mon runs every 1 s and marks a client inactive (available = false) if no SLAVE_INFO or HEARTBEAT was received for 3 s (three missed 1 s heartbeats). A later heartbeat sets available true again and logs reactivation.

Slave: on first matching DISCOVER, logs joined network N, master …, sends SLAVE_INFO once, then sends HEARTBEAT to the master every 1 s. While joined, periodic discovers from the same master refresh a “master alive” timer; if no discover arrives for 5 s, the slave treats the master as lost, clears join state, and will register again on the next discover (reconnect). Discover from a different master is ignored while already joined.

Monitor via USB-JTAG (/dev/ttyACM0) while using a USB-serial adapter on GPIO2/3 (/dev/ttyUSB0) for UART — they are different interfaces.

UART (master only)

Hardware: UART1, 921600 baud, TX = GPIO3, RX = GPIO2 (adapter TX → ESP RX, adapter RX → ESP TX).

Frame format

Field Value
Start 0xAA
Length 1 byte, payload size 1252
Payload length bytes
Checksum XOR of all payload bytes
Stop 0xCC

Command handler payload

Offset Meaning
0 Command ID (MessageType / msg_id)
1… Arguments (handler receives bytes after ID only)

Example VERSION request: single-byte payload 03 → frame AA 01 03 03 CC.

Logging:

  • [UART] received message cmd=0x… len=…
  • [CMDH] trigger command VERSION (0x03) (or other name from message_type_name())

Command handler

Generic dispatch for host commands (UART today; msg_post() for in-firmware sources later).

UART  →  generic_msg_t queue  →  vCmdDispatcherTask  →  registered handler
API Description
init_cmdHandler(queue) Start dispatcher task (priority 5)
msg_register_handler(id, cb) Register callback; max 32 handlers
msg_post(id, data, len) Enqueue from firmware (e.g. future ESP-NOW → PC path)
typedef void (*msg_callback_t)(const uint8_t *data, size_t len);

Init order on master:

cmd_queue = xQueueCreate(10, sizeof(generic_msg_t));
init_cmdHandler(cmd_queue);
init_uart(cmd_queue);
cmd_version_register();

Protobuf (proto/uart_messages.proto)

Host and master speak nanopb-encoded UartMessage inside UART frames (byte 0 = MessageType, bytes 1… = encoded message).

ID Name Status
3 VERSION Implemented (cmd_version.c)
4 CLIENT_INFO Implemented (cmd_client_info.c) — slave list from registry
5 CLIENT_INPUT Planned
1620 OTA / ESP-NOW OTA Planned

Regenerate C code:

make proto_generate
# or individually:
make proto_generate_uart
make proto_generate_espnow

After generation, ensure main/proto/*.pb.c includes use #include "…pb.h" (not main/proto/…).

Build embeds POWERPOD_GIT_HASH via git rev-parse in main/CMakeLists.txt.

VERSION command

Request: framed payload 03 only.

Response: payload 03 + nanopb UartMessage:

  • type = VERSION
  • version_response.versionPOWERPOD_FW_VERSION
  • version_response.git_hash — build git hash string

Encoding: uart_send_uart_message() in uart_proto.c.

CLIENT_INFO command

Request: framed payload 04 only (MessageType.CLIENT_INFO).

Response: payload 04 + nanopb UartMessage with client_info_response.clients — one ClientInfo per registered slave (from ESP-NOW SLAVE_INFO).

Fields per client: id, mac, version, available, used, last_ping, last_success_pingmilliseconds since the last packet / last successful heartbeat (computed when CLIENT_INFO is answered; typically 01000 while the slave is heartbeating every 1 s).

Client registry

API Description
client_registry_init() Clear all slots (called from esp_now_comm_init)
client_registry_upsert(mac, id, version, …) Add or refresh client; updates ping timestamps
client_registry_heartbeat(mac, id, version, …) Same as upsert for heartbeats; reactivates inactive clients
client_registry_check_timeouts(timeout_ms) Mark stale clients inactive (master monitor task)
client_registry_count() / client_registry_at(i) Iterate for UART encoding

Slaves register when the master receives SLAVE_INFO on the matching network; HEARTBEAT keeps them marked available.

Host tool (goTool/)

Go CLI to test UART from a PC connected to the master only.

cd goTool
go mod tidy
go run . -port /dev/ttyUSB0 version
go run . -port /dev/ttyUSB0 clients
Flag Default Description
-port (required) Serial device, e.g. /dev/ttyUSB0
-baud 921600 Must match UART_BAUD_RATE
Command Description
version Firmware version and git hash
clients Registered slaves from master client registry

Regenerate Go protobuf:

protoc --go_out=./pb --go_opt=paths=source_relative \
  --go_opt=Muart_messages.proto=powerpod/gotool/pb \
  -I ../main/proto ../main/proto/uart_messages.proto

See goTool/README.md for tool-only notes.

Build and flash

source ~/esp/esp-idf/export.sh   # or export.fish in fish
cd /path/to/powerpod
idf.py build
idf.py -p /dev/ttyUSB0 flash monitor   # USB-JTAG / console

Target: ESP32-S3. Close serial monitor on the UART adapter port before running goTool on the same device.

Source files

File Role
powerpod.c app_main, DIP/network config, init order
powerpod.h Pin defines
app_config.h app_config_t
esp_now_comm.c/h WiFi, ESP-NOW, discover / slave info
uart.c/h Framed UART RX/TX
uart_proto.c/h Encode/send UartMessage
cmd_handler.c/h Command queue and dispatch
cmd_version.c/h VERSION handler
cmd_client_info.c/h CLIENT_INFO handler
client_registry.c/h Registered slave table
led_ring.c/h LED digit display
proto/uart_messages.proto UART protocol schema
proto/esp_now_messages.proto ESP-NOW protocol schema
esp_now_proto.c/h Encode/decode EspNowMessage
proto/*.pb.c/h Generated nanopb
CMakeLists.txt Sources, esp_wifi, drivers, git hash

Adding a new UART command

  1. Add or extend messages in uart_messages.proto and regenerate nanopb.
  2. Create cmd_*.c with a handler; register with msg_register_handler(MessageType_…, handler).
  3. Reply via uart_send_uart_message() where needed.
  4. Extend goTool or another host client to send the matching frame.

For ESP-NOW-driven PC updates later: map slave state to ClientInfo and send CLIENT_INFO over UART from the master.