Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f90dfe82f | ||
|
|
141176a824 | ||
|
|
9046677460 | ||
|
|
73f48c8002 | ||
|
|
7f28a48a10 | ||
|
|
c3b20887bf | ||
|
|
1adaddd6c8 | ||
|
|
178ab46cec | ||
|
|
ae8ef40820 | ||
|
|
567dab6d0a | ||
|
|
af20635047 | ||
|
|
aa67edfe94 | ||
|
|
92a13d7fd0 | ||
|
|
d7441cb85f | ||
|
|
f006f8b912 | ||
|
|
be18b758ed | ||
|
|
490e0ee61f | ||
|
|
ac223ada72 | ||
|
|
35ce1476d8 | ||
|
|
f89ea3cbe3 | ||
|
|
99956e3362 |
+18
-12
@@ -79,8 +79,9 @@ sequenceDiagram
|
||||
|
||||
| Feld | Quelle | Bedeutung |
|
||||
|------|--------|-----------|
|
||||
| `master` | `DIP_MASTER` (GPIO 4): Low = Master | Steuert UART + Registry-Nutzung |
|
||||
| `network` | IO-Expander, Bits 5–8 (nibble reversed) | 1–8 → WiFi/ESP-NOW-Kanal |
|
||||
| `master` | `DIP_MASTER` (GPIO 4): Low = Master | Steuert Host-Transport + Registry |
|
||||
| `network` | IO-Expander 8er-DIP-Bank, High-Nibble PCB-reversed; DIP1–7 one-hot | 1–7 → WiFi/ESP-NOW-Kanal |
|
||||
| `host_ble` | Expander DIP8 (nach Reverse+Invert) | `false`=UART, `true`=BLE (Boot only) |
|
||||
| `running_partition` | `esp_ota_get_running_partition()` | Aktives OTA-Label (`ota_0` / `ota_1`) |
|
||||
|
||||
**Dateien:** `main/app_config.h`, `main/powerpod.c`, `main/powerpod.h` (Pins).
|
||||
@@ -96,17 +97,21 @@ sequenceDiagram
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ Dispatch: cmd_handler.c (Queue + vCmdDispatcherTask) │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ Transport UART: uart.c (Framing) │ ESP-NOW: esp_now_comm │
|
||||
│ Host transport: uart.c / ble_host.c (Framing via host_transport) │
|
||||
│ ESP-NOW: esp_now_comm │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ Protokoll: uart_messages.proto │ esp_now_messages.proto │
|
||||
│ Codec: nanopb (proto/*.pb.c) │ esp_now_proto.c │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ Domäne: client_registry, bosch456, led_ring, ota_*, board │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ ESP-IDF: UART, WiFi, ESP-NOW, I2C, ADC, OTA, NVS, FreeRTOS │
|
||||
│ ESP-IDF: UART, NimBLE, WiFi, ESP-NOW, I2C, ADC, OTA, NVS │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Host UART vs BLE:** Nur einer aktiv (DIP8). Gleiches Framing (`0xAA`…`0xCC`) und dieselbe `cmd_queue`. BLE: NUS-ähnliche GATT-Char, Write=RX, Notify=TX; Frames größer als ATT MTU-3 werden in aufeinanderfolgende Notifies gestückelt (Host-Parser reassembliert). `gotool serve` verbindet UART oder BLE über Dashboard/REST/WS (`managedHost`); **OTA** über UART oder BLE (ATT MTU ≥ ~219, preferred 517). Bei BLE-Host und aktivem Accel-Stream pusht `ble_stream` periodisch `BLE_STREAM_TICK` (10–16 ms, Default 10; pausiert während OTA) mit `esp_timestamp_us` (Live-Pfad bevorzugt Tick statt CACHE_STATUS-Poll).
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 4. Datenfluss: Commands (Befehle)
|
||||
@@ -205,9 +210,9 @@ sequenceDiagram
|
||||
```
|
||||
|
||||
**Beispiel-Implementierung:** `main/cmd/cmd_accel_deadzone.c`
|
||||
**Weitere Bridge-Handler:** `cmd_accel_stream.c`, `cmd_tap_notify.c`, `cmd_led_ring.c`, `cmd_espnow_find_me.c`, `cmd_restart.c`, `cmd_espnow_unicast_test.c`, `cmd/cmd_ota.c` (OTA + `ota_espnow.c`).
|
||||
**Weitere Bridge-Handler:** `cmd_accel_stream.c`, `cmd_tap_notify.c`, `cmd_led_ring.c`, `cmd_espnow_find_me.c`, `cmd_restart.c`, `cmd_espnow_unicast_test.c`, `cmd_espnow_echo_ping.c`, `cmd/cmd_ota.c` (OTA + `ota_espnow.c`).
|
||||
|
||||
**Nur Master / nur Cache (kein Slave-Roundtrip):** `cmd_client_info.c`, `cmd_battery.c`, `cmd_cache_status.c`, `cmd_version.c`.
|
||||
**Nur Master / nur Cache (kein Slave-Roundtrip):** `cmd_client_info.c`, `cmd_battery.c`, `cmd_cache_status.c`, `cmd_version.c`, `cmd_set_log_level.c`.
|
||||
|
||||
---
|
||||
|
||||
@@ -220,12 +225,12 @@ Unabhängig von Protobuf — reines Bytestream-Framing:
|
||||
| Feld | Wert |
|
||||
|------|------|
|
||||
| Start | `0xAA` |
|
||||
| Länge | 1 Byte (1–252) |
|
||||
| Länge | 2 Bytes little-endian (1–1024) |
|
||||
| Payload | `length` Bytes |
|
||||
| Prüfsumme | XOR aller Payload-Bytes |
|
||||
| Stopp | `0xCC` |
|
||||
|
||||
**Parameter:** `main/uart.h` — `UART_NUM_1`, `921600` Baud, TX GPIO **2**, RX GPIO **3**, `MAX_PAYLOAD_SIZE` 248.
|
||||
**Parameter:** `main/uart.h` — `UART_NUM_1`, `921600` Baud, TX GPIO **2**, RX GPIO **3**, `MAX_PAYLOAD_SIZE` 1024.
|
||||
|
||||
### 5.2 Nutzlast (Anwendung)
|
||||
|
||||
@@ -310,7 +315,7 @@ Alle Master-Sends laufen über `send_message()` / `send_message_ex()`:
|
||||
|
||||
| Nachricht | Task / Trigger | Master-Ziel |
|
||||
|-----------|----------------|-------------|
|
||||
| `ESPNOW_ACCEL_SAMPLE` | `slave_accel_stream_task` 16 ms | `client_registry_update_accel()` |
|
||||
| `ESPNOW_ACCEL_SAMPLE` | `accel_stream_task` poll 10 ms + deadzone (nur bei Änderung), 1 ms slots via Discover `master_time_us` | `client_registry_update_accel()` |
|
||||
| `ESPNOW_TAP_EVENT` | BMA456-IRQ → `on_bma456_tap` | `client_registry_update_tap()` |
|
||||
| `ESPNOW_BATTERY_REPORT` | Heartbeat + nach Join | `client_registry_update_battery()` |
|
||||
| `ESPNOW_SLAVE_INFO` / `HEARTBEAT` | `slave_tx_task` / Heartbeat-Task | `client_registry_heartbeat()` |
|
||||
@@ -320,7 +325,7 @@ Alle Master-Sends laufen über `send_message()` / `send_message_ex()`:
|
||||
|
||||
### 6.5 OTA über ESP-NOW
|
||||
|
||||
Nach erfolgreichem UART-OTA auf dem Master (oder `OTA_START_ESPNOW`): `ota_espnow.c` liest gestagte Partition, sendet `OTA_START` / `PAYLOAD` (≤200 B, `send_message_ex` mit ACK-Semaphore) / `OTA_END` an alle **available** Slaves.
|
||||
Nach erfolgreichem UART-OTA auf dem Master (oder `OTA_START_ESPNOW`): `ota_espnow.c` liest gestagte Partition, sendet `OTA_START` / `PAYLOAD` (≤200 B, `send_message_ex` mit ACK-Semaphore) / `OTA_END` an alle **available** Slaves. Anschließend Reboot; Bootloader führt neues Image als `PENDING_VERIFY`, `ota_confirm` bestätigt nach stabilem Boot.
|
||||
|
||||
Gemeinsamer Flash-Puffer: `ota_uart.c` (4 KiB Blockgröße).
|
||||
|
||||
@@ -343,7 +348,7 @@ flowchart TB
|
||||
|-------|-------------------|-------------------|
|
||||
| Presence, Version | HEARTBEAT / SLAVE_INFO | `CLIENT_INFO` |
|
||||
| Accel-Stream-Samples | `ESPNOW_ACCEL_SAMPLE` | `CACHE_STATUS` |
|
||||
| Tap-Events (16 ms Cache) | `ESPNOW_TAP_EVENT` | `CACHE_STATUS` |
|
||||
| Tap-Events (10 ms Cache) | `ESPNOW_TAP_EVENT` | `CACHE_STATUS` |
|
||||
| Batterie | `ESPNOW_BATTERY_REPORT` + Master-ADC | `BATTERY_STATUS` |
|
||||
| Konfig-Flags | Handler + ESP-NOW | `CLIENT_INFO`, diverse GET |
|
||||
|
||||
@@ -361,7 +366,7 @@ flowchart TB
|
||||
| `espnow_mon` | Master | 1 s Timeout + Master-Battery |
|
||||
| `espnow_stx` | Slave | Queue: SLAVE_INFO, Battery nach Join |
|
||||
| `espnow_hb` | Slave | 1 s Heartbeat + 30 s Battery |
|
||||
| `espnow_accel` | Slave | 16 ms Accel wenn Stream an |
|
||||
| `espnow_accel` | Slave | Accel poll 10 ms / TX bei Deadzone-Änderung, ID-Slot 1 ms wenn Stream an |
|
||||
| `bma456_poll` | Beide | 10 Hz (`bosch456.c`) |
|
||||
| `vTaskLedRing` | Beide | LED-Ring-Befehle |
|
||||
|
||||
@@ -386,6 +391,7 @@ flowchart TB
|
||||
| LED | `main/led_ring.c` | 95 LEDs, Digit-Maps |
|
||||
| OTA UART | `main/ota_uart.c`, `cmd/cmd_ota.c` | A/B-Partition Upload |
|
||||
| OTA ESP-NOW | `main/ota_espnow.c` | Verteilung an Slaves |
|
||||
| OTA Confirm | `main/ota_confirm.c` | Nach ~8 s `mark_app_valid` (Rollback) |
|
||||
| Einstellungen NVS | `main/pod_settings.c` | Accel-Deadzone persistent |
|
||||
| Board | `main/board_input.c` | Taster, LiPo-ADC |
|
||||
| Protobuf Schemas | `main/proto/*.proto` | Vertrag UART / ESP-NOW |
|
||||
|
||||
+125
-28
@@ -47,14 +47,17 @@ Zielbild für den Host: Befehle an den Master senden; der Master steuert Slaves
|
||||
|--------|------|----------------|
|
||||
| DIP Master/Slave | 4 | `powerpod.h` — Low = Master |
|
||||
| I2C SCL / SDA | 5 / 6 | IO-Expander `0x20`, BMA456 `0x18` |
|
||||
| UART1 TX / RX | **2 / 3** | `uart.h` (Adapter: ESP-TX → Host-RX) |
|
||||
| Expander DIP-Bank (8) | PCF/TCA @ `0x20` | DIP1–7 = Netzwerk 1–7 (one-hot); **DIP8 ON = BLE host**, OFF = UART. High-Nibble auf PCB vertauscht → `reverse_high_nibble_lut`. Wechsel nur nach Reboot. |
|
||||
| UART1 TX / RX | **2 / 3** | `uart.h` (Adapter: ESP-TX → Host-RX); nur wenn DIP8 = UART |
|
||||
| LED-Ring (WS2812) | 7 | `led_ring.c` |
|
||||
| BMA456 Interrupt | 10 | `bosch456.c` |
|
||||
| Taster | 12 | `board_input.c` |
|
||||
| LiPo ADC 1 | 1 | `board_input.c` |
|
||||
| LiPo ADC 2 | 12 | Entfällt wenn = Taster-GPIO |
|
||||
| LiPo ADC 2 | 11 | `board_input.c` |
|
||||
|
||||
**UART:** `UART_NUM_1`, **921600** Baud, 8N1, kein Flow-Control.
|
||||
**Host-Protokoll (UART oder BLE):** Gleiches Framing (`0xAA`…`0xCC`) + nanopb. **UART:** `UART_NUM_1`, **921600** Baud, 8N1. **BLE:** Gerät `Powerpod`, NUS-UUIDs `6e400001`/`6e400002` (Write=RX, Notify=TX). goTool CLI: `-port …` oder `-ble` / `-ble-name Powerpod`. **`gotool serve`** startet ohne Pflicht-Gerät; Dashboard/REST/WS verbinden UART oder BLE dynamisch (`-port`/`-ble` nur optionale Vorbelegung). **OTA** über UART oder BLE (DIP8); BLE braucht ATT MTU ≥ ~219 für 200-B-Chunks. Bei BLE + Accel-Stream: periodische `BLE_STREAM_TICK`-Notifies (`period_ms` 10–16; während OTA pausiert).
|
||||
|
||||
**ESP-IDF-Log (Debug):** `esp_log_*` geht auf **UART0** (Standard-Konsole, typisch USB am Dev-Board, **115200** Baud, `CONFIG_ESP_CONSOLE_UART_NUM=0`). Das ist **getrennt** vom Host-UART1 — goTool liest keine `esp_log`-Ausgabe. Ohne angeschlossenes Debug-Kabel werden aktivierte Logs trotzdem formatiert und an UART0 gesendet (CPU-Overhead bleibt); nur `ESP_LOG_NONE` / Level-Filter vermeiden die Arbeit.
|
||||
|
||||
**I2C:** 100 kHz, interne Pull-ups, gemeinsamer Bus für Expander und BMA456H.
|
||||
|
||||
@@ -89,21 +92,23 @@ idf.py -p /dev/ttyUSB0 flash monitor
|
||||
### 4.1 Initialisierungsreihenfolge (`powerpod.c`)
|
||||
|
||||
1. `pod_settings_init()` — NVS
|
||||
2. DIP → `app_config.master`
|
||||
3. I2C + IO-Expander → `app_config.network` (1–8)
|
||||
4. `init_bma456()` — optional, bei Fehler weiter ohne Sensor
|
||||
5. `pod_settings_apply_accel_deadzone()` wenn Sensor da
|
||||
6. OTA-Partition → `app_config.running_partition`
|
||||
7. `board_input_init()`
|
||||
8. `esp_now_comm_init(&app_config)` — **immer** (Master + Slave)
|
||||
9. `led_ring_init()`
|
||||
10. **Nur Master:** `cmd_queue` → `init_cmdHandler` → `init_uart` → alle `cmd_*_register()`
|
||||
2. `board_input_init_adc_only()` — LiPo-ADC
|
||||
3. Bei `POWERPOD_BATTERY_UV_ENABLE`: `battery_uv_evaluate_boot()` → ggf. Energiesparmodus (nur LED + ADC, **kein** weiterer Init)
|
||||
4. DIP → `app_config.master`
|
||||
5. I2C + IO-Expander → `app_config.network` (1–7) + `app_config.host_ble` (DIP8)
|
||||
6. `init_bma456()` — optional, bei Fehler weiter ohne Sensor
|
||||
7. `pod_settings_apply_accel_deadzone()` wenn Sensor da
|
||||
8. OTA-Partition → `app_config.running_partition`
|
||||
9. `board_input_start_lipo_monitor()` + `board_input_init_button()`
|
||||
10. `esp_now_comm_init(&app_config)` — **normaler Pfad** (Master + Slave)
|
||||
11. `led_ring_init()`
|
||||
12. **Nur Master:** `cmd_queue` → `init_cmdHandler` → `init_uart` **oder** `init_ble_host` → alle `cmd_*_register()`
|
||||
|
||||
### 4.2 Master vs. Slave
|
||||
|
||||
| Funktion | Master | Slave |
|
||||
|----------|--------|-------|
|
||||
| UART Command-Handler | Ja | Nein |
|
||||
| Host Command-Handler (UART oder BLE) | Ja | Nein |
|
||||
| ESP-NOW Discover senden | Ja (Broadcast) | Nein |
|
||||
| ESP-NOW auf Discover reagieren | Nein | Ja |
|
||||
| `client_registry` für Fremdknoten | Ja | Nein (nur eigener Join-State) |
|
||||
@@ -119,10 +124,10 @@ idf.py -p /dev/ttyUSB0 flash monitor
|
||||
| Byte | Inhalt |
|
||||
|------|--------|
|
||||
| 0 | `0xAA` Start |
|
||||
| 1 | Länge N (1–252) |
|
||||
| 2…N+1 | Payload |
|
||||
| N+2 | XOR-Checksum über Payload |
|
||||
| N+3 | `0xCC` Stopp |
|
||||
| 1–2 | Länge N little-endian (1–1024) |
|
||||
| 3…N+2 | Payload |
|
||||
| N+3 | XOR-Checksum über Payload |
|
||||
| N+4 | `0xCC` Stopp |
|
||||
|
||||
Implementierung: `uart.c` — `parse_uart_byte()`, `uart_send_framed()`.
|
||||
|
||||
@@ -171,7 +176,8 @@ UART_CMD_REQ(&uart_msg, alox_UartMessage_accel_deadzone_request_tag, accel_deadz
|
||||
| `ESPNOW_SLAVE_INFO` | S→M | `slave_info` | Erste Registrierung |
|
||||
| `ESPNOW_HEARTBEAT` | S→M | `heartbeat` | Keepalive 1 s |
|
||||
| `ESPNOW_SET_ACCEL_DEADZONE` | M→S | `accel_deadzone` | Deadzone LSB |
|
||||
| `ESPNOW_SET_ACCEL_STREAM` | M→S | `accel_stream` | Stream ~16 ms |
|
||||
| `ESPNOW_SET_TAP_CONFIG` | M→S | `tap_config` | Multitap-Parameter + NVS |
|
||||
| `ESPNOW_SET_ACCEL_STREAM` | M→S | `accel_stream` | Stream poll ~10 ms, TX on deadzone change |
|
||||
| `ESPNOW_ACCEL_SAMPLE` | S→M | `accel_sample` | x/y/z Roh-LSB |
|
||||
| `ESPNOW_SET_TAP_NOTIFY` | M→S | `tap_notify` | Tap-Arten filtern |
|
||||
| `ESPNOW_TAP_EVENT` | S→M | `tap_event` | kind 1/2/3 |
|
||||
@@ -181,6 +187,8 @@ UART_CMD_REQ(&uart_msg, alox_UartMessage_accel_deadzone_request_tag, accel_deadz
|
||||
| `ESPNOW_FIND_ME` | M→S | `find_me` | LED-Locate |
|
||||
| `ESPNOW_RESTART` | M→S | `restart` | Reboot Slave |
|
||||
| `ESPNOW_UNICAST_TEST` | M→S | `unicast_test` | Link-Test |
|
||||
| `ESPNOW_ECHO_PING` | M→S | `echo_ping` | Latenztest (Host-Timestamp + `master_time_us`) |
|
||||
| `ESPNOW_ECHO_PONG` | S→M | `echo_pong` | Echo unverändert zurück zum Master |
|
||||
| `ESPNOW_OTA_*` | M↔S | `ota_*` | Firmware-Verteilung |
|
||||
|
||||
### 6.3 Zeitkonstanten (`esp_now_comm.c`)
|
||||
@@ -191,8 +199,9 @@ UART_CMD_REQ(&uart_msg, alox_UartMessage_accel_deadzone_request_tag, accel_deadz
|
||||
| Heartbeat | 1000 ms |
|
||||
| Client-Timeout (Master) | 3 × Heartbeat = 3 s → `available=false` |
|
||||
| Master-Verlust (Slave) | 5 s ohne Discover |
|
||||
| Accel-Stream | 16 ms |
|
||||
| Accel-Stream | Poll 10 ms; TX nur bei Deadzone-Änderung (best-effort) |
|
||||
| Batterie-Report | 30 s (+ einmal 150 ms nach Join) |
|
||||
| Echo-Ping Timeout (Master) | 500 ms (`ESPNOW_ECHO_PING_TIMEOUT_MS`) |
|
||||
|
||||
### 6.4 `EspNowSlavePresence`
|
||||
|
||||
@@ -217,7 +226,7 @@ Nur auf dem **Master** registriert (`powerpod.c`). IDs aus `MessageType` in `uar
|
||||
| 6 | ACCEL_DEADZONE | `cmd_accel_deadzone.c` | Get/Set Deadzone lokal + ESP-NOW |
|
||||
| 7 | ESPNOW_UNICAST_TEST | `cmd_espnow_unicast_test.c` | Link-Test zu Slave |
|
||||
| 8 | LED_RING | `cmd_led_ring.c` | Ring lokal / ESP-NOW |
|
||||
| 16 | OTA_START | `cmd_ota.c` | UART-OTA beginnen |
|
||||
| 16 | OTA_START | `cmd_ota.c` | Host-OTA beginnen (UART oder BLE) |
|
||||
| 17 | OTA_PAYLOAD | `cmd_ota.c` | Chunk ≤200 B |
|
||||
| 18 | OTA_END | `cmd_ota.c` | Abschluss + ESP-NOW-Verteilung |
|
||||
| 19 | OTA_STATUS | `cmd_ota.c` | Gerät → Host Status |
|
||||
@@ -225,10 +234,14 @@ Nur auf dem **Master** registriert (`powerpod.c`). IDs aus `MessageType` in `uar
|
||||
| 21 | OTA_SLAVE_PROGRESS | `cmd_ota_slave_progress.c` | Fortschritt pro Slave |
|
||||
| 22 | FIND_ME | `cmd_espnow_find_me.c` | LED Locate |
|
||||
| 23 | RESTART | `cmd_restart.c` | Master oder Slave reboot |
|
||||
| 25 | ACCEL_STREAM | `cmd_accel_stream.c` | ESP-NOW Accel-Stream an/aus |
|
||||
| 25 | ACCEL_STREAM | `cmd_accel_stream.c` | ESP-NOW Accel-Stream an/aus (+ `period_ms` für BLE-Push) |
|
||||
| 26 | BATTERY_STATUS | `cmd_battery.c` | Cache LiPo Master + Slaves |
|
||||
| 27 | TAP_NOTIFY | `cmd_tap_notify.c` | Tap-Weiterleitung konfigurieren |
|
||||
| 29 | CACHE_STATUS | `cmd_cache_status.c` | Accel + Tap Cache (ein Round-Trip) |
|
||||
| 33 | BLE_STREAM_TICK | `ble_stream.c` | Master→Host Notify (nur BLE): Snapshot + `esp_timestamp_us` |
|
||||
| 30 | ESPNOW_ECHO_PING | `cmd_espnow_echo_ping.c` | Timestamp-Echo über ESP-NOW (Latenztest) |
|
||||
| 31 | SET_LOG_LEVEL | `cmd_set_log_level.c` | ESP-IDF-Log-Level global (`"*"`) lesen/setzen |
|
||||
| 32 | TAP_CONFIG | `cmd_tap_config.c` | BMA456 Multitap-Parameter + NVS |
|
||||
|
||||
### 7.1 VERSION (3)
|
||||
|
||||
@@ -247,14 +260,21 @@ Nur auf dem **Master** registriert (`powerpod.c`). IDs aus `MessageType` in `uar
|
||||
|
||||
### 7.4 ACCEL_STREAM (25)
|
||||
|
||||
- Optional `period_ms` (10–16): BLE-Host-Push-Intervall für `BLE_STREAM_TICK` (UART ignoriert; Default 10).
|
||||
- Master setzt `client_registry_set_accel_stream` und sendet `ESPNOW_SET_ACCEL_STREAM`.
|
||||
- Slave-Task `slave_accel_stream_task` sendet `ESPNOW_ACCEL_SAMPLE` alle 16 ms.
|
||||
- Slave-Task `accel_stream_task` pollt alle 10 ms; sendet `ESPNOW_ACCEL_SAMPLE` nur wenn eine Achse die Accel-Deadzone seit dem letzten gesendeten Sample überschreitet (best-effort / `send_best_effort`; Baseline-Reset beim Stream-Enable).
|
||||
|
||||
### 7.5 TAP_NOTIFY (27)
|
||||
|
||||
- Konfiguriert auf Slave welche Tap-Arten `ESPNOW_TAP_EVENT` auslösen.
|
||||
- Tap-Quelle: BMA456-Interrupt → `on_bma456_tap` in `esp_now_comm.c`.
|
||||
|
||||
### 7.5a TAP_CONFIG (32)
|
||||
|
||||
- **Request:** `write`, `client_id` (0=lokal), `all_clients`, Multitap-Felder (`tap_sens_thres` … `enable_triple`).
|
||||
- **Write:** `bma456_set_tap_config` + NVS `tap_cfg` lokal; Slaves via `ESPNOW_SET_TAP_CONFIG`.
|
||||
- Boot: `pod_settings_apply_tap_config()` nach `init_bma456()`.
|
||||
|
||||
### 7.6 CACHE_STATUS (29)
|
||||
|
||||
- **Request:** leer.
|
||||
@@ -287,13 +307,60 @@ Nur auf dem **Master** registriert (`powerpod.c`). IDs aus `MessageType` in `uar
|
||||
|
||||
Minimaler Master→Slave-Ping; Slave loggt `UNICAST TEST OK`.
|
||||
|
||||
### 7.11 ESPNOW_ECHO_PING (30)
|
||||
|
||||
Round-Trip-Latenztest zu einem **Slave** (`client_id` > 0, muss in der Registry sein).
|
||||
|
||||
**Ablauf:**
|
||||
|
||||
1. Host (goTool) setzt `timestamp_us` (Unix-µs) und sendet `EspNowEchoPingRequest` per UART.
|
||||
2. Master (`cmd_espnow_echo_ping.c`) löst MAC aus Registry auf, ruft `esp_now_comm_echo_ping()` auf.
|
||||
3. Master sendet `ESPNOW_ECHO_PING` mit `host_timestamp_us` und `master_time_us` (`esp_timer_get_time()` kurz vor Send).
|
||||
4. Slave (`handle_echo_ping`) antwortet mit `ESPNOW_ECHO_PONG` (Felder unverändert).
|
||||
5. Master empfängt Pong, berechnet `esp_rtt_us = esp_timer_get_time() - master_time_us`, antwortet per UART.
|
||||
|
||||
**Request:** `client_id`, `timestamp_us` (vom Host gesetzt).
|
||||
|
||||
**Response:** `success`, `client_id`, `timestamp_us` (echoed), `esp_rtt_us` (nur bei Erfolg).
|
||||
|
||||
| Feld | Quelle | Bedeutung |
|
||||
|------|--------|-----------|
|
||||
| `timestamp_us` | Host → Slave → Host | Korrelations-ID; muss mit Request übereinstimmen |
|
||||
| `esp_rtt_us` | Master | Rohe µs-Differenz von `esp_timer_get_time()` (Ping-Send → Pong-Empfang im `recv_cb`) |
|
||||
|
||||
**Implementierung:** `main/cmd/cmd_espnow_echo_ping.c`, `esp_now_comm_echo_ping()` in `esp_now_master.c`, Slave `handle_echo_ping` in `esp_now_slave.c`.
|
||||
|
||||
**Host-seitig (goTool):** `rtt_ms` = volle UART-Kette (Send bis Response-Empfang); unabhängig von `esp_rtt_us`.
|
||||
|
||||
### 7.12 SET_LOG_LEVEL (31)
|
||||
|
||||
Laufzeit-Steuerung des **globalen** ESP-IDF-Log-Levels auf dem Master (`esp_log_level_set("*", …)`). Kein ESP-NOW, kein NVS — nur Master.
|
||||
|
||||
**Request:** `write`, `level` (`esp_log_level_t`: 0=NONE, 1=ERROR, 2=WARN, 3=INFO, 4=DEBUG, 5=VERBOSE).
|
||||
|
||||
| `write` | Verhalten |
|
||||
|---------|-----------|
|
||||
| `false` / leer | Aktuelles Level lesen (`esp_log_level_get("*")`) |
|
||||
| `true` | Level setzen; ungültige Werte (>5) → `success=false` |
|
||||
|
||||
**Response:** `success`, `level` (aktuell bzw. gesetzt).
|
||||
|
||||
**Boot-Default:** `CONFIG_LOG_DEFAULT_LEVEL` in `sdkconfig` (aktuell **INFO** = 3). Kann per UART/Web-UI zur Laufzeit geändert werden; nach Reboot gilt wieder der sdkconfig-Wert.
|
||||
|
||||
**Ausgabe:** Logs erscheinen auf **UART0** (Debug-USB), nicht auf dem Host-UART1 (goTool).
|
||||
|
||||
```bash
|
||||
go run . -port /dev/ttyUSB0 log-level
|
||||
go run . -port /dev/ttyUSB0 log-level -set -level 3
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. OTA
|
||||
|
||||
### 8.1 UART-OTA (nur Master)
|
||||
### 8.1 Host-OTA (nur Master, UART oder BLE)
|
||||
|
||||
Implementierung: `ota_uart.c`, Steuerung `cmd/cmd_ota.c`.
|
||||
Implementierung: `ota_uart.c`, Steuerung `cmd/cmd_ota.c`. Gleiches Protokoll über DIP8-Host (UART oder BLE NUS).
|
||||
|
||||
| Phase | Host → Master | Master → Host |
|
||||
|-------|---------------|---------------|
|
||||
@@ -303,17 +370,20 @@ Implementierung: `ota_uart.c`, Steuerung `cmd/cmd_ota.c`.
|
||||
|
||||
- Inaktive Partition: `esp_ota_get_next_update_partition()`.
|
||||
- LED-Ring zeigt Fortschritt (blau Schreiben, grün Verteilung).
|
||||
- BLE: ATT MTU ≥ ~219 (preferred 517); große Antworten werden über mehrere Notifies gestückelt; `BLE_STREAM_TICK` pausiert während `ota_session_busy()`.
|
||||
- **BLE+ESP-NOW Coex:** Tag `wifi` auf `ESP_LOG_NONE` (WiFi-Task-Stack + Error-Logs → Overflow/Panic); während ESP-NOW-Dist `OTA_STATUS` DISTRIBUTING max. ~4/s und kürzere BLE-Notify-Retries.
|
||||
- **App-Rollback** (`CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE`): neues Image bootet als `PENDING_VERIFY`. `ota_confirm` markiert nach ~8 s stabilem Lauf `mark_app_valid`; Crash/Reset davor → Bootloader rollt auf die vorherige App zurück. Während `PENDING_VERIFY` ist ein neues `OTA_START` blockiert.
|
||||
|
||||
### 8.2 ESP-NOW-OTA (Master → Slaves)
|
||||
|
||||
`ota_espnow.c` — gleiche Status-Codes wie UART.
|
||||
`ota_espnow.c` — gleiche Status-Codes wie Host-OTA.
|
||||
|
||||
1. `ESPNOW_OTA_START` + `total_size` → Slave `ota_espnow_slave_on_start`
|
||||
2. `ESPNOW_OTA_PAYLOAD` bis 200 B → 4 KiB Buffer auf Slave
|
||||
3. `ESPNOW_OTA_END` → Boot-Partition setzen
|
||||
3. `ESPNOW_OTA_END` → Boot-Partition setzen + automatischer Reboot
|
||||
4. Slave antwortet `ESPNOW_OTA_STATUS` (mit `send_message_ex` + Semaphore auf Master)
|
||||
|
||||
Nach Erfolg: **alle Knoten neu starten**.
|
||||
Während Distribution: keine Client-Timeouts in der Registry (Heartbeats werden im recv_cb verworfen). Nach Erfolg: Master und Slaves starten automatisch neu (Rollback-Confirm siehe 8.1).
|
||||
|
||||
### 8.3 OTA_SLAVE_PROGRESS (21)
|
||||
|
||||
@@ -343,6 +413,7 @@ Ohne Sensor: `bma456_is_ready() == false`, Firmware läuft weiter.
|
||||
- Segment-Karten für Ziffern 0–10 in `digit_lookup[]`.
|
||||
- **Kein** lokaler Demo-Loop — Anzeige nur über UART (`cmd_led_ring.c`) oder ESP-NOW `ESPNOW_LED_RING`.
|
||||
- Helligkeit: `intensity` 0 → Default ~5 % (`LED_RING_DEFAULT_INTENSITY`).
|
||||
- Modi: `0` clear … `5` color, `6` **battery-low** (erste 4 LEDs rot ~10 %, 5 s).
|
||||
|
||||
---
|
||||
|
||||
@@ -351,12 +422,28 @@ Ohne Sensor: `bma456_is_ready() == false`, Firmware läuft weiter.
|
||||
`board_input.c`:
|
||||
|
||||
- **Taster** GPIO 12 — Logging bei Druck.
|
||||
- **LiPo-ADC** GPIO 1 (und optional 2, wenn nicht Taster).
|
||||
- **LiPo-ADC** GPIO 1 und GPIO 11 (Pin-Spannung in mV).
|
||||
- Master: `master_monitor_task` aktualisiert Master-Batterie alle 30 s.
|
||||
- Slave: `slave_send_battery_report_to_master` nach Join, Heartbeat und Query.
|
||||
|
||||
Spannungen in Millivolt in Registry und `BATTERY_STATUS`-UART.
|
||||
|
||||
### Software-UV-Schutz (`battery_uv.c`)
|
||||
|
||||
Aktivierbar mit `POWERPOD_BATTERY_UV_ENABLE` in `powerpod.h` (CMake-Override möglich). Runtime zusätzlich NVS `uv_en` (Default **aus**) — UART `BATTERY_UV` / Dashboard / ESP-NOW `ESPNOW_SET_BATTERY_UV`.
|
||||
|
||||
| Zustand | ADC-Schwelle | Logik |
|
||||
|---------|--------------|-------|
|
||||
| Extern / kein Pack | < 100 mV | Alle gültigen Kanäle → normaler Boot |
|
||||
| UV | 100…2299 mV | Ein gültiger Kanal reicht |
|
||||
| Geladen | ≥ 3000 mV | Alle nicht-absent gültigen Kanäle |
|
||||
|
||||
- NVS-Keys `uv_latch`, `uv_en` in `pod_settings.c`
|
||||
- Boot: ADC früh, bei UV nur LED + ADC-Poll (kein ESP-NOW/WiFi/UART/I2C/Button); übersprungen wenn `uv_en=0`
|
||||
- Runtime: `battery_uv_poll()` im LiPo-Monitor (2 Samples Debounce), gated by `uv_en`
|
||||
- UART `BATTERY_UV`: `client_id` / `all_clients`; Disable cleart Latch
|
||||
- LED-Anzeige: Mode `6` battery-low (`led_ring_show_battery_low`)
|
||||
|
||||
---
|
||||
|
||||
## 12. Persistenz (NVS)
|
||||
@@ -366,6 +453,9 @@ Spannungen in Millivolt in Registry und `BATTERY_STATUS`-UART.
|
||||
| Key | Inhalt |
|
||||
|-----|--------|
|
||||
| `accel_dz` | Accel-Deadzone LSB |
|
||||
| `tap_cfg` | BMA456 Multitap-Config (Blob) |
|
||||
| `uv_latch` | Software-UV-Zustand (`1` = Energiesparmodus) |
|
||||
| `uv_en` | Runtime-Enable UV-Schutz (`0`/`1`, fehlend = aus) |
|
||||
|
||||
Gespeichert bei lokalem Set (UART `client_id=0`, ESP-NOW auf Slave). Geladen nach `init_bma456()`.
|
||||
|
||||
@@ -420,6 +510,7 @@ Includes in generierten `.pb.c` müssen `"uart_messages.pb.h"` heißen (nicht `m
|
||||
| `cmd_client_info.c` | CLIENT_INFO |
|
||||
| `cmd_accel_deadzone.c` | ACCEL_DEADZONE |
|
||||
| `cmd_accel_stream.c` | ACCEL_STREAM |
|
||||
| `cmd_tap_config.c` | TAP_CONFIG |
|
||||
| `cmd_tap_notify.c` | TAP_NOTIFY |
|
||||
| `cmd_cache_status.c` | CACHE_STATUS |
|
||||
| `cmd_battery.c` | BATTERY_STATUS |
|
||||
@@ -427,6 +518,8 @@ Includes in generierten `.pb.c` müssen `"uart_messages.pb.h"` heißen (nicht `m
|
||||
| `cmd_espnow_find_me.c` | FIND_ME |
|
||||
| `cmd_restart.c` | RESTART |
|
||||
| `cmd_espnow_unicast_test.c` | ESPNOW_UNICAST_TEST |
|
||||
| `cmd_espnow_echo_ping.c` | ESPNOW_ECHO_PING |
|
||||
| `cmd_set_log_level.c` | SET_LOG_LEVEL |
|
||||
| `cmd_ota.c` | OTA_* |
|
||||
| `cmd_ota_slave_progress.c` | OTA_SLAVE_PROGRESS |
|
||||
|
||||
@@ -437,6 +530,7 @@ Includes in generierten `.pb.c` müssen `"uart_messages.pb.h"` heißen (nicht `m
|
||||
| `bosch456.c` | Accelerometer + Tap |
|
||||
| `led_ring.c` | LED-Anzeige |
|
||||
| `board_input.c` | Taster, ADC |
|
||||
| `battery_uv.c` | Software-UV-Schutz, Energiesparmodus |
|
||||
| `pod_settings.c` | NVS |
|
||||
| `pod_reboot.c` | Verzögerter Restart |
|
||||
| `ota_uart.c` | Flash-Puffer UART-OTA |
|
||||
@@ -455,9 +549,12 @@ Includes in generierten `.pb.c` müssen `"uart_messages.pb.h"` heißen (nicht `m
|
||||
|
||||
## 15. Logging-Tags
|
||||
|
||||
**Level zur Laufzeit:** UART `SET_LOG_LEVEL` (31) oder Dashboard „ESP Log-Level“ — steuert nur die Konsolen-Ausgabe auf UART0, nicht das Host-Protokoll.
|
||||
|
||||
| Tag | Modul |
|
||||
|-----|--------|
|
||||
| `[Main]` | powerpod.c |
|
||||
| `[LOG_LVL]` | cmd_set_log_level.c |
|
||||
| `[UART]` | uart.c |
|
||||
| `[CMDH]` | cmd_handler.c |
|
||||
| `[UART_CMD]` | uart_cmd.c |
|
||||
|
||||
@@ -287,7 +287,7 @@ Entspricht `main/README.md`:
|
||||
|
||||
| Byte | Inhalt |
|
||||
|------|--------|
|
||||
| Frame | `0xAA`, Länge, Payload, XOR-Checksum, `0xCC` |
|
||||
| Frame | `0xAA`, Länge (uint16 LE), Payload, XOR-Checksum, `0xCC` |
|
||||
| Payload[0] | `MessageType` (z.B. `22` = FIND_ME) |
|
||||
| Payload[1…] | Nanopb-`UartMessage` |
|
||||
|
||||
@@ -345,7 +345,7 @@ idf.py build
|
||||
|
||||
Ähnliche Features zum Abgucken:
|
||||
|
||||
- **Nur Master, kein ESP-NOW:** `main/cmd/cmd_version.c`, `main/cmd/cmd_led_ring.c`
|
||||
- **Nur Master, kein ESP-NOW:** `main/cmd/cmd_version.c`, `main/cmd/cmd_led_ring.c`, `main/cmd/cmd_set_log_level.c`
|
||||
- **Nur Slave per ESP-NOW (Master leitet nur durch):** `main/cmd/cmd_espnow_unicast_test.c`
|
||||
- **Master + alle Slaves / Filter:** `main/cmd/cmd_accel_deadzone.c`
|
||||
- **Großer ESP-NOW-Fluss mit Status:** `ota_espnow.c`, `main/cmd/cmd_ota.c`
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
win:
|
||||
GOOS=windows GOARCH=386 go build .
|
||||
+36
-10
@@ -25,16 +25,21 @@ go run . -port /dev/ttyUSB0 clients
|
||||
| `version` | `0x03` | Prints `version` and `git_hash` from firmware |
|
||||
| `clients` | `0x04` | Lists slaves registered on the master via ESP-NOW |
|
||||
| `deadzone` | `0x06` | Get/set accelerometer deadzone LSB (`-set`, `-value`, `-client`, `-all`) |
|
||||
| `tap-config` | `0x20` | Get/set BMA456 tap detection params (`-set`, `-client`, `-all`, `-sens`, `-max-gest`, `-shock`, `-quiet`, `-wait`, `-axis`, `-single`, `-double`, `-triple`) |
|
||||
| `tap-notify` | `0x1b` | Get/set which tap kinds (single/double/triple) notify via ESP-NOW (`-set`, `-client`, `-all`, `-single`, `-double`, `-triple`) |
|
||||
| `cache-status` | `0x1d` | Subscribed accel + tap cache (`CACHE_STATUS`); one UART round-trip for 16 ms polling |
|
||||
| `cache-status` | `0x1d` | Subscribed accel + tap cache (`CACHE_STATUS`); one UART round-trip for ~8 ms polling |
|
||||
| `unicast-test` | `0x07` | Sends ESP-NOW unicast test to one slave (`-client`, `-seq`) |
|
||||
| `echo-ping` | `0x1e` | ESP-NOW echo round-trip to one slave (`-client`); prints `rtt_ms` (host UART chain) and `esp_rtt_us` (master ESP-NOW, raw µs) |
|
||||
| `test` | — | Run an automated scenario (JSON configs under `testdata/`) |
|
||||
| `serve` | — | Web dashboard at `http://localhost:8080` (WebSocket live updates) |
|
||||
| `ota` | 16–19 | UART firmware upload to master; firmware then pushes to slaves via ESP-NOW |
|
||||
| `ota-progress` | 21 | Query per-slave ESP-NOW OTA progress on the master (`-client N`, default all) |
|
||||
| `led-ring` | 8 | LED ring: `-mode clear\|color\|progress\|digit\|blink\|find-me`, `-client`, `-all` |
|
||||
| `led-ring` | 8 | LED ring: `-mode clear\|color\|progress\|digit\|blink\|find-me\|battery-low`, `-client`, `-all` |
|
||||
| `find-me` | 22 | Locate pod (`-client 0` master, `>0` slave via ESP-NOW) |
|
||||
| `restart` | 23 | Reboot master or slave (`-client 0` / `>0`) |
|
||||
| `log-level` | `0x1f` | Get/set master ESP-IDF log level for tag `"*"` (`-set`, `-level` 0–5); output on UART0 debug, not host UART |
|
||||
| `battery-uv` | `0x24` | Get/set software LiPo UV protection (`-set -enable`/`-enable=false`, `-client`, `-all`) |
|
||||
| `input-send-mode` | `0x23` | Get/set slave accel/tap ESP-NOW TX path (`-set -mode 0..3`, `-all` or `-client N`): best_effort/fast/wait/reliable |
|
||||
|
||||
`clients` requires slaves to have responded to master discover broadcasts first.
|
||||
|
||||
@@ -58,40 +63,44 @@ See [`testdata/README.md`](testdata/README.md) for the JSON schema.
|
||||
|
||||
### Web dashboard
|
||||
|
||||
Polls the master over UART and pushes state to the browser via WebSocket (Alpine.js + Bootstrap 5).
|
||||
Starts without a required host link. Connect UART or BLE from the UI (or pre-connect with `-port` / `-ble`). State is pushed to the browser via WebSocket (Alpine.js + Bootstrap 5). OTA works over UART or BLE (BLE needs ATT MTU ≥ 219).
|
||||
|
||||
```bash
|
||||
go run . serve
|
||||
go run . -port /dev/ttyUSB0 serve
|
||||
go run . -port /dev/ttyUSB0 serve -addr :8080 -interval 2s
|
||||
go run . -port /dev/ttyUSB0 serve -api-addr :8081 -accel-interval 16ms
|
||||
go run . -ble serve
|
||||
go run . serve -addr :8080 -interval 2s
|
||||
go run . serve -api-addr :8081 -accel-interval 16ms
|
||||
make gotool-serve PORT=/dev/ttyUSB0
|
||||
```
|
||||
|
||||
Open [http://localhost:8080](http://localhost:8080) — shows master firmware info and the ESP-NOW client table from `CLIENT_INFO`.
|
||||
Open [http://localhost:8080](http://localhost:8080) — Host-Verbindung panel (UART ports / BLE scan), master firmware info, ESP-NOW client table.
|
||||
|
||||
**Tap (dashboard):** two independent controls per slave:
|
||||
|
||||
| Column | Meaning |
|
||||
|--------|---------|
|
||||
| Tap-Notify (S/D/T) | Which tap kinds the **slave** sends to the master over ESP-NOW (UART `TAP_NOTIFY`) — does **not** poll UART |
|
||||
| Tap (An/Aus) | Host **receive**: poll master tap cache (~16 ms) and show last tap for **≥2 s** |
|
||||
| Tap (An/Aus) | Host **receive**: poll master tap cache (~8 ms) and show last tap for **≥2 s** |
|
||||
|
||||
Enable notify first, then turn receive on to see events. Same split as the external WebSocket API (`set_tap_notify` vs `set_tap_stream`).
|
||||
|
||||
If the UART device is unplugged or the port disappears, `serve` keeps running and retries on each poll interval; the UI shows **UART off** until the port is available again.
|
||||
If the host disconnects, `serve` keeps running; the badge shows **off** until you reconnect from the UI or via REST/WS.
|
||||
|
||||
### HTTP / WebSocket API
|
||||
|
||||
`serve` also listens on **`:8081`** for external programs (`-api-addr`, empty to disable). Same UART as the dashboard.
|
||||
`serve` also listens on **`:8081`** for external programs (`-api-addr`, empty to disable). Same `managedHost` as the dashboard (UART or BLE).
|
||||
|
||||
| Doc | Content |
|
||||
|-----|---------|
|
||||
| **[docs/API_WEBSOCKET.md](docs/API_WEBSOCKET.md)** | `ws://…:8081/ws` commands, **`accel` / `tap` push stream** format, dashboard `ws://…:8080/ws` |
|
||||
| **[docs/API_WEBSOCKET.md](docs/API_WEBSOCKET.md)** | `ws://…:8081/ws` commands and **`input` push stream** (accel + tap) |
|
||||
| **[docs/API_REST.md](docs/API_REST.md)** | REST on `:8080` (dashboard) and `:8081` (battery, LED, service info) |
|
||||
|
||||
CLI:
|
||||
|
||||
```bash
|
||||
go run . -port /dev/ttyUSB0 tap-config
|
||||
go run . -port /dev/ttyUSB0 tap-config -set -sens 5 -axis 2
|
||||
go run . -port /dev/ttyUSB0 tap-notify -client 16 -set -single
|
||||
go run . -port /dev/ttyUSB0 cache-status
|
||||
```
|
||||
@@ -103,6 +112,7 @@ go run . -port /dev/ttyUSB0 cache-status
|
||||
|
||||
```bash
|
||||
go run . -port /dev/ttyUSB0 ota build/powerpod.bin
|
||||
go run . -ble ota build/powerpod.bin
|
||||
```
|
||||
|
||||
Waits for **ready** after start (~30 s erase), sends 200-byte `OTA_PAYLOAD` frames, reads **block_ack** every 4 KiB, then `OTA_END`. The master then distributes to all available slaves (no extra host traffic); **success** is reported only when that finishes. Allow several minutes for large images. Reboot master and slaves to boot the new firmware.
|
||||
@@ -113,8 +123,24 @@ go run . -port /dev/ttyUSB0 unicast-test -client 16 -seq 42
|
||||
|
||||
On success the slave serial log should show `UNICAST TEST OK from master … seq=42`.
|
||||
|
||||
```bash
|
||||
go run . -port /dev/ttyUSB0 echo-ping -client 16
|
||||
go run . -port /dev/ttyUSB0 log-level
|
||||
go run . -port /dev/ttyUSB0 log-level -set -level 3
|
||||
```
|
||||
|
||||
`log-level` controls `esp_log_*` on the master (UART0 USB console). The host protocol UART (GPIO 2/3) is unchanged.
|
||||
|
||||
Measures latency to one slave. `rtt_ms` is the full host round-trip (UART + ESP-NOW + UART back). `esp_rtt_us` is the master-side ESP-NOW leg only (`esp_timer_get_time()` delta, raw microseconds from firmware).
|
||||
|
||||
Example output:
|
||||
|
||||
```
|
||||
echo ping: success=true client_id=16 rtt_ms=49.729 esp_rtt_us=18234
|
||||
```
|
||||
|
||||
`clients` example:
|
||||
|
||||
```
|
||||
clients (2):
|
||||
[0] id=42 mac=aabbccddeeff ver=1 available=true used=false last_ping=250 last_success_ping=250
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
type bleRttAPIRequest struct {
|
||||
Count *int `json:"count"`
|
||||
Warmup *int `json:"warmup"`
|
||||
SettleS *float64 `json:"settle_s"`
|
||||
}
|
||||
|
||||
type bleStreamAPIRequest struct {
|
||||
ClientID *uint32 `json:"client_id"`
|
||||
PeriodMs *uint32 `json:"period_ms"`
|
||||
Count *int `json:"count"`
|
||||
TolMs *float64 `json:"tol_ms"`
|
||||
TimeoutS *float64 `json:"timeout_s"`
|
||||
}
|
||||
|
||||
func mountBleTestAPI(mux *http.ServeMux, link *managedHost) {
|
||||
mux.HandleFunc("/api/ble-rtt", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
serveBleRtt(w, r, link)
|
||||
})
|
||||
mux.HandleFunc("/api/ble-stream", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
serveBleStreamTest(w, r, link)
|
||||
})
|
||||
}
|
||||
|
||||
func requireBLEHost(w http.ResponseWriter, link *managedHost) bool {
|
||||
if link.Transport() != hostTransportBLE || !link.IsConnected() {
|
||||
writeJSON(w, http.StatusBadRequest, bleRttResult{
|
||||
Error: "BLE host required (connect via BLE, DIP8=1)",
|
||||
})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func serveBleRtt(w http.ResponseWriter, r *http.Request, link *managedHost) {
|
||||
if !requireBLEHost(w, link) {
|
||||
return
|
||||
}
|
||||
var body bleRttAPIRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil && !errors.Is(err, io.EOF) {
|
||||
writeJSON(w, http.StatusBadRequest, bleRttResult{Error: "invalid JSON"})
|
||||
return
|
||||
}
|
||||
count, warmup := 50, 5
|
||||
settle := 0.0
|
||||
if body.Count != nil {
|
||||
count = *body.Count
|
||||
}
|
||||
if body.Warmup != nil {
|
||||
warmup = *body.Warmup
|
||||
}
|
||||
if body.SettleS != nil {
|
||||
settle = *body.SettleS
|
||||
}
|
||||
|
||||
if !link.beginPausePoll() {
|
||||
writeJSON(w, http.StatusConflict, bleRttResult{Error: "host busy"})
|
||||
return
|
||||
}
|
||||
defer link.endPausePoll()
|
||||
|
||||
out := measureBleRtt(link, count, warmup, time.Duration(settle*float64(time.Second)))
|
||||
status := http.StatusOK
|
||||
if !out.Success {
|
||||
status = http.StatusBadRequest
|
||||
}
|
||||
writeJSON(w, status, out)
|
||||
}
|
||||
|
||||
func serveBleStreamTest(w http.ResponseWriter, r *http.Request, link *managedHost) {
|
||||
if !requireBLEHost(w, link) {
|
||||
return
|
||||
}
|
||||
var body bleStreamAPIRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil && !errors.Is(err, io.EOF) {
|
||||
writeJSON(w, http.StatusBadRequest, bleStreamResult{Error: "invalid JSON"})
|
||||
return
|
||||
}
|
||||
count := 50
|
||||
periodMs := uint32(10)
|
||||
tolMs := 2.0
|
||||
timeoutS := 15.0
|
||||
var clientID uint32
|
||||
if body.Count != nil {
|
||||
count = *body.Count
|
||||
}
|
||||
if body.PeriodMs != nil {
|
||||
periodMs = *body.PeriodMs
|
||||
}
|
||||
if body.TolMs != nil {
|
||||
tolMs = *body.TolMs
|
||||
}
|
||||
if body.TimeoutS != nil {
|
||||
timeoutS = *body.TimeoutS
|
||||
}
|
||||
if body.ClientID != nil {
|
||||
clientID = *body.ClientID
|
||||
}
|
||||
|
||||
if !link.beginPausePoll() {
|
||||
writeJSON(w, http.StatusConflict, bleStreamResult{Error: "host busy"})
|
||||
return
|
||||
}
|
||||
defer link.endPausePoll()
|
||||
|
||||
out := measureBleStream(link, clientID, periodMs, count, tolMs, timeoutS)
|
||||
status := http.StatusOK
|
||||
if !out.Success {
|
||||
status = http.StatusBadRequest
|
||||
}
|
||||
writeJSON(w, status, out)
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
type hostAPIResponse struct {
|
||||
Transport string `json:"transport"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
Connected bool `json:"connected"`
|
||||
BleName string `json:"ble_name,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type hostConnectRequest struct {
|
||||
Transport string `json:"transport"` // uart|ble
|
||||
Port string `json:"port,omitempty"`
|
||||
Address string `json:"address,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Baud int `json:"baud,omitempty"`
|
||||
}
|
||||
|
||||
type bleScanRequest struct {
|
||||
TimeoutS float64 `json:"timeout_s"`
|
||||
NameFilter string `json:"name_filter"`
|
||||
}
|
||||
|
||||
type bleScanResponse struct {
|
||||
Devices []bleScanDevice `json:"devices"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type serialPortsResponse struct {
|
||||
Ports []string `json:"ports"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func mountHostAPI(mux *http.ServeMux, link *managedHost) {
|
||||
mux.HandleFunc("/api/host", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
st := link.HostStatus()
|
||||
writeJSON(w, http.StatusOK, hostAPIResponse{
|
||||
Transport: st["transport"].(string),
|
||||
Endpoint: st["endpoint"].(string),
|
||||
Connected: st["connected"].(bool),
|
||||
BleName: st["ble_name"].(string),
|
||||
})
|
||||
})
|
||||
|
||||
mux.HandleFunc("/api/host/connect", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var body hostConnectRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, hostAPIResponse{Error: "invalid JSON"})
|
||||
return
|
||||
}
|
||||
var err error
|
||||
switch body.Transport {
|
||||
case hostTransportUART:
|
||||
err = link.ConnectUART(body.Port, body.Baud)
|
||||
case hostTransportBLE:
|
||||
err = link.ConnectBLE(body.Address, body.Name)
|
||||
default:
|
||||
writeJSON(w, http.StatusBadRequest, hostAPIResponse{Error: "transport must be uart or ble"})
|
||||
return
|
||||
}
|
||||
st := link.HostStatus()
|
||||
resp := hostAPIResponse{
|
||||
Transport: st["transport"].(string),
|
||||
Endpoint: st["endpoint"].(string),
|
||||
Connected: st["connected"].(bool),
|
||||
BleName: st["ble_name"].(string),
|
||||
}
|
||||
if err != nil {
|
||||
resp.Error = err.Error()
|
||||
resp.Connected = false
|
||||
writeJSON(w, http.StatusBadRequest, resp)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
})
|
||||
|
||||
mux.HandleFunc("/api/host/disconnect", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
_ = link.Disconnect()
|
||||
st := link.HostStatus()
|
||||
writeJSON(w, http.StatusOK, hostAPIResponse{
|
||||
Transport: st["transport"].(string),
|
||||
Endpoint: st["endpoint"].(string),
|
||||
Connected: false,
|
||||
})
|
||||
})
|
||||
|
||||
mux.HandleFunc("/api/serial/ports", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
ports, err := listSerialPorts()
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusOK, serialPortsResponse{Ports: []string{}, Error: err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, serialPortsResponse{Ports: ports})
|
||||
})
|
||||
|
||||
mux.HandleFunc("/api/ble/scan", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var body bleScanRequest
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
timeout := time.Duration(body.TimeoutS * float64(time.Second))
|
||||
if timeout <= 0 {
|
||||
timeout = 5 * time.Second
|
||||
}
|
||||
// Disconnect active BLE before scan (BlueZ adapter contention).
|
||||
if link.Transport() == hostTransportBLE {
|
||||
_ = link.Disconnect()
|
||||
}
|
||||
devs, err := scanBLEDevices(timeout, body.NameFilter)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusOK, bleScanResponse{Devices: []bleScanDevice{}, Error: err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, bleScanResponse{Devices: devs})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
pb "powerpod/gotool/pb"
|
||||
)
|
||||
|
||||
type inputSendModeAPIResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Mode uint32 `json:"mode"`
|
||||
ModeName string `json:"mode_name"`
|
||||
ClientID uint32 `json:"client_id,omitempty"`
|
||||
SlavesUpdated uint32 `json:"slaves_updated,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func inputSendModeName(mode pb.InputSendMode) string {
|
||||
switch mode {
|
||||
case pb.InputSendMode_INPUT_SEND_BEST_EFFORT:
|
||||
return "best_effort"
|
||||
case pb.InputSendMode_INPUT_SEND_FAST:
|
||||
return "fast"
|
||||
case pb.InputSendMode_INPUT_SEND_WAIT:
|
||||
return "wait"
|
||||
case pb.InputSendMode_INPUT_SEND_RELIABLE:
|
||||
return "reliable"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
func mountInputSendModeAPI(mux *http.ServeMux, link *managedSerial) {
|
||||
mux.HandleFunc("/api/input-send-mode", func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
serveInputSendModeGet(w, link)
|
||||
case http.MethodPut, http.MethodPost:
|
||||
serveInputSendModePut(w, r, link)
|
||||
default:
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func serveInputSendModeGet(w http.ResponseWriter, link *managedSerial) {
|
||||
resp, err := link.InputSendMode(&pb.InputSendModeRequest{Write: false})
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadGateway, inputSendModeAPIResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, inputSendModeAPIResponse{
|
||||
Success: resp.GetSuccess(),
|
||||
Mode: uint32(resp.GetMode()),
|
||||
ModeName: inputSendModeName(resp.GetMode()),
|
||||
})
|
||||
}
|
||||
|
||||
func serveInputSendModePut(w http.ResponseWriter, r *http.Request, link *managedSerial) {
|
||||
var body struct {
|
||||
Mode uint32 `json:"mode"`
|
||||
ClientID uint32 `json:"client_id"`
|
||||
AllClients bool `json:"all_clients"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, inputSendModeAPIResponse{Error: "invalid JSON"})
|
||||
return
|
||||
}
|
||||
if body.Mode > uint32(pb.InputSendMode_INPUT_SEND_RELIABLE) {
|
||||
writeJSON(w, http.StatusBadRequest, inputSendModeAPIResponse{Error: "mode must be 0..3"})
|
||||
return
|
||||
}
|
||||
if !body.AllClients && body.ClientID == 0 {
|
||||
// Dashboard default: apply to every registered slave.
|
||||
body.AllClients = true
|
||||
}
|
||||
|
||||
resp, err := link.InputSendMode(&pb.InputSendModeRequest{
|
||||
Write: true,
|
||||
Mode: pb.InputSendMode(body.Mode),
|
||||
ClientId: body.ClientID,
|
||||
AllClients: body.AllClients,
|
||||
})
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadGateway, inputSendModeAPIResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
status := http.StatusOK
|
||||
if !resp.GetSuccess() {
|
||||
status = http.StatusBadRequest
|
||||
}
|
||||
writeJSON(w, status, inputSendModeAPIResponse{
|
||||
Success: resp.GetSuccess(),
|
||||
Mode: uint32(resp.GetMode()),
|
||||
ModeName: inputSendModeName(resp.GetMode()),
|
||||
ClientID: resp.GetClientId(),
|
||||
SlavesUpdated: resp.GetSlavesUpdated(),
|
||||
})
|
||||
}
|
||||
+140
-4
@@ -41,6 +41,19 @@ type unicastAPIResponse struct {
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type echoPingAPIRequest struct {
|
||||
ClientID uint32 `json:"client_id"`
|
||||
}
|
||||
|
||||
type echoPingAPIResponse struct {
|
||||
Success bool `json:"success"`
|
||||
ClientID uint32 `json:"client_id,omitempty"`
|
||||
TimestampUs uint64 `json:"timestamp_us,omitempty"`
|
||||
RttMs float64 `json:"rtt_ms"`
|
||||
EspRttUs uint32 `json:"esp_rtt_us"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type findMeAPIRequest struct {
|
||||
ClientID uint32 `json:"client_id"`
|
||||
}
|
||||
@@ -51,6 +64,11 @@ type findMeAPIResponse struct {
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type resetIdMapAPIResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type restartAPIRequest struct {
|
||||
ClientID uint32 `json:"client_id"`
|
||||
}
|
||||
@@ -61,17 +79,33 @@ type restartAPIResponse struct {
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type logLevelAPIResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Level uint32 `json:"level"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type logLevelAPIRequest struct {
|
||||
Write bool `json:"write"`
|
||||
Level uint32 `json:"level"`
|
||||
}
|
||||
|
||||
type otaAPIResponse struct {
|
||||
Success bool `json:"success"`
|
||||
BytesWritten uint32 `json:"bytes_written,omitempty"`
|
||||
TargetSlot uint32 `json:"target_slot,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Success bool `json:"success"`
|
||||
BytesWritten uint32 `json:"bytes_written,omitempty"`
|
||||
TargetSlot uint32 `json:"target_slot,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func mountServeAPI(mux *http.ServeMux, link *managedSerial, hub *wsHub, streamCtl *accelStreamCtl, tapCtl *tapNotifyCtl) {
|
||||
mountHostAPI(mux, link)
|
||||
mountBleTestAPI(mux, link)
|
||||
mountLiveStreamAPI(mux, hub)
|
||||
mountAccelStreamAPI(mux, link, hub, streamCtl)
|
||||
mountInputSendModeAPI(mux, link)
|
||||
mountTapAPI(mux, link, hub, tapCtl)
|
||||
mountTapConfigAPI(mux, link)
|
||||
mountBatteryUvAPI(mux, link)
|
||||
mountLedRingAPI(mux, link)
|
||||
mountBatteryAPI(mux, link)
|
||||
mux.HandleFunc("/api/deadzone", func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -91,6 +125,13 @@ func mountServeAPI(mux *http.ServeMux, link *managedSerial, hub *wsHub, streamCt
|
||||
}
|
||||
serveUnicastTest(w, r, link)
|
||||
})
|
||||
mux.HandleFunc("/api/echo-ping", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
serveEchoPing(w, r, link)
|
||||
})
|
||||
mux.HandleFunc("/api/find-me", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
@@ -105,6 +146,19 @@ func mountServeAPI(mux *http.ServeMux, link *managedSerial, hub *wsHub, streamCt
|
||||
}
|
||||
serveRestart(w, r, link)
|
||||
})
|
||||
mux.HandleFunc("POST /api/clients/reset-id-map", func(w http.ResponseWriter, r *http.Request) {
|
||||
serveResetClientIdMap(w, r, link)
|
||||
})
|
||||
mux.HandleFunc("/api/log-level", func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
serveLogLevelGet(w, r, link)
|
||||
case http.MethodPost:
|
||||
serveLogLevelPost(w, r, link)
|
||||
default:
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
})
|
||||
mux.HandleFunc("/api/ota", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
@@ -115,6 +169,10 @@ func mountServeAPI(mux *http.ServeMux, link *managedSerial, hub *wsHub, streamCt
|
||||
}
|
||||
|
||||
func serveOTAUpload(w http.ResponseWriter, r *http.Request, link *managedSerial, hub *wsHub) {
|
||||
if !link.IsConnected() {
|
||||
writeJSON(w, http.StatusBadRequest, otaAPIResponse{Error: "host not connected"})
|
||||
return
|
||||
}
|
||||
if err := r.ParseMultipartForm(otaMaxFirmwareSize); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, otaAPIResponse{Error: "invalid form"})
|
||||
return
|
||||
@@ -260,6 +318,43 @@ func applyDeadzoneToSlaves(link *managedSerial, deadzone uint32) (uint32, error)
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
func serveLogLevelGet(w http.ResponseWriter, r *http.Request, link *managedSerial) {
|
||||
resp, err := link.SetLogLevel(false, 0)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, logLevelAPIResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, logLevelAPIResponse{
|
||||
Success: resp.GetSuccess(),
|
||||
Level: resp.GetLevel(),
|
||||
})
|
||||
}
|
||||
|
||||
func serveLogLevelPost(w http.ResponseWriter, r *http.Request, link *managedSerial) {
|
||||
var body logLevelAPIRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, logLevelAPIResponse{Error: "invalid JSON"})
|
||||
return
|
||||
}
|
||||
if !body.Write {
|
||||
writeJSON(w, http.StatusBadRequest, logLevelAPIResponse{Error: "write must be true"})
|
||||
return
|
||||
}
|
||||
if body.Level > 5 {
|
||||
writeJSON(w, http.StatusBadRequest, logLevelAPIResponse{Error: "level must be 0–5"})
|
||||
return
|
||||
}
|
||||
resp, err := link.SetLogLevel(true, body.Level)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, logLevelAPIResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, logLevelAPIResponse{
|
||||
Success: resp.GetSuccess(),
|
||||
Level: resp.GetLevel(),
|
||||
})
|
||||
}
|
||||
|
||||
func serveRestart(w http.ResponseWriter, r *http.Request, link *managedSerial) {
|
||||
var body restartAPIRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
@@ -276,6 +371,23 @@ func serveRestart(w http.ResponseWriter, r *http.Request, link *managedSerial) {
|
||||
writeJSON(w, http.StatusOK, restartAPIResponse{Success: true, ClientID: body.ClientID})
|
||||
}
|
||||
|
||||
func serveResetClientIdMap(w http.ResponseWriter, r *http.Request, link *managedSerial) {
|
||||
if !link.IsConnected() {
|
||||
writeJSON(w, http.StatusServiceUnavailable, resetIdMapAPIResponse{Error: "host not connected"})
|
||||
return
|
||||
}
|
||||
resp, err := link.resetClientIdMap()
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, resetIdMapAPIResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
if !resp.GetSuccess() {
|
||||
writeJSON(w, http.StatusOK, resetIdMapAPIResponse{Success: false, Error: "rejected by device"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resetIdMapAPIResponse{Success: true})
|
||||
}
|
||||
|
||||
func serveFindMe(w http.ResponseWriter, r *http.Request, link *managedSerial) {
|
||||
var body findMeAPIRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
@@ -316,6 +428,30 @@ func serveUnicastTest(w http.ResponseWriter, r *http.Request, link *managedSeria
|
||||
})
|
||||
}
|
||||
|
||||
func serveEchoPing(w http.ResponseWriter, r *http.Request, link *managedSerial) {
|
||||
var body echoPingAPIRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, echoPingAPIResponse{Error: "invalid JSON"})
|
||||
return
|
||||
}
|
||||
if body.ClientID == 0 {
|
||||
writeJSON(w, http.StatusBadRequest, echoPingAPIResponse{Error: "client_id required"})
|
||||
return
|
||||
}
|
||||
result, err := link.EchoPing(body.ClientID)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, echoPingAPIResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, echoPingAPIResponse{
|
||||
Success: result.Success,
|
||||
ClientID: result.ClientID,
|
||||
TimestampUs: result.TimestampUs,
|
||||
RttMs: result.RttMs,
|
||||
EspRttUs: result.EspRttUs,
|
||||
})
|
||||
}
|
||||
|
||||
func parseUintQuery(r *http.Request, key string, def uint32) (uint32, error) {
|
||||
s := r.URL.Query().Get(key)
|
||||
if s == "" {
|
||||
|
||||
+242
-92
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"sync"
|
||||
@@ -15,7 +16,7 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
defaultAccelStreamInterval = 16 * time.Millisecond
|
||||
defaultAccelStreamInterval = 10 * time.Millisecond
|
||||
defaultPreFetchMs = 2
|
||||
minAPIStreamInterval = 1 * time.Millisecond
|
||||
maxAPIStreamInterval = 10 * time.Second
|
||||
@@ -31,7 +32,7 @@ type InputClientSample struct {
|
||||
Y int32 `json:"y,omitempty"`
|
||||
Z int32 `json:"z,omitempty"`
|
||||
AccelAgeMs uint32 `json:"accel_age_ms,omitempty"`
|
||||
TapKind string `json:"tap_kind"`
|
||||
TapKind string `json:"tap_kind,omitempty"`
|
||||
TapAgeMs uint32 `json:"tap_age_ms,omitempty"`
|
||||
}
|
||||
|
||||
@@ -39,6 +40,9 @@ type InputClientSample struct {
|
||||
type InputStreamMessage struct {
|
||||
Type string `json:"type"` // "hello" | "input"
|
||||
Serial string `json:"serial_port,omitempty"`
|
||||
Transport string `json:"transport,omitempty"`
|
||||
Endpoint string `json:"endpoint,omitempty"`
|
||||
Connected *bool `json:"connected,omitempty"`
|
||||
IntervalMs int `json:"interval_ms,omitempty"`
|
||||
PreFetchMs int `json:"pre_fetch_ms,omitempty"`
|
||||
TapDisplayMinMs int `json:"tap_display_min_ms,omitempty"`
|
||||
@@ -51,6 +55,33 @@ type InputStreamMessage struct {
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// HostStatusMessage is the reply to get_host / host_connect / host_disconnect.
|
||||
type HostStatusMessage struct {
|
||||
Type string `json:"type"` // "host_status"
|
||||
Transport string `json:"transport"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
Connected bool `json:"connected"`
|
||||
BleName string `json:"ble_name,omitempty"`
|
||||
Success bool `json:"success"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// SerialPortsMessage is the reply to list_serial_ports.
|
||||
type SerialPortsMessage struct {
|
||||
Type string `json:"type"` // "serial_ports"
|
||||
Ports []string `json:"ports"`
|
||||
Success bool `json:"success"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// BleScanResultMessage is the reply to ble_scan.
|
||||
type BleScanResultMessage struct {
|
||||
Type string `json:"type"` // "ble_scan_result"
|
||||
Devices []bleScanDevice `json:"devices"`
|
||||
Success bool `json:"success"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// StreamStatusMessage is the reply to set_stream / get_stream (this connection).
|
||||
type StreamStatusMessage struct {
|
||||
Type string `json:"type"` // "stream_status"
|
||||
@@ -116,12 +147,23 @@ type accelWSCommand struct {
|
||||
DoubleTap *bool `json:"double_tap"`
|
||||
Triple *bool `json:"triple"`
|
||||
AllClients bool `json:"all_clients"`
|
||||
// Host connect / BLE scan
|
||||
Transport string `json:"transport"`
|
||||
Port string `json:"port"`
|
||||
Address string `json:"address"`
|
||||
Name string `json:"name"`
|
||||
Baud int `json:"baud"`
|
||||
TimeoutS float64 `json:"timeout_s"`
|
||||
NameFilter string `json:"name_filter"`
|
||||
}
|
||||
|
||||
type APIInfoResponse struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
SerialPort string `json:"serial_port"`
|
||||
Transport string `json:"transport"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
Connected bool `json:"connected"`
|
||||
WebSocket string `json:"websocket"`
|
||||
DefaultIntervalMs int `json:"default_interval_ms"`
|
||||
DefaultPreFetchMs int `json:"default_pre_fetch_ms"`
|
||||
@@ -139,12 +181,31 @@ type cachedTapEvent struct {
|
||||
|
||||
type wsSubscriber struct {
|
||||
conn *websocket.Conn
|
||||
writeMu sync.Mutex // gorilla: one writer per connection
|
||||
receiveInput bool
|
||||
interval time.Duration
|
||||
preFetch time.Duration
|
||||
lastInputSent time.Time
|
||||
}
|
||||
|
||||
func (sub *wsSubscriber) writeBytes(data []byte) error {
|
||||
if sub == nil {
|
||||
return fmt.Errorf("nil subscriber")
|
||||
}
|
||||
sub.writeMu.Lock()
|
||||
defer sub.writeMu.Unlock()
|
||||
_ = sub.conn.SetWriteDeadline(time.Now().Add(2 * time.Second))
|
||||
return sub.conn.WriteMessage(websocket.TextMessage, data)
|
||||
}
|
||||
|
||||
func (sub *wsSubscriber) writeJSON(v any) {
|
||||
data, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_ = sub.writeBytes(data)
|
||||
}
|
||||
|
||||
type pendingInputCache struct {
|
||||
cache *pb.CacheStatusResponse
|
||||
readAt time.Time
|
||||
@@ -196,7 +257,7 @@ func clampPreFetch(d time.Duration) time.Duration {
|
||||
return d
|
||||
}
|
||||
|
||||
func (h *accelStreamHub) register(conn *websocket.Conn, portName string) *wsSubscriber {
|
||||
func (h *accelStreamHub) register(conn *websocket.Conn, link *managedSerial) *wsSubscriber {
|
||||
sub := &wsSubscriber{
|
||||
conn: conn,
|
||||
receiveInput: false,
|
||||
@@ -207,14 +268,20 @@ func (h *accelStreamHub) register(conn *websocket.Conn, portName string) *wsSubs
|
||||
h.clients[conn] = sub
|
||||
h.mu.Unlock()
|
||||
|
||||
st := link.HostStatus()
|
||||
connected := st["connected"].(bool)
|
||||
hello := InputStreamMessage{
|
||||
Type: "hello",
|
||||
Serial: portName,
|
||||
Serial: st["endpoint"].(string),
|
||||
Transport: st["transport"].(string),
|
||||
Endpoint: st["endpoint"].(string),
|
||||
Connected: &connected,
|
||||
IntervalMs: int(h.defaultInterval / time.Millisecond),
|
||||
PreFetchMs: int(h.defaultPreFetch / time.Millisecond),
|
||||
TapDisplayMinMs: apiTapDisplayMinMs,
|
||||
Note: "set_tap_notify configures slave S/D/T only; set_stream enables input polling/push on this connection",
|
||||
Commands: []string{
|
||||
"get_host", "list_serial_ports", "ble_scan", "host_connect", "host_disconnect",
|
||||
"list_clients",
|
||||
"set_stream", "get_stream",
|
||||
"set_input_stream", "get_input_stream",
|
||||
@@ -222,9 +289,7 @@ func (h *accelStreamHub) register(conn *websocket.Conn, portName string) *wsSubs
|
||||
"set_led_ring", "get_battery",
|
||||
},
|
||||
}
|
||||
if data, err := json.Marshal(hello); err == nil {
|
||||
_ = conn.WriteMessage(websocket.TextMessage, data)
|
||||
}
|
||||
sub.writeJSON(hello)
|
||||
return sub
|
||||
}
|
||||
|
||||
@@ -389,7 +454,6 @@ func (h *accelStreamHub) inputClientsFromCacheLocked(cache *pb.CacheStatusRespon
|
||||
for _, c := range cache.GetClients() {
|
||||
sample := InputClientSample{
|
||||
ClientID: c.GetClientId(),
|
||||
TapKind: "none",
|
||||
}
|
||||
if a := c.GetAccel(); a != nil {
|
||||
sample.Valid = a.GetValid()
|
||||
@@ -421,7 +485,11 @@ func (h *accelStreamHub) deliverInput(msg InputStreamMessage) {
|
||||
|
||||
now := time.Now()
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
type pending struct {
|
||||
conn *websocket.Conn
|
||||
sub *wsSubscriber
|
||||
}
|
||||
var out []pending
|
||||
for conn, sub := range h.clients {
|
||||
if !sub.receiveInput {
|
||||
continue
|
||||
@@ -430,13 +498,26 @@ func (h *accelStreamHub) deliverInput(msg InputStreamMessage) {
|
||||
continue
|
||||
}
|
||||
sub.lastInputSent = now
|
||||
if err := conn.WriteMessage(websocket.TextMessage, data); err != nil {
|
||||
delete(h.clients, conn)
|
||||
_ = conn.Close()
|
||||
out = append(out, pending{conn: conn, sub: sub})
|
||||
}
|
||||
h.mu.Unlock()
|
||||
|
||||
for _, p := range out {
|
||||
if err := p.sub.writeBytes(data); err != nil {
|
||||
h.mu.Lock()
|
||||
delete(h.clients, p.conn)
|
||||
h.mu.Unlock()
|
||||
_ = p.conn.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *accelStreamHub) clientsFromCache(cache *pb.CacheStatusResponse) []InputClientSample {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
return h.inputClientsFromCacheLocked(cache, time.Now())
|
||||
}
|
||||
|
||||
func runInputStreamer(link *managedSerial, hub *accelStreamHub, dash *wsHub, ctl *accelStreamCtl, tapCtl *tapNotifyCtl, stop <-chan struct{}) {
|
||||
ticker := time.NewTicker(minAPIStreamInterval)
|
||||
defer ticker.Stop()
|
||||
@@ -450,11 +531,31 @@ func runInputStreamer(link *managedSerial, hub *accelStreamHub, dash *wsHub, ctl
|
||||
case <-hub.configChanged:
|
||||
pending = nil
|
||||
case now := <-ticker.C:
|
||||
if !hub.anyWantsInput() || !inputPollingActive(dash, ctl, tapCtl) {
|
||||
if !hub.anyWantsInput() || !inputPollingActive(dash, ctl, tapCtl) || link.PollPaused() {
|
||||
pending = nil
|
||||
continue
|
||||
}
|
||||
|
||||
// Prefer BLE_STREAM_TICK when on BLE (firmware push).
|
||||
if link.Transport() == hostTransportBLE {
|
||||
payload, err := link.NextStreamTick(20 * time.Millisecond)
|
||||
if err == nil {
|
||||
cache, cerr := cacheStatusFromBLEStreamPayload(payload)
|
||||
if cerr == nil {
|
||||
hub.ingestTapFromCache(cache)
|
||||
hub.deliverInput(InputStreamMessage{
|
||||
Type: "input",
|
||||
T: time.Now().UnixNano(),
|
||||
Success: true,
|
||||
Clients: hub.clientsFromCache(cache),
|
||||
})
|
||||
pending = nil
|
||||
continue
|
||||
}
|
||||
}
|
||||
// Fall through to CACHE_STATUS poll if no tick yet.
|
||||
}
|
||||
|
||||
needRead, needDeliver, waitPreFetch := hub.streamTiming(now)
|
||||
|
||||
if needRead && pending == nil {
|
||||
@@ -488,14 +589,11 @@ func runInputStreamer(link *managedSerial, hub *accelStreamHub, dash *wsHub, ctl
|
||||
}
|
||||
|
||||
if pending.cache != nil && now.Sub(pending.readAt) >= waitPreFetch {
|
||||
hub.mu.RLock()
|
||||
clients := hub.inputClientsFromCacheLocked(pending.cache, now)
|
||||
hub.mu.RUnlock()
|
||||
hub.deliverInput(InputStreamMessage{
|
||||
Type: "input",
|
||||
T: ts,
|
||||
Success: true,
|
||||
Clients: clients,
|
||||
Clients: hub.clientsFromCache(pending.cache),
|
||||
})
|
||||
pending = nil
|
||||
}
|
||||
@@ -513,46 +611,29 @@ func inputPollingActive(dash *wsHub, ctl *accelStreamCtl, tapCtl *tapNotifyCtl)
|
||||
return dash != nil && dash.anyAccelStreamEnabled()
|
||||
}
|
||||
|
||||
func writeStreamStatus(conn *websocket.Conn, msg StreamStatusMessage) {
|
||||
data, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_ = conn.WriteMessage(websocket.TextMessage, data)
|
||||
func writeStreamStatus(sub *wsSubscriber, msg StreamStatusMessage) {
|
||||
sub.writeJSON(msg)
|
||||
}
|
||||
|
||||
func writeBatteryStatus(conn *websocket.Conn, out batteryAPIResponse) {
|
||||
func writeBatteryStatus(sub *wsSubscriber, out batteryAPIResponse) {
|
||||
out.Type = "battery_status"
|
||||
data, err := json.Marshal(out)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_ = conn.WriteMessage(websocket.TextMessage, data)
|
||||
sub.writeJSON(out)
|
||||
}
|
||||
|
||||
func writeLedRingStatus(conn *websocket.Conn, out ledRingAPIResponse) {
|
||||
func writeLedRingStatus(sub *wsSubscriber, out ledRingAPIResponse) {
|
||||
out.Type = "led_ring_status"
|
||||
data, err := json.Marshal(out)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_ = conn.WriteMessage(websocket.TextMessage, data)
|
||||
sub.writeJSON(out)
|
||||
}
|
||||
|
||||
func writeInputStreamStatus(conn *websocket.Conn, out accelStreamAPIResponse) {
|
||||
msg := InputStreamStatusMessage{
|
||||
func writeInputStreamStatus(sub *wsSubscriber, out accelStreamAPIResponse) {
|
||||
sub.writeJSON(InputStreamStatusMessage{
|
||||
Type: "input_stream_status",
|
||||
ClientID: out.ClientID,
|
||||
Enabled: out.Enabled,
|
||||
Success: out.Success,
|
||||
SlavesUpdated: out.SlavesUpdated,
|
||||
Error: out.Error,
|
||||
}
|
||||
data, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_ = conn.WriteMessage(websocket.TextMessage, data)
|
||||
})
|
||||
}
|
||||
|
||||
func clientInfoToAPI(c *pb.ClientInfo) APIClientInfo {
|
||||
@@ -571,16 +652,12 @@ func clientInfoToAPI(c *pb.ClientInfo) APIClientInfo {
|
||||
}
|
||||
}
|
||||
|
||||
func writeClientList(conn *websocket.Conn, msg ClientListMessage) {
|
||||
data, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_ = conn.WriteMessage(websocket.TextMessage, data)
|
||||
func writeClientList(sub *wsSubscriber, msg ClientListMessage) {
|
||||
sub.writeJSON(msg)
|
||||
}
|
||||
|
||||
func writeTapNotifyStatus(conn *websocket.Conn, out tapNotifyAPIResponse) {
|
||||
msg := TapNotifyStatusMessage{
|
||||
func writeTapNotifyStatus(sub *wsSubscriber, out tapNotifyAPIResponse) {
|
||||
sub.writeJSON(TapNotifyStatusMessage{
|
||||
Type: "tap_notify_status",
|
||||
ClientID: out.ClientID,
|
||||
Single: out.Single,
|
||||
@@ -589,12 +666,7 @@ func writeTapNotifyStatus(conn *websocket.Conn, out tapNotifyAPIResponse) {
|
||||
Success: out.Success,
|
||||
SlavesUpdated: out.SlavesUpdated,
|
||||
Error: out.Error,
|
||||
}
|
||||
data, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_ = conn.WriteMessage(websocket.TextMessage, data)
|
||||
})
|
||||
}
|
||||
|
||||
func applyTapNotifyClientWS(link *managedSerial, dash *wsHub, tapCtl *tapNotifyCtl, clientID uint32, single, doubleTap, triple bool) tapNotifyAPIResponse {
|
||||
@@ -627,18 +699,70 @@ func applyTapNotifyClientWS(link *managedSerial, dash *wsHub, tapCtl *tapNotifyC
|
||||
return out
|
||||
}
|
||||
|
||||
func handleAccelWSCommand(conn *websocket.Conn, sub *wsSubscriber, data []byte, link *managedSerial, dash *wsHub, ctl *accelStreamCtl, tapCtl *tapNotifyCtl, hub *accelStreamHub) {
|
||||
func handleAccelWSCommand(sub *wsSubscriber, data []byte, link *managedSerial, dash *wsHub, ctl *accelStreamCtl, tapCtl *tapNotifyCtl, hub *accelStreamHub) {
|
||||
var cmd accelWSCommand
|
||||
if err := json.Unmarshal(data, &cmd); err != nil {
|
||||
writeStreamStatus(conn, StreamStatusMessage{Type: "stream_status", Error: "invalid JSON"})
|
||||
writeStreamStatus(sub, StreamStatusMessage{Type: "stream_status", Error: "invalid JSON"})
|
||||
return
|
||||
}
|
||||
|
||||
switch cmd.Type {
|
||||
case "get_host":
|
||||
writeHostStatus(sub, hostStatusFromLink(link, true, ""))
|
||||
|
||||
case "list_serial_ports":
|
||||
ports, err := listSerialPorts()
|
||||
msg := SerialPortsMessage{Type: "serial_ports", Ports: ports, Success: err == nil}
|
||||
if err != nil {
|
||||
msg.Error = err.Error()
|
||||
msg.Ports = []string{}
|
||||
}
|
||||
writeWSJSON(sub, msg)
|
||||
|
||||
case "ble_scan":
|
||||
timeout := time.Duration(cmd.TimeoutS * float64(time.Second))
|
||||
if timeout <= 0 {
|
||||
timeout = 5 * time.Second
|
||||
}
|
||||
if link.Transport() == hostTransportBLE {
|
||||
_ = link.Disconnect()
|
||||
}
|
||||
devs, err := scanBLEDevices(timeout, cmd.NameFilter)
|
||||
msg := BleScanResultMessage{Type: "ble_scan_result", Devices: devs, Success: err == nil}
|
||||
if err != nil {
|
||||
msg.Error = err.Error()
|
||||
msg.Devices = []bleScanDevice{}
|
||||
}
|
||||
writeWSJSON(sub, msg)
|
||||
|
||||
case "host_connect":
|
||||
var err error
|
||||
switch cmd.Transport {
|
||||
case hostTransportUART:
|
||||
err = link.ConnectUART(cmd.Port, cmd.Baud)
|
||||
case hostTransportBLE:
|
||||
err = link.ConnectBLE(cmd.Address, cmd.Name)
|
||||
default:
|
||||
writeHostStatus(sub, HostStatusMessage{
|
||||
Type: "host_status",
|
||||
Error: "transport must be uart or ble",
|
||||
})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
writeHostStatus(sub, hostStatusFromLink(link, false, err.Error()))
|
||||
return
|
||||
}
|
||||
writeHostStatus(sub, hostStatusFromLink(link, true, ""))
|
||||
|
||||
case "host_disconnect":
|
||||
_ = link.Disconnect()
|
||||
writeHostStatus(sub, hostStatusFromLink(link, true, ""))
|
||||
|
||||
case "list_clients":
|
||||
clients, err := link.listClientsPoll()
|
||||
if err != nil {
|
||||
writeClientList(conn, ClientListMessage{
|
||||
writeClientList(sub, ClientListMessage{
|
||||
Type: "client_list",
|
||||
Error: err.Error(),
|
||||
})
|
||||
@@ -648,7 +772,7 @@ func handleAccelWSCommand(conn *websocket.Conn, sub *wsSubscriber, data []byte,
|
||||
for _, c := range clients {
|
||||
out = append(out, clientInfoToAPI(c))
|
||||
}
|
||||
writeClientList(conn, ClientListMessage{
|
||||
writeClientList(sub, ClientListMessage{
|
||||
Type: "client_list",
|
||||
Success: true,
|
||||
Clients: out,
|
||||
@@ -656,34 +780,34 @@ func handleAccelWSCommand(conn *websocket.Conn, sub *wsSubscriber, data []byte,
|
||||
|
||||
case "set_stream":
|
||||
if cmd.Enable == nil {
|
||||
writeStreamStatus(conn, StreamStatusMessage{
|
||||
writeStreamStatus(sub, StreamStatusMessage{
|
||||
Type: "stream_status",
|
||||
Error: "enable required",
|
||||
})
|
||||
return
|
||||
}
|
||||
writeStreamStatus(conn, hub.setStream(sub, *cmd.Enable, cmd.IntervalMs, cmd.PreFetch))
|
||||
writeStreamStatus(sub, hub.setStream(sub, *cmd.Enable, cmd.IntervalMs, cmd.PreFetch))
|
||||
|
||||
case "get_stream":
|
||||
writeStreamStatus(conn, hub.getStream(sub))
|
||||
writeStreamStatus(sub, hub.getStream(sub))
|
||||
|
||||
case "set_input_stream":
|
||||
if cmd.ClientID == 0 {
|
||||
writeInputStreamStatus(conn, accelStreamAPIResponse{Error: "client_id required"})
|
||||
writeInputStreamStatus(sub, accelStreamAPIResponse{Error: "client_id required"})
|
||||
return
|
||||
}
|
||||
if cmd.Enable == nil {
|
||||
writeInputStreamStatus(conn, accelStreamAPIResponse{
|
||||
writeInputStreamStatus(sub, accelStreamAPIResponse{
|
||||
ClientID: cmd.ClientID,
|
||||
Error: "enable required",
|
||||
})
|
||||
return
|
||||
}
|
||||
writeInputStreamStatus(conn, applyAccelStreamClient(link, dash, ctl, cmd.ClientID, *cmd.Enable))
|
||||
writeInputStreamStatus(sub, applyAccelStreamClient(link, dash, ctl, cmd.ClientID, *cmd.Enable))
|
||||
|
||||
case "get_input_stream":
|
||||
if cmd.ClientID == 0 {
|
||||
writeInputStreamStatus(conn, accelStreamAPIResponse{Error: "client_id required"})
|
||||
writeInputStreamStatus(sub, accelStreamAPIResponse{Error: "client_id required"})
|
||||
return
|
||||
}
|
||||
resp, err := link.AccelStreamPoll(&pb.AccelStreamRequest{
|
||||
@@ -691,7 +815,7 @@ func handleAccelWSCommand(conn *websocket.Conn, sub *wsSubscriber, data []byte,
|
||||
ClientId: cmd.ClientID,
|
||||
})
|
||||
if err != nil {
|
||||
writeInputStreamStatus(conn, accelStreamAPIResponse{
|
||||
writeInputStreamStatus(sub, accelStreamAPIResponse{
|
||||
ClientID: cmd.ClientID,
|
||||
Error: err.Error(),
|
||||
})
|
||||
@@ -700,7 +824,7 @@ func handleAccelWSCommand(conn *websocket.Conn, sub *wsSubscriber, data []byte,
|
||||
if ctl != nil {
|
||||
ctl.Set(cmd.ClientID, resp.GetEnabled())
|
||||
}
|
||||
writeInputStreamStatus(conn, accelStreamAPIResponse{
|
||||
writeInputStreamStatus(sub, accelStreamAPIResponse{
|
||||
Enabled: resp.GetEnabled(),
|
||||
ClientID: resp.GetClientId(),
|
||||
Success: resp.GetSuccess(),
|
||||
@@ -709,15 +833,15 @@ func handleAccelWSCommand(conn *websocket.Conn, sub *wsSubscriber, data []byte,
|
||||
case "set_tap_notify":
|
||||
if cmd.AllClients {
|
||||
if cmd.Single == nil || cmd.DoubleTap == nil || cmd.Triple == nil {
|
||||
writeTapNotifyStatus(conn, tapNotifyAPIResponse{Error: "single, double_tap, triple required"})
|
||||
writeTapNotifyStatus(sub, tapNotifyAPIResponse{Error: "single, double_tap, triple required"})
|
||||
return
|
||||
}
|
||||
updated, err := applyTapNotifyAll(link, dash, tapCtl, *cmd.Single, *cmd.DoubleTap, *cmd.Triple)
|
||||
if err != nil {
|
||||
writeTapNotifyStatus(conn, tapNotifyAPIResponse{Error: err.Error()})
|
||||
writeTapNotifyStatus(sub, tapNotifyAPIResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
writeTapNotifyStatus(conn, tapNotifyAPIResponse{
|
||||
writeTapNotifyStatus(sub, tapNotifyAPIResponse{
|
||||
Success: updated > 0,
|
||||
SlavesUpdated: updated,
|
||||
Single: *cmd.Single,
|
||||
@@ -727,21 +851,21 @@ func handleAccelWSCommand(conn *websocket.Conn, sub *wsSubscriber, data []byte,
|
||||
return
|
||||
}
|
||||
if cmd.ClientID == 0 {
|
||||
writeTapNotifyStatus(conn, tapNotifyAPIResponse{Error: "client_id required"})
|
||||
writeTapNotifyStatus(sub, tapNotifyAPIResponse{Error: "client_id required"})
|
||||
return
|
||||
}
|
||||
if cmd.Single == nil || cmd.DoubleTap == nil || cmd.Triple == nil {
|
||||
writeTapNotifyStatus(conn, tapNotifyAPIResponse{
|
||||
writeTapNotifyStatus(sub, tapNotifyAPIResponse{
|
||||
ClientID: cmd.ClientID,
|
||||
Error: "single, double_tap, triple required",
|
||||
})
|
||||
return
|
||||
}
|
||||
writeTapNotifyStatus(conn, applyTapNotifyClientWS(link, dash, tapCtl, cmd.ClientID, *cmd.Single, *cmd.DoubleTap, *cmd.Triple))
|
||||
writeTapNotifyStatus(sub, applyTapNotifyClientWS(link, dash, tapCtl, cmd.ClientID, *cmd.Single, *cmd.DoubleTap, *cmd.Triple))
|
||||
|
||||
case "get_tap_notify":
|
||||
if cmd.ClientID == 0 {
|
||||
writeTapNotifyStatus(conn, tapNotifyAPIResponse{Error: "client_id required"})
|
||||
writeTapNotifyStatus(sub, tapNotifyAPIResponse{Error: "client_id required"})
|
||||
return
|
||||
}
|
||||
resp, err := link.TapNotifyPoll(&pb.TapNotifyRequest{
|
||||
@@ -749,7 +873,7 @@ func handleAccelWSCommand(conn *websocket.Conn, sub *wsSubscriber, data []byte,
|
||||
ClientId: cmd.ClientID,
|
||||
})
|
||||
if err != nil {
|
||||
writeTapNotifyStatus(conn, tapNotifyAPIResponse{
|
||||
writeTapNotifyStatus(sub, tapNotifyAPIResponse{
|
||||
ClientID: cmd.ClientID,
|
||||
Error: err.Error(),
|
||||
})
|
||||
@@ -758,7 +882,7 @@ func handleAccelWSCommand(conn *websocket.Conn, sub *wsSubscriber, data []byte,
|
||||
if tapCtl != nil {
|
||||
tapCtl.Set(cmd.ClientID, resp.GetSingle(), resp.GetDoubleTap(), resp.GetTriple())
|
||||
}
|
||||
writeTapNotifyStatus(conn, tapNotifyAPIResponse{
|
||||
writeTapNotifyStatus(sub, tapNotifyAPIResponse{
|
||||
ClientID: cmd.ClientID,
|
||||
Success: resp.GetSuccess(),
|
||||
Single: resp.GetSingle(),
|
||||
@@ -769,36 +893,57 @@ func handleAccelWSCommand(conn *websocket.Conn, sub *wsSubscriber, data []byte,
|
||||
case "set_led_ring":
|
||||
var body ledRingAPIRequest
|
||||
if err := json.Unmarshal(data, &body); err != nil {
|
||||
writeLedRingStatus(conn, ledRingAPIResponse{Error: "invalid JSON"})
|
||||
writeLedRingStatus(sub, ledRingAPIResponse{Error: "invalid JSON"})
|
||||
return
|
||||
}
|
||||
if body.Mode == "" {
|
||||
writeLedRingStatus(conn, ledRingAPIResponse{Error: "mode required"})
|
||||
writeLedRingStatus(sub, ledRingAPIResponse{Error: "mode required"})
|
||||
return
|
||||
}
|
||||
writeLedRingStatus(conn, applyLedRing(link, body))
|
||||
writeLedRingStatus(sub, applyLedRing(link, body))
|
||||
|
||||
case "get_battery":
|
||||
var body batteryAPIRequest
|
||||
if err := json.Unmarshal(data, &body); err != nil {
|
||||
writeBatteryStatus(conn, batteryAPIResponse{Error: "invalid JSON"})
|
||||
writeBatteryStatus(sub, batteryAPIResponse{Error: "invalid JSON"})
|
||||
return
|
||||
}
|
||||
if !body.AllClients && body.ClientID == 0 {
|
||||
body.AllClients = true
|
||||
}
|
||||
writeBatteryStatus(conn, applyBatteryStatus(link, body))
|
||||
writeBatteryStatus(sub, applyBatteryStatus(link, body))
|
||||
|
||||
default:
|
||||
writeStreamStatus(conn, StreamStatusMessage{
|
||||
writeStreamStatus(sub, StreamStatusMessage{
|
||||
Type: "stream_status",
|
||||
Error: "unknown type (list_clients, set_stream, get_stream, set_input_stream, get_input_stream, set_tap_notify, get_tap_notify, set_led_ring, get_battery)",
|
||||
Error: "unknown type (get_host, list_serial_ports, ble_scan, host_connect, host_disconnect, list_clients, set_stream, get_stream, set_input_stream, get_input_stream, set_tap_notify, get_tap_notify, set_led_ring, get_battery)",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func serveExternalWS(conn *websocket.Conn, link *managedSerial, dash *wsHub, ctl *accelStreamCtl, tapCtl *tapNotifyCtl, portName string, hub *accelStreamHub) {
|
||||
sub := hub.register(conn, portName)
|
||||
func hostStatusFromLink(link *managedSerial, success bool, errMsg string) HostStatusMessage {
|
||||
st := link.HostStatus()
|
||||
return HostStatusMessage{
|
||||
Type: "host_status",
|
||||
Transport: st["transport"].(string),
|
||||
Endpoint: st["endpoint"].(string),
|
||||
Connected: st["connected"].(bool),
|
||||
BleName: st["ble_name"].(string),
|
||||
Success: success && errMsg == "",
|
||||
Error: errMsg,
|
||||
}
|
||||
}
|
||||
|
||||
func writeHostStatus(sub *wsSubscriber, msg HostStatusMessage) {
|
||||
sub.writeJSON(msg)
|
||||
}
|
||||
|
||||
func writeWSJSON(sub *wsSubscriber, msg any) {
|
||||
sub.writeJSON(msg)
|
||||
}
|
||||
|
||||
func serveExternalWS(conn *websocket.Conn, link *managedSerial, dash *wsHub, ctl *accelStreamCtl, tapCtl *tapNotifyCtl, hub *accelStreamHub) {
|
||||
sub := hub.register(conn, link)
|
||||
defer hub.unregister(conn)
|
||||
defer conn.Close()
|
||||
|
||||
@@ -807,11 +952,12 @@ func serveExternalWS(conn *websocket.Conn, link *managedSerial, dash *wsHub, ctl
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
handleAccelWSCommand(conn, sub, data, link, dash, ctl, tapCtl, hub)
|
||||
handleAccelWSCommand(sub, data, link, dash, ctl, tapCtl, hub)
|
||||
}
|
||||
}
|
||||
|
||||
func mountExternalAPI(mux *http.ServeMux, portName string, defaultInterval time.Duration, hub *accelStreamHub, link *managedSerial, dash *wsHub, ctl *accelStreamCtl, tapCtl *tapNotifyCtl) {
|
||||
_ = portName
|
||||
defMs := int(defaultInterval / time.Millisecond)
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/" && r.URL.Path != "/api/v1" && r.URL.Path != "/api/v1/" {
|
||||
@@ -822,17 +968,21 @@ func mountExternalAPI(mux *http.ServeMux, portName string, defaultInterval time.
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
st := link.HostStatus()
|
||||
writeJSON(w, http.StatusOK, APIInfoResponse{
|
||||
Name: "powerpod-external-api",
|
||||
Version: "1",
|
||||
SerialPort: portName,
|
||||
SerialPort: st["endpoint"].(string),
|
||||
Transport: st["transport"].(string),
|
||||
Endpoint: st["endpoint"].(string),
|
||||
Connected: st["connected"].(bool),
|
||||
WebSocket: "/ws",
|
||||
DefaultIntervalMs: defMs,
|
||||
DefaultPreFetchMs: defaultPreFetchMs,
|
||||
MinIntervalMs: int(minAPIStreamInterval / time.Millisecond),
|
||||
MaxIntervalMs: int(maxAPIStreamInterval / time.Millisecond),
|
||||
TapDisplayMinMs: apiTapDisplayMinMs,
|
||||
Description: "WebSocket: set_input_stream + set_stream for input (accel + tap); set_tap_notify configures slave tap kinds",
|
||||
Description: "WebSocket: host_connect/ble_scan + set_input_stream + set_stream for input (accel + tap)",
|
||||
})
|
||||
})
|
||||
|
||||
@@ -842,7 +992,7 @@ func mountExternalAPI(mux *http.ServeMux, portName string, defaultInterval time.
|
||||
log.Printf("api websocket upgrade: %v", err)
|
||||
return
|
||||
}
|
||||
serveExternalWS(conn, link, dash, ctl, tapCtl, portName, hub)
|
||||
serveExternalWS(conn, link, dash, ctl, tapCtl, hub)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"powerpod/gotool/pb"
|
||||
)
|
||||
|
||||
type tapConfigFields struct {
|
||||
TapSensThres uint32 `json:"tap_sens_thres"`
|
||||
MaxGestDur uint32 `json:"max_gest_dur"`
|
||||
TapShockDur uint32 `json:"tap_shock_dur"`
|
||||
QuiteTimeAfterGest uint32 `json:"quite_time_after_gest"`
|
||||
WaitForTimeout uint32 `json:"wait_for_timeout"`
|
||||
AxisSel uint32 `json:"axis_sel"`
|
||||
EnableSingle bool `json:"enable_single"`
|
||||
EnableDouble bool `json:"enable_double"`
|
||||
EnableTriple bool `json:"enable_triple"`
|
||||
}
|
||||
|
||||
type tapConfigAPIResponse struct {
|
||||
tapConfigFields
|
||||
ClientID uint32 `json:"client_id"`
|
||||
Success bool `json:"success"`
|
||||
SlavesUpdated uint32 `json:"slaves_updated"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type tapConfigAPIRequest struct {
|
||||
Write bool `json:"write"`
|
||||
ClientID uint32 `json:"client_id"`
|
||||
AllClients bool `json:"all_clients"`
|
||||
// SlavesOnly: with all_clients, push to ESP-NOW slaves only (master BMA456 unchanged).
|
||||
SlavesOnly bool `json:"slaves_only"`
|
||||
tapConfigFields
|
||||
}
|
||||
|
||||
func tapConfigFieldsFromPB(r *pb.TapConfigResponse) tapConfigFields {
|
||||
return tapConfigFields{
|
||||
TapSensThres: r.GetTapSensThres(),
|
||||
MaxGestDur: r.GetMaxGestDur(),
|
||||
TapShockDur: r.GetTapShockDur(),
|
||||
QuiteTimeAfterGest: r.GetQuiteTimeAfterGest(),
|
||||
WaitForTimeout: r.GetWaitForTimeout(),
|
||||
AxisSel: r.GetAxisSel(),
|
||||
EnableSingle: r.GetEnableSingle(),
|
||||
EnableDouble: r.GetEnableDouble(),
|
||||
EnableTriple: r.GetEnableTriple(),
|
||||
}
|
||||
}
|
||||
|
||||
func (f tapConfigFields) toPBRequest(write bool, clientID uint32, all bool) *pb.TapConfigRequest {
|
||||
return &pb.TapConfigRequest{
|
||||
Write: write,
|
||||
ClientId: clientID,
|
||||
AllClients: all,
|
||||
TapSensThres: f.TapSensThres,
|
||||
MaxGestDur: f.MaxGestDur,
|
||||
TapShockDur: f.TapShockDur,
|
||||
QuiteTimeAfterGest: f.QuiteTimeAfterGest,
|
||||
WaitForTimeout: f.WaitForTimeout,
|
||||
AxisSel: f.AxisSel,
|
||||
EnableSingle: f.EnableSingle,
|
||||
EnableDouble: f.EnableDouble,
|
||||
EnableTriple: f.EnableTriple,
|
||||
}
|
||||
}
|
||||
|
||||
func mountTapConfigAPI(mux *http.ServeMux, link *managedSerial) {
|
||||
mux.HandleFunc("/api/tap-config", func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
serveTapConfigGet(w, r, link)
|
||||
case http.MethodPost:
|
||||
serveTapConfigPost(w, r, link)
|
||||
default:
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func serveTapConfigGet(w http.ResponseWriter, r *http.Request, link *managedSerial) {
|
||||
clientID, err := parseUintQuery(r, "client_id", 0)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, tapConfigAPIResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
resp, err := link.TapConfig(&pb.TapConfigRequest{
|
||||
Write: false,
|
||||
ClientId: clientID,
|
||||
})
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, tapConfigAPIResponse{
|
||||
ClientID: clientID,
|
||||
Error: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
out := tapConfigAPIResponse{
|
||||
tapConfigFields: tapConfigFieldsFromPB(resp),
|
||||
ClientID: resp.GetClientId(),
|
||||
Success: resp.GetSuccess(),
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
func serveTapConfigPost(w http.ResponseWriter, r *http.Request, link *managedSerial) {
|
||||
var body tapConfigAPIRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, tapConfigAPIResponse{Error: "invalid JSON"})
|
||||
return
|
||||
}
|
||||
if body.AllClients && body.SlavesOnly {
|
||||
updated, err := applyTapConfigToSlaves(link, body.tapConfigFields)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, tapConfigAPIResponse{
|
||||
tapConfigFields: body.tapConfigFields,
|
||||
Error: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, tapConfigAPIResponse{
|
||||
tapConfigFields: body.tapConfigFields,
|
||||
Success: updated > 0,
|
||||
SlavesUpdated: updated,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := link.TapConfig(body.tapConfigFields.toPBRequest(true, body.ClientID, body.AllClients))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, tapConfigAPIResponse{
|
||||
ClientID: body.ClientID,
|
||||
tapConfigFields: body.tapConfigFields,
|
||||
Error: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, tapConfigAPIResponse{
|
||||
tapConfigFields: tapConfigFieldsFromPB(resp),
|
||||
ClientID: resp.GetClientId(),
|
||||
Success: resp.GetSuccess(),
|
||||
SlavesUpdated: resp.GetSlavesUpdated(),
|
||||
})
|
||||
}
|
||||
|
||||
func applyTapConfigToSlaves(link *managedSerial, fields tapConfigFields) (uint32, error) {
|
||||
clients, err := link.listClients()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var updated uint32
|
||||
for _, c := range clients {
|
||||
resp, err := link.TapConfig(fields.toPBRequest(true, c.GetId(), false))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if resp.GetSuccess() {
|
||||
updated++
|
||||
}
|
||||
}
|
||||
if len(clients) == 0 {
|
||||
return 0, fmt.Errorf("no slaves registered")
|
||||
}
|
||||
if updated == 0 {
|
||||
return 0, fmt.Errorf("tap config not applied to any slave")
|
||||
}
|
||||
return updated, nil
|
||||
}
|
||||
@@ -84,13 +84,7 @@ func NewBench(cfg Config) (*Bench, error) {
|
||||
return nil, fmt.Errorf("config %q slave %q: %w", cfg.ID, s.ID, err)
|
||||
}
|
||||
s.MAC = mac
|
||||
if s.ClientID == nil {
|
||||
parts := strings.Split(mac, ":")
|
||||
var last byte
|
||||
fmt.Sscanf(parts[5], "%02x", &last)
|
||||
id := uint(last)
|
||||
s.ClientID = &id
|
||||
}
|
||||
/* client_id is master-assigned (sequential); omit until known from `gotool clients`. */
|
||||
b.slaveByID[s.ID] = s
|
||||
}
|
||||
return b, nil
|
||||
@@ -112,6 +106,9 @@ func (b *Bench) ResolveClientID(slaveID string) (uint32, error) {
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if s.ClientID == nil {
|
||||
return 0, fmt.Errorf("slave %q: client_id required (master-assigned; set in config after join)", slaveID)
|
||||
}
|
||||
return uint32(*s.ClientID), nil
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ package autotest
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNewBenchClientIDFromMAC(t *testing.T) {
|
||||
func TestNewBenchNoDefaultClientIDFromMAC(t *testing.T) {
|
||||
cfg := Config{
|
||||
ID: "t",
|
||||
Network: 1,
|
||||
@@ -20,8 +20,39 @@ func TestNewBenchClientIDFromMAC(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s, _ := b.Slave("pod")
|
||||
if *s.ClientID != 16 {
|
||||
t.Fatalf("client_id=%d want 16", *s.ClientID)
|
||||
if s.ClientID != nil {
|
||||
t.Fatalf("client_id should be unset, got %d", *s.ClientID)
|
||||
}
|
||||
if _, err := b.ResolveClientID("pod"); err == nil {
|
||||
t.Fatal("ResolveClientID should fail without client_id")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewBenchExplicitClientID(t *testing.T) {
|
||||
id := uint(3)
|
||||
cfg := Config{
|
||||
ID: "t",
|
||||
Network: 1,
|
||||
MasterMAC: "aa:bb:cc:dd:ee:ff",
|
||||
UART: UARTConfig{
|
||||
Master: "/dev/ttyUSB0",
|
||||
},
|
||||
Slaves: []SlaveNode{{
|
||||
ID: "pod",
|
||||
MAC: "50:78:7d:18:01:10",
|
||||
ClientID: &id,
|
||||
}},
|
||||
}
|
||||
b, err := NewBench(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := b.ResolveClientID("pod")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != 3 {
|
||||
t.Fatalf("client_id=%d want 3", got)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -195,7 +195,7 @@ func checkClients(bench *Bench, step Step, client MasterClient) error {
|
||||
if found == nil {
|
||||
return fmt.Errorf("slave %q mac %s not in client list", name, want.MAC)
|
||||
}
|
||||
if uint32(*want.ClientID) != found.GetId() {
|
||||
if want.ClientID != nil && uint32(*want.ClientID) != found.GetId() {
|
||||
return fmt.Errorf("slave %q id=%d want client_id=%d", name, found.GetId(), *want.ClientID)
|
||||
}
|
||||
if e.Available != nil && found.GetAvailable() != *e.Available {
|
||||
@@ -347,6 +347,8 @@ func ledRingModeValue(mode string) (uint32, error) {
|
||||
return 3, nil
|
||||
case "find_me", "findme":
|
||||
return 4, nil
|
||||
case "battery_low", "batterylow":
|
||||
return 6, nil
|
||||
case "color", "solid", "fill":
|
||||
return 5, nil
|
||||
default:
|
||||
|
||||
@@ -12,6 +12,7 @@ func TestLedRingModeValue(t *testing.T) {
|
||||
{"digit", 2},
|
||||
{"blink", 3},
|
||||
{"find-me", 4},
|
||||
{"battery-low", 6},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
got, err := ledRingModeValue(tc.mode)
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"tinygo.org/x/bluetooth"
|
||||
|
||||
pb "powerpod/gotool/pb"
|
||||
uartframe "powerpod/gotool/uart"
|
||||
)
|
||||
|
||||
const (
|
||||
bleServiceUUID = "6e400001-b5a3-f393-e0a9-e50e24dcca9e"
|
||||
bleCharUUID = "6e400002-b5a3-f393-e0a9-e50e24dcca9e"
|
||||
bleDefaultName = "Powerpod"
|
||||
)
|
||||
|
||||
type bleLink struct {
|
||||
device bluetooth.Device
|
||||
char bluetooth.DeviceCharacteristic
|
||||
mu sync.Mutex
|
||||
parserMu sync.Mutex
|
||||
parser *uartframe.Parser
|
||||
respCh chan []byte
|
||||
streamCh chan []byte
|
||||
quiet bool
|
||||
attMTU int // negotiated ATT MTU (0 if unknown)
|
||||
}
|
||||
|
||||
func openBLE(name string, settle time.Duration) (*bleLink, error) {
|
||||
if name == "" {
|
||||
name = bleDefaultName
|
||||
}
|
||||
adapter := bluetooth.DefaultAdapter
|
||||
if err := adapter.Enable(); err != nil {
|
||||
return nil, fmt.Errorf("enable bluetooth adapter: %w (check BlueZ / permissions)", err)
|
||||
}
|
||||
|
||||
log.Printf("BLE scanning for %q…", name)
|
||||
var foundAddr bluetooth.Address
|
||||
var found bool
|
||||
go func() {
|
||||
time.Sleep(15 * time.Second)
|
||||
_ = adapter.StopScan()
|
||||
}()
|
||||
err := adapter.Scan(func(a *bluetooth.Adapter, result bluetooth.ScanResult) {
|
||||
if result.LocalName() != name {
|
||||
return
|
||||
}
|
||||
foundAddr = result.Address
|
||||
found = true
|
||||
_ = a.StopScan()
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan: %w", err)
|
||||
}
|
||||
if !found {
|
||||
return nil, fmt.Errorf("device %q not found (check DIP8=BLE and advertising)", name)
|
||||
}
|
||||
return connectBLEDevice(foundAddr, settle)
|
||||
}
|
||||
|
||||
func openBLEAddress(addrStr string, settle time.Duration) (*bleLink, error) {
|
||||
adapter := bluetooth.DefaultAdapter
|
||||
if err := adapter.Enable(); err != nil {
|
||||
return nil, fmt.Errorf("enable bluetooth adapter: %w (check BlueZ / permissions)", err)
|
||||
}
|
||||
if mac, err := bluetooth.ParseMAC(addrStr); err == nil {
|
||||
var addr bluetooth.Address
|
||||
addr.Set(mac.String())
|
||||
return connectBLEDevice(addr, settle)
|
||||
}
|
||||
// Fallback: scan until address string matches
|
||||
log.Printf("BLE resolving address %s via scan…", addrStr)
|
||||
var foundAddr bluetooth.Address
|
||||
var found bool
|
||||
go func() {
|
||||
time.Sleep(15 * time.Second)
|
||||
_ = adapter.StopScan()
|
||||
}()
|
||||
err := adapter.Scan(func(a *bluetooth.Adapter, result bluetooth.ScanResult) {
|
||||
if result.Address.String() != addrStr {
|
||||
return
|
||||
}
|
||||
foundAddr = result.Address
|
||||
found = true
|
||||
_ = a.StopScan()
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan for address: %w", err)
|
||||
}
|
||||
if !found {
|
||||
return nil, fmt.Errorf("BLE address %s not found", addrStr)
|
||||
}
|
||||
return connectBLEDevice(foundAddr, settle)
|
||||
}
|
||||
|
||||
func connectBLEDevice(addr bluetooth.Address, settle time.Duration) (*bleLink, error) {
|
||||
svcUUID, err := bluetooth.ParseUUID(bleServiceUUID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
chrUUID, err := bluetooth.ParseUUID(bleCharUUID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
adapter := bluetooth.DefaultAdapter
|
||||
log.Printf("BLE connecting to %s…", addr.String())
|
||||
device, err := adapter.Connect(addr, bluetooth.ConnectionParams{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("connect: %w", err)
|
||||
}
|
||||
|
||||
services, err := device.DiscoverServices([]bluetooth.UUID{svcUUID})
|
||||
if err != nil || len(services) == 0 {
|
||||
_ = device.Disconnect()
|
||||
return nil, fmt.Errorf("discover service: %v", err)
|
||||
}
|
||||
chars, err := services[0].DiscoverCharacteristics([]bluetooth.UUID{chrUUID})
|
||||
if err != nil || len(chars) == 0 {
|
||||
_ = device.Disconnect()
|
||||
return nil, fmt.Errorf("discover characteristic: %v", err)
|
||||
}
|
||||
char := chars[0]
|
||||
|
||||
attMTU := 0
|
||||
if mtu, err := char.GetMTU(); err == nil {
|
||||
attMTU = int(mtu)
|
||||
log.Printf("BLE ATT MTU=%d", attMTU)
|
||||
if attMTU < otaMinATTMTU {
|
||||
log.Printf("warning: MTU %d < %d — OTA (200 B chunks) and large stream ticks may fail",
|
||||
attMTU, otaMinATTMTU)
|
||||
}
|
||||
}
|
||||
|
||||
link := &bleLink{
|
||||
device: device,
|
||||
char: char,
|
||||
parser: uartframe.NewParser(),
|
||||
respCh: make(chan []byte, 8),
|
||||
streamCh: make(chan []byte, 64),
|
||||
attMTU: attMTU,
|
||||
}
|
||||
|
||||
if err := char.EnableNotifications(func(buf []byte) {
|
||||
link.onNotify(buf)
|
||||
}); err != nil {
|
||||
_ = device.Disconnect()
|
||||
return nil, fmt.Errorf("enable notifications: %w (CCCD required before commands)", err)
|
||||
}
|
||||
|
||||
if settle <= 0 {
|
||||
settle = time.Second
|
||||
}
|
||||
log.Printf("BLE connected; settling %v…", settle)
|
||||
time.Sleep(settle)
|
||||
|
||||
return link, nil
|
||||
}
|
||||
|
||||
func (b *bleLink) onNotify(buf []byte) {
|
||||
b.parserMu.Lock()
|
||||
defer b.parserMu.Unlock()
|
||||
for _, by := range buf {
|
||||
payload, ok, err := b.parser.Feed(by)
|
||||
if err != nil || !ok {
|
||||
continue
|
||||
}
|
||||
if len(payload) == 0 {
|
||||
continue
|
||||
}
|
||||
if payload[0] == byte(pb.MessageType_BLE_STREAM_TICK) {
|
||||
select {
|
||||
case b.streamCh <- payload:
|
||||
default:
|
||||
}
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case b.respCh <- payload:
|
||||
default:
|
||||
// Prefer dropping the oldest queued frame so a late/stale reply
|
||||
// does not block the matching response for the in-flight command.
|
||||
select {
|
||||
case <-b.respCh:
|
||||
default:
|
||||
}
|
||||
select {
|
||||
case b.respCh <- payload:
|
||||
default:
|
||||
log.Printf("BLE: response channel full, dropping frame type=0x%02x", payload[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (b *bleLink) resetParser() {
|
||||
b.parserMu.Lock()
|
||||
b.parser = uartframe.NewParser()
|
||||
b.parserMu.Unlock()
|
||||
}
|
||||
|
||||
func (b *bleLink) Close() error {
|
||||
return b.device.Disconnect()
|
||||
}
|
||||
|
||||
func (b *bleLink) exchange(cmdID byte, cmdName string) ([]byte, error) {
|
||||
return b.exchangePayload([]byte{cmdID}, cmdName)
|
||||
}
|
||||
|
||||
func (b *bleLink) exchangePayload(payload []byte, cmdName string) ([]byte, error) {
|
||||
return b.exchangePayloadTimeout(payload, cmdName, readTimeout)
|
||||
}
|
||||
|
||||
func (b *bleLink) exchangePayloadTimeout(payload []byte, cmdName string, timeout time.Duration) ([]byte, error) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
|
||||
if len(payload) == 0 {
|
||||
return nil, fmt.Errorf("empty payload")
|
||||
}
|
||||
expect := payload[0]
|
||||
if timeout <= 0 {
|
||||
timeout = readTimeout
|
||||
}
|
||||
|
||||
frame, err := uartframe.EncodeFrame(payload)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode frame: %w", err)
|
||||
}
|
||||
if !b.quiet {
|
||||
log.Printf("BLE sending %s (%d bytes)", cmdName, len(frame))
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-b.respCh:
|
||||
default:
|
||||
goto sent
|
||||
}
|
||||
}
|
||||
sent:
|
||||
if _, err := b.char.WriteWithoutResponse(frame); err != nil {
|
||||
return nil, fmt.Errorf("BLE write: %w (check DIP8=BLE)", err)
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(timeout)
|
||||
for {
|
||||
remain := time.Until(deadline)
|
||||
if remain <= 0 {
|
||||
// Drop a half-parsed frame (e.g. aborted multi-notify) so the next
|
||||
// exchange does not treat a new 0xAA as payload data.
|
||||
b.resetParser()
|
||||
return nil, fmt.Errorf("BLE read timeout for %s (check DIP8=BLE / -ble)", cmdName)
|
||||
}
|
||||
timer := time.NewTimer(remain)
|
||||
select {
|
||||
case resp := <-b.respCh:
|
||||
timer.Stop()
|
||||
if len(resp) == 0 {
|
||||
continue
|
||||
}
|
||||
if resp[0] != expect {
|
||||
// Stale reply (e.g. late BATTERY_STATUS after a short timeout).
|
||||
if !b.quiet {
|
||||
log.Printf("BLE: discard type=0x%02x while waiting for 0x%02x (%s)", resp[0], expect, cmdName)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !b.quiet {
|
||||
log.Printf("BLE response (%d bytes)", len(resp))
|
||||
}
|
||||
return resp, nil
|
||||
case <-timer.C:
|
||||
b.resetParser()
|
||||
return nil, fmt.Errorf("BLE read timeout for %s (check DIP8=BLE / -ble)", cmdName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (b *bleLink) NextStreamTick(timeout time.Duration) ([]byte, error) {
|
||||
if timeout <= 0 {
|
||||
timeout = 5 * time.Second
|
||||
}
|
||||
timer := time.NewTimer(timeout)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case p := <-b.streamCh:
|
||||
return p, nil
|
||||
case <-timer.C:
|
||||
return nil, fmt.Errorf("BLE stream tick timeout")
|
||||
}
|
||||
}
|
||||
|
||||
// ensureOTAMTU rejects OTA when the negotiated ATT MTU cannot carry 200 B chunks.
|
||||
func (b *bleLink) ensureOTAMTU() error {
|
||||
mtu := b.attMTU
|
||||
if mtu <= 0 {
|
||||
if v, err := b.char.GetMTU(); err == nil {
|
||||
mtu = int(v)
|
||||
b.attMTU = mtu
|
||||
}
|
||||
}
|
||||
if mtu < otaMinATTMTU {
|
||||
return fmt.Errorf("BLE ATT MTU %d < %d required for OTA (negotiate larger MTU)", mtu, otaMinATTMTU)
|
||||
}
|
||||
maxWrite := mtu - 3
|
||||
if maxWrite < 216 {
|
||||
return fmt.Errorf("BLE max write %d < 216 needed for 200 B OTA frames", maxWrite)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *bleLink) writeMsg(msg *pb.UartMessage) error {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
frame, err := encodeUartMessage(msg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if b.attMTU > 0 && len(frame) > b.attMTU-3 {
|
||||
return fmt.Errorf("OTA frame %d exceeds BLE max write %d (MTU=%d)",
|
||||
len(frame), b.attMTU-3, b.attMTU)
|
||||
}
|
||||
if _, err := b.char.WriteWithoutResponse(frame); err != nil {
|
||||
return fmt.Errorf("BLE write: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *bleLink) readFrame(timeout time.Duration) ([]byte, error) {
|
||||
if timeout <= 0 {
|
||||
timeout = readTimeout
|
||||
}
|
||||
timer := time.NewTimer(timeout)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case p := <-b.respCh:
|
||||
return p, nil
|
||||
case <-timer.C:
|
||||
return nil, fmt.Errorf("BLE read timeout")
|
||||
}
|
||||
}
|
||||
|
||||
func (b *bleLink) flushInput() {
|
||||
for {
|
||||
select {
|
||||
case <-b.respCh:
|
||||
default:
|
||||
goto streams
|
||||
}
|
||||
}
|
||||
streams:
|
||||
for {
|
||||
select {
|
||||
case <-b.streamCh:
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (b *bleLink) chunkPace() time.Duration { return otaHostChunkPaceBLE }
|
||||
@@ -0,0 +1,224 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
pb "powerpod/gotool/pb"
|
||||
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
type latencyStats struct {
|
||||
N int `json:"n"`
|
||||
MinMs float64 `json:"min_ms"`
|
||||
MedMs float64 `json:"median_ms"`
|
||||
MeanMs float64 `json:"mean_ms"`
|
||||
MaxMs float64 `json:"max_ms"`
|
||||
}
|
||||
|
||||
type bleRttResult struct {
|
||||
Success bool `json:"success"`
|
||||
Count int `json:"count"`
|
||||
Warmup int `json:"warmup"`
|
||||
Stats latencyStats `json:"stats"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type bleStreamResult struct {
|
||||
Success bool `json:"success"`
|
||||
Received int `json:"received"`
|
||||
Requested int `json:"requested"`
|
||||
PeriodMs uint32 `json:"period_ms"`
|
||||
TolMs float64 `json:"tol_ms"`
|
||||
OutsideTol int `json:"outside_tol"`
|
||||
WindowOK bool `json:"window_ok"`
|
||||
DTEsp latencyStats `json:"dt_esp_ms"`
|
||||
DRPc latencyStats `json:"dr_pc_ms"`
|
||||
ExtraVsFirst latencyStats `json:"extra_vs_first_ms"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func measureBleRtt(h hostConn, count, warmup int, settle time.Duration) bleRttResult {
|
||||
out := bleRttResult{Count: count, Warmup: warmup}
|
||||
if count < 1 {
|
||||
out.Error = "count must be >= 1"
|
||||
return out
|
||||
}
|
||||
if warmup < 0 {
|
||||
out.Error = "warmup must be >= 0"
|
||||
return out
|
||||
}
|
||||
if settle > 0 {
|
||||
time.Sleep(settle)
|
||||
}
|
||||
|
||||
samples := make([]float64, 0, count)
|
||||
total := warmup + count
|
||||
for i := 0; i < total; i++ {
|
||||
t0 := time.Now()
|
||||
if _, err := hostGetVersion(h); err != nil {
|
||||
out.Error = fmt.Sprintf("VERSION round-trip %d: %v", i, err)
|
||||
return out
|
||||
}
|
||||
ms := float64(time.Since(t0).Microseconds()) / 1000.0
|
||||
if i >= warmup {
|
||||
samples = append(samples, ms)
|
||||
}
|
||||
}
|
||||
out.Stats = makeLatencyStats(samples)
|
||||
out.Success = true
|
||||
return out
|
||||
}
|
||||
|
||||
func measureBleStream(h hostConn, clientID, periodMs uint32, count int, tolMs, timeoutSec float64) bleStreamResult {
|
||||
out := bleStreamResult{
|
||||
Requested: count,
|
||||
PeriodMs: periodMs,
|
||||
TolMs: tolMs,
|
||||
}
|
||||
if periodMs < 10 || periodMs > 16 {
|
||||
out.Error = "period_ms must be 10..16"
|
||||
return out
|
||||
}
|
||||
if count < 2 {
|
||||
out.Error = "count must be >= 2"
|
||||
return out
|
||||
}
|
||||
sc, ok := h.(hostStreamConn)
|
||||
if !ok {
|
||||
out.Error = "BLE stream ticks require BLE host"
|
||||
return out
|
||||
}
|
||||
|
||||
req := &pb.AccelStreamRequest{
|
||||
Write: true,
|
||||
Enable: true,
|
||||
PeriodMs: periodMs,
|
||||
}
|
||||
if clientID == 0 {
|
||||
req.AllClients = true
|
||||
} else {
|
||||
req.ClientId = clientID
|
||||
}
|
||||
resp, err := hostAccelStream(h, req)
|
||||
if err != nil {
|
||||
out.Error = err.Error()
|
||||
return out
|
||||
}
|
||||
if !resp.GetSuccess() {
|
||||
out.Error = "accel stream enable failed"
|
||||
return out
|
||||
}
|
||||
|
||||
type sample struct {
|
||||
espUs uint64
|
||||
pcT float64
|
||||
}
|
||||
samples := make([]sample, 0, count)
|
||||
deadline := time.Now().Add(time.Duration(timeoutSec * float64(time.Second)))
|
||||
if timeoutSec <= 0 {
|
||||
deadline = time.Now().Add(15 * time.Second)
|
||||
}
|
||||
|
||||
for len(samples) < count {
|
||||
remain := time.Until(deadline)
|
||||
if remain <= 0 {
|
||||
break
|
||||
}
|
||||
payload, err := sc.NextStreamTick(remain)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
pcT := time.Now().UnixMicro()
|
||||
var msg pb.UartMessage
|
||||
if err := proto.Unmarshal(payload[1:], &msg); err != nil {
|
||||
continue
|
||||
}
|
||||
tick := msg.GetBleStreamTick()
|
||||
if tick == nil {
|
||||
continue
|
||||
}
|
||||
samples = append(samples, sample{espUs: tick.GetEspTimestampUs(), pcT: float64(pcT)})
|
||||
}
|
||||
|
||||
off := &pb.AccelStreamRequest{Write: true, Enable: false, AllClients: req.AllClients, ClientId: req.ClientId}
|
||||
_, _ = hostAccelStream(h, off)
|
||||
|
||||
out.Received = len(samples)
|
||||
if len(samples) < 2 {
|
||||
out.Error = fmt.Sprintf("need >=2 ticks, got %d (stream running / DIP8=BLE?)", len(samples))
|
||||
return out
|
||||
}
|
||||
|
||||
var dT, dR, extra []float64
|
||||
esp0 := samples[0].espUs
|
||||
pc0 := samples[0].pcT
|
||||
for i := 1; i < len(samples); i++ {
|
||||
dT = append(dT, float64(samples[i].espUs-samples[i-1].espUs)/1000.0)
|
||||
dR = append(dR, (samples[i].pcT-samples[i-1].pcT)/1000.0)
|
||||
relEsp := float64(samples[i].espUs-esp0) / 1000.0
|
||||
relPc := (samples[i].pcT - pc0) / 1000.0
|
||||
extra = append(extra, relPc-relEsp)
|
||||
}
|
||||
|
||||
period := float64(periodMs)
|
||||
outCount := 0
|
||||
for _, dt := range dT {
|
||||
if math.Abs(dt-period) > tolMs {
|
||||
outCount++
|
||||
}
|
||||
}
|
||||
out.OutsideTol = outCount
|
||||
out.WindowOK = outCount*2 <= len(dT)
|
||||
out.DTEsp = makeLatencyStats(dT)
|
||||
out.DRPc = makeLatencyStats(dR)
|
||||
out.ExtraVsFirst = makeLatencyStats(extra)
|
||||
out.Success = true
|
||||
return out
|
||||
}
|
||||
|
||||
func makeLatencyStats(v []float64) latencyStats {
|
||||
if len(v) == 0 {
|
||||
return latencyStats{}
|
||||
}
|
||||
s := append([]float64(nil), v...)
|
||||
sort.Float64s(s)
|
||||
return latencyStats{
|
||||
N: len(s),
|
||||
MinMs: s[0],
|
||||
MedMs: percentile(s, 0.5),
|
||||
MeanMs: mean(s),
|
||||
MaxMs: s[len(s)-1],
|
||||
}
|
||||
}
|
||||
|
||||
func mean(v []float64) float64 {
|
||||
if len(v) == 0 {
|
||||
return math.NaN()
|
||||
}
|
||||
var s float64
|
||||
for _, x := range v {
|
||||
s += x
|
||||
}
|
||||
return s / float64(len(v))
|
||||
}
|
||||
|
||||
func percentile(sorted []float64, p float64) float64 {
|
||||
if len(sorted) == 0 {
|
||||
return math.NaN()
|
||||
}
|
||||
if len(sorted) == 1 {
|
||||
return sorted[0]
|
||||
}
|
||||
idx := p * float64(len(sorted)-1)
|
||||
lo := int(idx)
|
||||
hi := lo + 1
|
||||
if hi >= len(sorted) {
|
||||
return sorted[lo]
|
||||
}
|
||||
frac := idx - float64(lo)
|
||||
return sorted[lo]*(1-frac) + sorted[hi]*frac
|
||||
}
|
||||
+35
-1
@@ -1,6 +1,12 @@
|
||||
package main
|
||||
|
||||
import "powerpod/gotool/pb"
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"powerpod/gotool/pb"
|
||||
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
// accelSamplesFromCacheStatus maps combined CACHE_STATUS entries to AccelSample
|
||||
// (for dashboard / WebSocket accel push).
|
||||
@@ -47,3 +53,31 @@ func tapEventsFromCacheStatus(r *pb.CacheStatusResponse) []*pb.TapEvent {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// cacheStatusFromBLEStreamPayload parses a framed BLE_STREAM_TICK payload
|
||||
// (type byte + protobuf UartMessage) into a CacheStatusResponse-shaped view.
|
||||
func cacheStatusFromBLEStreamPayload(payload []byte) (*pb.CacheStatusResponse, error) {
|
||||
tick, err := parseBleStreamTickPayload(payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &pb.CacheStatusResponse{Clients: tick.GetClients()}, nil
|
||||
}
|
||||
|
||||
func parseBleStreamTickPayload(payload []byte) (*pb.BleStreamTick, error) {
|
||||
if len(payload) < 2 {
|
||||
return nil, fmt.Errorf("BLE stream tick too short")
|
||||
}
|
||||
if payload[0] != byte(pb.MessageType_BLE_STREAM_TICK) {
|
||||
return nil, fmt.Errorf("unexpected BLE tick type 0x%02x", payload[0])
|
||||
}
|
||||
var msg pb.UartMessage
|
||||
if err := proto.Unmarshal(payload[1:], &msg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tick := msg.GetBleStreamTick()
|
||||
if tick == nil {
|
||||
return nil, fmt.Errorf("missing ble_stream_tick")
|
||||
}
|
||||
return tick, nil
|
||||
}
|
||||
|
||||
+344
-78
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"google.golang.org/protobuf/proto"
|
||||
|
||||
@@ -63,16 +64,14 @@ func decodeBatteryStatusPayload(payload []byte) (*pb.BatteryStatusResponse, erro
|
||||
}
|
||||
|
||||
func (m *managedSerial) BatteryStatus(req *pb.BatteryStatusRequest) (*pb.BatteryStatusResponse, error) {
|
||||
var resp *pb.BatteryStatusResponse
|
||||
err := m.withPort(func(sp *serialPort) error {
|
||||
var e error
|
||||
resp, e = sp.batteryStatus(req)
|
||||
return e
|
||||
})
|
||||
return resp, err
|
||||
return m.batteryStatusExchange(false, req)
|
||||
}
|
||||
|
||||
func (m *managedSerial) BatteryStatusPoll(req *pb.BatteryStatusRequest) (*pb.BatteryStatusResponse, error) {
|
||||
return m.batteryStatusExchange(true, req)
|
||||
}
|
||||
|
||||
func (m *managedSerial) batteryStatusExchange(poll bool, req *pb.BatteryStatusRequest) (*pb.BatteryStatusResponse, error) {
|
||||
msg := &pb.UartMessage{
|
||||
Type: pb.MessageType_BATTERY_STATUS,
|
||||
Payload: &pb.UartMessage_BatteryStatusRequest{
|
||||
@@ -84,23 +83,18 @@ func (m *managedSerial) BatteryStatusPoll(req *pb.BatteryStatusRequest) (*pb.Bat
|
||||
return nil, fmt.Errorf("encode: %w", err)
|
||||
}
|
||||
payload := append([]byte{byte(pb.MessageType_BATTERY_STATUS)}, body...)
|
||||
respPayload, err := m.batteryStatusPayloadPoll(payload)
|
||||
var respPayload []byte
|
||||
if poll {
|
||||
respPayload, err = m.exchangePayloadMode(true, payload, "BATTERY_STATUS", batteryReadTimeout)
|
||||
} else {
|
||||
respPayload, err = m.exchangePayloadMode(false, payload, "BATTERY_STATUS", batteryReadTimeout)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return decodeBatteryStatusPayload(respPayload)
|
||||
}
|
||||
|
||||
func (m *managedSerial) batteryStatusPayloadPoll(payload []byte) ([]byte, error) {
|
||||
var resp []byte
|
||||
err := m.withPortPoll(func(sp *serialPort) error {
|
||||
var e error
|
||||
resp, e = sp.exchangePayloadForBattery(payload, "BATTERY_STATUS")
|
||||
return e
|
||||
})
|
||||
return resp, err
|
||||
}
|
||||
|
||||
func (s *serialPort) batteryStatus(req *pb.BatteryStatusRequest) (*pb.BatteryStatusResponse, error) {
|
||||
msg := &pb.UartMessage{
|
||||
Type: pb.MessageType_BATTERY_STATUS,
|
||||
@@ -121,11 +115,11 @@ func (s *serialPort) batteryStatus(req *pb.BatteryStatusRequest) (*pb.BatterySta
|
||||
}
|
||||
|
||||
func (m *managedSerial) AccelStream(req *pb.AccelStreamRequest) (*pb.AccelStreamResponse, error) {
|
||||
return m.accelStreamVia(m.withPort, req)
|
||||
return m.accelStreamExchange(false, req)
|
||||
}
|
||||
|
||||
func (m *managedSerial) AccelStreamPoll(req *pb.AccelStreamRequest) (*pb.AccelStreamResponse, error) {
|
||||
return m.accelStreamVia(m.withPortPoll, req)
|
||||
return m.accelStreamExchange(true, req)
|
||||
}
|
||||
|
||||
// SetAccelStream enables or disables the ESP-NOW accel stream for one slave (master UART).
|
||||
@@ -153,24 +147,41 @@ func (m *managedSerial) GetAccelStream(clientID uint32) (bool, error) {
|
||||
}
|
||||
|
||||
func (m *managedSerial) TapNotify(req *pb.TapNotifyRequest) (*pb.TapNotifyResponse, error) {
|
||||
return m.tapNotifyVia(m.withPort, req)
|
||||
return m.tapNotifyExchange(false, req)
|
||||
}
|
||||
|
||||
func (m *managedSerial) TapNotifyPoll(req *pb.TapNotifyRequest) (*pb.TapNotifyResponse, error) {
|
||||
return m.tapNotifyVia(m.withPortPoll, req)
|
||||
return m.tapNotifyExchange(true, req)
|
||||
}
|
||||
|
||||
func (m *managedSerial) tapNotifyVia(
|
||||
portFn func(func(*serialPort) error) error,
|
||||
req *pb.TapNotifyRequest,
|
||||
) (*pb.TapNotifyResponse, error) {
|
||||
var resp *pb.TapNotifyResponse
|
||||
err := portFn(func(sp *serialPort) error {
|
||||
var e error
|
||||
resp, e = sp.TapNotify(req)
|
||||
return e
|
||||
})
|
||||
return resp, err
|
||||
func (m *managedSerial) tapNotifyExchange(poll bool, req *pb.TapNotifyRequest) (*pb.TapNotifyResponse, error) {
|
||||
msg := &pb.UartMessage{
|
||||
Type: pb.MessageType_TAP_NOTIFY,
|
||||
Payload: &pb.UartMessage_TapNotifyRequest{TapNotifyRequest: req},
|
||||
}
|
||||
body, err := proto.Marshal(msg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode: %w", err)
|
||||
}
|
||||
payload := append([]byte{byte(pb.MessageType_TAP_NOTIFY)}, body...)
|
||||
var respPayload []byte
|
||||
if poll {
|
||||
respPayload, err = m.exchangePayloadPoll(payload, "TAP_NOTIFY")
|
||||
} else {
|
||||
respPayload, err = m.exchangePayload(payload, "TAP_NOTIFY")
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var respMsg pb.UartMessage
|
||||
if err := proto.Unmarshal(respPayload[1:], &respMsg); err != nil {
|
||||
return nil, fmt.Errorf("decode: %w", err)
|
||||
}
|
||||
r := respMsg.GetTapNotifyResponse()
|
||||
if r == nil {
|
||||
return nil, fmt.Errorf("missing tap_notify_response")
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func (m *managedSerial) readCacheStatusPoll() (*pb.CacheStatusResponse, error) {
|
||||
@@ -203,48 +214,176 @@ func decodeCacheStatusPayload(payload []byte) (*pb.CacheStatusResponse, error) {
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func (m *managedSerial) accelStreamVia(
|
||||
portFn func(func(*serialPort) error) error,
|
||||
req *pb.AccelStreamRequest,
|
||||
) (*pb.AccelStreamResponse, error) {
|
||||
var resp *pb.AccelStreamResponse
|
||||
err := portFn(func(sp *serialPort) error {
|
||||
var e error
|
||||
resp, e = sp.AccelStream(req)
|
||||
return e
|
||||
})
|
||||
return resp, err
|
||||
func (m *managedSerial) accelStreamExchange(poll bool, req *pb.AccelStreamRequest) (*pb.AccelStreamResponse, error) {
|
||||
msg := &pb.UartMessage{
|
||||
Type: pb.MessageType_ACCEL_STREAM,
|
||||
Payload: &pb.UartMessage_AccelStreamRequest{AccelStreamRequest: req},
|
||||
}
|
||||
body, err := proto.Marshal(msg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode: %w", err)
|
||||
}
|
||||
payload := append([]byte{byte(pb.MessageType_ACCEL_STREAM)}, body...)
|
||||
var respPayload []byte
|
||||
if poll {
|
||||
respPayload, err = m.exchangePayloadPoll(payload, "ACCEL_STREAM")
|
||||
} else {
|
||||
respPayload, err = m.exchangePayload(payload, "ACCEL_STREAM")
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var respMsg pb.UartMessage
|
||||
if err := proto.Unmarshal(respPayload[1:], &respMsg); err != nil {
|
||||
return nil, fmt.Errorf("decode: %w", err)
|
||||
}
|
||||
r := respMsg.GetAccelStreamResponse()
|
||||
if r == nil {
|
||||
return nil, fmt.Errorf("missing accel_stream_response")
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func (m *managedSerial) AccelDeadzone(req *pb.AccelDeadzoneRequest) (*pb.AccelDeadzoneResponse, error) {
|
||||
return m.accelDeadzoneVia(m.withPort, req)
|
||||
return m.accelDeadzoneExchange(false, req)
|
||||
}
|
||||
|
||||
func (m *managedSerial) AccelDeadzonePoll(req *pb.AccelDeadzoneRequest) (*pb.AccelDeadzoneResponse, error) {
|
||||
return m.accelDeadzoneVia(m.withPortPoll, req)
|
||||
return m.accelDeadzoneExchange(true, req)
|
||||
}
|
||||
|
||||
func (m *managedSerial) accelDeadzoneVia(
|
||||
portFn func(func(*serialPort) error) error,
|
||||
req *pb.AccelDeadzoneRequest,
|
||||
) (*pb.AccelDeadzoneResponse, error) {
|
||||
var resp *pb.AccelDeadzoneResponse
|
||||
err := portFn(func(sp *serialPort) error {
|
||||
var e error
|
||||
resp, e = sp.AccelDeadzone(req)
|
||||
return e
|
||||
})
|
||||
return resp, err
|
||||
func (m *managedSerial) TapConfig(req *pb.TapConfigRequest) (*pb.TapConfigResponse, error) {
|
||||
return m.tapConfigExchange(false, req)
|
||||
}
|
||||
|
||||
func (m *managedSerial) TapConfigPoll(req *pb.TapConfigRequest) (*pb.TapConfigResponse, error) {
|
||||
return m.tapConfigExchange(true, req)
|
||||
}
|
||||
|
||||
func (m *managedSerial) tapConfigExchange(poll bool, req *pb.TapConfigRequest) (*pb.TapConfigResponse, error) {
|
||||
msg := &pb.UartMessage{
|
||||
Type: pb.MessageType_TAP_CONFIG,
|
||||
Payload: &pb.UartMessage_TapConfigRequest{TapConfigRequest: req},
|
||||
}
|
||||
body, err := proto.Marshal(msg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode: %w", err)
|
||||
}
|
||||
payload := append([]byte{byte(pb.MessageType_TAP_CONFIG)}, body...)
|
||||
var respPayload []byte
|
||||
if poll {
|
||||
respPayload, err = m.exchangePayloadPoll(payload, "TAP_CONFIG")
|
||||
} else {
|
||||
respPayload, err = m.exchangePayload(payload, "TAP_CONFIG")
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var respMsg pb.UartMessage
|
||||
if err := proto.Unmarshal(respPayload[1:], &respMsg); err != nil {
|
||||
return nil, fmt.Errorf("decode: %w", err)
|
||||
}
|
||||
r := respMsg.GetTapConfigResponse()
|
||||
if r == nil {
|
||||
return nil, fmt.Errorf("missing tap_config_response")
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func (m *managedSerial) BatteryUv(req *pb.BatteryUvRequest) (*pb.BatteryUvResponse, error) {
|
||||
return m.batteryUvExchange(false, req)
|
||||
}
|
||||
|
||||
func (m *managedSerial) BatteryUvPoll(req *pb.BatteryUvRequest) (*pb.BatteryUvResponse, error) {
|
||||
return m.batteryUvExchange(true, req)
|
||||
}
|
||||
|
||||
func (m *managedSerial) batteryUvExchange(poll bool, req *pb.BatteryUvRequest) (*pb.BatteryUvResponse, error) {
|
||||
msg := &pb.UartMessage{
|
||||
Type: pb.MessageType_BATTERY_UV,
|
||||
Payload: &pb.UartMessage_BatteryUvRequest{BatteryUvRequest: req},
|
||||
}
|
||||
body, err := proto.Marshal(msg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode: %w", err)
|
||||
}
|
||||
payload := append([]byte{byte(pb.MessageType_BATTERY_UV)}, body...)
|
||||
var respPayload []byte
|
||||
if poll {
|
||||
respPayload, err = m.exchangePayloadPoll(payload, "BATTERY_UV")
|
||||
} else {
|
||||
respPayload, err = m.exchangePayload(payload, "BATTERY_UV")
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var respMsg pb.UartMessage
|
||||
if err := proto.Unmarshal(respPayload[1:], &respMsg); err != nil {
|
||||
return nil, fmt.Errorf("decode: %w", err)
|
||||
}
|
||||
r := respMsg.GetBatteryUvResponse()
|
||||
if r == nil {
|
||||
return nil, fmt.Errorf("missing battery_uv_response")
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func (m *managedSerial) accelDeadzoneExchange(poll bool, req *pb.AccelDeadzoneRequest) (*pb.AccelDeadzoneResponse, error) {
|
||||
msg := &pb.UartMessage{
|
||||
Type: pb.MessageType_ACCEL_DEADZONE,
|
||||
Payload: &pb.UartMessage_AccelDeadzoneRequest{AccelDeadzoneRequest: req},
|
||||
}
|
||||
body, err := proto.Marshal(msg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode: %w", err)
|
||||
}
|
||||
payload := append([]byte{byte(pb.MessageType_ACCEL_DEADZONE)}, body...)
|
||||
var respPayload []byte
|
||||
if poll {
|
||||
respPayload, err = m.exchangePayloadPoll(payload, "ACCEL_DEADZONE")
|
||||
} else {
|
||||
respPayload, err = m.exchangePayload(payload, "ACCEL_DEADZONE")
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var respMsg pb.UartMessage
|
||||
if err := proto.Unmarshal(respPayload[1:], &respMsg); err != nil {
|
||||
return nil, fmt.Errorf("decode: %w", err)
|
||||
}
|
||||
r := respMsg.GetAccelDeadzoneResponse()
|
||||
if r == nil {
|
||||
return nil, fmt.Errorf("missing accel_deadzone_response")
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func (m *managedSerial) EspnowUnicastTest(clientID, seq uint32) (*pb.EspNowUnicastTestResponse, error) {
|
||||
var resp *pb.EspNowUnicastTestResponse
|
||||
err := m.withPort(func(sp *serialPort) error {
|
||||
var e error
|
||||
resp, e = sp.EspnowUnicastTest(clientID, seq)
|
||||
return e
|
||||
})
|
||||
return resp, err
|
||||
req := &pb.EspNowUnicastTestRequest{ClientId: clientID, Seq: seq}
|
||||
msg := &pb.UartMessage{
|
||||
Type: pb.MessageType_ESPNOW_UNICAST_TEST,
|
||||
Payload: &pb.UartMessage_EspnowUnicastTestRequest{
|
||||
EspnowUnicastTestRequest: req,
|
||||
},
|
||||
}
|
||||
body, err := proto.Marshal(msg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode: %w", err)
|
||||
}
|
||||
payload := append([]byte{byte(pb.MessageType_ESPNOW_UNICAST_TEST)}, body...)
|
||||
respPayload, err := m.exchangePayload(payload, "ESPNOW_UNICAST_TEST")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var respMsg pb.UartMessage
|
||||
if err := proto.Unmarshal(respPayload[1:], &respMsg); err != nil {
|
||||
return nil, fmt.Errorf("decode: %w", err)
|
||||
}
|
||||
r := respMsg.GetEspnowUnicastTestResponse()
|
||||
if r == nil {
|
||||
return nil, fmt.Errorf("missing espnow_unicast_test_response")
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func decodeVersionPayload(payload []byte) (*pb.VersionResponse, error) {
|
||||
@@ -382,6 +521,60 @@ func (s *serialPort) accelDeadzone(req *pb.AccelDeadzoneRequest) (*pb.AccelDeadz
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func (s *serialPort) tapConfig(req *pb.TapConfigRequest) (*pb.TapConfigResponse, error) {
|
||||
msg := &pb.UartMessage{
|
||||
Type: pb.MessageType_TAP_CONFIG,
|
||||
Payload: &pb.UartMessage_TapConfigRequest{
|
||||
TapConfigRequest: req,
|
||||
},
|
||||
}
|
||||
body, err := proto.Marshal(msg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode: %w", err)
|
||||
}
|
||||
payload := append([]byte{byte(pb.MessageType_TAP_CONFIG)}, body...)
|
||||
respPayload, err := s.exchangePayload(payload, "TAP_CONFIG")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var respMsg pb.UartMessage
|
||||
if err := proto.Unmarshal(respPayload[1:], &respMsg); err != nil {
|
||||
return nil, fmt.Errorf("decode: %w", err)
|
||||
}
|
||||
r := respMsg.GetTapConfigResponse()
|
||||
if r == nil {
|
||||
return nil, fmt.Errorf("missing tap_config_response")
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func (s *serialPort) batteryUv(req *pb.BatteryUvRequest) (*pb.BatteryUvResponse, error) {
|
||||
msg := &pb.UartMessage{
|
||||
Type: pb.MessageType_BATTERY_UV,
|
||||
Payload: &pb.UartMessage_BatteryUvRequest{
|
||||
BatteryUvRequest: req,
|
||||
},
|
||||
}
|
||||
body, err := proto.Marshal(msg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode: %w", err)
|
||||
}
|
||||
payload := append([]byte{byte(pb.MessageType_BATTERY_UV)}, body...)
|
||||
respPayload, err := s.exchangePayload(payload, "BATTERY_UV")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var respMsg pb.UartMessage
|
||||
if err := proto.Unmarshal(respPayload[1:], &respMsg); err != nil {
|
||||
return nil, fmt.Errorf("decode: %w", err)
|
||||
}
|
||||
r := respMsg.GetBatteryUvResponse()
|
||||
if r == nil {
|
||||
return nil, fmt.Errorf("missing battery_uv_response")
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func (s *serialPort) espnowUnicastTest(clientID, seq uint32) (*pb.EspNowUnicastTestResponse, error) {
|
||||
req := &pb.EspNowUnicastTestRequest{ClientId: clientID, Seq: seq}
|
||||
msg := &pb.UartMessage{
|
||||
@@ -410,17 +603,80 @@ func (s *serialPort) espnowUnicastTest(clientID, seq uint32) (*pb.EspNowUnicastT
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// EchoPingResult is the host-side round-trip for ESP-NOW echo ping.
|
||||
type EchoPingResult struct {
|
||||
Success bool `json:"success"`
|
||||
ClientID uint32 `json:"client_id"`
|
||||
TimestampUs uint64 `json:"timestamp_us"`
|
||||
RttMs float64 `json:"rtt_ms"` // goTool: full UART round-trip
|
||||
EspRttUs uint32 `json:"esp_rtt_us"` // master: µs delta ping send → pong recv
|
||||
}
|
||||
|
||||
func (s *serialPort) echoPing(clientID uint32) (*EchoPingResult, error) {
|
||||
t0 := time.Now()
|
||||
timestampUs := uint64(t0.UnixMicro())
|
||||
req := &pb.EspNowEchoPingRequest{
|
||||
ClientId: clientID,
|
||||
TimestampUs: timestampUs,
|
||||
}
|
||||
msg := &pb.UartMessage{
|
||||
Type: pb.MessageType_ESPNOW_ECHO_PING,
|
||||
Payload: &pb.UartMessage_EspnowEchoPingRequest{
|
||||
EspnowEchoPingRequest: req,
|
||||
},
|
||||
}
|
||||
body, err := proto.Marshal(msg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode: %w", err)
|
||||
}
|
||||
payload := append([]byte{byte(pb.MessageType_ESPNOW_ECHO_PING)}, body...)
|
||||
respPayload, err := s.exchangePayload(payload, "ESPNOW_ECHO_PING")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rttMs := float64(time.Since(t0).Microseconds()) / 1000.0
|
||||
|
||||
var respMsg pb.UartMessage
|
||||
if err := proto.Unmarshal(respPayload[1:], &respMsg); err != nil {
|
||||
return nil, fmt.Errorf("decode: %w", err)
|
||||
}
|
||||
r := respMsg.GetEspnowEchoPingResponse()
|
||||
if r == nil {
|
||||
return nil, fmt.Errorf("missing espnow_echo_ping_response")
|
||||
}
|
||||
if !r.GetSuccess() {
|
||||
return &EchoPingResult{
|
||||
Success: false,
|
||||
ClientID: r.GetClientId(),
|
||||
RttMs: rttMs,
|
||||
}, nil
|
||||
}
|
||||
if r.GetTimestampUs() != timestampUs {
|
||||
return nil, fmt.Errorf("timestamp mismatch: sent %d got %d", timestampUs, r.GetTimestampUs())
|
||||
}
|
||||
return &EchoPingResult{
|
||||
Success: true,
|
||||
ClientID: r.GetClientId(),
|
||||
TimestampUs: r.GetTimestampUs(),
|
||||
RttMs: rttMs,
|
||||
EspRttUs: r.GetEspRttUs(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *managedSerial) FindMe(clientID uint32) error {
|
||||
return m.withPort(func(sp *serialPort) error {
|
||||
return runFindMeClient(sp, clientID)
|
||||
})
|
||||
return hostFindMe(m, clientID)
|
||||
}
|
||||
|
||||
func (m *managedSerial) SetLogLevel(write bool, level uint32) (*pb.SetLogLevelResponse, error) {
|
||||
return hostSetLogLevel(m, write, level)
|
||||
}
|
||||
|
||||
func (m *managedSerial) InputSendMode(req *pb.InputSendModeRequest) (*pb.InputSendModeResponse, error) {
|
||||
return hostInputSendMode(m, req)
|
||||
}
|
||||
|
||||
func (m *managedSerial) Restart(clientID uint32) error {
|
||||
err := m.withPort(func(sp *serialPort) error {
|
||||
return runRestartClient(sp, clientID)
|
||||
})
|
||||
if err != nil {
|
||||
if err := hostRestart(m, clientID); err != nil {
|
||||
return err
|
||||
}
|
||||
if clientID == 0 {
|
||||
@@ -486,22 +742,32 @@ func (s *serialPort) AccelDeadzone(req *pb.AccelDeadzoneRequest) (*pb.AccelDeadz
|
||||
return s.accelDeadzone(req)
|
||||
}
|
||||
|
||||
func (s *serialPort) TapConfig(req *pb.TapConfigRequest) (*pb.TapConfigResponse, error) {
|
||||
return s.tapConfig(req)
|
||||
}
|
||||
|
||||
func (s *serialPort) BatteryUv(req *pb.BatteryUvRequest) (*pb.BatteryUvResponse, error) {
|
||||
return s.batteryUv(req)
|
||||
}
|
||||
|
||||
func (s *serialPort) EspnowUnicastTest(clientID, seq uint32) (*pb.EspNowUnicastTestResponse, error) {
|
||||
return s.espnowUnicastTest(clientID, seq)
|
||||
}
|
||||
|
||||
func (s *serialPort) EchoPing(clientID uint32) (*EchoPingResult, error) {
|
||||
return s.echoPing(clientID)
|
||||
}
|
||||
|
||||
func (m *managedSerial) EchoPing(clientID uint32) (*EchoPingResult, error) {
|
||||
return hostEchoPing(m, clientID)
|
||||
}
|
||||
|
||||
func (s *serialPort) LedRing(req *pb.LedRingProgressRequest) (*pb.LedRingProgressResponse, error) {
|
||||
return s.ledRingProgress(req)
|
||||
}
|
||||
|
||||
func (m *managedSerial) LedRing(req *pb.LedRingProgressRequest) (*pb.LedRingProgressResponse, error) {
|
||||
var resp *pb.LedRingProgressResponse
|
||||
err := m.withPort(func(sp *serialPort) error {
|
||||
var e error
|
||||
resp, e = sp.LedRing(req)
|
||||
return e
|
||||
})
|
||||
return resp, err
|
||||
return hostLedRing(m, req)
|
||||
}
|
||||
|
||||
func (s *serialPort) FindMe(clientID uint32) (*pb.EspNowFindMeResponse, error) {
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
|
||||
pb "powerpod/gotool/pb"
|
||||
)
|
||||
|
||||
func runAccelStreamCmd(h hostConn, args []string) error {
|
||||
fs := flag.NewFlagSet("accel-stream", flag.ExitOnError)
|
||||
clientID := fs.Uint("client", 0, "slave client id (0 with -all)")
|
||||
all := fs.Bool("all", false, "all registered clients")
|
||||
enable := fs.Bool("enable", true, "enable stream")
|
||||
disable := fs.Bool("disable", false, "disable stream")
|
||||
period := fs.Uint("period", 0, "BLE push period_ms 10–16 (0=leave/default)")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
on := *enable
|
||||
if *disable {
|
||||
on = false
|
||||
}
|
||||
if !*all && *clientID == 0 {
|
||||
return fmt.Errorf("need -client ID or -all")
|
||||
}
|
||||
req := &pb.AccelStreamRequest{
|
||||
Write: true,
|
||||
Enable: on,
|
||||
PeriodMs: uint32(*period),
|
||||
}
|
||||
if *all {
|
||||
req.AllClients = true
|
||||
} else {
|
||||
req.ClientId = uint32(*clientID)
|
||||
}
|
||||
r, err := hostAccelStream(h, req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("accel-stream: success=%v enabled=%v client_id=%d slaves_updated=%d\n",
|
||||
r.GetSuccess(), r.GetEnabled(), r.GetClientId(), r.GetSlavesUpdated())
|
||||
return nil
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
func runBleRtt(h hostConn, args []string) error {
|
||||
fs := flag.NewFlagSet("ble-rtt", flag.ExitOnError)
|
||||
count := fs.Int("count", 50, "number of measured VERSION round-trips")
|
||||
settle := fs.Float64("settle", 0, "extra settle seconds before measurement (0 = already settled on connect)")
|
||||
warmup := fs.Int("warmup", 5, "discarded warm-up round-trips")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
out := measureBleRtt(h, *count, *warmup, time.Duration(*settle*float64(time.Second)))
|
||||
if !out.Success {
|
||||
return fmt.Errorf("%s", out.Error)
|
||||
}
|
||||
fmt.Printf("ble-rtt (VERSION): n=%d warmup=%d\n", out.Stats.N, out.Warmup)
|
||||
fmt.Printf(" min=%.3f median=%.3f mean=%.3f max=%.3f ms\n",
|
||||
out.Stats.MinMs, out.Stats.MedMs, out.Stats.MeanMs, out.Stats.MaxMs)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
func runBleStream(h hostConn, args []string) error {
|
||||
fs := flag.NewFlagSet("ble-stream", flag.ExitOnError)
|
||||
clientID := fs.Uint("client", 0, "slave client id (0 = all_clients)")
|
||||
periodMs := fs.Uint("period", 10, "BLE push period_ms (10–16)")
|
||||
count := fs.Int("count", 50, "number of BleStreamTick samples")
|
||||
tolMs := fs.Float64("tol", 2.0, "tolerance ms around period for window_ok")
|
||||
duration := fs.Float64("timeout", 15.0, "max seconds waiting for ticks")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("accel stream on; period_ms=%d; collecting %d ticks…\n", *periodMs, *count)
|
||||
out := measureBleStream(h, uint32(*clientID), uint32(*periodMs), *count, *tolMs, *duration)
|
||||
if !out.Success {
|
||||
return fmt.Errorf("%s", out.Error)
|
||||
}
|
||||
fmt.Printf("received=%d/%d period_ms=%d tol=±%.1f ms\n", out.Received, out.Requested, out.PeriodMs, out.TolMs)
|
||||
printStat("dT ESP inter-depart", out.DTEsp)
|
||||
printStat("dR PC inter-arrive", out.DRPc)
|
||||
printStat("extra vs first", out.ExtraVsFirst)
|
||||
fmt.Printf("outside_tol=%d/%d window_ok=%s\n", out.OutsideTol, out.DTEsp.N,
|
||||
map[bool]string{true: "yes", false: "no"}[out.WindowOK])
|
||||
return nil
|
||||
}
|
||||
|
||||
func printStat(name string, s latencyStats) {
|
||||
if s.N == 0 {
|
||||
fmt.Printf("%s: (none)\n", name)
|
||||
return
|
||||
}
|
||||
fmt.Printf("%s: n=%d min=%.3f median=%.3f mean=%.3f max=%.3f ms\n",
|
||||
name, s.N, s.MinMs, s.MedMs, s.MeanMs, s.MaxMs)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
func runEchoPing(sp *serialPort, args []string) error {
|
||||
fs := flag.NewFlagSet("echo-ping", flag.ExitOnError)
|
||||
clientID := fs.Uint("client", 0, "slave client id from `clients`")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
if *clientID == 0 {
|
||||
return fmt.Errorf("client id required (see `gotool clients`)")
|
||||
}
|
||||
|
||||
r, err := sp.echoPing(uint32(*clientID))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("echo ping: success=%v client_id=%d rtt_ms=%.3f esp_rtt_us=%d\n",
|
||||
r.Success, r.ClientID, r.RttMs, r.EspRttUs)
|
||||
return nil
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
|
||||
func runLedRing(sp *serialPort, args []string) error {
|
||||
fs := flag.NewFlagSet("led-ring", flag.ExitOnError)
|
||||
mode := fs.String("mode", "progress", "clear, color, progress, digit, blink, or find-me")
|
||||
mode := fs.String("mode", "progress", "clear, color, progress, digit, blink, find-me, or battery-low")
|
||||
clientID := fs.Uint("client", 0, "0=master ring, >0=slave via ESP-NOW")
|
||||
allClients := fs.Bool("all", false, "broadcast to all slaves")
|
||||
slavesOnly := fs.Bool("slaves-only", false, "with -all: do not change master ring")
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
|
||||
"google.golang.org/protobuf/proto"
|
||||
"powerpod/gotool/pb"
|
||||
)
|
||||
|
||||
func runLogLevel(sp *serialPort, args []string) error {
|
||||
fs := flag.NewFlagSet("log-level", flag.ExitOnError)
|
||||
write := fs.Bool("set", false, "write log level (default: read)")
|
||||
level := fs.Uint("level", 0, "esp_log_level_t 0–5 (with -set)")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := sp.setLogLevel(*write, uint32(*level))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !resp.GetSuccess() {
|
||||
return fmt.Errorf("set_log_level rejected (level=%d)", resp.GetLevel())
|
||||
}
|
||||
fmt.Printf("log_level=%d success=%v\n", resp.GetLevel(), resp.GetSuccess())
|
||||
return nil
|
||||
}
|
||||
|
||||
func runSetLogLevelClient(sp *serialPort, write bool, level uint32) (*pb.SetLogLevelResponse, error) {
|
||||
return sp.setLogLevel(write, level)
|
||||
}
|
||||
|
||||
func (s *serialPort) setLogLevel(write bool, level uint32) (*pb.SetLogLevelResponse, error) {
|
||||
msg := &pb.UartMessage{
|
||||
Type: pb.MessageType_SET_LOG_LEVEL,
|
||||
Payload: &pb.UartMessage_SetLogLevelRequest{
|
||||
SetLogLevelRequest: &pb.SetLogLevelRequest{
|
||||
Write: write,
|
||||
Level: level,
|
||||
},
|
||||
},
|
||||
}
|
||||
body, err := proto.Marshal(msg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode: %w", err)
|
||||
}
|
||||
payload := append([]byte{byte(pb.MessageType_SET_LOG_LEVEL)}, body...)
|
||||
respPayload, err := s.exchangePayload(payload, "SET_LOG_LEVEL")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var respMsg pb.UartMessage
|
||||
if err := proto.Unmarshal(respPayload[1:], &respMsg); err != nil {
|
||||
return nil, fmt.Errorf("decode: %w", err)
|
||||
}
|
||||
r := respMsg.GetSetLogLevelResponse()
|
||||
if r == nil {
|
||||
return nil, fmt.Errorf("missing set_log_level_response")
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
+12
-4
@@ -5,7 +5,7 @@ import (
|
||||
"os"
|
||||
)
|
||||
|
||||
func runOTA(sp *serialPort, args []string) error {
|
||||
func runOTA(t otaTransport, args []string) error {
|
||||
if len(args) < 1 {
|
||||
return fmt.Errorf("usage: ota <firmware.bin>")
|
||||
}
|
||||
@@ -14,9 +14,17 @@ func runOTA(sp *serialPort, args []string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
sp.mu.Lock()
|
||||
defer sp.mu.Unlock()
|
||||
return runOTAOnPortUnlocked(sp, data, func(p OTAProgress) {
|
||||
switch x := t.(type) {
|
||||
case *serialPort:
|
||||
x.mu.Lock()
|
||||
defer x.mu.Unlock()
|
||||
case *bleLink:
|
||||
if err := x.ensureOTAMTU(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return runOTAUnlocked(t, data, func(p OTAProgress) {
|
||||
switch p.Phase {
|
||||
case "preparing", "ready":
|
||||
fmt.Println(p.Message)
|
||||
|
||||
@@ -5,14 +5,19 @@ import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
func runOtaProgress(sp *serialPort, args []string) error {
|
||||
func runOtaProgress(t otaTransport, args []string) error {
|
||||
fs := flag.NewFlagSet("ota-progress", flag.ExitOnError)
|
||||
clientID := fs.Uint("client", 0, "slave client id (0 = all in session)")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
r, err := QueryOtaSlaveProgress(sp, uint32(*clientID))
|
||||
if sp, ok := t.(*serialPort); ok {
|
||||
sp.mu.Lock()
|
||||
defer sp.mu.Unlock()
|
||||
}
|
||||
|
||||
r, err := QueryOtaSlaveProgress(t, uint32(*clientID))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
+40
-10
@@ -20,21 +20,48 @@ var wsUpgrader = websocket.Upgrader{
|
||||
CheckOrigin: func(r *http.Request) bool { return true },
|
||||
}
|
||||
|
||||
func runServe(portName string, baud int, args []string) error {
|
||||
type serveOpts struct {
|
||||
portName string
|
||||
baud int
|
||||
useBLE bool
|
||||
bleName string
|
||||
settle time.Duration
|
||||
}
|
||||
|
||||
func runServe(opts serveOpts, args []string) error {
|
||||
serveFlags := flag.NewFlagSet("serve", flag.ExitOnError)
|
||||
addr := serveFlags.String("addr", ":8080", "dashboard HTTP listen address")
|
||||
apiAddr := serveFlags.String("api-addr", ":8081", "external API HTTP listen address (empty to disable)")
|
||||
accelInterval := serveFlags.Duration("accel-interval", defaultAccelStreamInterval, "accel WebSocket sample period on API server")
|
||||
interval := serveFlags.Duration("interval", 2*time.Second, "UART poll interval")
|
||||
interval := serveFlags.Duration("interval", 2*time.Second, "host poll interval")
|
||||
if err := serveFlags.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
if portName == "" {
|
||||
return fmt.Errorf("serve requires -port (master UART)")
|
||||
if opts.useBLE && opts.portName != "" {
|
||||
return fmt.Errorf("use either -port or -ble, not both")
|
||||
}
|
||||
|
||||
link := newManagedSerial(portName, baud)
|
||||
link := newManagedHost(opts.baud)
|
||||
link.quiet = true
|
||||
link.settle = opts.settle
|
||||
if opts.settle <= 0 {
|
||||
link.settle = time.Second
|
||||
}
|
||||
|
||||
switch {
|
||||
case opts.portName != "":
|
||||
if err := link.ConnectUART(opts.portName, opts.baud); err != nil {
|
||||
log.Printf("initial UART connect failed (UI can retry): %v", err)
|
||||
}
|
||||
case opts.useBLE:
|
||||
name := opts.bleName
|
||||
if name == "" {
|
||||
name = bleDefaultName
|
||||
}
|
||||
if err := link.ConnectBLE("", name); err != nil {
|
||||
log.Printf("initial BLE connect failed (UI can scan/retry): %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
hub := newWSHub()
|
||||
streamCtl := newAccelStreamCtl()
|
||||
@@ -48,16 +75,17 @@ func runServe(portName string, baud int, args []string) error {
|
||||
shutdownHTTPServer(dashSrv)
|
||||
shutdownAPIServer(apiSrv)
|
||||
if err := link.Close(); err != nil {
|
||||
log.Printf("UART close: %v", err)
|
||||
log.Printf("host close: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
go runPoller(link, portName, hub, streamCtl, tapCtl, *interval, stop)
|
||||
go runPoller(link, link.Endpoint(), hub, streamCtl, tapCtl, *interval, stop)
|
||||
go runBatteryPoller(link, hub, 5*time.Second, stop)
|
||||
go runCacheStatusDashboardPoller(link, hub, *accelInterval, stop)
|
||||
go runBLEStreamDashboardPoller(link, hub, streamCtl, stop)
|
||||
|
||||
if *apiAddr != "" {
|
||||
apiSrv = runAPIServer(portName, link, *apiAddr, *accelInterval, hub, streamCtl, tapCtl, stop)
|
||||
apiSrv = runAPIServer(link.Endpoint(), link, *apiAddr, *accelInterval, hub, streamCtl, tapCtl, stop)
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
@@ -85,10 +113,12 @@ func runServe(portName string, baud int, args []string) error {
|
||||
}
|
||||
mux.Handle("/", http.FileServer(http.FS(ui)))
|
||||
|
||||
log.Printf("dashboard http://localhost%s (UART %s @ %d baud, poll %s, live-stream %s, auto-reconnect)",
|
||||
*addr, portName, baud, interval.String(), accelInterval.String())
|
||||
log.Printf("dashboard http://localhost%s (transport=%s endpoint=%q poll %s, live-stream %s)",
|
||||
*addr, link.Transport(), link.Endpoint(), interval.String(), accelInterval.String())
|
||||
if *apiAddr == "" {
|
||||
log.Printf("external API disabled (-api-addr \"\")")
|
||||
} else {
|
||||
log.Printf("external API http://localhost%s ws://localhost%s/ws", *apiAddr, *apiAddr)
|
||||
}
|
||||
|
||||
dashSrv = &http.Server{Addr: *addr, Handler: mux}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
|
||||
"powerpod/gotool/pb"
|
||||
)
|
||||
|
||||
func runTapConfig(sp *serialPort, args []string) error {
|
||||
fs := flag.NewFlagSet("tap-config", flag.ExitOnError)
|
||||
write := fs.Bool("set", false, "write tap config (default: read)")
|
||||
clientID := fs.Uint("client", 0, "client id (0=local master BMA456)")
|
||||
all := fs.Bool("all", false, "apply to all registered slaves (master only)")
|
||||
sens := fs.Uint("sens", 5, "tap_sens_thres 0=most … 15=least")
|
||||
maxGest := fs.Uint("max-gest", 100, "max_gest_dur (×5 ms)")
|
||||
shock := fs.Uint("shock", 6, "tap_shock_dur (×5 ms)")
|
||||
quiet := fs.Uint("quiet", 60, "quite_time_after_gest (×5 ms)")
|
||||
wait := fs.Uint("wait", 0, "wait_for_timeout 0|1")
|
||||
axis := fs.Uint("axis", 2, "axis_sel 0=X 1=Y 2=Z")
|
||||
single := fs.Bool("single", true, "enable single tap")
|
||||
doubleTap := fs.Bool("double", true, "enable double tap")
|
||||
triple := fs.Bool("triple", true, "enable triple tap")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
r, err := sp.tapConfig(&pb.TapConfigRequest{
|
||||
Write: *write,
|
||||
ClientId: uint32(*clientID),
|
||||
AllClients: *all,
|
||||
TapSensThres: uint32(*sens),
|
||||
MaxGestDur: uint32(*maxGest),
|
||||
TapShockDur: uint32(*shock),
|
||||
QuiteTimeAfterGest: uint32(*quiet),
|
||||
WaitForTimeout: uint32(*wait),
|
||||
AxisSel: uint32(*axis),
|
||||
EnableSingle: *single,
|
||||
EnableDouble: *doubleTap,
|
||||
EnableTriple: *triple,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("client_id=%d success=%v slaves_updated=%d\n",
|
||||
r.GetClientId(), r.GetSuccess(), r.GetSlavesUpdated())
|
||||
fmt.Printf(" sens=%d max_gest=%d shock=%d quiet=%d wait=%d axis=%d\n",
|
||||
r.GetTapSensThres(), r.GetMaxGestDur(), r.GetTapShockDur(),
|
||||
r.GetQuiteTimeAfterGest(), r.GetWaitForTimeout(), r.GetAxisSel())
|
||||
fmt.Printf(" enable single=%v double=%v triple=%v\n",
|
||||
r.GetEnableSingle(), r.GetEnableDouble(), r.GetEnableTriple())
|
||||
return nil
|
||||
}
|
||||
+301
-50
@@ -15,35 +15,52 @@ import (
|
||||
)
|
||||
|
||||
type MasterView struct {
|
||||
Version uint32 `json:"version"`
|
||||
GitHash string `json:"git_hash"`
|
||||
RunningPartition string `json:"running_partition,omitempty"`
|
||||
Deadzone uint32 `json:"deadzone,omitempty"`
|
||||
Lipo1 lipoReadingJSON `json:"lipo1"`
|
||||
Lipo2 lipoReadingJSON `json:"lipo2"`
|
||||
BatteryAgeMs uint32 `json:"battery_age_ms,omitempty"`
|
||||
OK bool `json:"ok"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Version uint32 `json:"version"`
|
||||
GitHash string `json:"git_hash"`
|
||||
RunningPartition string `json:"running_partition,omitempty"`
|
||||
Deadzone uint32 `json:"deadzone,omitempty"`
|
||||
TapConfig *TapConfigView `json:"tap_config,omitempty"`
|
||||
UvProtection *bool `json:"uv_protection,omitempty"`
|
||||
UvLatched bool `json:"uv_latched,omitempty"`
|
||||
Lipo1 lipoReadingJSON `json:"lipo1"`
|
||||
Lipo2 lipoReadingJSON `json:"lipo2"`
|
||||
BatteryAgeMs uint32 `json:"battery_age_ms,omitempty"`
|
||||
OK bool `json:"ok"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type TapConfigView struct {
|
||||
TapSensThres uint32 `json:"tap_sens_thres"`
|
||||
MaxGestDur uint32 `json:"max_gest_dur"`
|
||||
TapShockDur uint32 `json:"tap_shock_dur"`
|
||||
QuiteTimeAfterGest uint32 `json:"quite_time_after_gest"`
|
||||
WaitForTimeout uint32 `json:"wait_for_timeout"`
|
||||
AxisSel uint32 `json:"axis_sel"`
|
||||
EnableSingle bool `json:"enable_single"`
|
||||
EnableDouble bool `json:"enable_double"`
|
||||
EnableTriple bool `json:"enable_triple"`
|
||||
}
|
||||
|
||||
type ClientView struct {
|
||||
ID uint32 `json:"id"`
|
||||
MAC string `json:"mac"`
|
||||
Version uint32 `json:"version"`
|
||||
Deadzone uint32 `json:"deadzone,omitempty"`
|
||||
Available bool `json:"available"`
|
||||
Used bool `json:"used"`
|
||||
LastPing uint32 `json:"last_ping"`
|
||||
LastSuccessPing uint32 `json:"last_success_ping"`
|
||||
AccelValid bool `json:"accel_valid"`
|
||||
AccelX int32 `json:"accel_x"`
|
||||
AccelY int32 `json:"accel_y"`
|
||||
AccelZ int32 `json:"accel_z"`
|
||||
AccelAgeMs uint32 `json:"accel_age_ms"`
|
||||
ID uint32 `json:"id"`
|
||||
MAC string `json:"mac"`
|
||||
Version uint32 `json:"version"`
|
||||
Deadzone uint32 `json:"deadzone,omitempty"`
|
||||
TapConfig *TapConfigView `json:"tap_config,omitempty"`
|
||||
Available bool `json:"available"`
|
||||
Used bool `json:"used"`
|
||||
LastPing uint32 `json:"last_ping"`
|
||||
LastSuccessPing uint32 `json:"last_success_ping"`
|
||||
AccelValid bool `json:"accel_valid"`
|
||||
AccelX int32 `json:"accel_x"`
|
||||
AccelY int32 `json:"accel_y"`
|
||||
AccelZ int32 `json:"accel_z"`
|
||||
AccelAgeMs uint32 `json:"accel_age_ms"`
|
||||
AccelStream bool `json:"accel_stream"`
|
||||
TapNotifySingle bool `json:"tap_notify_single"`
|
||||
TapNotifyDouble bool `json:"tap_notify_double"`
|
||||
TapNotifyTriple bool `json:"tap_notify_triple"`
|
||||
UvProtection bool `json:"uv_protection"`
|
||||
LastTap string `json:"last_tap,omitempty"`
|
||||
LastTapAt int64 `json:"last_tap_at,omitempty"`
|
||||
Lipo1 lipoReadingJSON `json:"lipo1"`
|
||||
@@ -52,22 +69,91 @@ type ClientView struct {
|
||||
}
|
||||
|
||||
type DashboardState struct {
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
SerialPort string `json:"serial_port"`
|
||||
UARTConnected bool `json:"uart_connected"`
|
||||
SerialOK bool `json:"serial_ok"`
|
||||
SerialError string `json:"serial_error,omitempty"`
|
||||
/** Host: fast CACHE_STATUS poll (~16 ms) for accel + tap. */
|
||||
LiveStream bool `json:"live_stream"`
|
||||
Master MasterView `json:"master"`
|
||||
Clients []ClientView `json:"clients"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
SerialPort string `json:"serial_port"`
|
||||
/** Alias of host_endpoint for older UI. */
|
||||
HostEndpoint string `json:"host_endpoint"`
|
||||
HostTransport string `json:"host_transport"`
|
||||
HostConnected bool `json:"host_connected"`
|
||||
UARTConnected bool `json:"uart_connected"`
|
||||
SerialOK bool `json:"serial_ok"`
|
||||
SerialError string `json:"serial_error,omitempty"`
|
||||
/** Host: fast CACHE_STATUS poll (~8 ms) for accel + tap. */
|
||||
LiveStream bool `json:"live_stream"`
|
||||
/** BLE live path: inter-tick stats from BleStreamTick.esp_timestamp_us. */
|
||||
BleTick *BleTickView `json:"ble_tick,omitempty"`
|
||||
Master MasterView `json:"master"`
|
||||
Clients []ClientView `json:"clients"`
|
||||
}
|
||||
|
||||
// BleTickView is median/mean inter-departure of BLE_STREAM_TICK (ESP timer).
|
||||
type BleTickView struct {
|
||||
PeriodMs uint32 `json:"period_ms"`
|
||||
MedianMs float64 `json:"median_ms"`
|
||||
MeanMs float64 `json:"mean_ms"`
|
||||
MinMs float64 `json:"min_ms"`
|
||||
MaxMs float64 `json:"max_ms"`
|
||||
Samples int `json:"samples"`
|
||||
LastEspUs uint64 `json:"last_esp_us,omitempty"`
|
||||
}
|
||||
|
||||
const bleTickDeltaCap = 64
|
||||
|
||||
type bleTickTracker struct {
|
||||
lastEspUs uint64
|
||||
haveLast bool
|
||||
periodMs uint32
|
||||
deltas [bleTickDeltaCap]float64
|
||||
next int
|
||||
count int
|
||||
}
|
||||
|
||||
func (t *bleTickTracker) reset() {
|
||||
*t = bleTickTracker{}
|
||||
}
|
||||
|
||||
func (t *bleTickTracker) add(espUs uint64, periodMs uint32) BleTickView {
|
||||
if periodMs != 0 {
|
||||
t.periodMs = periodMs
|
||||
}
|
||||
if t.haveLast && espUs > t.lastEspUs {
|
||||
dt := float64(espUs-t.lastEspUs) / 1000.0
|
||||
t.deltas[t.next] = dt
|
||||
t.next = (t.next + 1) % bleTickDeltaCap
|
||||
if t.count < bleTickDeltaCap {
|
||||
t.count++
|
||||
}
|
||||
}
|
||||
t.lastEspUs = espUs
|
||||
t.haveLast = true
|
||||
|
||||
samples := make([]float64, 0, t.count)
|
||||
if t.count < bleTickDeltaCap {
|
||||
samples = append(samples, t.deltas[:t.count]...)
|
||||
} else {
|
||||
samples = append(samples, t.deltas[t.next:]...)
|
||||
samples = append(samples, t.deltas[:t.next]...)
|
||||
}
|
||||
st := makeLatencyStats(samples)
|
||||
return BleTickView{
|
||||
PeriodMs: t.periodMs,
|
||||
MedianMs: st.MedMs,
|
||||
MeanMs: st.MeanMs,
|
||||
MinMs: st.MinMs,
|
||||
MaxMs: st.MaxMs,
|
||||
Samples: st.N,
|
||||
LastEspUs: espUs,
|
||||
}
|
||||
}
|
||||
|
||||
type wsHub struct {
|
||||
mu sync.RWMutex
|
||||
writeMu sync.Mutex // gorilla websocket: one writer at a time
|
||||
clients map[*websocket.Conn]struct{}
|
||||
state DashboardState
|
||||
liveStream bool
|
||||
bleTicks bleTickTracker
|
||||
lastPush time.Time // throttle BLE tick WS pushes
|
||||
}
|
||||
|
||||
func newWSHub() *wsHub {
|
||||
@@ -78,6 +164,10 @@ func (h *wsHub) setState(st DashboardState) {
|
||||
h.mu.Lock()
|
||||
prev := h.state
|
||||
st.LiveStream = prev.LiveStream
|
||||
if st.BleTick == nil && prev.BleTick != nil && st.LiveStream {
|
||||
cp := *prev.BleTick
|
||||
st.BleTick = &cp
|
||||
}
|
||||
st.Clients = preserveClientAccel(st.Clients, prev.Clients, st.LiveStream)
|
||||
st.Clients = preserveClientBattery(st.Clients, prev.Clients)
|
||||
st.Clients = preserveClientTap(st.Clients, prev.Clients)
|
||||
@@ -122,15 +212,25 @@ func (h *wsHub) unregister(c *websocket.Conn) {
|
||||
h.mu.Unlock()
|
||||
}
|
||||
|
||||
// writeJSON sends to one client; removes it on error or panic (closed connection).
|
||||
// dropClient removes the client and closes the socket so the browser reconnects.
|
||||
func (h *wsHub) dropClient(c *websocket.Conn) {
|
||||
h.unregister(c)
|
||||
_ = c.Close()
|
||||
}
|
||||
|
||||
// writeJSON sends to one client; removes+closes it on error or panic.
|
||||
func (h *wsHub) writeJSON(c *websocket.Conn, data []byte) {
|
||||
defer func() {
|
||||
if recover() != nil {
|
||||
h.unregister(c)
|
||||
h.dropClient(c)
|
||||
}
|
||||
}()
|
||||
if err := c.WriteMessage(websocket.TextMessage, data); err != nil {
|
||||
h.unregister(c)
|
||||
h.writeMu.Lock()
|
||||
_ = c.SetWriteDeadline(time.Now().Add(2 * time.Second))
|
||||
err := c.WriteMessage(websocket.TextMessage, data)
|
||||
h.writeMu.Unlock()
|
||||
if err != nil {
|
||||
h.dropClient(c)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -392,13 +492,15 @@ func (h *wsHub) snapshotClients() []ClientView {
|
||||
return out
|
||||
}
|
||||
|
||||
// patchLiveStream toggles host CACHE_STATUS polling (~16 ms).
|
||||
// patchLiveStream toggles host CACHE_STATUS polling (~8 ms).
|
||||
func (h *wsHub) patchLiveStream(enabled bool) {
|
||||
h.mu.Lock()
|
||||
h.liveStream = enabled
|
||||
st := h.state
|
||||
st.LiveStream = enabled
|
||||
if !enabled {
|
||||
h.bleTicks.reset()
|
||||
st.BleTick = nil
|
||||
for i := range st.Clients {
|
||||
st.Clients[i].AccelValid = false
|
||||
st.Clients[i].AccelX = 0
|
||||
@@ -483,6 +585,49 @@ func (h *wsHub) mergeAccel(samples []*pb.AccelSample) {
|
||||
}
|
||||
}
|
||||
|
||||
// mergeBleStreamTick applies accel/tap from a BLE tick and updates inter-tick stats.
|
||||
// State is updated every tick; WebSocket push is throttled (~50 Hz) so concurrent
|
||||
// writers (poller/battery) cannot flood or race the gorilla conn.
|
||||
func (h *wsHub) mergeBleStreamTick(tick *pb.BleStreamTick) {
|
||||
if tick == nil || !h.liveStreamEnabled() {
|
||||
return
|
||||
}
|
||||
cache := &pb.CacheStatusResponse{Clients: tick.GetClients()}
|
||||
const minPushGap = 20 * time.Millisecond
|
||||
|
||||
h.mu.Lock()
|
||||
view := h.bleTicks.add(tick.GetEspTimestampUs(), tick.GetPeriodMs())
|
||||
st := h.state
|
||||
st.Clients = applyAccelSamples(st.Clients, accelSamplesFromCacheStatus(cache))
|
||||
st.Clients = applyTapEvents(st.Clients, tapEventsFromCacheStatus(cache))
|
||||
st.BleTick = &view
|
||||
st.UpdatedAt = time.Now().Format(time.RFC3339)
|
||||
h.state = st
|
||||
push := time.Since(h.lastPush) >= minPushGap
|
||||
if push {
|
||||
h.lastPush = time.Now()
|
||||
}
|
||||
var conns []*websocket.Conn
|
||||
if push {
|
||||
conns = make([]*websocket.Conn, 0, len(h.clients))
|
||||
for c := range h.clients {
|
||||
conns = append(conns, c)
|
||||
}
|
||||
}
|
||||
h.mu.Unlock()
|
||||
|
||||
if !push {
|
||||
return
|
||||
}
|
||||
data, err := json.Marshal(st)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, c := range conns {
|
||||
h.writeJSON(c, data)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *wsHub) mergeTap(events []*pb.TapEvent) {
|
||||
if len(events) == 0 || !h.liveStreamEnabled() {
|
||||
return
|
||||
@@ -516,20 +661,35 @@ func (h *wsHub) broadcastRaw(v any) {
|
||||
}
|
||||
|
||||
func pollDashboard(link *managedSerial, portName string, last *DashboardState, streamCtl *accelStreamCtl, tapCtl *tapNotifyCtl) DashboardState {
|
||||
ep := link.Endpoint()
|
||||
if ep == "" {
|
||||
ep = portName
|
||||
}
|
||||
tr := link.Transport()
|
||||
st := DashboardState{
|
||||
UpdatedAt: time.Now().Format(time.RFC3339),
|
||||
SerialPort: portName,
|
||||
Clients: []ClientView{},
|
||||
UpdatedAt: time.Now().Format(time.RFC3339),
|
||||
SerialPort: ep,
|
||||
HostEndpoint: ep,
|
||||
HostTransport: tr,
|
||||
Clients: []ClientView{},
|
||||
}
|
||||
if !link.IsConnected() && tr == hostTransportNone {
|
||||
st.SerialError = "not connected — use UART or BLE connect"
|
||||
return st
|
||||
}
|
||||
|
||||
ver, err := link.getVersionPoll()
|
||||
if errors.Is(err, errUARTBusy) {
|
||||
return pausedPollState(portName, last)
|
||||
return pausedPollState(ep, last)
|
||||
}
|
||||
if err != nil {
|
||||
return disconnectedState(portName, err)
|
||||
return disconnectedState(ep, err)
|
||||
}
|
||||
st.UARTConnected = true
|
||||
st.HostConnected = true
|
||||
st.HostTransport = link.Transport()
|
||||
st.HostEndpoint = link.Endpoint()
|
||||
st.SerialPort = st.HostEndpoint
|
||||
st.SerialOK = true
|
||||
st.Master = MasterView{
|
||||
Version: ver.GetVersion(),
|
||||
@@ -540,6 +700,14 @@ func pollDashboard(link *managedSerial, portName string, last *DashboardState, s
|
||||
if dz, err := readDeadzonePoll(link, 0); err == nil {
|
||||
st.Master.Deadzone = dz
|
||||
}
|
||||
if tc, err := readTapConfigPoll(link, 0); err == nil {
|
||||
st.Master.TapConfig = tc
|
||||
}
|
||||
if uv, err := readBatteryUvPoll(link, 0); err == nil {
|
||||
en := uv.GetEnabled()
|
||||
st.Master.UvProtection = &en
|
||||
st.Master.UvLatched = uv.GetLatched()
|
||||
}
|
||||
|
||||
clients, err := link.listClientsPoll()
|
||||
if err != nil {
|
||||
@@ -548,7 +716,8 @@ func pollDashboard(link *managedSerial, portName string, last *DashboardState, s
|
||||
}
|
||||
st.SerialOK = false
|
||||
st.SerialError = err.Error()
|
||||
st.UARTConnected = link.IsConnected()
|
||||
st.HostConnected = link.IsConnected()
|
||||
st.UARTConnected = st.HostConnected
|
||||
return st
|
||||
}
|
||||
|
||||
@@ -565,6 +734,7 @@ func pollDashboard(link *managedSerial, portName string, last *DashboardState, s
|
||||
TapNotifySingle: c.GetTapNotifySingle(),
|
||||
TapNotifyDouble: c.GetTapNotifyDouble(),
|
||||
TapNotifyTriple: c.GetTapNotifyTriple(),
|
||||
UvProtection: c.GetUvProtectionEnabled(),
|
||||
}
|
||||
st.Clients = append(st.Clients, cv)
|
||||
}
|
||||
@@ -574,6 +744,9 @@ func pollDashboard(link *managedSerial, portName string, last *DashboardState, s
|
||||
if dz, err := readDeadzonePoll(link, c.GetId()); err == nil {
|
||||
st.Clients[i].Deadzone = dz
|
||||
}
|
||||
if tc, err := readTapConfigPoll(link, c.GetId()); err == nil {
|
||||
st.Clients[i].TapConfig = tc
|
||||
}
|
||||
}
|
||||
}
|
||||
if last != nil {
|
||||
@@ -633,7 +806,7 @@ func runBatteryPoller(link *managedSerial, hub *wsHub, interval time.Duration, s
|
||||
case <-stop:
|
||||
return
|
||||
case <-ticker.C:
|
||||
if hub.clientCount() == 0 {
|
||||
if !link.IsConnected() || link.PollPaused() {
|
||||
continue
|
||||
}
|
||||
bat, err := link.BatteryStatusPoll(&pb.BatteryStatusRequest{AllClients: true})
|
||||
@@ -654,7 +827,11 @@ func runCacheStatusDashboardPoller(link *managedSerial, hub *wsHub, interval tim
|
||||
case <-stop:
|
||||
return
|
||||
case <-ticker.C:
|
||||
if !hub.liveStreamEnabled() {
|
||||
if !hub.liveStreamEnabled() || link.PollPaused() {
|
||||
continue
|
||||
}
|
||||
// BLE live path prefers BLE_STREAM_TICK pushes.
|
||||
if link.Transport() == hostTransportBLE {
|
||||
continue
|
||||
}
|
||||
cache, err := link.readCacheStatusPoll()
|
||||
@@ -667,6 +844,35 @@ func runCacheStatusDashboardPoller(link *managedSerial, hub *wsHub, interval tim
|
||||
}
|
||||
}
|
||||
|
||||
// runBLEStreamDashboardPoller feeds live accel/tap from BLE_STREAM_TICK when live_stream is on.
|
||||
func runBLEStreamDashboardPoller(link *managedSerial, hub *wsHub, streamCtl *accelStreamCtl, stop <-chan struct{}) {
|
||||
_ = streamCtl
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
}
|
||||
if link.Transport() != hostTransportBLE || !hub.liveStreamEnabled() || link.PollPaused() {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
}
|
||||
continue
|
||||
}
|
||||
payload, err := link.NextStreamTick(200 * time.Millisecond)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
tick, err := parseBleStreamTickPayload(payload)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
hub.mergeBleStreamTick(tick)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *wsHub) clientCount() int {
|
||||
h.mu.RLock()
|
||||
n := len(h.clients)
|
||||
@@ -675,11 +881,14 @@ func (h *wsHub) clientCount() int {
|
||||
}
|
||||
|
||||
func pausedPollState(portName string, last *DashboardState) DashboardState {
|
||||
if last != nil && last.UARTConnected {
|
||||
if last != nil && (last.HostConnected || last.UARTConnected) {
|
||||
st := *last
|
||||
st.UpdatedAt = time.Now().Format(time.RFC3339)
|
||||
st.SerialPort = portName
|
||||
st.HostEndpoint = portName
|
||||
st.SerialOK = true
|
||||
st.HostConnected = true
|
||||
st.UARTConnected = true
|
||||
st.SerialError = "Live-Polling pausiert (OTA läuft)"
|
||||
return st
|
||||
}
|
||||
@@ -714,6 +923,48 @@ func readDeadzonePoll(link *managedSerial, clientID uint32) (uint32, error) {
|
||||
return r.GetDeadzone(), nil
|
||||
}
|
||||
|
||||
func tapConfigViewFromPB(r *pb.TapConfigResponse) *TapConfigView {
|
||||
return &TapConfigView{
|
||||
TapSensThres: r.GetTapSensThres(),
|
||||
MaxGestDur: r.GetMaxGestDur(),
|
||||
TapShockDur: r.GetTapShockDur(),
|
||||
QuiteTimeAfterGest: r.GetQuiteTimeAfterGest(),
|
||||
WaitForTimeout: r.GetWaitForTimeout(),
|
||||
AxisSel: r.GetAxisSel(),
|
||||
EnableSingle: r.GetEnableSingle(),
|
||||
EnableDouble: r.GetEnableDouble(),
|
||||
EnableTriple: r.GetEnableTriple(),
|
||||
}
|
||||
}
|
||||
|
||||
func readBatteryUvPoll(link *managedSerial, clientID uint32) (*pb.BatteryUvResponse, error) {
|
||||
r, err := link.BatteryUvPoll(&pb.BatteryUvRequest{
|
||||
Write: false,
|
||||
ClientId: clientID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !r.GetSuccess() {
|
||||
return nil, fmt.Errorf("battery uv read failed for client %d", clientID)
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func readTapConfigPoll(link *managedSerial, clientID uint32) (*TapConfigView, error) {
|
||||
r, err := link.TapConfigPoll(&pb.TapConfigRequest{
|
||||
Write: false,
|
||||
ClientId: clientID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !r.GetSuccess() {
|
||||
return nil, fmt.Errorf("tap config read failed for client %d", clientID)
|
||||
}
|
||||
return tapConfigViewFromPB(r), nil
|
||||
}
|
||||
|
||||
func formatMAC(mac []byte) string {
|
||||
if len(mac) == 0 {
|
||||
return ""
|
||||
@@ -726,20 +977,20 @@ func runPoller(link *managedSerial, portName string, hub *wsHub, streamCtl *acce
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
uartUp := false
|
||||
hostUp := false
|
||||
var lastGood DashboardState
|
||||
publish := func() {
|
||||
st := pollDashboard(link, portName, &lastGood, streamCtl, tapCtl)
|
||||
hub.setState(st)
|
||||
if st.UARTConnected && st.SerialOK {
|
||||
if st.HostConnected && st.SerialOK {
|
||||
hub.mu.RLock()
|
||||
lastGood = hub.state
|
||||
hub.mu.RUnlock()
|
||||
}
|
||||
if st.UARTConnected && !uartUp {
|
||||
log.Printf("UART %s connected", portName)
|
||||
if st.HostConnected && !hostUp {
|
||||
log.Printf("host %s connected (%s)", st.HostEndpoint, st.HostTransport)
|
||||
}
|
||||
uartUp = st.UARTConnected
|
||||
hostUp = st.HostConnected
|
||||
}
|
||||
|
||||
publish()
|
||||
|
||||
+238
-5
@@ -1,6 +1,6 @@
|
||||
# REST API
|
||||
|
||||
`go run . -port /dev/ttyUSB0 serve` starts two HTTP servers on the same UART link:
|
||||
`go run . serve` (optional `-port` / `-ble`) starts two HTTP servers on the same `managedHost` (UART or BLE):
|
||||
|
||||
| Base URL | Flag | Used by |
|
||||
|----------|------|---------|
|
||||
@@ -27,6 +27,9 @@ GET /api/v1/
|
||||
"name": "powerpod-external-api",
|
||||
"version": "1",
|
||||
"serial_port": "/dev/ttyUSB0",
|
||||
"transport": "uart",
|
||||
"endpoint": "/dev/ttyUSB0",
|
||||
"connected": true,
|
||||
"websocket": "/ws",
|
||||
"default_interval_ms": 16,
|
||||
"min_interval_ms": 1,
|
||||
@@ -96,6 +99,7 @@ Body:
|
||||
| `digit` | `digit` 0–10 |
|
||||
| `blink` | `blink_ms`, `blink_count` |
|
||||
| `find-me` | Locate pod |
|
||||
| `battery-low` | First 4 LEDs red at ~10 % for 5 s (UV indicator test) |
|
||||
|
||||
Use `client_id` (`0` = master) or `all_clients` (+ optional `slaves_only`) for broadcast.
|
||||
|
||||
@@ -109,7 +113,48 @@ WebSocket: `set_led_ring` with the same fields plus `"type":"set_led_ring"` →
|
||||
|
||||
Used by the web UI; safe for scripts that drive the same features.
|
||||
|
||||
### Live stream (host `CACHE_STATUS` poll ~16 ms)
|
||||
### Host link (UART / BLE)
|
||||
|
||||
```http
|
||||
GET /api/host
|
||||
→ {"transport":"none|uart|ble","endpoint":"…","connected":false,"ble_name":""}
|
||||
|
||||
GET /api/serial/ports
|
||||
→ {"ports":["/dev/ttyUSB0",…]}
|
||||
|
||||
POST /api/host/connect
|
||||
Content-Type: application/json
|
||||
{"transport":"uart","port":"/dev/ttyUSB0"}
|
||||
{"transport":"ble","address":"AA:BB:…","name":"Powerpod"}
|
||||
|
||||
POST /api/host/disconnect
|
||||
|
||||
POST /api/ble/scan
|
||||
{"timeout_s":5,"name_filter":"Powerpod"}
|
||||
→ {"devices":[{"address":"…","name":"Powerpod","rssi":-55}]}
|
||||
```
|
||||
|
||||
`name_filter` is optional substring match (case-insensitive); omit to list all LE advertisements. Active BLE is disconnected before scan (BlueZ adapter).
|
||||
|
||||
Dashboard WebSocket state includes `host_connected`, `host_transport`, `host_endpoint` (aliases `uart_connected` / `serial_port` kept).
|
||||
|
||||
**OTA** (`POST /api/ota`) returns **400** unless a host (UART or BLE) is connected.
|
||||
|
||||
### BLE latency tests (BLE host only)
|
||||
|
||||
```http
|
||||
POST /api/ble-rtt
|
||||
{"count":50,"warmup":5,"settle_s":0}
|
||||
→ {"success":true,"count":50,"warmup":5,"stats":{"n":50,"min_ms":…,"median_ms":…,"mean_ms":…,"max_ms":…}}
|
||||
|
||||
POST /api/ble-stream
|
||||
{"count":50,"period_ms":8,"tol_ms":2,"timeout_s":15,"client_id":0}
|
||||
→ {"success":true,"received":50,"requested":50,"window_ok":true,"dt_esp_ms":{…},"dr_pc_ms":{…},…}
|
||||
```
|
||||
|
||||
Pauses dashboard/API polling while running. Requires active BLE host (`DIP8=1`).
|
||||
|
||||
### Live stream (host `CACHE_STATUS` poll ~8 ms; BLE uses `BLE_STREAM_TICK`)
|
||||
|
||||
```http
|
||||
GET /api/live-stream
|
||||
@@ -208,6 +253,46 @@ With `all_clients` + `slaves_only`: push to ESP-NOW slaves only (master BMA456 u
|
||||
{"deadzone": 128, "client_id": 0, "success": true, "slaves_updated": 2}
|
||||
```
|
||||
|
||||
### Tap config (BMA456 multitap)
|
||||
|
||||
```http
|
||||
GET /api/tap-config?client_id=0
|
||||
POST /api/tap-config
|
||||
Content-Type: application/json
|
||||
{
|
||||
"write": true,
|
||||
"client_id": 0,
|
||||
"tap_sens_thres": 5,
|
||||
"max_gest_dur": 100,
|
||||
"tap_shock_dur": 6,
|
||||
"quite_time_after_gest": 60,
|
||||
"wait_for_timeout": 0,
|
||||
"axis_sel": 2,
|
||||
"enable_single": true,
|
||||
"enable_double": true,
|
||||
"enable_triple": true
|
||||
}
|
||||
```
|
||||
|
||||
Same `client_id` / `all_clients` / `slaves_only` semantics as deadzone. Values are applied to the sensor and persisted in NVS on each target node.
|
||||
|
||||
```json
|
||||
{
|
||||
"client_id": 0,
|
||||
"success": true,
|
||||
"slaves_updated": 0,
|
||||
"tap_sens_thres": 5,
|
||||
"max_gest_dur": 100,
|
||||
"tap_shock_dur": 6,
|
||||
"quite_time_after_gest": 60,
|
||||
"wait_for_timeout": 0,
|
||||
"axis_sel": 2,
|
||||
"enable_single": true,
|
||||
"enable_double": true,
|
||||
"enable_triple": true
|
||||
}
|
||||
```
|
||||
|
||||
### Unicast test
|
||||
|
||||
```http
|
||||
@@ -216,6 +301,54 @@ Content-Type: application/json
|
||||
{"client_id": 16, "seq": 42}
|
||||
```
|
||||
|
||||
### Echo ping (ESP-NOW round-trip latency)
|
||||
|
||||
```http
|
||||
POST /api/echo-ping
|
||||
Content-Type: application/json
|
||||
{"client_id": 16}
|
||||
```
|
||||
|
||||
`client_id` must be a registered slave id (`> 0`). The host sends a microsecond timestamp; the master forwards it over ESP-NOW and the slave echoes it back unchanged.
|
||||
|
||||
**Flow:** Host → UART → `cmd_espnow_echo_ping` → `ESPNOW_ECHO_PING` (with `master_time_us` from `esp_timer_get_time()`) → Slave → `ESPNOW_ECHO_PONG` → Master `recv_cb` → UART response.
|
||||
|
||||
**Response fields:**
|
||||
|
||||
| Field | Unit | Meaning |
|
||||
|-------|------|---------|
|
||||
| `success` | — | `true` if pong received within 500 ms |
|
||||
| `client_id` | — | Echo of request |
|
||||
| `timestamp_us` | µs (Unix) | Echoed host timestamp; must match request on success |
|
||||
| `rtt_ms` | ms | **Host-side** round-trip: goTool send → UART response (full chain incl. USB serial) |
|
||||
| `esp_rtt_us` | µs | **Master-side** ESP-NOW only: `esp_timer_get_time()` delta from ping send to pong recv |
|
||||
|
||||
`esp_rtt_us` is the raw firmware value (microseconds, not converted). The web dashboard displays it converted to milliseconds for readability (`esp_rtt_us / 1000`, 3 decimal places). The success banner stays visible for at least 5 seconds.
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"client_id": 16,
|
||||
"timestamp_us": 1717654321123456,
|
||||
"rtt_ms": 49.729,
|
||||
"esp_rtt_us": 18234
|
||||
}
|
||||
```
|
||||
|
||||
On failure (`success: false`), `esp_rtt_us` is omitted; `rtt_ms` still reflects the host round-trip. HTTP 503 if UART exchange fails (e.g. timeout, client not in registry).
|
||||
|
||||
**CLI:**
|
||||
|
||||
```bash
|
||||
go run . -port /dev/ttyUSB0 echo-ping -client 16
|
||||
```
|
||||
|
||||
Example output:
|
||||
|
||||
```
|
||||
echo ping: success=true client_id=16 rtt_ms=49.729 esp_rtt_us=18234
|
||||
```
|
||||
|
||||
### Find me
|
||||
|
||||
```http
|
||||
@@ -234,14 +367,103 @@ Content-Type: application/json
|
||||
{"client_id": 16}
|
||||
```
|
||||
|
||||
### OTA (master UART upload)
|
||||
### Log level (master ESP-IDF console)
|
||||
|
||||
Runtime get/set of the **global** log filter on the master (`esp_log_level_set("*", …)`). Output goes to **UART0** (USB debug, 115200), not the host protocol UART (GPIO 2/3, 921600).
|
||||
|
||||
```http
|
||||
GET /api/log-level
|
||||
POST /api/log-level
|
||||
Content-Type: application/json
|
||||
{"write": true, "level": 3}
|
||||
```
|
||||
|
||||
| `level` | Meaning |
|
||||
|---------|---------|
|
||||
| 0 | None (no log output) |
|
||||
| 1 | Error |
|
||||
| 2 | Warn |
|
||||
| 3 | Info |
|
||||
| 4 | Debug |
|
||||
| 5 | Verbose |
|
||||
|
||||
```json
|
||||
{"success": true, "level": 3}
|
||||
```
|
||||
|
||||
**CLI:**
|
||||
|
||||
```bash
|
||||
go run . -port /dev/ttyUSB0 log-level
|
||||
go run . -port /dev/ttyUSB0 log-level -set -level 3
|
||||
```
|
||||
|
||||
Dashboard: Master card → dropdown **ESP Log-Level** with **Lesen** / **Setzen**.
|
||||
|
||||
### Battery UV protection
|
||||
|
||||
Runtime enable for software LiPo under-voltage protection (NVS `uv_en` per pod). Disable also clears `uv_latch`. `all_clients` applies to master + every registered slave; `slaves_only` with `all_clients` skips the master.
|
||||
|
||||
```http
|
||||
GET /api/battery-uv?client_id=0
|
||||
POST /api/battery-uv
|
||||
Content-Type: application/json
|
||||
{"write": true, "enabled": false, "client_id": 0}
|
||||
{"write": true, "enabled": false, "all_clients": true}
|
||||
{"write": true, "enabled": false, "all_clients": true, "slaves_only": true}
|
||||
```
|
||||
|
||||
```json
|
||||
{"success": true, "client_id": 0, "enabled": false, "latched": false, "slaves_updated": 0}
|
||||
```
|
||||
|
||||
```bash
|
||||
go run . -port /dev/ttyUSB0 battery-uv
|
||||
go run . -port /dev/ttyUSB0 battery-uv -set -enable=false -client 0
|
||||
go run . -port /dev/ttyUSB0 battery-uv -set -enable=false -all
|
||||
```
|
||||
|
||||
Dashboard: Master **UV-Schutz** checkbox + **Alle an/aus**; per-slave **UV** column.
|
||||
|
||||
### Input send mode (slave accel/tap ESP-NOW TX path)
|
||||
|
||||
Debug A/B for how slaves transmit `ACCEL_SAMPLE` / `TAP_EVENT`. Default on slaves is `best_effort` (0). Master remembers the last mode for GET; write pushes via ESP-NOW.
|
||||
|
||||
```http
|
||||
GET /api/input-send-mode
|
||||
PUT /api/input-send-mode
|
||||
Content-Type: application/json
|
||||
{"mode": 2, "all_clients": true}
|
||||
```
|
||||
|
||||
| `mode` | Path |
|
||||
|--------|------|
|
||||
| 0 | `best_effort` (fire-and-forget, quiet) |
|
||||
| 1 | `fast` (fire-and-forget, logs failures) |
|
||||
| 2 | `wait` (send-done + retries) |
|
||||
| 3 | `reliable` (OTA-style retries; can stall high-rate accel) |
|
||||
|
||||
```json
|
||||
{"success": true, "mode": 2, "mode_name": "wait", "slaves_updated": 2}
|
||||
```
|
||||
|
||||
**CLI:**
|
||||
|
||||
```bash
|
||||
go run . -port /dev/ttyUSB0 input-send-mode
|
||||
go run . -port /dev/ttyUSB0 input-send-mode -set -mode 2 -all
|
||||
```
|
||||
|
||||
Dashboard: ESP-NOW Slaves toolbar → **Input-Send** dropdown + **Setzen** / **Lesen**.
|
||||
|
||||
### OTA (master host upload, UART or BLE)
|
||||
|
||||
```http
|
||||
POST /api/ota
|
||||
Content-Type: multipart/form-data
|
||||
```
|
||||
|
||||
Form field **`firmware`**: binary image, max **2 MiB**.
|
||||
Form field **`firmware`**: binary image, max **2 MiB**. Requires connected host (`transport` uart or ble). BLE needs ATT MTU ≥ 219 for 200 B chunks.
|
||||
|
||||
```json
|
||||
{"success": true, "bytes_written": 123456, "target_slot": 1}
|
||||
@@ -249,7 +471,12 @@ Form field **`firmware`**: binary image, max **2 MiB**.
|
||||
|
||||
Firmware distributes to slaves over ESP-NOW after `OTA_END`. Progress also appears on dashboard WebSocket as `ota_progress` messages.
|
||||
|
||||
CLI equivalent: `go run . -port /dev/ttyUSB0 ota build/powerpod.bin`
|
||||
CLI equivalent:
|
||||
|
||||
```bash
|
||||
go run . -port /dev/ttyUSB0 ota build/powerpod.bin
|
||||
go run . -ble ota build/powerpod.bin
|
||||
```
|
||||
|
||||
### LED ring and battery
|
||||
|
||||
@@ -279,6 +506,12 @@ Same as external API:
|
||||
| Nur Master deadzone | `POST /api/deadzone` `client_id: 0` or CLI `deadzone -set -client 0` |
|
||||
| Einzelner Slave | `client_id: <id>` |
|
||||
| Alle Slaves deadzone | `all_clients` + `slaves_only` on POST |
|
||||
| Master tap config | `GET` / `POST /api/tap-config` `client_id: 0` or CLI `tap-config` |
|
||||
| Tap-Cfg alle Slaves | `POST /api/tap-config` with `all_clients` + `slaves_only` |
|
||||
| Unicast test | `POST /api/unicast-test` |
|
||||
| Echo ping | `POST /api/echo-ping` (per-slave latency; UI shows Host ms + ESP ms) |
|
||||
| Master log level | `GET` / `POST /api/log-level` or CLI `log-level` |
|
||||
| UV protection | `GET` / `POST /api/battery-uv` or CLI `battery-uv` |
|
||||
| Input-Send mode | `GET` / `PUT /api/input-send-mode` or CLI `input-send-mode` |
|
||||
| Tap notify S/D/T | `PUT /api/clients/{id}/tap-notify` |
|
||||
| Tap receive (UI) | Live stream + tap notify; see WebSocket doc for external API |
|
||||
|
||||
+104
-100
@@ -1,58 +1,62 @@
|
||||
# WebSocket API
|
||||
|
||||
`go run . -port /dev/ttyUSB0 serve` exposes the WebSocket enpoint
|
||||
External API: `ws://localhost:8081/ws` (default `-api-addr`, disable with empty string).
|
||||
|
||||
| URL | Port (default) | Role |
|
||||
|-----|----------------|------|
|
||||
| `ws://localhost:8081/ws` | External API (`-api-addr`) | Request/response commands + optional **input** push stream |
|
||||
Start with `go run . serve` (optional `-port` / `-ble` to pre-connect). Host can be opened later via `host_connect` / dashboard REST.
|
||||
|
||||
---
|
||||
|
||||
## External API (`:8081/ws`)
|
||||
## Connection flow
|
||||
|
||||
### Connection flow
|
||||
1. Connect → server sends `hello` (host status, push off; defaults and command list).
|
||||
2. Optionally `ble_scan` / `list_serial_ports` then `host_connect` if not already linked.
|
||||
3. Send JSON commands → one reply per message (`*_status`, `client_list`, `ble_scan_result`, …).
|
||||
4. After `set_stream` with `enable: true`, server may push `input` messages without a prior command.
|
||||
|
||||
1. Connect → server sends **`hello`** (receive off; lists available commands).
|
||||
2. Send JSON commands → server replies with a matching `*_status` or `client_list` message (one reply per command).
|
||||
3. After `set_stream` with `enable: true`, the server may send **`input`** messages **without** a prior command (push stream).
|
||||
Commands and pushes share one socket — always branch on `type`.
|
||||
|
||||
Commands and stream pushes are multiplexed on one socket. While streaming, always parse `type` and branch (status vs sample vs error).
|
||||
On disconnect, `set_stream` state for that socket is dropped. Firmware settings (`set_input_stream`, `set_tap_notify`) stay on the master until changed.
|
||||
|
||||
### Two layers (firmware vs host)
|
||||
---
|
||||
|
||||
| Layer | Commands | Effect |
|
||||
|-------|----------|--------|
|
||||
| **Firmware (ESP-NOW)** | `set_input_stream`, `set_tap_notify` | Per `client_id`: slave sends accel samples and/or tap events to the master |
|
||||
| **This connection (host)** | `set_stream` | Whether **you** receive push JSON, at what rate (`interval_ms`, 1 ms … 10 s), and how early the UART read starts (`pre_fetch`) |
|
||||
## Two layers (firmware vs host)
|
||||
|
||||
- **UART polling** runs only if at least one connection has `receive_input: true` (`set_stream`) **and** at least one slave streams input (`set_input_stream`) or has tap notify enabled (`set_tap_notify`).
|
||||
- **`set_tap_notify` alone** configures which tap kinds the slave reports; it does **not** enable host push by itself — you still need `set_stream`.
|
||||
|
||||
| Layer | Commands | Effect |
|
||||
| ------------------ | ------------------------------------ | ------------------------------------------------------------------ |
|
||||
| Firmware (ESP-NOW) | `set_input_stream`, `set_tap_notify` | Per `client_id`: slave sends accel and/or tap events to the master |
|
||||
| This connection | `set_stream` | Whether you receive push JSON on this socket |
|
||||
|
||||
|
||||
Host polling / BLE ticks run only when at least one connection has `receive_input: true` **and** at least one slave streams input or has tap notify enabled. `set_tap_notify` alone does not enable push — you still need `set_stream`.
|
||||
|
||||
On **BLE**, live `input` prefers firmware `BLE_STREAM_TICK` pushes; otherwise falls back to `CACHE_STATUS` like UART.
|
||||
|
||||
### Push timing (per connection)
|
||||
|
||||
|
||||
| Field | Where | Meaning |
|
||||
| ------------- | -------------------------------------- | ------------------------------------------------------------ |
|
||||
| `interval_ms` | `hello`, `set_stream`, `stream_status` | Minimum ms between `input` pushes on this socket (1 … 10000) |
|
||||
| `pre_fetch` | `set_stream`, `stream_status` | Ms before each push when the host starts the UART cache read |
|
||||
|
||||
|
||||
Global host poll interval = minimum `interval_ms` among all connections with push enabled.
|
||||
|
||||
Typical sequence:
|
||||
|
||||
1. `list_clients` → slave IDs
|
||||
2. Per slave: `set_input_stream` and/or `set_tap_notify` as needed
|
||||
2. Per slave: `set_input_stream` and/or `set_tap_notify`
|
||||
3. `set_stream` with `"enable": true`
|
||||
4. Read **`input`** messages in a loop
|
||||
|
||||
There is **no per-slave filter** on push messages: each `input` contains all cached slaves. Filter by `client_id` in your app.
|
||||
4. Read `input` messages; filter by `client_id` in your app (no per-slave filter on the wire)
|
||||
|
||||
---
|
||||
|
||||
## Push stream messages
|
||||
## Push: `input`
|
||||
|
||||
These are the samples you get after enabling receive. Timing is per WebSocket connection:
|
||||
Combines latest accel cache and visible tap state for every slave slot on the master.
|
||||
|
||||
- **`interval_ms`** — minimum time between consecutive `input` pushes on this socket.
|
||||
- **`pre_fetch`** — milliseconds **before** each scheduled push when the host sends the UART cache read, so the master has time to collect data from all slaves before the JSON goes out.
|
||||
|
||||
The server UART poll uses the **minimum** `interval_ms` among all subscribers with `receive_input: true`.
|
||||
|
||||
### `input` (type `"input"`)
|
||||
|
||||
Sent when `set_stream` has `enable: true` and the poll tick fires for this connection (after the UART read started `pre_fetch` ms earlier). Each message combines the latest accel cache and visible tap state for every slave slot on the master.
|
||||
|
||||
**Success** — all slaves with a cache entry (not only those with `valid: true`):
|
||||
**Success:**
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -72,57 +76,56 @@ Sent when `set_stream` has `enable: true` and the poll tick fires for this conne
|
||||
},
|
||||
{
|
||||
"client_id": 42,
|
||||
"valid": false,
|
||||
"tap_kind": "none"
|
||||
"valid": false
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Meaning |
|
||||
|-------|---------|
|
||||
| `t` | Unix timestamp in **nanoseconds** when the host read the cache |
|
||||
| `success` | `true` if `CACHE_STATUS` succeeded |
|
||||
| `clients[]` | One entry per slave slot in the master cache |
|
||||
| `client_id` | ESP-NOW client id (same as `list_clients`) |
|
||||
| `valid` | `false` if no accel sample yet or stale; omit `x`/`y`/`z` when false |
|
||||
| `x`, `y`, `z` | Raw accelerometer LSB (BMA456, ±2 g scale on the pod) |
|
||||
| `accel_age_ms` | Milliseconds since the master received this accel sample |
|
||||
| `tap_kind` | `"none"`, `"single"`, `"double"`, or `"triple"` |
|
||||
| `tap_age_ms` | Milliseconds since the tap was seen in the master cache; omit when `tap_kind` is `"none"` |
|
||||
|
||||
Tap events stay visible for **`tap_display_min_ms`** (2000 ms, also in `hello`) after the API first saw them, even if the hardware age grows.
|
||||
| Field | Meaning |
|
||||
| -------------- | -------------------------------------------------------------------- |
|
||||
| `t` | Unix timestamp in nanoseconds when the host read the cache |
|
||||
| `success` | `true` if `CACHE_STATUS` succeeded |
|
||||
| `clients[]` | One entry per slave slot (includes invalid/stale entries) |
|
||||
| `client_id` | Same id as in `list_clients` |
|
||||
| `valid` | `false` if no accel sample yet or stale; omit `x`/`y`/`z` when false |
|
||||
| `x`, `y`, `z` | Raw accelerometer LSB (BMA456, ±2 g) |
|
||||
| `accel_age_ms` | Ms since the master received this accel sample |
|
||||
| `tap_kind` | `"single"`, `"double"`, or `"triple"`; omit when no recent tap |
|
||||
| `tap_age_ms` | Ms since tap in master cache; omit with `tap_kind` |
|
||||
|
||||
**Failure** (e.g. UART busy):
|
||||
|
||||
Tap events stay visible for `tap_display_min_ms` (2000, in `hello`) after the API first saw them.
|
||||
|
||||
**Failure** (no `clients` array):
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "input",
|
||||
"t": 1716900123456789012,
|
||||
"success": false,
|
||||
"error": "uart busy"
|
||||
}
|
||||
{"type":"input","t":1716900123456789012,"success":false,"error":"uart busy"}
|
||||
```
|
||||
|
||||
No `clients` array on failure.
|
||||
|
||||
---
|
||||
|
||||
## Commands (request → response)
|
||||
## Commands
|
||||
|
||||
Send one JSON object per message. Field `type` selects the command.
|
||||
One JSON object per message; field `type` selects the command.
|
||||
|
||||
### `hello` (server → client, on connect)
|
||||
**Errors:** Replies use the matching response `type`. On failure: `success: false` (or omitted) and `"error": "…"`. Malformed JSON or unknown `type` → `stream_status` with `error`.
|
||||
|
||||
### `hello` (server → client)
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "hello",
|
||||
"serial_port": "/dev/ttyUSB0",
|
||||
"transport": "uart",
|
||||
"endpoint": "/dev/ttyUSB0",
|
||||
"connected": true,
|
||||
"interval_ms": 16,
|
||||
"pre_fetch_ms": 2,
|
||||
"tap_display_min_ms": 2000,
|
||||
"note": "set_tap_notify configures slave S/D/T only; set_stream enables input polling/push on this connection",
|
||||
"commands": [
|
||||
"get_host", "list_serial_ports", "ble_scan", "host_connect", "host_disconnect",
|
||||
"list_clients",
|
||||
"set_stream", "get_stream",
|
||||
"set_input_stream", "get_input_stream",
|
||||
@@ -132,6 +135,23 @@ Send one JSON object per message. Field `type` selects the command.
|
||||
}
|
||||
```
|
||||
|
||||
### Host link
|
||||
|
||||
| Client `type` | Reply `type` | Notes |
|
||||
|---------------|--------------|--------|
|
||||
| `get_host` | `host_status` | `transport`, `endpoint`, `connected` |
|
||||
| `list_serial_ports` | `serial_ports` | `{ports:[…]}` |
|
||||
| `ble_scan` | `ble_scan_result` | optional `timeout_s`, `name_filter`; disconnects active BLE first |
|
||||
| `host_connect` | `host_status` | `transport:"uart","port"` or `transport:"ble","address","name?"` |
|
||||
| `host_disconnect` | `host_status` | |
|
||||
|
||||
```json
|
||||
{"type":"host_connect","transport":"ble","address":"AA:BB:CC:DD:EE:FF","name":"Powerpod"}
|
||||
{"type":"host_status","transport":"ble","endpoint":"AA:BB:…","connected":true,"success":true}
|
||||
```
|
||||
|
||||
If the host is not connected, other commands that need the master return their usual error in the status reply.
|
||||
|
||||
### `list_clients`
|
||||
|
||||
Request: `{"type":"list_clients"}`
|
||||
@@ -146,11 +166,7 @@ Response `client_list`:
|
||||
{
|
||||
"id": 16,
|
||||
"mac": "aa:bb:cc:dd:ee:10",
|
||||
"version": 1,
|
||||
"available": true,
|
||||
"used": true,
|
||||
"last_ping": 1234,
|
||||
"last_success_ping": 1200,
|
||||
"input_stream": false,
|
||||
"tap_notify_single": false,
|
||||
"tap_notify_double": false,
|
||||
@@ -160,28 +176,24 @@ Response `client_list`:
|
||||
}
|
||||
```
|
||||
|
||||
### `set_stream` / `get_stream` (receive input on this connection)
|
||||
Also per client: `version`, `used`, `last_ping`, `last_success_ping`.
|
||||
|
||||
### `set_stream` / `get_stream`
|
||||
|
||||
```json
|
||||
{"type":"set_stream","enable":true,"interval_ms":32,"pre_fetch":2}
|
||||
{"type":"get_stream"}
|
||||
```
|
||||
|
||||
| Field | Meaning |
|
||||
|-------|---------|
|
||||
| `enable` | Turn push stream on/off for this connection |
|
||||
| `interval_ms` | Minimum time between `input` pushes (1 … 10000) |
|
||||
| `pre_fetch` | Milliseconds before each push when the host starts the UART cache read; optional, default in `hello` (`pre_fetch_ms`) |
|
||||
|
||||
Response `stream_status`:
|
||||
|
||||
```json
|
||||
{"type":"stream_status","receive_input":true,"interval_ms":32,"pre_fetch":2,"success":true}
|
||||
```
|
||||
|
||||
### `set_input_stream` / `get_input_stream` (firmware, per slave)
|
||||
### `set_input_stream` / `get_input_stream` (firmware)
|
||||
|
||||
`client_id` required (> 0). Enables accel streaming from the slave to the master.
|
||||
`client_id` required (> 0).
|
||||
|
||||
```json
|
||||
{"type":"set_input_stream","client_id":16,"enable":true}
|
||||
@@ -194,51 +206,44 @@ Response `input_stream_status`:
|
||||
{"type":"input_stream_status","client_id":16,"enabled":true,"success":true}
|
||||
```
|
||||
|
||||
### `set_tap_notify` / `get_tap_notify` (firmware, per slave)
|
||||
### `set_tap_notify` / `get_tap_notify` (firmware)
|
||||
|
||||
Per client: `single`, `double_tap`, `triple` required on set.
|
||||
Set requires `single`, `double_tap`, `triple` per client, or `"all_clients": true` for broadcast.
|
||||
|
||||
```json
|
||||
{"type":"set_tap_notify","client_id":16,"single":true,"double_tap":false,"triple":false}
|
||||
{"type":"get_tap_notify","client_id":16}
|
||||
```
|
||||
|
||||
Broadcast: `"all_clients": true` with the three booleans.
|
||||
|
||||
Response `tap_notify_status`:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "tap_notify_status",
|
||||
"client_id": 16,
|
||||
"success": true,
|
||||
"single": true,
|
||||
"double_tap": false,
|
||||
"triple": false
|
||||
}
|
||||
{"type":"tap_notify_status","client_id":16,"success":true,"single":true,"double_tap":false,"triple":false}
|
||||
```
|
||||
|
||||
### `set_led_ring`
|
||||
|
||||
Control the LED ring on the master or a slave.
|
||||
|
||||
```json
|
||||
{"type":"set_led_ring","mode":"color","client_id":16,"r":255,"g":0,"b":0,"intensity":128}
|
||||
{"type":"set_led_ring","mode":"digit","client_id":0,"digit":3,"r":0,"g":255,"b":0}
|
||||
{"type":"set_led_ring","mode":"find-me","all_clients":true,"slaves_only":true}
|
||||
```
|
||||
|
||||
| `mode` | Notes |
|
||||
|--------|--------|
|
||||
| `clear` | Turn off |
|
||||
| `color` | Full ring RGB + `intensity` |
|
||||
| `progress` | `progress` 0–100 |
|
||||
| `digit` | `digit` 0–10 |
|
||||
| `blink` | `blink_ms`, `blink_count` |
|
||||
| `find-me` | Locate pod |
|
||||
|
||||
Use `client_id` (`0` = master) or `all_clients` (+ optional `slaves_only`) for broadcast.
|
||||
| Request `mode` | Notes |
|
||||
| -------------- | --------------------------- |
|
||||
| `clear` | Turn off |
|
||||
| `color` | Full ring RGB + `intensity` |
|
||||
| `progress` | `progress` 0–100 |
|
||||
| `digit` | `digit` 0–10 |
|
||||
| `blink` | `blink_ms`, `blink_count` |
|
||||
| `find-me` | Locate pod |
|
||||
| `battery-low` | UV indicator (4 LEDs, 5 s) |
|
||||
|
||||
Response `led_ring_status`:
|
||||
|
||||
Target: `client_id` (`0` = master) or `all_clients` (+ optional `slaves_only`).
|
||||
|
||||
Response `led_ring_status` — `mode` is numeric: 0=clear, 1=progress, 2=digit, 3=blink, 4=find-me, 5=color, 6=battery-low.
|
||||
|
||||
```json
|
||||
{"type":"led_ring_status","success":true,"mode":5,"client_id":16,"slaves_updated":1}
|
||||
@@ -246,15 +251,13 @@ Response `led_ring_status`:
|
||||
|
||||
### `get_battery`
|
||||
|
||||
Read cached battery samples from the master. Slaves push battery every **30 s**; this command reads the master cache.
|
||||
Slaves push battery every 30 s; this reads the master cache. Default: all clients.
|
||||
|
||||
```json
|
||||
{"type":"get_battery","all_clients":true}
|
||||
{"type":"get_battery","client_id":16}
|
||||
```
|
||||
|
||||
Default if omitted: all clients.
|
||||
|
||||
Response `battery_status`:
|
||||
|
||||
```json
|
||||
@@ -271,3 +274,4 @@ Response `battery_status`:
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -6,9 +6,19 @@ require (
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
go.bug.st/serial v1.6.4
|
||||
google.golang.org/protobuf v1.36.11
|
||||
tinygo.org/x/bluetooth v0.13.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/creack/goselect v0.1.2 // indirect
|
||||
github.com/go-ole/go-ole v1.2.6 // indirect
|
||||
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
||||
github.com/saltosystems/winrt-go v0.0.0-20240509164145-4f7860a3bd2b // indirect
|
||||
github.com/sirupsen/logrus v1.9.3 // indirect
|
||||
github.com/soypat/cyw43439 v0.0.0-20250505012923-830110c8f4af // indirect
|
||||
github.com/soypat/seqs v0.0.0-20250124201400-0d65bc7c1710 // indirect
|
||||
github.com/tinygo-org/cbgo v0.0.4 // indirect
|
||||
github.com/tinygo-org/pio v0.2.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20241204233417-43b7b7cde48d // indirect
|
||||
golang.org/x/sys v0.19.0 // indirect
|
||||
)
|
||||
|
||||
@@ -1,20 +1,51 @@
|
||||
github.com/creack/goselect v0.1.2 h1:2DNy14+JPjRBgPzAd1thbQp4BSIihxcBf0IXhQXDRa0=
|
||||
github.com/creack/goselect v0.1.2/go.mod h1:a/NhLweNvqIYMuxcMOuWY516Cimucms3DglDzQP3hKY=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
|
||||
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
|
||||
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/saltosystems/winrt-go v0.0.0-20240509164145-4f7860a3bd2b h1:du3zG5fd8snsFN6RBoLA7fpaYV9ZQIsyH9snlk2Zvik=
|
||||
github.com/saltosystems/winrt-go v0.0.0-20240509164145-4f7860a3bd2b/go.mod h1:CIltaIm7qaANUIvzr0Vmz71lmQMAIbGJ7cvgzX7FMfA=
|
||||
github.com/sirupsen/logrus v1.5.0/go.mod h1:+F7Ogzej0PZc/94MaYx/nvG9jOFMD2osvC3s+Squfpo=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/soypat/cyw43439 v0.0.0-20250505012923-830110c8f4af h1:ZfFq94aH/BCSWWKd9RPUgdHOdgGKCnfl2VdvU9UksTA=
|
||||
github.com/soypat/cyw43439 v0.0.0-20250505012923-830110c8f4af/go.mod h1:MUaGO5m6X7xrkHrPDmnaxCEcuCCFN/0ZFh9oie+exbU=
|
||||
github.com/soypat/seqs v0.0.0-20250124201400-0d65bc7c1710 h1:Y9fBuiR/urFY/m76+SAZTxk2xAOS2n85f+H1CugajeA=
|
||||
github.com/soypat/seqs v0.0.0-20250124201400-0d65bc7c1710/go.mod h1:oCVCNGCHMKoBj97Zp9znLbQ1nHxpkmOY9X+UAGzOxc8=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/tinygo-org/cbgo v0.0.4 h1:3D76CRYbH03Rudi8sEgs/YO0x3JIMdyq8jlQtk/44fU=
|
||||
github.com/tinygo-org/cbgo v0.0.4/go.mod h1:7+HgWIHd4nbAz0ESjGlJ1/v9LDU1Ox8MGzP9mah/fLk=
|
||||
github.com/tinygo-org/pio v0.2.0 h1:vo3xa6xDZ2rVtxrks/KcTZHF3qq4lyWOntvEvl2pOhU=
|
||||
github.com/tinygo-org/pio v0.2.0/go.mod h1:LU7Dw00NJ+N86QkeTGjMLNkYcEYMor6wTDpTCu0EaH8=
|
||||
go.bug.st/serial v1.6.4 h1:7FmqNPgVp3pu2Jz5PoPtbZ9jJO5gnEnZIvnI1lzve8A=
|
||||
go.bug.st/serial v1.6.4/go.mod h1:nofMJxTeNVny/m6+KaafC6vJGj3miwQZ6vW4BZUGJPI=
|
||||
golang.org/x/exp v0.0.0-20241204233417-43b7b7cde48d h1:0olWaB5pg3+oychR51GUVCEsGkeCU/2JxjBgIo4f3M0=
|
||||
golang.org/x/exp v0.0.0-20241204233417-43b7b7cde48d/go.mod h1:qj5a5QZpwLU2NLQudwIN5koi3beDhSAlJwa67PuM98c=
|
||||
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o=
|
||||
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
tinygo.org/x/bluetooth v0.13.0 h1:3pkTMcfqv71HoAxG4DBTm2n+1bm6Nqqz8eoHjSW9+5g=
|
||||
tinygo.org/x/bluetooth v0.13.0/go.mod h1:YnyJRVX09i+wkFeHpXut0b+qHq+T2WwKBRRiF/scANA=
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"google.golang.org/protobuf/proto"
|
||||
pb "powerpod/gotool/pb"
|
||||
)
|
||||
|
||||
// hostConn is the UART or BLE host transport used by CLI commands.
|
||||
type hostConn interface {
|
||||
exchangePayload(payload []byte, cmdName string) ([]byte, error)
|
||||
exchange(cmdID byte, cmdName string) ([]byte, error)
|
||||
Close() error
|
||||
}
|
||||
|
||||
// optional stream source (BLE only)
|
||||
type hostStreamConn interface {
|
||||
hostConn
|
||||
// NextStreamTick waits for a BLE_STREAM_TICK payload (type byte + protobuf).
|
||||
NextStreamTick(timeout time.Duration) ([]byte, error)
|
||||
}
|
||||
|
||||
func hostGetVersion(h hostConn) (*pb.VersionResponse, error) {
|
||||
payload, err := h.exchange(byte(pb.MessageType_VERSION), "VERSION")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return decodeVersionPayload(payload)
|
||||
}
|
||||
|
||||
func hostListClients(h hostConn) ([]*pb.ClientInfo, error) {
|
||||
payload, err := h.exchange(byte(pb.MessageType_CLIENT_INFO), "CLIENT_INFO")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return decodeClientsPayload(payload)
|
||||
}
|
||||
|
||||
func hostReadCacheStatus(h hostConn) (*pb.CacheStatusResponse, error) {
|
||||
payload, err := h.exchange(byte(pb.MessageType_CACHE_STATUS), "CACHE_STATUS")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return decodeCacheStatusPayload(payload)
|
||||
}
|
||||
|
||||
func hostAccelStream(h hostConn, req *pb.AccelStreamRequest) (*pb.AccelStreamResponse, error) {
|
||||
msg := &pb.UartMessage{
|
||||
Type: pb.MessageType_ACCEL_STREAM,
|
||||
Payload: &pb.UartMessage_AccelStreamRequest{
|
||||
AccelStreamRequest: req,
|
||||
},
|
||||
}
|
||||
body, err := proto.Marshal(msg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode: %w", err)
|
||||
}
|
||||
payload := append([]byte{byte(pb.MessageType_ACCEL_STREAM)}, body...)
|
||||
respPayload, err := h.exchangePayload(payload, "ACCEL_STREAM")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var respMsg pb.UartMessage
|
||||
if err := proto.Unmarshal(respPayload[1:], &respMsg); err != nil {
|
||||
return nil, fmt.Errorf("decode: %w", err)
|
||||
}
|
||||
r := respMsg.GetAccelStreamResponse()
|
||||
if r == nil {
|
||||
return nil, fmt.Errorf("missing accel_stream_response")
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func hostFindMe(h hostConn, clientID uint32) error {
|
||||
msg := &pb.UartMessage{
|
||||
Type: pb.MessageType_FIND_ME,
|
||||
Payload: &pb.UartMessage_EspnowFindMeRequest{
|
||||
EspnowFindMeRequest: &pb.EspNowFindMeRequest{ClientId: clientID},
|
||||
},
|
||||
}
|
||||
body, err := proto.Marshal(msg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode: %w", err)
|
||||
}
|
||||
payload := append([]byte{byte(pb.MessageType_FIND_ME)}, body...)
|
||||
respPayload, err := h.exchangePayload(payload, "FIND_ME")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var respMsg pb.UartMessage
|
||||
if err := proto.Unmarshal(respPayload[1:], &respMsg); err != nil {
|
||||
return fmt.Errorf("decode: %w", err)
|
||||
}
|
||||
r := respMsg.GetEspnowFindMeResponse()
|
||||
if r == nil {
|
||||
return fmt.Errorf("missing espnow_find_me_response")
|
||||
}
|
||||
if !r.GetSuccess() {
|
||||
return fmt.Errorf("find-me rejected (client_id=%d)", r.GetClientId())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func hostRestart(h hostConn, clientID uint32) error {
|
||||
msg := &pb.UartMessage{
|
||||
Type: pb.MessageType_RESTART,
|
||||
Payload: &pb.UartMessage_RestartRequest{
|
||||
RestartRequest: &pb.RestartRequest{ClientId: clientID},
|
||||
},
|
||||
}
|
||||
body, err := proto.Marshal(msg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode: %w", err)
|
||||
}
|
||||
payload := append([]byte{byte(pb.MessageType_RESTART)}, body...)
|
||||
respPayload, err := h.exchangePayload(payload, "RESTART")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var respMsg pb.UartMessage
|
||||
if err := proto.Unmarshal(respPayload[1:], &respMsg); err != nil {
|
||||
return fmt.Errorf("decode: %w", err)
|
||||
}
|
||||
r := respMsg.GetRestartResponse()
|
||||
if r == nil {
|
||||
return fmt.Errorf("missing restart_response")
|
||||
}
|
||||
if !r.GetSuccess() {
|
||||
return fmt.Errorf("restart rejected (client_id=%d)", r.GetClientId())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func hostSetLogLevel(h hostConn, write bool, level uint32) (*pb.SetLogLevelResponse, error) {
|
||||
msg := &pb.UartMessage{
|
||||
Type: pb.MessageType_SET_LOG_LEVEL,
|
||||
Payload: &pb.UartMessage_SetLogLevelRequest{
|
||||
SetLogLevelRequest: &pb.SetLogLevelRequest{Write: write, Level: level},
|
||||
},
|
||||
}
|
||||
body, err := proto.Marshal(msg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode: %w", err)
|
||||
}
|
||||
payload := append([]byte{byte(pb.MessageType_SET_LOG_LEVEL)}, body...)
|
||||
respPayload, err := h.exchangePayload(payload, "SET_LOG_LEVEL")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var respMsg pb.UartMessage
|
||||
if err := proto.Unmarshal(respPayload[1:], &respMsg); err != nil {
|
||||
return nil, fmt.Errorf("decode: %w", err)
|
||||
}
|
||||
r := respMsg.GetSetLogLevelResponse()
|
||||
if r == nil {
|
||||
return nil, fmt.Errorf("missing set_log_level_response")
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func hostInputSendMode(h hostConn, req *pb.InputSendModeRequest) (*pb.InputSendModeResponse, error) {
|
||||
msg := &pb.UartMessage{
|
||||
Type: pb.MessageType_INPUT_SEND_MODE,
|
||||
Payload: &pb.UartMessage_InputSendModeRequest{
|
||||
InputSendModeRequest: req,
|
||||
},
|
||||
}
|
||||
body, err := proto.Marshal(msg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode: %w", err)
|
||||
}
|
||||
payload := append([]byte{byte(pb.MessageType_INPUT_SEND_MODE)}, body...)
|
||||
respPayload, err := h.exchangePayload(payload, "INPUT_SEND_MODE")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var respMsg pb.UartMessage
|
||||
if err := proto.Unmarshal(respPayload[1:], &respMsg); err != nil {
|
||||
return nil, fmt.Errorf("decode: %w", err)
|
||||
}
|
||||
r := respMsg.GetInputSendModeResponse()
|
||||
if r == nil {
|
||||
return nil, fmt.Errorf("missing input_send_mode_response")
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func hostLedRing(h hostConn, req *pb.LedRingProgressRequest) (*pb.LedRingProgressResponse, error) {
|
||||
msg := &pb.UartMessage{
|
||||
Type: pb.MessageType_LED_RING,
|
||||
Payload: &pb.UartMessage_LedRingProgressRequest{
|
||||
LedRingProgressRequest: req,
|
||||
},
|
||||
}
|
||||
body, err := proto.Marshal(msg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode: %w", err)
|
||||
}
|
||||
payload := append([]byte{byte(pb.MessageType_LED_RING)}, body...)
|
||||
respPayload, err := h.exchangePayload(payload, "LED_RING")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var respMsg pb.UartMessage
|
||||
if err := proto.Unmarshal(respPayload[1:], &respMsg); err != nil {
|
||||
return nil, fmt.Errorf("decode: %w", err)
|
||||
}
|
||||
r := respMsg.GetLedRingProgressResponse()
|
||||
if r == nil {
|
||||
return nil, fmt.Errorf("missing led_ring_progress_response")
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func hostEchoPing(h hostConn, clientID uint32) (*EchoPingResult, error) {
|
||||
t0 := time.Now()
|
||||
timestampUs := uint64(t0.UnixMicro())
|
||||
req := &pb.EspNowEchoPingRequest{
|
||||
ClientId: clientID,
|
||||
TimestampUs: timestampUs,
|
||||
}
|
||||
msg := &pb.UartMessage{
|
||||
Type: pb.MessageType_ESPNOW_ECHO_PING,
|
||||
Payload: &pb.UartMessage_EspnowEchoPingRequest{
|
||||
EspnowEchoPingRequest: req,
|
||||
},
|
||||
}
|
||||
body, err := proto.Marshal(msg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode: %w", err)
|
||||
}
|
||||
payload := append([]byte{byte(pb.MessageType_ESPNOW_ECHO_PING)}, body...)
|
||||
respPayload, err := h.exchangePayload(payload, "ESPNOW_ECHO_PING")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rttMs := float64(time.Since(t0).Microseconds()) / 1000.0
|
||||
|
||||
var respMsg pb.UartMessage
|
||||
if err := proto.Unmarshal(respPayload[1:], &respMsg); err != nil {
|
||||
return nil, fmt.Errorf("decode: %w", err)
|
||||
}
|
||||
r := respMsg.GetEspnowEchoPingResponse()
|
||||
if r == nil {
|
||||
return nil, fmt.Errorf("missing espnow_echo_ping_response")
|
||||
}
|
||||
if !r.GetSuccess() {
|
||||
return &EchoPingResult{
|
||||
Success: false,
|
||||
ClientID: r.GetClientId(),
|
||||
RttMs: rttMs,
|
||||
}, nil
|
||||
}
|
||||
if r.GetTimestampUs() != timestampUs {
|
||||
return nil, fmt.Errorf("timestamp mismatch: sent %d got %d", timestampUs, r.GetTimestampUs())
|
||||
}
|
||||
return &EchoPingResult{
|
||||
Success: true,
|
||||
ClientID: r.GetClientId(),
|
||||
TimestampUs: r.GetTimestampUs(),
|
||||
RttMs: rttMs,
|
||||
EspRttUs: r.GetEspRttUs(),
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,491 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go.bug.st/serial"
|
||||
"tinygo.org/x/bluetooth"
|
||||
)
|
||||
|
||||
const (
|
||||
hostTransportNone = "none"
|
||||
hostTransportUART = "uart"
|
||||
hostTransportBLE = "ble"
|
||||
)
|
||||
|
||||
// errHostBusy is returned when the link is held for OTA.
|
||||
var errHostBusy = errUARTBusy
|
||||
|
||||
// managedHost is a reconnecting UART or BLE host link for serve/CLI.
|
||||
type managedHost struct {
|
||||
quiet bool
|
||||
baud int
|
||||
settle time.Duration
|
||||
|
||||
mu sync.Mutex
|
||||
transport string // none|uart|ble
|
||||
endpoint string // port path or BLE address string
|
||||
bleName string // last connected BLE local name (optional)
|
||||
sp *serialPort
|
||||
ble *bleLink
|
||||
otaActive bool
|
||||
pausePoll bool // true while BLE latency tests run (pollers yield)
|
||||
uartPort string // remembered UART port for reconnect
|
||||
}
|
||||
|
||||
func newManagedHost(baud int) *managedHost {
|
||||
if baud <= 0 {
|
||||
baud = defaultBaud
|
||||
}
|
||||
return &managedHost{
|
||||
baud: baud,
|
||||
transport: hostTransportNone,
|
||||
settle: time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
// newManagedSerial keeps the old constructor name for call sites during migrate.
|
||||
func newManagedSerial(portName string, baud int) *managedHost {
|
||||
m := newManagedHost(baud)
|
||||
if portName != "" {
|
||||
m.uartPort = portName
|
||||
m.endpoint = portName
|
||||
m.transport = hostTransportUART
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *managedHost) Transport() string {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.transport == "" {
|
||||
return hostTransportNone
|
||||
}
|
||||
return m.transport
|
||||
}
|
||||
|
||||
func (m *managedHost) Endpoint() string {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.endpoint
|
||||
}
|
||||
|
||||
func (m *managedHost) Close() error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.disconnectLocked()
|
||||
}
|
||||
|
||||
func (m *managedHost) IsConnected() bool {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
switch m.transport {
|
||||
case hostTransportUART:
|
||||
return m.sp != nil
|
||||
case hostTransportBLE:
|
||||
return m.ble != nil
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (m *managedHost) disconnectLocked() error {
|
||||
var err error
|
||||
if m.sp != nil {
|
||||
err = m.sp.port.Close()
|
||||
m.sp = nil
|
||||
}
|
||||
if m.ble != nil {
|
||||
e := m.ble.Close()
|
||||
if err == nil {
|
||||
err = e
|
||||
}
|
||||
m.ble = nil
|
||||
}
|
||||
m.transport = hostTransportNone
|
||||
return err
|
||||
}
|
||||
|
||||
func (m *managedHost) Disconnect() error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
ep := m.endpoint
|
||||
err := m.disconnectLocked()
|
||||
if !m.quiet && ep != "" {
|
||||
log.Printf("host disconnected (%s)", ep)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (m *managedHost) ConnectUART(port string, baud int) error {
|
||||
if port == "" {
|
||||
return fmt.Errorf("UART port required")
|
||||
}
|
||||
if baud <= 0 {
|
||||
baud = m.baud
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.otaActive {
|
||||
return errHostBusy
|
||||
}
|
||||
_ = m.disconnectLocked()
|
||||
m.baud = baud
|
||||
m.uartPort = port
|
||||
m.endpoint = port
|
||||
m.transport = hostTransportUART
|
||||
if err := m.openUARTLocked(); err != nil {
|
||||
m.transport = hostTransportNone
|
||||
m.endpoint = ""
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *managedHost) ConnectBLE(address string, name string) error {
|
||||
if address == "" && name == "" {
|
||||
return fmt.Errorf("BLE address or name required")
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.otaActive {
|
||||
return errHostBusy
|
||||
}
|
||||
_ = m.disconnectLocked()
|
||||
|
||||
var link *bleLink
|
||||
var err error
|
||||
settle := m.settle
|
||||
m.mu.Unlock()
|
||||
if address != "" {
|
||||
link, err = openBLEAddress(address, settle)
|
||||
} else {
|
||||
link, err = openBLE(name, settle)
|
||||
}
|
||||
m.mu.Lock()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
link.quiet = m.quiet
|
||||
m.ble = link
|
||||
m.transport = hostTransportBLE
|
||||
if address != "" {
|
||||
m.endpoint = address
|
||||
} else {
|
||||
m.endpoint = name
|
||||
}
|
||||
m.bleName = name
|
||||
if !m.quiet {
|
||||
log.Printf("BLE host connected (%s)", m.endpoint)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *managedHost) openUARTLocked() error {
|
||||
sp, err := openSerial(m.uartPort, m.baud)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sp.quiet = m.quiet
|
||||
m.sp = sp
|
||||
if !m.quiet {
|
||||
log.Printf("UART %s connected (%d baud)", m.uartPort, m.baud)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *managedHost) openLocked() error {
|
||||
return m.openUARTLocked()
|
||||
}
|
||||
|
||||
func (m *managedHost) closeLocked() error {
|
||||
return m.disconnectLocked()
|
||||
}
|
||||
|
||||
func (m *managedHost) invalidateLocked(reason error) {
|
||||
if m.sp == nil && m.ble == nil {
|
||||
return
|
||||
}
|
||||
if !m.quiet {
|
||||
log.Printf("host %s disconnected: %v", m.endpoint, reason)
|
||||
}
|
||||
_ = m.disconnectLocked()
|
||||
// remember transport intent for UART reconnect
|
||||
if m.uartPort != "" && m.transport == hostTransportNone {
|
||||
// after invalidate, leave as none; poller will try Connect again via open
|
||||
m.transport = hostTransportUART
|
||||
m.endpoint = m.uartPort
|
||||
}
|
||||
}
|
||||
|
||||
func (m *managedHost) withPort(fn func(*serialPort) error) error {
|
||||
return m.withPortLocked(false, fn)
|
||||
}
|
||||
|
||||
func (m *managedHost) withPortPoll(fn func(*serialPort) error) error {
|
||||
return m.withPortLocked(true, fn)
|
||||
}
|
||||
|
||||
func (m *managedHost) withPortLocked(poll bool, fn func(*serialPort) error) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.otaActive {
|
||||
return errHostBusy
|
||||
}
|
||||
if m.transport == hostTransportBLE {
|
||||
return fmt.Errorf("operation requires UART host (DIP8=0)")
|
||||
}
|
||||
if m.transport != hostTransportUART && m.uartPort == "" {
|
||||
return fmt.Errorf("not connected")
|
||||
}
|
||||
if m.transport == hostTransportNone && m.uartPort != "" {
|
||||
m.transport = hostTransportUART
|
||||
m.endpoint = m.uartPort
|
||||
}
|
||||
if m.sp == nil {
|
||||
if err := m.openUARTLocked(); err != nil {
|
||||
return fmt.Errorf("%s: %w", m.uartPort, err)
|
||||
}
|
||||
}
|
||||
err := fn(m.sp)
|
||||
if err != nil {
|
||||
m.invalidateLocked(err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (m *managedHost) recoverAfterMasterRestart() {
|
||||
const bootWait = 6 * time.Second
|
||||
m.mu.Lock()
|
||||
tr := m.transport
|
||||
m.disconnectLocked()
|
||||
if tr == hostTransportUART {
|
||||
m.transport = hostTransportUART
|
||||
m.endpoint = m.uartPort
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
log.Printf("host: master restart — waiting %s for boot", bootWait)
|
||||
time.Sleep(bootWait)
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.transport == hostTransportUART {
|
||||
if err := m.openUARTLocked(); err != nil {
|
||||
log.Printf("UART reconnect after master restart: %v", err)
|
||||
return
|
||||
}
|
||||
m.sp.flushInput()
|
||||
log.Printf("UART %s ready after master restart", m.uartPort)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *managedHost) exchangePayload(payload []byte, cmdName string) ([]byte, error) {
|
||||
return m.exchangePayloadMode(false, payload, cmdName, readTimeout)
|
||||
}
|
||||
|
||||
func (m *managedHost) exchangePayloadPoll(payload []byte, cmdName string) ([]byte, error) {
|
||||
return m.exchangePayloadMode(true, payload, cmdName, readTimeout)
|
||||
}
|
||||
|
||||
func (m *managedHost) beginPausePoll() bool {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.otaActive || m.pausePoll {
|
||||
return false
|
||||
}
|
||||
m.pausePoll = true
|
||||
return true
|
||||
}
|
||||
|
||||
func (m *managedHost) endPausePoll() {
|
||||
m.mu.Lock()
|
||||
m.pausePoll = false
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
func (m *managedHost) PollPaused() bool {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.pausePoll || m.otaActive
|
||||
}
|
||||
|
||||
func (m *managedHost) exchangePayloadMode(poll bool, payload []byte, cmdName string, timeout time.Duration) ([]byte, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.otaActive || (poll && m.pausePoll) {
|
||||
return nil, errHostBusy
|
||||
}
|
||||
switch m.transport {
|
||||
case hostTransportBLE:
|
||||
if m.ble == nil {
|
||||
return nil, fmt.Errorf("BLE not connected")
|
||||
}
|
||||
resp, err := m.ble.exchangePayloadTimeout(payload, cmdName, timeout)
|
||||
if err != nil {
|
||||
m.invalidateLocked(err)
|
||||
}
|
||||
return resp, err
|
||||
case hostTransportUART, hostTransportNone:
|
||||
if m.uartPort == "" && m.sp == nil {
|
||||
return nil, fmt.Errorf("not connected")
|
||||
}
|
||||
if m.transport == hostTransportNone {
|
||||
m.transport = hostTransportUART
|
||||
m.endpoint = m.uartPort
|
||||
}
|
||||
if m.sp == nil {
|
||||
if err := m.openUARTLocked(); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", m.uartPort, err)
|
||||
}
|
||||
}
|
||||
resp, err := m.sp.exchangePayloadLocked(payload, cmdName, timeout)
|
||||
if err != nil {
|
||||
m.invalidateLocked(err)
|
||||
}
|
||||
return resp, err
|
||||
default:
|
||||
return nil, fmt.Errorf("not connected")
|
||||
}
|
||||
}
|
||||
|
||||
func (m *managedHost) exchange(cmdID byte, cmdName string) ([]byte, error) {
|
||||
return m.exchangePayload([]byte{cmdID}, cmdName)
|
||||
}
|
||||
|
||||
func (m *managedHost) exchangePoll(cmdID byte, cmdName string) ([]byte, error) {
|
||||
return m.exchangePayloadPoll([]byte{cmdID}, cmdName)
|
||||
}
|
||||
|
||||
func (m *managedHost) exchangePayloadVia(
|
||||
portFn func(func(*serialPort) error) error,
|
||||
payload []byte, cmdName string,
|
||||
) ([]byte, error) {
|
||||
// Prefer transport-aware path (works for BLE too).
|
||||
_ = portFn
|
||||
return m.exchangePayload(payload, cmdName)
|
||||
}
|
||||
|
||||
func (m *managedHost) exchangeVia(
|
||||
portFn func(func(*serialPort) error) error,
|
||||
cmdID byte, cmdName string,
|
||||
) ([]byte, error) {
|
||||
_ = portFn
|
||||
return m.exchange(cmdID, cmdName)
|
||||
}
|
||||
|
||||
func (m *managedHost) NextStreamTick(timeout time.Duration) ([]byte, error) {
|
||||
m.mu.Lock()
|
||||
ble := m.ble
|
||||
tr := m.transport
|
||||
m.mu.Unlock()
|
||||
if tr != hostTransportBLE || ble == nil {
|
||||
return nil, fmt.Errorf("BLE stream ticks require BLE host")
|
||||
}
|
||||
return ble.NextStreamTick(timeout)
|
||||
}
|
||||
|
||||
func (m *managedHost) HostStatus() map[string]any {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
connected := false
|
||||
switch m.transport {
|
||||
case hostTransportUART:
|
||||
connected = m.sp != nil
|
||||
case hostTransportBLE:
|
||||
connected = m.ble != nil
|
||||
}
|
||||
return map[string]any{
|
||||
"transport": m.transportOrNone(),
|
||||
"endpoint": m.endpoint,
|
||||
"connected": connected,
|
||||
"ble_name": m.bleName,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *managedHost) transportOrNone() string {
|
||||
if m.transport == "" {
|
||||
return hostTransportNone
|
||||
}
|
||||
return m.transport
|
||||
}
|
||||
|
||||
func listSerialPorts() ([]string, error) {
|
||||
ports, err := serial.GetPortsList()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ports == nil {
|
||||
return []string{}, nil
|
||||
}
|
||||
return ports, nil
|
||||
}
|
||||
|
||||
type bleScanDevice struct {
|
||||
Address string `json:"address"`
|
||||
Name string `json:"name"`
|
||||
RSSI int `json:"rssi"`
|
||||
}
|
||||
|
||||
func scanBLEDevices(timeout time.Duration, nameFilter string) ([]bleScanDevice, error) {
|
||||
if timeout <= 0 {
|
||||
timeout = 5 * time.Second
|
||||
}
|
||||
adapter := bluetooth.DefaultAdapter
|
||||
if err := adapter.Enable(); err != nil {
|
||||
return nil, fmt.Errorf("enable bluetooth: %w", err)
|
||||
}
|
||||
|
||||
seen := map[string]bleScanDevice{}
|
||||
var mu sync.Mutex
|
||||
go func() {
|
||||
time.Sleep(timeout)
|
||||
_ = adapter.StopScan()
|
||||
}()
|
||||
filter := strings.TrimSpace(nameFilter)
|
||||
_ = adapter.Scan(func(a *bluetooth.Adapter, result bluetooth.ScanResult) {
|
||||
name := result.LocalName()
|
||||
if filter != "" && !strings.Contains(strings.ToLower(name), strings.ToLower(filter)) {
|
||||
return
|
||||
}
|
||||
addr := result.Address.String()
|
||||
mu.Lock()
|
||||
seen[addr] = bleScanDevice{
|
||||
Address: addr,
|
||||
Name: name,
|
||||
RSSI: int(result.RSSI),
|
||||
}
|
||||
mu.Unlock()
|
||||
})
|
||||
// StopScan ends Scan; ignore error and return whatever was found.
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
out := make([]bleScanDevice, 0, len(seen))
|
||||
for _, d := range seen {
|
||||
out = append(out, d)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func disconnectedState(portName string, err error) DashboardState {
|
||||
msg := "host disconnected"
|
||||
if err != nil {
|
||||
msg = err.Error()
|
||||
}
|
||||
return DashboardState{
|
||||
UpdatedAt: time.Now().Format(time.RFC3339),
|
||||
SerialPort: portName,
|
||||
HostEndpoint: portName,
|
||||
UARTConnected: false,
|
||||
HostConnected: false,
|
||||
HostTransport: hostTransportNone,
|
||||
SerialOK: false,
|
||||
SerialError: msg,
|
||||
Master: MasterView{OK: false, Error: msg},
|
||||
Clients: []ClientView{},
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ const (
|
||||
ledRingModeBlink = 3
|
||||
ledRingModeFindMe = 4
|
||||
ledRingModeColor = 5
|
||||
ledRingModeBatteryLow = 6
|
||||
)
|
||||
|
||||
type ledRingAPIRequest struct {
|
||||
@@ -56,8 +57,10 @@ func ledRingModeFromString(s string) (uint32, error) {
|
||||
return ledRingModeBlink, nil
|
||||
case "find-me", "find_me", "findme":
|
||||
return ledRingModeFindMe, nil
|
||||
case "battery-low", "battery_low", "batterylow":
|
||||
return ledRingModeBatteryLow, nil
|
||||
default:
|
||||
return 0, fmt.Errorf("unknown mode %q (clear, color, progress, digit, blink, find-me)", s)
|
||||
return 0, fmt.Errorf("unknown mode %q (clear, color, progress, digit, blink, find-me, battery-low)", s)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+219
-30
@@ -5,33 +5,47 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
const defaultBaud = 921600
|
||||
|
||||
func usage() {
|
||||
fmt.Fprintf(os.Stderr, "usage: gotool [-port /dev/ttyUSB0] <command>\n")
|
||||
fmt.Fprintf(os.Stderr, "usage: gotool [-port /dev/ttyUSB0 | -ble] <command>\n")
|
||||
fmt.Fprintf(os.Stderr, " DIP8 on master: 0=UART (-port), 1=BLE (-ble); reboot after DIP change\n")
|
||||
fmt.Fprintf(os.Stderr, " test uses uart.master from bench config when -port is omitted\n\n")
|
||||
fmt.Fprintf(os.Stderr, "commands:\n")
|
||||
fmt.Fprintf(os.Stderr, " version firmware version and git hash\n")
|
||||
fmt.Fprintf(os.Stderr, " clients registered ESP-NOW slaves on the master\n")
|
||||
fmt.Fprintf(os.Stderr, " clients registered ESP-NOW slaves on the master\n")
|
||||
fmt.Fprintf(os.Stderr, " clients-reset-ids clear master id map + live registry (notifies online slaves)\n")
|
||||
fmt.Fprintf(os.Stderr, " deadzone get/set accelerometer deadzone (LSB)\n")
|
||||
fmt.Fprintf(os.Stderr, " tap-config get/set BMA456 tap detection parameters\n")
|
||||
fmt.Fprintf(os.Stderr, " tap-notify get/set which tap kinds notify via ESP-NOW\n")
|
||||
fmt.Fprintf(os.Stderr, " cache-status subscribed accel + tap cache (one UART round-trip)\n")
|
||||
fmt.Fprintf(os.Stderr, " cache-status subscribed accel + tap cache (one round-trip)\n")
|
||||
fmt.Fprintf(os.Stderr, " accel-stream enable/disable ESP-NOW accel stream (+ BLE period)\n")
|
||||
fmt.Fprintf(os.Stderr, " unicast-test send ESP-NOW unicast test to one slave\n")
|
||||
fmt.Fprintf(os.Stderr, " echo-ping ESP-NOW timestamp echo round-trip to one slave\n")
|
||||
fmt.Fprintf(os.Stderr, " ble-rtt Host↔Master RTT via VERSION (N samples)\n")
|
||||
fmt.Fprintf(os.Stderr, " ble-stream enable stream + measure BleStreamTick window\n")
|
||||
fmt.Fprintf(os.Stderr, " test run automated scenario (see testdata/)\n")
|
||||
fmt.Fprintf(os.Stderr, " serve web dashboard (Bootstrap + WebSocket)\n")
|
||||
fmt.Fprintf(os.Stderr, " ota UART OTA upload (A/B partitions)\n")
|
||||
fmt.Fprintf(os.Stderr, " serve web dashboard (optional -port / -ble; connect from UI)\n")
|
||||
fmt.Fprintf(os.Stderr, " ota host OTA upload (A/B partitions; UART or BLE)\n")
|
||||
fmt.Fprintf(os.Stderr, " ota-progress query per-slave ESP-NOW OTA progress on master\n")
|
||||
fmt.Fprintf(os.Stderr, " led-ring set LED ring progress bar (0–100%%, rgb, intensity)\n")
|
||||
fmt.Fprintf(os.Stderr, " find-me blink LED ring red/green/blue (3× each, full brightness)\n")
|
||||
fmt.Fprintf(os.Stderr, " restart reboot master or slave (ESP-NOW)\n\n")
|
||||
fmt.Fprintf(os.Stderr, " restart reboot master or slave (ESP-NOW)\n")
|
||||
fmt.Fprintf(os.Stderr, " log-level get/set master ESP-IDF log level (global)\n")
|
||||
fmt.Fprintf(os.Stderr, " battery-uv get/set software LiPo UV protection (per pod / all)\n")
|
||||
fmt.Fprintf(os.Stderr, " input-send-mode get/set slave accel/tap ESP-NOW send path\n\n")
|
||||
flag.PrintDefaults()
|
||||
}
|
||||
|
||||
func main() {
|
||||
portName := flag.String("port", "", "serial port (e.g. /dev/ttyUSB0)")
|
||||
portName := flag.String("port", "", "serial port (e.g. /dev/ttyUSB0); XOR with -ble")
|
||||
baud := flag.Int("baud", defaultBaud, "UART baud rate")
|
||||
useBLE := flag.Bool("ble", false, "use BLE host transport (DIP8=1); XOR with -port")
|
||||
bleName := flag.String("ble-name", bleDefaultName, "BLE advertised name")
|
||||
bleSettle := flag.Float64("ble-settle", 1.0, "seconds to settle after BLE connect")
|
||||
flag.Parse()
|
||||
|
||||
if flag.NArg() < 1 {
|
||||
@@ -44,50 +58,152 @@ func main() {
|
||||
var runErr error
|
||||
switch cmd {
|
||||
case "test", "autotest":
|
||||
if *useBLE {
|
||||
runErr = fmt.Errorf("autotest requires -port (UART)")
|
||||
break
|
||||
}
|
||||
runErr = runTest(*portName, *baud, flag.Args()[1:])
|
||||
case "serve", "web", "dashboard":
|
||||
if *portName == "" {
|
||||
fmt.Fprintf(os.Stderr, "command %q requires -port\n\n", cmd)
|
||||
if *useBLE && *portName != "" {
|
||||
fmt.Fprintf(os.Stderr, "use either -port or -ble, not both\n")
|
||||
os.Exit(2)
|
||||
}
|
||||
runErr = runServe(serveOpts{
|
||||
portName: *portName,
|
||||
baud: *baud,
|
||||
useBLE: *useBLE,
|
||||
bleName: *bleName,
|
||||
settle: time.Duration(*bleSettle * float64(time.Second)),
|
||||
}, flag.Args()[1:])
|
||||
case "version", "clients", "client-info", "clients-reset-ids", "clients_reset_ids", "deadzone", "accel-deadzone", "tap-config", "tap_config", "tap-notify", "tap_notify", "cache-status", "cache_status", "accel-stream", "accel_stream", "unicast-test", "unicast_test", "echo-ping", "echo_ping", "ble-rtt", "ble_rtt", "ble-stream", "ble_stream", "led-ring", "led_ring", "find-me", "find_me", "restart", "log-level", "log_level", "input-send-mode", "input_send_mode", "ota", "ota-progress", "ota_progress":
|
||||
if *useBLE && *portName != "" {
|
||||
fmt.Fprintf(os.Stderr, "use either -port or -ble, not both\n")
|
||||
os.Exit(2)
|
||||
}
|
||||
if !*useBLE && *portName == "" {
|
||||
fmt.Fprintf(os.Stderr, "command %q requires -port or -ble\n\n", cmd)
|
||||
usage()
|
||||
os.Exit(2)
|
||||
}
|
||||
runErr = runServe(*portName, *baud, flag.Args()[1:])
|
||||
case "version", "clients", "client-info", "deadzone", "accel-deadzone", "tap-notify", "tap_notify", "cache-status", "cache_status", "unicast-test", "unicast_test", "led-ring", "led_ring", "find-me", "find_me", "restart", "ota", "ota-progress", "ota_progress":
|
||||
if *portName == "" {
|
||||
fmt.Fprintf(os.Stderr, "command %q requires -port\n\n", cmd)
|
||||
usage()
|
||||
os.Exit(2)
|
||||
var h hostConn
|
||||
if *useBLE {
|
||||
bl, err := openBLE(*bleName, time.Duration(*bleSettle*float64(time.Second)))
|
||||
if err != nil {
|
||||
log.Fatalf("open BLE: %v", err)
|
||||
}
|
||||
h = bl
|
||||
} else {
|
||||
sp, err := openSerial(*portName, *baud)
|
||||
if err != nil {
|
||||
log.Fatalf("open serial: %v", err)
|
||||
}
|
||||
h = sp
|
||||
}
|
||||
sp, err := openSerial(*portName, *baud)
|
||||
if err != nil {
|
||||
log.Fatalf("open serial: %v", err)
|
||||
}
|
||||
registerShutdown(func() { _ = sp.Close() })
|
||||
registerShutdown(func() { _ = h.Close() })
|
||||
enableShutdownOnInterrupt()
|
||||
defer sp.Close()
|
||||
defer h.Close()
|
||||
|
||||
switch cmd {
|
||||
case "version":
|
||||
runErr = runVersion(sp)
|
||||
runErr = runVersionHost(h)
|
||||
case "clients", "client-info":
|
||||
runErr = runClients(sp)
|
||||
case "deadzone", "accel-deadzone":
|
||||
runErr = runDeadzone(sp, flag.Args()[1:])
|
||||
case "tap-notify", "tap_notify":
|
||||
runErr = runTapNotify(sp, flag.Args()[1:])
|
||||
runErr = runClientsHost(h)
|
||||
case "clients-reset-ids", "clients_reset_ids":
|
||||
sp, ok := h.(*serialPort)
|
||||
if !ok {
|
||||
runErr = fmt.Errorf("%s over BLE not wired yet; use -port", cmd)
|
||||
break
|
||||
}
|
||||
runErr = runClientsResetIds(sp)
|
||||
case "cache-status", "cache_status":
|
||||
runErr = runCacheStatus(sp)
|
||||
runErr = runCacheStatusHost(h)
|
||||
case "accel-stream", "accel_stream":
|
||||
runErr = runAccelStreamCmd(h, flag.Args()[1:])
|
||||
case "echo-ping", "echo_ping":
|
||||
runErr = runEchoPingHost(h, flag.Args()[1:])
|
||||
case "ble-rtt", "ble_rtt":
|
||||
runErr = runBleRtt(h, flag.Args()[1:])
|
||||
case "ble-stream", "ble_stream":
|
||||
runErr = runBleStream(h, flag.Args()[1:])
|
||||
case "deadzone", "accel-deadzone":
|
||||
sp, ok := h.(*serialPort)
|
||||
if !ok {
|
||||
runErr = fmt.Errorf("%s over BLE not wired yet; use -port", cmd)
|
||||
break
|
||||
}
|
||||
runErr = runDeadzone(sp, flag.Args()[1:])
|
||||
case "tap-config", "tap_config":
|
||||
sp, ok := h.(*serialPort)
|
||||
if !ok {
|
||||
runErr = fmt.Errorf("%s over BLE not wired yet; use -port", cmd)
|
||||
break
|
||||
}
|
||||
runErr = runTapConfig(sp, flag.Args()[1:])
|
||||
case "battery-uv", "battery_uv":
|
||||
sp, ok := h.(*serialPort)
|
||||
if !ok {
|
||||
runErr = fmt.Errorf("%s over BLE not wired yet; use -port", cmd)
|
||||
break
|
||||
}
|
||||
runErr = runBatteryUv(sp, flag.Args()[1:])
|
||||
case "tap-notify", "tap_notify":
|
||||
sp, ok := h.(*serialPort)
|
||||
if !ok {
|
||||
runErr = fmt.Errorf("%s over BLE not wired yet; use -port", cmd)
|
||||
break
|
||||
}
|
||||
runErr = runTapNotify(sp, flag.Args()[1:])
|
||||
case "unicast-test", "unicast_test":
|
||||
sp, ok := h.(*serialPort)
|
||||
if !ok {
|
||||
runErr = fmt.Errorf("%s over BLE not wired yet; use -port", cmd)
|
||||
break
|
||||
}
|
||||
runErr = runUnicastTest(sp, flag.Args()[1:])
|
||||
case "led-ring", "led_ring":
|
||||
sp, ok := h.(*serialPort)
|
||||
if !ok {
|
||||
runErr = fmt.Errorf("%s over BLE not wired yet; use -port", cmd)
|
||||
break
|
||||
}
|
||||
runErr = runLedRing(sp, flag.Args()[1:])
|
||||
case "find-me", "find_me":
|
||||
sp, ok := h.(*serialPort)
|
||||
if !ok {
|
||||
runErr = fmt.Errorf("%s over BLE not wired yet; use -port", cmd)
|
||||
break
|
||||
}
|
||||
runErr = runFindMe(sp, flag.Args()[1:])
|
||||
case "restart":
|
||||
sp, ok := h.(*serialPort)
|
||||
if !ok {
|
||||
runErr = fmt.Errorf("%s over BLE not wired yet; use -port", cmd)
|
||||
break
|
||||
}
|
||||
runErr = runRestart(sp, flag.Args()[1:])
|
||||
case "log-level", "log_level":
|
||||
sp, ok := h.(*serialPort)
|
||||
if !ok {
|
||||
runErr = fmt.Errorf("%s over BLE not wired yet; use -port", cmd)
|
||||
break
|
||||
}
|
||||
runErr = runLogLevel(sp, flag.Args()[1:])
|
||||
case "input-send-mode", "input_send_mode":
|
||||
runErr = runInputSendModeCmd(h, flag.Args()[1:])
|
||||
case "ota":
|
||||
runErr = runOTA(sp, flag.Args()[1:])
|
||||
t, ok := h.(otaTransport)
|
||||
if !ok {
|
||||
runErr = fmt.Errorf("ota: unsupported host transport")
|
||||
break
|
||||
}
|
||||
runErr = runOTA(t, flag.Args()[1:])
|
||||
case "ota-progress", "ota_progress":
|
||||
runErr = runOtaProgress(sp, flag.Args()[1:])
|
||||
t, ok := h.(otaTransport)
|
||||
if !ok {
|
||||
runErr = fmt.Errorf("ota-progress: unsupported host transport")
|
||||
break
|
||||
}
|
||||
runErr = runOtaProgress(t, flag.Args()[1:])
|
||||
}
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "unknown command %q\n\n", cmd)
|
||||
@@ -99,3 +215,76 @@ func main() {
|
||||
log.Fatal(runErr)
|
||||
}
|
||||
}
|
||||
|
||||
func runVersionHost(h hostConn) error {
|
||||
ver, err := hostGetVersion(h)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("version: %d\n", ver.GetVersion())
|
||||
fmt.Printf("git_hash: %s\n", ver.GetGitHash())
|
||||
return nil
|
||||
}
|
||||
|
||||
func runClientsHost(h hostConn) error {
|
||||
clients, err := hostListClients(h)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(clients) == 0 {
|
||||
fmt.Println("no clients registered")
|
||||
return nil
|
||||
}
|
||||
fmt.Printf("clients (%d):\n", len(clients))
|
||||
for i, c := range clients {
|
||||
fmt.Printf(" [%d] id=%d ver=%d available=%v used=%v\n",
|
||||
i, c.GetId(), c.GetVersion(), c.GetAvailable(), c.GetUsed())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runCacheStatusHost(h hostConn) error {
|
||||
r, err := hostReadCacheStatus(h)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
clients := r.GetClients()
|
||||
if len(clients) == 0 {
|
||||
fmt.Println("(no slaves with accel stream or tap notify enabled)")
|
||||
return nil
|
||||
}
|
||||
for _, c := range clients {
|
||||
id := c.GetClientId()
|
||||
if a := c.GetAccel(); a != nil {
|
||||
if !a.GetValid() {
|
||||
fmt.Printf("client %d accel: no sample yet\n", id)
|
||||
} else {
|
||||
fmt.Printf("client %d accel: x=%d y=%d z=%d (age %d ms)\n",
|
||||
id, a.GetX(), a.GetY(), a.GetZ(), a.GetAgeMs())
|
||||
}
|
||||
}
|
||||
if t := c.GetTap(); t != nil {
|
||||
fmt.Printf("client %d tap: %s (age %d ms)\n",
|
||||
id, tapKindLabel(t.GetKind()), t.GetAgeMs())
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runEchoPingHost(h hostConn, args []string) error {
|
||||
fs := flag.NewFlagSet("echo-ping", flag.ExitOnError)
|
||||
clientID := fs.Uint("client", 0, "slave client id from `clients`")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
if *clientID == 0 {
|
||||
return fmt.Errorf("client id required (see `gotool clients`)")
|
||||
}
|
||||
r, err := hostEchoPing(h, uint32(*clientID))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("echo ping: success=%v client_id=%d rtt_ms=%.3f (host↔master↔slave) esp_rtt_us=%d (ESP-NOW only)\n",
|
||||
r.Success, r.ClientID, r.RttMs, r.EspRttUs)
|
||||
return nil
|
||||
}
|
||||
|
||||
+194
-138
@@ -6,20 +6,27 @@ import (
|
||||
|
||||
"google.golang.org/protobuf/proto"
|
||||
|
||||
uartframe "powerpod/gotool/uart"
|
||||
"powerpod/gotool/pb"
|
||||
uartframe "powerpod/gotool/uart"
|
||||
)
|
||||
|
||||
const (
|
||||
otaHostChunkSize = 200
|
||||
otaFlashBlockSize = 4096
|
||||
otaPrepareTimeout = 120 * time.Second
|
||||
otaHostChunkSize = 200
|
||||
otaFlashBlockSize = 4096
|
||||
otaPrepareTimeout = 120 * time.Second
|
||||
otaDefaultTimeout = 15 * time.Second
|
||||
otaStatusPollTimeout = 3 * time.Second
|
||||
otaDistReadTimeout = 400 * time.Millisecond
|
||||
otaDistQueryInterval = 500 * time.Millisecond
|
||||
otaDistQueryTimeout = 2 * time.Second
|
||||
otaDistEmitMinInterval = 150 * time.Millisecond
|
||||
// Pace host chunks so the master UART RX ring is not overrun (~20 frames/block).
|
||||
otaHostChunkPaceUART = 5 * time.Millisecond
|
||||
// Slightly slower for BLE WriteWithoutResponse flow control.
|
||||
otaHostChunkPaceBLE = 6 * time.Millisecond
|
||||
otaBlockMaxRetries = 3
|
||||
// 200 B OTA_PAYLOAD → ~216 B framed; needs ATT MTU >= 219.
|
||||
otaMinATTMTU = 219
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -34,6 +41,14 @@ const (
|
||||
otaDistTimeout = 45 * time.Minute
|
||||
)
|
||||
|
||||
// otaTransport is the host IO used by the OTA protocol (UART or BLE).
|
||||
type otaTransport interface {
|
||||
writeMsg(msg *pb.UartMessage) error
|
||||
readFrame(timeout time.Duration) ([]byte, error)
|
||||
flushInput()
|
||||
chunkPace() time.Duration
|
||||
}
|
||||
|
||||
// OtaSlaveDetail is per-slave ESP-NOW OTA state from OTA_SLAVE_PROGRESS.
|
||||
type OtaSlaveDetail struct {
|
||||
BytesWritten uint32 `json:"bytes_written"`
|
||||
@@ -44,20 +59,20 @@ type OtaSlaveDetail struct {
|
||||
|
||||
// OTAProgress is pushed to the dashboard during web uploads.
|
||||
type OTAProgress struct {
|
||||
Type string `json:"type"` // always "ota_progress"
|
||||
Phase string `json:"phase"`
|
||||
Step string `json:"step,omitempty"` // master, slaves
|
||||
Percent int `json:"percent"`
|
||||
MasterPercent int `json:"master_percent,omitempty"`
|
||||
MasterDone bool `json:"master_done,omitempty"`
|
||||
Message string `json:"message"`
|
||||
MasterMessage string `json:"master_message,omitempty"`
|
||||
Bytes uint32 `json:"bytes_written,omitempty"`
|
||||
Slot uint32 `json:"target_slot,omitempty"`
|
||||
Slaves uint32 `json:"slaves,omitempty"`
|
||||
ImageSize uint32 `json:"image_size,omitempty"`
|
||||
SlaveProgress map[uint32]uint32 `json:"slave_progress,omitempty"` // client_id -> bytes
|
||||
SlaveDetails map[uint32]OtaSlaveDetail `json:"slave_details,omitempty"`
|
||||
Type string `json:"type"` // always "ota_progress"
|
||||
Phase string `json:"phase"`
|
||||
Step string `json:"step,omitempty"` // master, slaves
|
||||
Percent int `json:"percent"`
|
||||
MasterPercent int `json:"master_percent,omitempty"`
|
||||
MasterDone bool `json:"master_done,omitempty"`
|
||||
Message string `json:"message"`
|
||||
MasterMessage string `json:"master_message,omitempty"`
|
||||
Bytes uint32 `json:"bytes_written,omitempty"`
|
||||
Slot uint32 `json:"target_slot,omitempty"`
|
||||
Slaves uint32 `json:"slaves,omitempty"`
|
||||
ImageSize uint32 `json:"image_size,omitempty"`
|
||||
SlaveProgress map[uint32]uint32 `json:"slave_progress,omitempty"` // client_id -> bytes
|
||||
SlaveDetails map[uint32]OtaSlaveDetail `json:"slave_details,omitempty"`
|
||||
}
|
||||
|
||||
type otaProgressFn func(OTAProgress)
|
||||
@@ -77,27 +92,52 @@ func runOTAUpload(m *managedSerial, firmware []byte, onProgress otaProgressFn) e
|
||||
Percent: 0, Message: msg, MasterMessage: msg,
|
||||
})
|
||||
}
|
||||
push("preparing", "UART wird vorbereitet…")
|
||||
push("preparing", "Host wird für OTA vorbereitet…")
|
||||
|
||||
// Block until the UART is free, then hold m.mu for the entire upload so
|
||||
// dashboard/API polling cannot interleave on the serial port.
|
||||
// Hold m.mu for the entire upload so dashboard/API polling cannot interleave.
|
||||
m.mu.Lock()
|
||||
if m.otaActive {
|
||||
m.mu.Unlock()
|
||||
return errOTAInProgress
|
||||
}
|
||||
m.otaActive = true
|
||||
if m.sp == nil {
|
||||
if err := m.openLocked(); err != nil {
|
||||
|
||||
var t otaTransport
|
||||
switch m.transport {
|
||||
case hostTransportBLE:
|
||||
if m.ble == nil {
|
||||
m.otaActive = false
|
||||
m.mu.Unlock()
|
||||
err := fmt.Errorf("BLE not connected")
|
||||
push("error", err.Error())
|
||||
return err
|
||||
}
|
||||
if err := m.ble.ensureOTAMTU(); err != nil {
|
||||
m.otaActive = false
|
||||
m.mu.Unlock()
|
||||
push("error", err.Error())
|
||||
return err
|
||||
}
|
||||
t = m.ble
|
||||
case hostTransportUART, hostTransportNone:
|
||||
if m.sp == nil {
|
||||
if err := m.openLocked(); err != nil {
|
||||
m.otaActive = false
|
||||
m.mu.Unlock()
|
||||
push("error", err.Error())
|
||||
return err
|
||||
}
|
||||
}
|
||||
t = m.sp
|
||||
default:
|
||||
m.otaActive = false
|
||||
m.mu.Unlock()
|
||||
err := fmt.Errorf("not connected")
|
||||
push("error", err.Error())
|
||||
return err
|
||||
}
|
||||
sp := m.sp
|
||||
|
||||
err := runOTAOnPortUnlocked(sp, firmware, onProgress)
|
||||
err := runOTAUnlocked(t, firmware, onProgress)
|
||||
if err != nil {
|
||||
m.invalidateLocked(err)
|
||||
}
|
||||
@@ -106,7 +146,7 @@ func runOTAUpload(m *managedSerial, firmware []byte, onProgress otaProgressFn) e
|
||||
return err
|
||||
}
|
||||
|
||||
func runOTAOnPortUnlocked(sp *serialPort, firmware []byte, onProgress otaProgressFn) error {
|
||||
func runOTAUnlocked(t otaTransport, firmware []byte, onProgress otaProgressFn) error {
|
||||
if len(firmware) == 0 {
|
||||
return fmt.Errorf("empty firmware")
|
||||
}
|
||||
@@ -148,16 +188,11 @@ func runOTAOnPortUnlocked(sp *serialPort, firmware []byte, onProgress otaProgres
|
||||
onProgress(p)
|
||||
}
|
||||
|
||||
if err := sp.port.SetReadTimeout(readTimeout); err != nil {
|
||||
notify("error", "", 0, err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
notify("preparing", otaStepMaster, 0, fmt.Sprintf("Master: OTA start (%d bytes)…", imageSize))
|
||||
|
||||
flushSerialInput(sp)
|
||||
t.flushInput()
|
||||
|
||||
if err := writeUartMessage(sp, &pb.UartMessage{
|
||||
if err := t.writeMsg(&pb.UartMessage{
|
||||
Type: pb.MessageType_OTA_START,
|
||||
Payload: &pb.UartMessage_OtaStart{
|
||||
OtaStart: &pb.OtaStartPayload{TotalSize: uint32(imageSize)},
|
||||
@@ -167,13 +202,7 @@ func runOTAOnPortUnlocked(sp *serialPort, firmware []byte, onProgress otaProgres
|
||||
return err
|
||||
}
|
||||
|
||||
if err := sp.port.SetReadTimeout(otaPrepareTimeout); err != nil {
|
||||
notify("error", "", 0, err.Error())
|
||||
return err
|
||||
}
|
||||
defer func() { _ = sp.port.SetReadTimeout(readTimeout) }()
|
||||
|
||||
ready, err := waitOtaStatus(sp, otaStReady, otaPrepareTimeout, func(msg string) {
|
||||
ready, err := waitOtaStatus(t, otaStReady, otaPrepareTimeout, func(msg string) {
|
||||
notify("preparing", otaStepMaster, 2, msg)
|
||||
})
|
||||
if err != nil {
|
||||
@@ -182,66 +211,93 @@ func runOTAOnPortUnlocked(sp *serialPort, firmware []byte, onProgress otaProgres
|
||||
}
|
||||
notify("ready", otaStepMaster, 5, fmt.Sprintf("Master: Slot %d bereit", ready.GetTargetSlot()))
|
||||
|
||||
if err := sp.port.SetReadTimeout(otaDefaultTimeout); err != nil {
|
||||
notify("error", "", 0, err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
pace := t.chunkPace()
|
||||
var seq uint32
|
||||
for offset := 0; offset < imageSize; {
|
||||
bytesInBlock := 0
|
||||
for bytesInBlock < otaFlashBlockSize && offset < imageSize {
|
||||
n := otaHostChunkSize
|
||||
room := otaFlashBlockSize - bytesInBlock
|
||||
if n > room {
|
||||
n = room
|
||||
}
|
||||
if offset+n > imageSize {
|
||||
n = imageSize - offset
|
||||
}
|
||||
chunk := firmware[offset : offset+n]
|
||||
blockStart := offset
|
||||
blockStartSeq := seq
|
||||
|
||||
if err := writeUartMessage(sp, &pb.UartMessage{
|
||||
Type: pb.MessageType_OTA_PAYLOAD,
|
||||
Payload: &pb.UartMessage_OtaPayload{
|
||||
OtaPayload: &pb.OtaPayload{Seq: seq, Data: chunk},
|
||||
},
|
||||
}); err != nil {
|
||||
notify("error", "", 0, err.Error())
|
||||
return err
|
||||
}
|
||||
seq++
|
||||
offset += n
|
||||
bytesInBlock += n
|
||||
sendBlock := func() (fullBlock bool, err error) {
|
||||
bytesInBlock := 0
|
||||
for bytesInBlock < otaFlashBlockSize && offset < imageSize {
|
||||
n := otaHostChunkSize
|
||||
room := otaFlashBlockSize - bytesInBlock
|
||||
if n > room {
|
||||
n = room
|
||||
}
|
||||
if offset+n > imageSize {
|
||||
n = imageSize - offset
|
||||
}
|
||||
chunk := firmware[offset : offset+n]
|
||||
|
||||
pct := offset * 100 / imageSize
|
||||
if pct > 99 {
|
||||
pct = 99
|
||||
if err := t.writeMsg(&pb.UartMessage{
|
||||
Type: pb.MessageType_OTA_PAYLOAD,
|
||||
Payload: &pb.UartMessage_OtaPayload{
|
||||
OtaPayload: &pb.OtaPayload{Seq: seq, Data: chunk},
|
||||
},
|
||||
}); err != nil {
|
||||
return false, err
|
||||
}
|
||||
time.Sleep(pace)
|
||||
seq++
|
||||
offset += n
|
||||
bytesInBlock += n
|
||||
|
||||
pct := offset * 100 / imageSize
|
||||
if pct > 99 {
|
||||
pct = 99
|
||||
}
|
||||
notify("uploading", otaStepMaster, pct,
|
||||
fmt.Sprintf("Master: %d / %d bytes", offset, imageSize))
|
||||
}
|
||||
notify("uploading", otaStepMaster, pct, fmt.Sprintf("Master: %d / %d bytes", offset, imageSize))
|
||||
return bytesInBlock == otaFlashBlockSize, nil
|
||||
}
|
||||
|
||||
if bytesInBlock == otaFlashBlockSize {
|
||||
st, err := waitOtaStatus(sp, otaStBlockAck, otaDefaultTimeout, nil)
|
||||
if err != nil {
|
||||
notify("error", "", 0, err.Error())
|
||||
return err
|
||||
}
|
||||
pct := offset * 100 / imageSize
|
||||
if pct > 99 {
|
||||
pct = 99
|
||||
}
|
||||
notify("uploading", otaStepMaster, pct,
|
||||
fmt.Sprintf("Master: Block geschrieben (%d bytes)", st.GetBytesWritten()),
|
||||
OTAProgress{Bytes: st.GetBytesWritten()})
|
||||
fullBlock, err := sendBlock()
|
||||
if err != nil {
|
||||
notify("error", "", 0, err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
if !fullBlock {
|
||||
continue
|
||||
}
|
||||
|
||||
var st *pb.OtaStatusPayload
|
||||
var ackErr error
|
||||
for attempt := 0; attempt < otaBlockMaxRetries; attempt++ {
|
||||
if attempt > 0 {
|
||||
offset = blockStart
|
||||
seq = blockStartSeq
|
||||
if _, err := sendBlock(); err != nil {
|
||||
notify("error", "", 0, err.Error())
|
||||
return err
|
||||
}
|
||||
}
|
||||
st, ackErr = waitOtaStatus(t, otaStBlockAck, otaDefaultTimeout, nil)
|
||||
if ackErr == nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
if ackErr != nil {
|
||||
notify("error", "", 0, ackErr.Error())
|
||||
return ackErr
|
||||
}
|
||||
|
||||
pct := offset * 100 / imageSize
|
||||
if pct > 99 {
|
||||
pct = 99
|
||||
}
|
||||
notify("uploading", otaStepMaster, pct,
|
||||
fmt.Sprintf("Master: Block geschrieben (%d bytes)", st.GetBytesWritten()),
|
||||
OTAProgress{Bytes: st.GetBytesWritten()})
|
||||
}
|
||||
|
||||
masterPct = 100
|
||||
masterMsg = "Master: UART-Upload abgeschlossen"
|
||||
masterMsg = "Master: Upload abgeschlossen"
|
||||
notify("uploading", otaStepMaster, 100, masterMsg)
|
||||
|
||||
if err := writeUartMessage(sp, &pb.UartMessage{
|
||||
if err := t.writeMsg(&pb.UartMessage{
|
||||
Type: pb.MessageType_OTA_END,
|
||||
Payload: &pb.UartMessage_OtaEnd{
|
||||
OtaEnd: &pb.OtaEndPayload{},
|
||||
@@ -280,7 +336,8 @@ func runOTAOnPortUnlocked(sp *serialPort, firmware []byte, onProgress otaProgres
|
||||
}
|
||||
}
|
||||
if slaveCount == 0 {
|
||||
return "Keine verfügbaren Slaves — Verteilung übersprungen", 0, 0
|
||||
// Do not claim "skipped" mid-flight — details often arrive after the first query.
|
||||
return "ESP-NOW: warte auf Slave-Fortschritt…", 0, 0
|
||||
}
|
||||
return fmt.Sprintf("ESP-NOW: %d / %d bytes (%d Slaves)",
|
||||
aggBytes, imageSize, slaveCount), aggBytes, slaveCount
|
||||
@@ -300,7 +357,7 @@ func runOTAOnPortUnlocked(sp *serialPort, firmware []byte, onProgress otaProgres
|
||||
return
|
||||
}
|
||||
lastQuery = time.Now()
|
||||
prog, err := queryOtaSlaveProgressLocked(sp, 0, onDistStatus, otaDistQueryTimeout)
|
||||
prog, err := queryOtaSlaveProgress(t, 0, onDistStatus, otaDistQueryTimeout)
|
||||
if err != nil {
|
||||
if len(slaveDetails) > 0 {
|
||||
emitSlaveThrottled(true)
|
||||
@@ -322,17 +379,17 @@ func runOTAOnPortUnlocked(sp *serialPort, firmware []byte, onProgress otaProgres
|
||||
|
||||
lastQuery = time.Time{} // first query immediately when distribution starts
|
||||
querySlaveProgress()
|
||||
st, err := waitOtaComplete(sp, otaDistTimeout, pushSlaveDist, onWaitTick, otaDistReadTimeout)
|
||||
st, err := waitOtaComplete(t, otaDistTimeout, pushSlaveDist, onWaitTick, otaDistReadTimeout)
|
||||
if err != nil {
|
||||
notify("error", "", 0, err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
if prog, err := queryOtaSlaveProgressLocked(sp, 0, nil, otaDistQueryTimeout); err == nil {
|
||||
if prog, err := queryOtaSlaveProgress(t, 0, nil, otaDistQueryTimeout); err == nil {
|
||||
mergeSlaveProgressResponse(prog, slaveBytes, slaveDetails)
|
||||
}
|
||||
notify("done", "", 100,
|
||||
fmt.Sprintf("Fertig — %d bytes, Boot-Slot %d. Master und Slaves neu starten.",
|
||||
fmt.Sprintf("Fertig — %d bytes, Boot-Slot %d. Geräte starten neu auf die neue Firmware.",
|
||||
st.GetBytesWritten(), st.GetTargetSlot()),
|
||||
OTAProgress{
|
||||
Bytes: st.GetBytesWritten(), Slot: st.GetTargetSlot(),
|
||||
@@ -344,13 +401,11 @@ func runOTAOnPortUnlocked(sp *serialPort, firmware []byte, onProgress otaProgres
|
||||
}
|
||||
|
||||
// QueryOtaSlaveProgress queries the master for per-slave ESP-NOW OTA progress.
|
||||
func QueryOtaSlaveProgress(sp *serialPort, clientID uint32) (*pb.OtaSlaveProgressResponse, error) {
|
||||
sp.mu.Lock()
|
||||
defer sp.mu.Unlock()
|
||||
return queryOtaSlaveProgressLocked(sp, clientID, nil, otaDefaultTimeout)
|
||||
func QueryOtaSlaveProgress(t otaTransport, clientID uint32) (*pb.OtaSlaveProgressResponse, error) {
|
||||
return queryOtaSlaveProgress(t, clientID, nil, otaDefaultTimeout)
|
||||
}
|
||||
|
||||
func queryOtaSlaveProgressLocked(sp *serialPort, clientID uint32,
|
||||
func queryOtaSlaveProgress(t otaTransport, clientID uint32,
|
||||
onStatus func(*pb.OtaStatusPayload), queryTimeout time.Duration) (*pb.OtaSlaveProgressResponse, error) {
|
||||
req := &pb.UartMessage{
|
||||
Type: pb.MessageType_OTA_SLAVE_PROGRESS,
|
||||
@@ -360,14 +415,14 @@ func queryOtaSlaveProgressLocked(sp *serialPort, clientID uint32,
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := writeUartMessage(sp, req); err != nil {
|
||||
if err := t.writeMsg(req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if queryTimeout <= 0 {
|
||||
queryTimeout = otaDefaultTimeout
|
||||
}
|
||||
deadline := time.Now().Add(queryTimeout)
|
||||
msg, err := readUartMessageUntil(sp, deadline, pb.MessageType_OTA_SLAVE_PROGRESS, onStatus, otaDistReadTimeout)
|
||||
msg, err := readUartMessageUntil(t, deadline, pb.MessageType_OTA_SLAVE_PROGRESS, onStatus, otaDistReadTimeout)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -400,7 +455,7 @@ func applyDistributingOtaStatus(st *pb.OtaStatusPayload, imageSize int,
|
||||
details[id] = d
|
||||
}
|
||||
|
||||
func readUartMessageUntil(sp *serialPort, deadline time.Time, want pb.MessageType,
|
||||
func readUartMessageUntil(t otaTransport, deadline time.Time, want pb.MessageType,
|
||||
onStatus func(*pb.OtaStatusPayload), readChunk time.Duration) (*pb.UartMessage, error) {
|
||||
if readChunk <= 0 {
|
||||
readChunk = otaStatusPollTimeout
|
||||
@@ -413,10 +468,7 @@ func readUartMessageUntil(sp *serialPort, deadline time.Time, want pb.MessageTyp
|
||||
if wait > readChunk {
|
||||
wait = readChunk
|
||||
}
|
||||
if err := sp.port.SetReadTimeout(wait); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
payload, err := uartframe.ReadFrame(sp.port, nil, wait)
|
||||
payload, err := t.readFrame(wait)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -471,7 +523,7 @@ func copySlaveMap(m map[uint32]uint32) map[uint32]uint32 {
|
||||
return out
|
||||
}
|
||||
|
||||
func waitOtaComplete(sp *serialPort, timeout time.Duration,
|
||||
func waitOtaComplete(t otaTransport, timeout time.Duration,
|
||||
onDistributing func(*pb.OtaStatusPayload), onInterval func(),
|
||||
readTimeout time.Duration) (*pb.OtaStatusPayload, error) {
|
||||
if readTimeout <= 0 {
|
||||
@@ -486,10 +538,7 @@ func waitOtaComplete(sp *serialPort, timeout time.Duration,
|
||||
if readWait > readTimeout {
|
||||
readWait = readTimeout
|
||||
}
|
||||
if err := sp.port.SetReadTimeout(readWait); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
st, err := readOtaStatus(sp)
|
||||
st, err := readOtaStatus(t, readWait)
|
||||
if err != nil {
|
||||
if onInterval != nil {
|
||||
onInterval()
|
||||
@@ -514,16 +563,7 @@ func waitOtaComplete(sp *serialPort, timeout time.Duration,
|
||||
}
|
||||
}
|
||||
|
||||
func writeUartMessage(sp *serialPort, msg *pb.UartMessage) error {
|
||||
frame, err := encodeUartMessage(msg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = sp.port.Write(frame)
|
||||
return err
|
||||
}
|
||||
|
||||
func waitOtaStatus(sp *serialPort, want uint32, timeout time.Duration, onPreparing func(string)) (*pb.OtaStatusPayload, error) {
|
||||
func waitOtaStatus(t otaTransport, want uint32, timeout time.Duration, onPreparing func(string)) (*pb.OtaStatusPayload, error) {
|
||||
deadline := time.Now().Add(timeout)
|
||||
for {
|
||||
if time.Now().After(deadline) {
|
||||
@@ -533,10 +573,7 @@ func waitOtaStatus(sp *serialPort, want uint32, timeout time.Duration, onPrepari
|
||||
if readWait > otaStatusPollTimeout {
|
||||
readWait = otaStatusPollTimeout
|
||||
}
|
||||
if err := sp.port.SetReadTimeout(readWait); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
payload, err := uartframe.ReadFrame(sp.port, nil, readWait)
|
||||
payload, err := t.readFrame(readWait)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
@@ -561,8 +598,8 @@ func waitOtaStatus(sp *serialPort, want uint32, timeout time.Duration, onPrepari
|
||||
}
|
||||
}
|
||||
|
||||
func readOtaStatus(sp *serialPort) (*pb.OtaStatusPayload, error) {
|
||||
payload, err := uartframe.ReadFrame(sp.port, nil, readTimeout)
|
||||
func readOtaStatus(t otaTransport, timeout time.Duration) (*pb.OtaStatusPayload, error) {
|
||||
payload, err := t.readFrame(timeout)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read response: %w", err)
|
||||
}
|
||||
@@ -589,8 +626,37 @@ func encodeUartMessage(msg *pb.UartMessage) ([]byte, error) {
|
||||
return uartframe.EncodeFrame(payload)
|
||||
}
|
||||
|
||||
// flushSerialInput drops stale RX bytes (not full frames — avoids ReadFrame blocking).
|
||||
func flushSerialInput(sp *serialPort) {
|
||||
func decodeUartPayload(payload []byte) (*pb.UartMessage, error) {
|
||||
if len(payload) == 0 {
|
||||
return nil, fmt.Errorf("empty response")
|
||||
}
|
||||
var msg pb.UartMessage
|
||||
if err := proto.Unmarshal(payload[1:], &msg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
msg.Type = pb.MessageType(payload[0])
|
||||
return &msg, nil
|
||||
}
|
||||
|
||||
// --- UART otaTransport ---
|
||||
|
||||
func (sp *serialPort) writeMsg(msg *pb.UartMessage) error {
|
||||
frame, err := encodeUartMessage(msg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = sp.port.Write(frame)
|
||||
return err
|
||||
}
|
||||
|
||||
func (sp *serialPort) readFrame(timeout time.Duration) ([]byte, error) {
|
||||
if err := sp.port.SetReadTimeout(timeout); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return uartframe.ReadFrame(sp.port, nil, timeout)
|
||||
}
|
||||
|
||||
func (sp *serialPort) flushInput() {
|
||||
if sp == nil {
|
||||
return
|
||||
}
|
||||
@@ -605,14 +671,4 @@ func flushSerialInput(sp *serialPort) {
|
||||
}
|
||||
}
|
||||
|
||||
func decodeUartPayload(payload []byte) (*pb.UartMessage, error) {
|
||||
if len(payload) == 0 {
|
||||
return nil, fmt.Errorf("empty response")
|
||||
}
|
||||
var msg pb.UartMessage
|
||||
if err := proto.Unmarshal(payload[1:], &msg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
msg.Type = pb.MessageType(payload[0])
|
||||
return &msg, nil
|
||||
}
|
||||
func (sp *serialPort) chunkPace() time.Duration { return otaHostChunkPaceUART }
|
||||
|
||||
+1582
-196
File diff suppressed because it is too large
Load Diff
+3
-175
@@ -1,12 +1,6 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
import "errors"
|
||||
|
||||
// errUARTBusy is returned when the port is held for OTA (poller should not treat as unplug).
|
||||
var errUARTBusy = errors.New("uart busy (OTA in progress)")
|
||||
@@ -14,171 +8,5 @@ var errUARTBusy = errors.New("uart busy (OTA in progress)")
|
||||
// errOTAInProgress is returned when a second OTA upload is attempted while one is running.
|
||||
var errOTAInProgress = errors.New("OTA upload already in progress")
|
||||
|
||||
// managedSerial keeps the UART open and reconnects after I/O failures or unplug.
|
||||
type managedSerial struct {
|
||||
portName string
|
||||
baud int
|
||||
quiet bool
|
||||
|
||||
mu sync.Mutex
|
||||
sp *serialPort
|
||||
otaActive bool // UART held for firmware upload; poll/API must not interleave
|
||||
}
|
||||
|
||||
func newManagedSerial(portName string, baud int) *managedSerial {
|
||||
return &managedSerial{
|
||||
portName: portName,
|
||||
baud: baud,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *managedSerial) Close() error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.closeLocked()
|
||||
}
|
||||
|
||||
func (m *managedSerial) IsConnected() bool {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.sp != nil
|
||||
}
|
||||
|
||||
func (m *managedSerial) openLocked() error {
|
||||
sp, err := openSerial(m.portName, m.baud)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sp.quiet = m.quiet
|
||||
m.sp = sp
|
||||
if !m.quiet {
|
||||
log.Printf("UART %s connected (%d baud)", m.portName, m.baud)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *managedSerial) closeLocked() error {
|
||||
if m.sp == nil {
|
||||
return nil
|
||||
}
|
||||
err := m.sp.port.Close()
|
||||
m.sp = nil
|
||||
return err
|
||||
}
|
||||
|
||||
func (m *managedSerial) invalidateLocked(reason error) {
|
||||
if m.sp == nil {
|
||||
return
|
||||
}
|
||||
if !m.quiet {
|
||||
log.Printf("UART %s disconnected: %v", m.portName, reason)
|
||||
}
|
||||
_ = m.closeLocked()
|
||||
}
|
||||
|
||||
func (m *managedSerial) withPort(fn func(*serialPort) error) error {
|
||||
return m.withPortLocked(false, fn)
|
||||
}
|
||||
|
||||
// withPortPoll is like withPort but returns errUARTBusy during OTA (no TryLock race).
|
||||
func (m *managedSerial) withPortPoll(fn func(*serialPort) error) error {
|
||||
return m.withPortLocked(true, fn)
|
||||
}
|
||||
|
||||
func (m *managedSerial) withPortLocked(poll bool, fn func(*serialPort) error) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.otaActive {
|
||||
return errUARTBusy
|
||||
}
|
||||
|
||||
if m.sp == nil {
|
||||
if err := m.openLocked(); err != nil {
|
||||
return fmt.Errorf("%s: %w", m.portName, err)
|
||||
}
|
||||
}
|
||||
|
||||
err := fn(m.sp)
|
||||
if err != nil {
|
||||
m.invalidateLocked(err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (m *managedSerial) recoverAfterMasterRestart() {
|
||||
const bootWait = 6 * time.Second
|
||||
|
||||
m.mu.Lock()
|
||||
m.closeLocked()
|
||||
m.mu.Unlock()
|
||||
|
||||
log.Printf("UART: master restart — waiting %s for boot", bootWait)
|
||||
time.Sleep(bootWait)
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if err := m.openLocked(); err != nil {
|
||||
log.Printf("UART reconnect after master restart: %v", err)
|
||||
return
|
||||
}
|
||||
flushSerialInput(m.sp)
|
||||
log.Printf("UART %s ready after master restart", m.portName)
|
||||
}
|
||||
|
||||
func (m *managedSerial) exchangePayload(payload []byte, cmdName string) ([]byte, error) {
|
||||
return m.exchangePayloadVia(m.withPort, payload, cmdName)
|
||||
}
|
||||
|
||||
func (m *managedSerial) exchangePayloadPoll(payload []byte, cmdName string) ([]byte, error) {
|
||||
return m.exchangePayloadVia(m.withPortPoll, payload, cmdName)
|
||||
}
|
||||
|
||||
func (m *managedSerial) exchangePayloadVia(
|
||||
portFn func(func(*serialPort) error) error,
|
||||
payload []byte, cmdName string,
|
||||
) ([]byte, error) {
|
||||
var resp []byte
|
||||
err := portFn(func(sp *serialPort) error {
|
||||
var e error
|
||||
resp, e = sp.exchangePayloadLocked(payload, cmdName, readTimeout)
|
||||
return e
|
||||
})
|
||||
return resp, err
|
||||
}
|
||||
|
||||
func (m *managedSerial) exchange(cmdID byte, cmdName string) ([]byte, error) {
|
||||
return m.exchangeVia(m.withPort, cmdID, cmdName)
|
||||
}
|
||||
|
||||
func (m *managedSerial) exchangePoll(cmdID byte, cmdName string) ([]byte, error) {
|
||||
return m.exchangeVia(m.withPortPoll, cmdID, cmdName)
|
||||
}
|
||||
|
||||
func (m *managedSerial) exchangeVia(
|
||||
portFn func(func(*serialPort) error) error,
|
||||
cmdID byte, cmdName string,
|
||||
) ([]byte, error) {
|
||||
var resp []byte
|
||||
err := portFn(func(sp *serialPort) error {
|
||||
var e error
|
||||
resp, e = sp.exchangeLocked(cmdID, cmdName)
|
||||
return e
|
||||
})
|
||||
return resp, err
|
||||
}
|
||||
|
||||
func disconnectedState(portName string, err error) DashboardState {
|
||||
msg := "UART disconnected"
|
||||
if err != nil {
|
||||
msg = err.Error()
|
||||
}
|
||||
return DashboardState{
|
||||
UpdatedAt: time.Now().Format(time.RFC3339),
|
||||
SerialPort: portName,
|
||||
UARTConnected: false,
|
||||
SerialOK: false,
|
||||
SerialError: msg,
|
||||
Master: MasterView{OK: false, Error: msg},
|
||||
Clients: []ClientView{},
|
||||
}
|
||||
}
|
||||
// managedSerial is an alias for managedHost (UART-first history).
|
||||
type managedSerial = managedHost
|
||||
|
||||
Vendored
+1
-1
@@ -44,7 +44,7 @@ Ordered steps: UART commands, delays, or esptool reset.
|
||||
**unicast_test** — `input`: `slave` or `client_id`, `seq`
|
||||
`expect`: `success`, `seq`
|
||||
|
||||
**led_ring** — `input`: `mode` (`clear`, `progress`, `digit`, `blink`, `find_me`), `progress`, `digit`, `r`/`g`/`b`, `intensity`, `blink_ms`, `blink_count`
|
||||
**led_ring** — `input`: `mode` (`clear`, `progress`, `digit`, `blink`, `find_me`, `battery_low`), `progress`, `digit`, `r`/`g`/`b`, `intensity`, `blink_ms`, `blink_count`
|
||||
`expect`: `success`, `mode`, `progress`, `digit`
|
||||
|
||||
**find_me** — `input`: `client` / `client_id` or `slave` (`0` = master ring)
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@
|
||||
{
|
||||
"id": "pod-1",
|
||||
"mac": "50:78:7d:18:01:10",
|
||||
"client_id": 16,
|
||||
"client_id": 1,
|
||||
"console": "/dev/ttyACM1"
|
||||
}
|
||||
]
|
||||
|
||||
+21
-11
@@ -10,8 +10,10 @@ import (
|
||||
const (
|
||||
StartMarker = 0xAA
|
||||
StopMarker = 0xCC
|
||||
// Must match main/uart.h MAX_PAYLOAD_SIZE (MAX_BUF_SIZE - 4).
|
||||
MaxPayload = 248
|
||||
// Must match main/uart.h MAX_PAYLOAD_SIZE.
|
||||
MaxPayload = 1024
|
||||
// AA | len_lo | len_hi | payload | xor | CC
|
||||
FrameOverhead = 5
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -19,14 +21,15 @@ var (
|
||||
ErrTimeout = errors.New("read timeout")
|
||||
)
|
||||
|
||||
// EncodeFrame builds a framed packet: 0xAA len payload xor(payload) 0xCC.
|
||||
// EncodeFrame builds a framed packet: 0xAA len_lo len_hi payload xor(payload) 0xCC.
|
||||
func EncodeFrame(payload []byte) ([]byte, error) {
|
||||
if len(payload) == 0 || len(payload) > MaxPayload {
|
||||
return nil, fmt.Errorf("payload length %d out of range 1..%d", len(payload), MaxPayload)
|
||||
}
|
||||
|
||||
frame := make([]byte, 0, 4+len(payload))
|
||||
frame = append(frame, StartMarker, byte(len(payload)))
|
||||
n := len(payload)
|
||||
frame := make([]byte, 0, FrameOverhead+n)
|
||||
frame = append(frame, StartMarker, byte(n&0xff), byte((n>>8)&0xff))
|
||||
var checksum byte
|
||||
for _, b := range payload {
|
||||
frame = append(frame, b)
|
||||
@@ -39,6 +42,7 @@ func EncodeFrame(payload []byte) ([]byte, error) {
|
||||
type Parser struct {
|
||||
state int
|
||||
len int
|
||||
lenLo byte
|
||||
payload []byte
|
||||
index int
|
||||
checksum byte
|
||||
@@ -46,7 +50,8 @@ type Parser struct {
|
||||
|
||||
const (
|
||||
stateStart = iota
|
||||
stateLen
|
||||
stateLenLo
|
||||
stateLenHi
|
||||
stateData
|
||||
stateChecksum
|
||||
stateStop
|
||||
@@ -63,14 +68,19 @@ func (p *Parser) Feed(b byte) (payload []byte, ok bool, err error) {
|
||||
if b == StartMarker {
|
||||
p.index = 0
|
||||
p.checksum = 0
|
||||
p.state = stateLen
|
||||
p.state = stateLenLo
|
||||
}
|
||||
case stateLen:
|
||||
if b == 0 || b > MaxPayload {
|
||||
case stateLenLo:
|
||||
p.lenLo = b
|
||||
p.state = stateLenHi
|
||||
case stateLenHi:
|
||||
n := int(p.lenLo) | (int(b) << 8)
|
||||
if n == 0 || n > MaxPayload {
|
||||
p.state = stateStart
|
||||
} else {
|
||||
p.len = int(b)
|
||||
p.len = n
|
||||
p.payload = make([]byte, p.len)
|
||||
p.index = 0
|
||||
p.state = stateData
|
||||
}
|
||||
case stateData:
|
||||
@@ -102,7 +112,7 @@ func (p *Parser) Feed(b byte) (payload []byte, ok bool, err error) {
|
||||
// maxWait bounds total wait time; zero means no limit (serial read timeouts retry forever).
|
||||
func ReadFrame(r io.Reader, buf []byte, maxWait time.Duration) ([]byte, error) {
|
||||
if buf == nil {
|
||||
buf = make([]byte, 256)
|
||||
buf = make([]byte, 1024)
|
||||
}
|
||||
parser := NewParser()
|
||||
|
||||
|
||||
+999
-73
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,9 @@ idf_component_register(
|
||||
"powerpod.c"
|
||||
"led_ring.c"
|
||||
"uart.c"
|
||||
"host_transport.c"
|
||||
"ble_host.c"
|
||||
"ble_stream.c"
|
||||
"uart_proto.c"
|
||||
"uart_cmd.c"
|
||||
"cmd/cmd_handler.c"
|
||||
@@ -19,19 +22,26 @@ idf_component_register(
|
||||
"cmd/cmd_client_info.c"
|
||||
"cmd/cmd_accel_deadzone.c"
|
||||
"cmd/cmd_accel_stream.c"
|
||||
"cmd/cmd_input_send_mode.c"
|
||||
"cmd/cmd_tap_notify.c"
|
||||
"cmd/cmd_tap_config.c"
|
||||
"cmd/cmd_cache_status.c"
|
||||
"cmd/cmd_espnow_unicast_test.c"
|
||||
"cmd/cmd_espnow_echo_ping.c"
|
||||
"cmd/cmd_espnow_find_me.c"
|
||||
"cmd/cmd_restart.c"
|
||||
"cmd/cmd_client_id_map_reset.c"
|
||||
"pod_reboot.c"
|
||||
"cmd/cmd_led_ring.c"
|
||||
"cmd/cmd_battery.c"
|
||||
"cmd/cmd_battery_uv.c"
|
||||
"cmd/cmd_set_log_level.c"
|
||||
"cmd/cmd_ota.c"
|
||||
"cmd/cmd_ota_slave_progress.c"
|
||||
"ota_uart.c"
|
||||
"ota_espnow.c"
|
||||
"ota_session.c"
|
||||
"ota_confirm.c"
|
||||
"client_registry.c"
|
||||
"esp_now_comm.c"
|
||||
"esp_now_core.c"
|
||||
@@ -40,6 +50,7 @@ idf_component_register(
|
||||
"esp_now_proto.c"
|
||||
"bosch456.c"
|
||||
"board_input.c"
|
||||
"battery_uv.c"
|
||||
"pod_settings.c"
|
||||
"proto/uart_messages.pb.c"
|
||||
"proto/esp_now_messages.pb.c"
|
||||
@@ -55,13 +66,17 @@ idf_component_register(
|
||||
esp_netif
|
||||
esp_event
|
||||
nvs_flash
|
||||
bt
|
||||
PRIV_REQUIRES
|
||||
esp_driver_gpio
|
||||
esp_driver_uart
|
||||
esp_driver_i2c
|
||||
esp_adc
|
||||
app_update
|
||||
esp_timer
|
||||
bma456)
|
||||
|
||||
target_compile_definitions(${COMPONENT_LIB}
|
||||
PRIVATE "POWERPOD_GIT_HASH=\"${POWERPOD_GIT_HASH}\"")
|
||||
# Optional: disable software UV protection
|
||||
# target_compile_definitions(${COMPONENT_LIB} PRIVATE POWERPOD_BATTERY_UV_ENABLE=0)
|
||||
|
||||
+98
-18
@@ -55,18 +55,20 @@ Pins (`powerpod.h`):
|
||||
| BMA456 INT | 10 |
|
||||
| Button (Taster) | 12 |
|
||||
| LiPo sense 1 (ADC) | 1 |
|
||||
| LiPo sense 2 (ADC) | 12 (skipped if same as button) |
|
||||
| LiPo sense 2 (ADC) | 11 |
|
||||
|
||||
> **TODO:** GPIO assignments above are provisional; confirm pinning against the real board before release.
|
||||
|
||||
Startup order:
|
||||
Startup order (normal path; UV energy-save skips I2C/BMA456/ESP-NOW/UART/button — see **Software UV protection**):
|
||||
|
||||
1. Read DIP + IO expander → `app_config`
|
||||
2. **I2C bus** — IO expander `0x20`; optional **BMA456H** (`init_bma456`, same bus)
|
||||
3. `esp_now_comm_init(&app_config)` — WiFi + ESP-NOW
|
||||
4. `led_ring_init()`
|
||||
5. `board_input_init()` — button press logs, LiPo ADC logs every **10 s**
|
||||
6. **Master only:** command queue, UART, registered commands (e.g. VERSION)
|
||||
1. `pod_settings_init()` — NVS
|
||||
2. LiPo ADC init + optional UV boot check (`battery_uv.c`)
|
||||
3. Read DIP + IO expander → `app_config`
|
||||
4. **I2C bus** — IO expander `0x20`; optional **BMA456H** (`init_bma456`, same bus)
|
||||
5. `esp_now_comm_init(&app_config)` — WiFi + ESP-NOW
|
||||
6. `led_ring_init()`
|
||||
7. LiPo monitor task + button (`board_input.c`)
|
||||
8. **Master only:** command queue, UART, registered commands (e.g. VERSION)
|
||||
|
||||
## BMA456 accelerometer (`bosch456.c`)
|
||||
|
||||
@@ -86,12 +88,16 @@ Powerpod uses the Bosch **BMA456H** (hearable) variant, not the generic `bma456w
|
||||
|
||||
**Persistence:** The local deadzone is stored in the **`nvs`** partition (namespace `powerpod`, key `accel_dz`) via `pod_settings.c`. Each node (master or slave) keeps its own value across reboot. Loaded at boot after `init_bma456()`; saved when set locally (UART `client_id = 0`, `all_clients` on master, or ESP-NOW deadzone on a slave).
|
||||
|
||||
Tap detection parameters (`bma456_tap_config_t`) use the same pattern with NVS key `tap_cfg` (blob), UART `TAP_CONFIG`, and ESP-NOW `ESPNOW_SET_TAP_CONFIG`.
|
||||
|
||||
**Configuration paths:**
|
||||
|
||||
| Path | Effect |
|
||||
|------|--------|
|
||||
| UART `ACCEL_DEADZONE` with `client_id = 0` | Set + save local deadzone |
|
||||
| ESP-NOW `SET_ACCEL_DEADZONE` | Set + save on the receiving slave |
|
||||
| UART `TAP_CONFIG` with `client_id = 0` | Set + save local tap config |
|
||||
| ESP-NOW `SET_TAP_CONFIG` | Set + save tap config on the receiving slave |
|
||||
| `make gotool-deadzone-set DEADZONE=… CLIENT=0` | Host shortcut for local deadzone |
|
||||
|
||||
**Logs:** `[BMA456] ACC X=… Y=… Z=…` when deadzone exceeded; `[BMA456] tap: single|double|triple` on interrupt.
|
||||
@@ -110,14 +116,17 @@ Schema: `proto/esp_now_messages.proto`. Encode/decode: `esp_now_proto.c`. The ES
|
||||
|
||||
| `EspNowMessageType` | Direction | `oneof` payload |
|
||||
|---------------------|-----------|-----------------|
|
||||
| `ESPNOW_DISCOVER` | Master → broadcast `FF:FF:FF:FF:FF:FF` | `EspNowDiscover` (`network`) |
|
||||
| `ESPNOW_DISCOVER` | Master → broadcast `FF:FF:FF:FF:FF:FF` | `EspNowDiscover` (`network`, `master_time_us` for accel TX slot sync) |
|
||||
| `ESPNOW_SLAVE_INFO` | Slave → master | `EspNowSlavePresence` |
|
||||
| `ESPNOW_HEARTBEAT` | Slave → master | `EspNowSlavePresence` (same fields) |
|
||||
| `ESPNOW_SET_ACCEL_DEADZONE` | Master → slave | `EspNowAccelDeadzone` (`deadzone` LSB) |
|
||||
| `ESPNOW_SET_TAP_CONFIG` | Master → slave | `EspNowTapConfig` (multitap sens/timing/axis + enable S/D/T) |
|
||||
| `ESPNOW_SET_BATTERY_UV` | Master → slave | `EspNowBatteryUv` (runtime UV enable + clear latch when off) |
|
||||
| `ESPNOW_UNICAST_TEST` | Master → slave | `EspNowUnicastTest` (`seq`) |
|
||||
| `ESPNOW_FIND_ME` | Master → slave | `EspNowFindMe` (`client_id` filter) — LED locate sequence |
|
||||
| `ESPNOW_RESTART` | Master → slave | `EspNowRestart` (`client_id` filter) — reboot slave |
|
||||
| `ESPNOW_ACCEL_SAMPLE` | Slave → master | `EspNowAccelSample` (`slave_id`, `x`, `y`, `z` raw LSB) — ~every 16 ms |
|
||||
| `ESPNOW_ACCEL_SAMPLE` | Slave → master | `EspNowAccelSample` (`slave_id`, `x`, `y`, `z` raw LSB) — ~every 10 ms in 1 ms slots `offset_ms=(id-1)%8` via Discover sync; TX path via `INPUT_SEND_MODE` |
|
||||
| `ESPNOW_SET_INPUT_SEND_MODE` | Master → slave | `EspNowInputSendMode` (`mode` 0..3 = best_effort/fast/wait/reliable) |
|
||||
| `ESPNOW_SET_TAP_NOTIFY` | Master → slave | `EspNowTapNotify` (`client_id`, `single`, `double_tap`, `triple`) — which tap kinds to forward |
|
||||
| `ESPNOW_TAP_EVENT` | Slave → master | `EspNowTapEvent` (`client_id`, `kind`) — on BMA456 tap interrupt if notify enabled |
|
||||
| `ESPNOW_BATTERY_REPORT` | Slave → master | `EspNowBatteryReport` (`client_id`, `lipo1/2` mV) — ~every 30 s; cached in `client_registry` |
|
||||
@@ -157,7 +166,7 @@ Hardware: **UART1**, **921600** baud, **TX = GPIO3**, **RX = GPIO2** (adapter TX
|
||||
| Field | Value |
|
||||
|-------|--------|
|
||||
| Start | `0xAA` |
|
||||
| Length | 1 byte, payload size 1–252 |
|
||||
| Length | 2 bytes little-endian, payload size 1–1024 |
|
||||
| Payload | `length` bytes |
|
||||
| Checksum | XOR of all payload bytes |
|
||||
| Stop | `0xCC` |
|
||||
@@ -169,7 +178,7 @@ Hardware: **UART1**, **921600** baud, **TX = GPIO3**, **RX = GPIO2** (adapter TX
|
||||
| 0 | Command ID (`MessageType` / `msg_id`) |
|
||||
| 1… | Arguments (handler receives bytes after ID only) |
|
||||
|
||||
Example VERSION request: single-byte payload `03` → frame `AA 01 03 03 CC`.
|
||||
Example VERSION request: single-byte payload `03` → frame `AA 01 00 03 03 CC`.
|
||||
|
||||
Logging:
|
||||
|
||||
@@ -226,8 +235,13 @@ Host and master speak nanopb-encoded `UartMessage` inside UART frames (byte 0 =
|
||||
| 22 | `FIND_ME` | Implemented (`cmd/cmd_espnow_find_me.c`) — `client_id=0` local ring, `>0` ESP-NOW to slave |
|
||||
| 23 | `RESTART` | Implemented (`cmd/cmd_restart.c`) — `client_id=0` reboot master, `>0` ESP-NOW reboot slave |
|
||||
| 25 | `ACCEL_STREAM` | Implemented — enable/disable slave ESP-NOW accel stream to master |
|
||||
| 35 | `INPUT_SEND_MODE` | Implemented — get/set slave accel/tap ESP-NOW send path (best_effort/fast/wait/reliable) |
|
||||
| 27 | `TAP_NOTIFY` | Implemented (`cmd/cmd_tap_notify.c`) — get/set which tap kinds notify via ESP-NOW |
|
||||
| 29 | `CACHE_STATUS` | Implemented (`cmd/cmd_cache_status.c`) — subscribed accel + tap cache (one UART round-trip) |
|
||||
| 30 | `ESPNOW_ECHO_PING` | Implemented (`cmd/cmd_espnow_echo_ping.c`) — ESP-NOW timestamp echo (latency test) |
|
||||
| 31 | `SET_LOG_LEVEL` | Implemented (`cmd/cmd_set_log_level.c`) — get/set global `esp_log_level_set("*", …)` on master |
|
||||
| 32 | `TAP_CONFIG` | Implemented (`cmd/cmd_tap_config.c`) — get/set BMA456H multitap params (+ NVS) |
|
||||
| 36 | `BATTERY_UV` | Implemented (`cmd/cmd_battery_uv.c`) — get/set software UV enable (+ NVS / ESP-NOW) |
|
||||
|
||||
Regenerate C code:
|
||||
|
||||
@@ -321,6 +335,27 @@ Sets the **software** deadzone used by `bosch456.c` when logging accel (see [BMA
|
||||
|
||||
**Response:** `accel_deadzone_response` with applied `deadzone`, `success`, and `slaves_updated` (ESP-NOW count).
|
||||
|
||||
### TAP_CONFIG command
|
||||
|
||||
Get/set **hardware** BMA456H multitap parameters (`tap_sens_thres`, timing fields, `axis_sel`, enable single/double/triple). Applied via `bma456_set_tap_config()` and persisted in NVS (`tap_cfg`) on each target node.
|
||||
|
||||
**Request:** framed `20` (`0x20`) + `tap_config_request`:
|
||||
|
||||
| Field | Meaning |
|
||||
|-------|---------|
|
||||
| `write` | `false` = read, `true` = write |
|
||||
| `tap_sens_thres` … `enable_triple` | Same fields as `bma456_tap_config_t` |
|
||||
| `client_id` | `0` = local sensor; `>0` = slave id (master) |
|
||||
| `all_clients` | Master: ESP-NOW unicast to every registered slave |
|
||||
|
||||
**Response:** `tap_config_response` with fields echo, `success`, `slaves_updated`.
|
||||
|
||||
```bash
|
||||
go run . -port /dev/ttyUSB0 tap-config
|
||||
go run . -port /dev/ttyUSB0 tap-config -set -sens 5 -axis 2
|
||||
go run . -port /dev/ttyUSB0 tap-config -client 16 -set -sens 7
|
||||
```
|
||||
|
||||
### TAP_NOTIFY command
|
||||
|
||||
Configure which BMA456 tap kinds a **slave** forwards to the master over ESP-NOW. The slave only sends `ESPNOW_TAP_EVENT` when the matching notify flag is enabled (set locally on the slave via ESP-NOW).
|
||||
@@ -345,7 +380,7 @@ go run . -port /dev/ttyUSB0 tap-notify -client 16
|
||||
|
||||
### CACHE_STATUS command
|
||||
|
||||
Read **cached** accel and/or tap data on the **master** in one UART round-trip. Slaves send `ESPNOW_ACCEL_SAMPLE` every **16 ms** when streaming; tap events arrive via `ESPNOW_TAP_EVENT` and are held up to **16 ms** (`CLIENT_REGISTRY_TAP_MAX_AGE_MS`). Pending taps are **consumed** on read (like the former `TAP_SNAPSHOT`).
|
||||
Read **cached** accel and/or tap data on the **master** in one UART round-trip. Slaves send `ESPNOW_ACCEL_SAMPLE` every **8 ms** (best-effort) when streaming; tap events arrive via `ESPNOW_TAP_EVENT` and are held up to **8 ms** (`CLIENT_REGISTRY_TAP_MAX_AGE_MS`). Pending taps are **consumed** on read (like the former `TAP_SNAPSHOT`).
|
||||
|
||||
**Request:** framed `1d` (`0x1d`) only — no body (`CacheStatusRequest` empty).
|
||||
|
||||
@@ -359,7 +394,7 @@ Read **cached** accel and/or tap data on the **master** in one UART round-trip.
|
||||
|
||||
Unsubscribed submessages are omitted on the wire (proto3 defaults). The master walks `client_registry` once per request (`cmd/cmd_cache_status.c`).
|
||||
|
||||
Host tools poll this at **16 ms** when live-stream / WebSocket receive is enabled. Tap events stay visible for **2 s** in the UI/API after first sight.
|
||||
Host tools poll this at **~8 ms** when live-stream / WebSocket receive is enabled. Tap events stay visible for **2 s** in the UI/API after first sight.
|
||||
|
||||
```bash
|
||||
go run . -port /dev/ttyUSB0 cache-status
|
||||
@@ -390,6 +425,30 @@ go run . -port /dev/ttyUSB0 find-me
|
||||
go run . -port /dev/ttyUSB0 find-me -client 16
|
||||
```
|
||||
|
||||
### SET_LOG_LEVEL command
|
||||
|
||||
Read or set the **global** ESP-IDF log filter on the master (`esp_log_level_set("*", level)`). Does not affect the host UART protocol (UART1); `esp_log_*` output goes to the **debug console UART0** (USB, 115200).
|
||||
|
||||
**Request:** framed `31` (`0x1f`) + `set_log_level_request` (`write`, `level` 0–5).
|
||||
|
||||
**Response:** `set_log_level_response` (`success`, `level`).
|
||||
|
||||
| `level` | `esp_log_level_t` |
|
||||
|---------|-------------------|
|
||||
| 0 | NONE |
|
||||
| 1 | ERROR |
|
||||
| 2 | WARN |
|
||||
| 3 | INFO |
|
||||
| 4 | DEBUG |
|
||||
| 5 | VERBOSE |
|
||||
|
||||
Boot default follows `CONFIG_LOG_DEFAULT_LEVEL` in `sdkconfig` (not persisted across reboot).
|
||||
|
||||
```bash
|
||||
go run . -port /dev/ttyUSB0 log-level
|
||||
go run . -port /dev/ttyUSB0 log-level -set -level 0
|
||||
```
|
||||
|
||||
### RESTART command
|
||||
|
||||
Reboot the master (`client_id=0`) or one slave via ESP-NOW (`client_id` = registry id). The device sends the UART response, then restarts after ~150 ms.
|
||||
@@ -414,6 +473,22 @@ Read **cached** LiPo ADC values on the **master** (master local + one entry per
|
||||
# Host / goTool: all_clients returns master (id 0) + slaves from cache
|
||||
```
|
||||
|
||||
### Software UV protection (LiPo)
|
||||
|
||||
When `POWERPOD_BATTERY_UV_ENABLE` is `1` (default in `powerpod.h`, disable via CMake), the firmware can monitor ADC pin voltages on GPIO 1 and 11. Runtime enable is NVS `uv_en` per pod (missing key → **disabled**); UART `BATTERY_UV` / dashboard toggles it on master and slaves.
|
||||
|
||||
| Condition | ADC threshold | Action |
|
||||
|-----------|---------------|--------|
|
||||
| External / absent | every valid channel < **100 mV** | Normal boot (no UV); clears latch if set |
|
||||
| Under-voltage | any valid channel in **100…2299 mV** | NVS latch `uv_latch`, restart into energy-save mode |
|
||||
| Charged | every non-absent valid channel ≥ **3000 mV** | Clear latch, restart into normal boot |
|
||||
|
||||
**Energy-save boot:** LED mode `6` (first 4 LEDs red ~10 % for 5 s), then no WiFi/ESP-NOW, UART, I2C, BMA456, or button — only LiPo ADC polling every 30 s until charged. Disabling UV while latched clears the latch (requires a reachable host — not while already in energy-save).
|
||||
|
||||
**UART:** `BATTERY_UV` (`0x24`) — `client_id=0` master, `>0` one slave, `all_clients` master + all slaves. Disabling also clears `uv_latch`.
|
||||
|
||||
**Test LED pattern from host:** `led-ring -mode battery-low` (works even when UV feature is disabled).
|
||||
|
||||
### LED_RING command
|
||||
|
||||
Control the 95-LED ring from the host. The firmware **does not** animate digits locally; only UART updates the display.
|
||||
@@ -422,7 +497,7 @@ Control the 95-LED ring from the host. The firmware **does not** animate digits
|
||||
|
||||
| Field | Meaning |
|
||||
|-------|---------|
|
||||
| `mode` | `0` = clear, `1` = progress, `2` = digit (0–10), `3` = blink, `4` = find-me, `5` = solid color (all LEDs) |
|
||||
| `mode` | `0` = clear, `1` = progress, `2` = digit (0–10), `3` = blink, `4` = find-me, `5` = solid color (all LEDs), `6` = battery-low (first 4 LEDs red ~10 % for 5 s) |
|
||||
| `progress` | 0–100 (% of ring lit, mode `1`) |
|
||||
| `digit` | 0–10 (mode `2`, segment maps in `led_ring.c`) |
|
||||
| `r`, `g`, `b` | Color 0–255 |
|
||||
@@ -444,6 +519,7 @@ go run . -port /dev/ttyUSB0 led-ring -mode blink -g 255 -blink-count 2
|
||||
go run . -port /dev/ttyUSB0 find-me
|
||||
go run . -port /dev/ttyUSB0 find-me -client 16
|
||||
go run . -port /dev/ttyUSB0 led-ring -mode find-me
|
||||
go run . -port /dev/ttyUSB0 led-ring -mode battery-low -client 0
|
||||
go run . -port /dev/ttyUSB0 led-ring -mode color -r 255 -g 0 -b 0 -client 16
|
||||
go run . -port /dev/ttyUSB0 led-ring -mode digit -digit 5 -all
|
||||
```
|
||||
@@ -465,7 +541,7 @@ Fields per client: `id`, `mac`, `version`, `available`, `used`, `last_ping`, `la
|
||||
| `client_registry_heartbeat(mac, id, version, …)` | Same as upsert for heartbeats; reactivates inactive clients |
|
||||
| `client_registry_check_timeouts(timeout_ms)` | Mark stale clients inactive (master monitor task) |
|
||||
| `client_registry_count()` / `client_registry_at(i)` | Iterate for UART encoding |
|
||||
| `client_registry_set_tap_notify()` / `client_registry_take_tap()` | Tap notify flags + short-lived tap cache (16 ms) |
|
||||
| `client_registry_set_tap_notify()` / `client_registry_take_tap()` | Tap notify flags + short-lived tap cache (8 ms) |
|
||||
|
||||
Slaves register when the master receives `SLAVE_INFO` on the matching network; `HEARTBEAT` keeps them marked available. The registry **MAC is always the ESP-NOW source address** (`recv_info.src_addr`), not the optional `mac` bytes in the protobuf (used only on the wire for debugging).
|
||||
|
||||
@@ -536,11 +612,15 @@ Target: ESP32-S3. Close serial monitor on the UART adapter port before running `
|
||||
| `client_registry.c/h` | Registered slave table |
|
||||
| `bosch456.c/h` | BMA456H I2C driver, accel poll, on-demand read, tap INT, deadzone filter |
|
||||
| `cmd/cmd_tap_notify.c` | UART `TAP_NOTIFY` — ESP-NOW tap notify config |
|
||||
| `cmd/cmd_tap_config.c` | UART `TAP_CONFIG` — BMA456 multitap params + NVS |
|
||||
| `cmd/cmd_cache_status.c` | UART `CACHE_STATUS` — subscribed accel + tap cache poll |
|
||||
| `board_input.c/h` | Taster GPIO12, LiPo ADC on GPIO1 / GPIO12 |
|
||||
| `pod_settings.c/h` | NVS persistence (accel deadzone, …) |
|
||||
| `board_input.c/h` | Taster GPIO12, LiPo ADC on GPIO1 / GPIO11 |
|
||||
| `battery_uv.c/h` | Software LiPo UV latch, energy-save boot (`POWERPOD_BATTERY_UV_ENABLE`) |
|
||||
| `pod_settings.c/h` | NVS persistence (accel deadzone, tap config, UV latch) |
|
||||
| `led_ring.c/h` | LED ring (digit display, progress bar) |
|
||||
| `cmd/cmd_led_ring.c` | UART `LED_RING` progress command |
|
||||
| `cmd/cmd_battery_uv.c` | UART `BATTERY_UV` — software UV enable + NVS / ESP-NOW |
|
||||
| `cmd/cmd_set_log_level.c` | UART `SET_LOG_LEVEL` — runtime ESP-IDF log level |
|
||||
| `proto/uart_messages.proto` | UART protocol schema |
|
||||
| `proto/esp_now_messages.proto` | ESP-NOW protocol schema |
|
||||
| `esp_now_proto.c/h` | Encode/decode `EspNowMessage` |
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
typedef struct {
|
||||
bool master;
|
||||
uint8_t network;
|
||||
/** false = UART host, true = BLE host (DIP8; mutual exclusive, boot-only). */
|
||||
bool host_ble;
|
||||
char running_partition[APP_RUNNING_PARTITION_LABEL_MAX];
|
||||
} app_config_t;
|
||||
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
#include "battery_uv.h"
|
||||
|
||||
#if POWERPOD_BATTERY_UV_ENABLE
|
||||
|
||||
#include "led_ring.h"
|
||||
#include "pod_reboot.h"
|
||||
#include "pod_settings.h"
|
||||
#include "esp_log.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
|
||||
static const char *TAG = "[BATTERY_UV]";
|
||||
|
||||
#define UV_DEBOUNCE_SAMPLES 2u
|
||||
#define UV_POLL_INTERVAL_MS 30000u
|
||||
|
||||
static uint8_t s_uv_streak;
|
||||
static uint8_t s_charge_streak;
|
||||
|
||||
bool battery_uv_is_latched(void) { return pod_settings_is_uv_latched(); }
|
||||
|
||||
static bool channel_is_absent(uint32_t mv) {
|
||||
return mv < BATTERY_UV_ABSENT_ADC_MV;
|
||||
}
|
||||
|
||||
static bool channel_is_under(uint32_t mv) {
|
||||
return !channel_is_absent(mv) && mv < BATTERY_UV_ADC_MV;
|
||||
}
|
||||
|
||||
static void log_lipo_reading(const char *ctx, const board_lipo_reading_t *reading) {
|
||||
if (reading == NULL) {
|
||||
return;
|
||||
}
|
||||
ESP_LOGI(TAG,
|
||||
"%s LIPO1 %s %lu mV LIPO2 %s %lu mV", ctx,
|
||||
reading->lipo1_valid ? "ok" : "n/a",
|
||||
(unsigned long)reading->lipo1_mv, reading->lipo2_valid ? "ok" : "n/a",
|
||||
(unsigned long)reading->lipo2_mv);
|
||||
}
|
||||
|
||||
bool battery_uv_reading_is_external(const board_lipo_reading_t *reading) {
|
||||
if (reading == NULL) {
|
||||
return false;
|
||||
}
|
||||
|
||||
bool any_valid = false;
|
||||
if (reading->lipo1_valid) {
|
||||
any_valid = true;
|
||||
if (!channel_is_absent(reading->lipo1_mv)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (reading->lipo2_valid) {
|
||||
any_valid = true;
|
||||
if (!channel_is_absent(reading->lipo2_mv)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return any_valid;
|
||||
}
|
||||
|
||||
bool battery_uv_reading_is_under(const board_lipo_reading_t *reading) {
|
||||
if (reading == NULL) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (reading->lipo1_valid && channel_is_under(reading->lipo1_mv)) {
|
||||
return true;
|
||||
}
|
||||
if (reading->lipo2_valid && channel_is_under(reading->lipo2_mv)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool battery_uv_reading_is_charged(const board_lipo_reading_t *reading) {
|
||||
if (reading == NULL) {
|
||||
return false;
|
||||
}
|
||||
|
||||
bool any_pack = false;
|
||||
if (reading->lipo1_valid && !channel_is_absent(reading->lipo1_mv)) {
|
||||
any_pack = true;
|
||||
if (reading->lipo1_mv < BATTERY_CHARGE_ADC_MV) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (reading->lipo2_valid && !channel_is_absent(reading->lipo2_mv)) {
|
||||
any_pack = true;
|
||||
if (reading->lipo2_mv < BATTERY_CHARGE_ADC_MV) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return any_pack;
|
||||
}
|
||||
|
||||
bool battery_uv_evaluate_boot(void) {
|
||||
if (!pod_settings_is_uv_enabled()) {
|
||||
ESP_LOGI(TAG, "UV protection disabled — normal boot");
|
||||
return false;
|
||||
}
|
||||
|
||||
board_lipo_reading_t reading;
|
||||
board_input_read_lipo(&reading);
|
||||
log_lipo_reading("boot check", &reading);
|
||||
|
||||
if (battery_uv_reading_is_external(&reading)) {
|
||||
ESP_LOGI(TAG, "near-zero LiPo sense — external supply, normal boot");
|
||||
if (pod_settings_is_uv_latched()) {
|
||||
pod_settings_clear_uv_latched();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (pod_settings_is_uv_latched() && battery_uv_reading_is_charged(&reading)) {
|
||||
ESP_LOGI(TAG, "charged — clearing UV latch");
|
||||
pod_settings_clear_uv_latched();
|
||||
return false;
|
||||
}
|
||||
|
||||
if (pod_settings_is_uv_latched() || battery_uv_reading_is_under(&reading)) {
|
||||
if (!pod_settings_is_uv_latched()) {
|
||||
ESP_LOGW(TAG, "under-voltage at boot — latching UV");
|
||||
} else {
|
||||
ESP_LOGW(TAG, "UV latched — energy-save boot");
|
||||
}
|
||||
pod_settings_set_uv_latched(true);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
void battery_uv_poll(const board_lipo_reading_t *reading) {
|
||||
if (reading == NULL || !pod_settings_is_uv_enabled() ||
|
||||
pod_settings_is_uv_latched()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (battery_uv_reading_is_external(reading)) {
|
||||
s_uv_streak = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
if (battery_uv_reading_is_under(reading)) {
|
||||
s_uv_streak++;
|
||||
log_lipo_reading("under-voltage", reading);
|
||||
ESP_LOGW(TAG, "under-voltage sample %u/%u", (unsigned)s_uv_streak,
|
||||
(unsigned)UV_DEBOUNCE_SAMPLES);
|
||||
if (s_uv_streak >= UV_DEBOUNCE_SAMPLES) {
|
||||
ESP_LOGW(TAG, "UV confirmed — latching and restarting");
|
||||
pod_settings_set_uv_latched(true);
|
||||
pod_schedule_restart();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
s_uv_streak = 0;
|
||||
}
|
||||
|
||||
void battery_uv_run_mode(void) {
|
||||
s_charge_streak = 0;
|
||||
s_uv_streak = 0;
|
||||
|
||||
led_ring_init();
|
||||
led_ring_show_battery_low();
|
||||
|
||||
ESP_LOGW(TAG, "energy-save mode — ADC poll every %u ms", UV_POLL_INTERVAL_MS);
|
||||
|
||||
while (1) {
|
||||
board_lipo_reading_t reading;
|
||||
board_input_read_lipo(&reading);
|
||||
log_lipo_reading("energy-save", &reading);
|
||||
|
||||
if (battery_uv_reading_is_external(&reading)) {
|
||||
ESP_LOGI(TAG, "external supply — clearing UV latch and restarting");
|
||||
pod_settings_clear_uv_latched();
|
||||
pod_schedule_restart();
|
||||
} else if (battery_uv_reading_is_charged(&reading)) {
|
||||
s_charge_streak++;
|
||||
ESP_LOGI(TAG, "charge sample %u/%u", (unsigned)s_charge_streak,
|
||||
(unsigned)UV_DEBOUNCE_SAMPLES);
|
||||
if (s_charge_streak >= UV_DEBOUNCE_SAMPLES) {
|
||||
ESP_LOGI(TAG, "charged — clearing UV latch and restarting");
|
||||
pod_settings_clear_uv_latched();
|
||||
pod_schedule_restart();
|
||||
}
|
||||
} else {
|
||||
s_charge_streak = 0;
|
||||
}
|
||||
|
||||
vTaskDelay(pdMS_TO_TICKS(UV_POLL_INTERVAL_MS));
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,63 @@
|
||||
#ifndef BATTERY_UV_H
|
||||
#define BATTERY_UV_H
|
||||
|
||||
#include "board_input.h"
|
||||
#include "powerpod.h"
|
||||
#include <stdbool.h>
|
||||
|
||||
#if POWERPOD_BATTERY_UV_ENABLE
|
||||
|
||||
/** ADC pin voltage (mV) below which UV is triggered (3.0 V pack via 33k/10k divider). */
|
||||
#define BATTERY_UV_ADC_MV 2300u
|
||||
/** ADC pin voltage (mV) at which charging is detected (4.0 V pack, rounded). */
|
||||
#define BATTERY_CHARGE_ADC_MV 3000u
|
||||
/**
|
||||
* ADC readings below this are treated as no pack / external supply (not UV).
|
||||
* Noise floor / open sense divider → near 0 mV.
|
||||
*/
|
||||
#define BATTERY_UV_ABSENT_ADC_MV 100u
|
||||
|
||||
bool battery_uv_is_latched(void);
|
||||
bool battery_uv_reading_is_under(const board_lipo_reading_t *reading);
|
||||
bool battery_uv_reading_is_charged(const board_lipo_reading_t *reading);
|
||||
/** true when every valid channel is near 0 (externally powered / no LiPo). */
|
||||
bool battery_uv_reading_is_external(const board_lipo_reading_t *reading);
|
||||
|
||||
/** true → enter UV energy-save path (does not return). */
|
||||
bool battery_uv_evaluate_boot(void);
|
||||
|
||||
void battery_uv_poll(const board_lipo_reading_t *reading);
|
||||
|
||||
/** Blocks: LED indicator, then minimal ADC polling until charged. */
|
||||
void battery_uv_run_mode(void);
|
||||
|
||||
#else
|
||||
|
||||
static inline bool battery_uv_is_latched(void) { return false; }
|
||||
|
||||
static inline bool battery_uv_reading_is_under(const board_lipo_reading_t *reading) {
|
||||
(void)reading;
|
||||
return false;
|
||||
}
|
||||
|
||||
static inline bool battery_uv_reading_is_charged(const board_lipo_reading_t *reading) {
|
||||
(void)reading;
|
||||
return false;
|
||||
}
|
||||
|
||||
static inline bool battery_uv_reading_is_external(const board_lipo_reading_t *reading) {
|
||||
(void)reading;
|
||||
return false;
|
||||
}
|
||||
|
||||
static inline bool battery_uv_evaluate_boot(void) { return false; }
|
||||
|
||||
static inline void battery_uv_poll(const board_lipo_reading_t *reading) {
|
||||
(void)reading;
|
||||
}
|
||||
|
||||
static inline void battery_uv_run_mode(void) {}
|
||||
|
||||
#endif
|
||||
|
||||
#endif
|
||||
+496
@@ -0,0 +1,496 @@
|
||||
#include "ble_host.h"
|
||||
#include "esp_log.h"
|
||||
#include "esp_timer.h"
|
||||
#include "host_transport.h"
|
||||
#include "nvs_flash.h"
|
||||
#include "ota_espnow.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/semphr.h"
|
||||
#include "freertos/task.h"
|
||||
|
||||
#include "nimble/nimble_port.h"
|
||||
#include "nimble/nimble_port_freertos.h"
|
||||
#include "host/ble_att.h"
|
||||
#include "host/ble_hs.h"
|
||||
#include "host/util/util.h"
|
||||
#include "services/gap/ble_svc_gap.h"
|
||||
#include "services/gatt/ble_svc_gatt.h"
|
||||
|
||||
#include <string.h>
|
||||
|
||||
static const char *TAG = "[BLE_HOST]";
|
||||
static const char *DEVICE_NAME = "Powerpod";
|
||||
|
||||
#define BLE_CONN_ITVL_MIN_UNITS 6 /* 7.5 ms */
|
||||
#define BLE_CONN_ITVL_MAX_UNITS 6
|
||||
#define BLE_CONN_TUNE_MAX_TRIES 6
|
||||
/** Prefer large ATT MTU so CLIENT_INFO / BATTERY fit in fewer notify chunks. */
|
||||
#define BLE_PREFERRED_ATT_MTU 517
|
||||
#define BLE_NOTIFY_CHUNK_RETRIES 24
|
||||
/** During ESP-NOW OTA dist, fail notify sooner so WiFi/ESP-NOW keeps airtime. */
|
||||
#define BLE_NOTIFY_CHUNK_RETRIES_OTA_DIST 4
|
||||
#define BLE_NOTIFY_CHUNK_RETRY_MS 5
|
||||
/** Pace multi-chunk notifies so the central parser sees contiguous frame bytes. */
|
||||
#define BLE_NOTIFY_CHUNK_GAP_MS 8
|
||||
#define BLE_NOTIFY_CHUNK_GAP_OTA_DIST_MS 2
|
||||
|
||||
/*
|
||||
* Nordic UART style:
|
||||
* Service: 6e400001-b5a3-f393-e0a9-e50e24dcca9e
|
||||
* Characteristic: 6e400002-b5a3-f393-e0a9-e50e24dcca9e
|
||||
* Write = host→ESP (framed), Notify = ESP→host (framed).
|
||||
*/
|
||||
static const ble_uuid128_t ble_svc_uuid =
|
||||
BLE_UUID128_INIT(0x9e, 0xca, 0xdc, 0x24, 0x0e, 0xe5, 0xa9, 0xe0, 0x93, 0xf3,
|
||||
0xa3, 0xb5, 0x01, 0x00, 0x40, 0x6e);
|
||||
static const ble_uuid128_t ble_chr_uuid =
|
||||
BLE_UUID128_INIT(0x9e, 0xca, 0xdc, 0x24, 0x0e, 0xe5, 0xa9, 0xe0, 0x93, 0xf3,
|
||||
0xa3, 0xb5, 0x02, 0x00, 0x40, 0x6e);
|
||||
|
||||
static uint8_t own_addr_type;
|
||||
static uint16_t conn_handle = BLE_HS_CONN_HANDLE_NONE;
|
||||
static uint16_t ble_chr_val_handle;
|
||||
static bool notify_enabled;
|
||||
static uint16_t att_mtu = 23;
|
||||
static esp_timer_handle_t conn_tune_timer;
|
||||
static uint8_t conn_tune_tries;
|
||||
static SemaphoreHandle_t s_tx_mu;
|
||||
|
||||
static int ble_gap_event(struct ble_gap_event *event, void *arg);
|
||||
static void ble_request_fast_conn_params(uint16_t handle);
|
||||
static void ble_optimize_link(uint16_t handle);
|
||||
|
||||
bool ble_host_ready(void) {
|
||||
return notify_enabled && conn_handle != BLE_HS_CONN_HANDLE_NONE;
|
||||
}
|
||||
|
||||
uint16_t ble_host_att_mtu(void) { return att_mtu; }
|
||||
|
||||
uint16_t ble_host_max_payload(void) {
|
||||
if (att_mtu < 4) {
|
||||
return 20;
|
||||
}
|
||||
return (uint16_t)(att_mtu - 3);
|
||||
}
|
||||
|
||||
/**
|
||||
* One ATT notification (≤ MTU-3). Retries on transient NimBLE buffer pressure.
|
||||
*/
|
||||
static esp_err_t ble_notify_chunk(const uint8_t *data, uint16_t len) {
|
||||
const int max_retries = ota_espnow_distribution_active()
|
||||
? BLE_NOTIFY_CHUNK_RETRIES_OTA_DIST
|
||||
: BLE_NOTIFY_CHUNK_RETRIES;
|
||||
for (int attempt = 0; attempt < max_retries; attempt++) {
|
||||
struct os_mbuf *om = ble_hs_mbuf_from_flat(data, len);
|
||||
if (om == NULL) {
|
||||
vTaskDelay(pdMS_TO_TICKS(BLE_NOTIFY_CHUNK_RETRY_MS));
|
||||
continue;
|
||||
}
|
||||
|
||||
int rc = ble_gatts_notify_custom(conn_handle, ble_chr_val_handle, om);
|
||||
if (rc == 0) {
|
||||
return ESP_OK;
|
||||
}
|
||||
/* Ownership stays with us on failure. */
|
||||
os_mbuf_free_chain(om);
|
||||
if (!ble_host_ready()) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
ESP_LOGD(TAG, "notify chunk rc=%d attempt=%d len=%u", rc, attempt,
|
||||
(unsigned)len);
|
||||
vTaskDelay(pdMS_TO_TICKS(BLE_NOTIFY_CHUNK_RETRY_MS));
|
||||
}
|
||||
ESP_LOGW(TAG, "notify chunk gave up after retries len=%u", (unsigned)len);
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a full host frame. If larger than ATT MTU-3, split across consecutive
|
||||
* notifies; goTool feeds every notify into the same byte parser, so the frame
|
||||
* reassembles without a protocol change. Mutex keeps stream ticks from
|
||||
* interleaving mid-frame.
|
||||
*/
|
||||
static esp_err_t ble_host_tx(const uint8_t *frame, size_t frame_len) {
|
||||
if (!ble_host_ready()) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
if (frame == NULL || frame_len == 0) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
|
||||
uint16_t max_chunk = ble_host_max_payload();
|
||||
if (max_chunk == 0) {
|
||||
return ESP_ERR_INVALID_SIZE;
|
||||
}
|
||||
|
||||
if (s_tx_mu == NULL ||
|
||||
xSemaphoreTake(s_tx_mu, pdMS_TO_TICKS(2000)) != pdTRUE) {
|
||||
return ESP_ERR_TIMEOUT;
|
||||
}
|
||||
|
||||
esp_err_t result = ESP_OK;
|
||||
size_t off = 0;
|
||||
while (off < frame_len) {
|
||||
if (!ble_host_ready()) {
|
||||
result = ESP_ERR_INVALID_STATE;
|
||||
break;
|
||||
}
|
||||
size_t remain = frame_len - off;
|
||||
uint16_t n =
|
||||
(uint16_t)(remain > max_chunk ? max_chunk : remain);
|
||||
result = ble_notify_chunk(&frame[off], n);
|
||||
if (result != ESP_OK) {
|
||||
ESP_LOGW(TAG, "TX abort at %u/%u (chunk %u, mtu_pay %u)",
|
||||
(unsigned)off, (unsigned)frame_len, (unsigned)n,
|
||||
(unsigned)max_chunk);
|
||||
break;
|
||||
}
|
||||
off += n;
|
||||
if (off < frame_len) {
|
||||
const uint32_t gap_ms = ota_espnow_distribution_active()
|
||||
? BLE_NOTIFY_CHUNK_GAP_OTA_DIST_MS
|
||||
: BLE_NOTIFY_CHUNK_GAP_MS;
|
||||
vTaskDelay(pdMS_TO_TICKS(gap_ms));
|
||||
}
|
||||
}
|
||||
|
||||
xSemaphoreGive(s_tx_mu);
|
||||
return result;
|
||||
}
|
||||
|
||||
static void ble_conn_tune_timer_cb(void *arg) {
|
||||
struct ble_gap_conn_desc desc;
|
||||
|
||||
(void)arg;
|
||||
if (conn_handle == BLE_HS_CONN_HANDLE_NONE) {
|
||||
return;
|
||||
}
|
||||
if (ble_gap_conn_find(conn_handle, &desc) != 0) {
|
||||
return;
|
||||
}
|
||||
if (desc.conn_itvl <= BLE_CONN_ITVL_MAX_UNITS) {
|
||||
ESP_LOGI(TAG, "conn already at interval_us=%lu",
|
||||
(unsigned long)desc.conn_itvl * 1250UL);
|
||||
return;
|
||||
}
|
||||
if (conn_tune_tries >= BLE_CONN_TUNE_MAX_TRIES) {
|
||||
ESP_LOGW(TAG, "central kept interval_us=%lu (ESP requested 7500)",
|
||||
(unsigned long)desc.conn_itvl * 1250UL);
|
||||
return;
|
||||
}
|
||||
|
||||
conn_tune_tries++;
|
||||
ESP_LOGI(TAG, "retry fast conn params (%u/%u); current interval_us=%lu",
|
||||
conn_tune_tries, BLE_CONN_TUNE_MAX_TRIES,
|
||||
(unsigned long)desc.conn_itvl * 1250UL);
|
||||
ble_request_fast_conn_params(conn_handle);
|
||||
esp_timer_start_once(conn_tune_timer, 750 * 1000);
|
||||
}
|
||||
|
||||
static void ble_schedule_conn_tune(void) {
|
||||
conn_tune_tries = 0;
|
||||
esp_timer_stop(conn_tune_timer);
|
||||
esp_timer_start_once(conn_tune_timer, 300 * 1000);
|
||||
}
|
||||
|
||||
static void ble_request_fast_conn_params(uint16_t handle) {
|
||||
struct ble_gap_upd_params params = {
|
||||
.itvl_min = BLE_CONN_ITVL_MIN_UNITS,
|
||||
.itvl_max = BLE_CONN_ITVL_MAX_UNITS,
|
||||
.latency = 0,
|
||||
.supervision_timeout = 400,
|
||||
.min_ce_len = 0,
|
||||
.max_ce_len = 24,
|
||||
};
|
||||
int rc = ble_gap_update_params(handle, ¶ms);
|
||||
if (rc != 0) {
|
||||
ESP_LOGW(TAG, "conn param update request failed: %d", rc);
|
||||
} else {
|
||||
ESP_LOGI(TAG, "requested conn interval 7.5 ms");
|
||||
}
|
||||
}
|
||||
|
||||
static void ble_optimize_link(uint16_t handle) {
|
||||
int rc;
|
||||
|
||||
/*
|
||||
* Do NOT request 2M PHY here. With ESP-NOW/WiFi coexistence the controller
|
||||
* can assert in llc_phy_upd (e.g. "BLE assert llc_phy_upd.c …") and the
|
||||
* host link dies — no further responses. Stay on default 1M.
|
||||
*/
|
||||
ble_request_fast_conn_params(handle);
|
||||
|
||||
rc = ble_gap_set_data_len(handle, 251, 2120);
|
||||
if (rc != 0) {
|
||||
ESP_LOGW(TAG, "data len request failed: %d", rc);
|
||||
}
|
||||
|
||||
ble_schedule_conn_tune();
|
||||
}
|
||||
|
||||
static void ble_advertise(void) {
|
||||
struct ble_gap_adv_params adv_params;
|
||||
struct ble_hs_adv_fields fields;
|
||||
int rc;
|
||||
|
||||
memset(&fields, 0, sizeof(fields));
|
||||
fields.flags = BLE_HS_ADV_F_DISC_GEN | BLE_HS_ADV_F_BREDR_UNSUP;
|
||||
fields.tx_pwr_lvl_is_present = 1;
|
||||
fields.tx_pwr_lvl = BLE_HS_ADV_TX_PWR_LVL_AUTO;
|
||||
fields.name = (uint8_t *)DEVICE_NAME;
|
||||
fields.name_len = strlen(DEVICE_NAME);
|
||||
fields.name_is_complete = 1;
|
||||
|
||||
rc = ble_gap_adv_set_fields(&fields);
|
||||
if (rc != 0) {
|
||||
ESP_LOGE(TAG, "adv set fields failed: %d", rc);
|
||||
return;
|
||||
}
|
||||
|
||||
memset(&adv_params, 0, sizeof(adv_params));
|
||||
adv_params.conn_mode = BLE_GAP_CONN_MODE_UND;
|
||||
adv_params.disc_mode = BLE_GAP_DISC_MODE_GEN;
|
||||
adv_params.itvl_min = 0x20;
|
||||
adv_params.itvl_max = 0x30;
|
||||
|
||||
rc = ble_gap_adv_start(own_addr_type, NULL, BLE_HS_FOREVER, &adv_params,
|
||||
ble_gap_event, NULL);
|
||||
if (rc != 0) {
|
||||
ESP_LOGE(TAG, "adv start failed: %d", rc);
|
||||
return;
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "advertising as \"%s\"", DEVICE_NAME);
|
||||
}
|
||||
|
||||
static int ble_chr_access(uint16_t conn_handle_arg, uint16_t attr_handle,
|
||||
struct ble_gatt_access_ctxt *ctxt, void *arg) {
|
||||
(void)conn_handle_arg;
|
||||
(void)attr_handle;
|
||||
(void)arg;
|
||||
|
||||
switch (ctxt->op) {
|
||||
case BLE_GATT_ACCESS_OP_READ_CHR:
|
||||
return 0;
|
||||
|
||||
case BLE_GATT_ACCESS_OP_WRITE_CHR: {
|
||||
uint16_t om_len = OS_MBUF_PKTLEN(ctxt->om);
|
||||
uint8_t buf[512];
|
||||
uint16_t copied = 0;
|
||||
int rc;
|
||||
|
||||
if (om_len == 0 || om_len > sizeof(buf)) {
|
||||
return BLE_ATT_ERR_INVALID_ATTR_VALUE_LEN;
|
||||
}
|
||||
|
||||
rc = ble_hs_mbuf_to_flat(ctxt->om, buf, sizeof(buf), &copied);
|
||||
if (rc != 0) {
|
||||
return BLE_ATT_ERR_UNLIKELY;
|
||||
}
|
||||
|
||||
host_rx_bytes(buf, copied);
|
||||
return 0;
|
||||
}
|
||||
|
||||
default:
|
||||
return BLE_ATT_ERR_UNLIKELY;
|
||||
}
|
||||
}
|
||||
|
||||
static const struct ble_gatt_svc_def gatt_svcs[] = {
|
||||
{
|
||||
.type = BLE_GATT_SVC_TYPE_PRIMARY,
|
||||
.uuid = &ble_svc_uuid.u,
|
||||
.characteristics =
|
||||
(struct ble_gatt_chr_def[]){
|
||||
{
|
||||
.uuid = &ble_chr_uuid.u,
|
||||
.access_cb = ble_chr_access,
|
||||
.val_handle = &ble_chr_val_handle,
|
||||
.flags = BLE_GATT_CHR_F_READ | BLE_GATT_CHR_F_WRITE |
|
||||
BLE_GATT_CHR_F_WRITE_NO_RSP | BLE_GATT_CHR_F_NOTIFY,
|
||||
},
|
||||
{0},
|
||||
},
|
||||
},
|
||||
{0},
|
||||
};
|
||||
|
||||
static int gatt_svr_init(void) {
|
||||
int rc;
|
||||
|
||||
ble_svc_gap_init();
|
||||
ble_svc_gatt_init();
|
||||
|
||||
rc = ble_gatts_count_cfg(gatt_svcs);
|
||||
if (rc != 0) {
|
||||
return rc;
|
||||
}
|
||||
|
||||
return ble_gatts_add_svcs(gatt_svcs);
|
||||
}
|
||||
|
||||
static int ble_gap_event(struct ble_gap_event *event, void *arg) {
|
||||
(void)arg;
|
||||
|
||||
switch (event->type) {
|
||||
case BLE_GAP_EVENT_CONNECT:
|
||||
ESP_LOGI(TAG, "connection %s; status=%d handle=%d",
|
||||
event->connect.status == 0 ? "established" : "failed",
|
||||
event->connect.status, event->connect.conn_handle);
|
||||
if (event->connect.status != 0) {
|
||||
ble_advertise();
|
||||
} else {
|
||||
conn_handle = event->connect.conn_handle;
|
||||
ble_optimize_link(conn_handle);
|
||||
}
|
||||
return 0;
|
||||
|
||||
case BLE_GAP_EVENT_DISCONNECT:
|
||||
ESP_LOGI(TAG, "disconnect; reason=%d", event->disconnect.reason);
|
||||
esp_timer_stop(conn_tune_timer);
|
||||
conn_tune_tries = 0;
|
||||
conn_handle = BLE_HS_CONN_HANDLE_NONE;
|
||||
notify_enabled = false;
|
||||
att_mtu = 23;
|
||||
ble_advertise();
|
||||
return 0;
|
||||
|
||||
case BLE_GAP_EVENT_CONN_UPDATE: {
|
||||
struct ble_gap_conn_desc desc;
|
||||
if (ble_gap_conn_find(event->conn_update.conn_handle, &desc) == 0) {
|
||||
ESP_LOGI(TAG, "conn updated; status=%d interval_us=%lu latency=%u",
|
||||
event->conn_update.status,
|
||||
(unsigned long)desc.conn_itvl * 1250UL, desc.conn_latency);
|
||||
if (event->conn_update.status == 0 &&
|
||||
desc.conn_itvl > BLE_CONN_ITVL_MAX_UNITS &&
|
||||
conn_tune_tries < BLE_CONN_TUNE_MAX_TRIES) {
|
||||
esp_timer_stop(conn_tune_timer);
|
||||
esp_timer_start_once(conn_tune_timer, 500 * 1000);
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
case BLE_GAP_EVENT_PHY_UPDATE_COMPLETE:
|
||||
ESP_LOGI(TAG, "phy update; status=%d tx=%u rx=%u",
|
||||
event->phy_updated.status, event->phy_updated.tx_phy,
|
||||
event->phy_updated.rx_phy);
|
||||
return 0;
|
||||
|
||||
case BLE_GAP_EVENT_ADV_COMPLETE:
|
||||
ESP_LOGI(TAG, "advertise complete; reason=%d", event->adv_complete.reason);
|
||||
ble_advertise();
|
||||
return 0;
|
||||
|
||||
case BLE_GAP_EVENT_SUBSCRIBE:
|
||||
if (event->subscribe.attr_handle == ble_chr_val_handle) {
|
||||
notify_enabled = event->subscribe.cur_notify;
|
||||
ESP_LOGI(TAG, "notify %s", notify_enabled ? "enabled" : "disabled");
|
||||
if (notify_enabled && conn_handle != BLE_HS_CONN_HANDLE_NONE) {
|
||||
ble_request_fast_conn_params(conn_handle);
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
|
||||
case BLE_GAP_EVENT_MTU:
|
||||
att_mtu = event->mtu.value;
|
||||
ESP_LOGI(TAG, "mtu update; handle=%d mtu=%d", event->mtu.conn_handle,
|
||||
event->mtu.value);
|
||||
return 0;
|
||||
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
static void ble_on_sync(void) {
|
||||
int rc;
|
||||
|
||||
rc = ble_hs_id_infer_auto(0, &own_addr_type);
|
||||
if (rc != 0) {
|
||||
ESP_LOGE(TAG, "ble_hs_id_infer_auto failed: %d", rc);
|
||||
return;
|
||||
}
|
||||
|
||||
uint8_t addr_val[6] = {0};
|
||||
ble_hs_id_copy_addr(own_addr_type, addr_val, NULL);
|
||||
ESP_LOGI(TAG, "device address: %02x:%02x:%02x:%02x:%02x:%02x", addr_val[5],
|
||||
addr_val[4], addr_val[3], addr_val[2], addr_val[1], addr_val[0]);
|
||||
|
||||
ble_advertise();
|
||||
}
|
||||
|
||||
static void ble_on_reset(int reason) {
|
||||
ESP_LOGE(TAG, "nimble reset; reason=%d", reason);
|
||||
}
|
||||
|
||||
static void ble_host_task(void *param) {
|
||||
(void)param;
|
||||
ESP_LOGI(TAG, "nimble host task started");
|
||||
nimble_port_run();
|
||||
nimble_port_freertos_deinit();
|
||||
}
|
||||
|
||||
esp_err_t init_ble_host(QueueHandle_t cmd_queue) {
|
||||
esp_err_t ret;
|
||||
|
||||
host_transport_set_cmd_queue(cmd_queue);
|
||||
host_transport_set_tx(ble_host_tx);
|
||||
|
||||
s_tx_mu = xSemaphoreCreateMutex();
|
||||
if (s_tx_mu == NULL) {
|
||||
ESP_LOGE(TAG, "failed to create TX mutex");
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
|
||||
/* NVS may already be init via pod_settings; ignore ALREADY_INIT. */
|
||||
ret = nvs_flash_init();
|
||||
if (ret == ESP_ERR_NVS_NO_FREE_PAGES ||
|
||||
ret == ESP_ERR_NVS_NEW_VERSION_FOUND) {
|
||||
ESP_ERROR_CHECK(nvs_flash_erase());
|
||||
ret = nvs_flash_init();
|
||||
}
|
||||
if (ret != ESP_OK && ret != ESP_ERR_INVALID_STATE) {
|
||||
ESP_LOGE(TAG, "nvs_flash_init failed: %s", esp_err_to_name(ret));
|
||||
return ret;
|
||||
}
|
||||
|
||||
const esp_timer_create_args_t tune_args = {
|
||||
.callback = &ble_conn_tune_timer_cb,
|
||||
.name = "ble_conn_tune",
|
||||
};
|
||||
ESP_ERROR_CHECK(esp_timer_create(&tune_args, &conn_tune_timer));
|
||||
|
||||
ret = nimble_port_init();
|
||||
if (ret != ESP_OK) {
|
||||
ESP_LOGE(TAG, "nimble_port_init failed: %s", esp_err_to_name(ret));
|
||||
return ret;
|
||||
}
|
||||
|
||||
ble_hs_cfg.sync_cb = ble_on_sync;
|
||||
ble_hs_cfg.reset_cb = ble_on_reset;
|
||||
|
||||
int rc = gatt_svr_init();
|
||||
if (rc != 0) {
|
||||
ESP_LOGE(TAG, "gatt_svr_init failed: %d", rc);
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
rc = ble_svc_gap_device_name_set(DEVICE_NAME);
|
||||
if (rc != 0) {
|
||||
ESP_LOGE(TAG, "device name set failed: %d", rc);
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
rc = ble_att_set_preferred_mtu(BLE_PREFERRED_ATT_MTU);
|
||||
if (rc != 0) {
|
||||
ESP_LOGW(TAG, "preferred MTU set failed: %d", rc);
|
||||
} else {
|
||||
ESP_LOGI(TAG, "preferred ATT MTU %u", (unsigned)BLE_PREFERRED_ATT_MTU);
|
||||
}
|
||||
|
||||
nimble_port_freertos_init(ble_host_task);
|
||||
ESP_LOGI(TAG, "BLE host transport started");
|
||||
return ESP_OK;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
#ifndef BLE_HOST_H
|
||||
#define BLE_HOST_H
|
||||
|
||||
#include "esp_err.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/queue.h"
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
/** Start NimBLE host transport (Master only). Uses same framing as UART. */
|
||||
esp_err_t init_ble_host(QueueHandle_t cmd_queue);
|
||||
|
||||
/** True when a central is connected and CCCD notify is enabled. */
|
||||
bool ble_host_ready(void);
|
||||
|
||||
/** Negotiated ATT MTU (default 23 until exchange). */
|
||||
uint16_t ble_host_att_mtu(void);
|
||||
|
||||
/** Max bytes per ATT notify/write (ATT MTU - 3). Frames may span multiple notifies. */
|
||||
uint16_t ble_host_max_payload(void);
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,196 @@
|
||||
#include "ble_stream.h"
|
||||
#include "app_config.h"
|
||||
#include "ble_host.h"
|
||||
#include "client_registry.h"
|
||||
#include "esp_log.h"
|
||||
#include "esp_timer.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
#include "ota_session.h"
|
||||
#include "uart_cmd.h"
|
||||
#include "uart_messages.pb.h"
|
||||
#include "uart_proto.h"
|
||||
#include <string.h>
|
||||
|
||||
static const char *TAG = "[BLE_STREAM]";
|
||||
|
||||
/* Min matches FreeRTOS tick (HZ=100 → 10 ms); 8 ms would delay 0 ticks. */
|
||||
#define BLE_STREAM_PERIOD_MIN_MS 10u
|
||||
#define BLE_STREAM_PERIOD_MAX_MS 16u
|
||||
#define BLE_STREAM_PERIOD_DEFAULT_MS 10u
|
||||
|
||||
static uint32_t s_period_ms = BLE_STREAM_PERIOD_DEFAULT_MS;
|
||||
static TaskHandle_t s_task;
|
||||
static volatile bool s_running;
|
||||
|
||||
static bool tap_notify_any(const client_info_t *client) {
|
||||
return client != NULL &&
|
||||
(client->tap_notify_single || client->tap_notify_double ||
|
||||
client->tap_notify_triple);
|
||||
}
|
||||
|
||||
static alox_TapKind tap_kind_from_registry(uint32_t kind) {
|
||||
switch (kind) {
|
||||
case 1:
|
||||
return alox_TapKind_TAP_SINGLE;
|
||||
case 2:
|
||||
return alox_TapKind_TAP_DOUBLE;
|
||||
case 3:
|
||||
return alox_TapKind_TAP_TRIPLE;
|
||||
default:
|
||||
return alox_TapKind_TAP_NONE;
|
||||
}
|
||||
}
|
||||
|
||||
static void fill_clients(alox_BleStreamTick *tick) {
|
||||
tick->clients_count = 0;
|
||||
size_t count = client_registry_count();
|
||||
for (size_t i = 0; i < count; i++) {
|
||||
const client_info_t *client = client_registry_at(i);
|
||||
if (client == NULL) {
|
||||
continue;
|
||||
}
|
||||
const bool want_accel = client->accel_stream_enabled;
|
||||
const bool want_tap = tap_notify_any(client);
|
||||
if (!want_accel && !want_tap) {
|
||||
continue;
|
||||
}
|
||||
if (tick->clients_count >=
|
||||
sizeof(tick->clients) / sizeof(tick->clients[0])) {
|
||||
break;
|
||||
}
|
||||
|
||||
alox_CacheClientStatus *entry = &tick->clients[tick->clients_count++];
|
||||
entry->client_id = client->id;
|
||||
entry->has_accel = false;
|
||||
entry->has_tap = false;
|
||||
|
||||
if (want_accel) {
|
||||
entry->has_accel = true;
|
||||
entry->accel.valid = client->accel_valid;
|
||||
if (client->accel_valid) {
|
||||
entry->accel.x = client->accel_x;
|
||||
entry->accel.y = client->accel_y;
|
||||
entry->accel.z = client->accel_z;
|
||||
entry->accel.age_ms =
|
||||
client_registry_ms_since(client->accel_updated_at);
|
||||
}
|
||||
}
|
||||
|
||||
if (want_tap) {
|
||||
uint32_t kind = 0;
|
||||
uint32_t age_ms = 0;
|
||||
if (client_registry_take_tap(client->id, &kind, &age_ms)) {
|
||||
entry->has_tap = true;
|
||||
entry->tap.kind = tap_kind_from_registry(kind);
|
||||
entry->tap.age_ms = age_ms;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static bool any_accel_stream(void) {
|
||||
size_t count = client_registry_count();
|
||||
for (size_t i = 0; i < count; i++) {
|
||||
const client_info_t *client = client_registry_at(i);
|
||||
if (client != NULL && client->accel_stream_enabled) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static esp_err_t send_tick_truncated(alox_UartMessage *msg) {
|
||||
/*
|
||||
* ble_host_tx splits frames larger than ATT MTU-3 across notifies. Only
|
||||
* shrink the tick if it cannot encode into MAX_PAYLOAD_SIZE.
|
||||
*/
|
||||
while (true) {
|
||||
esp_err_t err = uart_send_uart_message(msg);
|
||||
if (err == ESP_OK) {
|
||||
return ESP_OK;
|
||||
}
|
||||
if (msg->payload.ble_stream_tick.clients_count == 0) {
|
||||
ESP_LOGW(TAG, "tick too large even with 0 clients: %s",
|
||||
esp_err_to_name(err));
|
||||
return err;
|
||||
}
|
||||
msg->payload.ble_stream_tick.clients_count--;
|
||||
ESP_LOGW(TAG, "truncating tick clients to %u for encode buffer",
|
||||
(unsigned)msg->payload.ble_stream_tick.clients_count);
|
||||
}
|
||||
}
|
||||
|
||||
static void ble_stream_task(void *param) {
|
||||
(void)param;
|
||||
ESP_LOGI(TAG, "BLE stream task started period_ms=%lu",
|
||||
(unsigned long)s_period_ms);
|
||||
|
||||
while (s_running) {
|
||||
uint32_t period = s_period_ms;
|
||||
if (period < BLE_STREAM_PERIOD_MIN_MS) {
|
||||
period = BLE_STREAM_PERIOD_MIN_MS;
|
||||
}
|
||||
if (period > BLE_STREAM_PERIOD_MAX_MS) {
|
||||
period = BLE_STREAM_PERIOD_MAX_MS;
|
||||
}
|
||||
|
||||
/* Pause ticks during host OTA / ESP-NOW distribute so ATT bandwidth
|
||||
* stays available for OTA_STATUS notifies. */
|
||||
if (ble_host_ready() && any_accel_stream() && !ota_session_busy()) {
|
||||
alox_UartMessage msg = alox_UartMessage_init_zero;
|
||||
uart_cmd_init_response(&msg, alox_MessageType_BLE_STREAM_TICK,
|
||||
alox_UartMessage_ble_stream_tick_tag);
|
||||
msg.payload.ble_stream_tick.esp_timestamp_us =
|
||||
(uint64_t)esp_timer_get_time();
|
||||
msg.payload.ble_stream_tick.period_ms = period;
|
||||
fill_clients(&msg.payload.ble_stream_tick);
|
||||
|
||||
esp_err_t err = send_tick_truncated(&msg);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGW(TAG, "tick send failed: %s", esp_err_to_name(err));
|
||||
}
|
||||
}
|
||||
|
||||
TickType_t delay_ticks = pdMS_TO_TICKS(period);
|
||||
if (delay_ticks < 1) {
|
||||
delay_ticks = 1;
|
||||
}
|
||||
vTaskDelay(delay_ticks);
|
||||
}
|
||||
|
||||
s_task = NULL;
|
||||
vTaskDelete(NULL);
|
||||
}
|
||||
|
||||
void ble_stream_set_period_ms(uint32_t period_ms) {
|
||||
if (period_ms == 0) {
|
||||
period_ms = BLE_STREAM_PERIOD_DEFAULT_MS;
|
||||
}
|
||||
if (period_ms < BLE_STREAM_PERIOD_MIN_MS) {
|
||||
period_ms = BLE_STREAM_PERIOD_MIN_MS;
|
||||
}
|
||||
if (period_ms > BLE_STREAM_PERIOD_MAX_MS) {
|
||||
period_ms = BLE_STREAM_PERIOD_MAX_MS;
|
||||
}
|
||||
s_period_ms = period_ms;
|
||||
}
|
||||
|
||||
uint32_t ble_stream_period_ms(void) { return s_period_ms; }
|
||||
|
||||
void ble_stream_update(void) {
|
||||
const app_config_t *cfg = app_config_get();
|
||||
bool want = cfg != NULL && cfg->host_ble && any_accel_stream();
|
||||
|
||||
if (want && !s_running) {
|
||||
s_running = true;
|
||||
if (xTaskCreate(ble_stream_task, "ble_stream", 8192, NULL, 5, &s_task) !=
|
||||
pdPASS) {
|
||||
s_running = false;
|
||||
s_task = NULL;
|
||||
ESP_LOGE(TAG, "failed to create ble_stream task");
|
||||
}
|
||||
} else if (!want && s_running) {
|
||||
s_running = false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
#ifndef BLE_STREAM_H
|
||||
#define BLE_STREAM_H
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
|
||||
/** Clamp and store BLE push period (10–16 ms; min = FreeRTOS tick). */
|
||||
void ble_stream_set_period_ms(uint32_t period_ms);
|
||||
|
||||
uint32_t ble_stream_period_ms(void);
|
||||
|
||||
/**
|
||||
* Re-evaluate whether the BLE push task should run (host_ble + any accel
|
||||
* stream enabled). Safe to call from accel-stream handlers.
|
||||
*/
|
||||
void ble_stream_update(void);
|
||||
|
||||
#endif
|
||||
+74
-40
@@ -1,6 +1,10 @@
|
||||
#include "board_input.h"
|
||||
#include "powerpod.h"
|
||||
#include "client_registry.h"
|
||||
#include "driver/gpio.h"
|
||||
#if POWERPOD_BATTERY_UV_ENABLE
|
||||
#include "battery_uv.h"
|
||||
#endif
|
||||
#include "esp_adc/adc_oneshot.h"
|
||||
#include "esp_log.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
@@ -18,34 +22,51 @@ static const char *TAG_LIPO = "[LIPO]";
|
||||
#define LIPO_ADC_MAX_RAW 4095
|
||||
|
||||
static QueueHandle_t s_button_queue;
|
||||
static adc_oneshot_unit_handle_t s_adc;
|
||||
static bool s_lipo1_ok;
|
||||
static bool s_lipo2_ok;
|
||||
static adc_channel_t s_lipo1_ch;
|
||||
static adc_channel_t s_lipo2_ch;
|
||||
|
||||
static esp_err_t adc_init_channel(int gpio, adc_channel_t *out_ch, bool *out_ok) {
|
||||
adc_unit_t unit;
|
||||
esp_err_t err = adc_oneshot_io_to_channel(gpio, &unit, out_ch);
|
||||
typedef struct {
|
||||
adc_oneshot_unit_handle_t unit;
|
||||
adc_channel_t ch;
|
||||
bool ok;
|
||||
} lipo_adc_t;
|
||||
|
||||
static lipo_adc_t s_lipo1;
|
||||
static lipo_adc_t s_lipo2;
|
||||
|
||||
static esp_err_t adc_init_gpio(int gpio, lipo_adc_t *out) {
|
||||
out->unit = NULL;
|
||||
out->ok = false;
|
||||
|
||||
adc_unit_t unit_id;
|
||||
esp_err_t err = adc_oneshot_io_to_channel(gpio, &unit_id, &out->ch);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGW(TAG_LIPO, "GPIO%d not an ADC channel: %s", gpio, esp_err_to_name(err));
|
||||
*out_ok = false;
|
||||
return err;
|
||||
}
|
||||
if (unit != ADC_UNIT_1) {
|
||||
ESP_LOGW(TAG_LIPO, "GPIO%d on ADC unit %d (expected ADC1)", gpio, (int)unit);
|
||||
|
||||
adc_oneshot_unit_init_cfg_t init_cfg = {
|
||||
.unit_id = unit_id,
|
||||
};
|
||||
err = adc_oneshot_new_unit(&init_cfg, &out->unit);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGW(TAG_LIPO, "ADC unit %d init GPIO%d failed: %s", (int)unit_id, gpio,
|
||||
esp_err_to_name(err));
|
||||
return err;
|
||||
}
|
||||
|
||||
adc_oneshot_chan_cfg_t chan_cfg = {
|
||||
.atten = ADC_ATTEN_DB_12,
|
||||
.bitwidth = ADC_BITWIDTH_DEFAULT,
|
||||
};
|
||||
err = adc_oneshot_config_channel(s_adc, *out_ch, &chan_cfg);
|
||||
err = adc_oneshot_config_channel(out->unit, out->ch, &chan_cfg);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGW(TAG_LIPO, "ADC config GPIO%d failed: %s", gpio, esp_err_to_name(err));
|
||||
*out_ok = false;
|
||||
adc_oneshot_del_unit(out->unit);
|
||||
out->unit = NULL;
|
||||
return err;
|
||||
}
|
||||
*out_ok = true;
|
||||
|
||||
out->ok = true;
|
||||
ESP_LOGI(TAG_LIPO, "GPIO%d ready (ADC unit %d)", gpio, (int)unit_id);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
@@ -56,15 +77,15 @@ static uint32_t raw_to_mv(int raw) {
|
||||
return (uint32_t)((raw * LIPO_ADC_FULL_SCALE_MV) / LIPO_ADC_MAX_RAW);
|
||||
}
|
||||
|
||||
static void sample_one_channel(adc_channel_t ch, bool ok, uint32_t *mv_out,
|
||||
static void sample_one_channel(const lipo_adc_t *adc, uint32_t *mv_out,
|
||||
bool *valid_out) {
|
||||
*valid_out = false;
|
||||
*mv_out = 0;
|
||||
if (!ok || s_adc == NULL) {
|
||||
if (adc == NULL || !adc->ok || adc->unit == NULL) {
|
||||
return;
|
||||
}
|
||||
int raw = 0;
|
||||
if (adc_oneshot_read(s_adc, ch, &raw) == ESP_OK) {
|
||||
if (adc_oneshot_read(adc->unit, adc->ch, &raw) == ESP_OK) {
|
||||
*valid_out = true;
|
||||
*mv_out = raw_to_mv(raw);
|
||||
}
|
||||
@@ -75,8 +96,8 @@ void board_input_read_lipo(board_lipo_reading_t *out) {
|
||||
return;
|
||||
}
|
||||
memset(out, 0, sizeof(*out));
|
||||
sample_one_channel(s_lipo1_ch, s_lipo1_ok, &out->lipo1_mv, &out->lipo1_valid);
|
||||
sample_one_channel(s_lipo2_ch, s_lipo2_ok, &out->lipo2_mv, &out->lipo2_valid);
|
||||
sample_one_channel(&s_lipo1, &out->lipo1_mv, &out->lipo1_valid);
|
||||
sample_one_channel(&s_lipo2, &out->lipo2_mv, &out->lipo2_valid);
|
||||
}
|
||||
|
||||
static void lipo_monitor_task(void *param) {
|
||||
@@ -87,6 +108,7 @@ static void lipo_monitor_task(void *param) {
|
||||
while (1) {
|
||||
board_lipo_reading_t reading;
|
||||
board_input_read_lipo(&reading);
|
||||
client_registry_set_master_battery(&reading);
|
||||
|
||||
ESP_LOGI(TAG_LIPO,
|
||||
"LIPO1 GPIO%d %s %lu mV LIPO2 GPIO%d %s %lu mV",
|
||||
@@ -95,6 +117,10 @@ static void lipo_monitor_task(void *param) {
|
||||
reading.lipo2_valid ? "ok" : "n/a",
|
||||
(unsigned long)reading.lipo2_mv);
|
||||
|
||||
#if POWERPOD_BATTERY_UV_ENABLE
|
||||
battery_uv_poll(&reading);
|
||||
#endif
|
||||
|
||||
vTaskDelay(pdMS_TO_TICKS(LIPO_SAMPLE_INTERVAL_MS));
|
||||
}
|
||||
}
|
||||
@@ -165,29 +191,30 @@ static esp_err_t init_button(void) {
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
static esp_err_t init_lipo_adc(void) {
|
||||
adc_oneshot_unit_init_cfg_t init_cfg = {
|
||||
.unit_id = ADC_UNIT_1,
|
||||
};
|
||||
esp_err_t err = adc_oneshot_new_unit(&init_cfg, &s_adc);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGW(TAG_LIPO, "ADC init failed: %s", esp_err_to_name(err));
|
||||
return err;
|
||||
}
|
||||
static esp_err_t init_lipo_adc_hw(void) {
|
||||
memset(&s_lipo1, 0, sizeof(s_lipo1));
|
||||
memset(&s_lipo2, 0, sizeof(s_lipo2));
|
||||
|
||||
adc_init_channel(V_LIPO_1_GPIO, &s_lipo1_ch, &s_lipo1_ok);
|
||||
adc_init_gpio(V_LIPO_1_GPIO, &s_lipo1);
|
||||
|
||||
if (V_LIPO_2_GPIO == TASTER_GPIO) {
|
||||
ESP_LOGW(TAG_LIPO, "LIPO2 on GPIO%d skipped (button uses same pin)",
|
||||
V_LIPO_2_GPIO);
|
||||
s_lipo2_ok = false;
|
||||
} else {
|
||||
adc_init_channel(V_LIPO_2_GPIO, &s_lipo2_ch, &s_lipo2_ok);
|
||||
adc_init_gpio(V_LIPO_2_GPIO, &s_lipo2);
|
||||
}
|
||||
|
||||
if (!s_lipo1_ok && !s_lipo2_ok) {
|
||||
adc_oneshot_del_unit(s_adc);
|
||||
s_adc = NULL;
|
||||
if (!s_lipo1.ok && !s_lipo2.ok) {
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t board_input_init_adc_only(void) { return init_lipo_adc_hw(); }
|
||||
|
||||
esp_err_t board_input_start_lipo_monitor(void) {
|
||||
if (!s_lipo1.ok && !s_lipo2.ok) {
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
@@ -198,16 +225,23 @@ static esp_err_t init_lipo_adc(void) {
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t board_input_init_button(void) { return init_button(); }
|
||||
|
||||
esp_err_t board_input_init(void) {
|
||||
esp_err_t err = init_button();
|
||||
esp_err_t err = board_input_init_adc_only();
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGW(TAG_LIPO, "ADC init failed: %s", esp_err_to_name(err));
|
||||
} else {
|
||||
err = board_input_start_lipo_monitor();
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGW(TAG_LIPO, "monitor task failed: %s", esp_err_to_name(err));
|
||||
}
|
||||
}
|
||||
|
||||
err = board_input_init_button();
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGW(TAG_BTN, "init failed: %s", esp_err_to_name(err));
|
||||
}
|
||||
|
||||
err = init_lipo_adc();
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGW(TAG_LIPO, "init failed: %s", esp_err_to_name(err));
|
||||
}
|
||||
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
@@ -19,6 +19,15 @@ typedef struct {
|
||||
*/
|
||||
esp_err_t board_input_init(void);
|
||||
|
||||
/** LiPo ADC channels only (no button, no background task). */
|
||||
esp_err_t board_input_init_adc_only(void);
|
||||
|
||||
/** Start 10 s LiPo monitor task (normal boot only). */
|
||||
esp_err_t board_input_start_lipo_monitor(void);
|
||||
|
||||
/** Front-panel button debounce task. */
|
||||
esp_err_t board_input_init_button(void);
|
||||
|
||||
/** On-demand ADC read of both LiPo sense inputs (if configured). */
|
||||
void board_input_read_lipo(board_lipo_reading_t *out);
|
||||
|
||||
|
||||
+115
-46
@@ -2,7 +2,7 @@
|
||||
* BMA456H integration for Powerpod (ESP-IDF I2C master + Bosch SensorAPI).
|
||||
*
|
||||
* Polls accelerometer at 10 Hz; tap events arrive on BMA456_INT_GPIO.
|
||||
* Accel logging is filtered in software (deadzone); slaves stream samples via ESP-NOW.
|
||||
* Accel log + ESP-NOW stream share a software deadzone filter (emit on change).
|
||||
*/
|
||||
|
||||
#include "bosch456.h"
|
||||
@@ -33,12 +33,16 @@ static int16_t s_last_x;
|
||||
static int16_t s_last_y;
|
||||
static int16_t s_last_z;
|
||||
static bool s_have_last_sample;
|
||||
static int16_t s_stream_last_x;
|
||||
static int16_t s_stream_last_y;
|
||||
static int16_t s_stream_last_z;
|
||||
static bool s_stream_have_last;
|
||||
|
||||
static volatile bool s_int_pending;
|
||||
static SemaphoreHandle_t s_accel_mutex;
|
||||
static bma456_tap_handler_t s_tap_handler;
|
||||
static void *s_tap_handler_ctx;
|
||||
static const bma456_tap_config_t s_tap_config = BMA456_TAP_CONFIG_DEFAULT;
|
||||
static bma456_tap_config_t s_tap_config = BMA456_TAP_CONFIG_DEFAULT;
|
||||
|
||||
static esp_err_t check_bma4(const char *api_name, int8_t rslt);
|
||||
|
||||
@@ -119,6 +123,7 @@ bool bma456_is_ready(void) { return s_bma456_ready; }
|
||||
void bma456_set_accel_deadzone(uint32_t deadzone_lsb) {
|
||||
s_accel_deadzone = deadzone_lsb;
|
||||
s_have_last_sample = false;
|
||||
s_stream_have_last = false;
|
||||
if (s_bma456_ready) {
|
||||
ESP_LOGI(TAG, "accel deadzone %lu LSB", (unsigned long)deadzone_lsb);
|
||||
}
|
||||
@@ -126,6 +131,113 @@ void bma456_set_accel_deadzone(uint32_t deadzone_lsb) {
|
||||
|
||||
uint32_t bma456_get_accel_deadzone(void) { return s_accel_deadzone; }
|
||||
|
||||
void bma456_accel_stream_reset_baseline(void) { s_stream_have_last = false; }
|
||||
|
||||
bool bma456_accel_stream_take_if_changed(int16_t x, int16_t y, int16_t z) {
|
||||
if (s_stream_have_last) {
|
||||
if (axis_delta(x, s_stream_last_x) <= (int16_t)s_accel_deadzone &&
|
||||
axis_delta(y, s_stream_last_y) <= (int16_t)s_accel_deadzone &&
|
||||
axis_delta(z, s_stream_last_z) <= (int16_t)s_accel_deadzone) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
s_stream_last_x = x;
|
||||
s_stream_last_y = y;
|
||||
s_stream_last_z = z;
|
||||
s_stream_have_last = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool bma456_tap_config_valid(const bma456_tap_config_t *cfg) {
|
||||
if (cfg == NULL) {
|
||||
return false;
|
||||
}
|
||||
if (cfg->tap_sens_thres > 15u) {
|
||||
return false;
|
||||
}
|
||||
if (cfg->axis_sel > 2u) {
|
||||
return false;
|
||||
}
|
||||
if (cfg->wait_for_timeout > 1u) {
|
||||
return false;
|
||||
}
|
||||
if (!cfg->enable_single && !cfg->enable_double && !cfg->enable_triple) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void bma456_get_tap_config(bma456_tap_config_t *cfg_out) {
|
||||
if (cfg_out == NULL) {
|
||||
return;
|
||||
}
|
||||
*cfg_out = s_tap_config;
|
||||
}
|
||||
|
||||
static esp_err_t apply_tap_parameters(const bma456_tap_config_t *cfg) {
|
||||
struct bma456h_multitap_settings tap_settings = {0};
|
||||
int8_t ret = bma456h_tap_get_parameter(&tap_settings, &s_bma456);
|
||||
if (check_bma4("bma456h_tap_get_parameter", ret) != ESP_OK) {
|
||||
return ESP_FAIL;
|
||||
}
|
||||
tap_settings.tap_sens_thres = cfg->tap_sens_thres;
|
||||
tap_settings.max_gest_dur = cfg->max_gest_dur;
|
||||
tap_settings.tap_shock_dur = cfg->tap_shock_dur;
|
||||
tap_settings.quite_time_after_gest = cfg->quite_time_after_gest;
|
||||
tap_settings.wait_for_timeout = cfg->wait_for_timeout;
|
||||
tap_settings.axis_sel = cfg->axis_sel;
|
||||
ret = bma456h_tap_set_parameter(&tap_settings, &s_bma456);
|
||||
if (check_bma4("bma456h_tap_set_parameter", ret) != ESP_OK) {
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
/* Disable all tap kinds first, then enable the requested set. */
|
||||
uint16_t all_taps =
|
||||
BMA456H_SINGLE_TAP_EN | BMA456H_DOUBLE_TAP_EN | BMA456H_TRIPLE_TAP_EN;
|
||||
ret = bma456h_feature_enable(all_taps, BMA4_DISABLE, &s_bma456);
|
||||
if (check_bma4("bma456h_feature_enable(disable taps)", ret) != ESP_OK) {
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
uint16_t tap_features = 0;
|
||||
if (cfg->enable_single) {
|
||||
tap_features |= BMA456H_SINGLE_TAP_EN;
|
||||
}
|
||||
if (cfg->enable_double) {
|
||||
tap_features |= BMA456H_DOUBLE_TAP_EN;
|
||||
}
|
||||
if (cfg->enable_triple) {
|
||||
tap_features |= BMA456H_TRIPLE_TAP_EN;
|
||||
}
|
||||
ret = bma456h_feature_enable(tap_features, BMA4_ENABLE, &s_bma456);
|
||||
if (check_bma4("bma456h_feature_enable(taps)", ret) != ESP_OK) {
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG,
|
||||
"tap config sens=%u max_gest=%u shock=%u quiet=%u wait=%u axis=%u "
|
||||
"(single=%d double=%d triple=%d)",
|
||||
(unsigned)cfg->tap_sens_thres, (unsigned)cfg->max_gest_dur,
|
||||
(unsigned)cfg->tap_shock_dur, (unsigned)cfg->quite_time_after_gest,
|
||||
(unsigned)cfg->wait_for_timeout, (unsigned)cfg->axis_sel,
|
||||
cfg->enable_single, cfg->enable_double, cfg->enable_triple);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t bma456_set_tap_config(const bma456_tap_config_t *cfg) {
|
||||
if (!bma456_tap_config_valid(cfg)) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
if (s_bma456_ready) {
|
||||
esp_err_t err = apply_tap_parameters(cfg);
|
||||
if (err != ESP_OK) {
|
||||
return err;
|
||||
}
|
||||
}
|
||||
s_tap_config = *cfg;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
void bma456_set_tap_handler(bma456_tap_handler_t handler, void *ctx) {
|
||||
s_tap_handler = handler;
|
||||
s_tap_handler_ctx = ctx;
|
||||
@@ -259,52 +371,9 @@ static esp_err_t configure_tap_interrupt(void) {
|
||||
int8_t ret;
|
||||
const uint8_t int_line = BMA4_INTR2_MAP;
|
||||
|
||||
struct bma456h_multitap_settings tap_settings = {0};
|
||||
ret = bma456h_tap_get_parameter(&tap_settings, &s_bma456);
|
||||
if (check_bma4("bma456h_tap_get_parameter", ret) != ESP_OK) {
|
||||
if (apply_tap_parameters(&s_tap_config) != ESP_OK) {
|
||||
return ESP_FAIL;
|
||||
}
|
||||
tap_settings.tap_sens_thres = s_tap_config.tap_sens_thres;
|
||||
tap_settings.max_gest_dur = s_tap_config.max_gest_dur;
|
||||
tap_settings.tap_shock_dur = s_tap_config.tap_shock_dur;
|
||||
tap_settings.quite_time_after_gest = s_tap_config.quite_time_after_gest;
|
||||
tap_settings.wait_for_timeout = s_tap_config.wait_for_timeout;
|
||||
tap_settings.axis_sel = s_tap_config.axis_sel;
|
||||
ret = bma456h_tap_set_parameter(&tap_settings, &s_bma456);
|
||||
if (check_bma4("bma456h_tap_set_parameter", ret) != ESP_OK) {
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
uint16_t tap_features = 0;
|
||||
if (s_tap_config.enable_single) {
|
||||
tap_features |= BMA456H_SINGLE_TAP_EN;
|
||||
}
|
||||
if (s_tap_config.enable_double) {
|
||||
tap_features |= BMA456H_DOUBLE_TAP_EN;
|
||||
}
|
||||
if (s_tap_config.enable_triple) {
|
||||
tap_features |= BMA456H_TRIPLE_TAP_EN;
|
||||
}
|
||||
if (tap_features == 0) {
|
||||
ESP_LOGW(TAG, "tap config: no tap kinds enabled");
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
|
||||
ret = bma456h_feature_enable(tap_features, BMA4_ENABLE, &s_bma456);
|
||||
if (check_bma4("bma456h_feature_enable", ret) != ESP_OK) {
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG,
|
||||
"tap config sens=%u max_gest=%u shock=%u quiet=%u wait=%u axis=%u "
|
||||
"(single=%d double=%d triple=%d)",
|
||||
(unsigned)s_tap_config.tap_sens_thres,
|
||||
(unsigned)s_tap_config.max_gest_dur,
|
||||
(unsigned)s_tap_config.tap_shock_dur,
|
||||
(unsigned)s_tap_config.quite_time_after_gest,
|
||||
(unsigned)s_tap_config.wait_for_timeout,
|
||||
(unsigned)s_tap_config.axis_sel, s_tap_config.enable_single,
|
||||
s_tap_config.enable_double, s_tap_config.enable_triple);
|
||||
|
||||
ret = bma456h_map_interrupt(int_line, BMA456H_TAP_OUT_INT, BMA4_ENABLE,
|
||||
&s_bma456);
|
||||
|
||||
+25
-2
@@ -19,7 +19,7 @@
|
||||
/** Sensor interrupt line → ESP32 GPIO (active high, rising edge). */
|
||||
#define BMA456_INT_GPIO 10
|
||||
|
||||
/** Software filter: log accel only when |axis - last| > deadzone (raw LSB). */
|
||||
/** Software filter: emit accel only when |axis - last| > deadzone (raw LSB). */
|
||||
#define BMA456_DEFAULT_ACCEL_DEADZONE 100u
|
||||
|
||||
/**
|
||||
@@ -42,7 +42,7 @@ typedef struct {
|
||||
bool enable_triple;
|
||||
} bma456_tap_config_t;
|
||||
|
||||
/** Edit these values to tune tap detection, then rebuild. */
|
||||
/** Edit these values for the compile-time default (overridden by NVS if set). */
|
||||
#define BMA456_TAP_CONFIG_DEFAULT \
|
||||
{ \
|
||||
.tap_sens_thres = 5, /* sensitive; 0=max, Bosch default=9 */ \
|
||||
@@ -56,6 +56,19 @@ typedef struct {
|
||||
.enable_triple = true, \
|
||||
}
|
||||
|
||||
/** True if cfg fields are within sensor limits and at least one tap kind is on. */
|
||||
bool bma456_tap_config_valid(const bma456_tap_config_t *cfg);
|
||||
|
||||
/** Copy current runtime tap config (compile default until set/NVS-applied). */
|
||||
void bma456_get_tap_config(bma456_tap_config_t *cfg_out);
|
||||
|
||||
/**
|
||||
* Apply tap config to the sensor (and keep RAM copy).
|
||||
* Returns ESP_ERR_INVALID_ARG if invalid. When the sensor is not ready, only
|
||||
* the RAM copy is updated (ESP_OK).
|
||||
*/
|
||||
esp_err_t bma456_set_tap_config(const bma456_tap_config_t *cfg);
|
||||
|
||||
/**
|
||||
* Probe and configure the sensor on bus_handle (100 kHz device).
|
||||
* On failure the device is removed and ESP_ERR_NOT_FOUND / ESP_FAIL is returned;
|
||||
@@ -68,6 +81,16 @@ bool bma456_is_ready(void);
|
||||
void bma456_set_accel_deadzone(uint32_t deadzone_lsb);
|
||||
uint32_t bma456_get_accel_deadzone(void);
|
||||
|
||||
/**
|
||||
* ESP-NOW stream filter: true if sample exceeds deadzone vs last streamed
|
||||
* sample (or no baseline yet). On true, commits (x,y,z) as stream baseline.
|
||||
* Independent of the log filter baseline.
|
||||
*/
|
||||
bool bma456_accel_stream_take_if_changed(int16_t x, int16_t y, int16_t z);
|
||||
|
||||
/** Force next stream take to accept (call when accel stream is enabled). */
|
||||
void bma456_accel_stream_reset_baseline(void);
|
||||
|
||||
/** Log accel when any axis moved more than deadzone since last reported sample. */
|
||||
void bma456_report_accel_if_changed(int16_t x, int16_t y, int16_t z);
|
||||
|
||||
|
||||
+234
-18
@@ -1,4 +1,5 @@
|
||||
#include "client_registry.h"
|
||||
#include "pod_settings.h"
|
||||
#include "esp_log.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/idf_additions.h"
|
||||
@@ -13,12 +14,68 @@ typedef struct {
|
||||
} client_slot_t;
|
||||
|
||||
static client_slot_t s_clients[CLIENT_REGISTRY_MAX];
|
||||
static pod_id_map_t s_id_map;
|
||||
|
||||
static struct {
|
||||
board_lipo_reading_t reading;
|
||||
uint32_t updated_at;
|
||||
} s_master_battery;
|
||||
|
||||
static bool id_in_use(uint32_t id, const uint8_t *except_mac) {
|
||||
for (uint8_t i = 0; i < s_id_map.count; i++) {
|
||||
if (s_id_map.entries[i].id != id) {
|
||||
continue;
|
||||
}
|
||||
if (except_mac != NULL &&
|
||||
memcmp(s_id_map.entries[i].mac, except_mac, CLIENT_MAC_LEN) == 0) {
|
||||
continue;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static int find_map_index(const uint8_t mac[CLIENT_MAC_LEN]) {
|
||||
for (uint8_t i = 0; i < s_id_map.count; i++) {
|
||||
if (memcmp(s_id_map.entries[i].mac, mac, CLIENT_MAC_LEN) == 0) {
|
||||
return (int)i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
static esp_err_t persist_id_map(void) {
|
||||
return pod_settings_save_id_map(&s_id_map);
|
||||
}
|
||||
|
||||
static uint32_t alloc_next_free_id(void) {
|
||||
if (s_id_map.count >= POD_ID_MAP_MAX) {
|
||||
return 0;
|
||||
}
|
||||
uint8_t start = s_id_map.next_id;
|
||||
if (start < POD_CLIENT_ID_MIN) {
|
||||
start = (uint8_t)POD_CLIENT_ID_MIN;
|
||||
}
|
||||
for (uint32_t n = 0; n < POD_CLIENT_ID_MAX; n++) {
|
||||
uint32_t id = (uint32_t)start + n;
|
||||
if (id > POD_CLIENT_ID_MAX) {
|
||||
id = id - POD_CLIENT_ID_MAX;
|
||||
}
|
||||
if (id < POD_CLIENT_ID_MIN) {
|
||||
continue;
|
||||
}
|
||||
if (!id_in_use(id, NULL)) {
|
||||
uint32_t next = id + 1;
|
||||
if (next > POD_CLIENT_ID_MAX) {
|
||||
next = POD_CLIENT_ID_MIN;
|
||||
}
|
||||
s_id_map.next_id = (uint8_t)next;
|
||||
return id;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
uint32_t client_registry_now_ms(void) {
|
||||
return (uint32_t)(xTaskGetTickCount() * portTICK_PERIOD_MS);
|
||||
}
|
||||
@@ -82,6 +139,11 @@ static client_slot_t *alloc_slot(const uint8_t mac[CLIENT_MAC_LEN],
|
||||
slot->active = true;
|
||||
memcpy(slot->info.mac, mac, CLIENT_MAC_LEN);
|
||||
slot->info.accel_deadzone = CLIENT_REGISTRY_DEFAULT_ACCEL_DEADZONE;
|
||||
{
|
||||
static const bma456_tap_config_t k_tap_default = BMA456_TAP_CONFIG_DEFAULT;
|
||||
slot->info.tap_config = k_tap_default;
|
||||
}
|
||||
slot->info.uv_protection_enabled = false;
|
||||
if (out_is_new != NULL) {
|
||||
*out_is_new = true;
|
||||
}
|
||||
@@ -91,7 +153,84 @@ static client_slot_t *alloc_slot(const uint8_t mac[CLIENT_MAC_LEN],
|
||||
return NULL;
|
||||
}
|
||||
|
||||
void client_registry_init(void) { memset(s_clients, 0, sizeof(s_clients)); }
|
||||
void client_registry_init(void) {
|
||||
memset(s_clients, 0, sizeof(s_clients));
|
||||
memset(&s_id_map, 0, sizeof(s_id_map));
|
||||
s_id_map.next_id = (uint8_t)POD_CLIENT_ID_MIN;
|
||||
(void)pod_settings_load_id_map(&s_id_map);
|
||||
if (s_id_map.next_id < POD_CLIENT_ID_MIN) {
|
||||
s_id_map.next_id = (uint8_t)POD_CLIENT_ID_MIN;
|
||||
}
|
||||
}
|
||||
|
||||
esp_err_t client_registry_assign_id(const uint8_t mac[CLIENT_MAC_LEN],
|
||||
uint32_t preferred_id, uint32_t *out_id) {
|
||||
if (mac == NULL || out_id == NULL) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
|
||||
int idx = find_map_index(mac);
|
||||
if (idx >= 0) {
|
||||
*out_id = s_id_map.entries[idx].id;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
uint32_t id = 0;
|
||||
if (preferred_id >= POD_CLIENT_ID_MIN && preferred_id <= POD_CLIENT_ID_MAX &&
|
||||
!id_in_use(preferred_id, mac)) {
|
||||
id = preferred_id;
|
||||
} else {
|
||||
id = alloc_next_free_id();
|
||||
}
|
||||
if (id == 0) {
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
|
||||
if (s_id_map.count >= POD_ID_MAP_MAX) {
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
|
||||
memcpy(s_id_map.entries[s_id_map.count].mac, mac, CLIENT_MAC_LEN);
|
||||
s_id_map.entries[s_id_map.count].id = (uint8_t)id;
|
||||
s_id_map.count++;
|
||||
if (id >= s_id_map.next_id) {
|
||||
uint32_t next = id + 1;
|
||||
if (next > POD_CLIENT_ID_MAX) {
|
||||
next = POD_CLIENT_ID_MIN;
|
||||
}
|
||||
s_id_map.next_id = (uint8_t)next;
|
||||
}
|
||||
|
||||
esp_err_t err = persist_id_map();
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGW(TAG, "persist id_map failed after assign id=%lu: %s",
|
||||
(unsigned long)id, esp_err_to_name(err));
|
||||
}
|
||||
*out_id = id;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
void client_registry_clear_id_map(void) {
|
||||
memset(s_clients, 0, sizeof(s_clients));
|
||||
memset(&s_id_map, 0, sizeof(s_id_map));
|
||||
s_id_map.next_id = (uint8_t)POD_CLIENT_ID_MIN;
|
||||
esp_err_t err = pod_settings_clear_id_map();
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGW(TAG, "clear id_map NVS failed: %s", esp_err_to_name(err));
|
||||
}
|
||||
ESP_LOGI(TAG, "cleared id map and live registry");
|
||||
}
|
||||
|
||||
void client_registry_mark_assign_sent(const uint8_t mac[CLIENT_MAC_LEN]) {
|
||||
if (mac == NULL) {
|
||||
return;
|
||||
}
|
||||
client_slot_t *slot = find_slot(mac);
|
||||
if (slot == NULL) {
|
||||
return;
|
||||
}
|
||||
slot->info.last_assign_sent_at = now_ms();
|
||||
}
|
||||
|
||||
const client_info_t *
|
||||
client_registry_find_by_mac(const uint8_t mac[CLIENT_MAC_LEN]) {
|
||||
@@ -167,18 +306,19 @@ void client_registry_check_timeouts(uint32_t timeout_ms) {
|
||||
uint32_t now = now_ms();
|
||||
|
||||
for (size_t i = 0; i < CLIENT_REGISTRY_MAX; i++) {
|
||||
if (!s_clients[i].active || !s_clients[i].info.available) {
|
||||
if (!s_clients[i].active) {
|
||||
continue;
|
||||
}
|
||||
|
||||
uint32_t elapsed = now - s_clients[i].info.last_success_ping_at;
|
||||
if (elapsed > timeout_ms) {
|
||||
s_clients[i].info.available = false;
|
||||
char mac_str[18];
|
||||
mac_to_str(s_clients[i].info.mac, mac_str, sizeof(mac_str));
|
||||
ESP_LOGW(TAG, "client inactive id=%lu mac=%s (no heartbeat for %lu ms)",
|
||||
ESP_LOGW(TAG, "client dropped id=%lu mac=%s (no heartbeat for %lu ms)",
|
||||
(unsigned long)s_clients[i].info.id, mac_str,
|
||||
(unsigned long)elapsed);
|
||||
s_clients[i].active = false;
|
||||
memset(&s_clients[i].info, 0, sizeof(s_clients[i].info));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -246,6 +386,91 @@ size_t client_registry_set_accel_deadzone_all(uint32_t deadzone) {
|
||||
return n;
|
||||
}
|
||||
|
||||
esp_err_t client_registry_set_tap_config(uint32_t client_id,
|
||||
const bma456_tap_config_t *cfg) {
|
||||
if (cfg == NULL || !bma456_tap_config_valid(cfg)) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
const client_info_t *info = client_registry_find_by_id(client_id);
|
||||
if (info == NULL) {
|
||||
return ESP_ERR_NOT_FOUND;
|
||||
}
|
||||
|
||||
for (size_t i = 0; i < CLIENT_REGISTRY_MAX; i++) {
|
||||
if (s_clients[i].active && s_clients[i].info.id == client_id) {
|
||||
s_clients[i].info.tap_config = *cfg;
|
||||
return ESP_OK;
|
||||
}
|
||||
}
|
||||
return ESP_ERR_NOT_FOUND;
|
||||
}
|
||||
|
||||
esp_err_t client_registry_get_tap_config(uint32_t client_id,
|
||||
bma456_tap_config_t *cfg_out) {
|
||||
if (cfg_out == NULL) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
|
||||
const client_info_t *info = client_registry_find_by_id(client_id);
|
||||
if (info == NULL) {
|
||||
return ESP_ERR_NOT_FOUND;
|
||||
}
|
||||
|
||||
*cfg_out = info->tap_config;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
size_t client_registry_set_tap_config_all(const bma456_tap_config_t *cfg) {
|
||||
if (cfg == NULL || !bma456_tap_config_valid(cfg)) {
|
||||
return 0;
|
||||
}
|
||||
size_t n = 0;
|
||||
|
||||
for (size_t i = 0; i < CLIENT_REGISTRY_MAX; i++) {
|
||||
if (!s_clients[i].active) {
|
||||
continue;
|
||||
}
|
||||
s_clients[i].info.tap_config = *cfg;
|
||||
n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
esp_err_t client_registry_set_uv_protection(uint32_t client_id, bool enabled) {
|
||||
for (size_t i = 0; i < CLIENT_REGISTRY_MAX; i++) {
|
||||
if (s_clients[i].active && s_clients[i].info.id == client_id) {
|
||||
s_clients[i].info.uv_protection_enabled = enabled;
|
||||
return ESP_OK;
|
||||
}
|
||||
}
|
||||
return ESP_ERR_NOT_FOUND;
|
||||
}
|
||||
|
||||
esp_err_t client_registry_get_uv_protection(uint32_t client_id,
|
||||
bool *enabled_out) {
|
||||
if (enabled_out == NULL) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
const client_info_t *info = client_registry_find_by_id(client_id);
|
||||
if (info == NULL) {
|
||||
return ESP_ERR_NOT_FOUND;
|
||||
}
|
||||
*enabled_out = info->uv_protection_enabled;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
size_t client_registry_set_uv_protection_all(bool enabled) {
|
||||
size_t n = 0;
|
||||
for (size_t i = 0; i < CLIENT_REGISTRY_MAX; i++) {
|
||||
if (!s_clients[i].active) {
|
||||
continue;
|
||||
}
|
||||
s_clients[i].info.uv_protection_enabled = enabled;
|
||||
n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
static void clear_client_accel(client_slot_t *slot) {
|
||||
if (slot == NULL) {
|
||||
return;
|
||||
@@ -498,24 +723,15 @@ esp_err_t client_registry_update_battery(const uint8_t mac[CLIENT_MAC_LEN],
|
||||
|
||||
client_slot_t *slot = find_slot(mac);
|
||||
if (slot == NULL) {
|
||||
bool is_new = false;
|
||||
esp_err_t err = client_registry_upsert(mac, slave_id, 0, true, false, &is_new);
|
||||
if (err != ESP_OK) {
|
||||
return err;
|
||||
}
|
||||
slot = find_slot(mac);
|
||||
if (slot == NULL) {
|
||||
return ESP_ERR_NOT_FOUND;
|
||||
}
|
||||
ESP_LOGI(TAG, "battery auto-registered id=%lu (report before heartbeat)",
|
||||
(unsigned long)slave_id);
|
||||
/* Ids are assigned on presence only; ignore early battery reports. */
|
||||
return ESP_ERR_NOT_FOUND;
|
||||
}
|
||||
|
||||
if (slot->info.id != slave_id) {
|
||||
ESP_LOGW(TAG, "battery id %lu → %lu for mac %02x:…:%02x",
|
||||
if (slave_id != 0 && slot->info.id != slave_id) {
|
||||
ESP_LOGW(TAG, "battery id mismatch slot=%lu report=%lu mac %02x:…:%02x",
|
||||
(unsigned long)slot->info.id, (unsigned long)slave_id, mac[0],
|
||||
mac[5]);
|
||||
slot->info.id = slave_id;
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
slot->info.lipo1_valid = lipo1_valid;
|
||||
|
||||
+40
-2
@@ -2,6 +2,7 @@
|
||||
#define CLIENT_REGISTRY_H
|
||||
|
||||
#include "board_input.h"
|
||||
#include "bosch456.h"
|
||||
#include "esp_err.h"
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
@@ -22,6 +23,8 @@ typedef struct {
|
||||
uint32_t version;
|
||||
/** Accel deadzone in raw LSB per axis (master copy for ESP-NOW config). */
|
||||
uint32_t accel_deadzone;
|
||||
/** Tap detection config (master copy for ESP-NOW / UART read). */
|
||||
bma456_tap_config_t tap_config;
|
||||
/** Latest accel from slave ESP-NOW stream (master only). */
|
||||
bool accel_valid;
|
||||
int16_t accel_x;
|
||||
@@ -44,14 +47,35 @@ typedef struct {
|
||||
uint32_t lipo1_mv;
|
||||
uint32_t lipo2_mv;
|
||||
uint32_t battery_updated_at;
|
||||
/** Master cache: software UV protection enabled on slave (default false). */
|
||||
bool uv_protection_enabled;
|
||||
/** Master: last ESPNOW_ASSIGN_ID send time (rate-limit resends). */
|
||||
uint32_t last_assign_sent_at;
|
||||
} client_info_t;
|
||||
|
||||
#define CLIENT_REGISTRY_DEFAULT_ACCEL_DEADZONE 100u
|
||||
/** Tap events older than this are discarded (matches accel stream interval). */
|
||||
#define CLIENT_REGISTRY_TAP_MAX_AGE_MS 16u
|
||||
#define CLIENT_REGISTRY_TAP_MAX_AGE_MS 10u
|
||||
|
||||
void client_registry_init(void);
|
||||
|
||||
/**
|
||||
* Assign a stable logical id for mac (1…255). Honors preferred if free or
|
||||
* already owned by mac; otherwise allocates the next free id. Persists the
|
||||
* master id map to NVS.
|
||||
*/
|
||||
esp_err_t client_registry_assign_id(const uint8_t mac[CLIENT_MAC_LEN],
|
||||
uint32_t preferred_id, uint32_t *out_id);
|
||||
|
||||
/**
|
||||
* Clear NVS id map, RAM map, and all live registry slots. Does not notify
|
||||
* slaves (caller sends CLEAR_ID_BINDING).
|
||||
*/
|
||||
void client_registry_clear_id_map(void);
|
||||
|
||||
/** Record that ASSIGN_ID was sent to this MAC (for resend rate limiting). */
|
||||
void client_registry_mark_assign_sent(const uint8_t mac[CLIENT_MAC_LEN]);
|
||||
|
||||
/** Milliseconds since boot (same clock as stored ping timestamps). */
|
||||
uint32_t client_registry_now_ms(void);
|
||||
|
||||
@@ -72,7 +96,10 @@ esp_err_t client_registry_heartbeat(const uint8_t mac[CLIENT_MAC_LEN],
|
||||
uint32_t id, uint32_t version, bool used,
|
||||
bool *out_is_new, bool *out_reactivated);
|
||||
|
||||
/** Mark clients inactive when last_success_ping_at is older than timeout_ms. */
|
||||
/**
|
||||
* Drop live registry slots whose last_success_ping_at is older than
|
||||
* timeout_ms (NVS id map unchanged). Offline clients disappear from CLIENT_INFO.
|
||||
*/
|
||||
void client_registry_check_timeouts(uint32_t timeout_ms);
|
||||
|
||||
size_t client_registry_count(void);
|
||||
@@ -88,6 +115,17 @@ esp_err_t client_registry_get_accel_deadzone(uint32_t client_id,
|
||||
/** Push deadzone to all active registry entries; returns count updated. */
|
||||
size_t client_registry_set_accel_deadzone_all(uint32_t deadzone);
|
||||
|
||||
esp_err_t client_registry_set_tap_config(uint32_t client_id,
|
||||
const bma456_tap_config_t *cfg);
|
||||
esp_err_t client_registry_get_tap_config(uint32_t client_id,
|
||||
bma456_tap_config_t *cfg_out);
|
||||
size_t client_registry_set_tap_config_all(const bma456_tap_config_t *cfg);
|
||||
|
||||
esp_err_t client_registry_set_uv_protection(uint32_t client_id, bool enabled);
|
||||
esp_err_t client_registry_get_uv_protection(uint32_t client_id,
|
||||
bool *enabled_out);
|
||||
size_t client_registry_set_uv_protection_all(bool enabled);
|
||||
|
||||
/** Store latest accel sample from a slave (matched by sender MAC). */
|
||||
esp_err_t client_registry_update_accel(const uint8_t mac[CLIENT_MAC_LEN],
|
||||
uint32_t slave_id, int16_t x, int16_t y,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#include "client_registry.h"
|
||||
#include "cmd_accel_stream.h"
|
||||
#include "ble_stream.h"
|
||||
#include "esp_log.h"
|
||||
#include "esp_now_comm.h"
|
||||
#include "uart_cmd.h"
|
||||
@@ -50,6 +51,10 @@ static void handle_accel_stream(const uint8_t *data, size_t len) {
|
||||
}
|
||||
|
||||
if (req.write) {
|
||||
if (req.period_ms != 0) {
|
||||
ble_stream_set_period_ms(req.period_ms);
|
||||
}
|
||||
|
||||
if (req.all_clients) {
|
||||
size_t n = client_registry_set_accel_stream_all(req.enable);
|
||||
uint32_t sent = 0;
|
||||
@@ -65,8 +70,10 @@ static void handle_accel_stream(const uint8_t *data, size_t len) {
|
||||
}
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "accel stream %s for %u/%u slaves",
|
||||
req.enable ? "on" : "off", (unsigned)sent, (unsigned)n);
|
||||
ESP_LOGI(TAG, "accel stream %s for %u/%u slaves period_ms=%lu",
|
||||
req.enable ? "on" : "off", (unsigned)sent, (unsigned)n,
|
||||
(unsigned long)ble_stream_period_ms());
|
||||
ble_stream_update();
|
||||
reply(req.enable, 0, sent > 0, sent);
|
||||
return;
|
||||
}
|
||||
@@ -85,6 +92,7 @@ static void handle_accel_stream(const uint8_t *data, size_t len) {
|
||||
}
|
||||
|
||||
esp_err_t err = push_stream_to_slave(client, req.enable);
|
||||
ble_stream_update();
|
||||
reply(req.enable, req.client_id, err == ESP_OK, err == ESP_OK ? 1u : 0u);
|
||||
return;
|
||||
}
|
||||
|
||||
+6
-10
@@ -33,18 +33,14 @@ static bool append_battery_sample(alox_BatteryStatusResponse *resp,
|
||||
return lipo1_valid || lipo2_valid;
|
||||
}
|
||||
|
||||
static bool append_master_cached(alox_BatteryStatusResponse *resp) {
|
||||
static bool append_master_sample(alox_BatteryStatusResponse *resp) {
|
||||
board_lipo_reading_t reading;
|
||||
uint32_t age_ms = 0;
|
||||
|
||||
if (!client_registry_get_master_battery(&reading, &age_ms)) {
|
||||
board_input_read_lipo(&reading);
|
||||
client_registry_set_master_battery(&reading);
|
||||
age_ms = 0;
|
||||
}
|
||||
board_input_read_lipo(&reading);
|
||||
client_registry_set_master_battery(&reading);
|
||||
|
||||
return append_battery_sample(resp, 0, reading.lipo1_valid, reading.lipo1_mv,
|
||||
reading.lipo2_valid, reading.lipo2_mv, age_ms);
|
||||
reading.lipo2_valid, reading.lipo2_mv, 0);
|
||||
}
|
||||
|
||||
static bool append_slave_cached(alox_BatteryStatusResponse *resp,
|
||||
@@ -102,7 +98,7 @@ static void handle_battery_status(const uint8_t *data, size_t len) {
|
||||
bool any = false;
|
||||
|
||||
if (req.all_clients) {
|
||||
any |= append_master_cached(resp);
|
||||
any |= append_master_sample(resp);
|
||||
for (size_t i = 0; i < client_registry_count(); i++) {
|
||||
const client_info_t *client = client_registry_at(i);
|
||||
if (client == NULL) {
|
||||
@@ -113,7 +109,7 @@ static void handle_battery_status(const uint8_t *data, size_t len) {
|
||||
ESP_LOGI(TAG, "battery cache all_clients → %u samples",
|
||||
(unsigned)resp->samples_count);
|
||||
} else if (req.client_id == 0) {
|
||||
any = append_master_cached(resp);
|
||||
any = append_master_sample(resp);
|
||||
ESP_LOGI(TAG, "battery cache master");
|
||||
} else {
|
||||
const client_info_t *client = client_registry_find_by_id(req.client_id);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
#ifndef CMD_BATTERY_UV_H
|
||||
#define CMD_BATTERY_UV_H
|
||||
|
||||
void cmd_battery_uv_register(void);
|
||||
|
||||
#endif
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
#pragma once
|
||||
|
||||
void cmd_client_id_map_reset_register(void);
|
||||
@@ -31,6 +31,7 @@ static bool encode_clients_list(pb_ostream_t *stream, const pb_field_t *field,
|
||||
proto.tap_notify_single = client->tap_notify_single;
|
||||
proto.tap_notify_double = client->tap_notify_double;
|
||||
proto.tap_notify_triple = client->tap_notify_triple;
|
||||
proto.uv_protection_enabled = client->uv_protection_enabled;
|
||||
proto.mac.funcs.encode = uart_cmd_encode_bytes;
|
||||
proto.mac.arg = &mac;
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
#include "client_registry.h"
|
||||
#include "cmd_espnow_echo_ping.h"
|
||||
#include "esp_log.h"
|
||||
#include "esp_now_comm.h"
|
||||
#include "uart_cmd.h"
|
||||
|
||||
static const char *TAG = "[ECHO_PING]";
|
||||
|
||||
static void reply(bool success, uint32_t client_id, uint64_t timestamp_us,
|
||||
uint32_t esp_rtt_us) {
|
||||
alox_UartMessage response;
|
||||
uart_cmd_init_response(&response, alox_MessageType_ESPNOW_ECHO_PING,
|
||||
alox_UartMessage_espnow_echo_ping_response_tag);
|
||||
response.payload.espnow_echo_ping_response.success = success;
|
||||
response.payload.espnow_echo_ping_response.client_id = client_id;
|
||||
response.payload.espnow_echo_ping_response.timestamp_us = timestamp_us;
|
||||
response.payload.espnow_echo_ping_response.esp_rtt_us = esp_rtt_us;
|
||||
uart_cmd_send(&response, TAG);
|
||||
}
|
||||
|
||||
static void handle_espnow_echo_ping(const uint8_t *data, size_t len) {
|
||||
alox_UartMessage uart_msg;
|
||||
|
||||
if (uart_cmd_decode(data, len, &uart_msg) != ESP_OK) {
|
||||
ESP_LOGW(TAG, "decode failed");
|
||||
reply(false, 0, 0, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
const alox_EspNowEchoPingRequest *req = UART_CMD_REQ(
|
||||
&uart_msg, alox_UartMessage_espnow_echo_ping_request_tag,
|
||||
espnow_echo_ping_request);
|
||||
if (req == NULL || req->client_id == 0) {
|
||||
ESP_LOGW(TAG, "need client_id in request");
|
||||
reply(false, 0, 0, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
const client_info_t *client = client_registry_find_by_id(req->client_id);
|
||||
if (client == NULL) {
|
||||
ESP_LOGW(TAG, "client id %lu not in registry",
|
||||
(unsigned long)req->client_id);
|
||||
reply(false, req->client_id, 0, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "UART request client_id=%lu host_ts=%llu",
|
||||
(unsigned long)req->client_id,
|
||||
(unsigned long long)req->timestamp_us);
|
||||
|
||||
esp_now_echo_ping_result_t ping_result = {0};
|
||||
esp_err_t err =
|
||||
esp_now_comm_echo_ping(client->mac, req->timestamp_us, &ping_result);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGW(TAG, "echo ping to id=%lu failed: %s", (unsigned long)req->client_id,
|
||||
esp_err_to_name(err));
|
||||
reply(false, req->client_id, 0, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "UART reply client_id=%lu host_ts=%llu esp_rtt_us=%lu",
|
||||
(unsigned long)req->client_id,
|
||||
(unsigned long long)ping_result.echoed_us,
|
||||
(unsigned long)ping_result.esp_rtt_us);
|
||||
reply(true, req->client_id, ping_result.echoed_us, ping_result.esp_rtt_us);
|
||||
}
|
||||
|
||||
void cmd_espnow_echo_ping_register(void) {
|
||||
uart_cmd_register(alox_MessageType_ESPNOW_ECHO_PING, handle_espnow_echo_ping);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
#ifndef CMD_ESPNOW_ECHO_PING_H
|
||||
#define CMD_ESPNOW_ECHO_PING_H
|
||||
|
||||
void cmd_espnow_echo_ping_register(void);
|
||||
|
||||
#endif
|
||||
+17
-2
@@ -57,6 +57,16 @@ static const char *message_type_name(uint16_t id) {
|
||||
return "TAP_NOTIFY";
|
||||
case alox_MessageType_CACHE_STATUS:
|
||||
return "CACHE_STATUS";
|
||||
case alox_MessageType_ESPNOW_ECHO_PING:
|
||||
return "ESPNOW_ECHO_PING";
|
||||
case alox_MessageType_SET_LOG_LEVEL:
|
||||
return "SET_LOG_LEVEL";
|
||||
case alox_MessageType_TAP_CONFIG:
|
||||
return "TAP_CONFIG";
|
||||
case alox_MessageType_CLIENT_ID_MAP_RESET:
|
||||
return "CLIENT_ID_MAP_RESET";
|
||||
case alox_MessageType_INPUT_SEND_MODE:
|
||||
return "INPUT_SEND_MODE";
|
||||
default:
|
||||
return "UNKNOWN";
|
||||
}
|
||||
@@ -105,8 +115,13 @@ void vCmdDispatcherTask(void *param) {
|
||||
bool handled = false;
|
||||
for (int i = 0; i < handler_count; i++) {
|
||||
if (handlers[i].msg_id == msg.msg_id) {
|
||||
ESP_LOGI(TAG, "trigger command %s (0x%02x)", message_type_name(msg.msg_id),
|
||||
(unsigned)msg.msg_id);
|
||||
if (msg.msg_id == (uint16_t)alox_MessageType_OTA_PAYLOAD) {
|
||||
ESP_LOGD(TAG, "trigger command %s (0x%02x)",
|
||||
message_type_name(msg.msg_id), (unsigned)msg.msg_id);
|
||||
} else {
|
||||
ESP_LOGI(TAG, "trigger command %s (0x%02x)",
|
||||
message_type_name(msg.msg_id), (unsigned)msg.msg_id);
|
||||
}
|
||||
handlers[i].callback(msg.payload, msg.len);
|
||||
handled = true;
|
||||
break;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
#ifndef CMD_INPUT_SEND_MODE_H
|
||||
#define CMD_INPUT_SEND_MODE_H
|
||||
|
||||
void cmd_input_send_mode_register(void);
|
||||
|
||||
#endif
|
||||
@@ -13,6 +13,7 @@ static const char *TAG = "[LED_RING_CMD]";
|
||||
#define LED_RING_MODE_BLINK 3
|
||||
#define LED_RING_MODE_FIND_ME 4
|
||||
#define LED_RING_MODE_COLOR 5
|
||||
#define LED_RING_MODE_BATTERY_LOW 6
|
||||
|
||||
static uint8_t clamp_u8(uint32_t v) {
|
||||
if (v > 255) {
|
||||
@@ -90,6 +91,10 @@ bool cmd_led_ring_apply(const alox_LedRingProgressRequest *req) {
|
||||
led_ring_find_me();
|
||||
return true;
|
||||
|
||||
case LED_RING_MODE_BATTERY_LOW:
|
||||
led_ring_show_battery_low();
|
||||
return true;
|
||||
|
||||
case LED_RING_MODE_BLINK:
|
||||
cmd.mode = LED_CMD_BLINK;
|
||||
cmd.r = r;
|
||||
|
||||
+56
-12
@@ -2,10 +2,13 @@
|
||||
#include "led_ring.h"
|
||||
#include "ota_espnow.h"
|
||||
#include "ota_uart.h"
|
||||
#include "pod_reboot.h"
|
||||
#include "uart_cmd.h"
|
||||
#include "esp_log.h"
|
||||
#include "esp_timer.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/idf_additions.h"
|
||||
#include <stdint.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
@@ -13,6 +16,8 @@ static const char *TAG = "[OTA_CMD]";
|
||||
|
||||
#define OTA_PREPARE_STACK 8192
|
||||
#define OTA_PREPARE_PRIO 5
|
||||
/* Block buffer is static .bss; stack is for nested EspNowMessage + encode buf +
|
||||
* esp_now_send path. 4 KiB overflowed; 8 KiB matches the pre-crash budget. */
|
||||
#define OTA_DIST_STACK 8192
|
||||
#define OTA_DIST_PRIO 5
|
||||
|
||||
@@ -29,6 +34,14 @@ typedef struct {
|
||||
uint32_t written;
|
||||
int slot;
|
||||
} ota_dist_job_t;
|
||||
|
||||
/** Cap host DISTRIBUTING pushes (BLE notifies compete with ESP-NOW OTA TX). */
|
||||
#define OTA_DIST_NOTIFY_MIN_US (250 * 1000ull)
|
||||
|
||||
static int64_t s_last_dist_agg_us;
|
||||
static int64_t s_last_dist_slave_us;
|
||||
static uint32_t s_last_dist_agg_bytes = UINT32_MAX;
|
||||
|
||||
static void send_ota_status(ota_uart_status_t status, uint32_t err_code) {
|
||||
alox_UartMessage response;
|
||||
uart_cmd_init_response(&response, alox_MessageType_OTA_STATUS,
|
||||
@@ -61,15 +74,33 @@ static void send_ota_distributing(uint32_t kind, uint32_t bytes_done,
|
||||
|
||||
static void ota_dist_aggregate(uint32_t bytes_done, uint32_t total_bytes,
|
||||
uint8_t slave_count) {
|
||||
(void)slave_count;
|
||||
led_ring_show_ota_progress(bytes_done, total_bytes, OTA_LED_ESPNOW_TX_R,
|
||||
OTA_LED_ESPNOW_TX_G, OTA_LED_ESPNOW_TX_B);
|
||||
|
||||
const int64_t now = esp_timer_get_time();
|
||||
const bool first = (s_last_dist_agg_bytes == UINT32_MAX);
|
||||
const bool boundary =
|
||||
(bytes_done == 0) || (total_bytes > 0 && bytes_done >= total_bytes);
|
||||
const bool paced =
|
||||
(now - s_last_dist_agg_us) >= (int64_t)OTA_DIST_NOTIFY_MIN_US;
|
||||
if (!first && !boundary && !paced) {
|
||||
return;
|
||||
}
|
||||
s_last_dist_agg_us = now;
|
||||
s_last_dist_agg_bytes = bytes_done;
|
||||
send_ota_distributing(OTA_DIST_AGGREGATE, bytes_done, (uint32_t)slave_count);
|
||||
}
|
||||
|
||||
static void ota_dist_per_slave(uint32_t slave_id, uint32_t bytes_done,
|
||||
uint32_t total_bytes) {
|
||||
(void)total_bytes;
|
||||
/* Host also polls OTA_SLAVE_PROGRESS; keep per-slave pushes rare on BLE. */
|
||||
const int64_t now = esp_timer_get_time();
|
||||
if ((now - s_last_dist_slave_us) < (int64_t)OTA_DIST_NOTIFY_MIN_US &&
|
||||
bytes_done > 0) {
|
||||
return;
|
||||
}
|
||||
s_last_dist_slave_us = now;
|
||||
send_ota_distributing(OTA_DIST_PER_SLAVE, bytes_done, slave_id);
|
||||
}
|
||||
|
||||
@@ -175,8 +206,20 @@ static void handle_ota_payload(const uint8_t *data, size_t len) {
|
||||
return;
|
||||
}
|
||||
|
||||
ota_feed_result_t r =
|
||||
ota_uart_feed(req_ptr->data.bytes, req_ptr->data.size);
|
||||
ota_feed_result_t r = ota_uart_feed_chunk(req_ptr->seq, req_ptr->data.bytes,
|
||||
req_ptr->data.size);
|
||||
/* Gap: keep session; host block-retries from the last ACK boundary. */
|
||||
if (r == OTA_FEED_SEQ_GAP) {
|
||||
ESP_LOGW(TAG, "OTA_PAYLOAD seq gap (got %lu) — waiting for host retry",
|
||||
(unsigned long)req_ptr->seq);
|
||||
return;
|
||||
}
|
||||
if (r == OTA_FEED_SEQ_DUP) {
|
||||
if (ota_uart_block_ready_for_reack()) {
|
||||
send_ota_status(OTA_UART_ST_BLOCK_ACK, 0);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (r == OTA_FEED_ERROR) {
|
||||
send_ota_failed( 13);
|
||||
return;
|
||||
@@ -189,15 +232,6 @@ static void handle_ota_payload(const uint8_t *data, size_t len) {
|
||||
led_ring_show_ota_progress(done, total, OTA_LED_UART_R, OTA_LED_UART_G,
|
||||
OTA_LED_UART_B);
|
||||
send_ota_status(OTA_UART_ST_BLOCK_ACK, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (r == OTA_FEED_OK) {
|
||||
uint32_t total = ota_uart_total_size();
|
||||
if (total > 0) {
|
||||
led_ring_show_ota_progress(ota_uart_bytes_received(), total,
|
||||
OTA_LED_UART_R, OTA_LED_UART_G, OTA_LED_UART_B);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -219,6 +253,9 @@ static void ota_distribute_task(void *param) {
|
||||
|
||||
led_ring_show_ota_progress(0, image_size, OTA_LED_ESPNOW_TX_R, OTA_LED_ESPNOW_TX_G,
|
||||
OTA_LED_ESPNOW_TX_B);
|
||||
s_last_dist_agg_us = 0;
|
||||
s_last_dist_slave_us = 0;
|
||||
s_last_dist_agg_bytes = UINT32_MAX;
|
||||
send_ota_distributing(OTA_DIST_AGGREGATE, 0, 0);
|
||||
|
||||
esp_err_t err = ota_espnow_distribute(part, image_size, &s_dist_progress);
|
||||
@@ -254,6 +291,8 @@ static void ota_distribute_task(void *param) {
|
||||
|
||||
led_ring_ota_success();
|
||||
free(job);
|
||||
/* Host already got SUCCESS; reboot onto the staged image. */
|
||||
pod_schedule_restart();
|
||||
vTaskDelete(NULL);
|
||||
}
|
||||
|
||||
@@ -307,6 +346,10 @@ static void ota_start_espnow_task(void *param) {
|
||||
return;
|
||||
}
|
||||
|
||||
s_last_dist_agg_us = 0;
|
||||
s_last_dist_slave_us = 0;
|
||||
s_last_dist_agg_bytes = UINT32_MAX;
|
||||
|
||||
esp_err_t err = ota_espnow_distribute(part, image_size, &s_dist_progress);
|
||||
if (err != ESP_OK) {
|
||||
send_ota_failed(42);
|
||||
@@ -329,6 +372,7 @@ static void ota_start_espnow_task(void *param) {
|
||||
response.payload.ota_status.error = 0;
|
||||
uart_cmd_send(&response, TAG);
|
||||
led_ring_ota_success();
|
||||
pod_schedule_restart();
|
||||
vTaskDelete(NULL);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
#include "cmd_set_log_level.h"
|
||||
#include "esp_log.h"
|
||||
#include "uart_cmd.h"
|
||||
|
||||
static const char *TAG = "[LOG_LVL]";
|
||||
|
||||
static bool valid_level(uint32_t level) { return level <= ESP_LOG_VERBOSE; }
|
||||
|
||||
static void reply(uint32_t level, bool success) {
|
||||
alox_UartMessage response;
|
||||
uart_cmd_init_response(&response, alox_MessageType_SET_LOG_LEVEL,
|
||||
alox_UartMessage_set_log_level_response_tag);
|
||||
response.payload.set_log_level_response.success = success;
|
||||
response.payload.set_log_level_response.level = level;
|
||||
uart_cmd_send(&response, TAG);
|
||||
}
|
||||
|
||||
static void handle_set_log_level(const uint8_t *data, size_t len) {
|
||||
alox_UartMessage uart_msg;
|
||||
alox_SetLogLevelRequest req = alox_SetLogLevelRequest_init_zero;
|
||||
|
||||
if (uart_cmd_decode(data, len, &uart_msg) != ESP_OK) {
|
||||
ESP_LOGW(TAG, "decode failed");
|
||||
reply((uint32_t)esp_log_level_get("*"), false);
|
||||
return;
|
||||
}
|
||||
|
||||
const alox_SetLogLevelRequest *req_ptr = UART_CMD_REQ(
|
||||
&uart_msg, alox_UartMessage_set_log_level_request_tag, set_log_level_request);
|
||||
if (req_ptr != NULL) {
|
||||
req = *req_ptr;
|
||||
}
|
||||
|
||||
if (req.write) {
|
||||
if (!valid_level(req.level)) {
|
||||
ESP_LOGW(TAG, "invalid level %lu", (unsigned long)req.level);
|
||||
reply((uint32_t)esp_log_level_get("*"), false);
|
||||
return;
|
||||
}
|
||||
esp_log_level_set("*", (esp_log_level_t)req.level);
|
||||
ESP_LOGI(TAG, "global log level set to %lu", (unsigned long)req.level);
|
||||
reply(req.level, true);
|
||||
return;
|
||||
}
|
||||
|
||||
reply((uint32_t)esp_log_level_get("*"), true);
|
||||
}
|
||||
|
||||
void cmd_set_log_level_register(void) {
|
||||
uart_cmd_register(alox_MessageType_SET_LOG_LEVEL, handle_set_log_level);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
#ifndef CMD_SET_LOG_LEVEL_H
|
||||
#define CMD_SET_LOG_LEVEL_H
|
||||
|
||||
void cmd_set_log_level_register(void);
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,162 @@
|
||||
#include "bosch456.h"
|
||||
#include "client_registry.h"
|
||||
#include "cmd_tap_config.h"
|
||||
#include "esp_log.h"
|
||||
#include "esp_now_comm.h"
|
||||
#include "pod_settings.h"
|
||||
#include "uart_cmd.h"
|
||||
|
||||
static const char *TAG = "[TAP_CFG]";
|
||||
|
||||
static void fill_response_fields(alox_TapConfigResponse *out,
|
||||
const bma456_tap_config_t *cfg) {
|
||||
out->tap_sens_thres = cfg->tap_sens_thres;
|
||||
out->max_gest_dur = cfg->max_gest_dur;
|
||||
out->tap_shock_dur = cfg->tap_shock_dur;
|
||||
out->quite_time_after_gest = cfg->quite_time_after_gest;
|
||||
out->wait_for_timeout = cfg->wait_for_timeout;
|
||||
out->axis_sel = cfg->axis_sel;
|
||||
out->enable_single = cfg->enable_single;
|
||||
out->enable_double = cfg->enable_double;
|
||||
out->enable_triple = cfg->enable_triple;
|
||||
}
|
||||
|
||||
static void reply(uint32_t client_id, bool success, uint32_t slaves_updated,
|
||||
const bma456_tap_config_t *cfg) {
|
||||
alox_UartMessage response;
|
||||
uart_cmd_init_response(&response, alox_MessageType_TAP_CONFIG,
|
||||
alox_UartMessage_tap_config_response_tag);
|
||||
response.payload.tap_config_response.client_id = client_id;
|
||||
response.payload.tap_config_response.success = success;
|
||||
response.payload.tap_config_response.slaves_updated = slaves_updated;
|
||||
if (cfg != NULL) {
|
||||
fill_response_fields(&response.payload.tap_config_response, cfg);
|
||||
}
|
||||
uart_cmd_send(&response, TAG);
|
||||
}
|
||||
|
||||
static void req_to_cfg(const alox_TapConfigRequest *req,
|
||||
bma456_tap_config_t *cfg) {
|
||||
cfg->tap_sens_thres = (uint16_t)req->tap_sens_thres;
|
||||
cfg->max_gest_dur = (uint16_t)req->max_gest_dur;
|
||||
cfg->tap_shock_dur = (uint16_t)req->tap_shock_dur;
|
||||
cfg->quite_time_after_gest = (uint16_t)req->quite_time_after_gest;
|
||||
cfg->wait_for_timeout = (uint16_t)req->wait_for_timeout;
|
||||
cfg->axis_sel = (uint16_t)req->axis_sel;
|
||||
cfg->enable_single = req->enable_single;
|
||||
cfg->enable_double = req->enable_double;
|
||||
cfg->enable_triple = req->enable_triple;
|
||||
}
|
||||
|
||||
static void apply_local_tap_config(const bma456_tap_config_t *cfg) {
|
||||
if (bma456_set_tap_config(cfg) != ESP_OK) {
|
||||
ESP_LOGW(TAG, "local tap config apply failed");
|
||||
return;
|
||||
}
|
||||
if (pod_settings_save_tap_config(cfg) != ESP_OK) {
|
||||
ESP_LOGW(TAG, "tap config applied but not saved to NVS");
|
||||
}
|
||||
}
|
||||
|
||||
static esp_err_t push_tap_config_to_slave(const client_info_t *client,
|
||||
const bma456_tap_config_t *cfg) {
|
||||
if (client == NULL || cfg == NULL) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
|
||||
esp_err_t err = client_registry_set_tap_config(client->id, cfg);
|
||||
if (err != ESP_OK) {
|
||||
return err;
|
||||
}
|
||||
|
||||
return esp_now_comm_send_tap_config(client->mac, client->id, cfg);
|
||||
}
|
||||
|
||||
static void handle_tap_config(const uint8_t *data, size_t len) {
|
||||
alox_UartMessage uart_msg;
|
||||
alox_TapConfigRequest req = alox_TapConfigRequest_init_zero;
|
||||
static const bma456_tap_config_t k_default = BMA456_TAP_CONFIG_DEFAULT;
|
||||
|
||||
if (uart_cmd_decode(data, len, &uart_msg) != ESP_OK) {
|
||||
ESP_LOGW(TAG, "decode failed");
|
||||
reply(0, false, 0, &k_default);
|
||||
return;
|
||||
}
|
||||
|
||||
const alox_TapConfigRequest *req_ptr =
|
||||
UART_CMD_REQ(&uart_msg, alox_UartMessage_tap_config_request_tag,
|
||||
tap_config_request);
|
||||
if (req_ptr != NULL) {
|
||||
req = *req_ptr;
|
||||
}
|
||||
|
||||
bma456_tap_config_t cfg;
|
||||
req_to_cfg(&req, &cfg);
|
||||
|
||||
if (req.write) {
|
||||
if (!bma456_tap_config_valid(&cfg)) {
|
||||
ESP_LOGW(TAG, "invalid tap config");
|
||||
reply(req.client_id, false, 0, &cfg);
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.all_clients) {
|
||||
size_t n = client_registry_set_tap_config_all(&cfg);
|
||||
uint32_t sent = 0;
|
||||
|
||||
for (size_t i = 0; i < client_registry_count(); i++) {
|
||||
const client_info_t *client = client_registry_at(i);
|
||||
if (client == NULL) {
|
||||
continue;
|
||||
}
|
||||
if (esp_now_comm_send_tap_config(client->mac, client->id, &cfg) ==
|
||||
ESP_OK) {
|
||||
sent++;
|
||||
}
|
||||
}
|
||||
|
||||
if (bma456_is_ready()) {
|
||||
apply_local_tap_config(&cfg);
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "set tap config via unicast to %u/%u slaves",
|
||||
(unsigned)sent, (unsigned)n);
|
||||
reply(0, sent > 0 || bma456_is_ready(), sent, &cfg);
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.client_id == 0) {
|
||||
apply_local_tap_config(&cfg);
|
||||
ESP_LOGI(TAG, "set local tap config (no ESP-NOW; use -client or -all "
|
||||
"for slaves)");
|
||||
reply(0, true, 0, &cfg);
|
||||
return;
|
||||
}
|
||||
|
||||
const client_info_t *client = client_registry_find_by_id(req.client_id);
|
||||
if (client == NULL) {
|
||||
ESP_LOGW(TAG, "client id %lu not found", (unsigned long)req.client_id);
|
||||
reply(req.client_id, false, 0, &cfg);
|
||||
return;
|
||||
}
|
||||
|
||||
esp_err_t err = push_tap_config_to_slave(client, &cfg);
|
||||
reply(req.client_id, err == ESP_OK, err == ESP_OK ? 1 : 0, &cfg);
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.all_clients || req.client_id == 0) {
|
||||
bma456_tap_config_t local;
|
||||
bma456_get_tap_config(&local);
|
||||
reply(0, true, 0, &local);
|
||||
return;
|
||||
}
|
||||
|
||||
bma456_tap_config_t cached;
|
||||
esp_err_t err = client_registry_get_tap_config(req.client_id, &cached);
|
||||
reply(req.client_id, err == ESP_OK, 0, err == ESP_OK ? &cached : &k_default);
|
||||
}
|
||||
|
||||
void cmd_tap_config_register(void) {
|
||||
uart_cmd_register(alox_MessageType_TAP_CONFIG, handle_tap_config);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
#ifndef CMD_TAP_CONFIG_H
|
||||
#define CMD_TAP_CONFIG_H
|
||||
|
||||
void cmd_tap_config_register(void);
|
||||
|
||||
#endif
|
||||
@@ -13,6 +13,10 @@ esp_err_t esp_now_comm_init(const app_config_t *config);
|
||||
esp_err_t esp_now_comm_send_accel_stream(const uint8_t mac[CLIENT_MAC_LEN],
|
||||
uint32_t client_id, bool enable);
|
||||
|
||||
/** Master: set accel/tap ESP-NOW send path on one slave (InputSendMode). */
|
||||
esp_err_t esp_now_comm_send_input_send_mode(const uint8_t mac[CLIENT_MAC_LEN],
|
||||
uint32_t client_id, uint32_t mode);
|
||||
|
||||
/** Master: configure tap notify flags on one slave. */
|
||||
esp_err_t esp_now_comm_send_tap_notify(const uint8_t mac[CLIENT_MAC_LEN],
|
||||
uint32_t client_id, bool single,
|
||||
@@ -22,10 +26,30 @@ esp_err_t esp_now_comm_send_tap_notify(const uint8_t mac[CLIENT_MAC_LEN],
|
||||
esp_err_t esp_now_comm_send_accel_deadzone(const uint8_t mac[CLIENT_MAC_LEN],
|
||||
uint32_t client_id, uint32_t deadzone);
|
||||
|
||||
/** Master: unicast BMA456 tap config to one slave. */
|
||||
esp_err_t esp_now_comm_send_tap_config(const uint8_t mac[CLIENT_MAC_LEN],
|
||||
uint32_t client_id,
|
||||
const bma456_tap_config_t *cfg);
|
||||
|
||||
/** Master: unicast software UV protection enable to one slave. */
|
||||
esp_err_t esp_now_comm_send_battery_uv(const uint8_t mac[CLIENT_MAC_LEN],
|
||||
uint32_t client_id, bool enabled);
|
||||
|
||||
/** Master: send minimal unicast test payload to verify master→slave path. */
|
||||
esp_err_t esp_now_comm_send_unicast_test(const uint8_t mac[CLIENT_MAC_LEN],
|
||||
uint32_t seq);
|
||||
|
||||
/** Result of a master-side ESP-NOW echo ping round-trip. */
|
||||
typedef struct {
|
||||
uint64_t echoed_us;
|
||||
uint32_t esp_rtt_us;
|
||||
} esp_now_echo_ping_result_t;
|
||||
|
||||
/** Master: ESP-NOW echo ping round-trip; fills result on success. */
|
||||
esp_err_t esp_now_comm_echo_ping(const uint8_t mac[CLIENT_MAC_LEN],
|
||||
uint64_t timestamp_us,
|
||||
esp_now_echo_ping_result_t *result);
|
||||
|
||||
/** Master: trigger find-me LED sequence on one slave. */
|
||||
esp_err_t esp_now_comm_send_find_me(const uint8_t mac[CLIENT_MAC_LEN],
|
||||
uint32_t client_id);
|
||||
@@ -39,6 +63,22 @@ esp_err_t esp_now_comm_send_led_ring(const uint8_t mac[CLIENT_MAC_LEN],
|
||||
esp_err_t esp_now_comm_send_restart(const uint8_t mac[CLIENT_MAC_LEN],
|
||||
uint32_t client_id);
|
||||
|
||||
/** Master: tell slave its assigned logical client id. */
|
||||
esp_err_t esp_now_comm_send_assign_id(const uint8_t mac[CLIENT_MAC_LEN],
|
||||
uint32_t assigned_id);
|
||||
|
||||
/** Master: tell slave to forget persisted master/id binding. */
|
||||
esp_err_t esp_now_comm_send_clear_id_binding(const uint8_t mac[CLIENT_MAC_LEN]);
|
||||
|
||||
/**
|
||||
* Master: clear NVS id map + live registry and unicast CLEAR_ID_BINDING to
|
||||
* every currently registered live slave (snapshot before clear).
|
||||
*/
|
||||
esp_err_t esp_now_comm_reset_client_id_map(void);
|
||||
|
||||
/** Slave: clear local NVS master/id binding (UART reset on slave). */
|
||||
esp_err_t esp_now_comm_clear_local_id_binding(void);
|
||||
|
||||
/** Master → slave OTA (unicast). */
|
||||
esp_err_t esp_now_comm_send_ota_start(const uint8_t mac[CLIENT_MAC_LEN],
|
||||
uint32_t total_size);
|
||||
|
||||
+150
-17
@@ -21,7 +21,18 @@ static app_config_t s_config;
|
||||
static uint8_t s_wifi_channel;
|
||||
static uint8_t s_own_mac[ESP_NOW_ETH_ALEN];
|
||||
static SemaphoreHandle_t s_send_done;
|
||||
static SemaphoreHandle_t s_send_lock;
|
||||
static bool s_send_cb_ready;
|
||||
static volatile esp_now_send_status_t s_last_send_status;
|
||||
static volatile bool s_last_send_ok;
|
||||
|
||||
#define ESPNOW_SEND_DONE_TIMEOUT_MS 500u
|
||||
#define ESPNOW_SEND_MAX_ATTEMPTS 8u
|
||||
#define ESPNOW_SEND_RETRY_DELAY_MS 10u
|
||||
#define ESPNOW_NOMEM_RETRY_DELAY_MS 50u
|
||||
|
||||
#define ESPNOW_RELIABLE_MAX_ATTEMPTS 24u
|
||||
#define ESPNOW_RELIABLE_NOMEM_DELAY_MS 50u
|
||||
|
||||
static uint8_t network_to_channel(uint8_t network) {
|
||||
if (network < 1 || network > 13) {
|
||||
@@ -33,7 +44,8 @@ static uint8_t network_to_channel(uint8_t network) {
|
||||
static void espnow_send_done_cb(const esp_now_send_info_t *tx_info,
|
||||
esp_now_send_status_t status) {
|
||||
(void)tx_info;
|
||||
(void)status;
|
||||
s_last_send_status = status;
|
||||
s_last_send_ok = (status == ESP_NOW_SEND_SUCCESS);
|
||||
if (s_send_done != NULL) {
|
||||
xSemaphoreGive(s_send_done);
|
||||
}
|
||||
@@ -93,46 +105,158 @@ esp_err_t esp_now_core_ensure_broadcast_peer(void) {
|
||||
return esp_now_core_ensure_peer(ESPNOW_BCAST);
|
||||
}
|
||||
|
||||
esp_err_t esp_now_core_send_wait(const uint8_t *dest_mac,
|
||||
const alox_EspNowMessage *msg) {
|
||||
static esp_err_t send_with_backpressure(const uint8_t *dest_mac,
|
||||
const alox_EspNowMessage *msg,
|
||||
uint32_t max_attempts,
|
||||
uint32_t nomem_delay_ms,
|
||||
uint32_t retry_delay_ms,
|
||||
bool quiet_retries) {
|
||||
if (s_send_lock == NULL ||
|
||||
xSemaphoreTake(s_send_lock, pdMS_TO_TICKS(5000)) != pdTRUE) {
|
||||
return ESP_ERR_TIMEOUT;
|
||||
}
|
||||
|
||||
uint8_t buf[ESPNOW_PB_MAX_SIZE];
|
||||
size_t len = 0;
|
||||
esp_err_t result = ESP_FAIL;
|
||||
|
||||
esp_err_t err = esp_now_proto_encode(msg, buf, sizeof(buf), &len);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGW(TAG, "encode failed");
|
||||
return err;
|
||||
result = err;
|
||||
goto out;
|
||||
}
|
||||
|
||||
if (len > ESP_NOW_MAX_DATA_LEN) {
|
||||
ESP_LOGW(TAG, "encoded len %u > ESP-NOW max %u", (unsigned)len,
|
||||
(unsigned)ESP_NOW_MAX_DATA_LEN);
|
||||
result = ESP_ERR_INVALID_SIZE;
|
||||
goto out;
|
||||
}
|
||||
|
||||
if (esp_now_core_ensure_peer(dest_mac) != ESP_OK) {
|
||||
result = ESP_FAIL;
|
||||
goto out;
|
||||
}
|
||||
|
||||
for (uint32_t attempt = 0; attempt < max_attempts; attempt++) {
|
||||
if (s_send_cb_ready && s_send_done != NULL) {
|
||||
xSemaphoreTake(s_send_done, 0);
|
||||
}
|
||||
s_last_send_ok = false;
|
||||
|
||||
err = esp_now_send(dest_mac, buf, len);
|
||||
if (err != ESP_OK) {
|
||||
const bool last = (attempt + 1 >= max_attempts);
|
||||
if (!quiet_retries || last) {
|
||||
ESP_LOGW(TAG, "send type=%u failed (attempt %lu/%lu): %s",
|
||||
(unsigned)msg->type, (unsigned long)(attempt + 1),
|
||||
(unsigned long)max_attempts, esp_err_to_name(err));
|
||||
}
|
||||
vTaskDelay(pdMS_TO_TICKS(err == ESP_ERR_ESPNOW_NO_MEM ? nomem_delay_ms
|
||||
: retry_delay_ms));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (s_send_cb_ready && s_send_done != NULL) {
|
||||
if (xSemaphoreTake(s_send_done, pdMS_TO_TICKS(ESPNOW_SEND_DONE_TIMEOUT_MS)) !=
|
||||
pdTRUE) {
|
||||
const bool last = (attempt + 1 >= max_attempts);
|
||||
if (!quiet_retries || last) {
|
||||
ESP_LOGW(TAG, "send type=%u done timeout (attempt %lu/%lu)",
|
||||
(unsigned)msg->type, (unsigned long)(attempt + 1),
|
||||
(unsigned long)max_attempts);
|
||||
}
|
||||
vTaskDelay(pdMS_TO_TICKS(retry_delay_ms));
|
||||
continue;
|
||||
}
|
||||
if (!s_last_send_ok) {
|
||||
const bool last = (attempt + 1 >= max_attempts);
|
||||
if (!quiet_retries || last) {
|
||||
ESP_LOGW(TAG, "send type=%u peer status=%d (attempt %lu/%lu)",
|
||||
(unsigned)msg->type, (int)s_last_send_status,
|
||||
(unsigned long)(attempt + 1), (unsigned long)max_attempts);
|
||||
}
|
||||
vTaskDelay(pdMS_TO_TICKS(retry_delay_ms));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
result = ESP_OK;
|
||||
goto out;
|
||||
}
|
||||
|
||||
out:
|
||||
xSemaphoreGive(s_send_lock);
|
||||
return result;
|
||||
}
|
||||
|
||||
esp_err_t esp_now_core_send_wait(const uint8_t *dest_mac,
|
||||
const alox_EspNowMessage *msg) {
|
||||
return send_with_backpressure(dest_mac, msg, ESPNOW_SEND_MAX_ATTEMPTS,
|
||||
ESPNOW_NOMEM_RETRY_DELAY_MS,
|
||||
ESPNOW_SEND_RETRY_DELAY_MS, false);
|
||||
}
|
||||
|
||||
esp_err_t esp_now_core_send_reliable(const uint8_t *dest_mac,
|
||||
const alox_EspNowMessage *msg) {
|
||||
return send_with_backpressure(dest_mac, msg, ESPNOW_RELIABLE_MAX_ATTEMPTS,
|
||||
ESPNOW_RELIABLE_NOMEM_DELAY_MS,
|
||||
ESPNOW_SEND_RETRY_DELAY_MS, true);
|
||||
}
|
||||
|
||||
static esp_err_t send_fast_internal(const uint8_t *dest_mac,
|
||||
const alox_EspNowMessage *msg,
|
||||
bool quiet) {
|
||||
if (s_send_lock == NULL ||
|
||||
xSemaphoreTake(s_send_lock, pdMS_TO_TICKS(5)) != pdTRUE) {
|
||||
return ESP_ERR_TIMEOUT;
|
||||
}
|
||||
|
||||
uint8_t buf[ESPNOW_PB_MAX_SIZE];
|
||||
size_t len = 0;
|
||||
|
||||
esp_err_t err = esp_now_proto_encode(msg, buf, sizeof(buf), &len);
|
||||
if (err != ESP_OK) {
|
||||
if (!quiet) {
|
||||
ESP_LOGW(TAG, "encode failed");
|
||||
}
|
||||
xSemaphoreGive(s_send_lock);
|
||||
return err;
|
||||
}
|
||||
|
||||
if (len > ESP_NOW_MAX_DATA_LEN) {
|
||||
if (!quiet) {
|
||||
ESP_LOGW(TAG, "encoded len %u > ESP-NOW max %u", (unsigned)len,
|
||||
(unsigned)ESP_NOW_MAX_DATA_LEN);
|
||||
}
|
||||
xSemaphoreGive(s_send_lock);
|
||||
return ESP_ERR_INVALID_SIZE;
|
||||
}
|
||||
|
||||
if (esp_now_core_ensure_peer(dest_mac) != ESP_OK) {
|
||||
xSemaphoreGive(s_send_lock);
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
if (s_send_cb_ready && s_send_done != NULL) {
|
||||
xSemaphoreTake(s_send_done, 0);
|
||||
}
|
||||
|
||||
err = esp_now_send(dest_mac, buf, len);
|
||||
if (err != ESP_OK) {
|
||||
if (err != ESP_OK && !quiet) {
|
||||
ESP_LOGW(TAG, "send type=%u failed: %s", (unsigned)msg->type,
|
||||
esp_err_to_name(err));
|
||||
return err;
|
||||
}
|
||||
|
||||
if (s_send_cb_ready && s_send_done != NULL) {
|
||||
if (xSemaphoreTake(s_send_done, pdMS_TO_TICKS(50)) != pdTRUE) {
|
||||
ESP_LOGW(TAG, "send type=%u done timeout", (unsigned)msg->type);
|
||||
}
|
||||
}
|
||||
xSemaphoreGive(s_send_lock);
|
||||
return err;
|
||||
}
|
||||
|
||||
esp_err_t esp_now_core_send_fast(const uint8_t *dest_mac,
|
||||
const alox_EspNowMessage *msg) {
|
||||
return send_fast_internal(dest_mac, msg, false);
|
||||
}
|
||||
|
||||
esp_err_t esp_now_core_send_best_effort(const uint8_t *dest_mac,
|
||||
const alox_EspNowMessage *msg) {
|
||||
return send_fast_internal(dest_mac, msg, true);
|
||||
}
|
||||
|
||||
esp_err_t esp_now_core_send(const uint8_t *dest_mac,
|
||||
const alox_EspNowMessage *msg) {
|
||||
return esp_now_core_send_wait(dest_mac, msg);
|
||||
@@ -156,6 +280,14 @@ esp_err_t esp_now_core_init_radio(uint8_t channel) {
|
||||
ESP_ERROR_CHECK(esp_wifi_set_ps(WIFI_PS_NONE));
|
||||
ESP_ERROR_CHECK(esp_wifi_set_channel(channel, WIFI_SECOND_CHAN_NONE));
|
||||
|
||||
/* WiFi task stack is tiny; ERROR logs from pp/coex (e.g. under BLE+ESP-NOW
|
||||
* OTA) call esp_log_write on that stack and can overflow → panic reboot.
|
||||
* Mute after esp_wifi_init (which resets the wifi tag to default level). */
|
||||
esp_log_level_set("wifi", ESP_LOG_NONE);
|
||||
esp_log_level_set("wifi_init", ESP_LOG_WARN);
|
||||
esp_log_level_set("coex", ESP_LOG_WARN);
|
||||
esp_log_level_set("NimBLE", ESP_LOG_WARN);
|
||||
|
||||
ESP_ERROR_CHECK(esp_read_mac(s_own_mac, ESP_MAC_WIFI_STA));
|
||||
s_wifi_channel = channel;
|
||||
return ESP_OK;
|
||||
@@ -163,7 +295,8 @@ esp_err_t esp_now_core_init_radio(uint8_t channel) {
|
||||
|
||||
void esp_now_core_init_send_done(void) {
|
||||
s_send_done = xSemaphoreCreateBinary();
|
||||
if (s_send_done != NULL &&
|
||||
s_send_lock = xSemaphoreCreateMutex();
|
||||
if (s_send_done != NULL && s_send_lock != NULL &&
|
||||
esp_now_register_send_cb(espnow_send_done_cb) == ESP_OK) {
|
||||
s_send_cb_ready = true;
|
||||
} else {
|
||||
|
||||
@@ -23,8 +23,20 @@ esp_err_t esp_now_core_ensure_broadcast_peer(void);
|
||||
|
||||
esp_err_t esp_now_core_send(const uint8_t *dest_mac,
|
||||
const alox_EspNowMessage *msg);
|
||||
/** Like send but does not wait for esp_now send-done (lower latency). */
|
||||
esp_err_t esp_now_core_send_fast(const uint8_t *dest_mac,
|
||||
const alox_EspNowMessage *msg);
|
||||
/**
|
||||
* Fire-and-forget without failure logs — for high-rate gaming input
|
||||
* (accel/tap) where drops are expected under load.
|
||||
*/
|
||||
esp_err_t esp_now_core_send_best_effort(const uint8_t *dest_mac,
|
||||
const alox_EspNowMessage *msg);
|
||||
esp_err_t esp_now_core_send_wait(const uint8_t *dest_mac,
|
||||
const alox_EspNowMessage *msg);
|
||||
/** OTA payloads: wait for send-done with extra NO_MEM backoff (queue drain). */
|
||||
esp_err_t esp_now_core_send_reliable(const uint8_t *dest_mac,
|
||||
const alox_EspNowMessage *msg);
|
||||
|
||||
esp_err_t esp_now_core_init_radio(uint8_t channel);
|
||||
void esp_now_core_init_send_done(void);
|
||||
|
||||
+338
-12
@@ -5,8 +5,10 @@
|
||||
#include "esp_now_proto.h"
|
||||
#include "board_input.h"
|
||||
#include "ota_espnow.h"
|
||||
#include "ota_session.h"
|
||||
#include "ota_uart.h"
|
||||
#include "esp_log.h"
|
||||
#include "esp_timer.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/idf_additions.h"
|
||||
#include <string.h>
|
||||
@@ -20,6 +22,8 @@ static const uint8_t ESPNOW_BCAST[ESP_NOW_ETH_ALEN] = {0xff, 0xff, 0xff,
|
||||
#define ESPNOW_CLIENT_TIMEOUT_MS \
|
||||
(ESPNOW_HEARTBEAT_INTERVAL_MS * ESPNOW_HEARTBEAT_MISS_COUNT)
|
||||
#define ESPNOW_BATTERY_INTERVAL_MS 30000
|
||||
/** Min gap between ASSIGN_ID resends when slave has not yet echoed the id. */
|
||||
#define ESPNOW_ASSIGN_RESEND_MS 2000u
|
||||
|
||||
static const char *TAG = "[ESPNOW_M]";
|
||||
|
||||
@@ -33,6 +37,16 @@ static esp_err_t send_accel_stream(const uint8_t *dest_mac, uint32_t client_id,
|
||||
return esp_now_core_send(dest_mac, &msg);
|
||||
}
|
||||
|
||||
static esp_err_t send_input_send_mode(const uint8_t *dest_mac,
|
||||
uint32_t client_id, uint32_t mode) {
|
||||
alox_EspNowMessage msg = alox_EspNowMessage_init_zero;
|
||||
msg.type = alox_EspNowMessageType_ESPNOW_SET_INPUT_SEND_MODE;
|
||||
msg.which_payload = alox_EspNowMessage_input_send_mode_tag;
|
||||
msg.payload.input_send_mode.client_id = client_id;
|
||||
msg.payload.input_send_mode.mode = mode;
|
||||
return esp_now_core_send(dest_mac, &msg);
|
||||
}
|
||||
|
||||
static esp_err_t send_accel_deadzone(const uint8_t *dest_mac, uint32_t client_id,
|
||||
uint32_t deadzone) {
|
||||
alox_EspNowMessage msg = alox_EspNowMessage_init_zero;
|
||||
@@ -43,6 +57,34 @@ static esp_err_t send_accel_deadzone(const uint8_t *dest_mac, uint32_t client_id
|
||||
return esp_now_core_send(dest_mac, &msg);
|
||||
}
|
||||
|
||||
static esp_err_t send_tap_config(const uint8_t *dest_mac, uint32_t client_id,
|
||||
const bma456_tap_config_t *cfg) {
|
||||
alox_EspNowMessage msg = alox_EspNowMessage_init_zero;
|
||||
msg.type = alox_EspNowMessageType_ESPNOW_SET_TAP_CONFIG;
|
||||
msg.which_payload = alox_EspNowMessage_tap_config_tag;
|
||||
msg.payload.tap_config.client_id = client_id;
|
||||
msg.payload.tap_config.tap_sens_thres = cfg->tap_sens_thres;
|
||||
msg.payload.tap_config.max_gest_dur = cfg->max_gest_dur;
|
||||
msg.payload.tap_config.tap_shock_dur = cfg->tap_shock_dur;
|
||||
msg.payload.tap_config.quite_time_after_gest = cfg->quite_time_after_gest;
|
||||
msg.payload.tap_config.wait_for_timeout = cfg->wait_for_timeout;
|
||||
msg.payload.tap_config.axis_sel = cfg->axis_sel;
|
||||
msg.payload.tap_config.enable_single = cfg->enable_single;
|
||||
msg.payload.tap_config.enable_double = cfg->enable_double;
|
||||
msg.payload.tap_config.enable_triple = cfg->enable_triple;
|
||||
return esp_now_core_send(dest_mac, &msg);
|
||||
}
|
||||
|
||||
static esp_err_t send_battery_uv(const uint8_t *dest_mac, uint32_t client_id,
|
||||
bool enabled) {
|
||||
alox_EspNowMessage msg = alox_EspNowMessage_init_zero;
|
||||
msg.type = alox_EspNowMessageType_ESPNOW_SET_BATTERY_UV;
|
||||
msg.which_payload = alox_EspNowMessage_battery_uv_tag;
|
||||
msg.payload.battery_uv.client_id = client_id;
|
||||
msg.payload.battery_uv.enabled = enabled;
|
||||
return esp_now_core_send(dest_mac, &msg);
|
||||
}
|
||||
|
||||
static esp_err_t send_unicast_test(const uint8_t *dest_mac, uint32_t seq) {
|
||||
alox_EspNowMessage msg = alox_EspNowMessage_init_zero;
|
||||
msg.type = alox_EspNowMessageType_ESPNOW_UNICAST_TEST;
|
||||
@@ -51,6 +93,66 @@ static esp_err_t send_unicast_test(const uint8_t *dest_mac, uint32_t seq) {
|
||||
return esp_now_core_send(dest_mac, &msg);
|
||||
}
|
||||
|
||||
#define ESPNOW_ECHO_PING_TIMEOUT_MS 500
|
||||
#define ECHO_PING_TAG "[ECHO_PING]"
|
||||
|
||||
static SemaphoreHandle_t s_echo_pong_sem;
|
||||
static bool s_echo_waiting;
|
||||
static uint64_t s_echo_expect_ts;
|
||||
static uint32_t s_echo_esp_rtt_us;
|
||||
static uint8_t s_echo_expect_mac[ESP_NOW_ETH_ALEN];
|
||||
|
||||
static esp_err_t echo_ping_init(void) {
|
||||
if (s_echo_pong_sem != NULL) {
|
||||
return ESP_OK;
|
||||
}
|
||||
s_echo_pong_sem = xSemaphoreCreateBinary();
|
||||
if (s_echo_pong_sem == NULL) {
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
static esp_err_t send_echo_ping(const uint8_t *dest_mac, uint64_t host_timestamp_us,
|
||||
uint64_t master_time_us) {
|
||||
alox_EspNowMessage msg = alox_EspNowMessage_init_zero;
|
||||
msg.type = alox_EspNowMessageType_ESPNOW_ECHO_PING;
|
||||
msg.which_payload = alox_EspNowMessage_echo_ping_tag;
|
||||
msg.payload.echo_ping.host_timestamp_us = host_timestamp_us;
|
||||
msg.payload.echo_ping.master_time_us = master_time_us;
|
||||
char mac_str[18];
|
||||
esp_now_core_mac_to_str(dest_mac, mac_str, sizeof(mac_str));
|
||||
ESP_LOGI(ECHO_PING_TAG, "ESP-NOW PING send to %s host_ts=%llu master_time_us=%llu",
|
||||
mac_str, (unsigned long long)host_timestamp_us,
|
||||
(unsigned long long)master_time_us);
|
||||
return esp_now_core_send_fast(dest_mac, &msg);
|
||||
}
|
||||
|
||||
static void echo_ping_on_pong(const uint8_t mac[ESP_NOW_ETH_ALEN],
|
||||
const alox_EspNowEchoPong *pong) {
|
||||
if (pong == NULL || !s_echo_waiting) {
|
||||
return;
|
||||
}
|
||||
if (!esp_now_core_mac_equal(mac, s_echo_expect_mac)) {
|
||||
return;
|
||||
}
|
||||
if (pong->host_timestamp_us != s_echo_expect_ts) {
|
||||
return;
|
||||
}
|
||||
int64_t now_us = esp_timer_get_time();
|
||||
s_echo_esp_rtt_us =
|
||||
(uint32_t)(now_us - (int64_t)pong->master_time_us);
|
||||
char mac_str[18];
|
||||
esp_now_core_mac_to_str(mac, mac_str, sizeof(mac_str));
|
||||
ESP_LOGI(ECHO_PING_TAG,
|
||||
"ESP-NOW PONG recv from %s host_ts=%llu master_time_us=%llu "
|
||||
"recv_time_us=%lld esp_rtt_us=%lu",
|
||||
mac_str, (unsigned long long)pong->host_timestamp_us,
|
||||
(unsigned long long)pong->master_time_us, (long long)now_us,
|
||||
(unsigned long)s_echo_esp_rtt_us);
|
||||
xSemaphoreGive(s_echo_pong_sem);
|
||||
}
|
||||
|
||||
static esp_err_t send_find_me(const uint8_t *dest_mac, uint32_t client_id) {
|
||||
alox_EspNowMessage msg = alox_EspNowMessage_init_zero;
|
||||
msg.type = alox_EspNowMessageType_ESPNOW_FIND_ME;
|
||||
@@ -88,6 +190,22 @@ static esp_err_t send_restart(const uint8_t *dest_mac, uint32_t client_id) {
|
||||
return esp_now_core_send(dest_mac, &msg);
|
||||
}
|
||||
|
||||
static esp_err_t send_assign_id(const uint8_t *dest_mac, uint32_t assigned_id) {
|
||||
alox_EspNowMessage msg = alox_EspNowMessage_init_zero;
|
||||
msg.type = alox_EspNowMessageType_ESPNOW_ASSIGN_ID;
|
||||
msg.which_payload = alox_EspNowMessage_assign_id_tag;
|
||||
msg.payload.assign_id.assigned_id = assigned_id;
|
||||
/* Fast path: avoid 8× NO_MEM retries blocking the shared send lock. */
|
||||
return esp_now_core_send_fast(dest_mac, &msg);
|
||||
}
|
||||
|
||||
static esp_err_t send_clear_id_binding(const uint8_t *dest_mac) {
|
||||
alox_EspNowMessage msg = alox_EspNowMessage_init_zero;
|
||||
msg.type = alox_EspNowMessageType_ESPNOW_CLEAR_ID_BINDING;
|
||||
msg.which_payload = alox_EspNowMessage_clear_id_binding_tag;
|
||||
return esp_now_core_send_fast(dest_mac, &msg);
|
||||
}
|
||||
|
||||
static esp_err_t send_tap_notify(const uint8_t *dest_mac, uint32_t client_id,
|
||||
bool single, bool double_tap, bool triple) {
|
||||
alox_EspNowMessage msg = alox_EspNowMessage_init_zero;
|
||||
@@ -119,7 +237,7 @@ static esp_err_t send_ota_payload(const uint8_t *dest_mac, uint32_t seq,
|
||||
msg.payload.ota_payload.seq = seq;
|
||||
msg.payload.ota_payload.data.size = len;
|
||||
memcpy(msg.payload.ota_payload.data.bytes, data, len);
|
||||
return esp_now_core_send_wait(dest_mac, &msg);
|
||||
return esp_now_core_send_reliable(dest_mac, &msg);
|
||||
}
|
||||
|
||||
static esp_err_t send_ota_end(const uint8_t *dest_mac) {
|
||||
@@ -171,6 +289,70 @@ esp_err_t esp_now_comm_send_restart(const uint8_t mac[CLIENT_MAC_LEN],
|
||||
return err;
|
||||
}
|
||||
|
||||
esp_err_t esp_now_comm_send_assign_id(const uint8_t mac[CLIENT_MAC_LEN],
|
||||
uint32_t assigned_id) {
|
||||
if (mac == NULL || !esp_now_core_is_master()) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
char mac_str[18];
|
||||
esp_now_core_mac_to_str(mac, mac_str, sizeof(mac_str));
|
||||
esp_err_t err = send_assign_id(mac, assigned_id);
|
||||
if (err == ESP_OK) {
|
||||
ESP_LOGI(TAG, "unicast ASSIGN_ID %lu to %s", (unsigned long)assigned_id,
|
||||
mac_str);
|
||||
} else {
|
||||
ESP_LOGW(TAG, "unicast ASSIGN_ID to %s failed: %s", mac_str,
|
||||
esp_err_to_name(err));
|
||||
}
|
||||
return err;
|
||||
}
|
||||
|
||||
esp_err_t esp_now_comm_send_clear_id_binding(const uint8_t mac[CLIENT_MAC_LEN]) {
|
||||
if (mac == NULL || !esp_now_core_is_master()) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
char mac_str[18];
|
||||
esp_now_core_mac_to_str(mac, mac_str, sizeof(mac_str));
|
||||
esp_err_t err = send_clear_id_binding(mac);
|
||||
if (err == ESP_OK) {
|
||||
ESP_LOGI(TAG, "unicast CLEAR_ID_BINDING to %s", mac_str);
|
||||
} else {
|
||||
ESP_LOGW(TAG, "unicast CLEAR_ID_BINDING to %s failed: %s", mac_str,
|
||||
esp_err_to_name(err));
|
||||
}
|
||||
return err;
|
||||
}
|
||||
|
||||
esp_err_t esp_now_comm_reset_client_id_map(void) {
|
||||
if (!esp_now_core_is_master()) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
uint8_t macs[CLIENT_REGISTRY_MAX][CLIENT_MAC_LEN];
|
||||
size_t n = client_registry_count();
|
||||
if (n > CLIENT_REGISTRY_MAX) {
|
||||
n = CLIENT_REGISTRY_MAX;
|
||||
}
|
||||
size_t copied = 0;
|
||||
for (size_t i = 0; i < n; i++) {
|
||||
const client_info_t *c = client_registry_at(i);
|
||||
if (c == NULL) {
|
||||
continue;
|
||||
}
|
||||
memcpy(macs[copied], c->mac, CLIENT_MAC_LEN);
|
||||
copied++;
|
||||
}
|
||||
|
||||
client_registry_clear_id_map();
|
||||
|
||||
for (size_t i = 0; i < copied; i++) {
|
||||
(void)esp_now_comm_send_clear_id_binding(macs[i]);
|
||||
}
|
||||
ESP_LOGI(TAG, "client id map reset; notified %u slave(s)", (unsigned)copied);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
|
||||
esp_err_t esp_now_comm_send_find_me(const uint8_t mac[CLIENT_MAC_LEN],
|
||||
uint32_t client_id) {
|
||||
if (mac == NULL || !esp_now_core_is_master()) {
|
||||
@@ -224,6 +406,48 @@ esp_err_t esp_now_comm_send_unicast_test(const uint8_t mac[CLIENT_MAC_LEN],
|
||||
return err;
|
||||
}
|
||||
|
||||
esp_err_t esp_now_comm_echo_ping(const uint8_t mac[CLIENT_MAC_LEN],
|
||||
uint64_t timestamp_us,
|
||||
esp_now_echo_ping_result_t *result) {
|
||||
if (mac == NULL || !esp_now_core_is_master()) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
if (echo_ping_init() != ESP_OK) {
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
|
||||
xSemaphoreTake(s_echo_pong_sem, 0);
|
||||
s_echo_waiting = true;
|
||||
s_echo_expect_ts = timestamp_us;
|
||||
s_echo_esp_rtt_us = 0;
|
||||
memcpy(s_echo_expect_mac, mac, ESP_NOW_ETH_ALEN);
|
||||
|
||||
uint64_t master_time_us = (uint64_t)esp_timer_get_time();
|
||||
esp_err_t err = send_echo_ping(mac, timestamp_us, master_time_us);
|
||||
if (err != ESP_OK) {
|
||||
s_echo_waiting = false;
|
||||
return err;
|
||||
}
|
||||
|
||||
if (xSemaphoreTake(s_echo_pong_sem,
|
||||
pdMS_TO_TICKS(ESPNOW_ECHO_PING_TIMEOUT_MS)) != pdTRUE) {
|
||||
s_echo_waiting = false;
|
||||
char mac_str[18];
|
||||
esp_now_core_mac_to_str(mac, mac_str, sizeof(mac_str));
|
||||
ESP_LOGW(ECHO_PING_TAG,
|
||||
"ESP-NOW PONG timeout to %s host_ts=%llu",
|
||||
mac_str, (unsigned long long)timestamp_us);
|
||||
return ESP_ERR_TIMEOUT;
|
||||
}
|
||||
|
||||
s_echo_waiting = false;
|
||||
if (result != NULL) {
|
||||
result->echoed_us = timestamp_us;
|
||||
result->esp_rtt_us = s_echo_esp_rtt_us;
|
||||
}
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t esp_now_comm_send_accel_stream(const uint8_t mac[CLIENT_MAC_LEN],
|
||||
uint32_t client_id, bool enable) {
|
||||
if (mac == NULL || !esp_now_core_is_master()) {
|
||||
@@ -242,6 +466,24 @@ esp_err_t esp_now_comm_send_accel_stream(const uint8_t mac[CLIENT_MAC_LEN],
|
||||
return err;
|
||||
}
|
||||
|
||||
esp_err_t esp_now_comm_send_input_send_mode(const uint8_t mac[CLIENT_MAC_LEN],
|
||||
uint32_t client_id, uint32_t mode) {
|
||||
if (mac == NULL || !esp_now_core_is_master()) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
char mac_str[18];
|
||||
esp_now_core_mac_to_str(mac, mac_str, sizeof(mac_str));
|
||||
esp_err_t err = send_input_send_mode(mac, client_id, mode);
|
||||
if (err == ESP_OK) {
|
||||
ESP_LOGI(TAG, "unicast SET_INPUT_SEND_MODE to %s: mode=%lu client_id=%lu",
|
||||
mac_str, (unsigned long)mode, (unsigned long)client_id);
|
||||
} else {
|
||||
ESP_LOGW(TAG, "unicast SET_INPUT_SEND_MODE to %s failed: %s", mac_str,
|
||||
esp_err_to_name(err));
|
||||
}
|
||||
return err;
|
||||
}
|
||||
|
||||
esp_err_t esp_now_comm_send_tap_notify(const uint8_t mac[CLIENT_MAC_LEN],
|
||||
uint32_t client_id, bool single,
|
||||
bool double_tap, bool triple) {
|
||||
@@ -283,6 +525,46 @@ esp_err_t esp_now_comm_send_accel_deadzone(const uint8_t mac[CLIENT_MAC_LEN],
|
||||
return err;
|
||||
}
|
||||
|
||||
esp_err_t esp_now_comm_send_tap_config(const uint8_t mac[CLIENT_MAC_LEN],
|
||||
uint32_t client_id,
|
||||
const bma456_tap_config_t *cfg) {
|
||||
if (mac == NULL || cfg == NULL || !esp_now_core_is_master()) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
char mac_str[18];
|
||||
esp_now_core_mac_to_str(mac, mac_str, sizeof(mac_str));
|
||||
esp_err_t err = send_tap_config(mac, client_id, cfg);
|
||||
if (err == ESP_OK) {
|
||||
ESP_LOGI(TAG,
|
||||
"unicast SET_TAP_CONFIG to %s: sens=%u client_id=%lu "
|
||||
"(S=%d D=%d T=%d)",
|
||||
mac_str, (unsigned)cfg->tap_sens_thres, (unsigned long)client_id,
|
||||
cfg->enable_single, cfg->enable_double, cfg->enable_triple);
|
||||
} else {
|
||||
ESP_LOGW(TAG, "unicast SET_TAP_CONFIG to %s failed: %s", mac_str,
|
||||
esp_err_to_name(err));
|
||||
}
|
||||
return err;
|
||||
}
|
||||
|
||||
esp_err_t esp_now_comm_send_battery_uv(const uint8_t mac[CLIENT_MAC_LEN],
|
||||
uint32_t client_id, bool enabled) {
|
||||
if (mac == NULL || !esp_now_core_is_master()) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
char mac_str[18];
|
||||
esp_now_core_mac_to_str(mac, mac_str, sizeof(mac_str));
|
||||
esp_err_t err = send_battery_uv(mac, client_id, enabled);
|
||||
if (err == ESP_OK) {
|
||||
ESP_LOGI(TAG, "unicast SET_BATTERY_UV to %s: enabled=%d client_id=%lu",
|
||||
mac_str, enabled, (unsigned long)client_id);
|
||||
} else {
|
||||
ESP_LOGW(TAG, "unicast SET_BATTERY_UV to %s failed: %s", mac_str,
|
||||
esp_err_to_name(err));
|
||||
}
|
||||
return err;
|
||||
}
|
||||
|
||||
static void handle_accel_sample(const uint8_t mac[CLIENT_MAC_LEN],
|
||||
const alox_EspNowAccelSample *sample) {
|
||||
if (sample == NULL) {
|
||||
@@ -291,11 +573,13 @@ static void handle_accel_sample(const uint8_t mac[CLIENT_MAC_LEN],
|
||||
esp_err_t err = client_registry_update_accel(
|
||||
mac, sample->slave_id, (int16_t)sample->x, (int16_t)sample->y,
|
||||
(int16_t)sample->z);
|
||||
if (err == ESP_ERR_NOT_FOUND) {
|
||||
if (err == ESP_ERR_NOT_FOUND || err == ESP_ERR_INVALID_STATE) {
|
||||
// Unknown MAC, or stream already off (in-flight samples after disable).
|
||||
return;
|
||||
}
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGW(TAG, "accel sample id mismatch from %02x:…:%02x", mac[0], mac[5]);
|
||||
ESP_LOGW(TAG, "accel sample id mismatch from %02x:…:%02x (slave_id=%lu)",
|
||||
mac[0], mac[5], (unsigned long)sample->slave_id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -349,25 +633,53 @@ static void handle_client_presence(const alox_EspNowSlavePresence *presence,
|
||||
|
||||
esp_now_core_ensure_peer(mac);
|
||||
|
||||
uint32_t assigned_id = 0;
|
||||
esp_err_t err =
|
||||
client_registry_assign_id(mac, presence->slave_id, &assigned_id);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGW(TAG, "id assign failed: %s", esp_err_to_name(err));
|
||||
return;
|
||||
}
|
||||
|
||||
bool is_new = false;
|
||||
bool reactivated = false;
|
||||
esp_err_t err = client_registry_heartbeat(
|
||||
mac, presence->slave_id, presence->version, presence->used, &is_new,
|
||||
&reactivated);
|
||||
err = client_registry_heartbeat(mac, assigned_id, presence->version,
|
||||
presence->used, &is_new, &reactivated);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGW(TAG, "client registry full");
|
||||
return;
|
||||
}
|
||||
|
||||
/*
|
||||
* Send ASSIGN when new/rejoined, or when slave has not echoed the id yet.
|
||||
* Rate-limit mismatch resends so failed assigns do not flood ESP-NOW buffers
|
||||
* (ESP_ERR_ESPNOW_NO_MEM) and block other unicast traffic.
|
||||
*/
|
||||
const bool needs_assign =
|
||||
is_new || reactivated || presence->slave_id != assigned_id;
|
||||
if (needs_assign) {
|
||||
const client_info_t *client = client_registry_find_by_mac(mac);
|
||||
const bool due =
|
||||
is_new || reactivated || client == NULL ||
|
||||
client->last_assign_sent_at == 0 ||
|
||||
client_registry_ms_since(client->last_assign_sent_at) >=
|
||||
ESPNOW_ASSIGN_RESEND_MS;
|
||||
if (due) {
|
||||
if (esp_now_comm_send_assign_id(mac, assigned_id) == ESP_OK) {
|
||||
client_registry_mark_assign_sent(mac);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
char mac_str[18];
|
||||
esp_now_core_mac_to_str(mac, mac_str, sizeof(mac_str));
|
||||
if (is_new) {
|
||||
ESP_LOGI(TAG, "client registered id=%lu mac=%s ver=%lu",
|
||||
(unsigned long)presence->slave_id, mac_str,
|
||||
(unsigned long)presence->version);
|
||||
ESP_LOGI(TAG, "client registered id=%lu (preferred=%lu) mac=%s ver=%lu",
|
||||
(unsigned long)assigned_id, (unsigned long)presence->slave_id,
|
||||
mac_str, (unsigned long)presence->version);
|
||||
} else if (reactivated) {
|
||||
ESP_LOGI(TAG, "client reconnected id=%lu mac=%s",
|
||||
(unsigned long)presence->slave_id, mac_str);
|
||||
(unsigned long)assigned_id, mac_str);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -415,6 +727,12 @@ void esp_now_master_on_recv(const esp_now_recv_info_t *info, const uint8_t *data
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.which_payload == alox_EspNowMessage_echo_pong_tag) {
|
||||
esp_now_core_ensure_peer(info->src_addr);
|
||||
echo_ping_on_pong(info->src_addr, &msg.payload.echo_pong);
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type == alox_EspNowMessageType_ESPNOW_BATTERY_REPORT &&
|
||||
msg.which_payload != alox_EspNowMessage_battery_report_tag) {
|
||||
ESP_LOGW(TAG, "BATTERY_REPORT type but which=%u", msg.which_payload);
|
||||
@@ -439,7 +757,11 @@ static void discover_task(void *param) {
|
||||
(unsigned)esp_now_core_wifi_channel());
|
||||
|
||||
while (1) {
|
||||
esp_now_core_send(ESPNOW_BCAST, &msg);
|
||||
msg.payload.discover.master_ota_pending = ota_session_busy();
|
||||
msg.payload.discover.master_time_us = (uint64_t)esp_timer_get_time();
|
||||
if (!ota_espnow_distribution_active()) {
|
||||
esp_now_core_send_fast(ESPNOW_BCAST, &msg);
|
||||
}
|
||||
vTaskDelay(pdMS_TO_TICKS(ESPNOW_DISCOVER_INTERVAL_MS));
|
||||
}
|
||||
}
|
||||
@@ -458,7 +780,11 @@ static void monitor_task(void *param) {
|
||||
|
||||
while (1) {
|
||||
vTaskDelay(pdMS_TO_TICKS(ESPNOW_HEARTBEAT_INTERVAL_MS));
|
||||
client_registry_check_timeouts(ESPNOW_CLIENT_TIMEOUT_MS);
|
||||
/* During ESP-NOW OTA dist the recv_cb drops heartbeats; do not wipe
|
||||
* targets from the registry while they are still being updated. */
|
||||
if (!ota_espnow_distribution_active()) {
|
||||
client_registry_check_timeouts(ESPNOW_CLIENT_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
uint32_t t = esp_now_core_now_ms();
|
||||
if (t - last_local_battery_ms >= ESPNOW_BATTERY_INTERVAL_MS) {
|
||||
|
||||
+453
-45
@@ -11,8 +11,10 @@
|
||||
#include "pod_reboot.h"
|
||||
#include "pod_settings.h"
|
||||
#include "esp_log.h"
|
||||
#include "esp_timer.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/idf_additions.h"
|
||||
#include "freertos/semphr.h"
|
||||
#include <string.h>
|
||||
|
||||
#ifndef POWERPOD_FW_VERSION
|
||||
@@ -21,19 +23,45 @@
|
||||
|
||||
#define ESPNOW_HEARTBEAT_INTERVAL_MS 1000
|
||||
#define SLAVE_MASTER_LOST_MS (ESPNOW_HEARTBEAT_INTERVAL_MS * 5)
|
||||
#define ESPNOW_ACCEL_INTERVAL_MS 16
|
||||
/** While master or slave OTA is in progress (discover may be sparse). */
|
||||
#define SLAVE_MASTER_OTA_GRACE_MS 300000u
|
||||
/**
|
||||
* Accel stream poll cadence (10 ms frame). TX only when sample exceeds the
|
||||
* BMA456 deadzone vs last sent sample. TX path is selectable (INPUT_SEND_MODE).
|
||||
* With master Discover.master_time_us, slaves phase into 1 ms slots:
|
||||
* offset_ms = (assigned_id - 1) % 8. Slot wait uses esp_timer (FreeRTOS tick
|
||||
* is 10 ms — too coarse for intra-frame offsets).
|
||||
*/
|
||||
#define ESPNOW_ACCEL_INTERVAL_MS 10
|
||||
#define ESPNOW_ACCEL_INTERVAL_US (ESPNOW_ACCEL_INTERVAL_MS * 1000ull)
|
||||
#define ESPNOW_ACCEL_SLOT_COUNT 8u
|
||||
#define ESPNOW_ACCEL_SLOT_US 1000ull
|
||||
#define ESPNOW_BATTERY_INTERVAL_MS 30000
|
||||
#define SLAVE_BATTERY_AFTER_JOIN_MS 150
|
||||
|
||||
static const char *TAG = "[ESPNOW_S]";
|
||||
|
||||
static bool s_joined;
|
||||
static bool s_master_ota_grace;
|
||||
static bool s_accel_stream_enabled;
|
||||
static bool s_tap_notify_single;
|
||||
static bool s_tap_notify_double;
|
||||
static bool s_tap_notify_triple;
|
||||
/** Accel/tap TX path; default matches historical best-effort gaming input. */
|
||||
static uint32_t s_input_send_mode =
|
||||
(uint32_t)alox_InputSendMode_INPUT_SEND_BEST_EFFORT;
|
||||
static uint8_t s_master_mac[ESP_NOW_ETH_ALEN];
|
||||
static uint32_t s_last_discover_ms;
|
||||
/** Logical id assigned by current/last master (0 = unbound). */
|
||||
static uint32_t s_assigned_id;
|
||||
/** Master MAC from NVS binding (may differ from current s_master_mac). */
|
||||
static uint8_t s_bound_master_mac[ESP_NOW_ETH_ALEN];
|
||||
static bool s_has_binding;
|
||||
/** estimated_master_us = esp_timer_get_time() + s_master_offset_us */
|
||||
static int64_t s_master_offset_us;
|
||||
static bool s_master_time_synced;
|
||||
static SemaphoreHandle_t s_accel_slot_sem;
|
||||
static esp_timer_handle_t s_accel_slot_timer;
|
||||
|
||||
typedef enum {
|
||||
SLAVE_TX_SLAVE_INFO = 1,
|
||||
@@ -42,15 +70,94 @@ typedef enum {
|
||||
|
||||
static QueueHandle_t s_tx_queue;
|
||||
|
||||
static void queue_tx(slave_tx_op_t op);
|
||||
static uint32_t accel_slot_offset_us(void);
|
||||
|
||||
static bool from_joined_master(const uint8_t *master_mac) {
|
||||
return s_joined && esp_now_core_mac_equal(master_mac, s_master_mac);
|
||||
}
|
||||
|
||||
static uint32_t my_client_id(void) { return s_assigned_id; }
|
||||
|
||||
/** True when command targets this slave (0 = broadcast / any). */
|
||||
static bool client_id_matches(uint32_t client_id) {
|
||||
if (client_id == 0) {
|
||||
return true;
|
||||
}
|
||||
return s_assigned_id != 0 && client_id == s_assigned_id;
|
||||
}
|
||||
|
||||
static uint32_t preferred_id_for_master(void) {
|
||||
if (!s_has_binding || s_assigned_id == 0) {
|
||||
return 0;
|
||||
}
|
||||
if (!s_joined || !esp_now_core_mac_equal(s_master_mac, s_bound_master_mac)) {
|
||||
return 0;
|
||||
}
|
||||
return s_assigned_id;
|
||||
}
|
||||
|
||||
static void load_id_binding(void) {
|
||||
uint8_t mac[ESP_NOW_ETH_ALEN];
|
||||
uint32_t id = 0;
|
||||
s_has_binding = pod_settings_load_id_binding(mac, &id);
|
||||
if (s_has_binding) {
|
||||
memcpy(s_bound_master_mac, mac, ESP_NOW_ETH_ALEN);
|
||||
s_assigned_id = id;
|
||||
ESP_LOGI(TAG, "loaded id binding id=%lu master=%02x:…:%02x",
|
||||
(unsigned long)id, mac[0], mac[5]);
|
||||
} else {
|
||||
memset(s_bound_master_mac, 0, sizeof(s_bound_master_mac));
|
||||
s_assigned_id = 0;
|
||||
}
|
||||
}
|
||||
|
||||
static void apply_assigned_id(uint32_t assigned_id) {
|
||||
if (assigned_id < POD_CLIENT_ID_MIN || assigned_id > POD_CLIENT_ID_MAX) {
|
||||
ESP_LOGW(TAG, "ignore invalid assigned_id=%lu", (unsigned long)assigned_id);
|
||||
return;
|
||||
}
|
||||
const bool same =
|
||||
s_has_binding && s_assigned_id == assigned_id &&
|
||||
esp_now_core_mac_equal(s_bound_master_mac, s_master_mac);
|
||||
s_assigned_id = assigned_id;
|
||||
memcpy(s_bound_master_mac, s_master_mac, ESP_NOW_ETH_ALEN);
|
||||
s_has_binding = true;
|
||||
if (same) {
|
||||
return;
|
||||
}
|
||||
if (pod_settings_save_id_binding(s_master_mac, assigned_id) != ESP_OK) {
|
||||
ESP_LOGW(TAG, "assigned id=%lu but NVS save failed",
|
||||
(unsigned long)assigned_id);
|
||||
} else {
|
||||
ESP_LOGI(TAG, "assigned id=%lu (persisted, accel slot %lu us)",
|
||||
(unsigned long)assigned_id,
|
||||
(unsigned long)accel_slot_offset_us());
|
||||
}
|
||||
queue_tx(SLAVE_TX_BATTERY);
|
||||
}
|
||||
|
||||
static void clear_id_binding_local(void) {
|
||||
s_assigned_id = 0;
|
||||
s_has_binding = false;
|
||||
memset(s_bound_master_mac, 0, sizeof(s_bound_master_mac));
|
||||
(void)pod_settings_clear_id_binding();
|
||||
ESP_LOGI(TAG, "cleared local id binding");
|
||||
}
|
||||
|
||||
esp_err_t esp_now_comm_clear_local_id_binding(void) {
|
||||
if (esp_now_core_is_master()) {
|
||||
return ESP_OK;
|
||||
}
|
||||
clear_id_binding_local();
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
static void fill_presence(alox_EspNowSlavePresence *presence) {
|
||||
const uint8_t *own = esp_now_core_own_mac();
|
||||
presence->network = esp_now_core_network();
|
||||
presence->version = POWERPOD_FW_VERSION;
|
||||
presence->slave_id = own[5];
|
||||
presence->slave_id = preferred_id_for_master();
|
||||
presence->available = true;
|
||||
presence->used = false;
|
||||
esp_now_proto_setup_presence_encode(presence, own);
|
||||
@@ -72,6 +179,21 @@ static void send_presence(const uint8_t *dest_mac, alox_EspNowMessageType type)
|
||||
esp_now_core_send(dest_mac, &msg);
|
||||
}
|
||||
|
||||
static esp_err_t send_input_message(const uint8_t *dest_mac,
|
||||
const alox_EspNowMessage *msg) {
|
||||
switch (s_input_send_mode) {
|
||||
case (uint32_t)alox_InputSendMode_INPUT_SEND_FAST:
|
||||
return esp_now_core_send_fast(dest_mac, msg);
|
||||
case (uint32_t)alox_InputSendMode_INPUT_SEND_WAIT:
|
||||
return esp_now_core_send_wait(dest_mac, msg);
|
||||
case (uint32_t)alox_InputSendMode_INPUT_SEND_RELIABLE:
|
||||
return esp_now_core_send_reliable(dest_mac, msg);
|
||||
case (uint32_t)alox_InputSendMode_INPUT_SEND_BEST_EFFORT:
|
||||
default:
|
||||
return esp_now_core_send_best_effort(dest_mac, msg);
|
||||
}
|
||||
}
|
||||
|
||||
static esp_err_t send_accel_sample(const uint8_t *dest_mac, uint32_t slave_id,
|
||||
int16_t x, int16_t y, int16_t z) {
|
||||
alox_EspNowMessage msg = alox_EspNowMessage_init_zero;
|
||||
@@ -81,7 +203,7 @@ static esp_err_t send_accel_sample(const uint8_t *dest_mac, uint32_t slave_id,
|
||||
msg.payload.accel_sample.x = x;
|
||||
msg.payload.accel_sample.y = y;
|
||||
msg.payload.accel_sample.z = z;
|
||||
return esp_now_core_send(dest_mac, &msg);
|
||||
return send_input_message(dest_mac, &msg);
|
||||
}
|
||||
|
||||
static esp_err_t send_tap_event(const uint8_t *dest_mac, uint32_t slave_id,
|
||||
@@ -91,7 +213,7 @@ static esp_err_t send_tap_event(const uint8_t *dest_mac, uint32_t slave_id,
|
||||
msg.which_payload = alox_EspNowMessage_tap_event_tag;
|
||||
msg.payload.tap_event.slave_id = slave_id;
|
||||
msg.payload.tap_event.kind = kind;
|
||||
return esp_now_core_send(dest_mac, &msg);
|
||||
return send_input_message(dest_mac, &msg);
|
||||
}
|
||||
|
||||
static esp_err_t send_battery_report(const uint8_t *dest_mac,
|
||||
@@ -106,9 +228,21 @@ static esp_err_t send_battery_report(const uint8_t *dest_mac,
|
||||
return esp_now_core_send(dest_mac, &msg);
|
||||
}
|
||||
|
||||
static uint32_t slave_master_lost_ms(void) {
|
||||
if (ota_uart_is_active() || s_master_ota_grace) {
|
||||
return SLAVE_MASTER_OTA_GRACE_MS;
|
||||
}
|
||||
return SLAVE_MASTER_LOST_MS;
|
||||
}
|
||||
|
||||
static void touch_master_presence(uint32_t now) { s_last_discover_ms = now; }
|
||||
|
||||
static void reset_join(void) {
|
||||
s_joined = false;
|
||||
s_master_ota_grace = false;
|
||||
s_accel_stream_enabled = false;
|
||||
s_master_time_synced = false;
|
||||
s_master_offset_us = 0;
|
||||
memset(s_master_mac, 0, sizeof(s_master_mac));
|
||||
s_last_discover_ms = 0;
|
||||
if (s_tx_queue != NULL) {
|
||||
@@ -116,6 +250,66 @@ static void reset_join(void) {
|
||||
}
|
||||
}
|
||||
|
||||
static void apply_master_time_sync(uint64_t master_time_us) {
|
||||
if (master_time_us == 0) {
|
||||
return;
|
||||
}
|
||||
s_master_offset_us = (int64_t)master_time_us - esp_timer_get_time();
|
||||
s_master_time_synced = true;
|
||||
}
|
||||
|
||||
static uint32_t accel_slot_offset_us(void) {
|
||||
if (s_assigned_id == 0) {
|
||||
return 0;
|
||||
}
|
||||
return (uint32_t)(((s_assigned_id - 1u) % ESPNOW_ACCEL_SLOT_COUNT) *
|
||||
ESPNOW_ACCEL_SLOT_US);
|
||||
}
|
||||
|
||||
static void accel_slot_timer_cb(void *arg) {
|
||||
(void)arg;
|
||||
if (s_accel_slot_sem != NULL) {
|
||||
xSemaphoreGive(s_accel_slot_sem);
|
||||
}
|
||||
}
|
||||
|
||||
/** Sleep until this slave's 1 ms slot in the shared 10 ms master frame. */
|
||||
static void wait_next_accel_slot(void) {
|
||||
if (!s_master_time_synced || s_accel_slot_sem == NULL ||
|
||||
s_accel_slot_timer == NULL) {
|
||||
const TickType_t period_ticks = pdMS_TO_TICKS(ESPNOW_ACCEL_INTERVAL_MS);
|
||||
vTaskDelay(period_ticks > 0 ? period_ticks : 1);
|
||||
return;
|
||||
}
|
||||
|
||||
const int64_t period_us = (int64_t)ESPNOW_ACCEL_INTERVAL_US;
|
||||
const int64_t slot_us = (int64_t)accel_slot_offset_us();
|
||||
const int64_t master_now = esp_timer_get_time() + s_master_offset_us;
|
||||
int64_t phase = master_now % period_us;
|
||||
if (phase < 0) {
|
||||
phase += period_us;
|
||||
}
|
||||
int64_t wait_us = slot_us - phase;
|
||||
if (wait_us < 200) {
|
||||
/* Too close / already past — take the next frame. */
|
||||
wait_us += period_us;
|
||||
}
|
||||
|
||||
(void)xSemaphoreTake(s_accel_slot_sem, 0);
|
||||
(void)esp_timer_stop(s_accel_slot_timer);
|
||||
esp_err_t err = esp_timer_start_once(s_accel_slot_timer, (uint64_t)wait_us);
|
||||
if (err != ESP_OK) {
|
||||
vTaskDelay(pdMS_TO_TICKS(ESPNOW_ACCEL_INTERVAL_MS) > 0
|
||||
? pdMS_TO_TICKS(ESPNOW_ACCEL_INTERVAL_MS)
|
||||
: 1);
|
||||
return;
|
||||
}
|
||||
if (xSemaphoreTake(s_accel_slot_sem,
|
||||
pdMS_TO_TICKS(ESPNOW_ACCEL_INTERVAL_MS * 3)) != pdTRUE) {
|
||||
(void)esp_timer_stop(s_accel_slot_timer);
|
||||
}
|
||||
}
|
||||
|
||||
static void queue_tx(slave_tx_op_t op) {
|
||||
if (s_tx_queue == NULL) {
|
||||
return;
|
||||
@@ -126,7 +320,7 @@ static void queue_tx(slave_tx_op_t op) {
|
||||
}
|
||||
|
||||
static void send_battery_to_master(void) {
|
||||
if (!s_joined) {
|
||||
if (!s_joined || s_assigned_id == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -134,7 +328,7 @@ static void send_battery_to_master(void) {
|
||||
board_input_read_lipo(&reading);
|
||||
|
||||
alox_EspNowBatteryReport report = alox_EspNowBatteryReport_init_zero;
|
||||
report.client_id = esp_now_core_own_mac()[5];
|
||||
report.client_id = s_assigned_id;
|
||||
report.lipo1_valid = reading.lipo1_valid;
|
||||
report.lipo2_valid = reading.lipo2_valid;
|
||||
report.lipo1_mv = reading.lipo1_mv;
|
||||
@@ -216,12 +410,39 @@ static void handle_unicast_test(const uint8_t *master_mac,
|
||||
(unsigned long)test->seq, (int)s_joined);
|
||||
}
|
||||
|
||||
static void handle_echo_ping(const uint8_t *master_mac,
|
||||
const alox_EspNowEchoPing *ping) {
|
||||
if (ping == NULL || !s_joined ||
|
||||
!esp_now_core_mac_equal(master_mac, s_master_mac)) {
|
||||
return;
|
||||
}
|
||||
|
||||
char mac_str[18];
|
||||
esp_now_core_mac_to_str(master_mac, mac_str, sizeof(mac_str));
|
||||
ESP_LOGI(TAG, "ESP-NOW PING recv from %s host_ts=%llu master_time_us=%llu",
|
||||
mac_str, (unsigned long long)ping->host_timestamp_us,
|
||||
(unsigned long long)ping->master_time_us);
|
||||
|
||||
alox_EspNowMessage msg = alox_EspNowMessage_init_zero;
|
||||
msg.type = alox_EspNowMessageType_ESPNOW_ECHO_PONG;
|
||||
msg.which_payload = alox_EspNowMessage_echo_pong_tag;
|
||||
msg.payload.echo_pong.host_timestamp_us = ping->host_timestamp_us;
|
||||
msg.payload.echo_pong.master_time_us = ping->master_time_us;
|
||||
|
||||
esp_err_t err = esp_now_core_send_fast(s_master_mac, &msg);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGW(TAG, "ECHO PONG send failed: %s", esp_err_to_name(err));
|
||||
return;
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "ESP-NOW PONG send to %s host_ts=%llu master_time_us=%llu",
|
||||
mac_str, (unsigned long long)ping->host_timestamp_us,
|
||||
(unsigned long long)ping->master_time_us);
|
||||
}
|
||||
|
||||
static void handle_restart(const uint8_t *master_mac,
|
||||
const alox_EspNowRestart *req) {
|
||||
const uint8_t *own = esp_now_core_own_mac();
|
||||
uint32_t my_id = own[5];
|
||||
|
||||
if (req->client_id != 0 && req->client_id != my_id) {
|
||||
if (!client_id_matches(req->client_id)) {
|
||||
return;
|
||||
}
|
||||
if (s_joined && !esp_now_core_mac_equal(master_mac, s_master_mac)) {
|
||||
@@ -230,14 +451,14 @@ static void handle_restart(const uint8_t *master_mac,
|
||||
|
||||
char mac_str[18];
|
||||
esp_now_core_mac_to_str(master_mac, mac_str, sizeof(mac_str));
|
||||
ESP_LOGI(TAG, "RESTART from master %s (id=%lu)", mac_str, (unsigned long)my_id);
|
||||
ESP_LOGI(TAG, "RESTART from master %s (id=%lu)", mac_str,
|
||||
(unsigned long)my_client_id());
|
||||
pod_schedule_restart();
|
||||
}
|
||||
|
||||
static void handle_battery_query(const uint8_t *master_mac,
|
||||
const alox_EspNowBatteryQuery *query) {
|
||||
uint32_t my_id = esp_now_core_own_mac()[5];
|
||||
if (query->client_id != 0 && query->client_id != my_id) {
|
||||
if (!client_id_matches(query->client_id)) {
|
||||
return;
|
||||
}
|
||||
if (s_joined && !esp_now_core_mac_equal(master_mac, s_master_mac)) {
|
||||
@@ -248,8 +469,7 @@ static void handle_battery_query(const uint8_t *master_mac,
|
||||
|
||||
static void handle_led_ring(const uint8_t *master_mac,
|
||||
const alox_EspNowLedRing *msg) {
|
||||
uint32_t my_id = esp_now_core_own_mac()[5];
|
||||
if (msg->client_id != 0 && msg->client_id != my_id) {
|
||||
if (!client_id_matches(msg->client_id)) {
|
||||
return;
|
||||
}
|
||||
if (s_joined && !esp_now_core_mac_equal(master_mac, s_master_mac)) {
|
||||
@@ -270,13 +490,12 @@ static void handle_led_ring(const uint8_t *master_mac,
|
||||
char mac_str[18];
|
||||
esp_now_core_mac_to_str(master_mac, mac_str, sizeof(mac_str));
|
||||
ESP_LOGI(TAG, "LED_RING mode %lu from master %s (id=%lu)",
|
||||
(unsigned long)req.mode, mac_str, (unsigned long)my_id);
|
||||
(unsigned long)req.mode, mac_str, (unsigned long)my_client_id());
|
||||
cmd_led_ring_apply(&req);
|
||||
}
|
||||
|
||||
static void handle_find_me(const uint8_t *master_mac, const alox_EspNowFindMe *req) {
|
||||
uint32_t my_id = esp_now_core_own_mac()[5];
|
||||
if (req->client_id != 0 && req->client_id != my_id) {
|
||||
if (!client_id_matches(req->client_id)) {
|
||||
return;
|
||||
}
|
||||
if (s_joined && !esp_now_core_mac_equal(master_mac, s_master_mac)) {
|
||||
@@ -284,30 +503,69 @@ static void handle_find_me(const uint8_t *master_mac, const alox_EspNowFindMe *r
|
||||
}
|
||||
char mac_str[18];
|
||||
esp_now_core_mac_to_str(master_mac, mac_str, sizeof(mac_str));
|
||||
ESP_LOGI(TAG, "FIND_ME from master %s (id=%lu)", mac_str, (unsigned long)my_id);
|
||||
ESP_LOGI(TAG, "FIND_ME from master %s (id=%lu)", mac_str,
|
||||
(unsigned long)my_client_id());
|
||||
led_ring_find_me();
|
||||
}
|
||||
|
||||
static void handle_accel_stream(const uint8_t *master_mac,
|
||||
const alox_EspNowAccelStream *cfg) {
|
||||
uint32_t my_id = esp_now_core_own_mac()[5];
|
||||
if (cfg->client_id != 0 && cfg->client_id != my_id) {
|
||||
if (!client_id_matches(cfg->client_id)) {
|
||||
return;
|
||||
}
|
||||
if (s_joined && !esp_now_core_mac_equal(master_mac, s_master_mac)) {
|
||||
return;
|
||||
}
|
||||
s_accel_stream_enabled = cfg->enable;
|
||||
if (cfg->enable) {
|
||||
bma456_accel_stream_reset_baseline();
|
||||
}
|
||||
char mac_str[18];
|
||||
esp_now_core_mac_to_str(master_mac, mac_str, sizeof(mac_str));
|
||||
ESP_LOGI(TAG, "accel stream %s from master %s (id=%lu)",
|
||||
cfg->enable ? "on" : "off", mac_str, (unsigned long)my_id);
|
||||
cfg->enable ? "on" : "off", mac_str, (unsigned long)my_client_id());
|
||||
}
|
||||
|
||||
static const char *input_send_mode_name(uint32_t mode) {
|
||||
switch (mode) {
|
||||
case (uint32_t)alox_InputSendMode_INPUT_SEND_BEST_EFFORT:
|
||||
return "best_effort";
|
||||
case (uint32_t)alox_InputSendMode_INPUT_SEND_FAST:
|
||||
return "fast";
|
||||
case (uint32_t)alox_InputSendMode_INPUT_SEND_WAIT:
|
||||
return "wait";
|
||||
case (uint32_t)alox_InputSendMode_INPUT_SEND_RELIABLE:
|
||||
return "reliable";
|
||||
default:
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
static void handle_input_send_mode(const uint8_t *master_mac,
|
||||
const alox_EspNowInputSendMode *cfg) {
|
||||
if (!client_id_matches(cfg->client_id)) {
|
||||
return;
|
||||
}
|
||||
if (s_joined && !esp_now_core_mac_equal(master_mac, s_master_mac)) {
|
||||
return;
|
||||
}
|
||||
if (cfg->mode > (uint32_t)alox_InputSendMode_INPUT_SEND_RELIABLE) {
|
||||
ESP_LOGW(TAG, "input send mode %lu invalid (id=%lu)",
|
||||
(unsigned long)cfg->mode, (unsigned long)my_client_id());
|
||||
return;
|
||||
}
|
||||
s_input_send_mode = cfg->mode;
|
||||
char mac_str[18];
|
||||
esp_now_core_mac_to_str(master_mac, mac_str, sizeof(mac_str));
|
||||
ESP_LOGI(TAG, "input send mode %s (%lu) from master %s (id=%lu)",
|
||||
input_send_mode_name(s_input_send_mode),
|
||||
(unsigned long)s_input_send_mode, mac_str,
|
||||
(unsigned long)my_client_id());
|
||||
}
|
||||
|
||||
static void handle_tap_notify(const uint8_t *master_mac,
|
||||
const alox_EspNowTapNotify *cfg) {
|
||||
uint32_t my_id = esp_now_core_own_mac()[5];
|
||||
if (cfg->client_id != 0 && cfg->client_id != my_id) {
|
||||
if (!client_id_matches(cfg->client_id)) {
|
||||
return;
|
||||
}
|
||||
if (s_joined && !esp_now_core_mac_equal(master_mac, s_master_mac)) {
|
||||
@@ -321,13 +579,12 @@ static void handle_tap_notify(const uint8_t *master_mac,
|
||||
ESP_LOGI(TAG,
|
||||
"tap notify single=%d double=%d triple=%d from master %s (id=%lu)",
|
||||
cfg->single, cfg->double_tap, cfg->triple, mac_str,
|
||||
(unsigned long)my_id);
|
||||
(unsigned long)my_client_id());
|
||||
}
|
||||
|
||||
static void handle_accel_deadzone(const uint8_t *master_mac,
|
||||
const alox_EspNowAccelDeadzone *cfg) {
|
||||
uint32_t my_id = esp_now_core_own_mac()[5];
|
||||
if (cfg->client_id != 0 && cfg->client_id != my_id) {
|
||||
if (!client_id_matches(cfg->client_id)) {
|
||||
return;
|
||||
}
|
||||
if (s_joined && !esp_now_core_mac_equal(master_mac, s_master_mac)) {
|
||||
@@ -337,7 +594,7 @@ static void handle_accel_deadzone(const uint8_t *master_mac,
|
||||
esp_now_core_mac_to_str(master_mac, mac_str, sizeof(mac_str));
|
||||
ESP_LOGI(TAG,
|
||||
"accel deadzone from master %s: %lu LSB id=%lu (sensor %s)", mac_str,
|
||||
(unsigned long)cfg->deadzone, (unsigned long)my_id,
|
||||
(unsigned long)cfg->deadzone, (unsigned long)my_client_id(),
|
||||
bma456_is_ready() ? "ok" : "not installed");
|
||||
bma456_set_accel_deadzone(cfg->deadzone);
|
||||
if (pod_settings_save_accel_deadzone(cfg->deadzone) != ESP_OK) {
|
||||
@@ -346,6 +603,86 @@ static void handle_accel_deadzone(const uint8_t *master_mac,
|
||||
}
|
||||
}
|
||||
|
||||
static void handle_tap_config(const uint8_t *master_mac,
|
||||
const alox_EspNowTapConfig *msg) {
|
||||
if (!client_id_matches(msg->client_id)) {
|
||||
return;
|
||||
}
|
||||
if (s_joined && !esp_now_core_mac_equal(master_mac, s_master_mac)) {
|
||||
return;
|
||||
}
|
||||
|
||||
bma456_tap_config_t cfg = {
|
||||
.tap_sens_thres = (uint16_t)msg->tap_sens_thres,
|
||||
.max_gest_dur = (uint16_t)msg->max_gest_dur,
|
||||
.tap_shock_dur = (uint16_t)msg->tap_shock_dur,
|
||||
.quite_time_after_gest = (uint16_t)msg->quite_time_after_gest,
|
||||
.wait_for_timeout = (uint16_t)msg->wait_for_timeout,
|
||||
.axis_sel = (uint16_t)msg->axis_sel,
|
||||
.enable_single = msg->enable_single,
|
||||
.enable_double = msg->enable_double,
|
||||
.enable_triple = msg->enable_triple,
|
||||
};
|
||||
|
||||
char mac_str[18];
|
||||
esp_now_core_mac_to_str(master_mac, mac_str, sizeof(mac_str));
|
||||
ESP_LOGI(TAG,
|
||||
"tap config from master %s: sens=%u id=%lu (sensor %s)", mac_str,
|
||||
(unsigned)cfg.tap_sens_thres, (unsigned long)my_client_id(),
|
||||
bma456_is_ready() ? "ok" : "not installed");
|
||||
|
||||
if (!bma456_tap_config_valid(&cfg)) {
|
||||
ESP_LOGW(TAG, "invalid tap config from master; ignored");
|
||||
return;
|
||||
}
|
||||
if (bma456_set_tap_config(&cfg) != ESP_OK) {
|
||||
ESP_LOGW(TAG, "tap config apply failed");
|
||||
return;
|
||||
}
|
||||
if (pod_settings_save_tap_config(&cfg) != ESP_OK) {
|
||||
ESP_LOGW(TAG, "tap config applied but not saved to NVS");
|
||||
}
|
||||
}
|
||||
|
||||
static void handle_battery_uv(const uint8_t *master_mac,
|
||||
const alox_EspNowBatteryUv *msg) {
|
||||
if (!client_id_matches(msg->client_id)) {
|
||||
return;
|
||||
}
|
||||
if (s_joined && !esp_now_core_mac_equal(master_mac, s_master_mac)) {
|
||||
return;
|
||||
}
|
||||
|
||||
char mac_str[18];
|
||||
esp_now_core_mac_to_str(master_mac, mac_str, sizeof(mac_str));
|
||||
ESP_LOGI(TAG, "UV protection %s from master %s (id=%lu)",
|
||||
msg->enabled ? "enabled" : "disabled", mac_str,
|
||||
(unsigned long)my_client_id());
|
||||
|
||||
if (pod_settings_set_uv_enabled(msg->enabled) != ESP_OK) {
|
||||
ESP_LOGW(TAG, "UV enable save failed");
|
||||
return;
|
||||
}
|
||||
if (!msg->enabled) {
|
||||
(void)pod_settings_clear_uv_latched();
|
||||
}
|
||||
}
|
||||
|
||||
static void handle_assign_id(const uint8_t *master_mac,
|
||||
const alox_EspNowAssignId *msg) {
|
||||
if (!from_joined_master(master_mac)) {
|
||||
return;
|
||||
}
|
||||
apply_assigned_id(msg->assigned_id);
|
||||
}
|
||||
|
||||
static void handle_clear_id_binding(const uint8_t *master_mac) {
|
||||
if (!from_joined_master(master_mac)) {
|
||||
return;
|
||||
}
|
||||
clear_id_binding_local();
|
||||
}
|
||||
|
||||
static void handle_discover(const uint8_t *sender_mac,
|
||||
const alox_EspNowDiscover *discover) {
|
||||
if (discover->network != esp_now_core_network()) {
|
||||
@@ -358,8 +695,14 @@ static void handle_discover(const uint8_t *sender_mac,
|
||||
if (!esp_now_core_mac_equal(sender_mac, s_master_mac)) {
|
||||
return;
|
||||
}
|
||||
if ((now - s_last_discover_ms) <= SLAVE_MASTER_LOST_MS) {
|
||||
s_last_discover_ms = now;
|
||||
s_master_ota_grace = discover->master_ota_pending;
|
||||
apply_master_time_sync(discover->master_time_us);
|
||||
if ((now - s_last_discover_ms) <= slave_master_lost_ms()) {
|
||||
touch_master_presence(now);
|
||||
return;
|
||||
}
|
||||
if (ota_uart_is_active()) {
|
||||
touch_master_presence(now);
|
||||
return;
|
||||
}
|
||||
ESP_LOGW(TAG, "master lost, rejoining");
|
||||
@@ -368,40 +711,51 @@ static void handle_discover(const uint8_t *sender_mac,
|
||||
|
||||
memcpy(s_master_mac, sender_mac, ESP_NOW_ETH_ALEN);
|
||||
s_joined = true;
|
||||
s_last_discover_ms = now;
|
||||
s_master_ota_grace = discover->master_ota_pending;
|
||||
apply_master_time_sync(discover->master_time_us);
|
||||
touch_master_presence(now);
|
||||
esp_now_core_ensure_peer(sender_mac);
|
||||
|
||||
char mac_str[18];
|
||||
esp_now_core_mac_to_str(sender_mac, mac_str, sizeof(mac_str));
|
||||
ESP_LOGI(TAG, "joined network %u, master %s", (unsigned)discover->network,
|
||||
mac_str);
|
||||
ESP_LOGI(TAG, "joined network %u, master %s (preferred_id=%lu)",
|
||||
(unsigned)discover->network, mac_str,
|
||||
(unsigned long)preferred_id_for_master());
|
||||
|
||||
led_ring_connected_anim();
|
||||
queue_tx(SLAVE_TX_SLAVE_INFO);
|
||||
queue_tx(SLAVE_TX_BATTERY);
|
||||
/* Battery after ASSIGN_ID so reports carry the definitive client_id. */
|
||||
}
|
||||
|
||||
static void check_master_timeout(void) {
|
||||
if (!s_joined || s_last_discover_ms == 0) {
|
||||
return;
|
||||
}
|
||||
if (ota_uart_is_active()) {
|
||||
return;
|
||||
}
|
||||
uint32_t now = esp_now_core_now_ms();
|
||||
if ((now - s_last_discover_ms) > SLAVE_MASTER_LOST_MS) {
|
||||
ESP_LOGW(TAG, "no master discover for %u ms, reconnecting",
|
||||
(unsigned)(now - s_last_discover_ms));
|
||||
uint32_t limit = slave_master_lost_ms();
|
||||
if ((now - s_last_discover_ms) > limit) {
|
||||
ESP_LOGW(TAG, "no master discover for %u ms (limit %u), reconnecting",
|
||||
(unsigned)(now - s_last_discover_ms), (unsigned)limit);
|
||||
reset_join();
|
||||
}
|
||||
}
|
||||
|
||||
static void accel_stream_task(void *param) {
|
||||
(void)param;
|
||||
const uint8_t *own = esp_now_core_own_mac();
|
||||
|
||||
ESP_LOGI(TAG, "accel stream task (interval %u ms)",
|
||||
(unsigned)ESPNOW_ACCEL_INTERVAL_MS);
|
||||
ESP_LOGI(TAG,
|
||||
"accel stream task (poll %u ms, deadzone TX, %u×%llu us slots, "
|
||||
"mode selectable)",
|
||||
(unsigned)ESPNOW_ACCEL_INTERVAL_MS, (unsigned)ESPNOW_ACCEL_SLOT_COUNT,
|
||||
(unsigned long long)ESPNOW_ACCEL_SLOT_US);
|
||||
|
||||
while (1) {
|
||||
vTaskDelay(pdMS_TO_TICKS(ESPNOW_ACCEL_INTERVAL_MS));
|
||||
if (!s_joined || !s_accel_stream_enabled || !bma456_is_ready()) {
|
||||
wait_next_accel_slot();
|
||||
if (!s_joined || s_assigned_id == 0 || !s_accel_stream_enabled ||
|
||||
!bma456_is_ready()) {
|
||||
continue;
|
||||
}
|
||||
int16_t x = 0;
|
||||
@@ -410,13 +764,16 @@ static void accel_stream_task(void *param) {
|
||||
if (bma456_read_accel(&x, &y, &z) != ESP_OK) {
|
||||
continue;
|
||||
}
|
||||
(void)send_accel_sample(s_master_mac, own[5], x, y, z);
|
||||
if (!bma456_accel_stream_take_if_changed(x, y, z)) {
|
||||
continue;
|
||||
}
|
||||
(void)send_accel_sample(s_master_mac, s_assigned_id, x, y, z);
|
||||
}
|
||||
}
|
||||
|
||||
static void on_bma456_tap(bma456_tap_kind_t kind, void *ctx) {
|
||||
(void)ctx;
|
||||
if (!s_joined) {
|
||||
if (!s_joined || s_assigned_id == 0) {
|
||||
return;
|
||||
}
|
||||
bool enabled = false;
|
||||
@@ -436,7 +793,7 @@ static void on_bma456_tap(bma456_tap_kind_t kind, void *ctx) {
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
(void)send_tap_event(s_master_mac, esp_now_core_own_mac()[5], (uint32_t)kind);
|
||||
(void)send_tap_event(s_master_mac, s_assigned_id, (uint32_t)kind);
|
||||
}
|
||||
|
||||
static void heartbeat_task(void *param) {
|
||||
@@ -481,12 +838,16 @@ void esp_now_slave_on_recv(const esp_now_recv_info_t *info, const uint8_t *data,
|
||||
|
||||
if (ota_uart_is_active()) {
|
||||
switch (msg.which_payload) {
|
||||
case alox_EspNowMessage_discover_tag:
|
||||
handle_discover(info->src_addr, &msg.payload.discover);
|
||||
break;
|
||||
case alox_EspNowMessage_ota_start_tag:
|
||||
case alox_EspNowMessage_ota_payload_tag:
|
||||
case alox_EspNowMessage_ota_end_tag:
|
||||
if (!from_joined_master(info->src_addr)) {
|
||||
break;
|
||||
}
|
||||
touch_master_presence(esp_now_core_now_ms());
|
||||
if (msg.which_payload == alox_EspNowMessage_ota_start_tag) {
|
||||
ota_espnow_slave_on_start(info->src_addr, &msg.payload.ota_start);
|
||||
} else if (msg.which_payload == alox_EspNowMessage_ota_payload_tag) {
|
||||
@@ -510,11 +871,26 @@ void esp_now_slave_on_recv(const esp_now_recv_info_t *info, const uint8_t *data,
|
||||
handle_unicast_test(info->src_addr, &msg.payload.unicast_test);
|
||||
}
|
||||
break;
|
||||
case alox_EspNowMessage_echo_ping_tag:
|
||||
if (from_joined_master(info->src_addr)) {
|
||||
handle_echo_ping(info->src_addr, &msg.payload.echo_ping);
|
||||
}
|
||||
break;
|
||||
case alox_EspNowMessage_accel_deadzone_tag:
|
||||
if (from_joined_master(info->src_addr)) {
|
||||
handle_accel_deadzone(info->src_addr, &msg.payload.accel_deadzone);
|
||||
}
|
||||
break;
|
||||
case alox_EspNowMessage_tap_config_tag:
|
||||
if (from_joined_master(info->src_addr)) {
|
||||
handle_tap_config(info->src_addr, &msg.payload.tap_config);
|
||||
}
|
||||
break;
|
||||
case alox_EspNowMessage_battery_uv_tag:
|
||||
if (from_joined_master(info->src_addr)) {
|
||||
handle_battery_uv(info->src_addr, &msg.payload.battery_uv);
|
||||
}
|
||||
break;
|
||||
case alox_EspNowMessage_accel_stream_tag:
|
||||
if (from_joined_master(info->src_addr)) {
|
||||
handle_accel_stream(info->src_addr, &msg.payload.accel_stream);
|
||||
@@ -525,6 +901,11 @@ void esp_now_slave_on_recv(const esp_now_recv_info_t *info, const uint8_t *data,
|
||||
handle_tap_notify(info->src_addr, &msg.payload.tap_notify);
|
||||
}
|
||||
break;
|
||||
case alox_EspNowMessage_input_send_mode_tag:
|
||||
if (from_joined_master(info->src_addr)) {
|
||||
handle_input_send_mode(info->src_addr, &msg.payload.input_send_mode);
|
||||
}
|
||||
break;
|
||||
case alox_EspNowMessage_battery_query_tag:
|
||||
if (from_joined_master(info->src_addr)) {
|
||||
handle_battery_query(info->src_addr, &msg.payload.battery_query);
|
||||
@@ -545,6 +926,17 @@ void esp_now_slave_on_recv(const esp_now_recv_info_t *info, const uint8_t *data,
|
||||
handle_restart(info->src_addr, &msg.payload.restart);
|
||||
}
|
||||
break;
|
||||
case alox_EspNowMessage_assign_id_tag:
|
||||
handle_assign_id(info->src_addr, &msg.payload.assign_id);
|
||||
break;
|
||||
case alox_EspNowMessage_clear_id_binding_tag:
|
||||
handle_clear_id_binding(info->src_addr);
|
||||
break;
|
||||
case alox_EspNowMessage_ota_start_tag:
|
||||
if (from_joined_master(info->src_addr)) {
|
||||
ota_espnow_slave_on_start(info->src_addr, &msg.payload.ota_start);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
ESP_LOGW(TAG, "unhandled which=%u type=%u", msg.which_payload,
|
||||
(unsigned)msg.type);
|
||||
@@ -554,6 +946,22 @@ void esp_now_slave_on_recv(const esp_now_recv_info_t *info, const uint8_t *data,
|
||||
|
||||
esp_err_t esp_now_slave_start(void) {
|
||||
reset_join();
|
||||
load_id_binding();
|
||||
|
||||
s_accel_slot_sem = xSemaphoreCreateBinary();
|
||||
if (s_accel_slot_sem == NULL) {
|
||||
ESP_LOGE(TAG, "failed to create accel slot semaphore");
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
const esp_timer_create_args_t slot_timer_args = {
|
||||
.callback = &accel_slot_timer_cb,
|
||||
.name = "accel_slot",
|
||||
};
|
||||
esp_err_t terr = esp_timer_create(&slot_timer_args, &s_accel_slot_timer);
|
||||
if (terr != ESP_OK) {
|
||||
ESP_LOGE(TAG, "failed to create accel slot timer: %s", esp_err_to_name(terr));
|
||||
return terr;
|
||||
}
|
||||
|
||||
s_tx_queue = xQueueCreate(4, sizeof(slave_tx_op_t));
|
||||
if (s_tx_queue == NULL) {
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
#include "host_transport.h"
|
||||
#include "cmd_handler.h"
|
||||
#include "esp_log.h"
|
||||
#include "uart.h"
|
||||
#include "uart_messages.pb.h"
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
static const char *TAG = "[HOST]";
|
||||
|
||||
static host_tx_fn_t s_tx_fn;
|
||||
static QueueHandle_t s_cmd_queue;
|
||||
static uart_packet_t s_rx_packet = {.state = STATE_START};
|
||||
static TickType_t s_last_byte_ticks;
|
||||
static const TickType_t s_rx_timeout_ticks = pdMS_TO_TICKS(50);
|
||||
|
||||
void host_transport_set_tx(host_tx_fn_t tx_fn) { s_tx_fn = tx_fn; }
|
||||
|
||||
void host_transport_set_cmd_queue(QueueHandle_t cmd_queue) {
|
||||
s_cmd_queue = cmd_queue;
|
||||
}
|
||||
|
||||
QueueHandle_t host_transport_cmd_queue(void) { return s_cmd_queue; }
|
||||
|
||||
bool host_enqueue_packet(const uint8_t *payload, size_t len) {
|
||||
if (s_cmd_queue == NULL || payload == NULL || len == 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
generic_msg_t msg = {
|
||||
.msg_id = payload[0],
|
||||
.len = len > 1 ? (len - 1) : 0,
|
||||
.payload = NULL,
|
||||
};
|
||||
|
||||
if (msg.len > 0) {
|
||||
msg.payload = malloc(msg.len);
|
||||
if (msg.payload == NULL) {
|
||||
ESP_LOGE(TAG, "failed to allocate command payload");
|
||||
return false;
|
||||
}
|
||||
memcpy(msg.payload, &payload[1], msg.len);
|
||||
}
|
||||
|
||||
if (xQueueSend(s_cmd_queue, &msg, pdMS_TO_TICKS(500)) != pdPASS) {
|
||||
free(msg.payload);
|
||||
ESP_LOGW(TAG, "command queue full (cmd 0x%02x)", (unsigned)msg.msg_id);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
esp_err_t host_send_framed(const uint8_t *payload, size_t len) {
|
||||
if (payload == NULL || len == 0 || len > MAX_PAYLOAD_SIZE) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
if (s_tx_fn == NULL) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
uint8_t checksum = 0;
|
||||
for (size_t i = 0; i < len; i++) {
|
||||
checksum ^= payload[i];
|
||||
}
|
||||
|
||||
uint8_t frame[UART_FRAME_OVERHEAD + MAX_PAYLOAD_SIZE];
|
||||
size_t pos = 0;
|
||||
frame[pos++] = START_MARKER;
|
||||
frame[pos++] = (uint8_t)(len & 0xff);
|
||||
frame[pos++] = (uint8_t)((len >> 8) & 0xff);
|
||||
memcpy(&frame[pos], payload, len);
|
||||
pos += len;
|
||||
frame[pos++] = checksum;
|
||||
frame[pos++] = STOP_MARKER;
|
||||
|
||||
return s_tx_fn(frame, pos);
|
||||
}
|
||||
|
||||
void host_rx_bytes(const uint8_t *data, size_t len) {
|
||||
if (data == NULL || len == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
TickType_t now = xTaskGetTickCount();
|
||||
if (s_rx_packet.state != STATE_START &&
|
||||
(now - s_last_byte_ticks) > s_rx_timeout_ticks) {
|
||||
s_rx_packet.state = STATE_START;
|
||||
}
|
||||
|
||||
for (size_t i = 0; i < len; i++) {
|
||||
if (parse_uart_byte(data[i], &s_rx_packet)) {
|
||||
uint8_t cmd =
|
||||
s_rx_packet.len > 0 ? s_rx_packet.payload[0] : 0;
|
||||
/* OTA payloads are high-rate; avoid INFO spam starving the dispatcher. */
|
||||
if (cmd == (uint8_t)alox_MessageType_OTA_PAYLOAD) {
|
||||
ESP_LOGD(TAG, "received message cmd=0x%02x len=%u", cmd,
|
||||
(unsigned)s_rx_packet.len);
|
||||
} else {
|
||||
ESP_LOGI(TAG, "received message cmd=0x%02x len=%u", cmd,
|
||||
(unsigned)s_rx_packet.len);
|
||||
}
|
||||
host_enqueue_packet(s_rx_packet.payload, s_rx_packet.len);
|
||||
}
|
||||
}
|
||||
s_last_byte_ticks = now;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
#ifndef HOST_TRANSPORT_H
|
||||
#define HOST_TRANSPORT_H
|
||||
|
||||
#include "esp_err.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/queue.h"
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
/** TX callback: send a complete framed packet (0xAA … 0xCC). */
|
||||
typedef esp_err_t (*host_tx_fn_t)(const uint8_t *frame, size_t frame_len);
|
||||
|
||||
/** Set the active host TX backend (UART write or BLE notify). */
|
||||
void host_transport_set_tx(host_tx_fn_t tx_fn);
|
||||
|
||||
/** Bind the shared cmd_queue used by uart_enqueue_packet / host RX. */
|
||||
void host_transport_set_cmd_queue(QueueHandle_t cmd_queue);
|
||||
|
||||
QueueHandle_t host_transport_cmd_queue(void);
|
||||
|
||||
/**
|
||||
* Build AA|len_lo|len_hi|payload|xor|CC and send via the registered TX backend.
|
||||
* Same semantics as uart_send_framed.
|
||||
*/
|
||||
esp_err_t host_send_framed(const uint8_t *payload, size_t len);
|
||||
|
||||
/**
|
||||
* Feed raw host RX bytes into the shared frame parser and enqueue complete
|
||||
* packets onto the cmd_queue.
|
||||
*/
|
||||
void host_rx_bytes(const uint8_t *data, size_t len);
|
||||
|
||||
/** Enqueue a completed uart_packet_t (payload[0]=msg type). */
|
||||
bool host_enqueue_packet(const uint8_t *payload, size_t len);
|
||||
|
||||
#endif
|
||||
+80
-1
@@ -5,6 +5,7 @@
|
||||
#include "esp_log.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
#include "freertos/semphr.h"
|
||||
#include "led_strip.h"
|
||||
#include <stdint.h>
|
||||
|
||||
@@ -23,8 +24,22 @@ static led_strip_handle_t led_ring;
|
||||
#define LED_RING_FIND_ME_ON_MS 300
|
||||
#define LED_RING_FIND_ME_OFF_MS 150
|
||||
#define LED_RING_FIND_ME_BLINKS_PER_COLOR 3
|
||||
#define LED_RING_BATTERY_LOW_R 255
|
||||
#define LED_RING_BATTERY_LOW_G 0
|
||||
#define LED_RING_BATTERY_LOW_B 0
|
||||
/** Boot chase: all RING_LEDS within ~1 s. */
|
||||
#define LED_RING_BOOT_STEP_MS 10
|
||||
#define LED_RING_BOOT_R 255
|
||||
#define LED_RING_BOOT_G 255
|
||||
#define LED_RING_BOOT_B 255
|
||||
/** Connected: every other LED briefly blue. */
|
||||
#define LED_RING_CONNECTED_HOLD_MS 120
|
||||
#define LED_RING_CONNECTED_R 0
|
||||
#define LED_RING_CONNECTED_G 0
|
||||
#define LED_RING_CONNECTED_B 255
|
||||
|
||||
static QueueHandle_t led_queue;
|
||||
static SemaphoreHandle_t s_battery_low_done;
|
||||
|
||||
// Led Matrix Maps
|
||||
const uint8_t d0[] = {46, 47, 60, 61, 62, 75, 78, 79,
|
||||
@@ -145,6 +160,49 @@ void vTaskLedRing(void *pvParameters) {
|
||||
}
|
||||
}
|
||||
continue;
|
||||
} else if (cmd.mode == LED_CMD_BATTERY_LOW) {
|
||||
uint8_t r = LED_RING_BATTERY_LOW_R;
|
||||
uint8_t g = LED_RING_BATTERY_LOW_G;
|
||||
uint8_t b = LED_RING_BATTERY_LOW_B;
|
||||
led_ring_scale_rgb(&r, &g, &b, LED_RING_BATTERY_LOW_INTENSITY);
|
||||
for (uint32_t i = 0; i < LED_RING_BATTERY_LOW_LEDS; i++) {
|
||||
led_strip_set_pixel(led_ring, i, r, g, b);
|
||||
}
|
||||
led_strip_refresh(led_ring);
|
||||
vTaskDelay(pdMS_TO_TICKS(LED_RING_BATTERY_LOW_HOLD_MS));
|
||||
led_strip_clear(led_ring);
|
||||
led_strip_refresh(led_ring);
|
||||
if (s_battery_low_done != NULL) {
|
||||
xSemaphoreGive(s_battery_low_done);
|
||||
}
|
||||
continue;
|
||||
} else if (cmd.mode == LED_CMD_BOOT) {
|
||||
uint8_t br = LED_RING_BOOT_R;
|
||||
uint8_t bg = LED_RING_BOOT_G;
|
||||
uint8_t bb = LED_RING_BOOT_B;
|
||||
led_ring_scale_rgb(&br, &bg, &bb, LED_RING_DEFAULT_INTENSITY);
|
||||
for (uint32_t i = 0; i < RING_LEDS; i++) {
|
||||
led_strip_clear(led_ring);
|
||||
led_strip_set_pixel(led_ring, i, br, bg, bb);
|
||||
led_strip_refresh(led_ring);
|
||||
vTaskDelay(pdMS_TO_TICKS(LED_RING_BOOT_STEP_MS));
|
||||
}
|
||||
led_strip_clear(led_ring);
|
||||
led_strip_refresh(led_ring);
|
||||
continue;
|
||||
} else if (cmd.mode == LED_CMD_CONNECTED) {
|
||||
uint8_t cr = LED_RING_CONNECTED_R;
|
||||
uint8_t cg = LED_RING_CONNECTED_G;
|
||||
uint8_t cb = LED_RING_CONNECTED_B;
|
||||
led_ring_scale_rgb(&cr, &cg, &cb, LED_RING_DEFAULT_INTENSITY);
|
||||
for (uint32_t i = 0; i < RING_LEDS; i += 2) {
|
||||
led_strip_set_pixel(led_ring, i, cr, cg, cb);
|
||||
}
|
||||
led_strip_refresh(led_ring);
|
||||
vTaskDelay(pdMS_TO_TICKS(LED_RING_CONNECTED_HOLD_MS));
|
||||
led_strip_clear(led_ring);
|
||||
led_strip_refresh(led_ring);
|
||||
continue;
|
||||
}
|
||||
led_strip_refresh(led_ring);
|
||||
}
|
||||
@@ -153,12 +211,13 @@ void vTaskLedRing(void *pvParameters) {
|
||||
|
||||
void led_ring_init(void) {
|
||||
led_queue = xQueueCreate(10, sizeof(led_command_t));
|
||||
s_battery_low_done = xSemaphoreCreateBinary();
|
||||
xTaskCreate(vTaskLedRing, "led_task", 4096, NULL, 5, NULL);
|
||||
}
|
||||
|
||||
void led_ring_send_command(led_command_t *cmd) {
|
||||
if (led_queue != NULL) {
|
||||
xQueueSend(led_queue, cmd, portMAX_DELAY);
|
||||
(void)xQueueSend(led_queue, cmd, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,3 +283,23 @@ void led_ring_find_me(void) {
|
||||
led_command_t cmd = {.mode = LED_CMD_FIND_ME};
|
||||
led_ring_send_command(&cmd);
|
||||
}
|
||||
|
||||
void led_ring_show_battery_low(void) {
|
||||
if (led_queue == NULL || s_battery_low_done == NULL) {
|
||||
return;
|
||||
}
|
||||
(void)xSemaphoreTake(s_battery_low_done, 0);
|
||||
led_command_t cmd = {.mode = LED_CMD_BATTERY_LOW};
|
||||
led_ring_send_command(&cmd);
|
||||
(void)xSemaphoreTake(s_battery_low_done, portMAX_DELAY);
|
||||
}
|
||||
|
||||
void led_ring_boot_anim(void) {
|
||||
led_command_t cmd = {.mode = LED_CMD_BOOT};
|
||||
led_ring_send_command(&cmd);
|
||||
}
|
||||
|
||||
void led_ring_connected_anim(void) {
|
||||
led_command_t cmd = {.mode = LED_CMD_CONNECTED};
|
||||
led_ring_send_command(&cmd);
|
||||
}
|
||||
|
||||
+17
-1
@@ -7,6 +7,10 @@
|
||||
#define LED_RING_DEFAULT_INTENSITY 13
|
||||
/** Full brightness for find-me and similar alerts. */
|
||||
#define LED_RING_FULL_INTENSITY 255
|
||||
/** ~10 % brightness for battery-low indicator. */
|
||||
#define LED_RING_BATTERY_LOW_INTENSITY 26
|
||||
#define LED_RING_BATTERY_LOW_LEDS 4
|
||||
#define LED_RING_BATTERY_LOW_HOLD_MS 5000
|
||||
|
||||
typedef enum {
|
||||
LED_CMD_CLEAR,
|
||||
@@ -14,7 +18,10 @@ typedef enum {
|
||||
LED_CMD_SET_COLOR,
|
||||
LED_CMD_PROGRESS,
|
||||
LED_CMD_BLINK,
|
||||
LED_CMD_FIND_ME
|
||||
LED_CMD_FIND_ME,
|
||||
LED_CMD_BATTERY_LOW,
|
||||
LED_CMD_BOOT,
|
||||
LED_CMD_CONNECTED
|
||||
} led_mode_t;
|
||||
|
||||
typedef struct {
|
||||
@@ -45,4 +52,13 @@ void led_ring_ota_failed(void);
|
||||
/** Red / green / blue: 3 blinks each at full intensity. */
|
||||
void led_ring_find_me(void);
|
||||
|
||||
/** First 4 LEDs red at ~10 % for 5 s, then off (blocking in LED task). */
|
||||
void led_ring_show_battery_low(void);
|
||||
|
||||
/** Chase: each ring LED lights once in sequence (~1 s total). */
|
||||
void led_ring_boot_anim(void);
|
||||
|
||||
/** Every second LED briefly blue (join / reconnect). */
|
||||
void led_ring_connected_anim(void);
|
||||
|
||||
#endif
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
#ifndef OTA_CONFIRM_H
|
||||
#define OTA_CONFIRM_H
|
||||
|
||||
/**
|
||||
* After OTA with BOOTLOADER_APP_ROLLBACK_ENABLE, the new image boots as
|
||||
* ESP_OTA_IMG_PENDING_VERIFY. If we crash/reset before confirming, the
|
||||
* bootloader rolls back to the previous app on the next boot.
|
||||
*
|
||||
* Call once from app_main after init: schedules a delayed confirm so early
|
||||
* panics (task asserts, etc.) still trigger rollback.
|
||||
*/
|
||||
void ota_confirm_schedule(void);
|
||||
|
||||
#endif
|
||||
+237
-38
@@ -10,6 +10,7 @@
|
||||
#include "freertos/event_groups.h"
|
||||
#include "freertos/idf_additions.h"
|
||||
#include "ota_uart.h"
|
||||
#include "pod_reboot.h"
|
||||
#include <string.h>
|
||||
|
||||
static const char *TAG = "[OTA_ESPNOW]";
|
||||
@@ -18,9 +19,9 @@ static const char *TAG = "[OTA_ESPNOW]";
|
||||
#define OTA_ESPNOW_PREPARE_PRIO 5
|
||||
|
||||
#define OTA_PREPARE_TIMEOUT_MS 120000u
|
||||
#define OTA_BLOCK_TIMEOUT_MS 30000u
|
||||
#define OTA_BLOCK_TIMEOUT_PER_SLAVE_MS 2000u
|
||||
#define OTA_BLOCK_MAX_RETRIES 2u
|
||||
#define OTA_END_TIMEOUT_MS 60000u
|
||||
#define OTA_PAYLOAD_DELAY_MS 3
|
||||
|
||||
#define OTA_ST_PREPARING 1u
|
||||
#define OTA_ST_READY 2u
|
||||
@@ -28,6 +29,12 @@ static const char *TAG = "[OTA_ESPNOW]";
|
||||
#define OTA_ST_SUCCESS 4u
|
||||
#define OTA_ST_FAILED 5u
|
||||
|
||||
/**
|
||||
* Event-group fail bit. FreeRTOS reserves the top 8 bits (assert if used);
|
||||
* targets use bits 0..CLIENT_REGISTRY_MAX-1 (max 16).
|
||||
*/
|
||||
#define OTA_EG_FAIL_BIT (1u << 16)
|
||||
|
||||
/** ESP-NOW OTA receive on slave (blue progress bar). */
|
||||
#define OTA_LED_ESPNOW_RX_R 0
|
||||
#define OTA_LED_ESPNOW_RX_G 0
|
||||
@@ -35,7 +42,8 @@ static const char *TAG = "[OTA_ESPNOW]";
|
||||
|
||||
#define OTA_MAX_TARGETS CLIENT_REGISTRY_MAX
|
||||
|
||||
#define OTA_SLAVE_WORK_QUEUE_LEN 12
|
||||
/** ~21 payloads per 4 KiB block; headroom for bursts + status/end. */
|
||||
#define OTA_SLAVE_WORK_QUEUE_LEN 32
|
||||
#define OTA_SLAVE_WORK_STACK 8192
|
||||
#define OTA_SLAVE_WORK_PRIO 5
|
||||
|
||||
@@ -57,6 +65,7 @@ typedef struct {
|
||||
static EventGroupHandle_t s_eg;
|
||||
static QueueHandle_t s_slave_work_queue;
|
||||
static bool s_distribution_active;
|
||||
static TaskHandle_t s_slave_prep_task;
|
||||
|
||||
typedef struct {
|
||||
uint8_t count;
|
||||
@@ -69,6 +78,9 @@ typedef struct {
|
||||
|
||||
static ota_dist_t s_dist;
|
||||
|
||||
/** Always-reserved flash block for ESP-NOW distribute (not on task stack). */
|
||||
static uint8_t s_ota_block_buf[OTA_UART_FLASH_BLOCK_SIZE];
|
||||
|
||||
typedef struct {
|
||||
uint32_t client_id;
|
||||
uint32_t bytes_written;
|
||||
@@ -163,14 +175,100 @@ static uint32_t all_target_bits(void) {
|
||||
return (1u << s_dist.count) - 1u;
|
||||
}
|
||||
|
||||
static bool any_target_failed(void) {
|
||||
for (uint8_t i = 0; i < s_prog.count; i++) {
|
||||
if (s_prog.entries[i].status == OTA_ST_FAILED) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait until all want_bits are set, or OTA_EG_FAIL_BIT (slave FAILED).
|
||||
* Returns true only when every target bit is set; false on fail or timeout.
|
||||
*
|
||||
* Wait only for *still missing* bits. Waiting on the full mask with "any bit"
|
||||
* busy-spins after the first slave ACK (already-set bits make WaitBits return
|
||||
* immediately) and starves IDLE → task_wdt.
|
||||
*/
|
||||
static bool wait_target_bits(uint32_t want_bits, uint32_t timeout_ms) {
|
||||
if (s_eg == NULL || want_bits == 0) {
|
||||
return false;
|
||||
}
|
||||
EventBits_t got =
|
||||
xEventGroupWaitBits(s_eg, want_bits, pdTRUE, pdTRUE,
|
||||
pdMS_TO_TICKS(timeout_ms));
|
||||
return (got & want_bits) == want_bits;
|
||||
|
||||
const TickType_t start = xTaskGetTickCount();
|
||||
const TickType_t timeout_ticks = pdMS_TO_TICKS(timeout_ms);
|
||||
|
||||
while (true) {
|
||||
TickType_t elapsed = xTaskGetTickCount() - start;
|
||||
if (elapsed >= timeout_ticks) {
|
||||
return false;
|
||||
}
|
||||
TickType_t remain = timeout_ticks - elapsed;
|
||||
|
||||
EventBits_t have = xEventGroupGetBits(s_eg);
|
||||
if (have & OTA_EG_FAIL_BIT) {
|
||||
xEventGroupClearBits(s_eg, OTA_EG_FAIL_BIT);
|
||||
return false;
|
||||
}
|
||||
if ((have & want_bits) == want_bits) {
|
||||
xEventGroupClearBits(s_eg, want_bits);
|
||||
return true;
|
||||
}
|
||||
|
||||
EventBits_t missing = want_bits & ~have;
|
||||
(void)xEventGroupWaitBits(s_eg, missing | OTA_EG_FAIL_BIT, pdFALSE,
|
||||
pdFALSE, remain);
|
||||
}
|
||||
}
|
||||
|
||||
static uint32_t block_ack_timeout_ms(void) {
|
||||
if (s_dist.count == 0) {
|
||||
return OTA_BLOCK_TIMEOUT_PER_SLAVE_MS;
|
||||
}
|
||||
return (uint32_t)s_dist.count * OTA_BLOCK_TIMEOUT_PER_SLAVE_MS;
|
||||
}
|
||||
|
||||
static void log_missing_block_acks(uint32_t expected_bytes) {
|
||||
if (s_eg == NULL || s_dist.count == 0) {
|
||||
return;
|
||||
}
|
||||
EventBits_t bits = xEventGroupGetBits(s_eg);
|
||||
for (uint8_t i = 0; i < s_dist.count; i++) {
|
||||
uint32_t bit = (1u << (unsigned)i);
|
||||
if (bits & bit) {
|
||||
continue;
|
||||
}
|
||||
const ota_prog_entry_t *e = &s_prog.entries[i];
|
||||
ESP_LOGE(TAG,
|
||||
"slave %lu missing block ack @%lu (last status=%lu bytes=%lu err=%lu)",
|
||||
(unsigned long)s_dist.id[i], (unsigned long)expected_bytes,
|
||||
(unsigned long)e->status, (unsigned long)e->bytes_written,
|
||||
(unsigned long)e->error);
|
||||
}
|
||||
}
|
||||
|
||||
static esp_err_t send_block_payloads(const uint8_t *block_buf, uint32_t block_len,
|
||||
uint32_t *seq_io) {
|
||||
uint32_t sent = 0;
|
||||
while (sent < block_len) {
|
||||
uint32_t chunk = block_len - sent;
|
||||
if (chunk > OTA_UART_HOST_CHUNK_SIZE) {
|
||||
chunk = OTA_UART_HOST_CHUNK_SIZE;
|
||||
}
|
||||
|
||||
for (uint8_t i = 0; i < s_dist.count; i++) {
|
||||
esp_err_t err = esp_now_comm_send_ota_payload(s_dist.mac[i], *seq_io,
|
||||
block_buf + sent, chunk);
|
||||
if (err != ESP_OK) {
|
||||
return err;
|
||||
}
|
||||
}
|
||||
(*seq_io)++;
|
||||
sent += chunk;
|
||||
}
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
bool ota_espnow_distribution_active(void) { return s_distribution_active; }
|
||||
@@ -221,8 +319,21 @@ static void process_slave_payload(const uint8_t master_mac[6],
|
||||
ESP_LOGI(TAG, "ESP-NOW OTA payloads started");
|
||||
}
|
||||
|
||||
ota_feed_result_t r =
|
||||
ota_uart_feed(payload->data.bytes, payload->data.size);
|
||||
ota_feed_result_t r = ota_uart_feed_chunk(payload->seq, payload->data.bytes,
|
||||
payload->data.size);
|
||||
/* Gap: keep session; master block-retries from the last ACK boundary. */
|
||||
if (r == OTA_FEED_SEQ_GAP) {
|
||||
ESP_LOGW(TAG, "ESP-NOW OTA seq gap (got %lu) — waiting for retry",
|
||||
(unsigned long)payload->seq);
|
||||
return;
|
||||
}
|
||||
if (r == OTA_FEED_SEQ_DUP) {
|
||||
if (ota_uart_block_ready_for_reack()) {
|
||||
send_slave_status(master_mac, OTA_ST_BLOCK_ACK, ota_uart_bytes_written(),
|
||||
0);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (r == OTA_FEED_ERROR) {
|
||||
led_ring_ota_failed();
|
||||
send_slave_status(master_mac, OTA_ST_FAILED, ota_uart_bytes_written(), 13);
|
||||
@@ -268,6 +379,9 @@ static void process_slave_end(const uint8_t master_mac[6]) {
|
||||
led_ring_ota_success();
|
||||
ESP_LOGI(TAG, "slave OTA success (%lu bytes), reboot to run",
|
||||
(unsigned long)written);
|
||||
/* Boot partition already set in ota_uart_finish(true); must reboot or we
|
||||
* keep running the old image while the master expects the new one. */
|
||||
pod_schedule_restart();
|
||||
}
|
||||
|
||||
static void ota_slave_work_task(void *param) {
|
||||
@@ -311,11 +425,21 @@ void ota_espnow_slave_init(void) {
|
||||
}
|
||||
}
|
||||
|
||||
static void drain_slave_work_queue(void) {
|
||||
if (s_slave_work_queue == NULL) {
|
||||
return;
|
||||
}
|
||||
ota_slave_work_t junk;
|
||||
while (xQueueReceive(s_slave_work_queue, &junk, 0) == pdTRUE) {
|
||||
}
|
||||
}
|
||||
|
||||
static void ota_slave_prepare_task(void *param) {
|
||||
uint32_t total_size = (uint32_t)(uintptr_t)param;
|
||||
uint8_t master_mac[6];
|
||||
|
||||
if (!esp_now_comm_get_master_mac(master_mac)) {
|
||||
s_slave_prep_task = NULL;
|
||||
vTaskDelete(NULL);
|
||||
return;
|
||||
}
|
||||
@@ -325,6 +449,7 @@ static void ota_slave_prepare_task(void *param) {
|
||||
int slot = ota_uart_prepare(total_size);
|
||||
if (slot < 0) {
|
||||
send_slave_status(master_mac, OTA_ST_FAILED, 0, 1);
|
||||
s_slave_prep_task = NULL;
|
||||
vTaskDelete(NULL);
|
||||
return;
|
||||
}
|
||||
@@ -332,6 +457,7 @@ static void ota_slave_prepare_task(void *param) {
|
||||
send_slave_status(master_mac, OTA_ST_READY, 0, 0);
|
||||
led_ring_show_ota_progress(0, total_size, OTA_LED_ESPNOW_RX_R, OTA_LED_ESPNOW_RX_G,
|
||||
OTA_LED_ESPNOW_RX_B);
|
||||
s_slave_prep_task = NULL;
|
||||
vTaskDelete(NULL);
|
||||
}
|
||||
|
||||
@@ -343,14 +469,31 @@ void ota_espnow_slave_on_start(const uint8_t master_mac[6],
|
||||
|
||||
ESP_LOGI(TAG, "ESP-NOW OTA_START (%lu bytes)", (unsigned long)start->total_size);
|
||||
|
||||
if (ota_uart_is_active()) {
|
||||
queue_slave_status(master_mac, OTA_ST_FAILED, 0, 4);
|
||||
/* Duplicate START while prepare task still running — ignore. */
|
||||
if (s_slave_prep_task != NULL) {
|
||||
ESP_LOGW(TAG, "OTA prepare already in flight — ignore START");
|
||||
return;
|
||||
}
|
||||
|
||||
if (ota_uart_is_active()) {
|
||||
/* Same image, nothing written yet: sticky READY after master timeout /
|
||||
* duplicate START — re-ACK instead of failing with err=4. */
|
||||
if (ota_uart_total_size() == start->total_size &&
|
||||
ota_uart_bytes_received() == 0) {
|
||||
ESP_LOGI(TAG, "OTA already prepared for %lu bytes — re-send READY",
|
||||
(unsigned long)start->total_size);
|
||||
queue_slave_status(master_mac, OTA_ST_READY, 0, 0);
|
||||
return;
|
||||
}
|
||||
ESP_LOGW(TAG, "aborting sticky OTA session for new START");
|
||||
ota_uart_abort();
|
||||
drain_slave_work_queue();
|
||||
}
|
||||
|
||||
if (xTaskCreate(ota_slave_prepare_task, "ota_esp_prep", OTA_ESPNOW_PREPARE_STACK,
|
||||
(void *)(uintptr_t)start->total_size, OTA_ESPNOW_PREPARE_PRIO,
|
||||
NULL) != pdPASS) {
|
||||
&s_slave_prep_task) != pdPASS) {
|
||||
s_slave_prep_task = NULL;
|
||||
queue_slave_status(master_mac, OTA_ST_FAILED, 0, 5);
|
||||
}
|
||||
}
|
||||
@@ -418,6 +561,8 @@ void ota_espnow_master_on_status(const uint8_t slave_mac[6],
|
||||
status->error);
|
||||
ESP_LOGW(TAG, "slave %lu OTA failed (err=%lu)",
|
||||
(unsigned long)s_dist.id[idx], (unsigned long)status->error);
|
||||
/* Wake wait_target_bits so distribute aborts instead of waiting for READY. */
|
||||
xEventGroupSetBits(s_eg, OTA_EG_FAIL_BIT);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
@@ -466,7 +611,7 @@ static esp_err_t distribute_image(const esp_partition_t *partition,
|
||||
uint32_t target_mask = all_target_bits();
|
||||
esp_err_t err;
|
||||
|
||||
xEventGroupClearBits(s_eg, target_mask);
|
||||
xEventGroupClearBits(s_eg, target_mask | OTA_EG_FAIL_BIT);
|
||||
for (uint8_t i = 0; i < s_dist.count; i++) {
|
||||
err = esp_now_comm_send_ota_start(s_dist.mac[i], size);
|
||||
if (err != ESP_OK) {
|
||||
@@ -479,6 +624,12 @@ static esp_err_t distribute_image(const esp_partition_t *partition,
|
||||
}
|
||||
|
||||
if (!wait_target_bits(target_mask, OTA_PREPARE_TIMEOUT_MS)) {
|
||||
if (any_target_failed()) {
|
||||
ESP_LOGE(TAG, "slave OTA failed during prepare");
|
||||
prog_end();
|
||||
s_distribution_active = false;
|
||||
return ESP_FAIL;
|
||||
}
|
||||
ESP_LOGE(TAG, "timeout waiting for slave OTA ready");
|
||||
prog_end();
|
||||
s_distribution_active = false;
|
||||
@@ -490,7 +641,6 @@ static esp_err_t distribute_image(const esp_partition_t *partition,
|
||||
s_dist.progress.aggregate(0, size, s_dist.count);
|
||||
}
|
||||
|
||||
uint8_t block_buf[OTA_UART_FLASH_BLOCK_SIZE];
|
||||
uint32_t offset = 0;
|
||||
uint32_t seq = 0;
|
||||
|
||||
@@ -500,7 +650,7 @@ static esp_err_t distribute_image(const esp_partition_t *partition,
|
||||
block_len = OTA_UART_FLASH_BLOCK_SIZE;
|
||||
}
|
||||
|
||||
err = esp_partition_read(partition, offset, block_buf, block_len);
|
||||
err = esp_partition_read(partition, offset, s_ota_block_buf, block_len);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "partition read @%lu failed: %s", (unsigned long)offset,
|
||||
esp_err_to_name(err));
|
||||
@@ -509,35 +659,78 @@ static esp_err_t distribute_image(const esp_partition_t *partition,
|
||||
return err;
|
||||
}
|
||||
|
||||
uint32_t sent = 0;
|
||||
while (sent < block_len) {
|
||||
uint32_t chunk = block_len - sent;
|
||||
if (chunk > OTA_UART_HOST_CHUNK_SIZE) {
|
||||
chunk = OTA_UART_HOST_CHUNK_SIZE;
|
||||
}
|
||||
const bool full_block = (block_len >= OTA_UART_FLASH_BLOCK_SIZE);
|
||||
s_dist.expected_bytes = offset + block_len;
|
||||
const uint32_t block_start_seq = seq;
|
||||
|
||||
for (uint8_t i = 0; i < s_dist.count; i++) {
|
||||
err = esp_now_comm_send_ota_payload(s_dist.mac[i], seq,
|
||||
block_buf + sent, chunk);
|
||||
if (full_block) {
|
||||
xEventGroupClearBits(s_eg, target_mask);
|
||||
}
|
||||
|
||||
bool block_sent = false;
|
||||
for (uint32_t send_attempt = 0; send_attempt <= OTA_BLOCK_MAX_RETRIES;
|
||||
send_attempt++) {
|
||||
if (send_attempt > 0) {
|
||||
seq = block_start_seq;
|
||||
if (full_block) {
|
||||
xEventGroupClearBits(s_eg, target_mask);
|
||||
}
|
||||
ESP_LOGW(TAG, "block send failed @%lu — resend %lu/%lu",
|
||||
(unsigned long)s_dist.expected_bytes,
|
||||
(unsigned long)send_attempt,
|
||||
(unsigned long)OTA_BLOCK_MAX_RETRIES);
|
||||
}
|
||||
err = send_block_payloads(s_ota_block_buf, block_len, &seq);
|
||||
if (err == ESP_OK) {
|
||||
block_sent = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!block_sent) {
|
||||
ESP_LOGE(TAG, "block send failed @%lu after %lu retries",
|
||||
(unsigned long)s_dist.expected_bytes,
|
||||
(unsigned long)OTA_BLOCK_MAX_RETRIES);
|
||||
prog_end();
|
||||
s_distribution_active = false;
|
||||
return err;
|
||||
}
|
||||
|
||||
if (full_block) {
|
||||
const uint32_t ack_timeout = block_ack_timeout_ms();
|
||||
bool acked = false;
|
||||
for (uint32_t attempt = 0; attempt <= OTA_BLOCK_MAX_RETRIES; attempt++) {
|
||||
if (wait_target_bits(target_mask, ack_timeout)) {
|
||||
acked = true;
|
||||
break;
|
||||
}
|
||||
if (any_target_failed()) {
|
||||
ESP_LOGE(TAG, "slave OTA failed during block @%lu",
|
||||
(unsigned long)s_dist.expected_bytes);
|
||||
prog_end();
|
||||
s_distribution_active = false;
|
||||
return ESP_FAIL;
|
||||
}
|
||||
log_missing_block_acks(s_dist.expected_bytes);
|
||||
if (attempt >= OTA_BLOCK_MAX_RETRIES) {
|
||||
break;
|
||||
}
|
||||
ESP_LOGW(TAG, "block ack timeout @%lu — resend %lu/%lu",
|
||||
(unsigned long)s_dist.expected_bytes,
|
||||
(unsigned long)(attempt + 1),
|
||||
(unsigned long)OTA_BLOCK_MAX_RETRIES);
|
||||
xEventGroupClearBits(s_eg, target_mask | OTA_EG_FAIL_BIT);
|
||||
seq = block_start_seq;
|
||||
err = send_block_payloads(s_ota_block_buf, block_len, &seq);
|
||||
if (err != ESP_OK) {
|
||||
prog_end();
|
||||
s_distribution_active = false;
|
||||
return err;
|
||||
}
|
||||
}
|
||||
seq++;
|
||||
sent += chunk;
|
||||
vTaskDelay(pdMS_TO_TICKS(OTA_PAYLOAD_DELAY_MS));
|
||||
}
|
||||
|
||||
const bool full_block = (block_len >= OTA_UART_FLASH_BLOCK_SIZE);
|
||||
s_dist.expected_bytes = offset + block_len;
|
||||
|
||||
if (full_block) {
|
||||
xEventGroupClearBits(s_eg, target_mask);
|
||||
if (!wait_target_bits(target_mask, OTA_BLOCK_TIMEOUT_MS)) {
|
||||
ESP_LOGE(TAG, "timeout block ack @%lu bytes",
|
||||
(unsigned long)s_dist.expected_bytes);
|
||||
if (!acked) {
|
||||
ESP_LOGE(TAG, "timeout block ack @%lu bytes after %lu retries",
|
||||
(unsigned long)s_dist.expected_bytes,
|
||||
(unsigned long)OTA_BLOCK_MAX_RETRIES);
|
||||
prog_end();
|
||||
s_distribution_active = false;
|
||||
return ESP_ERR_TIMEOUT;
|
||||
@@ -556,7 +749,7 @@ static esp_err_t distribute_image(const esp_partition_t *partition,
|
||||
}
|
||||
}
|
||||
|
||||
xEventGroupClearBits(s_eg, target_mask);
|
||||
xEventGroupClearBits(s_eg, target_mask | OTA_EG_FAIL_BIT);
|
||||
for (uint8_t i = 0; i < s_dist.count; i++) {
|
||||
err = esp_now_comm_send_ota_end(s_dist.mac[i]);
|
||||
if (err != ESP_OK) {
|
||||
@@ -567,6 +760,12 @@ static esp_err_t distribute_image(const esp_partition_t *partition,
|
||||
}
|
||||
|
||||
if (!wait_target_bits(target_mask, OTA_END_TIMEOUT_MS)) {
|
||||
if (any_target_failed()) {
|
||||
ESP_LOGE(TAG, "slave OTA failed during end");
|
||||
prog_end();
|
||||
s_distribution_active = false;
|
||||
return ESP_FAIL;
|
||||
}
|
||||
ESP_LOGE(TAG, "timeout waiting for slave OTA success");
|
||||
prog_end();
|
||||
s_distribution_active = false;
|
||||
|
||||
+36
-2
@@ -12,6 +12,7 @@ typedef struct {
|
||||
uint32_t total_size;
|
||||
uint32_t received;
|
||||
uint32_t written;
|
||||
uint32_t expected_seq;
|
||||
int target_slot;
|
||||
uint8_t block_buf[OTA_UART_FLASH_BLOCK_SIZE];
|
||||
size_t block_len;
|
||||
@@ -96,7 +97,13 @@ int ota_uart_prepare(uint32_t total_size) {
|
||||
esp_ota_handle_t handle;
|
||||
esp_err_t err = esp_ota_begin(update_partition, OTA_SIZE_UNKNOWN, &handle);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "esp_ota_begin failed: %s", esp_err_to_name(err));
|
||||
if (err == ESP_ERR_OTA_ROLLBACK_INVALID_STATE) {
|
||||
ESP_LOGE(TAG,
|
||||
"esp_ota_begin blocked: running app still PENDING_VERIFY "
|
||||
"(wait for ota_confirm ~8 s after boot, or let rollback finish)");
|
||||
} else {
|
||||
ESP_LOGE(TAG, "esp_ota_begin failed: %s", esp_err_to_name(err));
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
@@ -112,10 +119,30 @@ int ota_uart_prepare(uint32_t total_size) {
|
||||
return s_ota.target_slot;
|
||||
}
|
||||
|
||||
ota_feed_result_t ota_uart_feed(const uint8_t *data, size_t len) {
|
||||
bool ota_uart_block_ready_for_reack(void) {
|
||||
if (!s_ota.active) {
|
||||
return false;
|
||||
}
|
||||
return s_ota.written > 0 &&
|
||||
(s_ota.written % OTA_UART_FLASH_BLOCK_SIZE) == 0 &&
|
||||
s_ota.block_len == 0;
|
||||
}
|
||||
|
||||
ota_feed_result_t ota_uart_feed_chunk(uint32_t seq, const uint8_t *data,
|
||||
size_t len) {
|
||||
if (!s_ota.active || data == NULL || len == 0) {
|
||||
return OTA_FEED_ERROR;
|
||||
}
|
||||
if (seq < s_ota.expected_seq) {
|
||||
return OTA_FEED_SEQ_DUP;
|
||||
}
|
||||
if (seq > s_ota.expected_seq) {
|
||||
ESP_LOGW(TAG, "seq gap: got %lu expected %lu", (unsigned long)seq,
|
||||
(unsigned long)s_ota.expected_seq);
|
||||
return OTA_FEED_SEQ_GAP;
|
||||
}
|
||||
s_ota.expected_seq++;
|
||||
|
||||
if (len > OTA_UART_HOST_CHUNK_SIZE) {
|
||||
ESP_LOGW(TAG, "chunk %u > %u, truncating", (unsigned)len,
|
||||
OTA_UART_HOST_CHUNK_SIZE);
|
||||
@@ -200,6 +227,13 @@ esp_err_t ota_uart_finish(bool set_boot, bool *success_out) {
|
||||
return err;
|
||||
}
|
||||
|
||||
if (s_ota.total_size > 0 && s_ota.received != s_ota.total_size) {
|
||||
ESP_LOGE(TAG, "size mismatch: received=%lu expected=%lu",
|
||||
(unsigned long)s_ota.received, (unsigned long)s_ota.total_size);
|
||||
ota_uart_abort();
|
||||
return ESP_ERR_INVALID_SIZE;
|
||||
}
|
||||
|
||||
err = esp_ota_end(s_ota.handle);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "esp_ota_end failed: %s", esp_err_to_name(err));
|
||||
|
||||
+12
-2
@@ -28,6 +28,8 @@ typedef enum {
|
||||
typedef enum {
|
||||
OTA_FEED_OK = 0,
|
||||
OTA_FEED_BLOCK_WRITTEN,
|
||||
OTA_FEED_SEQ_DUP,
|
||||
OTA_FEED_SEQ_GAP,
|
||||
OTA_FEED_ERROR,
|
||||
} ota_feed_result_t;
|
||||
|
||||
@@ -41,8 +43,16 @@ int ota_uart_prepare(uint32_t total_size);
|
||||
|
||||
void ota_uart_abort(void);
|
||||
|
||||
/** Append up to 200 bytes; flushes 4 KiB blocks to flash when full. */
|
||||
ota_feed_result_t ota_uart_feed(const uint8_t *data, size_t len);
|
||||
/**
|
||||
* Append up to 200 bytes with strict seq checking (0, 1, 2, …).
|
||||
* Duplicates (seq < expected) return OTA_FEED_SEQ_DUP.
|
||||
* Gaps (seq > expected) return OTA_FEED_SEQ_GAP without consuming — caller
|
||||
* should keep the session so the host can retry the current flash block.
|
||||
*/
|
||||
ota_feed_result_t ota_uart_feed_chunk(uint32_t seq, const uint8_t *data, size_t len);
|
||||
|
||||
/** True when a full 4 KiB block is in flash (used to re-ACK host block retries). */
|
||||
bool ota_uart_block_ready_for_reack(void);
|
||||
|
||||
uint32_t ota_uart_bytes_written(void);
|
||||
|
||||
|
||||
@@ -3,12 +3,51 @@
|
||||
#include "esp_log.h"
|
||||
#include "nvs.h"
|
||||
#include "nvs_flash.h"
|
||||
#include <string.h>
|
||||
|
||||
static const char *TAG = "[SETTINGS]";
|
||||
static const char *NS = "powerpod";
|
||||
static const char *KEY_ACCEL_DZ = "accel_dz";
|
||||
static const char *KEY_UV_LATCH = "uv_latch";
|
||||
static const char *KEY_UV_EN = "uv_en";
|
||||
static const char *KEY_TAP_CFG = "tap_cfg";
|
||||
static const char *KEY_ID_MAP = "id_map";
|
||||
static const char *KEY_ID_BIND = "id_bind";
|
||||
|
||||
#define ACCEL_DEADZONE_MAX 4095u
|
||||
#define TAP_CFG_NVS_VERSION 1u
|
||||
#define ID_MAP_NVS_VERSION 1u
|
||||
#define ID_BIND_NVS_VERSION 1u
|
||||
|
||||
typedef struct __attribute__((packed)) {
|
||||
uint8_t mac[POD_ID_MAC_LEN];
|
||||
uint8_t id;
|
||||
} id_map_entry_nvs_t;
|
||||
|
||||
typedef struct __attribute__((packed)) {
|
||||
uint8_t version;
|
||||
uint8_t next_id;
|
||||
uint8_t count;
|
||||
id_map_entry_nvs_t entries[POD_ID_MAP_MAX];
|
||||
} id_map_nvs_t;
|
||||
|
||||
typedef struct __attribute__((packed)) {
|
||||
uint8_t version;
|
||||
uint8_t master_mac[POD_ID_MAC_LEN];
|
||||
uint8_t assigned_id;
|
||||
} id_bind_nvs_t;
|
||||
|
||||
/** Packed layout for NVS blob (stable across rebuilds). */
|
||||
typedef struct __attribute__((packed)) {
|
||||
uint8_t version;
|
||||
uint16_t tap_sens_thres;
|
||||
uint16_t max_gest_dur;
|
||||
uint16_t tap_shock_dur;
|
||||
uint16_t quite_time_after_gest;
|
||||
uint16_t wait_for_timeout;
|
||||
uint16_t axis_sel;
|
||||
uint8_t enable_flags; /* bit0=single, bit1=double, bit2=triple */
|
||||
} tap_config_nvs_t;
|
||||
|
||||
static bool s_nvs_ready;
|
||||
|
||||
@@ -108,3 +147,458 @@ void pod_settings_apply_accel_deadzone(void) {
|
||||
bma456_set_accel_deadzone(deadzone);
|
||||
}
|
||||
}
|
||||
|
||||
static void tap_config_to_nvs(const bma456_tap_config_t *cfg,
|
||||
tap_config_nvs_t *out) {
|
||||
memset(out, 0, sizeof(*out));
|
||||
out->version = TAP_CFG_NVS_VERSION;
|
||||
out->tap_sens_thres = cfg->tap_sens_thres;
|
||||
out->max_gest_dur = cfg->max_gest_dur;
|
||||
out->tap_shock_dur = cfg->tap_shock_dur;
|
||||
out->quite_time_after_gest = cfg->quite_time_after_gest;
|
||||
out->wait_for_timeout = cfg->wait_for_timeout;
|
||||
out->axis_sel = cfg->axis_sel;
|
||||
if (cfg->enable_single) {
|
||||
out->enable_flags |= 0x01u;
|
||||
}
|
||||
if (cfg->enable_double) {
|
||||
out->enable_flags |= 0x02u;
|
||||
}
|
||||
if (cfg->enable_triple) {
|
||||
out->enable_flags |= 0x04u;
|
||||
}
|
||||
}
|
||||
|
||||
static bool tap_config_from_nvs(const tap_config_nvs_t *in,
|
||||
bma456_tap_config_t *cfg) {
|
||||
if (in->version != TAP_CFG_NVS_VERSION) {
|
||||
return false;
|
||||
}
|
||||
cfg->tap_sens_thres = in->tap_sens_thres;
|
||||
cfg->max_gest_dur = in->max_gest_dur;
|
||||
cfg->tap_shock_dur = in->tap_shock_dur;
|
||||
cfg->quite_time_after_gest = in->quite_time_after_gest;
|
||||
cfg->wait_for_timeout = in->wait_for_timeout;
|
||||
cfg->axis_sel = in->axis_sel;
|
||||
cfg->enable_single = (in->enable_flags & 0x01u) != 0;
|
||||
cfg->enable_double = (in->enable_flags & 0x02u) != 0;
|
||||
cfg->enable_triple = (in->enable_flags & 0x04u) != 0;
|
||||
return bma456_tap_config_valid(cfg);
|
||||
}
|
||||
|
||||
bool pod_settings_load_tap_config(bma456_tap_config_t *cfg_out) {
|
||||
static const bma456_tap_config_t k_default = BMA456_TAP_CONFIG_DEFAULT;
|
||||
if (cfg_out == NULL) {
|
||||
return false;
|
||||
}
|
||||
*cfg_out = k_default;
|
||||
if (!s_nvs_ready) {
|
||||
return false;
|
||||
}
|
||||
|
||||
nvs_handle_t handle;
|
||||
esp_err_t err = nvs_open(NS, NVS_READONLY, &handle);
|
||||
if (err != ESP_OK) {
|
||||
return false;
|
||||
}
|
||||
|
||||
tap_config_nvs_t blob = {0};
|
||||
size_t len = sizeof(blob);
|
||||
err = nvs_get_blob(handle, KEY_TAP_CFG, &blob, &len);
|
||||
nvs_close(handle);
|
||||
|
||||
if (err == ESP_ERR_NVS_NOT_FOUND) {
|
||||
return false;
|
||||
}
|
||||
if (err != ESP_OK || len != sizeof(blob)) {
|
||||
ESP_LOGW(TAG, "read tap_cfg failed: %s (len=%u)", esp_err_to_name(err),
|
||||
(unsigned)len);
|
||||
return false;
|
||||
}
|
||||
if (!tap_config_from_nvs(&blob, cfg_out)) {
|
||||
ESP_LOGW(TAG, "tap_cfg blob invalid; using defaults");
|
||||
*cfg_out = k_default;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
esp_err_t pod_settings_save_tap_config(const bma456_tap_config_t *cfg) {
|
||||
if (!bma456_tap_config_valid(cfg)) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
|
||||
esp_err_t err = ensure_nvs();
|
||||
if (err != ESP_OK) {
|
||||
return err;
|
||||
}
|
||||
|
||||
nvs_handle_t handle;
|
||||
err = nvs_open(NS, NVS_READWRITE, &handle);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "nvs_open failed: %s", esp_err_to_name(err));
|
||||
return err;
|
||||
}
|
||||
|
||||
tap_config_nvs_t blob;
|
||||
tap_config_to_nvs(cfg, &blob);
|
||||
err = nvs_set_blob(handle, KEY_TAP_CFG, &blob, sizeof(blob));
|
||||
if (err == ESP_OK) {
|
||||
err = nvs_commit(handle);
|
||||
}
|
||||
nvs_close(handle);
|
||||
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "save tap_cfg failed: %s", esp_err_to_name(err));
|
||||
return err;
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG,
|
||||
"saved tap config sens=%u max_gest=%u shock=%u quiet=%u wait=%u "
|
||||
"axis=%u (S=%d D=%d T=%d)",
|
||||
(unsigned)cfg->tap_sens_thres, (unsigned)cfg->max_gest_dur,
|
||||
(unsigned)cfg->tap_shock_dur, (unsigned)cfg->quite_time_after_gest,
|
||||
(unsigned)cfg->wait_for_timeout, (unsigned)cfg->axis_sel,
|
||||
cfg->enable_single, cfg->enable_double, cfg->enable_triple);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
void pod_settings_apply_tap_config(void) {
|
||||
bma456_tap_config_t cfg;
|
||||
if (!pod_settings_load_tap_config(&cfg)) {
|
||||
return;
|
||||
}
|
||||
if (!bma456_is_ready()) {
|
||||
return;
|
||||
}
|
||||
if (bma456_set_tap_config(&cfg) != ESP_OK) {
|
||||
ESP_LOGW(TAG, "failed to apply NVS tap config");
|
||||
}
|
||||
}
|
||||
|
||||
bool pod_settings_is_uv_latched(void) {
|
||||
if (!s_nvs_ready) {
|
||||
return false;
|
||||
}
|
||||
|
||||
nvs_handle_t handle;
|
||||
esp_err_t err = nvs_open(NS, NVS_READONLY, &handle);
|
||||
if (err != ESP_OK) {
|
||||
return false;
|
||||
}
|
||||
|
||||
uint8_t value = 0;
|
||||
err = nvs_get_u8(handle, KEY_UV_LATCH, &value);
|
||||
nvs_close(handle);
|
||||
|
||||
return err == ESP_OK && value != 0;
|
||||
}
|
||||
|
||||
esp_err_t pod_settings_set_uv_latched(bool latched) {
|
||||
esp_err_t err = ensure_nvs();
|
||||
if (err != ESP_OK) {
|
||||
return err;
|
||||
}
|
||||
|
||||
nvs_handle_t handle;
|
||||
err = nvs_open(NS, NVS_READWRITE, &handle);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "nvs_open failed: %s", esp_err_to_name(err));
|
||||
return err;
|
||||
}
|
||||
|
||||
err = nvs_set_u8(handle, KEY_UV_LATCH, latched ? 1u : 0u);
|
||||
if (err == ESP_OK) {
|
||||
err = nvs_commit(handle);
|
||||
}
|
||||
nvs_close(handle);
|
||||
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "save uv_latch failed: %s", esp_err_to_name(err));
|
||||
return err;
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "UV latch %s", latched ? "set" : "cleared");
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t pod_settings_clear_uv_latched(void) {
|
||||
return pod_settings_set_uv_latched(false);
|
||||
}
|
||||
|
||||
bool pod_settings_is_uv_enabled(void) {
|
||||
if (!s_nvs_ready) {
|
||||
return false;
|
||||
}
|
||||
|
||||
nvs_handle_t handle;
|
||||
esp_err_t err = nvs_open(NS, NVS_READONLY, &handle);
|
||||
if (err != ESP_OK) {
|
||||
return false;
|
||||
}
|
||||
|
||||
uint8_t value = 0;
|
||||
err = nvs_get_u8(handle, KEY_UV_EN, &value);
|
||||
nvs_close(handle);
|
||||
|
||||
if (err == ESP_ERR_NVS_NOT_FOUND) {
|
||||
return false;
|
||||
}
|
||||
if (err != ESP_OK) {
|
||||
return false;
|
||||
}
|
||||
return value != 0;
|
||||
}
|
||||
|
||||
esp_err_t pod_settings_set_uv_enabled(bool enabled) {
|
||||
esp_err_t err = ensure_nvs();
|
||||
if (err != ESP_OK) {
|
||||
return err;
|
||||
}
|
||||
|
||||
nvs_handle_t handle;
|
||||
err = nvs_open(NS, NVS_READWRITE, &handle);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "nvs_open failed: %s", esp_err_to_name(err));
|
||||
return err;
|
||||
}
|
||||
|
||||
err = nvs_set_u8(handle, KEY_UV_EN, enabled ? 1u : 0u);
|
||||
if (err == ESP_OK) {
|
||||
err = nvs_commit(handle);
|
||||
}
|
||||
nvs_close(handle);
|
||||
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "save uv_en failed: %s", esp_err_to_name(err));
|
||||
return err;
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "UV protection %s", enabled ? "enabled" : "disabled");
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t pod_settings_load_id_map(pod_id_map_t *out) {
|
||||
if (out == NULL) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
memset(out, 0, sizeof(*out));
|
||||
out->next_id = (uint8_t)POD_CLIENT_ID_MIN;
|
||||
if (!s_nvs_ready) {
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
nvs_handle_t handle;
|
||||
esp_err_t err = nvs_open(NS, NVS_READONLY, &handle);
|
||||
if (err != ESP_OK) {
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
id_map_nvs_t blob;
|
||||
memset(&blob, 0, sizeof(blob));
|
||||
size_t len = sizeof(blob);
|
||||
err = nvs_get_blob(handle, KEY_ID_MAP, &blob, &len);
|
||||
nvs_close(handle);
|
||||
|
||||
if (err == ESP_ERR_NVS_NOT_FOUND) {
|
||||
return ESP_OK;
|
||||
}
|
||||
if (err != ESP_OK || len < 3 || blob.version != ID_MAP_NVS_VERSION) {
|
||||
ESP_LOGW(TAG, "read id_map failed: %s (len=%u)", esp_err_to_name(err),
|
||||
(unsigned)len);
|
||||
return ESP_OK;
|
||||
}
|
||||
if (blob.next_id < POD_CLIENT_ID_MIN) {
|
||||
blob.next_id = (uint8_t)POD_CLIENT_ID_MIN;
|
||||
}
|
||||
|
||||
out->next_id = blob.next_id;
|
||||
out->count = blob.count;
|
||||
for (uint8_t i = 0; i < blob.count; i++) {
|
||||
memcpy(out->entries[i].mac, blob.entries[i].mac, POD_ID_MAC_LEN);
|
||||
out->entries[i].id = blob.entries[i].id;
|
||||
}
|
||||
ESP_LOGI(TAG, "loaded id_map count=%u next_id=%u", (unsigned)out->count,
|
||||
(unsigned)out->next_id);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t pod_settings_save_id_map(const pod_id_map_t *map) {
|
||||
if (map == NULL) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
if (map->next_id < POD_CLIENT_ID_MIN) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
|
||||
esp_err_t err = ensure_nvs();
|
||||
if (err != ESP_OK) {
|
||||
return err;
|
||||
}
|
||||
|
||||
id_map_nvs_t blob;
|
||||
memset(&blob, 0, sizeof(blob));
|
||||
blob.version = ID_MAP_NVS_VERSION;
|
||||
blob.next_id = map->next_id;
|
||||
blob.count = map->count;
|
||||
for (uint8_t i = 0; i < map->count; i++) {
|
||||
memcpy(blob.entries[i].mac, map->entries[i].mac, POD_ID_MAC_LEN);
|
||||
blob.entries[i].id = map->entries[i].id;
|
||||
}
|
||||
|
||||
nvs_handle_t handle;
|
||||
err = nvs_open(NS, NVS_READWRITE, &handle);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "nvs_open failed: %s", esp_err_to_name(err));
|
||||
return err;
|
||||
}
|
||||
|
||||
err = nvs_set_blob(handle, KEY_ID_MAP, &blob, sizeof(blob));
|
||||
if (err == ESP_OK) {
|
||||
err = nvs_commit(handle);
|
||||
}
|
||||
nvs_close(handle);
|
||||
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "save id_map failed: %s", esp_err_to_name(err));
|
||||
return err;
|
||||
}
|
||||
ESP_LOGI(TAG, "saved id_map count=%u next_id=%u", (unsigned)map->count,
|
||||
(unsigned)map->next_id);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t pod_settings_clear_id_map(void) {
|
||||
esp_err_t err = ensure_nvs();
|
||||
if (err != ESP_OK) {
|
||||
return err;
|
||||
}
|
||||
|
||||
nvs_handle_t handle;
|
||||
err = nvs_open(NS, NVS_READWRITE, &handle);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "nvs_open failed: %s", esp_err_to_name(err));
|
||||
return err;
|
||||
}
|
||||
|
||||
err = nvs_erase_key(handle, KEY_ID_MAP);
|
||||
if (err == ESP_ERR_NVS_NOT_FOUND) {
|
||||
err = ESP_OK;
|
||||
}
|
||||
if (err == ESP_OK) {
|
||||
err = nvs_commit(handle);
|
||||
}
|
||||
nvs_close(handle);
|
||||
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "clear id_map failed: %s", esp_err_to_name(err));
|
||||
return err;
|
||||
}
|
||||
ESP_LOGI(TAG, "cleared id_map");
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
bool pod_settings_load_id_binding(uint8_t master_mac[POD_ID_MAC_LEN],
|
||||
uint32_t *assigned_id_out) {
|
||||
if (master_mac == NULL || assigned_id_out == NULL) {
|
||||
return false;
|
||||
}
|
||||
memset(master_mac, 0, POD_ID_MAC_LEN);
|
||||
*assigned_id_out = 0;
|
||||
if (!s_nvs_ready) {
|
||||
return false;
|
||||
}
|
||||
|
||||
nvs_handle_t handle;
|
||||
esp_err_t err = nvs_open(NS, NVS_READONLY, &handle);
|
||||
if (err != ESP_OK) {
|
||||
return false;
|
||||
}
|
||||
|
||||
id_bind_nvs_t blob = {0};
|
||||
size_t len = sizeof(blob);
|
||||
err = nvs_get_blob(handle, KEY_ID_BIND, &blob, &len);
|
||||
nvs_close(handle);
|
||||
|
||||
if (err != ESP_OK || len != sizeof(blob) ||
|
||||
blob.version != ID_BIND_NVS_VERSION) {
|
||||
return false;
|
||||
}
|
||||
if (blob.assigned_id < POD_CLIENT_ID_MIN) {
|
||||
return false;
|
||||
}
|
||||
|
||||
memcpy(master_mac, blob.master_mac, POD_ID_MAC_LEN);
|
||||
*assigned_id_out = blob.assigned_id;
|
||||
return true;
|
||||
}
|
||||
|
||||
esp_err_t pod_settings_save_id_binding(const uint8_t master_mac[POD_ID_MAC_LEN],
|
||||
uint32_t assigned_id) {
|
||||
if (master_mac == NULL || assigned_id < POD_CLIENT_ID_MIN ||
|
||||
assigned_id > POD_CLIENT_ID_MAX) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
|
||||
esp_err_t err = ensure_nvs();
|
||||
if (err != ESP_OK) {
|
||||
return err;
|
||||
}
|
||||
|
||||
id_bind_nvs_t blob = {
|
||||
.version = ID_BIND_NVS_VERSION,
|
||||
.assigned_id = (uint8_t)assigned_id,
|
||||
};
|
||||
memcpy(blob.master_mac, master_mac, POD_ID_MAC_LEN);
|
||||
|
||||
nvs_handle_t handle;
|
||||
err = nvs_open(NS, NVS_READWRITE, &handle);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "nvs_open failed: %s", esp_err_to_name(err));
|
||||
return err;
|
||||
}
|
||||
|
||||
err = nvs_set_blob(handle, KEY_ID_BIND, &blob, sizeof(blob));
|
||||
if (err == ESP_OK) {
|
||||
err = nvs_commit(handle);
|
||||
}
|
||||
nvs_close(handle);
|
||||
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "save id_bind failed: %s", esp_err_to_name(err));
|
||||
return err;
|
||||
}
|
||||
ESP_LOGI(TAG, "saved id_bind id=%lu master=%02x:…:%02x",
|
||||
(unsigned long)assigned_id, master_mac[0], master_mac[5]);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t pod_settings_clear_id_binding(void) {
|
||||
esp_err_t err = ensure_nvs();
|
||||
if (err != ESP_OK) {
|
||||
return err;
|
||||
}
|
||||
|
||||
nvs_handle_t handle;
|
||||
err = nvs_open(NS, NVS_READWRITE, &handle);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "nvs_open failed: %s", esp_err_to_name(err));
|
||||
return err;
|
||||
}
|
||||
|
||||
err = nvs_erase_key(handle, KEY_ID_BIND);
|
||||
if (err == ESP_ERR_NVS_NOT_FOUND) {
|
||||
err = ESP_OK;
|
||||
}
|
||||
if (err == ESP_OK) {
|
||||
err = nvs_commit(handle);
|
||||
}
|
||||
nvs_close(handle);
|
||||
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "clear id_bind failed: %s", esp_err_to_name(err));
|
||||
return err;
|
||||
}
|
||||
ESP_LOGI(TAG, "cleared id_bind");
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
#ifndef POD_SETTINGS_H
|
||||
#define POD_SETTINGS_H
|
||||
|
||||
#include "bosch456.h"
|
||||
#include "esp_err.h"
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
|
||||
/** Initialize NVS (idempotent) and log stored settings. Call once early in app_main. */
|
||||
@@ -16,4 +18,60 @@ uint32_t pod_settings_load_accel_deadzone(void);
|
||||
/** Apply NVS deadzone to BMA456 when the sensor is present. */
|
||||
void pod_settings_apply_accel_deadzone(void);
|
||||
|
||||
/** Persist local BMA456 tap detection config. */
|
||||
esp_err_t pod_settings_save_tap_config(const bma456_tap_config_t *cfg);
|
||||
|
||||
/**
|
||||
* Load tap config from NVS into *cfg_out.
|
||||
* Returns true when a valid blob was loaded; false → compile-time defaults.
|
||||
*/
|
||||
bool pod_settings_load_tap_config(bma456_tap_config_t *cfg_out);
|
||||
|
||||
/** Apply NVS tap config to BMA456 when the sensor is present. */
|
||||
void pod_settings_apply_tap_config(void);
|
||||
|
||||
/** LiPo under-voltage latch persisted across reboot (software UV protection). */
|
||||
bool pod_settings_is_uv_latched(void);
|
||||
esp_err_t pod_settings_set_uv_latched(bool latched);
|
||||
esp_err_t pod_settings_clear_uv_latched(void);
|
||||
|
||||
/**
|
||||
* Runtime enable for software UV protection (NVS). Missing key → disabled.
|
||||
* Independent of POWERPOD_BATTERY_UV_ENABLE compile gate.
|
||||
*/
|
||||
bool pod_settings_is_uv_enabled(void);
|
||||
esp_err_t pod_settings_set_uv_enabled(bool enabled);
|
||||
|
||||
/** Logical slave ids: 1…POD_CLIENT_ID_MAX (0 reserved for local master). */
|
||||
#define POD_CLIENT_ID_MIN 1u
|
||||
#define POD_CLIENT_ID_MAX 255u
|
||||
#define POD_ID_MAP_MAX 255u
|
||||
#define POD_ID_MAC_LEN 6u
|
||||
|
||||
typedef struct {
|
||||
uint8_t mac[POD_ID_MAC_LEN];
|
||||
uint8_t id;
|
||||
} pod_id_map_entry_t;
|
||||
|
||||
typedef struct {
|
||||
uint8_t next_id; /**< Next candidate when allocating (1…255). */
|
||||
uint8_t count;
|
||||
pod_id_map_entry_t entries[POD_ID_MAP_MAX];
|
||||
} pod_id_map_t;
|
||||
|
||||
/** Master: load MAC→id map from NVS (zeros *out if unset). */
|
||||
esp_err_t pod_settings_load_id_map(pod_id_map_t *out);
|
||||
esp_err_t pod_settings_save_id_map(const pod_id_map_t *map);
|
||||
esp_err_t pod_settings_clear_id_map(void);
|
||||
|
||||
/**
|
||||
* Slave: load persisted {master_mac, assigned_id}.
|
||||
* Returns true when a valid binding was loaded.
|
||||
*/
|
||||
bool pod_settings_load_id_binding(uint8_t master_mac[POD_ID_MAC_LEN],
|
||||
uint32_t *assigned_id_out);
|
||||
esp_err_t pod_settings_save_id_binding(const uint8_t master_mac[POD_ID_MAC_LEN],
|
||||
uint32_t assigned_id);
|
||||
esp_err_t pod_settings_clear_id_binding(void);
|
||||
|
||||
#endif
|
||||
|
||||
+61
-9
@@ -2,17 +2,24 @@
|
||||
#include "cmd_handler.h"
|
||||
#include "cmd_accel_deadzone.h"
|
||||
#include "cmd_accel_stream.h"
|
||||
#include "cmd_input_send_mode.h"
|
||||
#include "cmd_tap_notify.h"
|
||||
#include "cmd_tap_config.h"
|
||||
#include "cmd_cache_status.h"
|
||||
#include "cmd_espnow_unicast_test.h"
|
||||
#include "cmd_espnow_echo_ping.h"
|
||||
#include "cmd_espnow_find_me.h"
|
||||
#include "cmd_restart.h"
|
||||
#include "cmd_client_id_map_reset.h"
|
||||
#include "cmd_client_info.h"
|
||||
#include "cmd_version.h"
|
||||
#include "cmd_ota.h"
|
||||
#include "cmd_ota_slave_progress.h"
|
||||
#include "ota_confirm.h"
|
||||
#include "cmd_led_ring.h"
|
||||
#include "cmd_battery.h"
|
||||
#include "cmd_battery_uv.h"
|
||||
#include "cmd_set_log_level.h"
|
||||
#include "esp_now_comm.h"
|
||||
#include "powerpod.h"
|
||||
#include "driver/gpio.h"
|
||||
@@ -29,7 +36,10 @@
|
||||
#include "led_ring.h"
|
||||
#include "pod_settings.h"
|
||||
#include "uart.h"
|
||||
#include "ble_host.h"
|
||||
#include "battery_uv.h"
|
||||
#include <stdint.h>
|
||||
#include <string.h>
|
||||
|
||||
enum MASTER_STATES {
|
||||
MSTATE_INIT,
|
||||
@@ -76,11 +86,22 @@ void app_main(void) {
|
||||
ESP_LOGW(TAG, "settings NVS init failed; using defaults");
|
||||
}
|
||||
|
||||
if (board_input_init_adc_only() != ESP_OK) {
|
||||
ESP_LOGW(TAG, "LiPo ADC init failed");
|
||||
}
|
||||
|
||||
#if POWERPOD_BATTERY_UV_ENABLE
|
||||
if (battery_uv_evaluate_boot()) {
|
||||
battery_uv_run_mode();
|
||||
}
|
||||
#endif
|
||||
|
||||
// Get Master Mode Pin
|
||||
gpio_reset_pin(DIP_MASTER);
|
||||
gpio_set_direction(DIP_MASTER, GPIO_MODE_INPUT);
|
||||
uint8_t master = gpio_get_level(DIP_MASTER) ? 0 : 1;
|
||||
uint8_t network = 0;
|
||||
uint8_t network = 1;
|
||||
bool host_ble = false;
|
||||
|
||||
esp_err_t err;
|
||||
|
||||
@@ -123,12 +144,20 @@ void app_main(void) {
|
||||
-1);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGI(TAG, "Could not read IO Expander input %d", err);
|
||||
} else {
|
||||
/* High nibble physically reversed on PCB; DIP1..8 after reverse+invert. */
|
||||
uint8_t bits = (uint8_t)(~reverse_high_nibble_lut(read_val));
|
||||
/* DIP8 (bit7) = host transport: 1 = BLE, 0 = UART */
|
||||
host_ble = (bits & 0x80u) != 0;
|
||||
uint8_t net_bits = (uint8_t)(bits & 0x7Fu);
|
||||
if (net_bits == 0 || (net_bits & (net_bits - 1u)) != 0) {
|
||||
ESP_LOGW(TAG, "invalid network DIP one-hot=0x%02x; fallback network=1",
|
||||
net_bits);
|
||||
network = 1;
|
||||
} else {
|
||||
network = (uint8_t)(__builtin_ctz(net_bits) + 1); /* 1..7 */
|
||||
}
|
||||
}
|
||||
// Build NetworkNumber because Pin 5-8 are reversed
|
||||
network = reverse_high_nibble_lut(read_val);
|
||||
network = ~network; // reverse bits
|
||||
network = __builtin_ctz(network) +
|
||||
1; // map network to 0-8, 0 is not a valid network
|
||||
}
|
||||
|
||||
if (bus_handle != NULL) {
|
||||
@@ -137,6 +166,7 @@ void app_main(void) {
|
||||
ESP_LOGI(TAG, "BMA456 init skipped: %s", esp_err_to_name(bma_err));
|
||||
} else {
|
||||
pod_settings_apply_accel_deadzone();
|
||||
pod_settings_apply_tap_config();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,6 +182,7 @@ void app_main(void) {
|
||||
|
||||
app_config.master = (master != 0);
|
||||
app_config.network = network;
|
||||
app_config.host_ble = host_ble;
|
||||
if (running != NULL) {
|
||||
memcpy(app_config.running_partition, running->label,
|
||||
sizeof(app_config.running_partition));
|
||||
@@ -162,10 +193,13 @@ void app_main(void) {
|
||||
ESP_LOGI(TAG, "RUNNING CONFIG:");
|
||||
ESP_LOGI(TAG, "Master: %d", app_config.master);
|
||||
ESP_LOGI(TAG, "Network: %d", app_config.network);
|
||||
ESP_LOGI(TAG, "Host: %s (DIP8; reboot to switch)",
|
||||
app_config.host_ble ? "BLE" : "UART");
|
||||
ESP_LOGI(TAG, "Running Partition: %s (OTA slot %d)",
|
||||
app_config.running_partition, ota_slot);
|
||||
|
||||
board_input_init();
|
||||
board_input_start_lipo_monitor();
|
||||
board_input_init_button();
|
||||
|
||||
err = esp_now_comm_init(&app_config);
|
||||
if (err != ESP_OK) {
|
||||
@@ -173,27 +207,45 @@ void app_main(void) {
|
||||
}
|
||||
|
||||
led_ring_init();
|
||||
led_ring_boot_anim();
|
||||
|
||||
if (app_config.master) {
|
||||
cmd_queue = xQueueCreate(64, sizeof(generic_msg_t));
|
||||
init_cmdHandler(cmd_queue);
|
||||
init_uart(cmd_queue);
|
||||
if (app_config.host_ble) {
|
||||
err = init_ble_host(cmd_queue);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "BLE host init failed: %s", esp_err_to_name(err));
|
||||
}
|
||||
} else {
|
||||
init_uart(cmd_queue);
|
||||
}
|
||||
cmd_version_register();
|
||||
cmd_client_info_register();
|
||||
cmd_accel_deadzone_register();
|
||||
cmd_accel_stream_register();
|
||||
cmd_input_send_mode_register();
|
||||
cmd_tap_notify_register();
|
||||
cmd_tap_config_register();
|
||||
cmd_cache_status_register();
|
||||
cmd_espnow_unicast_test_register();
|
||||
cmd_espnow_echo_ping_register();
|
||||
cmd_espnow_find_me_register();
|
||||
cmd_restart_register();
|
||||
cmd_client_id_map_reset_register();
|
||||
cmd_led_ring_register();
|
||||
cmd_battery_register();
|
||||
cmd_battery_uv_register();
|
||||
cmd_ota_register();
|
||||
cmd_ota_slave_progress_register();
|
||||
cmd_set_log_level_register();
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "LED ring: UART LED_RING commands only (no local demo loop)");
|
||||
ESP_LOGI(TAG, "LED ring: boot anim + UART LED_RING commands");
|
||||
|
||||
/* After OTA: PENDING_VERIFY until confirm; crash before this → rollback. */
|
||||
ota_confirm_schedule();
|
||||
|
||||
while (1) {
|
||||
vTaskDelay(portMAX_DELAY);
|
||||
}
|
||||
|
||||
+7
-3
@@ -1,6 +1,11 @@
|
||||
#ifndef POWERPOD_H
|
||||
#define POWERPOD_H
|
||||
|
||||
/** 1 = software LiPo UV latch + energy-save mode; 0 = disabled (LED mode 6 still works). */
|
||||
#ifndef POWERPOD_BATTERY_UV_ENABLE
|
||||
#define POWERPOD_BATTERY_UV_ENABLE 1
|
||||
#endif
|
||||
|
||||
#define DIP_MASTER 4
|
||||
#define I2C_SCL 5
|
||||
#define I2C_SDA 6
|
||||
@@ -11,9 +16,8 @@
|
||||
/** Front-panel button (active low, internal pull-up). */
|
||||
#define TASTER_GPIO 12
|
||||
|
||||
/** LiPo voltage sense inputs (ADC1-capable GPIOs). */
|
||||
/** LiPo voltage sense inputs (ADC-capable GPIOs; GPIO11 = ADC2 on ESP32-S3). */
|
||||
#define V_LIPO_1_GPIO 1
|
||||
/** Shares GPIO with TASTER on current bench wiring; second ADC is skipped in board_input.c. */
|
||||
#define V_LIPO_2_GPIO 12
|
||||
#define V_LIPO_2_GPIO 11
|
||||
|
||||
#endif
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user