Compare commits

...
21 Commits
Author SHA1 Message Date
skrueckenandCursor 8f90dfe82f Send ESP-NOW accel only on deadzone change and harden BLE+WiFi OTA coex.
Quiet pods no longer spam 100 Hz samples; mute wifi logs and throttle BLE
notifies during slave distribution so the wifi task stack does not panic.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-02 00:55:50 +01:00
skrueckenandCursor 141176a824 Send large BLE host frames across multiple notifies.
Prefer ATT MTU 517 and chunk oversized responses so CLIENT_INFO/BATTERY work with many pods; reset the goTool parser after timeouts so partial frames do not poison the next exchange.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-01 23:53:40 +01:00
skrueckenandCursor 9046677460 Raise host frame payload limit so multi-client UART replies fit.
Use a uint16 LE length and 1024-byte max payload so CLIENT_INFO and BATTERY_STATUS no longer fail encoding past ~10 connected pods.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-01 23:43:33 +01:00
skrueckenandCursor 73f48c8002 Add LED ring boot chase and connect flash animations.
Give pods quick visual feedback on power-up and when a slave joins the master.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-01 22:57:31 +01:00
skrueckenandCursor 7f28a48a10 Add runtime UV protection toggle and treat near-zero sense as external power.
Per-pod NVS enable (default off) via UART/ESP-NOW/dashboard, and skip UV latch when LiPo ADC reads ~0 mV so USB/external supply still boots.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-01 22:51:17 +01:00
skrueckenandCursor c3b20887bf Phase accel ESP-NOW TX into 1 ms slots from Discover sync.
Master broadcasts master_time_us; slaves schedule sends at
offset_ms=(assigned_id-1)%8 so concurrent streams collide less.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-01 22:35:47 +01:00
skrueckenandCursor 1adaddd6c8 Add selectable ESP-NOW send path for accel/tap input.
Expose best_effort/fast/wait/reliable via UART, dashboard, and CLI so
loss vs latency can be compared under real multi-pod load.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-01 22:34:03 +01:00
skrueckenandCursor 178ab46cec Make controller input best-effort and harden OTA rollback.
Accel/tap use fire-and-forget ESP-NOW with a 10 ms FreeRTOS-safe cadence, OTA recovers from seq gaps and auto-reboots after success, and bootloader app rollback confirms after a stable boot.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-01 22:16:28 +01:00
skrueckenandCursor ae8ef40820 Enable host OTA over BLE and harden ESP-NOW slave distribution.
Allow the same staged firmware upload path on BLE as UART, fix distribute stack/event-group crashes, and recover sticky slave OTA sessions instead of failing with err=4.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-01 20:42:14 +01:00
skrueckenandCursor 567dab6d0a Assign sequential pod IDs from the master with NVS-stable bindings.
Replaces MAC-byte self-IDs with master allocation (1–255), slave preferred-ID reconnect, debug reset via UART/UI, and rate-limited ASSIGN to avoid ESP-NOW NO_MEM storms.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-01 17:25:07 +01:00
skrueckenandCursor af20635047 Harden BLE host link and expose latency tools in the dashboard.
Add BLE RTT/stream tests and live tick median in the UI, fix concurrent WebSocket writes and late BATTERY replies, and skip 2M PHY updates that assert under ESP-NOW coexistence.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-23 23:27:39 +02:00
skrueckenandCursor aa67edfe94 Add dynamic UART/BLE host connect for serve, dashboard, and WS.
Serve starts without a required port; REST/WS/UI can scan and switch transports, with BLE live ticks and UART-only OTA.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-23 22:40:28 +02:00
skrueckenandCursor 92a13d7fd0 Add BLE host transport selectable via DIP8 alongside UART.
Master can speak the same framed protobuf protocol over NimBLE or UART; goTool gains -ble, ble-rtt, and ble-stream timing checks for ESP-NOW coexistence.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-23 16:11:10 +02:00
simonandCursor d7441cb85f Add runtime BMA456 tap config with NVS persistence and goTool UI.
Expose multitap parameters over UART/ESP-NOW (like deadzone), store per node in NVS, and allow get/set from CLI, REST, and the dashboard without poll-overwrite while editing.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-26 13:07:13 +02:00
skrueckenandCursor f006f8b912 Add software LiPo UV protection with energy-save boot.
Detect under-voltage on GPIO1/11, persist UV state in NVS, enter a minimal
power mode without ESP-NOW, and recover after charge; expose LED mode 6
battery-low for host testing via goTool.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 18:22:52 +02:00
skruecken be18b758ed Added LIPO 2 Reading 2026-06-23 17:54:18 +02:00
skrueckenandCursor 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
skruecken ac223ada72 Added ECHO cmd and fixed some timing issues 2026-06-06 17:44:40 +02:00
skruecken 35ce1476d8 Missed for Previous Commit 2026-06-06 17:17:42 +02:00
skruecken f89ea3cbe3 Stabelized OTA Update with Retries 2026-06-06 17:15:55 +02:00
skruecken 99956e3362 Update InputClientSample struct to make TapKind optional and improve WebSocket API documentation. Adjust README to reflect changes in input push stream format and clarify connection flow. Enhance API_WEBSOCKET.md with detailed command and push message descriptions. 2026-06-06 12:26:31 +02:00
112 changed files with 13037 additions and 1439 deletions
+18 -12
View File
@@ -79,8 +79,9 @@ sequenceDiagram
| Feld | Quelle | Bedeutung |
|------|--------|-----------|
| `master` | `DIP_MASTER` (GPIO 4): Low = Master | Steuert UART + Registry-Nutzung |
| `network` | IO-Expander, Bits 5–8 (nibble reversed) | 1–8 → WiFi/ESP-NOW-Kanal |
| `master` | `DIP_MASTER` (GPIO 4): Low = Master | Steuert Host-Transport + Registry |
| `network` | IO-Expander 8er-DIP-Bank, High-Nibble PCB-reversed; DIP1–7 one-hot | 1–7 → WiFi/ESP-NOW-Kanal |
| `host_ble` | Expander DIP8 (nach Reverse+Invert) | `false`=UART, `true`=BLE (Boot only) |
| `running_partition` | `esp_ota_get_running_partition()` | Aktives OTA-Label (`ota_0` / `ota_1`) |
**Dateien:** `main/app_config.h`, `main/powerpod.c`, `main/powerpod.h` (Pins).
@@ -96,17 +97,21 @@ sequenceDiagram
├─────────────────────────────────────────────────────────────┤
│ Dispatch: cmd_handler.c (Queue + vCmdDispatcherTask) │
├─────────────────────────────────────────────────────────────┤
│ Transport UART: uart.c (Framing) │ ESP-NOW: esp_now_comm │
│ Host transport: uart.c / ble_host.c (Framing via host_transport) │
│ ESP-NOW: esp_now_comm │
├─────────────────────────────────────────────────────────────┤
│ Protokoll: uart_messages.proto │ esp_now_messages.proto │
│ Codec: nanopb (proto/*.pb.c) │ esp_now_proto.c │
├─────────────────────────────────────────────────────────────┤
│ Domäne: client_registry, bosch456, led_ring, ota_*, board │
├─────────────────────────────────────────────────────────────┤
│ ESP-IDF: UART, WiFi, ESP-NOW, I2C, ADC, OTA, NVS, FreeRTOS │
│ ESP-IDF: UART, NimBLE, WiFi, ESP-NOW, I2C, ADC, OTA, NVS │
└─────────────────────────────────────────────────────────────┘
```
**Host UART vs BLE:** Nur einer aktiv (DIP8). Gleiches Framing (`0xAA`…`0xCC`) und dieselbe `cmd_queue`. BLE: NUS-ähnliche GATT-Char, Write=RX, Notify=TX; Frames größer als ATT MTU-3 werden in aufeinanderfolgende Notifies gestückelt (Host-Parser reassembliert). `gotool serve` verbindet UART oder BLE über Dashboard/REST/WS (`managedHost`); **OTA** über UART oder BLE (ATT MTU ≥ ~219, preferred 517). Bei BLE-Host und aktivem Accel-Stream pusht `ble_stream` periodisch `BLE_STREAM_TICK` (10–16 ms, Default 10; pausiert während OTA) mit `esp_timestamp_us` (Live-Pfad bevorzugt Tick statt CACHE_STATUS-Poll).
---
## 4. Datenfluss: Commands (Befehle)
@@ -205,9 +210,9 @@ sequenceDiagram
```
**Beispiel-Implementierung:** `main/cmd/cmd_accel_deadzone.c`
**Weitere Bridge-Handler:** `cmd_accel_stream.c`, `cmd_tap_notify.c`, `cmd_led_ring.c`, `cmd_espnow_find_me.c`, `cmd_restart.c`, `cmd_espnow_unicast_test.c`, `cmd/cmd_ota.c` (OTA + `ota_espnow.c`).
**Weitere Bridge-Handler:** `cmd_accel_stream.c`, `cmd_tap_notify.c`, `cmd_led_ring.c`, `cmd_espnow_find_me.c`, `cmd_restart.c`, `cmd_espnow_unicast_test.c`, `cmd_espnow_echo_ping.c`, `cmd/cmd_ota.c` (OTA + `ota_espnow.c`).
**Nur Master / nur Cache (kein Slave-Roundtrip):** `cmd_client_info.c`, `cmd_battery.c`, `cmd_cache_status.c`, `cmd_version.c`.
**Nur Master / nur Cache (kein Slave-Roundtrip):** `cmd_client_info.c`, `cmd_battery.c`, `cmd_cache_status.c`, `cmd_version.c`, `cmd_set_log_level.c`.
---
@@ -220,12 +225,12 @@ Unabhängig von Protobuf — reines Bytestream-Framing:
| Feld | Wert |
|------|------|
| Start | `0xAA` |
| Länge | 1 Byte (1–252) |
| Länge | 2 Bytes little-endian (1–1024) |
| Payload | `length` Bytes |
| Prüfsumme | XOR aller Payload-Bytes |
| Stopp | `0xCC` |
**Parameter:** `main/uart.h` — `UART_NUM_1`, `921600` Baud, TX GPIO **2**, RX GPIO **3**, `MAX_PAYLOAD_SIZE` 248.
**Parameter:** `main/uart.h` — `UART_NUM_1`, `921600` Baud, TX GPIO **2**, RX GPIO **3**, `MAX_PAYLOAD_SIZE` 1024.
### 5.2 Nutzlast (Anwendung)
@@ -310,7 +315,7 @@ Alle Master-Sends laufen über `send_message()` / `send_message_ex()`:
| Nachricht | Task / Trigger | Master-Ziel |
|-----------|----------------|-------------|
| `ESPNOW_ACCEL_SAMPLE` | `slave_accel_stream_task` 16 ms | `client_registry_update_accel()` |
| `ESPNOW_ACCEL_SAMPLE` | `accel_stream_task` poll 10 ms + deadzone (nur bei Änderung), 1 ms slots via Discover `master_time_us` | `client_registry_update_accel()` |
| `ESPNOW_TAP_EVENT` | BMA456-IRQ → `on_bma456_tap` | `client_registry_update_tap()` |
| `ESPNOW_BATTERY_REPORT` | Heartbeat + nach Join | `client_registry_update_battery()` |
| `ESPNOW_SLAVE_INFO` / `HEARTBEAT` | `slave_tx_task` / Heartbeat-Task | `client_registry_heartbeat()` |
@@ -320,7 +325,7 @@ Alle Master-Sends laufen über `send_message()` / `send_message_ex()`:
### 6.5 OTA über ESP-NOW
Nach erfolgreichem UART-OTA auf dem Master (oder `OTA_START_ESPNOW`): `ota_espnow.c` liest gestagte Partition, sendet `OTA_START` / `PAYLOAD` (≤200 B, `send_message_ex` mit ACK-Semaphore) / `OTA_END` an alle **available** Slaves.
Nach erfolgreichem UART-OTA auf dem Master (oder `OTA_START_ESPNOW`): `ota_espnow.c` liest gestagte Partition, sendet `OTA_START` / `PAYLOAD` (≤200 B, `send_message_ex` mit ACK-Semaphore) / `OTA_END` an alle **available** Slaves. Anschließend Reboot; Bootloader führt neues Image als `PENDING_VERIFY`, `ota_confirm` bestätigt nach stabilem Boot.
Gemeinsamer Flash-Puffer: `ota_uart.c` (4 KiB Blockgröße).
@@ -343,7 +348,7 @@ flowchart TB
|-------|-------------------|-------------------|
| Presence, Version | HEARTBEAT / SLAVE_INFO | `CLIENT_INFO` |
| Accel-Stream-Samples | `ESPNOW_ACCEL_SAMPLE` | `CACHE_STATUS` |
| Tap-Events (16 ms Cache) | `ESPNOW_TAP_EVENT` | `CACHE_STATUS` |
| Tap-Events (10 ms Cache) | `ESPNOW_TAP_EVENT` | `CACHE_STATUS` |
| Batterie | `ESPNOW_BATTERY_REPORT` + Master-ADC | `BATTERY_STATUS` |
| Konfig-Flags | Handler + ESP-NOW | `CLIENT_INFO`, diverse GET |
@@ -361,7 +366,7 @@ flowchart TB
| `espnow_mon` | Master | 1 s Timeout + Master-Battery |
| `espnow_stx` | Slave | Queue: SLAVE_INFO, Battery nach Join |
| `espnow_hb` | Slave | 1 s Heartbeat + 30 s Battery |
| `espnow_accel` | Slave | 16 ms Accel wenn Stream an |
| `espnow_accel` | Slave | Accel poll 10 ms / TX bei Deadzone-Änderung, ID-Slot 1 ms wenn Stream an |
| `bma456_poll` | Beide | 10 Hz (`bosch456.c`) |
| `vTaskLedRing` | Beide | LED-Ring-Befehle |
@@ -386,6 +391,7 @@ flowchart TB
| LED | `main/led_ring.c` | 95 LEDs, Digit-Maps |
| OTA UART | `main/ota_uart.c`, `cmd/cmd_ota.c` | A/B-Partition Upload |
| OTA ESP-NOW | `main/ota_espnow.c` | Verteilung an Slaves |
| OTA Confirm | `main/ota_confirm.c` | Nach ~8 s `mark_app_valid` (Rollback) |
| Einstellungen NVS | `main/pod_settings.c` | Accel-Deadzone persistent |
| Board | `main/board_input.c` | Taster, LiPo-ADC |
| Protobuf Schemas | `main/proto/*.proto` | Vertrag UART / ESP-NOW |
+125 -28
View File
@@ -47,14 +47,17 @@ Zielbild für den Host: Befehle an den Master senden; der Master steuert Slaves
|--------|------|----------------|
| DIP Master/Slave | 4 | `powerpod.h` — Low = Master |
| I2C SCL / SDA | 5 / 6 | IO-Expander `0x20`, BMA456 `0x18` |
| UART1 TX / RX | **2 / 3** | `uart.h` (Adapter: ESP-TX → Host-RX) |
| Expander DIP-Bank (8) | PCF/TCA @ `0x20` | DIP1–7 = Netzwerk 1–7 (one-hot); **DIP8 ON = BLE host**, OFF = UART. High-Nibble auf PCB vertauscht → `reverse_high_nibble_lut`. Wechsel nur nach Reboot. |
| UART1 TX / RX | **2 / 3** | `uart.h` (Adapter: ESP-TX → Host-RX); nur wenn DIP8 = UART |
| LED-Ring (WS2812) | 7 | `led_ring.c` |
| BMA456 Interrupt | 10 | `bosch456.c` |
| Taster | 12 | `board_input.c` |
| LiPo ADC 1 | 1 | `board_input.c` |
| LiPo ADC 2 | 12 | Entfällt wenn = Taster-GPIO |
| LiPo ADC 2 | 11 | `board_input.c` |
**UART:** `UART_NUM_1`, **921600** Baud, 8N1, kein Flow-Control.
**Host-Protokoll (UART oder BLE):** Gleiches Framing (`0xAA`…`0xCC`) + nanopb. **UART:** `UART_NUM_1`, **921600** Baud, 8N1. **BLE:** Gerät `Powerpod`, NUS-UUIDs `6e400001`/`6e400002` (Write=RX, Notify=TX). goTool CLI: `-port …` oder `-ble` / `-ble-name Powerpod`. **`gotool serve`** startet ohne Pflicht-Gerät; Dashboard/REST/WS verbinden UART oder BLE dynamisch (`-port`/`-ble` nur optionale Vorbelegung). **OTA** über UART oder BLE (DIP8); BLE braucht ATT MTU ≥ ~219 für 200-B-Chunks. Bei BLE + Accel-Stream: periodische `BLE_STREAM_TICK`-Notifies (`period_ms` 10–16; während OTA pausiert).
**ESP-IDF-Log (Debug):** `esp_log_*` geht auf **UART0** (Standard-Konsole, typisch USB am Dev-Board, **115200** Baud, `CONFIG_ESP_CONSOLE_UART_NUM=0`). Das ist **getrennt** vom Host-UART1 — goTool liest keine `esp_log`-Ausgabe. Ohne angeschlossenes Debug-Kabel werden aktivierte Logs trotzdem formatiert und an UART0 gesendet (CPU-Overhead bleibt); nur `ESP_LOG_NONE` / Level-Filter vermeiden die Arbeit.
**I2C:** 100 kHz, interne Pull-ups, gemeinsamer Bus für Expander und BMA456H.
@@ -89,21 +92,23 @@ idf.py -p /dev/ttyUSB0 flash monitor
### 4.1 Initialisierungsreihenfolge (`powerpod.c`)
1. `pod_settings_init()` — NVS
2. DIP → `app_config.master`
3. I2C + IO-Expander → `app_config.network` (1–8)
4. `init_bma456()` — optional, bei Fehler weiter ohne Sensor
5. `pod_settings_apply_accel_deadzone()` wenn Sensor da
6. OTA-Partition → `app_config.running_partition`
7. `board_input_init()`
8. `esp_now_comm_init(&app_config)` — **immer** (Master + Slave)
9. `led_ring_init()`
10. **Nur Master:** `cmd_queue` → `init_cmdHandler` → `init_uart` → alle `cmd_*_register()`
2. `board_input_init_adc_only()` — LiPo-ADC
3. Bei `POWERPOD_BATTERY_UV_ENABLE`: `battery_uv_evaluate_boot()` → ggf. Energiesparmodus (nur LED + ADC, **kein** weiterer Init)
4. DIP → `app_config.master`
5. I2C + IO-Expander → `app_config.network` (1–7) + `app_config.host_ble` (DIP8)
6. `init_bma456()` — optional, bei Fehler weiter ohne Sensor
7. `pod_settings_apply_accel_deadzone()` wenn Sensor da
8. OTA-Partition → `app_config.running_partition`
9. `board_input_start_lipo_monitor()` + `board_input_init_button()`
10. `esp_now_comm_init(&app_config)` — **normaler Pfad** (Master + Slave)
11. `led_ring_init()`
12. **Nur Master:** `cmd_queue` → `init_cmdHandler` → `init_uart` **oder** `init_ble_host` → alle `cmd_*_register()`
### 4.2 Master vs. Slave
| Funktion | Master | Slave |
|----------|--------|-------|
| UART Command-Handler | Ja | Nein |
| Host Command-Handler (UART oder BLE) | Ja | Nein |
| ESP-NOW Discover senden | Ja (Broadcast) | Nein |
| ESP-NOW auf Discover reagieren | Nein | Ja |
| `client_registry` für Fremdknoten | Ja | Nein (nur eigener Join-State) |
@@ -119,10 +124,10 @@ idf.py -p /dev/ttyUSB0 flash monitor
| Byte | Inhalt |
|------|--------|
| 0 | `0xAA` Start |
| 1 | Länge N (1–252) |
| 2…N+1 | Payload |
| N+2 | XOR-Checksum über Payload |
| N+3 | `0xCC` Stopp |
| 1–2 | Länge N little-endian (1–1024) |
| 3…N+2 | Payload |
| N+3 | XOR-Checksum über Payload |
| N+4 | `0xCC` Stopp |
Implementierung: `uart.c` — `parse_uart_byte()`, `uart_send_framed()`.
@@ -171,7 +176,8 @@ UART_CMD_REQ(&uart_msg, alox_UartMessage_accel_deadzone_request_tag, accel_deadz
| `ESPNOW_SLAVE_INFO` | S→M | `slave_info` | Erste Registrierung |
| `ESPNOW_HEARTBEAT` | S→M | `heartbeat` | Keepalive 1 s |
| `ESPNOW_SET_ACCEL_DEADZONE` | M→S | `accel_deadzone` | Deadzone LSB |
| `ESPNOW_SET_ACCEL_STREAM` | M→S | `accel_stream` | Stream ~16 ms |
| `ESPNOW_SET_TAP_CONFIG` | M→S | `tap_config` | Multitap-Parameter + NVS |
| `ESPNOW_SET_ACCEL_STREAM` | M→S | `accel_stream` | Stream poll ~10 ms, TX on deadzone change |
| `ESPNOW_ACCEL_SAMPLE` | S→M | `accel_sample` | x/y/z Roh-LSB |
| `ESPNOW_SET_TAP_NOTIFY` | M→S | `tap_notify` | Tap-Arten filtern |
| `ESPNOW_TAP_EVENT` | S→M | `tap_event` | kind 1/2/3 |
@@ -181,6 +187,8 @@ UART_CMD_REQ(&uart_msg, alox_UartMessage_accel_deadzone_request_tag, accel_deadz
| `ESPNOW_FIND_ME` | M→S | `find_me` | LED-Locate |
| `ESPNOW_RESTART` | M→S | `restart` | Reboot Slave |
| `ESPNOW_UNICAST_TEST` | M→S | `unicast_test` | Link-Test |
| `ESPNOW_ECHO_PING` | M→S | `echo_ping` | Latenztest (Host-Timestamp + `master_time_us`) |
| `ESPNOW_ECHO_PONG` | S→M | `echo_pong` | Echo unverändert zurück zum Master |
| `ESPNOW_OTA_*` | M↔S | `ota_*` | Firmware-Verteilung |
### 6.3 Zeitkonstanten (`esp_now_comm.c`)
@@ -191,8 +199,9 @@ UART_CMD_REQ(&uart_msg, alox_UartMessage_accel_deadzone_request_tag, accel_deadz
| Heartbeat | 1000 ms |
| Client-Timeout (Master) | 3 × Heartbeat = 3 s → `available=false` |
| Master-Verlust (Slave) | 5 s ohne Discover |
| Accel-Stream | 16 ms |
| Accel-Stream | Poll 10 ms; TX nur bei Deadzone-Änderung (best-effort) |
| Batterie-Report | 30 s (+ einmal 150 ms nach Join) |
| Echo-Ping Timeout (Master) | 500 ms (`ESPNOW_ECHO_PING_TIMEOUT_MS`) |
### 6.4 `EspNowSlavePresence`
@@ -217,7 +226,7 @@ Nur auf dem **Master** registriert (`powerpod.c`). IDs aus `MessageType` in `uar
| 6 | ACCEL_DEADZONE | `cmd_accel_deadzone.c` | Get/Set Deadzone lokal + ESP-NOW |
| 7 | ESPNOW_UNICAST_TEST | `cmd_espnow_unicast_test.c` | Link-Test zu Slave |
| 8 | LED_RING | `cmd_led_ring.c` | Ring lokal / ESP-NOW |
| 16 | OTA_START | `cmd_ota.c` | UART-OTA beginnen |
| 16 | OTA_START | `cmd_ota.c` | Host-OTA beginnen (UART oder BLE) |
| 17 | OTA_PAYLOAD | `cmd_ota.c` | Chunk ≤200 B |
| 18 | OTA_END | `cmd_ota.c` | Abschluss + ESP-NOW-Verteilung |
| 19 | OTA_STATUS | `cmd_ota.c` | Gerät → Host Status |
@@ -225,10 +234,14 @@ Nur auf dem **Master** registriert (`powerpod.c`). IDs aus `MessageType` in `uar
| 21 | OTA_SLAVE_PROGRESS | `cmd_ota_slave_progress.c` | Fortschritt pro Slave |
| 22 | FIND_ME | `cmd_espnow_find_me.c` | LED Locate |
| 23 | RESTART | `cmd_restart.c` | Master oder Slave reboot |
| 25 | ACCEL_STREAM | `cmd_accel_stream.c` | ESP-NOW Accel-Stream an/aus |
| 25 | ACCEL_STREAM | `cmd_accel_stream.c` | ESP-NOW Accel-Stream an/aus (+ `period_ms` für BLE-Push) |
| 26 | BATTERY_STATUS | `cmd_battery.c` | Cache LiPo Master + Slaves |
| 27 | TAP_NOTIFY | `cmd_tap_notify.c` | Tap-Weiterleitung konfigurieren |
| 29 | CACHE_STATUS | `cmd_cache_status.c` | Accel + Tap Cache (ein Round-Trip) |
| 33 | BLE_STREAM_TICK | `ble_stream.c` | Master→Host Notify (nur BLE): Snapshot + `esp_timestamp_us` |
| 30 | ESPNOW_ECHO_PING | `cmd_espnow_echo_ping.c` | Timestamp-Echo über ESP-NOW (Latenztest) |
| 31 | SET_LOG_LEVEL | `cmd_set_log_level.c` | ESP-IDF-Log-Level global (`"*"`) lesen/setzen |
| 32 | TAP_CONFIG | `cmd_tap_config.c` | BMA456 Multitap-Parameter + NVS |
### 7.1 VERSION (3)
@@ -247,14 +260,21 @@ Nur auf dem **Master** registriert (`powerpod.c`). IDs aus `MessageType` in `uar
### 7.4 ACCEL_STREAM (25)
- Optional `period_ms` (10–16): BLE-Host-Push-Intervall für `BLE_STREAM_TICK` (UART ignoriert; Default 10).
- Master setzt `client_registry_set_accel_stream` und sendet `ESPNOW_SET_ACCEL_STREAM`.
- Slave-Task `slave_accel_stream_task` sendet `ESPNOW_ACCEL_SAMPLE` alle 16 ms.
- Slave-Task `accel_stream_task` pollt alle 10 ms; sendet `ESPNOW_ACCEL_SAMPLE` nur wenn eine Achse die Accel-Deadzone seit dem letzten gesendeten Sample überschreitet (best-effort / `send_best_effort`; Baseline-Reset beim Stream-Enable).
### 7.5 TAP_NOTIFY (27)
- Konfiguriert auf Slave welche Tap-Arten `ESPNOW_TAP_EVENT` auslösen.
- Tap-Quelle: BMA456-Interrupt → `on_bma456_tap` in `esp_now_comm.c`.
### 7.5a TAP_CONFIG (32)
- **Request:** `write`, `client_id` (0=lokal), `all_clients`, Multitap-Felder (`tap_sens_thres` … `enable_triple`).
- **Write:** `bma456_set_tap_config` + NVS `tap_cfg` lokal; Slaves via `ESPNOW_SET_TAP_CONFIG`.
- Boot: `pod_settings_apply_tap_config()` nach `init_bma456()`.
### 7.6 CACHE_STATUS (29)
- **Request:** leer.
@@ -287,13 +307,60 @@ Nur auf dem **Master** registriert (`powerpod.c`). IDs aus `MessageType` in `uar
Minimaler Master→Slave-Ping; Slave loggt `UNICAST TEST OK`.
### 7.11 ESPNOW_ECHO_PING (30)
Round-Trip-Latenztest zu einem **Slave** (`client_id` > 0, muss in der Registry sein).
**Ablauf:**
1. Host (goTool) setzt `timestamp_us` (Unix-µs) und sendet `EspNowEchoPingRequest` per UART.
2. Master (`cmd_espnow_echo_ping.c`) löst MAC aus Registry auf, ruft `esp_now_comm_echo_ping()` auf.
3. Master sendet `ESPNOW_ECHO_PING` mit `host_timestamp_us` und `master_time_us` (`esp_timer_get_time()` kurz vor Send).
4. Slave (`handle_echo_ping`) antwortet mit `ESPNOW_ECHO_PONG` (Felder unverändert).
5. Master empfängt Pong, berechnet `esp_rtt_us = esp_timer_get_time() - master_time_us`, antwortet per UART.
**Request:** `client_id`, `timestamp_us` (vom Host gesetzt).
**Response:** `success`, `client_id`, `timestamp_us` (echoed), `esp_rtt_us` (nur bei Erfolg).
| Feld | Quelle | Bedeutung |
|------|--------|-----------|
| `timestamp_us` | Host → Slave → Host | Korrelations-ID; muss mit Request übereinstimmen |
| `esp_rtt_us` | Master | Rohe µs-Differenz von `esp_timer_get_time()` (Ping-Send → Pong-Empfang im `recv_cb`) |
**Implementierung:** `main/cmd/cmd_espnow_echo_ping.c`, `esp_now_comm_echo_ping()` in `esp_now_master.c`, Slave `handle_echo_ping` in `esp_now_slave.c`.
**Host-seitig (goTool):** `rtt_ms` = volle UART-Kette (Send bis Response-Empfang); unabhängig von `esp_rtt_us`.
### 7.12 SET_LOG_LEVEL (31)
Laufzeit-Steuerung des **globalen** ESP-IDF-Log-Levels auf dem Master (`esp_log_level_set("*", …)`). Kein ESP-NOW, kein NVS — nur Master.
**Request:** `write`, `level` (`esp_log_level_t`: 0=NONE, 1=ERROR, 2=WARN, 3=INFO, 4=DEBUG, 5=VERBOSE).
| `write` | Verhalten |
|---------|-----------|
| `false` / leer | Aktuelles Level lesen (`esp_log_level_get("*")`) |
| `true` | Level setzen; ungültige Werte (>5) → `success=false` |
**Response:** `success`, `level` (aktuell bzw. gesetzt).
**Boot-Default:** `CONFIG_LOG_DEFAULT_LEVEL` in `sdkconfig` (aktuell **INFO** = 3). Kann per UART/Web-UI zur Laufzeit geändert werden; nach Reboot gilt wieder der sdkconfig-Wert.
**Ausgabe:** Logs erscheinen auf **UART0** (Debug-USB), nicht auf dem Host-UART1 (goTool).
```bash
go run . -port /dev/ttyUSB0 log-level
go run . -port /dev/ttyUSB0 log-level -set -level 3
```
---
## 8. OTA
### 8.1 UART-OTA (nur Master)
### 8.1 Host-OTA (nur Master, UART oder BLE)
Implementierung: `ota_uart.c`, Steuerung `cmd/cmd_ota.c`.
Implementierung: `ota_uart.c`, Steuerung `cmd/cmd_ota.c`. Gleiches Protokoll über DIP8-Host (UART oder BLE NUS).
| Phase | Host → Master | Master → Host |
|-------|---------------|---------------|
@@ -303,17 +370,20 @@ Implementierung: `ota_uart.c`, Steuerung `cmd/cmd_ota.c`.
- Inaktive Partition: `esp_ota_get_next_update_partition()`.
- LED-Ring zeigt Fortschritt (blau Schreiben, grün Verteilung).
- BLE: ATT MTU ≥ ~219 (preferred 517); große Antworten werden über mehrere Notifies gestückelt; `BLE_STREAM_TICK` pausiert während `ota_session_busy()`.
- **BLE+ESP-NOW Coex:** Tag `wifi` auf `ESP_LOG_NONE` (WiFi-Task-Stack + Error-Logs → Overflow/Panic); während ESP-NOW-Dist `OTA_STATUS` DISTRIBUTING max. ~4/s und kürzere BLE-Notify-Retries.
- **App-Rollback** (`CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE`): neues Image bootet als `PENDING_VERIFY`. `ota_confirm` markiert nach ~8 s stabilem Lauf `mark_app_valid`; Crash/Reset davor → Bootloader rollt auf die vorherige App zurück. Während `PENDING_VERIFY` ist ein neues `OTA_START` blockiert.
### 8.2 ESP-NOW-OTA (Master → Slaves)
`ota_espnow.c` — gleiche Status-Codes wie UART.
`ota_espnow.c` — gleiche Status-Codes wie Host-OTA.
1. `ESPNOW_OTA_START` + `total_size` → Slave `ota_espnow_slave_on_start`
2. `ESPNOW_OTA_PAYLOAD` bis 200 B → 4 KiB Buffer auf Slave
3. `ESPNOW_OTA_END` → Boot-Partition setzen
3. `ESPNOW_OTA_END` → Boot-Partition setzen + automatischer Reboot
4. Slave antwortet `ESPNOW_OTA_STATUS` (mit `send_message_ex` + Semaphore auf Master)
Nach Erfolg: **alle Knoten neu starten**.
Während Distribution: keine Client-Timeouts in der Registry (Heartbeats werden im recv_cb verworfen). Nach Erfolg: Master und Slaves starten automatisch neu (Rollback-Confirm siehe 8.1).
### 8.3 OTA_SLAVE_PROGRESS (21)
@@ -343,6 +413,7 @@ Ohne Sensor: `bma456_is_ready() == false`, Firmware läuft weiter.
- Segment-Karten für Ziffern 0–10 in `digit_lookup[]`.
- **Kein** lokaler Demo-Loop — Anzeige nur über UART (`cmd_led_ring.c`) oder ESP-NOW `ESPNOW_LED_RING`.
- Helligkeit: `intensity` 0 → Default ~5 % (`LED_RING_DEFAULT_INTENSITY`).
- Modi: `0` clear … `5` color, `6` **battery-low** (erste 4 LEDs rot ~10 %, 5 s).
---
@@ -351,12 +422,28 @@ Ohne Sensor: `bma456_is_ready() == false`, Firmware läuft weiter.
`board_input.c`:
- **Taster** GPIO 12 — Logging bei Druck.
- **LiPo-ADC** GPIO 1 (und optional 2, wenn nicht Taster).
- **LiPo-ADC** GPIO 1 und GPIO 11 (Pin-Spannung in mV).
- Master: `master_monitor_task` aktualisiert Master-Batterie alle 30 s.
- Slave: `slave_send_battery_report_to_master` nach Join, Heartbeat und Query.
Spannungen in Millivolt in Registry und `BATTERY_STATUS`-UART.
### Software-UV-Schutz (`battery_uv.c`)
Aktivierbar mit `POWERPOD_BATTERY_UV_ENABLE` in `powerpod.h` (CMake-Override möglich). Runtime zusätzlich NVS `uv_en` (Default **aus**) — UART `BATTERY_UV` / Dashboard / ESP-NOW `ESPNOW_SET_BATTERY_UV`.
| Zustand | ADC-Schwelle | Logik |
|---------|--------------|-------|
| Extern / kein Pack | &lt; 100 mV | Alle gültigen Kanäle → normaler Boot |
| UV | 100…2299 mV | Ein gültiger Kanal reicht |
| Geladen | ≥ 3000 mV | Alle nicht-absent gültigen Kanäle |
- NVS-Keys `uv_latch`, `uv_en` in `pod_settings.c`
- Boot: ADC früh, bei UV nur LED + ADC-Poll (kein ESP-NOW/WiFi/UART/I2C/Button); übersprungen wenn `uv_en=0`
- Runtime: `battery_uv_poll()` im LiPo-Monitor (2 Samples Debounce), gated by `uv_en`
- UART `BATTERY_UV`: `client_id` / `all_clients`; Disable cleart Latch
- LED-Anzeige: Mode `6` battery-low (`led_ring_show_battery_low`)
---
## 12. Persistenz (NVS)
@@ -366,6 +453,9 @@ Spannungen in Millivolt in Registry und `BATTERY_STATUS`-UART.
| Key | Inhalt |
|-----|--------|
| `accel_dz` | Accel-Deadzone LSB |
| `tap_cfg` | BMA456 Multitap-Config (Blob) |
| `uv_latch` | Software-UV-Zustand (`1` = Energiesparmodus) |
| `uv_en` | Runtime-Enable UV-Schutz (`0`/`1`, fehlend = aus) |
Gespeichert bei lokalem Set (UART `client_id=0`, ESP-NOW auf Slave). Geladen nach `init_bma456()`.
@@ -420,6 +510,7 @@ Includes in generierten `.pb.c` müssen `"uart_messages.pb.h"` heißen (nicht `m
| `cmd_client_info.c` | CLIENT_INFO |
| `cmd_accel_deadzone.c` | ACCEL_DEADZONE |
| `cmd_accel_stream.c` | ACCEL_STREAM |
| `cmd_tap_config.c` | TAP_CONFIG |
| `cmd_tap_notify.c` | TAP_NOTIFY |
| `cmd_cache_status.c` | CACHE_STATUS |
| `cmd_battery.c` | BATTERY_STATUS |
@@ -427,6 +518,8 @@ Includes in generierten `.pb.c` müssen `"uart_messages.pb.h"` heißen (nicht `m
| `cmd_espnow_find_me.c` | FIND_ME |
| `cmd_restart.c` | RESTART |
| `cmd_espnow_unicast_test.c` | ESPNOW_UNICAST_TEST |
| `cmd_espnow_echo_ping.c` | ESPNOW_ECHO_PING |
| `cmd_set_log_level.c` | SET_LOG_LEVEL |
| `cmd_ota.c` | OTA_* |
| `cmd_ota_slave_progress.c` | OTA_SLAVE_PROGRESS |
@@ -437,6 +530,7 @@ Includes in generierten `.pb.c` müssen `"uart_messages.pb.h"` heißen (nicht `m
| `bosch456.c` | Accelerometer + Tap |
| `led_ring.c` | LED-Anzeige |
| `board_input.c` | Taster, ADC |
| `battery_uv.c` | Software-UV-Schutz, Energiesparmodus |
| `pod_settings.c` | NVS |
| `pod_reboot.c` | Verzögerter Restart |
| `ota_uart.c` | Flash-Puffer UART-OTA |
@@ -455,9 +549,12 @@ Includes in generierten `.pb.c` müssen `"uart_messages.pb.h"` heißen (nicht `m
## 15. Logging-Tags
**Level zur Laufzeit:** UART `SET_LOG_LEVEL` (31) oder Dashboard „ESP Log-Level“ — steuert nur die Konsolen-Ausgabe auf UART0, nicht das Host-Protokoll.
| Tag | Modul |
|-----|--------|
| `[Main]` | powerpod.c |
| `[LOG_LVL]` | cmd_set_log_level.c |
| `[UART]` | uart.c |
| `[CMDH]` | cmd_handler.c |
| `[UART_CMD]` | uart_cmd.c |
+2 -2
View File
@@ -287,7 +287,7 @@ Entspricht `main/README.md`:
| Byte | Inhalt |
|------|--------|
| Frame | `0xAA`, Länge, Payload, XOR-Checksum, `0xCC` |
| Frame | `0xAA`, Länge (uint16 LE), Payload, XOR-Checksum, `0xCC` |
| Payload[0] | `MessageType` (z.B. `22` = FIND_ME) |
| Payload[1…] | Nanopb-`UartMessage` |
@@ -345,7 +345,7 @@ idf.py build
Ähnliche Features zum Abgucken:
- **Nur Master, kein ESP-NOW:** `main/cmd/cmd_version.c`, `main/cmd/cmd_led_ring.c`
- **Nur Master, kein ESP-NOW:** `main/cmd/cmd_version.c`, `main/cmd/cmd_led_ring.c`, `main/cmd/cmd_set_log_level.c`
- **Nur Slave per ESP-NOW (Master leitet nur durch):** `main/cmd/cmd_espnow_unicast_test.c`
- **Master + alle Slaves / Filter:** `main/cmd/cmd_accel_deadzone.c`
- **Großer ESP-NOW-Fluss mit Status:** `ota_espnow.c`, `main/cmd/cmd_ota.c`
+2
View File
@@ -0,0 +1,2 @@
win:
GOOS=windows GOARCH=386 go build .
+36 -10
View File
@@ -25,16 +25,21 @@ go run . -port /dev/ttyUSB0 clients
| `version` | `0x03` | Prints `version` and `git_hash` from firmware |
| `clients` | `0x04` | Lists slaves registered on the master via ESP-NOW |
| `deadzone` | `0x06` | Get/set accelerometer deadzone LSB (`-set`, `-value`, `-client`, `-all`) |
| `tap-config` | `0x20` | Get/set BMA456 tap detection params (`-set`, `-client`, `-all`, `-sens`, `-max-gest`, `-shock`, `-quiet`, `-wait`, `-axis`, `-single`, `-double`, `-triple`) |
| `tap-notify` | `0x1b` | Get/set which tap kinds (single/double/triple) notify via ESP-NOW (`-set`, `-client`, `-all`, `-single`, `-double`, `-triple`) |
| `cache-status` | `0x1d` | Subscribed accel + tap cache (`CACHE_STATUS`); one UART round-trip for 16 ms polling |
| `cache-status` | `0x1d` | Subscribed accel + tap cache (`CACHE_STATUS`); one UART round-trip for ~8 ms polling |
| `unicast-test` | `0x07` | Sends ESP-NOW unicast test to one slave (`-client`, `-seq`) |
| `echo-ping` | `0x1e` | ESP-NOW echo round-trip to one slave (`-client`); prints `rtt_ms` (host UART chain) and `esp_rtt_us` (master ESP-NOW, raw µs) |
| `test` | — | Run an automated scenario (JSON configs under `testdata/`) |
| `serve` | — | Web dashboard at `http://localhost:8080` (WebSocket live updates) |
| `ota` | 16–19 | UART firmware upload to master; firmware then pushes to slaves via ESP-NOW |
| `ota-progress` | 21 | Query per-slave ESP-NOW OTA progress on the master (`-client N`, default all) |
| `led-ring` | 8 | LED ring: `-mode clear\|color\|progress\|digit\|blink\|find-me`, `-client`, `-all` |
| `led-ring` | 8 | LED ring: `-mode clear\|color\|progress\|digit\|blink\|find-me\|battery-low`, `-client`, `-all` |
| `find-me` | 22 | Locate pod (`-client 0` master, `>0` slave via ESP-NOW) |
| `restart` | 23 | Reboot master or slave (`-client 0` / `>0`) |
| `log-level` | `0x1f` | Get/set master ESP-IDF log level for tag `"*"` (`-set`, `-level` 0–5); output on UART0 debug, not host UART |
| `battery-uv` | `0x24` | Get/set software LiPo UV protection (`-set -enable`/`-enable=false`, `-client`, `-all`) |
| `input-send-mode` | `0x23` | Get/set slave accel/tap ESP-NOW TX path (`-set -mode 0..3`, `-all` or `-client N`): best_effort/fast/wait/reliable |
`clients` requires slaves to have responded to master discover broadcasts first.
@@ -58,40 +63,44 @@ See [`testdata/README.md`](testdata/README.md) for the JSON schema.
### Web dashboard
Polls the master over UART and pushes state to the browser via WebSocket (Alpine.js + Bootstrap 5).
Starts without a required host link. Connect UART or BLE from the UI (or pre-connect with `-port` / `-ble`). State is pushed to the browser via WebSocket (Alpine.js + Bootstrap 5). OTA works over UART or BLE (BLE needs ATT MTU ≥ 219).
```bash
go run . serve
go run . -port /dev/ttyUSB0 serve
go run . -port /dev/ttyUSB0 serve -addr :8080 -interval 2s
go run . -port /dev/ttyUSB0 serve -api-addr :8081 -accel-interval 16ms
go run . -ble serve
go run . serve -addr :8080 -interval 2s
go run . serve -api-addr :8081 -accel-interval 16ms
make gotool-serve PORT=/dev/ttyUSB0
```
Open [http://localhost:8080](http://localhost:8080) — shows master firmware info and the ESP-NOW client table from `CLIENT_INFO`.
Open [http://localhost:8080](http://localhost:8080) — Host-Verbindung panel (UART ports / BLE scan), master firmware info, ESP-NOW client table.
**Tap (dashboard):** two independent controls per slave:
| Column | Meaning |
|--------|---------|
| Tap-Notify (S/D/T) | Which tap kinds the **slave** sends to the master over ESP-NOW (UART `TAP_NOTIFY`) — does **not** poll UART |
| Tap (An/Aus) | Host **receive**: poll master tap cache (~16 ms) and show last tap for **≥2 s** |
| Tap (An/Aus) | Host **receive**: poll master tap cache (~8 ms) and show last tap for **≥2 s** |
Enable notify first, then turn receive on to see events. Same split as the external WebSocket API (`set_tap_notify` vs `set_tap_stream`).
If the UART device is unplugged or the port disappears, `serve` keeps running and retries on each poll interval; the UI shows **UART off** until the port is available again.
If the host disconnects, `serve` keeps running; the badge shows **off** until you reconnect from the UI or via REST/WS.
### HTTP / WebSocket API
`serve` also listens on **`:8081`** for external programs (`-api-addr`, empty to disable). Same UART as the dashboard.
`serve` also listens on **`:8081`** for external programs (`-api-addr`, empty to disable). Same `managedHost` as the dashboard (UART or BLE).
| Doc | Content |
|-----|---------|
| **[docs/API_WEBSOCKET.md](docs/API_WEBSOCKET.md)** | `ws://…:8081/ws` commands, **`accel` / `tap` push stream** format, dashboard `ws://…:8080/ws` |
| **[docs/API_WEBSOCKET.md](docs/API_WEBSOCKET.md)** | `ws://…:8081/ws` commands and **`input` push stream** (accel + tap) |
| **[docs/API_REST.md](docs/API_REST.md)** | REST on `:8080` (dashboard) and `:8081` (battery, LED, service info) |
CLI:
```bash
go run . -port /dev/ttyUSB0 tap-config
go run . -port /dev/ttyUSB0 tap-config -set -sens 5 -axis 2
go run . -port /dev/ttyUSB0 tap-notify -client 16 -set -single
go run . -port /dev/ttyUSB0 cache-status
```
@@ -103,6 +112,7 @@ go run . -port /dev/ttyUSB0 cache-status
```bash
go run . -port /dev/ttyUSB0 ota build/powerpod.bin
go run . -ble ota build/powerpod.bin
```
Waits for **ready** after start (~30 s erase), sends 200-byte `OTA_PAYLOAD` frames, reads **block_ack** every 4 KiB, then `OTA_END`. The master then distributes to all available slaves (no extra host traffic); **success** is reported only when that finishes. Allow several minutes for large images. Reboot master and slaves to boot the new firmware.
@@ -113,8 +123,24 @@ go run . -port /dev/ttyUSB0 unicast-test -client 16 -seq 42
On success the slave serial log should show `UNICAST TEST OK from master … seq=42`.
```bash
go run . -port /dev/ttyUSB0 echo-ping -client 16
go run . -port /dev/ttyUSB0 log-level
go run . -port /dev/ttyUSB0 log-level -set -level 3
```
`log-level` controls `esp_log_*` on the master (UART0 USB console). The host protocol UART (GPIO 2/3) is unchanged.
Measures latency to one slave. `rtt_ms` is the full host round-trip (UART + ESP-NOW + UART back). `esp_rtt_us` is the master-side ESP-NOW leg only (`esp_timer_get_time()` delta, raw microseconds from firmware).
Example output:
```
echo ping: success=true client_id=16 rtt_ms=49.729 esp_rtt_us=18234
```
`clients` example:
```
clients (2):
[0] id=42 mac=aabbccddeeff ver=1 available=true used=false last_ping=250 last_success_ping=250
+140
View File
@@ -0,0 +1,140 @@
package main
import (
"encoding/json"
"fmt"
"net/http"
"powerpod/gotool/pb"
)
type batteryUvAPIRequest struct {
Write bool `json:"write"`
Enabled bool `json:"enabled"`
ClientID uint32 `json:"client_id"`
AllClients bool `json:"all_clients"`
// SlavesOnly: with all_clients, push to ESP-NOW slaves only (master NVS unchanged).
SlavesOnly bool `json:"slaves_only"`
}
type batteryUvAPIResponse struct {
ClientID uint32 `json:"client_id"`
Success bool `json:"success"`
Enabled bool `json:"enabled"`
Latched bool `json:"latched"`
SlavesUpdated uint32 `json:"slaves_updated"`
Error string `json:"error,omitempty"`
}
func mountBatteryUvAPI(mux *http.ServeMux, link *managedSerial) {
mux.HandleFunc("/api/battery-uv", func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
serveBatteryUvGet(w, r, link)
case http.MethodPost:
serveBatteryUvPost(w, r, link)
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
})
}
func serveBatteryUvGet(w http.ResponseWriter, r *http.Request, link *managedSerial) {
clientID, err := parseUintQuery(r, "client_id", 0)
if err != nil {
writeJSON(w, http.StatusBadRequest, batteryUvAPIResponse{Error: err.Error()})
return
}
resp, err := link.BatteryUv(&pb.BatteryUvRequest{
Write: false,
ClientId: clientID,
})
if err != nil {
writeJSON(w, http.StatusServiceUnavailable, batteryUvAPIResponse{
ClientID: clientID,
Error: err.Error(),
})
return
}
writeJSON(w, http.StatusOK, batteryUvAPIResponse{
ClientID: resp.GetClientId(),
Success: resp.GetSuccess(),
Enabled: resp.GetEnabled(),
Latched: resp.GetLatched(),
SlavesUpdated: resp.GetSlavesUpdated(),
})
}
func serveBatteryUvPost(w http.ResponseWriter, r *http.Request, link *managedSerial) {
var body batteryUvAPIRequest
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, batteryUvAPIResponse{Error: "invalid JSON"})
return
}
if body.AllClients && body.SlavesOnly {
updated, err := applyBatteryUvToSlaves(link, body.Enabled)
if err != nil {
writeJSON(w, http.StatusServiceUnavailable, batteryUvAPIResponse{
Enabled: body.Enabled,
Error: err.Error(),
})
return
}
writeJSON(w, http.StatusOK, batteryUvAPIResponse{
Success: updated > 0,
Enabled: body.Enabled,
SlavesUpdated: updated,
})
return
}
resp, err := link.BatteryUv(&pb.BatteryUvRequest{
Write: true,
Enabled: body.Enabled,
ClientId: body.ClientID,
AllClients: body.AllClients,
})
if err != nil {
writeJSON(w, http.StatusServiceUnavailable, batteryUvAPIResponse{
ClientID: body.ClientID,
Enabled: body.Enabled,
Error: err.Error(),
})
return
}
writeJSON(w, http.StatusOK, batteryUvAPIResponse{
ClientID: resp.GetClientId(),
Success: resp.GetSuccess(),
Enabled: resp.GetEnabled(),
Latched: resp.GetLatched(),
SlavesUpdated: resp.GetSlavesUpdated(),
})
}
func applyBatteryUvToSlaves(link *managedSerial, enabled bool) (uint32, error) {
clients, err := link.listClients()
if err != nil {
return 0, err
}
var updated uint32
for _, c := range clients {
resp, err := link.BatteryUv(&pb.BatteryUvRequest{
Write: true,
Enabled: enabled,
ClientId: c.GetId(),
})
if err != nil {
continue
}
if resp.GetSuccess() {
updated++
}
}
if len(clients) == 0 {
return 0, fmt.Errorf("no slaves registered")
}
if updated == 0 {
return 0, fmt.Errorf("UV protection not applied to any slave")
}
return updated, nil
}
+129
View File
@@ -0,0 +1,129 @@
package main
import (
"encoding/json"
"errors"
"io"
"net/http"
"time"
)
type bleRttAPIRequest struct {
Count *int `json:"count"`
Warmup *int `json:"warmup"`
SettleS *float64 `json:"settle_s"`
}
type bleStreamAPIRequest struct {
ClientID *uint32 `json:"client_id"`
PeriodMs *uint32 `json:"period_ms"`
Count *int `json:"count"`
TolMs *float64 `json:"tol_ms"`
TimeoutS *float64 `json:"timeout_s"`
}
func mountBleTestAPI(mux *http.ServeMux, link *managedHost) {
mux.HandleFunc("/api/ble-rtt", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
serveBleRtt(w, r, link)
})
mux.HandleFunc("/api/ble-stream", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
serveBleStreamTest(w, r, link)
})
}
func requireBLEHost(w http.ResponseWriter, link *managedHost) bool {
if link.Transport() != hostTransportBLE || !link.IsConnected() {
writeJSON(w, http.StatusBadRequest, bleRttResult{
Error: "BLE host required (connect via BLE, DIP8=1)",
})
return false
}
return true
}
func serveBleRtt(w http.ResponseWriter, r *http.Request, link *managedHost) {
if !requireBLEHost(w, link) {
return
}
var body bleRttAPIRequest
if err := json.NewDecoder(r.Body).Decode(&body); err != nil && !errors.Is(err, io.EOF) {
writeJSON(w, http.StatusBadRequest, bleRttResult{Error: "invalid JSON"})
return
}
count, warmup := 50, 5
settle := 0.0
if body.Count != nil {
count = *body.Count
}
if body.Warmup != nil {
warmup = *body.Warmup
}
if body.SettleS != nil {
settle = *body.SettleS
}
if !link.beginPausePoll() {
writeJSON(w, http.StatusConflict, bleRttResult{Error: "host busy"})
return
}
defer link.endPausePoll()
out := measureBleRtt(link, count, warmup, time.Duration(settle*float64(time.Second)))
status := http.StatusOK
if !out.Success {
status = http.StatusBadRequest
}
writeJSON(w, status, out)
}
func serveBleStreamTest(w http.ResponseWriter, r *http.Request, link *managedHost) {
if !requireBLEHost(w, link) {
return
}
var body bleStreamAPIRequest
if err := json.NewDecoder(r.Body).Decode(&body); err != nil && !errors.Is(err, io.EOF) {
writeJSON(w, http.StatusBadRequest, bleStreamResult{Error: "invalid JSON"})
return
}
count := 50
periodMs := uint32(10)
tolMs := 2.0
timeoutS := 15.0
var clientID uint32
if body.Count != nil {
count = *body.Count
}
if body.PeriodMs != nil {
periodMs = *body.PeriodMs
}
if body.TolMs != nil {
tolMs = *body.TolMs
}
if body.TimeoutS != nil {
timeoutS = *body.TimeoutS
}
if body.ClientID != nil {
clientID = *body.ClientID
}
if !link.beginPausePoll() {
writeJSON(w, http.StatusConflict, bleStreamResult{Error: "host busy"})
return
}
defer link.endPausePoll()
out := measureBleStream(link, clientID, periodMs, count, tolMs, timeoutS)
status := http.StatusOK
if !out.Success {
status = http.StatusBadRequest
}
writeJSON(w, status, out)
}
+140
View File
@@ -0,0 +1,140 @@
package main
import (
"encoding/json"
"net/http"
"time"
)
type hostAPIResponse struct {
Transport string `json:"transport"`
Endpoint string `json:"endpoint"`
Connected bool `json:"connected"`
BleName string `json:"ble_name,omitempty"`
Error string `json:"error,omitempty"`
}
type hostConnectRequest struct {
Transport string `json:"transport"` // uart|ble
Port string `json:"port,omitempty"`
Address string `json:"address,omitempty"`
Name string `json:"name,omitempty"`
Baud int `json:"baud,omitempty"`
}
type bleScanRequest struct {
TimeoutS float64 `json:"timeout_s"`
NameFilter string `json:"name_filter"`
}
type bleScanResponse struct {
Devices []bleScanDevice `json:"devices"`
Error string `json:"error,omitempty"`
}
type serialPortsResponse struct {
Ports []string `json:"ports"`
Error string `json:"error,omitempty"`
}
func mountHostAPI(mux *http.ServeMux, link *managedHost) {
mux.HandleFunc("/api/host", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
st := link.HostStatus()
writeJSON(w, http.StatusOK, hostAPIResponse{
Transport: st["transport"].(string),
Endpoint: st["endpoint"].(string),
Connected: st["connected"].(bool),
BleName: st["ble_name"].(string),
})
})
mux.HandleFunc("/api/host/connect", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var body hostConnectRequest
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, hostAPIResponse{Error: "invalid JSON"})
return
}
var err error
switch body.Transport {
case hostTransportUART:
err = link.ConnectUART(body.Port, body.Baud)
case hostTransportBLE:
err = link.ConnectBLE(body.Address, body.Name)
default:
writeJSON(w, http.StatusBadRequest, hostAPIResponse{Error: "transport must be uart or ble"})
return
}
st := link.HostStatus()
resp := hostAPIResponse{
Transport: st["transport"].(string),
Endpoint: st["endpoint"].(string),
Connected: st["connected"].(bool),
BleName: st["ble_name"].(string),
}
if err != nil {
resp.Error = err.Error()
resp.Connected = false
writeJSON(w, http.StatusBadRequest, resp)
return
}
writeJSON(w, http.StatusOK, resp)
})
mux.HandleFunc("/api/host/disconnect", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
_ = link.Disconnect()
st := link.HostStatus()
writeJSON(w, http.StatusOK, hostAPIResponse{
Transport: st["transport"].(string),
Endpoint: st["endpoint"].(string),
Connected: false,
})
})
mux.HandleFunc("/api/serial/ports", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
ports, err := listSerialPorts()
if err != nil {
writeJSON(w, http.StatusOK, serialPortsResponse{Ports: []string{}, Error: err.Error()})
return
}
writeJSON(w, http.StatusOK, serialPortsResponse{Ports: ports})
})
mux.HandleFunc("/api/ble/scan", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var body bleScanRequest
_ = json.NewDecoder(r.Body).Decode(&body)
timeout := time.Duration(body.TimeoutS * float64(time.Second))
if timeout <= 0 {
timeout = 5 * time.Second
}
// Disconnect active BLE before scan (BlueZ adapter contention).
if link.Transport() == hostTransportBLE {
_ = link.Disconnect()
}
devs, err := scanBLEDevices(timeout, body.NameFilter)
if err != nil {
writeJSON(w, http.StatusOK, bleScanResponse{Devices: []bleScanDevice{}, Error: err.Error()})
return
}
writeJSON(w, http.StatusOK, bleScanResponse{Devices: devs})
})
}
+100
View File
@@ -0,0 +1,100 @@
package main
import (
"encoding/json"
"net/http"
pb "powerpod/gotool/pb"
)
type inputSendModeAPIResponse struct {
Success bool `json:"success"`
Mode uint32 `json:"mode"`
ModeName string `json:"mode_name"`
ClientID uint32 `json:"client_id,omitempty"`
SlavesUpdated uint32 `json:"slaves_updated,omitempty"`
Error string `json:"error,omitempty"`
}
func inputSendModeName(mode pb.InputSendMode) string {
switch mode {
case pb.InputSendMode_INPUT_SEND_BEST_EFFORT:
return "best_effort"
case pb.InputSendMode_INPUT_SEND_FAST:
return "fast"
case pb.InputSendMode_INPUT_SEND_WAIT:
return "wait"
case pb.InputSendMode_INPUT_SEND_RELIABLE:
return "reliable"
default:
return "unknown"
}
}
func mountInputSendModeAPI(mux *http.ServeMux, link *managedSerial) {
mux.HandleFunc("/api/input-send-mode", func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
serveInputSendModeGet(w, link)
case http.MethodPut, http.MethodPost:
serveInputSendModePut(w, r, link)
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
})
}
func serveInputSendModeGet(w http.ResponseWriter, link *managedSerial) {
resp, err := link.InputSendMode(&pb.InputSendModeRequest{Write: false})
if err != nil {
writeJSON(w, http.StatusBadGateway, inputSendModeAPIResponse{Error: err.Error()})
return
}
writeJSON(w, http.StatusOK, inputSendModeAPIResponse{
Success: resp.GetSuccess(),
Mode: uint32(resp.GetMode()),
ModeName: inputSendModeName(resp.GetMode()),
})
}
func serveInputSendModePut(w http.ResponseWriter, r *http.Request, link *managedSerial) {
var body struct {
Mode uint32 `json:"mode"`
ClientID uint32 `json:"client_id"`
AllClients bool `json:"all_clients"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, inputSendModeAPIResponse{Error: "invalid JSON"})
return
}
if body.Mode > uint32(pb.InputSendMode_INPUT_SEND_RELIABLE) {
writeJSON(w, http.StatusBadRequest, inputSendModeAPIResponse{Error: "mode must be 0..3"})
return
}
if !body.AllClients && body.ClientID == 0 {
// Dashboard default: apply to every registered slave.
body.AllClients = true
}
resp, err := link.InputSendMode(&pb.InputSendModeRequest{
Write: true,
Mode: pb.InputSendMode(body.Mode),
ClientId: body.ClientID,
AllClients: body.AllClients,
})
if err != nil {
writeJSON(w, http.StatusBadGateway, inputSendModeAPIResponse{Error: err.Error()})
return
}
status := http.StatusOK
if !resp.GetSuccess() {
status = http.StatusBadRequest
}
writeJSON(w, status, inputSendModeAPIResponse{
Success: resp.GetSuccess(),
Mode: uint32(resp.GetMode()),
ModeName: inputSendModeName(resp.GetMode()),
ClientID: resp.GetClientId(),
SlavesUpdated: resp.GetSlavesUpdated(),
})
}
+140 -4
View File
@@ -41,6 +41,19 @@ type unicastAPIResponse struct {
Error string `json:"error,omitempty"`
}
type echoPingAPIRequest struct {
ClientID uint32 `json:"client_id"`
}
type echoPingAPIResponse struct {
Success bool `json:"success"`
ClientID uint32 `json:"client_id,omitempty"`
TimestampUs uint64 `json:"timestamp_us,omitempty"`
RttMs float64 `json:"rtt_ms"`
EspRttUs uint32 `json:"esp_rtt_us"`
Error string `json:"error,omitempty"`
}
type findMeAPIRequest struct {
ClientID uint32 `json:"client_id"`
}
@@ -51,6 +64,11 @@ type findMeAPIResponse struct {
Error string `json:"error,omitempty"`
}
type resetIdMapAPIResponse struct {
Success bool `json:"success"`
Error string `json:"error,omitempty"`
}
type restartAPIRequest struct {
ClientID uint32 `json:"client_id"`
}
@@ -61,17 +79,33 @@ type restartAPIResponse struct {
Error string `json:"error,omitempty"`
}
type logLevelAPIResponse struct {
Success bool `json:"success"`
Level uint32 `json:"level"`
Error string `json:"error,omitempty"`
}
type logLevelAPIRequest struct {
Write bool `json:"write"`
Level uint32 `json:"level"`
}
type otaAPIResponse struct {
Success bool `json:"success"`
BytesWritten uint32 `json:"bytes_written,omitempty"`
TargetSlot uint32 `json:"target_slot,omitempty"`
Error string `json:"error,omitempty"`
Success bool `json:"success"`
BytesWritten uint32 `json:"bytes_written,omitempty"`
TargetSlot uint32 `json:"target_slot,omitempty"`
Error string `json:"error,omitempty"`
}
func mountServeAPI(mux *http.ServeMux, link *managedSerial, hub *wsHub, streamCtl *accelStreamCtl, tapCtl *tapNotifyCtl) {
mountHostAPI(mux, link)
mountBleTestAPI(mux, link)
mountLiveStreamAPI(mux, hub)
mountAccelStreamAPI(mux, link, hub, streamCtl)
mountInputSendModeAPI(mux, link)
mountTapAPI(mux, link, hub, tapCtl)
mountTapConfigAPI(mux, link)
mountBatteryUvAPI(mux, link)
mountLedRingAPI(mux, link)
mountBatteryAPI(mux, link)
mux.HandleFunc("/api/deadzone", func(w http.ResponseWriter, r *http.Request) {
@@ -91,6 +125,13 @@ func mountServeAPI(mux *http.ServeMux, link *managedSerial, hub *wsHub, streamCt
}
serveUnicastTest(w, r, link)
})
mux.HandleFunc("/api/echo-ping", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
serveEchoPing(w, r, link)
})
mux.HandleFunc("/api/find-me", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
@@ -105,6 +146,19 @@ func mountServeAPI(mux *http.ServeMux, link *managedSerial, hub *wsHub, streamCt
}
serveRestart(w, r, link)
})
mux.HandleFunc("POST /api/clients/reset-id-map", func(w http.ResponseWriter, r *http.Request) {
serveResetClientIdMap(w, r, link)
})
mux.HandleFunc("/api/log-level", func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
serveLogLevelGet(w, r, link)
case http.MethodPost:
serveLogLevelPost(w, r, link)
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
})
mux.HandleFunc("/api/ota", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
@@ -115,6 +169,10 @@ func mountServeAPI(mux *http.ServeMux, link *managedSerial, hub *wsHub, streamCt
}
func serveOTAUpload(w http.ResponseWriter, r *http.Request, link *managedSerial, hub *wsHub) {
if !link.IsConnected() {
writeJSON(w, http.StatusBadRequest, otaAPIResponse{Error: "host not connected"})
return
}
if err := r.ParseMultipartForm(otaMaxFirmwareSize); err != nil {
writeJSON(w, http.StatusBadRequest, otaAPIResponse{Error: "invalid form"})
return
@@ -260,6 +318,43 @@ func applyDeadzoneToSlaves(link *managedSerial, deadzone uint32) (uint32, error)
return updated, nil
}
func serveLogLevelGet(w http.ResponseWriter, r *http.Request, link *managedSerial) {
resp, err := link.SetLogLevel(false, 0)
if err != nil {
writeJSON(w, http.StatusServiceUnavailable, logLevelAPIResponse{Error: err.Error()})
return
}
writeJSON(w, http.StatusOK, logLevelAPIResponse{
Success: resp.GetSuccess(),
Level: resp.GetLevel(),
})
}
func serveLogLevelPost(w http.ResponseWriter, r *http.Request, link *managedSerial) {
var body logLevelAPIRequest
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, logLevelAPIResponse{Error: "invalid JSON"})
return
}
if !body.Write {
writeJSON(w, http.StatusBadRequest, logLevelAPIResponse{Error: "write must be true"})
return
}
if body.Level > 5 {
writeJSON(w, http.StatusBadRequest, logLevelAPIResponse{Error: "level must be 0–5"})
return
}
resp, err := link.SetLogLevel(true, body.Level)
if err != nil {
writeJSON(w, http.StatusServiceUnavailable, logLevelAPIResponse{Error: err.Error()})
return
}
writeJSON(w, http.StatusOK, logLevelAPIResponse{
Success: resp.GetSuccess(),
Level: resp.GetLevel(),
})
}
func serveRestart(w http.ResponseWriter, r *http.Request, link *managedSerial) {
var body restartAPIRequest
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
@@ -276,6 +371,23 @@ func serveRestart(w http.ResponseWriter, r *http.Request, link *managedSerial) {
writeJSON(w, http.StatusOK, restartAPIResponse{Success: true, ClientID: body.ClientID})
}
func serveResetClientIdMap(w http.ResponseWriter, r *http.Request, link *managedSerial) {
if !link.IsConnected() {
writeJSON(w, http.StatusServiceUnavailable, resetIdMapAPIResponse{Error: "host not connected"})
return
}
resp, err := link.resetClientIdMap()
if err != nil {
writeJSON(w, http.StatusServiceUnavailable, resetIdMapAPIResponse{Error: err.Error()})
return
}
if !resp.GetSuccess() {
writeJSON(w, http.StatusOK, resetIdMapAPIResponse{Success: false, Error: "rejected by device"})
return
}
writeJSON(w, http.StatusOK, resetIdMapAPIResponse{Success: true})
}
func serveFindMe(w http.ResponseWriter, r *http.Request, link *managedSerial) {
var body findMeAPIRequest
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
@@ -316,6 +428,30 @@ func serveUnicastTest(w http.ResponseWriter, r *http.Request, link *managedSeria
})
}
func serveEchoPing(w http.ResponseWriter, r *http.Request, link *managedSerial) {
var body echoPingAPIRequest
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, echoPingAPIResponse{Error: "invalid JSON"})
return
}
if body.ClientID == 0 {
writeJSON(w, http.StatusBadRequest, echoPingAPIResponse{Error: "client_id required"})
return
}
result, err := link.EchoPing(body.ClientID)
if err != nil {
writeJSON(w, http.StatusServiceUnavailable, echoPingAPIResponse{Error: err.Error()})
return
}
writeJSON(w, http.StatusOK, echoPingAPIResponse{
Success: result.Success,
ClientID: result.ClientID,
TimestampUs: result.TimestampUs,
RttMs: result.RttMs,
EspRttUs: result.EspRttUs,
})
}
func parseUintQuery(r *http.Request, key string, def uint32) (uint32, error) {
s := r.URL.Query().Get(key)
if s == "" {
+242 -92
View File
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"sync"
@@ -15,7 +16,7 @@ import (
)
const (
defaultAccelStreamInterval = 16 * time.Millisecond
defaultAccelStreamInterval = 10 * time.Millisecond
defaultPreFetchMs = 2
minAPIStreamInterval = 1 * time.Millisecond
maxAPIStreamInterval = 10 * time.Second
@@ -31,7 +32,7 @@ type InputClientSample struct {
Y int32 `json:"y,omitempty"`
Z int32 `json:"z,omitempty"`
AccelAgeMs uint32 `json:"accel_age_ms,omitempty"`
TapKind string `json:"tap_kind"`
TapKind string `json:"tap_kind,omitempty"`
TapAgeMs uint32 `json:"tap_age_ms,omitempty"`
}
@@ -39,6 +40,9 @@ type InputClientSample struct {
type InputStreamMessage struct {
Type string `json:"type"` // "hello" | "input"
Serial string `json:"serial_port,omitempty"`
Transport string `json:"transport,omitempty"`
Endpoint string `json:"endpoint,omitempty"`
Connected *bool `json:"connected,omitempty"`
IntervalMs int `json:"interval_ms,omitempty"`
PreFetchMs int `json:"pre_fetch_ms,omitempty"`
TapDisplayMinMs int `json:"tap_display_min_ms,omitempty"`
@@ -51,6 +55,33 @@ type InputStreamMessage struct {
Error string `json:"error,omitempty"`
}
// HostStatusMessage is the reply to get_host / host_connect / host_disconnect.
type HostStatusMessage struct {
Type string `json:"type"` // "host_status"
Transport string `json:"transport"`
Endpoint string `json:"endpoint"`
Connected bool `json:"connected"`
BleName string `json:"ble_name,omitempty"`
Success bool `json:"success"`
Error string `json:"error,omitempty"`
}
// SerialPortsMessage is the reply to list_serial_ports.
type SerialPortsMessage struct {
Type string `json:"type"` // "serial_ports"
Ports []string `json:"ports"`
Success bool `json:"success"`
Error string `json:"error,omitempty"`
}
// BleScanResultMessage is the reply to ble_scan.
type BleScanResultMessage struct {
Type string `json:"type"` // "ble_scan_result"
Devices []bleScanDevice `json:"devices"`
Success bool `json:"success"`
Error string `json:"error,omitempty"`
}
// StreamStatusMessage is the reply to set_stream / get_stream (this connection).
type StreamStatusMessage struct {
Type string `json:"type"` // "stream_status"
@@ -116,12 +147,23 @@ type accelWSCommand struct {
DoubleTap *bool `json:"double_tap"`
Triple *bool `json:"triple"`
AllClients bool `json:"all_clients"`
// Host connect / BLE scan
Transport string `json:"transport"`
Port string `json:"port"`
Address string `json:"address"`
Name string `json:"name"`
Baud int `json:"baud"`
TimeoutS float64 `json:"timeout_s"`
NameFilter string `json:"name_filter"`
}
type APIInfoResponse struct {
Name string `json:"name"`
Version string `json:"version"`
SerialPort string `json:"serial_port"`
Transport string `json:"transport"`
Endpoint string `json:"endpoint"`
Connected bool `json:"connected"`
WebSocket string `json:"websocket"`
DefaultIntervalMs int `json:"default_interval_ms"`
DefaultPreFetchMs int `json:"default_pre_fetch_ms"`
@@ -139,12 +181,31 @@ type cachedTapEvent struct {
type wsSubscriber struct {
conn *websocket.Conn
writeMu sync.Mutex // gorilla: one writer per connection
receiveInput bool
interval time.Duration
preFetch time.Duration
lastInputSent time.Time
}
func (sub *wsSubscriber) writeBytes(data []byte) error {
if sub == nil {
return fmt.Errorf("nil subscriber")
}
sub.writeMu.Lock()
defer sub.writeMu.Unlock()
_ = sub.conn.SetWriteDeadline(time.Now().Add(2 * time.Second))
return sub.conn.WriteMessage(websocket.TextMessage, data)
}
func (sub *wsSubscriber) writeJSON(v any) {
data, err := json.Marshal(v)
if err != nil {
return
}
_ = sub.writeBytes(data)
}
type pendingInputCache struct {
cache *pb.CacheStatusResponse
readAt time.Time
@@ -196,7 +257,7 @@ func clampPreFetch(d time.Duration) time.Duration {
return d
}
func (h *accelStreamHub) register(conn *websocket.Conn, portName string) *wsSubscriber {
func (h *accelStreamHub) register(conn *websocket.Conn, link *managedSerial) *wsSubscriber {
sub := &wsSubscriber{
conn: conn,
receiveInput: false,
@@ -207,14 +268,20 @@ func (h *accelStreamHub) register(conn *websocket.Conn, portName string) *wsSubs
h.clients[conn] = sub
h.mu.Unlock()
st := link.HostStatus()
connected := st["connected"].(bool)
hello := InputStreamMessage{
Type: "hello",
Serial: portName,
Serial: st["endpoint"].(string),
Transport: st["transport"].(string),
Endpoint: st["endpoint"].(string),
Connected: &connected,
IntervalMs: int(h.defaultInterval / time.Millisecond),
PreFetchMs: int(h.defaultPreFetch / time.Millisecond),
TapDisplayMinMs: apiTapDisplayMinMs,
Note: "set_tap_notify configures slave S/D/T only; set_stream enables input polling/push on this connection",
Commands: []string{
"get_host", "list_serial_ports", "ble_scan", "host_connect", "host_disconnect",
"list_clients",
"set_stream", "get_stream",
"set_input_stream", "get_input_stream",
@@ -222,9 +289,7 @@ func (h *accelStreamHub) register(conn *websocket.Conn, portName string) *wsSubs
"set_led_ring", "get_battery",
},
}
if data, err := json.Marshal(hello); err == nil {
_ = conn.WriteMessage(websocket.TextMessage, data)
}
sub.writeJSON(hello)
return sub
}
@@ -389,7 +454,6 @@ func (h *accelStreamHub) inputClientsFromCacheLocked(cache *pb.CacheStatusRespon
for _, c := range cache.GetClients() {
sample := InputClientSample{
ClientID: c.GetClientId(),
TapKind: "none",
}
if a := c.GetAccel(); a != nil {
sample.Valid = a.GetValid()
@@ -421,7 +485,11 @@ func (h *accelStreamHub) deliverInput(msg InputStreamMessage) {
now := time.Now()
h.mu.Lock()
defer h.mu.Unlock()
type pending struct {
conn *websocket.Conn
sub *wsSubscriber
}
var out []pending
for conn, sub := range h.clients {
if !sub.receiveInput {
continue
@@ -430,13 +498,26 @@ func (h *accelStreamHub) deliverInput(msg InputStreamMessage) {
continue
}
sub.lastInputSent = now
if err := conn.WriteMessage(websocket.TextMessage, data); err != nil {
delete(h.clients, conn)
_ = conn.Close()
out = append(out, pending{conn: conn, sub: sub})
}
h.mu.Unlock()
for _, p := range out {
if err := p.sub.writeBytes(data); err != nil {
h.mu.Lock()
delete(h.clients, p.conn)
h.mu.Unlock()
_ = p.conn.Close()
}
}
}
func (h *accelStreamHub) clientsFromCache(cache *pb.CacheStatusResponse) []InputClientSample {
h.mu.Lock()
defer h.mu.Unlock()
return h.inputClientsFromCacheLocked(cache, time.Now())
}
func runInputStreamer(link *managedSerial, hub *accelStreamHub, dash *wsHub, ctl *accelStreamCtl, tapCtl *tapNotifyCtl, stop <-chan struct{}) {
ticker := time.NewTicker(minAPIStreamInterval)
defer ticker.Stop()
@@ -450,11 +531,31 @@ func runInputStreamer(link *managedSerial, hub *accelStreamHub, dash *wsHub, ctl
case <-hub.configChanged:
pending = nil
case now := <-ticker.C:
if !hub.anyWantsInput() || !inputPollingActive(dash, ctl, tapCtl) {
if !hub.anyWantsInput() || !inputPollingActive(dash, ctl, tapCtl) || link.PollPaused() {
pending = nil
continue
}
// Prefer BLE_STREAM_TICK when on BLE (firmware push).
if link.Transport() == hostTransportBLE {
payload, err := link.NextStreamTick(20 * time.Millisecond)
if err == nil {
cache, cerr := cacheStatusFromBLEStreamPayload(payload)
if cerr == nil {
hub.ingestTapFromCache(cache)
hub.deliverInput(InputStreamMessage{
Type: "input",
T: time.Now().UnixNano(),
Success: true,
Clients: hub.clientsFromCache(cache),
})
pending = nil
continue
}
}
// Fall through to CACHE_STATUS poll if no tick yet.
}
needRead, needDeliver, waitPreFetch := hub.streamTiming(now)
if needRead && pending == nil {
@@ -488,14 +589,11 @@ func runInputStreamer(link *managedSerial, hub *accelStreamHub, dash *wsHub, ctl
}
if pending.cache != nil && now.Sub(pending.readAt) >= waitPreFetch {
hub.mu.RLock()
clients := hub.inputClientsFromCacheLocked(pending.cache, now)
hub.mu.RUnlock()
hub.deliverInput(InputStreamMessage{
Type: "input",
T: ts,
Success: true,
Clients: clients,
Clients: hub.clientsFromCache(pending.cache),
})
pending = nil
}
@@ -513,46 +611,29 @@ func inputPollingActive(dash *wsHub, ctl *accelStreamCtl, tapCtl *tapNotifyCtl)
return dash != nil && dash.anyAccelStreamEnabled()
}
func writeStreamStatus(conn *websocket.Conn, msg StreamStatusMessage) {
data, err := json.Marshal(msg)
if err != nil {
return
}
_ = conn.WriteMessage(websocket.TextMessage, data)
func writeStreamStatus(sub *wsSubscriber, msg StreamStatusMessage) {
sub.writeJSON(msg)
}
func writeBatteryStatus(conn *websocket.Conn, out batteryAPIResponse) {
func writeBatteryStatus(sub *wsSubscriber, out batteryAPIResponse) {
out.Type = "battery_status"
data, err := json.Marshal(out)
if err != nil {
return
}
_ = conn.WriteMessage(websocket.TextMessage, data)
sub.writeJSON(out)
}
func writeLedRingStatus(conn *websocket.Conn, out ledRingAPIResponse) {
func writeLedRingStatus(sub *wsSubscriber, out ledRingAPIResponse) {
out.Type = "led_ring_status"
data, err := json.Marshal(out)
if err != nil {
return
}
_ = conn.WriteMessage(websocket.TextMessage, data)
sub.writeJSON(out)
}
func writeInputStreamStatus(conn *websocket.Conn, out accelStreamAPIResponse) {
msg := InputStreamStatusMessage{
func writeInputStreamStatus(sub *wsSubscriber, out accelStreamAPIResponse) {
sub.writeJSON(InputStreamStatusMessage{
Type: "input_stream_status",
ClientID: out.ClientID,
Enabled: out.Enabled,
Success: out.Success,
SlavesUpdated: out.SlavesUpdated,
Error: out.Error,
}
data, err := json.Marshal(msg)
if err != nil {
return
}
_ = conn.WriteMessage(websocket.TextMessage, data)
})
}
func clientInfoToAPI(c *pb.ClientInfo) APIClientInfo {
@@ -571,16 +652,12 @@ func clientInfoToAPI(c *pb.ClientInfo) APIClientInfo {
}
}
func writeClientList(conn *websocket.Conn, msg ClientListMessage) {
data, err := json.Marshal(msg)
if err != nil {
return
}
_ = conn.WriteMessage(websocket.TextMessage, data)
func writeClientList(sub *wsSubscriber, msg ClientListMessage) {
sub.writeJSON(msg)
}
func writeTapNotifyStatus(conn *websocket.Conn, out tapNotifyAPIResponse) {
msg := TapNotifyStatusMessage{
func writeTapNotifyStatus(sub *wsSubscriber, out tapNotifyAPIResponse) {
sub.writeJSON(TapNotifyStatusMessage{
Type: "tap_notify_status",
ClientID: out.ClientID,
Single: out.Single,
@@ -589,12 +666,7 @@ func writeTapNotifyStatus(conn *websocket.Conn, out tapNotifyAPIResponse) {
Success: out.Success,
SlavesUpdated: out.SlavesUpdated,
Error: out.Error,
}
data, err := json.Marshal(msg)
if err != nil {
return
}
_ = conn.WriteMessage(websocket.TextMessage, data)
})
}
func applyTapNotifyClientWS(link *managedSerial, dash *wsHub, tapCtl *tapNotifyCtl, clientID uint32, single, doubleTap, triple bool) tapNotifyAPIResponse {
@@ -627,18 +699,70 @@ func applyTapNotifyClientWS(link *managedSerial, dash *wsHub, tapCtl *tapNotifyC
return out
}
func handleAccelWSCommand(conn *websocket.Conn, sub *wsSubscriber, data []byte, link *managedSerial, dash *wsHub, ctl *accelStreamCtl, tapCtl *tapNotifyCtl, hub *accelStreamHub) {
func handleAccelWSCommand(sub *wsSubscriber, data []byte, link *managedSerial, dash *wsHub, ctl *accelStreamCtl, tapCtl *tapNotifyCtl, hub *accelStreamHub) {
var cmd accelWSCommand
if err := json.Unmarshal(data, &cmd); err != nil {
writeStreamStatus(conn, StreamStatusMessage{Type: "stream_status", Error: "invalid JSON"})
writeStreamStatus(sub, StreamStatusMessage{Type: "stream_status", Error: "invalid JSON"})
return
}
switch cmd.Type {
case "get_host":
writeHostStatus(sub, hostStatusFromLink(link, true, ""))
case "list_serial_ports":
ports, err := listSerialPorts()
msg := SerialPortsMessage{Type: "serial_ports", Ports: ports, Success: err == nil}
if err != nil {
msg.Error = err.Error()
msg.Ports = []string{}
}
writeWSJSON(sub, msg)
case "ble_scan":
timeout := time.Duration(cmd.TimeoutS * float64(time.Second))
if timeout <= 0 {
timeout = 5 * time.Second
}
if link.Transport() == hostTransportBLE {
_ = link.Disconnect()
}
devs, err := scanBLEDevices(timeout, cmd.NameFilter)
msg := BleScanResultMessage{Type: "ble_scan_result", Devices: devs, Success: err == nil}
if err != nil {
msg.Error = err.Error()
msg.Devices = []bleScanDevice{}
}
writeWSJSON(sub, msg)
case "host_connect":
var err error
switch cmd.Transport {
case hostTransportUART:
err = link.ConnectUART(cmd.Port, cmd.Baud)
case hostTransportBLE:
err = link.ConnectBLE(cmd.Address, cmd.Name)
default:
writeHostStatus(sub, HostStatusMessage{
Type: "host_status",
Error: "transport must be uart or ble",
})
return
}
if err != nil {
writeHostStatus(sub, hostStatusFromLink(link, false, err.Error()))
return
}
writeHostStatus(sub, hostStatusFromLink(link, true, ""))
case "host_disconnect":
_ = link.Disconnect()
writeHostStatus(sub, hostStatusFromLink(link, true, ""))
case "list_clients":
clients, err := link.listClientsPoll()
if err != nil {
writeClientList(conn, ClientListMessage{
writeClientList(sub, ClientListMessage{
Type: "client_list",
Error: err.Error(),
})
@@ -648,7 +772,7 @@ func handleAccelWSCommand(conn *websocket.Conn, sub *wsSubscriber, data []byte,
for _, c := range clients {
out = append(out, clientInfoToAPI(c))
}
writeClientList(conn, ClientListMessage{
writeClientList(sub, ClientListMessage{
Type: "client_list",
Success: true,
Clients: out,
@@ -656,34 +780,34 @@ func handleAccelWSCommand(conn *websocket.Conn, sub *wsSubscriber, data []byte,
case "set_stream":
if cmd.Enable == nil {
writeStreamStatus(conn, StreamStatusMessage{
writeStreamStatus(sub, StreamStatusMessage{
Type: "stream_status",
Error: "enable required",
})
return
}
writeStreamStatus(conn, hub.setStream(sub, *cmd.Enable, cmd.IntervalMs, cmd.PreFetch))
writeStreamStatus(sub, hub.setStream(sub, *cmd.Enable, cmd.IntervalMs, cmd.PreFetch))
case "get_stream":
writeStreamStatus(conn, hub.getStream(sub))
writeStreamStatus(sub, hub.getStream(sub))
case "set_input_stream":
if cmd.ClientID == 0 {
writeInputStreamStatus(conn, accelStreamAPIResponse{Error: "client_id required"})
writeInputStreamStatus(sub, accelStreamAPIResponse{Error: "client_id required"})
return
}
if cmd.Enable == nil {
writeInputStreamStatus(conn, accelStreamAPIResponse{
writeInputStreamStatus(sub, accelStreamAPIResponse{
ClientID: cmd.ClientID,
Error: "enable required",
})
return
}
writeInputStreamStatus(conn, applyAccelStreamClient(link, dash, ctl, cmd.ClientID, *cmd.Enable))
writeInputStreamStatus(sub, applyAccelStreamClient(link, dash, ctl, cmd.ClientID, *cmd.Enable))
case "get_input_stream":
if cmd.ClientID == 0 {
writeInputStreamStatus(conn, accelStreamAPIResponse{Error: "client_id required"})
writeInputStreamStatus(sub, accelStreamAPIResponse{Error: "client_id required"})
return
}
resp, err := link.AccelStreamPoll(&pb.AccelStreamRequest{
@@ -691,7 +815,7 @@ func handleAccelWSCommand(conn *websocket.Conn, sub *wsSubscriber, data []byte,
ClientId: cmd.ClientID,
})
if err != nil {
writeInputStreamStatus(conn, accelStreamAPIResponse{
writeInputStreamStatus(sub, accelStreamAPIResponse{
ClientID: cmd.ClientID,
Error: err.Error(),
})
@@ -700,7 +824,7 @@ func handleAccelWSCommand(conn *websocket.Conn, sub *wsSubscriber, data []byte,
if ctl != nil {
ctl.Set(cmd.ClientID, resp.GetEnabled())
}
writeInputStreamStatus(conn, accelStreamAPIResponse{
writeInputStreamStatus(sub, accelStreamAPIResponse{
Enabled: resp.GetEnabled(),
ClientID: resp.GetClientId(),
Success: resp.GetSuccess(),
@@ -709,15 +833,15 @@ func handleAccelWSCommand(conn *websocket.Conn, sub *wsSubscriber, data []byte,
case "set_tap_notify":
if cmd.AllClients {
if cmd.Single == nil || cmd.DoubleTap == nil || cmd.Triple == nil {
writeTapNotifyStatus(conn, tapNotifyAPIResponse{Error: "single, double_tap, triple required"})
writeTapNotifyStatus(sub, tapNotifyAPIResponse{Error: "single, double_tap, triple required"})
return
}
updated, err := applyTapNotifyAll(link, dash, tapCtl, *cmd.Single, *cmd.DoubleTap, *cmd.Triple)
if err != nil {
writeTapNotifyStatus(conn, tapNotifyAPIResponse{Error: err.Error()})
writeTapNotifyStatus(sub, tapNotifyAPIResponse{Error: err.Error()})
return
}
writeTapNotifyStatus(conn, tapNotifyAPIResponse{
writeTapNotifyStatus(sub, tapNotifyAPIResponse{
Success: updated > 0,
SlavesUpdated: updated,
Single: *cmd.Single,
@@ -727,21 +851,21 @@ func handleAccelWSCommand(conn *websocket.Conn, sub *wsSubscriber, data []byte,
return
}
if cmd.ClientID == 0 {
writeTapNotifyStatus(conn, tapNotifyAPIResponse{Error: "client_id required"})
writeTapNotifyStatus(sub, tapNotifyAPIResponse{Error: "client_id required"})
return
}
if cmd.Single == nil || cmd.DoubleTap == nil || cmd.Triple == nil {
writeTapNotifyStatus(conn, tapNotifyAPIResponse{
writeTapNotifyStatus(sub, tapNotifyAPIResponse{
ClientID: cmd.ClientID,
Error: "single, double_tap, triple required",
})
return
}
writeTapNotifyStatus(conn, applyTapNotifyClientWS(link, dash, tapCtl, cmd.ClientID, *cmd.Single, *cmd.DoubleTap, *cmd.Triple))
writeTapNotifyStatus(sub, applyTapNotifyClientWS(link, dash, tapCtl, cmd.ClientID, *cmd.Single, *cmd.DoubleTap, *cmd.Triple))
case "get_tap_notify":
if cmd.ClientID == 0 {
writeTapNotifyStatus(conn, tapNotifyAPIResponse{Error: "client_id required"})
writeTapNotifyStatus(sub, tapNotifyAPIResponse{Error: "client_id required"})
return
}
resp, err := link.TapNotifyPoll(&pb.TapNotifyRequest{
@@ -749,7 +873,7 @@ func handleAccelWSCommand(conn *websocket.Conn, sub *wsSubscriber, data []byte,
ClientId: cmd.ClientID,
})
if err != nil {
writeTapNotifyStatus(conn, tapNotifyAPIResponse{
writeTapNotifyStatus(sub, tapNotifyAPIResponse{
ClientID: cmd.ClientID,
Error: err.Error(),
})
@@ -758,7 +882,7 @@ func handleAccelWSCommand(conn *websocket.Conn, sub *wsSubscriber, data []byte,
if tapCtl != nil {
tapCtl.Set(cmd.ClientID, resp.GetSingle(), resp.GetDoubleTap(), resp.GetTriple())
}
writeTapNotifyStatus(conn, tapNotifyAPIResponse{
writeTapNotifyStatus(sub, tapNotifyAPIResponse{
ClientID: cmd.ClientID,
Success: resp.GetSuccess(),
Single: resp.GetSingle(),
@@ -769,36 +893,57 @@ func handleAccelWSCommand(conn *websocket.Conn, sub *wsSubscriber, data []byte,
case "set_led_ring":
var body ledRingAPIRequest
if err := json.Unmarshal(data, &body); err != nil {
writeLedRingStatus(conn, ledRingAPIResponse{Error: "invalid JSON"})
writeLedRingStatus(sub, ledRingAPIResponse{Error: "invalid JSON"})
return
}
if body.Mode == "" {
writeLedRingStatus(conn, ledRingAPIResponse{Error: "mode required"})
writeLedRingStatus(sub, ledRingAPIResponse{Error: "mode required"})
return
}
writeLedRingStatus(conn, applyLedRing(link, body))
writeLedRingStatus(sub, applyLedRing(link, body))
case "get_battery":
var body batteryAPIRequest
if err := json.Unmarshal(data, &body); err != nil {
writeBatteryStatus(conn, batteryAPIResponse{Error: "invalid JSON"})
writeBatteryStatus(sub, batteryAPIResponse{Error: "invalid JSON"})
return
}
if !body.AllClients && body.ClientID == 0 {
body.AllClients = true
}
writeBatteryStatus(conn, applyBatteryStatus(link, body))
writeBatteryStatus(sub, applyBatteryStatus(link, body))
default:
writeStreamStatus(conn, StreamStatusMessage{
writeStreamStatus(sub, StreamStatusMessage{
Type: "stream_status",
Error: "unknown type (list_clients, set_stream, get_stream, set_input_stream, get_input_stream, set_tap_notify, get_tap_notify, set_led_ring, get_battery)",
Error: "unknown type (get_host, list_serial_ports, ble_scan, host_connect, host_disconnect, list_clients, set_stream, get_stream, set_input_stream, get_input_stream, set_tap_notify, get_tap_notify, set_led_ring, get_battery)",
})
}
}
func serveExternalWS(conn *websocket.Conn, link *managedSerial, dash *wsHub, ctl *accelStreamCtl, tapCtl *tapNotifyCtl, portName string, hub *accelStreamHub) {
sub := hub.register(conn, portName)
func hostStatusFromLink(link *managedSerial, success bool, errMsg string) HostStatusMessage {
st := link.HostStatus()
return HostStatusMessage{
Type: "host_status",
Transport: st["transport"].(string),
Endpoint: st["endpoint"].(string),
Connected: st["connected"].(bool),
BleName: st["ble_name"].(string),
Success: success && errMsg == "",
Error: errMsg,
}
}
func writeHostStatus(sub *wsSubscriber, msg HostStatusMessage) {
sub.writeJSON(msg)
}
func writeWSJSON(sub *wsSubscriber, msg any) {
sub.writeJSON(msg)
}
func serveExternalWS(conn *websocket.Conn, link *managedSerial, dash *wsHub, ctl *accelStreamCtl, tapCtl *tapNotifyCtl, hub *accelStreamHub) {
sub := hub.register(conn, link)
defer hub.unregister(conn)
defer conn.Close()
@@ -807,11 +952,12 @@ func serveExternalWS(conn *websocket.Conn, link *managedSerial, dash *wsHub, ctl
if err != nil {
return
}
handleAccelWSCommand(conn, sub, data, link, dash, ctl, tapCtl, hub)
handleAccelWSCommand(sub, data, link, dash, ctl, tapCtl, hub)
}
}
func mountExternalAPI(mux *http.ServeMux, portName string, defaultInterval time.Duration, hub *accelStreamHub, link *managedSerial, dash *wsHub, ctl *accelStreamCtl, tapCtl *tapNotifyCtl) {
_ = portName
defMs := int(defaultInterval / time.Millisecond)
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" && r.URL.Path != "/api/v1" && r.URL.Path != "/api/v1/" {
@@ -822,17 +968,21 @@ func mountExternalAPI(mux *http.ServeMux, portName string, defaultInterval time.
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
st := link.HostStatus()
writeJSON(w, http.StatusOK, APIInfoResponse{
Name: "powerpod-external-api",
Version: "1",
SerialPort: portName,
SerialPort: st["endpoint"].(string),
Transport: st["transport"].(string),
Endpoint: st["endpoint"].(string),
Connected: st["connected"].(bool),
WebSocket: "/ws",
DefaultIntervalMs: defMs,
DefaultPreFetchMs: defaultPreFetchMs,
MinIntervalMs: int(minAPIStreamInterval / time.Millisecond),
MaxIntervalMs: int(maxAPIStreamInterval / time.Millisecond),
TapDisplayMinMs: apiTapDisplayMinMs,
Description: "WebSocket: set_input_stream + set_stream for input (accel + tap); set_tap_notify configures slave tap kinds",
Description: "WebSocket: host_connect/ble_scan + set_input_stream + set_stream for input (accel + tap)",
})
})
@@ -842,7 +992,7 @@ func mountExternalAPI(mux *http.ServeMux, portName string, defaultInterval time.
log.Printf("api websocket upgrade: %v", err)
return
}
serveExternalWS(conn, link, dash, ctl, tapCtl, portName, hub)
serveExternalWS(conn, link, dash, ctl, tapCtl, hub)
})
}
+171
View File
@@ -0,0 +1,171 @@
package main
import (
"encoding/json"
"fmt"
"net/http"
"powerpod/gotool/pb"
)
type tapConfigFields struct {
TapSensThres uint32 `json:"tap_sens_thres"`
MaxGestDur uint32 `json:"max_gest_dur"`
TapShockDur uint32 `json:"tap_shock_dur"`
QuiteTimeAfterGest uint32 `json:"quite_time_after_gest"`
WaitForTimeout uint32 `json:"wait_for_timeout"`
AxisSel uint32 `json:"axis_sel"`
EnableSingle bool `json:"enable_single"`
EnableDouble bool `json:"enable_double"`
EnableTriple bool `json:"enable_triple"`
}
type tapConfigAPIResponse struct {
tapConfigFields
ClientID uint32 `json:"client_id"`
Success bool `json:"success"`
SlavesUpdated uint32 `json:"slaves_updated"`
Error string `json:"error,omitempty"`
}
type tapConfigAPIRequest struct {
Write bool `json:"write"`
ClientID uint32 `json:"client_id"`
AllClients bool `json:"all_clients"`
// SlavesOnly: with all_clients, push to ESP-NOW slaves only (master BMA456 unchanged).
SlavesOnly bool `json:"slaves_only"`
tapConfigFields
}
func tapConfigFieldsFromPB(r *pb.TapConfigResponse) tapConfigFields {
return tapConfigFields{
TapSensThres: r.GetTapSensThres(),
MaxGestDur: r.GetMaxGestDur(),
TapShockDur: r.GetTapShockDur(),
QuiteTimeAfterGest: r.GetQuiteTimeAfterGest(),
WaitForTimeout: r.GetWaitForTimeout(),
AxisSel: r.GetAxisSel(),
EnableSingle: r.GetEnableSingle(),
EnableDouble: r.GetEnableDouble(),
EnableTriple: r.GetEnableTriple(),
}
}
func (f tapConfigFields) toPBRequest(write bool, clientID uint32, all bool) *pb.TapConfigRequest {
return &pb.TapConfigRequest{
Write: write,
ClientId: clientID,
AllClients: all,
TapSensThres: f.TapSensThres,
MaxGestDur: f.MaxGestDur,
TapShockDur: f.TapShockDur,
QuiteTimeAfterGest: f.QuiteTimeAfterGest,
WaitForTimeout: f.WaitForTimeout,
AxisSel: f.AxisSel,
EnableSingle: f.EnableSingle,
EnableDouble: f.EnableDouble,
EnableTriple: f.EnableTriple,
}
}
func mountTapConfigAPI(mux *http.ServeMux, link *managedSerial) {
mux.HandleFunc("/api/tap-config", func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
serveTapConfigGet(w, r, link)
case http.MethodPost:
serveTapConfigPost(w, r, link)
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
})
}
func serveTapConfigGet(w http.ResponseWriter, r *http.Request, link *managedSerial) {
clientID, err := parseUintQuery(r, "client_id", 0)
if err != nil {
writeJSON(w, http.StatusBadRequest, tapConfigAPIResponse{Error: err.Error()})
return
}
resp, err := link.TapConfig(&pb.TapConfigRequest{
Write: false,
ClientId: clientID,
})
if err != nil {
writeJSON(w, http.StatusServiceUnavailable, tapConfigAPIResponse{
ClientID: clientID,
Error: err.Error(),
})
return
}
out := tapConfigAPIResponse{
tapConfigFields: tapConfigFieldsFromPB(resp),
ClientID: resp.GetClientId(),
Success: resp.GetSuccess(),
}
writeJSON(w, http.StatusOK, out)
}
func serveTapConfigPost(w http.ResponseWriter, r *http.Request, link *managedSerial) {
var body tapConfigAPIRequest
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, tapConfigAPIResponse{Error: "invalid JSON"})
return
}
if body.AllClients && body.SlavesOnly {
updated, err := applyTapConfigToSlaves(link, body.tapConfigFields)
if err != nil {
writeJSON(w, http.StatusServiceUnavailable, tapConfigAPIResponse{
tapConfigFields: body.tapConfigFields,
Error: err.Error(),
})
return
}
writeJSON(w, http.StatusOK, tapConfigAPIResponse{
tapConfigFields: body.tapConfigFields,
Success: updated > 0,
SlavesUpdated: updated,
})
return
}
resp, err := link.TapConfig(body.tapConfigFields.toPBRequest(true, body.ClientID, body.AllClients))
if err != nil {
writeJSON(w, http.StatusServiceUnavailable, tapConfigAPIResponse{
ClientID: body.ClientID,
tapConfigFields: body.tapConfigFields,
Error: err.Error(),
})
return
}
writeJSON(w, http.StatusOK, tapConfigAPIResponse{
tapConfigFields: tapConfigFieldsFromPB(resp),
ClientID: resp.GetClientId(),
Success: resp.GetSuccess(),
SlavesUpdated: resp.GetSlavesUpdated(),
})
}
func applyTapConfigToSlaves(link *managedSerial, fields tapConfigFields) (uint32, error) {
clients, err := link.listClients()
if err != nil {
return 0, err
}
var updated uint32
for _, c := range clients {
resp, err := link.TapConfig(fields.toPBRequest(true, c.GetId(), false))
if err != nil {
continue
}
if resp.GetSuccess() {
updated++
}
}
if len(clients) == 0 {
return 0, fmt.Errorf("no slaves registered")
}
if updated == 0 {
return 0, fmt.Errorf("tap config not applied to any slave")
}
return updated, nil
}
+4 -7
View File
@@ -84,13 +84,7 @@ func NewBench(cfg Config) (*Bench, error) {
return nil, fmt.Errorf("config %q slave %q: %w", cfg.ID, s.ID, err)
}
s.MAC = mac
if s.ClientID == nil {
parts := strings.Split(mac, ":")
var last byte
fmt.Sscanf(parts[5], "%02x", &last)
id := uint(last)
s.ClientID = &id
}
/* client_id is master-assigned (sequential); omit until known from `gotool clients`. */
b.slaveByID[s.ID] = s
}
return b, nil
@@ -112,6 +106,9 @@ func (b *Bench) ResolveClientID(slaveID string) (uint32, error) {
if err != nil {
return 0, err
}
if s.ClientID == nil {
return 0, fmt.Errorf("slave %q: client_id required (master-assigned; set in config after join)", slaveID)
}
return uint32(*s.ClientID), nil
}
+34 -3
View File
@@ -2,7 +2,7 @@ package autotest
import "testing"
func TestNewBenchClientIDFromMAC(t *testing.T) {
func TestNewBenchNoDefaultClientIDFromMAC(t *testing.T) {
cfg := Config{
ID: "t",
Network: 1,
@@ -20,8 +20,39 @@ func TestNewBenchClientIDFromMAC(t *testing.T) {
t.Fatal(err)
}
s, _ := b.Slave("pod")
if *s.ClientID != 16 {
t.Fatalf("client_id=%d want 16", *s.ClientID)
if s.ClientID != nil {
t.Fatalf("client_id should be unset, got %d", *s.ClientID)
}
if _, err := b.ResolveClientID("pod"); err == nil {
t.Fatal("ResolveClientID should fail without client_id")
}
}
func TestNewBenchExplicitClientID(t *testing.T) {
id := uint(3)
cfg := Config{
ID: "t",
Network: 1,
MasterMAC: "aa:bb:cc:dd:ee:ff",
UART: UARTConfig{
Master: "/dev/ttyUSB0",
},
Slaves: []SlaveNode{{
ID: "pod",
MAC: "50:78:7d:18:01:10",
ClientID: &id,
}},
}
b, err := NewBench(cfg)
if err != nil {
t.Fatal(err)
}
got, err := b.ResolveClientID("pod")
if err != nil {
t.Fatal(err)
}
if got != 3 {
t.Fatalf("client_id=%d want 3", got)
}
}
+3 -1
View File
@@ -195,7 +195,7 @@ func checkClients(bench *Bench, step Step, client MasterClient) error {
if found == nil {
return fmt.Errorf("slave %q mac %s not in client list", name, want.MAC)
}
if uint32(*want.ClientID) != found.GetId() {
if want.ClientID != nil && uint32(*want.ClientID) != found.GetId() {
return fmt.Errorf("slave %q id=%d want client_id=%d", name, found.GetId(), *want.ClientID)
}
if e.Available != nil && found.GetAvailable() != *e.Available {
@@ -347,6 +347,8 @@ func ledRingModeValue(mode string) (uint32, error) {
return 3, nil
case "find_me", "findme":
return 4, nil
case "battery_low", "batterylow":
return 6, nil
case "color", "solid", "fill":
return 5, nil
default:
+1
View File
@@ -12,6 +12,7 @@ func TestLedRingModeValue(t *testing.T) {
{"digit", 2},
{"blink", 3},
{"find-me", 4},
{"battery-low", 6},
}
for _, tc := range tests {
got, err := ledRingModeValue(tc.mode)
+367
View File
@@ -0,0 +1,367 @@
package main
import (
"fmt"
"log"
"sync"
"time"
"tinygo.org/x/bluetooth"
pb "powerpod/gotool/pb"
uartframe "powerpod/gotool/uart"
)
const (
bleServiceUUID = "6e400001-b5a3-f393-e0a9-e50e24dcca9e"
bleCharUUID = "6e400002-b5a3-f393-e0a9-e50e24dcca9e"
bleDefaultName = "Powerpod"
)
type bleLink struct {
device bluetooth.Device
char bluetooth.DeviceCharacteristic
mu sync.Mutex
parserMu sync.Mutex
parser *uartframe.Parser
respCh chan []byte
streamCh chan []byte
quiet bool
attMTU int // negotiated ATT MTU (0 if unknown)
}
func openBLE(name string, settle time.Duration) (*bleLink, error) {
if name == "" {
name = bleDefaultName
}
adapter := bluetooth.DefaultAdapter
if err := adapter.Enable(); err != nil {
return nil, fmt.Errorf("enable bluetooth adapter: %w (check BlueZ / permissions)", err)
}
log.Printf("BLE scanning for %q…", name)
var foundAddr bluetooth.Address
var found bool
go func() {
time.Sleep(15 * time.Second)
_ = adapter.StopScan()
}()
err := adapter.Scan(func(a *bluetooth.Adapter, result bluetooth.ScanResult) {
if result.LocalName() != name {
return
}
foundAddr = result.Address
found = true
_ = a.StopScan()
})
if err != nil {
return nil, fmt.Errorf("scan: %w", err)
}
if !found {
return nil, fmt.Errorf("device %q not found (check DIP8=BLE and advertising)", name)
}
return connectBLEDevice(foundAddr, settle)
}
func openBLEAddress(addrStr string, settle time.Duration) (*bleLink, error) {
adapter := bluetooth.DefaultAdapter
if err := adapter.Enable(); err != nil {
return nil, fmt.Errorf("enable bluetooth adapter: %w (check BlueZ / permissions)", err)
}
if mac, err := bluetooth.ParseMAC(addrStr); err == nil {
var addr bluetooth.Address
addr.Set(mac.String())
return connectBLEDevice(addr, settle)
}
// Fallback: scan until address string matches
log.Printf("BLE resolving address %s via scan…", addrStr)
var foundAddr bluetooth.Address
var found bool
go func() {
time.Sleep(15 * time.Second)
_ = adapter.StopScan()
}()
err := adapter.Scan(func(a *bluetooth.Adapter, result bluetooth.ScanResult) {
if result.Address.String() != addrStr {
return
}
foundAddr = result.Address
found = true
_ = a.StopScan()
})
if err != nil {
return nil, fmt.Errorf("scan for address: %w", err)
}
if !found {
return nil, fmt.Errorf("BLE address %s not found", addrStr)
}
return connectBLEDevice(foundAddr, settle)
}
func connectBLEDevice(addr bluetooth.Address, settle time.Duration) (*bleLink, error) {
svcUUID, err := bluetooth.ParseUUID(bleServiceUUID)
if err != nil {
return nil, err
}
chrUUID, err := bluetooth.ParseUUID(bleCharUUID)
if err != nil {
return nil, err
}
adapter := bluetooth.DefaultAdapter
log.Printf("BLE connecting to %s…", addr.String())
device, err := adapter.Connect(addr, bluetooth.ConnectionParams{})
if err != nil {
return nil, fmt.Errorf("connect: %w", err)
}
services, err := device.DiscoverServices([]bluetooth.UUID{svcUUID})
if err != nil || len(services) == 0 {
_ = device.Disconnect()
return nil, fmt.Errorf("discover service: %v", err)
}
chars, err := services[0].DiscoverCharacteristics([]bluetooth.UUID{chrUUID})
if err != nil || len(chars) == 0 {
_ = device.Disconnect()
return nil, fmt.Errorf("discover characteristic: %v", err)
}
char := chars[0]
attMTU := 0
if mtu, err := char.GetMTU(); err == nil {
attMTU = int(mtu)
log.Printf("BLE ATT MTU=%d", attMTU)
if attMTU < otaMinATTMTU {
log.Printf("warning: MTU %d < %d — OTA (200 B chunks) and large stream ticks may fail",
attMTU, otaMinATTMTU)
}
}
link := &bleLink{
device: device,
char: char,
parser: uartframe.NewParser(),
respCh: make(chan []byte, 8),
streamCh: make(chan []byte, 64),
attMTU: attMTU,
}
if err := char.EnableNotifications(func(buf []byte) {
link.onNotify(buf)
}); err != nil {
_ = device.Disconnect()
return nil, fmt.Errorf("enable notifications: %w (CCCD required before commands)", err)
}
if settle <= 0 {
settle = time.Second
}
log.Printf("BLE connected; settling %v…", settle)
time.Sleep(settle)
return link, nil
}
func (b *bleLink) onNotify(buf []byte) {
b.parserMu.Lock()
defer b.parserMu.Unlock()
for _, by := range buf {
payload, ok, err := b.parser.Feed(by)
if err != nil || !ok {
continue
}
if len(payload) == 0 {
continue
}
if payload[0] == byte(pb.MessageType_BLE_STREAM_TICK) {
select {
case b.streamCh <- payload:
default:
}
continue
}
select {
case b.respCh <- payload:
default:
// Prefer dropping the oldest queued frame so a late/stale reply
// does not block the matching response for the in-flight command.
select {
case <-b.respCh:
default:
}
select {
case b.respCh <- payload:
default:
log.Printf("BLE: response channel full, dropping frame type=0x%02x", payload[0])
}
}
}
}
func (b *bleLink) resetParser() {
b.parserMu.Lock()
b.parser = uartframe.NewParser()
b.parserMu.Unlock()
}
func (b *bleLink) Close() error {
return b.device.Disconnect()
}
func (b *bleLink) exchange(cmdID byte, cmdName string) ([]byte, error) {
return b.exchangePayload([]byte{cmdID}, cmdName)
}
func (b *bleLink) exchangePayload(payload []byte, cmdName string) ([]byte, error) {
return b.exchangePayloadTimeout(payload, cmdName, readTimeout)
}
func (b *bleLink) exchangePayloadTimeout(payload []byte, cmdName string, timeout time.Duration) ([]byte, error) {
b.mu.Lock()
defer b.mu.Unlock()
if len(payload) == 0 {
return nil, fmt.Errorf("empty payload")
}
expect := payload[0]
if timeout <= 0 {
timeout = readTimeout
}
frame, err := uartframe.EncodeFrame(payload)
if err != nil {
return nil, fmt.Errorf("encode frame: %w", err)
}
if !b.quiet {
log.Printf("BLE sending %s (%d bytes)", cmdName, len(frame))
}
for {
select {
case <-b.respCh:
default:
goto sent
}
}
sent:
if _, err := b.char.WriteWithoutResponse(frame); err != nil {
return nil, fmt.Errorf("BLE write: %w (check DIP8=BLE)", err)
}
deadline := time.Now().Add(timeout)
for {
remain := time.Until(deadline)
if remain <= 0 {
// Drop a half-parsed frame (e.g. aborted multi-notify) so the next
// exchange does not treat a new 0xAA as payload data.
b.resetParser()
return nil, fmt.Errorf("BLE read timeout for %s (check DIP8=BLE / -ble)", cmdName)
}
timer := time.NewTimer(remain)
select {
case resp := <-b.respCh:
timer.Stop()
if len(resp) == 0 {
continue
}
if resp[0] != expect {
// Stale reply (e.g. late BATTERY_STATUS after a short timeout).
if !b.quiet {
log.Printf("BLE: discard type=0x%02x while waiting for 0x%02x (%s)", resp[0], expect, cmdName)
}
continue
}
if !b.quiet {
log.Printf("BLE response (%d bytes)", len(resp))
}
return resp, nil
case <-timer.C:
b.resetParser()
return nil, fmt.Errorf("BLE read timeout for %s (check DIP8=BLE / -ble)", cmdName)
}
}
}
func (b *bleLink) NextStreamTick(timeout time.Duration) ([]byte, error) {
if timeout <= 0 {
timeout = 5 * time.Second
}
timer := time.NewTimer(timeout)
defer timer.Stop()
select {
case p := <-b.streamCh:
return p, nil
case <-timer.C:
return nil, fmt.Errorf("BLE stream tick timeout")
}
}
// ensureOTAMTU rejects OTA when the negotiated ATT MTU cannot carry 200 B chunks.
func (b *bleLink) ensureOTAMTU() error {
mtu := b.attMTU
if mtu <= 0 {
if v, err := b.char.GetMTU(); err == nil {
mtu = int(v)
b.attMTU = mtu
}
}
if mtu < otaMinATTMTU {
return fmt.Errorf("BLE ATT MTU %d < %d required for OTA (negotiate larger MTU)", mtu, otaMinATTMTU)
}
maxWrite := mtu - 3
if maxWrite < 216 {
return fmt.Errorf("BLE max write %d < 216 needed for 200 B OTA frames", maxWrite)
}
return nil
}
func (b *bleLink) writeMsg(msg *pb.UartMessage) error {
b.mu.Lock()
defer b.mu.Unlock()
frame, err := encodeUartMessage(msg)
if err != nil {
return err
}
if b.attMTU > 0 && len(frame) > b.attMTU-3 {
return fmt.Errorf("OTA frame %d exceeds BLE max write %d (MTU=%d)",
len(frame), b.attMTU-3, b.attMTU)
}
if _, err := b.char.WriteWithoutResponse(frame); err != nil {
return fmt.Errorf("BLE write: %w", err)
}
return nil
}
func (b *bleLink) readFrame(timeout time.Duration) ([]byte, error) {
if timeout <= 0 {
timeout = readTimeout
}
timer := time.NewTimer(timeout)
defer timer.Stop()
select {
case p := <-b.respCh:
return p, nil
case <-timer.C:
return nil, fmt.Errorf("BLE read timeout")
}
}
func (b *bleLink) flushInput() {
for {
select {
case <-b.respCh:
default:
goto streams
}
}
streams:
for {
select {
case <-b.streamCh:
default:
return
}
}
}
func (b *bleLink) chunkPace() time.Duration { return otaHostChunkPaceBLE }
+224
View File
@@ -0,0 +1,224 @@
package main
import (
"fmt"
"math"
"sort"
"time"
pb "powerpod/gotool/pb"
"google.golang.org/protobuf/proto"
)
type latencyStats struct {
N int `json:"n"`
MinMs float64 `json:"min_ms"`
MedMs float64 `json:"median_ms"`
MeanMs float64 `json:"mean_ms"`
MaxMs float64 `json:"max_ms"`
}
type bleRttResult struct {
Success bool `json:"success"`
Count int `json:"count"`
Warmup int `json:"warmup"`
Stats latencyStats `json:"stats"`
Error string `json:"error,omitempty"`
}
type bleStreamResult struct {
Success bool `json:"success"`
Received int `json:"received"`
Requested int `json:"requested"`
PeriodMs uint32 `json:"period_ms"`
TolMs float64 `json:"tol_ms"`
OutsideTol int `json:"outside_tol"`
WindowOK bool `json:"window_ok"`
DTEsp latencyStats `json:"dt_esp_ms"`
DRPc latencyStats `json:"dr_pc_ms"`
ExtraVsFirst latencyStats `json:"extra_vs_first_ms"`
Error string `json:"error,omitempty"`
}
func measureBleRtt(h hostConn, count, warmup int, settle time.Duration) bleRttResult {
out := bleRttResult{Count: count, Warmup: warmup}
if count < 1 {
out.Error = "count must be >= 1"
return out
}
if warmup < 0 {
out.Error = "warmup must be >= 0"
return out
}
if settle > 0 {
time.Sleep(settle)
}
samples := make([]float64, 0, count)
total := warmup + count
for i := 0; i < total; i++ {
t0 := time.Now()
if _, err := hostGetVersion(h); err != nil {
out.Error = fmt.Sprintf("VERSION round-trip %d: %v", i, err)
return out
}
ms := float64(time.Since(t0).Microseconds()) / 1000.0
if i >= warmup {
samples = append(samples, ms)
}
}
out.Stats = makeLatencyStats(samples)
out.Success = true
return out
}
func measureBleStream(h hostConn, clientID, periodMs uint32, count int, tolMs, timeoutSec float64) bleStreamResult {
out := bleStreamResult{
Requested: count,
PeriodMs: periodMs,
TolMs: tolMs,
}
if periodMs < 10 || periodMs > 16 {
out.Error = "period_ms must be 10..16"
return out
}
if count < 2 {
out.Error = "count must be >= 2"
return out
}
sc, ok := h.(hostStreamConn)
if !ok {
out.Error = "BLE stream ticks require BLE host"
return out
}
req := &pb.AccelStreamRequest{
Write: true,
Enable: true,
PeriodMs: periodMs,
}
if clientID == 0 {
req.AllClients = true
} else {
req.ClientId = clientID
}
resp, err := hostAccelStream(h, req)
if err != nil {
out.Error = err.Error()
return out
}
if !resp.GetSuccess() {
out.Error = "accel stream enable failed"
return out
}
type sample struct {
espUs uint64
pcT float64
}
samples := make([]sample, 0, count)
deadline := time.Now().Add(time.Duration(timeoutSec * float64(time.Second)))
if timeoutSec <= 0 {
deadline = time.Now().Add(15 * time.Second)
}
for len(samples) < count {
remain := time.Until(deadline)
if remain <= 0 {
break
}
payload, err := sc.NextStreamTick(remain)
if err != nil {
break
}
pcT := time.Now().UnixMicro()
var msg pb.UartMessage
if err := proto.Unmarshal(payload[1:], &msg); err != nil {
continue
}
tick := msg.GetBleStreamTick()
if tick == nil {
continue
}
samples = append(samples, sample{espUs: tick.GetEspTimestampUs(), pcT: float64(pcT)})
}
off := &pb.AccelStreamRequest{Write: true, Enable: false, AllClients: req.AllClients, ClientId: req.ClientId}
_, _ = hostAccelStream(h, off)
out.Received = len(samples)
if len(samples) < 2 {
out.Error = fmt.Sprintf("need >=2 ticks, got %d (stream running / DIP8=BLE?)", len(samples))
return out
}
var dT, dR, extra []float64
esp0 := samples[0].espUs
pc0 := samples[0].pcT
for i := 1; i < len(samples); i++ {
dT = append(dT, float64(samples[i].espUs-samples[i-1].espUs)/1000.0)
dR = append(dR, (samples[i].pcT-samples[i-1].pcT)/1000.0)
relEsp := float64(samples[i].espUs-esp0) / 1000.0
relPc := (samples[i].pcT - pc0) / 1000.0
extra = append(extra, relPc-relEsp)
}
period := float64(periodMs)
outCount := 0
for _, dt := range dT {
if math.Abs(dt-period) > tolMs {
outCount++
}
}
out.OutsideTol = outCount
out.WindowOK = outCount*2 <= len(dT)
out.DTEsp = makeLatencyStats(dT)
out.DRPc = makeLatencyStats(dR)
out.ExtraVsFirst = makeLatencyStats(extra)
out.Success = true
return out
}
func makeLatencyStats(v []float64) latencyStats {
if len(v) == 0 {
return latencyStats{}
}
s := append([]float64(nil), v...)
sort.Float64s(s)
return latencyStats{
N: len(s),
MinMs: s[0],
MedMs: percentile(s, 0.5),
MeanMs: mean(s),
MaxMs: s[len(s)-1],
}
}
func mean(v []float64) float64 {
if len(v) == 0 {
return math.NaN()
}
var s float64
for _, x := range v {
s += x
}
return s / float64(len(v))
}
func percentile(sorted []float64, p float64) float64 {
if len(sorted) == 0 {
return math.NaN()
}
if len(sorted) == 1 {
return sorted[0]
}
idx := p * float64(len(sorted)-1)
lo := int(idx)
hi := lo + 1
if hi >= len(sorted) {
return sorted[lo]
}
frac := idx - float64(lo)
return sorted[lo]*(1-frac) + sorted[hi]*frac
}
+35 -1
View File
@@ -1,6 +1,12 @@
package main
import "powerpod/gotool/pb"
import (
"fmt"
"powerpod/gotool/pb"
"google.golang.org/protobuf/proto"
)
// accelSamplesFromCacheStatus maps combined CACHE_STATUS entries to AccelSample
// (for dashboard / WebSocket accel push).
@@ -47,3 +53,31 @@ func tapEventsFromCacheStatus(r *pb.CacheStatusResponse) []*pb.TapEvent {
}
return out
}
// cacheStatusFromBLEStreamPayload parses a framed BLE_STREAM_TICK payload
// (type byte + protobuf UartMessage) into a CacheStatusResponse-shaped view.
func cacheStatusFromBLEStreamPayload(payload []byte) (*pb.CacheStatusResponse, error) {
tick, err := parseBleStreamTickPayload(payload)
if err != nil {
return nil, err
}
return &pb.CacheStatusResponse{Clients: tick.GetClients()}, nil
}
func parseBleStreamTickPayload(payload []byte) (*pb.BleStreamTick, error) {
if len(payload) < 2 {
return nil, fmt.Errorf("BLE stream tick too short")
}
if payload[0] != byte(pb.MessageType_BLE_STREAM_TICK) {
return nil, fmt.Errorf("unexpected BLE tick type 0x%02x", payload[0])
}
var msg pb.UartMessage
if err := proto.Unmarshal(payload[1:], &msg); err != nil {
return nil, err
}
tick := msg.GetBleStreamTick()
if tick == nil {
return nil, fmt.Errorf("missing ble_stream_tick")
}
return tick, nil
}
+344 -78
View File
@@ -2,6 +2,7 @@ package main
import (
"fmt"
"time"
"google.golang.org/protobuf/proto"
@@ -63,16 +64,14 @@ func decodeBatteryStatusPayload(payload []byte) (*pb.BatteryStatusResponse, erro
}
func (m *managedSerial) BatteryStatus(req *pb.BatteryStatusRequest) (*pb.BatteryStatusResponse, error) {
var resp *pb.BatteryStatusResponse
err := m.withPort(func(sp *serialPort) error {
var e error
resp, e = sp.batteryStatus(req)
return e
})
return resp, err
return m.batteryStatusExchange(false, req)
}
func (m *managedSerial) BatteryStatusPoll(req *pb.BatteryStatusRequest) (*pb.BatteryStatusResponse, error) {
return m.batteryStatusExchange(true, req)
}
func (m *managedSerial) batteryStatusExchange(poll bool, req *pb.BatteryStatusRequest) (*pb.BatteryStatusResponse, error) {
msg := &pb.UartMessage{
Type: pb.MessageType_BATTERY_STATUS,
Payload: &pb.UartMessage_BatteryStatusRequest{
@@ -84,23 +83,18 @@ func (m *managedSerial) BatteryStatusPoll(req *pb.BatteryStatusRequest) (*pb.Bat
return nil, fmt.Errorf("encode: %w", err)
}
payload := append([]byte{byte(pb.MessageType_BATTERY_STATUS)}, body...)
respPayload, err := m.batteryStatusPayloadPoll(payload)
var respPayload []byte
if poll {
respPayload, err = m.exchangePayloadMode(true, payload, "BATTERY_STATUS", batteryReadTimeout)
} else {
respPayload, err = m.exchangePayloadMode(false, payload, "BATTERY_STATUS", batteryReadTimeout)
}
if err != nil {
return nil, err
}
return decodeBatteryStatusPayload(respPayload)
}
func (m *managedSerial) batteryStatusPayloadPoll(payload []byte) ([]byte, error) {
var resp []byte
err := m.withPortPoll(func(sp *serialPort) error {
var e error
resp, e = sp.exchangePayloadForBattery(payload, "BATTERY_STATUS")
return e
})
return resp, err
}
func (s *serialPort) batteryStatus(req *pb.BatteryStatusRequest) (*pb.BatteryStatusResponse, error) {
msg := &pb.UartMessage{
Type: pb.MessageType_BATTERY_STATUS,
@@ -121,11 +115,11 @@ func (s *serialPort) batteryStatus(req *pb.BatteryStatusRequest) (*pb.BatterySta
}
func (m *managedSerial) AccelStream(req *pb.AccelStreamRequest) (*pb.AccelStreamResponse, error) {
return m.accelStreamVia(m.withPort, req)
return m.accelStreamExchange(false, req)
}
func (m *managedSerial) AccelStreamPoll(req *pb.AccelStreamRequest) (*pb.AccelStreamResponse, error) {
return m.accelStreamVia(m.withPortPoll, req)
return m.accelStreamExchange(true, req)
}
// SetAccelStream enables or disables the ESP-NOW accel stream for one slave (master UART).
@@ -153,24 +147,41 @@ func (m *managedSerial) GetAccelStream(clientID uint32) (bool, error) {
}
func (m *managedSerial) TapNotify(req *pb.TapNotifyRequest) (*pb.TapNotifyResponse, error) {
return m.tapNotifyVia(m.withPort, req)
return m.tapNotifyExchange(false, req)
}
func (m *managedSerial) TapNotifyPoll(req *pb.TapNotifyRequest) (*pb.TapNotifyResponse, error) {
return m.tapNotifyVia(m.withPortPoll, req)
return m.tapNotifyExchange(true, req)
}
func (m *managedSerial) tapNotifyVia(
portFn func(func(*serialPort) error) error,
req *pb.TapNotifyRequest,
) (*pb.TapNotifyResponse, error) {
var resp *pb.TapNotifyResponse
err := portFn(func(sp *serialPort) error {
var e error
resp, e = sp.TapNotify(req)
return e
})
return resp, err
func (m *managedSerial) tapNotifyExchange(poll bool, req *pb.TapNotifyRequest) (*pb.TapNotifyResponse, error) {
msg := &pb.UartMessage{
Type: pb.MessageType_TAP_NOTIFY,
Payload: &pb.UartMessage_TapNotifyRequest{TapNotifyRequest: req},
}
body, err := proto.Marshal(msg)
if err != nil {
return nil, fmt.Errorf("encode: %w", err)
}
payload := append([]byte{byte(pb.MessageType_TAP_NOTIFY)}, body...)
var respPayload []byte
if poll {
respPayload, err = m.exchangePayloadPoll(payload, "TAP_NOTIFY")
} else {
respPayload, err = m.exchangePayload(payload, "TAP_NOTIFY")
}
if err != nil {
return nil, err
}
var respMsg pb.UartMessage
if err := proto.Unmarshal(respPayload[1:], &respMsg); err != nil {
return nil, fmt.Errorf("decode: %w", err)
}
r := respMsg.GetTapNotifyResponse()
if r == nil {
return nil, fmt.Errorf("missing tap_notify_response")
}
return r, nil
}
func (m *managedSerial) readCacheStatusPoll() (*pb.CacheStatusResponse, error) {
@@ -203,48 +214,176 @@ func decodeCacheStatusPayload(payload []byte) (*pb.CacheStatusResponse, error) {
return r, nil
}
func (m *managedSerial) accelStreamVia(
portFn func(func(*serialPort) error) error,
req *pb.AccelStreamRequest,
) (*pb.AccelStreamResponse, error) {
var resp *pb.AccelStreamResponse
err := portFn(func(sp *serialPort) error {
var e error
resp, e = sp.AccelStream(req)
return e
})
return resp, err
func (m *managedSerial) accelStreamExchange(poll bool, req *pb.AccelStreamRequest) (*pb.AccelStreamResponse, error) {
msg := &pb.UartMessage{
Type: pb.MessageType_ACCEL_STREAM,
Payload: &pb.UartMessage_AccelStreamRequest{AccelStreamRequest: req},
}
body, err := proto.Marshal(msg)
if err != nil {
return nil, fmt.Errorf("encode: %w", err)
}
payload := append([]byte{byte(pb.MessageType_ACCEL_STREAM)}, body...)
var respPayload []byte
if poll {
respPayload, err = m.exchangePayloadPoll(payload, "ACCEL_STREAM")
} else {
respPayload, err = m.exchangePayload(payload, "ACCEL_STREAM")
}
if err != nil {
return nil, err
}
var respMsg pb.UartMessage
if err := proto.Unmarshal(respPayload[1:], &respMsg); err != nil {
return nil, fmt.Errorf("decode: %w", err)
}
r := respMsg.GetAccelStreamResponse()
if r == nil {
return nil, fmt.Errorf("missing accel_stream_response")
}
return r, nil
}
func (m *managedSerial) AccelDeadzone(req *pb.AccelDeadzoneRequest) (*pb.AccelDeadzoneResponse, error) {
return m.accelDeadzoneVia(m.withPort, req)
return m.accelDeadzoneExchange(false, req)
}
func (m *managedSerial) AccelDeadzonePoll(req *pb.AccelDeadzoneRequest) (*pb.AccelDeadzoneResponse, error) {
return m.accelDeadzoneVia(m.withPortPoll, req)
return m.accelDeadzoneExchange(true, req)
}
func (m *managedSerial) accelDeadzoneVia(
portFn func(func(*serialPort) error) error,
req *pb.AccelDeadzoneRequest,
) (*pb.AccelDeadzoneResponse, error) {
var resp *pb.AccelDeadzoneResponse
err := portFn(func(sp *serialPort) error {
var e error
resp, e = sp.AccelDeadzone(req)
return e
})
return resp, err
func (m *managedSerial) TapConfig(req *pb.TapConfigRequest) (*pb.TapConfigResponse, error) {
return m.tapConfigExchange(false, req)
}
func (m *managedSerial) TapConfigPoll(req *pb.TapConfigRequest) (*pb.TapConfigResponse, error) {
return m.tapConfigExchange(true, req)
}
func (m *managedSerial) tapConfigExchange(poll bool, req *pb.TapConfigRequest) (*pb.TapConfigResponse, error) {
msg := &pb.UartMessage{
Type: pb.MessageType_TAP_CONFIG,
Payload: &pb.UartMessage_TapConfigRequest{TapConfigRequest: req},
}
body, err := proto.Marshal(msg)
if err != nil {
return nil, fmt.Errorf("encode: %w", err)
}
payload := append([]byte{byte(pb.MessageType_TAP_CONFIG)}, body...)
var respPayload []byte
if poll {
respPayload, err = m.exchangePayloadPoll(payload, "TAP_CONFIG")
} else {
respPayload, err = m.exchangePayload(payload, "TAP_CONFIG")
}
if err != nil {
return nil, err
}
var respMsg pb.UartMessage
if err := proto.Unmarshal(respPayload[1:], &respMsg); err != nil {
return nil, fmt.Errorf("decode: %w", err)
}
r := respMsg.GetTapConfigResponse()
if r == nil {
return nil, fmt.Errorf("missing tap_config_response")
}
return r, nil
}
func (m *managedSerial) BatteryUv(req *pb.BatteryUvRequest) (*pb.BatteryUvResponse, error) {
return m.batteryUvExchange(false, req)
}
func (m *managedSerial) BatteryUvPoll(req *pb.BatteryUvRequest) (*pb.BatteryUvResponse, error) {
return m.batteryUvExchange(true, req)
}
func (m *managedSerial) batteryUvExchange(poll bool, req *pb.BatteryUvRequest) (*pb.BatteryUvResponse, error) {
msg := &pb.UartMessage{
Type: pb.MessageType_BATTERY_UV,
Payload: &pb.UartMessage_BatteryUvRequest{BatteryUvRequest: req},
}
body, err := proto.Marshal(msg)
if err != nil {
return nil, fmt.Errorf("encode: %w", err)
}
payload := append([]byte{byte(pb.MessageType_BATTERY_UV)}, body...)
var respPayload []byte
if poll {
respPayload, err = m.exchangePayloadPoll(payload, "BATTERY_UV")
} else {
respPayload, err = m.exchangePayload(payload, "BATTERY_UV")
}
if err != nil {
return nil, err
}
var respMsg pb.UartMessage
if err := proto.Unmarshal(respPayload[1:], &respMsg); err != nil {
return nil, fmt.Errorf("decode: %w", err)
}
r := respMsg.GetBatteryUvResponse()
if r == nil {
return nil, fmt.Errorf("missing battery_uv_response")
}
return r, nil
}
func (m *managedSerial) accelDeadzoneExchange(poll bool, req *pb.AccelDeadzoneRequest) (*pb.AccelDeadzoneResponse, error) {
msg := &pb.UartMessage{
Type: pb.MessageType_ACCEL_DEADZONE,
Payload: &pb.UartMessage_AccelDeadzoneRequest{AccelDeadzoneRequest: req},
}
body, err := proto.Marshal(msg)
if err != nil {
return nil, fmt.Errorf("encode: %w", err)
}
payload := append([]byte{byte(pb.MessageType_ACCEL_DEADZONE)}, body...)
var respPayload []byte
if poll {
respPayload, err = m.exchangePayloadPoll(payload, "ACCEL_DEADZONE")
} else {
respPayload, err = m.exchangePayload(payload, "ACCEL_DEADZONE")
}
if err != nil {
return nil, err
}
var respMsg pb.UartMessage
if err := proto.Unmarshal(respPayload[1:], &respMsg); err != nil {
return nil, fmt.Errorf("decode: %w", err)
}
r := respMsg.GetAccelDeadzoneResponse()
if r == nil {
return nil, fmt.Errorf("missing accel_deadzone_response")
}
return r, nil
}
func (m *managedSerial) EspnowUnicastTest(clientID, seq uint32) (*pb.EspNowUnicastTestResponse, error) {
var resp *pb.EspNowUnicastTestResponse
err := m.withPort(func(sp *serialPort) error {
var e error
resp, e = sp.EspnowUnicastTest(clientID, seq)
return e
})
return resp, err
req := &pb.EspNowUnicastTestRequest{ClientId: clientID, Seq: seq}
msg := &pb.UartMessage{
Type: pb.MessageType_ESPNOW_UNICAST_TEST,
Payload: &pb.UartMessage_EspnowUnicastTestRequest{
EspnowUnicastTestRequest: req,
},
}
body, err := proto.Marshal(msg)
if err != nil {
return nil, fmt.Errorf("encode: %w", err)
}
payload := append([]byte{byte(pb.MessageType_ESPNOW_UNICAST_TEST)}, body...)
respPayload, err := m.exchangePayload(payload, "ESPNOW_UNICAST_TEST")
if err != nil {
return nil, err
}
var respMsg pb.UartMessage
if err := proto.Unmarshal(respPayload[1:], &respMsg); err != nil {
return nil, fmt.Errorf("decode: %w", err)
}
r := respMsg.GetEspnowUnicastTestResponse()
if r == nil {
return nil, fmt.Errorf("missing espnow_unicast_test_response")
}
return r, nil
}
func decodeVersionPayload(payload []byte) (*pb.VersionResponse, error) {
@@ -382,6 +521,60 @@ func (s *serialPort) accelDeadzone(req *pb.AccelDeadzoneRequest) (*pb.AccelDeadz
return r, nil
}
func (s *serialPort) tapConfig(req *pb.TapConfigRequest) (*pb.TapConfigResponse, error) {
msg := &pb.UartMessage{
Type: pb.MessageType_TAP_CONFIG,
Payload: &pb.UartMessage_TapConfigRequest{
TapConfigRequest: req,
},
}
body, err := proto.Marshal(msg)
if err != nil {
return nil, fmt.Errorf("encode: %w", err)
}
payload := append([]byte{byte(pb.MessageType_TAP_CONFIG)}, body...)
respPayload, err := s.exchangePayload(payload, "TAP_CONFIG")
if err != nil {
return nil, err
}
var respMsg pb.UartMessage
if err := proto.Unmarshal(respPayload[1:], &respMsg); err != nil {
return nil, fmt.Errorf("decode: %w", err)
}
r := respMsg.GetTapConfigResponse()
if r == nil {
return nil, fmt.Errorf("missing tap_config_response")
}
return r, nil
}
func (s *serialPort) batteryUv(req *pb.BatteryUvRequest) (*pb.BatteryUvResponse, error) {
msg := &pb.UartMessage{
Type: pb.MessageType_BATTERY_UV,
Payload: &pb.UartMessage_BatteryUvRequest{
BatteryUvRequest: req,
},
}
body, err := proto.Marshal(msg)
if err != nil {
return nil, fmt.Errorf("encode: %w", err)
}
payload := append([]byte{byte(pb.MessageType_BATTERY_UV)}, body...)
respPayload, err := s.exchangePayload(payload, "BATTERY_UV")
if err != nil {
return nil, err
}
var respMsg pb.UartMessage
if err := proto.Unmarshal(respPayload[1:], &respMsg); err != nil {
return nil, fmt.Errorf("decode: %w", err)
}
r := respMsg.GetBatteryUvResponse()
if r == nil {
return nil, fmt.Errorf("missing battery_uv_response")
}
return r, nil
}
func (s *serialPort) espnowUnicastTest(clientID, seq uint32) (*pb.EspNowUnicastTestResponse, error) {
req := &pb.EspNowUnicastTestRequest{ClientId: clientID, Seq: seq}
msg := &pb.UartMessage{
@@ -410,17 +603,80 @@ func (s *serialPort) espnowUnicastTest(clientID, seq uint32) (*pb.EspNowUnicastT
return r, nil
}
// EchoPingResult is the host-side round-trip for ESP-NOW echo ping.
type EchoPingResult struct {
Success bool `json:"success"`
ClientID uint32 `json:"client_id"`
TimestampUs uint64 `json:"timestamp_us"`
RttMs float64 `json:"rtt_ms"` // goTool: full UART round-trip
EspRttUs uint32 `json:"esp_rtt_us"` // master: µs delta ping send → pong recv
}
func (s *serialPort) echoPing(clientID uint32) (*EchoPingResult, error) {
t0 := time.Now()
timestampUs := uint64(t0.UnixMicro())
req := &pb.EspNowEchoPingRequest{
ClientId: clientID,
TimestampUs: timestampUs,
}
msg := &pb.UartMessage{
Type: pb.MessageType_ESPNOW_ECHO_PING,
Payload: &pb.UartMessage_EspnowEchoPingRequest{
EspnowEchoPingRequest: req,
},
}
body, err := proto.Marshal(msg)
if err != nil {
return nil, fmt.Errorf("encode: %w", err)
}
payload := append([]byte{byte(pb.MessageType_ESPNOW_ECHO_PING)}, body...)
respPayload, err := s.exchangePayload(payload, "ESPNOW_ECHO_PING")
if err != nil {
return nil, err
}
rttMs := float64(time.Since(t0).Microseconds()) / 1000.0
var respMsg pb.UartMessage
if err := proto.Unmarshal(respPayload[1:], &respMsg); err != nil {
return nil, fmt.Errorf("decode: %w", err)
}
r := respMsg.GetEspnowEchoPingResponse()
if r == nil {
return nil, fmt.Errorf("missing espnow_echo_ping_response")
}
if !r.GetSuccess() {
return &EchoPingResult{
Success: false,
ClientID: r.GetClientId(),
RttMs: rttMs,
}, nil
}
if r.GetTimestampUs() != timestampUs {
return nil, fmt.Errorf("timestamp mismatch: sent %d got %d", timestampUs, r.GetTimestampUs())
}
return &EchoPingResult{
Success: true,
ClientID: r.GetClientId(),
TimestampUs: r.GetTimestampUs(),
RttMs: rttMs,
EspRttUs: r.GetEspRttUs(),
}, nil
}
func (m *managedSerial) FindMe(clientID uint32) error {
return m.withPort(func(sp *serialPort) error {
return runFindMeClient(sp, clientID)
})
return hostFindMe(m, clientID)
}
func (m *managedSerial) SetLogLevel(write bool, level uint32) (*pb.SetLogLevelResponse, error) {
return hostSetLogLevel(m, write, level)
}
func (m *managedSerial) InputSendMode(req *pb.InputSendModeRequest) (*pb.InputSendModeResponse, error) {
return hostInputSendMode(m, req)
}
func (m *managedSerial) Restart(clientID uint32) error {
err := m.withPort(func(sp *serialPort) error {
return runRestartClient(sp, clientID)
})
if err != nil {
if err := hostRestart(m, clientID); err != nil {
return err
}
if clientID == 0 {
@@ -486,22 +742,32 @@ func (s *serialPort) AccelDeadzone(req *pb.AccelDeadzoneRequest) (*pb.AccelDeadz
return s.accelDeadzone(req)
}
func (s *serialPort) TapConfig(req *pb.TapConfigRequest) (*pb.TapConfigResponse, error) {
return s.tapConfig(req)
}
func (s *serialPort) BatteryUv(req *pb.BatteryUvRequest) (*pb.BatteryUvResponse, error) {
return s.batteryUv(req)
}
func (s *serialPort) EspnowUnicastTest(clientID, seq uint32) (*pb.EspNowUnicastTestResponse, error) {
return s.espnowUnicastTest(clientID, seq)
}
func (s *serialPort) EchoPing(clientID uint32) (*EchoPingResult, error) {
return s.echoPing(clientID)
}
func (m *managedSerial) EchoPing(clientID uint32) (*EchoPingResult, error) {
return hostEchoPing(m, clientID)
}
func (s *serialPort) LedRing(req *pb.LedRingProgressRequest) (*pb.LedRingProgressResponse, error) {
return s.ledRingProgress(req)
}
func (m *managedSerial) LedRing(req *pb.LedRingProgressRequest) (*pb.LedRingProgressResponse, error) {
var resp *pb.LedRingProgressResponse
err := m.withPort(func(sp *serialPort) error {
var e error
resp, e = sp.LedRing(req)
return e
})
return resp, err
return hostLedRing(m, req)
}
func (s *serialPort) FindMe(clientID uint32) (*pb.EspNowFindMeResponse, error) {
+44
View File
@@ -0,0 +1,44 @@
package main
import (
"flag"
"fmt"
pb "powerpod/gotool/pb"
)
func runAccelStreamCmd(h hostConn, args []string) error {
fs := flag.NewFlagSet("accel-stream", flag.ExitOnError)
clientID := fs.Uint("client", 0, "slave client id (0 with -all)")
all := fs.Bool("all", false, "all registered clients")
enable := fs.Bool("enable", true, "enable stream")
disable := fs.Bool("disable", false, "disable stream")
period := fs.Uint("period", 0, "BLE push period_ms 10–16 (0=leave/default)")
if err := fs.Parse(args); err != nil {
return err
}
on := *enable
if *disable {
on = false
}
if !*all && *clientID == 0 {
return fmt.Errorf("need -client ID or -all")
}
req := &pb.AccelStreamRequest{
Write: true,
Enable: on,
PeriodMs: uint32(*period),
}
if *all {
req.AllClients = true
} else {
req.ClientId = uint32(*clientID)
}
r, err := hostAccelStream(h, req)
if err != nil {
return err
}
fmt.Printf("accel-stream: success=%v enabled=%v client_id=%d slaves_updated=%d\n",
r.GetSuccess(), r.GetEnabled(), r.GetClientId(), r.GetSlavesUpdated())
return nil
}
+34
View File
@@ -0,0 +1,34 @@
package main
import (
"flag"
"fmt"
"powerpod/gotool/pb"
)
func runBatteryUv(sp *serialPort, args []string) error {
fs := flag.NewFlagSet("battery-uv", flag.ExitOnError)
write := fs.Bool("set", false, "write UV protection enable (default: read)")
enabled := fs.Bool("enable", false, "enable software UV protection")
clientID := fs.Uint("client", 0, "client id (0=local master)")
all := fs.Bool("all", false, "apply to master + all registered slaves")
if err := fs.Parse(args); err != nil {
return err
}
r, err := sp.batteryUv(&pb.BatteryUvRequest{
Write: *write,
Enabled: *enabled,
ClientId: uint32(*clientID),
AllClients: *all,
})
if err != nil {
return err
}
fmt.Printf("client_id=%d success=%v enabled=%v latched=%v slaves_updated=%d\n",
r.GetClientId(), r.GetSuccess(), r.GetEnabled(), r.GetLatched(),
r.GetSlavesUpdated())
return nil
}
+26
View File
@@ -0,0 +1,26 @@
package main
import (
"flag"
"fmt"
"time"
)
func runBleRtt(h hostConn, args []string) error {
fs := flag.NewFlagSet("ble-rtt", flag.ExitOnError)
count := fs.Int("count", 50, "number of measured VERSION round-trips")
settle := fs.Float64("settle", 0, "extra settle seconds before measurement (0 = already settled on connect)")
warmup := fs.Int("warmup", 5, "discarded warm-up round-trips")
if err := fs.Parse(args); err != nil {
return err
}
out := measureBleRtt(h, *count, *warmup, time.Duration(*settle*float64(time.Second)))
if !out.Success {
return fmt.Errorf("%s", out.Error)
}
fmt.Printf("ble-rtt (VERSION): n=%d warmup=%d\n", out.Stats.N, out.Warmup)
fmt.Printf(" min=%.3f median=%.3f mean=%.3f max=%.3f ms\n",
out.Stats.MinMs, out.Stats.MedMs, out.Stats.MeanMs, out.Stats.MaxMs)
return nil
}
+40
View File
@@ -0,0 +1,40 @@
package main
import (
"flag"
"fmt"
)
func runBleStream(h hostConn, args []string) error {
fs := flag.NewFlagSet("ble-stream", flag.ExitOnError)
clientID := fs.Uint("client", 0, "slave client id (0 = all_clients)")
periodMs := fs.Uint("period", 10, "BLE push period_ms (10–16)")
count := fs.Int("count", 50, "number of BleStreamTick samples")
tolMs := fs.Float64("tol", 2.0, "tolerance ms around period for window_ok")
duration := fs.Float64("timeout", 15.0, "max seconds waiting for ticks")
if err := fs.Parse(args); err != nil {
return err
}
fmt.Printf("accel stream on; period_ms=%d; collecting %d ticks…\n", *periodMs, *count)
out := measureBleStream(h, uint32(*clientID), uint32(*periodMs), *count, *tolMs, *duration)
if !out.Success {
return fmt.Errorf("%s", out.Error)
}
fmt.Printf("received=%d/%d period_ms=%d tol=±%.1f ms\n", out.Received, out.Requested, out.PeriodMs, out.TolMs)
printStat("dT ESP inter-depart", out.DTEsp)
printStat("dR PC inter-arrive", out.DRPc)
printStat("extra vs first", out.ExtraVsFirst)
fmt.Printf("outside_tol=%d/%d window_ok=%s\n", out.OutsideTol, out.DTEsp.N,
map[bool]string{true: "yes", false: "no"}[out.WindowOK])
return nil
}
func printStat(name string, s latencyStats) {
if s.N == 0 {
fmt.Printf("%s: (none)\n", name)
return
}
fmt.Printf("%s: n=%d min=%.3f median=%.3f mean=%.3f max=%.3f ms\n",
name, s.N, s.MinMs, s.MedMs, s.MeanMs, s.MaxMs)
}
+55
View File
@@ -0,0 +1,55 @@
package main
import (
"fmt"
"google.golang.org/protobuf/proto"
"powerpod/gotool/pb"
)
func runClientsResetIds(sp *serialPort) error {
resp, err := sp.resetClientIdMap()
if err != nil {
return err
}
if !resp.GetSuccess() {
return fmt.Errorf("client id map reset rejected")
}
fmt.Println("client id map cleared (master map + live registry; online slaves notified)")
return nil
}
func decodeClientIdMapResetPayload(payload []byte) (*pb.ClientIdMapResetResponse, error) {
if len(payload) < 2 {
return nil, fmt.Errorf("short client id map reset response")
}
if payload[0] != byte(pb.MessageType_CLIENT_ID_MAP_RESET) {
return nil, fmt.Errorf("unexpected command id 0x%02x (want 0x%02x)",
payload[0], byte(pb.MessageType_CLIENT_ID_MAP_RESET))
}
var respMsg pb.UartMessage
if err := proto.Unmarshal(payload[1:], &respMsg); err != nil {
return nil, fmt.Errorf("decode: %w", err)
}
r := respMsg.GetClientIdMapResetResponse()
if r == nil {
return nil, fmt.Errorf("missing client_id_map_reset_response")
}
return r, nil
}
func (s *serialPort) resetClientIdMap() (*pb.ClientIdMapResetResponse, error) {
payload, err := s.exchange(byte(pb.MessageType_CLIENT_ID_MAP_RESET), "CLIENT_ID_MAP_RESET")
if err != nil {
return nil, err
}
return decodeClientIdMapResetPayload(payload)
}
func (m *managedSerial) resetClientIdMap() (*pb.ClientIdMapResetResponse, error) {
payload, err := m.exchange(byte(pb.MessageType_CLIENT_ID_MAP_RESET), "CLIENT_ID_MAP_RESET")
if err != nil {
return nil, err
}
return decodeClientIdMapResetPayload(payload)
}
+26
View File
@@ -0,0 +1,26 @@
package main
import (
"flag"
"fmt"
)
func runEchoPing(sp *serialPort, args []string) error {
fs := flag.NewFlagSet("echo-ping", flag.ExitOnError)
clientID := fs.Uint("client", 0, "slave client id from `clients`")
if err := fs.Parse(args); err != nil {
return err
}
if *clientID == 0 {
return fmt.Errorf("client id required (see `gotool clients`)")
}
r, err := sp.echoPing(uint32(*clientID))
if err != nil {
return err
}
fmt.Printf("echo ping: success=%v client_id=%d rtt_ms=%.3f esp_rtt_us=%d\n",
r.Success, r.ClientID, r.RttMs, r.EspRttUs)
return nil
}
+42
View File
@@ -0,0 +1,42 @@
package main
import (
"flag"
"fmt"
pb "powerpod/gotool/pb"
)
func runInputSendModeCmd(h hostConn, args []string) error {
fs := flag.NewFlagSet("input-send-mode", flag.ExitOnError)
write := fs.Bool("set", false, "write mode to slave(s)")
mode := fs.Uint("mode", 0, "0=best_effort 1=fast 2=wait 3=reliable")
clientID := fs.Uint("client", 0, "slave client_id (required unless -all)")
all := fs.Bool("all", false, "apply to every registered slave")
_ = fs.Parse(args)
req := &pb.InputSendModeRequest{
Write: *write,
Mode: pb.InputSendMode(*mode),
ClientId: uint32(*clientID),
AllClients: *all,
}
if *write && !*all && *clientID == 0 {
return fmt.Errorf("need -client N or -all when using -set")
}
if *write && *mode > uint(pb.InputSendMode_INPUT_SEND_RELIABLE) {
return fmt.Errorf("mode must be 0..3")
}
r, err := hostInputSendMode(h, req)
if err != nil {
return err
}
fmt.Printf("input-send-mode: success=%v mode=%d (%s) client_id=%d slaves_updated=%d\n",
r.GetSuccess(), r.GetMode(), inputSendModeName(r.GetMode()), r.GetClientId(),
r.GetSlavesUpdated())
if *write && !r.GetSuccess() {
return fmt.Errorf("input-send-mode rejected")
}
return nil
}
+1 -1
View File
@@ -9,7 +9,7 @@ import (
func runLedRing(sp *serialPort, args []string) error {
fs := flag.NewFlagSet("led-ring", flag.ExitOnError)
mode := fs.String("mode", "progress", "clear, color, progress, digit, blink, or find-me")
mode := fs.String("mode", "progress", "clear, color, progress, digit, blink, find-me, or battery-low")
clientID := fs.Uint("client", 0, "0=master ring, >0=slave via ESP-NOW")
allClients := fs.Bool("all", false, "broadcast to all slaves")
slavesOnly := fs.Bool("slaves-only", false, "with -all: do not change master ring")
+61
View File
@@ -0,0 +1,61 @@
package main
import (
"flag"
"fmt"
"google.golang.org/protobuf/proto"
"powerpod/gotool/pb"
)
func runLogLevel(sp *serialPort, args []string) error {
fs := flag.NewFlagSet("log-level", flag.ExitOnError)
write := fs.Bool("set", false, "write log level (default: read)")
level := fs.Uint("level", 0, "esp_log_level_t 0–5 (with -set)")
if err := fs.Parse(args); err != nil {
return err
}
resp, err := sp.setLogLevel(*write, uint32(*level))
if err != nil {
return err
}
if !resp.GetSuccess() {
return fmt.Errorf("set_log_level rejected (level=%d)", resp.GetLevel())
}
fmt.Printf("log_level=%d success=%v\n", resp.GetLevel(), resp.GetSuccess())
return nil
}
func runSetLogLevelClient(sp *serialPort, write bool, level uint32) (*pb.SetLogLevelResponse, error) {
return sp.setLogLevel(write, level)
}
func (s *serialPort) setLogLevel(write bool, level uint32) (*pb.SetLogLevelResponse, error) {
msg := &pb.UartMessage{
Type: pb.MessageType_SET_LOG_LEVEL,
Payload: &pb.UartMessage_SetLogLevelRequest{
SetLogLevelRequest: &pb.SetLogLevelRequest{
Write: write,
Level: level,
},
},
}
body, err := proto.Marshal(msg)
if err != nil {
return nil, fmt.Errorf("encode: %w", err)
}
payload := append([]byte{byte(pb.MessageType_SET_LOG_LEVEL)}, body...)
respPayload, err := s.exchangePayload(payload, "SET_LOG_LEVEL")
if err != nil {
return nil, err
}
var respMsg pb.UartMessage
if err := proto.Unmarshal(respPayload[1:], &respMsg); err != nil {
return nil, fmt.Errorf("decode: %w", err)
}
r := respMsg.GetSetLogLevelResponse()
if r == nil {
return nil, fmt.Errorf("missing set_log_level_response")
}
return r, nil
}
+12 -4
View File
@@ -5,7 +5,7 @@ import (
"os"
)
func runOTA(sp *serialPort, args []string) error {
func runOTA(t otaTransport, args []string) error {
if len(args) < 1 {
return fmt.Errorf("usage: ota <firmware.bin>")
}
@@ -14,9 +14,17 @@ func runOTA(sp *serialPort, args []string) error {
return err
}
sp.mu.Lock()
defer sp.mu.Unlock()
return runOTAOnPortUnlocked(sp, data, func(p OTAProgress) {
switch x := t.(type) {
case *serialPort:
x.mu.Lock()
defer x.mu.Unlock()
case *bleLink:
if err := x.ensureOTAMTU(); err != nil {
return err
}
}
return runOTAUnlocked(t, data, func(p OTAProgress) {
switch p.Phase {
case "preparing", "ready":
fmt.Println(p.Message)
+7 -2
View File
@@ -5,14 +5,19 @@ import (
"fmt"
)
func runOtaProgress(sp *serialPort, args []string) error {
func runOtaProgress(t otaTransport, args []string) error {
fs := flag.NewFlagSet("ota-progress", flag.ExitOnError)
clientID := fs.Uint("client", 0, "slave client id (0 = all in session)")
if err := fs.Parse(args); err != nil {
return err
}
r, err := QueryOtaSlaveProgress(sp, uint32(*clientID))
if sp, ok := t.(*serialPort); ok {
sp.mu.Lock()
defer sp.mu.Unlock()
}
r, err := QueryOtaSlaveProgress(t, uint32(*clientID))
if err != nil {
return err
}
+40 -10
View File
@@ -20,21 +20,48 @@ var wsUpgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool { return true },
}
func runServe(portName string, baud int, args []string) error {
type serveOpts struct {
portName string
baud int
useBLE bool
bleName string
settle time.Duration
}
func runServe(opts serveOpts, args []string) error {
serveFlags := flag.NewFlagSet("serve", flag.ExitOnError)
addr := serveFlags.String("addr", ":8080", "dashboard HTTP listen address")
apiAddr := serveFlags.String("api-addr", ":8081", "external API HTTP listen address (empty to disable)")
accelInterval := serveFlags.Duration("accel-interval", defaultAccelStreamInterval, "accel WebSocket sample period on API server")
interval := serveFlags.Duration("interval", 2*time.Second, "UART poll interval")
interval := serveFlags.Duration("interval", 2*time.Second, "host poll interval")
if err := serveFlags.Parse(args); err != nil {
return err
}
if portName == "" {
return fmt.Errorf("serve requires -port (master UART)")
if opts.useBLE && opts.portName != "" {
return fmt.Errorf("use either -port or -ble, not both")
}
link := newManagedSerial(portName, baud)
link := newManagedHost(opts.baud)
link.quiet = true
link.settle = opts.settle
if opts.settle <= 0 {
link.settle = time.Second
}
switch {
case opts.portName != "":
if err := link.ConnectUART(opts.portName, opts.baud); err != nil {
log.Printf("initial UART connect failed (UI can retry): %v", err)
}
case opts.useBLE:
name := opts.bleName
if name == "" {
name = bleDefaultName
}
if err := link.ConnectBLE("", name); err != nil {
log.Printf("initial BLE connect failed (UI can scan/retry): %v", err)
}
}
hub := newWSHub()
streamCtl := newAccelStreamCtl()
@@ -48,16 +75,17 @@ func runServe(portName string, baud int, args []string) error {
shutdownHTTPServer(dashSrv)
shutdownAPIServer(apiSrv)
if err := link.Close(); err != nil {
log.Printf("UART close: %v", err)
log.Printf("host close: %v", err)
}
})
go runPoller(link, portName, hub, streamCtl, tapCtl, *interval, stop)
go runPoller(link, link.Endpoint(), hub, streamCtl, tapCtl, *interval, stop)
go runBatteryPoller(link, hub, 5*time.Second, stop)
go runCacheStatusDashboardPoller(link, hub, *accelInterval, stop)
go runBLEStreamDashboardPoller(link, hub, streamCtl, stop)
if *apiAddr != "" {
apiSrv = runAPIServer(portName, link, *apiAddr, *accelInterval, hub, streamCtl, tapCtl, stop)
apiSrv = runAPIServer(link.Endpoint(), link, *apiAddr, *accelInterval, hub, streamCtl, tapCtl, stop)
}
mux := http.NewServeMux()
@@ -85,10 +113,12 @@ func runServe(portName string, baud int, args []string) error {
}
mux.Handle("/", http.FileServer(http.FS(ui)))
log.Printf("dashboard http://localhost%s (UART %s @ %d baud, poll %s, live-stream %s, auto-reconnect)",
*addr, portName, baud, interval.String(), accelInterval.String())
log.Printf("dashboard http://localhost%s (transport=%s endpoint=%q poll %s, live-stream %s)",
*addr, link.Transport(), link.Endpoint(), interval.String(), accelInterval.String())
if *apiAddr == "" {
log.Printf("external API disabled (-api-addr \"\")")
} else {
log.Printf("external API http://localhost%s ws://localhost%s/ws", *apiAddr, *apiAddr)
}
dashSrv = &http.Server{Addr: *addr, Handler: mux}
+54
View File
@@ -0,0 +1,54 @@
package main
import (
"flag"
"fmt"
"powerpod/gotool/pb"
)
func runTapConfig(sp *serialPort, args []string) error {
fs := flag.NewFlagSet("tap-config", flag.ExitOnError)
write := fs.Bool("set", false, "write tap config (default: read)")
clientID := fs.Uint("client", 0, "client id (0=local master BMA456)")
all := fs.Bool("all", false, "apply to all registered slaves (master only)")
sens := fs.Uint("sens", 5, "tap_sens_thres 0=most … 15=least")
maxGest := fs.Uint("max-gest", 100, "max_gest_dur (×5 ms)")
shock := fs.Uint("shock", 6, "tap_shock_dur (×5 ms)")
quiet := fs.Uint("quiet", 60, "quite_time_after_gest (×5 ms)")
wait := fs.Uint("wait", 0, "wait_for_timeout 0|1")
axis := fs.Uint("axis", 2, "axis_sel 0=X 1=Y 2=Z")
single := fs.Bool("single", true, "enable single tap")
doubleTap := fs.Bool("double", true, "enable double tap")
triple := fs.Bool("triple", true, "enable triple tap")
if err := fs.Parse(args); err != nil {
return err
}
r, err := sp.tapConfig(&pb.TapConfigRequest{
Write: *write,
ClientId: uint32(*clientID),
AllClients: *all,
TapSensThres: uint32(*sens),
MaxGestDur: uint32(*maxGest),
TapShockDur: uint32(*shock),
QuiteTimeAfterGest: uint32(*quiet),
WaitForTimeout: uint32(*wait),
AxisSel: uint32(*axis),
EnableSingle: *single,
EnableDouble: *doubleTap,
EnableTriple: *triple,
})
if err != nil {
return err
}
fmt.Printf("client_id=%d success=%v slaves_updated=%d\n",
r.GetClientId(), r.GetSuccess(), r.GetSlavesUpdated())
fmt.Printf(" sens=%d max_gest=%d shock=%d quiet=%d wait=%d axis=%d\n",
r.GetTapSensThres(), r.GetMaxGestDur(), r.GetTapShockDur(),
r.GetQuiteTimeAfterGest(), r.GetWaitForTimeout(), r.GetAxisSel())
fmt.Printf(" enable single=%v double=%v triple=%v\n",
r.GetEnableSingle(), r.GetEnableDouble(), r.GetEnableTriple())
return nil
}
+301 -50
View File
@@ -15,35 +15,52 @@ import (
)
type MasterView struct {
Version uint32 `json:"version"`
GitHash string `json:"git_hash"`
RunningPartition string `json:"running_partition,omitempty"`
Deadzone uint32 `json:"deadzone,omitempty"`
Lipo1 lipoReadingJSON `json:"lipo1"`
Lipo2 lipoReadingJSON `json:"lipo2"`
BatteryAgeMs uint32 `json:"battery_age_ms,omitempty"`
OK bool `json:"ok"`
Error string `json:"error,omitempty"`
Version uint32 `json:"version"`
GitHash string `json:"git_hash"`
RunningPartition string `json:"running_partition,omitempty"`
Deadzone uint32 `json:"deadzone,omitempty"`
TapConfig *TapConfigView `json:"tap_config,omitempty"`
UvProtection *bool `json:"uv_protection,omitempty"`
UvLatched bool `json:"uv_latched,omitempty"`
Lipo1 lipoReadingJSON `json:"lipo1"`
Lipo2 lipoReadingJSON `json:"lipo2"`
BatteryAgeMs uint32 `json:"battery_age_ms,omitempty"`
OK bool `json:"ok"`
Error string `json:"error,omitempty"`
}
type TapConfigView struct {
TapSensThres uint32 `json:"tap_sens_thres"`
MaxGestDur uint32 `json:"max_gest_dur"`
TapShockDur uint32 `json:"tap_shock_dur"`
QuiteTimeAfterGest uint32 `json:"quite_time_after_gest"`
WaitForTimeout uint32 `json:"wait_for_timeout"`
AxisSel uint32 `json:"axis_sel"`
EnableSingle bool `json:"enable_single"`
EnableDouble bool `json:"enable_double"`
EnableTriple bool `json:"enable_triple"`
}
type ClientView struct {
ID uint32 `json:"id"`
MAC string `json:"mac"`
Version uint32 `json:"version"`
Deadzone uint32 `json:"deadzone,omitempty"`
Available bool `json:"available"`
Used bool `json:"used"`
LastPing uint32 `json:"last_ping"`
LastSuccessPing uint32 `json:"last_success_ping"`
AccelValid bool `json:"accel_valid"`
AccelX int32 `json:"accel_x"`
AccelY int32 `json:"accel_y"`
AccelZ int32 `json:"accel_z"`
AccelAgeMs uint32 `json:"accel_age_ms"`
ID uint32 `json:"id"`
MAC string `json:"mac"`
Version uint32 `json:"version"`
Deadzone uint32 `json:"deadzone,omitempty"`
TapConfig *TapConfigView `json:"tap_config,omitempty"`
Available bool `json:"available"`
Used bool `json:"used"`
LastPing uint32 `json:"last_ping"`
LastSuccessPing uint32 `json:"last_success_ping"`
AccelValid bool `json:"accel_valid"`
AccelX int32 `json:"accel_x"`
AccelY int32 `json:"accel_y"`
AccelZ int32 `json:"accel_z"`
AccelAgeMs uint32 `json:"accel_age_ms"`
AccelStream bool `json:"accel_stream"`
TapNotifySingle bool `json:"tap_notify_single"`
TapNotifyDouble bool `json:"tap_notify_double"`
TapNotifyTriple bool `json:"tap_notify_triple"`
UvProtection bool `json:"uv_protection"`
LastTap string `json:"last_tap,omitempty"`
LastTapAt int64 `json:"last_tap_at,omitempty"`
Lipo1 lipoReadingJSON `json:"lipo1"`
@@ -52,22 +69,91 @@ type ClientView struct {
}
type DashboardState struct {
UpdatedAt string `json:"updated_at"`
SerialPort string `json:"serial_port"`
UARTConnected bool `json:"uart_connected"`
SerialOK bool `json:"serial_ok"`
SerialError string `json:"serial_error,omitempty"`
/** Host: fast CACHE_STATUS poll (~16 ms) for accel + tap. */
LiveStream bool `json:"live_stream"`
Master MasterView `json:"master"`
Clients []ClientView `json:"clients"`
UpdatedAt string `json:"updated_at"`
SerialPort string `json:"serial_port"`
/** Alias of host_endpoint for older UI. */
HostEndpoint string `json:"host_endpoint"`
HostTransport string `json:"host_transport"`
HostConnected bool `json:"host_connected"`
UARTConnected bool `json:"uart_connected"`
SerialOK bool `json:"serial_ok"`
SerialError string `json:"serial_error,omitempty"`
/** Host: fast CACHE_STATUS poll (~8 ms) for accel + tap. */
LiveStream bool `json:"live_stream"`
/** BLE live path: inter-tick stats from BleStreamTick.esp_timestamp_us. */
BleTick *BleTickView `json:"ble_tick,omitempty"`
Master MasterView `json:"master"`
Clients []ClientView `json:"clients"`
}
// BleTickView is median/mean inter-departure of BLE_STREAM_TICK (ESP timer).
type BleTickView struct {
PeriodMs uint32 `json:"period_ms"`
MedianMs float64 `json:"median_ms"`
MeanMs float64 `json:"mean_ms"`
MinMs float64 `json:"min_ms"`
MaxMs float64 `json:"max_ms"`
Samples int `json:"samples"`
LastEspUs uint64 `json:"last_esp_us,omitempty"`
}
const bleTickDeltaCap = 64
type bleTickTracker struct {
lastEspUs uint64
haveLast bool
periodMs uint32
deltas [bleTickDeltaCap]float64
next int
count int
}
func (t *bleTickTracker) reset() {
*t = bleTickTracker{}
}
func (t *bleTickTracker) add(espUs uint64, periodMs uint32) BleTickView {
if periodMs != 0 {
t.periodMs = periodMs
}
if t.haveLast && espUs > t.lastEspUs {
dt := float64(espUs-t.lastEspUs) / 1000.0
t.deltas[t.next] = dt
t.next = (t.next + 1) % bleTickDeltaCap
if t.count < bleTickDeltaCap {
t.count++
}
}
t.lastEspUs = espUs
t.haveLast = true
samples := make([]float64, 0, t.count)
if t.count < bleTickDeltaCap {
samples = append(samples, t.deltas[:t.count]...)
} else {
samples = append(samples, t.deltas[t.next:]...)
samples = append(samples, t.deltas[:t.next]...)
}
st := makeLatencyStats(samples)
return BleTickView{
PeriodMs: t.periodMs,
MedianMs: st.MedMs,
MeanMs: st.MeanMs,
MinMs: st.MinMs,
MaxMs: st.MaxMs,
Samples: st.N,
LastEspUs: espUs,
}
}
type wsHub struct {
mu sync.RWMutex
writeMu sync.Mutex // gorilla websocket: one writer at a time
clients map[*websocket.Conn]struct{}
state DashboardState
liveStream bool
bleTicks bleTickTracker
lastPush time.Time // throttle BLE tick WS pushes
}
func newWSHub() *wsHub {
@@ -78,6 +164,10 @@ func (h *wsHub) setState(st DashboardState) {
h.mu.Lock()
prev := h.state
st.LiveStream = prev.LiveStream
if st.BleTick == nil && prev.BleTick != nil && st.LiveStream {
cp := *prev.BleTick
st.BleTick = &cp
}
st.Clients = preserveClientAccel(st.Clients, prev.Clients, st.LiveStream)
st.Clients = preserveClientBattery(st.Clients, prev.Clients)
st.Clients = preserveClientTap(st.Clients, prev.Clients)
@@ -122,15 +212,25 @@ func (h *wsHub) unregister(c *websocket.Conn) {
h.mu.Unlock()
}
// writeJSON sends to one client; removes it on error or panic (closed connection).
// dropClient removes the client and closes the socket so the browser reconnects.
func (h *wsHub) dropClient(c *websocket.Conn) {
h.unregister(c)
_ = c.Close()
}
// writeJSON sends to one client; removes+closes it on error or panic.
func (h *wsHub) writeJSON(c *websocket.Conn, data []byte) {
defer func() {
if recover() != nil {
h.unregister(c)
h.dropClient(c)
}
}()
if err := c.WriteMessage(websocket.TextMessage, data); err != nil {
h.unregister(c)
h.writeMu.Lock()
_ = c.SetWriteDeadline(time.Now().Add(2 * time.Second))
err := c.WriteMessage(websocket.TextMessage, data)
h.writeMu.Unlock()
if err != nil {
h.dropClient(c)
}
}
@@ -392,13 +492,15 @@ func (h *wsHub) snapshotClients() []ClientView {
return out
}
// patchLiveStream toggles host CACHE_STATUS polling (~16 ms).
// patchLiveStream toggles host CACHE_STATUS polling (~8 ms).
func (h *wsHub) patchLiveStream(enabled bool) {
h.mu.Lock()
h.liveStream = enabled
st := h.state
st.LiveStream = enabled
if !enabled {
h.bleTicks.reset()
st.BleTick = nil
for i := range st.Clients {
st.Clients[i].AccelValid = false
st.Clients[i].AccelX = 0
@@ -483,6 +585,49 @@ func (h *wsHub) mergeAccel(samples []*pb.AccelSample) {
}
}
// mergeBleStreamTick applies accel/tap from a BLE tick and updates inter-tick stats.
// State is updated every tick; WebSocket push is throttled (~50 Hz) so concurrent
// writers (poller/battery) cannot flood or race the gorilla conn.
func (h *wsHub) mergeBleStreamTick(tick *pb.BleStreamTick) {
if tick == nil || !h.liveStreamEnabled() {
return
}
cache := &pb.CacheStatusResponse{Clients: tick.GetClients()}
const minPushGap = 20 * time.Millisecond
h.mu.Lock()
view := h.bleTicks.add(tick.GetEspTimestampUs(), tick.GetPeriodMs())
st := h.state
st.Clients = applyAccelSamples(st.Clients, accelSamplesFromCacheStatus(cache))
st.Clients = applyTapEvents(st.Clients, tapEventsFromCacheStatus(cache))
st.BleTick = &view
st.UpdatedAt = time.Now().Format(time.RFC3339)
h.state = st
push := time.Since(h.lastPush) >= minPushGap
if push {
h.lastPush = time.Now()
}
var conns []*websocket.Conn
if push {
conns = make([]*websocket.Conn, 0, len(h.clients))
for c := range h.clients {
conns = append(conns, c)
}
}
h.mu.Unlock()
if !push {
return
}
data, err := json.Marshal(st)
if err != nil {
return
}
for _, c := range conns {
h.writeJSON(c, data)
}
}
func (h *wsHub) mergeTap(events []*pb.TapEvent) {
if len(events) == 0 || !h.liveStreamEnabled() {
return
@@ -516,20 +661,35 @@ func (h *wsHub) broadcastRaw(v any) {
}
func pollDashboard(link *managedSerial, portName string, last *DashboardState, streamCtl *accelStreamCtl, tapCtl *tapNotifyCtl) DashboardState {
ep := link.Endpoint()
if ep == "" {
ep = portName
}
tr := link.Transport()
st := DashboardState{
UpdatedAt: time.Now().Format(time.RFC3339),
SerialPort: portName,
Clients: []ClientView{},
UpdatedAt: time.Now().Format(time.RFC3339),
SerialPort: ep,
HostEndpoint: ep,
HostTransport: tr,
Clients: []ClientView{},
}
if !link.IsConnected() && tr == hostTransportNone {
st.SerialError = "not connected — use UART or BLE connect"
return st
}
ver, err := link.getVersionPoll()
if errors.Is(err, errUARTBusy) {
return pausedPollState(portName, last)
return pausedPollState(ep, last)
}
if err != nil {
return disconnectedState(portName, err)
return disconnectedState(ep, err)
}
st.UARTConnected = true
st.HostConnected = true
st.HostTransport = link.Transport()
st.HostEndpoint = link.Endpoint()
st.SerialPort = st.HostEndpoint
st.SerialOK = true
st.Master = MasterView{
Version: ver.GetVersion(),
@@ -540,6 +700,14 @@ func pollDashboard(link *managedSerial, portName string, last *DashboardState, s
if dz, err := readDeadzonePoll(link, 0); err == nil {
st.Master.Deadzone = dz
}
if tc, err := readTapConfigPoll(link, 0); err == nil {
st.Master.TapConfig = tc
}
if uv, err := readBatteryUvPoll(link, 0); err == nil {
en := uv.GetEnabled()
st.Master.UvProtection = &en
st.Master.UvLatched = uv.GetLatched()
}
clients, err := link.listClientsPoll()
if err != nil {
@@ -548,7 +716,8 @@ func pollDashboard(link *managedSerial, portName string, last *DashboardState, s
}
st.SerialOK = false
st.SerialError = err.Error()
st.UARTConnected = link.IsConnected()
st.HostConnected = link.IsConnected()
st.UARTConnected = st.HostConnected
return st
}
@@ -565,6 +734,7 @@ func pollDashboard(link *managedSerial, portName string, last *DashboardState, s
TapNotifySingle: c.GetTapNotifySingle(),
TapNotifyDouble: c.GetTapNotifyDouble(),
TapNotifyTriple: c.GetTapNotifyTriple(),
UvProtection: c.GetUvProtectionEnabled(),
}
st.Clients = append(st.Clients, cv)
}
@@ -574,6 +744,9 @@ func pollDashboard(link *managedSerial, portName string, last *DashboardState, s
if dz, err := readDeadzonePoll(link, c.GetId()); err == nil {
st.Clients[i].Deadzone = dz
}
if tc, err := readTapConfigPoll(link, c.GetId()); err == nil {
st.Clients[i].TapConfig = tc
}
}
}
if last != nil {
@@ -633,7 +806,7 @@ func runBatteryPoller(link *managedSerial, hub *wsHub, interval time.Duration, s
case <-stop:
return
case <-ticker.C:
if hub.clientCount() == 0 {
if !link.IsConnected() || link.PollPaused() {
continue
}
bat, err := link.BatteryStatusPoll(&pb.BatteryStatusRequest{AllClients: true})
@@ -654,7 +827,11 @@ func runCacheStatusDashboardPoller(link *managedSerial, hub *wsHub, interval tim
case <-stop:
return
case <-ticker.C:
if !hub.liveStreamEnabled() {
if !hub.liveStreamEnabled() || link.PollPaused() {
continue
}
// BLE live path prefers BLE_STREAM_TICK pushes.
if link.Transport() == hostTransportBLE {
continue
}
cache, err := link.readCacheStatusPoll()
@@ -667,6 +844,35 @@ func runCacheStatusDashboardPoller(link *managedSerial, hub *wsHub, interval tim
}
}
// runBLEStreamDashboardPoller feeds live accel/tap from BLE_STREAM_TICK when live_stream is on.
func runBLEStreamDashboardPoller(link *managedSerial, hub *wsHub, streamCtl *accelStreamCtl, stop <-chan struct{}) {
_ = streamCtl
for {
select {
case <-stop:
return
default:
}
if link.Transport() != hostTransportBLE || !hub.liveStreamEnabled() || link.PollPaused() {
select {
case <-stop:
return
case <-time.After(50 * time.Millisecond):
}
continue
}
payload, err := link.NextStreamTick(200 * time.Millisecond)
if err != nil {
continue
}
tick, err := parseBleStreamTickPayload(payload)
if err != nil {
continue
}
hub.mergeBleStreamTick(tick)
}
}
func (h *wsHub) clientCount() int {
h.mu.RLock()
n := len(h.clients)
@@ -675,11 +881,14 @@ func (h *wsHub) clientCount() int {
}
func pausedPollState(portName string, last *DashboardState) DashboardState {
if last != nil && last.UARTConnected {
if last != nil && (last.HostConnected || last.UARTConnected) {
st := *last
st.UpdatedAt = time.Now().Format(time.RFC3339)
st.SerialPort = portName
st.HostEndpoint = portName
st.SerialOK = true
st.HostConnected = true
st.UARTConnected = true
st.SerialError = "Live-Polling pausiert (OTA läuft)"
return st
}
@@ -714,6 +923,48 @@ func readDeadzonePoll(link *managedSerial, clientID uint32) (uint32, error) {
return r.GetDeadzone(), nil
}
func tapConfigViewFromPB(r *pb.TapConfigResponse) *TapConfigView {
return &TapConfigView{
TapSensThres: r.GetTapSensThres(),
MaxGestDur: r.GetMaxGestDur(),
TapShockDur: r.GetTapShockDur(),
QuiteTimeAfterGest: r.GetQuiteTimeAfterGest(),
WaitForTimeout: r.GetWaitForTimeout(),
AxisSel: r.GetAxisSel(),
EnableSingle: r.GetEnableSingle(),
EnableDouble: r.GetEnableDouble(),
EnableTriple: r.GetEnableTriple(),
}
}
func readBatteryUvPoll(link *managedSerial, clientID uint32) (*pb.BatteryUvResponse, error) {
r, err := link.BatteryUvPoll(&pb.BatteryUvRequest{
Write: false,
ClientId: clientID,
})
if err != nil {
return nil, err
}
if !r.GetSuccess() {
return nil, fmt.Errorf("battery uv read failed for client %d", clientID)
}
return r, nil
}
func readTapConfigPoll(link *managedSerial, clientID uint32) (*TapConfigView, error) {
r, err := link.TapConfigPoll(&pb.TapConfigRequest{
Write: false,
ClientId: clientID,
})
if err != nil {
return nil, err
}
if !r.GetSuccess() {
return nil, fmt.Errorf("tap config read failed for client %d", clientID)
}
return tapConfigViewFromPB(r), nil
}
func formatMAC(mac []byte) string {
if len(mac) == 0 {
return ""
@@ -726,20 +977,20 @@ func runPoller(link *managedSerial, portName string, hub *wsHub, streamCtl *acce
ticker := time.NewTicker(interval)
defer ticker.Stop()
uartUp := false
hostUp := false
var lastGood DashboardState
publish := func() {
st := pollDashboard(link, portName, &lastGood, streamCtl, tapCtl)
hub.setState(st)
if st.UARTConnected && st.SerialOK {
if st.HostConnected && st.SerialOK {
hub.mu.RLock()
lastGood = hub.state
hub.mu.RUnlock()
}
if st.UARTConnected && !uartUp {
log.Printf("UART %s connected", portName)
if st.HostConnected && !hostUp {
log.Printf("host %s connected (%s)", st.HostEndpoint, st.HostTransport)
}
uartUp = st.UARTConnected
hostUp = st.HostConnected
}
publish()
+238 -5
View File
@@ -1,6 +1,6 @@
# REST API
`go run . -port /dev/ttyUSB0 serve` starts two HTTP servers on the same UART link:
`go run . serve` (optional `-port` / `-ble`) starts two HTTP servers on the same `managedHost` (UART or BLE):
| Base URL | Flag | Used by |
|----------|------|---------|
@@ -27,6 +27,9 @@ GET /api/v1/
"name": "powerpod-external-api",
"version": "1",
"serial_port": "/dev/ttyUSB0",
"transport": "uart",
"endpoint": "/dev/ttyUSB0",
"connected": true,
"websocket": "/ws",
"default_interval_ms": 16,
"min_interval_ms": 1,
@@ -96,6 +99,7 @@ Body:
| `digit` | `digit` 0–10 |
| `blink` | `blink_ms`, `blink_count` |
| `find-me` | Locate pod |
| `battery-low` | First 4 LEDs red at ~10 % for 5 s (UV indicator test) |
Use `client_id` (`0` = master) or `all_clients` (+ optional `slaves_only`) for broadcast.
@@ -109,7 +113,48 @@ WebSocket: `set_led_ring` with the same fields plus `"type":"set_led_ring"` →
Used by the web UI; safe for scripts that drive the same features.
### Live stream (host `CACHE_STATUS` poll ~16 ms)
### Host link (UART / BLE)
```http
GET /api/host
→ {"transport":"none|uart|ble","endpoint":"…","connected":false,"ble_name":""}
GET /api/serial/ports
→ {"ports":["/dev/ttyUSB0",…]}
POST /api/host/connect
Content-Type: application/json
{"transport":"uart","port":"/dev/ttyUSB0"}
{"transport":"ble","address":"AA:BB:…","name":"Powerpod"}
POST /api/host/disconnect
POST /api/ble/scan
{"timeout_s":5,"name_filter":"Powerpod"}
→ {"devices":[{"address":"…","name":"Powerpod","rssi":-55}]}
```
`name_filter` is optional substring match (case-insensitive); omit to list all LE advertisements. Active BLE is disconnected before scan (BlueZ adapter).
Dashboard WebSocket state includes `host_connected`, `host_transport`, `host_endpoint` (aliases `uart_connected` / `serial_port` kept).
**OTA** (`POST /api/ota`) returns **400** unless a host (UART or BLE) is connected.
### BLE latency tests (BLE host only)
```http
POST /api/ble-rtt
{"count":50,"warmup":5,"settle_s":0}
→ {"success":true,"count":50,"warmup":5,"stats":{"n":50,"min_ms":…,"median_ms":…,"mean_ms":…,"max_ms":…}}
POST /api/ble-stream
{"count":50,"period_ms":8,"tol_ms":2,"timeout_s":15,"client_id":0}
→ {"success":true,"received":50,"requested":50,"window_ok":true,"dt_esp_ms":{…},"dr_pc_ms":{…},…}
```
Pauses dashboard/API polling while running. Requires active BLE host (`DIP8=1`).
### Live stream (host `CACHE_STATUS` poll ~8 ms; BLE uses `BLE_STREAM_TICK`)
```http
GET /api/live-stream
@@ -208,6 +253,46 @@ With `all_clients` + `slaves_only`: push to ESP-NOW slaves only (master BMA456 u
{"deadzone": 128, "client_id": 0, "success": true, "slaves_updated": 2}
```
### Tap config (BMA456 multitap)
```http
GET /api/tap-config?client_id=0
POST /api/tap-config
Content-Type: application/json
{
"write": true,
"client_id": 0,
"tap_sens_thres": 5,
"max_gest_dur": 100,
"tap_shock_dur": 6,
"quite_time_after_gest": 60,
"wait_for_timeout": 0,
"axis_sel": 2,
"enable_single": true,
"enable_double": true,
"enable_triple": true
}
```
Same `client_id` / `all_clients` / `slaves_only` semantics as deadzone. Values are applied to the sensor and persisted in NVS on each target node.
```json
{
"client_id": 0,
"success": true,
"slaves_updated": 0,
"tap_sens_thres": 5,
"max_gest_dur": 100,
"tap_shock_dur": 6,
"quite_time_after_gest": 60,
"wait_for_timeout": 0,
"axis_sel": 2,
"enable_single": true,
"enable_double": true,
"enable_triple": true
}
```
### Unicast test
```http
@@ -216,6 +301,54 @@ Content-Type: application/json
{"client_id": 16, "seq": 42}
```
### Echo ping (ESP-NOW round-trip latency)
```http
POST /api/echo-ping
Content-Type: application/json
{"client_id": 16}
```
`client_id` must be a registered slave id (`> 0`). The host sends a microsecond timestamp; the master forwards it over ESP-NOW and the slave echoes it back unchanged.
**Flow:** Host → UART → `cmd_espnow_echo_ping` → `ESPNOW_ECHO_PING` (with `master_time_us` from `esp_timer_get_time()`) → Slave → `ESPNOW_ECHO_PONG` → Master `recv_cb` → UART response.
**Response fields:**
| Field | Unit | Meaning |
|-------|------|---------|
| `success` | — | `true` if pong received within 500 ms |
| `client_id` | — | Echo of request |
| `timestamp_us` | µs (Unix) | Echoed host timestamp; must match request on success |
| `rtt_ms` | ms | **Host-side** round-trip: goTool send → UART response (full chain incl. USB serial) |
| `esp_rtt_us` | µs | **Master-side** ESP-NOW only: `esp_timer_get_time()` delta from ping send to pong recv |
`esp_rtt_us` is the raw firmware value (microseconds, not converted). The web dashboard displays it converted to milliseconds for readability (`esp_rtt_us / 1000`, 3 decimal places). The success banner stays visible for at least 5 seconds.
```json
{
"success": true,
"client_id": 16,
"timestamp_us": 1717654321123456,
"rtt_ms": 49.729,
"esp_rtt_us": 18234
}
```
On failure (`success: false`), `esp_rtt_us` is omitted; `rtt_ms` still reflects the host round-trip. HTTP 503 if UART exchange fails (e.g. timeout, client not in registry).
**CLI:**
```bash
go run . -port /dev/ttyUSB0 echo-ping -client 16
```
Example output:
```
echo ping: success=true client_id=16 rtt_ms=49.729 esp_rtt_us=18234
```
### Find me
```http
@@ -234,14 +367,103 @@ Content-Type: application/json
{"client_id": 16}
```
### OTA (master UART upload)
### Log level (master ESP-IDF console)
Runtime get/set of the **global** log filter on the master (`esp_log_level_set("*", …)`). Output goes to **UART0** (USB debug, 115200), not the host protocol UART (GPIO 2/3, 921600).
```http
GET /api/log-level
POST /api/log-level
Content-Type: application/json
{"write": true, "level": 3}
```
| `level` | Meaning |
|---------|---------|
| 0 | None (no log output) |
| 1 | Error |
| 2 | Warn |
| 3 | Info |
| 4 | Debug |
| 5 | Verbose |
```json
{"success": true, "level": 3}
```
**CLI:**
```bash
go run . -port /dev/ttyUSB0 log-level
go run . -port /dev/ttyUSB0 log-level -set -level 3
```
Dashboard: Master card → dropdown **ESP Log-Level** with **Lesen** / **Setzen**.
### Battery UV protection
Runtime enable for software LiPo under-voltage protection (NVS `uv_en` per pod). Disable also clears `uv_latch`. `all_clients` applies to master + every registered slave; `slaves_only` with `all_clients` skips the master.
```http
GET /api/battery-uv?client_id=0
POST /api/battery-uv
Content-Type: application/json
{"write": true, "enabled": false, "client_id": 0}
{"write": true, "enabled": false, "all_clients": true}
{"write": true, "enabled": false, "all_clients": true, "slaves_only": true}
```
```json
{"success": true, "client_id": 0, "enabled": false, "latched": false, "slaves_updated": 0}
```
```bash
go run . -port /dev/ttyUSB0 battery-uv
go run . -port /dev/ttyUSB0 battery-uv -set -enable=false -client 0
go run . -port /dev/ttyUSB0 battery-uv -set -enable=false -all
```
Dashboard: Master **UV-Schutz** checkbox + **Alle an/aus**; per-slave **UV** column.
### Input send mode (slave accel/tap ESP-NOW TX path)
Debug A/B for how slaves transmit `ACCEL_SAMPLE` / `TAP_EVENT`. Default on slaves is `best_effort` (0). Master remembers the last mode for GET; write pushes via ESP-NOW.
```http
GET /api/input-send-mode
PUT /api/input-send-mode
Content-Type: application/json
{"mode": 2, "all_clients": true}
```
| `mode` | Path |
|--------|------|
| 0 | `best_effort` (fire-and-forget, quiet) |
| 1 | `fast` (fire-and-forget, logs failures) |
| 2 | `wait` (send-done + retries) |
| 3 | `reliable` (OTA-style retries; can stall high-rate accel) |
```json
{"success": true, "mode": 2, "mode_name": "wait", "slaves_updated": 2}
```
**CLI:**
```bash
go run . -port /dev/ttyUSB0 input-send-mode
go run . -port /dev/ttyUSB0 input-send-mode -set -mode 2 -all
```
Dashboard: ESP-NOW Slaves toolbar → **Input-Send** dropdown + **Setzen** / **Lesen**.
### OTA (master host upload, UART or BLE)
```http
POST /api/ota
Content-Type: multipart/form-data
```
Form field **`firmware`**: binary image, max **2 MiB**.
Form field **`firmware`**: binary image, max **2 MiB**. Requires connected host (`transport` uart or ble). BLE needs ATT MTU ≥ 219 for 200 B chunks.
```json
{"success": true, "bytes_written": 123456, "target_slot": 1}
@@ -249,7 +471,12 @@ Form field **`firmware`**: binary image, max **2 MiB**.
Firmware distributes to slaves over ESP-NOW after `OTA_END`. Progress also appears on dashboard WebSocket as `ota_progress` messages.
CLI equivalent: `go run . -port /dev/ttyUSB0 ota build/powerpod.bin`
CLI equivalent:
```bash
go run . -port /dev/ttyUSB0 ota build/powerpod.bin
go run . -ble ota build/powerpod.bin
```
### LED ring and battery
@@ -279,6 +506,12 @@ Same as external API:
| Nur Master deadzone | `POST /api/deadzone` `client_id: 0` or CLI `deadzone -set -client 0` |
| Einzelner Slave | `client_id: <id>` |
| Alle Slaves deadzone | `all_clients` + `slaves_only` on POST |
| Master tap config | `GET` / `POST /api/tap-config` `client_id: 0` or CLI `tap-config` |
| Tap-Cfg alle Slaves | `POST /api/tap-config` with `all_clients` + `slaves_only` |
| Unicast test | `POST /api/unicast-test` |
| Echo ping | `POST /api/echo-ping` (per-slave latency; UI shows Host ms + ESP ms) |
| Master log level | `GET` / `POST /api/log-level` or CLI `log-level` |
| UV protection | `GET` / `POST /api/battery-uv` or CLI `battery-uv` |
| Input-Send mode | `GET` / `PUT /api/input-send-mode` or CLI `input-send-mode` |
| Tap notify S/D/T | `PUT /api/clients/{id}/tap-notify` |
| Tap receive (UI) | Live stream + tap notify; see WebSocket doc for external API |
+104 -100
View File
@@ -1,58 +1,62 @@
# WebSocket API
`go run . -port /dev/ttyUSB0 serve` exposes the WebSocket enpoint
External API: `ws://localhost:8081/ws` (default `-api-addr`, disable with empty string).
| URL | Port (default) | Role |
|-----|----------------|------|
| `ws://localhost:8081/ws` | External API (`-api-addr`) | Request/response commands + optional **input** push stream |
Start with `go run . serve` (optional `-port` / `-ble` to pre-connect). Host can be opened later via `host_connect` / dashboard REST.
---
## External API (`:8081/ws`)
## Connection flow
### Connection flow
1. Connect → server sends `hello` (host status, push off; defaults and command list).
2. Optionally `ble_scan` / `list_serial_ports` then `host_connect` if not already linked.
3. Send JSON commands → one reply per message (`*_status`, `client_list`, `ble_scan_result`, …).
4. After `set_stream` with `enable: true`, server may push `input` messages without a prior command.
1. Connect → server sends **`hello`** (receive off; lists available commands).
2. Send JSON commands → server replies with a matching `*_status` or `client_list` message (one reply per command).
3. After `set_stream` with `enable: true`, the server may send **`input`** messages **without** a prior command (push stream).
Commands and pushes share one socket — always branch on `type`.
Commands and stream pushes are multiplexed on one socket. While streaming, always parse `type` and branch (status vs sample vs error).
On disconnect, `set_stream` state for that socket is dropped. Firmware settings (`set_input_stream`, `set_tap_notify`) stay on the master until changed.
### Two layers (firmware vs host)
---
| Layer | Commands | Effect |
|-------|----------|--------|
| **Firmware (ESP-NOW)** | `set_input_stream`, `set_tap_notify` | Per `client_id`: slave sends accel samples and/or tap events to the master |
| **This connection (host)** | `set_stream` | Whether **you** receive push JSON, at what rate (`interval_ms`, 1 ms … 10 s), and how early the UART read starts (`pre_fetch`) |
## Two layers (firmware vs host)
- **UART polling** runs only if at least one connection has `receive_input: true` (`set_stream`) **and** at least one slave streams input (`set_input_stream`) or has tap notify enabled (`set_tap_notify`).
- **`set_tap_notify` alone** configures which tap kinds the slave reports; it does **not** enable host push by itself — you still need `set_stream`.
| Layer | Commands | Effect |
| ------------------ | ------------------------------------ | ------------------------------------------------------------------ |
| Firmware (ESP-NOW) | `set_input_stream`, `set_tap_notify` | Per `client_id`: slave sends accel and/or tap events to the master |
| This connection | `set_stream` | Whether you receive push JSON on this socket |
Host polling / BLE ticks run only when at least one connection has `receive_input: true` **and** at least one slave streams input or has tap notify enabled. `set_tap_notify` alone does not enable push — you still need `set_stream`.
On **BLE**, live `input` prefers firmware `BLE_STREAM_TICK` pushes; otherwise falls back to `CACHE_STATUS` like UART.
### Push timing (per connection)
| Field | Where | Meaning |
| ------------- | -------------------------------------- | ------------------------------------------------------------ |
| `interval_ms` | `hello`, `set_stream`, `stream_status` | Minimum ms between `input` pushes on this socket (1 … 10000) |
| `pre_fetch` | `set_stream`, `stream_status` | Ms before each push when the host starts the UART cache read |
Global host poll interval = minimum `interval_ms` among all connections with push enabled.
Typical sequence:
1. `list_clients` → slave IDs
2. Per slave: `set_input_stream` and/or `set_tap_notify` as needed
2. Per slave: `set_input_stream` and/or `set_tap_notify`
3. `set_stream` with `"enable": true`
4. Read **`input`** messages in a loop
There is **no per-slave filter** on push messages: each `input` contains all cached slaves. Filter by `client_id` in your app.
4. Read `input` messages; filter by `client_id` in your app (no per-slave filter on the wire)
---
## Push stream messages
## Push: `input`
These are the samples you get after enabling receive. Timing is per WebSocket connection:
Combines latest accel cache and visible tap state for every slave slot on the master.
- **`interval_ms`** — minimum time between consecutive `input` pushes on this socket.
- **`pre_fetch`** — milliseconds **before** each scheduled push when the host sends the UART cache read, so the master has time to collect data from all slaves before the JSON goes out.
The server UART poll uses the **minimum** `interval_ms` among all subscribers with `receive_input: true`.
### `input` (type `"input"`)
Sent when `set_stream` has `enable: true` and the poll tick fires for this connection (after the UART read started `pre_fetch` ms earlier). Each message combines the latest accel cache and visible tap state for every slave slot on the master.
**Success** — all slaves with a cache entry (not only those with `valid: true`):
**Success:**
```json
{
@@ -72,57 +76,56 @@ Sent when `set_stream` has `enable: true` and the poll tick fires for this conne
},
{
"client_id": 42,
"valid": false,
"tap_kind": "none"
"valid": false
}
]
}
```
| Field | Meaning |
|-------|---------|
| `t` | Unix timestamp in **nanoseconds** when the host read the cache |
| `success` | `true` if `CACHE_STATUS` succeeded |
| `clients[]` | One entry per slave slot in the master cache |
| `client_id` | ESP-NOW client id (same as `list_clients`) |
| `valid` | `false` if no accel sample yet or stale; omit `x`/`y`/`z` when false |
| `x`, `y`, `z` | Raw accelerometer LSB (BMA456, ±2 g scale on the pod) |
| `accel_age_ms` | Milliseconds since the master received this accel sample |
| `tap_kind` | `"none"`, `"single"`, `"double"`, or `"triple"` |
| `tap_age_ms` | Milliseconds since the tap was seen in the master cache; omit when `tap_kind` is `"none"` |
Tap events stay visible for **`tap_display_min_ms`** (2000 ms, also in `hello`) after the API first saw them, even if the hardware age grows.
| Field | Meaning |
| -------------- | -------------------------------------------------------------------- |
| `t` | Unix timestamp in nanoseconds when the host read the cache |
| `success` | `true` if `CACHE_STATUS` succeeded |
| `clients[]` | One entry per slave slot (includes invalid/stale entries) |
| `client_id` | Same id as in `list_clients` |
| `valid` | `false` if no accel sample yet or stale; omit `x`/`y`/`z` when false |
| `x`, `y`, `z` | Raw accelerometer LSB (BMA456, ±2 g) |
| `accel_age_ms` | Ms since the master received this accel sample |
| `tap_kind` | `"single"`, `"double"`, or `"triple"`; omit when no recent tap |
| `tap_age_ms` | Ms since tap in master cache; omit with `tap_kind` |
**Failure** (e.g. UART busy):
Tap events stay visible for `tap_display_min_ms` (2000, in `hello`) after the API first saw them.
**Failure** (no `clients` array):
```json
{
"type": "input",
"t": 1716900123456789012,
"success": false,
"error": "uart busy"
}
{"type":"input","t":1716900123456789012,"success":false,"error":"uart busy"}
```
No `clients` array on failure.
---
## Commands (request → response)
## Commands
Send one JSON object per message. Field `type` selects the command.
One JSON object per message; field `type` selects the command.
### `hello` (server → client, on connect)
**Errors:** Replies use the matching response `type`. On failure: `success: false` (or omitted) and `"error": "…"`. Malformed JSON or unknown `type` → `stream_status` with `error`.
### `hello` (server → client)
```json
{
"type": "hello",
"serial_port": "/dev/ttyUSB0",
"transport": "uart",
"endpoint": "/dev/ttyUSB0",
"connected": true,
"interval_ms": 16,
"pre_fetch_ms": 2,
"tap_display_min_ms": 2000,
"note": "set_tap_notify configures slave S/D/T only; set_stream enables input polling/push on this connection",
"commands": [
"get_host", "list_serial_ports", "ble_scan", "host_connect", "host_disconnect",
"list_clients",
"set_stream", "get_stream",
"set_input_stream", "get_input_stream",
@@ -132,6 +135,23 @@ Send one JSON object per message. Field `type` selects the command.
}
```
### Host link
| Client `type` | Reply `type` | Notes |
|---------------|--------------|--------|
| `get_host` | `host_status` | `transport`, `endpoint`, `connected` |
| `list_serial_ports` | `serial_ports` | `{ports:[…]}` |
| `ble_scan` | `ble_scan_result` | optional `timeout_s`, `name_filter`; disconnects active BLE first |
| `host_connect` | `host_status` | `transport:"uart","port"` or `transport:"ble","address","name?"` |
| `host_disconnect` | `host_status` | |
```json
{"type":"host_connect","transport":"ble","address":"AA:BB:CC:DD:EE:FF","name":"Powerpod"}
{"type":"host_status","transport":"ble","endpoint":"AA:BB:…","connected":true,"success":true}
```
If the host is not connected, other commands that need the master return their usual error in the status reply.
### `list_clients`
Request: `{"type":"list_clients"}`
@@ -146,11 +166,7 @@ Response `client_list`:
{
"id": 16,
"mac": "aa:bb:cc:dd:ee:10",
"version": 1,
"available": true,
"used": true,
"last_ping": 1234,
"last_success_ping": 1200,
"input_stream": false,
"tap_notify_single": false,
"tap_notify_double": false,
@@ -160,28 +176,24 @@ Response `client_list`:
}
```
### `set_stream` / `get_stream` (receive input on this connection)
Also per client: `version`, `used`, `last_ping`, `last_success_ping`.
### `set_stream` / `get_stream`
```json
{"type":"set_stream","enable":true,"interval_ms":32,"pre_fetch":2}
{"type":"get_stream"}
```
| Field | Meaning |
|-------|---------|
| `enable` | Turn push stream on/off for this connection |
| `interval_ms` | Minimum time between `input` pushes (1 … 10000) |
| `pre_fetch` | Milliseconds before each push when the host starts the UART cache read; optional, default in `hello` (`pre_fetch_ms`) |
Response `stream_status`:
```json
{"type":"stream_status","receive_input":true,"interval_ms":32,"pre_fetch":2,"success":true}
```
### `set_input_stream` / `get_input_stream` (firmware, per slave)
### `set_input_stream` / `get_input_stream` (firmware)
`client_id` required (> 0). Enables accel streaming from the slave to the master.
`client_id` required (> 0).
```json
{"type":"set_input_stream","client_id":16,"enable":true}
@@ -194,51 +206,44 @@ Response `input_stream_status`:
{"type":"input_stream_status","client_id":16,"enabled":true,"success":true}
```
### `set_tap_notify` / `get_tap_notify` (firmware, per slave)
### `set_tap_notify` / `get_tap_notify` (firmware)
Per client: `single`, `double_tap`, `triple` required on set.
Set requires `single`, `double_tap`, `triple` per client, or `"all_clients": true` for broadcast.
```json
{"type":"set_tap_notify","client_id":16,"single":true,"double_tap":false,"triple":false}
{"type":"get_tap_notify","client_id":16}
```
Broadcast: `"all_clients": true` with the three booleans.
Response `tap_notify_status`:
```json
{
"type": "tap_notify_status",
"client_id": 16,
"success": true,
"single": true,
"double_tap": false,
"triple": false
}
{"type":"tap_notify_status","client_id":16,"success":true,"single":true,"double_tap":false,"triple":false}
```
### `set_led_ring`
Control the LED ring on the master or a slave.
```json
{"type":"set_led_ring","mode":"color","client_id":16,"r":255,"g":0,"b":0,"intensity":128}
{"type":"set_led_ring","mode":"digit","client_id":0,"digit":3,"r":0,"g":255,"b":0}
{"type":"set_led_ring","mode":"find-me","all_clients":true,"slaves_only":true}
```
| `mode` | Notes |
|--------|--------|
| `clear` | Turn off |
| `color` | Full ring RGB + `intensity` |
| `progress` | `progress` 0–100 |
| `digit` | `digit` 0–10 |
| `blink` | `blink_ms`, `blink_count` |
| `find-me` | Locate pod |
Use `client_id` (`0` = master) or `all_clients` (+ optional `slaves_only`) for broadcast.
| Request `mode` | Notes |
| -------------- | --------------------------- |
| `clear` | Turn off |
| `color` | Full ring RGB + `intensity` |
| `progress` | `progress` 0–100 |
| `digit` | `digit` 0–10 |
| `blink` | `blink_ms`, `blink_count` |
| `find-me` | Locate pod |
| `battery-low` | UV indicator (4 LEDs, 5 s) |
Response `led_ring_status`:
Target: `client_id` (`0` = master) or `all_clients` (+ optional `slaves_only`).
Response `led_ring_status` — `mode` is numeric: 0=clear, 1=progress, 2=digit, 3=blink, 4=find-me, 5=color, 6=battery-low.
```json
{"type":"led_ring_status","success":true,"mode":5,"client_id":16,"slaves_updated":1}
@@ -246,15 +251,13 @@ Response `led_ring_status`:
### `get_battery`
Read cached battery samples from the master. Slaves push battery every **30 s**; this command reads the master cache.
Slaves push battery every 30 s; this reads the master cache. Default: all clients.
```json
{"type":"get_battery","all_clients":true}
{"type":"get_battery","client_id":16}
```
Default if omitted: all clients.
Response `battery_status`:
```json
@@ -271,3 +274,4 @@ Response `battery_status`:
]
}
```
+10
View File
@@ -6,9 +6,19 @@ require (
github.com/gorilla/websocket v1.5.3
go.bug.st/serial v1.6.4
google.golang.org/protobuf v1.36.11
tinygo.org/x/bluetooth v0.13.0
)
require (
github.com/creack/goselect v0.1.2 // indirect
github.com/go-ole/go-ole v1.2.6 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/saltosystems/winrt-go v0.0.0-20240509164145-4f7860a3bd2b // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/soypat/cyw43439 v0.0.0-20250505012923-830110c8f4af // indirect
github.com/soypat/seqs v0.0.0-20250124201400-0d65bc7c1710 // indirect
github.com/tinygo-org/cbgo v0.0.4 // indirect
github.com/tinygo-org/pio v0.2.0 // indirect
golang.org/x/exp v0.0.0-20241204233417-43b7b7cde48d // indirect
golang.org/x/sys v0.19.0 // indirect
)
+31
View File
@@ -1,20 +1,51 @@
github.com/creack/goselect v0.1.2 h1:2DNy14+JPjRBgPzAd1thbQp4BSIihxcBf0IXhQXDRa0=
github.com/creack/goselect v0.1.2/go.mod h1:a/NhLweNvqIYMuxcMOuWY516Cimucms3DglDzQP3hKY=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/saltosystems/winrt-go v0.0.0-20240509164145-4f7860a3bd2b h1:du3zG5fd8snsFN6RBoLA7fpaYV9ZQIsyH9snlk2Zvik=
github.com/saltosystems/winrt-go v0.0.0-20240509164145-4f7860a3bd2b/go.mod h1:CIltaIm7qaANUIvzr0Vmz71lmQMAIbGJ7cvgzX7FMfA=
github.com/sirupsen/logrus v1.5.0/go.mod h1:+F7Ogzej0PZc/94MaYx/nvG9jOFMD2osvC3s+Squfpo=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/soypat/cyw43439 v0.0.0-20250505012923-830110c8f4af h1:ZfFq94aH/BCSWWKd9RPUgdHOdgGKCnfl2VdvU9UksTA=
github.com/soypat/cyw43439 v0.0.0-20250505012923-830110c8f4af/go.mod h1:MUaGO5m6X7xrkHrPDmnaxCEcuCCFN/0ZFh9oie+exbU=
github.com/soypat/seqs v0.0.0-20250124201400-0d65bc7c1710 h1:Y9fBuiR/urFY/m76+SAZTxk2xAOS2n85f+H1CugajeA=
github.com/soypat/seqs v0.0.0-20250124201400-0d65bc7c1710/go.mod h1:oCVCNGCHMKoBj97Zp9znLbQ1nHxpkmOY9X+UAGzOxc8=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/tinygo-org/cbgo v0.0.4 h1:3D76CRYbH03Rudi8sEgs/YO0x3JIMdyq8jlQtk/44fU=
github.com/tinygo-org/cbgo v0.0.4/go.mod h1:7+HgWIHd4nbAz0ESjGlJ1/v9LDU1Ox8MGzP9mah/fLk=
github.com/tinygo-org/pio v0.2.0 h1:vo3xa6xDZ2rVtxrks/KcTZHF3qq4lyWOntvEvl2pOhU=
github.com/tinygo-org/pio v0.2.0/go.mod h1:LU7Dw00NJ+N86QkeTGjMLNkYcEYMor6wTDpTCu0EaH8=
go.bug.st/serial v1.6.4 h1:7FmqNPgVp3pu2Jz5PoPtbZ9jJO5gnEnZIvnI1lzve8A=
go.bug.st/serial v1.6.4/go.mod h1:nofMJxTeNVny/m6+KaafC6vJGj3miwQZ6vW4BZUGJPI=
golang.org/x/exp v0.0.0-20241204233417-43b7b7cde48d h1:0olWaB5pg3+oychR51GUVCEsGkeCU/2JxjBgIo4f3M0=
golang.org/x/exp v0.0.0-20241204233417-43b7b7cde48d/go.mod h1:qj5a5QZpwLU2NLQudwIN5koi3beDhSAlJwa67PuM98c=
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o=
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
tinygo.org/x/bluetooth v0.13.0 h1:3pkTMcfqv71HoAxG4DBTm2n+1bm6Nqqz8eoHjSW9+5g=
tinygo.org/x/bluetooth v0.13.0/go.mod h1:YnyJRVX09i+wkFeHpXut0b+qHq+T2WwKBRRiF/scANA=
+266
View File
@@ -0,0 +1,266 @@
package main
import (
"fmt"
"time"
"google.golang.org/protobuf/proto"
pb "powerpod/gotool/pb"
)
// hostConn is the UART or BLE host transport used by CLI commands.
type hostConn interface {
exchangePayload(payload []byte, cmdName string) ([]byte, error)
exchange(cmdID byte, cmdName string) ([]byte, error)
Close() error
}
// optional stream source (BLE only)
type hostStreamConn interface {
hostConn
// NextStreamTick waits for a BLE_STREAM_TICK payload (type byte + protobuf).
NextStreamTick(timeout time.Duration) ([]byte, error)
}
func hostGetVersion(h hostConn) (*pb.VersionResponse, error) {
payload, err := h.exchange(byte(pb.MessageType_VERSION), "VERSION")
if err != nil {
return nil, err
}
return decodeVersionPayload(payload)
}
func hostListClients(h hostConn) ([]*pb.ClientInfo, error) {
payload, err := h.exchange(byte(pb.MessageType_CLIENT_INFO), "CLIENT_INFO")
if err != nil {
return nil, err
}
return decodeClientsPayload(payload)
}
func hostReadCacheStatus(h hostConn) (*pb.CacheStatusResponse, error) {
payload, err := h.exchange(byte(pb.MessageType_CACHE_STATUS), "CACHE_STATUS")
if err != nil {
return nil, err
}
return decodeCacheStatusPayload(payload)
}
func hostAccelStream(h hostConn, req *pb.AccelStreamRequest) (*pb.AccelStreamResponse, error) {
msg := &pb.UartMessage{
Type: pb.MessageType_ACCEL_STREAM,
Payload: &pb.UartMessage_AccelStreamRequest{
AccelStreamRequest: req,
},
}
body, err := proto.Marshal(msg)
if err != nil {
return nil, fmt.Errorf("encode: %w", err)
}
payload := append([]byte{byte(pb.MessageType_ACCEL_STREAM)}, body...)
respPayload, err := h.exchangePayload(payload, "ACCEL_STREAM")
if err != nil {
return nil, err
}
var respMsg pb.UartMessage
if err := proto.Unmarshal(respPayload[1:], &respMsg); err != nil {
return nil, fmt.Errorf("decode: %w", err)
}
r := respMsg.GetAccelStreamResponse()
if r == nil {
return nil, fmt.Errorf("missing accel_stream_response")
}
return r, nil
}
func hostFindMe(h hostConn, clientID uint32) error {
msg := &pb.UartMessage{
Type: pb.MessageType_FIND_ME,
Payload: &pb.UartMessage_EspnowFindMeRequest{
EspnowFindMeRequest: &pb.EspNowFindMeRequest{ClientId: clientID},
},
}
body, err := proto.Marshal(msg)
if err != nil {
return fmt.Errorf("encode: %w", err)
}
payload := append([]byte{byte(pb.MessageType_FIND_ME)}, body...)
respPayload, err := h.exchangePayload(payload, "FIND_ME")
if err != nil {
return err
}
var respMsg pb.UartMessage
if err := proto.Unmarshal(respPayload[1:], &respMsg); err != nil {
return fmt.Errorf("decode: %w", err)
}
r := respMsg.GetEspnowFindMeResponse()
if r == nil {
return fmt.Errorf("missing espnow_find_me_response")
}
if !r.GetSuccess() {
return fmt.Errorf("find-me rejected (client_id=%d)", r.GetClientId())
}
return nil
}
func hostRestart(h hostConn, clientID uint32) error {
msg := &pb.UartMessage{
Type: pb.MessageType_RESTART,
Payload: &pb.UartMessage_RestartRequest{
RestartRequest: &pb.RestartRequest{ClientId: clientID},
},
}
body, err := proto.Marshal(msg)
if err != nil {
return fmt.Errorf("encode: %w", err)
}
payload := append([]byte{byte(pb.MessageType_RESTART)}, body...)
respPayload, err := h.exchangePayload(payload, "RESTART")
if err != nil {
return err
}
var respMsg pb.UartMessage
if err := proto.Unmarshal(respPayload[1:], &respMsg); err != nil {
return fmt.Errorf("decode: %w", err)
}
r := respMsg.GetRestartResponse()
if r == nil {
return fmt.Errorf("missing restart_response")
}
if !r.GetSuccess() {
return fmt.Errorf("restart rejected (client_id=%d)", r.GetClientId())
}
return nil
}
func hostSetLogLevel(h hostConn, write bool, level uint32) (*pb.SetLogLevelResponse, error) {
msg := &pb.UartMessage{
Type: pb.MessageType_SET_LOG_LEVEL,
Payload: &pb.UartMessage_SetLogLevelRequest{
SetLogLevelRequest: &pb.SetLogLevelRequest{Write: write, Level: level},
},
}
body, err := proto.Marshal(msg)
if err != nil {
return nil, fmt.Errorf("encode: %w", err)
}
payload := append([]byte{byte(pb.MessageType_SET_LOG_LEVEL)}, body...)
respPayload, err := h.exchangePayload(payload, "SET_LOG_LEVEL")
if err != nil {
return nil, err
}
var respMsg pb.UartMessage
if err := proto.Unmarshal(respPayload[1:], &respMsg); err != nil {
return nil, fmt.Errorf("decode: %w", err)
}
r := respMsg.GetSetLogLevelResponse()
if r == nil {
return nil, fmt.Errorf("missing set_log_level_response")
}
return r, nil
}
func hostInputSendMode(h hostConn, req *pb.InputSendModeRequest) (*pb.InputSendModeResponse, error) {
msg := &pb.UartMessage{
Type: pb.MessageType_INPUT_SEND_MODE,
Payload: &pb.UartMessage_InputSendModeRequest{
InputSendModeRequest: req,
},
}
body, err := proto.Marshal(msg)
if err != nil {
return nil, fmt.Errorf("encode: %w", err)
}
payload := append([]byte{byte(pb.MessageType_INPUT_SEND_MODE)}, body...)
respPayload, err := h.exchangePayload(payload, "INPUT_SEND_MODE")
if err != nil {
return nil, err
}
var respMsg pb.UartMessage
if err := proto.Unmarshal(respPayload[1:], &respMsg); err != nil {
return nil, fmt.Errorf("decode: %w", err)
}
r := respMsg.GetInputSendModeResponse()
if r == nil {
return nil, fmt.Errorf("missing input_send_mode_response")
}
return r, nil
}
func hostLedRing(h hostConn, req *pb.LedRingProgressRequest) (*pb.LedRingProgressResponse, error) {
msg := &pb.UartMessage{
Type: pb.MessageType_LED_RING,
Payload: &pb.UartMessage_LedRingProgressRequest{
LedRingProgressRequest: req,
},
}
body, err := proto.Marshal(msg)
if err != nil {
return nil, fmt.Errorf("encode: %w", err)
}
payload := append([]byte{byte(pb.MessageType_LED_RING)}, body...)
respPayload, err := h.exchangePayload(payload, "LED_RING")
if err != nil {
return nil, err
}
var respMsg pb.UartMessage
if err := proto.Unmarshal(respPayload[1:], &respMsg); err != nil {
return nil, fmt.Errorf("decode: %w", err)
}
r := respMsg.GetLedRingProgressResponse()
if r == nil {
return nil, fmt.Errorf("missing led_ring_progress_response")
}
return r, nil
}
func hostEchoPing(h hostConn, clientID uint32) (*EchoPingResult, error) {
t0 := time.Now()
timestampUs := uint64(t0.UnixMicro())
req := &pb.EspNowEchoPingRequest{
ClientId: clientID,
TimestampUs: timestampUs,
}
msg := &pb.UartMessage{
Type: pb.MessageType_ESPNOW_ECHO_PING,
Payload: &pb.UartMessage_EspnowEchoPingRequest{
EspnowEchoPingRequest: req,
},
}
body, err := proto.Marshal(msg)
if err != nil {
return nil, fmt.Errorf("encode: %w", err)
}
payload := append([]byte{byte(pb.MessageType_ESPNOW_ECHO_PING)}, body...)
respPayload, err := h.exchangePayload(payload, "ESPNOW_ECHO_PING")
if err != nil {
return nil, err
}
rttMs := float64(time.Since(t0).Microseconds()) / 1000.0
var respMsg pb.UartMessage
if err := proto.Unmarshal(respPayload[1:], &respMsg); err != nil {
return nil, fmt.Errorf("decode: %w", err)
}
r := respMsg.GetEspnowEchoPingResponse()
if r == nil {
return nil, fmt.Errorf("missing espnow_echo_ping_response")
}
if !r.GetSuccess() {
return &EchoPingResult{
Success: false,
ClientID: r.GetClientId(),
RttMs: rttMs,
}, nil
}
if r.GetTimestampUs() != timestampUs {
return nil, fmt.Errorf("timestamp mismatch: sent %d got %d", timestampUs, r.GetTimestampUs())
}
return &EchoPingResult{
Success: true,
ClientID: r.GetClientId(),
TimestampUs: r.GetTimestampUs(),
RttMs: rttMs,
EspRttUs: r.GetEspRttUs(),
}, nil
}
+491
View File
@@ -0,0 +1,491 @@
package main
import (
"fmt"
"log"
"strings"
"sync"
"time"
"go.bug.st/serial"
"tinygo.org/x/bluetooth"
)
const (
hostTransportNone = "none"
hostTransportUART = "uart"
hostTransportBLE = "ble"
)
// errHostBusy is returned when the link is held for OTA.
var errHostBusy = errUARTBusy
// managedHost is a reconnecting UART or BLE host link for serve/CLI.
type managedHost struct {
quiet bool
baud int
settle time.Duration
mu sync.Mutex
transport string // none|uart|ble
endpoint string // port path or BLE address string
bleName string // last connected BLE local name (optional)
sp *serialPort
ble *bleLink
otaActive bool
pausePoll bool // true while BLE latency tests run (pollers yield)
uartPort string // remembered UART port for reconnect
}
func newManagedHost(baud int) *managedHost {
if baud <= 0 {
baud = defaultBaud
}
return &managedHost{
baud: baud,
transport: hostTransportNone,
settle: time.Second,
}
}
// newManagedSerial keeps the old constructor name for call sites during migrate.
func newManagedSerial(portName string, baud int) *managedHost {
m := newManagedHost(baud)
if portName != "" {
m.uartPort = portName
m.endpoint = portName
m.transport = hostTransportUART
}
return m
}
func (m *managedHost) Transport() string {
m.mu.Lock()
defer m.mu.Unlock()
if m.transport == "" {
return hostTransportNone
}
return m.transport
}
func (m *managedHost) Endpoint() string {
m.mu.Lock()
defer m.mu.Unlock()
return m.endpoint
}
func (m *managedHost) Close() error {
m.mu.Lock()
defer m.mu.Unlock()
return m.disconnectLocked()
}
func (m *managedHost) IsConnected() bool {
m.mu.Lock()
defer m.mu.Unlock()
switch m.transport {
case hostTransportUART:
return m.sp != nil
case hostTransportBLE:
return m.ble != nil
default:
return false
}
}
func (m *managedHost) disconnectLocked() error {
var err error
if m.sp != nil {
err = m.sp.port.Close()
m.sp = nil
}
if m.ble != nil {
e := m.ble.Close()
if err == nil {
err = e
}
m.ble = nil
}
m.transport = hostTransportNone
return err
}
func (m *managedHost) Disconnect() error {
m.mu.Lock()
defer m.mu.Unlock()
ep := m.endpoint
err := m.disconnectLocked()
if !m.quiet && ep != "" {
log.Printf("host disconnected (%s)", ep)
}
return err
}
func (m *managedHost) ConnectUART(port string, baud int) error {
if port == "" {
return fmt.Errorf("UART port required")
}
if baud <= 0 {
baud = m.baud
}
m.mu.Lock()
defer m.mu.Unlock()
if m.otaActive {
return errHostBusy
}
_ = m.disconnectLocked()
m.baud = baud
m.uartPort = port
m.endpoint = port
m.transport = hostTransportUART
if err := m.openUARTLocked(); err != nil {
m.transport = hostTransportNone
m.endpoint = ""
return err
}
return nil
}
func (m *managedHost) ConnectBLE(address string, name string) error {
if address == "" && name == "" {
return fmt.Errorf("BLE address or name required")
}
m.mu.Lock()
defer m.mu.Unlock()
if m.otaActive {
return errHostBusy
}
_ = m.disconnectLocked()
var link *bleLink
var err error
settle := m.settle
m.mu.Unlock()
if address != "" {
link, err = openBLEAddress(address, settle)
} else {
link, err = openBLE(name, settle)
}
m.mu.Lock()
if err != nil {
return err
}
link.quiet = m.quiet
m.ble = link
m.transport = hostTransportBLE
if address != "" {
m.endpoint = address
} else {
m.endpoint = name
}
m.bleName = name
if !m.quiet {
log.Printf("BLE host connected (%s)", m.endpoint)
}
return nil
}
func (m *managedHost) openUARTLocked() error {
sp, err := openSerial(m.uartPort, m.baud)
if err != nil {
return err
}
sp.quiet = m.quiet
m.sp = sp
if !m.quiet {
log.Printf("UART %s connected (%d baud)", m.uartPort, m.baud)
}
return nil
}
func (m *managedHost) openLocked() error {
return m.openUARTLocked()
}
func (m *managedHost) closeLocked() error {
return m.disconnectLocked()
}
func (m *managedHost) invalidateLocked(reason error) {
if m.sp == nil && m.ble == nil {
return
}
if !m.quiet {
log.Printf("host %s disconnected: %v", m.endpoint, reason)
}
_ = m.disconnectLocked()
// remember transport intent for UART reconnect
if m.uartPort != "" && m.transport == hostTransportNone {
// after invalidate, leave as none; poller will try Connect again via open
m.transport = hostTransportUART
m.endpoint = m.uartPort
}
}
func (m *managedHost) withPort(fn func(*serialPort) error) error {
return m.withPortLocked(false, fn)
}
func (m *managedHost) withPortPoll(fn func(*serialPort) error) error {
return m.withPortLocked(true, fn)
}
func (m *managedHost) withPortLocked(poll bool, fn func(*serialPort) error) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.otaActive {
return errHostBusy
}
if m.transport == hostTransportBLE {
return fmt.Errorf("operation requires UART host (DIP8=0)")
}
if m.transport != hostTransportUART && m.uartPort == "" {
return fmt.Errorf("not connected")
}
if m.transport == hostTransportNone && m.uartPort != "" {
m.transport = hostTransportUART
m.endpoint = m.uartPort
}
if m.sp == nil {
if err := m.openUARTLocked(); err != nil {
return fmt.Errorf("%s: %w", m.uartPort, err)
}
}
err := fn(m.sp)
if err != nil {
m.invalidateLocked(err)
}
return err
}
func (m *managedHost) recoverAfterMasterRestart() {
const bootWait = 6 * time.Second
m.mu.Lock()
tr := m.transport
m.disconnectLocked()
if tr == hostTransportUART {
m.transport = hostTransportUART
m.endpoint = m.uartPort
}
m.mu.Unlock()
log.Printf("host: master restart — waiting %s for boot", bootWait)
time.Sleep(bootWait)
m.mu.Lock()
defer m.mu.Unlock()
if m.transport == hostTransportUART {
if err := m.openUARTLocked(); err != nil {
log.Printf("UART reconnect after master restart: %v", err)
return
}
m.sp.flushInput()
log.Printf("UART %s ready after master restart", m.uartPort)
}
}
func (m *managedHost) exchangePayload(payload []byte, cmdName string) ([]byte, error) {
return m.exchangePayloadMode(false, payload, cmdName, readTimeout)
}
func (m *managedHost) exchangePayloadPoll(payload []byte, cmdName string) ([]byte, error) {
return m.exchangePayloadMode(true, payload, cmdName, readTimeout)
}
func (m *managedHost) beginPausePoll() bool {
m.mu.Lock()
defer m.mu.Unlock()
if m.otaActive || m.pausePoll {
return false
}
m.pausePoll = true
return true
}
func (m *managedHost) endPausePoll() {
m.mu.Lock()
m.pausePoll = false
m.mu.Unlock()
}
func (m *managedHost) PollPaused() bool {
m.mu.Lock()
defer m.mu.Unlock()
return m.pausePoll || m.otaActive
}
func (m *managedHost) exchangePayloadMode(poll bool, payload []byte, cmdName string, timeout time.Duration) ([]byte, error) {
m.mu.Lock()
defer m.mu.Unlock()
if m.otaActive || (poll && m.pausePoll) {
return nil, errHostBusy
}
switch m.transport {
case hostTransportBLE:
if m.ble == nil {
return nil, fmt.Errorf("BLE not connected")
}
resp, err := m.ble.exchangePayloadTimeout(payload, cmdName, timeout)
if err != nil {
m.invalidateLocked(err)
}
return resp, err
case hostTransportUART, hostTransportNone:
if m.uartPort == "" && m.sp == nil {
return nil, fmt.Errorf("not connected")
}
if m.transport == hostTransportNone {
m.transport = hostTransportUART
m.endpoint = m.uartPort
}
if m.sp == nil {
if err := m.openUARTLocked(); err != nil {
return nil, fmt.Errorf("%s: %w", m.uartPort, err)
}
}
resp, err := m.sp.exchangePayloadLocked(payload, cmdName, timeout)
if err != nil {
m.invalidateLocked(err)
}
return resp, err
default:
return nil, fmt.Errorf("not connected")
}
}
func (m *managedHost) exchange(cmdID byte, cmdName string) ([]byte, error) {
return m.exchangePayload([]byte{cmdID}, cmdName)
}
func (m *managedHost) exchangePoll(cmdID byte, cmdName string) ([]byte, error) {
return m.exchangePayloadPoll([]byte{cmdID}, cmdName)
}
func (m *managedHost) exchangePayloadVia(
portFn func(func(*serialPort) error) error,
payload []byte, cmdName string,
) ([]byte, error) {
// Prefer transport-aware path (works for BLE too).
_ = portFn
return m.exchangePayload(payload, cmdName)
}
func (m *managedHost) exchangeVia(
portFn func(func(*serialPort) error) error,
cmdID byte, cmdName string,
) ([]byte, error) {
_ = portFn
return m.exchange(cmdID, cmdName)
}
func (m *managedHost) NextStreamTick(timeout time.Duration) ([]byte, error) {
m.mu.Lock()
ble := m.ble
tr := m.transport
m.mu.Unlock()
if tr != hostTransportBLE || ble == nil {
return nil, fmt.Errorf("BLE stream ticks require BLE host")
}
return ble.NextStreamTick(timeout)
}
func (m *managedHost) HostStatus() map[string]any {
m.mu.Lock()
defer m.mu.Unlock()
connected := false
switch m.transport {
case hostTransportUART:
connected = m.sp != nil
case hostTransportBLE:
connected = m.ble != nil
}
return map[string]any{
"transport": m.transportOrNone(),
"endpoint": m.endpoint,
"connected": connected,
"ble_name": m.bleName,
}
}
func (m *managedHost) transportOrNone() string {
if m.transport == "" {
return hostTransportNone
}
return m.transport
}
func listSerialPorts() ([]string, error) {
ports, err := serial.GetPortsList()
if err != nil {
return nil, err
}
if ports == nil {
return []string{}, nil
}
return ports, nil
}
type bleScanDevice struct {
Address string `json:"address"`
Name string `json:"name"`
RSSI int `json:"rssi"`
}
func scanBLEDevices(timeout time.Duration, nameFilter string) ([]bleScanDevice, error) {
if timeout <= 0 {
timeout = 5 * time.Second
}
adapter := bluetooth.DefaultAdapter
if err := adapter.Enable(); err != nil {
return nil, fmt.Errorf("enable bluetooth: %w", err)
}
seen := map[string]bleScanDevice{}
var mu sync.Mutex
go func() {
time.Sleep(timeout)
_ = adapter.StopScan()
}()
filter := strings.TrimSpace(nameFilter)
_ = adapter.Scan(func(a *bluetooth.Adapter, result bluetooth.ScanResult) {
name := result.LocalName()
if filter != "" && !strings.Contains(strings.ToLower(name), strings.ToLower(filter)) {
return
}
addr := result.Address.String()
mu.Lock()
seen[addr] = bleScanDevice{
Address: addr,
Name: name,
RSSI: int(result.RSSI),
}
mu.Unlock()
})
// StopScan ends Scan; ignore error and return whatever was found.
mu.Lock()
defer mu.Unlock()
out := make([]bleScanDevice, 0, len(seen))
for _, d := range seen {
out = append(out, d)
}
return out, nil
}
func disconnectedState(portName string, err error) DashboardState {
msg := "host disconnected"
if err != nil {
msg = err.Error()
}
return DashboardState{
UpdatedAt: time.Now().Format(time.RFC3339),
SerialPort: portName,
HostEndpoint: portName,
UARTConnected: false,
HostConnected: false,
HostTransport: hostTransportNone,
SerialOK: false,
SerialError: msg,
Master: MasterView{OK: false, Error: msg},
Clients: []ClientView{},
}
}
+4 -1
View File
@@ -14,6 +14,7 @@ const (
ledRingModeBlink = 3
ledRingModeFindMe = 4
ledRingModeColor = 5
ledRingModeBatteryLow = 6
)
type ledRingAPIRequest struct {
@@ -56,8 +57,10 @@ func ledRingModeFromString(s string) (uint32, error) {
return ledRingModeBlink, nil
case "find-me", "find_me", "findme":
return ledRingModeFindMe, nil
case "battery-low", "battery_low", "batterylow":
return ledRingModeBatteryLow, nil
default:
return 0, fmt.Errorf("unknown mode %q (clear, color, progress, digit, blink, find-me)", s)
return 0, fmt.Errorf("unknown mode %q (clear, color, progress, digit, blink, find-me, battery-low)", s)
}
}
+219 -30
View File
@@ -5,33 +5,47 @@ import (
"fmt"
"log"
"os"
"time"
)
const defaultBaud = 921600
func usage() {
fmt.Fprintf(os.Stderr, "usage: gotool [-port /dev/ttyUSB0] <command>\n")
fmt.Fprintf(os.Stderr, "usage: gotool [-port /dev/ttyUSB0 | -ble] <command>\n")
fmt.Fprintf(os.Stderr, " DIP8 on master: 0=UART (-port), 1=BLE (-ble); reboot after DIP change\n")
fmt.Fprintf(os.Stderr, " test uses uart.master from bench config when -port is omitted\n\n")
fmt.Fprintf(os.Stderr, "commands:\n")
fmt.Fprintf(os.Stderr, " version firmware version and git hash\n")
fmt.Fprintf(os.Stderr, " clients registered ESP-NOW slaves on the master\n")
fmt.Fprintf(os.Stderr, " clients registered ESP-NOW slaves on the master\n")
fmt.Fprintf(os.Stderr, " clients-reset-ids clear master id map + live registry (notifies online slaves)\n")
fmt.Fprintf(os.Stderr, " deadzone get/set accelerometer deadzone (LSB)\n")
fmt.Fprintf(os.Stderr, " tap-config get/set BMA456 tap detection parameters\n")
fmt.Fprintf(os.Stderr, " tap-notify get/set which tap kinds notify via ESP-NOW\n")
fmt.Fprintf(os.Stderr, " cache-status subscribed accel + tap cache (one UART round-trip)\n")
fmt.Fprintf(os.Stderr, " cache-status subscribed accel + tap cache (one round-trip)\n")
fmt.Fprintf(os.Stderr, " accel-stream enable/disable ESP-NOW accel stream (+ BLE period)\n")
fmt.Fprintf(os.Stderr, " unicast-test send ESP-NOW unicast test to one slave\n")
fmt.Fprintf(os.Stderr, " echo-ping ESP-NOW timestamp echo round-trip to one slave\n")
fmt.Fprintf(os.Stderr, " ble-rtt Host↔Master RTT via VERSION (N samples)\n")
fmt.Fprintf(os.Stderr, " ble-stream enable stream + measure BleStreamTick window\n")
fmt.Fprintf(os.Stderr, " test run automated scenario (see testdata/)\n")
fmt.Fprintf(os.Stderr, " serve web dashboard (Bootstrap + WebSocket)\n")
fmt.Fprintf(os.Stderr, " ota UART OTA upload (A/B partitions)\n")
fmt.Fprintf(os.Stderr, " serve web dashboard (optional -port / -ble; connect from UI)\n")
fmt.Fprintf(os.Stderr, " ota host OTA upload (A/B partitions; UART or BLE)\n")
fmt.Fprintf(os.Stderr, " ota-progress query per-slave ESP-NOW OTA progress on master\n")
fmt.Fprintf(os.Stderr, " led-ring set LED ring progress bar (0–100%%, rgb, intensity)\n")
fmt.Fprintf(os.Stderr, " find-me blink LED ring red/green/blue (3× each, full brightness)\n")
fmt.Fprintf(os.Stderr, " restart reboot master or slave (ESP-NOW)\n\n")
fmt.Fprintf(os.Stderr, " restart reboot master or slave (ESP-NOW)\n")
fmt.Fprintf(os.Stderr, " log-level get/set master ESP-IDF log level (global)\n")
fmt.Fprintf(os.Stderr, " battery-uv get/set software LiPo UV protection (per pod / all)\n")
fmt.Fprintf(os.Stderr, " input-send-mode get/set slave accel/tap ESP-NOW send path\n\n")
flag.PrintDefaults()
}
func main() {
portName := flag.String("port", "", "serial port (e.g. /dev/ttyUSB0)")
portName := flag.String("port", "", "serial port (e.g. /dev/ttyUSB0); XOR with -ble")
baud := flag.Int("baud", defaultBaud, "UART baud rate")
useBLE := flag.Bool("ble", false, "use BLE host transport (DIP8=1); XOR with -port")
bleName := flag.String("ble-name", bleDefaultName, "BLE advertised name")
bleSettle := flag.Float64("ble-settle", 1.0, "seconds to settle after BLE connect")
flag.Parse()
if flag.NArg() < 1 {
@@ -44,50 +58,152 @@ func main() {
var runErr error
switch cmd {
case "test", "autotest":
if *useBLE {
runErr = fmt.Errorf("autotest requires -port (UART)")
break
}
runErr = runTest(*portName, *baud, flag.Args()[1:])
case "serve", "web", "dashboard":
if *portName == "" {
fmt.Fprintf(os.Stderr, "command %q requires -port\n\n", cmd)
if *useBLE && *portName != "" {
fmt.Fprintf(os.Stderr, "use either -port or -ble, not both\n")
os.Exit(2)
}
runErr = runServe(serveOpts{
portName: *portName,
baud: *baud,
useBLE: *useBLE,
bleName: *bleName,
settle: time.Duration(*bleSettle * float64(time.Second)),
}, flag.Args()[1:])
case "version", "clients", "client-info", "clients-reset-ids", "clients_reset_ids", "deadzone", "accel-deadzone", "tap-config", "tap_config", "tap-notify", "tap_notify", "cache-status", "cache_status", "accel-stream", "accel_stream", "unicast-test", "unicast_test", "echo-ping", "echo_ping", "ble-rtt", "ble_rtt", "ble-stream", "ble_stream", "led-ring", "led_ring", "find-me", "find_me", "restart", "log-level", "log_level", "input-send-mode", "input_send_mode", "ota", "ota-progress", "ota_progress":
if *useBLE && *portName != "" {
fmt.Fprintf(os.Stderr, "use either -port or -ble, not both\n")
os.Exit(2)
}
if !*useBLE && *portName == "" {
fmt.Fprintf(os.Stderr, "command %q requires -port or -ble\n\n", cmd)
usage()
os.Exit(2)
}
runErr = runServe(*portName, *baud, flag.Args()[1:])
case "version", "clients", "client-info", "deadzone", "accel-deadzone", "tap-notify", "tap_notify", "cache-status", "cache_status", "unicast-test", "unicast_test", "led-ring", "led_ring", "find-me", "find_me", "restart", "ota", "ota-progress", "ota_progress":
if *portName == "" {
fmt.Fprintf(os.Stderr, "command %q requires -port\n\n", cmd)
usage()
os.Exit(2)
var h hostConn
if *useBLE {
bl, err := openBLE(*bleName, time.Duration(*bleSettle*float64(time.Second)))
if err != nil {
log.Fatalf("open BLE: %v", err)
}
h = bl
} else {
sp, err := openSerial(*portName, *baud)
if err != nil {
log.Fatalf("open serial: %v", err)
}
h = sp
}
sp, err := openSerial(*portName, *baud)
if err != nil {
log.Fatalf("open serial: %v", err)
}
registerShutdown(func() { _ = sp.Close() })
registerShutdown(func() { _ = h.Close() })
enableShutdownOnInterrupt()
defer sp.Close()
defer h.Close()
switch cmd {
case "version":
runErr = runVersion(sp)
runErr = runVersionHost(h)
case "clients", "client-info":
runErr = runClients(sp)
case "deadzone", "accel-deadzone":
runErr = runDeadzone(sp, flag.Args()[1:])
case "tap-notify", "tap_notify":
runErr = runTapNotify(sp, flag.Args()[1:])
runErr = runClientsHost(h)
case "clients-reset-ids", "clients_reset_ids":
sp, ok := h.(*serialPort)
if !ok {
runErr = fmt.Errorf("%s over BLE not wired yet; use -port", cmd)
break
}
runErr = runClientsResetIds(sp)
case "cache-status", "cache_status":
runErr = runCacheStatus(sp)
runErr = runCacheStatusHost(h)
case "accel-stream", "accel_stream":
runErr = runAccelStreamCmd(h, flag.Args()[1:])
case "echo-ping", "echo_ping":
runErr = runEchoPingHost(h, flag.Args()[1:])
case "ble-rtt", "ble_rtt":
runErr = runBleRtt(h, flag.Args()[1:])
case "ble-stream", "ble_stream":
runErr = runBleStream(h, flag.Args()[1:])
case "deadzone", "accel-deadzone":
sp, ok := h.(*serialPort)
if !ok {
runErr = fmt.Errorf("%s over BLE not wired yet; use -port", cmd)
break
}
runErr = runDeadzone(sp, flag.Args()[1:])
case "tap-config", "tap_config":
sp, ok := h.(*serialPort)
if !ok {
runErr = fmt.Errorf("%s over BLE not wired yet; use -port", cmd)
break
}
runErr = runTapConfig(sp, flag.Args()[1:])
case "battery-uv", "battery_uv":
sp, ok := h.(*serialPort)
if !ok {
runErr = fmt.Errorf("%s over BLE not wired yet; use -port", cmd)
break
}
runErr = runBatteryUv(sp, flag.Args()[1:])
case "tap-notify", "tap_notify":
sp, ok := h.(*serialPort)
if !ok {
runErr = fmt.Errorf("%s over BLE not wired yet; use -port", cmd)
break
}
runErr = runTapNotify(sp, flag.Args()[1:])
case "unicast-test", "unicast_test":
sp, ok := h.(*serialPort)
if !ok {
runErr = fmt.Errorf("%s over BLE not wired yet; use -port", cmd)
break
}
runErr = runUnicastTest(sp, flag.Args()[1:])
case "led-ring", "led_ring":
sp, ok := h.(*serialPort)
if !ok {
runErr = fmt.Errorf("%s over BLE not wired yet; use -port", cmd)
break
}
runErr = runLedRing(sp, flag.Args()[1:])
case "find-me", "find_me":
sp, ok := h.(*serialPort)
if !ok {
runErr = fmt.Errorf("%s over BLE not wired yet; use -port", cmd)
break
}
runErr = runFindMe(sp, flag.Args()[1:])
case "restart":
sp, ok := h.(*serialPort)
if !ok {
runErr = fmt.Errorf("%s over BLE not wired yet; use -port", cmd)
break
}
runErr = runRestart(sp, flag.Args()[1:])
case "log-level", "log_level":
sp, ok := h.(*serialPort)
if !ok {
runErr = fmt.Errorf("%s over BLE not wired yet; use -port", cmd)
break
}
runErr = runLogLevel(sp, flag.Args()[1:])
case "input-send-mode", "input_send_mode":
runErr = runInputSendModeCmd(h, flag.Args()[1:])
case "ota":
runErr = runOTA(sp, flag.Args()[1:])
t, ok := h.(otaTransport)
if !ok {
runErr = fmt.Errorf("ota: unsupported host transport")
break
}
runErr = runOTA(t, flag.Args()[1:])
case "ota-progress", "ota_progress":
runErr = runOtaProgress(sp, flag.Args()[1:])
t, ok := h.(otaTransport)
if !ok {
runErr = fmt.Errorf("ota-progress: unsupported host transport")
break
}
runErr = runOtaProgress(t, flag.Args()[1:])
}
default:
fmt.Fprintf(os.Stderr, "unknown command %q\n\n", cmd)
@@ -99,3 +215,76 @@ func main() {
log.Fatal(runErr)
}
}
func runVersionHost(h hostConn) error {
ver, err := hostGetVersion(h)
if err != nil {
return err
}
fmt.Printf("version: %d\n", ver.GetVersion())
fmt.Printf("git_hash: %s\n", ver.GetGitHash())
return nil
}
func runClientsHost(h hostConn) error {
clients, err := hostListClients(h)
if err != nil {
return err
}
if len(clients) == 0 {
fmt.Println("no clients registered")
return nil
}
fmt.Printf("clients (%d):\n", len(clients))
for i, c := range clients {
fmt.Printf(" [%d] id=%d ver=%d available=%v used=%v\n",
i, c.GetId(), c.GetVersion(), c.GetAvailable(), c.GetUsed())
}
return nil
}
func runCacheStatusHost(h hostConn) error {
r, err := hostReadCacheStatus(h)
if err != nil {
return err
}
clients := r.GetClients()
if len(clients) == 0 {
fmt.Println("(no slaves with accel stream or tap notify enabled)")
return nil
}
for _, c := range clients {
id := c.GetClientId()
if a := c.GetAccel(); a != nil {
if !a.GetValid() {
fmt.Printf("client %d accel: no sample yet\n", id)
} else {
fmt.Printf("client %d accel: x=%d y=%d z=%d (age %d ms)\n",
id, a.GetX(), a.GetY(), a.GetZ(), a.GetAgeMs())
}
}
if t := c.GetTap(); t != nil {
fmt.Printf("client %d tap: %s (age %d ms)\n",
id, tapKindLabel(t.GetKind()), t.GetAgeMs())
}
}
return nil
}
func runEchoPingHost(h hostConn, args []string) error {
fs := flag.NewFlagSet("echo-ping", flag.ExitOnError)
clientID := fs.Uint("client", 0, "slave client id from `clients`")
if err := fs.Parse(args); err != nil {
return err
}
if *clientID == 0 {
return fmt.Errorf("client id required (see `gotool clients`)")
}
r, err := hostEchoPing(h, uint32(*clientID))
if err != nil {
return err
}
fmt.Printf("echo ping: success=%v client_id=%d rtt_ms=%.3f (host↔master↔slave) esp_rtt_us=%d (ESP-NOW only)\n",
r.Success, r.ClientID, r.RttMs, r.EspRttUs)
return nil
}
+194 -138
View File
@@ -6,20 +6,27 @@ import (
"google.golang.org/protobuf/proto"
uartframe "powerpod/gotool/uart"
"powerpod/gotool/pb"
uartframe "powerpod/gotool/uart"
)
const (
otaHostChunkSize = 200
otaFlashBlockSize = 4096
otaPrepareTimeout = 120 * time.Second
otaHostChunkSize = 200
otaFlashBlockSize = 4096
otaPrepareTimeout = 120 * time.Second
otaDefaultTimeout = 15 * time.Second
otaStatusPollTimeout = 3 * time.Second
otaDistReadTimeout = 400 * time.Millisecond
otaDistQueryInterval = 500 * time.Millisecond
otaDistQueryTimeout = 2 * time.Second
otaDistEmitMinInterval = 150 * time.Millisecond
// Pace host chunks so the master UART RX ring is not overrun (~20 frames/block).
otaHostChunkPaceUART = 5 * time.Millisecond
// Slightly slower for BLE WriteWithoutResponse flow control.
otaHostChunkPaceBLE = 6 * time.Millisecond
otaBlockMaxRetries = 3
// 200 B OTA_PAYLOAD → ~216 B framed; needs ATT MTU >= 219.
otaMinATTMTU = 219
)
const (
@@ -34,6 +41,14 @@ const (
otaDistTimeout = 45 * time.Minute
)
// otaTransport is the host IO used by the OTA protocol (UART or BLE).
type otaTransport interface {
writeMsg(msg *pb.UartMessage) error
readFrame(timeout time.Duration) ([]byte, error)
flushInput()
chunkPace() time.Duration
}
// OtaSlaveDetail is per-slave ESP-NOW OTA state from OTA_SLAVE_PROGRESS.
type OtaSlaveDetail struct {
BytesWritten uint32 `json:"bytes_written"`
@@ -44,20 +59,20 @@ type OtaSlaveDetail struct {
// OTAProgress is pushed to the dashboard during web uploads.
type OTAProgress struct {
Type string `json:"type"` // always "ota_progress"
Phase string `json:"phase"`
Step string `json:"step,omitempty"` // master, slaves
Percent int `json:"percent"`
MasterPercent int `json:"master_percent,omitempty"`
MasterDone bool `json:"master_done,omitempty"`
Message string `json:"message"`
MasterMessage string `json:"master_message,omitempty"`
Bytes uint32 `json:"bytes_written,omitempty"`
Slot uint32 `json:"target_slot,omitempty"`
Slaves uint32 `json:"slaves,omitempty"`
ImageSize uint32 `json:"image_size,omitempty"`
SlaveProgress map[uint32]uint32 `json:"slave_progress,omitempty"` // client_id -> bytes
SlaveDetails map[uint32]OtaSlaveDetail `json:"slave_details,omitempty"`
Type string `json:"type"` // always "ota_progress"
Phase string `json:"phase"`
Step string `json:"step,omitempty"` // master, slaves
Percent int `json:"percent"`
MasterPercent int `json:"master_percent,omitempty"`
MasterDone bool `json:"master_done,omitempty"`
Message string `json:"message"`
MasterMessage string `json:"master_message,omitempty"`
Bytes uint32 `json:"bytes_written,omitempty"`
Slot uint32 `json:"target_slot,omitempty"`
Slaves uint32 `json:"slaves,omitempty"`
ImageSize uint32 `json:"image_size,omitempty"`
SlaveProgress map[uint32]uint32 `json:"slave_progress,omitempty"` // client_id -> bytes
SlaveDetails map[uint32]OtaSlaveDetail `json:"slave_details,omitempty"`
}
type otaProgressFn func(OTAProgress)
@@ -77,27 +92,52 @@ func runOTAUpload(m *managedSerial, firmware []byte, onProgress otaProgressFn) e
Percent: 0, Message: msg, MasterMessage: msg,
})
}
push("preparing", "UART wird vorbereitet…")
push("preparing", "Host wird für OTA vorbereitet…")
// Block until the UART is free, then hold m.mu for the entire upload so
// dashboard/API polling cannot interleave on the serial port.
// Hold m.mu for the entire upload so dashboard/API polling cannot interleave.
m.mu.Lock()
if m.otaActive {
m.mu.Unlock()
return errOTAInProgress
}
m.otaActive = true
if m.sp == nil {
if err := m.openLocked(); err != nil {
var t otaTransport
switch m.transport {
case hostTransportBLE:
if m.ble == nil {
m.otaActive = false
m.mu.Unlock()
err := fmt.Errorf("BLE not connected")
push("error", err.Error())
return err
}
if err := m.ble.ensureOTAMTU(); err != nil {
m.otaActive = false
m.mu.Unlock()
push("error", err.Error())
return err
}
t = m.ble
case hostTransportUART, hostTransportNone:
if m.sp == nil {
if err := m.openLocked(); err != nil {
m.otaActive = false
m.mu.Unlock()
push("error", err.Error())
return err
}
}
t = m.sp
default:
m.otaActive = false
m.mu.Unlock()
err := fmt.Errorf("not connected")
push("error", err.Error())
return err
}
sp := m.sp
err := runOTAOnPortUnlocked(sp, firmware, onProgress)
err := runOTAUnlocked(t, firmware, onProgress)
if err != nil {
m.invalidateLocked(err)
}
@@ -106,7 +146,7 @@ func runOTAUpload(m *managedSerial, firmware []byte, onProgress otaProgressFn) e
return err
}
func runOTAOnPortUnlocked(sp *serialPort, firmware []byte, onProgress otaProgressFn) error {
func runOTAUnlocked(t otaTransport, firmware []byte, onProgress otaProgressFn) error {
if len(firmware) == 0 {
return fmt.Errorf("empty firmware")
}
@@ -148,16 +188,11 @@ func runOTAOnPortUnlocked(sp *serialPort, firmware []byte, onProgress otaProgres
onProgress(p)
}
if err := sp.port.SetReadTimeout(readTimeout); err != nil {
notify("error", "", 0, err.Error())
return err
}
notify("preparing", otaStepMaster, 0, fmt.Sprintf("Master: OTA start (%d bytes)…", imageSize))
flushSerialInput(sp)
t.flushInput()
if err := writeUartMessage(sp, &pb.UartMessage{
if err := t.writeMsg(&pb.UartMessage{
Type: pb.MessageType_OTA_START,
Payload: &pb.UartMessage_OtaStart{
OtaStart: &pb.OtaStartPayload{TotalSize: uint32(imageSize)},
@@ -167,13 +202,7 @@ func runOTAOnPortUnlocked(sp *serialPort, firmware []byte, onProgress otaProgres
return err
}
if err := sp.port.SetReadTimeout(otaPrepareTimeout); err != nil {
notify("error", "", 0, err.Error())
return err
}
defer func() { _ = sp.port.SetReadTimeout(readTimeout) }()
ready, err := waitOtaStatus(sp, otaStReady, otaPrepareTimeout, func(msg string) {
ready, err := waitOtaStatus(t, otaStReady, otaPrepareTimeout, func(msg string) {
notify("preparing", otaStepMaster, 2, msg)
})
if err != nil {
@@ -182,66 +211,93 @@ func runOTAOnPortUnlocked(sp *serialPort, firmware []byte, onProgress otaProgres
}
notify("ready", otaStepMaster, 5, fmt.Sprintf("Master: Slot %d bereit", ready.GetTargetSlot()))
if err := sp.port.SetReadTimeout(otaDefaultTimeout); err != nil {
notify("error", "", 0, err.Error())
return err
}
pace := t.chunkPace()
var seq uint32
for offset := 0; offset < imageSize; {
bytesInBlock := 0
for bytesInBlock < otaFlashBlockSize && offset < imageSize {
n := otaHostChunkSize
room := otaFlashBlockSize - bytesInBlock
if n > room {
n = room
}
if offset+n > imageSize {
n = imageSize - offset
}
chunk := firmware[offset : offset+n]
blockStart := offset
blockStartSeq := seq
if err := writeUartMessage(sp, &pb.UartMessage{
Type: pb.MessageType_OTA_PAYLOAD,
Payload: &pb.UartMessage_OtaPayload{
OtaPayload: &pb.OtaPayload{Seq: seq, Data: chunk},
},
}); err != nil {
notify("error", "", 0, err.Error())
return err
}
seq++
offset += n
bytesInBlock += n
sendBlock := func() (fullBlock bool, err error) {
bytesInBlock := 0
for bytesInBlock < otaFlashBlockSize && offset < imageSize {
n := otaHostChunkSize
room := otaFlashBlockSize - bytesInBlock
if n > room {
n = room
}
if offset+n > imageSize {
n = imageSize - offset
}
chunk := firmware[offset : offset+n]
pct := offset * 100 / imageSize
if pct > 99 {
pct = 99
if err := t.writeMsg(&pb.UartMessage{
Type: pb.MessageType_OTA_PAYLOAD,
Payload: &pb.UartMessage_OtaPayload{
OtaPayload: &pb.OtaPayload{Seq: seq, Data: chunk},
},
}); err != nil {
return false, err
}
time.Sleep(pace)
seq++
offset += n
bytesInBlock += n
pct := offset * 100 / imageSize
if pct > 99 {
pct = 99
}
notify("uploading", otaStepMaster, pct,
fmt.Sprintf("Master: %d / %d bytes", offset, imageSize))
}
notify("uploading", otaStepMaster, pct, fmt.Sprintf("Master: %d / %d bytes", offset, imageSize))
return bytesInBlock == otaFlashBlockSize, nil
}
if bytesInBlock == otaFlashBlockSize {
st, err := waitOtaStatus(sp, otaStBlockAck, otaDefaultTimeout, nil)
if err != nil {
notify("error", "", 0, err.Error())
return err
}
pct := offset * 100 / imageSize
if pct > 99 {
pct = 99
}
notify("uploading", otaStepMaster, pct,
fmt.Sprintf("Master: Block geschrieben (%d bytes)", st.GetBytesWritten()),
OTAProgress{Bytes: st.GetBytesWritten()})
fullBlock, err := sendBlock()
if err != nil {
notify("error", "", 0, err.Error())
return err
}
if !fullBlock {
continue
}
var st *pb.OtaStatusPayload
var ackErr error
for attempt := 0; attempt < otaBlockMaxRetries; attempt++ {
if attempt > 0 {
offset = blockStart
seq = blockStartSeq
if _, err := sendBlock(); err != nil {
notify("error", "", 0, err.Error())
return err
}
}
st, ackErr = waitOtaStatus(t, otaStBlockAck, otaDefaultTimeout, nil)
if ackErr == nil {
break
}
}
if ackErr != nil {
notify("error", "", 0, ackErr.Error())
return ackErr
}
pct := offset * 100 / imageSize
if pct > 99 {
pct = 99
}
notify("uploading", otaStepMaster, pct,
fmt.Sprintf("Master: Block geschrieben (%d bytes)", st.GetBytesWritten()),
OTAProgress{Bytes: st.GetBytesWritten()})
}
masterPct = 100
masterMsg = "Master: UART-Upload abgeschlossen"
masterMsg = "Master: Upload abgeschlossen"
notify("uploading", otaStepMaster, 100, masterMsg)
if err := writeUartMessage(sp, &pb.UartMessage{
if err := t.writeMsg(&pb.UartMessage{
Type: pb.MessageType_OTA_END,
Payload: &pb.UartMessage_OtaEnd{
OtaEnd: &pb.OtaEndPayload{},
@@ -280,7 +336,8 @@ func runOTAOnPortUnlocked(sp *serialPort, firmware []byte, onProgress otaProgres
}
}
if slaveCount == 0 {
return "Keine verfügbaren Slaves — Verteilung übersprungen", 0, 0
// Do not claim "skipped" mid-flight — details often arrive after the first query.
return "ESP-NOW: warte auf Slave-Fortschritt…", 0, 0
}
return fmt.Sprintf("ESP-NOW: %d / %d bytes (%d Slaves)",
aggBytes, imageSize, slaveCount), aggBytes, slaveCount
@@ -300,7 +357,7 @@ func runOTAOnPortUnlocked(sp *serialPort, firmware []byte, onProgress otaProgres
return
}
lastQuery = time.Now()
prog, err := queryOtaSlaveProgressLocked(sp, 0, onDistStatus, otaDistQueryTimeout)
prog, err := queryOtaSlaveProgress(t, 0, onDistStatus, otaDistQueryTimeout)
if err != nil {
if len(slaveDetails) > 0 {
emitSlaveThrottled(true)
@@ -322,17 +379,17 @@ func runOTAOnPortUnlocked(sp *serialPort, firmware []byte, onProgress otaProgres
lastQuery = time.Time{} // first query immediately when distribution starts
querySlaveProgress()
st, err := waitOtaComplete(sp, otaDistTimeout, pushSlaveDist, onWaitTick, otaDistReadTimeout)
st, err := waitOtaComplete(t, otaDistTimeout, pushSlaveDist, onWaitTick, otaDistReadTimeout)
if err != nil {
notify("error", "", 0, err.Error())
return err
}
if prog, err := queryOtaSlaveProgressLocked(sp, 0, nil, otaDistQueryTimeout); err == nil {
if prog, err := queryOtaSlaveProgress(t, 0, nil, otaDistQueryTimeout); err == nil {
mergeSlaveProgressResponse(prog, slaveBytes, slaveDetails)
}
notify("done", "", 100,
fmt.Sprintf("Fertig — %d bytes, Boot-Slot %d. Master und Slaves neu starten.",
fmt.Sprintf("Fertig — %d bytes, Boot-Slot %d. Geräte starten neu auf die neue Firmware.",
st.GetBytesWritten(), st.GetTargetSlot()),
OTAProgress{
Bytes: st.GetBytesWritten(), Slot: st.GetTargetSlot(),
@@ -344,13 +401,11 @@ func runOTAOnPortUnlocked(sp *serialPort, firmware []byte, onProgress otaProgres
}
// QueryOtaSlaveProgress queries the master for per-slave ESP-NOW OTA progress.
func QueryOtaSlaveProgress(sp *serialPort, clientID uint32) (*pb.OtaSlaveProgressResponse, error) {
sp.mu.Lock()
defer sp.mu.Unlock()
return queryOtaSlaveProgressLocked(sp, clientID, nil, otaDefaultTimeout)
func QueryOtaSlaveProgress(t otaTransport, clientID uint32) (*pb.OtaSlaveProgressResponse, error) {
return queryOtaSlaveProgress(t, clientID, nil, otaDefaultTimeout)
}
func queryOtaSlaveProgressLocked(sp *serialPort, clientID uint32,
func queryOtaSlaveProgress(t otaTransport, clientID uint32,
onStatus func(*pb.OtaStatusPayload), queryTimeout time.Duration) (*pb.OtaSlaveProgressResponse, error) {
req := &pb.UartMessage{
Type: pb.MessageType_OTA_SLAVE_PROGRESS,
@@ -360,14 +415,14 @@ func queryOtaSlaveProgressLocked(sp *serialPort, clientID uint32,
},
},
}
if err := writeUartMessage(sp, req); err != nil {
if err := t.writeMsg(req); err != nil {
return nil, err
}
if queryTimeout <= 0 {
queryTimeout = otaDefaultTimeout
}
deadline := time.Now().Add(queryTimeout)
msg, err := readUartMessageUntil(sp, deadline, pb.MessageType_OTA_SLAVE_PROGRESS, onStatus, otaDistReadTimeout)
msg, err := readUartMessageUntil(t, deadline, pb.MessageType_OTA_SLAVE_PROGRESS, onStatus, otaDistReadTimeout)
if err != nil {
return nil, err
}
@@ -400,7 +455,7 @@ func applyDistributingOtaStatus(st *pb.OtaStatusPayload, imageSize int,
details[id] = d
}
func readUartMessageUntil(sp *serialPort, deadline time.Time, want pb.MessageType,
func readUartMessageUntil(t otaTransport, deadline time.Time, want pb.MessageType,
onStatus func(*pb.OtaStatusPayload), readChunk time.Duration) (*pb.UartMessage, error) {
if readChunk <= 0 {
readChunk = otaStatusPollTimeout
@@ -413,10 +468,7 @@ func readUartMessageUntil(sp *serialPort, deadline time.Time, want pb.MessageTyp
if wait > readChunk {
wait = readChunk
}
if err := sp.port.SetReadTimeout(wait); err != nil {
return nil, err
}
payload, err := uartframe.ReadFrame(sp.port, nil, wait)
payload, err := t.readFrame(wait)
if err != nil {
return nil, err
}
@@ -471,7 +523,7 @@ func copySlaveMap(m map[uint32]uint32) map[uint32]uint32 {
return out
}
func waitOtaComplete(sp *serialPort, timeout time.Duration,
func waitOtaComplete(t otaTransport, timeout time.Duration,
onDistributing func(*pb.OtaStatusPayload), onInterval func(),
readTimeout time.Duration) (*pb.OtaStatusPayload, error) {
if readTimeout <= 0 {
@@ -486,10 +538,7 @@ func waitOtaComplete(sp *serialPort, timeout time.Duration,
if readWait > readTimeout {
readWait = readTimeout
}
if err := sp.port.SetReadTimeout(readWait); err != nil {
return nil, err
}
st, err := readOtaStatus(sp)
st, err := readOtaStatus(t, readWait)
if err != nil {
if onInterval != nil {
onInterval()
@@ -514,16 +563,7 @@ func waitOtaComplete(sp *serialPort, timeout time.Duration,
}
}
func writeUartMessage(sp *serialPort, msg *pb.UartMessage) error {
frame, err := encodeUartMessage(msg)
if err != nil {
return err
}
_, err = sp.port.Write(frame)
return err
}
func waitOtaStatus(sp *serialPort, want uint32, timeout time.Duration, onPreparing func(string)) (*pb.OtaStatusPayload, error) {
func waitOtaStatus(t otaTransport, want uint32, timeout time.Duration, onPreparing func(string)) (*pb.OtaStatusPayload, error) {
deadline := time.Now().Add(timeout)
for {
if time.Now().After(deadline) {
@@ -533,10 +573,7 @@ func waitOtaStatus(sp *serialPort, want uint32, timeout time.Duration, onPrepari
if readWait > otaStatusPollTimeout {
readWait = otaStatusPollTimeout
}
if err := sp.port.SetReadTimeout(readWait); err != nil {
return nil, err
}
payload, err := uartframe.ReadFrame(sp.port, nil, readWait)
payload, err := t.readFrame(readWait)
if err != nil {
continue
}
@@ -561,8 +598,8 @@ func waitOtaStatus(sp *serialPort, want uint32, timeout time.Duration, onPrepari
}
}
func readOtaStatus(sp *serialPort) (*pb.OtaStatusPayload, error) {
payload, err := uartframe.ReadFrame(sp.port, nil, readTimeout)
func readOtaStatus(t otaTransport, timeout time.Duration) (*pb.OtaStatusPayload, error) {
payload, err := t.readFrame(timeout)
if err != nil {
return nil, fmt.Errorf("read response: %w", err)
}
@@ -589,8 +626,37 @@ func encodeUartMessage(msg *pb.UartMessage) ([]byte, error) {
return uartframe.EncodeFrame(payload)
}
// flushSerialInput drops stale RX bytes (not full frames — avoids ReadFrame blocking).
func flushSerialInput(sp *serialPort) {
func decodeUartPayload(payload []byte) (*pb.UartMessage, error) {
if len(payload) == 0 {
return nil, fmt.Errorf("empty response")
}
var msg pb.UartMessage
if err := proto.Unmarshal(payload[1:], &msg); err != nil {
return nil, err
}
msg.Type = pb.MessageType(payload[0])
return &msg, nil
}
// --- UART otaTransport ---
func (sp *serialPort) writeMsg(msg *pb.UartMessage) error {
frame, err := encodeUartMessage(msg)
if err != nil {
return err
}
_, err = sp.port.Write(frame)
return err
}
func (sp *serialPort) readFrame(timeout time.Duration) ([]byte, error) {
if err := sp.port.SetReadTimeout(timeout); err != nil {
return nil, err
}
return uartframe.ReadFrame(sp.port, nil, timeout)
}
func (sp *serialPort) flushInput() {
if sp == nil {
return
}
@@ -605,14 +671,4 @@ func flushSerialInput(sp *serialPort) {
}
}
func decodeUartPayload(payload []byte) (*pb.UartMessage, error) {
if len(payload) == 0 {
return nil, fmt.Errorf("empty response")
}
var msg pb.UartMessage
if err := proto.Unmarshal(payload[1:], &msg); err != nil {
return nil, err
}
msg.Type = pb.MessageType(payload[0])
return &msg, nil
}
func (sp *serialPort) chunkPace() time.Duration { return otaHostChunkPaceUART }
File diff suppressed because it is too large Load Diff
+3 -175
View File
@@ -1,12 +1,6 @@
package main
import (
"errors"
"fmt"
"log"
"sync"
"time"
)
import "errors"
// errUARTBusy is returned when the port is held for OTA (poller should not treat as unplug).
var errUARTBusy = errors.New("uart busy (OTA in progress)")
@@ -14,171 +8,5 @@ var errUARTBusy = errors.New("uart busy (OTA in progress)")
// errOTAInProgress is returned when a second OTA upload is attempted while one is running.
var errOTAInProgress = errors.New("OTA upload already in progress")
// managedSerial keeps the UART open and reconnects after I/O failures or unplug.
type managedSerial struct {
portName string
baud int
quiet bool
mu sync.Mutex
sp *serialPort
otaActive bool // UART held for firmware upload; poll/API must not interleave
}
func newManagedSerial(portName string, baud int) *managedSerial {
return &managedSerial{
portName: portName,
baud: baud,
}
}
func (m *managedSerial) Close() error {
m.mu.Lock()
defer m.mu.Unlock()
return m.closeLocked()
}
func (m *managedSerial) IsConnected() bool {
m.mu.Lock()
defer m.mu.Unlock()
return m.sp != nil
}
func (m *managedSerial) openLocked() error {
sp, err := openSerial(m.portName, m.baud)
if err != nil {
return err
}
sp.quiet = m.quiet
m.sp = sp
if !m.quiet {
log.Printf("UART %s connected (%d baud)", m.portName, m.baud)
}
return nil
}
func (m *managedSerial) closeLocked() error {
if m.sp == nil {
return nil
}
err := m.sp.port.Close()
m.sp = nil
return err
}
func (m *managedSerial) invalidateLocked(reason error) {
if m.sp == nil {
return
}
if !m.quiet {
log.Printf("UART %s disconnected: %v", m.portName, reason)
}
_ = m.closeLocked()
}
func (m *managedSerial) withPort(fn func(*serialPort) error) error {
return m.withPortLocked(false, fn)
}
// withPortPoll is like withPort but returns errUARTBusy during OTA (no TryLock race).
func (m *managedSerial) withPortPoll(fn func(*serialPort) error) error {
return m.withPortLocked(true, fn)
}
func (m *managedSerial) withPortLocked(poll bool, fn func(*serialPort) error) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.otaActive {
return errUARTBusy
}
if m.sp == nil {
if err := m.openLocked(); err != nil {
return fmt.Errorf("%s: %w", m.portName, err)
}
}
err := fn(m.sp)
if err != nil {
m.invalidateLocked(err)
}
return err
}
func (m *managedSerial) recoverAfterMasterRestart() {
const bootWait = 6 * time.Second
m.mu.Lock()
m.closeLocked()
m.mu.Unlock()
log.Printf("UART: master restart — waiting %s for boot", bootWait)
time.Sleep(bootWait)
m.mu.Lock()
defer m.mu.Unlock()
if err := m.openLocked(); err != nil {
log.Printf("UART reconnect after master restart: %v", err)
return
}
flushSerialInput(m.sp)
log.Printf("UART %s ready after master restart", m.portName)
}
func (m *managedSerial) exchangePayload(payload []byte, cmdName string) ([]byte, error) {
return m.exchangePayloadVia(m.withPort, payload, cmdName)
}
func (m *managedSerial) exchangePayloadPoll(payload []byte, cmdName string) ([]byte, error) {
return m.exchangePayloadVia(m.withPortPoll, payload, cmdName)
}
func (m *managedSerial) exchangePayloadVia(
portFn func(func(*serialPort) error) error,
payload []byte, cmdName string,
) ([]byte, error) {
var resp []byte
err := portFn(func(sp *serialPort) error {
var e error
resp, e = sp.exchangePayloadLocked(payload, cmdName, readTimeout)
return e
})
return resp, err
}
func (m *managedSerial) exchange(cmdID byte, cmdName string) ([]byte, error) {
return m.exchangeVia(m.withPort, cmdID, cmdName)
}
func (m *managedSerial) exchangePoll(cmdID byte, cmdName string) ([]byte, error) {
return m.exchangeVia(m.withPortPoll, cmdID, cmdName)
}
func (m *managedSerial) exchangeVia(
portFn func(func(*serialPort) error) error,
cmdID byte, cmdName string,
) ([]byte, error) {
var resp []byte
err := portFn(func(sp *serialPort) error {
var e error
resp, e = sp.exchangeLocked(cmdID, cmdName)
return e
})
return resp, err
}
func disconnectedState(portName string, err error) DashboardState {
msg := "UART disconnected"
if err != nil {
msg = err.Error()
}
return DashboardState{
UpdatedAt: time.Now().Format(time.RFC3339),
SerialPort: portName,
UARTConnected: false,
SerialOK: false,
SerialError: msg,
Master: MasterView{OK: false, Error: msg},
Clients: []ClientView{},
}
}
// managedSerial is an alias for managedHost (UART-first history).
type managedSerial = managedHost
+1 -1
View File
@@ -44,7 +44,7 @@ Ordered steps: UART commands, delays, or esptool reset.
**unicast_test** — `input`: `slave` or `client_id`, `seq`
`expect`: `success`, `seq`
**led_ring** — `input`: `mode` (`clear`, `progress`, `digit`, `blink`, `find_me`), `progress`, `digit`, `r`/`g`/`b`, `intensity`, `blink_ms`, `blink_count`
**led_ring** — `input`: `mode` (`clear`, `progress`, `digit`, `blink`, `find_me`, `battery_low`), `progress`, `digit`, `r`/`g`/`b`, `intensity`, `blink_ms`, `blink_count`
`expect`: `success`, `mode`, `progress`, `digit`
**find_me** — `input`: `client` / `client_id` or `slave` (`0` = master ring)
+1 -1
View File
@@ -12,7 +12,7 @@
{
"id": "pod-1",
"mac": "50:78:7d:18:01:10",
"client_id": 16,
"client_id": 1,
"console": "/dev/ttyACM1"
}
]
+21 -11
View File
@@ -10,8 +10,10 @@ import (
const (
StartMarker = 0xAA
StopMarker = 0xCC
// Must match main/uart.h MAX_PAYLOAD_SIZE (MAX_BUF_SIZE - 4).
MaxPayload = 248
// Must match main/uart.h MAX_PAYLOAD_SIZE.
MaxPayload = 1024
// AA | len_lo | len_hi | payload | xor | CC
FrameOverhead = 5
)
var (
@@ -19,14 +21,15 @@ var (
ErrTimeout = errors.New("read timeout")
)
// EncodeFrame builds a framed packet: 0xAA len payload xor(payload) 0xCC.
// EncodeFrame builds a framed packet: 0xAA len_lo len_hi payload xor(payload) 0xCC.
func EncodeFrame(payload []byte) ([]byte, error) {
if len(payload) == 0 || len(payload) > MaxPayload {
return nil, fmt.Errorf("payload length %d out of range 1..%d", len(payload), MaxPayload)
}
frame := make([]byte, 0, 4+len(payload))
frame = append(frame, StartMarker, byte(len(payload)))
n := len(payload)
frame := make([]byte, 0, FrameOverhead+n)
frame = append(frame, StartMarker, byte(n&0xff), byte((n>>8)&0xff))
var checksum byte
for _, b := range payload {
frame = append(frame, b)
@@ -39,6 +42,7 @@ func EncodeFrame(payload []byte) ([]byte, error) {
type Parser struct {
state int
len int
lenLo byte
payload []byte
index int
checksum byte
@@ -46,7 +50,8 @@ type Parser struct {
const (
stateStart = iota
stateLen
stateLenLo
stateLenHi
stateData
stateChecksum
stateStop
@@ -63,14 +68,19 @@ func (p *Parser) Feed(b byte) (payload []byte, ok bool, err error) {
if b == StartMarker {
p.index = 0
p.checksum = 0
p.state = stateLen
p.state = stateLenLo
}
case stateLen:
if b == 0 || b > MaxPayload {
case stateLenLo:
p.lenLo = b
p.state = stateLenHi
case stateLenHi:
n := int(p.lenLo) | (int(b) << 8)
if n == 0 || n > MaxPayload {
p.state = stateStart
} else {
p.len = int(b)
p.len = n
p.payload = make([]byte, p.len)
p.index = 0
p.state = stateData
}
case stateData:
@@ -102,7 +112,7 @@ func (p *Parser) Feed(b byte) (payload []byte, ok bool, err error) {
// maxWait bounds total wait time; zero means no limit (serial read timeouts retry forever).
func ReadFrame(r io.Reader, buf []byte, maxWait time.Duration) ([]byte, error) {
if buf == nil {
buf = make([]byte, 256)
buf = make([]byte, 1024)
}
parser := NewParser()
+999 -73
View File
File diff suppressed because it is too large Load Diff
+15
View File
@@ -12,6 +12,9 @@ idf_component_register(
"powerpod.c"
"led_ring.c"
"uart.c"
"host_transport.c"
"ble_host.c"
"ble_stream.c"
"uart_proto.c"
"uart_cmd.c"
"cmd/cmd_handler.c"
@@ -19,19 +22,26 @@ idf_component_register(
"cmd/cmd_client_info.c"
"cmd/cmd_accel_deadzone.c"
"cmd/cmd_accel_stream.c"
"cmd/cmd_input_send_mode.c"
"cmd/cmd_tap_notify.c"
"cmd/cmd_tap_config.c"
"cmd/cmd_cache_status.c"
"cmd/cmd_espnow_unicast_test.c"
"cmd/cmd_espnow_echo_ping.c"
"cmd/cmd_espnow_find_me.c"
"cmd/cmd_restart.c"
"cmd/cmd_client_id_map_reset.c"
"pod_reboot.c"
"cmd/cmd_led_ring.c"
"cmd/cmd_battery.c"
"cmd/cmd_battery_uv.c"
"cmd/cmd_set_log_level.c"
"cmd/cmd_ota.c"
"cmd/cmd_ota_slave_progress.c"
"ota_uart.c"
"ota_espnow.c"
"ota_session.c"
"ota_confirm.c"
"client_registry.c"
"esp_now_comm.c"
"esp_now_core.c"
@@ -40,6 +50,7 @@ idf_component_register(
"esp_now_proto.c"
"bosch456.c"
"board_input.c"
"battery_uv.c"
"pod_settings.c"
"proto/uart_messages.pb.c"
"proto/esp_now_messages.pb.c"
@@ -55,13 +66,17 @@ idf_component_register(
esp_netif
esp_event
nvs_flash
bt
PRIV_REQUIRES
esp_driver_gpio
esp_driver_uart
esp_driver_i2c
esp_adc
app_update
esp_timer
bma456)
target_compile_definitions(${COMPONENT_LIB}
PRIVATE "POWERPOD_GIT_HASH=\"${POWERPOD_GIT_HASH}\"")
# Optional: disable software UV protection
# target_compile_definitions(${COMPONENT_LIB} PRIVATE POWERPOD_BATTERY_UV_ENABLE=0)
+98 -18
View File
@@ -55,18 +55,20 @@ Pins (`powerpod.h`):
| BMA456 INT | 10 |
| Button (Taster) | 12 |
| LiPo sense 1 (ADC) | 1 |
| LiPo sense 2 (ADC) | 12 (skipped if same as button) |
| LiPo sense 2 (ADC) | 11 |
> **TODO:** GPIO assignments above are provisional; confirm pinning against the real board before release.
Startup order:
Startup order (normal path; UV energy-save skips I2C/BMA456/ESP-NOW/UART/button — see **Software UV protection**):
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)
1. `pod_settings_init()` — NVS
2. LiPo ADC init + optional UV boot check (`battery_uv.c`)
3. Read DIP + IO expander → `app_config`
4. **I2C bus** — IO expander `0x20`; optional **BMA456H** (`init_bma456`, same bus)
5. `esp_now_comm_init(&app_config)` — WiFi + ESP-NOW
6. `led_ring_init()`
7. LiPo monitor task + button (`board_input.c`)
8. **Master only:** command queue, UART, registered commands (e.g. VERSION)
## BMA456 accelerometer (`bosch456.c`)
@@ -86,12 +88,16 @@ Powerpod uses the Bosch **BMA456H** (hearable) variant, not the generic `bma456w
**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).
Tap detection parameters (`bma456_tap_config_t`) use the same pattern with NVS key `tap_cfg` (blob), UART `TAP_CONFIG`, and ESP-NOW `ESPNOW_SET_TAP_CONFIG`.
**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 |
| UART `TAP_CONFIG` with `client_id = 0` | Set + save local tap config |
| ESP-NOW `SET_TAP_CONFIG` | Set + save tap config 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.
@@ -110,14 +116,17 @@ Schema: `proto/esp_now_messages.proto`. Encode/decode: `esp_now_proto.c`. The ES
| `EspNowMessageType` | Direction | `oneof` payload |
|---------------------|-----------|-----------------|
| `ESPNOW_DISCOVER` | Master → broadcast `FF:FF:FF:FF:FF:FF` | `EspNowDiscover` (`network`) |
| `ESPNOW_DISCOVER` | Master → broadcast `FF:FF:FF:FF:FF:FF` | `EspNowDiscover` (`network`, `master_time_us` for accel TX slot sync) |
| `ESPNOW_SLAVE_INFO` | Slave → master | `EspNowSlavePresence` |
| `ESPNOW_HEARTBEAT` | Slave → master | `EspNowSlavePresence` (same fields) |
| `ESPNOW_SET_ACCEL_DEADZONE` | Master → slave | `EspNowAccelDeadzone` (`deadzone` LSB) |
| `ESPNOW_SET_TAP_CONFIG` | Master → slave | `EspNowTapConfig` (multitap sens/timing/axis + enable S/D/T) |
| `ESPNOW_SET_BATTERY_UV` | Master → slave | `EspNowBatteryUv` (runtime UV enable + clear latch when off) |
| `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_ACCEL_SAMPLE` | Slave → master | `EspNowAccelSample` (`slave_id`, `x`, `y`, `z` raw LSB) — ~every 10 ms in 1 ms slots `offset_ms=(id-1)%8` via Discover sync; TX path via `INPUT_SEND_MODE` |
| `ESPNOW_SET_INPUT_SEND_MODE` | Master → slave | `EspNowInputSendMode` (`mode` 0..3 = best_effort/fast/wait/reliable) |
| `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` |
@@ -157,7 +166,7 @@ Hardware: **UART1**, **921600** baud, **TX = GPIO3**, **RX = GPIO2** (adapter TX
| Field | Value |
|-------|--------|
| Start | `0xAA` |
| Length | 1 byte, payload size 1–252 |
| Length | 2 bytes little-endian, payload size 1–1024 |
| Payload | `length` bytes |
| Checksum | XOR of all payload bytes |
| Stop | `0xCC` |
@@ -169,7 +178,7 @@ Hardware: **UART1**, **921600** baud, **TX = GPIO3**, **RX = GPIO2** (adapter TX
| 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`.
Example VERSION request: single-byte payload `03` → frame `AA 01 00 03 03 CC`.
Logging:
@@ -226,8 +235,13 @@ Host and master speak nanopb-encoded `UartMessage` inside UART frames (byte 0 =
| 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 |
| 35 | `INPUT_SEND_MODE` | Implemented — get/set slave accel/tap ESP-NOW send path (best_effort/fast/wait/reliable) |
| 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 |
| 32 | `TAP_CONFIG` | Implemented (`cmd/cmd_tap_config.c`) — get/set BMA456H multitap params (+ NVS) |
| 36 | `BATTERY_UV` | Implemented (`cmd/cmd_battery_uv.c`) — get/set software UV enable (+ NVS / ESP-NOW) |
Regenerate C code:
@@ -321,6 +335,27 @@ Sets the **software** deadzone used by `bosch456.c` when logging accel (see [BMA
**Response:** `accel_deadzone_response` with applied `deadzone`, `success`, and `slaves_updated` (ESP-NOW count).
### TAP_CONFIG command
Get/set **hardware** BMA456H multitap parameters (`tap_sens_thres`, timing fields, `axis_sel`, enable single/double/triple). Applied via `bma456_set_tap_config()` and persisted in NVS (`tap_cfg`) on each target node.
**Request:** framed `20` (`0x20`) + `tap_config_request`:
| Field | Meaning |
|-------|---------|
| `write` | `false` = read, `true` = write |
| `tap_sens_thres` … `enable_triple` | Same fields as `bma456_tap_config_t` |
| `client_id` | `0` = local sensor; `>0` = slave id (master) |
| `all_clients` | Master: ESP-NOW unicast to every registered slave |
**Response:** `tap_config_response` with fields echo, `success`, `slaves_updated`.
```bash
go run . -port /dev/ttyUSB0 tap-config
go run . -port /dev/ttyUSB0 tap-config -set -sens 5 -axis 2
go run . -port /dev/ttyUSB0 tap-config -client 16 -set -sens 7
```
### 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).
@@ -345,7 +380,7 @@ 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`).
Read **cached** accel and/or tap data on the **master** in one UART round-trip. Slaves send `ESPNOW_ACCEL_SAMPLE` every **8 ms** (best-effort) when streaming; tap events arrive via `ESPNOW_TAP_EVENT` and are held up to **8 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).
@@ -359,7 +394,7 @@ Read **cached** accel and/or tap data on the **master** in one UART round-trip.
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.
Host tools poll this at **~8 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
@@ -390,6 +425,30 @@ 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.
@@ -414,6 +473,22 @@ Read **cached** LiPo ADC values on the **master** (master local + one entry per
# Host / goTool: all_clients returns master (id 0) + slaves from cache
```
### Software UV protection (LiPo)
When `POWERPOD_BATTERY_UV_ENABLE` is `1` (default in `powerpod.h`, disable via CMake), the firmware can monitor ADC pin voltages on GPIO 1 and 11. Runtime enable is NVS `uv_en` per pod (missing key → **disabled**); UART `BATTERY_UV` / dashboard toggles it on master and slaves.
| Condition | ADC threshold | Action |
|-----------|---------------|--------|
| External / absent | every valid channel &lt; **100 mV** | Normal boot (no UV); clears latch if set |
| Under-voltage | any valid channel in **100…2299 mV** | NVS latch `uv_latch`, restart into energy-save mode |
| Charged | every non-absent valid channel ≥ **3000 mV** | Clear latch, restart into normal boot |
**Energy-save boot:** LED mode `6` (first 4 LEDs red ~10 % for 5 s), then no WiFi/ESP-NOW, UART, I2C, BMA456, or button — only LiPo ADC polling every 30 s until charged. Disabling UV while latched clears the latch (requires a reachable host — not while already in energy-save).
**UART:** `BATTERY_UV` (`0x24`) — `client_id=0` master, `>0` one slave, `all_clients` master + all slaves. Disabling also clears `uv_latch`.
**Test LED pattern from host:** `led-ring -mode battery-low` (works even when UV feature is disabled).
### LED_RING command
Control the 95-LED ring from the host. The firmware **does not** animate digits locally; only UART updates the display.
@@ -422,7 +497,7 @@ Control the 95-LED ring from the host. The firmware **does not** animate digits
| Field | Meaning |
|-------|---------|
| `mode` | `0` = clear, `1` = progress, `2` = digit (0–10), `3` = blink, `4` = find-me, `5` = solid color (all LEDs) |
| `mode` | `0` = clear, `1` = progress, `2` = digit (0–10), `3` = blink, `4` = find-me, `5` = solid color (all LEDs), `6` = battery-low (first 4 LEDs red ~10 % for 5 s) |
| `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 |
@@ -444,6 +519,7 @@ 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 battery-low -client 0
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
```
@@ -465,7 +541,7 @@ Fields per client: `id`, `mac`, `version`, `available`, `used`, `last_ping`, `la
| `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) |
| `client_registry_set_tap_notify()` / `client_registry_take_tap()` | Tap notify flags + short-lived tap cache (8 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).
@@ -536,11 +612,15 @@ Target: ESP32-S3. Close serial monitor on the UART adapter port before running `
| `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_tap_config.c` | UART `TAP_CONFIG` — BMA456 multitap params + NVS |
| `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, …) |
| `board_input.c/h` | Taster GPIO12, LiPo ADC on GPIO1 / GPIO11 |
| `battery_uv.c/h` | Software LiPo UV latch, energy-save boot (`POWERPOD_BATTERY_UV_ENABLE`) |
| `pod_settings.c/h` | NVS persistence (accel deadzone, tap config, UV latch) |
| `led_ring.c/h` | LED ring (digit display, progress bar) |
| `cmd/cmd_led_ring.c` | UART `LED_RING` progress command |
| `cmd/cmd_battery_uv.c` | UART `BATTERY_UV` — software UV enable + NVS / ESP-NOW |
| `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` |
+2
View File
@@ -9,6 +9,8 @@
typedef struct {
bool master;
uint8_t network;
/** false = UART host, true = BLE host (DIP8; mutual exclusive, boot-only). */
bool host_ble;
char running_partition[APP_RUNNING_PARTITION_LABEL_MAX];
} app_config_t;
+196
View File
@@ -0,0 +1,196 @@
#include "battery_uv.h"
#if POWERPOD_BATTERY_UV_ENABLE
#include "led_ring.h"
#include "pod_reboot.h"
#include "pod_settings.h"
#include "esp_log.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
static const char *TAG = "[BATTERY_UV]";
#define UV_DEBOUNCE_SAMPLES 2u
#define UV_POLL_INTERVAL_MS 30000u
static uint8_t s_uv_streak;
static uint8_t s_charge_streak;
bool battery_uv_is_latched(void) { return pod_settings_is_uv_latched(); }
static bool channel_is_absent(uint32_t mv) {
return mv < BATTERY_UV_ABSENT_ADC_MV;
}
static bool channel_is_under(uint32_t mv) {
return !channel_is_absent(mv) && mv < BATTERY_UV_ADC_MV;
}
static void log_lipo_reading(const char *ctx, const board_lipo_reading_t *reading) {
if (reading == NULL) {
return;
}
ESP_LOGI(TAG,
"%s LIPO1 %s %lu mV LIPO2 %s %lu mV", ctx,
reading->lipo1_valid ? "ok" : "n/a",
(unsigned long)reading->lipo1_mv, reading->lipo2_valid ? "ok" : "n/a",
(unsigned long)reading->lipo2_mv);
}
bool battery_uv_reading_is_external(const board_lipo_reading_t *reading) {
if (reading == NULL) {
return false;
}
bool any_valid = false;
if (reading->lipo1_valid) {
any_valid = true;
if (!channel_is_absent(reading->lipo1_mv)) {
return false;
}
}
if (reading->lipo2_valid) {
any_valid = true;
if (!channel_is_absent(reading->lipo2_mv)) {
return false;
}
}
return any_valid;
}
bool battery_uv_reading_is_under(const board_lipo_reading_t *reading) {
if (reading == NULL) {
return false;
}
if (reading->lipo1_valid && channel_is_under(reading->lipo1_mv)) {
return true;
}
if (reading->lipo2_valid && channel_is_under(reading->lipo2_mv)) {
return true;
}
return false;
}
bool battery_uv_reading_is_charged(const board_lipo_reading_t *reading) {
if (reading == NULL) {
return false;
}
bool any_pack = false;
if (reading->lipo1_valid && !channel_is_absent(reading->lipo1_mv)) {
any_pack = true;
if (reading->lipo1_mv < BATTERY_CHARGE_ADC_MV) {
return false;
}
}
if (reading->lipo2_valid && !channel_is_absent(reading->lipo2_mv)) {
any_pack = true;
if (reading->lipo2_mv < BATTERY_CHARGE_ADC_MV) {
return false;
}
}
return any_pack;
}
bool battery_uv_evaluate_boot(void) {
if (!pod_settings_is_uv_enabled()) {
ESP_LOGI(TAG, "UV protection disabled — normal boot");
return false;
}
board_lipo_reading_t reading;
board_input_read_lipo(&reading);
log_lipo_reading("boot check", &reading);
if (battery_uv_reading_is_external(&reading)) {
ESP_LOGI(TAG, "near-zero LiPo sense — external supply, normal boot");
if (pod_settings_is_uv_latched()) {
pod_settings_clear_uv_latched();
}
return false;
}
if (pod_settings_is_uv_latched() && battery_uv_reading_is_charged(&reading)) {
ESP_LOGI(TAG, "charged — clearing UV latch");
pod_settings_clear_uv_latched();
return false;
}
if (pod_settings_is_uv_latched() || battery_uv_reading_is_under(&reading)) {
if (!pod_settings_is_uv_latched()) {
ESP_LOGW(TAG, "under-voltage at boot — latching UV");
} else {
ESP_LOGW(TAG, "UV latched — energy-save boot");
}
pod_settings_set_uv_latched(true);
return true;
}
return false;
}
void battery_uv_poll(const board_lipo_reading_t *reading) {
if (reading == NULL || !pod_settings_is_uv_enabled() ||
pod_settings_is_uv_latched()) {
return;
}
if (battery_uv_reading_is_external(reading)) {
s_uv_streak = 0;
return;
}
if (battery_uv_reading_is_under(reading)) {
s_uv_streak++;
log_lipo_reading("under-voltage", reading);
ESP_LOGW(TAG, "under-voltage sample %u/%u", (unsigned)s_uv_streak,
(unsigned)UV_DEBOUNCE_SAMPLES);
if (s_uv_streak >= UV_DEBOUNCE_SAMPLES) {
ESP_LOGW(TAG, "UV confirmed — latching and restarting");
pod_settings_set_uv_latched(true);
pod_schedule_restart();
}
return;
}
s_uv_streak = 0;
}
void battery_uv_run_mode(void) {
s_charge_streak = 0;
s_uv_streak = 0;
led_ring_init();
led_ring_show_battery_low();
ESP_LOGW(TAG, "energy-save mode — ADC poll every %u ms", UV_POLL_INTERVAL_MS);
while (1) {
board_lipo_reading_t reading;
board_input_read_lipo(&reading);
log_lipo_reading("energy-save", &reading);
if (battery_uv_reading_is_external(&reading)) {
ESP_LOGI(TAG, "external supply — clearing UV latch and restarting");
pod_settings_clear_uv_latched();
pod_schedule_restart();
} else if (battery_uv_reading_is_charged(&reading)) {
s_charge_streak++;
ESP_LOGI(TAG, "charge sample %u/%u", (unsigned)s_charge_streak,
(unsigned)UV_DEBOUNCE_SAMPLES);
if (s_charge_streak >= UV_DEBOUNCE_SAMPLES) {
ESP_LOGI(TAG, "charged — clearing UV latch and restarting");
pod_settings_clear_uv_latched();
pod_schedule_restart();
}
} else {
s_charge_streak = 0;
}
vTaskDelay(pdMS_TO_TICKS(UV_POLL_INTERVAL_MS));
}
}
#endif
+63
View File
@@ -0,0 +1,63 @@
#ifndef BATTERY_UV_H
#define BATTERY_UV_H
#include "board_input.h"
#include "powerpod.h"
#include <stdbool.h>
#if POWERPOD_BATTERY_UV_ENABLE
/** ADC pin voltage (mV) below which UV is triggered (3.0 V pack via 33k/10k divider). */
#define BATTERY_UV_ADC_MV 2300u
/** ADC pin voltage (mV) at which charging is detected (4.0 V pack, rounded). */
#define BATTERY_CHARGE_ADC_MV 3000u
/**
* ADC readings below this are treated as no pack / external supply (not UV).
* Noise floor / open sense divider → near 0 mV.
*/
#define BATTERY_UV_ABSENT_ADC_MV 100u
bool battery_uv_is_latched(void);
bool battery_uv_reading_is_under(const board_lipo_reading_t *reading);
bool battery_uv_reading_is_charged(const board_lipo_reading_t *reading);
/** true when every valid channel is near 0 (externally powered / no LiPo). */
bool battery_uv_reading_is_external(const board_lipo_reading_t *reading);
/** true → enter UV energy-save path (does not return). */
bool battery_uv_evaluate_boot(void);
void battery_uv_poll(const board_lipo_reading_t *reading);
/** Blocks: LED indicator, then minimal ADC polling until charged. */
void battery_uv_run_mode(void);
#else
static inline bool battery_uv_is_latched(void) { return false; }
static inline bool battery_uv_reading_is_under(const board_lipo_reading_t *reading) {
(void)reading;
return false;
}
static inline bool battery_uv_reading_is_charged(const board_lipo_reading_t *reading) {
(void)reading;
return false;
}
static inline bool battery_uv_reading_is_external(const board_lipo_reading_t *reading) {
(void)reading;
return false;
}
static inline bool battery_uv_evaluate_boot(void) { return false; }
static inline void battery_uv_poll(const board_lipo_reading_t *reading) {
(void)reading;
}
static inline void battery_uv_run_mode(void) {}
#endif
#endif
+496
View File
@@ -0,0 +1,496 @@
#include "ble_host.h"
#include "esp_log.h"
#include "esp_timer.h"
#include "host_transport.h"
#include "nvs_flash.h"
#include "ota_espnow.h"
#include "freertos/FreeRTOS.h"
#include "freertos/semphr.h"
#include "freertos/task.h"
#include "nimble/nimble_port.h"
#include "nimble/nimble_port_freertos.h"
#include "host/ble_att.h"
#include "host/ble_hs.h"
#include "host/util/util.h"
#include "services/gap/ble_svc_gap.h"
#include "services/gatt/ble_svc_gatt.h"
#include <string.h>
static const char *TAG = "[BLE_HOST]";
static const char *DEVICE_NAME = "Powerpod";
#define BLE_CONN_ITVL_MIN_UNITS 6 /* 7.5 ms */
#define BLE_CONN_ITVL_MAX_UNITS 6
#define BLE_CONN_TUNE_MAX_TRIES 6
/** Prefer large ATT MTU so CLIENT_INFO / BATTERY fit in fewer notify chunks. */
#define BLE_PREFERRED_ATT_MTU 517
#define BLE_NOTIFY_CHUNK_RETRIES 24
/** During ESP-NOW OTA dist, fail notify sooner so WiFi/ESP-NOW keeps airtime. */
#define BLE_NOTIFY_CHUNK_RETRIES_OTA_DIST 4
#define BLE_NOTIFY_CHUNK_RETRY_MS 5
/** Pace multi-chunk notifies so the central parser sees contiguous frame bytes. */
#define BLE_NOTIFY_CHUNK_GAP_MS 8
#define BLE_NOTIFY_CHUNK_GAP_OTA_DIST_MS 2
/*
* Nordic UART style:
* Service: 6e400001-b5a3-f393-e0a9-e50e24dcca9e
* Characteristic: 6e400002-b5a3-f393-e0a9-e50e24dcca9e
* Write = host→ESP (framed), Notify = ESP→host (framed).
*/
static const ble_uuid128_t ble_svc_uuid =
BLE_UUID128_INIT(0x9e, 0xca, 0xdc, 0x24, 0x0e, 0xe5, 0xa9, 0xe0, 0x93, 0xf3,
0xa3, 0xb5, 0x01, 0x00, 0x40, 0x6e);
static const ble_uuid128_t ble_chr_uuid =
BLE_UUID128_INIT(0x9e, 0xca, 0xdc, 0x24, 0x0e, 0xe5, 0xa9, 0xe0, 0x93, 0xf3,
0xa3, 0xb5, 0x02, 0x00, 0x40, 0x6e);
static uint8_t own_addr_type;
static uint16_t conn_handle = BLE_HS_CONN_HANDLE_NONE;
static uint16_t ble_chr_val_handle;
static bool notify_enabled;
static uint16_t att_mtu = 23;
static esp_timer_handle_t conn_tune_timer;
static uint8_t conn_tune_tries;
static SemaphoreHandle_t s_tx_mu;
static int ble_gap_event(struct ble_gap_event *event, void *arg);
static void ble_request_fast_conn_params(uint16_t handle);
static void ble_optimize_link(uint16_t handle);
bool ble_host_ready(void) {
return notify_enabled && conn_handle != BLE_HS_CONN_HANDLE_NONE;
}
uint16_t ble_host_att_mtu(void) { return att_mtu; }
uint16_t ble_host_max_payload(void) {
if (att_mtu < 4) {
return 20;
}
return (uint16_t)(att_mtu - 3);
}
/**
* One ATT notification (≤ MTU-3). Retries on transient NimBLE buffer pressure.
*/
static esp_err_t ble_notify_chunk(const uint8_t *data, uint16_t len) {
const int max_retries = ota_espnow_distribution_active()
? BLE_NOTIFY_CHUNK_RETRIES_OTA_DIST
: BLE_NOTIFY_CHUNK_RETRIES;
for (int attempt = 0; attempt < max_retries; attempt++) {
struct os_mbuf *om = ble_hs_mbuf_from_flat(data, len);
if (om == NULL) {
vTaskDelay(pdMS_TO_TICKS(BLE_NOTIFY_CHUNK_RETRY_MS));
continue;
}
int rc = ble_gatts_notify_custom(conn_handle, ble_chr_val_handle, om);
if (rc == 0) {
return ESP_OK;
}
/* Ownership stays with us on failure. */
os_mbuf_free_chain(om);
if (!ble_host_ready()) {
return ESP_ERR_INVALID_STATE;
}
ESP_LOGD(TAG, "notify chunk rc=%d attempt=%d len=%u", rc, attempt,
(unsigned)len);
vTaskDelay(pdMS_TO_TICKS(BLE_NOTIFY_CHUNK_RETRY_MS));
}
ESP_LOGW(TAG, "notify chunk gave up after retries len=%u", (unsigned)len);
return ESP_ERR_NO_MEM;
}
/**
* Send a full host frame. If larger than ATT MTU-3, split across consecutive
* notifies; goTool feeds every notify into the same byte parser, so the frame
* reassembles without a protocol change. Mutex keeps stream ticks from
* interleaving mid-frame.
*/
static esp_err_t ble_host_tx(const uint8_t *frame, size_t frame_len) {
if (!ble_host_ready()) {
return ESP_ERR_INVALID_STATE;
}
if (frame == NULL || frame_len == 0) {
return ESP_ERR_INVALID_ARG;
}
uint16_t max_chunk = ble_host_max_payload();
if (max_chunk == 0) {
return ESP_ERR_INVALID_SIZE;
}
if (s_tx_mu == NULL ||
xSemaphoreTake(s_tx_mu, pdMS_TO_TICKS(2000)) != pdTRUE) {
return ESP_ERR_TIMEOUT;
}
esp_err_t result = ESP_OK;
size_t off = 0;
while (off < frame_len) {
if (!ble_host_ready()) {
result = ESP_ERR_INVALID_STATE;
break;
}
size_t remain = frame_len - off;
uint16_t n =
(uint16_t)(remain > max_chunk ? max_chunk : remain);
result = ble_notify_chunk(&frame[off], n);
if (result != ESP_OK) {
ESP_LOGW(TAG, "TX abort at %u/%u (chunk %u, mtu_pay %u)",
(unsigned)off, (unsigned)frame_len, (unsigned)n,
(unsigned)max_chunk);
break;
}
off += n;
if (off < frame_len) {
const uint32_t gap_ms = ota_espnow_distribution_active()
? BLE_NOTIFY_CHUNK_GAP_OTA_DIST_MS
: BLE_NOTIFY_CHUNK_GAP_MS;
vTaskDelay(pdMS_TO_TICKS(gap_ms));
}
}
xSemaphoreGive(s_tx_mu);
return result;
}
static void ble_conn_tune_timer_cb(void *arg) {
struct ble_gap_conn_desc desc;
(void)arg;
if (conn_handle == BLE_HS_CONN_HANDLE_NONE) {
return;
}
if (ble_gap_conn_find(conn_handle, &desc) != 0) {
return;
}
if (desc.conn_itvl <= BLE_CONN_ITVL_MAX_UNITS) {
ESP_LOGI(TAG, "conn already at interval_us=%lu",
(unsigned long)desc.conn_itvl * 1250UL);
return;
}
if (conn_tune_tries >= BLE_CONN_TUNE_MAX_TRIES) {
ESP_LOGW(TAG, "central kept interval_us=%lu (ESP requested 7500)",
(unsigned long)desc.conn_itvl * 1250UL);
return;
}
conn_tune_tries++;
ESP_LOGI(TAG, "retry fast conn params (%u/%u); current interval_us=%lu",
conn_tune_tries, BLE_CONN_TUNE_MAX_TRIES,
(unsigned long)desc.conn_itvl * 1250UL);
ble_request_fast_conn_params(conn_handle);
esp_timer_start_once(conn_tune_timer, 750 * 1000);
}
static void ble_schedule_conn_tune(void) {
conn_tune_tries = 0;
esp_timer_stop(conn_tune_timer);
esp_timer_start_once(conn_tune_timer, 300 * 1000);
}
static void ble_request_fast_conn_params(uint16_t handle) {
struct ble_gap_upd_params params = {
.itvl_min = BLE_CONN_ITVL_MIN_UNITS,
.itvl_max = BLE_CONN_ITVL_MAX_UNITS,
.latency = 0,
.supervision_timeout = 400,
.min_ce_len = 0,
.max_ce_len = 24,
};
int rc = ble_gap_update_params(handle, &params);
if (rc != 0) {
ESP_LOGW(TAG, "conn param update request failed: %d", rc);
} else {
ESP_LOGI(TAG, "requested conn interval 7.5 ms");
}
}
static void ble_optimize_link(uint16_t handle) {
int rc;
/*
* Do NOT request 2M PHY here. With ESP-NOW/WiFi coexistence the controller
* can assert in llc_phy_upd (e.g. "BLE assert llc_phy_upd.c …") and the
* host link dies — no further responses. Stay on default 1M.
*/
ble_request_fast_conn_params(handle);
rc = ble_gap_set_data_len(handle, 251, 2120);
if (rc != 0) {
ESP_LOGW(TAG, "data len request failed: %d", rc);
}
ble_schedule_conn_tune();
}
static void ble_advertise(void) {
struct ble_gap_adv_params adv_params;
struct ble_hs_adv_fields fields;
int rc;
memset(&fields, 0, sizeof(fields));
fields.flags = BLE_HS_ADV_F_DISC_GEN | BLE_HS_ADV_F_BREDR_UNSUP;
fields.tx_pwr_lvl_is_present = 1;
fields.tx_pwr_lvl = BLE_HS_ADV_TX_PWR_LVL_AUTO;
fields.name = (uint8_t *)DEVICE_NAME;
fields.name_len = strlen(DEVICE_NAME);
fields.name_is_complete = 1;
rc = ble_gap_adv_set_fields(&fields);
if (rc != 0) {
ESP_LOGE(TAG, "adv set fields failed: %d", rc);
return;
}
memset(&adv_params, 0, sizeof(adv_params));
adv_params.conn_mode = BLE_GAP_CONN_MODE_UND;
adv_params.disc_mode = BLE_GAP_DISC_MODE_GEN;
adv_params.itvl_min = 0x20;
adv_params.itvl_max = 0x30;
rc = ble_gap_adv_start(own_addr_type, NULL, BLE_HS_FOREVER, &adv_params,
ble_gap_event, NULL);
if (rc != 0) {
ESP_LOGE(TAG, "adv start failed: %d", rc);
return;
}
ESP_LOGI(TAG, "advertising as \"%s\"", DEVICE_NAME);
}
static int ble_chr_access(uint16_t conn_handle_arg, uint16_t attr_handle,
struct ble_gatt_access_ctxt *ctxt, void *arg) {
(void)conn_handle_arg;
(void)attr_handle;
(void)arg;
switch (ctxt->op) {
case BLE_GATT_ACCESS_OP_READ_CHR:
return 0;
case BLE_GATT_ACCESS_OP_WRITE_CHR: {
uint16_t om_len = OS_MBUF_PKTLEN(ctxt->om);
uint8_t buf[512];
uint16_t copied = 0;
int rc;
if (om_len == 0 || om_len > sizeof(buf)) {
return BLE_ATT_ERR_INVALID_ATTR_VALUE_LEN;
}
rc = ble_hs_mbuf_to_flat(ctxt->om, buf, sizeof(buf), &copied);
if (rc != 0) {
return BLE_ATT_ERR_UNLIKELY;
}
host_rx_bytes(buf, copied);
return 0;
}
default:
return BLE_ATT_ERR_UNLIKELY;
}
}
static const struct ble_gatt_svc_def gatt_svcs[] = {
{
.type = BLE_GATT_SVC_TYPE_PRIMARY,
.uuid = &ble_svc_uuid.u,
.characteristics =
(struct ble_gatt_chr_def[]){
{
.uuid = &ble_chr_uuid.u,
.access_cb = ble_chr_access,
.val_handle = &ble_chr_val_handle,
.flags = BLE_GATT_CHR_F_READ | BLE_GATT_CHR_F_WRITE |
BLE_GATT_CHR_F_WRITE_NO_RSP | BLE_GATT_CHR_F_NOTIFY,
},
{0},
},
},
{0},
};
static int gatt_svr_init(void) {
int rc;
ble_svc_gap_init();
ble_svc_gatt_init();
rc = ble_gatts_count_cfg(gatt_svcs);
if (rc != 0) {
return rc;
}
return ble_gatts_add_svcs(gatt_svcs);
}
static int ble_gap_event(struct ble_gap_event *event, void *arg) {
(void)arg;
switch (event->type) {
case BLE_GAP_EVENT_CONNECT:
ESP_LOGI(TAG, "connection %s; status=%d handle=%d",
event->connect.status == 0 ? "established" : "failed",
event->connect.status, event->connect.conn_handle);
if (event->connect.status != 0) {
ble_advertise();
} else {
conn_handle = event->connect.conn_handle;
ble_optimize_link(conn_handle);
}
return 0;
case BLE_GAP_EVENT_DISCONNECT:
ESP_LOGI(TAG, "disconnect; reason=%d", event->disconnect.reason);
esp_timer_stop(conn_tune_timer);
conn_tune_tries = 0;
conn_handle = BLE_HS_CONN_HANDLE_NONE;
notify_enabled = false;
att_mtu = 23;
ble_advertise();
return 0;
case BLE_GAP_EVENT_CONN_UPDATE: {
struct ble_gap_conn_desc desc;
if (ble_gap_conn_find(event->conn_update.conn_handle, &desc) == 0) {
ESP_LOGI(TAG, "conn updated; status=%d interval_us=%lu latency=%u",
event->conn_update.status,
(unsigned long)desc.conn_itvl * 1250UL, desc.conn_latency);
if (event->conn_update.status == 0 &&
desc.conn_itvl > BLE_CONN_ITVL_MAX_UNITS &&
conn_tune_tries < BLE_CONN_TUNE_MAX_TRIES) {
esp_timer_stop(conn_tune_timer);
esp_timer_start_once(conn_tune_timer, 500 * 1000);
}
}
return 0;
}
case BLE_GAP_EVENT_PHY_UPDATE_COMPLETE:
ESP_LOGI(TAG, "phy update; status=%d tx=%u rx=%u",
event->phy_updated.status, event->phy_updated.tx_phy,
event->phy_updated.rx_phy);
return 0;
case BLE_GAP_EVENT_ADV_COMPLETE:
ESP_LOGI(TAG, "advertise complete; reason=%d", event->adv_complete.reason);
ble_advertise();
return 0;
case BLE_GAP_EVENT_SUBSCRIBE:
if (event->subscribe.attr_handle == ble_chr_val_handle) {
notify_enabled = event->subscribe.cur_notify;
ESP_LOGI(TAG, "notify %s", notify_enabled ? "enabled" : "disabled");
if (notify_enabled && conn_handle != BLE_HS_CONN_HANDLE_NONE) {
ble_request_fast_conn_params(conn_handle);
}
}
return 0;
case BLE_GAP_EVENT_MTU:
att_mtu = event->mtu.value;
ESP_LOGI(TAG, "mtu update; handle=%d mtu=%d", event->mtu.conn_handle,
event->mtu.value);
return 0;
default:
return 0;
}
}
static void ble_on_sync(void) {
int rc;
rc = ble_hs_id_infer_auto(0, &own_addr_type);
if (rc != 0) {
ESP_LOGE(TAG, "ble_hs_id_infer_auto failed: %d", rc);
return;
}
uint8_t addr_val[6] = {0};
ble_hs_id_copy_addr(own_addr_type, addr_val, NULL);
ESP_LOGI(TAG, "device address: %02x:%02x:%02x:%02x:%02x:%02x", addr_val[5],
addr_val[4], addr_val[3], addr_val[2], addr_val[1], addr_val[0]);
ble_advertise();
}
static void ble_on_reset(int reason) {
ESP_LOGE(TAG, "nimble reset; reason=%d", reason);
}
static void ble_host_task(void *param) {
(void)param;
ESP_LOGI(TAG, "nimble host task started");
nimble_port_run();
nimble_port_freertos_deinit();
}
esp_err_t init_ble_host(QueueHandle_t cmd_queue) {
esp_err_t ret;
host_transport_set_cmd_queue(cmd_queue);
host_transport_set_tx(ble_host_tx);
s_tx_mu = xSemaphoreCreateMutex();
if (s_tx_mu == NULL) {
ESP_LOGE(TAG, "failed to create TX mutex");
return ESP_ERR_NO_MEM;
}
/* NVS may already be init via pod_settings; ignore ALREADY_INIT. */
ret = nvs_flash_init();
if (ret == ESP_ERR_NVS_NO_FREE_PAGES ||
ret == ESP_ERR_NVS_NEW_VERSION_FOUND) {
ESP_ERROR_CHECK(nvs_flash_erase());
ret = nvs_flash_init();
}
if (ret != ESP_OK && ret != ESP_ERR_INVALID_STATE) {
ESP_LOGE(TAG, "nvs_flash_init failed: %s", esp_err_to_name(ret));
return ret;
}
const esp_timer_create_args_t tune_args = {
.callback = &ble_conn_tune_timer_cb,
.name = "ble_conn_tune",
};
ESP_ERROR_CHECK(esp_timer_create(&tune_args, &conn_tune_timer));
ret = nimble_port_init();
if (ret != ESP_OK) {
ESP_LOGE(TAG, "nimble_port_init failed: %s", esp_err_to_name(ret));
return ret;
}
ble_hs_cfg.sync_cb = ble_on_sync;
ble_hs_cfg.reset_cb = ble_on_reset;
int rc = gatt_svr_init();
if (rc != 0) {
ESP_LOGE(TAG, "gatt_svr_init failed: %d", rc);
return ESP_FAIL;
}
rc = ble_svc_gap_device_name_set(DEVICE_NAME);
if (rc != 0) {
ESP_LOGE(TAG, "device name set failed: %d", rc);
return ESP_FAIL;
}
rc = ble_att_set_preferred_mtu(BLE_PREFERRED_ATT_MTU);
if (rc != 0) {
ESP_LOGW(TAG, "preferred MTU set failed: %d", rc);
} else {
ESP_LOGI(TAG, "preferred ATT MTU %u", (unsigned)BLE_PREFERRED_ATT_MTU);
}
nimble_port_freertos_init(ble_host_task);
ESP_LOGI(TAG, "BLE host transport started");
return ESP_OK;
}
+22
View File
@@ -0,0 +1,22 @@
#ifndef BLE_HOST_H
#define BLE_HOST_H
#include "esp_err.h"
#include "freertos/FreeRTOS.h"
#include "freertos/queue.h"
#include <stddef.h>
#include <stdint.h>
/** Start NimBLE host transport (Master only). Uses same framing as UART. */
esp_err_t init_ble_host(QueueHandle_t cmd_queue);
/** True when a central is connected and CCCD notify is enabled. */
bool ble_host_ready(void);
/** Negotiated ATT MTU (default 23 until exchange). */
uint16_t ble_host_att_mtu(void);
/** Max bytes per ATT notify/write (ATT MTU - 3). Frames may span multiple notifies. */
uint16_t ble_host_max_payload(void);
#endif
+196
View File
@@ -0,0 +1,196 @@
#include "ble_stream.h"
#include "app_config.h"
#include "ble_host.h"
#include "client_registry.h"
#include "esp_log.h"
#include "esp_timer.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "ota_session.h"
#include "uart_cmd.h"
#include "uart_messages.pb.h"
#include "uart_proto.h"
#include <string.h>
static const char *TAG = "[BLE_STREAM]";
/* Min matches FreeRTOS tick (HZ=100 → 10 ms); 8 ms would delay 0 ticks. */
#define BLE_STREAM_PERIOD_MIN_MS 10u
#define BLE_STREAM_PERIOD_MAX_MS 16u
#define BLE_STREAM_PERIOD_DEFAULT_MS 10u
static uint32_t s_period_ms = BLE_STREAM_PERIOD_DEFAULT_MS;
static TaskHandle_t s_task;
static volatile bool s_running;
static bool tap_notify_any(const client_info_t *client) {
return client != NULL &&
(client->tap_notify_single || client->tap_notify_double ||
client->tap_notify_triple);
}
static alox_TapKind tap_kind_from_registry(uint32_t kind) {
switch (kind) {
case 1:
return alox_TapKind_TAP_SINGLE;
case 2:
return alox_TapKind_TAP_DOUBLE;
case 3:
return alox_TapKind_TAP_TRIPLE;
default:
return alox_TapKind_TAP_NONE;
}
}
static void fill_clients(alox_BleStreamTick *tick) {
tick->clients_count = 0;
size_t count = client_registry_count();
for (size_t i = 0; i < count; i++) {
const client_info_t *client = client_registry_at(i);
if (client == NULL) {
continue;
}
const bool want_accel = client->accel_stream_enabled;
const bool want_tap = tap_notify_any(client);
if (!want_accel && !want_tap) {
continue;
}
if (tick->clients_count >=
sizeof(tick->clients) / sizeof(tick->clients[0])) {
break;
}
alox_CacheClientStatus *entry = &tick->clients[tick->clients_count++];
entry->client_id = client->id;
entry->has_accel = false;
entry->has_tap = false;
if (want_accel) {
entry->has_accel = true;
entry->accel.valid = client->accel_valid;
if (client->accel_valid) {
entry->accel.x = client->accel_x;
entry->accel.y = client->accel_y;
entry->accel.z = client->accel_z;
entry->accel.age_ms =
client_registry_ms_since(client->accel_updated_at);
}
}
if (want_tap) {
uint32_t kind = 0;
uint32_t age_ms = 0;
if (client_registry_take_tap(client->id, &kind, &age_ms)) {
entry->has_tap = true;
entry->tap.kind = tap_kind_from_registry(kind);
entry->tap.age_ms = age_ms;
}
}
}
}
static bool any_accel_stream(void) {
size_t count = client_registry_count();
for (size_t i = 0; i < count; i++) {
const client_info_t *client = client_registry_at(i);
if (client != NULL && client->accel_stream_enabled) {
return true;
}
}
return false;
}
static esp_err_t send_tick_truncated(alox_UartMessage *msg) {
/*
* ble_host_tx splits frames larger than ATT MTU-3 across notifies. Only
* shrink the tick if it cannot encode into MAX_PAYLOAD_SIZE.
*/
while (true) {
esp_err_t err = uart_send_uart_message(msg);
if (err == ESP_OK) {
return ESP_OK;
}
if (msg->payload.ble_stream_tick.clients_count == 0) {
ESP_LOGW(TAG, "tick too large even with 0 clients: %s",
esp_err_to_name(err));
return err;
}
msg->payload.ble_stream_tick.clients_count--;
ESP_LOGW(TAG, "truncating tick clients to %u for encode buffer",
(unsigned)msg->payload.ble_stream_tick.clients_count);
}
}
static void ble_stream_task(void *param) {
(void)param;
ESP_LOGI(TAG, "BLE stream task started period_ms=%lu",
(unsigned long)s_period_ms);
while (s_running) {
uint32_t period = s_period_ms;
if (period < BLE_STREAM_PERIOD_MIN_MS) {
period = BLE_STREAM_PERIOD_MIN_MS;
}
if (period > BLE_STREAM_PERIOD_MAX_MS) {
period = BLE_STREAM_PERIOD_MAX_MS;
}
/* Pause ticks during host OTA / ESP-NOW distribute so ATT bandwidth
* stays available for OTA_STATUS notifies. */
if (ble_host_ready() && any_accel_stream() && !ota_session_busy()) {
alox_UartMessage msg = alox_UartMessage_init_zero;
uart_cmd_init_response(&msg, alox_MessageType_BLE_STREAM_TICK,
alox_UartMessage_ble_stream_tick_tag);
msg.payload.ble_stream_tick.esp_timestamp_us =
(uint64_t)esp_timer_get_time();
msg.payload.ble_stream_tick.period_ms = period;
fill_clients(&msg.payload.ble_stream_tick);
esp_err_t err = send_tick_truncated(&msg);
if (err != ESP_OK) {
ESP_LOGW(TAG, "tick send failed: %s", esp_err_to_name(err));
}
}
TickType_t delay_ticks = pdMS_TO_TICKS(period);
if (delay_ticks < 1) {
delay_ticks = 1;
}
vTaskDelay(delay_ticks);
}
s_task = NULL;
vTaskDelete(NULL);
}
void ble_stream_set_period_ms(uint32_t period_ms) {
if (period_ms == 0) {
period_ms = BLE_STREAM_PERIOD_DEFAULT_MS;
}
if (period_ms < BLE_STREAM_PERIOD_MIN_MS) {
period_ms = BLE_STREAM_PERIOD_MIN_MS;
}
if (period_ms > BLE_STREAM_PERIOD_MAX_MS) {
period_ms = BLE_STREAM_PERIOD_MAX_MS;
}
s_period_ms = period_ms;
}
uint32_t ble_stream_period_ms(void) { return s_period_ms; }
void ble_stream_update(void) {
const app_config_t *cfg = app_config_get();
bool want = cfg != NULL && cfg->host_ble && any_accel_stream();
if (want && !s_running) {
s_running = true;
if (xTaskCreate(ble_stream_task, "ble_stream", 8192, NULL, 5, &s_task) !=
pdPASS) {
s_running = false;
s_task = NULL;
ESP_LOGE(TAG, "failed to create ble_stream task");
}
} else if (!want && s_running) {
s_running = false;
}
}
+18
View File
@@ -0,0 +1,18 @@
#ifndef BLE_STREAM_H
#define BLE_STREAM_H
#include <stdbool.h>
#include <stdint.h>
/** Clamp and store BLE push period (10–16 ms; min = FreeRTOS tick). */
void ble_stream_set_period_ms(uint32_t period_ms);
uint32_t ble_stream_period_ms(void);
/**
* Re-evaluate whether the BLE push task should run (host_ble + any accel
* stream enabled). Safe to call from accel-stream handlers.
*/
void ble_stream_update(void);
#endif
+74 -40
View File
@@ -1,6 +1,10 @@
#include "board_input.h"
#include "powerpod.h"
#include "client_registry.h"
#include "driver/gpio.h"
#if POWERPOD_BATTERY_UV_ENABLE
#include "battery_uv.h"
#endif
#include "esp_adc/adc_oneshot.h"
#include "esp_log.h"
#include "freertos/FreeRTOS.h"
@@ -18,34 +22,51 @@ static const char *TAG_LIPO = "[LIPO]";
#define LIPO_ADC_MAX_RAW 4095
static QueueHandle_t s_button_queue;
static adc_oneshot_unit_handle_t s_adc;
static bool s_lipo1_ok;
static bool s_lipo2_ok;
static adc_channel_t s_lipo1_ch;
static adc_channel_t s_lipo2_ch;
static esp_err_t adc_init_channel(int gpio, adc_channel_t *out_ch, bool *out_ok) {
adc_unit_t unit;
esp_err_t err = adc_oneshot_io_to_channel(gpio, &unit, out_ch);
typedef struct {
adc_oneshot_unit_handle_t unit;
adc_channel_t ch;
bool ok;
} lipo_adc_t;
static lipo_adc_t s_lipo1;
static lipo_adc_t s_lipo2;
static esp_err_t adc_init_gpio(int gpio, lipo_adc_t *out) {
out->unit = NULL;
out->ok = false;
adc_unit_t unit_id;
esp_err_t err = adc_oneshot_io_to_channel(gpio, &unit_id, &out->ch);
if (err != ESP_OK) {
ESP_LOGW(TAG_LIPO, "GPIO%d not an ADC channel: %s", gpio, esp_err_to_name(err));
*out_ok = false;
return err;
}
if (unit != ADC_UNIT_1) {
ESP_LOGW(TAG_LIPO, "GPIO%d on ADC unit %d (expected ADC1)", gpio, (int)unit);
adc_oneshot_unit_init_cfg_t init_cfg = {
.unit_id = unit_id,
};
err = adc_oneshot_new_unit(&init_cfg, &out->unit);
if (err != ESP_OK) {
ESP_LOGW(TAG_LIPO, "ADC unit %d init GPIO%d failed: %s", (int)unit_id, gpio,
esp_err_to_name(err));
return err;
}
adc_oneshot_chan_cfg_t chan_cfg = {
.atten = ADC_ATTEN_DB_12,
.bitwidth = ADC_BITWIDTH_DEFAULT,
};
err = adc_oneshot_config_channel(s_adc, *out_ch, &chan_cfg);
err = adc_oneshot_config_channel(out->unit, out->ch, &chan_cfg);
if (err != ESP_OK) {
ESP_LOGW(TAG_LIPO, "ADC config GPIO%d failed: %s", gpio, esp_err_to_name(err));
*out_ok = false;
adc_oneshot_del_unit(out->unit);
out->unit = NULL;
return err;
}
*out_ok = true;
out->ok = true;
ESP_LOGI(TAG_LIPO, "GPIO%d ready (ADC unit %d)", gpio, (int)unit_id);
return ESP_OK;
}
@@ -56,15 +77,15 @@ static uint32_t raw_to_mv(int raw) {
return (uint32_t)((raw * LIPO_ADC_FULL_SCALE_MV) / LIPO_ADC_MAX_RAW);
}
static void sample_one_channel(adc_channel_t ch, bool ok, uint32_t *mv_out,
static void sample_one_channel(const lipo_adc_t *adc, uint32_t *mv_out,
bool *valid_out) {
*valid_out = false;
*mv_out = 0;
if (!ok || s_adc == NULL) {
if (adc == NULL || !adc->ok || adc->unit == NULL) {
return;
}
int raw = 0;
if (adc_oneshot_read(s_adc, ch, &raw) == ESP_OK) {
if (adc_oneshot_read(adc->unit, adc->ch, &raw) == ESP_OK) {
*valid_out = true;
*mv_out = raw_to_mv(raw);
}
@@ -75,8 +96,8 @@ void board_input_read_lipo(board_lipo_reading_t *out) {
return;
}
memset(out, 0, sizeof(*out));
sample_one_channel(s_lipo1_ch, s_lipo1_ok, &out->lipo1_mv, &out->lipo1_valid);
sample_one_channel(s_lipo2_ch, s_lipo2_ok, &out->lipo2_mv, &out->lipo2_valid);
sample_one_channel(&s_lipo1, &out->lipo1_mv, &out->lipo1_valid);
sample_one_channel(&s_lipo2, &out->lipo2_mv, &out->lipo2_valid);
}
static void lipo_monitor_task(void *param) {
@@ -87,6 +108,7 @@ static void lipo_monitor_task(void *param) {
while (1) {
board_lipo_reading_t reading;
board_input_read_lipo(&reading);
client_registry_set_master_battery(&reading);
ESP_LOGI(TAG_LIPO,
"LIPO1 GPIO%d %s %lu mV LIPO2 GPIO%d %s %lu mV",
@@ -95,6 +117,10 @@ static void lipo_monitor_task(void *param) {
reading.lipo2_valid ? "ok" : "n/a",
(unsigned long)reading.lipo2_mv);
#if POWERPOD_BATTERY_UV_ENABLE
battery_uv_poll(&reading);
#endif
vTaskDelay(pdMS_TO_TICKS(LIPO_SAMPLE_INTERVAL_MS));
}
}
@@ -165,29 +191,30 @@ static esp_err_t init_button(void) {
return ESP_OK;
}
static esp_err_t init_lipo_adc(void) {
adc_oneshot_unit_init_cfg_t init_cfg = {
.unit_id = ADC_UNIT_1,
};
esp_err_t err = adc_oneshot_new_unit(&init_cfg, &s_adc);
if (err != ESP_OK) {
ESP_LOGW(TAG_LIPO, "ADC init failed: %s", esp_err_to_name(err));
return err;
}
static esp_err_t init_lipo_adc_hw(void) {
memset(&s_lipo1, 0, sizeof(s_lipo1));
memset(&s_lipo2, 0, sizeof(s_lipo2));
adc_init_channel(V_LIPO_1_GPIO, &s_lipo1_ch, &s_lipo1_ok);
adc_init_gpio(V_LIPO_1_GPIO, &s_lipo1);
if (V_LIPO_2_GPIO == TASTER_GPIO) {
ESP_LOGW(TAG_LIPO, "LIPO2 on GPIO%d skipped (button uses same pin)",
V_LIPO_2_GPIO);
s_lipo2_ok = false;
} else {
adc_init_channel(V_LIPO_2_GPIO, &s_lipo2_ch, &s_lipo2_ok);
adc_init_gpio(V_LIPO_2_GPIO, &s_lipo2);
}
if (!s_lipo1_ok && !s_lipo2_ok) {
adc_oneshot_del_unit(s_adc);
s_adc = NULL;
if (!s_lipo1.ok && !s_lipo2.ok) {
return ESP_FAIL;
}
return ESP_OK;
}
esp_err_t board_input_init_adc_only(void) { return init_lipo_adc_hw(); }
esp_err_t board_input_start_lipo_monitor(void) {
if (!s_lipo1.ok && !s_lipo2.ok) {
return ESP_FAIL;
}
@@ -198,16 +225,23 @@ static esp_err_t init_lipo_adc(void) {
return ESP_OK;
}
esp_err_t board_input_init_button(void) { return init_button(); }
esp_err_t board_input_init(void) {
esp_err_t err = init_button();
esp_err_t err = board_input_init_adc_only();
if (err != ESP_OK) {
ESP_LOGW(TAG_LIPO, "ADC init failed: %s", esp_err_to_name(err));
} else {
err = board_input_start_lipo_monitor();
if (err != ESP_OK) {
ESP_LOGW(TAG_LIPO, "monitor task failed: %s", esp_err_to_name(err));
}
}
err = board_input_init_button();
if (err != ESP_OK) {
ESP_LOGW(TAG_BTN, "init failed: %s", esp_err_to_name(err));
}
err = init_lipo_adc();
if (err != ESP_OK) {
ESP_LOGW(TAG_LIPO, "init failed: %s", esp_err_to_name(err));
}
return ESP_OK;
}
+9
View File
@@ -19,6 +19,15 @@ typedef struct {
*/
esp_err_t board_input_init(void);
/** LiPo ADC channels only (no button, no background task). */
esp_err_t board_input_init_adc_only(void);
/** Start 10 s LiPo monitor task (normal boot only). */
esp_err_t board_input_start_lipo_monitor(void);
/** Front-panel button debounce task. */
esp_err_t board_input_init_button(void);
/** On-demand ADC read of both LiPo sense inputs (if configured). */
void board_input_read_lipo(board_lipo_reading_t *out);
+115 -46
View File
@@ -2,7 +2,7 @@
* BMA456H integration for Powerpod (ESP-IDF I2C master + Bosch SensorAPI).
*
* Polls accelerometer at 10 Hz; tap events arrive on BMA456_INT_GPIO.
* Accel logging is filtered in software (deadzone); slaves stream samples via ESP-NOW.
* Accel log + ESP-NOW stream share a software deadzone filter (emit on change).
*/
#include "bosch456.h"
@@ -33,12 +33,16 @@ static int16_t s_last_x;
static int16_t s_last_y;
static int16_t s_last_z;
static bool s_have_last_sample;
static int16_t s_stream_last_x;
static int16_t s_stream_last_y;
static int16_t s_stream_last_z;
static bool s_stream_have_last;
static volatile bool s_int_pending;
static SemaphoreHandle_t s_accel_mutex;
static bma456_tap_handler_t s_tap_handler;
static void *s_tap_handler_ctx;
static const bma456_tap_config_t s_tap_config = BMA456_TAP_CONFIG_DEFAULT;
static bma456_tap_config_t s_tap_config = BMA456_TAP_CONFIG_DEFAULT;
static esp_err_t check_bma4(const char *api_name, int8_t rslt);
@@ -119,6 +123,7 @@ bool bma456_is_ready(void) { return s_bma456_ready; }
void bma456_set_accel_deadzone(uint32_t deadzone_lsb) {
s_accel_deadzone = deadzone_lsb;
s_have_last_sample = false;
s_stream_have_last = false;
if (s_bma456_ready) {
ESP_LOGI(TAG, "accel deadzone %lu LSB", (unsigned long)deadzone_lsb);
}
@@ -126,6 +131,113 @@ void bma456_set_accel_deadzone(uint32_t deadzone_lsb) {
uint32_t bma456_get_accel_deadzone(void) { return s_accel_deadzone; }
void bma456_accel_stream_reset_baseline(void) { s_stream_have_last = false; }
bool bma456_accel_stream_take_if_changed(int16_t x, int16_t y, int16_t z) {
if (s_stream_have_last) {
if (axis_delta(x, s_stream_last_x) <= (int16_t)s_accel_deadzone &&
axis_delta(y, s_stream_last_y) <= (int16_t)s_accel_deadzone &&
axis_delta(z, s_stream_last_z) <= (int16_t)s_accel_deadzone) {
return false;
}
}
s_stream_last_x = x;
s_stream_last_y = y;
s_stream_last_z = z;
s_stream_have_last = true;
return true;
}
bool bma456_tap_config_valid(const bma456_tap_config_t *cfg) {
if (cfg == NULL) {
return false;
}
if (cfg->tap_sens_thres > 15u) {
return false;
}
if (cfg->axis_sel > 2u) {
return false;
}
if (cfg->wait_for_timeout > 1u) {
return false;
}
if (!cfg->enable_single && !cfg->enable_double && !cfg->enable_triple) {
return false;
}
return true;
}
void bma456_get_tap_config(bma456_tap_config_t *cfg_out) {
if (cfg_out == NULL) {
return;
}
*cfg_out = s_tap_config;
}
static esp_err_t apply_tap_parameters(const bma456_tap_config_t *cfg) {
struct bma456h_multitap_settings tap_settings = {0};
int8_t ret = bma456h_tap_get_parameter(&tap_settings, &s_bma456);
if (check_bma4("bma456h_tap_get_parameter", ret) != ESP_OK) {
return ESP_FAIL;
}
tap_settings.tap_sens_thres = cfg->tap_sens_thres;
tap_settings.max_gest_dur = cfg->max_gest_dur;
tap_settings.tap_shock_dur = cfg->tap_shock_dur;
tap_settings.quite_time_after_gest = cfg->quite_time_after_gest;
tap_settings.wait_for_timeout = cfg->wait_for_timeout;
tap_settings.axis_sel = cfg->axis_sel;
ret = bma456h_tap_set_parameter(&tap_settings, &s_bma456);
if (check_bma4("bma456h_tap_set_parameter", ret) != ESP_OK) {
return ESP_FAIL;
}
/* Disable all tap kinds first, then enable the requested set. */
uint16_t all_taps =
BMA456H_SINGLE_TAP_EN | BMA456H_DOUBLE_TAP_EN | BMA456H_TRIPLE_TAP_EN;
ret = bma456h_feature_enable(all_taps, BMA4_DISABLE, &s_bma456);
if (check_bma4("bma456h_feature_enable(disable taps)", ret) != ESP_OK) {
return ESP_FAIL;
}
uint16_t tap_features = 0;
if (cfg->enable_single) {
tap_features |= BMA456H_SINGLE_TAP_EN;
}
if (cfg->enable_double) {
tap_features |= BMA456H_DOUBLE_TAP_EN;
}
if (cfg->enable_triple) {
tap_features |= BMA456H_TRIPLE_TAP_EN;
}
ret = bma456h_feature_enable(tap_features, BMA4_ENABLE, &s_bma456);
if (check_bma4("bma456h_feature_enable(taps)", ret) != ESP_OK) {
return ESP_FAIL;
}
ESP_LOGI(TAG,
"tap config sens=%u max_gest=%u shock=%u quiet=%u wait=%u axis=%u "
"(single=%d double=%d triple=%d)",
(unsigned)cfg->tap_sens_thres, (unsigned)cfg->max_gest_dur,
(unsigned)cfg->tap_shock_dur, (unsigned)cfg->quite_time_after_gest,
(unsigned)cfg->wait_for_timeout, (unsigned)cfg->axis_sel,
cfg->enable_single, cfg->enable_double, cfg->enable_triple);
return ESP_OK;
}
esp_err_t bma456_set_tap_config(const bma456_tap_config_t *cfg) {
if (!bma456_tap_config_valid(cfg)) {
return ESP_ERR_INVALID_ARG;
}
if (s_bma456_ready) {
esp_err_t err = apply_tap_parameters(cfg);
if (err != ESP_OK) {
return err;
}
}
s_tap_config = *cfg;
return ESP_OK;
}
void bma456_set_tap_handler(bma456_tap_handler_t handler, void *ctx) {
s_tap_handler = handler;
s_tap_handler_ctx = ctx;
@@ -259,52 +371,9 @@ static esp_err_t configure_tap_interrupt(void) {
int8_t ret;
const uint8_t int_line = BMA4_INTR2_MAP;
struct bma456h_multitap_settings tap_settings = {0};
ret = bma456h_tap_get_parameter(&tap_settings, &s_bma456);
if (check_bma4("bma456h_tap_get_parameter", ret) != ESP_OK) {
if (apply_tap_parameters(&s_tap_config) != ESP_OK) {
return ESP_FAIL;
}
tap_settings.tap_sens_thres = s_tap_config.tap_sens_thres;
tap_settings.max_gest_dur = s_tap_config.max_gest_dur;
tap_settings.tap_shock_dur = s_tap_config.tap_shock_dur;
tap_settings.quite_time_after_gest = s_tap_config.quite_time_after_gest;
tap_settings.wait_for_timeout = s_tap_config.wait_for_timeout;
tap_settings.axis_sel = s_tap_config.axis_sel;
ret = bma456h_tap_set_parameter(&tap_settings, &s_bma456);
if (check_bma4("bma456h_tap_set_parameter", ret) != ESP_OK) {
return ESP_FAIL;
}
uint16_t tap_features = 0;
if (s_tap_config.enable_single) {
tap_features |= BMA456H_SINGLE_TAP_EN;
}
if (s_tap_config.enable_double) {
tap_features |= BMA456H_DOUBLE_TAP_EN;
}
if (s_tap_config.enable_triple) {
tap_features |= BMA456H_TRIPLE_TAP_EN;
}
if (tap_features == 0) {
ESP_LOGW(TAG, "tap config: no tap kinds enabled");
return ESP_ERR_INVALID_ARG;
}
ret = bma456h_feature_enable(tap_features, BMA4_ENABLE, &s_bma456);
if (check_bma4("bma456h_feature_enable", ret) != ESP_OK) {
return ESP_FAIL;
}
ESP_LOGI(TAG,
"tap config sens=%u max_gest=%u shock=%u quiet=%u wait=%u axis=%u "
"(single=%d double=%d triple=%d)",
(unsigned)s_tap_config.tap_sens_thres,
(unsigned)s_tap_config.max_gest_dur,
(unsigned)s_tap_config.tap_shock_dur,
(unsigned)s_tap_config.quite_time_after_gest,
(unsigned)s_tap_config.wait_for_timeout,
(unsigned)s_tap_config.axis_sel, s_tap_config.enable_single,
s_tap_config.enable_double, s_tap_config.enable_triple);
ret = bma456h_map_interrupt(int_line, BMA456H_TAP_OUT_INT, BMA4_ENABLE,
&s_bma456);
+25 -2
View File
@@ -19,7 +19,7 @@
/** Sensor interrupt line → ESP32 GPIO (active high, rising edge). */
#define BMA456_INT_GPIO 10
/** Software filter: log accel only when |axis - last| > deadzone (raw LSB). */
/** Software filter: emit accel only when |axis - last| > deadzone (raw LSB). */
#define BMA456_DEFAULT_ACCEL_DEADZONE 100u
/**
@@ -42,7 +42,7 @@ typedef struct {
bool enable_triple;
} bma456_tap_config_t;
/** Edit these values to tune tap detection, then rebuild. */
/** Edit these values for the compile-time default (overridden by NVS if set). */
#define BMA456_TAP_CONFIG_DEFAULT \
{ \
.tap_sens_thres = 5, /* sensitive; 0=max, Bosch default=9 */ \
@@ -56,6 +56,19 @@ typedef struct {
.enable_triple = true, \
}
/** True if cfg fields are within sensor limits and at least one tap kind is on. */
bool bma456_tap_config_valid(const bma456_tap_config_t *cfg);
/** Copy current runtime tap config (compile default until set/NVS-applied). */
void bma456_get_tap_config(bma456_tap_config_t *cfg_out);
/**
* Apply tap config to the sensor (and keep RAM copy).
* Returns ESP_ERR_INVALID_ARG if invalid. When the sensor is not ready, only
* the RAM copy is updated (ESP_OK).
*/
esp_err_t bma456_set_tap_config(const bma456_tap_config_t *cfg);
/**
* Probe and configure the sensor on bus_handle (100 kHz device).
* On failure the device is removed and ESP_ERR_NOT_FOUND / ESP_FAIL is returned;
@@ -68,6 +81,16 @@ bool bma456_is_ready(void);
void bma456_set_accel_deadzone(uint32_t deadzone_lsb);
uint32_t bma456_get_accel_deadzone(void);
/**
* ESP-NOW stream filter: true if sample exceeds deadzone vs last streamed
* sample (or no baseline yet). On true, commits (x,y,z) as stream baseline.
* Independent of the log filter baseline.
*/
bool bma456_accel_stream_take_if_changed(int16_t x, int16_t y, int16_t z);
/** Force next stream take to accept (call when accel stream is enabled). */
void bma456_accel_stream_reset_baseline(void);
/** Log accel when any axis moved more than deadzone since last reported sample. */
void bma456_report_accel_if_changed(int16_t x, int16_t y, int16_t z);
+234 -18
View File
@@ -1,4 +1,5 @@
#include "client_registry.h"
#include "pod_settings.h"
#include "esp_log.h"
#include "freertos/FreeRTOS.h"
#include "freertos/idf_additions.h"
@@ -13,12 +14,68 @@ typedef struct {
} client_slot_t;
static client_slot_t s_clients[CLIENT_REGISTRY_MAX];
static pod_id_map_t s_id_map;
static struct {
board_lipo_reading_t reading;
uint32_t updated_at;
} s_master_battery;
static bool id_in_use(uint32_t id, const uint8_t *except_mac) {
for (uint8_t i = 0; i < s_id_map.count; i++) {
if (s_id_map.entries[i].id != id) {
continue;
}
if (except_mac != NULL &&
memcmp(s_id_map.entries[i].mac, except_mac, CLIENT_MAC_LEN) == 0) {
continue;
}
return true;
}
return false;
}
static int find_map_index(const uint8_t mac[CLIENT_MAC_LEN]) {
for (uint8_t i = 0; i < s_id_map.count; i++) {
if (memcmp(s_id_map.entries[i].mac, mac, CLIENT_MAC_LEN) == 0) {
return (int)i;
}
}
return -1;
}
static esp_err_t persist_id_map(void) {
return pod_settings_save_id_map(&s_id_map);
}
static uint32_t alloc_next_free_id(void) {
if (s_id_map.count >= POD_ID_MAP_MAX) {
return 0;
}
uint8_t start = s_id_map.next_id;
if (start < POD_CLIENT_ID_MIN) {
start = (uint8_t)POD_CLIENT_ID_MIN;
}
for (uint32_t n = 0; n < POD_CLIENT_ID_MAX; n++) {
uint32_t id = (uint32_t)start + n;
if (id > POD_CLIENT_ID_MAX) {
id = id - POD_CLIENT_ID_MAX;
}
if (id < POD_CLIENT_ID_MIN) {
continue;
}
if (!id_in_use(id, NULL)) {
uint32_t next = id + 1;
if (next > POD_CLIENT_ID_MAX) {
next = POD_CLIENT_ID_MIN;
}
s_id_map.next_id = (uint8_t)next;
return id;
}
}
return 0;
}
uint32_t client_registry_now_ms(void) {
return (uint32_t)(xTaskGetTickCount() * portTICK_PERIOD_MS);
}
@@ -82,6 +139,11 @@ static client_slot_t *alloc_slot(const uint8_t mac[CLIENT_MAC_LEN],
slot->active = true;
memcpy(slot->info.mac, mac, CLIENT_MAC_LEN);
slot->info.accel_deadzone = CLIENT_REGISTRY_DEFAULT_ACCEL_DEADZONE;
{
static const bma456_tap_config_t k_tap_default = BMA456_TAP_CONFIG_DEFAULT;
slot->info.tap_config = k_tap_default;
}
slot->info.uv_protection_enabled = false;
if (out_is_new != NULL) {
*out_is_new = true;
}
@@ -91,7 +153,84 @@ static client_slot_t *alloc_slot(const uint8_t mac[CLIENT_MAC_LEN],
return NULL;
}
void client_registry_init(void) { memset(s_clients, 0, sizeof(s_clients)); }
void client_registry_init(void) {
memset(s_clients, 0, sizeof(s_clients));
memset(&s_id_map, 0, sizeof(s_id_map));
s_id_map.next_id = (uint8_t)POD_CLIENT_ID_MIN;
(void)pod_settings_load_id_map(&s_id_map);
if (s_id_map.next_id < POD_CLIENT_ID_MIN) {
s_id_map.next_id = (uint8_t)POD_CLIENT_ID_MIN;
}
}
esp_err_t client_registry_assign_id(const uint8_t mac[CLIENT_MAC_LEN],
uint32_t preferred_id, uint32_t *out_id) {
if (mac == NULL || out_id == NULL) {
return ESP_ERR_INVALID_ARG;
}
int idx = find_map_index(mac);
if (idx >= 0) {
*out_id = s_id_map.entries[idx].id;
return ESP_OK;
}
uint32_t id = 0;
if (preferred_id >= POD_CLIENT_ID_MIN && preferred_id <= POD_CLIENT_ID_MAX &&
!id_in_use(preferred_id, mac)) {
id = preferred_id;
} else {
id = alloc_next_free_id();
}
if (id == 0) {
return ESP_ERR_NO_MEM;
}
if (s_id_map.count >= POD_ID_MAP_MAX) {
return ESP_ERR_NO_MEM;
}
memcpy(s_id_map.entries[s_id_map.count].mac, mac, CLIENT_MAC_LEN);
s_id_map.entries[s_id_map.count].id = (uint8_t)id;
s_id_map.count++;
if (id >= s_id_map.next_id) {
uint32_t next = id + 1;
if (next > POD_CLIENT_ID_MAX) {
next = POD_CLIENT_ID_MIN;
}
s_id_map.next_id = (uint8_t)next;
}
esp_err_t err = persist_id_map();
if (err != ESP_OK) {
ESP_LOGW(TAG, "persist id_map failed after assign id=%lu: %s",
(unsigned long)id, esp_err_to_name(err));
}
*out_id = id;
return ESP_OK;
}
void client_registry_clear_id_map(void) {
memset(s_clients, 0, sizeof(s_clients));
memset(&s_id_map, 0, sizeof(s_id_map));
s_id_map.next_id = (uint8_t)POD_CLIENT_ID_MIN;
esp_err_t err = pod_settings_clear_id_map();
if (err != ESP_OK) {
ESP_LOGW(TAG, "clear id_map NVS failed: %s", esp_err_to_name(err));
}
ESP_LOGI(TAG, "cleared id map and live registry");
}
void client_registry_mark_assign_sent(const uint8_t mac[CLIENT_MAC_LEN]) {
if (mac == NULL) {
return;
}
client_slot_t *slot = find_slot(mac);
if (slot == NULL) {
return;
}
slot->info.last_assign_sent_at = now_ms();
}
const client_info_t *
client_registry_find_by_mac(const uint8_t mac[CLIENT_MAC_LEN]) {
@@ -167,18 +306,19 @@ void client_registry_check_timeouts(uint32_t timeout_ms) {
uint32_t now = now_ms();
for (size_t i = 0; i < CLIENT_REGISTRY_MAX; i++) {
if (!s_clients[i].active || !s_clients[i].info.available) {
if (!s_clients[i].active) {
continue;
}
uint32_t elapsed = now - s_clients[i].info.last_success_ping_at;
if (elapsed > timeout_ms) {
s_clients[i].info.available = false;
char mac_str[18];
mac_to_str(s_clients[i].info.mac, mac_str, sizeof(mac_str));
ESP_LOGW(TAG, "client inactive id=%lu mac=%s (no heartbeat for %lu ms)",
ESP_LOGW(TAG, "client dropped id=%lu mac=%s (no heartbeat for %lu ms)",
(unsigned long)s_clients[i].info.id, mac_str,
(unsigned long)elapsed);
s_clients[i].active = false;
memset(&s_clients[i].info, 0, sizeof(s_clients[i].info));
}
}
}
@@ -246,6 +386,91 @@ size_t client_registry_set_accel_deadzone_all(uint32_t deadzone) {
return n;
}
esp_err_t client_registry_set_tap_config(uint32_t client_id,
const bma456_tap_config_t *cfg) {
if (cfg == NULL || !bma456_tap_config_valid(cfg)) {
return ESP_ERR_INVALID_ARG;
}
const client_info_t *info = client_registry_find_by_id(client_id);
if (info == NULL) {
return ESP_ERR_NOT_FOUND;
}
for (size_t i = 0; i < CLIENT_REGISTRY_MAX; i++) {
if (s_clients[i].active && s_clients[i].info.id == client_id) {
s_clients[i].info.tap_config = *cfg;
return ESP_OK;
}
}
return ESP_ERR_NOT_FOUND;
}
esp_err_t client_registry_get_tap_config(uint32_t client_id,
bma456_tap_config_t *cfg_out) {
if (cfg_out == NULL) {
return ESP_ERR_INVALID_ARG;
}
const client_info_t *info = client_registry_find_by_id(client_id);
if (info == NULL) {
return ESP_ERR_NOT_FOUND;
}
*cfg_out = info->tap_config;
return ESP_OK;
}
size_t client_registry_set_tap_config_all(const bma456_tap_config_t *cfg) {
if (cfg == NULL || !bma456_tap_config_valid(cfg)) {
return 0;
}
size_t n = 0;
for (size_t i = 0; i < CLIENT_REGISTRY_MAX; i++) {
if (!s_clients[i].active) {
continue;
}
s_clients[i].info.tap_config = *cfg;
n++;
}
return n;
}
esp_err_t client_registry_set_uv_protection(uint32_t client_id, bool enabled) {
for (size_t i = 0; i < CLIENT_REGISTRY_MAX; i++) {
if (s_clients[i].active && s_clients[i].info.id == client_id) {
s_clients[i].info.uv_protection_enabled = enabled;
return ESP_OK;
}
}
return ESP_ERR_NOT_FOUND;
}
esp_err_t client_registry_get_uv_protection(uint32_t client_id,
bool *enabled_out) {
if (enabled_out == NULL) {
return ESP_ERR_INVALID_ARG;
}
const client_info_t *info = client_registry_find_by_id(client_id);
if (info == NULL) {
return ESP_ERR_NOT_FOUND;
}
*enabled_out = info->uv_protection_enabled;
return ESP_OK;
}
size_t client_registry_set_uv_protection_all(bool enabled) {
size_t n = 0;
for (size_t i = 0; i < CLIENT_REGISTRY_MAX; i++) {
if (!s_clients[i].active) {
continue;
}
s_clients[i].info.uv_protection_enabled = enabled;
n++;
}
return n;
}
static void clear_client_accel(client_slot_t *slot) {
if (slot == NULL) {
return;
@@ -498,24 +723,15 @@ esp_err_t client_registry_update_battery(const uint8_t mac[CLIENT_MAC_LEN],
client_slot_t *slot = find_slot(mac);
if (slot == NULL) {
bool is_new = false;
esp_err_t err = client_registry_upsert(mac, slave_id, 0, true, false, &is_new);
if (err != ESP_OK) {
return err;
}
slot = find_slot(mac);
if (slot == NULL) {
return ESP_ERR_NOT_FOUND;
}
ESP_LOGI(TAG, "battery auto-registered id=%lu (report before heartbeat)",
(unsigned long)slave_id);
/* Ids are assigned on presence only; ignore early battery reports. */
return ESP_ERR_NOT_FOUND;
}
if (slot->info.id != slave_id) {
ESP_LOGW(TAG, "battery id %lu → %lu for mac %02x:…:%02x",
if (slave_id != 0 && slot->info.id != slave_id) {
ESP_LOGW(TAG, "battery id mismatch slot=%lu report=%lu mac %02x:…:%02x",
(unsigned long)slot->info.id, (unsigned long)slave_id, mac[0],
mac[5]);
slot->info.id = slave_id;
return ESP_ERR_INVALID_STATE;
}
slot->info.lipo1_valid = lipo1_valid;
+40 -2
View File
@@ -2,6 +2,7 @@
#define CLIENT_REGISTRY_H
#include "board_input.h"
#include "bosch456.h"
#include "esp_err.h"
#include <stdbool.h>
#include <stddef.h>
@@ -22,6 +23,8 @@ typedef struct {
uint32_t version;
/** Accel deadzone in raw LSB per axis (master copy for ESP-NOW config). */
uint32_t accel_deadzone;
/** Tap detection config (master copy for ESP-NOW / UART read). */
bma456_tap_config_t tap_config;
/** Latest accel from slave ESP-NOW stream (master only). */
bool accel_valid;
int16_t accel_x;
@@ -44,14 +47,35 @@ typedef struct {
uint32_t lipo1_mv;
uint32_t lipo2_mv;
uint32_t battery_updated_at;
/** Master cache: software UV protection enabled on slave (default false). */
bool uv_protection_enabled;
/** Master: last ESPNOW_ASSIGN_ID send time (rate-limit resends). */
uint32_t last_assign_sent_at;
} client_info_t;
#define CLIENT_REGISTRY_DEFAULT_ACCEL_DEADZONE 100u
/** Tap events older than this are discarded (matches accel stream interval). */
#define CLIENT_REGISTRY_TAP_MAX_AGE_MS 16u
#define CLIENT_REGISTRY_TAP_MAX_AGE_MS 10u
void client_registry_init(void);
/**
* Assign a stable logical id for mac (1…255). Honors preferred if free or
* already owned by mac; otherwise allocates the next free id. Persists the
* master id map to NVS.
*/
esp_err_t client_registry_assign_id(const uint8_t mac[CLIENT_MAC_LEN],
uint32_t preferred_id, uint32_t *out_id);
/**
* Clear NVS id map, RAM map, and all live registry slots. Does not notify
* slaves (caller sends CLEAR_ID_BINDING).
*/
void client_registry_clear_id_map(void);
/** Record that ASSIGN_ID was sent to this MAC (for resend rate limiting). */
void client_registry_mark_assign_sent(const uint8_t mac[CLIENT_MAC_LEN]);
/** Milliseconds since boot (same clock as stored ping timestamps). */
uint32_t client_registry_now_ms(void);
@@ -72,7 +96,10 @@ esp_err_t client_registry_heartbeat(const uint8_t mac[CLIENT_MAC_LEN],
uint32_t id, uint32_t version, bool used,
bool *out_is_new, bool *out_reactivated);
/** Mark clients inactive when last_success_ping_at is older than timeout_ms. */
/**
* Drop live registry slots whose last_success_ping_at is older than
* timeout_ms (NVS id map unchanged). Offline clients disappear from CLIENT_INFO.
*/
void client_registry_check_timeouts(uint32_t timeout_ms);
size_t client_registry_count(void);
@@ -88,6 +115,17 @@ esp_err_t client_registry_get_accel_deadzone(uint32_t client_id,
/** Push deadzone to all active registry entries; returns count updated. */
size_t client_registry_set_accel_deadzone_all(uint32_t deadzone);
esp_err_t client_registry_set_tap_config(uint32_t client_id,
const bma456_tap_config_t *cfg);
esp_err_t client_registry_get_tap_config(uint32_t client_id,
bma456_tap_config_t *cfg_out);
size_t client_registry_set_tap_config_all(const bma456_tap_config_t *cfg);
esp_err_t client_registry_set_uv_protection(uint32_t client_id, bool enabled);
esp_err_t client_registry_get_uv_protection(uint32_t client_id,
bool *enabled_out);
size_t client_registry_set_uv_protection_all(bool enabled);
/** Store latest accel sample from a slave (matched by sender MAC). */
esp_err_t client_registry_update_accel(const uint8_t mac[CLIENT_MAC_LEN],
uint32_t slave_id, int16_t x, int16_t y,
+10 -2
View File
@@ -1,5 +1,6 @@
#include "client_registry.h"
#include "cmd_accel_stream.h"
#include "ble_stream.h"
#include "esp_log.h"
#include "esp_now_comm.h"
#include "uart_cmd.h"
@@ -50,6 +51,10 @@ static void handle_accel_stream(const uint8_t *data, size_t len) {
}
if (req.write) {
if (req.period_ms != 0) {
ble_stream_set_period_ms(req.period_ms);
}
if (req.all_clients) {
size_t n = client_registry_set_accel_stream_all(req.enable);
uint32_t sent = 0;
@@ -65,8 +70,10 @@ static void handle_accel_stream(const uint8_t *data, size_t len) {
}
}
ESP_LOGI(TAG, "accel stream %s for %u/%u slaves",
req.enable ? "on" : "off", (unsigned)sent, (unsigned)n);
ESP_LOGI(TAG, "accel stream %s for %u/%u slaves period_ms=%lu",
req.enable ? "on" : "off", (unsigned)sent, (unsigned)n,
(unsigned long)ble_stream_period_ms());
ble_stream_update();
reply(req.enable, 0, sent > 0, sent);
return;
}
@@ -85,6 +92,7 @@ static void handle_accel_stream(const uint8_t *data, size_t len) {
}
esp_err_t err = push_stream_to_slave(client, req.enable);
ble_stream_update();
reply(req.enable, req.client_id, err == ESP_OK, err == ESP_OK ? 1u : 0u);
return;
}
+6 -10
View File
@@ -33,18 +33,14 @@ static bool append_battery_sample(alox_BatteryStatusResponse *resp,
return lipo1_valid || lipo2_valid;
}
static bool append_master_cached(alox_BatteryStatusResponse *resp) {
static bool append_master_sample(alox_BatteryStatusResponse *resp) {
board_lipo_reading_t reading;
uint32_t age_ms = 0;
if (!client_registry_get_master_battery(&reading, &age_ms)) {
board_input_read_lipo(&reading);
client_registry_set_master_battery(&reading);
age_ms = 0;
}
board_input_read_lipo(&reading);
client_registry_set_master_battery(&reading);
return append_battery_sample(resp, 0, reading.lipo1_valid, reading.lipo1_mv,
reading.lipo2_valid, reading.lipo2_mv, age_ms);
reading.lipo2_valid, reading.lipo2_mv, 0);
}
static bool append_slave_cached(alox_BatteryStatusResponse *resp,
@@ -102,7 +98,7 @@ static void handle_battery_status(const uint8_t *data, size_t len) {
bool any = false;
if (req.all_clients) {
any |= append_master_cached(resp);
any |= append_master_sample(resp);
for (size_t i = 0; i < client_registry_count(); i++) {
const client_info_t *client = client_registry_at(i);
if (client == NULL) {
@@ -113,7 +109,7 @@ static void handle_battery_status(const uint8_t *data, size_t len) {
ESP_LOGI(TAG, "battery cache all_clients → %u samples",
(unsigned)resp->samples_count);
} else if (req.client_id == 0) {
any = append_master_cached(resp);
any = append_master_sample(resp);
ESP_LOGI(TAG, "battery cache master");
} else {
const client_info_t *client = client_registry_find_by_id(req.client_id);
+118
View File
@@ -0,0 +1,118 @@
#include "cmd_battery_uv.h"
#include "client_registry.h"
#include "esp_log.h"
#include "esp_now_comm.h"
#include "pod_settings.h"
#include "uart_cmd.h"
static const char *TAG = "[BATTERY_UV_CMD]";
static void reply(uint32_t client_id, bool success, bool enabled, bool latched,
uint32_t slaves_updated) {
alox_UartMessage response;
uart_cmd_init_response(&response, alox_MessageType_BATTERY_UV,
alox_UartMessage_battery_uv_response_tag);
response.payload.battery_uv_response.client_id = client_id;
response.payload.battery_uv_response.success = success;
response.payload.battery_uv_response.enabled = enabled;
response.payload.battery_uv_response.latched = latched;
response.payload.battery_uv_response.slaves_updated = slaves_updated;
uart_cmd_send(&response, TAG);
}
static esp_err_t apply_local_uv(bool enabled) {
esp_err_t err = pod_settings_set_uv_enabled(enabled);
if (err != ESP_OK) {
return err;
}
if (!enabled) {
(void)pod_settings_clear_uv_latched();
}
return ESP_OK;
}
static esp_err_t push_uv_to_slave(const client_info_t *client, bool enabled) {
if (client == NULL) {
return ESP_ERR_INVALID_ARG;
}
esp_err_t err = client_registry_set_uv_protection(client->id, enabled);
if (err != ESP_OK) {
return err;
}
return esp_now_comm_send_battery_uv(client->mac, client->id, enabled);
}
static void handle_battery_uv(const uint8_t *data, size_t len) {
alox_UartMessage uart_msg;
alox_BatteryUvRequest req = alox_BatteryUvRequest_init_zero;
if (uart_cmd_decode(data, len, &uart_msg) != ESP_OK) {
ESP_LOGW(TAG, "decode failed");
reply(0, false, pod_settings_is_uv_enabled(), pod_settings_is_uv_latched(),
0);
return;
}
const alox_BatteryUvRequest *req_ptr = UART_CMD_REQ(
&uart_msg, alox_UartMessage_battery_uv_request_tag, battery_uv_request);
if (req_ptr != NULL) {
req = *req_ptr;
}
if (req.write) {
if (req.all_clients) {
(void)client_registry_set_uv_protection_all(req.enabled);
uint32_t sent = 0;
for (size_t i = 0; i < client_registry_count(); i++) {
const client_info_t *client = client_registry_at(i);
if (client == NULL) {
continue;
}
if (esp_now_comm_send_battery_uv(client->mac, client->id,
req.enabled) == ESP_OK) {
sent++;
}
}
bool local_ok = apply_local_uv(req.enabled) == ESP_OK;
ESP_LOGI(TAG, "UV protection %s local=%d slaves=%u",
req.enabled ? "on" : "off", local_ok, (unsigned)sent);
reply(0, local_ok || sent > 0, req.enabled,
pod_settings_is_uv_latched(), sent);
return;
}
if (req.client_id == 0) {
esp_err_t err = apply_local_uv(req.enabled);
reply(0, err == ESP_OK, pod_settings_is_uv_enabled(),
pod_settings_is_uv_latched(), 0);
return;
}
const client_info_t *client = client_registry_find_by_id(req.client_id);
if (client == NULL) {
ESP_LOGW(TAG, "client id %lu not found", (unsigned long)req.client_id);
reply(req.client_id, false, req.enabled, false, 0);
return;
}
esp_err_t err = push_uv_to_slave(client, req.enabled);
reply(req.client_id, err == ESP_OK, req.enabled, false,
err == ESP_OK ? 1 : 0);
return;
}
if (req.all_clients || req.client_id == 0) {
reply(0, true, pod_settings_is_uv_enabled(), pod_settings_is_uv_latched(),
0);
return;
}
bool enabled = true;
esp_err_t err = client_registry_get_uv_protection(req.client_id, &enabled);
reply(req.client_id, err == ESP_OK, enabled, false, 0);
}
void cmd_battery_uv_register(void) {
uart_cmd_register(alox_MessageType_BATTERY_UV, handle_battery_uv);
}
+6
View File
@@ -0,0 +1,6 @@
#ifndef CMD_BATTERY_UV_H
#define CMD_BATTERY_UV_H
void cmd_battery_uv_register(void);
#endif
+35
View File
@@ -0,0 +1,35 @@
#include "cmd_client_id_map_reset.h"
#include "esp_log.h"
#include "esp_now_comm.h"
#include "esp_now_core.h"
#include "uart_cmd.h"
static const char *TAG = "[ID_MAP_RESET]";
static void reply(bool success) {
alox_UartMessage response;
uart_cmd_init_response(&response, alox_MessageType_CLIENT_ID_MAP_RESET,
alox_UartMessage_client_id_map_reset_response_tag);
response.payload.client_id_map_reset_response.success = success;
uart_cmd_send(&response, TAG);
}
static void handle_client_id_map_reset(const uint8_t *data, size_t len) {
(void)data;
(void)len;
esp_err_t err;
if (esp_now_core_is_master()) {
ESP_LOGI(TAG, "reset master id map + notify online slaves");
err = esp_now_comm_reset_client_id_map();
} else {
ESP_LOGI(TAG, "clear local slave id binding");
err = esp_now_comm_clear_local_id_binding();
}
reply(err == ESP_OK);
}
void cmd_client_id_map_reset_register(void) {
uart_cmd_register(alox_MessageType_CLIENT_ID_MAP_RESET,
handle_client_id_map_reset);
}
+3
View File
@@ -0,0 +1,3 @@
#pragma once
void cmd_client_id_map_reset_register(void);
+1
View File
@@ -31,6 +31,7 @@ static bool encode_clients_list(pb_ostream_t *stream, const pb_field_t *field,
proto.tap_notify_single = client->tap_notify_single;
proto.tap_notify_double = client->tap_notify_double;
proto.tap_notify_triple = client->tap_notify_triple;
proto.uv_protection_enabled = client->uv_protection_enabled;
proto.mac.funcs.encode = uart_cmd_encode_bytes;
proto.mac.arg = &mac;
+70
View File
@@ -0,0 +1,70 @@
#include "client_registry.h"
#include "cmd_espnow_echo_ping.h"
#include "esp_log.h"
#include "esp_now_comm.h"
#include "uart_cmd.h"
static const char *TAG = "[ECHO_PING]";
static void reply(bool success, uint32_t client_id, uint64_t timestamp_us,
uint32_t esp_rtt_us) {
alox_UartMessage response;
uart_cmd_init_response(&response, alox_MessageType_ESPNOW_ECHO_PING,
alox_UartMessage_espnow_echo_ping_response_tag);
response.payload.espnow_echo_ping_response.success = success;
response.payload.espnow_echo_ping_response.client_id = client_id;
response.payload.espnow_echo_ping_response.timestamp_us = timestamp_us;
response.payload.espnow_echo_ping_response.esp_rtt_us = esp_rtt_us;
uart_cmd_send(&response, TAG);
}
static void handle_espnow_echo_ping(const uint8_t *data, size_t len) {
alox_UartMessage uart_msg;
if (uart_cmd_decode(data, len, &uart_msg) != ESP_OK) {
ESP_LOGW(TAG, "decode failed");
reply(false, 0, 0, 0);
return;
}
const alox_EspNowEchoPingRequest *req = UART_CMD_REQ(
&uart_msg, alox_UartMessage_espnow_echo_ping_request_tag,
espnow_echo_ping_request);
if (req == NULL || req->client_id == 0) {
ESP_LOGW(TAG, "need client_id in request");
reply(false, 0, 0, 0);
return;
}
const client_info_t *client = client_registry_find_by_id(req->client_id);
if (client == NULL) {
ESP_LOGW(TAG, "client id %lu not in registry",
(unsigned long)req->client_id);
reply(false, req->client_id, 0, 0);
return;
}
ESP_LOGI(TAG, "UART request client_id=%lu host_ts=%llu",
(unsigned long)req->client_id,
(unsigned long long)req->timestamp_us);
esp_now_echo_ping_result_t ping_result = {0};
esp_err_t err =
esp_now_comm_echo_ping(client->mac, req->timestamp_us, &ping_result);
if (err != ESP_OK) {
ESP_LOGW(TAG, "echo ping to id=%lu failed: %s", (unsigned long)req->client_id,
esp_err_to_name(err));
reply(false, req->client_id, 0, 0);
return;
}
ESP_LOGI(TAG, "UART reply client_id=%lu host_ts=%llu esp_rtt_us=%lu",
(unsigned long)req->client_id,
(unsigned long long)ping_result.echoed_us,
(unsigned long)ping_result.esp_rtt_us);
reply(true, req->client_id, ping_result.echoed_us, ping_result.esp_rtt_us);
}
void cmd_espnow_echo_ping_register(void) {
uart_cmd_register(alox_MessageType_ESPNOW_ECHO_PING, handle_espnow_echo_ping);
}
+6
View File
@@ -0,0 +1,6 @@
#ifndef CMD_ESPNOW_ECHO_PING_H
#define CMD_ESPNOW_ECHO_PING_H
void cmd_espnow_echo_ping_register(void);
#endif
+17 -2
View File
@@ -57,6 +57,16 @@ static const char *message_type_name(uint16_t id) {
return "TAP_NOTIFY";
case alox_MessageType_CACHE_STATUS:
return "CACHE_STATUS";
case alox_MessageType_ESPNOW_ECHO_PING:
return "ESPNOW_ECHO_PING";
case alox_MessageType_SET_LOG_LEVEL:
return "SET_LOG_LEVEL";
case alox_MessageType_TAP_CONFIG:
return "TAP_CONFIG";
case alox_MessageType_CLIENT_ID_MAP_RESET:
return "CLIENT_ID_MAP_RESET";
case alox_MessageType_INPUT_SEND_MODE:
return "INPUT_SEND_MODE";
default:
return "UNKNOWN";
}
@@ -105,8 +115,13 @@ void vCmdDispatcherTask(void *param) {
bool handled = false;
for (int i = 0; i < handler_count; i++) {
if (handlers[i].msg_id == msg.msg_id) {
ESP_LOGI(TAG, "trigger command %s (0x%02x)", message_type_name(msg.msg_id),
(unsigned)msg.msg_id);
if (msg.msg_id == (uint16_t)alox_MessageType_OTA_PAYLOAD) {
ESP_LOGD(TAG, "trigger command %s (0x%02x)",
message_type_name(msg.msg_id), (unsigned)msg.msg_id);
} else {
ESP_LOGI(TAG, "trigger command %s (0x%02x)",
message_type_name(msg.msg_id), (unsigned)msg.msg_id);
}
handlers[i].callback(msg.payload, msg.len);
handled = true;
break;
+109
View File
@@ -0,0 +1,109 @@
#include "cmd_input_send_mode.h"
#include "client_registry.h"
#include "esp_log.h"
#include "esp_now_comm.h"
#include "uart_cmd.h"
static const char *TAG = "[INPUT_SEND]";
/** Last mode pushed from host (GET returns this; slaves may lag until re-push). */
static alox_InputSendMode s_mode = alox_InputSendMode_INPUT_SEND_BEST_EFFORT;
static bool valid_mode(alox_InputSendMode mode) {
return mode <= alox_InputSendMode_INPUT_SEND_RELIABLE;
}
static void reply(bool success, alox_InputSendMode mode, uint32_t client_id,
uint32_t slaves_updated) {
alox_UartMessage response;
uart_cmd_init_response(&response, alox_MessageType_INPUT_SEND_MODE,
alox_UartMessage_input_send_mode_response_tag);
response.payload.input_send_mode_response.success = success;
response.payload.input_send_mode_response.mode = mode;
response.payload.input_send_mode_response.client_id = client_id;
response.payload.input_send_mode_response.slaves_updated = slaves_updated;
uart_cmd_send(&response, TAG);
}
static esp_err_t push_mode_to_slave(const client_info_t *client,
alox_InputSendMode mode) {
if (client == NULL) {
return ESP_ERR_INVALID_ARG;
}
return esp_now_comm_send_input_send_mode(client->mac, client->id,
(uint32_t)mode);
}
static void handle_input_send_mode(const uint8_t *data, size_t len) {
alox_UartMessage uart_msg;
alox_InputSendModeRequest req = alox_InputSendModeRequest_init_zero;
if (len > 0) {
if (uart_cmd_decode(data, len, &uart_msg) != ESP_OK) {
ESP_LOGW(TAG, "decode failed");
reply(false, s_mode, 0, 0);
return;
}
const alox_InputSendModeRequest *req_ptr =
UART_CMD_REQ(&uart_msg, alox_UartMessage_input_send_mode_request_tag,
input_send_mode_request);
if (req_ptr == NULL) {
ESP_LOGW(TAG, "missing input_send_mode_request");
reply(false, s_mode, 0, 0);
return;
}
req = *req_ptr;
}
if (req.write) {
if (!valid_mode(req.mode)) {
ESP_LOGW(TAG, "invalid mode %lu", (unsigned long)req.mode);
reply(false, s_mode, req.client_id, 0);
return;
}
if (req.all_clients) {
uint32_t sent = 0;
for (size_t i = 0; i < client_registry_count(); i++) {
const client_info_t *client = client_registry_at(i);
if (client == NULL) {
continue;
}
if (push_mode_to_slave(client, req.mode) == ESP_OK) {
sent++;
}
}
s_mode = req.mode;
ESP_LOGI(TAG, "input send mode %lu for %u slaves",
(unsigned long)req.mode, (unsigned)sent);
reply(sent > 0 || client_registry_count() == 0, req.mode, 0, sent);
return;
}
if (req.client_id == 0) {
ESP_LOGW(TAG, "client_id required (or all_clients)");
reply(false, req.mode, 0, 0);
return;
}
const client_info_t *client = client_registry_find_by_id(req.client_id);
if (client == NULL) {
ESP_LOGW(TAG, "client id %lu not found", (unsigned long)req.client_id);
reply(false, req.mode, req.client_id, 0);
return;
}
esp_err_t err = push_mode_to_slave(client, req.mode);
if (err == ESP_OK) {
s_mode = req.mode;
}
reply(err == ESP_OK, req.mode, req.client_id, err == ESP_OK ? 1u : 0u);
return;
}
reply(true, s_mode, 0, 0);
}
void cmd_input_send_mode_register(void) {
uart_cmd_register(alox_MessageType_INPUT_SEND_MODE, handle_input_send_mode);
}
+6
View File
@@ -0,0 +1,6 @@
#ifndef CMD_INPUT_SEND_MODE_H
#define CMD_INPUT_SEND_MODE_H
void cmd_input_send_mode_register(void);
#endif
+5
View File
@@ -13,6 +13,7 @@ static const char *TAG = "[LED_RING_CMD]";
#define LED_RING_MODE_BLINK 3
#define LED_RING_MODE_FIND_ME 4
#define LED_RING_MODE_COLOR 5
#define LED_RING_MODE_BATTERY_LOW 6
static uint8_t clamp_u8(uint32_t v) {
if (v > 255) {
@@ -90,6 +91,10 @@ bool cmd_led_ring_apply(const alox_LedRingProgressRequest *req) {
led_ring_find_me();
return true;
case LED_RING_MODE_BATTERY_LOW:
led_ring_show_battery_low();
return true;
case LED_RING_MODE_BLINK:
cmd.mode = LED_CMD_BLINK;
cmd.r = r;
+56 -12
View File
@@ -2,10 +2,13 @@
#include "led_ring.h"
#include "ota_espnow.h"
#include "ota_uart.h"
#include "pod_reboot.h"
#include "uart_cmd.h"
#include "esp_log.h"
#include "esp_timer.h"
#include "freertos/FreeRTOS.h"
#include "freertos/idf_additions.h"
#include <stdint.h>
#include <stdlib.h>
#include <string.h>
@@ -13,6 +16,8 @@ static const char *TAG = "[OTA_CMD]";
#define OTA_PREPARE_STACK 8192
#define OTA_PREPARE_PRIO 5
/* Block buffer is static .bss; stack is for nested EspNowMessage + encode buf +
* esp_now_send path. 4 KiB overflowed; 8 KiB matches the pre-crash budget. */
#define OTA_DIST_STACK 8192
#define OTA_DIST_PRIO 5
@@ -29,6 +34,14 @@ typedef struct {
uint32_t written;
int slot;
} ota_dist_job_t;
/** Cap host DISTRIBUTING pushes (BLE notifies compete with ESP-NOW OTA TX). */
#define OTA_DIST_NOTIFY_MIN_US (250 * 1000ull)
static int64_t s_last_dist_agg_us;
static int64_t s_last_dist_slave_us;
static uint32_t s_last_dist_agg_bytes = UINT32_MAX;
static void send_ota_status(ota_uart_status_t status, uint32_t err_code) {
alox_UartMessage response;
uart_cmd_init_response(&response, alox_MessageType_OTA_STATUS,
@@ -61,15 +74,33 @@ static void send_ota_distributing(uint32_t kind, uint32_t bytes_done,
static void ota_dist_aggregate(uint32_t bytes_done, uint32_t total_bytes,
uint8_t slave_count) {
(void)slave_count;
led_ring_show_ota_progress(bytes_done, total_bytes, OTA_LED_ESPNOW_TX_R,
OTA_LED_ESPNOW_TX_G, OTA_LED_ESPNOW_TX_B);
const int64_t now = esp_timer_get_time();
const bool first = (s_last_dist_agg_bytes == UINT32_MAX);
const bool boundary =
(bytes_done == 0) || (total_bytes > 0 && bytes_done >= total_bytes);
const bool paced =
(now - s_last_dist_agg_us) >= (int64_t)OTA_DIST_NOTIFY_MIN_US;
if (!first && !boundary && !paced) {
return;
}
s_last_dist_agg_us = now;
s_last_dist_agg_bytes = bytes_done;
send_ota_distributing(OTA_DIST_AGGREGATE, bytes_done, (uint32_t)slave_count);
}
static void ota_dist_per_slave(uint32_t slave_id, uint32_t bytes_done,
uint32_t total_bytes) {
(void)total_bytes;
/* Host also polls OTA_SLAVE_PROGRESS; keep per-slave pushes rare on BLE. */
const int64_t now = esp_timer_get_time();
if ((now - s_last_dist_slave_us) < (int64_t)OTA_DIST_NOTIFY_MIN_US &&
bytes_done > 0) {
return;
}
s_last_dist_slave_us = now;
send_ota_distributing(OTA_DIST_PER_SLAVE, bytes_done, slave_id);
}
@@ -175,8 +206,20 @@ static void handle_ota_payload(const uint8_t *data, size_t len) {
return;
}
ota_feed_result_t r =
ota_uart_feed(req_ptr->data.bytes, req_ptr->data.size);
ota_feed_result_t r = ota_uart_feed_chunk(req_ptr->seq, req_ptr->data.bytes,
req_ptr->data.size);
/* Gap: keep session; host block-retries from the last ACK boundary. */
if (r == OTA_FEED_SEQ_GAP) {
ESP_LOGW(TAG, "OTA_PAYLOAD seq gap (got %lu) — waiting for host retry",
(unsigned long)req_ptr->seq);
return;
}
if (r == OTA_FEED_SEQ_DUP) {
if (ota_uart_block_ready_for_reack()) {
send_ota_status(OTA_UART_ST_BLOCK_ACK, 0);
}
return;
}
if (r == OTA_FEED_ERROR) {
send_ota_failed( 13);
return;
@@ -189,15 +232,6 @@ static void handle_ota_payload(const uint8_t *data, size_t len) {
led_ring_show_ota_progress(done, total, OTA_LED_UART_R, OTA_LED_UART_G,
OTA_LED_UART_B);
send_ota_status(OTA_UART_ST_BLOCK_ACK, 0);
return;
}
if (r == OTA_FEED_OK) {
uint32_t total = ota_uart_total_size();
if (total > 0) {
led_ring_show_ota_progress(ota_uart_bytes_received(), total,
OTA_LED_UART_R, OTA_LED_UART_G, OTA_LED_UART_B);
}
}
}
@@ -219,6 +253,9 @@ static void ota_distribute_task(void *param) {
led_ring_show_ota_progress(0, image_size, OTA_LED_ESPNOW_TX_R, OTA_LED_ESPNOW_TX_G,
OTA_LED_ESPNOW_TX_B);
s_last_dist_agg_us = 0;
s_last_dist_slave_us = 0;
s_last_dist_agg_bytes = UINT32_MAX;
send_ota_distributing(OTA_DIST_AGGREGATE, 0, 0);
esp_err_t err = ota_espnow_distribute(part, image_size, &s_dist_progress);
@@ -254,6 +291,8 @@ static void ota_distribute_task(void *param) {
led_ring_ota_success();
free(job);
/* Host already got SUCCESS; reboot onto the staged image. */
pod_schedule_restart();
vTaskDelete(NULL);
}
@@ -307,6 +346,10 @@ static void ota_start_espnow_task(void *param) {
return;
}
s_last_dist_agg_us = 0;
s_last_dist_slave_us = 0;
s_last_dist_agg_bytes = UINT32_MAX;
esp_err_t err = ota_espnow_distribute(part, image_size, &s_dist_progress);
if (err != ESP_OK) {
send_ota_failed(42);
@@ -329,6 +372,7 @@ static void ota_start_espnow_task(void *param) {
response.payload.ota_status.error = 0;
uart_cmd_send(&response, TAG);
led_ring_ota_success();
pod_schedule_restart();
vTaskDelete(NULL);
}
+51
View File
@@ -0,0 +1,51 @@
#include "cmd_set_log_level.h"
#include "esp_log.h"
#include "uart_cmd.h"
static const char *TAG = "[LOG_LVL]";
static bool valid_level(uint32_t level) { return level <= ESP_LOG_VERBOSE; }
static void reply(uint32_t level, bool success) {
alox_UartMessage response;
uart_cmd_init_response(&response, alox_MessageType_SET_LOG_LEVEL,
alox_UartMessage_set_log_level_response_tag);
response.payload.set_log_level_response.success = success;
response.payload.set_log_level_response.level = level;
uart_cmd_send(&response, TAG);
}
static void handle_set_log_level(const uint8_t *data, size_t len) {
alox_UartMessage uart_msg;
alox_SetLogLevelRequest req = alox_SetLogLevelRequest_init_zero;
if (uart_cmd_decode(data, len, &uart_msg) != ESP_OK) {
ESP_LOGW(TAG, "decode failed");
reply((uint32_t)esp_log_level_get("*"), false);
return;
}
const alox_SetLogLevelRequest *req_ptr = UART_CMD_REQ(
&uart_msg, alox_UartMessage_set_log_level_request_tag, set_log_level_request);
if (req_ptr != NULL) {
req = *req_ptr;
}
if (req.write) {
if (!valid_level(req.level)) {
ESP_LOGW(TAG, "invalid level %lu", (unsigned long)req.level);
reply((uint32_t)esp_log_level_get("*"), false);
return;
}
esp_log_level_set("*", (esp_log_level_t)req.level);
ESP_LOGI(TAG, "global log level set to %lu", (unsigned long)req.level);
reply(req.level, true);
return;
}
reply((uint32_t)esp_log_level_get("*"), true);
}
void cmd_set_log_level_register(void) {
uart_cmd_register(alox_MessageType_SET_LOG_LEVEL, handle_set_log_level);
}
+6
View File
@@ -0,0 +1,6 @@
#ifndef CMD_SET_LOG_LEVEL_H
#define CMD_SET_LOG_LEVEL_H
void cmd_set_log_level_register(void);
#endif
+162
View File
@@ -0,0 +1,162 @@
#include "bosch456.h"
#include "client_registry.h"
#include "cmd_tap_config.h"
#include "esp_log.h"
#include "esp_now_comm.h"
#include "pod_settings.h"
#include "uart_cmd.h"
static const char *TAG = "[TAP_CFG]";
static void fill_response_fields(alox_TapConfigResponse *out,
const bma456_tap_config_t *cfg) {
out->tap_sens_thres = cfg->tap_sens_thres;
out->max_gest_dur = cfg->max_gest_dur;
out->tap_shock_dur = cfg->tap_shock_dur;
out->quite_time_after_gest = cfg->quite_time_after_gest;
out->wait_for_timeout = cfg->wait_for_timeout;
out->axis_sel = cfg->axis_sel;
out->enable_single = cfg->enable_single;
out->enable_double = cfg->enable_double;
out->enable_triple = cfg->enable_triple;
}
static void reply(uint32_t client_id, bool success, uint32_t slaves_updated,
const bma456_tap_config_t *cfg) {
alox_UartMessage response;
uart_cmd_init_response(&response, alox_MessageType_TAP_CONFIG,
alox_UartMessage_tap_config_response_tag);
response.payload.tap_config_response.client_id = client_id;
response.payload.tap_config_response.success = success;
response.payload.tap_config_response.slaves_updated = slaves_updated;
if (cfg != NULL) {
fill_response_fields(&response.payload.tap_config_response, cfg);
}
uart_cmd_send(&response, TAG);
}
static void req_to_cfg(const alox_TapConfigRequest *req,
bma456_tap_config_t *cfg) {
cfg->tap_sens_thres = (uint16_t)req->tap_sens_thres;
cfg->max_gest_dur = (uint16_t)req->max_gest_dur;
cfg->tap_shock_dur = (uint16_t)req->tap_shock_dur;
cfg->quite_time_after_gest = (uint16_t)req->quite_time_after_gest;
cfg->wait_for_timeout = (uint16_t)req->wait_for_timeout;
cfg->axis_sel = (uint16_t)req->axis_sel;
cfg->enable_single = req->enable_single;
cfg->enable_double = req->enable_double;
cfg->enable_triple = req->enable_triple;
}
static void apply_local_tap_config(const bma456_tap_config_t *cfg) {
if (bma456_set_tap_config(cfg) != ESP_OK) {
ESP_LOGW(TAG, "local tap config apply failed");
return;
}
if (pod_settings_save_tap_config(cfg) != ESP_OK) {
ESP_LOGW(TAG, "tap config applied but not saved to NVS");
}
}
static esp_err_t push_tap_config_to_slave(const client_info_t *client,
const bma456_tap_config_t *cfg) {
if (client == NULL || cfg == NULL) {
return ESP_ERR_INVALID_ARG;
}
esp_err_t err = client_registry_set_tap_config(client->id, cfg);
if (err != ESP_OK) {
return err;
}
return esp_now_comm_send_tap_config(client->mac, client->id, cfg);
}
static void handle_tap_config(const uint8_t *data, size_t len) {
alox_UartMessage uart_msg;
alox_TapConfigRequest req = alox_TapConfigRequest_init_zero;
static const bma456_tap_config_t k_default = BMA456_TAP_CONFIG_DEFAULT;
if (uart_cmd_decode(data, len, &uart_msg) != ESP_OK) {
ESP_LOGW(TAG, "decode failed");
reply(0, false, 0, &k_default);
return;
}
const alox_TapConfigRequest *req_ptr =
UART_CMD_REQ(&uart_msg, alox_UartMessage_tap_config_request_tag,
tap_config_request);
if (req_ptr != NULL) {
req = *req_ptr;
}
bma456_tap_config_t cfg;
req_to_cfg(&req, &cfg);
if (req.write) {
if (!bma456_tap_config_valid(&cfg)) {
ESP_LOGW(TAG, "invalid tap config");
reply(req.client_id, false, 0, &cfg);
return;
}
if (req.all_clients) {
size_t n = client_registry_set_tap_config_all(&cfg);
uint32_t sent = 0;
for (size_t i = 0; i < client_registry_count(); i++) {
const client_info_t *client = client_registry_at(i);
if (client == NULL) {
continue;
}
if (esp_now_comm_send_tap_config(client->mac, client->id, &cfg) ==
ESP_OK) {
sent++;
}
}
if (bma456_is_ready()) {
apply_local_tap_config(&cfg);
}
ESP_LOGI(TAG, "set tap config via unicast to %u/%u slaves",
(unsigned)sent, (unsigned)n);
reply(0, sent > 0 || bma456_is_ready(), sent, &cfg);
return;
}
if (req.client_id == 0) {
apply_local_tap_config(&cfg);
ESP_LOGI(TAG, "set local tap config (no ESP-NOW; use -client or -all "
"for slaves)");
reply(0, true, 0, &cfg);
return;
}
const client_info_t *client = client_registry_find_by_id(req.client_id);
if (client == NULL) {
ESP_LOGW(TAG, "client id %lu not found", (unsigned long)req.client_id);
reply(req.client_id, false, 0, &cfg);
return;
}
esp_err_t err = push_tap_config_to_slave(client, &cfg);
reply(req.client_id, err == ESP_OK, err == ESP_OK ? 1 : 0, &cfg);
return;
}
if (req.all_clients || req.client_id == 0) {
bma456_tap_config_t local;
bma456_get_tap_config(&local);
reply(0, true, 0, &local);
return;
}
bma456_tap_config_t cached;
esp_err_t err = client_registry_get_tap_config(req.client_id, &cached);
reply(req.client_id, err == ESP_OK, 0, err == ESP_OK ? &cached : &k_default);
}
void cmd_tap_config_register(void) {
uart_cmd_register(alox_MessageType_TAP_CONFIG, handle_tap_config);
}
+6
View File
@@ -0,0 +1,6 @@
#ifndef CMD_TAP_CONFIG_H
#define CMD_TAP_CONFIG_H
void cmd_tap_config_register(void);
#endif
+40
View File
@@ -13,6 +13,10 @@ esp_err_t esp_now_comm_init(const app_config_t *config);
esp_err_t esp_now_comm_send_accel_stream(const uint8_t mac[CLIENT_MAC_LEN],
uint32_t client_id, bool enable);
/** Master: set accel/tap ESP-NOW send path on one slave (InputSendMode). */
esp_err_t esp_now_comm_send_input_send_mode(const uint8_t mac[CLIENT_MAC_LEN],
uint32_t client_id, uint32_t mode);
/** Master: configure tap notify flags on one slave. */
esp_err_t esp_now_comm_send_tap_notify(const uint8_t mac[CLIENT_MAC_LEN],
uint32_t client_id, bool single,
@@ -22,10 +26,30 @@ esp_err_t esp_now_comm_send_tap_notify(const uint8_t mac[CLIENT_MAC_LEN],
esp_err_t esp_now_comm_send_accel_deadzone(const uint8_t mac[CLIENT_MAC_LEN],
uint32_t client_id, uint32_t deadzone);
/** Master: unicast BMA456 tap config to one slave. */
esp_err_t esp_now_comm_send_tap_config(const uint8_t mac[CLIENT_MAC_LEN],
uint32_t client_id,
const bma456_tap_config_t *cfg);
/** Master: unicast software UV protection enable to one slave. */
esp_err_t esp_now_comm_send_battery_uv(const uint8_t mac[CLIENT_MAC_LEN],
uint32_t client_id, bool enabled);
/** Master: send minimal unicast test payload to verify master→slave path. */
esp_err_t esp_now_comm_send_unicast_test(const uint8_t mac[CLIENT_MAC_LEN],
uint32_t seq);
/** Result of a master-side ESP-NOW echo ping round-trip. */
typedef struct {
uint64_t echoed_us;
uint32_t esp_rtt_us;
} esp_now_echo_ping_result_t;
/** Master: ESP-NOW echo ping round-trip; fills result on success. */
esp_err_t esp_now_comm_echo_ping(const uint8_t mac[CLIENT_MAC_LEN],
uint64_t timestamp_us,
esp_now_echo_ping_result_t *result);
/** Master: trigger find-me LED sequence on one slave. */
esp_err_t esp_now_comm_send_find_me(const uint8_t mac[CLIENT_MAC_LEN],
uint32_t client_id);
@@ -39,6 +63,22 @@ esp_err_t esp_now_comm_send_led_ring(const uint8_t mac[CLIENT_MAC_LEN],
esp_err_t esp_now_comm_send_restart(const uint8_t mac[CLIENT_MAC_LEN],
uint32_t client_id);
/** Master: tell slave its assigned logical client id. */
esp_err_t esp_now_comm_send_assign_id(const uint8_t mac[CLIENT_MAC_LEN],
uint32_t assigned_id);
/** Master: tell slave to forget persisted master/id binding. */
esp_err_t esp_now_comm_send_clear_id_binding(const uint8_t mac[CLIENT_MAC_LEN]);
/**
* Master: clear NVS id map + live registry and unicast CLEAR_ID_BINDING to
* every currently registered live slave (snapshot before clear).
*/
esp_err_t esp_now_comm_reset_client_id_map(void);
/** Slave: clear local NVS master/id binding (UART reset on slave). */
esp_err_t esp_now_comm_clear_local_id_binding(void);
/** Master → slave OTA (unicast). */
esp_err_t esp_now_comm_send_ota_start(const uint8_t mac[CLIENT_MAC_LEN],
uint32_t total_size);
+150 -17
View File
@@ -21,7 +21,18 @@ static app_config_t s_config;
static uint8_t s_wifi_channel;
static uint8_t s_own_mac[ESP_NOW_ETH_ALEN];
static SemaphoreHandle_t s_send_done;
static SemaphoreHandle_t s_send_lock;
static bool s_send_cb_ready;
static volatile esp_now_send_status_t s_last_send_status;
static volatile bool s_last_send_ok;
#define ESPNOW_SEND_DONE_TIMEOUT_MS 500u
#define ESPNOW_SEND_MAX_ATTEMPTS 8u
#define ESPNOW_SEND_RETRY_DELAY_MS 10u
#define ESPNOW_NOMEM_RETRY_DELAY_MS 50u
#define ESPNOW_RELIABLE_MAX_ATTEMPTS 24u
#define ESPNOW_RELIABLE_NOMEM_DELAY_MS 50u
static uint8_t network_to_channel(uint8_t network) {
if (network < 1 || network > 13) {
@@ -33,7 +44,8 @@ static uint8_t network_to_channel(uint8_t network) {
static void espnow_send_done_cb(const esp_now_send_info_t *tx_info,
esp_now_send_status_t status) {
(void)tx_info;
(void)status;
s_last_send_status = status;
s_last_send_ok = (status == ESP_NOW_SEND_SUCCESS);
if (s_send_done != NULL) {
xSemaphoreGive(s_send_done);
}
@@ -93,46 +105,158 @@ esp_err_t esp_now_core_ensure_broadcast_peer(void) {
return esp_now_core_ensure_peer(ESPNOW_BCAST);
}
esp_err_t esp_now_core_send_wait(const uint8_t *dest_mac,
const alox_EspNowMessage *msg) {
static esp_err_t send_with_backpressure(const uint8_t *dest_mac,
const alox_EspNowMessage *msg,
uint32_t max_attempts,
uint32_t nomem_delay_ms,
uint32_t retry_delay_ms,
bool quiet_retries) {
if (s_send_lock == NULL ||
xSemaphoreTake(s_send_lock, pdMS_TO_TICKS(5000)) != pdTRUE) {
return ESP_ERR_TIMEOUT;
}
uint8_t buf[ESPNOW_PB_MAX_SIZE];
size_t len = 0;
esp_err_t result = ESP_FAIL;
esp_err_t err = esp_now_proto_encode(msg, buf, sizeof(buf), &len);
if (err != ESP_OK) {
ESP_LOGW(TAG, "encode failed");
return err;
result = err;
goto out;
}
if (len > ESP_NOW_MAX_DATA_LEN) {
ESP_LOGW(TAG, "encoded len %u > ESP-NOW max %u", (unsigned)len,
(unsigned)ESP_NOW_MAX_DATA_LEN);
result = ESP_ERR_INVALID_SIZE;
goto out;
}
if (esp_now_core_ensure_peer(dest_mac) != ESP_OK) {
result = ESP_FAIL;
goto out;
}
for (uint32_t attempt = 0; attempt < max_attempts; attempt++) {
if (s_send_cb_ready && s_send_done != NULL) {
xSemaphoreTake(s_send_done, 0);
}
s_last_send_ok = false;
err = esp_now_send(dest_mac, buf, len);
if (err != ESP_OK) {
const bool last = (attempt + 1 >= max_attempts);
if (!quiet_retries || last) {
ESP_LOGW(TAG, "send type=%u failed (attempt %lu/%lu): %s",
(unsigned)msg->type, (unsigned long)(attempt + 1),
(unsigned long)max_attempts, esp_err_to_name(err));
}
vTaskDelay(pdMS_TO_TICKS(err == ESP_ERR_ESPNOW_NO_MEM ? nomem_delay_ms
: retry_delay_ms));
continue;
}
if (s_send_cb_ready && s_send_done != NULL) {
if (xSemaphoreTake(s_send_done, pdMS_TO_TICKS(ESPNOW_SEND_DONE_TIMEOUT_MS)) !=
pdTRUE) {
const bool last = (attempt + 1 >= max_attempts);
if (!quiet_retries || last) {
ESP_LOGW(TAG, "send type=%u done timeout (attempt %lu/%lu)",
(unsigned)msg->type, (unsigned long)(attempt + 1),
(unsigned long)max_attempts);
}
vTaskDelay(pdMS_TO_TICKS(retry_delay_ms));
continue;
}
if (!s_last_send_ok) {
const bool last = (attempt + 1 >= max_attempts);
if (!quiet_retries || last) {
ESP_LOGW(TAG, "send type=%u peer status=%d (attempt %lu/%lu)",
(unsigned)msg->type, (int)s_last_send_status,
(unsigned long)(attempt + 1), (unsigned long)max_attempts);
}
vTaskDelay(pdMS_TO_TICKS(retry_delay_ms));
continue;
}
}
result = ESP_OK;
goto out;
}
out:
xSemaphoreGive(s_send_lock);
return result;
}
esp_err_t esp_now_core_send_wait(const uint8_t *dest_mac,
const alox_EspNowMessage *msg) {
return send_with_backpressure(dest_mac, msg, ESPNOW_SEND_MAX_ATTEMPTS,
ESPNOW_NOMEM_RETRY_DELAY_MS,
ESPNOW_SEND_RETRY_DELAY_MS, false);
}
esp_err_t esp_now_core_send_reliable(const uint8_t *dest_mac,
const alox_EspNowMessage *msg) {
return send_with_backpressure(dest_mac, msg, ESPNOW_RELIABLE_MAX_ATTEMPTS,
ESPNOW_RELIABLE_NOMEM_DELAY_MS,
ESPNOW_SEND_RETRY_DELAY_MS, true);
}
static esp_err_t send_fast_internal(const uint8_t *dest_mac,
const alox_EspNowMessage *msg,
bool quiet) {
if (s_send_lock == NULL ||
xSemaphoreTake(s_send_lock, pdMS_TO_TICKS(5)) != pdTRUE) {
return ESP_ERR_TIMEOUT;
}
uint8_t buf[ESPNOW_PB_MAX_SIZE];
size_t len = 0;
esp_err_t err = esp_now_proto_encode(msg, buf, sizeof(buf), &len);
if (err != ESP_OK) {
if (!quiet) {
ESP_LOGW(TAG, "encode failed");
}
xSemaphoreGive(s_send_lock);
return err;
}
if (len > ESP_NOW_MAX_DATA_LEN) {
if (!quiet) {
ESP_LOGW(TAG, "encoded len %u > ESP-NOW max %u", (unsigned)len,
(unsigned)ESP_NOW_MAX_DATA_LEN);
}
xSemaphoreGive(s_send_lock);
return ESP_ERR_INVALID_SIZE;
}
if (esp_now_core_ensure_peer(dest_mac) != ESP_OK) {
xSemaphoreGive(s_send_lock);
return ESP_FAIL;
}
if (s_send_cb_ready && s_send_done != NULL) {
xSemaphoreTake(s_send_done, 0);
}
err = esp_now_send(dest_mac, buf, len);
if (err != ESP_OK) {
if (err != ESP_OK && !quiet) {
ESP_LOGW(TAG, "send type=%u failed: %s", (unsigned)msg->type,
esp_err_to_name(err));
return err;
}
if (s_send_cb_ready && s_send_done != NULL) {
if (xSemaphoreTake(s_send_done, pdMS_TO_TICKS(50)) != pdTRUE) {
ESP_LOGW(TAG, "send type=%u done timeout", (unsigned)msg->type);
}
}
xSemaphoreGive(s_send_lock);
return err;
}
esp_err_t esp_now_core_send_fast(const uint8_t *dest_mac,
const alox_EspNowMessage *msg) {
return send_fast_internal(dest_mac, msg, false);
}
esp_err_t esp_now_core_send_best_effort(const uint8_t *dest_mac,
const alox_EspNowMessage *msg) {
return send_fast_internal(dest_mac, msg, true);
}
esp_err_t esp_now_core_send(const uint8_t *dest_mac,
const alox_EspNowMessage *msg) {
return esp_now_core_send_wait(dest_mac, msg);
@@ -156,6 +280,14 @@ esp_err_t esp_now_core_init_radio(uint8_t channel) {
ESP_ERROR_CHECK(esp_wifi_set_ps(WIFI_PS_NONE));
ESP_ERROR_CHECK(esp_wifi_set_channel(channel, WIFI_SECOND_CHAN_NONE));
/* WiFi task stack is tiny; ERROR logs from pp/coex (e.g. under BLE+ESP-NOW
* OTA) call esp_log_write on that stack and can overflow → panic reboot.
* Mute after esp_wifi_init (which resets the wifi tag to default level). */
esp_log_level_set("wifi", ESP_LOG_NONE);
esp_log_level_set("wifi_init", ESP_LOG_WARN);
esp_log_level_set("coex", ESP_LOG_WARN);
esp_log_level_set("NimBLE", ESP_LOG_WARN);
ESP_ERROR_CHECK(esp_read_mac(s_own_mac, ESP_MAC_WIFI_STA));
s_wifi_channel = channel;
return ESP_OK;
@@ -163,7 +295,8 @@ esp_err_t esp_now_core_init_radio(uint8_t channel) {
void esp_now_core_init_send_done(void) {
s_send_done = xSemaphoreCreateBinary();
if (s_send_done != NULL &&
s_send_lock = xSemaphoreCreateMutex();
if (s_send_done != NULL && s_send_lock != NULL &&
esp_now_register_send_cb(espnow_send_done_cb) == ESP_OK) {
s_send_cb_ready = true;
} else {
+12
View File
@@ -23,8 +23,20 @@ esp_err_t esp_now_core_ensure_broadcast_peer(void);
esp_err_t esp_now_core_send(const uint8_t *dest_mac,
const alox_EspNowMessage *msg);
/** Like send but does not wait for esp_now send-done (lower latency). */
esp_err_t esp_now_core_send_fast(const uint8_t *dest_mac,
const alox_EspNowMessage *msg);
/**
* Fire-and-forget without failure logs — for high-rate gaming input
* (accel/tap) where drops are expected under load.
*/
esp_err_t esp_now_core_send_best_effort(const uint8_t *dest_mac,
const alox_EspNowMessage *msg);
esp_err_t esp_now_core_send_wait(const uint8_t *dest_mac,
const alox_EspNowMessage *msg);
/** OTA payloads: wait for send-done with extra NO_MEM backoff (queue drain). */
esp_err_t esp_now_core_send_reliable(const uint8_t *dest_mac,
const alox_EspNowMessage *msg);
esp_err_t esp_now_core_init_radio(uint8_t channel);
void esp_now_core_init_send_done(void);
+338 -12
View File
@@ -5,8 +5,10 @@
#include "esp_now_proto.h"
#include "board_input.h"
#include "ota_espnow.h"
#include "ota_session.h"
#include "ota_uart.h"
#include "esp_log.h"
#include "esp_timer.h"
#include "freertos/FreeRTOS.h"
#include "freertos/idf_additions.h"
#include <string.h>
@@ -20,6 +22,8 @@ static const uint8_t ESPNOW_BCAST[ESP_NOW_ETH_ALEN] = {0xff, 0xff, 0xff,
#define ESPNOW_CLIENT_TIMEOUT_MS \
(ESPNOW_HEARTBEAT_INTERVAL_MS * ESPNOW_HEARTBEAT_MISS_COUNT)
#define ESPNOW_BATTERY_INTERVAL_MS 30000
/** Min gap between ASSIGN_ID resends when slave has not yet echoed the id. */
#define ESPNOW_ASSIGN_RESEND_MS 2000u
static const char *TAG = "[ESPNOW_M]";
@@ -33,6 +37,16 @@ static esp_err_t send_accel_stream(const uint8_t *dest_mac, uint32_t client_id,
return esp_now_core_send(dest_mac, &msg);
}
static esp_err_t send_input_send_mode(const uint8_t *dest_mac,
uint32_t client_id, uint32_t mode) {
alox_EspNowMessage msg = alox_EspNowMessage_init_zero;
msg.type = alox_EspNowMessageType_ESPNOW_SET_INPUT_SEND_MODE;
msg.which_payload = alox_EspNowMessage_input_send_mode_tag;
msg.payload.input_send_mode.client_id = client_id;
msg.payload.input_send_mode.mode = mode;
return esp_now_core_send(dest_mac, &msg);
}
static esp_err_t send_accel_deadzone(const uint8_t *dest_mac, uint32_t client_id,
uint32_t deadzone) {
alox_EspNowMessage msg = alox_EspNowMessage_init_zero;
@@ -43,6 +57,34 @@ static esp_err_t send_accel_deadzone(const uint8_t *dest_mac, uint32_t client_id
return esp_now_core_send(dest_mac, &msg);
}
static esp_err_t send_tap_config(const uint8_t *dest_mac, uint32_t client_id,
const bma456_tap_config_t *cfg) {
alox_EspNowMessage msg = alox_EspNowMessage_init_zero;
msg.type = alox_EspNowMessageType_ESPNOW_SET_TAP_CONFIG;
msg.which_payload = alox_EspNowMessage_tap_config_tag;
msg.payload.tap_config.client_id = client_id;
msg.payload.tap_config.tap_sens_thres = cfg->tap_sens_thres;
msg.payload.tap_config.max_gest_dur = cfg->max_gest_dur;
msg.payload.tap_config.tap_shock_dur = cfg->tap_shock_dur;
msg.payload.tap_config.quite_time_after_gest = cfg->quite_time_after_gest;
msg.payload.tap_config.wait_for_timeout = cfg->wait_for_timeout;
msg.payload.tap_config.axis_sel = cfg->axis_sel;
msg.payload.tap_config.enable_single = cfg->enable_single;
msg.payload.tap_config.enable_double = cfg->enable_double;
msg.payload.tap_config.enable_triple = cfg->enable_triple;
return esp_now_core_send(dest_mac, &msg);
}
static esp_err_t send_battery_uv(const uint8_t *dest_mac, uint32_t client_id,
bool enabled) {
alox_EspNowMessage msg = alox_EspNowMessage_init_zero;
msg.type = alox_EspNowMessageType_ESPNOW_SET_BATTERY_UV;
msg.which_payload = alox_EspNowMessage_battery_uv_tag;
msg.payload.battery_uv.client_id = client_id;
msg.payload.battery_uv.enabled = enabled;
return esp_now_core_send(dest_mac, &msg);
}
static esp_err_t send_unicast_test(const uint8_t *dest_mac, uint32_t seq) {
alox_EspNowMessage msg = alox_EspNowMessage_init_zero;
msg.type = alox_EspNowMessageType_ESPNOW_UNICAST_TEST;
@@ -51,6 +93,66 @@ static esp_err_t send_unicast_test(const uint8_t *dest_mac, uint32_t seq) {
return esp_now_core_send(dest_mac, &msg);
}
#define ESPNOW_ECHO_PING_TIMEOUT_MS 500
#define ECHO_PING_TAG "[ECHO_PING]"
static SemaphoreHandle_t s_echo_pong_sem;
static bool s_echo_waiting;
static uint64_t s_echo_expect_ts;
static uint32_t s_echo_esp_rtt_us;
static uint8_t s_echo_expect_mac[ESP_NOW_ETH_ALEN];
static esp_err_t echo_ping_init(void) {
if (s_echo_pong_sem != NULL) {
return ESP_OK;
}
s_echo_pong_sem = xSemaphoreCreateBinary();
if (s_echo_pong_sem == NULL) {
return ESP_ERR_NO_MEM;
}
return ESP_OK;
}
static esp_err_t send_echo_ping(const uint8_t *dest_mac, uint64_t host_timestamp_us,
uint64_t master_time_us) {
alox_EspNowMessage msg = alox_EspNowMessage_init_zero;
msg.type = alox_EspNowMessageType_ESPNOW_ECHO_PING;
msg.which_payload = alox_EspNowMessage_echo_ping_tag;
msg.payload.echo_ping.host_timestamp_us = host_timestamp_us;
msg.payload.echo_ping.master_time_us = master_time_us;
char mac_str[18];
esp_now_core_mac_to_str(dest_mac, mac_str, sizeof(mac_str));
ESP_LOGI(ECHO_PING_TAG, "ESP-NOW PING send to %s host_ts=%llu master_time_us=%llu",
mac_str, (unsigned long long)host_timestamp_us,
(unsigned long long)master_time_us);
return esp_now_core_send_fast(dest_mac, &msg);
}
static void echo_ping_on_pong(const uint8_t mac[ESP_NOW_ETH_ALEN],
const alox_EspNowEchoPong *pong) {
if (pong == NULL || !s_echo_waiting) {
return;
}
if (!esp_now_core_mac_equal(mac, s_echo_expect_mac)) {
return;
}
if (pong->host_timestamp_us != s_echo_expect_ts) {
return;
}
int64_t now_us = esp_timer_get_time();
s_echo_esp_rtt_us =
(uint32_t)(now_us - (int64_t)pong->master_time_us);
char mac_str[18];
esp_now_core_mac_to_str(mac, mac_str, sizeof(mac_str));
ESP_LOGI(ECHO_PING_TAG,
"ESP-NOW PONG recv from %s host_ts=%llu master_time_us=%llu "
"recv_time_us=%lld esp_rtt_us=%lu",
mac_str, (unsigned long long)pong->host_timestamp_us,
(unsigned long long)pong->master_time_us, (long long)now_us,
(unsigned long)s_echo_esp_rtt_us);
xSemaphoreGive(s_echo_pong_sem);
}
static esp_err_t send_find_me(const uint8_t *dest_mac, uint32_t client_id) {
alox_EspNowMessage msg = alox_EspNowMessage_init_zero;
msg.type = alox_EspNowMessageType_ESPNOW_FIND_ME;
@@ -88,6 +190,22 @@ static esp_err_t send_restart(const uint8_t *dest_mac, uint32_t client_id) {
return esp_now_core_send(dest_mac, &msg);
}
static esp_err_t send_assign_id(const uint8_t *dest_mac, uint32_t assigned_id) {
alox_EspNowMessage msg = alox_EspNowMessage_init_zero;
msg.type = alox_EspNowMessageType_ESPNOW_ASSIGN_ID;
msg.which_payload = alox_EspNowMessage_assign_id_tag;
msg.payload.assign_id.assigned_id = assigned_id;
/* Fast path: avoid 8× NO_MEM retries blocking the shared send lock. */
return esp_now_core_send_fast(dest_mac, &msg);
}
static esp_err_t send_clear_id_binding(const uint8_t *dest_mac) {
alox_EspNowMessage msg = alox_EspNowMessage_init_zero;
msg.type = alox_EspNowMessageType_ESPNOW_CLEAR_ID_BINDING;
msg.which_payload = alox_EspNowMessage_clear_id_binding_tag;
return esp_now_core_send_fast(dest_mac, &msg);
}
static esp_err_t send_tap_notify(const uint8_t *dest_mac, uint32_t client_id,
bool single, bool double_tap, bool triple) {
alox_EspNowMessage msg = alox_EspNowMessage_init_zero;
@@ -119,7 +237,7 @@ static esp_err_t send_ota_payload(const uint8_t *dest_mac, uint32_t seq,
msg.payload.ota_payload.seq = seq;
msg.payload.ota_payload.data.size = len;
memcpy(msg.payload.ota_payload.data.bytes, data, len);
return esp_now_core_send_wait(dest_mac, &msg);
return esp_now_core_send_reliable(dest_mac, &msg);
}
static esp_err_t send_ota_end(const uint8_t *dest_mac) {
@@ -171,6 +289,70 @@ esp_err_t esp_now_comm_send_restart(const uint8_t mac[CLIENT_MAC_LEN],
return err;
}
esp_err_t esp_now_comm_send_assign_id(const uint8_t mac[CLIENT_MAC_LEN],
uint32_t assigned_id) {
if (mac == NULL || !esp_now_core_is_master()) {
return ESP_ERR_INVALID_STATE;
}
char mac_str[18];
esp_now_core_mac_to_str(mac, mac_str, sizeof(mac_str));
esp_err_t err = send_assign_id(mac, assigned_id);
if (err == ESP_OK) {
ESP_LOGI(TAG, "unicast ASSIGN_ID %lu to %s", (unsigned long)assigned_id,
mac_str);
} else {
ESP_LOGW(TAG, "unicast ASSIGN_ID to %s failed: %s", mac_str,
esp_err_to_name(err));
}
return err;
}
esp_err_t esp_now_comm_send_clear_id_binding(const uint8_t mac[CLIENT_MAC_LEN]) {
if (mac == NULL || !esp_now_core_is_master()) {
return ESP_ERR_INVALID_STATE;
}
char mac_str[18];
esp_now_core_mac_to_str(mac, mac_str, sizeof(mac_str));
esp_err_t err = send_clear_id_binding(mac);
if (err == ESP_OK) {
ESP_LOGI(TAG, "unicast CLEAR_ID_BINDING to %s", mac_str);
} else {
ESP_LOGW(TAG, "unicast CLEAR_ID_BINDING to %s failed: %s", mac_str,
esp_err_to_name(err));
}
return err;
}
esp_err_t esp_now_comm_reset_client_id_map(void) {
if (!esp_now_core_is_master()) {
return ESP_ERR_INVALID_STATE;
}
uint8_t macs[CLIENT_REGISTRY_MAX][CLIENT_MAC_LEN];
size_t n = client_registry_count();
if (n > CLIENT_REGISTRY_MAX) {
n = CLIENT_REGISTRY_MAX;
}
size_t copied = 0;
for (size_t i = 0; i < n; i++) {
const client_info_t *c = client_registry_at(i);
if (c == NULL) {
continue;
}
memcpy(macs[copied], c->mac, CLIENT_MAC_LEN);
copied++;
}
client_registry_clear_id_map();
for (size_t i = 0; i < copied; i++) {
(void)esp_now_comm_send_clear_id_binding(macs[i]);
}
ESP_LOGI(TAG, "client id map reset; notified %u slave(s)", (unsigned)copied);
return ESP_OK;
}
esp_err_t esp_now_comm_send_find_me(const uint8_t mac[CLIENT_MAC_LEN],
uint32_t client_id) {
if (mac == NULL || !esp_now_core_is_master()) {
@@ -224,6 +406,48 @@ esp_err_t esp_now_comm_send_unicast_test(const uint8_t mac[CLIENT_MAC_LEN],
return err;
}
esp_err_t esp_now_comm_echo_ping(const uint8_t mac[CLIENT_MAC_LEN],
uint64_t timestamp_us,
esp_now_echo_ping_result_t *result) {
if (mac == NULL || !esp_now_core_is_master()) {
return ESP_ERR_INVALID_STATE;
}
if (echo_ping_init() != ESP_OK) {
return ESP_ERR_NO_MEM;
}
xSemaphoreTake(s_echo_pong_sem, 0);
s_echo_waiting = true;
s_echo_expect_ts = timestamp_us;
s_echo_esp_rtt_us = 0;
memcpy(s_echo_expect_mac, mac, ESP_NOW_ETH_ALEN);
uint64_t master_time_us = (uint64_t)esp_timer_get_time();
esp_err_t err = send_echo_ping(mac, timestamp_us, master_time_us);
if (err != ESP_OK) {
s_echo_waiting = false;
return err;
}
if (xSemaphoreTake(s_echo_pong_sem,
pdMS_TO_TICKS(ESPNOW_ECHO_PING_TIMEOUT_MS)) != pdTRUE) {
s_echo_waiting = false;
char mac_str[18];
esp_now_core_mac_to_str(mac, mac_str, sizeof(mac_str));
ESP_LOGW(ECHO_PING_TAG,
"ESP-NOW PONG timeout to %s host_ts=%llu",
mac_str, (unsigned long long)timestamp_us);
return ESP_ERR_TIMEOUT;
}
s_echo_waiting = false;
if (result != NULL) {
result->echoed_us = timestamp_us;
result->esp_rtt_us = s_echo_esp_rtt_us;
}
return ESP_OK;
}
esp_err_t esp_now_comm_send_accel_stream(const uint8_t mac[CLIENT_MAC_LEN],
uint32_t client_id, bool enable) {
if (mac == NULL || !esp_now_core_is_master()) {
@@ -242,6 +466,24 @@ esp_err_t esp_now_comm_send_accel_stream(const uint8_t mac[CLIENT_MAC_LEN],
return err;
}
esp_err_t esp_now_comm_send_input_send_mode(const uint8_t mac[CLIENT_MAC_LEN],
uint32_t client_id, uint32_t mode) {
if (mac == NULL || !esp_now_core_is_master()) {
return ESP_ERR_INVALID_STATE;
}
char mac_str[18];
esp_now_core_mac_to_str(mac, mac_str, sizeof(mac_str));
esp_err_t err = send_input_send_mode(mac, client_id, mode);
if (err == ESP_OK) {
ESP_LOGI(TAG, "unicast SET_INPUT_SEND_MODE to %s: mode=%lu client_id=%lu",
mac_str, (unsigned long)mode, (unsigned long)client_id);
} else {
ESP_LOGW(TAG, "unicast SET_INPUT_SEND_MODE to %s failed: %s", mac_str,
esp_err_to_name(err));
}
return err;
}
esp_err_t esp_now_comm_send_tap_notify(const uint8_t mac[CLIENT_MAC_LEN],
uint32_t client_id, bool single,
bool double_tap, bool triple) {
@@ -283,6 +525,46 @@ esp_err_t esp_now_comm_send_accel_deadzone(const uint8_t mac[CLIENT_MAC_LEN],
return err;
}
esp_err_t esp_now_comm_send_tap_config(const uint8_t mac[CLIENT_MAC_LEN],
uint32_t client_id,
const bma456_tap_config_t *cfg) {
if (mac == NULL || cfg == NULL || !esp_now_core_is_master()) {
return ESP_ERR_INVALID_STATE;
}
char mac_str[18];
esp_now_core_mac_to_str(mac, mac_str, sizeof(mac_str));
esp_err_t err = send_tap_config(mac, client_id, cfg);
if (err == ESP_OK) {
ESP_LOGI(TAG,
"unicast SET_TAP_CONFIG to %s: sens=%u client_id=%lu "
"(S=%d D=%d T=%d)",
mac_str, (unsigned)cfg->tap_sens_thres, (unsigned long)client_id,
cfg->enable_single, cfg->enable_double, cfg->enable_triple);
} else {
ESP_LOGW(TAG, "unicast SET_TAP_CONFIG to %s failed: %s", mac_str,
esp_err_to_name(err));
}
return err;
}
esp_err_t esp_now_comm_send_battery_uv(const uint8_t mac[CLIENT_MAC_LEN],
uint32_t client_id, bool enabled) {
if (mac == NULL || !esp_now_core_is_master()) {
return ESP_ERR_INVALID_STATE;
}
char mac_str[18];
esp_now_core_mac_to_str(mac, mac_str, sizeof(mac_str));
esp_err_t err = send_battery_uv(mac, client_id, enabled);
if (err == ESP_OK) {
ESP_LOGI(TAG, "unicast SET_BATTERY_UV to %s: enabled=%d client_id=%lu",
mac_str, enabled, (unsigned long)client_id);
} else {
ESP_LOGW(TAG, "unicast SET_BATTERY_UV to %s failed: %s", mac_str,
esp_err_to_name(err));
}
return err;
}
static void handle_accel_sample(const uint8_t mac[CLIENT_MAC_LEN],
const alox_EspNowAccelSample *sample) {
if (sample == NULL) {
@@ -291,11 +573,13 @@ static void handle_accel_sample(const uint8_t mac[CLIENT_MAC_LEN],
esp_err_t err = client_registry_update_accel(
mac, sample->slave_id, (int16_t)sample->x, (int16_t)sample->y,
(int16_t)sample->z);
if (err == ESP_ERR_NOT_FOUND) {
if (err == ESP_ERR_NOT_FOUND || err == ESP_ERR_INVALID_STATE) {
// Unknown MAC, or stream already off (in-flight samples after disable).
return;
}
if (err != ESP_OK) {
ESP_LOGW(TAG, "accel sample id mismatch from %02x:…:%02x", mac[0], mac[5]);
ESP_LOGW(TAG, "accel sample id mismatch from %02x:…:%02x (slave_id=%lu)",
mac[0], mac[5], (unsigned long)sample->slave_id);
}
}
@@ -349,25 +633,53 @@ static void handle_client_presence(const alox_EspNowSlavePresence *presence,
esp_now_core_ensure_peer(mac);
uint32_t assigned_id = 0;
esp_err_t err =
client_registry_assign_id(mac, presence->slave_id, &assigned_id);
if (err != ESP_OK) {
ESP_LOGW(TAG, "id assign failed: %s", esp_err_to_name(err));
return;
}
bool is_new = false;
bool reactivated = false;
esp_err_t err = client_registry_heartbeat(
mac, presence->slave_id, presence->version, presence->used, &is_new,
&reactivated);
err = client_registry_heartbeat(mac, assigned_id, presence->version,
presence->used, &is_new, &reactivated);
if (err != ESP_OK) {
ESP_LOGW(TAG, "client registry full");
return;
}
/*
* Send ASSIGN when new/rejoined, or when slave has not echoed the id yet.
* Rate-limit mismatch resends so failed assigns do not flood ESP-NOW buffers
* (ESP_ERR_ESPNOW_NO_MEM) and block other unicast traffic.
*/
const bool needs_assign =
is_new || reactivated || presence->slave_id != assigned_id;
if (needs_assign) {
const client_info_t *client = client_registry_find_by_mac(mac);
const bool due =
is_new || reactivated || client == NULL ||
client->last_assign_sent_at == 0 ||
client_registry_ms_since(client->last_assign_sent_at) >=
ESPNOW_ASSIGN_RESEND_MS;
if (due) {
if (esp_now_comm_send_assign_id(mac, assigned_id) == ESP_OK) {
client_registry_mark_assign_sent(mac);
}
}
}
char mac_str[18];
esp_now_core_mac_to_str(mac, mac_str, sizeof(mac_str));
if (is_new) {
ESP_LOGI(TAG, "client registered id=%lu mac=%s ver=%lu",
(unsigned long)presence->slave_id, mac_str,
(unsigned long)presence->version);
ESP_LOGI(TAG, "client registered id=%lu (preferred=%lu) mac=%s ver=%lu",
(unsigned long)assigned_id, (unsigned long)presence->slave_id,
mac_str, (unsigned long)presence->version);
} else if (reactivated) {
ESP_LOGI(TAG, "client reconnected id=%lu mac=%s",
(unsigned long)presence->slave_id, mac_str);
(unsigned long)assigned_id, mac_str);
}
}
@@ -415,6 +727,12 @@ void esp_now_master_on_recv(const esp_now_recv_info_t *info, const uint8_t *data
return;
}
if (msg.which_payload == alox_EspNowMessage_echo_pong_tag) {
esp_now_core_ensure_peer(info->src_addr);
echo_ping_on_pong(info->src_addr, &msg.payload.echo_pong);
return;
}
if (msg.type == alox_EspNowMessageType_ESPNOW_BATTERY_REPORT &&
msg.which_payload != alox_EspNowMessage_battery_report_tag) {
ESP_LOGW(TAG, "BATTERY_REPORT type but which=%u", msg.which_payload);
@@ -439,7 +757,11 @@ static void discover_task(void *param) {
(unsigned)esp_now_core_wifi_channel());
while (1) {
esp_now_core_send(ESPNOW_BCAST, &msg);
msg.payload.discover.master_ota_pending = ota_session_busy();
msg.payload.discover.master_time_us = (uint64_t)esp_timer_get_time();
if (!ota_espnow_distribution_active()) {
esp_now_core_send_fast(ESPNOW_BCAST, &msg);
}
vTaskDelay(pdMS_TO_TICKS(ESPNOW_DISCOVER_INTERVAL_MS));
}
}
@@ -458,7 +780,11 @@ static void monitor_task(void *param) {
while (1) {
vTaskDelay(pdMS_TO_TICKS(ESPNOW_HEARTBEAT_INTERVAL_MS));
client_registry_check_timeouts(ESPNOW_CLIENT_TIMEOUT_MS);
/* During ESP-NOW OTA dist the recv_cb drops heartbeats; do not wipe
* targets from the registry while they are still being updated. */
if (!ota_espnow_distribution_active()) {
client_registry_check_timeouts(ESPNOW_CLIENT_TIMEOUT_MS);
}
uint32_t t = esp_now_core_now_ms();
if (t - last_local_battery_ms >= ESPNOW_BATTERY_INTERVAL_MS) {
+453 -45
View File
@@ -11,8 +11,10 @@
#include "pod_reboot.h"
#include "pod_settings.h"
#include "esp_log.h"
#include "esp_timer.h"
#include "freertos/FreeRTOS.h"
#include "freertos/idf_additions.h"
#include "freertos/semphr.h"
#include <string.h>
#ifndef POWERPOD_FW_VERSION
@@ -21,19 +23,45 @@
#define ESPNOW_HEARTBEAT_INTERVAL_MS 1000
#define SLAVE_MASTER_LOST_MS (ESPNOW_HEARTBEAT_INTERVAL_MS * 5)
#define ESPNOW_ACCEL_INTERVAL_MS 16
/** While master or slave OTA is in progress (discover may be sparse). */
#define SLAVE_MASTER_OTA_GRACE_MS 300000u
/**
* Accel stream poll cadence (10 ms frame). TX only when sample exceeds the
* BMA456 deadzone vs last sent sample. TX path is selectable (INPUT_SEND_MODE).
* With master Discover.master_time_us, slaves phase into 1 ms slots:
* offset_ms = (assigned_id - 1) % 8. Slot wait uses esp_timer (FreeRTOS tick
* is 10 ms — too coarse for intra-frame offsets).
*/
#define ESPNOW_ACCEL_INTERVAL_MS 10
#define ESPNOW_ACCEL_INTERVAL_US (ESPNOW_ACCEL_INTERVAL_MS * 1000ull)
#define ESPNOW_ACCEL_SLOT_COUNT 8u
#define ESPNOW_ACCEL_SLOT_US 1000ull
#define ESPNOW_BATTERY_INTERVAL_MS 30000
#define SLAVE_BATTERY_AFTER_JOIN_MS 150
static const char *TAG = "[ESPNOW_S]";
static bool s_joined;
static bool s_master_ota_grace;
static bool s_accel_stream_enabled;
static bool s_tap_notify_single;
static bool s_tap_notify_double;
static bool s_tap_notify_triple;
/** Accel/tap TX path; default matches historical best-effort gaming input. */
static uint32_t s_input_send_mode =
(uint32_t)alox_InputSendMode_INPUT_SEND_BEST_EFFORT;
static uint8_t s_master_mac[ESP_NOW_ETH_ALEN];
static uint32_t s_last_discover_ms;
/** Logical id assigned by current/last master (0 = unbound). */
static uint32_t s_assigned_id;
/** Master MAC from NVS binding (may differ from current s_master_mac). */
static uint8_t s_bound_master_mac[ESP_NOW_ETH_ALEN];
static bool s_has_binding;
/** estimated_master_us = esp_timer_get_time() + s_master_offset_us */
static int64_t s_master_offset_us;
static bool s_master_time_synced;
static SemaphoreHandle_t s_accel_slot_sem;
static esp_timer_handle_t s_accel_slot_timer;
typedef enum {
SLAVE_TX_SLAVE_INFO = 1,
@@ -42,15 +70,94 @@ typedef enum {
static QueueHandle_t s_tx_queue;
static void queue_tx(slave_tx_op_t op);
static uint32_t accel_slot_offset_us(void);
static bool from_joined_master(const uint8_t *master_mac) {
return s_joined && esp_now_core_mac_equal(master_mac, s_master_mac);
}
static uint32_t my_client_id(void) { return s_assigned_id; }
/** True when command targets this slave (0 = broadcast / any). */
static bool client_id_matches(uint32_t client_id) {
if (client_id == 0) {
return true;
}
return s_assigned_id != 0 && client_id == s_assigned_id;
}
static uint32_t preferred_id_for_master(void) {
if (!s_has_binding || s_assigned_id == 0) {
return 0;
}
if (!s_joined || !esp_now_core_mac_equal(s_master_mac, s_bound_master_mac)) {
return 0;
}
return s_assigned_id;
}
static void load_id_binding(void) {
uint8_t mac[ESP_NOW_ETH_ALEN];
uint32_t id = 0;
s_has_binding = pod_settings_load_id_binding(mac, &id);
if (s_has_binding) {
memcpy(s_bound_master_mac, mac, ESP_NOW_ETH_ALEN);
s_assigned_id = id;
ESP_LOGI(TAG, "loaded id binding id=%lu master=%02x:…:%02x",
(unsigned long)id, mac[0], mac[5]);
} else {
memset(s_bound_master_mac, 0, sizeof(s_bound_master_mac));
s_assigned_id = 0;
}
}
static void apply_assigned_id(uint32_t assigned_id) {
if (assigned_id < POD_CLIENT_ID_MIN || assigned_id > POD_CLIENT_ID_MAX) {
ESP_LOGW(TAG, "ignore invalid assigned_id=%lu", (unsigned long)assigned_id);
return;
}
const bool same =
s_has_binding && s_assigned_id == assigned_id &&
esp_now_core_mac_equal(s_bound_master_mac, s_master_mac);
s_assigned_id = assigned_id;
memcpy(s_bound_master_mac, s_master_mac, ESP_NOW_ETH_ALEN);
s_has_binding = true;
if (same) {
return;
}
if (pod_settings_save_id_binding(s_master_mac, assigned_id) != ESP_OK) {
ESP_LOGW(TAG, "assigned id=%lu but NVS save failed",
(unsigned long)assigned_id);
} else {
ESP_LOGI(TAG, "assigned id=%lu (persisted, accel slot %lu us)",
(unsigned long)assigned_id,
(unsigned long)accel_slot_offset_us());
}
queue_tx(SLAVE_TX_BATTERY);
}
static void clear_id_binding_local(void) {
s_assigned_id = 0;
s_has_binding = false;
memset(s_bound_master_mac, 0, sizeof(s_bound_master_mac));
(void)pod_settings_clear_id_binding();
ESP_LOGI(TAG, "cleared local id binding");
}
esp_err_t esp_now_comm_clear_local_id_binding(void) {
if (esp_now_core_is_master()) {
return ESP_OK;
}
clear_id_binding_local();
return ESP_OK;
}
static void fill_presence(alox_EspNowSlavePresence *presence) {
const uint8_t *own = esp_now_core_own_mac();
presence->network = esp_now_core_network();
presence->version = POWERPOD_FW_VERSION;
presence->slave_id = own[5];
presence->slave_id = preferred_id_for_master();
presence->available = true;
presence->used = false;
esp_now_proto_setup_presence_encode(presence, own);
@@ -72,6 +179,21 @@ static void send_presence(const uint8_t *dest_mac, alox_EspNowMessageType type)
esp_now_core_send(dest_mac, &msg);
}
static esp_err_t send_input_message(const uint8_t *dest_mac,
const alox_EspNowMessage *msg) {
switch (s_input_send_mode) {
case (uint32_t)alox_InputSendMode_INPUT_SEND_FAST:
return esp_now_core_send_fast(dest_mac, msg);
case (uint32_t)alox_InputSendMode_INPUT_SEND_WAIT:
return esp_now_core_send_wait(dest_mac, msg);
case (uint32_t)alox_InputSendMode_INPUT_SEND_RELIABLE:
return esp_now_core_send_reliable(dest_mac, msg);
case (uint32_t)alox_InputSendMode_INPUT_SEND_BEST_EFFORT:
default:
return esp_now_core_send_best_effort(dest_mac, msg);
}
}
static esp_err_t send_accel_sample(const uint8_t *dest_mac, uint32_t slave_id,
int16_t x, int16_t y, int16_t z) {
alox_EspNowMessage msg = alox_EspNowMessage_init_zero;
@@ -81,7 +203,7 @@ static esp_err_t send_accel_sample(const uint8_t *dest_mac, uint32_t slave_id,
msg.payload.accel_sample.x = x;
msg.payload.accel_sample.y = y;
msg.payload.accel_sample.z = z;
return esp_now_core_send(dest_mac, &msg);
return send_input_message(dest_mac, &msg);
}
static esp_err_t send_tap_event(const uint8_t *dest_mac, uint32_t slave_id,
@@ -91,7 +213,7 @@ static esp_err_t send_tap_event(const uint8_t *dest_mac, uint32_t slave_id,
msg.which_payload = alox_EspNowMessage_tap_event_tag;
msg.payload.tap_event.slave_id = slave_id;
msg.payload.tap_event.kind = kind;
return esp_now_core_send(dest_mac, &msg);
return send_input_message(dest_mac, &msg);
}
static esp_err_t send_battery_report(const uint8_t *dest_mac,
@@ -106,9 +228,21 @@ static esp_err_t send_battery_report(const uint8_t *dest_mac,
return esp_now_core_send(dest_mac, &msg);
}
static uint32_t slave_master_lost_ms(void) {
if (ota_uart_is_active() || s_master_ota_grace) {
return SLAVE_MASTER_OTA_GRACE_MS;
}
return SLAVE_MASTER_LOST_MS;
}
static void touch_master_presence(uint32_t now) { s_last_discover_ms = now; }
static void reset_join(void) {
s_joined = false;
s_master_ota_grace = false;
s_accel_stream_enabled = false;
s_master_time_synced = false;
s_master_offset_us = 0;
memset(s_master_mac, 0, sizeof(s_master_mac));
s_last_discover_ms = 0;
if (s_tx_queue != NULL) {
@@ -116,6 +250,66 @@ static void reset_join(void) {
}
}
static void apply_master_time_sync(uint64_t master_time_us) {
if (master_time_us == 0) {
return;
}
s_master_offset_us = (int64_t)master_time_us - esp_timer_get_time();
s_master_time_synced = true;
}
static uint32_t accel_slot_offset_us(void) {
if (s_assigned_id == 0) {
return 0;
}
return (uint32_t)(((s_assigned_id - 1u) % ESPNOW_ACCEL_SLOT_COUNT) *
ESPNOW_ACCEL_SLOT_US);
}
static void accel_slot_timer_cb(void *arg) {
(void)arg;
if (s_accel_slot_sem != NULL) {
xSemaphoreGive(s_accel_slot_sem);
}
}
/** Sleep until this slave's 1 ms slot in the shared 10 ms master frame. */
static void wait_next_accel_slot(void) {
if (!s_master_time_synced || s_accel_slot_sem == NULL ||
s_accel_slot_timer == NULL) {
const TickType_t period_ticks = pdMS_TO_TICKS(ESPNOW_ACCEL_INTERVAL_MS);
vTaskDelay(period_ticks > 0 ? period_ticks : 1);
return;
}
const int64_t period_us = (int64_t)ESPNOW_ACCEL_INTERVAL_US;
const int64_t slot_us = (int64_t)accel_slot_offset_us();
const int64_t master_now = esp_timer_get_time() + s_master_offset_us;
int64_t phase = master_now % period_us;
if (phase < 0) {
phase += period_us;
}
int64_t wait_us = slot_us - phase;
if (wait_us < 200) {
/* Too close / already past — take the next frame. */
wait_us += period_us;
}
(void)xSemaphoreTake(s_accel_slot_sem, 0);
(void)esp_timer_stop(s_accel_slot_timer);
esp_err_t err = esp_timer_start_once(s_accel_slot_timer, (uint64_t)wait_us);
if (err != ESP_OK) {
vTaskDelay(pdMS_TO_TICKS(ESPNOW_ACCEL_INTERVAL_MS) > 0
? pdMS_TO_TICKS(ESPNOW_ACCEL_INTERVAL_MS)
: 1);
return;
}
if (xSemaphoreTake(s_accel_slot_sem,
pdMS_TO_TICKS(ESPNOW_ACCEL_INTERVAL_MS * 3)) != pdTRUE) {
(void)esp_timer_stop(s_accel_slot_timer);
}
}
static void queue_tx(slave_tx_op_t op) {
if (s_tx_queue == NULL) {
return;
@@ -126,7 +320,7 @@ static void queue_tx(slave_tx_op_t op) {
}
static void send_battery_to_master(void) {
if (!s_joined) {
if (!s_joined || s_assigned_id == 0) {
return;
}
@@ -134,7 +328,7 @@ static void send_battery_to_master(void) {
board_input_read_lipo(&reading);
alox_EspNowBatteryReport report = alox_EspNowBatteryReport_init_zero;
report.client_id = esp_now_core_own_mac()[5];
report.client_id = s_assigned_id;
report.lipo1_valid = reading.lipo1_valid;
report.lipo2_valid = reading.lipo2_valid;
report.lipo1_mv = reading.lipo1_mv;
@@ -216,12 +410,39 @@ static void handle_unicast_test(const uint8_t *master_mac,
(unsigned long)test->seq, (int)s_joined);
}
static void handle_echo_ping(const uint8_t *master_mac,
const alox_EspNowEchoPing *ping) {
if (ping == NULL || !s_joined ||
!esp_now_core_mac_equal(master_mac, s_master_mac)) {
return;
}
char mac_str[18];
esp_now_core_mac_to_str(master_mac, mac_str, sizeof(mac_str));
ESP_LOGI(TAG, "ESP-NOW PING recv from %s host_ts=%llu master_time_us=%llu",
mac_str, (unsigned long long)ping->host_timestamp_us,
(unsigned long long)ping->master_time_us);
alox_EspNowMessage msg = alox_EspNowMessage_init_zero;
msg.type = alox_EspNowMessageType_ESPNOW_ECHO_PONG;
msg.which_payload = alox_EspNowMessage_echo_pong_tag;
msg.payload.echo_pong.host_timestamp_us = ping->host_timestamp_us;
msg.payload.echo_pong.master_time_us = ping->master_time_us;
esp_err_t err = esp_now_core_send_fast(s_master_mac, &msg);
if (err != ESP_OK) {
ESP_LOGW(TAG, "ECHO PONG send failed: %s", esp_err_to_name(err));
return;
}
ESP_LOGI(TAG, "ESP-NOW PONG send to %s host_ts=%llu master_time_us=%llu",
mac_str, (unsigned long long)ping->host_timestamp_us,
(unsigned long long)ping->master_time_us);
}
static void handle_restart(const uint8_t *master_mac,
const alox_EspNowRestart *req) {
const uint8_t *own = esp_now_core_own_mac();
uint32_t my_id = own[5];
if (req->client_id != 0 && req->client_id != my_id) {
if (!client_id_matches(req->client_id)) {
return;
}
if (s_joined && !esp_now_core_mac_equal(master_mac, s_master_mac)) {
@@ -230,14 +451,14 @@ static void handle_restart(const uint8_t *master_mac,
char mac_str[18];
esp_now_core_mac_to_str(master_mac, mac_str, sizeof(mac_str));
ESP_LOGI(TAG, "RESTART from master %s (id=%lu)", mac_str, (unsigned long)my_id);
ESP_LOGI(TAG, "RESTART from master %s (id=%lu)", mac_str,
(unsigned long)my_client_id());
pod_schedule_restart();
}
static void handle_battery_query(const uint8_t *master_mac,
const alox_EspNowBatteryQuery *query) {
uint32_t my_id = esp_now_core_own_mac()[5];
if (query->client_id != 0 && query->client_id != my_id) {
if (!client_id_matches(query->client_id)) {
return;
}
if (s_joined && !esp_now_core_mac_equal(master_mac, s_master_mac)) {
@@ -248,8 +469,7 @@ static void handle_battery_query(const uint8_t *master_mac,
static void handle_led_ring(const uint8_t *master_mac,
const alox_EspNowLedRing *msg) {
uint32_t my_id = esp_now_core_own_mac()[5];
if (msg->client_id != 0 && msg->client_id != my_id) {
if (!client_id_matches(msg->client_id)) {
return;
}
if (s_joined && !esp_now_core_mac_equal(master_mac, s_master_mac)) {
@@ -270,13 +490,12 @@ static void handle_led_ring(const uint8_t *master_mac,
char mac_str[18];
esp_now_core_mac_to_str(master_mac, mac_str, sizeof(mac_str));
ESP_LOGI(TAG, "LED_RING mode %lu from master %s (id=%lu)",
(unsigned long)req.mode, mac_str, (unsigned long)my_id);
(unsigned long)req.mode, mac_str, (unsigned long)my_client_id());
cmd_led_ring_apply(&req);
}
static void handle_find_me(const uint8_t *master_mac, const alox_EspNowFindMe *req) {
uint32_t my_id = esp_now_core_own_mac()[5];
if (req->client_id != 0 && req->client_id != my_id) {
if (!client_id_matches(req->client_id)) {
return;
}
if (s_joined && !esp_now_core_mac_equal(master_mac, s_master_mac)) {
@@ -284,30 +503,69 @@ static void handle_find_me(const uint8_t *master_mac, const alox_EspNowFindMe *r
}
char mac_str[18];
esp_now_core_mac_to_str(master_mac, mac_str, sizeof(mac_str));
ESP_LOGI(TAG, "FIND_ME from master %s (id=%lu)", mac_str, (unsigned long)my_id);
ESP_LOGI(TAG, "FIND_ME from master %s (id=%lu)", mac_str,
(unsigned long)my_client_id());
led_ring_find_me();
}
static void handle_accel_stream(const uint8_t *master_mac,
const alox_EspNowAccelStream *cfg) {
uint32_t my_id = esp_now_core_own_mac()[5];
if (cfg->client_id != 0 && cfg->client_id != my_id) {
if (!client_id_matches(cfg->client_id)) {
return;
}
if (s_joined && !esp_now_core_mac_equal(master_mac, s_master_mac)) {
return;
}
s_accel_stream_enabled = cfg->enable;
if (cfg->enable) {
bma456_accel_stream_reset_baseline();
}
char mac_str[18];
esp_now_core_mac_to_str(master_mac, mac_str, sizeof(mac_str));
ESP_LOGI(TAG, "accel stream %s from master %s (id=%lu)",
cfg->enable ? "on" : "off", mac_str, (unsigned long)my_id);
cfg->enable ? "on" : "off", mac_str, (unsigned long)my_client_id());
}
static const char *input_send_mode_name(uint32_t mode) {
switch (mode) {
case (uint32_t)alox_InputSendMode_INPUT_SEND_BEST_EFFORT:
return "best_effort";
case (uint32_t)alox_InputSendMode_INPUT_SEND_FAST:
return "fast";
case (uint32_t)alox_InputSendMode_INPUT_SEND_WAIT:
return "wait";
case (uint32_t)alox_InputSendMode_INPUT_SEND_RELIABLE:
return "reliable";
default:
return "unknown";
}
}
static void handle_input_send_mode(const uint8_t *master_mac,
const alox_EspNowInputSendMode *cfg) {
if (!client_id_matches(cfg->client_id)) {
return;
}
if (s_joined && !esp_now_core_mac_equal(master_mac, s_master_mac)) {
return;
}
if (cfg->mode > (uint32_t)alox_InputSendMode_INPUT_SEND_RELIABLE) {
ESP_LOGW(TAG, "input send mode %lu invalid (id=%lu)",
(unsigned long)cfg->mode, (unsigned long)my_client_id());
return;
}
s_input_send_mode = cfg->mode;
char mac_str[18];
esp_now_core_mac_to_str(master_mac, mac_str, sizeof(mac_str));
ESP_LOGI(TAG, "input send mode %s (%lu) from master %s (id=%lu)",
input_send_mode_name(s_input_send_mode),
(unsigned long)s_input_send_mode, mac_str,
(unsigned long)my_client_id());
}
static void handle_tap_notify(const uint8_t *master_mac,
const alox_EspNowTapNotify *cfg) {
uint32_t my_id = esp_now_core_own_mac()[5];
if (cfg->client_id != 0 && cfg->client_id != my_id) {
if (!client_id_matches(cfg->client_id)) {
return;
}
if (s_joined && !esp_now_core_mac_equal(master_mac, s_master_mac)) {
@@ -321,13 +579,12 @@ static void handle_tap_notify(const uint8_t *master_mac,
ESP_LOGI(TAG,
"tap notify single=%d double=%d triple=%d from master %s (id=%lu)",
cfg->single, cfg->double_tap, cfg->triple, mac_str,
(unsigned long)my_id);
(unsigned long)my_client_id());
}
static void handle_accel_deadzone(const uint8_t *master_mac,
const alox_EspNowAccelDeadzone *cfg) {
uint32_t my_id = esp_now_core_own_mac()[5];
if (cfg->client_id != 0 && cfg->client_id != my_id) {
if (!client_id_matches(cfg->client_id)) {
return;
}
if (s_joined && !esp_now_core_mac_equal(master_mac, s_master_mac)) {
@@ -337,7 +594,7 @@ static void handle_accel_deadzone(const uint8_t *master_mac,
esp_now_core_mac_to_str(master_mac, mac_str, sizeof(mac_str));
ESP_LOGI(TAG,
"accel deadzone from master %s: %lu LSB id=%lu (sensor %s)", mac_str,
(unsigned long)cfg->deadzone, (unsigned long)my_id,
(unsigned long)cfg->deadzone, (unsigned long)my_client_id(),
bma456_is_ready() ? "ok" : "not installed");
bma456_set_accel_deadzone(cfg->deadzone);
if (pod_settings_save_accel_deadzone(cfg->deadzone) != ESP_OK) {
@@ -346,6 +603,86 @@ static void handle_accel_deadzone(const uint8_t *master_mac,
}
}
static void handle_tap_config(const uint8_t *master_mac,
const alox_EspNowTapConfig *msg) {
if (!client_id_matches(msg->client_id)) {
return;
}
if (s_joined && !esp_now_core_mac_equal(master_mac, s_master_mac)) {
return;
}
bma456_tap_config_t cfg = {
.tap_sens_thres = (uint16_t)msg->tap_sens_thres,
.max_gest_dur = (uint16_t)msg->max_gest_dur,
.tap_shock_dur = (uint16_t)msg->tap_shock_dur,
.quite_time_after_gest = (uint16_t)msg->quite_time_after_gest,
.wait_for_timeout = (uint16_t)msg->wait_for_timeout,
.axis_sel = (uint16_t)msg->axis_sel,
.enable_single = msg->enable_single,
.enable_double = msg->enable_double,
.enable_triple = msg->enable_triple,
};
char mac_str[18];
esp_now_core_mac_to_str(master_mac, mac_str, sizeof(mac_str));
ESP_LOGI(TAG,
"tap config from master %s: sens=%u id=%lu (sensor %s)", mac_str,
(unsigned)cfg.tap_sens_thres, (unsigned long)my_client_id(),
bma456_is_ready() ? "ok" : "not installed");
if (!bma456_tap_config_valid(&cfg)) {
ESP_LOGW(TAG, "invalid tap config from master; ignored");
return;
}
if (bma456_set_tap_config(&cfg) != ESP_OK) {
ESP_LOGW(TAG, "tap config apply failed");
return;
}
if (pod_settings_save_tap_config(&cfg) != ESP_OK) {
ESP_LOGW(TAG, "tap config applied but not saved to NVS");
}
}
static void handle_battery_uv(const uint8_t *master_mac,
const alox_EspNowBatteryUv *msg) {
if (!client_id_matches(msg->client_id)) {
return;
}
if (s_joined && !esp_now_core_mac_equal(master_mac, s_master_mac)) {
return;
}
char mac_str[18];
esp_now_core_mac_to_str(master_mac, mac_str, sizeof(mac_str));
ESP_LOGI(TAG, "UV protection %s from master %s (id=%lu)",
msg->enabled ? "enabled" : "disabled", mac_str,
(unsigned long)my_client_id());
if (pod_settings_set_uv_enabled(msg->enabled) != ESP_OK) {
ESP_LOGW(TAG, "UV enable save failed");
return;
}
if (!msg->enabled) {
(void)pod_settings_clear_uv_latched();
}
}
static void handle_assign_id(const uint8_t *master_mac,
const alox_EspNowAssignId *msg) {
if (!from_joined_master(master_mac)) {
return;
}
apply_assigned_id(msg->assigned_id);
}
static void handle_clear_id_binding(const uint8_t *master_mac) {
if (!from_joined_master(master_mac)) {
return;
}
clear_id_binding_local();
}
static void handle_discover(const uint8_t *sender_mac,
const alox_EspNowDiscover *discover) {
if (discover->network != esp_now_core_network()) {
@@ -358,8 +695,14 @@ static void handle_discover(const uint8_t *sender_mac,
if (!esp_now_core_mac_equal(sender_mac, s_master_mac)) {
return;
}
if ((now - s_last_discover_ms) <= SLAVE_MASTER_LOST_MS) {
s_last_discover_ms = now;
s_master_ota_grace = discover->master_ota_pending;
apply_master_time_sync(discover->master_time_us);
if ((now - s_last_discover_ms) <= slave_master_lost_ms()) {
touch_master_presence(now);
return;
}
if (ota_uart_is_active()) {
touch_master_presence(now);
return;
}
ESP_LOGW(TAG, "master lost, rejoining");
@@ -368,40 +711,51 @@ static void handle_discover(const uint8_t *sender_mac,
memcpy(s_master_mac, sender_mac, ESP_NOW_ETH_ALEN);
s_joined = true;
s_last_discover_ms = now;
s_master_ota_grace = discover->master_ota_pending;
apply_master_time_sync(discover->master_time_us);
touch_master_presence(now);
esp_now_core_ensure_peer(sender_mac);
char mac_str[18];
esp_now_core_mac_to_str(sender_mac, mac_str, sizeof(mac_str));
ESP_LOGI(TAG, "joined network %u, master %s", (unsigned)discover->network,
mac_str);
ESP_LOGI(TAG, "joined network %u, master %s (preferred_id=%lu)",
(unsigned)discover->network, mac_str,
(unsigned long)preferred_id_for_master());
led_ring_connected_anim();
queue_tx(SLAVE_TX_SLAVE_INFO);
queue_tx(SLAVE_TX_BATTERY);
/* Battery after ASSIGN_ID so reports carry the definitive client_id. */
}
static void check_master_timeout(void) {
if (!s_joined || s_last_discover_ms == 0) {
return;
}
if (ota_uart_is_active()) {
return;
}
uint32_t now = esp_now_core_now_ms();
if ((now - s_last_discover_ms) > SLAVE_MASTER_LOST_MS) {
ESP_LOGW(TAG, "no master discover for %u ms, reconnecting",
(unsigned)(now - s_last_discover_ms));
uint32_t limit = slave_master_lost_ms();
if ((now - s_last_discover_ms) > limit) {
ESP_LOGW(TAG, "no master discover for %u ms (limit %u), reconnecting",
(unsigned)(now - s_last_discover_ms), (unsigned)limit);
reset_join();
}
}
static void accel_stream_task(void *param) {
(void)param;
const uint8_t *own = esp_now_core_own_mac();
ESP_LOGI(TAG, "accel stream task (interval %u ms)",
(unsigned)ESPNOW_ACCEL_INTERVAL_MS);
ESP_LOGI(TAG,
"accel stream task (poll %u ms, deadzone TX, %u×%llu us slots, "
"mode selectable)",
(unsigned)ESPNOW_ACCEL_INTERVAL_MS, (unsigned)ESPNOW_ACCEL_SLOT_COUNT,
(unsigned long long)ESPNOW_ACCEL_SLOT_US);
while (1) {
vTaskDelay(pdMS_TO_TICKS(ESPNOW_ACCEL_INTERVAL_MS));
if (!s_joined || !s_accel_stream_enabled || !bma456_is_ready()) {
wait_next_accel_slot();
if (!s_joined || s_assigned_id == 0 || !s_accel_stream_enabled ||
!bma456_is_ready()) {
continue;
}
int16_t x = 0;
@@ -410,13 +764,16 @@ static void accel_stream_task(void *param) {
if (bma456_read_accel(&x, &y, &z) != ESP_OK) {
continue;
}
(void)send_accel_sample(s_master_mac, own[5], x, y, z);
if (!bma456_accel_stream_take_if_changed(x, y, z)) {
continue;
}
(void)send_accel_sample(s_master_mac, s_assigned_id, x, y, z);
}
}
static void on_bma456_tap(bma456_tap_kind_t kind, void *ctx) {
(void)ctx;
if (!s_joined) {
if (!s_joined || s_assigned_id == 0) {
return;
}
bool enabled = false;
@@ -436,7 +793,7 @@ static void on_bma456_tap(bma456_tap_kind_t kind, void *ctx) {
if (!enabled) {
return;
}
(void)send_tap_event(s_master_mac, esp_now_core_own_mac()[5], (uint32_t)kind);
(void)send_tap_event(s_master_mac, s_assigned_id, (uint32_t)kind);
}
static void heartbeat_task(void *param) {
@@ -481,12 +838,16 @@ void esp_now_slave_on_recv(const esp_now_recv_info_t *info, const uint8_t *data,
if (ota_uart_is_active()) {
switch (msg.which_payload) {
case alox_EspNowMessage_discover_tag:
handle_discover(info->src_addr, &msg.payload.discover);
break;
case alox_EspNowMessage_ota_start_tag:
case alox_EspNowMessage_ota_payload_tag:
case alox_EspNowMessage_ota_end_tag:
if (!from_joined_master(info->src_addr)) {
break;
}
touch_master_presence(esp_now_core_now_ms());
if (msg.which_payload == alox_EspNowMessage_ota_start_tag) {
ota_espnow_slave_on_start(info->src_addr, &msg.payload.ota_start);
} else if (msg.which_payload == alox_EspNowMessage_ota_payload_tag) {
@@ -510,11 +871,26 @@ void esp_now_slave_on_recv(const esp_now_recv_info_t *info, const uint8_t *data,
handle_unicast_test(info->src_addr, &msg.payload.unicast_test);
}
break;
case alox_EspNowMessage_echo_ping_tag:
if (from_joined_master(info->src_addr)) {
handle_echo_ping(info->src_addr, &msg.payload.echo_ping);
}
break;
case alox_EspNowMessage_accel_deadzone_tag:
if (from_joined_master(info->src_addr)) {
handle_accel_deadzone(info->src_addr, &msg.payload.accel_deadzone);
}
break;
case alox_EspNowMessage_tap_config_tag:
if (from_joined_master(info->src_addr)) {
handle_tap_config(info->src_addr, &msg.payload.tap_config);
}
break;
case alox_EspNowMessage_battery_uv_tag:
if (from_joined_master(info->src_addr)) {
handle_battery_uv(info->src_addr, &msg.payload.battery_uv);
}
break;
case alox_EspNowMessage_accel_stream_tag:
if (from_joined_master(info->src_addr)) {
handle_accel_stream(info->src_addr, &msg.payload.accel_stream);
@@ -525,6 +901,11 @@ void esp_now_slave_on_recv(const esp_now_recv_info_t *info, const uint8_t *data,
handle_tap_notify(info->src_addr, &msg.payload.tap_notify);
}
break;
case alox_EspNowMessage_input_send_mode_tag:
if (from_joined_master(info->src_addr)) {
handle_input_send_mode(info->src_addr, &msg.payload.input_send_mode);
}
break;
case alox_EspNowMessage_battery_query_tag:
if (from_joined_master(info->src_addr)) {
handle_battery_query(info->src_addr, &msg.payload.battery_query);
@@ -545,6 +926,17 @@ void esp_now_slave_on_recv(const esp_now_recv_info_t *info, const uint8_t *data,
handle_restart(info->src_addr, &msg.payload.restart);
}
break;
case alox_EspNowMessage_assign_id_tag:
handle_assign_id(info->src_addr, &msg.payload.assign_id);
break;
case alox_EspNowMessage_clear_id_binding_tag:
handle_clear_id_binding(info->src_addr);
break;
case alox_EspNowMessage_ota_start_tag:
if (from_joined_master(info->src_addr)) {
ota_espnow_slave_on_start(info->src_addr, &msg.payload.ota_start);
}
break;
default:
ESP_LOGW(TAG, "unhandled which=%u type=%u", msg.which_payload,
(unsigned)msg.type);
@@ -554,6 +946,22 @@ void esp_now_slave_on_recv(const esp_now_recv_info_t *info, const uint8_t *data,
esp_err_t esp_now_slave_start(void) {
reset_join();
load_id_binding();
s_accel_slot_sem = xSemaphoreCreateBinary();
if (s_accel_slot_sem == NULL) {
ESP_LOGE(TAG, "failed to create accel slot semaphore");
return ESP_ERR_NO_MEM;
}
const esp_timer_create_args_t slot_timer_args = {
.callback = &accel_slot_timer_cb,
.name = "accel_slot",
};
esp_err_t terr = esp_timer_create(&slot_timer_args, &s_accel_slot_timer);
if (terr != ESP_OK) {
ESP_LOGE(TAG, "failed to create accel slot timer: %s", esp_err_to_name(terr));
return terr;
}
s_tx_queue = xQueueCreate(4, sizeof(slave_tx_op_t));
if (s_tx_queue == NULL) {
+106
View File
@@ -0,0 +1,106 @@
#include "host_transport.h"
#include "cmd_handler.h"
#include "esp_log.h"
#include "uart.h"
#include "uart_messages.pb.h"
#include <stdlib.h>
#include <string.h>
static const char *TAG = "[HOST]";
static host_tx_fn_t s_tx_fn;
static QueueHandle_t s_cmd_queue;
static uart_packet_t s_rx_packet = {.state = STATE_START};
static TickType_t s_last_byte_ticks;
static const TickType_t s_rx_timeout_ticks = pdMS_TO_TICKS(50);
void host_transport_set_tx(host_tx_fn_t tx_fn) { s_tx_fn = tx_fn; }
void host_transport_set_cmd_queue(QueueHandle_t cmd_queue) {
s_cmd_queue = cmd_queue;
}
QueueHandle_t host_transport_cmd_queue(void) { return s_cmd_queue; }
bool host_enqueue_packet(const uint8_t *payload, size_t len) {
if (s_cmd_queue == NULL || payload == NULL || len == 0) {
return false;
}
generic_msg_t msg = {
.msg_id = payload[0],
.len = len > 1 ? (len - 1) : 0,
.payload = NULL,
};
if (msg.len > 0) {
msg.payload = malloc(msg.len);
if (msg.payload == NULL) {
ESP_LOGE(TAG, "failed to allocate command payload");
return false;
}
memcpy(msg.payload, &payload[1], msg.len);
}
if (xQueueSend(s_cmd_queue, &msg, pdMS_TO_TICKS(500)) != pdPASS) {
free(msg.payload);
ESP_LOGW(TAG, "command queue full (cmd 0x%02x)", (unsigned)msg.msg_id);
return false;
}
return true;
}
esp_err_t host_send_framed(const uint8_t *payload, size_t len) {
if (payload == NULL || len == 0 || len > MAX_PAYLOAD_SIZE) {
return ESP_ERR_INVALID_ARG;
}
if (s_tx_fn == NULL) {
return ESP_ERR_INVALID_STATE;
}
uint8_t checksum = 0;
for (size_t i = 0; i < len; i++) {
checksum ^= payload[i];
}
uint8_t frame[UART_FRAME_OVERHEAD + MAX_PAYLOAD_SIZE];
size_t pos = 0;
frame[pos++] = START_MARKER;
frame[pos++] = (uint8_t)(len & 0xff);
frame[pos++] = (uint8_t)((len >> 8) & 0xff);
memcpy(&frame[pos], payload, len);
pos += len;
frame[pos++] = checksum;
frame[pos++] = STOP_MARKER;
return s_tx_fn(frame, pos);
}
void host_rx_bytes(const uint8_t *data, size_t len) {
if (data == NULL || len == 0) {
return;
}
TickType_t now = xTaskGetTickCount();
if (s_rx_packet.state != STATE_START &&
(now - s_last_byte_ticks) > s_rx_timeout_ticks) {
s_rx_packet.state = STATE_START;
}
for (size_t i = 0; i < len; i++) {
if (parse_uart_byte(data[i], &s_rx_packet)) {
uint8_t cmd =
s_rx_packet.len > 0 ? s_rx_packet.payload[0] : 0;
/* OTA payloads are high-rate; avoid INFO spam starving the dispatcher. */
if (cmd == (uint8_t)alox_MessageType_OTA_PAYLOAD) {
ESP_LOGD(TAG, "received message cmd=0x%02x len=%u", cmd,
(unsigned)s_rx_packet.len);
} else {
ESP_LOGI(TAG, "received message cmd=0x%02x len=%u", cmd,
(unsigned)s_rx_packet.len);
}
host_enqueue_packet(s_rx_packet.payload, s_rx_packet.len);
}
}
s_last_byte_ticks = now;
}
+36
View File
@@ -0,0 +1,36 @@
#ifndef HOST_TRANSPORT_H
#define HOST_TRANSPORT_H
#include "esp_err.h"
#include "freertos/FreeRTOS.h"
#include "freertos/queue.h"
#include <stddef.h>
#include <stdint.h>
/** TX callback: send a complete framed packet (0xAA … 0xCC). */
typedef esp_err_t (*host_tx_fn_t)(const uint8_t *frame, size_t frame_len);
/** Set the active host TX backend (UART write or BLE notify). */
void host_transport_set_tx(host_tx_fn_t tx_fn);
/** Bind the shared cmd_queue used by uart_enqueue_packet / host RX. */
void host_transport_set_cmd_queue(QueueHandle_t cmd_queue);
QueueHandle_t host_transport_cmd_queue(void);
/**
* Build AA|len_lo|len_hi|payload|xor|CC and send via the registered TX backend.
* Same semantics as uart_send_framed.
*/
esp_err_t host_send_framed(const uint8_t *payload, size_t len);
/**
* Feed raw host RX bytes into the shared frame parser and enqueue complete
* packets onto the cmd_queue.
*/
void host_rx_bytes(const uint8_t *data, size_t len);
/** Enqueue a completed uart_packet_t (payload[0]=msg type). */
bool host_enqueue_packet(const uint8_t *payload, size_t len);
#endif
+80 -1
View File
@@ -5,6 +5,7 @@
#include "esp_log.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/semphr.h"
#include "led_strip.h"
#include <stdint.h>
@@ -23,8 +24,22 @@ static led_strip_handle_t led_ring;
#define LED_RING_FIND_ME_ON_MS 300
#define LED_RING_FIND_ME_OFF_MS 150
#define LED_RING_FIND_ME_BLINKS_PER_COLOR 3
#define LED_RING_BATTERY_LOW_R 255
#define LED_RING_BATTERY_LOW_G 0
#define LED_RING_BATTERY_LOW_B 0
/** Boot chase: all RING_LEDS within ~1 s. */
#define LED_RING_BOOT_STEP_MS 10
#define LED_RING_BOOT_R 255
#define LED_RING_BOOT_G 255
#define LED_RING_BOOT_B 255
/** Connected: every other LED briefly blue. */
#define LED_RING_CONNECTED_HOLD_MS 120
#define LED_RING_CONNECTED_R 0
#define LED_RING_CONNECTED_G 0
#define LED_RING_CONNECTED_B 255
static QueueHandle_t led_queue;
static SemaphoreHandle_t s_battery_low_done;
// Led Matrix Maps
const uint8_t d0[] = {46, 47, 60, 61, 62, 75, 78, 79,
@@ -145,6 +160,49 @@ void vTaskLedRing(void *pvParameters) {
}
}
continue;
} else if (cmd.mode == LED_CMD_BATTERY_LOW) {
uint8_t r = LED_RING_BATTERY_LOW_R;
uint8_t g = LED_RING_BATTERY_LOW_G;
uint8_t b = LED_RING_BATTERY_LOW_B;
led_ring_scale_rgb(&r, &g, &b, LED_RING_BATTERY_LOW_INTENSITY);
for (uint32_t i = 0; i < LED_RING_BATTERY_LOW_LEDS; i++) {
led_strip_set_pixel(led_ring, i, r, g, b);
}
led_strip_refresh(led_ring);
vTaskDelay(pdMS_TO_TICKS(LED_RING_BATTERY_LOW_HOLD_MS));
led_strip_clear(led_ring);
led_strip_refresh(led_ring);
if (s_battery_low_done != NULL) {
xSemaphoreGive(s_battery_low_done);
}
continue;
} else if (cmd.mode == LED_CMD_BOOT) {
uint8_t br = LED_RING_BOOT_R;
uint8_t bg = LED_RING_BOOT_G;
uint8_t bb = LED_RING_BOOT_B;
led_ring_scale_rgb(&br, &bg, &bb, LED_RING_DEFAULT_INTENSITY);
for (uint32_t i = 0; i < RING_LEDS; i++) {
led_strip_clear(led_ring);
led_strip_set_pixel(led_ring, i, br, bg, bb);
led_strip_refresh(led_ring);
vTaskDelay(pdMS_TO_TICKS(LED_RING_BOOT_STEP_MS));
}
led_strip_clear(led_ring);
led_strip_refresh(led_ring);
continue;
} else if (cmd.mode == LED_CMD_CONNECTED) {
uint8_t cr = LED_RING_CONNECTED_R;
uint8_t cg = LED_RING_CONNECTED_G;
uint8_t cb = LED_RING_CONNECTED_B;
led_ring_scale_rgb(&cr, &cg, &cb, LED_RING_DEFAULT_INTENSITY);
for (uint32_t i = 0; i < RING_LEDS; i += 2) {
led_strip_set_pixel(led_ring, i, cr, cg, cb);
}
led_strip_refresh(led_ring);
vTaskDelay(pdMS_TO_TICKS(LED_RING_CONNECTED_HOLD_MS));
led_strip_clear(led_ring);
led_strip_refresh(led_ring);
continue;
}
led_strip_refresh(led_ring);
}
@@ -153,12 +211,13 @@ void vTaskLedRing(void *pvParameters) {
void led_ring_init(void) {
led_queue = xQueueCreate(10, sizeof(led_command_t));
s_battery_low_done = xSemaphoreCreateBinary();
xTaskCreate(vTaskLedRing, "led_task", 4096, NULL, 5, NULL);
}
void led_ring_send_command(led_command_t *cmd) {
if (led_queue != NULL) {
xQueueSend(led_queue, cmd, portMAX_DELAY);
(void)xQueueSend(led_queue, cmd, 0);
}
}
@@ -224,3 +283,23 @@ void led_ring_find_me(void) {
led_command_t cmd = {.mode = LED_CMD_FIND_ME};
led_ring_send_command(&cmd);
}
void led_ring_show_battery_low(void) {
if (led_queue == NULL || s_battery_low_done == NULL) {
return;
}
(void)xSemaphoreTake(s_battery_low_done, 0);
led_command_t cmd = {.mode = LED_CMD_BATTERY_LOW};
led_ring_send_command(&cmd);
(void)xSemaphoreTake(s_battery_low_done, portMAX_DELAY);
}
void led_ring_boot_anim(void) {
led_command_t cmd = {.mode = LED_CMD_BOOT};
led_ring_send_command(&cmd);
}
void led_ring_connected_anim(void) {
led_command_t cmd = {.mode = LED_CMD_CONNECTED};
led_ring_send_command(&cmd);
}
+17 -1
View File
@@ -7,6 +7,10 @@
#define LED_RING_DEFAULT_INTENSITY 13
/** Full brightness for find-me and similar alerts. */
#define LED_RING_FULL_INTENSITY 255
/** ~10 % brightness for battery-low indicator. */
#define LED_RING_BATTERY_LOW_INTENSITY 26
#define LED_RING_BATTERY_LOW_LEDS 4
#define LED_RING_BATTERY_LOW_HOLD_MS 5000
typedef enum {
LED_CMD_CLEAR,
@@ -14,7 +18,10 @@ typedef enum {
LED_CMD_SET_COLOR,
LED_CMD_PROGRESS,
LED_CMD_BLINK,
LED_CMD_FIND_ME
LED_CMD_FIND_ME,
LED_CMD_BATTERY_LOW,
LED_CMD_BOOT,
LED_CMD_CONNECTED
} led_mode_t;
typedef struct {
@@ -45,4 +52,13 @@ void led_ring_ota_failed(void);
/** Red / green / blue: 3 blinks each at full intensity. */
void led_ring_find_me(void);
/** First 4 LEDs red at ~10 % for 5 s, then off (blocking in LED task). */
void led_ring_show_battery_low(void);
/** Chase: each ring LED lights once in sequence (~1 s total). */
void led_ring_boot_anim(void);
/** Every second LED briefly blue (join / reconnect). */
void led_ring_connected_anim(void);
#endif
+60
View File
@@ -0,0 +1,60 @@
#include "ota_confirm.h"
#include "esp_log.h"
#include "esp_ota_ops.h"
#include "esp_partition.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
static const char *TAG = "[OTA_CONFIRM]";
/** Long enough for early crash loops (e.g. assert in a worker task) to reboot
* before we cancel rollback; short enough that a follow-up OTA is not blocked
* for long (esp_ota_begin fails while PENDING_VERIFY). */
#define OTA_CONFIRM_DELAY_MS 8000u
static void ota_confirm_task(void *param) {
(void)param;
vTaskDelay(pdMS_TO_TICKS(OTA_CONFIRM_DELAY_MS));
const esp_partition_t *running = esp_ota_get_running_partition();
if (running == NULL) {
ESP_LOGW(TAG, "no running partition");
vTaskDelete(NULL);
return;
}
esp_ota_img_states_t state = ESP_OTA_IMG_UNDEFINED;
esp_err_t err = esp_ota_get_state_partition(running, &state);
if (err != ESP_OK) {
/* otadata missing / non-OTA slot — nothing to confirm. */
ESP_LOGI(TAG, "ota state unavailable (%s) — skip confirm",
esp_err_to_name(err));
vTaskDelete(NULL);
return;
}
if (state != ESP_OTA_IMG_PENDING_VERIFY) {
ESP_LOGI(TAG, "running %s state=%d — no pending verify", running->label,
(int)state);
vTaskDelete(NULL);
return;
}
err = esp_ota_mark_app_valid_cancel_rollback();
if (err != ESP_OK) {
ESP_LOGE(TAG, "mark_app_valid failed: %s", esp_err_to_name(err));
} else {
ESP_LOGI(TAG, "app confirmed valid (%s) — rollback cancelled",
running->label);
}
vTaskDelete(NULL);
}
void ota_confirm_schedule(void) {
if (xTaskCreate(ota_confirm_task, "ota_confirm", 3072, NULL, 3, NULL) !=
pdPASS) {
ESP_LOGE(TAG, "failed to create confirm task — attempting immediate mark");
(void)esp_ota_mark_app_valid_cancel_rollback();
}
}
+14
View File
@@ -0,0 +1,14 @@
#ifndef OTA_CONFIRM_H
#define OTA_CONFIRM_H
/**
* After OTA with BOOTLOADER_APP_ROLLBACK_ENABLE, the new image boots as
* ESP_OTA_IMG_PENDING_VERIFY. If we crash/reset before confirming, the
* bootloader rolls back to the previous app on the next boot.
*
* Call once from app_main after init: schedules a delayed confirm so early
* panics (task asserts, etc.) still trigger rollback.
*/
void ota_confirm_schedule(void);
#endif
+237 -38
View File
@@ -10,6 +10,7 @@
#include "freertos/event_groups.h"
#include "freertos/idf_additions.h"
#include "ota_uart.h"
#include "pod_reboot.h"
#include <string.h>
static const char *TAG = "[OTA_ESPNOW]";
@@ -18,9 +19,9 @@ static const char *TAG = "[OTA_ESPNOW]";
#define OTA_ESPNOW_PREPARE_PRIO 5
#define OTA_PREPARE_TIMEOUT_MS 120000u
#define OTA_BLOCK_TIMEOUT_MS 30000u
#define OTA_BLOCK_TIMEOUT_PER_SLAVE_MS 2000u
#define OTA_BLOCK_MAX_RETRIES 2u
#define OTA_END_TIMEOUT_MS 60000u
#define OTA_PAYLOAD_DELAY_MS 3
#define OTA_ST_PREPARING 1u
#define OTA_ST_READY 2u
@@ -28,6 +29,12 @@ static const char *TAG = "[OTA_ESPNOW]";
#define OTA_ST_SUCCESS 4u
#define OTA_ST_FAILED 5u
/**
* Event-group fail bit. FreeRTOS reserves the top 8 bits (assert if used);
* targets use bits 0..CLIENT_REGISTRY_MAX-1 (max 16).
*/
#define OTA_EG_FAIL_BIT (1u << 16)
/** ESP-NOW OTA receive on slave (blue progress bar). */
#define OTA_LED_ESPNOW_RX_R 0
#define OTA_LED_ESPNOW_RX_G 0
@@ -35,7 +42,8 @@ static const char *TAG = "[OTA_ESPNOW]";
#define OTA_MAX_TARGETS CLIENT_REGISTRY_MAX
#define OTA_SLAVE_WORK_QUEUE_LEN 12
/** ~21 payloads per 4 KiB block; headroom for bursts + status/end. */
#define OTA_SLAVE_WORK_QUEUE_LEN 32
#define OTA_SLAVE_WORK_STACK 8192
#define OTA_SLAVE_WORK_PRIO 5
@@ -57,6 +65,7 @@ typedef struct {
static EventGroupHandle_t s_eg;
static QueueHandle_t s_slave_work_queue;
static bool s_distribution_active;
static TaskHandle_t s_slave_prep_task;
typedef struct {
uint8_t count;
@@ -69,6 +78,9 @@ typedef struct {
static ota_dist_t s_dist;
/** Always-reserved flash block for ESP-NOW distribute (not on task stack). */
static uint8_t s_ota_block_buf[OTA_UART_FLASH_BLOCK_SIZE];
typedef struct {
uint32_t client_id;
uint32_t bytes_written;
@@ -163,14 +175,100 @@ static uint32_t all_target_bits(void) {
return (1u << s_dist.count) - 1u;
}
static bool any_target_failed(void) {
for (uint8_t i = 0; i < s_prog.count; i++) {
if (s_prog.entries[i].status == OTA_ST_FAILED) {
return true;
}
}
return false;
}
/**
* Wait until all want_bits are set, or OTA_EG_FAIL_BIT (slave FAILED).
* Returns true only when every target bit is set; false on fail or timeout.
*
* Wait only for *still missing* bits. Waiting on the full mask with "any bit"
* busy-spins after the first slave ACK (already-set bits make WaitBits return
* immediately) and starves IDLE → task_wdt.
*/
static bool wait_target_bits(uint32_t want_bits, uint32_t timeout_ms) {
if (s_eg == NULL || want_bits == 0) {
return false;
}
EventBits_t got =
xEventGroupWaitBits(s_eg, want_bits, pdTRUE, pdTRUE,
pdMS_TO_TICKS(timeout_ms));
return (got & want_bits) == want_bits;
const TickType_t start = xTaskGetTickCount();
const TickType_t timeout_ticks = pdMS_TO_TICKS(timeout_ms);
while (true) {
TickType_t elapsed = xTaskGetTickCount() - start;
if (elapsed >= timeout_ticks) {
return false;
}
TickType_t remain = timeout_ticks - elapsed;
EventBits_t have = xEventGroupGetBits(s_eg);
if (have & OTA_EG_FAIL_BIT) {
xEventGroupClearBits(s_eg, OTA_EG_FAIL_BIT);
return false;
}
if ((have & want_bits) == want_bits) {
xEventGroupClearBits(s_eg, want_bits);
return true;
}
EventBits_t missing = want_bits & ~have;
(void)xEventGroupWaitBits(s_eg, missing | OTA_EG_FAIL_BIT, pdFALSE,
pdFALSE, remain);
}
}
static uint32_t block_ack_timeout_ms(void) {
if (s_dist.count == 0) {
return OTA_BLOCK_TIMEOUT_PER_SLAVE_MS;
}
return (uint32_t)s_dist.count * OTA_BLOCK_TIMEOUT_PER_SLAVE_MS;
}
static void log_missing_block_acks(uint32_t expected_bytes) {
if (s_eg == NULL || s_dist.count == 0) {
return;
}
EventBits_t bits = xEventGroupGetBits(s_eg);
for (uint8_t i = 0; i < s_dist.count; i++) {
uint32_t bit = (1u << (unsigned)i);
if (bits & bit) {
continue;
}
const ota_prog_entry_t *e = &s_prog.entries[i];
ESP_LOGE(TAG,
"slave %lu missing block ack @%lu (last status=%lu bytes=%lu err=%lu)",
(unsigned long)s_dist.id[i], (unsigned long)expected_bytes,
(unsigned long)e->status, (unsigned long)e->bytes_written,
(unsigned long)e->error);
}
}
static esp_err_t send_block_payloads(const uint8_t *block_buf, uint32_t block_len,
uint32_t *seq_io) {
uint32_t sent = 0;
while (sent < block_len) {
uint32_t chunk = block_len - sent;
if (chunk > OTA_UART_HOST_CHUNK_SIZE) {
chunk = OTA_UART_HOST_CHUNK_SIZE;
}
for (uint8_t i = 0; i < s_dist.count; i++) {
esp_err_t err = esp_now_comm_send_ota_payload(s_dist.mac[i], *seq_io,
block_buf + sent, chunk);
if (err != ESP_OK) {
return err;
}
}
(*seq_io)++;
sent += chunk;
}
return ESP_OK;
}
bool ota_espnow_distribution_active(void) { return s_distribution_active; }
@@ -221,8 +319,21 @@ static void process_slave_payload(const uint8_t master_mac[6],
ESP_LOGI(TAG, "ESP-NOW OTA payloads started");
}
ota_feed_result_t r =
ota_uart_feed(payload->data.bytes, payload->data.size);
ota_feed_result_t r = ota_uart_feed_chunk(payload->seq, payload->data.bytes,
payload->data.size);
/* Gap: keep session; master block-retries from the last ACK boundary. */
if (r == OTA_FEED_SEQ_GAP) {
ESP_LOGW(TAG, "ESP-NOW OTA seq gap (got %lu) — waiting for retry",
(unsigned long)payload->seq);
return;
}
if (r == OTA_FEED_SEQ_DUP) {
if (ota_uart_block_ready_for_reack()) {
send_slave_status(master_mac, OTA_ST_BLOCK_ACK, ota_uart_bytes_written(),
0);
}
return;
}
if (r == OTA_FEED_ERROR) {
led_ring_ota_failed();
send_slave_status(master_mac, OTA_ST_FAILED, ota_uart_bytes_written(), 13);
@@ -268,6 +379,9 @@ static void process_slave_end(const uint8_t master_mac[6]) {
led_ring_ota_success();
ESP_LOGI(TAG, "slave OTA success (%lu bytes), reboot to run",
(unsigned long)written);
/* Boot partition already set in ota_uart_finish(true); must reboot or we
* keep running the old image while the master expects the new one. */
pod_schedule_restart();
}
static void ota_slave_work_task(void *param) {
@@ -311,11 +425,21 @@ void ota_espnow_slave_init(void) {
}
}
static void drain_slave_work_queue(void) {
if (s_slave_work_queue == NULL) {
return;
}
ota_slave_work_t junk;
while (xQueueReceive(s_slave_work_queue, &junk, 0) == pdTRUE) {
}
}
static void ota_slave_prepare_task(void *param) {
uint32_t total_size = (uint32_t)(uintptr_t)param;
uint8_t master_mac[6];
if (!esp_now_comm_get_master_mac(master_mac)) {
s_slave_prep_task = NULL;
vTaskDelete(NULL);
return;
}
@@ -325,6 +449,7 @@ static void ota_slave_prepare_task(void *param) {
int slot = ota_uart_prepare(total_size);
if (slot < 0) {
send_slave_status(master_mac, OTA_ST_FAILED, 0, 1);
s_slave_prep_task = NULL;
vTaskDelete(NULL);
return;
}
@@ -332,6 +457,7 @@ static void ota_slave_prepare_task(void *param) {
send_slave_status(master_mac, OTA_ST_READY, 0, 0);
led_ring_show_ota_progress(0, total_size, OTA_LED_ESPNOW_RX_R, OTA_LED_ESPNOW_RX_G,
OTA_LED_ESPNOW_RX_B);
s_slave_prep_task = NULL;
vTaskDelete(NULL);
}
@@ -343,14 +469,31 @@ void ota_espnow_slave_on_start(const uint8_t master_mac[6],
ESP_LOGI(TAG, "ESP-NOW OTA_START (%lu bytes)", (unsigned long)start->total_size);
if (ota_uart_is_active()) {
queue_slave_status(master_mac, OTA_ST_FAILED, 0, 4);
/* Duplicate START while prepare task still running — ignore. */
if (s_slave_prep_task != NULL) {
ESP_LOGW(TAG, "OTA prepare already in flight — ignore START");
return;
}
if (ota_uart_is_active()) {
/* Same image, nothing written yet: sticky READY after master timeout /
* duplicate START — re-ACK instead of failing with err=4. */
if (ota_uart_total_size() == start->total_size &&
ota_uart_bytes_received() == 0) {
ESP_LOGI(TAG, "OTA already prepared for %lu bytes — re-send READY",
(unsigned long)start->total_size);
queue_slave_status(master_mac, OTA_ST_READY, 0, 0);
return;
}
ESP_LOGW(TAG, "aborting sticky OTA session for new START");
ota_uart_abort();
drain_slave_work_queue();
}
if (xTaskCreate(ota_slave_prepare_task, "ota_esp_prep", OTA_ESPNOW_PREPARE_STACK,
(void *)(uintptr_t)start->total_size, OTA_ESPNOW_PREPARE_PRIO,
NULL) != pdPASS) {
&s_slave_prep_task) != pdPASS) {
s_slave_prep_task = NULL;
queue_slave_status(master_mac, OTA_ST_FAILED, 0, 5);
}
}
@@ -418,6 +561,8 @@ void ota_espnow_master_on_status(const uint8_t slave_mac[6],
status->error);
ESP_LOGW(TAG, "slave %lu OTA failed (err=%lu)",
(unsigned long)s_dist.id[idx], (unsigned long)status->error);
/* Wake wait_target_bits so distribute aborts instead of waiting for READY. */
xEventGroupSetBits(s_eg, OTA_EG_FAIL_BIT);
break;
default:
break;
@@ -466,7 +611,7 @@ static esp_err_t distribute_image(const esp_partition_t *partition,
uint32_t target_mask = all_target_bits();
esp_err_t err;
xEventGroupClearBits(s_eg, target_mask);
xEventGroupClearBits(s_eg, target_mask | OTA_EG_FAIL_BIT);
for (uint8_t i = 0; i < s_dist.count; i++) {
err = esp_now_comm_send_ota_start(s_dist.mac[i], size);
if (err != ESP_OK) {
@@ -479,6 +624,12 @@ static esp_err_t distribute_image(const esp_partition_t *partition,
}
if (!wait_target_bits(target_mask, OTA_PREPARE_TIMEOUT_MS)) {
if (any_target_failed()) {
ESP_LOGE(TAG, "slave OTA failed during prepare");
prog_end();
s_distribution_active = false;
return ESP_FAIL;
}
ESP_LOGE(TAG, "timeout waiting for slave OTA ready");
prog_end();
s_distribution_active = false;
@@ -490,7 +641,6 @@ static esp_err_t distribute_image(const esp_partition_t *partition,
s_dist.progress.aggregate(0, size, s_dist.count);
}
uint8_t block_buf[OTA_UART_FLASH_BLOCK_SIZE];
uint32_t offset = 0;
uint32_t seq = 0;
@@ -500,7 +650,7 @@ static esp_err_t distribute_image(const esp_partition_t *partition,
block_len = OTA_UART_FLASH_BLOCK_SIZE;
}
err = esp_partition_read(partition, offset, block_buf, block_len);
err = esp_partition_read(partition, offset, s_ota_block_buf, block_len);
if (err != ESP_OK) {
ESP_LOGE(TAG, "partition read @%lu failed: %s", (unsigned long)offset,
esp_err_to_name(err));
@@ -509,35 +659,78 @@ static esp_err_t distribute_image(const esp_partition_t *partition,
return err;
}
uint32_t sent = 0;
while (sent < block_len) {
uint32_t chunk = block_len - sent;
if (chunk > OTA_UART_HOST_CHUNK_SIZE) {
chunk = OTA_UART_HOST_CHUNK_SIZE;
}
const bool full_block = (block_len >= OTA_UART_FLASH_BLOCK_SIZE);
s_dist.expected_bytes = offset + block_len;
const uint32_t block_start_seq = seq;
for (uint8_t i = 0; i < s_dist.count; i++) {
err = esp_now_comm_send_ota_payload(s_dist.mac[i], seq,
block_buf + sent, chunk);
if (full_block) {
xEventGroupClearBits(s_eg, target_mask);
}
bool block_sent = false;
for (uint32_t send_attempt = 0; send_attempt <= OTA_BLOCK_MAX_RETRIES;
send_attempt++) {
if (send_attempt > 0) {
seq = block_start_seq;
if (full_block) {
xEventGroupClearBits(s_eg, target_mask);
}
ESP_LOGW(TAG, "block send failed @%lu — resend %lu/%lu",
(unsigned long)s_dist.expected_bytes,
(unsigned long)send_attempt,
(unsigned long)OTA_BLOCK_MAX_RETRIES);
}
err = send_block_payloads(s_ota_block_buf, block_len, &seq);
if (err == ESP_OK) {
block_sent = true;
break;
}
}
if (!block_sent) {
ESP_LOGE(TAG, "block send failed @%lu after %lu retries",
(unsigned long)s_dist.expected_bytes,
(unsigned long)OTA_BLOCK_MAX_RETRIES);
prog_end();
s_distribution_active = false;
return err;
}
if (full_block) {
const uint32_t ack_timeout = block_ack_timeout_ms();
bool acked = false;
for (uint32_t attempt = 0; attempt <= OTA_BLOCK_MAX_RETRIES; attempt++) {
if (wait_target_bits(target_mask, ack_timeout)) {
acked = true;
break;
}
if (any_target_failed()) {
ESP_LOGE(TAG, "slave OTA failed during block @%lu",
(unsigned long)s_dist.expected_bytes);
prog_end();
s_distribution_active = false;
return ESP_FAIL;
}
log_missing_block_acks(s_dist.expected_bytes);
if (attempt >= OTA_BLOCK_MAX_RETRIES) {
break;
}
ESP_LOGW(TAG, "block ack timeout @%lu — resend %lu/%lu",
(unsigned long)s_dist.expected_bytes,
(unsigned long)(attempt + 1),
(unsigned long)OTA_BLOCK_MAX_RETRIES);
xEventGroupClearBits(s_eg, target_mask | OTA_EG_FAIL_BIT);
seq = block_start_seq;
err = send_block_payloads(s_ota_block_buf, block_len, &seq);
if (err != ESP_OK) {
prog_end();
s_distribution_active = false;
return err;
}
}
seq++;
sent += chunk;
vTaskDelay(pdMS_TO_TICKS(OTA_PAYLOAD_DELAY_MS));
}
const bool full_block = (block_len >= OTA_UART_FLASH_BLOCK_SIZE);
s_dist.expected_bytes = offset + block_len;
if (full_block) {
xEventGroupClearBits(s_eg, target_mask);
if (!wait_target_bits(target_mask, OTA_BLOCK_TIMEOUT_MS)) {
ESP_LOGE(TAG, "timeout block ack @%lu bytes",
(unsigned long)s_dist.expected_bytes);
if (!acked) {
ESP_LOGE(TAG, "timeout block ack @%lu bytes after %lu retries",
(unsigned long)s_dist.expected_bytes,
(unsigned long)OTA_BLOCK_MAX_RETRIES);
prog_end();
s_distribution_active = false;
return ESP_ERR_TIMEOUT;
@@ -556,7 +749,7 @@ static esp_err_t distribute_image(const esp_partition_t *partition,
}
}
xEventGroupClearBits(s_eg, target_mask);
xEventGroupClearBits(s_eg, target_mask | OTA_EG_FAIL_BIT);
for (uint8_t i = 0; i < s_dist.count; i++) {
err = esp_now_comm_send_ota_end(s_dist.mac[i]);
if (err != ESP_OK) {
@@ -567,6 +760,12 @@ static esp_err_t distribute_image(const esp_partition_t *partition,
}
if (!wait_target_bits(target_mask, OTA_END_TIMEOUT_MS)) {
if (any_target_failed()) {
ESP_LOGE(TAG, "slave OTA failed during end");
prog_end();
s_distribution_active = false;
return ESP_FAIL;
}
ESP_LOGE(TAG, "timeout waiting for slave OTA success");
prog_end();
s_distribution_active = false;
+36 -2
View File
@@ -12,6 +12,7 @@ typedef struct {
uint32_t total_size;
uint32_t received;
uint32_t written;
uint32_t expected_seq;
int target_slot;
uint8_t block_buf[OTA_UART_FLASH_BLOCK_SIZE];
size_t block_len;
@@ -96,7 +97,13 @@ int ota_uart_prepare(uint32_t total_size) {
esp_ota_handle_t handle;
esp_err_t err = esp_ota_begin(update_partition, OTA_SIZE_UNKNOWN, &handle);
if (err != ESP_OK) {
ESP_LOGE(TAG, "esp_ota_begin failed: %s", esp_err_to_name(err));
if (err == ESP_ERR_OTA_ROLLBACK_INVALID_STATE) {
ESP_LOGE(TAG,
"esp_ota_begin blocked: running app still PENDING_VERIFY "
"(wait for ota_confirm ~8 s after boot, or let rollback finish)");
} else {
ESP_LOGE(TAG, "esp_ota_begin failed: %s", esp_err_to_name(err));
}
return -1;
}
@@ -112,10 +119,30 @@ int ota_uart_prepare(uint32_t total_size) {
return s_ota.target_slot;
}
ota_feed_result_t ota_uart_feed(const uint8_t *data, size_t len) {
bool ota_uart_block_ready_for_reack(void) {
if (!s_ota.active) {
return false;
}
return s_ota.written > 0 &&
(s_ota.written % OTA_UART_FLASH_BLOCK_SIZE) == 0 &&
s_ota.block_len == 0;
}
ota_feed_result_t ota_uart_feed_chunk(uint32_t seq, const uint8_t *data,
size_t len) {
if (!s_ota.active || data == NULL || len == 0) {
return OTA_FEED_ERROR;
}
if (seq < s_ota.expected_seq) {
return OTA_FEED_SEQ_DUP;
}
if (seq > s_ota.expected_seq) {
ESP_LOGW(TAG, "seq gap: got %lu expected %lu", (unsigned long)seq,
(unsigned long)s_ota.expected_seq);
return OTA_FEED_SEQ_GAP;
}
s_ota.expected_seq++;
if (len > OTA_UART_HOST_CHUNK_SIZE) {
ESP_LOGW(TAG, "chunk %u > %u, truncating", (unsigned)len,
OTA_UART_HOST_CHUNK_SIZE);
@@ -200,6 +227,13 @@ esp_err_t ota_uart_finish(bool set_boot, bool *success_out) {
return err;
}
if (s_ota.total_size > 0 && s_ota.received != s_ota.total_size) {
ESP_LOGE(TAG, "size mismatch: received=%lu expected=%lu",
(unsigned long)s_ota.received, (unsigned long)s_ota.total_size);
ota_uart_abort();
return ESP_ERR_INVALID_SIZE;
}
err = esp_ota_end(s_ota.handle);
if (err != ESP_OK) {
ESP_LOGE(TAG, "esp_ota_end failed: %s", esp_err_to_name(err));
+12 -2
View File
@@ -28,6 +28,8 @@ typedef enum {
typedef enum {
OTA_FEED_OK = 0,
OTA_FEED_BLOCK_WRITTEN,
OTA_FEED_SEQ_DUP,
OTA_FEED_SEQ_GAP,
OTA_FEED_ERROR,
} ota_feed_result_t;
@@ -41,8 +43,16 @@ int ota_uart_prepare(uint32_t total_size);
void ota_uart_abort(void);
/** Append up to 200 bytes; flushes 4 KiB blocks to flash when full. */
ota_feed_result_t ota_uart_feed(const uint8_t *data, size_t len);
/**
* Append up to 200 bytes with strict seq checking (0, 1, 2, …).
* Duplicates (seq < expected) return OTA_FEED_SEQ_DUP.
* Gaps (seq > expected) return OTA_FEED_SEQ_GAP without consuming — caller
* should keep the session so the host can retry the current flash block.
*/
ota_feed_result_t ota_uart_feed_chunk(uint32_t seq, const uint8_t *data, size_t len);
/** True when a full 4 KiB block is in flash (used to re-ACK host block retries). */
bool ota_uart_block_ready_for_reack(void);
uint32_t ota_uart_bytes_written(void);
+494
View File
@@ -3,12 +3,51 @@
#include "esp_log.h"
#include "nvs.h"
#include "nvs_flash.h"
#include <string.h>
static const char *TAG = "[SETTINGS]";
static const char *NS = "powerpod";
static const char *KEY_ACCEL_DZ = "accel_dz";
static const char *KEY_UV_LATCH = "uv_latch";
static const char *KEY_UV_EN = "uv_en";
static const char *KEY_TAP_CFG = "tap_cfg";
static const char *KEY_ID_MAP = "id_map";
static const char *KEY_ID_BIND = "id_bind";
#define ACCEL_DEADZONE_MAX 4095u
#define TAP_CFG_NVS_VERSION 1u
#define ID_MAP_NVS_VERSION 1u
#define ID_BIND_NVS_VERSION 1u
typedef struct __attribute__((packed)) {
uint8_t mac[POD_ID_MAC_LEN];
uint8_t id;
} id_map_entry_nvs_t;
typedef struct __attribute__((packed)) {
uint8_t version;
uint8_t next_id;
uint8_t count;
id_map_entry_nvs_t entries[POD_ID_MAP_MAX];
} id_map_nvs_t;
typedef struct __attribute__((packed)) {
uint8_t version;
uint8_t master_mac[POD_ID_MAC_LEN];
uint8_t assigned_id;
} id_bind_nvs_t;
/** Packed layout for NVS blob (stable across rebuilds). */
typedef struct __attribute__((packed)) {
uint8_t version;
uint16_t tap_sens_thres;
uint16_t max_gest_dur;
uint16_t tap_shock_dur;
uint16_t quite_time_after_gest;
uint16_t wait_for_timeout;
uint16_t axis_sel;
uint8_t enable_flags; /* bit0=single, bit1=double, bit2=triple */
} tap_config_nvs_t;
static bool s_nvs_ready;
@@ -108,3 +147,458 @@ void pod_settings_apply_accel_deadzone(void) {
bma456_set_accel_deadzone(deadzone);
}
}
static void tap_config_to_nvs(const bma456_tap_config_t *cfg,
tap_config_nvs_t *out) {
memset(out, 0, sizeof(*out));
out->version = TAP_CFG_NVS_VERSION;
out->tap_sens_thres = cfg->tap_sens_thres;
out->max_gest_dur = cfg->max_gest_dur;
out->tap_shock_dur = cfg->tap_shock_dur;
out->quite_time_after_gest = cfg->quite_time_after_gest;
out->wait_for_timeout = cfg->wait_for_timeout;
out->axis_sel = cfg->axis_sel;
if (cfg->enable_single) {
out->enable_flags |= 0x01u;
}
if (cfg->enable_double) {
out->enable_flags |= 0x02u;
}
if (cfg->enable_triple) {
out->enable_flags |= 0x04u;
}
}
static bool tap_config_from_nvs(const tap_config_nvs_t *in,
bma456_tap_config_t *cfg) {
if (in->version != TAP_CFG_NVS_VERSION) {
return false;
}
cfg->tap_sens_thres = in->tap_sens_thres;
cfg->max_gest_dur = in->max_gest_dur;
cfg->tap_shock_dur = in->tap_shock_dur;
cfg->quite_time_after_gest = in->quite_time_after_gest;
cfg->wait_for_timeout = in->wait_for_timeout;
cfg->axis_sel = in->axis_sel;
cfg->enable_single = (in->enable_flags & 0x01u) != 0;
cfg->enable_double = (in->enable_flags & 0x02u) != 0;
cfg->enable_triple = (in->enable_flags & 0x04u) != 0;
return bma456_tap_config_valid(cfg);
}
bool pod_settings_load_tap_config(bma456_tap_config_t *cfg_out) {
static const bma456_tap_config_t k_default = BMA456_TAP_CONFIG_DEFAULT;
if (cfg_out == NULL) {
return false;
}
*cfg_out = k_default;
if (!s_nvs_ready) {
return false;
}
nvs_handle_t handle;
esp_err_t err = nvs_open(NS, NVS_READONLY, &handle);
if (err != ESP_OK) {
return false;
}
tap_config_nvs_t blob = {0};
size_t len = sizeof(blob);
err = nvs_get_blob(handle, KEY_TAP_CFG, &blob, &len);
nvs_close(handle);
if (err == ESP_ERR_NVS_NOT_FOUND) {
return false;
}
if (err != ESP_OK || len != sizeof(blob)) {
ESP_LOGW(TAG, "read tap_cfg failed: %s (len=%u)", esp_err_to_name(err),
(unsigned)len);
return false;
}
if (!tap_config_from_nvs(&blob, cfg_out)) {
ESP_LOGW(TAG, "tap_cfg blob invalid; using defaults");
*cfg_out = k_default;
return false;
}
return true;
}
esp_err_t pod_settings_save_tap_config(const bma456_tap_config_t *cfg) {
if (!bma456_tap_config_valid(cfg)) {
return ESP_ERR_INVALID_ARG;
}
esp_err_t err = ensure_nvs();
if (err != ESP_OK) {
return err;
}
nvs_handle_t handle;
err = nvs_open(NS, NVS_READWRITE, &handle);
if (err != ESP_OK) {
ESP_LOGE(TAG, "nvs_open failed: %s", esp_err_to_name(err));
return err;
}
tap_config_nvs_t blob;
tap_config_to_nvs(cfg, &blob);
err = nvs_set_blob(handle, KEY_TAP_CFG, &blob, sizeof(blob));
if (err == ESP_OK) {
err = nvs_commit(handle);
}
nvs_close(handle);
if (err != ESP_OK) {
ESP_LOGE(TAG, "save tap_cfg failed: %s", esp_err_to_name(err));
return err;
}
ESP_LOGI(TAG,
"saved tap config sens=%u max_gest=%u shock=%u quiet=%u wait=%u "
"axis=%u (S=%d D=%d T=%d)",
(unsigned)cfg->tap_sens_thres, (unsigned)cfg->max_gest_dur,
(unsigned)cfg->tap_shock_dur, (unsigned)cfg->quite_time_after_gest,
(unsigned)cfg->wait_for_timeout, (unsigned)cfg->axis_sel,
cfg->enable_single, cfg->enable_double, cfg->enable_triple);
return ESP_OK;
}
void pod_settings_apply_tap_config(void) {
bma456_tap_config_t cfg;
if (!pod_settings_load_tap_config(&cfg)) {
return;
}
if (!bma456_is_ready()) {
return;
}
if (bma456_set_tap_config(&cfg) != ESP_OK) {
ESP_LOGW(TAG, "failed to apply NVS tap config");
}
}
bool pod_settings_is_uv_latched(void) {
if (!s_nvs_ready) {
return false;
}
nvs_handle_t handle;
esp_err_t err = nvs_open(NS, NVS_READONLY, &handle);
if (err != ESP_OK) {
return false;
}
uint8_t value = 0;
err = nvs_get_u8(handle, KEY_UV_LATCH, &value);
nvs_close(handle);
return err == ESP_OK && value != 0;
}
esp_err_t pod_settings_set_uv_latched(bool latched) {
esp_err_t err = ensure_nvs();
if (err != ESP_OK) {
return err;
}
nvs_handle_t handle;
err = nvs_open(NS, NVS_READWRITE, &handle);
if (err != ESP_OK) {
ESP_LOGE(TAG, "nvs_open failed: %s", esp_err_to_name(err));
return err;
}
err = nvs_set_u8(handle, KEY_UV_LATCH, latched ? 1u : 0u);
if (err == ESP_OK) {
err = nvs_commit(handle);
}
nvs_close(handle);
if (err != ESP_OK) {
ESP_LOGE(TAG, "save uv_latch failed: %s", esp_err_to_name(err));
return err;
}
ESP_LOGI(TAG, "UV latch %s", latched ? "set" : "cleared");
return ESP_OK;
}
esp_err_t pod_settings_clear_uv_latched(void) {
return pod_settings_set_uv_latched(false);
}
bool pod_settings_is_uv_enabled(void) {
if (!s_nvs_ready) {
return false;
}
nvs_handle_t handle;
esp_err_t err = nvs_open(NS, NVS_READONLY, &handle);
if (err != ESP_OK) {
return false;
}
uint8_t value = 0;
err = nvs_get_u8(handle, KEY_UV_EN, &value);
nvs_close(handle);
if (err == ESP_ERR_NVS_NOT_FOUND) {
return false;
}
if (err != ESP_OK) {
return false;
}
return value != 0;
}
esp_err_t pod_settings_set_uv_enabled(bool enabled) {
esp_err_t err = ensure_nvs();
if (err != ESP_OK) {
return err;
}
nvs_handle_t handle;
err = nvs_open(NS, NVS_READWRITE, &handle);
if (err != ESP_OK) {
ESP_LOGE(TAG, "nvs_open failed: %s", esp_err_to_name(err));
return err;
}
err = nvs_set_u8(handle, KEY_UV_EN, enabled ? 1u : 0u);
if (err == ESP_OK) {
err = nvs_commit(handle);
}
nvs_close(handle);
if (err != ESP_OK) {
ESP_LOGE(TAG, "save uv_en failed: %s", esp_err_to_name(err));
return err;
}
ESP_LOGI(TAG, "UV protection %s", enabled ? "enabled" : "disabled");
return ESP_OK;
}
esp_err_t pod_settings_load_id_map(pod_id_map_t *out) {
if (out == NULL) {
return ESP_ERR_INVALID_ARG;
}
memset(out, 0, sizeof(*out));
out->next_id = (uint8_t)POD_CLIENT_ID_MIN;
if (!s_nvs_ready) {
return ESP_OK;
}
nvs_handle_t handle;
esp_err_t err = nvs_open(NS, NVS_READONLY, &handle);
if (err != ESP_OK) {
return ESP_OK;
}
id_map_nvs_t blob;
memset(&blob, 0, sizeof(blob));
size_t len = sizeof(blob);
err = nvs_get_blob(handle, KEY_ID_MAP, &blob, &len);
nvs_close(handle);
if (err == ESP_ERR_NVS_NOT_FOUND) {
return ESP_OK;
}
if (err != ESP_OK || len < 3 || blob.version != ID_MAP_NVS_VERSION) {
ESP_LOGW(TAG, "read id_map failed: %s (len=%u)", esp_err_to_name(err),
(unsigned)len);
return ESP_OK;
}
if (blob.next_id < POD_CLIENT_ID_MIN) {
blob.next_id = (uint8_t)POD_CLIENT_ID_MIN;
}
out->next_id = blob.next_id;
out->count = blob.count;
for (uint8_t i = 0; i < blob.count; i++) {
memcpy(out->entries[i].mac, blob.entries[i].mac, POD_ID_MAC_LEN);
out->entries[i].id = blob.entries[i].id;
}
ESP_LOGI(TAG, "loaded id_map count=%u next_id=%u", (unsigned)out->count,
(unsigned)out->next_id);
return ESP_OK;
}
esp_err_t pod_settings_save_id_map(const pod_id_map_t *map) {
if (map == NULL) {
return ESP_ERR_INVALID_ARG;
}
if (map->next_id < POD_CLIENT_ID_MIN) {
return ESP_ERR_INVALID_ARG;
}
esp_err_t err = ensure_nvs();
if (err != ESP_OK) {
return err;
}
id_map_nvs_t blob;
memset(&blob, 0, sizeof(blob));
blob.version = ID_MAP_NVS_VERSION;
blob.next_id = map->next_id;
blob.count = map->count;
for (uint8_t i = 0; i < map->count; i++) {
memcpy(blob.entries[i].mac, map->entries[i].mac, POD_ID_MAC_LEN);
blob.entries[i].id = map->entries[i].id;
}
nvs_handle_t handle;
err = nvs_open(NS, NVS_READWRITE, &handle);
if (err != ESP_OK) {
ESP_LOGE(TAG, "nvs_open failed: %s", esp_err_to_name(err));
return err;
}
err = nvs_set_blob(handle, KEY_ID_MAP, &blob, sizeof(blob));
if (err == ESP_OK) {
err = nvs_commit(handle);
}
nvs_close(handle);
if (err != ESP_OK) {
ESP_LOGE(TAG, "save id_map failed: %s", esp_err_to_name(err));
return err;
}
ESP_LOGI(TAG, "saved id_map count=%u next_id=%u", (unsigned)map->count,
(unsigned)map->next_id);
return ESP_OK;
}
esp_err_t pod_settings_clear_id_map(void) {
esp_err_t err = ensure_nvs();
if (err != ESP_OK) {
return err;
}
nvs_handle_t handle;
err = nvs_open(NS, NVS_READWRITE, &handle);
if (err != ESP_OK) {
ESP_LOGE(TAG, "nvs_open failed: %s", esp_err_to_name(err));
return err;
}
err = nvs_erase_key(handle, KEY_ID_MAP);
if (err == ESP_ERR_NVS_NOT_FOUND) {
err = ESP_OK;
}
if (err == ESP_OK) {
err = nvs_commit(handle);
}
nvs_close(handle);
if (err != ESP_OK) {
ESP_LOGE(TAG, "clear id_map failed: %s", esp_err_to_name(err));
return err;
}
ESP_LOGI(TAG, "cleared id_map");
return ESP_OK;
}
bool pod_settings_load_id_binding(uint8_t master_mac[POD_ID_MAC_LEN],
uint32_t *assigned_id_out) {
if (master_mac == NULL || assigned_id_out == NULL) {
return false;
}
memset(master_mac, 0, POD_ID_MAC_LEN);
*assigned_id_out = 0;
if (!s_nvs_ready) {
return false;
}
nvs_handle_t handle;
esp_err_t err = nvs_open(NS, NVS_READONLY, &handle);
if (err != ESP_OK) {
return false;
}
id_bind_nvs_t blob = {0};
size_t len = sizeof(blob);
err = nvs_get_blob(handle, KEY_ID_BIND, &blob, &len);
nvs_close(handle);
if (err != ESP_OK || len != sizeof(blob) ||
blob.version != ID_BIND_NVS_VERSION) {
return false;
}
if (blob.assigned_id < POD_CLIENT_ID_MIN) {
return false;
}
memcpy(master_mac, blob.master_mac, POD_ID_MAC_LEN);
*assigned_id_out = blob.assigned_id;
return true;
}
esp_err_t pod_settings_save_id_binding(const uint8_t master_mac[POD_ID_MAC_LEN],
uint32_t assigned_id) {
if (master_mac == NULL || assigned_id < POD_CLIENT_ID_MIN ||
assigned_id > POD_CLIENT_ID_MAX) {
return ESP_ERR_INVALID_ARG;
}
esp_err_t err = ensure_nvs();
if (err != ESP_OK) {
return err;
}
id_bind_nvs_t blob = {
.version = ID_BIND_NVS_VERSION,
.assigned_id = (uint8_t)assigned_id,
};
memcpy(blob.master_mac, master_mac, POD_ID_MAC_LEN);
nvs_handle_t handle;
err = nvs_open(NS, NVS_READWRITE, &handle);
if (err != ESP_OK) {
ESP_LOGE(TAG, "nvs_open failed: %s", esp_err_to_name(err));
return err;
}
err = nvs_set_blob(handle, KEY_ID_BIND, &blob, sizeof(blob));
if (err == ESP_OK) {
err = nvs_commit(handle);
}
nvs_close(handle);
if (err != ESP_OK) {
ESP_LOGE(TAG, "save id_bind failed: %s", esp_err_to_name(err));
return err;
}
ESP_LOGI(TAG, "saved id_bind id=%lu master=%02x:…:%02x",
(unsigned long)assigned_id, master_mac[0], master_mac[5]);
return ESP_OK;
}
esp_err_t pod_settings_clear_id_binding(void) {
esp_err_t err = ensure_nvs();
if (err != ESP_OK) {
return err;
}
nvs_handle_t handle;
err = nvs_open(NS, NVS_READWRITE, &handle);
if (err != ESP_OK) {
ESP_LOGE(TAG, "nvs_open failed: %s", esp_err_to_name(err));
return err;
}
err = nvs_erase_key(handle, KEY_ID_BIND);
if (err == ESP_ERR_NVS_NOT_FOUND) {
err = ESP_OK;
}
if (err == ESP_OK) {
err = nvs_commit(handle);
}
nvs_close(handle);
if (err != ESP_OK) {
ESP_LOGE(TAG, "clear id_bind failed: %s", esp_err_to_name(err));
return err;
}
ESP_LOGI(TAG, "cleared id_bind");
return ESP_OK;
}
+58
View File
@@ -1,7 +1,9 @@
#ifndef POD_SETTINGS_H
#define POD_SETTINGS_H
#include "bosch456.h"
#include "esp_err.h"
#include <stdbool.h>
#include <stdint.h>
/** Initialize NVS (idempotent) and log stored settings. Call once early in app_main. */
@@ -16,4 +18,60 @@ uint32_t pod_settings_load_accel_deadzone(void);
/** Apply NVS deadzone to BMA456 when the sensor is present. */
void pod_settings_apply_accel_deadzone(void);
/** Persist local BMA456 tap detection config. */
esp_err_t pod_settings_save_tap_config(const bma456_tap_config_t *cfg);
/**
* Load tap config from NVS into *cfg_out.
* Returns true when a valid blob was loaded; false → compile-time defaults.
*/
bool pod_settings_load_tap_config(bma456_tap_config_t *cfg_out);
/** Apply NVS tap config to BMA456 when the sensor is present. */
void pod_settings_apply_tap_config(void);
/** LiPo under-voltage latch persisted across reboot (software UV protection). */
bool pod_settings_is_uv_latched(void);
esp_err_t pod_settings_set_uv_latched(bool latched);
esp_err_t pod_settings_clear_uv_latched(void);
/**
* Runtime enable for software UV protection (NVS). Missing key → disabled.
* Independent of POWERPOD_BATTERY_UV_ENABLE compile gate.
*/
bool pod_settings_is_uv_enabled(void);
esp_err_t pod_settings_set_uv_enabled(bool enabled);
/** Logical slave ids: 1…POD_CLIENT_ID_MAX (0 reserved for local master). */
#define POD_CLIENT_ID_MIN 1u
#define POD_CLIENT_ID_MAX 255u
#define POD_ID_MAP_MAX 255u
#define POD_ID_MAC_LEN 6u
typedef struct {
uint8_t mac[POD_ID_MAC_LEN];
uint8_t id;
} pod_id_map_entry_t;
typedef struct {
uint8_t next_id; /**< Next candidate when allocating (1…255). */
uint8_t count;
pod_id_map_entry_t entries[POD_ID_MAP_MAX];
} pod_id_map_t;
/** Master: load MAC→id map from NVS (zeros *out if unset). */
esp_err_t pod_settings_load_id_map(pod_id_map_t *out);
esp_err_t pod_settings_save_id_map(const pod_id_map_t *map);
esp_err_t pod_settings_clear_id_map(void);
/**
* Slave: load persisted {master_mac, assigned_id}.
* Returns true when a valid binding was loaded.
*/
bool pod_settings_load_id_binding(uint8_t master_mac[POD_ID_MAC_LEN],
uint32_t *assigned_id_out);
esp_err_t pod_settings_save_id_binding(const uint8_t master_mac[POD_ID_MAC_LEN],
uint32_t assigned_id);
esp_err_t pod_settings_clear_id_binding(void);
#endif
+61 -9
View File
@@ -2,17 +2,24 @@
#include "cmd_handler.h"
#include "cmd_accel_deadzone.h"
#include "cmd_accel_stream.h"
#include "cmd_input_send_mode.h"
#include "cmd_tap_notify.h"
#include "cmd_tap_config.h"
#include "cmd_cache_status.h"
#include "cmd_espnow_unicast_test.h"
#include "cmd_espnow_echo_ping.h"
#include "cmd_espnow_find_me.h"
#include "cmd_restart.h"
#include "cmd_client_id_map_reset.h"
#include "cmd_client_info.h"
#include "cmd_version.h"
#include "cmd_ota.h"
#include "cmd_ota_slave_progress.h"
#include "ota_confirm.h"
#include "cmd_led_ring.h"
#include "cmd_battery.h"
#include "cmd_battery_uv.h"
#include "cmd_set_log_level.h"
#include "esp_now_comm.h"
#include "powerpod.h"
#include "driver/gpio.h"
@@ -29,7 +36,10 @@
#include "led_ring.h"
#include "pod_settings.h"
#include "uart.h"
#include "ble_host.h"
#include "battery_uv.h"
#include <stdint.h>
#include <string.h>
enum MASTER_STATES {
MSTATE_INIT,
@@ -76,11 +86,22 @@ void app_main(void) {
ESP_LOGW(TAG, "settings NVS init failed; using defaults");
}
if (board_input_init_adc_only() != ESP_OK) {
ESP_LOGW(TAG, "LiPo ADC init failed");
}
#if POWERPOD_BATTERY_UV_ENABLE
if (battery_uv_evaluate_boot()) {
battery_uv_run_mode();
}
#endif
// Get Master Mode Pin
gpio_reset_pin(DIP_MASTER);
gpio_set_direction(DIP_MASTER, GPIO_MODE_INPUT);
uint8_t master = gpio_get_level(DIP_MASTER) ? 0 : 1;
uint8_t network = 0;
uint8_t network = 1;
bool host_ble = false;
esp_err_t err;
@@ -123,12 +144,20 @@ void app_main(void) {
-1);
if (err != ESP_OK) {
ESP_LOGI(TAG, "Could not read IO Expander input %d", err);
} else {
/* High nibble physically reversed on PCB; DIP1..8 after reverse+invert. */
uint8_t bits = (uint8_t)(~reverse_high_nibble_lut(read_val));
/* DIP8 (bit7) = host transport: 1 = BLE, 0 = UART */
host_ble = (bits & 0x80u) != 0;
uint8_t net_bits = (uint8_t)(bits & 0x7Fu);
if (net_bits == 0 || (net_bits & (net_bits - 1u)) != 0) {
ESP_LOGW(TAG, "invalid network DIP one-hot=0x%02x; fallback network=1",
net_bits);
network = 1;
} else {
network = (uint8_t)(__builtin_ctz(net_bits) + 1); /* 1..7 */
}
}
// Build NetworkNumber because Pin 5-8 are reversed
network = reverse_high_nibble_lut(read_val);
network = ~network; // reverse bits
network = __builtin_ctz(network) +
1; // map network to 0-8, 0 is not a valid network
}
if (bus_handle != NULL) {
@@ -137,6 +166,7 @@ void app_main(void) {
ESP_LOGI(TAG, "BMA456 init skipped: %s", esp_err_to_name(bma_err));
} else {
pod_settings_apply_accel_deadzone();
pod_settings_apply_tap_config();
}
}
@@ -152,6 +182,7 @@ void app_main(void) {
app_config.master = (master != 0);
app_config.network = network;
app_config.host_ble = host_ble;
if (running != NULL) {
memcpy(app_config.running_partition, running->label,
sizeof(app_config.running_partition));
@@ -162,10 +193,13 @@ void app_main(void) {
ESP_LOGI(TAG, "RUNNING CONFIG:");
ESP_LOGI(TAG, "Master: %d", app_config.master);
ESP_LOGI(TAG, "Network: %d", app_config.network);
ESP_LOGI(TAG, "Host: %s (DIP8; reboot to switch)",
app_config.host_ble ? "BLE" : "UART");
ESP_LOGI(TAG, "Running Partition: %s (OTA slot %d)",
app_config.running_partition, ota_slot);
board_input_init();
board_input_start_lipo_monitor();
board_input_init_button();
err = esp_now_comm_init(&app_config);
if (err != ESP_OK) {
@@ -173,27 +207,45 @@ void app_main(void) {
}
led_ring_init();
led_ring_boot_anim();
if (app_config.master) {
cmd_queue = xQueueCreate(64, sizeof(generic_msg_t));
init_cmdHandler(cmd_queue);
init_uart(cmd_queue);
if (app_config.host_ble) {
err = init_ble_host(cmd_queue);
if (err != ESP_OK) {
ESP_LOGE(TAG, "BLE host init failed: %s", esp_err_to_name(err));
}
} else {
init_uart(cmd_queue);
}
cmd_version_register();
cmd_client_info_register();
cmd_accel_deadzone_register();
cmd_accel_stream_register();
cmd_input_send_mode_register();
cmd_tap_notify_register();
cmd_tap_config_register();
cmd_cache_status_register();
cmd_espnow_unicast_test_register();
cmd_espnow_echo_ping_register();
cmd_espnow_find_me_register();
cmd_restart_register();
cmd_client_id_map_reset_register();
cmd_led_ring_register();
cmd_battery_register();
cmd_battery_uv_register();
cmd_ota_register();
cmd_ota_slave_progress_register();
cmd_set_log_level_register();
}
ESP_LOGI(TAG, "LED ring: UART LED_RING commands only (no local demo loop)");
ESP_LOGI(TAG, "LED ring: boot anim + UART LED_RING commands");
/* After OTA: PENDING_VERIFY until confirm; crash before this → rollback. */
ota_confirm_schedule();
while (1) {
vTaskDelay(portMAX_DELAY);
}
+7 -3
View File
@@ -1,6 +1,11 @@
#ifndef POWERPOD_H
#define POWERPOD_H
/** 1 = software LiPo UV latch + energy-save mode; 0 = disabled (LED mode 6 still works). */
#ifndef POWERPOD_BATTERY_UV_ENABLE
#define POWERPOD_BATTERY_UV_ENABLE 1
#endif
#define DIP_MASTER 4
#define I2C_SCL 5
#define I2C_SDA 6
@@ -11,9 +16,8 @@
/** Front-panel button (active low, internal pull-up). */
#define TASTER_GPIO 12
/** LiPo voltage sense inputs (ADC1-capable GPIOs). */
/** LiPo voltage sense inputs (ADC-capable GPIOs; GPIO11 = ADC2 on ESP32-S3). */
#define V_LIPO_1_GPIO 1
/** Shares GPIO with TASTER on current bench wiring; second ADC is skipped in board_input.c. */
#define V_LIPO_2_GPIO 12
#define V_LIPO_2_GPIO 11
#endif

Some files were not shown because too many files have changed in this diff Show More