simon 490e0ee61f Add UART SET_LOG_LEVEL for runtime master ESP-IDF logging.
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>
2026-06-06 18:03:34 +02:00
..
2026-06-06 17:17:42 +02:00
2026-05-05 17:36:29 +02:00
2026-06-06 17:15:55 +02:00
2026-06-06 17:15:55 +02:00

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 (Datenflüsse UART/Commands/ESP-NOW), ../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 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.

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 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_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 (15). 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 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).

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/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 (0100 %, 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:

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

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:

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.

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

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.

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

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

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

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)

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

# 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 (010), 3 = blink, 4 = find-me, 5 = solid color (all LEDs)
progress 0100 (% of ring lit, mode 1)
digit 010 (mode 2, segment maps in led_ring.c)
r, g, b Color 0255
intensity Brightness 0255 (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.

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

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.