# 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 (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 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 # 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`. ### 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` | 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.