powerpods/main/README.md
simon 1ad527119d Add board input driver for button and LiPo ADC logging.
TODO: Hardware pinning in powerpod.h (TASTER_GPIO, V_LIPO_1/2_GPIO) does not
match final hardware yet — verify against schematic before production.

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

14 KiB
Raw Blame History

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)

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)
typedef void (*msg_callback_t)(const uint8_t *data, size_t len);

Init order on master:

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
1620 OTA / ESP-NOW OTA Planned

Regenerate C code:

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.versionPOWERPOD_FW_VERSION
  • version_response.git_hash — build git hash string

Encoding: uart_send_uart_message() in uart_proto.c.

ACCEL_DEADZONE command

Sets the software deadzone used by bosch456.c when logging accel (see BMA456 accelerometer). 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_pingmilliseconds 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.

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:

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

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
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.