Expose the command via goTool CLI/REST and dashboard controls so log verbosity can be tuned without reflashing. Co-authored-by: Cursor <cursoragent@cursor.com>
592 lines
28 KiB
Markdown
592 lines
28 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.
|
||
|
||
**Architektur & ESP-only Doku (ohne goTool):** [`../docs/ARCHITECTURE.md`](../docs/ARCHITECTURE.md) (Datenflüsse UART/Commands/ESP-NOW), [`../docs/DOCUMENTATION.md`](../docs/DOCUMENTATION.md) (vollständige Referenz).
|
||
|
||
## 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 over UART only.
|
||
|
||
```
|
||
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 |
|
||
|
||
During an OTA session (`ota_session_busy()`), the dispatcher rejects all UART commands except OTA_* and `OTA_SLAVE_PROGRESS` (see `ota_session.c`).
|
||
|
||
```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 |
|
||
| 25 | `ACCEL_STREAM` | Implemented — enable/disable slave ESP-NOW accel stream to master |
|
||
| 27 | `TAP_NOTIFY` | Implemented (`cmd/cmd_tap_notify.c`) — get/set which tap kinds notify via ESP-NOW |
|
||
| 29 | `CACHE_STATUS` | Implemented (`cmd/cmd_cache_status.c`) — subscribed accel + tap cache (one UART round-trip) |
|
||
| 30 | `ESPNOW_ECHO_PING` | Implemented (`cmd/cmd_espnow_echo_ping.c`) — ESP-NOW timestamp echo (latency test) |
|
||
| 31 | `SET_LOG_LEVEL` | Implemented (`cmd/cmd_set_log_level.c`) — get/set global `esp_log_level_set("*", …)` on master |
|
||
|
||
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).
|
||
|
||
### 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
|
||
```
|
||
|
||
### CACHE_STATUS command
|
||
|
||
Read **cached** accel and/or tap data on the **master** in one UART round-trip. Slaves send `ESPNOW_ACCEL_SAMPLE` every **16 ms** when streaming; tap events arrive via `ESPNOW_TAP_EVENT` and are held up to **16 ms** (`CLIENT_REGISTRY_TAP_MAX_AGE_MS`). Pending taps are **consumed** on read (like the former `TAP_SNAPSHOT`).
|
||
|
||
**Request:** framed `1d` (`0x1d`) only — no body (`CacheStatusRequest` empty).
|
||
|
||
**Response:** `cache_status_response.clients[]` — one entry per slave with `accel_stream_enabled` and/or any tap-notify flag:
|
||
|
||
| Field | When present |
|
||
|-------|----------------|
|
||
| `client_id` | Always (for listed slaves) |
|
||
| `accel` | Slave has accel stream on (`valid`, `x`/`y`/`z`, `age_ms` when sample fresh) |
|
||
| `tap` | Tap notify on **and** a pending tap was consumed (`kind`, `age_ms`) |
|
||
|
||
Unsubscribed submessages are omitted on the wire (proto3 defaults). The master walks `client_registry` once per request (`cmd/cmd_cache_status.c`).
|
||
|
||
Host tools poll this at **16 ms** when live-stream / WebSocket receive is enabled. Tap events stay visible for **2 s** in the UI/API after first sight.
|
||
|
||
```bash
|
||
go run . -port /dev/ttyUSB0 cache-status
|
||
```
|
||
|
||
External API (`serve -api-addr :8081`) uses the same command for WebSocket `accel` / `tap` push.
|
||
|
||
### 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
|
||
```
|
||
|
||
### SET_LOG_LEVEL command
|
||
|
||
Read or set the **global** ESP-IDF log filter on the master (`esp_log_level_set("*", level)`). Does not affect the host UART protocol (UART1); `esp_log_*` output goes to the **debug console UART0** (USB, 115200).
|
||
|
||
**Request:** framed `31` (`0x1f`) + `set_log_level_request` (`write`, `level` 0–5).
|
||
|
||
**Response:** `set_log_level_response` (`success`, `level`).
|
||
|
||
| `level` | `esp_log_level_t` |
|
||
|---------|-------------------|
|
||
| 0 | NONE |
|
||
| 1 | ERROR |
|
||
| 2 | WARN |
|
||
| 3 | INFO |
|
||
| 4 | DEBUG |
|
||
| 5 | VERBOSE |
|
||
|
||
Boot default follows `CONFIG_LOG_DEFAULT_LEVEL` in `sdkconfig` (not persisted across reboot).
|
||
|
||
```bash
|
||
go run . -port /dev/ttyUSB0 log-level
|
||
go run . -port /dev/ttyUSB0 log-level -set -level 0
|
||
```
|
||
|
||
### 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` | ESP-NOW init and recv router |
|
||
| `esp_now_core.c/h` | Shared WiFi, peer, send |
|
||
| `esp_now_master.c/h` | Master discover, monitor, unicast |
|
||
| `esp_now_slave.c/h` | Slave join, heartbeat, telemetry |
|
||
| `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_tap_notify.c` | UART `TAP_NOTIFY` — ESP-NOW tap notify config |
|
||
| `cmd/cmd_cache_status.c` | UART `CACHE_STATUS` — subscribed accel + tap cache poll |
|
||
| `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 |
|
||
| `cmd/cmd_set_log_level.c` | UART `SET_LOG_LEVEL` — runtime ESP-IDF log level |
|
||
| `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.
|