Compare commits

...
7 Commits
Author SHA1 Message Date
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
72 changed files with 4327 additions and 626 deletions
+6 -5
View File
@@ -109,7 +109,7 @@ sequenceDiagram
└─────────────────────────────────────────────────────────────┘ └─────────────────────────────────────────────────────────────┘
``` ```
**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. `gotool serve` verbindet UART oder BLE über Dashboard/REST/WS (`managedHost`); **OTA nur UART**. Bei BLE-Host und aktivem Accel-Stream pusht `ble_stream` periodisch `BLE_STREAM_TICK` (8–16 ms) mit `esp_timestamp_us` (Live-Pfad bevorzugt Tick statt CACHE_STATUS-Poll). **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. `gotool serve` verbindet UART oder BLE über Dashboard/REST/WS (`managedHost`); **OTA** über UART oder BLE (ATT MTU ≥ ~219). 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).
--- ---
@@ -315,7 +315,7 @@ Alle Master-Sends laufen über `send_message()` / `send_message_ex()`:
| Nachricht | Task / Trigger | Master-Ziel | | Nachricht | Task / Trigger | Master-Ziel |
|-----------|----------------|-------------| |-----------|----------------|-------------|
| `ESPNOW_ACCEL_SAMPLE` | `slave_accel_stream_task` 16 ms | `client_registry_update_accel()` | | `ESPNOW_ACCEL_SAMPLE` | `slave_accel_stream_task` 10 ms, 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_TAP_EVENT` | BMA456-IRQ → `on_bma456_tap` | `client_registry_update_tap()` |
| `ESPNOW_BATTERY_REPORT` | Heartbeat + nach Join | `client_registry_update_battery()` | | `ESPNOW_BATTERY_REPORT` | Heartbeat + nach Join | `client_registry_update_battery()` |
| `ESPNOW_SLAVE_INFO` / `HEARTBEAT` | `slave_tx_task` / Heartbeat-Task | `client_registry_heartbeat()` | | `ESPNOW_SLAVE_INFO` / `HEARTBEAT` | `slave_tx_task` / Heartbeat-Task | `client_registry_heartbeat()` |
@@ -325,7 +325,7 @@ Alle Master-Sends laufen über `send_message()` / `send_message_ex()`:
### 6.5 OTA über ESP-NOW ### 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). Gemeinsamer Flash-Puffer: `ota_uart.c` (4 KiB Blockgröße).
@@ -348,7 +348,7 @@ flowchart TB
|-------|-------------------|-------------------| |-------|-------------------|-------------------|
| Presence, Version | HEARTBEAT / SLAVE_INFO | `CLIENT_INFO` | | Presence, Version | HEARTBEAT / SLAVE_INFO | `CLIENT_INFO` |
| Accel-Stream-Samples | `ESPNOW_ACCEL_SAMPLE` | `CACHE_STATUS` | | 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` | | Batterie | `ESPNOW_BATTERY_REPORT` + Master-ADC | `BATTERY_STATUS` |
| Konfig-Flags | Handler + ESP-NOW | `CLIENT_INFO`, diverse GET | | Konfig-Flags | Handler + ESP-NOW | `CLIENT_INFO`, diverse GET |
@@ -366,7 +366,7 @@ flowchart TB
| `espnow_mon` | Master | 1 s Timeout + Master-Battery | | `espnow_mon` | Master | 1 s Timeout + Master-Battery |
| `espnow_stx` | Slave | Queue: SLAVE_INFO, Battery nach Join | | `espnow_stx` | Slave | Queue: SLAVE_INFO, Battery nach Join |
| `espnow_hb` | Slave | 1 s Heartbeat + 30 s Battery | | `espnow_hb` | Slave | 1 s Heartbeat + 30 s Battery |
| `espnow_accel` | Slave | 16 ms Accel wenn Stream an | | `espnow_accel` | Slave | 10 ms Accel in ID-Slot (1 ms) wenn Stream an |
| `bma456_poll` | Beide | 10 Hz (`bosch456.c`) | | `bma456_poll` | Beide | 10 Hz (`bosch456.c`) |
| `vTaskLedRing` | Beide | LED-Ring-Befehle | | `vTaskLedRing` | Beide | LED-Ring-Befehle |
@@ -391,6 +391,7 @@ flowchart TB
| LED | `main/led_ring.c` | 95 LEDs, Digit-Maps | | LED | `main/led_ring.c` | 95 LEDs, Digit-Maps |
| OTA UART | `main/ota_uart.c`, `cmd/cmd_ota.c` | A/B-Partition Upload | | 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 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 | | Einstellungen NVS | `main/pod_settings.c` | Accel-Deadzone persistent |
| Board | `main/board_input.c` | Taster, LiPo-ADC | | Board | `main/board_input.c` | Taster, LiPo-ADC |
| Protobuf Schemas | `main/proto/*.proto` | Vertrag UART / ESP-NOW | | Protobuf Schemas | `main/proto/*.proto` | Vertrag UART / ESP-NOW |
+22 -17
View File
@@ -55,7 +55,7 @@ Zielbild für den Host: Befehle an den Master senden; der Master steuert Slaves
| LiPo ADC 1 | 1 | `board_input.c` | | LiPo ADC 1 | 1 | `board_input.c` |
| LiPo ADC 2 | 11 | `board_input.c` | | LiPo ADC 2 | 11 | `board_input.c` |
**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 nur UART** (DIP8=0). Bei BLE + Accel-Stream: periodische `BLE_STREAM_TICK`-Notifies (`period_ms` 8–16) speisen Live-Accel im Dashboard/API. **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. **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.
@@ -177,7 +177,7 @@ UART_CMD_REQ(&uart_msg, alox_UartMessage_accel_deadzone_request_tag, accel_deadz
| `ESPNOW_HEARTBEAT` | S→M | `heartbeat` | Keepalive 1 s | | `ESPNOW_HEARTBEAT` | S→M | `heartbeat` | Keepalive 1 s |
| `ESPNOW_SET_ACCEL_DEADZONE` | M→S | `accel_deadzone` | Deadzone LSB | | `ESPNOW_SET_ACCEL_DEADZONE` | M→S | `accel_deadzone` | Deadzone LSB |
| `ESPNOW_SET_TAP_CONFIG` | M→S | `tap_config` | Multitap-Parameter + NVS | | `ESPNOW_SET_TAP_CONFIG` | M→S | `tap_config` | Multitap-Parameter + NVS |
| `ESPNOW_SET_ACCEL_STREAM` | M→S | `accel_stream` | Stream ~16 ms | | `ESPNOW_SET_ACCEL_STREAM` | M→S | `accel_stream` | Stream ~10 ms (best-effort) |
| `ESPNOW_ACCEL_SAMPLE` | S→M | `accel_sample` | x/y/z Roh-LSB | | `ESPNOW_ACCEL_SAMPLE` | S→M | `accel_sample` | x/y/z Roh-LSB |
| `ESPNOW_SET_TAP_NOTIFY` | M→S | `tap_notify` | Tap-Arten filtern | | `ESPNOW_SET_TAP_NOTIFY` | M→S | `tap_notify` | Tap-Arten filtern |
| `ESPNOW_TAP_EVENT` | S→M | `tap_event` | kind 1/2/3 | | `ESPNOW_TAP_EVENT` | S→M | `tap_event` | kind 1/2/3 |
@@ -199,7 +199,7 @@ UART_CMD_REQ(&uart_msg, alox_UartMessage_accel_deadzone_request_tag, accel_deadz
| Heartbeat | 1000 ms | | Heartbeat | 1000 ms |
| Client-Timeout (Master) | 3 × Heartbeat = 3 s → `available=false` | | Client-Timeout (Master) | 3 × Heartbeat = 3 s → `available=false` |
| Master-Verlust (Slave) | 5 s ohne Discover | | Master-Verlust (Slave) | 5 s ohne Discover |
| Accel-Stream | 16 ms | | Accel-Stream | 10 ms (best-effort, kein ACK-Wait; = FreeRTOS-Tick) |
| Batterie-Report | 30 s (+ einmal 150 ms nach Join) | | Batterie-Report | 30 s (+ einmal 150 ms nach Join) |
| Echo-Ping Timeout (Master) | 500 ms (`ESPNOW_ECHO_PING_TIMEOUT_MS`) | | Echo-Ping Timeout (Master) | 500 ms (`ESPNOW_ECHO_PING_TIMEOUT_MS`) |
@@ -226,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 | | 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 | | 7 | ESPNOW_UNICAST_TEST | `cmd_espnow_unicast_test.c` | Link-Test zu Slave |
| 8 | LED_RING | `cmd_led_ring.c` | Ring lokal / ESP-NOW | | 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 | | 17 | OTA_PAYLOAD | `cmd_ota.c` | Chunk ≤200 B |
| 18 | OTA_END | `cmd_ota.c` | Abschluss + ESP-NOW-Verteilung | | 18 | OTA_END | `cmd_ota.c` | Abschluss + ESP-NOW-Verteilung |
| 19 | OTA_STATUS | `cmd_ota.c` | Gerät → Host Status | | 19 | OTA_STATUS | `cmd_ota.c` | Gerät → Host Status |
@@ -260,9 +260,9 @@ Nur auf dem **Master** registriert (`powerpod.c`). IDs aus `MessageType` in `uar
### 7.4 ACCEL_STREAM (25) ### 7.4 ACCEL_STREAM (25)
- Optional `period_ms` (8–16): BLE-Host-Push-Intervall für `BLE_STREAM_TICK` (UART ignoriert; Default 16). - 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`. - 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 `slave_accel_stream_task` sendet `ESPNOW_ACCEL_SAMPLE` alle 10 ms (best-effort / `send_best_effort`).
### 7.5 TAP_NOTIFY (27) ### 7.5 TAP_NOTIFY (27)
@@ -358,9 +358,9 @@ go run . -port /dev/ttyUSB0 log-level -set -level 3
## 8. OTA ## 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 | | Phase | Host → Master | Master → Host |
|-------|---------------|---------------| |-------|---------------|---------------|
@@ -370,17 +370,19 @@ Implementierung: `ota_uart.c`, Steuerung `cmd/cmd_ota.c`.
- Inaktive Partition: `esp_ota_get_next_update_partition()`. - Inaktive Partition: `esp_ota_get_next_update_partition()`.
- LED-Ring zeigt Fortschritt (blau Schreiben, grün Verteilung). - LED-Ring zeigt Fortschritt (blau Schreiben, grün Verteilung).
- BLE: ATT MTU ≥ ~219 (preferred 247); `BLE_STREAM_TICK` pausiert während `ota_session_busy()`.
- **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) ### 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` 1. `ESPNOW_OTA_START` + `total_size` → Slave `ota_espnow_slave_on_start`
2. `ESPNOW_OTA_PAYLOAD` bis 200 B → 4 KiB Buffer auf Slave 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) 4. Slave antwortet `ESPNOW_OTA_STATUS` (mit `send_message_ex` + Semaphore auf Master)
Nach Erfolg: **alle Knoten neu starten**. Nach Erfolg: Master und Slaves starten automatisch neu (Rollback-Confirm siehe 8.1).
### 8.3 OTA_SLAVE_PROGRESS (21) ### 8.3 OTA_SLAVE_PROGRESS (21)
@@ -427,16 +429,18 @@ Spannungen in Millivolt in Registry und `BATTERY_STATUS`-UART.
### Software-UV-Schutz (`battery_uv.c`) ### Software-UV-Schutz (`battery_uv.c`)
Aktivierbar mit `POWERPOD_BATTERY_UV_ENABLE` in `powerpod.h` (CMake-Override möglich). 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 | | Zustand | ADC-Schwelle | Logik |
|---------|--------------|-------| |---------|--------------|-------|
| UV | &lt; 2300 mV | Ein gültiger Kanal reicht | | Extern / kein Pack | &lt; 100 mV | Alle gültigen Kanäle → normaler Boot |
| Geladen | ≥ 3000 mV | Alle gültigen Kanäle | | UV | 100…2299 mV | Ein gültiger Kanal reicht |
| Geladen | ≥ 3000 mV | Alle nicht-absent gültigen Kanäle |
- NVS-Key `uv_latch` in `pod_settings.c` - 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) - 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) - 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`) - LED-Anzeige: Mode `6` battery-low (`led_ring_show_battery_low`)
--- ---
@@ -450,6 +454,7 @@ Aktivierbar mit `POWERPOD_BATTERY_UV_ENABLE` in `powerpod.h` (CMake-Override mö
| `accel_dz` | Accel-Deadzone LSB | | `accel_dz` | Accel-Deadzone LSB |
| `tap_cfg` | BMA456 Multitap-Config (Blob) | | `tap_cfg` | BMA456 Multitap-Config (Blob) |
| `uv_latch` | Software-UV-Zustand (`1` = Energiesparmodus) | | `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()`. Gespeichert bei lokalem Set (UART `client_id=0`, ESP-NOW auf Slave). Geladen nach `init_bma456()`.
+6 -3
View File
@@ -27,7 +27,7 @@ go run . -port /dev/ttyUSB0 clients
| `deadzone` | `0x06` | Get/set accelerometer deadzone LSB (`-set`, `-value`, `-client`, `-all`) | | `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-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`) | | `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`) | | `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) | | `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/`) | | `test` | — | Run an automated scenario (JSON configs under `testdata/`) |
@@ -38,6 +38,8 @@ go run . -port /dev/ttyUSB0 clients
| `find-me` | 22 | Locate pod (`-client 0` master, `>0` slave via ESP-NOW) | | `find-me` | 22 | Locate pod (`-client 0` master, `>0` slave via ESP-NOW) |
| `restart` | 23 | Reboot master or slave (`-client 0` / `>0`) | | `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 | | `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. `clients` requires slaves to have responded to master discover broadcasts first.
@@ -61,7 +63,7 @@ See [`testdata/README.md`](testdata/README.md) for the JSON schema.
### Web dashboard ### Web dashboard
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 remains UART-only. 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 ```bash
go run . serve go run . serve
@@ -79,7 +81,7 @@ Open [http://localhost:8080](http://localhost:8080) — Host-Verbindung panel (U
| Column | Meaning | | 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-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`). Enable notify first, then turn receive on to see events. Same split as the external WebSocket API (`set_tap_notify` vs `set_tap_stream`).
@@ -110,6 +112,7 @@ go run . -port /dev/ttyUSB0 cache-status
```bash ```bash
go run . -port /dev/ttyUSB0 ota build/powerpod.bin 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. 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.
+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
}
+1 -1
View File
@@ -94,7 +94,7 @@ func serveBleStreamTest(w http.ResponseWriter, r *http.Request, link *managedHos
return return
} }
count := 50 count := 50
periodMs := uint32(16) periodMs := uint32(10)
tolMs := 2.0 tolMs := 2.0
timeoutS := 15.0 timeoutS := 15.0
var clientID uint32 var clientID uint32
+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(),
})
}
+29 -2
View File
@@ -64,6 +64,11 @@ type findMeAPIResponse struct {
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
} }
type resetIdMapAPIResponse struct {
Success bool `json:"success"`
Error string `json:"error,omitempty"`
}
type restartAPIRequest struct { type restartAPIRequest struct {
ClientID uint32 `json:"client_id"` ClientID uint32 `json:"client_id"`
} }
@@ -97,8 +102,10 @@ func mountServeAPI(mux *http.ServeMux, link *managedSerial, hub *wsHub, streamCt
mountBleTestAPI(mux, link) mountBleTestAPI(mux, link)
mountLiveStreamAPI(mux, hub) mountLiveStreamAPI(mux, hub)
mountAccelStreamAPI(mux, link, hub, streamCtl) mountAccelStreamAPI(mux, link, hub, streamCtl)
mountInputSendModeAPI(mux, link)
mountTapAPI(mux, link, hub, tapCtl) mountTapAPI(mux, link, hub, tapCtl)
mountTapConfigAPI(mux, link) mountTapConfigAPI(mux, link)
mountBatteryUvAPI(mux, link)
mountLedRingAPI(mux, link) mountLedRingAPI(mux, link)
mountBatteryAPI(mux, link) mountBatteryAPI(mux, link)
mux.HandleFunc("/api/deadzone", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/api/deadzone", func(w http.ResponseWriter, r *http.Request) {
@@ -139,6 +146,9 @@ func mountServeAPI(mux *http.ServeMux, link *managedSerial, hub *wsHub, streamCt
} }
serveRestart(w, r, link) 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) { mux.HandleFunc("/api/log-level", func(w http.ResponseWriter, r *http.Request) {
switch r.Method { switch r.Method {
case http.MethodGet: case http.MethodGet:
@@ -159,8 +169,8 @@ func mountServeAPI(mux *http.ServeMux, link *managedSerial, hub *wsHub, streamCt
} }
func serveOTAUpload(w http.ResponseWriter, r *http.Request, link *managedSerial, hub *wsHub) { func serveOTAUpload(w http.ResponseWriter, r *http.Request, link *managedSerial, hub *wsHub) {
if link.Transport() != hostTransportUART || !link.IsConnected() { if !link.IsConnected() {
writeJSON(w, http.StatusBadRequest, otaAPIResponse{Error: "OTA requires UART host (DIP8=0)"}) writeJSON(w, http.StatusBadRequest, otaAPIResponse{Error: "host not connected"})
return return
} }
if err := r.ParseMultipartForm(otaMaxFirmwareSize); err != nil { if err := r.ParseMultipartForm(otaMaxFirmwareSize); err != nil {
@@ -361,6 +371,23 @@ func serveRestart(w http.ResponseWriter, r *http.Request, link *managedSerial) {
writeJSON(w, http.StatusOK, restartAPIResponse{Success: true, ClientID: body.ClientID}) 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) { func serveFindMe(w http.ResponseWriter, r *http.Request, link *managedSerial) {
var body findMeAPIRequest var body findMeAPIRequest
if err := json.NewDecoder(r.Body).Decode(&body); err != nil { if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
+1 -1
View File
@@ -16,7 +16,7 @@ import (
) )
const ( const (
defaultAccelStreamInterval = 16 * time.Millisecond defaultAccelStreamInterval = 10 * time.Millisecond
defaultPreFetchMs = 2 defaultPreFetchMs = 2
minAPIStreamInterval = 1 * time.Millisecond minAPIStreamInterval = 1 * time.Millisecond
maxAPIStreamInterval = 10 * time.Second maxAPIStreamInterval = 10 * time.Second
+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) return nil, fmt.Errorf("config %q slave %q: %w", cfg.ID, s.ID, err)
} }
s.MAC = mac s.MAC = mac
if s.ClientID == nil { /* client_id is master-assigned (sequential); omit until known from `gotool clients`. */
parts := strings.Split(mac, ":")
var last byte
fmt.Sscanf(parts[5], "%02x", &last)
id := uint(last)
s.ClientID = &id
}
b.slaveByID[s.ID] = s b.slaveByID[s.ID] = s
} }
return b, nil return b, nil
@@ -112,6 +106,9 @@ func (b *Bench) ResolveClientID(slaveID string) (uint32, error) {
if err != nil { if err != nil {
return 0, err 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 return uint32(*s.ClientID), nil
} }
+34 -3
View File
@@ -2,7 +2,7 @@ package autotest
import "testing" import "testing"
func TestNewBenchClientIDFromMAC(t *testing.T) { func TestNewBenchNoDefaultClientIDFromMAC(t *testing.T) {
cfg := Config{ cfg := Config{
ID: "t", ID: "t",
Network: 1, Network: 1,
@@ -20,8 +20,39 @@ func TestNewBenchClientIDFromMAC(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
s, _ := b.Slave("pod") s, _ := b.Slave("pod")
if *s.ClientID != 16 { if s.ClientID != nil {
t.Fatalf("client_id=%d want 16", *s.ClientID) 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)
} }
} }
+1 -1
View File
@@ -195,7 +195,7 @@ func checkClients(bench *Bench, step Step, client MasterClient) error {
if found == nil { if found == nil {
return fmt.Errorf("slave %q mac %s not in client list", name, want.MAC) 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) return fmt.Errorf("slave %q id=%d want client_id=%d", name, found.GetId(), *want.ClientID)
} }
if e.Available != nil && found.GetAvailable() != *e.Available { if e.Available != nil && found.GetAvailable() != *e.Available {
+78 -3
View File
@@ -26,6 +26,7 @@ type bleLink struct {
respCh chan []byte respCh chan []byte
streamCh chan []byte streamCh chan []byte
quiet bool quiet bool
attMTU int // negotiated ATT MTU (0 if unknown)
} }
func openBLE(name string, settle time.Duration) (*bleLink, error) { func openBLE(name string, settle time.Duration) (*bleLink, error) {
@@ -125,10 +126,13 @@ func connectBLEDevice(addr bluetooth.Address, settle time.Duration) (*bleLink, e
} }
char := chars[0] char := chars[0]
attMTU := 0
if mtu, err := char.GetMTU(); err == nil { if mtu, err := char.GetMTU(); err == nil {
log.Printf("BLE ATT MTU=%d", mtu) attMTU = int(mtu)
if mtu < 203 { log.Printf("BLE ATT MTU=%d", attMTU)
log.Printf("warning: MTU %d < 203 — large frames/stream ticks may fail", mtu) if attMTU < otaMinATTMTU {
log.Printf("warning: MTU %d < %d — OTA (200 B chunks) and large stream ticks may fail",
attMTU, otaMinATTMTU)
} }
} }
@@ -138,6 +142,7 @@ func connectBLEDevice(addr bluetooth.Address, settle time.Duration) (*bleLink, e
parser: uartframe.NewParser(), parser: uartframe.NewParser(),
respCh: make(chan []byte, 8), respCh: make(chan []byte, 8),
streamCh: make(chan []byte, 64), streamCh: make(chan []byte, 64),
attMTU: attMTU,
} }
if err := char.EnableNotifications(func(buf []byte) { if err := char.EnableNotifications(func(buf []byte) {
@@ -277,3 +282,73 @@ func (b *bleLink) NextStreamTick(timeout time.Duration) ([]byte, error) {
return nil, fmt.Errorf("BLE stream tick timeout") 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 }
+2 -2
View File
@@ -79,8 +79,8 @@ func measureBleStream(h hostConn, clientID, periodMs uint32, count int, tolMs, t
PeriodMs: periodMs, PeriodMs: periodMs,
TolMs: tolMs, TolMs: tolMs,
} }
if periodMs < 8 || periodMs > 16 { if periodMs < 10 || periodMs > 16 {
out.Error = "period_ms must be 8..16" out.Error = "period_ms must be 10..16"
return out return out
} }
if count < 2 { if count < 2 {
+73
View File
@@ -290,6 +290,44 @@ func (m *managedSerial) tapConfigExchange(poll bool, req *pb.TapConfigRequest) (
return r, nil 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) { func (m *managedSerial) accelDeadzoneExchange(poll bool, req *pb.AccelDeadzoneRequest) (*pb.AccelDeadzoneResponse, error) {
msg := &pb.UartMessage{ msg := &pb.UartMessage{
Type: pb.MessageType_ACCEL_DEADZONE, Type: pb.MessageType_ACCEL_DEADZONE,
@@ -510,6 +548,33 @@ func (s *serialPort) tapConfig(req *pb.TapConfigRequest) (*pb.TapConfigResponse,
return r, nil 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) { func (s *serialPort) espnowUnicastTest(clientID, seq uint32) (*pb.EspNowUnicastTestResponse, error) {
req := &pb.EspNowUnicastTestRequest{ClientId: clientID, Seq: seq} req := &pb.EspNowUnicastTestRequest{ClientId: clientID, Seq: seq}
msg := &pb.UartMessage{ msg := &pb.UartMessage{
@@ -606,6 +671,10 @@ func (m *managedSerial) SetLogLevel(write bool, level uint32) (*pb.SetLogLevelRe
return hostSetLogLevel(m, write, level) 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 { func (m *managedSerial) Restart(clientID uint32) error {
if err := hostRestart(m, clientID); err != nil { if err := hostRestart(m, clientID); err != nil {
return err return err
@@ -677,6 +746,10 @@ func (s *serialPort) TapConfig(req *pb.TapConfigRequest) (*pb.TapConfigResponse,
return s.tapConfig(req) 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) { func (s *serialPort) EspnowUnicastTest(clientID, seq uint32) (*pb.EspNowUnicastTestResponse, error) {
return s.espnowUnicastTest(clientID, seq) return s.espnowUnicastTest(clientID, seq)
} }
+1 -1
View File
@@ -13,7 +13,7 @@ func runAccelStreamCmd(h hostConn, args []string) error {
all := fs.Bool("all", false, "all registered clients") all := fs.Bool("all", false, "all registered clients")
enable := fs.Bool("enable", true, "enable stream") enable := fs.Bool("enable", true, "enable stream")
disable := fs.Bool("disable", false, "disable stream") disable := fs.Bool("disable", false, "disable stream")
period := fs.Uint("period", 0, "BLE push period_ms 8–16 (0=leave/default)") period := fs.Uint("period", 0, "BLE push period_ms 10–16 (0=leave/default)")
if err := fs.Parse(args); err != nil { if err := fs.Parse(args); err != nil {
return err return err
} }
+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
}
+1 -1
View File
@@ -8,7 +8,7 @@ import (
func runBleStream(h hostConn, args []string) error { func runBleStream(h hostConn, args []string) error {
fs := flag.NewFlagSet("ble-stream", flag.ExitOnError) fs := flag.NewFlagSet("ble-stream", flag.ExitOnError)
clientID := fs.Uint("client", 0, "slave client id (0 = all_clients)") clientID := fs.Uint("client", 0, "slave client id (0 = all_clients)")
periodMs := fs.Uint("period", 16, "BLE push period_ms (8–16)") periodMs := fs.Uint("period", 10, "BLE push period_ms (10–16)")
count := fs.Int("count", 50, "number of BleStreamTick samples") count := fs.Int("count", 50, "number of BleStreamTick samples")
tolMs := fs.Float64("tol", 2.0, "tolerance ms around period for window_ok") tolMs := fs.Float64("tol", 2.0, "tolerance ms around period for window_ok")
duration := fs.Float64("timeout", 15.0, "max seconds waiting for ticks") duration := fs.Float64("timeout", 15.0, "max seconds waiting for ticks")
+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)
}
+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
}
+12 -4
View File
@@ -5,7 +5,7 @@ import (
"os" "os"
) )
func runOTA(sp *serialPort, args []string) error { func runOTA(t otaTransport, args []string) error {
if len(args) < 1 { if len(args) < 1 {
return fmt.Errorf("usage: ota <firmware.bin>") return fmt.Errorf("usage: ota <firmware.bin>")
} }
@@ -14,9 +14,17 @@ func runOTA(sp *serialPort, args []string) error {
return err return err
} }
sp.mu.Lock() switch x := t.(type) {
defer sp.mu.Unlock() case *serialPort:
return runOTAOnPortUnlocked(sp, data, func(p OTAProgress) { 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 { switch p.Phase {
case "preparing", "ready": case "preparing", "ready":
fmt.Println(p.Message) fmt.Println(p.Message)
+7 -2
View File
@@ -5,14 +5,19 @@ import (
"fmt" "fmt"
) )
func runOtaProgress(sp *serialPort, args []string) error { func runOtaProgress(t otaTransport, args []string) error {
fs := flag.NewFlagSet("ota-progress", flag.ExitOnError) fs := flag.NewFlagSet("ota-progress", flag.ExitOnError)
clientID := fs.Uint("client", 0, "slave client id (0 = all in session)") clientID := fs.Uint("client", 0, "slave client id (0 = all in session)")
if err := fs.Parse(args); err != nil { if err := fs.Parse(args); err != nil {
return err 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 { if err != nil {
return err return err
} }
+25 -2
View File
@@ -20,6 +20,8 @@ type MasterView struct {
RunningPartition string `json:"running_partition,omitempty"` RunningPartition string `json:"running_partition,omitempty"`
Deadzone uint32 `json:"deadzone,omitempty"` Deadzone uint32 `json:"deadzone,omitempty"`
TapConfig *TapConfigView `json:"tap_config,omitempty"` TapConfig *TapConfigView `json:"tap_config,omitempty"`
UvProtection *bool `json:"uv_protection,omitempty"`
UvLatched bool `json:"uv_latched,omitempty"`
Lipo1 lipoReadingJSON `json:"lipo1"` Lipo1 lipoReadingJSON `json:"lipo1"`
Lipo2 lipoReadingJSON `json:"lipo2"` Lipo2 lipoReadingJSON `json:"lipo2"`
BatteryAgeMs uint32 `json:"battery_age_ms,omitempty"` BatteryAgeMs uint32 `json:"battery_age_ms,omitempty"`
@@ -58,6 +60,7 @@ type ClientView struct {
TapNotifySingle bool `json:"tap_notify_single"` TapNotifySingle bool `json:"tap_notify_single"`
TapNotifyDouble bool `json:"tap_notify_double"` TapNotifyDouble bool `json:"tap_notify_double"`
TapNotifyTriple bool `json:"tap_notify_triple"` TapNotifyTriple bool `json:"tap_notify_triple"`
UvProtection bool `json:"uv_protection"`
LastTap string `json:"last_tap,omitempty"` LastTap string `json:"last_tap,omitempty"`
LastTapAt int64 `json:"last_tap_at,omitempty"` LastTapAt int64 `json:"last_tap_at,omitempty"`
Lipo1 lipoReadingJSON `json:"lipo1"` Lipo1 lipoReadingJSON `json:"lipo1"`
@@ -75,7 +78,7 @@ type DashboardState struct {
UARTConnected bool `json:"uart_connected"` UARTConnected bool `json:"uart_connected"`
SerialOK bool `json:"serial_ok"` SerialOK bool `json:"serial_ok"`
SerialError string `json:"serial_error,omitempty"` SerialError string `json:"serial_error,omitempty"`
/** Host: fast CACHE_STATUS poll (~16 ms) for accel + tap. */ /** Host: fast CACHE_STATUS poll (~8 ms) for accel + tap. */
LiveStream bool `json:"live_stream"` LiveStream bool `json:"live_stream"`
/** BLE live path: inter-tick stats from BleStreamTick.esp_timestamp_us. */ /** BLE live path: inter-tick stats from BleStreamTick.esp_timestamp_us. */
BleTick *BleTickView `json:"ble_tick,omitempty"` BleTick *BleTickView `json:"ble_tick,omitempty"`
@@ -489,7 +492,7 @@ func (h *wsHub) snapshotClients() []ClientView {
return out return out
} }
// patchLiveStream toggles host CACHE_STATUS polling (~16 ms). // patchLiveStream toggles host CACHE_STATUS polling (~8 ms).
func (h *wsHub) patchLiveStream(enabled bool) { func (h *wsHub) patchLiveStream(enabled bool) {
h.mu.Lock() h.mu.Lock()
h.liveStream = enabled h.liveStream = enabled
@@ -700,6 +703,11 @@ func pollDashboard(link *managedSerial, portName string, last *DashboardState, s
if tc, err := readTapConfigPoll(link, 0); err == nil { if tc, err := readTapConfigPoll(link, 0); err == nil {
st.Master.TapConfig = tc 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() clients, err := link.listClientsPoll()
if err != nil { if err != nil {
@@ -726,6 +734,7 @@ func pollDashboard(link *managedSerial, portName string, last *DashboardState, s
TapNotifySingle: c.GetTapNotifySingle(), TapNotifySingle: c.GetTapNotifySingle(),
TapNotifyDouble: c.GetTapNotifyDouble(), TapNotifyDouble: c.GetTapNotifyDouble(),
TapNotifyTriple: c.GetTapNotifyTriple(), TapNotifyTriple: c.GetTapNotifyTriple(),
UvProtection: c.GetUvProtectionEnabled(),
} }
st.Clients = append(st.Clients, cv) st.Clients = append(st.Clients, cv)
} }
@@ -928,6 +937,20 @@ func tapConfigViewFromPB(r *pb.TapConfigResponse) *TapConfigView {
} }
} }
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) { func readTapConfigPoll(link *managedSerial, clientID uint32) (*TapConfigView, error) {
r, err := link.TapConfigPoll(&pb.TapConfigRequest{ r, err := link.TapConfigPoll(&pb.TapConfigRequest{
Write: false, Write: false,
+69 -6
View File
@@ -138,7 +138,7 @@ POST /api/ble/scan
Dashboard WebSocket state includes `host_connected`, `host_transport`, `host_endpoint` (aliases `uart_connected` / `serial_port` kept). Dashboard WebSocket state includes `host_connected`, `host_transport`, `host_endpoint` (aliases `uart_connected` / `serial_port` kept).
**OTA** (`POST /api/ota`) returns **400** unless `transport==uart` and connected. **OTA** (`POST /api/ota`) returns **400** unless a host (UART or BLE) is connected.
### BLE latency tests (BLE host only) ### BLE latency tests (BLE host only)
@@ -148,13 +148,13 @@ POST /api/ble-rtt
→ {"success":true,"count":50,"warmup":5,"stats":{"n":50,"min_ms":…,"median_ms":…,"mean_ms":…,"max_ms":…}} → {"success":true,"count":50,"warmup":5,"stats":{"n":50,"min_ms":…,"median_ms":…,"mean_ms":…,"max_ms":…}}
POST /api/ble-stream POST /api/ble-stream
{"count":50,"period_ms":16,"tol_ms":2,"timeout_s":15,"client_id":0} {"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":{…},…} → {"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`). Pauses dashboard/API polling while running. Requires active BLE host (`DIP8=1`).
### Live stream (host `CACHE_STATUS` poll ~16 ms; BLE uses `BLE_STREAM_TICK`) ### Live stream (host `CACHE_STATUS` poll ~8 ms; BLE uses `BLE_STREAM_TICK`)
```http ```http
GET /api/live-stream GET /api/live-stream
@@ -400,14 +400,70 @@ go run . -port /dev/ttyUSB0 log-level -set -level 3
Dashboard: Master card → dropdown **ESP Log-Level** with **Lesen** / **Setzen**. Dashboard: Master card → dropdown **ESP Log-Level** with **Lesen** / **Setzen**.
### OTA (master UART upload) ### 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 ```http
POST /api/ota POST /api/ota
Content-Type: multipart/form-data 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 ```json
{"success": true, "bytes_written": 123456, "target_slot": 1} {"success": true, "bytes_written": 123456, "target_slot": 1}
@@ -415,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. 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 ### LED ring and battery
@@ -450,5 +511,7 @@ Same as external API:
| Unicast test | `POST /api/unicast-test` | | Unicast test | `POST /api/unicast-test` |
| Echo ping | `POST /api/echo-ping` (per-slave latency; UI shows Host ms + ESP ms) | | 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` | | 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 notify S/D/T | `PUT /api/clients/{id}/tap-notify` |
| Tap receive (UI) | Live stream + tap notify; see WebSocket doc for external API | | Tap receive (UI) | Live stream + tap notify; see WebSocket doc for external API |
+28 -1
View File
@@ -4,8 +4,8 @@ import (
"fmt" "fmt"
"time" "time"
pb "powerpod/gotool/pb"
"google.golang.org/protobuf/proto" "google.golang.org/protobuf/proto"
pb "powerpod/gotool/pb"
) )
// hostConn is the UART or BLE host transport used by CLI commands. // hostConn is the UART or BLE host transport used by CLI commands.
@@ -160,6 +160,33 @@ func hostSetLogLevel(h hostConn, write bool, level uint32) (*pb.SetLogLevelRespo
return r, nil 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) { func hostLedRing(h hostConn, req *pb.LedRingProgressRequest) (*pb.LedRingProgressResponse, error) {
msg := &pb.UartMessage{ msg := &pb.UartMessage{
Type: pb.MessageType_LED_RING, Type: pb.MessageType_LED_RING,
+2 -2
View File
@@ -17,7 +17,7 @@ const (
hostTransportBLE = "ble" hostTransportBLE = "ble"
) )
// errHostBusy is returned when the link is held for OTA (UART only). // errHostBusy is returned when the link is held for OTA.
var errHostBusy = errUARTBusy var errHostBusy = errUARTBusy
// managedHost is a reconnecting UART or BLE host link for serve/CLI. // managedHost is a reconnecting UART or BLE host link for serve/CLI.
@@ -279,7 +279,7 @@ func (m *managedHost) recoverAfterMasterRestart() {
log.Printf("UART reconnect after master restart: %v", err) log.Printf("UART reconnect after master restart: %v", err)
return return
} }
flushSerialInput(m.sp) m.sp.flushInput()
log.Printf("UART %s ready after master restart", m.uartPort) log.Printf("UART %s ready after master restart", m.uartPort)
} }
} }
+34 -14
View File
@@ -17,6 +17,7 @@ func usage() {
fmt.Fprintf(os.Stderr, "commands:\n") fmt.Fprintf(os.Stderr, "commands:\n")
fmt.Fprintf(os.Stderr, " version firmware version and git hash\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, " 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-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, " tap-notify get/set which tap kinds notify via ESP-NOW\n")
@@ -28,12 +29,14 @@ func usage() {
fmt.Fprintf(os.Stderr, " ble-stream enable stream + measure BleStreamTick window\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, " test run automated scenario (see testdata/)\n")
fmt.Fprintf(os.Stderr, " serve web dashboard (optional -port / -ble; connect from UI)\n") fmt.Fprintf(os.Stderr, " serve web dashboard (optional -port / -ble; connect from UI)\n")
fmt.Fprintf(os.Stderr, " ota UART OTA upload (A/B partitions; UART host only)\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, " 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, " 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, " 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") 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\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() flag.PrintDefaults()
} }
@@ -72,7 +75,7 @@ func main() {
bleName: *bleName, bleName: *bleName,
settle: time.Duration(*bleSettle * float64(time.Second)), settle: time.Duration(*bleSettle * float64(time.Second)),
}, flag.Args()[1:]) }, flag.Args()[1:])
case "version", "clients", "client-info", "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", "ota", "ota-progress", "ota_progress": 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 != "" { if *useBLE && *portName != "" {
fmt.Fprintf(os.Stderr, "use either -port or -ble, not both\n") fmt.Fprintf(os.Stderr, "use either -port or -ble, not both\n")
os.Exit(2) os.Exit(2)
@@ -82,13 +85,6 @@ func main() {
usage() usage()
os.Exit(2) os.Exit(2)
} }
if cmd == "ota" || cmd == "ota-progress" || cmd == "ota_progress" {
if *useBLE {
fmt.Fprintf(os.Stderr, "OTA requires UART host (DIP8=0, -port)\n")
os.Exit(2)
}
}
var h hostConn var h hostConn
if *useBLE { if *useBLE {
bl, err := openBLE(*bleName, time.Duration(*bleSettle*float64(time.Second))) bl, err := openBLE(*bleName, time.Duration(*bleSettle*float64(time.Second)))
@@ -112,6 +108,13 @@ func main() {
runErr = runVersionHost(h) runErr = runVersionHost(h)
case "clients", "client-info": case "clients", "client-info":
runErr = runClientsHost(h) 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": case "cache-status", "cache_status":
runErr = runCacheStatusHost(h) runErr = runCacheStatusHost(h)
case "accel-stream", "accel_stream": case "accel-stream", "accel_stream":
@@ -136,6 +139,13 @@ func main() {
break break
} }
runErr = runTapConfig(sp, flag.Args()[1:]) 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": case "tap-notify", "tap_notify":
sp, ok := h.(*serialPort) sp, ok := h.(*serialPort)
if !ok { if !ok {
@@ -178,12 +188,22 @@ func main() {
break break
} }
runErr = runLogLevel(sp, flag.Args()[1:]) runErr = runLogLevel(sp, flag.Args()[1:])
case "input-send-mode", "input_send_mode":
runErr = runInputSendModeCmd(h, flag.Args()[1:])
case "ota": case "ota":
sp := h.(*serialPort) t, ok := h.(otaTransport)
runErr = runOTA(sp, flag.Args()[1:]) if !ok {
runErr = fmt.Errorf("ota: unsupported host transport")
break
}
runErr = runOTA(t, flag.Args()[1:])
case "ota-progress", "ota_progress": case "ota-progress", "ota_progress":
sp := h.(*serialPort) t, ok := h.(otaTransport)
runErr = runOtaProgress(sp, flag.Args()[1:]) if !ok {
runErr = fmt.Errorf("ota-progress: unsupported host transport")
break
}
runErr = runOtaProgress(t, flag.Args()[1:])
} }
default: default:
fmt.Fprintf(os.Stderr, "unknown command %q\n\n", cmd) fmt.Fprintf(os.Stderr, "unknown command %q\n\n", cmd)
+105 -83
View File
@@ -6,8 +6,8 @@ import (
"google.golang.org/protobuf/proto" "google.golang.org/protobuf/proto"
uartframe "powerpod/gotool/uart"
"powerpod/gotool/pb" "powerpod/gotool/pb"
uartframe "powerpod/gotool/uart"
) )
const ( const (
@@ -21,8 +21,12 @@ const (
otaDistQueryTimeout = 2 * time.Second otaDistQueryTimeout = 2 * time.Second
otaDistEmitMinInterval = 150 * time.Millisecond otaDistEmitMinInterval = 150 * time.Millisecond
// Pace host chunks so the master UART RX ring is not overrun (~20 frames/block). // Pace host chunks so the master UART RX ring is not overrun (~20 frames/block).
otaHostChunkPace = 3 * time.Millisecond otaHostChunkPaceUART = 5 * time.Millisecond
// Slightly slower for BLE WriteWithoutResponse flow control.
otaHostChunkPaceBLE = 6 * time.Millisecond
otaBlockMaxRetries = 3 otaBlockMaxRetries = 3
// 200 B OTA_PAYLOAD → ~216 B framed; needs ATT MTU >= 219.
otaMinATTMTU = 219
) )
const ( const (
@@ -37,6 +41,14 @@ const (
otaDistTimeout = 45 * time.Minute 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. // OtaSlaveDetail is per-slave ESP-NOW OTA state from OTA_SLAVE_PROGRESS.
type OtaSlaveDetail struct { type OtaSlaveDetail struct {
BytesWritten uint32 `json:"bytes_written"` BytesWritten uint32 `json:"bytes_written"`
@@ -80,16 +92,34 @@ func runOTAUpload(m *managedSerial, firmware []byte, onProgress otaProgressFn) e
Percent: 0, Message: msg, MasterMessage: msg, 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 // Hold m.mu for the entire upload so dashboard/API polling cannot interleave.
// dashboard/API polling cannot interleave on the serial port.
m.mu.Lock() m.mu.Lock()
if m.otaActive { if m.otaActive {
m.mu.Unlock() m.mu.Unlock()
return errOTAInProgress return errOTAInProgress
} }
m.otaActive = true m.otaActive = true
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 m.sp == nil {
if err := m.openLocked(); err != nil { if err := m.openLocked(); err != nil {
m.otaActive = false m.otaActive = false
@@ -98,9 +128,16 @@ func runOTAUpload(m *managedSerial, firmware []byte, onProgress otaProgressFn) e
return err return err
} }
} }
sp := m.sp t = m.sp
default:
m.otaActive = false
m.mu.Unlock()
err := fmt.Errorf("not connected")
push("error", err.Error())
return err
}
err := runOTAOnPortUnlocked(sp, firmware, onProgress) err := runOTAUnlocked(t, firmware, onProgress)
if err != nil { if err != nil {
m.invalidateLocked(err) m.invalidateLocked(err)
} }
@@ -109,7 +146,7 @@ func runOTAUpload(m *managedSerial, firmware []byte, onProgress otaProgressFn) e
return err return err
} }
func runOTAOnPortUnlocked(sp *serialPort, firmware []byte, onProgress otaProgressFn) error { func runOTAUnlocked(t otaTransport, firmware []byte, onProgress otaProgressFn) error {
if len(firmware) == 0 { if len(firmware) == 0 {
return fmt.Errorf("empty firmware") return fmt.Errorf("empty firmware")
} }
@@ -151,16 +188,11 @@ func runOTAOnPortUnlocked(sp *serialPort, firmware []byte, onProgress otaProgres
onProgress(p) 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)) 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, Type: pb.MessageType_OTA_START,
Payload: &pb.UartMessage_OtaStart{ Payload: &pb.UartMessage_OtaStart{
OtaStart: &pb.OtaStartPayload{TotalSize: uint32(imageSize)}, OtaStart: &pb.OtaStartPayload{TotalSize: uint32(imageSize)},
@@ -170,13 +202,7 @@ func runOTAOnPortUnlocked(sp *serialPort, firmware []byte, onProgress otaProgres
return err return err
} }
if err := sp.port.SetReadTimeout(otaPrepareTimeout); err != nil { ready, err := waitOtaStatus(t, otaStReady, otaPrepareTimeout, func(msg string) {
notify("error", "", 0, err.Error())
return err
}
defer func() { _ = sp.port.SetReadTimeout(readTimeout) }()
ready, err := waitOtaStatus(sp, otaStReady, otaPrepareTimeout, func(msg string) {
notify("preparing", otaStepMaster, 2, msg) notify("preparing", otaStepMaster, 2, msg)
}) })
if err != nil { if err != nil {
@@ -185,11 +211,7 @@ func runOTAOnPortUnlocked(sp *serialPort, firmware []byte, onProgress otaProgres
} }
notify("ready", otaStepMaster, 5, fmt.Sprintf("Master: Slot %d bereit", ready.GetTargetSlot())) notify("ready", otaStepMaster, 5, fmt.Sprintf("Master: Slot %d bereit", ready.GetTargetSlot()))
if err := sp.port.SetReadTimeout(otaDefaultTimeout); err != nil { pace := t.chunkPace()
notify("error", "", 0, err.Error())
return err
}
var seq uint32 var seq uint32
for offset := 0; offset < imageSize; { for offset := 0; offset < imageSize; {
blockStart := offset blockStart := offset
@@ -208,7 +230,7 @@ func runOTAOnPortUnlocked(sp *serialPort, firmware []byte, onProgress otaProgres
} }
chunk := firmware[offset : offset+n] chunk := firmware[offset : offset+n]
if err := writeUartMessage(sp, &pb.UartMessage{ if err := t.writeMsg(&pb.UartMessage{
Type: pb.MessageType_OTA_PAYLOAD, Type: pb.MessageType_OTA_PAYLOAD,
Payload: &pb.UartMessage_OtaPayload{ Payload: &pb.UartMessage_OtaPayload{
OtaPayload: &pb.OtaPayload{Seq: seq, Data: chunk}, OtaPayload: &pb.OtaPayload{Seq: seq, Data: chunk},
@@ -216,7 +238,7 @@ func runOTAOnPortUnlocked(sp *serialPort, firmware []byte, onProgress otaProgres
}); err != nil { }); err != nil {
return false, err return false, err
} }
time.Sleep(otaHostChunkPace) time.Sleep(pace)
seq++ seq++
offset += n offset += n
bytesInBlock += n bytesInBlock += n
@@ -252,7 +274,7 @@ func runOTAOnPortUnlocked(sp *serialPort, firmware []byte, onProgress otaProgres
return err return err
} }
} }
st, ackErr = waitOtaStatus(sp, otaStBlockAck, otaDefaultTimeout, nil) st, ackErr = waitOtaStatus(t, otaStBlockAck, otaDefaultTimeout, nil)
if ackErr == nil { if ackErr == nil {
break break
} }
@@ -272,10 +294,10 @@ func runOTAOnPortUnlocked(sp *serialPort, firmware []byte, onProgress otaProgres
} }
masterPct = 100 masterPct = 100
masterMsg = "Master: UART-Upload abgeschlossen" masterMsg = "Master: Upload abgeschlossen"
notify("uploading", otaStepMaster, 100, masterMsg) notify("uploading", otaStepMaster, 100, masterMsg)
if err := writeUartMessage(sp, &pb.UartMessage{ if err := t.writeMsg(&pb.UartMessage{
Type: pb.MessageType_OTA_END, Type: pb.MessageType_OTA_END,
Payload: &pb.UartMessage_OtaEnd{ Payload: &pb.UartMessage_OtaEnd{
OtaEnd: &pb.OtaEndPayload{}, OtaEnd: &pb.OtaEndPayload{},
@@ -314,7 +336,8 @@ func runOTAOnPortUnlocked(sp *serialPort, firmware []byte, onProgress otaProgres
} }
} }
if slaveCount == 0 { 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)", return fmt.Sprintf("ESP-NOW: %d / %d bytes (%d Slaves)",
aggBytes, imageSize, slaveCount), aggBytes, slaveCount aggBytes, imageSize, slaveCount), aggBytes, slaveCount
@@ -334,7 +357,7 @@ func runOTAOnPortUnlocked(sp *serialPort, firmware []byte, onProgress otaProgres
return return
} }
lastQuery = time.Now() lastQuery = time.Now()
prog, err := queryOtaSlaveProgressLocked(sp, 0, onDistStatus, otaDistQueryTimeout) prog, err := queryOtaSlaveProgress(t, 0, onDistStatus, otaDistQueryTimeout)
if err != nil { if err != nil {
if len(slaveDetails) > 0 { if len(slaveDetails) > 0 {
emitSlaveThrottled(true) emitSlaveThrottled(true)
@@ -356,17 +379,17 @@ func runOTAOnPortUnlocked(sp *serialPort, firmware []byte, onProgress otaProgres
lastQuery = time.Time{} // first query immediately when distribution starts lastQuery = time.Time{} // first query immediately when distribution starts
querySlaveProgress() querySlaveProgress()
st, err := waitOtaComplete(sp, otaDistTimeout, pushSlaveDist, onWaitTick, otaDistReadTimeout) st, err := waitOtaComplete(t, otaDistTimeout, pushSlaveDist, onWaitTick, otaDistReadTimeout)
if err != nil { if err != nil {
notify("error", "", 0, err.Error()) notify("error", "", 0, err.Error())
return err 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) mergeSlaveProgressResponse(prog, slaveBytes, slaveDetails)
} }
notify("done", "", 100, 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()), st.GetBytesWritten(), st.GetTargetSlot()),
OTAProgress{ OTAProgress{
Bytes: st.GetBytesWritten(), Slot: st.GetTargetSlot(), Bytes: st.GetBytesWritten(), Slot: st.GetTargetSlot(),
@@ -378,13 +401,11 @@ func runOTAOnPortUnlocked(sp *serialPort, firmware []byte, onProgress otaProgres
} }
// QueryOtaSlaveProgress queries the master for per-slave ESP-NOW OTA progress. // QueryOtaSlaveProgress queries the master for per-slave ESP-NOW OTA progress.
func QueryOtaSlaveProgress(sp *serialPort, clientID uint32) (*pb.OtaSlaveProgressResponse, error) { func QueryOtaSlaveProgress(t otaTransport, clientID uint32) (*pb.OtaSlaveProgressResponse, error) {
sp.mu.Lock() return queryOtaSlaveProgress(t, clientID, nil, otaDefaultTimeout)
defer sp.mu.Unlock()
return queryOtaSlaveProgressLocked(sp, 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) { onStatus func(*pb.OtaStatusPayload), queryTimeout time.Duration) (*pb.OtaSlaveProgressResponse, error) {
req := &pb.UartMessage{ req := &pb.UartMessage{
Type: pb.MessageType_OTA_SLAVE_PROGRESS, Type: pb.MessageType_OTA_SLAVE_PROGRESS,
@@ -394,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 return nil, err
} }
if queryTimeout <= 0 { if queryTimeout <= 0 {
queryTimeout = otaDefaultTimeout queryTimeout = otaDefaultTimeout
} }
deadline := time.Now().Add(queryTimeout) 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 { if err != nil {
return nil, err return nil, err
} }
@@ -434,7 +455,7 @@ func applyDistributingOtaStatus(st *pb.OtaStatusPayload, imageSize int,
details[id] = d 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) { onStatus func(*pb.OtaStatusPayload), readChunk time.Duration) (*pb.UartMessage, error) {
if readChunk <= 0 { if readChunk <= 0 {
readChunk = otaStatusPollTimeout readChunk = otaStatusPollTimeout
@@ -447,10 +468,7 @@ func readUartMessageUntil(sp *serialPort, deadline time.Time, want pb.MessageTyp
if wait > readChunk { if wait > readChunk {
wait = readChunk wait = readChunk
} }
if err := sp.port.SetReadTimeout(wait); err != nil { payload, err := t.readFrame(wait)
return nil, err
}
payload, err := uartframe.ReadFrame(sp.port, nil, wait)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -505,7 +523,7 @@ func copySlaveMap(m map[uint32]uint32) map[uint32]uint32 {
return out return out
} }
func waitOtaComplete(sp *serialPort, timeout time.Duration, func waitOtaComplete(t otaTransport, timeout time.Duration,
onDistributing func(*pb.OtaStatusPayload), onInterval func(), onDistributing func(*pb.OtaStatusPayload), onInterval func(),
readTimeout time.Duration) (*pb.OtaStatusPayload, error) { readTimeout time.Duration) (*pb.OtaStatusPayload, error) {
if readTimeout <= 0 { if readTimeout <= 0 {
@@ -520,10 +538,7 @@ func waitOtaComplete(sp *serialPort, timeout time.Duration,
if readWait > readTimeout { if readWait > readTimeout {
readWait = readTimeout readWait = readTimeout
} }
if err := sp.port.SetReadTimeout(readWait); err != nil { st, err := readOtaStatus(t, readWait)
return nil, err
}
st, err := readOtaStatus(sp)
if err != nil { if err != nil {
if onInterval != nil { if onInterval != nil {
onInterval() onInterval()
@@ -548,16 +563,7 @@ func waitOtaComplete(sp *serialPort, timeout time.Duration,
} }
} }
func writeUartMessage(sp *serialPort, msg *pb.UartMessage) error { func waitOtaStatus(t otaTransport, want uint32, timeout time.Duration, onPreparing func(string)) (*pb.OtaStatusPayload, 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) {
deadline := time.Now().Add(timeout) deadline := time.Now().Add(timeout)
for { for {
if time.Now().After(deadline) { if time.Now().After(deadline) {
@@ -567,10 +573,7 @@ func waitOtaStatus(sp *serialPort, want uint32, timeout time.Duration, onPrepari
if readWait > otaStatusPollTimeout { if readWait > otaStatusPollTimeout {
readWait = otaStatusPollTimeout readWait = otaStatusPollTimeout
} }
if err := sp.port.SetReadTimeout(readWait); err != nil { payload, err := t.readFrame(readWait)
return nil, err
}
payload, err := uartframe.ReadFrame(sp.port, nil, readWait)
if err != nil { if err != nil {
continue continue
} }
@@ -595,8 +598,8 @@ func waitOtaStatus(sp *serialPort, want uint32, timeout time.Duration, onPrepari
} }
} }
func readOtaStatus(sp *serialPort) (*pb.OtaStatusPayload, error) { func readOtaStatus(t otaTransport, timeout time.Duration) (*pb.OtaStatusPayload, error) {
payload, err := uartframe.ReadFrame(sp.port, nil, readTimeout) payload, err := t.readFrame(timeout)
if err != nil { if err != nil {
return nil, fmt.Errorf("read response: %w", err) return nil, fmt.Errorf("read response: %w", err)
} }
@@ -623,8 +626,37 @@ func encodeUartMessage(msg *pb.UartMessage) ([]byte, error) {
return uartframe.EncodeFrame(payload) return uartframe.EncodeFrame(payload)
} }
// flushSerialInput drops stale RX bytes (not full frames — avoids ReadFrame blocking). func decodeUartPayload(payload []byte) (*pb.UartMessage, error) {
func flushSerialInput(sp *serialPort) { 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 { if sp == nil {
return return
} }
@@ -639,14 +671,4 @@ func flushSerialInput(sp *serialPort) {
} }
} }
func decodeUartPayload(payload []byte) (*pb.UartMessage, error) { func (sp *serialPort) chunkPace() time.Duration { return otaHostChunkPaceUART }
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
}
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -12,7 +12,7 @@
{ {
"id": "pod-1", "id": "pod-1",
"mac": "50:78:7d:18:01:10", "mac": "50:78:7d:18:01:10",
"client_id": 16, "client_id": 1,
"console": "/dev/ttyACM1" "console": "/dev/ttyACM1"
} }
] ]
+253 -26
View File
@@ -383,6 +383,11 @@
<dd class="col-7" x-text="formatLipo(state.master?.lipo1)"></dd> <dd class="col-7" x-text="formatLipo(state.master?.lipo1)"></dd>
<dt class="col-5 text-muted">LiPo 2</dt> <dt class="col-5 text-muted">LiPo 2</dt>
<dd class="col-7" x-text="formatLipo(state.master?.lipo2)"></dd> <dd class="col-7" x-text="formatLipo(state.master?.lipo2)"></dd>
<dt class="col-5 text-muted">UV-Schutz</dt>
<dd class="col-7">
<span x-text="state.master?.uv_protection == null ? '—' : (state.master.uv_protection ? 'an' : 'aus')"></span>
<span class="text-warning small ms-1" x-show="state.master?.uv_latched">· gelatcht</span>
</dd>
</dl> </dl>
</template> </template>
<template x-if="state.master && !state.master.ok"> <template x-if="state.master && !state.master.ok">
@@ -512,6 +517,31 @@
Setzen Setzen
</button> </button>
</div> </div>
<h6 class="text-secondary mb-2 mt-3">UV-Schutz (LiPo)</h6>
<div class="d-flex flex-wrap gap-2 align-items-center">
<label class="tap-toggle mb-0">
<input type="checkbox"
:checked="!!state.master?.uv_protection"
@change="setBatteryUv(0, $event.target.checked)"
:disabled="busy || !hostUp()">
Master aktiv
</label>
<button type="button" class="btn btn-outline-secondary btn-sm"
@click="setBatteryUvAll(true)"
:disabled="busy || !hostUp()"
title="UV-Schutz auf Master + alle Slaves einschalten">
Alle an
</button>
<button type="button" class="btn btn-outline-warning btn-sm"
@click="setBatteryUvAll(false)"
:disabled="busy || !hostUp()"
title="UV-Schutz auf Master + alle Slaves ausschalten (cleart Latch)">
Alle aus
</button>
</div>
<p class="text-muted small mt-1 mb-0">
Runtime-NVS pro Pod. Im Energiesparmodus (gelatcht) kein Host-Zugriff.
</p>
<p class="text-muted small mt-2 mb-0" x-show="!hostUp()"> <p class="text-muted small mt-2 mb-0" x-show="!hostUp()">
Host nicht verbunden — Eingabe gesperrt. Host nicht verbunden — Eingabe gesperrt.
</p> </p>
@@ -540,6 +570,18 @@
title="Master-Tap-Config an alle Slaves (ESP-NOW)"> title="Master-Tap-Config an alle Slaves (ESP-NOW)">
Tap-Cfg alle Tap-Cfg alle
</button> </button>
<button type="button" class="btn btn-outline-warning btn-sm"
@click="setBatteryUvSlavesOnly(false)"
:disabled="busy || !hostUp() || !(state.clients || []).length"
title="UV-Schutz nur auf allen Slaves ausschalten">
UV Slaves aus
</button>
<button type="button" class="btn btn-outline-danger btn-sm"
@click="resetClientIdMap()"
:disabled="busy || !hostUp()"
title="NVS-ID-Map + Live-Registry leeren; Online-Slaves verlieren Binding">
ID-Map leeren
</button>
<span class="badge bg-secondary" x-text="(state.clients || []).length + ' registered'"></span> <span class="badge bg-secondary" x-text="(state.clients || []).length + ' registered'"></span>
</div> </div>
</div> </div>
@@ -547,6 +589,8 @@
<strong>Live-Stream</strong>: UART ≈ <code>CACHE_STATUS</code>-Poll (~16&nbsp;ms); BLE ≈ <strong>Live-Stream</strong>: UART ≈ <code>CACHE_STATUS</code>-Poll (~16&nbsp;ms); BLE ≈
<code>BLE_STREAM_TICK</code>-Push. Pro Slave <strong>Accel</strong> aktiviert den ESP-NOW-Stream; <code>BLE_STREAM_TICK</code>-Push. Pro Slave <strong>Accel</strong> aktiviert den ESP-NOW-Stream;
Tap-Notify (S/D/T) steuert die Tap-Arten auf dem Slave. Tap-Notify (S/D/T) steuert die Tap-Arten auf dem Slave.
<strong>Input-Send</strong> wählt den ESP-NOW-TX-Pfad für Accel/Tap auf den Slaves
(<code>best_effort</code> Default; <code>wait</code>/<code>reliable</code> blockieren länger).
</p> </p>
<div class="px-3 pb-2 d-flex flex-wrap gap-2 align-items-center"> <div class="px-3 pb-2 d-flex flex-wrap gap-2 align-items-center">
<button type="button" <button type="button"
@@ -558,6 +602,28 @@
<span class="text-muted small font-monospace" x-show="isBLEHost() && state.live_stream" <span class="text-muted small font-monospace" x-show="isBLEHost() && state.live_stream"
x-text="bleTickLabel()" x-text="bleTickLabel()"
title="Median Δt zwischen BLE_STREAM_TICK (esp_timestamp_us)"></span> title="Median Δt zwischen BLE_STREAM_TICK (esp_timestamp_us)"></span>
<label class="text-muted small mb-0" for="input-send-mode">Input-Send:</label>
<select id="input-send-mode" class="form-select form-select-sm"
style="width: auto; min-width: 9rem;"
x-model.number="inputSendMode"
:disabled="busy || !hostUp() || !(state.clients || []).length">
<option :value="0">best_effort</option>
<option :value="1">fast</option>
<option :value="2">wait</option>
<option :value="3">reliable</option>
</select>
<button type="button" class="btn btn-outline-secondary btn-sm"
@click="setInputSendMode(inputSendMode)"
:disabled="busy || !hostUp() || !(state.clients || []).length"
title="Modus an alle registrierten Slaves pushen">
Setzen
</button>
<button type="button" class="btn btn-outline-secondary btn-sm"
@click="readInputSendMode()"
:disabled="busy || !hostUp()"
title="Zuletzt gesetzten Modus vom Master lesen">
Lesen
</button>
<span class="text-muted small">Tap alle Slaves:</span> <span class="text-muted small">Tap alle Slaves:</span>
<label class="tap-toggle"><input type="checkbox" x-model="allTapSingle" :disabled="busy"> S</label> <label class="tap-toggle"><input type="checkbox" x-model="allTapSingle" :disabled="busy"> S</label>
<label class="tap-toggle"><input type="checkbox" x-model="allTapDouble" :disabled="busy"> D</label> <label class="tap-toggle"><input type="checkbox" x-model="allTapDouble" :disabled="busy"> D</label>
@@ -581,6 +647,7 @@
<th>Tap-Cfg</th> <th>Tap-Cfg</th>
<th>Accel (LSB)</th> <th>Accel (LSB)</th>
<th>Akku</th> <th>Akku</th>
<th>UV</th>
<th>Accel</th> <th>Accel</th>
<th>Tap-Notify</th> <th>Tap-Notify</th>
<th>Tap</th> <th>Tap</th>
@@ -589,7 +656,7 @@
</thead> </thead>
<tbody> <tbody>
<template x-if="!(state.clients || []).length"> <template x-if="!(state.clients || []).length">
<tr><td colspan="12" class="text-muted text-center py-4">No clients</td></tr> <tr><td colspan="13" class="text-muted text-center py-4">No clients</td></tr>
</template> </template>
<template x-for="c in (state.clients || [])" :key="c.id + c.mac"> <template x-for="c in (state.clients || [])" :key="c.id + c.mac">
<tr> <tr>
@@ -618,6 +685,14 @@
:title="accelTitle(c)"></span> :title="accelTitle(c)"></span>
</td> </td>
<td class="small" x-text="formatLipoPair(c)" :title="lipoTitle(c)"></td> <td class="small" x-text="formatLipoPair(c)" :title="lipoTitle(c)"></td>
<td>
<label class="tap-toggle mb-0" title="Software-UV-Schutz (NVS auf Slave)">
<input type="checkbox"
:checked="!!c.uv_protection"
@change="setBatteryUv(c.id, $event.target.checked)"
:disabled="busy || !hostUp() || !c.available">
</label>
</td>
<td> <td>
<button type="button" <button type="button"
class="btn btn-sm" class="btn btn-sm"
@@ -782,23 +857,23 @@
</div> </div>
</section> </section>
<section class="col-12" x-show="isUARTHost() || !hostUp()"> <section class="col-12">
<div class="card"> <div class="card">
<div class="card-header">Firmware OTA (A/B)</div> <div class="card-header">Firmware OTA (A/B)</div>
<div class="card-body"> <div class="card-body">
<p class="text-muted small mb-3"> <p class="text-muted small mb-3">
Lädt eine <code>.bin</code> auf den Master (nur UART-Host, DIP8=0), danach verteilt die Firmware automatisch per ESP-NOW an alle verfügbaren Slaves. Lädt eine <code>.bin</code> auf den Master (UART oder BLE), danach verteilt die Firmware automatisch per ESP-NOW an alle verfügbaren Slaves.
Während des Uploads pausiert das Live-Polling. Bei BLE-Host ist OTA nicht verfügbar. Während des Uploads pausiert das Live-Polling. BLE braucht ATT MTU ≥ 219.
</p> </p>
<div class="mb-3"> <div class="mb-3">
<input type="file" class="form-control form-control-sm" accept=".bin,application/octet-stream" <input type="file" class="form-control form-control-sm" accept=".bin,application/octet-stream"
@change="otaFile = $event.target.files[0]" @change="otaFile = $event.target.files[0]"
:disabled="ota.active || busy || !isUARTHost()"> :disabled="ota.active || busy || !hostUp()">
</div> </div>
<div class="d-flex flex-wrap gap-2 align-items-center mb-2"> <div class="d-flex flex-wrap gap-2 align-items-center mb-2">
<button type="button" class="btn btn-primary btn-sm" <button type="button" class="btn btn-primary btn-sm"
@click="uploadOTA()" @click="uploadOTA()"
:disabled="ota.active || busy || !isUARTHost() || !otaFile"> :disabled="ota.active || busy || !hostUp() || !otaFile">
Firmware hochladen Firmware hochladen
</button> </button>
<span class="text-muted small" x-show="otaFile" <span class="text-muted small" x-show="otaFile"
@@ -806,7 +881,7 @@
</div> </div>
<div class="ota-progress-panel mt-3" <div class="ota-progress-panel mt-3"
x-show="ota.active || ota.phase === 'distributing' || ota.phase === 'done' || ota.phase === 'error'"> x-show="ota.active || ota.phase === 'distributing' || ota.phase === 'done' || ota.phase === 'error'">
<p class="small text-muted mb-2">Master (UART)</p> <p class="small text-muted mb-2">Master (Host)</p>
<table class="table table-sm pp-table ota-progress-table mb-3"> <table class="table table-sm pp-table ota-progress-table mb-3">
<tbody> <tbody>
<tr> <tr>
@@ -839,7 +914,8 @@
Slaves (ESP-NOW) Slaves (ESP-NOW)
<span x-text="'(' + otaSlaveRows().length + ')'"></span> <span x-text="'(' + otaSlaveRows().length + ')'"></span>
</p> </p>
<p class="small text-muted mb-2" x-show="ota.message && ota.step === 'slaves'" <p class="small text-muted mb-2"
x-show="ota.step === 'slaves' && otaSlaveRows().length > 0 && ota.message"
x-text="ota.message"></p> x-text="ota.message"></p>
<table class="table table-sm pp-table ota-progress-table mb-2"> <table class="table table-sm pp-table ota-progress-table mb-2">
<tbody> <tbody>
@@ -871,9 +947,8 @@
</tr> </tr>
</template> </template>
<tr x-show="otaSlaveRows().length === 0"> <tr x-show="otaSlaveRows().length === 0">
<td colspan="2" class="text-muted small py-3"> <td colspan="2" class="text-muted small py-3"
Warte auf Slave-Fortschritt… x-text="otaDistEmptyHint()"></td>
</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@@ -907,6 +982,7 @@
masterDz: 100, masterDz: 100,
masterDzSeeded: false, masterDzSeeded: false,
masterLogLevel: 0, masterLogLevel: 0,
inputSendMode: 0,
masterTapCfg: { masterTapCfg: {
tap_sens_thres: 5, tap_sens_thres: 5,
max_gest_dur: 100, max_gest_dur: 100,
@@ -1416,7 +1492,19 @@
if (status === 1 || status === 2) return 'bg-secondary'; if (status === 1 || status === 2) return 'bg-secondary';
return 'bg-info'; return 'bg-info';
}, },
otaDistEmptyHint() {
const msg = this.ota.message || '';
if (/übersprungen/i.test(msg)) {
return msg;
}
if (this.ota.phase === 'done') {
return 'Keine Slave-Verteilung gemeldet.';
}
return 'Warte auf Slave-Fortschritt…';
},
otaSlaveRows() { otaSlaveRows() {
// Only real OTA progress — do not invent placeholder rows from the
// client list (that contradicted "keine Slaves / übersprungen").
const details = this.mergeSlaveDetails(this.ota.slaveDetails); const details = this.mergeSlaveDetails(this.ota.slaveDetails);
const ids = new Set(Object.keys(details).map(Number).filter(id => !Number.isNaN(id))); const ids = new Set(Object.keys(details).map(Number).filter(id => !Number.isNaN(id)));
for (const [k, v] of Object.entries(this.ota.slaveProgress || {})) { for (const [k, v] of Object.entries(this.ota.slaveProgress || {})) {
@@ -1448,21 +1536,6 @@
bytesLabel: total ? `${bytes} / ${total} bytes` : `${bytes} bytes` bytesLabel: total ? `${bytes} / ${total} bytes` : `${bytes} bytes`
}); });
} }
if (rows.length === 0 && (this.ota.phase === 'distributing' || this.ota.step === 'slaves')) {
const targets = (this.state.clients || []).filter(c => c.available);
const limit = this.ota.slaves > 0 ? this.ota.slaves : targets.length;
for (const c of targets.slice(0, limit)) {
rows.push({
id: c.id,
mac: c.mac ? this.formatMac(c.mac) : '—',
percent: 0,
status: 1,
statusLabel: this.otaSlaveStatusLabel(1),
barClass: this.otaSlaveBarClass(1),
bytesLabel: '—'
});
}
}
return rows; return rows;
}, },
applyOTAProgress(p) { applyOTAProgress(p) {
@@ -1755,6 +1828,94 @@
this.busy = false; this.busy = false;
} }
}, },
patchBatteryUv(clientId, enabled, latched) {
if (clientId === 0) {
const master = {
...(this.state.master || {}),
uv_protection: !!enabled,
uv_latched: !!latched
};
this.state = { ...this.state, master };
return;
}
const clients = (this.state.clients || []).map((c) =>
c.id === clientId ? { ...c, uv_protection: !!enabled } : c);
this.state = { ...this.state, clients };
},
async setBatteryUv(clientId, enabled) {
this.busy = true;
try {
const r = await fetch('/api/battery-uv', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ write: true, enabled: !!enabled, client_id: clientId })
});
const data = await r.json();
if (!r.ok || !data.success) {
this.flash(data.error || `UV-Schutz ${clientId === 0 ? 'Master' : 'Slave ' + clientId} fehlgeschlagen`, false);
return;
}
this.patchBatteryUv(clientId, data.enabled, data.latched);
const who = clientId === 0 ? 'Master' : `Slave ${clientId}`;
this.flash(`${who}: UV-Schutz ${data.enabled ? 'an' : 'aus'}`, true);
} catch (e) {
this.flash(String(e), false);
} finally {
this.busy = false;
}
},
async setBatteryUvAll(enabled) {
this.busy = true;
try {
const r = await fetch('/api/battery-uv', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ write: true, enabled: !!enabled, all_clients: true })
});
const data = await r.json();
if (!r.ok || !data.success) {
this.flash(data.error || 'UV-Schutz für alle fehlgeschlagen', false);
return;
}
this.patchBatteryUv(0, data.enabled, data.latched);
for (const c of (this.state.clients || [])) {
this.patchBatteryUv(c.id, !!enabled, false);
}
this.flash(`UV-Schutz alle ${enabled ? 'an' : 'aus'} (${data.slaves_updated || 0} Slaves)`, true);
} catch (e) {
this.flash(String(e), false);
} finally {
this.busy = false;
}
},
async setBatteryUvSlavesOnly(enabled) {
this.busy = true;
try {
const r = await fetch('/api/battery-uv', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
write: true,
enabled: !!enabled,
all_clients: true,
slaves_only: true
})
});
const data = await r.json();
if (!r.ok || !data.success) {
this.flash(data.error || 'UV-Schutz Slaves fehlgeschlagen', false);
return;
}
for (const c of (this.state.clients || [])) {
this.patchBatteryUv(c.id, !!enabled, false);
}
this.flash(`UV-Schutz Slaves ${enabled ? 'an' : 'aus'} (${data.slaves_updated || 0})`, true);
} catch (e) {
this.flash(String(e), false);
} finally {
this.busy = false;
}
},
patchLiveStream(enabled) { patchLiveStream(enabled) {
let clients = this.state.clients || []; let clients = this.state.clients || [];
if (!enabled) { if (!enabled) {
@@ -1793,6 +1954,52 @@
this.busy = false; this.busy = false;
} }
}, },
formatInputSendMode(mode) {
const labels = ['best_effort', 'fast', 'wait', 'reliable'];
if (mode == null || mode < 0 || mode > 3) return '—';
return labels[mode];
},
async readInputSendMode() {
this.busy = true;
try {
const r = await fetch('/api/input-send-mode');
const data = await r.json();
if (!r.ok || !data.success) {
this.flash(data.error || 'Input-Send lesen fehlgeschlagen', false);
return;
}
this.inputSendMode = data.mode ?? 0;
this.flash(`Input-Send: ${data.mode_name || this.formatInputSendMode(data.mode)}`, true);
} catch (e) {
this.flash(String(e), false);
} finally {
this.busy = false;
}
},
async setInputSendMode(mode) {
this.busy = true;
try {
const r = await fetch('/api/input-send-mode', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mode, all_clients: true })
});
const data = await r.json();
if (!r.ok || !data.success) {
this.flash(data.error || 'Input-Send setzen fehlgeschlagen', false);
return;
}
this.inputSendMode = data.mode ?? mode;
this.flash(
`Input-Send ${data.mode_name || this.formatInputSendMode(data.mode)} → ${data.slaves_updated || 0} Slaves`,
true
);
} catch (e) {
this.flash(String(e), false);
} finally {
this.busy = false;
}
},
patchClientAccelStream(clientId, enabled) { patchClientAccelStream(clientId, enabled) {
const clients = (this.state.clients || []).map((c) => { const clients = (this.state.clients || []).map((c) => {
if (c.id !== clientId) return c; if (c.id !== clientId) return c;
@@ -1957,6 +2164,26 @@
this.busy = false; this.busy = false;
} }
}, },
async resetClientIdMap() {
if (!confirm('ID-Map leeren? Master vergisst alle MAC→ID-Zuordnungen; Online-Slaves verlieren ihr Binding.')) {
return;
}
this.busy = true;
try {
const r = await fetch('/api/clients/reset-id-map', { method: 'POST' });
const data = await r.json();
if (!r.ok || !data.success) {
this.flash(data.error || 'ID-Map leeren fehlgeschlagen', false);
return;
}
this.state = { ...this.state, clients: [] };
this.flash('ID-Map geleert — Slaves melden sich neu an', true);
} catch (e) {
this.flash(String(e), false);
} finally {
this.busy = false;
}
},
async restart(clientId = 0) { async restart(clientId = 0) {
this.busy = true; this.busy = true;
try { try {
+4
View File
@@ -22,6 +22,7 @@ idf_component_register(
"cmd/cmd_client_info.c" "cmd/cmd_client_info.c"
"cmd/cmd_accel_deadzone.c" "cmd/cmd_accel_deadzone.c"
"cmd/cmd_accel_stream.c" "cmd/cmd_accel_stream.c"
"cmd/cmd_input_send_mode.c"
"cmd/cmd_tap_notify.c" "cmd/cmd_tap_notify.c"
"cmd/cmd_tap_config.c" "cmd/cmd_tap_config.c"
"cmd/cmd_cache_status.c" "cmd/cmd_cache_status.c"
@@ -29,15 +30,18 @@ idf_component_register(
"cmd/cmd_espnow_echo_ping.c" "cmd/cmd_espnow_echo_ping.c"
"cmd/cmd_espnow_find_me.c" "cmd/cmd_espnow_find_me.c"
"cmd/cmd_restart.c" "cmd/cmd_restart.c"
"cmd/cmd_client_id_map_reset.c"
"pod_reboot.c" "pod_reboot.c"
"cmd/cmd_led_ring.c" "cmd/cmd_led_ring.c"
"cmd/cmd_battery.c" "cmd/cmd_battery.c"
"cmd/cmd_battery_uv.c"
"cmd/cmd_set_log_level.c" "cmd/cmd_set_log_level.c"
"cmd/cmd_ota.c" "cmd/cmd_ota.c"
"cmd/cmd_ota_slave_progress.c" "cmd/cmd_ota_slave_progress.c"
"ota_uart.c" "ota_uart.c"
"ota_espnow.c" "ota_espnow.c"
"ota_session.c" "ota_session.c"
"ota_confirm.c"
"client_registry.c" "client_registry.c"
"esp_now_comm.c" "esp_now_comm.c"
"esp_now_core.c" "esp_now_core.c"
+17 -9
View File
@@ -116,15 +116,17 @@ Schema: `proto/esp_now_messages.proto`. Encode/decode: `esp_now_proto.c`. The ES
| `EspNowMessageType` | Direction | `oneof` payload | | `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_SLAVE_INFO` | Slave → master | `EspNowSlavePresence` |
| `ESPNOW_HEARTBEAT` | Slave → master | `EspNowSlavePresence` (same fields) | | `ESPNOW_HEARTBEAT` | Slave → master | `EspNowSlavePresence` (same fields) |
| `ESPNOW_SET_ACCEL_DEADZONE` | Master → slave | `EspNowAccelDeadzone` (`deadzone` LSB) | | `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_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_UNICAST_TEST` | Master → slave | `EspNowUnicastTest` (`seq`) |
| `ESPNOW_FIND_ME` | Master → slave | `EspNowFindMe` (`client_id` filter) — LED locate sequence | | `ESPNOW_FIND_ME` | Master → slave | `EspNowFindMe` (`client_id` filter) — LED locate sequence |
| `ESPNOW_RESTART` | Master → slave | `EspNowRestart` (`client_id` filter) — reboot slave | | `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_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_TAP_EVENT` | Slave → master | `EspNowTapEvent` (`client_id`, `kind`) — on BMA456 tap interrupt if notify enabled |
| `ESPNOW_BATTERY_REPORT` | Slave → master | `EspNowBatteryReport` (`client_id`, `lipo1/2` mV) — ~every 30 s; cached in `client_registry` | | `ESPNOW_BATTERY_REPORT` | Slave → master | `EspNowBatteryReport` (`client_id`, `lipo1/2` mV) — ~every 30 s; cached in `client_registry` |
@@ -233,11 +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 | | 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 | | 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 | | 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 | | 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) | | 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) | | 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 | | 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) | | 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: Regenerate C code:
@@ -376,7 +380,7 @@ go run . -port /dev/ttyUSB0 tap-notify -client 16
### CACHE_STATUS command ### 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). **Request:** framed `1d` (`0x1d`) only — no body (`CacheStatusRequest` empty).
@@ -390,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`). 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 ```bash
go run . -port /dev/ttyUSB0 cache-status go run . -port /dev/ttyUSB0 cache-status
@@ -471,14 +475,17 @@ Read **cached** LiPo ADC values on the **master** (master local + one entry per
### Software UV protection (LiPo) ### Software UV protection (LiPo)
When `POWERPOD_BATTERY_UV_ENABLE` is `1` (default in `powerpod.h`, disable via CMake), the firmware monitors ADC pin voltages on GPIO 1 and 11: 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 | | Condition | ADC threshold | Action |
|-----------|---------------|--------| |-----------|---------------|--------|
| Under-voltage | any valid channel &lt; **2300 mV** (3.0 V pack) | NVS latch `uv_latch`, restart into energy-save mode | | External / absent | every valid channel &lt; **100 mV** | Normal boot (no UV); clears latch if set |
| Charged | all valid channels ≥ **3000 mV** (4.0 V pack) | Clear latch, restart into normal boot | | 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. **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). **Test LED pattern from host:** `led-ring -mode battery-low` (works even when UV feature is disabled).
@@ -534,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_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_check_timeouts(timeout_ms)` | Mark stale clients inactive (master monitor task) |
| `client_registry_count()` / `client_registry_at(i)` | Iterate for UART encoding | | `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). 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).
@@ -612,6 +619,7 @@ Target: ESP32-S3. Close serial monitor on the UART adapter port before running `
| `pod_settings.c/h` | NVS persistence (accel deadzone, tap config, UV latch) | | `pod_settings.c/h` | NVS persistence (accel deadzone, tap config, UV latch) |
| `led_ring.c/h` | LED ring (digit display, progress bar) | | `led_ring.c/h` | LED ring (digit display, progress bar) |
| `cmd/cmd_led_ring.c` | UART `LED_RING` progress command | | `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 | | `cmd/cmd_set_log_level.c` | UART `SET_LOG_LEVEL` — runtime ESP-IDF log level |
| `proto/uart_messages.proto` | UART protocol schema | | `proto/uart_messages.proto` | UART protocol schema |
| `proto/esp_now_messages.proto` | ESP-NOW protocol schema | | `proto/esp_now_messages.proto` | ESP-NOW protocol schema |
+76 -10
View File
@@ -19,15 +19,55 @@ static uint8_t s_charge_streak;
bool battery_uv_is_latched(void) { return pod_settings_is_uv_latched(); } 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) { bool battery_uv_reading_is_under(const board_lipo_reading_t *reading) {
if (reading == NULL) { if (reading == NULL) {
return false; return false;
} }
if (reading->lipo1_valid && reading->lipo1_mv < BATTERY_UV_ADC_MV) { if (reading->lipo1_valid && channel_is_under(reading->lipo1_mv)) {
return true; return true;
} }
if (reading->lipo2_valid && reading->lipo2_mv < BATTERY_UV_ADC_MV) { if (reading->lipo2_valid && channel_is_under(reading->lipo2_mv)) {
return true; return true;
} }
return false; return false;
@@ -38,25 +78,39 @@ bool battery_uv_reading_is_charged(const board_lipo_reading_t *reading) {
return false; return false;
} }
bool any_valid = false; bool any_pack = false;
if (reading->lipo1_valid) { if (reading->lipo1_valid && !channel_is_absent(reading->lipo1_mv)) {
any_valid = true; any_pack = true;
if (reading->lipo1_mv < BATTERY_CHARGE_ADC_MV) { if (reading->lipo1_mv < BATTERY_CHARGE_ADC_MV) {
return false; return false;
} }
} }
if (reading->lipo2_valid) { if (reading->lipo2_valid && !channel_is_absent(reading->lipo2_mv)) {
any_valid = true; any_pack = true;
if (reading->lipo2_mv < BATTERY_CHARGE_ADC_MV) { if (reading->lipo2_mv < BATTERY_CHARGE_ADC_MV) {
return false; return false;
} }
} }
return any_valid; return any_pack;
} }
bool battery_uv_evaluate_boot(void) { 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_lipo_reading_t reading;
board_input_read_lipo(&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)) { if (pod_settings_is_uv_latched() && battery_uv_reading_is_charged(&reading)) {
ESP_LOGI(TAG, "charged — clearing UV latch"); ESP_LOGI(TAG, "charged — clearing UV latch");
@@ -78,12 +132,19 @@ bool battery_uv_evaluate_boot(void) {
} }
void battery_uv_poll(const board_lipo_reading_t *reading) { void battery_uv_poll(const board_lipo_reading_t *reading) {
if (reading == NULL || pod_settings_is_uv_latched()) { 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; return;
} }
if (battery_uv_reading_is_under(reading)) { if (battery_uv_reading_is_under(reading)) {
s_uv_streak++; s_uv_streak++;
log_lipo_reading("under-voltage", reading);
ESP_LOGW(TAG, "under-voltage sample %u/%u", (unsigned)s_uv_streak, ESP_LOGW(TAG, "under-voltage sample %u/%u", (unsigned)s_uv_streak,
(unsigned)UV_DEBOUNCE_SAMPLES); (unsigned)UV_DEBOUNCE_SAMPLES);
if (s_uv_streak >= UV_DEBOUNCE_SAMPLES) { if (s_uv_streak >= UV_DEBOUNCE_SAMPLES) {
@@ -109,8 +170,13 @@ void battery_uv_run_mode(void) {
while (1) { while (1) {
board_lipo_reading_t reading; board_lipo_reading_t reading;
board_input_read_lipo(&reading); board_input_read_lipo(&reading);
log_lipo_reading("energy-save", &reading);
if (battery_uv_reading_is_charged(&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++; s_charge_streak++;
ESP_LOGI(TAG, "charge sample %u/%u", (unsigned)s_charge_streak, ESP_LOGI(TAG, "charge sample %u/%u", (unsigned)s_charge_streak,
(unsigned)UV_DEBOUNCE_SAMPLES); (unsigned)UV_DEBOUNCE_SAMPLES);
+12
View File
@@ -11,10 +11,17 @@
#define BATTERY_UV_ADC_MV 2300u #define BATTERY_UV_ADC_MV 2300u
/** ADC pin voltage (mV) at which charging is detected (4.0 V pack, rounded). */ /** ADC pin voltage (mV) at which charging is detected (4.0 V pack, rounded). */
#define BATTERY_CHARGE_ADC_MV 3000u #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_is_latched(void);
bool battery_uv_reading_is_under(const board_lipo_reading_t *reading); bool battery_uv_reading_is_under(const board_lipo_reading_t *reading);
bool battery_uv_reading_is_charged(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). */ /** true → enter UV energy-save path (does not return). */
bool battery_uv_evaluate_boot(void); bool battery_uv_evaluate_boot(void);
@@ -38,6 +45,11 @@ static inline bool battery_uv_reading_is_charged(const board_lipo_reading_t *rea
return false; 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 bool battery_uv_evaluate_boot(void) { return false; }
static inline void battery_uv_poll(const board_lipo_reading_t *reading) { static inline void battery_uv_poll(const board_lipo_reading_t *reading) {
+12 -4
View File
@@ -6,6 +6,7 @@
#include "esp_timer.h" #include "esp_timer.h"
#include "freertos/FreeRTOS.h" #include "freertos/FreeRTOS.h"
#include "freertos/task.h" #include "freertos/task.h"
#include "ota_session.h"
#include "pb_encode.h" #include "pb_encode.h"
#include "uart.h" #include "uart.h"
#include "uart_cmd.h" #include "uart_cmd.h"
@@ -14,9 +15,10 @@
static const char *TAG = "[BLE_STREAM]"; static const char *TAG = "[BLE_STREAM]";
#define BLE_STREAM_PERIOD_MIN_MS 8u /* 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_MAX_MS 16u
#define BLE_STREAM_PERIOD_DEFAULT_MS 16u #define BLE_STREAM_PERIOD_DEFAULT_MS 10u
static uint32_t s_period_ms = BLE_STREAM_PERIOD_DEFAULT_MS; static uint32_t s_period_ms = BLE_STREAM_PERIOD_DEFAULT_MS;
static TaskHandle_t s_task; static TaskHandle_t s_task;
@@ -146,7 +148,9 @@ static void ble_stream_task(void *param) {
period = BLE_STREAM_PERIOD_MAX_MS; period = BLE_STREAM_PERIOD_MAX_MS;
} }
if (ble_host_ready() && any_accel_stream()) { /* 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; alox_UartMessage msg = alox_UartMessage_init_zero;
uart_cmd_init_response(&msg, alox_MessageType_BLE_STREAM_TICK, uart_cmd_init_response(&msg, alox_MessageType_BLE_STREAM_TICK,
alox_UartMessage_ble_stream_tick_tag); alox_UartMessage_ble_stream_tick_tag);
@@ -161,7 +165,11 @@ static void ble_stream_task(void *param) {
} }
} }
vTaskDelay(pdMS_TO_TICKS(period)); TickType_t delay_ticks = pdMS_TO_TICKS(period);
if (delay_ticks < 1) {
delay_ticks = 1;
}
vTaskDelay(delay_ticks);
} }
s_task = NULL; s_task = NULL;
+1 -1
View File
@@ -4,7 +4,7 @@
#include <stdbool.h> #include <stdbool.h>
#include <stdint.h> #include <stdint.h>
/** Clamp and store BLE push period (8–16 ms). */ /** Clamp and store BLE push period (10–16 ms; min = FreeRTOS tick). */
void ble_stream_set_period_ms(uint32_t period_ms); void ble_stream_set_period_ms(uint32_t period_ms);
uint32_t ble_stream_period_ms(void); uint32_t ble_stream_period_ms(void);
+179 -17
View File
@@ -1,4 +1,5 @@
#include "client_registry.h" #include "client_registry.h"
#include "pod_settings.h"
#include "esp_log.h" #include "esp_log.h"
#include "freertos/FreeRTOS.h" #include "freertos/FreeRTOS.h"
#include "freertos/idf_additions.h" #include "freertos/idf_additions.h"
@@ -13,12 +14,68 @@ typedef struct {
} client_slot_t; } client_slot_t;
static client_slot_t s_clients[CLIENT_REGISTRY_MAX]; static client_slot_t s_clients[CLIENT_REGISTRY_MAX];
static pod_id_map_t s_id_map;
static struct { static struct {
board_lipo_reading_t reading; board_lipo_reading_t reading;
uint32_t updated_at; uint32_t updated_at;
} s_master_battery; } 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) { uint32_t client_registry_now_ms(void) {
return (uint32_t)(xTaskGetTickCount() * portTICK_PERIOD_MS); return (uint32_t)(xTaskGetTickCount() * portTICK_PERIOD_MS);
} }
@@ -86,6 +143,7 @@ static client_slot_t *alloc_slot(const uint8_t mac[CLIENT_MAC_LEN],
static const bma456_tap_config_t k_tap_default = BMA456_TAP_CONFIG_DEFAULT; static const bma456_tap_config_t k_tap_default = BMA456_TAP_CONFIG_DEFAULT;
slot->info.tap_config = k_tap_default; slot->info.tap_config = k_tap_default;
} }
slot->info.uv_protection_enabled = false;
if (out_is_new != NULL) { if (out_is_new != NULL) {
*out_is_new = true; *out_is_new = true;
} }
@@ -95,7 +153,84 @@ static client_slot_t *alloc_slot(const uint8_t mac[CLIENT_MAC_LEN],
return NULL; 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 * const client_info_t *
client_registry_find_by_mac(const uint8_t mac[CLIENT_MAC_LEN]) { client_registry_find_by_mac(const uint8_t mac[CLIENT_MAC_LEN]) {
@@ -171,18 +306,19 @@ void client_registry_check_timeouts(uint32_t timeout_ms) {
uint32_t now = now_ms(); uint32_t now = now_ms();
for (size_t i = 0; i < CLIENT_REGISTRY_MAX; i++) { 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; continue;
} }
uint32_t elapsed = now - s_clients[i].info.last_success_ping_at; uint32_t elapsed = now - s_clients[i].info.last_success_ping_at;
if (elapsed > timeout_ms) { if (elapsed > timeout_ms) {
s_clients[i].info.available = false;
char mac_str[18]; char mac_str[18];
mac_to_str(s_clients[i].info.mac, mac_str, sizeof(mac_str)); 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)s_clients[i].info.id, mac_str,
(unsigned long)elapsed); (unsigned long)elapsed);
s_clients[i].active = false;
memset(&s_clients[i].info, 0, sizeof(s_clients[i].info));
} }
} }
} }
@@ -300,6 +436,41 @@ size_t client_registry_set_tap_config_all(const bma456_tap_config_t *cfg) {
return 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) { static void clear_client_accel(client_slot_t *slot) {
if (slot == NULL) { if (slot == NULL) {
return; return;
@@ -552,24 +723,15 @@ esp_err_t client_registry_update_battery(const uint8_t mac[CLIENT_MAC_LEN],
client_slot_t *slot = find_slot(mac); client_slot_t *slot = find_slot(mac);
if (slot == NULL) { if (slot == NULL) {
bool is_new = false; /* Ids are assigned on presence only; ignore early battery reports. */
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; return ESP_ERR_NOT_FOUND;
} }
ESP_LOGI(TAG, "battery auto-registered id=%lu (report before heartbeat)",
(unsigned long)slave_id);
}
if (slot->info.id != slave_id) { if (slave_id != 0 && slot->info.id != slave_id) {
ESP_LOGW(TAG, "battery id %lu → %lu for mac %02x:…:%02x", ESP_LOGW(TAG, "battery id mismatch slot=%lu report=%lu mac %02x:…:%02x",
(unsigned long)slot->info.id, (unsigned long)slave_id, mac[0], (unsigned long)slot->info.id, (unsigned long)slave_id, mac[0],
mac[5]); mac[5]);
slot->info.id = slave_id; return ESP_ERR_INVALID_STATE;
} }
slot->info.lipo1_valid = lipo1_valid; slot->info.lipo1_valid = lipo1_valid;
+31 -2
View File
@@ -47,14 +47,35 @@ typedef struct {
uint32_t lipo1_mv; uint32_t lipo1_mv;
uint32_t lipo2_mv; uint32_t lipo2_mv;
uint32_t battery_updated_at; 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; } client_info_t;
#define CLIENT_REGISTRY_DEFAULT_ACCEL_DEADZONE 100u #define CLIENT_REGISTRY_DEFAULT_ACCEL_DEADZONE 100u
/** Tap events older than this are discarded (matches accel stream interval). */ /** 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); 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). */ /** Milliseconds since boot (same clock as stored ping timestamps). */
uint32_t client_registry_now_ms(void); uint32_t client_registry_now_ms(void);
@@ -75,7 +96,10 @@ esp_err_t client_registry_heartbeat(const uint8_t mac[CLIENT_MAC_LEN],
uint32_t id, uint32_t version, bool used, uint32_t id, uint32_t version, bool used,
bool *out_is_new, bool *out_reactivated); 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); void client_registry_check_timeouts(uint32_t timeout_ms);
size_t client_registry_count(void); size_t client_registry_count(void);
@@ -97,6 +121,11 @@ esp_err_t client_registry_get_tap_config(uint32_t client_id,
bma456_tap_config_t *cfg_out); bma456_tap_config_t *cfg_out);
size_t client_registry_set_tap_config_all(const bma456_tap_config_t *cfg); 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). */ /** 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], esp_err_t client_registry_update_accel(const uint8_t mac[CLIENT_MAC_LEN],
uint32_t slave_id, int16_t x, int16_t y, uint32_t slave_id, int16_t x, int16_t y,
+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_single = client->tap_notify_single;
proto.tap_notify_double = client->tap_notify_double; proto.tap_notify_double = client->tap_notify_double;
proto.tap_notify_triple = client->tap_notify_triple; 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.funcs.encode = uart_cmd_encode_bytes;
proto.mac.arg = &mac; proto.mac.arg = &mac;
+11 -2
View File
@@ -63,6 +63,10 @@ static const char *message_type_name(uint16_t id) {
return "SET_LOG_LEVEL"; return "SET_LOG_LEVEL";
case alox_MessageType_TAP_CONFIG: case alox_MessageType_TAP_CONFIG:
return "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: default:
return "UNKNOWN"; return "UNKNOWN";
} }
@@ -111,8 +115,13 @@ void vCmdDispatcherTask(void *param) {
bool handled = false; bool handled = false;
for (int i = 0; i < handler_count; i++) { for (int i = 0; i < handler_count; i++) {
if (handlers[i].msg_id == msg.msg_id) { if (handlers[i].msg_id == msg.msg_id) {
ESP_LOGI(TAG, "trigger command %s (0x%02x)", message_type_name(msg.msg_id), if (msg.msg_id == (uint16_t)alox_MessageType_OTA_PAYLOAD) {
(unsigned)msg.msg_id); 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); handlers[i].callback(msg.payload, msg.len);
handled = true; handled = true;
break; 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
+9 -20
View File
@@ -1,8 +1,8 @@
#include "cmd_ota.h" #include "cmd_ota.h"
#include "app_config.h"
#include "led_ring.h" #include "led_ring.h"
#include "ota_espnow.h" #include "ota_espnow.h"
#include "ota_uart.h" #include "ota_uart.h"
#include "pod_reboot.h"
#include "uart_cmd.h" #include "uart_cmd.h"
#include "esp_log.h" #include "esp_log.h"
#include "freertos/FreeRTOS.h" #include "freertos/FreeRTOS.h"
@@ -14,6 +14,8 @@ static const char *TAG = "[OTA_CMD]";
#define OTA_PREPARE_STACK 8192 #define OTA_PREPARE_STACK 8192
#define OTA_PREPARE_PRIO 5 #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_STACK 8192
#define OTA_DIST_PRIO 5 #define OTA_DIST_PRIO 5
@@ -104,24 +106,10 @@ static void ota_prepare_task(void *param) {
vTaskDelete(NULL); vTaskDelete(NULL);
} }
static bool ota_host_is_uart(void) {
const app_config_t *cfg = app_config_get();
if (cfg != NULL && cfg->host_ble) {
ESP_LOGW(TAG, "OTA requires UART host (DIP8=0); BLE host active");
return false;
}
return true;
}
static void handle_ota_start(const uint8_t *data, size_t len) { static void handle_ota_start(const uint8_t *data, size_t len) {
alox_UartMessage uart_msg; alox_UartMessage uart_msg;
alox_OtaStartPayload req = alox_OtaStartPayload_init_zero; alox_OtaStartPayload req = alox_OtaStartPayload_init_zero;
if (!ota_host_is_uart()) {
send_ota_failed(20);
return;
}
if (uart_cmd_decode(data, len, &uart_msg) != ESP_OK) { if (uart_cmd_decode(data, len, &uart_msg) != ESP_OK) {
send_ota_failed( 2); send_ota_failed( 2);
return; return;
@@ -192,8 +180,10 @@ static void handle_ota_payload(const uint8_t *data, size_t len) {
ota_feed_result_t r = ota_uart_feed_chunk(req_ptr->seq, req_ptr->data.bytes, ota_feed_result_t r = ota_uart_feed_chunk(req_ptr->seq, req_ptr->data.bytes,
req_ptr->data.size); req_ptr->data.size);
/* Gap: keep session; host block-retries from the last ACK boundary. */
if (r == OTA_FEED_SEQ_GAP) { if (r == OTA_FEED_SEQ_GAP) {
send_ota_failed(16); ESP_LOGW(TAG, "OTA_PAYLOAD seq gap (got %lu) — waiting for host retry",
(unsigned long)req_ptr->seq);
return; return;
} }
if (r == OTA_FEED_SEQ_DUP) { if (r == OTA_FEED_SEQ_DUP) {
@@ -270,6 +260,8 @@ static void ota_distribute_task(void *param) {
led_ring_ota_success(); led_ring_ota_success();
free(job); free(job);
/* Host already got SUCCESS; reboot onto the staged image. */
pod_schedule_restart();
vTaskDelete(NULL); vTaskDelete(NULL);
} }
@@ -345,14 +337,11 @@ static void ota_start_espnow_task(void *param) {
response.payload.ota_status.error = 0; response.payload.ota_status.error = 0;
uart_cmd_send(&response, TAG); uart_cmd_send(&response, TAG);
led_ring_ota_success(); led_ring_ota_success();
pod_schedule_restart();
vTaskDelete(NULL); vTaskDelete(NULL);
} }
static void handle_ota_start_espnow(const uint8_t *data, size_t len) { static void handle_ota_start_espnow(const uint8_t *data, size_t len) {
if (!ota_host_is_uart()) {
send_ota_failed(20);
return;
}
(void)data; (void)data;
(void)len; (void)len;
+24
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], esp_err_t esp_now_comm_send_accel_stream(const uint8_t mac[CLIENT_MAC_LEN],
uint32_t client_id, bool enable); 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. */ /** Master: configure tap notify flags on one slave. */
esp_err_t esp_now_comm_send_tap_notify(const uint8_t mac[CLIENT_MAC_LEN], esp_err_t esp_now_comm_send_tap_notify(const uint8_t mac[CLIENT_MAC_LEN],
uint32_t client_id, bool single, uint32_t client_id, bool single,
@@ -27,6 +31,10 @@ esp_err_t esp_now_comm_send_tap_config(const uint8_t mac[CLIENT_MAC_LEN],
uint32_t client_id, uint32_t client_id,
const bma456_tap_config_t *cfg); 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. */ /** 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], esp_err_t esp_now_comm_send_unicast_test(const uint8_t mac[CLIENT_MAC_LEN],
uint32_t seq); uint32_t seq);
@@ -55,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], esp_err_t esp_now_comm_send_restart(const uint8_t mac[CLIENT_MAC_LEN],
uint32_t client_id); 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). */ /** Master → slave OTA (unicast). */
esp_err_t esp_now_comm_send_ota_start(const uint8_t mac[CLIENT_MAC_LEN], esp_err_t esp_now_comm_send_ota_start(const uint8_t mac[CLIENT_MAC_LEN],
uint32_t total_size); uint32_t total_size);
+19 -4
View File
@@ -204,10 +204,11 @@ esp_err_t esp_now_core_send_reliable(const uint8_t *dest_mac,
ESPNOW_SEND_RETRY_DELAY_MS, true); ESPNOW_SEND_RETRY_DELAY_MS, true);
} }
esp_err_t esp_now_core_send_fast(const uint8_t *dest_mac, static esp_err_t send_fast_internal(const uint8_t *dest_mac,
const alox_EspNowMessage *msg) { const alox_EspNowMessage *msg,
bool quiet) {
if (s_send_lock == NULL || if (s_send_lock == NULL ||
xSemaphoreTake(s_send_lock, pdMS_TO_TICKS(5000)) != pdTRUE) { xSemaphoreTake(s_send_lock, pdMS_TO_TICKS(5)) != pdTRUE) {
return ESP_ERR_TIMEOUT; return ESP_ERR_TIMEOUT;
} }
@@ -216,14 +217,18 @@ esp_err_t esp_now_core_send_fast(const uint8_t *dest_mac,
esp_err_t err = esp_now_proto_encode(msg, buf, sizeof(buf), &len); esp_err_t err = esp_now_proto_encode(msg, buf, sizeof(buf), &len);
if (err != ESP_OK) { if (err != ESP_OK) {
if (!quiet) {
ESP_LOGW(TAG, "encode failed"); ESP_LOGW(TAG, "encode failed");
}
xSemaphoreGive(s_send_lock); xSemaphoreGive(s_send_lock);
return err; return err;
} }
if (len > ESP_NOW_MAX_DATA_LEN) { if (len > ESP_NOW_MAX_DATA_LEN) {
if (!quiet) {
ESP_LOGW(TAG, "encoded len %u > ESP-NOW max %u", (unsigned)len, ESP_LOGW(TAG, "encoded len %u > ESP-NOW max %u", (unsigned)len,
(unsigned)ESP_NOW_MAX_DATA_LEN); (unsigned)ESP_NOW_MAX_DATA_LEN);
}
xSemaphoreGive(s_send_lock); xSemaphoreGive(s_send_lock);
return ESP_ERR_INVALID_SIZE; return ESP_ERR_INVALID_SIZE;
} }
@@ -234,7 +239,7 @@ esp_err_t esp_now_core_send_fast(const uint8_t *dest_mac,
} }
err = esp_now_send(dest_mac, buf, len); 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_LOGW(TAG, "send type=%u failed: %s", (unsigned)msg->type,
esp_err_to_name(err)); esp_err_to_name(err));
} }
@@ -242,6 +247,16 @@ esp_err_t esp_now_core_send_fast(const uint8_t *dest_mac,
return err; 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, esp_err_t esp_now_core_send(const uint8_t *dest_mac,
const alox_EspNowMessage *msg) { const alox_EspNowMessage *msg) {
return esp_now_core_send_wait(dest_mac, msg); return esp_now_core_send_wait(dest_mac, msg);
+6
View File
@@ -26,6 +26,12 @@ esp_err_t esp_now_core_send(const uint8_t *dest_mac,
/** Like send but does not wait for esp_now send-done (lower latency). */ /** 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, esp_err_t esp_now_core_send_fast(const uint8_t *dest_mac,
const alox_EspNowMessage *msg); 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, esp_err_t esp_now_core_send_wait(const uint8_t *dest_mac,
const alox_EspNowMessage *msg); const alox_EspNowMessage *msg);
/** OTA payloads: wait for send-done with extra NO_MEM backoff (queue drain). */ /** OTA payloads: wait for send-done with extra NO_MEM backoff (queue drain). */
+174 -7
View File
@@ -22,6 +22,8 @@ static const uint8_t ESPNOW_BCAST[ESP_NOW_ETH_ALEN] = {0xff, 0xff, 0xff,
#define ESPNOW_CLIENT_TIMEOUT_MS \ #define ESPNOW_CLIENT_TIMEOUT_MS \
(ESPNOW_HEARTBEAT_INTERVAL_MS * ESPNOW_HEARTBEAT_MISS_COUNT) (ESPNOW_HEARTBEAT_INTERVAL_MS * ESPNOW_HEARTBEAT_MISS_COUNT)
#define ESPNOW_BATTERY_INTERVAL_MS 30000 #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]"; static const char *TAG = "[ESPNOW_M]";
@@ -35,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); 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, static esp_err_t send_accel_deadzone(const uint8_t *dest_mac, uint32_t client_id,
uint32_t deadzone) { uint32_t deadzone) {
alox_EspNowMessage msg = alox_EspNowMessage_init_zero; alox_EspNowMessage msg = alox_EspNowMessage_init_zero;
@@ -63,6 +75,16 @@ static esp_err_t send_tap_config(const uint8_t *dest_mac, uint32_t client_id,
return esp_now_core_send(dest_mac, &msg); 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) { static esp_err_t send_unicast_test(const uint8_t *dest_mac, uint32_t seq) {
alox_EspNowMessage msg = alox_EspNowMessage_init_zero; alox_EspNowMessage msg = alox_EspNowMessage_init_zero;
msg.type = alox_EspNowMessageType_ESPNOW_UNICAST_TEST; msg.type = alox_EspNowMessageType_ESPNOW_UNICAST_TEST;
@@ -168,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); 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, static esp_err_t send_tap_notify(const uint8_t *dest_mac, uint32_t client_id,
bool single, bool double_tap, bool triple) { bool single, bool double_tap, bool triple) {
alox_EspNowMessage msg = alox_EspNowMessage_init_zero; alox_EspNowMessage msg = alox_EspNowMessage_init_zero;
@@ -251,6 +289,70 @@ esp_err_t esp_now_comm_send_restart(const uint8_t mac[CLIENT_MAC_LEN],
return err; 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], esp_err_t esp_now_comm_send_find_me(const uint8_t mac[CLIENT_MAC_LEN],
uint32_t client_id) { uint32_t client_id) {
if (mac == NULL || !esp_now_core_is_master()) { if (mac == NULL || !esp_now_core_is_master()) {
@@ -364,6 +466,24 @@ esp_err_t esp_now_comm_send_accel_stream(const uint8_t mac[CLIENT_MAC_LEN],
return err; 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], esp_err_t esp_now_comm_send_tap_notify(const uint8_t mac[CLIENT_MAC_LEN],
uint32_t client_id, bool single, uint32_t client_id, bool single,
bool double_tap, bool triple) { bool double_tap, bool triple) {
@@ -427,6 +547,24 @@ esp_err_t esp_now_comm_send_tap_config(const uint8_t mac[CLIENT_MAC_LEN],
return 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], static void handle_accel_sample(const uint8_t mac[CLIENT_MAC_LEN],
const alox_EspNowAccelSample *sample) { const alox_EspNowAccelSample *sample) {
if (sample == NULL) { if (sample == NULL) {
@@ -495,25 +633,53 @@ static void handle_client_presence(const alox_EspNowSlavePresence *presence,
esp_now_core_ensure_peer(mac); 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 is_new = false;
bool reactivated = false; bool reactivated = false;
esp_err_t err = client_registry_heartbeat( err = client_registry_heartbeat(mac, assigned_id, presence->version,
mac, presence->slave_id, presence->version, presence->used, &is_new, presence->used, &is_new, &reactivated);
&reactivated);
if (err != ESP_OK) { if (err != ESP_OK) {
ESP_LOGW(TAG, "client registry full"); ESP_LOGW(TAG, "client registry full");
return; 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]; char mac_str[18];
esp_now_core_mac_to_str(mac, mac_str, sizeof(mac_str)); esp_now_core_mac_to_str(mac, mac_str, sizeof(mac_str));
if (is_new) { if (is_new) {
ESP_LOGI(TAG, "client registered id=%lu mac=%s ver=%lu", ESP_LOGI(TAG, "client registered id=%lu (preferred=%lu) mac=%s ver=%lu",
(unsigned long)presence->slave_id, mac_str, (unsigned long)assigned_id, (unsigned long)presence->slave_id,
(unsigned long)presence->version); mac_str, (unsigned long)presence->version);
} else if (reactivated) { } else if (reactivated) {
ESP_LOGI(TAG, "client reconnected id=%lu mac=%s", ESP_LOGI(TAG, "client reconnected id=%lu mac=%s",
(unsigned long)presence->slave_id, mac_str); (unsigned long)assigned_id, mac_str);
} }
} }
@@ -592,6 +758,7 @@ static void discover_task(void *param) {
while (1) { while (1) {
msg.payload.discover.master_ota_pending = ota_session_busy(); 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()) { if (!ota_espnow_distribution_active()) {
esp_now_core_send_fast(ESPNOW_BCAST, &msg); esp_now_core_send_fast(ESPNOW_BCAST, &msg);
} }
+328 -42
View File
@@ -11,8 +11,10 @@
#include "pod_reboot.h" #include "pod_reboot.h"
#include "pod_settings.h" #include "pod_settings.h"
#include "esp_log.h" #include "esp_log.h"
#include "esp_timer.h"
#include "freertos/FreeRTOS.h" #include "freertos/FreeRTOS.h"
#include "freertos/idf_additions.h" #include "freertos/idf_additions.h"
#include "freertos/semphr.h"
#include <string.h> #include <string.h>
#ifndef POWERPOD_FW_VERSION #ifndef POWERPOD_FW_VERSION
@@ -23,7 +25,16 @@
#define SLAVE_MASTER_LOST_MS (ESPNOW_HEARTBEAT_INTERVAL_MS * 5) #define SLAVE_MASTER_LOST_MS (ESPNOW_HEARTBEAT_INTERVAL_MS * 5)
/** While master or slave OTA is in progress (discover may be sparse). */ /** While master or slave OTA is in progress (discover may be sparse). */
#define SLAVE_MASTER_OTA_GRACE_MS 300000u #define SLAVE_MASTER_OTA_GRACE_MS 300000u
#define ESPNOW_ACCEL_INTERVAL_MS 16 /**
* Accel stream cadence (10 ms frame). 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 ESPNOW_BATTERY_INTERVAL_MS 30000
#define SLAVE_BATTERY_AFTER_JOIN_MS 150 #define SLAVE_BATTERY_AFTER_JOIN_MS 150
@@ -35,8 +46,21 @@ static bool s_accel_stream_enabled;
static bool s_tap_notify_single; static bool s_tap_notify_single;
static bool s_tap_notify_double; static bool s_tap_notify_double;
static bool s_tap_notify_triple; 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 uint8_t s_master_mac[ESP_NOW_ETH_ALEN];
static uint32_t s_last_discover_ms; 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 { typedef enum {
SLAVE_TX_SLAVE_INFO = 1, SLAVE_TX_SLAVE_INFO = 1,
@@ -45,15 +69,94 @@ typedef enum {
static QueueHandle_t s_tx_queue; 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) { static bool from_joined_master(const uint8_t *master_mac) {
return s_joined && esp_now_core_mac_equal(master_mac, s_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) { static void fill_presence(alox_EspNowSlavePresence *presence) {
const uint8_t *own = esp_now_core_own_mac(); const uint8_t *own = esp_now_core_own_mac();
presence->network = esp_now_core_network(); presence->network = esp_now_core_network();
presence->version = POWERPOD_FW_VERSION; presence->version = POWERPOD_FW_VERSION;
presence->slave_id = own[5]; presence->slave_id = preferred_id_for_master();
presence->available = true; presence->available = true;
presence->used = false; presence->used = false;
esp_now_proto_setup_presence_encode(presence, own); esp_now_proto_setup_presence_encode(presence, own);
@@ -75,6 +178,21 @@ static void send_presence(const uint8_t *dest_mac, alox_EspNowMessageType type)
esp_now_core_send(dest_mac, &msg); 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, 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) { int16_t x, int16_t y, int16_t z) {
alox_EspNowMessage msg = alox_EspNowMessage_init_zero; alox_EspNowMessage msg = alox_EspNowMessage_init_zero;
@@ -84,7 +202,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.x = x;
msg.payload.accel_sample.y = y; msg.payload.accel_sample.y = y;
msg.payload.accel_sample.z = z; 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, static esp_err_t send_tap_event(const uint8_t *dest_mac, uint32_t slave_id,
@@ -94,7 +212,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.which_payload = alox_EspNowMessage_tap_event_tag;
msg.payload.tap_event.slave_id = slave_id; msg.payload.tap_event.slave_id = slave_id;
msg.payload.tap_event.kind = kind; 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, static esp_err_t send_battery_report(const uint8_t *dest_mac,
@@ -122,6 +240,8 @@ static void reset_join(void) {
s_joined = false; s_joined = false;
s_master_ota_grace = false; s_master_ota_grace = false;
s_accel_stream_enabled = 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)); memset(s_master_mac, 0, sizeof(s_master_mac));
s_last_discover_ms = 0; s_last_discover_ms = 0;
if (s_tx_queue != NULL) { if (s_tx_queue != NULL) {
@@ -129,6 +249,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) { static void queue_tx(slave_tx_op_t op) {
if (s_tx_queue == NULL) { if (s_tx_queue == NULL) {
return; return;
@@ -139,7 +319,7 @@ static void queue_tx(slave_tx_op_t op) {
} }
static void send_battery_to_master(void) { static void send_battery_to_master(void) {
if (!s_joined) { if (!s_joined || s_assigned_id == 0) {
return; return;
} }
@@ -147,7 +327,7 @@ static void send_battery_to_master(void) {
board_input_read_lipo(&reading); board_input_read_lipo(&reading);
alox_EspNowBatteryReport report = alox_EspNowBatteryReport_init_zero; 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.lipo1_valid = reading.lipo1_valid;
report.lipo2_valid = reading.lipo2_valid; report.lipo2_valid = reading.lipo2_valid;
report.lipo1_mv = reading.lipo1_mv; report.lipo1_mv = reading.lipo1_mv;
@@ -261,10 +441,7 @@ static void handle_echo_ping(const uint8_t *master_mac,
static void handle_restart(const uint8_t *master_mac, static void handle_restart(const uint8_t *master_mac,
const alox_EspNowRestart *req) { const alox_EspNowRestart *req) {
const uint8_t *own = esp_now_core_own_mac(); if (!client_id_matches(req->client_id)) {
uint32_t my_id = own[5];
if (req->client_id != 0 && req->client_id != my_id) {
return; return;
} }
if (s_joined && !esp_now_core_mac_equal(master_mac, s_master_mac)) { if (s_joined && !esp_now_core_mac_equal(master_mac, s_master_mac)) {
@@ -273,14 +450,14 @@ static void handle_restart(const uint8_t *master_mac,
char mac_str[18]; char mac_str[18];
esp_now_core_mac_to_str(master_mac, mac_str, sizeof(mac_str)); 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(); pod_schedule_restart();
} }
static void handle_battery_query(const uint8_t *master_mac, static void handle_battery_query(const uint8_t *master_mac,
const alox_EspNowBatteryQuery *query) { const alox_EspNowBatteryQuery *query) {
uint32_t my_id = esp_now_core_own_mac()[5]; if (!client_id_matches(query->client_id)) {
if (query->client_id != 0 && query->client_id != my_id) {
return; return;
} }
if (s_joined && !esp_now_core_mac_equal(master_mac, s_master_mac)) { if (s_joined && !esp_now_core_mac_equal(master_mac, s_master_mac)) {
@@ -291,8 +468,7 @@ static void handle_battery_query(const uint8_t *master_mac,
static void handle_led_ring(const uint8_t *master_mac, static void handle_led_ring(const uint8_t *master_mac,
const alox_EspNowLedRing *msg) { const alox_EspNowLedRing *msg) {
uint32_t my_id = esp_now_core_own_mac()[5]; if (!client_id_matches(msg->client_id)) {
if (msg->client_id != 0 && msg->client_id != my_id) {
return; return;
} }
if (s_joined && !esp_now_core_mac_equal(master_mac, s_master_mac)) { if (s_joined && !esp_now_core_mac_equal(master_mac, s_master_mac)) {
@@ -313,13 +489,12 @@ static void handle_led_ring(const uint8_t *master_mac,
char mac_str[18]; char mac_str[18];
esp_now_core_mac_to_str(master_mac, mac_str, sizeof(mac_str)); 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)", 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); cmd_led_ring_apply(&req);
} }
static void handle_find_me(const uint8_t *master_mac, const alox_EspNowFindMe *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 (!client_id_matches(req->client_id)) {
if (req->client_id != 0 && req->client_id != my_id) {
return; return;
} }
if (s_joined && !esp_now_core_mac_equal(master_mac, s_master_mac)) { if (s_joined && !esp_now_core_mac_equal(master_mac, s_master_mac)) {
@@ -327,14 +502,14 @@ static void handle_find_me(const uint8_t *master_mac, const alox_EspNowFindMe *r
} }
char mac_str[18]; char mac_str[18];
esp_now_core_mac_to_str(master_mac, mac_str, sizeof(mac_str)); 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(); led_ring_find_me();
} }
static void handle_accel_stream(const uint8_t *master_mac, static void handle_accel_stream(const uint8_t *master_mac,
const alox_EspNowAccelStream *cfg) { const alox_EspNowAccelStream *cfg) {
uint32_t my_id = esp_now_core_own_mac()[5]; if (!client_id_matches(cfg->client_id)) {
if (cfg->client_id != 0 && cfg->client_id != my_id) {
return; return;
} }
if (s_joined && !esp_now_core_mac_equal(master_mac, s_master_mac)) { if (s_joined && !esp_now_core_mac_equal(master_mac, s_master_mac)) {
@@ -344,13 +519,49 @@ static void handle_accel_stream(const uint8_t *master_mac,
char mac_str[18]; char mac_str[18];
esp_now_core_mac_to_str(master_mac, mac_str, sizeof(mac_str)); esp_now_core_mac_to_str(master_mac, mac_str, sizeof(mac_str));
ESP_LOGI(TAG, "accel stream %s from master %s (id=%lu)", 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, static void handle_tap_notify(const uint8_t *master_mac,
const alox_EspNowTapNotify *cfg) { const alox_EspNowTapNotify *cfg) {
uint32_t my_id = esp_now_core_own_mac()[5]; if (!client_id_matches(cfg->client_id)) {
if (cfg->client_id != 0 && cfg->client_id != my_id) {
return; return;
} }
if (s_joined && !esp_now_core_mac_equal(master_mac, s_master_mac)) { if (s_joined && !esp_now_core_mac_equal(master_mac, s_master_mac)) {
@@ -364,13 +575,12 @@ static void handle_tap_notify(const uint8_t *master_mac,
ESP_LOGI(TAG, ESP_LOGI(TAG,
"tap notify single=%d double=%d triple=%d from master %s (id=%lu)", "tap notify single=%d double=%d triple=%d from master %s (id=%lu)",
cfg->single, cfg->double_tap, cfg->triple, mac_str, 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, static void handle_accel_deadzone(const uint8_t *master_mac,
const alox_EspNowAccelDeadzone *cfg) { const alox_EspNowAccelDeadzone *cfg) {
uint32_t my_id = esp_now_core_own_mac()[5]; if (!client_id_matches(cfg->client_id)) {
if (cfg->client_id != 0 && cfg->client_id != my_id) {
return; return;
} }
if (s_joined && !esp_now_core_mac_equal(master_mac, s_master_mac)) { if (s_joined && !esp_now_core_mac_equal(master_mac, s_master_mac)) {
@@ -380,7 +590,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_now_core_mac_to_str(master_mac, mac_str, sizeof(mac_str));
ESP_LOGI(TAG, ESP_LOGI(TAG,
"accel deadzone from master %s: %lu LSB id=%lu (sensor %s)", mac_str, "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_is_ready() ? "ok" : "not installed");
bma456_set_accel_deadzone(cfg->deadzone); bma456_set_accel_deadzone(cfg->deadzone);
if (pod_settings_save_accel_deadzone(cfg->deadzone) != ESP_OK) { if (pod_settings_save_accel_deadzone(cfg->deadzone) != ESP_OK) {
@@ -391,8 +601,7 @@ static void handle_accel_deadzone(const uint8_t *master_mac,
static void handle_tap_config(const uint8_t *master_mac, static void handle_tap_config(const uint8_t *master_mac,
const alox_EspNowTapConfig *msg) { const alox_EspNowTapConfig *msg) {
uint32_t my_id = esp_now_core_own_mac()[5]; if (!client_id_matches(msg->client_id)) {
if (msg->client_id != 0 && msg->client_id != my_id) {
return; return;
} }
if (s_joined && !esp_now_core_mac_equal(master_mac, s_master_mac)) { if (s_joined && !esp_now_core_mac_equal(master_mac, s_master_mac)) {
@@ -415,7 +624,7 @@ static void handle_tap_config(const uint8_t *master_mac,
esp_now_core_mac_to_str(master_mac, mac_str, sizeof(mac_str)); esp_now_core_mac_to_str(master_mac, mac_str, sizeof(mac_str));
ESP_LOGI(TAG, ESP_LOGI(TAG,
"tap config from master %s: sens=%u id=%lu (sensor %s)", mac_str, "tap config from master %s: sens=%u id=%lu (sensor %s)", mac_str,
(unsigned)cfg.tap_sens_thres, (unsigned long)my_id, (unsigned)cfg.tap_sens_thres, (unsigned long)my_client_id(),
bma456_is_ready() ? "ok" : "not installed"); bma456_is_ready() ? "ok" : "not installed");
if (!bma456_tap_config_valid(&cfg)) { if (!bma456_tap_config_valid(&cfg)) {
@@ -431,6 +640,45 @@ static void handle_tap_config(const uint8_t *master_mac,
} }
} }
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, static void handle_discover(const uint8_t *sender_mac,
const alox_EspNowDiscover *discover) { const alox_EspNowDiscover *discover) {
if (discover->network != esp_now_core_network()) { if (discover->network != esp_now_core_network()) {
@@ -444,6 +692,7 @@ static void handle_discover(const uint8_t *sender_mac,
return; return;
} }
s_master_ota_grace = discover->master_ota_pending; 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()) { if ((now - s_last_discover_ms) <= slave_master_lost_ms()) {
touch_master_presence(now); touch_master_presence(now);
return; return;
@@ -459,16 +708,19 @@ static void handle_discover(const uint8_t *sender_mac,
memcpy(s_master_mac, sender_mac, ESP_NOW_ETH_ALEN); memcpy(s_master_mac, sender_mac, ESP_NOW_ETH_ALEN);
s_joined = true; s_joined = true;
s_master_ota_grace = discover->master_ota_pending; s_master_ota_grace = discover->master_ota_pending;
apply_master_time_sync(discover->master_time_us);
touch_master_presence(now); touch_master_presence(now);
esp_now_core_ensure_peer(sender_mac); esp_now_core_ensure_peer(sender_mac);
char mac_str[18]; char mac_str[18];
esp_now_core_mac_to_str(sender_mac, mac_str, sizeof(mac_str)); esp_now_core_mac_to_str(sender_mac, mac_str, sizeof(mac_str));
ESP_LOGI(TAG, "joined network %u, master %s", (unsigned)discover->network, ESP_LOGI(TAG, "joined network %u, master %s (preferred_id=%lu)",
mac_str); (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_SLAVE_INFO);
queue_tx(SLAVE_TX_BATTERY); /* Battery after ASSIGN_ID so reports carry the definitive client_id. */
} }
static void check_master_timeout(void) { static void check_master_timeout(void) {
@@ -489,14 +741,16 @@ static void check_master_timeout(void) {
static void accel_stream_task(void *param) { static void accel_stream_task(void *param) {
(void)param; (void)param;
const uint8_t *own = esp_now_core_own_mac();
ESP_LOGI(TAG, "accel stream task (interval %u ms)", ESP_LOGI(TAG,
(unsigned)ESPNOW_ACCEL_INTERVAL_MS); "accel stream task (interval %u ms, %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) { while (1) {
vTaskDelay(pdMS_TO_TICKS(ESPNOW_ACCEL_INTERVAL_MS)); wait_next_accel_slot();
if (!s_joined || !s_accel_stream_enabled || !bma456_is_ready()) { if (!s_joined || s_assigned_id == 0 || !s_accel_stream_enabled ||
!bma456_is_ready()) {
continue; continue;
} }
int16_t x = 0; int16_t x = 0;
@@ -505,13 +759,13 @@ static void accel_stream_task(void *param) {
if (bma456_read_accel(&x, &y, &z) != ESP_OK) { if (bma456_read_accel(&x, &y, &z) != ESP_OK) {
continue; continue;
} }
(void)send_accel_sample(s_master_mac, own[5], x, y, z); (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) { static void on_bma456_tap(bma456_tap_kind_t kind, void *ctx) {
(void)ctx; (void)ctx;
if (!s_joined) { if (!s_joined || s_assigned_id == 0) {
return; return;
} }
bool enabled = false; bool enabled = false;
@@ -531,7 +785,7 @@ static void on_bma456_tap(bma456_tap_kind_t kind, void *ctx) {
if (!enabled) { if (!enabled) {
return; 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) { static void heartbeat_task(void *param) {
@@ -624,6 +878,11 @@ void esp_now_slave_on_recv(const esp_now_recv_info_t *info, const uint8_t *data,
handle_tap_config(info->src_addr, &msg.payload.tap_config); handle_tap_config(info->src_addr, &msg.payload.tap_config);
} }
break; 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: case alox_EspNowMessage_accel_stream_tag:
if (from_joined_master(info->src_addr)) { if (from_joined_master(info->src_addr)) {
handle_accel_stream(info->src_addr, &msg.payload.accel_stream); handle_accel_stream(info->src_addr, &msg.payload.accel_stream);
@@ -634,6 +893,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); handle_tap_notify(info->src_addr, &msg.payload.tap_notify);
} }
break; 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: case alox_EspNowMessage_battery_query_tag:
if (from_joined_master(info->src_addr)) { if (from_joined_master(info->src_addr)) {
handle_battery_query(info->src_addr, &msg.payload.battery_query); handle_battery_query(info->src_addr, &msg.payload.battery_query);
@@ -654,6 +918,12 @@ 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); handle_restart(info->src_addr, &msg.payload.restart);
} }
break; 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: case alox_EspNowMessage_ota_start_tag:
if (from_joined_master(info->src_addr)) { if (from_joined_master(info->src_addr)) {
ota_espnow_slave_on_start(info->src_addr, &msg.payload.ota_start); ota_espnow_slave_on_start(info->src_addr, &msg.payload.ota_start);
@@ -668,6 +938,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) { esp_err_t esp_now_slave_start(void) {
reset_join(); 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)); s_tx_queue = xQueueCreate(4, sizeof(slave_tx_op_t));
if (s_tx_queue == NULL) { if (s_tx_queue == NULL) {
+10 -2
View File
@@ -2,6 +2,7 @@
#include "cmd_handler.h" #include "cmd_handler.h"
#include "esp_log.h" #include "esp_log.h"
#include "uart.h" #include "uart.h"
#include "uart_messages.pb.h"
#include <stdlib.h> #include <stdlib.h>
#include <string.h> #include <string.h>
@@ -87,9 +88,16 @@ void host_rx_bytes(const uint8_t *data, size_t len) {
for (size_t i = 0; i < len; i++) { for (size_t i = 0; i < len; i++) {
if (parse_uart_byte(data[i], &s_rx_packet)) { if (parse_uart_byte(data[i], &s_rx_packet)) {
ESP_LOGI(TAG, "received message cmd=0x%02x len=%u", uint8_t cmd =
s_rx_packet.len > 0 ? s_rx_packet.payload[0] : 0, 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); (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); host_enqueue_packet(s_rx_packet.payload, s_rx_packet.len);
} }
} }
+47
View File
@@ -27,6 +27,16 @@ static led_strip_handle_t led_ring;
#define LED_RING_BATTERY_LOW_R 255 #define LED_RING_BATTERY_LOW_R 255
#define LED_RING_BATTERY_LOW_G 0 #define LED_RING_BATTERY_LOW_G 0
#define LED_RING_BATTERY_LOW_B 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 QueueHandle_t led_queue;
static SemaphoreHandle_t s_battery_low_done; static SemaphoreHandle_t s_battery_low_done;
@@ -166,6 +176,33 @@ void vTaskLedRing(void *pvParameters) {
xSemaphoreGive(s_battery_low_done); xSemaphoreGive(s_battery_low_done);
} }
continue; 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); led_strip_refresh(led_ring);
} }
@@ -256,3 +293,13 @@ void led_ring_show_battery_low(void) {
led_ring_send_command(&cmd); led_ring_send_command(&cmd);
(void)xSemaphoreTake(s_battery_low_done, portMAX_DELAY); (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);
}
+9 -1
View File
@@ -19,7 +19,9 @@ typedef enum {
LED_CMD_PROGRESS, LED_CMD_PROGRESS,
LED_CMD_BLINK, LED_CMD_BLINK,
LED_CMD_FIND_ME, LED_CMD_FIND_ME,
LED_CMD_BATTERY_LOW LED_CMD_BATTERY_LOW,
LED_CMD_BOOT,
LED_CMD_CONNECTED
} led_mode_t; } led_mode_t;
typedef struct { typedef struct {
@@ -53,4 +55,10 @@ void led_ring_find_me(void);
/** First 4 LEDs red at ~10 % for 5 s, then off (blocking in LED task). */ /** First 4 LEDs red at ~10 % for 5 s, then off (blocking in LED task). */
void led_ring_show_battery_low(void); 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 #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
+118 -16
View File
@@ -10,6 +10,7 @@
#include "freertos/event_groups.h" #include "freertos/event_groups.h"
#include "freertos/idf_additions.h" #include "freertos/idf_additions.h"
#include "ota_uart.h" #include "ota_uart.h"
#include "pod_reboot.h"
#include <string.h> #include <string.h>
static const char *TAG = "[OTA_ESPNOW]"; static const char *TAG = "[OTA_ESPNOW]";
@@ -28,6 +29,12 @@ static const char *TAG = "[OTA_ESPNOW]";
#define OTA_ST_SUCCESS 4u #define OTA_ST_SUCCESS 4u
#define OTA_ST_FAILED 5u #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). */ /** ESP-NOW OTA receive on slave (blue progress bar). */
#define OTA_LED_ESPNOW_RX_R 0 #define OTA_LED_ESPNOW_RX_R 0
#define OTA_LED_ESPNOW_RX_G 0 #define OTA_LED_ESPNOW_RX_G 0
@@ -58,6 +65,7 @@ typedef struct {
static EventGroupHandle_t s_eg; static EventGroupHandle_t s_eg;
static QueueHandle_t s_slave_work_queue; static QueueHandle_t s_slave_work_queue;
static bool s_distribution_active; static bool s_distribution_active;
static TaskHandle_t s_slave_prep_task;
typedef struct { typedef struct {
uint8_t count; uint8_t count;
@@ -70,6 +78,9 @@ typedef struct {
static ota_dist_t s_dist; 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 { typedef struct {
uint32_t client_id; uint32_t client_id;
uint32_t bytes_written; uint32_t bytes_written;
@@ -164,14 +175,52 @@ static uint32_t all_target_bits(void) {
return (1u << s_dist.count) - 1u; 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) { static bool wait_target_bits(uint32_t want_bits, uint32_t timeout_ms) {
if (s_eg == NULL || want_bits == 0) { if (s_eg == NULL || want_bits == 0) {
return false; return false;
} }
EventBits_t got =
xEventGroupWaitBits(s_eg, want_bits, pdTRUE, pdTRUE, const TickType_t start = xTaskGetTickCount();
pdMS_TO_TICKS(timeout_ms)); const TickType_t timeout_ticks = pdMS_TO_TICKS(timeout_ms);
return (got & want_bits) == want_bits;
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) { static uint32_t block_ack_timeout_ms(void) {
@@ -272,9 +321,10 @@ static void process_slave_payload(const uint8_t master_mac[6],
ota_feed_result_t r = ota_uart_feed_chunk(payload->seq, payload->data.bytes, ota_feed_result_t r = ota_uart_feed_chunk(payload->seq, payload->data.bytes,
payload->data.size); payload->data.size);
/* Gap: keep session; master block-retries from the last ACK boundary. */
if (r == OTA_FEED_SEQ_GAP) { if (r == OTA_FEED_SEQ_GAP) {
led_ring_ota_failed(); ESP_LOGW(TAG, "ESP-NOW OTA seq gap (got %lu) — waiting for retry",
send_slave_status(master_mac, OTA_ST_FAILED, ota_uart_bytes_written(), 16); (unsigned long)payload->seq);
return; return;
} }
if (r == OTA_FEED_SEQ_DUP) { if (r == OTA_FEED_SEQ_DUP) {
@@ -329,6 +379,9 @@ static void process_slave_end(const uint8_t master_mac[6]) {
led_ring_ota_success(); led_ring_ota_success();
ESP_LOGI(TAG, "slave OTA success (%lu bytes), reboot to run", ESP_LOGI(TAG, "slave OTA success (%lu bytes), reboot to run",
(unsigned long)written); (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) { static void ota_slave_work_task(void *param) {
@@ -372,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) { static void ota_slave_prepare_task(void *param) {
uint32_t total_size = (uint32_t)(uintptr_t)param; uint32_t total_size = (uint32_t)(uintptr_t)param;
uint8_t master_mac[6]; uint8_t master_mac[6];
if (!esp_now_comm_get_master_mac(master_mac)) { if (!esp_now_comm_get_master_mac(master_mac)) {
s_slave_prep_task = NULL;
vTaskDelete(NULL); vTaskDelete(NULL);
return; return;
} }
@@ -386,6 +449,7 @@ static void ota_slave_prepare_task(void *param) {
int slot = ota_uart_prepare(total_size); int slot = ota_uart_prepare(total_size);
if (slot < 0) { if (slot < 0) {
send_slave_status(master_mac, OTA_ST_FAILED, 0, 1); send_slave_status(master_mac, OTA_ST_FAILED, 0, 1);
s_slave_prep_task = NULL;
vTaskDelete(NULL); vTaskDelete(NULL);
return; return;
} }
@@ -393,6 +457,7 @@ static void ota_slave_prepare_task(void *param) {
send_slave_status(master_mac, OTA_ST_READY, 0, 0); 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, led_ring_show_ota_progress(0, total_size, OTA_LED_ESPNOW_RX_R, OTA_LED_ESPNOW_RX_G,
OTA_LED_ESPNOW_RX_B); OTA_LED_ESPNOW_RX_B);
s_slave_prep_task = NULL;
vTaskDelete(NULL); vTaskDelete(NULL);
} }
@@ -404,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); ESP_LOGI(TAG, "ESP-NOW OTA_START (%lu bytes)", (unsigned long)start->total_size);
if (ota_uart_is_active()) { /* Duplicate START while prepare task still running — ignore. */
queue_slave_status(master_mac, OTA_ST_FAILED, 0, 4); if (s_slave_prep_task != NULL) {
ESP_LOGW(TAG, "OTA prepare already in flight — ignore START");
return; 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, if (xTaskCreate(ota_slave_prepare_task, "ota_esp_prep", OTA_ESPNOW_PREPARE_STACK,
(void *)(uintptr_t)start->total_size, OTA_ESPNOW_PREPARE_PRIO, (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); queue_slave_status(master_mac, OTA_ST_FAILED, 0, 5);
} }
} }
@@ -479,6 +561,8 @@ void ota_espnow_master_on_status(const uint8_t slave_mac[6],
status->error); status->error);
ESP_LOGW(TAG, "slave %lu OTA failed (err=%lu)", ESP_LOGW(TAG, "slave %lu OTA failed (err=%lu)",
(unsigned long)s_dist.id[idx], (unsigned long)status->error); (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; break;
default: default:
break; break;
@@ -527,7 +611,7 @@ static esp_err_t distribute_image(const esp_partition_t *partition,
uint32_t target_mask = all_target_bits(); uint32_t target_mask = all_target_bits();
esp_err_t err; 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++) { for (uint8_t i = 0; i < s_dist.count; i++) {
err = esp_now_comm_send_ota_start(s_dist.mac[i], size); err = esp_now_comm_send_ota_start(s_dist.mac[i], size);
if (err != ESP_OK) { if (err != ESP_OK) {
@@ -540,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 (!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"); ESP_LOGE(TAG, "timeout waiting for slave OTA ready");
prog_end(); prog_end();
s_distribution_active = false; s_distribution_active = false;
@@ -551,7 +641,6 @@ static esp_err_t distribute_image(const esp_partition_t *partition,
s_dist.progress.aggregate(0, size, s_dist.count); s_dist.progress.aggregate(0, size, s_dist.count);
} }
uint8_t block_buf[OTA_UART_FLASH_BLOCK_SIZE];
uint32_t offset = 0; uint32_t offset = 0;
uint32_t seq = 0; uint32_t seq = 0;
@@ -561,7 +650,7 @@ static esp_err_t distribute_image(const esp_partition_t *partition,
block_len = OTA_UART_FLASH_BLOCK_SIZE; 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) { if (err != ESP_OK) {
ESP_LOGE(TAG, "partition read @%lu failed: %s", (unsigned long)offset, ESP_LOGE(TAG, "partition read @%lu failed: %s", (unsigned long)offset,
esp_err_to_name(err)); esp_err_to_name(err));
@@ -591,7 +680,7 @@ static esp_err_t distribute_image(const esp_partition_t *partition,
(unsigned long)send_attempt, (unsigned long)send_attempt,
(unsigned long)OTA_BLOCK_MAX_RETRIES); (unsigned long)OTA_BLOCK_MAX_RETRIES);
} }
err = send_block_payloads(block_buf, block_len, &seq); err = send_block_payloads(s_ota_block_buf, block_len, &seq);
if (err == ESP_OK) { if (err == ESP_OK) {
block_sent = true; block_sent = true;
break; break;
@@ -614,6 +703,13 @@ static esp_err_t distribute_image(const esp_partition_t *partition,
acked = true; acked = true;
break; 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); log_missing_block_acks(s_dist.expected_bytes);
if (attempt >= OTA_BLOCK_MAX_RETRIES) { if (attempt >= OTA_BLOCK_MAX_RETRIES) {
break; break;
@@ -622,9 +718,9 @@ static esp_err_t distribute_image(const esp_partition_t *partition,
(unsigned long)s_dist.expected_bytes, (unsigned long)s_dist.expected_bytes,
(unsigned long)(attempt + 1), (unsigned long)(attempt + 1),
(unsigned long)OTA_BLOCK_MAX_RETRIES); (unsigned long)OTA_BLOCK_MAX_RETRIES);
xEventGroupClearBits(s_eg, target_mask); xEventGroupClearBits(s_eg, target_mask | OTA_EG_FAIL_BIT);
seq = block_start_seq; seq = block_start_seq;
err = send_block_payloads(block_buf, block_len, &seq); err = send_block_payloads(s_ota_block_buf, block_len, &seq);
if (err != ESP_OK) { if (err != ESP_OK) {
prog_end(); prog_end();
s_distribution_active = false; s_distribution_active = false;
@@ -653,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++) { for (uint8_t i = 0; i < s_dist.count; i++) {
err = esp_now_comm_send_ota_end(s_dist.mac[i]); err = esp_now_comm_send_ota_end(s_dist.mac[i]);
if (err != ESP_OK) { if (err != ESP_OK) {
@@ -664,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 (!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"); ESP_LOGE(TAG, "timeout waiting for slave OTA success");
prog_end(); prog_end();
s_distribution_active = false; s_distribution_active = false;
+6
View File
@@ -97,7 +97,13 @@ int ota_uart_prepare(uint32_t total_size) {
esp_ota_handle_t handle; esp_ota_handle_t handle;
esp_err_t err = esp_ota_begin(update_partition, OTA_SIZE_UNKNOWN, &handle); esp_err_t err = esp_ota_begin(update_partition, OTA_SIZE_UNKNOWN, &handle);
if (err != ESP_OK) { if (err != ESP_OK) {
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)); ESP_LOGE(TAG, "esp_ota_begin failed: %s", esp_err_to_name(err));
}
return -1; return -1;
} }
+3 -1
View File
@@ -45,7 +45,9 @@ void ota_uart_abort(void);
/** /**
* Append up to 200 bytes with strict seq checking (0, 1, 2, …). * Append up to 200 bytes with strict seq checking (0, 1, 2, …).
* Duplicates (seq < expected) return OTA_FEED_SEQ_DUP; gaps return OTA_FEED_SEQ_GAP. * 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); ota_feed_result_t ota_uart_feed_chunk(uint32_t seq, const uint8_t *data, size_t len);
+300
View File
@@ -9,10 +9,33 @@ static const char *TAG = "[SETTINGS]";
static const char *NS = "powerpod"; static const char *NS = "powerpod";
static const char *KEY_ACCEL_DZ = "accel_dz"; static const char *KEY_ACCEL_DZ = "accel_dz";
static const char *KEY_UV_LATCH = "uv_latch"; 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_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 ACCEL_DEADZONE_MAX 4095u
#define TAP_CFG_NVS_VERSION 1u #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). */ /** Packed layout for NVS blob (stable across rebuilds). */
typedef struct __attribute__((packed)) { typedef struct __attribute__((packed)) {
@@ -302,3 +325,280 @@ esp_err_t pod_settings_set_uv_latched(bool latched) {
esp_err_t pod_settings_clear_uv_latched(void) { esp_err_t pod_settings_clear_uv_latched(void) {
return pod_settings_set_uv_latched(false); 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;
}
+39
View File
@@ -35,4 +35,43 @@ bool pod_settings_is_uv_latched(void);
esp_err_t pod_settings_set_uv_latched(bool latched); esp_err_t pod_settings_set_uv_latched(bool latched);
esp_err_t pod_settings_clear_uv_latched(void); 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 #endif
+13 -1
View File
@@ -2,6 +2,7 @@
#include "cmd_handler.h" #include "cmd_handler.h"
#include "cmd_accel_deadzone.h" #include "cmd_accel_deadzone.h"
#include "cmd_accel_stream.h" #include "cmd_accel_stream.h"
#include "cmd_input_send_mode.h"
#include "cmd_tap_notify.h" #include "cmd_tap_notify.h"
#include "cmd_tap_config.h" #include "cmd_tap_config.h"
#include "cmd_cache_status.h" #include "cmd_cache_status.h"
@@ -9,12 +10,15 @@
#include "cmd_espnow_echo_ping.h" #include "cmd_espnow_echo_ping.h"
#include "cmd_espnow_find_me.h" #include "cmd_espnow_find_me.h"
#include "cmd_restart.h" #include "cmd_restart.h"
#include "cmd_client_id_map_reset.h"
#include "cmd_client_info.h" #include "cmd_client_info.h"
#include "cmd_version.h" #include "cmd_version.h"
#include "cmd_ota.h" #include "cmd_ota.h"
#include "cmd_ota_slave_progress.h" #include "cmd_ota_slave_progress.h"
#include "ota_confirm.h"
#include "cmd_led_ring.h" #include "cmd_led_ring.h"
#include "cmd_battery.h" #include "cmd_battery.h"
#include "cmd_battery_uv.h"
#include "cmd_set_log_level.h" #include "cmd_set_log_level.h"
#include "esp_now_comm.h" #include "esp_now_comm.h"
#include "powerpod.h" #include "powerpod.h"
@@ -203,6 +207,7 @@ void app_main(void) {
} }
led_ring_init(); led_ring_init();
led_ring_boot_anim();
if (app_config.master) { if (app_config.master) {
cmd_queue = xQueueCreate(64, sizeof(generic_msg_t)); cmd_queue = xQueueCreate(64, sizeof(generic_msg_t));
@@ -219,6 +224,7 @@ void app_main(void) {
cmd_client_info_register(); cmd_client_info_register();
cmd_accel_deadzone_register(); cmd_accel_deadzone_register();
cmd_accel_stream_register(); cmd_accel_stream_register();
cmd_input_send_mode_register();
cmd_tap_notify_register(); cmd_tap_notify_register();
cmd_tap_config_register(); cmd_tap_config_register();
cmd_cache_status_register(); cmd_cache_status_register();
@@ -226,14 +232,20 @@ void app_main(void) {
cmd_espnow_echo_ping_register(); cmd_espnow_echo_ping_register();
cmd_espnow_find_me_register(); cmd_espnow_find_me_register();
cmd_restart_register(); cmd_restart_register();
cmd_client_id_map_reset_register();
cmd_led_ring_register(); cmd_led_ring_register();
cmd_battery_register(); cmd_battery_register();
cmd_battery_uv_register();
cmd_ota_register(); cmd_ota_register();
cmd_ota_slave_progress_register(); cmd_ota_slave_progress_register();
cmd_set_log_level_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) { while (1) {
vTaskDelay(portMAX_DELAY); vTaskDelay(portMAX_DELAY);
} }
+1 -1
View File
@@ -3,7 +3,7 @@
/** 1 = software LiPo UV latch + energy-save mode; 0 = disabled (LED mode 6 still works). */ /** 1 = software LiPo UV latch + energy-save mode; 0 = disabled (LED mode 6 still works). */
#ifndef POWERPOD_BATTERY_UV_ENABLE #ifndef POWERPOD_BATTERY_UV_ENABLE
#define POWERPOD_BATTERY_UV_ENABLE 0 #define POWERPOD_BATTERY_UV_ENABLE 1
#endif #endif
#define DIP_MASTER 4 #define DIP_MASTER 4
+12
View File
@@ -27,6 +27,12 @@ PB_BIND(alox_EspNowDiscover, alox_EspNowDiscover, AUTO)
PB_BIND(alox_EspNowSlavePresence, alox_EspNowSlavePresence, AUTO) PB_BIND(alox_EspNowSlavePresence, alox_EspNowSlavePresence, AUTO)
PB_BIND(alox_EspNowAssignId, alox_EspNowAssignId, AUTO)
PB_BIND(alox_EspNowClearIdBinding, alox_EspNowClearIdBinding, AUTO)
PB_BIND(alox_EspNowAccelDeadzone, alox_EspNowAccelDeadzone, AUTO) PB_BIND(alox_EspNowAccelDeadzone, alox_EspNowAccelDeadzone, AUTO)
@@ -48,6 +54,12 @@ PB_BIND(alox_EspNowTapConfig, alox_EspNowTapConfig, AUTO)
PB_BIND(alox_EspNowTapEvent, alox_EspNowTapEvent, AUTO) PB_BIND(alox_EspNowTapEvent, alox_EspNowTapEvent, AUTO)
PB_BIND(alox_EspNowInputSendMode, alox_EspNowInputSendMode, AUTO)
PB_BIND(alox_EspNowBatteryUv, alox_EspNowBatteryUv, AUTO)
PB_BIND(alox_EspNowBatteryReport, alox_EspNowBatteryReport, AUTO) PB_BIND(alox_EspNowBatteryReport, alox_EspNowBatteryReport, AUTO)
+116 -10
View File
@@ -32,7 +32,15 @@ typedef enum _alox_EspNowMessageType {
alox_EspNowMessageType_ESPNOW_TAP_EVENT = 18, alox_EspNowMessageType_ESPNOW_TAP_EVENT = 18,
alox_EspNowMessageType_ESPNOW_ECHO_PING = 19, alox_EspNowMessageType_ESPNOW_ECHO_PING = 19,
alox_EspNowMessageType_ESPNOW_ECHO_PONG = 20, alox_EspNowMessageType_ESPNOW_ECHO_PONG = 20,
alox_EspNowMessageType_ESPNOW_SET_TAP_CONFIG = 21 alox_EspNowMessageType_ESPNOW_SET_TAP_CONFIG = 21,
/* * Master → slave: assigned logical client id after join/presence. */
alox_EspNowMessageType_ESPNOW_ASSIGN_ID = 22,
/* * Master → slave: forget persisted master/id binding (debug reset). */
alox_EspNowMessageType_ESPNOW_CLEAR_ID_BINDING = 23,
/* * Master → slave: accel/tap ESP-NOW send path (best_effort/fast/wait/reliable). */
alox_EspNowMessageType_ESPNOW_SET_INPUT_SEND_MODE = 24,
/* * Master → slave: enable/disable software LiPo UV protection (NVS). */
alox_EspNowMessageType_ESPNOW_SET_BATTERY_UV = 25
} alox_EspNowMessageType; } alox_EspNowMessageType;
/* Struct definitions */ /* Struct definitions */
@@ -68,29 +76,44 @@ typedef struct _alox_EspNowDiscover {
uint32_t network; uint32_t network;
/* * Master is in an OTA session (UART upload or ESP-NOW distribution). */ /* * Master is in an OTA session (UART upload or ESP-NOW distribution). */
bool master_ota_pending; bool master_ota_pending;
/* *
Master esp_timer_get_time() (µs) at broadcast. Slaves use this to phase
accel TX into 1 ms slots: offset_ms = (assigned_id - 1) % 8. */
uint64_t master_time_us;
} alox_EspNowDiscover; } alox_EspNowDiscover;
typedef struct _alox_EspNowSlavePresence { typedef struct _alox_EspNowSlavePresence {
uint32_t network; uint32_t network;
pb_callback_t mac; pb_callback_t mac;
uint32_t version; uint32_t version;
/* * Preferred id from NVS for this master (0 = none / unbound). */
uint32_t slave_id; uint32_t slave_id;
bool available; bool available;
bool used; bool used;
} alox_EspNowSlavePresence; } alox_EspNowSlavePresence;
/* * Master → slave: definitive logical client id for this session/master. */
typedef struct _alox_EspNowAssignId {
uint32_t assigned_id;
} alox_EspNowAssignId;
/* * Master → slave: clear persisted {master_mac, assigned_id} binding. */
typedef struct _alox_EspNowClearIdBinding {
char dummy_field;
} alox_EspNowClearIdBinding;
typedef struct _alox_EspNowAccelDeadzone { typedef struct _alox_EspNowAccelDeadzone {
uint32_t deadzone; uint32_t deadzone;
uint32_t client_id; /* 0 = all slaves; otherwise only matching slave_id applies */ uint32_t client_id; /* 0 = all slaves; otherwise only matching slave_id applies */
} alox_EspNowAccelDeadzone; } alox_EspNowAccelDeadzone;
/* * Master → slave: enable/disable periodic accel ESP-NOW stream (~16 ms). */ /* * Master → slave: enable/disable periodic accel ESP-NOW stream (~10 ms). */
typedef struct _alox_EspNowAccelStream { typedef struct _alox_EspNowAccelStream {
bool enable; bool enable;
uint32_t client_id; uint32_t client_id;
} alox_EspNowAccelStream; } alox_EspNowAccelStream;
/* * Slave → master: latest BMA456 sample (sent ~every 16 ms). */ /* * Slave → master: latest BMA456 sample (best-effort, ~every 10 ms). */
typedef struct _alox_EspNowAccelSample { typedef struct _alox_EspNowAccelSample {
uint32_t slave_id; uint32_t slave_id;
int32_t x; int32_t x;
@@ -132,6 +155,20 @@ typedef struct _alox_EspNowTapEvent {
uint32_t kind; uint32_t kind;
} alox_EspNowTapEvent; } alox_EspNowTapEvent;
/* *
Master → slave: which esp_now_core_send_* path to use for ACCEL_SAMPLE / TAP_EVENT.
Values match uart InputSendMode (0=best_effort … 3=reliable). */
typedef struct _alox_EspNowInputSendMode {
uint32_t client_id; /* 0 = all slaves; otherwise only matching slave_id applies */
uint32_t mode;
} alox_EspNowInputSendMode;
/* * Master → slave: software LiPo UV protection enable (persisted in NVS). */
typedef struct _alox_EspNowBatteryUv {
uint32_t client_id; /* 0 = all slaves; otherwise only matching slave_id applies */
bool enabled;
} alox_EspNowBatteryUv;
/* * Slave → master: LiPo voltages (periodic ~30 s and on query). */ /* * Slave → master: LiPo voltages (periodic ~30 s and on query). */
typedef struct _alox_EspNowBatteryReport { typedef struct _alox_EspNowBatteryReport {
uint32_t client_id; uint32_t client_id;
@@ -204,6 +241,10 @@ typedef struct _alox_EspNowMessage {
alox_EspNowEchoPing echo_ping; alox_EspNowEchoPing echo_ping;
alox_EspNowEchoPong echo_pong; alox_EspNowEchoPong echo_pong;
alox_EspNowTapConfig tap_config; alox_EspNowTapConfig tap_config;
alox_EspNowAssignId assign_id;
alox_EspNowClearIdBinding clear_id_binding;
alox_EspNowInputSendMode input_send_mode;
alox_EspNowBatteryUv battery_uv;
} payload; } payload;
} alox_EspNowMessage; } alox_EspNowMessage;
@@ -214,8 +255,12 @@ extern "C" {
/* Helper constants for enums */ /* Helper constants for enums */
#define _alox_EspNowMessageType_MIN alox_EspNowMessageType_ESPNOW_UNKNOWN #define _alox_EspNowMessageType_MIN alox_EspNowMessageType_ESPNOW_UNKNOWN
#define _alox_EspNowMessageType_MAX alox_EspNowMessageType_ESPNOW_SET_TAP_CONFIG #define _alox_EspNowMessageType_MAX alox_EspNowMessageType_ESPNOW_SET_BATTERY_UV
#define _alox_EspNowMessageType_ARRAYSIZE ((alox_EspNowMessageType)(alox_EspNowMessageType_ESPNOW_SET_TAP_CONFIG+1)) #define _alox_EspNowMessageType_ARRAYSIZE ((alox_EspNowMessageType)(alox_EspNowMessageType_ESPNOW_SET_BATTERY_UV+1))
@@ -246,8 +291,10 @@ extern "C" {
#define alox_EspNowEchoPong_init_default {0, 0} #define alox_EspNowEchoPong_init_default {0, 0}
#define alox_EspNowFindMe_init_default {0} #define alox_EspNowFindMe_init_default {0}
#define alox_EspNowRestart_init_default {0} #define alox_EspNowRestart_init_default {0}
#define alox_EspNowDiscover_init_default {0, 0} #define alox_EspNowDiscover_init_default {0, 0, 0}
#define alox_EspNowSlavePresence_init_default {0, {{NULL}, NULL}, 0, 0, 0, 0} #define alox_EspNowSlavePresence_init_default {0, {{NULL}, NULL}, 0, 0, 0, 0}
#define alox_EspNowAssignId_init_default {0}
#define alox_EspNowClearIdBinding_init_default {0}
#define alox_EspNowAccelDeadzone_init_default {0, 0} #define alox_EspNowAccelDeadzone_init_default {0, 0}
#define alox_EspNowAccelStream_init_default {0, 0} #define alox_EspNowAccelStream_init_default {0, 0}
#define alox_EspNowAccelSample_init_default {0, 0, 0, 0} #define alox_EspNowAccelSample_init_default {0, 0, 0, 0}
@@ -255,6 +302,8 @@ extern "C" {
#define alox_EspNowTapNotify_init_default {0, 0, 0, 0} #define alox_EspNowTapNotify_init_default {0, 0, 0, 0}
#define alox_EspNowTapConfig_init_default {0, 0, 0, 0, 0, 0, 0, 0, 0, 0} #define alox_EspNowTapConfig_init_default {0, 0, 0, 0, 0, 0, 0, 0, 0, 0}
#define alox_EspNowTapEvent_init_default {0, 0} #define alox_EspNowTapEvent_init_default {0, 0}
#define alox_EspNowInputSendMode_init_default {0, 0}
#define alox_EspNowBatteryUv_init_default {0, 0}
#define alox_EspNowBatteryReport_init_default {0, 0, 0, 0, 0} #define alox_EspNowBatteryReport_init_default {0, 0, 0, 0, 0}
#define alox_EspNowLedRing_init_default {0, 0, 0, 0, 0, 0, 0, 0, 0, 0} #define alox_EspNowLedRing_init_default {0, 0, 0, 0, 0, 0, 0, 0, 0, 0}
#define alox_EspNowOtaStart_init_default {0} #define alox_EspNowOtaStart_init_default {0}
@@ -267,8 +316,10 @@ extern "C" {
#define alox_EspNowEchoPong_init_zero {0, 0} #define alox_EspNowEchoPong_init_zero {0, 0}
#define alox_EspNowFindMe_init_zero {0} #define alox_EspNowFindMe_init_zero {0}
#define alox_EspNowRestart_init_zero {0} #define alox_EspNowRestart_init_zero {0}
#define alox_EspNowDiscover_init_zero {0, 0} #define alox_EspNowDiscover_init_zero {0, 0, 0}
#define alox_EspNowSlavePresence_init_zero {0, {{NULL}, NULL}, 0, 0, 0, 0} #define alox_EspNowSlavePresence_init_zero {0, {{NULL}, NULL}, 0, 0, 0, 0}
#define alox_EspNowAssignId_init_zero {0}
#define alox_EspNowClearIdBinding_init_zero {0}
#define alox_EspNowAccelDeadzone_init_zero {0, 0} #define alox_EspNowAccelDeadzone_init_zero {0, 0}
#define alox_EspNowAccelStream_init_zero {0, 0} #define alox_EspNowAccelStream_init_zero {0, 0}
#define alox_EspNowAccelSample_init_zero {0, 0, 0, 0} #define alox_EspNowAccelSample_init_zero {0, 0, 0, 0}
@@ -276,6 +327,8 @@ extern "C" {
#define alox_EspNowTapNotify_init_zero {0, 0, 0, 0} #define alox_EspNowTapNotify_init_zero {0, 0, 0, 0}
#define alox_EspNowTapConfig_init_zero {0, 0, 0, 0, 0, 0, 0, 0, 0, 0} #define alox_EspNowTapConfig_init_zero {0, 0, 0, 0, 0, 0, 0, 0, 0, 0}
#define alox_EspNowTapEvent_init_zero {0, 0} #define alox_EspNowTapEvent_init_zero {0, 0}
#define alox_EspNowInputSendMode_init_zero {0, 0}
#define alox_EspNowBatteryUv_init_zero {0, 0}
#define alox_EspNowBatteryReport_init_zero {0, 0, 0, 0, 0} #define alox_EspNowBatteryReport_init_zero {0, 0, 0, 0, 0}
#define alox_EspNowLedRing_init_zero {0, 0, 0, 0, 0, 0, 0, 0, 0, 0} #define alox_EspNowLedRing_init_zero {0, 0, 0, 0, 0, 0, 0, 0, 0, 0}
#define alox_EspNowOtaStart_init_zero {0} #define alox_EspNowOtaStart_init_zero {0}
@@ -294,12 +347,14 @@ extern "C" {
#define alox_EspNowRestart_client_id_tag 1 #define alox_EspNowRestart_client_id_tag 1
#define alox_EspNowDiscover_network_tag 1 #define alox_EspNowDiscover_network_tag 1
#define alox_EspNowDiscover_master_ota_pending_tag 2 #define alox_EspNowDiscover_master_ota_pending_tag 2
#define alox_EspNowDiscover_master_time_us_tag 3
#define alox_EspNowSlavePresence_network_tag 1 #define alox_EspNowSlavePresence_network_tag 1
#define alox_EspNowSlavePresence_mac_tag 2 #define alox_EspNowSlavePresence_mac_tag 2
#define alox_EspNowSlavePresence_version_tag 3 #define alox_EspNowSlavePresence_version_tag 3
#define alox_EspNowSlavePresence_slave_id_tag 4 #define alox_EspNowSlavePresence_slave_id_tag 4
#define alox_EspNowSlavePresence_available_tag 5 #define alox_EspNowSlavePresence_available_tag 5
#define alox_EspNowSlavePresence_used_tag 6 #define alox_EspNowSlavePresence_used_tag 6
#define alox_EspNowAssignId_assigned_id_tag 1
#define alox_EspNowAccelDeadzone_deadzone_tag 1 #define alox_EspNowAccelDeadzone_deadzone_tag 1
#define alox_EspNowAccelDeadzone_client_id_tag 2 #define alox_EspNowAccelDeadzone_client_id_tag 2
#define alox_EspNowAccelStream_enable_tag 1 #define alox_EspNowAccelStream_enable_tag 1
@@ -325,6 +380,10 @@ extern "C" {
#define alox_EspNowTapConfig_enable_triple_tag 10 #define alox_EspNowTapConfig_enable_triple_tag 10
#define alox_EspNowTapEvent_slave_id_tag 1 #define alox_EspNowTapEvent_slave_id_tag 1
#define alox_EspNowTapEvent_kind_tag 2 #define alox_EspNowTapEvent_kind_tag 2
#define alox_EspNowInputSendMode_client_id_tag 1
#define alox_EspNowInputSendMode_mode_tag 2
#define alox_EspNowBatteryUv_client_id_tag 1
#define alox_EspNowBatteryUv_enabled_tag 2
#define alox_EspNowBatteryReport_client_id_tag 1 #define alox_EspNowBatteryReport_client_id_tag 1
#define alox_EspNowBatteryReport_lipo1_valid_tag 2 #define alox_EspNowBatteryReport_lipo1_valid_tag 2
#define alox_EspNowBatteryReport_lipo2_valid_tag 3 #define alox_EspNowBatteryReport_lipo2_valid_tag 3
@@ -368,6 +427,10 @@ extern "C" {
#define alox_EspNowMessage_echo_ping_tag 20 #define alox_EspNowMessage_echo_ping_tag 20
#define alox_EspNowMessage_echo_pong_tag 21 #define alox_EspNowMessage_echo_pong_tag 21
#define alox_EspNowMessage_tap_config_tag 22 #define alox_EspNowMessage_tap_config_tag 22
#define alox_EspNowMessage_assign_id_tag 23
#define alox_EspNowMessage_clear_id_binding_tag 24
#define alox_EspNowMessage_input_send_mode_tag 25
#define alox_EspNowMessage_battery_uv_tag 26
/* Struct field encoding specification for nanopb */ /* Struct field encoding specification for nanopb */
#define alox_EspNowUnicastTest_FIELDLIST(X, a) \ #define alox_EspNowUnicastTest_FIELDLIST(X, a) \
@@ -399,7 +462,8 @@ X(a, STATIC, SINGULAR, UINT32, client_id, 1)
#define alox_EspNowDiscover_FIELDLIST(X, a) \ #define alox_EspNowDiscover_FIELDLIST(X, a) \
X(a, STATIC, SINGULAR, UINT32, network, 1) \ X(a, STATIC, SINGULAR, UINT32, network, 1) \
X(a, STATIC, SINGULAR, BOOL, master_ota_pending, 2) X(a, STATIC, SINGULAR, BOOL, master_ota_pending, 2) \
X(a, STATIC, SINGULAR, UINT64, master_time_us, 3)
#define alox_EspNowDiscover_CALLBACK NULL #define alox_EspNowDiscover_CALLBACK NULL
#define alox_EspNowDiscover_DEFAULT NULL #define alox_EspNowDiscover_DEFAULT NULL
@@ -413,6 +477,16 @@ X(a, STATIC, SINGULAR, BOOL, used, 6)
#define alox_EspNowSlavePresence_CALLBACK pb_default_field_callback #define alox_EspNowSlavePresence_CALLBACK pb_default_field_callback
#define alox_EspNowSlavePresence_DEFAULT NULL #define alox_EspNowSlavePresence_DEFAULT NULL
#define alox_EspNowAssignId_FIELDLIST(X, a) \
X(a, STATIC, SINGULAR, UINT32, assigned_id, 1)
#define alox_EspNowAssignId_CALLBACK NULL
#define alox_EspNowAssignId_DEFAULT NULL
#define alox_EspNowClearIdBinding_FIELDLIST(X, a) \
#define alox_EspNowClearIdBinding_CALLBACK NULL
#define alox_EspNowClearIdBinding_DEFAULT NULL
#define alox_EspNowAccelDeadzone_FIELDLIST(X, a) \ #define alox_EspNowAccelDeadzone_FIELDLIST(X, a) \
X(a, STATIC, SINGULAR, UINT32, deadzone, 1) \ X(a, STATIC, SINGULAR, UINT32, deadzone, 1) \
X(a, STATIC, SINGULAR, UINT32, client_id, 2) X(a, STATIC, SINGULAR, UINT32, client_id, 2)
@@ -466,6 +540,18 @@ X(a, STATIC, SINGULAR, UINT32, kind, 2)
#define alox_EspNowTapEvent_CALLBACK NULL #define alox_EspNowTapEvent_CALLBACK NULL
#define alox_EspNowTapEvent_DEFAULT NULL #define alox_EspNowTapEvent_DEFAULT NULL
#define alox_EspNowInputSendMode_FIELDLIST(X, a) \
X(a, STATIC, SINGULAR, UINT32, client_id, 1) \
X(a, STATIC, SINGULAR, UINT32, mode, 2)
#define alox_EspNowInputSendMode_CALLBACK NULL
#define alox_EspNowInputSendMode_DEFAULT NULL
#define alox_EspNowBatteryUv_FIELDLIST(X, a) \
X(a, STATIC, SINGULAR, UINT32, client_id, 1) \
X(a, STATIC, SINGULAR, BOOL, enabled, 2)
#define alox_EspNowBatteryUv_CALLBACK NULL
#define alox_EspNowBatteryUv_DEFAULT NULL
#define alox_EspNowBatteryReport_FIELDLIST(X, a) \ #define alox_EspNowBatteryReport_FIELDLIST(X, a) \
X(a, STATIC, SINGULAR, UINT32, client_id, 1) \ X(a, STATIC, SINGULAR, UINT32, client_id, 1) \
X(a, STATIC, SINGULAR, BOOL, lipo1_valid, 2) \ X(a, STATIC, SINGULAR, BOOL, lipo1_valid, 2) \
@@ -534,7 +620,11 @@ X(a, STATIC, ONEOF, MESSAGE, (payload,tap_notify,payload.tap_notify), 18)
X(a, STATIC, ONEOF, MESSAGE, (payload,tap_event,payload.tap_event), 19) \ X(a, STATIC, ONEOF, MESSAGE, (payload,tap_event,payload.tap_event), 19) \
X(a, STATIC, ONEOF, MESSAGE, (payload,echo_ping,payload.echo_ping), 20) \ X(a, STATIC, ONEOF, MESSAGE, (payload,echo_ping,payload.echo_ping), 20) \
X(a, STATIC, ONEOF, MESSAGE, (payload,echo_pong,payload.echo_pong), 21) \ X(a, STATIC, ONEOF, MESSAGE, (payload,echo_pong,payload.echo_pong), 21) \
X(a, STATIC, ONEOF, MESSAGE, (payload,tap_config,payload.tap_config), 22) X(a, STATIC, ONEOF, MESSAGE, (payload,tap_config,payload.tap_config), 22) \
X(a, STATIC, ONEOF, MESSAGE, (payload,assign_id,payload.assign_id), 23) \
X(a, STATIC, ONEOF, MESSAGE, (payload,clear_id_binding,payload.clear_id_binding), 24) \
X(a, STATIC, ONEOF, MESSAGE, (payload,input_send_mode,payload.input_send_mode), 25) \
X(a, STATIC, ONEOF, MESSAGE, (payload,battery_uv,payload.battery_uv), 26)
#define alox_EspNowMessage_CALLBACK NULL #define alox_EspNowMessage_CALLBACK NULL
#define alox_EspNowMessage_DEFAULT NULL #define alox_EspNowMessage_DEFAULT NULL
#define alox_EspNowMessage_payload_discover_MSGTYPE alox_EspNowDiscover #define alox_EspNowMessage_payload_discover_MSGTYPE alox_EspNowDiscover
@@ -558,6 +648,10 @@ X(a, STATIC, ONEOF, MESSAGE, (payload,tap_config,payload.tap_config), 22)
#define alox_EspNowMessage_payload_echo_ping_MSGTYPE alox_EspNowEchoPing #define alox_EspNowMessage_payload_echo_ping_MSGTYPE alox_EspNowEchoPing
#define alox_EspNowMessage_payload_echo_pong_MSGTYPE alox_EspNowEchoPong #define alox_EspNowMessage_payload_echo_pong_MSGTYPE alox_EspNowEchoPong
#define alox_EspNowMessage_payload_tap_config_MSGTYPE alox_EspNowTapConfig #define alox_EspNowMessage_payload_tap_config_MSGTYPE alox_EspNowTapConfig
#define alox_EspNowMessage_payload_assign_id_MSGTYPE alox_EspNowAssignId
#define alox_EspNowMessage_payload_clear_id_binding_MSGTYPE alox_EspNowClearIdBinding
#define alox_EspNowMessage_payload_input_send_mode_MSGTYPE alox_EspNowInputSendMode
#define alox_EspNowMessage_payload_battery_uv_MSGTYPE alox_EspNowBatteryUv
extern const pb_msgdesc_t alox_EspNowUnicastTest_msg; extern const pb_msgdesc_t alox_EspNowUnicastTest_msg;
extern const pb_msgdesc_t alox_EspNowEchoPing_msg; extern const pb_msgdesc_t alox_EspNowEchoPing_msg;
@@ -566,6 +660,8 @@ extern const pb_msgdesc_t alox_EspNowFindMe_msg;
extern const pb_msgdesc_t alox_EspNowRestart_msg; extern const pb_msgdesc_t alox_EspNowRestart_msg;
extern const pb_msgdesc_t alox_EspNowDiscover_msg; extern const pb_msgdesc_t alox_EspNowDiscover_msg;
extern const pb_msgdesc_t alox_EspNowSlavePresence_msg; extern const pb_msgdesc_t alox_EspNowSlavePresence_msg;
extern const pb_msgdesc_t alox_EspNowAssignId_msg;
extern const pb_msgdesc_t alox_EspNowClearIdBinding_msg;
extern const pb_msgdesc_t alox_EspNowAccelDeadzone_msg; extern const pb_msgdesc_t alox_EspNowAccelDeadzone_msg;
extern const pb_msgdesc_t alox_EspNowAccelStream_msg; extern const pb_msgdesc_t alox_EspNowAccelStream_msg;
extern const pb_msgdesc_t alox_EspNowAccelSample_msg; extern const pb_msgdesc_t alox_EspNowAccelSample_msg;
@@ -573,6 +669,8 @@ extern const pb_msgdesc_t alox_EspNowBatteryQuery_msg;
extern const pb_msgdesc_t alox_EspNowTapNotify_msg; extern const pb_msgdesc_t alox_EspNowTapNotify_msg;
extern const pb_msgdesc_t alox_EspNowTapConfig_msg; extern const pb_msgdesc_t alox_EspNowTapConfig_msg;
extern const pb_msgdesc_t alox_EspNowTapEvent_msg; extern const pb_msgdesc_t alox_EspNowTapEvent_msg;
extern const pb_msgdesc_t alox_EspNowInputSendMode_msg;
extern const pb_msgdesc_t alox_EspNowBatteryUv_msg;
extern const pb_msgdesc_t alox_EspNowBatteryReport_msg; extern const pb_msgdesc_t alox_EspNowBatteryReport_msg;
extern const pb_msgdesc_t alox_EspNowLedRing_msg; extern const pb_msgdesc_t alox_EspNowLedRing_msg;
extern const pb_msgdesc_t alox_EspNowOtaStart_msg; extern const pb_msgdesc_t alox_EspNowOtaStart_msg;
@@ -589,6 +687,8 @@ extern const pb_msgdesc_t alox_EspNowMessage_msg;
#define alox_EspNowRestart_fields &alox_EspNowRestart_msg #define alox_EspNowRestart_fields &alox_EspNowRestart_msg
#define alox_EspNowDiscover_fields &alox_EspNowDiscover_msg #define alox_EspNowDiscover_fields &alox_EspNowDiscover_msg
#define alox_EspNowSlavePresence_fields &alox_EspNowSlavePresence_msg #define alox_EspNowSlavePresence_fields &alox_EspNowSlavePresence_msg
#define alox_EspNowAssignId_fields &alox_EspNowAssignId_msg
#define alox_EspNowClearIdBinding_fields &alox_EspNowClearIdBinding_msg
#define alox_EspNowAccelDeadzone_fields &alox_EspNowAccelDeadzone_msg #define alox_EspNowAccelDeadzone_fields &alox_EspNowAccelDeadzone_msg
#define alox_EspNowAccelStream_fields &alox_EspNowAccelStream_msg #define alox_EspNowAccelStream_fields &alox_EspNowAccelStream_msg
#define alox_EspNowAccelSample_fields &alox_EspNowAccelSample_msg #define alox_EspNowAccelSample_fields &alox_EspNowAccelSample_msg
@@ -596,6 +696,8 @@ extern const pb_msgdesc_t alox_EspNowMessage_msg;
#define alox_EspNowTapNotify_fields &alox_EspNowTapNotify_msg #define alox_EspNowTapNotify_fields &alox_EspNowTapNotify_msg
#define alox_EspNowTapConfig_fields &alox_EspNowTapConfig_msg #define alox_EspNowTapConfig_fields &alox_EspNowTapConfig_msg
#define alox_EspNowTapEvent_fields &alox_EspNowTapEvent_msg #define alox_EspNowTapEvent_fields &alox_EspNowTapEvent_msg
#define alox_EspNowInputSendMode_fields &alox_EspNowInputSendMode_msg
#define alox_EspNowBatteryUv_fields &alox_EspNowBatteryUv_msg
#define alox_EspNowBatteryReport_fields &alox_EspNowBatteryReport_msg #define alox_EspNowBatteryReport_fields &alox_EspNowBatteryReport_msg
#define alox_EspNowLedRing_fields &alox_EspNowLedRing_msg #define alox_EspNowLedRing_fields &alox_EspNowLedRing_msg
#define alox_EspNowOtaStart_fields &alox_EspNowOtaStart_msg #define alox_EspNowOtaStart_fields &alox_EspNowOtaStart_msg
@@ -611,12 +713,16 @@ extern const pb_msgdesc_t alox_EspNowMessage_msg;
#define alox_EspNowAccelDeadzone_size 12 #define alox_EspNowAccelDeadzone_size 12
#define alox_EspNowAccelSample_size 24 #define alox_EspNowAccelSample_size 24
#define alox_EspNowAccelStream_size 8 #define alox_EspNowAccelStream_size 8
#define alox_EspNowAssignId_size 6
#define alox_EspNowBatteryQuery_size 6 #define alox_EspNowBatteryQuery_size 6
#define alox_EspNowBatteryReport_size 22 #define alox_EspNowBatteryReport_size 22
#define alox_EspNowDiscover_size 8 #define alox_EspNowBatteryUv_size 8
#define alox_EspNowClearIdBinding_size 0
#define alox_EspNowDiscover_size 19
#define alox_EspNowEchoPing_size 22 #define alox_EspNowEchoPing_size 22
#define alox_EspNowEchoPong_size 22 #define alox_EspNowEchoPong_size 22
#define alox_EspNowFindMe_size 6 #define alox_EspNowFindMe_size 6
#define alox_EspNowInputSendMode_size 12
#define alox_EspNowLedRing_size 60 #define alox_EspNowLedRing_size 60
#define alox_EspNowOtaEnd_size 0 #define alox_EspNowOtaEnd_size 0
#define alox_EspNowOtaPayload_size 209 #define alox_EspNowOtaPayload_size 209
+43 -2
View File
@@ -27,6 +27,14 @@ enum EspNowMessageType {
ESPNOW_ECHO_PING = 19; ESPNOW_ECHO_PING = 19;
ESPNOW_ECHO_PONG = 20; ESPNOW_ECHO_PONG = 20;
ESPNOW_SET_TAP_CONFIG = 21; ESPNOW_SET_TAP_CONFIG = 21;
/** Master → slave: assigned logical client id after join/presence. */
ESPNOW_ASSIGN_ID = 22;
/** Master → slave: forget persisted master/id binding (debug reset). */
ESPNOW_CLEAR_ID_BINDING = 23;
/** Master → slave: accel/tap ESP-NOW send path (best_effort/fast/wait/reliable). */
ESPNOW_SET_INPUT_SEND_MODE = 24;
/** Master → slave: enable/disable software LiPo UV protection (NVS). */
ESPNOW_SET_BATTERY_UV = 25;
} }
message EspNowUnicastTest { message EspNowUnicastTest {
@@ -61,29 +69,43 @@ message EspNowDiscover {
uint32 network = 1; uint32 network = 1;
/** Master is in an OTA session (UART upload or ESP-NOW distribution). */ /** Master is in an OTA session (UART upload or ESP-NOW distribution). */
bool master_ota_pending = 2; bool master_ota_pending = 2;
/**
* Master esp_timer_get_time() (µs) at broadcast. Slaves use this to phase
* accel TX into 1 ms slots: offset_ms = (assigned_id - 1) % 8.
*/
uint64 master_time_us = 3;
} }
message EspNowSlavePresence { message EspNowSlavePresence {
uint32 network = 1; uint32 network = 1;
bytes mac = 2; bytes mac = 2;
uint32 version = 3; uint32 version = 3;
/** Preferred id from NVS for this master (0 = none / unbound). */
uint32 slave_id = 4; uint32 slave_id = 4;
bool available = 5; bool available = 5;
bool used = 6; bool used = 6;
} }
/** Master → slave: definitive logical client id for this session/master. */
message EspNowAssignId {
uint32 assigned_id = 1;
}
/** Master → slave: clear persisted {master_mac, assigned_id} binding. */
message EspNowClearIdBinding {}
message EspNowAccelDeadzone { message EspNowAccelDeadzone {
uint32 deadzone = 1; uint32 deadzone = 1;
uint32 client_id = 2; // 0 = all slaves; otherwise only matching slave_id applies uint32 client_id = 2; // 0 = all slaves; otherwise only matching slave_id applies
} }
/** Master → slave: enable/disable periodic accel ESP-NOW stream (~16 ms). */ /** Master → slave: enable/disable periodic accel ESP-NOW stream (~10 ms). */
message EspNowAccelStream { message EspNowAccelStream {
bool enable = 1; bool enable = 1;
uint32 client_id = 2; uint32 client_id = 2;
} }
/** Slave → master: latest BMA456 sample (sent ~every 16 ms). */ /** Slave → master: latest BMA456 sample (best-effort, ~every 10 ms). */
message EspNowAccelSample { message EspNowAccelSample {
uint32 slave_id = 1; uint32 slave_id = 1;
sint32 x = 2; sint32 x = 2;
@@ -125,6 +147,21 @@ message EspNowTapEvent {
uint32 kind = 2; uint32 kind = 2;
} }
/**
* Master → slave: which esp_now_core_send_* path to use for ACCEL_SAMPLE / TAP_EVENT.
* Values match uart InputSendMode (0=best_effort … 3=reliable).
*/
message EspNowInputSendMode {
uint32 client_id = 1; // 0 = all slaves; otherwise only matching slave_id applies
uint32 mode = 2;
}
/** Master → slave: software LiPo UV protection enable (persisted in NVS). */
message EspNowBatteryUv {
uint32 client_id = 1; // 0 = all slaves; otherwise only matching slave_id applies
bool enabled = 2;
}
/** Slave → master: LiPo voltages (periodic ~30 s and on query). */ /** Slave → master: LiPo voltages (periodic ~30 s and on query). */
message EspNowBatteryReport { message EspNowBatteryReport {
uint32 client_id = 1; uint32 client_id = 1;
@@ -193,5 +230,9 @@ message EspNowMessage {
EspNowEchoPing echo_ping = 20; EspNowEchoPing echo_ping = 20;
EspNowEchoPong echo_pong = 21; EspNowEchoPong echo_pong = 21;
EspNowTapConfig tap_config = 22; EspNowTapConfig tap_config = 22;
EspNowAssignId assign_id = 23;
EspNowClearIdBinding clear_id_binding = 24;
EspNowInputSendMode input_send_mode = 25;
EspNowBatteryUv battery_uv = 26;
} }
} }
+20
View File
@@ -42,6 +42,18 @@ PB_BIND(alox_AccelStreamRequest, alox_AccelStreamRequest, AUTO)
PB_BIND(alox_AccelStreamResponse, alox_AccelStreamResponse, AUTO) PB_BIND(alox_AccelStreamResponse, alox_AccelStreamResponse, AUTO)
PB_BIND(alox_InputSendModeRequest, alox_InputSendModeRequest, AUTO)
PB_BIND(alox_InputSendModeResponse, alox_InputSendModeResponse, AUTO)
PB_BIND(alox_BatteryUvRequest, alox_BatteryUvRequest, AUTO)
PB_BIND(alox_BatteryUvResponse, alox_BatteryUvResponse, AUTO)
PB_BIND(alox_BatteryStatusRequest, alox_BatteryStatusRequest, AUTO) PB_BIND(alox_BatteryStatusRequest, alox_BatteryStatusRequest, AUTO)
@@ -114,6 +126,12 @@ PB_BIND(alox_RestartRequest, alox_RestartRequest, AUTO)
PB_BIND(alox_RestartResponse, alox_RestartResponse, AUTO) PB_BIND(alox_RestartResponse, alox_RestartResponse, AUTO)
PB_BIND(alox_ClientIdMapResetRequest, alox_ClientIdMapResetRequest, AUTO)
PB_BIND(alox_ClientIdMapResetResponse, alox_ClientIdMapResetResponse, AUTO)
PB_BIND(alox_SetLogLevelRequest, alox_SetLogLevelRequest, AUTO) PB_BIND(alox_SetLogLevelRequest, alox_SetLogLevelRequest, AUTO)
@@ -152,3 +170,5 @@ PB_BIND(alox_OtaSlaveProgressResponse, alox_OtaSlaveProgressResponse, 2)
+207 -11
View File
@@ -31,7 +31,7 @@ typedef enum _alox_MessageType {
alox_MessageType_ACCEL_STREAM = 25, alox_MessageType_ACCEL_STREAM = 25,
alox_MessageType_BATTERY_STATUS = 26, alox_MessageType_BATTERY_STATUS = 26,
alox_MessageType_TAP_NOTIFY = 27, alox_MessageType_TAP_NOTIFY = 27,
/* * Combined cached accel + tap poll (one UART round-trip, ~16 ms cadence). */ /* * Combined cached accel + tap poll (one UART round-trip, ~10 ms cadence). */
alox_MessageType_CACHE_STATUS = 29, alox_MessageType_CACHE_STATUS = 29,
/* * Host → master → slave → master: timestamp echo round-trip (latency test). */ /* * Host → master → slave → master: timestamp echo round-trip (latency test). */
alox_MessageType_ESPNOW_ECHO_PING = 30, alox_MessageType_ESPNOW_ECHO_PING = 30,
@@ -40,9 +40,31 @@ typedef enum _alox_MessageType {
/* * Host → master: get/set BMA456H multitap detection parameters. */ /* * Host → master: get/set BMA456H multitap detection parameters. */
alox_MessageType_TAP_CONFIG = 32, alox_MessageType_TAP_CONFIG = 32,
/* * Master → host (BLE only): periodic pushed cache snapshot with ESP timestamp. */ /* * Master → host (BLE only): periodic pushed cache snapshot with ESP timestamp. */
alox_MessageType_BLE_STREAM_TICK = 33 alox_MessageType_BLE_STREAM_TICK = 33,
/* *
Host → device: clear master client-id NVS map + live registry (and notify
online slaves), or on a slave clear local master/id binding only. */
alox_MessageType_CLIENT_ID_MAP_RESET = 34,
/* *
Host → master: get/set slave ESP-NOW send path for accel/tap
(best_effort / fast / wait / reliable). */
alox_MessageType_INPUT_SEND_MODE = 35,
/* *
Host → master: get/set software LiPo UV protection enable (NVS per pod).
client_id 0 = master; >0 = one slave; all_clients = master + every slave. */
alox_MessageType_BATTERY_UV = 36
} alox_MessageType; } alox_MessageType;
/* *
Slave ESP-NOW TX path for ACCEL_SAMPLE / TAP_EVENT (debug A/B).
Maps 1:1 to esp_now_core_send_*. */
typedef enum _alox_InputSendMode {
alox_InputSendMode_INPUT_SEND_BEST_EFFORT = 0,
alox_InputSendMode_INPUT_SEND_FAST = 1,
alox_InputSendMode_INPUT_SEND_WAIT = 2,
alox_InputSendMode_INPUT_SEND_RELIABLE = 3
} alox_InputSendMode;
typedef enum _alox_TapKind { typedef enum _alox_TapKind {
alox_TapKind_TAP_NONE = 0, alox_TapKind_TAP_NONE = 0,
alox_TapKind_TAP_SINGLE = 1, alox_TapKind_TAP_SINGLE = 1,
@@ -80,6 +102,8 @@ typedef struct _alox_ClientInfo {
bool tap_notify_single; bool tap_notify_single;
bool tap_notify_double; bool tap_notify_double;
bool tap_notify_triple; bool tap_notify_triple;
/* * Master cache: software UV protection enabled on this slave (NVS on device). */
bool uv_protection_enabled;
} alox_ClientInfo; } alox_ClientInfo;
typedef struct _alox_ClientInfoResponse { typedef struct _alox_ClientInfoResponse {
@@ -114,9 +138,9 @@ typedef struct _alox_AccelDeadzoneResponse {
uint32_t slaves_updated; uint32_t slaves_updated;
} alox_AccelDeadzoneResponse; } alox_AccelDeadzoneResponse;
/* Host → master: enable/disable slave accel ESP-NOW stream (~16 ms per slave). /* Host → master: enable/disable slave accel ESP-NOW stream (~10 ms per slave, best-effort).
write=false: read; write=true: apply. client_id 0 invalid for write (use >0 or all_clients). write=false: read; write=true: apply. client_id 0 invalid for write (use >0 or all_clients).
period_ms (8–16): BLE host push interval for BleStreamTick; ignored on UART (default 16). */ period_ms (10–16): BLE host push interval for BleStreamTick; ignored on UART (default 10). */
typedef struct _alox_AccelStreamRequest { typedef struct _alox_AccelStreamRequest {
bool write; bool write;
bool enable; bool enable;
@@ -132,6 +156,40 @@ typedef struct _alox_AccelStreamResponse {
uint32_t slaves_updated; uint32_t slaves_updated;
} alox_AccelStreamResponse; } alox_AccelStreamResponse;
/* Host → master: get/set input send mode on slaves.
write=false: return last mode remembered on master (client_id ignored).
write=true: push mode via ESP-NOW (all_clients or one client_id). */
typedef struct _alox_InputSendModeRequest {
bool write;
alox_InputSendMode mode;
uint32_t client_id;
bool all_clients;
} alox_InputSendModeRequest;
typedef struct _alox_InputSendModeResponse {
bool success;
alox_InputSendMode mode;
uint32_t client_id;
uint32_t slaves_updated;
} alox_InputSendModeResponse;
/* write=false: read UV enable; write=true: persist enable (and clear latch when disabling).
client_id 0 = local master; >0 = one slave; all_clients = master + every registered slave. */
typedef struct _alox_BatteryUvRequest {
bool write;
bool enabled;
uint32_t client_id;
bool all_clients;
} alox_BatteryUvRequest;
typedef struct _alox_BatteryUvResponse {
uint32_t client_id;
bool success;
bool enabled;
bool latched;
uint32_t slaves_updated;
} alox_BatteryUvResponse;
/* * Host → master: read LiPo ADC voltages (master local and/or slaves via ESP-NOW). */ /* * Host → master: read LiPo ADC voltages (master local and/or slaves via ESP-NOW). */
typedef struct _alox_BatteryStatusRequest { typedef struct _alox_BatteryStatusRequest {
/* * 0 = master only; >0 = one slave; ignored when all_clients */ /* * 0 = master only; >0 = one slave; ignored when all_clients */
@@ -323,6 +381,15 @@ typedef struct _alox_RestartResponse {
uint32_t client_id; uint32_t client_id;
} alox_RestartResponse; } alox_RestartResponse;
/* * Host → master or slave: clear persisted client-id assignment state. */
typedef struct _alox_ClientIdMapResetRequest {
char dummy_field;
} alox_ClientIdMapResetRequest;
typedef struct _alox_ClientIdMapResetResponse {
bool success;
} alox_ClientIdMapResetResponse;
/* * Host → master: read/write global log level (esp_log_level_set("*", …)). */ /* * Host → master: read/write global log level (esp_log_level_set("*", …)). */
typedef struct _alox_SetLogLevelRequest { typedef struct _alox_SetLogLevelRequest {
bool write; bool write;
@@ -367,7 +434,7 @@ typedef struct _alox_TapConfigResponse {
bool enable_triple; bool enable_triple;
} alox_TapConfigResponse; } alox_TapConfigResponse;
/* Host → device: begin UART OTA (erase inactive OTA slot; device replies OTA_STATUS). */ /* Host → device: begin host OTA over UART or BLE (erase inactive OTA slot; device replies OTA_STATUS). */
typedef struct _alox_OtaStartPayload { typedef struct _alox_OtaStartPayload {
uint32_t total_size; uint32_t total_size;
} alox_OtaStartPayload; } alox_OtaStartPayload;
@@ -456,6 +523,12 @@ typedef struct _alox_UartMessage {
alox_TapConfigRequest tap_config_request; alox_TapConfigRequest tap_config_request;
alox_TapConfigResponse tap_config_response; alox_TapConfigResponse tap_config_response;
alox_BleStreamTick ble_stream_tick; alox_BleStreamTick ble_stream_tick;
alox_ClientIdMapResetRequest client_id_map_reset_request;
alox_ClientIdMapResetResponse client_id_map_reset_response;
alox_InputSendModeRequest input_send_mode_request;
alox_InputSendModeResponse input_send_mode_response;
alox_BatteryUvRequest battery_uv_request;
alox_BatteryUvResponse battery_uv_response;
} payload; } payload;
} alox_UartMessage; } alox_UartMessage;
@@ -466,8 +539,12 @@ extern "C" {
/* Helper constants for enums */ /* Helper constants for enums */
#define _alox_MessageType_MIN alox_MessageType_UNKNOWN #define _alox_MessageType_MIN alox_MessageType_UNKNOWN
#define _alox_MessageType_MAX alox_MessageType_BLE_STREAM_TICK #define _alox_MessageType_MAX alox_MessageType_BATTERY_UV
#define _alox_MessageType_ARRAYSIZE ((alox_MessageType)(alox_MessageType_BLE_STREAM_TICK+1)) #define _alox_MessageType_ARRAYSIZE ((alox_MessageType)(alox_MessageType_BATTERY_UV+1))
#define _alox_InputSendMode_MIN alox_InputSendMode_INPUT_SEND_BEST_EFFORT
#define _alox_InputSendMode_MAX alox_InputSendMode_INPUT_SEND_RELIABLE
#define _alox_InputSendMode_ARRAYSIZE ((alox_InputSendMode)(alox_InputSendMode_INPUT_SEND_RELIABLE+1))
#define _alox_TapKind_MIN alox_TapKind_TAP_NONE #define _alox_TapKind_MIN alox_TapKind_TAP_NONE
#define _alox_TapKind_MAX alox_TapKind_TAP_TRIPLE #define _alox_TapKind_MAX alox_TapKind_TAP_TRIPLE
@@ -486,6 +563,12 @@ extern "C" {
#define alox_InputSendModeRequest_mode_ENUMTYPE alox_InputSendMode
#define alox_InputSendModeResponse_mode_ENUMTYPE alox_InputSendMode
@@ -521,6 +604,8 @@ extern "C" {
@@ -529,7 +614,7 @@ extern "C" {
#define alox_Ack_init_default {0} #define alox_Ack_init_default {0}
#define alox_EchoPayload_init_default {{{NULL}, NULL}} #define alox_EchoPayload_init_default {{{NULL}, NULL}}
#define alox_VersionResponse_init_default {0, {{NULL}, NULL}, {{NULL}, NULL}} #define alox_VersionResponse_init_default {0, {{NULL}, NULL}, {{NULL}, NULL}}
#define alox_ClientInfo_init_default {0, 0, 0, {{NULL}, NULL}, 0, 0, 0, 0, 0, 0, 0} #define alox_ClientInfo_init_default {0, 0, 0, {{NULL}, NULL}, 0, 0, 0, 0, 0, 0, 0, 0}
#define alox_ClientInfoResponse_init_default {{{NULL}, NULL}} #define alox_ClientInfoResponse_init_default {{{NULL}, NULL}}
#define alox_ClientInput_init_default {0, 0, 0, 0} #define alox_ClientInput_init_default {0, 0, 0, 0}
#define alox_ClientInputResponse_init_default {{{NULL}, NULL}} #define alox_ClientInputResponse_init_default {{{NULL}, NULL}}
@@ -537,6 +622,10 @@ extern "C" {
#define alox_AccelDeadzoneResponse_init_default {0, 0, 0, 0} #define alox_AccelDeadzoneResponse_init_default {0, 0, 0, 0}
#define alox_AccelStreamRequest_init_default {0, 0, 0, 0, 0} #define alox_AccelStreamRequest_init_default {0, 0, 0, 0, 0}
#define alox_AccelStreamResponse_init_default {0, 0, 0, 0} #define alox_AccelStreamResponse_init_default {0, 0, 0, 0}
#define alox_InputSendModeRequest_init_default {0, _alox_InputSendMode_MIN, 0, 0}
#define alox_InputSendModeResponse_init_default {0, _alox_InputSendMode_MIN, 0, 0}
#define alox_BatteryUvRequest_init_default {0, 0, 0, 0}
#define alox_BatteryUvResponse_init_default {0, 0, 0, 0, 0}
#define alox_BatteryStatusRequest_init_default {0, 0} #define alox_BatteryStatusRequest_init_default {0, 0}
#define alox_LipoReading_init_default {0, 0} #define alox_LipoReading_init_default {0, 0}
#define alox_BatterySample_init_default {0, false, alox_LipoReading_init_default, false, alox_LipoReading_init_default, 0} #define alox_BatterySample_init_default {0, false, alox_LipoReading_init_default, false, alox_LipoReading_init_default, 0}
@@ -561,6 +650,8 @@ extern "C" {
#define alox_EspNowFindMeResponse_init_default {0, 0} #define alox_EspNowFindMeResponse_init_default {0, 0}
#define alox_RestartRequest_init_default {0} #define alox_RestartRequest_init_default {0}
#define alox_RestartResponse_init_default {0, 0} #define alox_RestartResponse_init_default {0, 0}
#define alox_ClientIdMapResetRequest_init_default {0}
#define alox_ClientIdMapResetResponse_init_default {0}
#define alox_SetLogLevelRequest_init_default {0, 0} #define alox_SetLogLevelRequest_init_default {0, 0}
#define alox_SetLogLevelResponse_init_default {0, 0} #define alox_SetLogLevelResponse_init_default {0, 0}
#define alox_TapConfigRequest_init_default {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0} #define alox_TapConfigRequest_init_default {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}
@@ -576,7 +667,7 @@ extern "C" {
#define alox_Ack_init_zero {0} #define alox_Ack_init_zero {0}
#define alox_EchoPayload_init_zero {{{NULL}, NULL}} #define alox_EchoPayload_init_zero {{{NULL}, NULL}}
#define alox_VersionResponse_init_zero {0, {{NULL}, NULL}, {{NULL}, NULL}} #define alox_VersionResponse_init_zero {0, {{NULL}, NULL}, {{NULL}, NULL}}
#define alox_ClientInfo_init_zero {0, 0, 0, {{NULL}, NULL}, 0, 0, 0, 0, 0, 0, 0} #define alox_ClientInfo_init_zero {0, 0, 0, {{NULL}, NULL}, 0, 0, 0, 0, 0, 0, 0, 0}
#define alox_ClientInfoResponse_init_zero {{{NULL}, NULL}} #define alox_ClientInfoResponse_init_zero {{{NULL}, NULL}}
#define alox_ClientInput_init_zero {0, 0, 0, 0} #define alox_ClientInput_init_zero {0, 0, 0, 0}
#define alox_ClientInputResponse_init_zero {{{NULL}, NULL}} #define alox_ClientInputResponse_init_zero {{{NULL}, NULL}}
@@ -584,6 +675,10 @@ extern "C" {
#define alox_AccelDeadzoneResponse_init_zero {0, 0, 0, 0} #define alox_AccelDeadzoneResponse_init_zero {0, 0, 0, 0}
#define alox_AccelStreamRequest_init_zero {0, 0, 0, 0, 0} #define alox_AccelStreamRequest_init_zero {0, 0, 0, 0, 0}
#define alox_AccelStreamResponse_init_zero {0, 0, 0, 0} #define alox_AccelStreamResponse_init_zero {0, 0, 0, 0}
#define alox_InputSendModeRequest_init_zero {0, _alox_InputSendMode_MIN, 0, 0}
#define alox_InputSendModeResponse_init_zero {0, _alox_InputSendMode_MIN, 0, 0}
#define alox_BatteryUvRequest_init_zero {0, 0, 0, 0}
#define alox_BatteryUvResponse_init_zero {0, 0, 0, 0, 0}
#define alox_BatteryStatusRequest_init_zero {0, 0} #define alox_BatteryStatusRequest_init_zero {0, 0}
#define alox_LipoReading_init_zero {0, 0} #define alox_LipoReading_init_zero {0, 0}
#define alox_BatterySample_init_zero {0, false, alox_LipoReading_init_zero, false, alox_LipoReading_init_zero, 0} #define alox_BatterySample_init_zero {0, false, alox_LipoReading_init_zero, false, alox_LipoReading_init_zero, 0}
@@ -608,6 +703,8 @@ extern "C" {
#define alox_EspNowFindMeResponse_init_zero {0, 0} #define alox_EspNowFindMeResponse_init_zero {0, 0}
#define alox_RestartRequest_init_zero {0} #define alox_RestartRequest_init_zero {0}
#define alox_RestartResponse_init_zero {0, 0} #define alox_RestartResponse_init_zero {0, 0}
#define alox_ClientIdMapResetRequest_init_zero {0}
#define alox_ClientIdMapResetResponse_init_zero {0}
#define alox_SetLogLevelRequest_init_zero {0, 0} #define alox_SetLogLevelRequest_init_zero {0, 0}
#define alox_SetLogLevelResponse_init_zero {0, 0} #define alox_SetLogLevelResponse_init_zero {0, 0}
#define alox_TapConfigRequest_init_zero {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0} #define alox_TapConfigRequest_init_zero {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}
@@ -636,6 +733,7 @@ extern "C" {
#define alox_ClientInfo_tap_notify_single_tag 9 #define alox_ClientInfo_tap_notify_single_tag 9
#define alox_ClientInfo_tap_notify_double_tag 10 #define alox_ClientInfo_tap_notify_double_tag 10
#define alox_ClientInfo_tap_notify_triple_tag 11 #define alox_ClientInfo_tap_notify_triple_tag 11
#define alox_ClientInfo_uv_protection_enabled_tag 12
#define alox_ClientInfoResponse_clients_tag 1 #define alox_ClientInfoResponse_clients_tag 1
#define alox_ClientInput_id_tag 1 #define alox_ClientInput_id_tag 1
#define alox_ClientInput_lage_x_tag 2 #define alox_ClientInput_lage_x_tag 2
@@ -659,6 +757,23 @@ extern "C" {
#define alox_AccelStreamResponse_client_id_tag 2 #define alox_AccelStreamResponse_client_id_tag 2
#define alox_AccelStreamResponse_success_tag 3 #define alox_AccelStreamResponse_success_tag 3
#define alox_AccelStreamResponse_slaves_updated_tag 4 #define alox_AccelStreamResponse_slaves_updated_tag 4
#define alox_InputSendModeRequest_write_tag 1
#define alox_InputSendModeRequest_mode_tag 2
#define alox_InputSendModeRequest_client_id_tag 3
#define alox_InputSendModeRequest_all_clients_tag 4
#define alox_InputSendModeResponse_success_tag 1
#define alox_InputSendModeResponse_mode_tag 2
#define alox_InputSendModeResponse_client_id_tag 3
#define alox_InputSendModeResponse_slaves_updated_tag 4
#define alox_BatteryUvRequest_write_tag 1
#define alox_BatteryUvRequest_enabled_tag 2
#define alox_BatteryUvRequest_client_id_tag 3
#define alox_BatteryUvRequest_all_clients_tag 4
#define alox_BatteryUvResponse_client_id_tag 1
#define alox_BatteryUvResponse_success_tag 2
#define alox_BatteryUvResponse_enabled_tag 3
#define alox_BatteryUvResponse_latched_tag 4
#define alox_BatteryUvResponse_slaves_updated_tag 5
#define alox_BatteryStatusRequest_client_id_tag 1 #define alox_BatteryStatusRequest_client_id_tag 1
#define alox_BatteryStatusRequest_all_clients_tag 2 #define alox_BatteryStatusRequest_all_clients_tag 2
#define alox_LipoReading_valid_tag 1 #define alox_LipoReading_valid_tag 1
@@ -739,6 +854,7 @@ extern "C" {
#define alox_RestartRequest_client_id_tag 1 #define alox_RestartRequest_client_id_tag 1
#define alox_RestartResponse_success_tag 1 #define alox_RestartResponse_success_tag 1
#define alox_RestartResponse_client_id_tag 2 #define alox_RestartResponse_client_id_tag 2
#define alox_ClientIdMapResetResponse_success_tag 1
#define alox_SetLogLevelRequest_write_tag 1 #define alox_SetLogLevelRequest_write_tag 1
#define alox_SetLogLevelRequest_level_tag 2 #define alox_SetLogLevelRequest_level_tag 2
#define alox_SetLogLevelResponse_success_tag 1 #define alox_SetLogLevelResponse_success_tag 1
@@ -822,6 +938,12 @@ extern "C" {
#define alox_UartMessage_tap_config_request_tag 39 #define alox_UartMessage_tap_config_request_tag 39
#define alox_UartMessage_tap_config_response_tag 40 #define alox_UartMessage_tap_config_response_tag 40
#define alox_UartMessage_ble_stream_tick_tag 41 #define alox_UartMessage_ble_stream_tick_tag 41
#define alox_UartMessage_client_id_map_reset_request_tag 42
#define alox_UartMessage_client_id_map_reset_response_tag 43
#define alox_UartMessage_input_send_mode_request_tag 44
#define alox_UartMessage_input_send_mode_response_tag 45
#define alox_UartMessage_battery_uv_request_tag 46
#define alox_UartMessage_battery_uv_response_tag 47
/* Struct field encoding specification for nanopb */ /* Struct field encoding specification for nanopb */
#define alox_UartMessage_FIELDLIST(X, a) \ #define alox_UartMessage_FIELDLIST(X, a) \
@@ -861,7 +983,13 @@ X(a, STATIC, ONEOF, MESSAGE, (payload,set_log_level_request,payload.set_lo
X(a, STATIC, ONEOF, MESSAGE, (payload,set_log_level_response,payload.set_log_level_response), 38) \ X(a, STATIC, ONEOF, MESSAGE, (payload,set_log_level_response,payload.set_log_level_response), 38) \
X(a, STATIC, ONEOF, MESSAGE, (payload,tap_config_request,payload.tap_config_request), 39) \ X(a, STATIC, ONEOF, MESSAGE, (payload,tap_config_request,payload.tap_config_request), 39) \
X(a, STATIC, ONEOF, MESSAGE, (payload,tap_config_response,payload.tap_config_response), 40) \ X(a, STATIC, ONEOF, MESSAGE, (payload,tap_config_response,payload.tap_config_response), 40) \
X(a, STATIC, ONEOF, MESSAGE, (payload,ble_stream_tick,payload.ble_stream_tick), 41) X(a, STATIC, ONEOF, MESSAGE, (payload,ble_stream_tick,payload.ble_stream_tick), 41) \
X(a, STATIC, ONEOF, MESSAGE, (payload,client_id_map_reset_request,payload.client_id_map_reset_request), 42) \
X(a, STATIC, ONEOF, MESSAGE, (payload,client_id_map_reset_response,payload.client_id_map_reset_response), 43) \
X(a, STATIC, ONEOF, MESSAGE, (payload,input_send_mode_request,payload.input_send_mode_request), 44) \
X(a, STATIC, ONEOF, MESSAGE, (payload,input_send_mode_response,payload.input_send_mode_response), 45) \
X(a, STATIC, ONEOF, MESSAGE, (payload,battery_uv_request,payload.battery_uv_request), 46) \
X(a, STATIC, ONEOF, MESSAGE, (payload,battery_uv_response,payload.battery_uv_response), 47)
#define alox_UartMessage_CALLBACK NULL #define alox_UartMessage_CALLBACK NULL
#define alox_UartMessage_DEFAULT NULL #define alox_UartMessage_DEFAULT NULL
#define alox_UartMessage_payload_ack_payload_MSGTYPE alox_Ack #define alox_UartMessage_payload_ack_payload_MSGTYPE alox_Ack
@@ -900,6 +1028,12 @@ X(a, STATIC, ONEOF, MESSAGE, (payload,ble_stream_tick,payload.ble_stream_t
#define alox_UartMessage_payload_tap_config_request_MSGTYPE alox_TapConfigRequest #define alox_UartMessage_payload_tap_config_request_MSGTYPE alox_TapConfigRequest
#define alox_UartMessage_payload_tap_config_response_MSGTYPE alox_TapConfigResponse #define alox_UartMessage_payload_tap_config_response_MSGTYPE alox_TapConfigResponse
#define alox_UartMessage_payload_ble_stream_tick_MSGTYPE alox_BleStreamTick #define alox_UartMessage_payload_ble_stream_tick_MSGTYPE alox_BleStreamTick
#define alox_UartMessage_payload_client_id_map_reset_request_MSGTYPE alox_ClientIdMapResetRequest
#define alox_UartMessage_payload_client_id_map_reset_response_MSGTYPE alox_ClientIdMapResetResponse
#define alox_UartMessage_payload_input_send_mode_request_MSGTYPE alox_InputSendModeRequest
#define alox_UartMessage_payload_input_send_mode_response_MSGTYPE alox_InputSendModeResponse
#define alox_UartMessage_payload_battery_uv_request_MSGTYPE alox_BatteryUvRequest
#define alox_UartMessage_payload_battery_uv_response_MSGTYPE alox_BatteryUvResponse
#define alox_Ack_FIELDLIST(X, a) \ #define alox_Ack_FIELDLIST(X, a) \
@@ -929,7 +1063,8 @@ X(a, STATIC, SINGULAR, UINT32, version, 7) \
X(a, STATIC, SINGULAR, BOOL, accel_stream_enabled, 8) \ X(a, STATIC, SINGULAR, BOOL, accel_stream_enabled, 8) \
X(a, STATIC, SINGULAR, BOOL, tap_notify_single, 9) \ X(a, STATIC, SINGULAR, BOOL, tap_notify_single, 9) \
X(a, STATIC, SINGULAR, BOOL, tap_notify_double, 10) \ X(a, STATIC, SINGULAR, BOOL, tap_notify_double, 10) \
X(a, STATIC, SINGULAR, BOOL, tap_notify_triple, 11) X(a, STATIC, SINGULAR, BOOL, tap_notify_triple, 11) \
X(a, STATIC, SINGULAR, BOOL, uv_protection_enabled, 12)
#define alox_ClientInfo_CALLBACK pb_default_field_callback #define alox_ClientInfo_CALLBACK pb_default_field_callback
#define alox_ClientInfo_DEFAULT NULL #define alox_ClientInfo_DEFAULT NULL
@@ -986,6 +1121,39 @@ X(a, STATIC, SINGULAR, UINT32, slaves_updated, 4)
#define alox_AccelStreamResponse_CALLBACK NULL #define alox_AccelStreamResponse_CALLBACK NULL
#define alox_AccelStreamResponse_DEFAULT NULL #define alox_AccelStreamResponse_DEFAULT NULL
#define alox_InputSendModeRequest_FIELDLIST(X, a) \
X(a, STATIC, SINGULAR, BOOL, write, 1) \
X(a, STATIC, SINGULAR, UENUM, mode, 2) \
X(a, STATIC, SINGULAR, UINT32, client_id, 3) \
X(a, STATIC, SINGULAR, BOOL, all_clients, 4)
#define alox_InputSendModeRequest_CALLBACK NULL
#define alox_InputSendModeRequest_DEFAULT NULL
#define alox_InputSendModeResponse_FIELDLIST(X, a) \
X(a, STATIC, SINGULAR, BOOL, success, 1) \
X(a, STATIC, SINGULAR, UENUM, mode, 2) \
X(a, STATIC, SINGULAR, UINT32, client_id, 3) \
X(a, STATIC, SINGULAR, UINT32, slaves_updated, 4)
#define alox_InputSendModeResponse_CALLBACK NULL
#define alox_InputSendModeResponse_DEFAULT NULL
#define alox_BatteryUvRequest_FIELDLIST(X, a) \
X(a, STATIC, SINGULAR, BOOL, write, 1) \
X(a, STATIC, SINGULAR, BOOL, enabled, 2) \
X(a, STATIC, SINGULAR, UINT32, client_id, 3) \
X(a, STATIC, SINGULAR, BOOL, all_clients, 4)
#define alox_BatteryUvRequest_CALLBACK NULL
#define alox_BatteryUvRequest_DEFAULT NULL
#define alox_BatteryUvResponse_FIELDLIST(X, a) \
X(a, STATIC, SINGULAR, UINT32, client_id, 1) \
X(a, STATIC, SINGULAR, BOOL, success, 2) \
X(a, STATIC, SINGULAR, BOOL, enabled, 3) \
X(a, STATIC, SINGULAR, BOOL, latched, 4) \
X(a, STATIC, SINGULAR, UINT32, slaves_updated, 5)
#define alox_BatteryUvResponse_CALLBACK NULL
#define alox_BatteryUvResponse_DEFAULT NULL
#define alox_BatteryStatusRequest_FIELDLIST(X, a) \ #define alox_BatteryStatusRequest_FIELDLIST(X, a) \
X(a, STATIC, SINGULAR, UINT32, client_id, 1) \ X(a, STATIC, SINGULAR, UINT32, client_id, 1) \
X(a, STATIC, SINGULAR, BOOL, all_clients, 2) X(a, STATIC, SINGULAR, BOOL, all_clients, 2)
@@ -1170,6 +1338,16 @@ X(a, STATIC, SINGULAR, UINT32, client_id, 2)
#define alox_RestartResponse_CALLBACK NULL #define alox_RestartResponse_CALLBACK NULL
#define alox_RestartResponse_DEFAULT NULL #define alox_RestartResponse_DEFAULT NULL
#define alox_ClientIdMapResetRequest_FIELDLIST(X, a) \
#define alox_ClientIdMapResetRequest_CALLBACK NULL
#define alox_ClientIdMapResetRequest_DEFAULT NULL
#define alox_ClientIdMapResetResponse_FIELDLIST(X, a) \
X(a, STATIC, SINGULAR, BOOL, success, 1)
#define alox_ClientIdMapResetResponse_CALLBACK NULL
#define alox_ClientIdMapResetResponse_DEFAULT NULL
#define alox_SetLogLevelRequest_FIELDLIST(X, a) \ #define alox_SetLogLevelRequest_FIELDLIST(X, a) \
X(a, STATIC, SINGULAR, BOOL, write, 1) \ X(a, STATIC, SINGULAR, BOOL, write, 1) \
X(a, STATIC, SINGULAR, UINT32, level, 2) X(a, STATIC, SINGULAR, UINT32, level, 2)
@@ -1274,6 +1452,10 @@ extern const pb_msgdesc_t alox_AccelDeadzoneRequest_msg;
extern const pb_msgdesc_t alox_AccelDeadzoneResponse_msg; extern const pb_msgdesc_t alox_AccelDeadzoneResponse_msg;
extern const pb_msgdesc_t alox_AccelStreamRequest_msg; extern const pb_msgdesc_t alox_AccelStreamRequest_msg;
extern const pb_msgdesc_t alox_AccelStreamResponse_msg; extern const pb_msgdesc_t alox_AccelStreamResponse_msg;
extern const pb_msgdesc_t alox_InputSendModeRequest_msg;
extern const pb_msgdesc_t alox_InputSendModeResponse_msg;
extern const pb_msgdesc_t alox_BatteryUvRequest_msg;
extern const pb_msgdesc_t alox_BatteryUvResponse_msg;
extern const pb_msgdesc_t alox_BatteryStatusRequest_msg; extern const pb_msgdesc_t alox_BatteryStatusRequest_msg;
extern const pb_msgdesc_t alox_LipoReading_msg; extern const pb_msgdesc_t alox_LipoReading_msg;
extern const pb_msgdesc_t alox_BatterySample_msg; extern const pb_msgdesc_t alox_BatterySample_msg;
@@ -1298,6 +1480,8 @@ extern const pb_msgdesc_t alox_EspNowFindMeRequest_msg;
extern const pb_msgdesc_t alox_EspNowFindMeResponse_msg; extern const pb_msgdesc_t alox_EspNowFindMeResponse_msg;
extern const pb_msgdesc_t alox_RestartRequest_msg; extern const pb_msgdesc_t alox_RestartRequest_msg;
extern const pb_msgdesc_t alox_RestartResponse_msg; extern const pb_msgdesc_t alox_RestartResponse_msg;
extern const pb_msgdesc_t alox_ClientIdMapResetRequest_msg;
extern const pb_msgdesc_t alox_ClientIdMapResetResponse_msg;
extern const pb_msgdesc_t alox_SetLogLevelRequest_msg; extern const pb_msgdesc_t alox_SetLogLevelRequest_msg;
extern const pb_msgdesc_t alox_SetLogLevelResponse_msg; extern const pb_msgdesc_t alox_SetLogLevelResponse_msg;
extern const pb_msgdesc_t alox_TapConfigRequest_msg; extern const pb_msgdesc_t alox_TapConfigRequest_msg;
@@ -1323,6 +1507,10 @@ extern const pb_msgdesc_t alox_OtaSlaveProgressResponse_msg;
#define alox_AccelDeadzoneResponse_fields &alox_AccelDeadzoneResponse_msg #define alox_AccelDeadzoneResponse_fields &alox_AccelDeadzoneResponse_msg
#define alox_AccelStreamRequest_fields &alox_AccelStreamRequest_msg #define alox_AccelStreamRequest_fields &alox_AccelStreamRequest_msg
#define alox_AccelStreamResponse_fields &alox_AccelStreamResponse_msg #define alox_AccelStreamResponse_fields &alox_AccelStreamResponse_msg
#define alox_InputSendModeRequest_fields &alox_InputSendModeRequest_msg
#define alox_InputSendModeResponse_fields &alox_InputSendModeResponse_msg
#define alox_BatteryUvRequest_fields &alox_BatteryUvRequest_msg
#define alox_BatteryUvResponse_fields &alox_BatteryUvResponse_msg
#define alox_BatteryStatusRequest_fields &alox_BatteryStatusRequest_msg #define alox_BatteryStatusRequest_fields &alox_BatteryStatusRequest_msg
#define alox_LipoReading_fields &alox_LipoReading_msg #define alox_LipoReading_fields &alox_LipoReading_msg
#define alox_BatterySample_fields &alox_BatterySample_msg #define alox_BatterySample_fields &alox_BatterySample_msg
@@ -1347,6 +1535,8 @@ extern const pb_msgdesc_t alox_OtaSlaveProgressResponse_msg;
#define alox_EspNowFindMeResponse_fields &alox_EspNowFindMeResponse_msg #define alox_EspNowFindMeResponse_fields &alox_EspNowFindMeResponse_msg
#define alox_RestartRequest_fields &alox_RestartRequest_msg #define alox_RestartRequest_fields &alox_RestartRequest_msg
#define alox_RestartResponse_fields &alox_RestartResponse_msg #define alox_RestartResponse_fields &alox_RestartResponse_msg
#define alox_ClientIdMapResetRequest_fields &alox_ClientIdMapResetRequest_msg
#define alox_ClientIdMapResetResponse_fields &alox_ClientIdMapResetResponse_msg
#define alox_SetLogLevelRequest_fields &alox_SetLogLevelRequest_msg #define alox_SetLogLevelRequest_fields &alox_SetLogLevelRequest_msg
#define alox_SetLogLevelResponse_fields &alox_SetLogLevelResponse_msg #define alox_SetLogLevelResponse_fields &alox_SetLogLevelResponse_msg
#define alox_TapConfigRequest_fields &alox_TapConfigRequest_msg #define alox_TapConfigRequest_fields &alox_TapConfigRequest_msg
@@ -1376,12 +1566,16 @@ extern const pb_msgdesc_t alox_OtaSlaveProgressResponse_msg;
#define alox_BatterySample_size 32 #define alox_BatterySample_size 32
#define alox_BatteryStatusRequest_size 8 #define alox_BatteryStatusRequest_size 8
#define alox_BatteryStatusResponse_size 580 #define alox_BatteryStatusResponse_size 580
#define alox_BatteryUvRequest_size 12
#define alox_BatteryUvResponse_size 18
#define alox_BleStreamTick_size 753 #define alox_BleStreamTick_size 753
#define alox_CacheClientAccel_size 26 #define alox_CacheClientAccel_size 26
#define alox_CacheClientStatus_size 44 #define alox_CacheClientStatus_size 44
#define alox_CacheClientTap_size 8 #define alox_CacheClientTap_size 8
#define alox_CacheStatusRequest_size 0 #define alox_CacheStatusRequest_size 0
#define alox_CacheStatusResponse_size 736 #define alox_CacheStatusResponse_size 736
#define alox_ClientIdMapResetRequest_size 0
#define alox_ClientIdMapResetResponse_size 2
#define alox_ClientInput_size 22 #define alox_ClientInput_size 22
#define alox_EspNowEchoPingRequest_size 17 #define alox_EspNowEchoPingRequest_size 17
#define alox_EspNowEchoPingResponse_size 25 #define alox_EspNowEchoPingResponse_size 25
@@ -1389,6 +1583,8 @@ extern const pb_msgdesc_t alox_OtaSlaveProgressResponse_msg;
#define alox_EspNowFindMeResponse_size 8 #define alox_EspNowFindMeResponse_size 8
#define alox_EspNowUnicastTestRequest_size 12 #define alox_EspNowUnicastTestRequest_size 12
#define alox_EspNowUnicastTestResponse_size 8 #define alox_EspNowUnicastTestResponse_size 8
#define alox_InputSendModeRequest_size 12
#define alox_InputSendModeResponse_size 16
#define alox_LedRingProgressRequest_size 64 #define alox_LedRingProgressRequest_size 64
#define alox_LedRingProgressResponse_size 32 #define alox_LedRingProgressResponse_size 32
#define alox_LipoReading_size 8 #define alox_LipoReading_size 8
+79 -4
View File
@@ -27,7 +27,7 @@ enum MessageType {
BATTERY_STATUS = 26; BATTERY_STATUS = 26;
TAP_NOTIFY = 27; TAP_NOTIFY = 27;
reserved 28; reserved 28;
/** Combined cached accel + tap poll (one UART round-trip, ~16 ms cadence). */ /** Combined cached accel + tap poll (one UART round-trip, ~10 ms cadence). */
CACHE_STATUS = 29; CACHE_STATUS = 29;
/** Host → master → slave → master: timestamp echo round-trip (latency test). */ /** Host → master → slave → master: timestamp echo round-trip (latency test). */
ESPNOW_ECHO_PING = 30; ESPNOW_ECHO_PING = 30;
@@ -37,6 +37,21 @@ enum MessageType {
TAP_CONFIG = 32; TAP_CONFIG = 32;
/** Master → host (BLE only): periodic pushed cache snapshot with ESP timestamp. */ /** Master → host (BLE only): periodic pushed cache snapshot with ESP timestamp. */
BLE_STREAM_TICK = 33; BLE_STREAM_TICK = 33;
/**
* Host → device: clear master client-id NVS map + live registry (and notify
* online slaves), or on a slave clear local master/id binding only.
*/
CLIENT_ID_MAP_RESET = 34;
/**
* Host → master: get/set slave ESP-NOW send path for accel/tap
* (best_effort / fast / wait / reliable).
*/
INPUT_SEND_MODE = 35;
/**
* Host → master: get/set software LiPo UV protection enable (NVS per pod).
* client_id 0 = master; >0 = one slave; all_clients = master + every slave.
*/
BATTERY_UV = 36;
} }
message UartMessage { message UartMessage {
@@ -78,6 +93,12 @@ message UartMessage {
TapConfigRequest tap_config_request = 39; TapConfigRequest tap_config_request = 39;
TapConfigResponse tap_config_response = 40; TapConfigResponse tap_config_response = 40;
BleStreamTick ble_stream_tick = 41; BleStreamTick ble_stream_tick = 41;
ClientIdMapResetRequest client_id_map_reset_request = 42;
ClientIdMapResetResponse client_id_map_reset_response = 43;
InputSendModeRequest input_send_mode_request = 44;
InputSendModeResponse input_send_mode_response = 45;
BatteryUvRequest battery_uv_request = 46;
BatteryUvResponse battery_uv_response = 47;
} }
} }
@@ -108,6 +129,8 @@ message ClientInfo {
bool tap_notify_single = 9; bool tap_notify_single = 9;
bool tap_notify_double = 10; bool tap_notify_double = 10;
bool tap_notify_triple = 11; bool tap_notify_triple = 11;
/** Master cache: software UV protection enabled on this slave (NVS on device). */
bool uv_protection_enabled = 12;
} }
message ClientInfoResponse { message ClientInfoResponse {
@@ -142,9 +165,9 @@ message AccelDeadzoneResponse {
uint32 slaves_updated = 4; uint32 slaves_updated = 4;
} }
// Host → master: enable/disable slave accel ESP-NOW stream (~16 ms per slave). // Host → master: enable/disable slave accel ESP-NOW stream (~10 ms per slave, best-effort).
// write=false: read; write=true: apply. client_id 0 invalid for write (use >0 or all_clients). // write=false: read; write=true: apply. client_id 0 invalid for write (use >0 or all_clients).
// period_ms (8–16): BLE host push interval for BleStreamTick; ignored on UART (default 16). // period_ms (10–16): BLE host push interval for BleStreamTick; ignored on UART (default 10).
message AccelStreamRequest { message AccelStreamRequest {
bool write = 1; bool write = 1;
bool enable = 2; bool enable = 2;
@@ -160,6 +183,51 @@ message AccelStreamResponse {
uint32 slaves_updated = 4; uint32 slaves_updated = 4;
} }
/**
* Slave ESP-NOW TX path for ACCEL_SAMPLE / TAP_EVENT (debug A/B).
* Maps 1:1 to esp_now_core_send_*.
*/
enum InputSendMode {
INPUT_SEND_BEST_EFFORT = 0;
INPUT_SEND_FAST = 1;
INPUT_SEND_WAIT = 2;
INPUT_SEND_RELIABLE = 3;
}
// Host → master: get/set input send mode on slaves.
// write=false: return last mode remembered on master (client_id ignored).
// write=true: push mode via ESP-NOW (all_clients or one client_id).
message InputSendModeRequest {
bool write = 1;
InputSendMode mode = 2;
uint32 client_id = 3;
bool all_clients = 4;
}
message InputSendModeResponse {
bool success = 1;
InputSendMode mode = 2;
uint32 client_id = 3;
uint32 slaves_updated = 4;
}
// write=false: read UV enable; write=true: persist enable (and clear latch when disabling).
// client_id 0 = local master; >0 = one slave; all_clients = master + every registered slave.
message BatteryUvRequest {
bool write = 1;
bool enabled = 2;
uint32 client_id = 3;
bool all_clients = 4;
}
message BatteryUvResponse {
uint32 client_id = 1;
bool success = 2;
bool enabled = 3;
bool latched = 4;
uint32 slaves_updated = 5;
}
/** Host → master: read LiPo ADC voltages (master local and/or slaves via ESP-NOW). */ /** Host → master: read LiPo ADC voltages (master local and/or slaves via ESP-NOW). */
message BatteryStatusRequest { message BatteryStatusRequest {
/** 0 = master only; >0 = one slave; ignored when all_clients */ /** 0 = master only; >0 = one slave; ignored when all_clients */
@@ -349,6 +417,13 @@ message RestartResponse {
uint32 client_id = 2; uint32 client_id = 2;
} }
/** Host → master or slave: clear persisted client-id assignment state. */
message ClientIdMapResetRequest {}
message ClientIdMapResetResponse {
bool success = 1;
}
/** Host → master: read/write global log level (esp_log_level_set("*", …)). */ /** Host → master: read/write global log level (esp_log_level_set("*", …)). */
message SetLogLevelRequest { message SetLogLevelRequest {
bool write = 1; bool write = 1;
@@ -393,7 +468,7 @@ message TapConfigResponse {
bool enable_triple = 12; bool enable_triple = 12;
} }
// Host → device: begin UART OTA (erase inactive OTA slot; device replies OTA_STATUS). // Host → device: begin host OTA over UART or BLE (erase inactive OTA slot; device replies OTA_STATUS).
message OtaStartPayload { message OtaStartPayload {
uint32 total_size = 1; uint32 total_size = 1;
} }
+4 -2
View File
@@ -421,7 +421,8 @@ CONFIG_BOOTLOADER_PROJECT_VER=1
# #
# Application Rollback # Application Rollback
# #
# CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE is not set CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE=y
# CONFIG_BOOTLOADER_APP_ANTI_ROLLBACK is not set
# end of Application Rollback # end of Application Rollback
# #
@@ -2529,7 +2530,8 @@ CONFIG_WIFI_PROV_STA_ALL_CHANNEL_SCAN=y
# Deprecated options for backward compatibility # Deprecated options for backward compatibility
# CONFIG_APP_BUILD_TYPE_ELF_RAM is not set # CONFIG_APP_BUILD_TYPE_ELF_RAM is not set
# CONFIG_NO_BLOBS is not set # CONFIG_NO_BLOBS is not set
# CONFIG_APP_ROLLBACK_ENABLE is not set CONFIG_APP_ROLLBACK_ENABLE=y
# CONFIG_APP_ANTI_ROLLBACK is not set
# CONFIG_LOG_BOOTLOADER_LEVEL_NONE is not set # CONFIG_LOG_BOOTLOADER_LEVEL_NONE is not set
# CONFIG_LOG_BOOTLOADER_LEVEL_ERROR is not set # CONFIG_LOG_BOOTLOADER_LEVEL_ERROR is not set
# CONFIG_LOG_BOOTLOADER_LEVEL_WARN is not set # CONFIG_LOG_BOOTLOADER_LEVEL_WARN is not set
+3
View File
@@ -6,3 +6,6 @@ CONFIG_ESP_COEX_SW_COEXIST_ENABLE=y
# Pin stacks to different cores (WiFi stays on core 0) # Pin stacks to different cores (WiFi stays on core 0)
CONFIG_BT_NIMBLE_PINNED_TO_CORE_1=y CONFIG_BT_NIMBLE_PINNED_TO_CORE_1=y
CONFIG_ESP_WIFI_TASK_PINNED_TO_CORE_0=y CONFIG_ESP_WIFI_TASK_PINNED_TO_CORE_0=y
# OTA A/B: new image boots PENDING_VERIFY; crash/reset before confirm → rollback
CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE=y