Slaves send HEARTBEAT every 1s; the master marks clients inactive after 3s without traffic and reactivates on reconnect. CLIENT_INFO reports last_ping as milliseconds since the last packet, not uptime. Co-authored-by: Cursor <cursoragent@cursor.com>
275 lines
10 KiB
Markdown
275 lines
10 KiB
Markdown
# 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 1–8 → ESP-NOW WiFi channel 1–8 |
|
||
| `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 1–13).
|
||
|
||
### Air protocol (compact binary, not protobuf)
|
||
|
||
| Byte | Field |
|
||
|------|--------|
|
||
| 0 | Magic `0xA1` |
|
||
| 1 | Message type |
|
||
| 2 | Network ID (must match local config) |
|
||
| 3+ | Type-specific |
|
||
|
||
| Type | Value | Direction | Purpose |
|
||
|------|-------|-----------|---------|
|
||
| `DISCOVER` | 1 | Master → broadcast `FF:FF:FF:FF:FF:FF` | Master is searching for slaves |
|
||
| `SLAVE_INFO` | 2 | Slave → master | Slave registration |
|
||
| `HEARTBEAT` | 3 | Slave → master | Keep-alive (header only, no extra payload) |
|
||
|
||
`SLAVE_INFO` payload (after header bytes 0–2):
|
||
|
||
| Field | Type | Description |
|
||
|-------|------|-------------|
|
||
| `mac` | 6 bytes | Slave WiFi MAC |
|
||
| `version` | uint32 | `POWERPOD_FW_VERSION` (default 1) |
|
||
| `slave_id` | uint32 | Currently last byte of MAC |
|
||
| `available` | uint8 | 1 = available |
|
||
| `used` | uint8 | 0 = unused |
|
||
|
||
**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 1–252 |
|
||
| 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 |
|
||
| 16–20 | OTA / ESP-NOW OTA | Planned |
|
||
|
||
Regenerate C code:
|
||
|
||
```bash
|
||
make proto_generate_uart
|
||
# or:
|
||
python libs/nanopb/generator/nanopb_generator.py main/proto/uart_messages.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`.
|
||
|
||
### 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 0–1000 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.
|
||
|
||
```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 |
|
||
| `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` | Protocol schema |
|
||
| `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.
|