powerpods/main/README.md
simon e95097085d Add uart_cmd helpers to deduplicate UART command handlers.
Centralize protobuf decode, response init/send, registration, and common
nanopb encode callbacks; refactor existing cmd_* modules to use them.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-18 23:21:48 +02:00

297 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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) |
| `ESPNOW_SET_ACCEL_DEADZONE` | Master → slave | `EspNowAccelDeadzone` (`deadzone` LSB) |
`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) |
```c
typedef void (*msg_callback_t)(const uint8_t *data, size_t len);
```
Init order on master:
```c
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 |
| 6 | `ACCEL_DEADZONE` | Implemented (`cmd_accel_deadzone.c`) — get/set accel filter LSB |
| 1620 | OTA / ESP-NOW OTA | Planned |
Regenerate C code:
```bash
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.version``POWERPOD_FW_VERSION`
- `version_response.git_hash` — build git hash string
Encoding: `uart_send_uart_message()` in `uart_proto.c`.
### ACCEL_DEADZONE command
Filters BMA456 logs: a new accel line is emitted only when any axis changes by more than `deadzone` raw LSB since the last reported sample (default **100**).
**Request:** framed `06` + nanopb `UartMessage` with `accel_deadzone_request`:
| Field | Meaning |
|-------|---------|
| `write` | `false` = read, `true` = write |
| `deadzone` | Threshold in LSB (write) |
| `client_id` | `0` = local sensor on this node; `>0` = slave id (master) |
| `all_clients` | Master: ESP-NOW unicast to every registered slave |
**Response:** `accel_deadzone_response` with applied `deadzone`, `success`, and `slaves_updated` (ESP-NOW count).
### ESPNOW_UNICAST_TEST command
Minimal master→slave ESP-NOW unicast check (no BMA456). Use this before debugging `ACCEL_DEADZONE` unicast.
**Request:** framed `07` + `espnow_unicast_test_request` (`client_id`, `seq`).
**Response:** `espnow_unicast_test_response` (`success`, `seq`).
**Firmware logs:** master `unicast TEST to … seq=N`; slave `UNICAST TEST OK from master … seq=N`.
### 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_ping`**milliseconds 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. The registry **MAC is always the ESP-NOW source address** (`recv_info.src_addr`), not the optional `mac` bytes in the protobuf (used only on the wire for debugging).
`slave_id` is the senders WiFi STA address last octet (`mac[5]`); it can collide across devices — use `gotool clients` and match the full MAC.
## Host tool (`goTool/`)
Go CLI to test UART from a PC connected to the **master** only.
```bash
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:
```bash
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
```bash
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 |
| `uart_cmd.c/h` | Shared UART decode/send helpers for handlers |
| `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 `uart_cmd_register(MessageType_…, handler)`.
3. Decode with `uart_cmd_decode()` / `UART_CMD_REQ()`; reply with `uart_cmd_init_response()` + `uart_cmd_send()`.
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.