# 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 | | 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. **Persistence:** The local deadzone is stored in the **`nvs`** partition (namespace `powerpod`, key `accel_dz`) via `pod_settings.c`. Each node (master or slave) keeps its own value across reboot. Loaded at boot after `init_bma456()`; saved when set locally (UART `client_id = 0`, `all_clients` on master, or ESP-NOW deadzone on a slave). **Configuration paths:** | Path | Effect | |------|--------| | UART `ACCEL_DEADZONE` with `client_id = 0` | Set + save local deadzone | | ESP-NOW `SET_ACCEL_DEADZONE` | Set + save on the receiving slave | | `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 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) | | `ESPNOW_SET_ACCEL_DEADZONE` | Master → slave | `EspNowAccelDeadzone` (`deadzone` LSB) | | `ESPNOW_UNICAST_TEST` | Master → slave | `EspNowUnicastTest` (`seq`) | | `ESPNOW_FIND_ME` | Master → slave | `EspNowFindMe` (`client_id` filter) — LED locate sequence | | `ESPNOW_RESTART` | Master → slave | `EspNowRestart` (`client_id` filter) — reboot slave | | `ESPNOW_ACCEL_SAMPLE` | Slave → master | `EspNowAccelSample` (`slave_id`, `x`, `y`, `z` raw LSB) — ~every 16 ms | | `ESPNOW_SET_TAP_NOTIFY` | Master → slave | `EspNowTapNotify` (`client_id`, `single`, `double_tap`, `triple`) — which tap kinds to forward | | `ESPNOW_TAP_EVENT` | Slave → master | `EspNowTapEvent` (`client_id`, `kind`) — on BMA456 tap interrupt if notify enabled | | `ESPNOW_BATTERY_REPORT` | Slave → master | `EspNowBatteryReport` (`client_id`, `lipo1/2` mV) — ~every 30 s; cached in `client_registry` | | `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 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/cmd_version.c`) | | 4 | `CLIENT_INFO` | Implemented (`cmd/cmd_client_info.c`) — slave list from registry | | 5 | `CLIENT_INPUT` | Planned | | 6 | `ACCEL_DEADZONE` | Implemented (`cmd/cmd_accel_deadzone.c`) — get/set accel filter LSB | | 7 | `ESPNOW_UNICAST_TEST` | Implemented (`cmd/cmd_espnow_unicast_test.c`) | | 8 | `LED_RING` | Implemented (`cmd/cmd_led_ring.c`) — ring progress bar (0–100 %, RGB, intensity) | | 26 | `BATTERY_STATUS` | Implemented (`cmd/cmd_battery.c`) — cached LiPo 1/2 per pod from `client_registry` (UART read, no slave round-trip) | | 16 | `OTA_START` | Implemented (`cmd/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 | | 21 | `OTA_SLAVE_PROGRESS` | Implemented (`cmd/cmd_ota_slave_progress.c`) — query per-slave ESP-NOW OTA progress | | 22 | `FIND_ME` | Implemented (`cmd/cmd_espnow_find_me.c`) — `client_id=0` local ring, `>0` ESP-NOW to slave | | 23 | `RESTART` | Implemented (`cmd/cmd_restart.c`) — `client_id=0` reboot master, `>0` ESP-NOW reboot slave | | 24 | `ACCEL_SNAPSHOT` | Implemented (`cmd/cmd_accel_snapshot.c`) — cached slave accel from ESP-NOW stream | | 27 | `TAP_NOTIFY` | Implemented (`cmd/cmd_tap_notify.c`) — get/set which tap kinds notify via ESP-NOW | | 28 | `TAP_SNAPSHOT` | Implemented (`cmd/cmd_tap_snapshot.c`) — consume cached tap events from master registry | 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. During OTA the LED ring shows progress at ~5 % brightness: **blue** while the image is written (UART on master, ESP-NOW on slaves), **green** on the master while it forwards the image to slaves over ESP-NOW. On **success** the ring gives one short **green** blink; on **failure** one **red** blink and ESP-NOW distribution is not started (failed UART upload / `OTA_END` validation). `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/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, `6` distributing (`bytes_written` = progress, `target_slot` = slave count). ### OTA_SLAVE_PROGRESS command **Request:** framed `15` (`0x15`) + optional `ota_slave_progress_request` (`client_id`; `0` = all slaves in the current/last distribution session). **Response:** `ota_slave_progress_response`: | Field | Meaning | |-------|---------| | `active` | ESP-NOW distribution running | | `total_bytes` | Image size | | `aggregate_bytes` | Overall bytes sent to all slaves | | `slave_count` | Number of slaves in session | | `slaves[]` | Per slave: `client_id`, `bytes_written`, `total_bytes`, `status`, `error` | Per-slave `status`: `0` idle, `1` preparing, `2` ready, `3` block_ack/distributing, `4` success, `5` failed. ```bash go run . -port /dev/ttyUSB0 ota-progress go run . -port /dev/ttyUSB0 ota-progress -client 16 ``` ### 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). ### ACCEL_SNAPSHOT command Read **cached** accelerometer samples on the **master** (one entry per registered slave). Slaves send `ESPNOW_ACCEL_SAMPLE` to the master every **16 ms** (`esp_now_comm.c`); the master stores the latest value per client in `client_registry.c`. **Request:** framed `18` (`0x18`) + optional `accel_snapshot_request` (`client_id`: `0` = all slaves, `>0` = one id). **Response:** `accel_snapshot_response.samples[]`: | Field | Meaning | |-------|---------| | `client_id` | Slave id (registry) | | `valid` | At least one ESP-NOW sample received since boot | | `x`, `y`, `z` | Raw BMA456 LSB (±2g) | | `age_ms` | Ms since last sample from that slave | Host: ```bash go run . -port /dev/ttyUSB0 accel ``` External API (`serve -api-addr :8081`) polls this command every 16 ms and streams JSON over WebSocket. ### TAP_NOTIFY command Configure which BMA456 tap kinds a **slave** forwards to the master over ESP-NOW. The slave only sends `ESPNOW_TAP_EVENT` when the matching notify flag is enabled (set locally on the slave via ESP-NOW). **Request:** framed `1b` (`0x1b`) + `tap_notify_request`: | Field | Meaning | |-------|---------| | `write` | `false` = read, `true` = write | | `single`, `double_tap`, `triple` | Which tap kinds to notify (write) | | `client_id` | Slave id (read/write one slave) | | `all_clients` | Master: ESP-NOW unicast to every registered slave | **Response:** `tap_notify_response` (`client_id`, `success`, `slaves_updated`, `single`, `double_tap`, `triple`). Notify flags are mirrored in `ClientInfo` (`tap_notify_single/double/triple`) for the dashboard. ```bash go run . -port /dev/ttyUSB0 tap-notify -client 16 -set -single go run . -port /dev/ttyUSB0 tap-notify -client 16 ``` ### TAP_SNAPSHOT command Read **cached** tap events on the **master** (one pending event per slave). Slaves send `ESPNOW_TAP_EVENT` on tap; the master stores the latest value per client in `client_registry.c` for up to **16 ms** (`CLIENT_REGISTRY_TAP_MAX_AGE_MS`). Each snapshot **consumes** fresh events (cleared after read). Only slaves with at least one tap-notify flag enabled are included. **Request:** framed `1c` (`0x1c`) + optional `tap_snapshot_request` (`client_id`: `0` = all, `>0` = one id). **Response:** `tap_snapshot_response.events[]`: | Field | Meaning | |-------|---------| | `client_id` | Slave id (registry) | | `valid` | Fresh tap available (≤16 ms) | | `kind` | `TAP_SINGLE`, `TAP_DOUBLE`, or `TAP_TRIPLE` | | `age_ms` | Ms since tap was received on master | Host tools poll this only when **receive** is enabled (dashboard tap column, WebSocket `set_tap_stream`). They keep events visible for **2 s** in the UI/API after first sight. ```bash go run . -port /dev/ttyUSB0 tap go run . -port /dev/ttyUSB0 tap -client 16 ``` ### 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`. ### FIND_ME command Locate a pod: the LED ring blinks **3× red, 3× green, 3× blue** at full brightness. **Request:** framed `22` + `espnow_find_me_request` (`client_id`: `0` = master only, `>0` = ESP-NOW unicast to that slave). **Response:** `espnow_find_me_response` (`success`, `client_id`). ```bash go run . -port /dev/ttyUSB0 find-me go run . -port /dev/ttyUSB0 find-me -client 16 ``` ### RESTART command Reboot the master (`client_id=0`) or one slave via ESP-NOW (`client_id` = registry id). The device sends the UART response, then restarts after ~150 ms. **Request:** framed `23` + `restart_request` **Response:** `restart_response` (`success`, `client_id`) ```bash go run . -port /dev/ttyUSB0 restart go run . -port /dev/ttyUSB0 restart -client 16 ``` ### BATTERY_STATUS command Read **cached** LiPo ADC values on the **master** (master local + one entry per registered slave). Slaves push `ESPNOW_BATTERY_REPORT` every **30 s**; the master stores them in `client_registry` (`lipo1/2_valid`, `lipo1/2_mv`, `battery_updated_at`). The master refreshes its own pack on the same interval in `master_monitor_task`. **Request:** framed `26` + optional `battery_status_request` (`client_id`, `all_clients`). **Response:** `battery_status_response` with `samples[]` (`client_id`, `lipo1`, `lipo2`, `age_ms`). ```bash # Host / goTool: all_clients returns master (id 0) + slaves from cache ``` ### LED_RING command Control the 95-LED ring from the host. The firmware **does not** animate digits locally; only UART updates the display. **Request:** framed `08` + `led_ring_progress_request`: | Field | Meaning | |-------|---------| | `mode` | `0` = clear, `1` = progress, `2` = digit (0–10), `3` = blink, `4` = find-me, `5` = solid color (all LEDs) | | `progress` | 0–100 (% of ring lit, mode `1`) | | `digit` | 0–10 (mode `2`, segment maps in `led_ring.c`) | | `r`, `g`, `b` | Color 0–255 | | `intensity` | Brightness 0–255 (scaled into RGB; `0` → firmware default ~5 %) | | `blink_ms`, `blink_count` | Pulse length and count (mode `3`; defaults 350 ms, 1) | | `client_id` | `0` = master ring only; `>0` = ESP-NOW unicast to one slave | | `all_clients` | Broadcast to all registered slaves | | `slaves_only` | With `all_clients`: do not change master ring | **Response:** `led_ring_progress_response` (`success`, `mode`, `progress`, `digit`, `client_id`, `slaves_updated`). Slaves receive the same command via ESP-NOW `ESPNOW_LED_RING` and run it locally. ```bash go run . -port /dev/ttyUSB0 led-ring -mode progress -progress 75 -g 80 -b 255 go run . -port /dev/ttyUSB0 led-ring -mode digit -digit 7 -r 255 -g 200 go run . -port /dev/ttyUSB0 led-ring -mode clear go run . -port /dev/ttyUSB0 led-ring -mode blink -g 255 -blink-count 2 go run . -port /dev/ttyUSB0 find-me go run . -port /dev/ttyUSB0 find-me -client 16 go run . -port /dev/ttyUSB0 led-ring -mode find-me go run . -port /dev/ttyUSB0 led-ring -mode color -r 255 -g 0 -b 0 -client 16 go run . -port /dev/ttyUSB0 led-ring -mode digit -digit 5 -all ``` ### 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`, `tap_notify_single`, `tap_notify_double`, `tap_notify_triple` — **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 | | `client_registry_set_tap_notify()` / `client_registry_take_tap()` | Tap notify flags + short-lived tap cache (16 ms) | 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 sender’s 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/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/cmd_handler.c/h` | Command queue and dispatch | | `uart_cmd.c/h` | Shared UART decode/send helpers for handlers | | `cmd/cmd_version.c/h` | VERSION handler | | `cmd/cmd_client_info.c/h` | CLIENT_INFO handler | | `client_registry.c/h` | Registered slave table | | `bosch456.c/h` | BMA456H I2C driver, accel poll, on-demand read, tap INT, deadzone filter | | `cmd/cmd_accel_snapshot.c` | UART `ACCEL_SNAPSHOT` — cached slave accel | | `cmd/cmd_tap_notify.c` | UART `TAP_NOTIFY` — ESP-NOW tap notify config | | `cmd/cmd_tap_snapshot.c` | UART `TAP_SNAPSHOT` — consume cached tap events | | `board_input.c/h` | Taster GPIO12, LiPo ADC on GPIO1 / GPIO12 | | `pod_settings.c/h` | NVS persistence (accel deadzone, …) | | `led_ring.c/h` | LED ring (digit display, progress bar) | | `cmd/cmd_led_ring.c` | UART `LED_RING` progress command | | `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 feature (UART → ESP-NOW) End-to-end walkthrough (protobuf, master handler, ESP-NOW unicast to slaves, goTool, dashboard) with **Find me** as the worked example: **[docs/adding-a-feature.md](../docs/adding-a-feature.md)** Short checklist: 1. Add or extend messages in `uart_messages.proto` (and `esp_now_messages.proto` if slaves are involved); run `make proto_generate` and `make gotool-proto`. 2. Implement device logic in a shared module (e.g. `led_ring.c`), not only in the UART handler. 3. Create `cmd/cmd_*.c`, register with `uart_cmd_register()`; decode with `uart_cmd_decode()` / `UART_CMD_REQ()`; reply with `uart_cmd_init_response()` + `uart_cmd_send()`. 4. Master → slave: `esp_now_comm_send_*()` + slave branch in `espnow_recv_cb`. 5. Extend `goTool` (CLI, optional `/api/…` and web UI). For ESP-NOW-driven PC updates later: map slave state to `ClientInfo` and send `CLIENT_INFO` over UART from the master.