powerpods/main/README.md
simon 5a948a5c8c Document ESP-NOW slave OTA and dashboard upload flow.
Describe master-to-slave distribution after UART OTA, partial final block, reboot, and goTool /api/ota behaviour.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-19 01:02:45 +02:00

389 lines
17 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 |
| BMA456 INT | 10 |
| Button (Taster) | 12 |
| LiPo sense 1 (ADC) | 1 |
| LiPo sense 2 (ADC) | 12 (skipped if same as button) |
> **TODO:** GPIO assignments above are provisional; confirm pinning against the real board before release.
Startup order:
1. Read DIP + IO expander → `app_config`
2. **I2C bus** — IO expander `0x20`; optional **BMA456H** (`init_bma456`, same bus)
3. `esp_now_comm_init(&app_config)` — WiFi + ESP-NOW
4. `led_ring_init()`
5. `board_input_init()` — button press logs, LiPo ADC logs every **10 s**
6. **Master only:** command queue, UART, registered commands (e.g. VERSION)
## BMA456 accelerometer (`bosch456.c`)
Powerpod uses the Bosch **BMA456H** (hearable) variant, not the generic `bma456w` examples in the vendor tree.
| Item | Value |
|------|--------|
| Project wrapper | `main/bosch456.c`, `main/bosch456.h` |
| Vendor component | `components/bma456` — only `bma4.c` + `bma456h.c` are linked |
| I2C | Shared bus with IO expander (SCL/SDA GPIO 5/6), address **0x18**, **100 kHz** |
| Interrupt | **GPIO 10**, active high, tap events (single / double / triple) |
| Polling | FreeRTOS task `bma456_poll`, **10 Hz** accel read |
**Boot:** `init_bma456(bus_handle)` runs on **master and slave** after the IO expander. If the sensor is missing or init fails, firmware logs `BMA456 init skipped` and continues (`bma456_is_ready() == false`).
**Accel logging:** Samples are printed only when any axis changes by more than the **deadzone** (raw LSB) since the last logged sample (default **100**). This is a **software** filter on top of the sensor; it does not change BMA456 hardware thresholds.
**Configuration paths:**
| Path | Effect |
|------|--------|
| UART `ACCEL_DEADZONE` with `client_id = 0` | `bma456_set_accel_deadzone()` on the local node |
| ESP-NOW `SET_ACCEL_DEADZONE` | Same on a slave (no-op log path if sensor not installed) |
| `make gotool-deadzone-set DEADZONE=… CLIENT=0` | Host shortcut for local deadzone |
**Logs:** `[BMA456] ACC X=… Y=… Z=…` when deadzone exceeded; `[BMA456] tap: single|double|triple` on interrupt.
Regenerate nanopb only when changing protos; sensor code has no code generation step.
## 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) |
| `ESPNOW_OTA_START` | Master → slave (unicast) | `EspNowOtaStart` (`total_size`) |
| `ESPNOW_OTA_PAYLOAD` | Master → slave | `EspNowOtaPayload` (`seq`, up to 200 B `data`) |
| `ESPNOW_OTA_END` | Master → slave | `EspNowOtaEnd` |
| `ESPNOW_OTA_STATUS` | Slave → master | `EspNowOtaStatus` (same status codes as UART OTA) |
### ESP-NOW OTA (master → slaves)
Triggered automatically after a successful UART `OTA_END` on the master (or manually via UART `OTA_START_ESPNOW` if an image is already **staged**). Implementation: `ota_espnow.c`.
| Step | Master → slave | Slave → master |
|------|----------------|----------------|
| 1 | `ESPNOW_OTA_START` + `total_size` | `ESPNOW_OTA_STATUS` preparing, then **ready** |
| 2 | `ESPNOW_OTA_PAYLOAD` (**≤200 B**, shared `seq`) | **block_ack** after each **4096 B** written to flash |
| 3 | `ESPNOW_OTA_END` | **success** or **failed** (+ `bytes_written`) |
Master reads the staged partition with `esp_partition_read` (same image just written via UART). Only **available** registry slaves are updated. The last transfer block may be **under 4096 B** — no block_ack is waited for that block; slaves flush the remainder on `ESPNOW_OTA_END`.
Status codes match UART `OtaStatusPayload` (`1``5`). After success, master and slaves have the boot partition set — **reboot all nodes** to run the new firmware.
`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 |
| 16 | `OTA_START` | Implemented (`cmd_ota.c`) — begin UART OTA on inactive slot |
| 17 | `OTA_PAYLOAD` | Implemented — up to 200 B per frame; device buffers 4 KiB |
| 18 | `OTA_END` | Implemented — flush, `esp_ota_end`, push image to slaves via ESP-NOW, set boot |
| 19 | `OTA_STATUS` | Device → host (prepare/ready/block ACK/success/failed) |
| 20 | `OTA_START_ESPNOW` | Implemented — re-distribute staged image to slaves only |
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
- `version_response.running_partition` — active OTA label (`ota_0` / `ota_1`)
Encoding: `uart_send_uart_message()` in `uart_proto.c`.
At boot, firmware logs the running partition and OTA slot index (A/B).
### UART OTA (A/B)
**UART upload is master-only.** Slaves receive the same image afterwards via [ESP-NOW OTA](#esp-now-ota-master--slaves).
Inactive app partition is selected with `esp_ota_get_next_update_partition()`; `esp_ota_begin` erases it (can take ~30 s — host should wait).
| Step | Host → master | Master → host |
|------|---------------|---------------|
| 1 | `OTA_START` + `total_size` | `OTA_STATUS` preparing, then **ready** (+ `target_slot` 0/1) |
| 2 | `OTA_PAYLOAD` chunks (**≤200 B**, `seq` optional) | `OTA_STATUS` **block_ack** only after each **4096 B** written to flash |
| 3 | `OTA_END` | Stages image, runs ESP-NOW OTA to all available slaves, sets boot partition, then `OTA_STATUS` **success** or **failed** |
`OTA_END` can take a long time on the wire (slave flash + ESP-NOW); the host should use a generous read timeout.
`OTA_START_ESPNOW` (type `20`): re-run ESP-NOW distribution from the last staged image without a new UART upload (no-op if nothing staged).
Implementation: `ota_uart.c` (4 KiB buffer, `esp_ota_write`), `ota_espnow.c`, `cmd_ota.c`.
Host upload:
```bash
go run . -port /dev/ttyUSB0 ota build/powerpod.bin
```
`OtaStatusPayload.status`: `1` preparing, `2` ready, `3` block_ack, `4` success, `5` failed.
### ACCEL_DEADZONE command
Sets the **software** deadzone used by `bosch456.c` when logging accel (see [BMA456 accelerometer](#bma456-accelerometer-bosch456c)). Default **100** LSB.
**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 / OTA send |
| `ota_uart.c/h` | Shared 4 KiB OTA flash buffer (UART + ESP-NOW) |
| `ota_espnow.c/h` | Master: distribute staged image to slaves |
| `cmd_ota.c/h` | UART OTA command handlers (master only) |
| `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 |
| `bosch456.c/h` | BMA456H I2C driver, accel poll, tap INT, deadzone filter |
| `board_input.c/h` | Taster GPIO12, LiPo ADC on GPIO1 / GPIO12 |
| `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.