Compare commits

...

6 Commits

Author SHA1 Message Date
ab1844ac32 Fix UART hang after master restart from the dashboard.
ReadFrame now returns on serial timeout instead of looping forever, and
serve closes and reopens the port after a master reboot so polling and
API commands can resume.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 16:45:57 +02:00
e4ce18edd8 Close UART gracefully on SIGINT/SIGTERM in goTool.
Go exits on Ctrl+C without running defers, leaving the serial port locked; register shutdown hooks for serve and CLI commands.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 16:40:22 +02:00
0eea27a876 Fix web OTA upload and isolate OTA sessions across firmware and goTool.
Split ESP-NOW into core/master/slave modules, block non-OTA UART traffic during updates, and hold the host serial port exclusively so dashboard polling cannot interleave with firmware uploads.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 16:35:18 +02:00
0cbc4d0644 Add ESP firmware architecture and reference documentation.
Document UART command flow, ESP-NOW data paths, and module map for
developers without covering goTool; link from main/README.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 15:55:32 +02:00
35b39fce46 Add configurable BMA456 tap detection profile in bosch456.h.
Centralize multitap tuning so sensitivity and timing can be adjusted without changing driver init logic.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 14:03:46 +02:00
41a66d4417 Unify external WebSocket push into a single input stream.
Replace separate accel/tap commands and messages with set_input_stream and input pushes that combine accel and tap per client, including pre_fetch timing.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 13:47:39 +02:00
39 changed files with 3229 additions and 1878 deletions

416
docs/ARCHITECTURE.md Normal file
View File

@ -0,0 +1,416 @@
# Powerpod — Architektur-Spezifikation
Dieses Dokument beschreibt die Firmware-Architektur des ESP32-S3-Projekts: Rollen, Schichten, Datenflüsse und die wichtigsten Implementierungsstellen. **goTool** (Host-CLI) ist bewusst ausgeschlossen — es nutzt nur das UART-Protokoll des Masters.
---
## 1. Systemüberblick
Master und Slave laufen mit **demselben Binary**. Die Rolle wird beim Boot per DIP-Schalter und I2C-IO-Expander festgelegt; danach verzweigt die Initialisierung.
```mermaid
flowchart TB
subgraph host["Externer Host (UART)"]
PC[PC / beliebiger UART-Client]
end
subgraph master["Master ESP32-S3"]
UART_RX[uart_read_task]
Q[cmd_queue]
DISP[vCmdDispatcherTask]
HAND[cmd/*.c Handler]
REG[client_registry]
ENOW_M[esp_now_comm Master]
end
subgraph slaves["Slave ESP32-S3 × N"]
ENOW_S[esp_now_comm Slave]
BMA[BMA456 + LED Ring]
end
PC <-->|UART1 921600 framed + protobuf| UART_RX
UART_RX --> Q --> DISP --> HAND
HAND --> REG
HAND --> ENOW_M
ENOW_M <-->|ESP-NOW nanopb| ENOW_S
ENOW_S --> BMA
ENOW_S -->|Accel/Tap/Battery| ENOW_M
ENOW_M --> REG
```
| Rolle | UART | ESP-NOW | Zentrale Datenhaltung |
|-------|------|---------|------------------------|
| **Master** | Ja — einziger Befehlseingang von außen | Discover (Broadcast), Unicast zu Slaves | `client_registry.c` |
| **Slave** | Nein | Antwort auf Discover, Heartbeat, Events zum Master | `esp_now_slave.c` |
**Einstieg:** `main/powerpod.c``app_main()`.
---
## 2. Boot und Konfiguration
### 2.1 Ablauf
```mermaid
sequenceDiagram
participant AM as app_main
participant NVS as pod_settings
participant I2C as I2C / IO-Expander
participant BMA as bosch456
participant EN as esp_now_comm
participant LR as led_ring
participant BI as board_input
participant CMD as cmd_handler + uart
AM->>NVS: pod_settings_init()
AM->>AM: GPIO DIP_MASTER → master/slave
AM->>I2C: Bus + Expander 0x20 → network 18
AM->>BMA: init_bma456() (Master + Slave)
AM->>AM: app_config_t füllen
AM->>BI: board_input_init()
AM->>EN: esp_now_comm_init(&app_config)
AM->>LR: led_ring_init()
alt master == true
AM->>CMD: Queue, Dispatcher, UART, Handler registrieren
end
```
### 2.2 `app_config_t`
| Feld | Quelle | Bedeutung |
|------|--------|-----------|
| `master` | `DIP_MASTER` (GPIO 4): Low = Master | Steuert UART + Registry-Nutzung |
| `network` | IO-Expander, Bits 58 (nibble reversed) | 18 → WiFi/ESP-NOW-Kanal |
| `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).
---
## 3. Schichtenmodell
```
┌─────────────────────────────────────────────────────────────┐
│ Befehlshandler (main/cmd/cmd_*.c) │
│ Decode/Encode: uart_cmd.c, Antwort: uart_proto.c │
├─────────────────────────────────────────────────────────────┤
│ Dispatch: cmd_handler.c (Queue + vCmdDispatcherTask) │
├─────────────────────────────────────────────────────────────┤
│ Transport UART: uart.c (Framing) │ 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 │
└─────────────────────────────────────────────────────────────┘
```
---
## 4. Datenfluss: Commands (Befehle)
Commands sind **asynchron über eine FreeRTOS-Queue** entkoppelt; der UART-Reader blockiert nicht auf Handler-Logik.
### 4.1 Eingang (UART → Handler)
```mermaid
flowchart LR
A[Bytes UART1] --> B[parse_uart_byte]
B --> C{Frame vollständig?}
C -->|ja| D[uart_enqueue_packet]
D --> E["generic_msg_t<br/>msg_id = payload[0]<br/>payload = Rest"]
E --> F[cmd_queue xQueueSend]
F --> G[vCmdDispatcherTask]
G --> H{msg_register_handler}
H --> I[cmd_*.c callback]
I --> J[free payload]
```
**Wichtige Stellen:**
| Schritt | Datei | Funktion |
|---------|--------|----------|
| Byte-Parser + Timeout 50 ms | `main/uart.c` | `parse_uart_byte()`, `uart_read_task()` |
| Queue-Eintrag | `main/uart.c` | `uart_enqueue_packet()` |
| Dispatcher | `main/cmd/cmd_handler.c` | `vCmdDispatcherTask()`, `msg_register_handler()` |
| Registrierung Master | `main/powerpod.c` | `cmd_*_register()` nach `init_uart()` |
**Nachrichten-ID:** Byte 0 des UART-Payloads = `alox_MessageType` (siehe `main/proto/uart_messages.proto`). Bytes 1… = nanopb-kodiertes `UartMessage` **ohne** wiederholtes Type-Feld — Handler erhalten nur den protobuf-Teil (`msg.payload` ab Offset 1).
### 4.2 Ausgang (Handler → UART)
```mermaid
flowchart LR
H[Handler baut alox_UartMessage] --> U[uart_cmd_send]
U --> P[uart_send_uart_message]
P --> E[pb_encode + Byte0 = type]
E --> F[uart_send_framed]
F --> W[uart_write_bytes]
```
**Wichtige Stellen:**
| Schritt | Datei | Funktion |
|---------|--------|----------|
| Handler-Hilfen | `main/uart_cmd.c` | `uart_cmd_decode()`, `uart_cmd_init_response()`, `uart_cmd_send()` |
| Protobuf + Framing | `main/uart_proto.c` | `uart_send_uart_message()` |
| XOR-Rahmen | `main/uart.c` | `uart_send_framed()` |
### 4.3 OTA-Sperre (keine parallelen Befehle)
Während einer OTA-Session (`ota_uart_is_active()` oder `ota_espnow_distribution_active()`) lehnt der Dispatcher alle UART-Befehle ab **außer**:
- `OTA_START`, `OTA_PAYLOAD`, `OTA_END`, `OTA_START_ESPNOW`, `OTA_SLAVE_PROGRESS`
Implementierung: `ota_session.c`, Prüfung in `vCmdDispatcherTask` vor Handler-Aufruf.
Auf dem **Slave** verarbeitet `espnow_recv_cb` während `ota_uart_is_active()` nur noch OTA-Nachrichten vom joined Master (kein Discover, Stream, LED, …).
Auf dem **Master** während ESP-NOW-Verteilung nur noch `ESPNOW_OTA_STATUS` aus dem recv_cb.
### 4.4 UART-Request-Decode (strikt)
| Regel | Beispiele |
|-------|-----------|
| Leerer protobuf-Body (`len == 0`) erlaubt | `VERSION`, `CLIENT_INFO`, `CACHE_STATUS`, `BATTERY_STATUS` (Defaults) |
| `len > 0` → Decode muss gelingen, `which_payload` muss passen | `ACCEL_STREAM`, `TAP_NOTIFY`, `RESTART`, OTA, … |
### 4.5 Bridge-Muster: UART-Befehl → ESP-NOW
Viele Master-Handler folgen demselben Muster:
1. `uart_cmd_decode()` → Request aus `UartMessage`
2. Lokale Aktion und/oder `client_registry_*` aktualisieren
3. `esp_now_comm_send_*()` mit MAC aus Registry
4. `uart_cmd_send()` mit Response
```mermaid
sequenceDiagram
participant Host
participant UART as uart.c
participant CMD as cmd_accel_deadzone.c
participant REG as client_registry
participant EN as esp_now_comm.c
participant SL as Slave
Host->>UART: Frame ACCEL_DEADZONE
UART->>CMD: generic_msg_t
CMD->>REG: set_accel_deadzone / lookup MAC
CMD->>EN: esp_now_comm_send_accel_deadzone
EN->>SL: ESPNOW_SET_ACCEL_DEADZONE
SL->>SL: bma456 + NVS
CMD->>Host: uart_cmd_send Response
```
**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`).
**Nur Master / nur Cache (kein Slave-Roundtrip):** `cmd_client_info.c`, `cmd_battery.c`, `cmd_cache_status.c`, `cmd_version.c`.
---
## 5. Datenfluss: UART
### 5.1 Rahmenformat (Transport)
Unabhängig von Protobuf — reines Bytestream-Framing:
| Feld | Wert |
|------|------|
| Start | `0xAA` |
| Länge | 1 Byte (1252) |
| 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.
### 5.2 Nutzlast (Anwendung)
```
Payload[0] = MessageType (enum, 1 Byte)
Payload[1…] = nanopb UartMessage (Felder ab type/payload oneof)
```
**Schema:** `main/proto/uart_messages.proto`
**Generiert:** `main/proto/uart_messages.pb.c/h` (`make proto_generate_uart`)
### 5.3 Tasks und Prioritäten
| Task | Stack | Priorität | Datei |
|------|-------|-----------|--------|
| `uart_rx` | 4096 | 5 | `uart.c` |
| `cmd_dispatch` | 8192 | 5 | `cmd_handler.c` |
Queue-Größe Master: **64** Einträge (`powerpod.c`); volle Queue → Warnung, Payload wird freigegeben.
---
## 6. Datenfluss: ESP-NOW
### 6.1 Stack-Initialisierung
`esp_now_comm_init()` (`esp_now_comm.c`) → `esp_now_core` (WiFi/Radio/Send) + Rolle:
1. `client_registry_init()` (Master)
2. `esp_now_init()`, recv_cb leitet an `esp_now_master_on_recv` / `esp_now_slave_on_recv`
3. **Master** (`esp_now_master.c`): `espnow_disc`, `espnow_mon`
4. **Slave** (`esp_now_slave.c`): `espnow_stx`, `espnow_hb`, `espnow_accel`, `ota_slave_work`
**Codec:** Rohes Paket = ein nanopb `EspNowMessage`**kein** zusätzliches Framing (`main/esp_now_proto.c`).
### 6.2 Discovery und Join (Slave)
```mermaid
sequenceDiagram
participant M as Master espnow_disc
participant S as Slave recv_cb
participant TX as slave_tx_task
loop alle 500 ms
M->>M: ESPNOW_DISCOVER → FF:FF:…:FF
end
S->>S: handle_discover (network match)
S->>S: s_slave_joined, s_master_mac
Note over S,TX: Kein Send aus recv_cb!
S->>TX: SLAVE_TX_SLAVE_INFO
TX->>M: ESPNOW_SLAVE_INFO
S->>TX: SLAVE_TX_BATTERY
TX->>M: ESPNOW_BATTERY_REPORT
loop alle 1 s
S->>M: ESPNOW_HEARTBEAT
end
```
**Registry-Schlüssel:** immer `recv_info.src_addr` (WiFi-MAC des Senders), **nicht** optionales `mac`-Feld in der Protobuf-Nachricht.
**Slave-ID:** `mac[5]` (letztes Oktett) — kann kollidieren; eindeutig ist die volle MAC in der Registry.
**Master-Verlust:** Slave setzt Join zurück, wenn **5 s** kein Discover vom gleichen Master (`SLAVE_MASTER_LOST_MS`).
**Join-Policy:** Master→Slave-Steuerung (Stream, Tap, LED, OTA, `UNICAST_TEST`, `SET_ACCEL_DEADZONE`, …) nur bei `s_slave_joined` und `src_addr == s_master_mac`. Während `ota_uart_is_active()` auf dem Slave verarbeitet der recv_cb nur OTA vom joined Master.
**Slave OTA:** Payload/End/Status-Sends laufen über `ota_slave_work_task` (Queue), nicht im ESP-NOW-recv_cb.
### 6.3 Master → Slave (Unicast)
Alle Master-Sends laufen über `send_message()` / `send_message_ex()`:
1. `esp_now_proto_encode()`
2. `ensure_peer(dest_mac)`
3. `esp_now_send()`
Öffentliche API: `main/esp_now_comm.h` (`esp_now_comm_send_*`).
**Empfang Slave:** `espnow_recv_cb()` → Switch auf `which_payload` → Handler (`handle_slave_*`).
### 6.4 Slave → Master (Events)
| Nachricht | Task / Trigger | Master-Ziel |
|-----------|----------------|-------------|
| `ESPNOW_ACCEL_SAMPLE` | `slave_accel_stream_task` 16 ms | `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()` |
| `ESPNOW_OTA_STATUS` | `ota_espnow_slave_*` | `ota_espnow_master_on_status()` |
**Master recv:** `espnow_recv_cb()` — Presence, Accel, Tap, Battery, OTA-Status.
### 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.
Gemeinsamer Flash-Puffer: `ota_uart.c` (4 KiB Blockgröße).
---
## 7. Client Registry (Master-Datenhub)
Die Registry ist die **zentrale Brücke** zwischen ESP-NOW-Echtzeitdaten und UART-Abfragen.
```mermaid
flowchart TB
EN_RX[espnow_recv_cb] --> REG[client_registry]
CMD_R[cmd_client_info / battery / cache_status] --> REG
CMD_W[cmd_* write paths] --> REG
REG --> CMD_R
EN_TX[esp_now_comm_send_*] --> MAC[MAC aus Registry]
```
| Daten | Aktualisiert durch | Gelesen durch UART |
|-------|-------------------|-------------------|
| 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` |
| Batterie | `ESPNOW_BATTERY_REPORT` + Master-ADC | `BATTERY_STATUS` |
| Konfig-Flags | Handler + ESP-NOW | `CLIENT_INFO`, diverse GET |
**Datei:** `main/client_registry.c`, `main/client_registry.h` (max. 16 Clients, Timeout 3 s ohne Heartbeat).
---
## 8. FreeRTOS-Tasks (Übersicht)
| Task | Rolle | Intervall / Trigger |
|------|-------|---------------------|
| `uart_rx` | Master | UART read 20 ms timeout |
| `cmd_dispatch` | Master | Queue blocking |
| `espnow_disc` | Master | 500 ms Discover |
| `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 |
| `bma456_poll` | Beide | 10 Hz (`bosch456.c`) |
| `vTaskLedRing` | Beide | LED-Ring-Befehle |
`app_main` endet in `while(1) vTaskDelay(portMAX_DELAY)` — alle Arbeit läuft in Tasks.
---
## 9. Implementierungskarte (wichtige Dateien)
| Bereich | Pfad | Verantwortung |
|---------|------|----------------|
| Einstieg / Init | `main/powerpod.c` | Boot-Reihenfolge, Master-Handler-Register |
| UART Transport | `main/uart.c`, `main/uart.h` | Framing RX/TX |
| UART Protobuf TX | `main/uart_proto.c` | `uart_send_uart_message` |
| UART Handler-Boilerplate | `main/uart_cmd.c`, `main/uart_cmd.h` | Decode, Register, Send |
| Command Dispatch | `main/cmd/cmd_handler.c` | Queue, Dispatcher |
| UART Commands | `main/cmd/cmd_*.c` | Pro Befehl ein Modul |
| ESP-NOW | `esp_now_comm.c` Init/Router; `esp_now_core.c` Send/Peer; `esp_now_master.c` / `esp_now_slave.c` Rollenlogik |
| ESP-NOW Codec | `main/esp_now_proto.c` | nanopb encode/decode |
| Registry | `main/client_registry.c` | Slave-Tabelle + Caches |
| BMA456 | `main/bosch456.c` | Sensor, Tap, Deadzone-Filter |
| 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 |
| 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 |
| Vendor BMA456 | `components/bma456/` | Nur `bma4.c` + `bma456h.c` gelinkt |
---
## 10. Protokoll-Verträge (Kurzreferenz)
- **UART:** `main/proto/uart_messages.proto` — Host ↔ Master
- **ESP-NOW:** `main/proto/esp_now_messages.proto` — Master ↔ Slave
Regenerierung: `make proto_generate` (siehe `DOCUMENTATION.md`).
---
## 11. Design-Entscheidungen
1. **Eine Queue für UART-Commands** — einziger Eingang vom Host; während OTA nur OTA-Befehle.
2. **Kein ESP-NOW-Send im `recv_cb`** — Slave antwortet auf Discover über `slave_tx_task` (Deadlock-/Stack-Risiko vermeiden).
3. **Registry-MAC = ESP-NOW-Quelladresse** — Protobuf-MAC ist optional/informativ.
4. **Gleiches Binary** — Konfiguration nur Hardware (DIP + Expander); reduziert Release-Komplexität.
5. **Nanopb statt vollem protobuf-c** — passend für ESP-RAM/Flash.
6. **Master aggregiert Sensor-Daten** — Slaves streamen; Host pollt Cache per UART (`CACHE_STATUS`), kein Durchreichen jedes Accel-Samples über UART.
---
Weitere Details (Befehlsliste, GPIO, Build, BMA456): [`DOCUMENTATION.md`](DOCUMENTATION.md).

486
docs/DOCUMENTATION.md Normal file
View File

@ -0,0 +1,486 @@
# Powerpod ESP-Firmware — Dokumentation
Vollständige Referenz für das ESP-IDF-Projekt unter `main/` und `components/`. Diese Doku beschreibt **nur die Firmware** — kein goTool, keine Host-Implementierung.
- **Architektur & Datenflüsse:** [`ARCHITECTURE.md`](ARCHITECTURE.md)
- **Neues Feature end-to-end:** [`adding-a-feature.md`](adding-a-feature.md)
---
## Inhaltsverzeichnis
1. [Projektziel](#1-projektziel)
2. [Hardware und Pins](#2-hardware-und-pins)
3. [Build und Flash](#3-build-und-flash)
4. [Boot-Konfiguration](#4-boot-konfiguration)
5. [UART-Protokoll](#5-uart-protokoll)
6. [ESP-NOW-Protokoll](#6-esp-now-protokoll)
7. [Befehlsreferenz (UART)](#7-befehlsreferenz-uart)
8. [OTA](#8-ota)
9. [BMA456 Beschleunigungssensor](#9-bma456-beschleunigungssensor)
10. [LED-Ring](#10-led-ring)
11. [Board-Input und Batterie](#11-board-input-und-batterie)
12. [Persistenz (NVS)](#12-persistenz-nvs)
13. [Protobuf und Code-Generierung](#13-protobuf-und-code-generierung)
14. [Modulreferenz](#14-modulreferenz)
15. [Logging-Tags](#15-logging-tags)
---
## 1. Projektziel
**Powerpod** ist Firmware für ESP32-S3-Knoten in einem verteilten System:
- Ein **Master** spricht per **UART** mit einem externen Host und verwaltet bis zu **16 Slaves** über **ESP-NOW**.
- **Slaves** haben keinen UART-Befehlspfad; sie joinen über periodisches **Discover**, senden Heartbeats und liefern Sensor-/Statusdaten.
- Master und Slave nutzen **identisches Firmware-Image**; Rolle und Funknetz werden beim Boot erkannt.
Zielbild für den Host: Befehle an den Master senden; der Master steuert Slaves und **cached** deren Telemetrie (`client_registry`) für schnelle UART-Abfragen.
---
## 2. Hardware und Pins
> Pinbelegung ist in `powerpod.h` als vorläufig markiert — mit Schaltplan abgleichen.
| Signal | GPIO | Datei / Modul |
|--------|------|----------------|
| 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) |
| 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 |
**UART:** `UART_NUM_1`, **921600** Baud, 8N1, kein Flow-Control.
**I2C:** 100 kHz, interne Pull-ups, gemeinsamer Bus für Expander und BMA456H.
---
## 3. Build und Flash
**Ziel-Chip:** ESP32-S3 (ESP-IDF).
```bash
source ~/esp/esp-idf/export.sh # oder export.fish
cd /pfad/zu/powerpod
idf.py build
idf.py -p /dev/ttyUSB0 flash monitor
```
- **Git-Hash** wird beim Build in `POWERPOD_GIT_HASH` eingebettet (`main/CMakeLists.txt`).
- **Firmware-Version:** `POWERPOD_FW_VERSION` (Default `1` in `esp_now_comm.c` / `cmd_version.c`).
**Komponenten:**
| Pfad | Inhalt |
|------|--------|
| `main/` | Anwendungslogik |
| `components/bma456/` | Bosch BMA456H-Treiber (Vendor) |
| `libs/nanopb/` | Protobuf-Codec für Embedded |
---
## 4. Boot-Konfiguration
### 4.1 Initialisierungsreihenfolge (`powerpod.c`)
1. `pod_settings_init()` — NVS
2. DIP → `app_config.master`
3. I2C + IO-Expander → `app_config.network` (18)
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()`
### 4.2 Master vs. Slave
| Funktion | Master | Slave |
|----------|--------|-------|
| UART Command-Handler | 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) |
---
## 5. UART-Protokoll
*(Transport + Anwendung — Datenfluss-Diagramme in [`ARCHITECTURE.md`](ARCHITECTURE.md) §45.)*
### 5.1 Rahmen
| Byte | Inhalt |
|------|--------|
| 0 | `0xAA` Start |
| 1 | Länge N (1252) |
| 2…N+1 | Payload |
| N+2 | XOR-Checksum über Payload |
| N+3 | `0xCC` Stopp |
Implementierung: `uart.c``parse_uart_byte()`, `uart_send_framed()`.
### 5.2 Anwendungs-Payload
| Offset | Bedeutung |
|--------|-----------|
| 0 | `MessageType` (siehe Enum in `uart_messages.proto`) |
| 1… | nanopb `UartMessage` (ohne separates type-Feld im protobuf-Teil) |
**Antworten** nutzen dieselbe Struktur: Byte 0 = Response-Typ, Rest = kodierte `UartMessage`.
### 5.3 Handler-Pipeline
1. `uart_read_task` parst Frames → `uart_enqueue_packet`
2. `generic_msg_t``cmd_queue`
3. `vCmdDispatcherTask` → registrierter `msg_callback_t`
4. Handler: `uart_cmd_decode(data, len, &msg)``data` ist **nur** protobuf-Teil; bei `len > 0` strikt (Decode/`which_payload`-Fehler → Fehlerantwort)
5. `ota_session_uart_cmd_allowed()` — während OTA nur OTA-Befehle
6. Antwort: `uart_cmd_init_response()` + Felder setzen + `uart_cmd_send()`
Hilfsmakro für Request-Felder:
```c
UART_CMD_REQ(&uart_msg, alox_UartMessage_accel_deadzone_request_tag, accel_deadzone_request)
```
---
## 6. ESP-NOW-Protokoll
*(Join, Tasks, Registry — [`ARCHITECTURE.md`](ARCHITECTURE.md) §6.)*
### 6.1 Grundlagen
- WiFi **STA**, keine AP-Verbindung.
- **Kanal** = `app_config.network` (113).
- Payload = ein `EspNowMessage` (nanopb), max. `ESP_NOW_MAX_DATA_LEN`.
- Schema: `main/proto/esp_now_messages.proto`.
### 6.2 Nachrichtentypen
| Type | Richtung | Payload | Zweck |
|------|----------|---------|--------|
| `ESPNOW_DISCOVER` | M→Broadcast | `discover.network` | Slaves finden Master |
| `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_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 |
| `ESPNOW_BATTERY_QUERY` | M→S | `battery_query` | On-demand |
| `ESPNOW_BATTERY_REPORT` | S→M | `battery_report` | mV LiPo 1/2 |
| `ESPNOW_LED_RING` | M→S | `led_ring` | Wie UART LED_RING |
| `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_OTA_*` | M↔S | `ota_*` | Firmware-Verteilung |
### 6.3 Zeitkonstanten (`esp_now_comm.c`)
| Konstante | Wert |
|-----------|------|
| Discover-Intervall | 500 ms |
| Heartbeat | 1000 ms |
| Client-Timeout (Master) | 3 × Heartbeat = 3 s → `available=false` |
| Master-Verlust (Slave) | 5 s ohne Discover |
| Accel-Stream | 16 ms |
| Batterie-Report | 30 s (+ einmal 150 ms nach Join) |
### 6.4 `EspNowSlavePresence`
Felder: `network`, `mac` (6 B), `version`, `slave_id`, `available`, `used`.
- `slave_id` = letztes Oktett der STA-MAC.
- Registry auf dem Master indexiert über **Sender-MAC** der ESP-NOW-Callback.
---
## 7. Befehlsreferenz (UART)
Nur auf dem **Master** registriert (`powerpod.c`). IDs aus `MessageType` in `uart_messages.proto`.
| ID | Name | Modul | Kurzbeschreibung |
|----|------|-------|------------------|
| 1 | ACK | — | Reserviert |
| 2 | ECHO | — | Reserviert |
| 3 | VERSION | `cmd_version.c` | FW-Version, Git-Hash, OTA-Partition |
| 4 | CLIENT_INFO | `cmd_client_info.c` | Liste `client_registry` |
| 5 | CLIENT_INPUT | — | Geplant |
| 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 |
| 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 |
| 20 | OTA_START_ESPNOW | `cmd_ota.c` | Nur ESP-NOW aus Staging |
| 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 |
| 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) |
### 7.1 VERSION (3)
- **Request:** nur Typ-Byte `0x03` oder leerer protobuf-Body.
- **Response:** `version`, `git_hash`, `running_partition`.
### 7.2 CLIENT_INFO (4)
- **Response:** wiederholtes `ClientInfo` pro Registry-Eintrag: `id`, `mac`, `version`, `available`, `used`, `last_ping`, `last_success_ping`, Tap-/Stream-Flags.
### 7.3 ACCEL_DEADZONE (6)
- **Request:** `write`, `deadzone`, `client_id` (0=lokal), `all_clients`.
- **Write Master:** Registry + `esp_now_comm_send_accel_deadzone` pro Slave; lokal `bma456` + NVS.
- **Slave-Empfang:** `handle_slave_accel_deadzone` in `esp_now_comm.c`.
### 7.4 ACCEL_STREAM (25)
- 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.
### 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.6 CACHE_STATUS (29)
- **Request:** leer.
- **Response:** pro Slave mit Stream und/oder Tap-Notify: gecachte Accel-Werte (`age_ms`) und konsumierte Tap-Events (`client_registry_take_tap`).
### 7.7 BATTERY_STATUS (26)
- Liest **nur Cache** — keine ESP-NOW-Roundtrip-Pflicht pro Abfrage.
- Master-Batterie: `client_registry_set_master_battery` (Monitor-Task).
### 7.8 LED_RING (8)
| mode | Bedeutung |
|------|-----------|
| 0 | Clear |
| 1 | Progress 0100 % |
| 2 | Digit 010 |
| 3 | Blink |
| 4 | Find-me (RGB-Sequenz) |
| 5 | Solid color |
`client_id=0` nur Master-Ring; `>0` ESP-NOW; `all_clients` Broadcast über Registry.
### 7.9 FIND_ME (22) / RESTART (23)
- `client_id=0`: lokaler Master (`led_ring_find_me` / `pod_schedule_restart`).
- `client_id>0`: ESP-NOW Unicast zum Slave.
### 7.10 ESPNOW_UNICAST_TEST (7)
Minimaler Master→Slave-Ping; Slave loggt `UNICAST TEST OK`.
---
## 8. OTA
### 8.1 UART-OTA (nur Master)
Implementierung: `ota_uart.c`, Steuerung `cmd/cmd_ota.c`.
| Phase | Host → Master | Master → Host |
|-------|---------------|---------------|
| Start | `OTA_START` + `total_size` | `OTA_STATUS` preparing → ready |
| Daten | `OTA_PAYLOAD` ≤200 B, `seq` | `block_ack` je 4096 B Flash |
| Ende | `OTA_END` | success/failed; startet ESP-NOW-Verteilung |
- Inaktive Partition: `esp_ota_get_next_update_partition()`.
- LED-Ring zeigt Fortschritt (blau Schreiben, grün Verteilung).
### 8.2 ESP-NOW-OTA (Master → Slaves)
`ota_espnow.c` — gleiche Status-Codes wie UART.
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
4. Slave antwortet `ESPNOW_OTA_STATUS` (mit `send_message_ex` + Semaphore auf Master)
Nach Erfolg: **alle Knoten neu starten**.
### 8.3 OTA_SLAVE_PROGRESS (21)
Abfrage laufender oder letzter ESP-NOW-Verteilung: pro Slave `bytes_written`, `status`, `error`.
---
## 9. BMA456 Beschleunigungssensor
| Thema | Detail |
|-------|--------|
| Wrapper | `bosch456.c` / `bosch456.h` |
| Chip-Variante | BMA456**H** (`bma456h.c` im Component) |
| I2C | Adresse 0x18, 100 kHz |
| Polling | Task ~10 Hz |
| Interrupt GPIO 10 | Single/Double/Triple Tap |
| Deadzone | Software-Filter für Logs/Stream (Default 100 LSB) |
| API | `bma456_read_accel`, `bma456_set_accel_deadzone`, `bma456_set_tap_handler` |
Ohne Sensor: `bma456_is_ready() == false`, Firmware läuft weiter.
---
## 10. LED-Ring
- **95 LEDs**, WS2812 über RMT (`led_ring.c`).
- Segment-Karten für Ziffern 010 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`).
---
## 11. Board-Input und Batterie
`board_input.c`:
- **Taster** GPIO 12 — Logging bei Druck.
- **LiPo-ADC** GPIO 1 (und optional 2, wenn nicht Taster).
- 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.
---
## 12. Persistenz (NVS)
`pod_settings.c` — Namespace `powerpod`:
| Key | Inhalt |
|-----|--------|
| `accel_dz` | Accel-Deadzone LSB |
Gespeichert bei lokalem Set (UART `client_id=0`, ESP-NOW auf Slave). Geladen nach `init_bma456()`.
---
## 13. Protobuf und Code-Generierung
```bash
make proto_generate # beide Schemas
make proto_generate_uart # nur uart_messages.proto
make proto_generate_espnow # nur esp_now_messages.proto
```
**Ausgabe:** `main/proto/*.pb.c`, `*.pb.h`
**Optionen:** `main/proto/uart_messages.options` (nanopb max_size etc.)
Includes in generierten `.pb.c` müssen `"uart_messages.pb.h"` heißen (nicht `main/proto/...`).
**Paketname protobuf:** `alox` → C-Prefix `alox_`.
---
## 14. Modulreferenz
### 14.1 Kern
| Datei | Rolle |
|-------|--------|
| `powerpod.c` | `app_main`, Init, Master-Register |
| `app_config.h` | Laufzeit-Konfiguration |
| `cmd_handler.c` | Queue + Dispatcher |
| `uart.c` | Framing |
| `uart_proto.c` | UartMessage send |
| `uart_cmd.c` | Handler-Hilfen |
### 14.2 Kommunikation
| Datei | Rolle |
|-------|--------|
| `esp_now_comm.c` | Init, recv-Router |
| `esp_now_core.c` | WiFi, Peer, gemeinsamer Send |
| `esp_now_master.c` | Master-Tasks, Registry, Unicast send |
| `esp_now_slave.c` | Join, Heartbeat, Accel/Tap send |
| `esp_now_proto.c` | nanopb für EspNowMessage |
| `client_registry.c` | Slave-Tabelle + Telemetrie-Cache |
### 14.3 Befehle (`main/cmd/`)
| Datei | UART-Typ |
|-------|----------|
| `cmd_version.c` | VERSION |
| `cmd_client_info.c` | CLIENT_INFO |
| `cmd_accel_deadzone.c` | ACCEL_DEADZONE |
| `cmd_accel_stream.c` | ACCEL_STREAM |
| `cmd_tap_notify.c` | TAP_NOTIFY |
| `cmd_cache_status.c` | CACHE_STATUS |
| `cmd_battery.c` | BATTERY_STATUS |
| `cmd_led_ring.c` | LED_RING |
| `cmd_espnow_find_me.c` | FIND_ME |
| `cmd_restart.c` | RESTART |
| `cmd_espnow_unicast_test.c` | ESPNOW_UNICAST_TEST |
| `cmd_ota.c` | OTA_* |
| `cmd_ota_slave_progress.c` | OTA_SLAVE_PROGRESS |
### 14.4 Domäne
| Datei | Rolle |
|-------|--------|
| `bosch456.c` | Accelerometer + Tap |
| `led_ring.c` | LED-Anzeige |
| `board_input.c` | Taster, ADC |
| `pod_settings.c` | NVS |
| `pod_reboot.c` | Verzögerter Restart |
| `ota_uart.c` | Flash-Puffer UART-OTA |
| `ota_espnow.c` | OTA Master/Slave; Slave-Work-Queue |
| `ota_session.c` | OTA-Sperre für UART-Dispatcher |
### 14.5 Protobuf
| Datei | Rolle |
|-------|--------|
| `proto/uart_messages.proto` | UART-Vertrag |
| `proto/esp_now_messages.proto` | ESP-NOW-Vertrag |
| `proto/pb_encode.c`, `pb_decode.c`, `pb_common.c` | nanopb Runtime |
---
## 15. Logging-Tags
| Tag | Modul |
|-----|--------|
| `[Main]` | powerpod.c |
| `[UART]` | uart.c |
| `[CMDH]` | cmd_handler.c |
| `[UART_CMD]` | uart_cmd.c |
| `[ESPNOW]` | esp_now_comm.c |
| `[CLIENTS]` | client_registry.c |
| `[BMA456]` | bosch456.c |
| `[VERSION]` etc. | jeweiliger cmd_* |
Typischer Ablauf bei UART-Befehl:
```
[UART] received message cmd=0x06 len=…
[CMDH] trigger command ACCEL_DEADZONE (0x06)
[ACCEL_DZ] …
```
---
## Anhang: Neues UART-Kommando hinzufügen
1. `uart_messages.proto` erweitern → `make proto_generate_uart`
2. `cmd/cmd_neu.c` mit `handle_*` + `uart_cmd_register()`
3. In `powerpod.c` `cmd_neu_register()` aufrufen (nur Master-Zweig)
4. Bei Slave-Bedarf: `esp_now_messages.proto` + `esp_now_comm_send_*` + Slave-Zweig in `espnow_recv_cb`
Siehe [`adding-a-feature.md`](adding-a-feature.md) für ein vollständiges Beispiel (Find Me).

View File

@ -2,6 +2,7 @@ package main
import ( import (
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@ -146,7 +147,11 @@ func serveOTAUpload(w http.ResponseWriter, r *http.Request, link *managedSerial,
if hub != nil { if hub != nil {
hub.broadcastRaw(OTAProgress{Type: "ota_progress", Phase: "error", Message: err.Error()}) hub.broadcastRaw(OTAProgress{Type: "ota_progress", Phase: "error", Message: err.Error()})
} }
writeJSON(w, http.StatusServiceUnavailable, otaAPIResponse{Error: err.Error()}) status := http.StatusServiceUnavailable
if errors.Is(err, errOTAInProgress) {
status = http.StatusConflict
}
writeJSON(w, status, otaAPIResponse{Error: err.Error()})
return return
} }
writeJSON(w, http.StatusOK, otaAPIResponse{ writeJSON(w, http.StatusOK, otaAPIResponse{

View File

@ -9,55 +9,61 @@ import (
"sync" "sync"
"time" "time"
"github.com/gorilla/websocket"
"powerpod/gotool/pb" "powerpod/gotool/pb"
"github.com/gorilla/websocket"
) )
const ( const (
defaultAccelStreamInterval = 16 * time.Millisecond defaultAccelStreamInterval = 16 * time.Millisecond
defaultPreFetchMs = 2
minAPIStreamInterval = 1 * time.Millisecond minAPIStreamInterval = 1 * time.Millisecond
maxAPIStreamInterval = 10 * time.Second maxAPIStreamInterval = 10 * time.Second
// How long tap events stay in API push/cache after first sight (matches dashboard). // How long tap events stay in API push/cache after first sight (matches dashboard).
apiTapDisplayMinMs = 2000 apiTapDisplayMinMs = 2000
) )
// AccelClientSample is one slave's cached accel on the master. // InputClientSample is one slave's cached accel + tap state on the master.
type AccelClientSample struct { type InputClientSample struct {
ClientID uint32 `json:"client_id"` ClientID uint32 `json:"client_id"`
Valid bool `json:"valid"` Valid bool `json:"valid"`
X int32 `json:"x,omitempty"` X int32 `json:"x,omitempty"`
Y int32 `json:"y,omitempty"` Y int32 `json:"y,omitempty"`
Z int32 `json:"z,omitempty"` Z int32 `json:"z,omitempty"`
AgeMs uint32 `json:"age_ms,omitempty"` AccelAgeMs uint32 `json:"accel_age_ms,omitempty"`
TapKind string `json:"tap_kind"`
TapAgeMs uint32 `json:"tap_age_ms,omitempty"`
} }
// AccelStreamMessage is sent to external WebSocket clients (hello + accel samples). // InputStreamMessage is sent to external WebSocket clients (hello + input samples).
type AccelStreamMessage struct { type InputStreamMessage struct {
Type string `json:"type"` // "hello" | "accel" Type string `json:"type"` // "hello" | "input"
Serial string `json:"serial_port,omitempty"` Serial string `json:"serial_port,omitempty"`
IntervalMs int `json:"interval_ms,omitempty"` IntervalMs int `json:"interval_ms,omitempty"`
PreFetchMs int `json:"pre_fetch_ms,omitempty"`
TapDisplayMinMs int `json:"tap_display_min_ms,omitempty"` TapDisplayMinMs int `json:"tap_display_min_ms,omitempty"`
Commands []string `json:"commands,omitempty"` Commands []string `json:"commands,omitempty"`
Note string `json:"note,omitempty"` Note string `json:"note,omitempty"`
T int64 `json:"t,omitempty"` // Unix nanoseconds T int64 `json:"t,omitempty"` // Unix nanoseconds
Success bool `json:"success,omitempty"` Success bool `json:"success,omitempty"`
Clients []AccelClientSample `json:"clients,omitempty"` Clients []InputClientSample `json:"clients,omitempty"`
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
} }
// StreamStatusMessage is the reply to set_stream / get_stream (this connection). // StreamStatusMessage is the reply to set_stream / get_stream (this connection).
type StreamStatusMessage struct { type StreamStatusMessage struct {
Type string `json:"type"` // "stream_status" Type string `json:"type"` // "stream_status"
ReceiveAccel bool `json:"receive_accel"` ReceiveInput bool `json:"receive_input"`
IntervalMs int `json:"interval_ms"` IntervalMs int `json:"interval_ms"`
PreFetch int `json:"pre_fetch"`
Success bool `json:"success"` Success bool `json:"success"`
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
} }
// AccelStreamStatusMessage is the reply to set_accel_stream / get_accel_stream (slave). // InputStreamStatusMessage is the reply to set_input_stream / get_input_stream (slave).
type AccelStreamStatusMessage struct { type InputStreamStatusMessage struct {
Type string `json:"type"` // "accel_stream_status" Type string `json:"type"` // "input_stream_status"
ClientID uint32 `json:"client_id"` ClientID uint32 `json:"client_id"`
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
Success bool `json:"success"` Success bool `json:"success"`
@ -65,33 +71,6 @@ type AccelStreamStatusMessage struct {
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
} }
// TapClientEvent is one tap visible to API clients (fresh or within tap_display_min_ms).
type TapClientEvent struct {
ClientID uint32 `json:"client_id"`
Valid bool `json:"valid"`
Kind string `json:"kind,omitempty"` // single | double | triple
AgeMs uint32 `json:"age_ms,omitempty"`
ShownAtMs int64 `json:"shown_at_ms,omitempty"` // Unix ms when API first saw this tap
}
// TapStreamMessage is pushed to external WebSocket clients when receive_tap is on.
type TapStreamMessage struct {
Type string `json:"type"` // "tap"
T int64 `json:"t,omitempty"`
Success bool `json:"success,omitempty"`
Events []TapClientEvent `json:"events,omitempty"`
Error string `json:"error,omitempty"`
}
// TapStreamStatusMessage is the reply to set_tap_stream / get_tap_stream (this connection).
type TapStreamStatusMessage struct {
Type string `json:"type"` // "tap_stream_status"
ReceiveTap bool `json:"receive_tap"`
IntervalMs int `json:"interval_ms"`
Success bool `json:"success"`
Error string `json:"error,omitempty"`
}
// APIClientInfo is one registered slave (or slot) from CLIENT_INFO. // APIClientInfo is one registered slave (or slot) from CLIENT_INFO.
type APIClientInfo struct { type APIClientInfo struct {
ID uint32 `json:"id"` ID uint32 `json:"id"`
@ -101,7 +80,7 @@ type APIClientInfo struct {
Used bool `json:"used"` Used bool `json:"used"`
LastPing uint32 `json:"last_ping"` LastPing uint32 `json:"last_ping"`
LastSuccessPing uint32 `json:"last_success_ping"` LastSuccessPing uint32 `json:"last_success_ping"`
AccelStream bool `json:"accel_stream"` InputStream bool `json:"input_stream"`
TapNotifySingle bool `json:"tap_notify_single"` TapNotifySingle bool `json:"tap_notify_single"`
TapNotifyDouble bool `json:"tap_notify_double"` TapNotifyDouble bool `json:"tap_notify_double"`
TapNotifyTriple bool `json:"tap_notify_triple"` TapNotifyTriple bool `json:"tap_notify_triple"`
@ -132,6 +111,7 @@ type accelWSCommand struct {
ClientID uint32 `json:"client_id"` ClientID uint32 `json:"client_id"`
Enable *bool `json:"enable"` Enable *bool `json:"enable"`
IntervalMs *int `json:"interval_ms"` IntervalMs *int `json:"interval_ms"`
PreFetch *int `json:"pre_fetch"`
Single *bool `json:"single"` Single *bool `json:"single"`
DoubleTap *bool `json:"double_tap"` DoubleTap *bool `json:"double_tap"`
Triple *bool `json:"triple"` Triple *bool `json:"triple"`
@ -144,6 +124,7 @@ type APIInfoResponse struct {
SerialPort string `json:"serial_port"` SerialPort string `json:"serial_port"`
WebSocket string `json:"websocket"` WebSocket string `json:"websocket"`
DefaultIntervalMs int `json:"default_interval_ms"` DefaultIntervalMs int `json:"default_interval_ms"`
DefaultPreFetchMs int `json:"default_pre_fetch_ms"`
MinIntervalMs int `json:"min_interval_ms"` MinIntervalMs int `json:"min_interval_ms"`
MaxIntervalMs int `json:"max_interval_ms"` MaxIntervalMs int `json:"max_interval_ms"`
TapDisplayMinMs int `json:"tap_display_min_ms"` TapDisplayMinMs int `json:"tap_display_min_ms"`
@ -153,21 +134,28 @@ type APIInfoResponse struct {
type cachedTapEvent struct { type cachedTapEvent struct {
kind string kind string
shownAt time.Time shownAt time.Time
ageMs uint32
} }
type wsSubscriber struct { type wsSubscriber struct {
conn *websocket.Conn conn *websocket.Conn
receiveAccel bool receiveInput bool
receiveTap bool
interval time.Duration interval time.Duration
lastAccelSent time.Time preFetch time.Duration
lastTapSent time.Time lastInputSent time.Time
}
type pendingInputCache struct {
cache *pb.CacheStatusResponse
readAt time.Time
readErr error
} }
type accelStreamHub struct { type accelStreamHub struct {
mu sync.RWMutex mu sync.RWMutex
clients map[*websocket.Conn]*wsSubscriber clients map[*websocket.Conn]*wsSubscriber
defaultInterval time.Duration defaultInterval time.Duration
defaultPreFetch time.Duration
configChanged chan struct{} configChanged chan struct{}
recentTaps map[uint32]cachedTapEvent recentTaps map[uint32]cachedTapEvent
} }
@ -176,6 +164,7 @@ func newAccelStreamHub(defaultInterval time.Duration) *accelStreamHub {
return &accelStreamHub{ return &accelStreamHub{
clients: make(map[*websocket.Conn]*wsSubscriber), clients: make(map[*websocket.Conn]*wsSubscriber),
defaultInterval: defaultInterval, defaultInterval: defaultInterval,
defaultPreFetch: defaultPreFetchMs * time.Millisecond,
configChanged: make(chan struct{}, 1), configChanged: make(chan struct{}, 1),
} }
} }
@ -197,26 +186,39 @@ func clampAPIInterval(d time.Duration) time.Duration {
return d return d
} }
func clampPreFetch(d time.Duration) time.Duration {
if d < 0 {
return 0
}
if d > maxAPIStreamInterval {
return maxAPIStreamInterval
}
return d
}
func (h *accelStreamHub) register(conn *websocket.Conn, portName string) *wsSubscriber { func (h *accelStreamHub) register(conn *websocket.Conn, portName string) *wsSubscriber {
sub := &wsSubscriber{ sub := &wsSubscriber{
conn: conn, conn: conn,
receiveAccel: false, receiveInput: false,
interval: h.defaultInterval, interval: h.defaultInterval,
preFetch: h.defaultPreFetch,
} }
h.mu.Lock() h.mu.Lock()
h.clients[conn] = sub h.clients[conn] = sub
h.mu.Unlock() h.mu.Unlock()
hello := AccelStreamMessage{ hello := InputStreamMessage{
Type: "hello", Type: "hello",
Serial: portName, Serial: portName,
IntervalMs: int(h.defaultInterval / time.Millisecond), IntervalMs: int(h.defaultInterval / time.Millisecond),
PreFetchMs: int(h.defaultPreFetch / time.Millisecond),
TapDisplayMinMs: apiTapDisplayMinMs, TapDisplayMinMs: apiTapDisplayMinMs,
Note: "set_tap_notify configures slave S/D/T only; set_tap_stream enables tap polling/push", Note: "set_tap_notify configures slave S/D/T only; set_stream enables input polling/push on this connection",
Commands: []string{ Commands: []string{
"list_clients", "list_clients",
"set_stream", "get_stream", "set_accel_stream", "get_accel_stream", "set_stream", "get_stream",
"set_tap_stream", "get_tap_stream", "set_tap_notify", "get_tap_notify", "set_input_stream", "get_input_stream",
"set_tap_notify", "get_tap_notify",
"set_led_ring", "get_battery", "set_led_ring", "get_battery",
}, },
} }
@ -229,36 +231,25 @@ func (h *accelStreamHub) register(conn *websocket.Conn, portName string) *wsSubs
func (h *accelStreamHub) unregister(conn *websocket.Conn) { func (h *accelStreamHub) unregister(conn *websocket.Conn) {
h.mu.Lock() h.mu.Lock()
delete(h.clients, conn) delete(h.clients, conn)
anyTap := false anyInput := false
for _, sub := range h.clients { for _, sub := range h.clients {
if sub.receiveTap { if sub.receiveInput {
anyTap = true anyInput = true
break break
} }
} }
if !anyTap { if !anyInput {
h.recentTaps = nil h.recentTaps = nil
} }
h.mu.Unlock() h.mu.Unlock()
h.notifyConfigChanged() h.notifyConfigChanged()
} }
func (h *accelStreamHub) anyWantsAccel() bool { func (h *accelStreamHub) anyWantsInput() bool {
h.mu.RLock() h.mu.RLock()
defer h.mu.RUnlock() defer h.mu.RUnlock()
for _, sub := range h.clients { for _, sub := range h.clients {
if sub.receiveAccel { if sub.receiveInput {
return true
}
}
return false
}
func (h *accelStreamHub) anyWantsTap() bool {
h.mu.RLock()
defer h.mu.RUnlock()
for _, sub := range h.clients {
if sub.receiveTap {
return true return true
} }
} }
@ -270,7 +261,7 @@ func (h *accelStreamHub) minWantedInterval() time.Duration {
defer h.mu.RUnlock() defer h.mu.RUnlock()
var min time.Duration var min time.Duration
for _, sub := range h.clients { for _, sub := range h.clients {
if !sub.receiveAccel && !sub.receiveTap { if !sub.receiveInput {
continue continue
} }
if min == 0 || sub.interval < min { if min == 0 || sub.interval < min {
@ -283,20 +274,28 @@ func (h *accelStreamHub) minWantedInterval() time.Duration {
return min return min
} }
func (h *accelStreamHub) setStream(sub *wsSubscriber, enable bool, intervalMs *int) StreamStatusMessage { func (h *accelStreamHub) setStream(sub *wsSubscriber, enable bool, intervalMs, preFetchMs *int) StreamStatusMessage {
h.mu.Lock() h.mu.Lock()
sub.receiveAccel = enable sub.receiveInput = enable
if !enable {
h.recentTaps = nil
}
if intervalMs != nil { if intervalMs != nil {
sub.interval = clampAPIInterval(time.Duration(*intervalMs) * time.Millisecond) sub.interval = clampAPIInterval(time.Duration(*intervalMs) * time.Millisecond)
} }
if preFetchMs != nil {
sub.preFetch = clampPreFetch(time.Duration(*preFetchMs) * time.Millisecond)
}
ms := int(sub.interval / time.Millisecond) ms := int(sub.interval / time.Millisecond)
pf := int(sub.preFetch / time.Millisecond)
h.mu.Unlock() h.mu.Unlock()
h.notifyConfigChanged() h.notifyConfigChanged()
return StreamStatusMessage{ return StreamStatusMessage{
Type: "stream_status", Type: "stream_status",
ReceiveAccel: enable, ReceiveInput: enable,
IntervalMs: ms, IntervalMs: ms,
PreFetch: pf,
Success: true, Success: true,
} }
} }
@ -306,45 +305,47 @@ func (h *accelStreamHub) getStream(sub *wsSubscriber) StreamStatusMessage {
defer h.mu.RUnlock() defer h.mu.RUnlock()
return StreamStatusMessage{ return StreamStatusMessage{
Type: "stream_status", Type: "stream_status",
ReceiveAccel: sub.receiveAccel, ReceiveInput: sub.receiveInput,
IntervalMs: int(sub.interval / time.Millisecond), IntervalMs: int(sub.interval / time.Millisecond),
PreFetch: int(sub.preFetch / time.Millisecond),
Success: true, Success: true,
} }
} }
func (h *accelStreamHub) setTapStream(sub *wsSubscriber, enable bool, intervalMs *int) TapStreamStatusMessage { func (h *accelStreamHub) streamTiming(now time.Time) (needRead, needDeliver bool, waitPreFetch time.Duration) {
h.mu.Lock()
sub.receiveTap = enable
if !enable {
h.recentTaps = nil
}
if intervalMs != nil {
sub.interval = clampAPIInterval(time.Duration(*intervalMs) * time.Millisecond)
}
ms := int(sub.interval / time.Millisecond)
h.mu.Unlock()
h.notifyConfigChanged()
return TapStreamStatusMessage{
Type: "tap_stream_status",
ReceiveTap: enable,
IntervalMs: ms,
Success: true,
}
}
func (h *accelStreamHub) getTapStream(sub *wsSubscriber) TapStreamStatusMessage {
h.mu.RLock() h.mu.RLock()
defer h.mu.RUnlock() defer h.mu.RUnlock()
return TapStreamStatusMessage{ for _, sub := range h.clients {
Type: "tap_stream_status", if !sub.receiveInput {
ReceiveTap: sub.receiveTap, continue
IntervalMs: int(sub.interval / time.Millisecond),
Success: true,
} }
if sub.lastInputSent.IsZero() {
needRead = true
needDeliver = true
if sub.preFetch > waitPreFetch {
waitPreFetch = sub.preFetch
}
continue
}
nextPush := sub.lastInputSent.Add(sub.interval)
readAt := nextPush.Add(-sub.preFetch)
if !now.Before(readAt) {
needRead = true
}
if !now.Before(nextPush) {
needDeliver = true
if sub.preFetch > waitPreFetch {
waitPreFetch = sub.preFetch
}
}
}
return needRead, needDeliver, waitPreFetch
} }
func (h *accelStreamHub) ingestTapEvents(incoming []TapClientEvent) []TapClientEvent { func (h *accelStreamHub) ingestTapFromCache(cache *pb.CacheStatusResponse) {
if cache == nil {
return
}
h.mu.Lock() h.mu.Lock()
defer h.mu.Unlock() defer h.mu.Unlock()
@ -352,39 +353,67 @@ func (h *accelStreamHub) ingestTapEvents(incoming []TapClientEvent) []TapClientE
if h.recentTaps == nil { if h.recentTaps == nil {
h.recentTaps = make(map[uint32]cachedTapEvent) h.recentTaps = make(map[uint32]cachedTapEvent)
} }
for _, e := range incoming { for _, c := range cache.GetClients() {
if !e.Valid || e.Kind == "" { t := c.GetTap()
if t == nil {
continue continue
} }
h.recentTaps[e.ClientID] = cachedTapEvent{kind: e.Kind, shownAt: now} kind := tapKindLabelPB(t.GetKind())
if kind == "" {
continue
} }
return h.activeTapEventsLocked(now) h.recentTaps[c.GetClientId()] = cachedTapEvent{
kind: kind,
shownAt: now,
ageMs: t.GetAgeMs(),
}
}
h.pruneRecentTapsLocked(now)
} }
func (h *accelStreamHub) activeTapEventsLocked(now time.Time) []TapClientEvent { func (h *accelStreamHub) pruneRecentTapsLocked(now time.Time) {
if len(h.recentTaps) == 0 { if len(h.recentTaps) == 0 {
return nil return
} }
cutoff := now.Add(-apiTapDisplayMinMs * time.Millisecond) cutoff := now.Add(-apiTapDisplayMinMs * time.Millisecond)
out := make([]TapClientEvent, 0, len(h.recentTaps))
for id, ev := range h.recentTaps { for id, ev := range h.recentTaps {
if ev.shownAt.Before(cutoff) { if ev.shownAt.Before(cutoff) {
delete(h.recentTaps, id) delete(h.recentTaps, id)
continue
} }
shownAtMs := ev.shownAt.UnixMilli() }
out = append(out, TapClientEvent{ }
ClientID: id,
Valid: true, func (h *accelStreamHub) inputClientsFromCacheLocked(cache *pb.CacheStatusResponse, now time.Time) []InputClientSample {
Kind: ev.kind, h.pruneRecentTapsLocked(now)
AgeMs: uint32(now.Sub(ev.shownAt).Milliseconds()), out := make([]InputClientSample, 0, len(cache.GetClients()))
ShownAtMs: shownAtMs, for _, c := range cache.GetClients() {
}) sample := InputClientSample{
ClientID: c.GetClientId(),
TapKind: "none",
}
if a := c.GetAccel(); a != nil {
sample.Valid = a.GetValid()
if a.GetValid() {
sample.X = a.GetX()
sample.Y = a.GetY()
sample.Z = a.GetZ()
sample.AccelAgeMs = a.GetAgeMs()
}
}
if ev, ok := h.recentTaps[c.GetClientId()]; ok {
sample.TapKind = ev.kind
if t := c.GetTap(); t != nil && tapKindLabelPB(t.GetKind()) == ev.kind {
sample.TapAgeMs = t.GetAgeMs()
} else {
sample.TapAgeMs = ev.ageMs + uint32(now.Sub(ev.shownAt).Milliseconds())
}
}
out = append(out, sample)
} }
return out return out
} }
func (h *accelStreamHub) deliver(msg AccelStreamMessage) { func (h *accelStreamHub) deliverInput(msg InputStreamMessage) {
data, err := json.Marshal(msg) data, err := json.Marshal(msg)
if err != nil { if err != nil {
return return
@ -394,13 +423,13 @@ func (h *accelStreamHub) deliver(msg AccelStreamMessage) {
h.mu.Lock() h.mu.Lock()
defer h.mu.Unlock() defer h.mu.Unlock()
for conn, sub := range h.clients { for conn, sub := range h.clients {
if !sub.receiveAccel { if !sub.receiveInput {
continue continue
} }
if !sub.lastAccelSent.IsZero() && now.Sub(sub.lastAccelSent) < sub.interval { if !sub.lastInputSent.IsZero() && now.Sub(sub.lastInputSent) < sub.interval {
continue continue
} }
sub.lastAccelSent = now sub.lastInputSent = now
if err := conn.WriteMessage(websocket.TextMessage, data); err != nil { if err := conn.WriteMessage(websocket.TextMessage, data); err != nil {
delete(h.clients, conn) delete(h.clients, conn)
_ = conn.Close() _ = conn.Close()
@ -408,155 +437,79 @@ func (h *accelStreamHub) deliver(msg AccelStreamMessage) {
} }
} }
func (h *accelStreamHub) deliverTap(msg TapStreamMessage) { func runInputStreamer(link *managedSerial, hub *accelStreamHub, dash *wsHub, ctl *accelStreamCtl, tapCtl *tapNotifyCtl, stop <-chan struct{}) {
data, err := json.Marshal(msg) ticker := time.NewTicker(minAPIStreamInterval)
if err != nil { defer ticker.Stop()
return
}
now := time.Now() var pending *pendingInputCache
h.mu.Lock()
defer h.mu.Unlock()
for conn, sub := range h.clients {
if !sub.receiveTap {
continue
}
if !sub.lastTapSent.IsZero() && now.Sub(sub.lastTapSent) < sub.interval {
continue
}
sub.lastTapSent = now
if err := conn.WriteMessage(websocket.TextMessage, data); err != nil {
delete(h.clients, conn)
_ = conn.Close()
}
}
}
func runAccelStreamer(link *managedSerial, hub *accelStreamHub, dash *wsHub, ctl *accelStreamCtl, tapCtl *tapNotifyCtl, stop <-chan struct{}) {
var ticker *time.Ticker
var tick <-chan time.Time
resetTicker := func() {
if ticker != nil {
ticker.Stop()
}
interval := hub.minWantedInterval()
ticker = time.NewTicker(interval)
tick = ticker.C
}
resetTicker()
defer func() {
if ticker != nil {
ticker.Stop()
}
}()
for { for {
select { select {
case <-stop: case <-stop:
return return
case <-hub.configChanged: case <-hub.configChanged:
resetTicker() pending = nil
case <-tick: case now := <-ticker.C:
wantAccel := hub.anyWantsAccel() && accelStreamPollingActive(dash, ctl) if !hub.anyWantsInput() || !inputPollingActive(dash, ctl, tapCtl) {
wantTap := hub.anyWantsTap() pending = nil
if !wantAccel && !wantTap {
continue continue
} }
now := time.Now().UnixNano() needRead, needDeliver, waitPreFetch := hub.streamTiming(now)
if needRead && pending == nil {
cache, err := link.readCacheStatusPoll() cache, err := link.readCacheStatusPoll()
if errors.Is(err, errUARTBusy) {
if wantAccel {
hub.deliver(AccelStreamMessage{
Type: "accel",
T: now,
Success: false,
Error: "uart busy",
})
}
if wantTap {
hub.deliverTap(TapStreamMessage{
Type: "tap",
T: now,
Success: false,
Error: "uart busy",
})
}
continue
}
if err != nil { if err != nil {
if wantAccel { pending = &pendingInputCache{readErr: err, readAt: now}
hub.deliver(AccelStreamMessage{ } else {
Type: "accel", hub.ingestTapFromCache(cache)
T: now, pending = &pendingInputCache{cache: cache, readAt: now}
Success: false,
Error: err.Error(),
})
} }
if wantTap {
hub.deliverTap(TapStreamMessage{
Type: "tap",
T: now,
Success: false,
Error: err.Error(),
})
} }
if !needDeliver || pending == nil {
continue continue
} }
if wantAccel { ts := now.UnixNano()
samples := accelSamplesFromCacheStatus(cache) if pending.readErr != nil {
clients := make([]AccelClientSample, 0, len(samples)) errMsg := pending.readErr.Error()
for _, s := range samples { if errors.Is(pending.readErr, errUARTBusy) {
clients = append(clients, AccelClientSample{ errMsg = "uart busy"
ClientID: s.GetClientId(),
Valid: s.GetValid(),
X: s.GetX(),
Y: s.GetY(),
Z: s.GetZ(),
AgeMs: s.GetAgeMs(),
})
} }
hub.deliver(AccelStreamMessage{ hub.deliverInput(InputStreamMessage{
Type: "accel", Type: "input",
T: now, T: ts,
Success: false,
Error: errMsg,
})
pending = nil
continue
}
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, Success: true,
Clients: clients, Clients: clients,
}) })
} pending = nil
if wantTap {
events := tapEventsFromCacheStatus(cache)
fresh := make([]TapClientEvent, 0, len(events))
for _, e := range events {
if !e.GetValid() {
continue
}
fresh = append(fresh, TapClientEvent{
ClientID: e.GetClientId(),
Valid: true,
Kind: tapKindLabelPB(e.GetKind()),
AgeMs: e.GetAgeMs(),
})
}
visible := hub.ingestTapEvents(fresh)
if len(visible) > 0 {
hub.deliverTap(TapStreamMessage{
Type: "tap",
T: now,
Success: true,
Events: visible,
})
}
} }
} }
} }
} }
func accelStreamPollingActive(dash *wsHub, ctl *accelStreamCtl) bool { func inputPollingActive(dash *wsHub, ctl *accelStreamCtl, tapCtl *tapNotifyCtl) bool {
if ctl != nil && ctl.Any() { if ctl != nil && ctl.Any() {
return true return true
} }
if tapCtl != nil && tapCtl.Any() {
return true
}
return dash != nil && dash.anyAccelStreamEnabled() return dash != nil && dash.anyAccelStreamEnabled()
} }
@ -586,9 +539,9 @@ func writeLedRingStatus(conn *websocket.Conn, out ledRingAPIResponse) {
_ = conn.WriteMessage(websocket.TextMessage, data) _ = conn.WriteMessage(websocket.TextMessage, data)
} }
func writeAccelStreamStatus(conn *websocket.Conn, out accelStreamAPIResponse) { func writeInputStreamStatus(conn *websocket.Conn, out accelStreamAPIResponse) {
msg := AccelStreamStatusMessage{ msg := InputStreamStatusMessage{
Type: "accel_stream_status", Type: "input_stream_status",
ClientID: out.ClientID, ClientID: out.ClientID,
Enabled: out.Enabled, Enabled: out.Enabled,
Success: out.Success, Success: out.Success,
@ -602,14 +555,6 @@ func writeAccelStreamStatus(conn *websocket.Conn, out accelStreamAPIResponse) {
_ = conn.WriteMessage(websocket.TextMessage, data) _ = conn.WriteMessage(websocket.TextMessage, data)
} }
func writeTapStreamStatus(conn *websocket.Conn, msg TapStreamStatusMessage) {
data, err := json.Marshal(msg)
if err != nil {
return
}
_ = conn.WriteMessage(websocket.TextMessage, data)
}
func clientInfoToAPI(c *pb.ClientInfo) APIClientInfo { func clientInfoToAPI(c *pb.ClientInfo) APIClientInfo {
return APIClientInfo{ return APIClientInfo{
ID: c.GetId(), ID: c.GetId(),
@ -619,7 +564,7 @@ func clientInfoToAPI(c *pb.ClientInfo) APIClientInfo {
Used: c.GetUsed(), Used: c.GetUsed(),
LastPing: c.GetLastPing(), LastPing: c.GetLastPing(),
LastSuccessPing: c.GetLastSuccessPing(), LastSuccessPing: c.GetLastSuccessPing(),
AccelStream: c.GetAccelStreamEnabled(), InputStream: c.GetAccelStreamEnabled(),
TapNotifySingle: c.GetTapNotifySingle(), TapNotifySingle: c.GetTapNotifySingle(),
TapNotifyDouble: c.GetTapNotifyDouble(), TapNotifyDouble: c.GetTapNotifyDouble(),
TapNotifyTriple: c.GetTapNotifyTriple(), TapNotifyTriple: c.GetTapNotifyTriple(),
@ -717,28 +662,28 @@ func handleAccelWSCommand(conn *websocket.Conn, sub *wsSubscriber, data []byte,
}) })
return return
} }
writeStreamStatus(conn, hub.setStream(sub, *cmd.Enable, cmd.IntervalMs)) writeStreamStatus(conn, hub.setStream(sub, *cmd.Enable, cmd.IntervalMs, cmd.PreFetch))
case "get_stream": case "get_stream":
writeStreamStatus(conn, hub.getStream(sub)) writeStreamStatus(conn, hub.getStream(sub))
case "set_accel_stream": case "set_input_stream":
if cmd.ClientID == 0 { if cmd.ClientID == 0 {
writeAccelStreamStatus(conn, accelStreamAPIResponse{Error: "client_id required"}) writeInputStreamStatus(conn, accelStreamAPIResponse{Error: "client_id required"})
return return
} }
if cmd.Enable == nil { if cmd.Enable == nil {
writeAccelStreamStatus(conn, accelStreamAPIResponse{ writeInputStreamStatus(conn, accelStreamAPIResponse{
ClientID: cmd.ClientID, ClientID: cmd.ClientID,
Error: "enable required", Error: "enable required",
}) })
return return
} }
writeAccelStreamStatus(conn, applyAccelStreamClient(link, dash, ctl, cmd.ClientID, *cmd.Enable)) writeInputStreamStatus(conn, applyAccelStreamClient(link, dash, ctl, cmd.ClientID, *cmd.Enable))
case "get_accel_stream": case "get_input_stream":
if cmd.ClientID == 0 { if cmd.ClientID == 0 {
writeAccelStreamStatus(conn, accelStreamAPIResponse{Error: "client_id required"}) writeInputStreamStatus(conn, accelStreamAPIResponse{Error: "client_id required"})
return return
} }
resp, err := link.AccelStreamPoll(&pb.AccelStreamRequest{ resp, err := link.AccelStreamPoll(&pb.AccelStreamRequest{
@ -746,7 +691,7 @@ func handleAccelWSCommand(conn *websocket.Conn, sub *wsSubscriber, data []byte,
ClientId: cmd.ClientID, ClientId: cmd.ClientID,
}) })
if err != nil { if err != nil {
writeAccelStreamStatus(conn, accelStreamAPIResponse{ writeInputStreamStatus(conn, accelStreamAPIResponse{
ClientID: cmd.ClientID, ClientID: cmd.ClientID,
Error: err.Error(), Error: err.Error(),
}) })
@ -755,25 +700,12 @@ func handleAccelWSCommand(conn *websocket.Conn, sub *wsSubscriber, data []byte,
if ctl != nil { if ctl != nil {
ctl.Set(cmd.ClientID, resp.GetEnabled()) ctl.Set(cmd.ClientID, resp.GetEnabled())
} }
writeAccelStreamStatus(conn, accelStreamAPIResponse{ writeInputStreamStatus(conn, accelStreamAPIResponse{
Enabled: resp.GetEnabled(), Enabled: resp.GetEnabled(),
ClientID: resp.GetClientId(), ClientID: resp.GetClientId(),
Success: resp.GetSuccess(), Success: resp.GetSuccess(),
}) })
case "set_tap_stream":
if cmd.Enable == nil {
writeTapStreamStatus(conn, TapStreamStatusMessage{
Type: "tap_stream_status",
Error: "enable required",
})
return
}
writeTapStreamStatus(conn, hub.setTapStream(sub, *cmd.Enable, cmd.IntervalMs))
case "get_tap_stream":
writeTapStreamStatus(conn, hub.getTapStream(sub))
case "set_tap_notify": case "set_tap_notify":
if cmd.AllClients { if cmd.AllClients {
if cmd.Single == nil || cmd.DoubleTap == nil || cmd.Triple == nil { if cmd.Single == nil || cmd.DoubleTap == nil || cmd.Triple == nil {
@ -860,7 +792,7 @@ func handleAccelWSCommand(conn *websocket.Conn, sub *wsSubscriber, data []byte,
default: default:
writeStreamStatus(conn, StreamStatusMessage{ writeStreamStatus(conn, StreamStatusMessage{
Type: "stream_status", Type: "stream_status",
Error: "unknown type (list_clients, set_stream, get_stream, set_accel_stream, get_accel_stream, set_tap_stream, get_tap_stream, set_tap_notify, get_tap_notify, set_led_ring, get_battery)", 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)",
}) })
} }
} }
@ -896,10 +828,11 @@ func mountExternalAPI(mux *http.ServeMux, portName string, defaultInterval time.
SerialPort: portName, SerialPort: portName,
WebSocket: "/ws", WebSocket: "/ws",
DefaultIntervalMs: defMs, DefaultIntervalMs: defMs,
DefaultPreFetchMs: defaultPreFetchMs,
MinIntervalMs: int(minAPIStreamInterval / time.Millisecond), MinIntervalMs: int(minAPIStreamInterval / time.Millisecond),
MaxIntervalMs: int(maxAPIStreamInterval / time.Millisecond), MaxIntervalMs: int(maxAPIStreamInterval / time.Millisecond),
TapDisplayMinMs: apiTapDisplayMinMs, TapDisplayMinMs: apiTapDisplayMinMs,
Description: "WebSocket: set_accel_stream + set_stream for accel; set_tap_notify (slave S/D/T) then set_tap_stream for tap events (shown ≥2s)", Description: "WebSocket: set_input_stream + set_stream for input (accel + tap); set_tap_notify configures slave tap kinds",
}) })
}) })
@ -915,7 +848,7 @@ func mountExternalAPI(mux *http.ServeMux, portName string, defaultInterval time.
func runAPIServer(portName string, link *managedSerial, addr string, defaultInterval time.Duration, dash *wsHub, ctl *accelStreamCtl, tapCtl *tapNotifyCtl, stop <-chan struct{}) *http.Server { func runAPIServer(portName string, link *managedSerial, addr string, defaultInterval time.Duration, dash *wsHub, ctl *accelStreamCtl, tapCtl *tapNotifyCtl, stop <-chan struct{}) *http.Server {
hub := newAccelStreamHub(defaultInterval) hub := newAccelStreamHub(defaultInterval)
go runAccelStreamer(link, hub, dash, ctl, tapCtl, stop) go runInputStreamer(link, hub, dash, ctl, tapCtl, stop)
mux := http.NewServeMux() mux := http.NewServeMux()
mountExternalAPI(mux, portName, defaultInterval, hub, link, dash, ctl, tapCtl) mountExternalAPI(mux, portName, defaultInterval, hub, link, dash, ctl, tapCtl)
@ -924,7 +857,7 @@ func runAPIServer(portName string, link *managedSerial, addr string, defaultInte
srv := &http.Server{Addr: addr, Handler: mux} srv := &http.Server{Addr: addr, Handler: mux}
go func() { go func() {
log.Printf("external API http://localhost%s WebSocket ws://localhost%s/ws (default stream interval %s, per-client via set_stream / set_tap_stream)", log.Printf("external API http://localhost%s WebSocket ws://localhost%s/ws (default stream interval %s, per-client via set_stream)",
addr, addr, defaultInterval.String()) addr, addr, defaultInterval.String())
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Printf("external API server: %v", err) log.Printf("external API server: %v", err)

View File

@ -417,9 +417,16 @@ func (m *managedSerial) FindMe(clientID uint32) error {
} }
func (m *managedSerial) Restart(clientID uint32) error { func (m *managedSerial) Restart(clientID uint32) error {
return m.withPort(func(sp *serialPort) error { err := m.withPort(func(sp *serialPort) error {
return runRestartClient(sp, clientID) return runRestartClient(sp, clientID)
}) })
if err != nil {
return err
}
if clientID == 0 {
m.recoverAfterMasterRestart()
}
return nil
} }
func (s *serialPort) ledRingProgress(req *pb.LedRingProgressRequest) (*pb.LedRingProgressResponse, error) { func (s *serialPort) ledRingProgress(req *pb.LedRingProgressRequest) (*pb.LedRingProgressResponse, error) {

View File

@ -62,6 +62,8 @@ func runTest(portOverride string, baudOverride int, args []string) error {
if err != nil { if err != nil {
return fmt.Errorf("open %s: %w", port, err) return fmt.Errorf("open %s: %w", port, err)
} }
registerShutdown(func() { _ = sp.Close() })
enableShutdownOnInterrupt()
defer sp.Close() defer sp.Close()
if !*verbose { if !*verbose {

View File

@ -16,8 +16,7 @@ func runOTA(sp *serialPort, args []string) error {
sp.mu.Lock() sp.mu.Lock()
defer sp.mu.Unlock() defer sp.mu.Unlock()
m := &managedSerial{quiet: false, sp: sp} return runOTAOnPortUnlocked(sp, data, func(p OTAProgress) {
return runOTAOnPortUnlocked(m, data, func(p OTAProgress) {
switch p.Phase { switch p.Phase {
case "preparing", "ready": case "preparing", "ready":
fmt.Println(p.Message) fmt.Println(p.Message)

View File

@ -2,6 +2,7 @@ package main
import ( import (
"embed" "embed"
"errors"
"flag" "flag"
"fmt" "fmt"
"io/fs" "io/fs"
@ -34,21 +35,29 @@ func runServe(portName string, baud int, args []string) error {
link := newManagedSerial(portName, baud) link := newManagedSerial(portName, baud)
link.quiet = true link.quiet = true
defer link.Close()
hub := newWSHub() hub := newWSHub()
streamCtl := newAccelStreamCtl() streamCtl := newAccelStreamCtl()
tapCtl := newTapNotifyCtl() tapCtl := newTapNotifyCtl()
stop := make(chan struct{}) stop := make(chan struct{})
defer close(stop)
var dashSrv *http.Server
var apiSrv *http.Server
registerShutdown(func() {
close(stop)
shutdownHTTPServer(dashSrv)
shutdownAPIServer(apiSrv)
if err := link.Close(); err != nil {
log.Printf("UART close: %v", err)
}
})
go runPoller(link, portName, hub, streamCtl, tapCtl, *interval, stop) go runPoller(link, portName, hub, streamCtl, tapCtl, *interval, stop)
go runBatteryPoller(link, hub, 5*time.Second, stop) go runBatteryPoller(link, hub, 5*time.Second, stop)
go runCacheStatusDashboardPoller(link, hub, *accelInterval, stop) go runCacheStatusDashboardPoller(link, hub, *accelInterval, stop)
var apiSrv *http.Server
if *apiAddr != "" { if *apiAddr != "" {
apiSrv = runAPIServer(portName, link, *apiAddr, *accelInterval, hub, streamCtl, tapCtl, stop) apiSrv = runAPIServer(portName, link, *apiAddr, *accelInterval, hub, streamCtl, tapCtl, stop)
defer shutdownAPIServer(apiSrv)
} }
mux := http.NewServeMux() mux := http.NewServeMux()
@ -81,5 +90,14 @@ func runServe(portName string, baud int, args []string) error {
if *apiAddr == "" { if *apiAddr == "" {
log.Printf("external API disabled (-api-addr \"\")") log.Printf("external API disabled (-api-addr \"\")")
} }
return http.ListenAndServe(*addr, mux)
dashSrv = &http.Server{Addr: *addr, Handler: mux}
go func() {
if err := dashSrv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Printf("dashboard server: %v", err)
}
}()
waitForShutdown()
return nil
} }

View File

@ -101,7 +101,7 @@ func (h *wsHub) setState(st DashboardState) {
return return
} }
for _, c := range conns { for _, c := range conns {
_ = c.WriteMessage(websocket.TextMessage, data) h.writeJSON(c, data)
} }
} }
@ -112,7 +112,7 @@ func (h *wsHub) register(c *websocket.Conn) {
h.mu.Unlock() h.mu.Unlock()
if data, err := json.Marshal(snap); err == nil { if data, err := json.Marshal(snap); err == nil {
_ = c.WriteMessage(websocket.TextMessage, data) h.writeJSON(c, data)
} }
} }
@ -122,6 +122,30 @@ func (h *wsHub) unregister(c *websocket.Conn) {
h.mu.Unlock() h.mu.Unlock()
} }
// writeJSON sends to one client; removes it on error or panic (closed connection).
func (h *wsHub) writeJSON(c *websocket.Conn, data []byte) {
defer func() {
if recover() != nil {
h.unregister(c)
}
}()
if err := c.WriteMessage(websocket.TextMessage, data); err != nil {
h.unregister(c)
}
}
func (h *wsHub) broadcastJSON(data []byte) {
h.mu.RLock()
conns := make([]*websocket.Conn, 0, len(h.clients))
for c := range h.clients {
conns = append(conns, c)
}
h.mu.RUnlock()
for _, c := range conns {
h.writeJSON(c, data)
}
}
func applyAccelSamples(clients []ClientView, samples []*pb.AccelSample) []ClientView { func applyAccelSamples(clients []ClientView, samples []*pb.AccelSample) []ClientView {
if len(samples) == 0 { if len(samples) == 0 {
return clients return clients
@ -338,7 +362,7 @@ func (h *wsHub) patchClientAccelStream(clientID uint32, enabled bool) {
return return
} }
for _, c := range conns { for _, c := range conns {
_ = c.WriteMessage(websocket.TextMessage, data) h.writeJSON(c, data)
} }
} }
@ -397,7 +421,7 @@ func (h *wsHub) patchLiveStream(enabled bool) {
return return
} }
for _, c := range conns { for _, c := range conns {
_ = c.WriteMessage(websocket.TextMessage, data) h.writeJSON(c, data)
} }
} }
@ -430,7 +454,7 @@ func (h *wsHub) patchClientTapNotify(clientID uint32, single, doubleTap, triple
return return
} }
for _, c := range conns { for _, c := range conns {
_ = c.WriteMessage(websocket.TextMessage, data) h.writeJSON(c, data)
} }
} }
@ -455,7 +479,7 @@ func (h *wsHub) mergeAccel(samples []*pb.AccelSample) {
return return
} }
for _, c := range conns { for _, c := range conns {
_ = c.WriteMessage(websocket.TextMessage, data) h.writeJSON(c, data)
} }
} }
@ -479,25 +503,16 @@ func (h *wsHub) mergeTap(events []*pb.TapEvent) {
return return
} }
for _, c := range conns { for _, c := range conns {
_ = c.WriteMessage(websocket.TextMessage, data) h.writeJSON(c, data)
} }
} }
func (h *wsHub) broadcastRaw(v any) { func (h *wsHub) broadcastRaw(v any) {
h.mu.RLock()
conns := make([]*websocket.Conn, 0, len(h.clients))
for c := range h.clients {
conns = append(conns, c)
}
h.mu.RUnlock()
data, err := json.Marshal(v) data, err := json.Marshal(v)
if err != nil { if err != nil {
return return
} }
for _, c := range conns { h.broadcastJSON(data)
_ = c.WriteMessage(websocket.TextMessage, data)
}
} }
func pollDashboard(link *managedSerial, portName string, last *DashboardState, streamCtl *accelStreamCtl, tapCtl *tapNotifyCtl) DashboardState { func pollDashboard(link *managedSerial, portName string, last *DashboardState, streamCtl *accelStreamCtl, tapCtl *tapNotifyCtl) DashboardState {
@ -575,6 +590,9 @@ func pollDashboard(link *managedSerial, portName string, last *DashboardState, s
func applyBatteryToState(link *managedSerial, st *DashboardState) { func applyBatteryToState(link *managedSerial, st *DashboardState) {
bat, err := link.BatteryStatusPoll(&pb.BatteryStatusRequest{AllClients: true}) bat, err := link.BatteryStatusPoll(&pb.BatteryStatusRequest{AllClients: true})
if errors.Is(err, errUARTBusy) {
return
}
if err != nil { if err != nil {
log.Printf("battery poll: %v", err) log.Printf("battery poll: %v", err)
return return
@ -602,7 +620,7 @@ func (h *wsHub) mergeBattery(samples []batterySampleJSON) {
return return
} }
for _, c := range conns { for _, c := range conns {
_ = c.WriteMessage(websocket.TextMessage, data) h.writeJSON(c, data)
} }
} }
@ -619,7 +637,7 @@ func runBatteryPoller(link *managedSerial, hub *wsHub, interval time.Duration, s
continue continue
} }
bat, err := link.BatteryStatusPoll(&pb.BatteryStatusRequest{AllClients: true}) bat, err := link.BatteryStatusPoll(&pb.BatteryStatusRequest{AllClients: true})
if err != nil { if errors.Is(err, errUARTBusy) || err != nil {
continue continue
} }
hub.mergeBattery(batterySamplesFromPB(bat.GetSamples())) hub.mergeBattery(batterySamplesFromPB(bat.GetSamples()))

View File

@ -1,15 +1,10 @@
# WebSocket API # WebSocket API
`go run . -port /dev/ttyUSB0 serve` exposes two WebSocket endpoints. They share the same UART link but serve different purposes. `go run . -port /dev/ttyUSB0 serve` exposes the WebSocket enpoint
| URL | Port (default) | Role | | URL | Port (default) | Role |
|-----|----------------|------| |-----|----------------|------|
| `ws://localhost:8080/ws` | Dashboard (`-addr`) | Server → client only: full `DashboardState` JSON (~2 s poll + live-stream accel/tap) | | `ws://localhost:8081/ws` | External API (`-api-addr`) | Request/response commands + optional **input** push stream |
| `ws://localhost:8081/ws` | External API (`-api-addr`) | Request/response commands + optional **accel** / **tap** push streams |
Disable the external server with `-api-addr ""`.
CLI overview and UART commands: [`../README.md`](../README.md). HTTP endpoints: [`API_REST.md`](API_REST.md).
--- ---
@ -19,44 +14,49 @@ CLI overview and UART commands: [`../README.md`](../README.md). HTTP endpoints:
1. Connect → server sends **`hello`** (receive off; lists available commands). 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). 2. Send JSON commands → server replies with a matching `*_status` or `client_list` message (one reply per command).
3. After `set_stream` / `set_tap_stream` with `enable: true`, the server may send **`accel`** and/or **`tap`** messages **without** a prior command (push stream). 3. After `set_stream` with `enable: true`, the server may send **`input`** messages **without** a prior command (push stream).
Commands and stream pushes are multiplexed on one socket. While streaming, always parse `type` and branch (status vs sample vs error). Commands and stream pushes are multiplexed on one socket. While streaming, always parse `type` and branch (status vs sample vs error).
### Two layers (accel and tap) ### Two layers (firmware vs host)
| Layer | Commands | Effect | | Layer | Commands | Effect |
|-------|----------|--------| |-------|----------|--------|
| **Firmware (ESP-NOW)** | `set_accel_stream`, `set_tap_notify` | Per `client_id`: slave sends accel or tap kinds to the master | | **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`, `set_tap_stream` | Whether **you** receive push JSON and at what rate (`interval_ms`, 1 ms … 10 s) | | **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`) |
- **Accel UART polling** runs only if at least one connection has `receive_accel: true` **and** at least one slave streams accel (`set_accel_stream` or dashboard). - **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`).
- **Tap UART polling** runs only if at least one connection has `receive_tap: true` (`set_tap_stream`). `set_tap_notify` alone does **not** poll. - **`set_tap_notify` alone** configures which tap kinds the slave reports; it does **not** enable host push by itself — you still need `set_stream`.
Typical sequence: Typical sequence:
1. `list_clients` → slave IDs 1. `list_clients` → slave IDs
2. Per slave: `set_accel_stream` / `set_tap_notify` as needed 2. Per slave: `set_input_stream` and/or `set_tap_notify` as needed
3. `set_stream` and/or `set_tap_stream` with `"enable": true` 3. `set_stream` with `"enable": true`
4. Read push messages in a loop 4. Read **`input`** messages in a loop
There is **no per-slave filter** on push messages: each `accel` contains all cached slaves; each `tap` contains all visible events. Filter by `client_id` in your app. There is **no per-slave filter** on push messages: each `input` contains all cached slaves. Filter by `client_id` in your app.
--- ---
## Push stream messages ## Push stream messages
These are the samples you get after enabling receive. Interval is per WebSocket connection; the server UART poll uses the **minimum** `interval_ms` among all subscribers that want accel or tap. These are the samples you get after enabling receive. Timing is per WebSocket connection:
### `accel` (type `"accel"`) - **`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.
Sent only when `set_stream` has `enable: true`, a slave streams accel, and the poll tick fires for this connection. The server UART poll uses the **minimum** `interval_ms` among all subscribers with `receive_input: true`.
**Success** — all slaves with a cache entry on the master (not only those with `valid: 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`):
```json ```json
{ {
"type": "accel", "type": "input",
"t": 1716900123456789012, "t": 1716900123456789012,
"success": true, "success": true,
"clients": [ "clients": [
@ -66,11 +66,14 @@ Sent only when `set_stream` has `enable: true`, a slave streams accel, and the p
"x": 12, "x": 12,
"y": -34, "y": -34,
"z": 16384, "z": 16384,
"age_ms": 8 "accel_age_ms": 8,
"tap_kind": "single",
"tap_age_ms": 3
}, },
{ {
"client_id": 42, "client_id": 42,
"valid": false "valid": false,
"tap_kind": "none"
} }
] ]
} }
@ -82,15 +85,19 @@ Sent only when `set_stream` has `enable: true`, a slave streams accel, and the p
| `success` | `true` if `CACHE_STATUS` succeeded | | `success` | `true` if `CACHE_STATUS` succeeded |
| `clients[]` | One entry per slave slot in the master cache | | `clients[]` | One entry per slave slot in the master cache |
| `client_id` | ESP-NOW client id (same as `list_clients`) | | `client_id` | ESP-NOW client id (same as `list_clients`) |
| `valid` | `false` if no sample yet or stale; omit `x`/`y`/`z` when false | | `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) | | `x`, `y`, `z` | Raw accelerometer LSB (BMA456, ±2 g scale on the pod) |
| `age_ms` | Milliseconds since the master received this sample | | `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.
**Failure** (e.g. UART busy): **Failure** (e.g. UART busy):
```json ```json
{ {
"type": "accel", "type": "input",
"t": 1716900123456789012, "t": 1716900123456789012,
"success": false, "success": false,
"error": "uart busy" "error": "uart busy"
@ -99,53 +106,6 @@ Sent only when `set_stream` has `enable: true`, a slave streams accel, and the p
No `clients` array on failure. No `clients` array on failure.
### `tap` (type `"tap"`)
Sent only when `set_tap_stream` has `enable: true` and there is at least one event to show.
Events appear when the master cache reports a new tap. Each event stays in push payloads for **`tap_display_min_ms`** (2000 ms, also in `hello`) after the API first saw it, even if the hardware age grows.
**Success**:
```json
{
"type": "tap",
"t": 1716900123456789012,
"success": true,
"events": [
{
"client_id": 16,
"valid": true,
"kind": "single",
"age_ms": 3,
"shown_at_ms": 1717000000123
}
]
}
```
| Field | Meaning |
|-------|---------|
| `t` | Unix timestamp in **nanoseconds** (poll time) |
| `events[]` | All taps currently “on screen” for the API |
| `client_id` | Slave that tapped |
| `kind` | `"single"`, `"double"`, or `"triple"` |
| `age_ms` | Age in the master cache when read |
| `shown_at_ms` | Unix **milliseconds** when this host first included the event |
If no events are visible, **no** `tap` message is sent on that tick (unlike accel, which can send empty `clients` only on success with cache data).
**Failure**:
```json
{
"type": "tap",
"t": 1716900123456789012,
"success": false,
"error": "uart busy"
}
```
--- ---
## Commands (request → response) ## Commands (request → response)
@ -159,13 +119,13 @@ Send one JSON object per message. Field `type` selects the command.
"type": "hello", "type": "hello",
"serial_port": "/dev/ttyUSB0", "serial_port": "/dev/ttyUSB0",
"interval_ms": 16, "interval_ms": 16,
"pre_fetch_ms": 2,
"tap_display_min_ms": 2000, "tap_display_min_ms": 2000,
"note": "set_tap_notify configures slave S/D/T only; set_tap_stream enables tap polling/push", "note": "set_tap_notify configures slave S/D/T only; set_stream enables input polling/push on this connection",
"commands": [ "commands": [
"list_clients", "list_clients",
"set_stream", "get_stream", "set_stream", "get_stream",
"set_accel_stream", "get_accel_stream", "set_input_stream", "get_input_stream",
"set_tap_stream", "get_tap_stream",
"set_tap_notify", "get_tap_notify", "set_tap_notify", "get_tap_notify",
"set_led_ring", "get_battery" "set_led_ring", "get_battery"
] ]
@ -191,7 +151,7 @@ Response `client_list`:
"used": true, "used": true,
"last_ping": 1234, "last_ping": 1234,
"last_success_ping": 1200, "last_success_ping": 1200,
"accel_stream": false, "input_stream": false,
"tap_notify_single": false, "tap_notify_single": false,
"tap_notify_double": false, "tap_notify_double": false,
"tap_notify_triple": false "tap_notify_triple": false
@ -200,45 +160,38 @@ Response `client_list`:
} }
``` ```
### `set_stream` / `get_stream` (receive accel on this connection) ### `set_stream` / `get_stream` (receive input on this connection)
```json ```json
{"type":"set_stream","enable":true,"interval_ms":32} {"type":"set_stream","enable":true,"interval_ms":32,"pre_fetch":2}
{"type":"get_stream"} {"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`: Response `stream_status`:
```json ```json
{"type":"stream_status","receive_accel":true,"interval_ms":32,"success":true} {"type":"stream_status","receive_input":true,"interval_ms":32,"pre_fetch":2,"success":true}
``` ```
### `set_accel_stream` / `get_accel_stream` (firmware, per slave) ### `set_input_stream` / `get_input_stream` (firmware, per slave)
`client_id` required (> 0). `client_id` required (> 0). Enables accel streaming from the slave to the master.
```json ```json
{"type":"set_accel_stream","client_id":16,"enable":true} {"type":"set_input_stream","client_id":16,"enable":true}
{"type":"get_accel_stream","client_id":16} {"type":"get_input_stream","client_id":16}
``` ```
Response `accel_stream_status`: Response `input_stream_status`:
```json ```json
{"type":"accel_stream_status","client_id":16,"enabled":true,"success":true} {"type":"input_stream_status","client_id":16,"enabled":true,"success":true}
```
### `set_tap_stream` / `get_tap_stream` (receive tap on this connection)
```json
{"type":"set_tap_stream","enable":true,"interval_ms":16}
{"type":"get_tap_stream"}
```
Response `tap_stream_status`:
```json
{"type":"tap_stream_status","receive_tap":true,"interval_ms":16,"success":true}
``` ```
### `set_tap_notify` / `get_tap_notify` (firmware, per slave) ### `set_tap_notify` / `get_tap_notify` (firmware, per slave)
@ -266,83 +219,55 @@ Response `tap_notify_status`:
### `set_led_ring` ### `set_led_ring`
Same JSON body as [`POST /api/led-ring`](API_REST.md#led-ring) with `"type":"set_led_ring"` added. Reply: `led_ring_status`. 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` 0100 |
| `digit` | `digit` 010 |
| `blink` | `blink_ms`, `blink_count` |
| `find-me` | Locate pod |
Use `client_id` (`0` = master) or `all_clients` (+ optional `slaves_only`) for broadcast.
Response `led_ring_status`:
```json
{"type":"led_ring_status","success":true,"mode":5,"client_id":16,"slaves_updated":1}
```
### `get_battery` ### `get_battery`
Body: `{"type":"get_battery","all_clients":true}` or `"client_id":16`. Default if omitted: all clients. Read cached battery samples from the master. Slaves push battery every **30 s**; this command reads the master cache.
Reply: `battery_status` with `samples[]` (see REST doc). ```json
{"type":"get_battery","all_clients":true}
--- {"type":"get_battery","client_id":16}
## Examples
### Accel stream
```python
import asyncio, json, websockets
async def main():
async with websockets.connect("ws://127.0.0.1:8081/ws") as ws:
print(await ws.recv()) # hello
await ws.send(json.dumps({"type": "list_clients"}))
clients = json.loads(await ws.recv())["clients"]
for c in clients:
if not c.get("available"):
continue
await ws.send(json.dumps({
"type": "set_accel_stream", "client_id": c["id"], "enable": True
}))
await ws.recv() # accel_stream_status
await ws.send(json.dumps({"type": "set_stream", "enable": True, "interval_ms": 16}))
await ws.recv() # stream_status
while True:
msg = json.loads(await ws.recv())
if msg.get("type") != "accel":
continue
if not msg.get("success"):
print("error:", msg.get("error"))
continue
for c in msg.get("clients", []):
if c.get("valid"):
print(c["client_id"], c["x"], c["y"], c["z"], "age", c.get("age_ms"))
asyncio.run(main())
``` ```
### Tap stream Default if omitted: all clients.
```python Response `battery_status`:
import asyncio, json, websockets
async def main(): ```json
async with websockets.connect("ws://127.0.0.1:8081/ws") as ws: {
print(await ws.recv()) # hello "type": "battery_status",
await ws.send(json.dumps({ "success": true,
"type": "set_tap_notify", "client_id": 16, "samples": [
"single": True, "double_tap": False, "triple": False {
})) "client_id": 16,
await ws.recv() # tap_notify_status "lipo1": {"valid": true, "voltage_mv": 3850, "percent": 71},
await ws.send(json.dumps({"type": "set_tap_stream", "enable": True, "interval_ms": 16})) "lipo2": {"valid": false},
await ws.recv() # tap_stream_status "age_ms": 1200
while True: }
msg = json.loads(await ws.recv()) ]
if msg.get("type") == "tap" and msg.get("events"): }
for e in msg["events"]:
print(e["client_id"], e["kind"], "age", e.get("age_ms"))
asyncio.run(main())
``` ```
---
## Dashboard WebSocket (`:8080/ws`)
Read-only from the browsers perspective: the server pushes JSON whenever state changes. Clients do not send commands on this socket (messages are ignored).
Payload shape: `DashboardState``updated_at`, `serial_port`, `uart_connected`, `live_stream`, `master`, `clients[]` (id, mac, accel, tap notify flags, battery, etc.). Accel/tap samples appear here when **Live stream** is enabled in the UI (`PUT /api/live-stream`).
During OTA, additional messages with `"type":"ota_progress"` may appear on the same socket.
Configure slaves via REST on `:8080` ([`API_REST.md`](API_REST.md)), not via this WebSocket.

View File

@ -62,6 +62,8 @@ func main() {
if err != nil { if err != nil {
log.Fatalf("open serial: %v", err) log.Fatalf("open serial: %v", err)
} }
registerShutdown(func() { _ = sp.Close() })
enableShutdownOnInterrupt()
defer sp.Close() defer sp.Close()
switch cmd { switch cmd {
case "version": case "version":

View File

@ -2,7 +2,6 @@ package main
import ( import (
"fmt" "fmt"
"log"
"time" "time"
"google.golang.org/protobuf/proto" "google.golang.org/protobuf/proto"
@ -69,16 +68,45 @@ const (
) )
func runOTAUpload(m *managedSerial, firmware []byte, onProgress otaProgressFn) error { func runOTAUpload(m *managedSerial, firmware []byte, onProgress otaProgressFn) error {
push := func(phase, msg string) {
if onProgress == nil {
return
}
onProgress(OTAProgress{
Type: "ota_progress", Phase: phase, Step: otaStepMaster,
Percent: 0, Message: msg, MasterMessage: msg,
})
}
push("preparing", "UART wird 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.
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() if m.otaActive {
err := runOTAOnPortUnlocked(m, firmware, onProgress) m.mu.Unlock()
return errOTAInProgress
}
m.otaActive = true
if m.sp == nil {
if err := m.openLocked(); err != nil {
m.otaActive = false
m.mu.Unlock()
push("error", err.Error())
return err
}
}
sp := m.sp
err := runOTAOnPortUnlocked(sp, firmware, onProgress)
if err != nil { if err != nil {
m.invalidateLocked(err) m.invalidateLocked(err)
} }
m.otaActive = false
m.mu.Unlock()
return err return err
} }
func runOTAOnPortUnlocked(m *managedSerial, firmware []byte, onProgress otaProgressFn) error { func runOTAOnPortUnlocked(sp *serialPort, firmware []byte, onProgress otaProgressFn) error {
if len(firmware) == 0 { if len(firmware) == 0 {
return fmt.Errorf("empty firmware") return fmt.Errorf("empty firmware")
} }
@ -120,32 +148,31 @@ func runOTAOnPortUnlocked(m *managedSerial, firmware []byte, onProgress otaProgr
onProgress(p) onProgress(p)
} }
if m.sp == nil { if err := sp.port.SetReadTimeout(readTimeout); err != nil {
if err := m.openLocked(); err != nil {
notify("error", "", 0, err.Error()) notify("error", "", 0, err.Error())
return err return err
} }
}
sp := m.sp
if err := sp.port.SetReadTimeout(otaPrepareTimeout); err != nil {
notify("error", "", 0, err.Error())
return err
}
defer sp.port.SetReadTimeout(readTimeout)
notify("preparing", otaStepMaster, 0, fmt.Sprintf("Master: OTA start (%d bytes)…", imageSize)) notify("preparing", otaStepMaster, 0, fmt.Sprintf("Master: OTA start (%d bytes)…", imageSize))
flushSerialInput(sp)
if err := writeUartMessage(sp, &pb.UartMessage{ if err := writeUartMessage(sp, &pb.UartMessage{
Type: pb.MessageType_OTA_START, Type: pb.MessageType_OTA_START,
Payload: &pb.UartMessage_OtaStart{ Payload: &pb.UartMessage_OtaStart{
OtaStart: &pb.OtaStartPayload{TotalSize: uint32(imageSize)}, OtaStart: &pb.OtaStartPayload{TotalSize: uint32(imageSize)},
}, },
}, false); err != nil { }); err != nil {
notify("error", "", 0, err.Error()) notify("error", "", 0, err.Error())
return err 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(sp, otaStReady, otaPrepareTimeout, func(msg string) {
notify("preparing", otaStepMaster, 2, msg) notify("preparing", otaStepMaster, 2, msg)
}) })
@ -179,7 +206,7 @@ func runOTAOnPortUnlocked(m *managedSerial, firmware []byte, onProgress otaProgr
Payload: &pb.UartMessage_OtaPayload{ Payload: &pb.UartMessage_OtaPayload{
OtaPayload: &pb.OtaPayload{Seq: seq, Data: chunk}, OtaPayload: &pb.OtaPayload{Seq: seq, Data: chunk},
}, },
}, false); err != nil { }); err != nil {
notify("error", "", 0, err.Error()) notify("error", "", 0, err.Error())
return err return err
} }
@ -219,7 +246,7 @@ func runOTAOnPortUnlocked(m *managedSerial, firmware []byte, onProgress otaProgr
Payload: &pb.UartMessage_OtaEnd{ Payload: &pb.UartMessage_OtaEnd{
OtaEnd: &pb.OtaEndPayload{}, OtaEnd: &pb.OtaEndPayload{},
}, },
}, false); err != nil { }); err != nil {
notify("error", "", 0, err.Error()) notify("error", "", 0, err.Error())
return err return err
} }
@ -333,7 +360,7 @@ func queryOtaSlaveProgressLocked(sp *serialPort, clientID uint32,
}, },
}, },
} }
if err := writeUartMessage(sp, req, false); err != nil { if err := writeUartMessage(sp, req); err != nil {
return nil, err return nil, err
} }
if queryTimeout <= 0 { if queryTimeout <= 0 {
@ -389,7 +416,7 @@ func readUartMessageUntil(sp *serialPort, deadline time.Time, want pb.MessageTyp
if err := sp.port.SetReadTimeout(wait); err != nil { if err := sp.port.SetReadTimeout(wait); err != nil {
return nil, err return nil, err
} }
payload, err := uartframe.ReadFrame(sp.port, nil) payload, err := uartframe.ReadFrame(sp.port, nil, wait)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -487,14 +514,11 @@ func waitOtaComplete(sp *serialPort, timeout time.Duration,
} }
} }
func writeUartMessage(sp *serialPort, msg *pb.UartMessage, logFrame bool) error { func writeUartMessage(sp *serialPort, msg *pb.UartMessage) error {
frame, err := encodeUartMessage(msg) frame, err := encodeUartMessage(msg)
if err != nil { if err != nil {
return err return err
} }
if logFrame {
log.Printf("sending %s (%d frame bytes)", msg.Type, len(frame))
}
_, err = sp.port.Write(frame) _, err = sp.port.Write(frame)
return err return err
} }
@ -505,12 +529,24 @@ func waitOtaStatus(sp *serialPort, want uint32, timeout time.Duration, onPrepari
if time.Now().After(deadline) { if time.Now().After(deadline) {
return nil, fmt.Errorf("timeout waiting for OTA status %d", want) return nil, fmt.Errorf("timeout waiting for OTA status %d", want)
} }
if err := sp.port.SetReadTimeout(time.Until(deadline)); err != nil { readWait := time.Until(deadline)
if readWait > otaStatusPollTimeout {
readWait = otaStatusPollTimeout
}
if err := sp.port.SetReadTimeout(readWait); err != nil {
return nil, err return nil, err
} }
st, err := readOtaStatus(sp) payload, err := uartframe.ReadFrame(sp.port, nil, readWait)
if err != nil { if err != nil {
return nil, err continue
}
msg, err := decodeUartPayload(payload)
if err != nil || msg.GetType() != pb.MessageType_OTA_STATUS {
continue
}
st := msg.GetOtaStatus()
if st == nil {
continue
} }
switch st.GetStatus() { switch st.GetStatus() {
case want: case want:
@ -526,7 +562,7 @@ func waitOtaStatus(sp *serialPort, want uint32, timeout time.Duration, onPrepari
} }
func readOtaStatus(sp *serialPort) (*pb.OtaStatusPayload, error) { func readOtaStatus(sp *serialPort) (*pb.OtaStatusPayload, error) {
payload, err := uartframe.ReadFrame(sp.port, nil) payload, err := uartframe.ReadFrame(sp.port, nil, readTimeout)
if err != nil { if err != nil {
return nil, fmt.Errorf("read response: %w", err) return nil, fmt.Errorf("read response: %w", err)
} }
@ -553,6 +589,22 @@ func encodeUartMessage(msg *pb.UartMessage) ([]byte, error) {
return uartframe.EncodeFrame(payload) return uartframe.EncodeFrame(payload)
} }
// flushSerialInput drops stale RX bytes (not full frames — avoids ReadFrame blocking).
func flushSerialInput(sp *serialPort) {
if sp == nil {
return
}
_ = sp.port.SetReadTimeout(10 * time.Millisecond)
buf := make([]byte, 256)
deadline := time.Now().Add(50 * time.Millisecond)
for time.Now().Before(deadline) {
n, err := sp.port.Read(buf)
if n == 0 || err != nil {
return
}
}
}
func decodeUartPayload(payload []byte) (*pb.UartMessage, error) { func decodeUartPayload(payload []byte) (*pb.UartMessage, error) {
if len(payload) == 0 { if len(payload) == 0 {
return nil, fmt.Errorf("empty response") return nil, fmt.Errorf("empty response")

View File

@ -11,6 +11,9 @@ import (
// errUARTBusy is returned when the port is held for OTA (poller should not treat as unplug). // 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)") 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. // managedSerial keeps the UART open and reconnects after I/O failures or unplug.
type managedSerial struct { type managedSerial struct {
portName string portName string
@ -19,6 +22,7 @@ type managedSerial struct {
mu sync.Mutex mu sync.Mutex
sp *serialPort sp *serialPort
otaActive bool // UART held for firmware upload; poll/API must not interleave
} }
func newManagedSerial(portName string, baud int) *managedSerial { func newManagedSerial(portName string, baud int) *managedSerial {
@ -76,20 +80,17 @@ func (m *managedSerial) withPort(fn func(*serialPort) error) error {
return m.withPortLocked(false, fn) return m.withPortLocked(false, fn)
} }
// withPortPoll is like withPort but returns errUARTBusy instead of blocking during OTA. // withPortPoll is like withPort but returns errUARTBusy during OTA (no TryLock race).
func (m *managedSerial) withPortPoll(fn func(*serialPort) error) error { func (m *managedSerial) withPortPoll(fn func(*serialPort) error) error {
return m.withPortLocked(true, fn) return m.withPortLocked(true, fn)
} }
func (m *managedSerial) withPortLocked(try bool, fn func(*serialPort) error) error { func (m *managedSerial) withPortLocked(poll bool, fn func(*serialPort) error) error {
if try { m.mu.Lock()
if !m.mu.TryLock() { defer m.mu.Unlock()
if m.otaActive {
return errUARTBusy return errUARTBusy
} }
} else {
m.mu.Lock()
}
defer m.mu.Unlock()
if m.sp == nil { if m.sp == nil {
if err := m.openLocked(); err != nil { if err := m.openLocked(); err != nil {
@ -104,6 +105,26 @@ func (m *managedSerial) withPortLocked(try bool, fn func(*serialPort) error) err
return 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) { func (m *managedSerial) exchangePayload(payload []byte, cmdName string) ([]byte, error) {
return m.exchangePayloadVia(m.withPort, payload, cmdName) return m.exchangePayloadVia(m.withPort, payload, cmdName)
} }

View File

@ -80,7 +80,7 @@ func (s *serialPort) exchangePayloadLocked(payload []byte, cmdName string, timeo
} }
defer func() { _ = s.port.SetReadTimeout(readTimeout) }() defer func() { _ = s.port.SetReadTimeout(readTimeout) }()
respPayload, err := uartframe.ReadFrame(s.port, nil) respPayload, err := uartframe.ReadFrame(s.port, nil, timeout)
if err != nil { if err != nil {
return nil, fmt.Errorf("read response: %w", err) return nil, fmt.Errorf("read response: %w", err)
} }
@ -113,7 +113,7 @@ func (s *serialPort) exchangeLocked(cmdID byte, cmdName string) ([]byte, error)
return nil, fmt.Errorf("write: %w", err) return nil, fmt.Errorf("write: %w", err)
} }
payload, err := uartframe.ReadFrame(s.port, nil) payload, err := uartframe.ReadFrame(s.port, nil, readTimeout)
if err != nil { if err != nil {
return nil, fmt.Errorf("read response: %w", err) return nil, fmt.Errorf("read response: %w", err)
} }

75
goTool/shutdown.go Normal file
View File

@ -0,0 +1,75 @@
package main
import (
"context"
"log"
"net/http"
"os"
"os/signal"
"sync"
"syscall"
"time"
)
var (
shutdownMu sync.Mutex
shutdownFns []func()
hooksOnce sync.Once
bgHandlerOnce sync.Once
)
// registerShutdown runs fn on SIGINT/SIGTERM (LIFO order).
func registerShutdown(fn func()) {
shutdownMu.Lock()
shutdownFns = append(shutdownFns, fn)
shutdownMu.Unlock()
}
func runShutdownHooks() {
hooksOnce.Do(func() {
shutdownMu.Lock()
fns := shutdownFns
shutdownMu.Unlock()
for i := len(fns) - 1; i >= 0; i-- {
fns[i]()
}
})
}
// enableShutdownOnInterrupt listens for SIGINT/SIGTERM in the background and exits
// after running shutdown hooks. Use for one-shot CLI commands (OTA, etc.).
func enableShutdownOnInterrupt() {
bgHandlerOnce.Do(func() {
ch := make(chan os.Signal, 1)
signal.Notify(ch, os.Interrupt, syscall.SIGTERM)
go func() {
sig := <-ch
signal.Stop(ch)
log.Printf("received %v, shutting down…", sig)
runShutdownHooks()
os.Exit(0)
}()
})
}
// waitForShutdown blocks until SIGINT/SIGTERM, runs hooks, and returns.
// Use for long-running servers (serve/dashboard).
func waitForShutdown() {
ch := make(chan os.Signal, 1)
signal.Notify(ch, os.Interrupt, syscall.SIGTERM)
sig := <-ch
signal.Stop(ch)
log.Printf("received %v, shutting down…", sig)
runShutdownHooks()
}
func shutdownHTTPServer(srv *http.Server) {
if srv == nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Printf("HTTP shutdown: %v", err)
}
}

View File

@ -4,12 +4,14 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"time"
) )
const ( const (
StartMarker = 0xAA StartMarker = 0xAA
StopMarker = 0xCC StopMarker = 0xCC
MaxPayload = 252 // Must match main/uart.h MAX_PAYLOAD_SIZE (MAX_BUF_SIZE - 4).
MaxPayload = 248
) )
var ( var (
@ -97,13 +99,22 @@ func (p *Parser) Feed(b byte) (payload []byte, ok bool, err error) {
} }
// ReadFrame reads bytes from r until one full frame is parsed or an error occurs. // ReadFrame reads bytes from r until one full frame is parsed or an error occurs.
func ReadFrame(r io.Reader, buf []byte) ([]byte, 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 { if buf == nil {
buf = make([]byte, 256) buf = make([]byte, 256)
} }
parser := NewParser() parser := NewParser()
var deadline time.Time
if maxWait > 0 {
deadline = time.Now().Add(maxWait)
}
for { for {
if !deadline.IsZero() && !time.Now().Before(deadline) {
return nil, ErrTimeout
}
n, err := r.Read(buf) n, err := r.Read(buf)
if n > 0 { if n > 0 {
for i := 0; i < n; i++ { for i := 0; i < n; i++ {

View File

@ -938,8 +938,21 @@
return rows; return rows;
}, },
applyOTAProgress(p) { applyOTAProgress(p) {
this.ota.phase = p.phase || ''; const prevPhase = this.ota.phase;
this.ota.step = p.step || this.ota.step || ''; const prevStep = this.ota.step;
if (p.phase) {
// Ignore out-of-order master upload updates after distribution started.
if (!(p.phase === 'uploading' && prevPhase === 'distributing')) {
this.ota.phase = p.phase;
}
}
if (p.step) {
if (!(p.step === 'master' && (prevStep === 'slaves' || prevPhase === 'distributing'))) {
this.ota.step = p.step;
}
} else if (!this.ota.step) {
this.ota.step = '';
}
this.ota.percent = p.percent ?? this.ota.percent; this.ota.percent = p.percent ?? this.ota.percent;
this.ota.message = p.message || ''; this.ota.message = p.message || '';
if (p.image_size) this.ota.imageSize = p.image_size; if (p.image_size) this.ota.imageSize = p.image_size;

View File

@ -31,8 +31,12 @@ idf_component_register(
"cmd/cmd_ota_slave_progress.c" "cmd/cmd_ota_slave_progress.c"
"ota_uart.c" "ota_uart.c"
"ota_espnow.c" "ota_espnow.c"
"ota_session.c"
"client_registry.c" "client_registry.c"
"esp_now_comm.c" "esp_now_comm.c"
"esp_now_core.c"
"esp_now_master.c"
"esp_now_slave.c"
"esp_now_proto.c" "esp_now_proto.c"
"bosch456.c" "bosch456.c"
"board_input.c" "board_input.c"

View File

@ -2,6 +2,8 @@
ESP32-S3 firmware for Powerpod nodes. Master and slave devices run the **same binary**; role and ESP-NOW network are selected at boot via DIP switches and an I2C IO expander. ESP32-S3 firmware for Powerpod nodes. Master and slave devices run the **same binary**; role and ESP-NOW network are selected at boot via DIP switches and an I2C IO expander.
**Architektur & ESP-only Doku (ohne goTool):** [`../docs/ARCHITECTURE.md`](../docs/ARCHITECTURE.md) (Datenflüsse UART/Commands/ESP-NOW), [`../docs/DOCUMENTATION.md`](../docs/DOCUMENTATION.md) (vollständige Referenz).
## System overview ## System overview
``` ```
@ -176,7 +178,7 @@ Logging:
## Command handler ## Command handler
Generic dispatch for host commands (UART today; `msg_post()` for in-firmware sources later). Generic dispatch for host commands over UART only.
``` ```
UART → generic_msg_t queue → vCmdDispatcherTask → registered handler UART → generic_msg_t queue → vCmdDispatcherTask → registered handler
@ -186,7 +188,8 @@ UART → generic_msg_t queue → vCmdDispatcherTask → registered handler
|-----|-------------| |-----|-------------|
| `init_cmdHandler(queue)` | Start dispatcher task (priority 5) | | `init_cmdHandler(queue)` | Start dispatcher task (priority 5) |
| `msg_register_handler(id, cb)` | Register callback; max 32 handlers | | `msg_register_handler(id, cb)` | Register callback; max 32 handlers |
| `msg_post(id, data, len)` | Enqueue from firmware (e.g. future ESP-NOW → PC path) |
During an OTA session (`ota_session_busy()`), the dispatcher rejects all UART commands except OTA_* and `OTA_SLAVE_PROGRESS` (see `ota_session.c`).
```c ```c
typedef void (*msg_callback_t)(const uint8_t *data, size_t len); typedef void (*msg_callback_t)(const uint8_t *data, size_t len);
@ -517,7 +520,10 @@ Target: ESP32-S3. Close serial monitor on the UART adapter port before running `
| `powerpod.c` | `app_main`, DIP/network config, init order | | `powerpod.c` | `app_main`, DIP/network config, init order |
| `powerpod.h` | Pin defines | | `powerpod.h` | Pin defines |
| `app_config.h` | `app_config_t` | | `app_config.h` | `app_config_t` |
| `esp_now_comm.c/h` | WiFi, ESP-NOW, discover / slave info / OTA send | | `esp_now_comm.c/h` | ESP-NOW init and recv router |
| `esp_now_core.c/h` | Shared WiFi, peer, send |
| `esp_now_master.c/h` | Master discover, monitor, unicast |
| `esp_now_slave.c/h` | Slave join, heartbeat, telemetry |
| `ota_uart.c/h` | Shared 4 KiB OTA flash buffer (UART + ESP-NOW) | | `ota_uart.c/h` | Shared 4 KiB OTA flash buffer (UART + ESP-NOW) |
| `ota_espnow.c/h` | Master: distribute staged image to slaves | | `ota_espnow.c/h` | Master: distribute staged image to slaves |
| `cmd/cmd_ota.c/h` | UART OTA command handlers (master only) | | `cmd/cmd_ota.c/h` | UART OTA command handlers (master only) |

View File

@ -38,6 +38,7 @@ static volatile bool s_int_pending;
static SemaphoreHandle_t s_accel_mutex; static SemaphoreHandle_t s_accel_mutex;
static bma456_tap_handler_t s_tap_handler; static bma456_tap_handler_t s_tap_handler;
static void *s_tap_handler_ctx; static void *s_tap_handler_ctx;
static const bma456_tap_config_t s_tap_config = BMA456_TAP_CONFIG_DEFAULT;
static esp_err_t check_bma4(const char *api_name, int8_t rslt); static esp_err_t check_bma4(const char *api_name, int8_t rslt);
@ -263,19 +264,48 @@ static esp_err_t configure_tap_interrupt(void) {
if (check_bma4("bma456h_tap_get_parameter", ret) != ESP_OK) { if (check_bma4("bma456h_tap_get_parameter", ret) != ESP_OK) {
return ESP_FAIL; return ESP_FAIL;
} }
tap_settings.tap_sens_thres = 0; 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); ret = bma456h_tap_set_parameter(&tap_settings, &s_bma456);
if (check_bma4("bma456h_tap_set_parameter", ret) != ESP_OK) { if (check_bma4("bma456h_tap_set_parameter", ret) != ESP_OK) {
return ESP_FAIL; return ESP_FAIL;
} }
ret = bma456h_feature_enable( uint16_t tap_features = 0;
(BMA456H_SINGLE_TAP_EN | BMA456H_DOUBLE_TAP_EN | BMA456H_TRIPLE_TAP_EN), if (s_tap_config.enable_single) {
BMA4_ENABLE, &s_bma456); 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) { if (check_bma4("bma456h_feature_enable", ret) != ESP_OK) {
return ESP_FAIL; 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, ret = bma456h_map_interrupt(int_line, BMA456H_TAP_OUT_INT, BMA4_ENABLE,
&s_bma456); &s_bma456);
if (check_bma4("bma456h_map_interrupt", ret) != ESP_OK) { if (check_bma4("bma456h_map_interrupt", ret) != ESP_OK) {

View File

@ -10,6 +10,8 @@
#include "driver/i2c_types.h" #include "driver/i2c_types.h"
#include "esp_err.h" #include "esp_err.h"
#include <stdbool.h>
#include <stdint.h>
/** 7-bit I2C address (SDO low). */ /** 7-bit I2C address (SDO low). */
#define BMA456_I2C_ADDR 0x18 #define BMA456_I2C_ADDR 0x18
@ -20,6 +22,40 @@
/** Software filter: log accel only when |axis - last| > deadzone (raw LSB). */ /** Software filter: log accel only when |axis - last| > deadzone (raw LSB). */
#define BMA456_DEFAULT_ACCEL_DEADZONE 100u #define BMA456_DEFAULT_ACCEL_DEADZONE 100u
/**
* BMA456H multitap tuning (see BST-BMA456-AN000).
*
* Time fields use register units: value × 5 ms (e.g. 100 500 ms).
* tap_sens_thres: 0 = most sensitive 15 = least (~78 mg per LSB).
* axis_sel: 0 = X, 1 = Y, 2 = Z.
* wait_for_timeout: 0 = report immediately, 1 = wait max_gest_dur for classification.
*/
typedef struct {
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;
bool enable_single;
bool enable_double;
bool enable_triple;
} bma456_tap_config_t;
/** Edit these values to tune tap detection, then rebuild. */
#define BMA456_TAP_CONFIG_DEFAULT \
{ \
.tap_sens_thres = 5, /* sensitive; 0=max, Bosch default=9 */ \
.max_gest_dur = 100, /* 500 ms window for double/triple */ \
.tap_shock_dur = 6, /* 30 ms debounce after each impulse */ \
.quite_time_after_gest = 60, /* 300 ms min gap between gestures */ \
.wait_for_timeout = 0, /* 0 = faster single-tap response */ \
.axis_sel = 2, /* Z axis */ \
.enable_single = true, \
.enable_double = true, \
.enable_triple = true, \
}
/** /**
* Probe and configure the sensor on bus_handle (100 kHz device). * 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; * On failure the device is removed and ESP_ERR_NOT_FOUND / ESP_FAIL is returned;

View File

@ -33,12 +33,20 @@ static void handle_accel_stream(const uint8_t *data, size_t len) {
alox_UartMessage uart_msg; alox_UartMessage uart_msg;
alox_AccelStreamRequest req = alox_AccelStreamRequest_init_zero; alox_AccelStreamRequest req = alox_AccelStreamRequest_init_zero;
if (uart_cmd_decode(data, len, &uart_msg) == ESP_OK) { if (len > 0) {
if (uart_cmd_decode(data, len, &uart_msg) != ESP_OK) {
ESP_LOGW(TAG, "decode failed");
reply(false, 0, false, 0);
return;
}
const alox_AccelStreamRequest *req_ptr = UART_CMD_REQ( const alox_AccelStreamRequest *req_ptr = UART_CMD_REQ(
&uart_msg, alox_UartMessage_accel_stream_request_tag, accel_stream_request); &uart_msg, alox_UartMessage_accel_stream_request_tag, accel_stream_request);
if (req_ptr != NULL) { if (req_ptr == NULL) {
req = *req_ptr; ESP_LOGW(TAG, "missing accel_stream_request");
reply(false, 0, false, 0);
return;
} }
req = *req_ptr;
} }
if (req.write) { if (req.write) {

View File

@ -67,15 +67,29 @@ static void handle_battery_status(const uint8_t *data, size_t len) {
if (len > 0) { if (len > 0) {
alox_UartMessage uart_msg; alox_UartMessage uart_msg;
if (uart_cmd_decode(data, len, &uart_msg) == ESP_OK) { if (uart_cmd_decode(data, len, &uart_msg) != ESP_OK) {
ESP_LOGW(TAG, "decode failed");
alox_UartMessage response;
uart_cmd_init_response(&response, alox_MessageType_BATTERY_STATUS,
alox_UartMessage_battery_status_response_tag);
response.payload.battery_status_response.success = false;
uart_cmd_send(&response, TAG);
return;
}
const alox_BatteryStatusRequest *req_ptr = UART_CMD_REQ( const alox_BatteryStatusRequest *req_ptr = UART_CMD_REQ(
&uart_msg, alox_UartMessage_battery_status_request_tag, &uart_msg, alox_UartMessage_battery_status_request_tag,
battery_status_request); battery_status_request);
if (req_ptr != NULL) { if (req_ptr == NULL) {
ESP_LOGW(TAG, "missing battery_status_request");
alox_UartMessage response;
uart_cmd_init_response(&response, alox_MessageType_BATTERY_STATUS,
alox_UartMessage_battery_status_response_tag);
response.payload.battery_status_response.success = false;
uart_cmd_send(&response, TAG);
return;
}
req = *req_ptr; req = *req_ptr;
} }
}
}
alox_UartMessage response; alox_UartMessage response;
uart_cmd_init_response(&response, alox_MessageType_BATTERY_STATUS, uart_cmd_init_response(&response, alox_MessageType_BATTERY_STATUS,

View File

@ -1,4 +1,5 @@
#include "cmd_handler.h" #include "cmd_handler.h"
#include "ota_session.h"
#include "esp_err.h" #include "esp_err.h"
#include "esp_log.h" #include "esp_log.h"
#include "freertos/FreeRTOS.h" #include "freertos/FreeRTOS.h"
@ -88,33 +89,19 @@ esp_err_t msg_register_handler(uint16_t id, msg_callback_t cb) {
return ESP_OK; return ESP_OK;
} }
esp_err_t msg_post(uint16_t id, const uint8_t *data, size_t len) {
if (cmd_queue == NULL) {
return ESP_ERR_INVALID_STATE;
}
generic_msg_t msg = {.msg_id = id, .len = len, .payload = NULL};
if (len > 0) {
msg.payload = malloc(len);
if (msg.payload == NULL) {
return ESP_ERR_NO_MEM;
}
memcpy(msg.payload, data, len);
}
if (xQueueSend(cmd_queue, &msg, pdMS_TO_TICKS(100)) != pdPASS) {
free(msg.payload);
return ESP_ERR_TIMEOUT;
}
return ESP_OK;
}
void vCmdDispatcherTask(void *param) { void vCmdDispatcherTask(void *param) {
(void)param; (void)param;
generic_msg_t msg; generic_msg_t msg;
while (1) { while (1) {
if (xQueueReceive(cmd_queue, &msg, portMAX_DELAY) == pdPASS) { if (xQueueReceive(cmd_queue, &msg, portMAX_DELAY) == pdPASS) {
if (!ota_session_uart_cmd_allowed(msg.msg_id)) {
ESP_LOGW(TAG, "reject %s (0x%02x) during OTA session",
message_type_name(msg.msg_id), (unsigned)msg.msg_id);
free(msg.payload);
continue;
}
bool handled = false; bool handled = false;
for (int i = 0; i < handler_count; i++) { for (int i = 0; i < handler_count; i++) {
if (handlers[i].msg_id == msg.msg_id) { if (handlers[i].msg_id == msg.msg_id) {

View File

@ -21,6 +21,5 @@ void init_cmdHandler(QueueHandle_t queue);
void vCmdDispatcherTask(void *param); void vCmdDispatcherTask(void *param);
esp_err_t msg_register_handler(uint16_t id, msg_callback_t cb); esp_err_t msg_register_handler(uint16_t id, msg_callback_t cb);
esp_err_t msg_post(uint16_t id, const uint8_t *data, size_t len);
#endif #endif

View File

@ -81,8 +81,6 @@ static const ota_espnow_progress_cbs_t s_dist_progress = {
static void ota_prepare_task(void *param) { static void ota_prepare_task(void *param) {
uint32_t total_size = (uint32_t)(uintptr_t)param; uint32_t total_size = (uint32_t)(uintptr_t)param;
send_ota_status(OTA_UART_ST_PREPARING, 0);
int slot = ota_uart_prepare(total_size); int slot = ota_uart_prepare(total_size);
if (slot < 0) { if (slot < 0) {
send_ota_failed(1); send_ota_failed(1);
@ -116,27 +114,32 @@ static void handle_ota_start(const uint8_t *data, size_t len) {
const alox_OtaStartPayload *req_ptr = const alox_OtaStartPayload *req_ptr =
UART_CMD_REQ(&uart_msg, alox_UartMessage_ota_start_tag, ota_start); UART_CMD_REQ(&uart_msg, alox_UartMessage_ota_start_tag, ota_start);
if (req_ptr != NULL) { if (req_ptr == NULL) {
req = *req_ptr; ESP_LOGW(TAG, "OTA_START: missing ota_start payload");
send_ota_failed(3);
return;
} }
req = *req_ptr;
if (req.total_size == 0) { if (req.total_size == 0) {
ESP_LOGW(TAG, "OTA_START: total_size required"); ESP_LOGW(TAG, "OTA_START: total_size required");
send_ota_failed( 3); send_ota_failed(3);
return; return;
} }
if (ota_uart_is_active()) { if (ota_uart_is_active()) {
ESP_LOGW(TAG, "OTA_START while session active"); ESP_LOGW(TAG, "OTA_START while session active");
send_ota_failed( 4); send_ota_failed(4);
return; return;
} }
send_ota_status(OTA_UART_ST_PREPARING, 0);
if (xTaskCreate(ota_prepare_task, "ota_prepare", OTA_PREPARE_STACK, if (xTaskCreate(ota_prepare_task, "ota_prepare", OTA_PREPARE_STACK,
(void *)(uintptr_t)req.total_size, OTA_PREPARE_PRIO, (void *)(uintptr_t)req.total_size, OTA_PREPARE_PRIO,
NULL) != pdPASS) { NULL) != pdPASS) {
ESP_LOGE(TAG, "failed to create ota_prepare task"); ESP_LOGE(TAG, "failed to create ota_prepare task");
send_ota_failed( 5); send_ota_failed(5);
} }
} }
@ -293,31 +296,28 @@ static void handle_ota_end(const uint8_t *data, size_t len) {
} }
} }
static void handle_ota_start_espnow(const uint8_t *data, size_t len) { static void ota_start_espnow_task(void *param) {
(void)data; (void)param;
(void)len;
if (ota_uart_is_active()) {
send_ota_failed( 40);
return;
}
const esp_partition_t *part = NULL; const esp_partition_t *part = NULL;
uint32_t image_size = 0; uint32_t image_size = 0;
if (!ota_uart_get_staged_image(&part, &image_size)) { if (!ota_uart_get_staged_image(&part, &image_size)) {
send_ota_failed( 41); send_ota_failed(41);
vTaskDelete(NULL);
return; return;
} }
esp_err_t err = ota_espnow_distribute(part, image_size, &s_dist_progress); esp_err_t err = ota_espnow_distribute(part, image_size, &s_dist_progress);
if (err != ESP_OK) { if (err != ESP_OK) {
send_ota_failed( 42); send_ota_failed(42);
vTaskDelete(NULL);
return; return;
} }
err = ota_uart_apply_boot(); err = ota_uart_apply_boot();
if (err != ESP_OK) { if (err != ESP_OK) {
send_ota_failed( (uint32_t)err); send_ota_failed((uint32_t)err);
vTaskDelete(NULL);
return; return;
} }
@ -329,6 +329,30 @@ static void handle_ota_start_espnow(const uint8_t *data, size_t len) {
response.payload.ota_status.error = 0; response.payload.ota_status.error = 0;
uart_cmd_send(&response, TAG); uart_cmd_send(&response, TAG);
led_ring_ota_success(); led_ring_ota_success();
vTaskDelete(NULL);
}
static void handle_ota_start_espnow(const uint8_t *data, size_t len) {
(void)data;
(void)len;
if (ota_uart_is_active()) {
send_ota_failed(40);
return;
}
const esp_partition_t *part = NULL;
uint32_t image_size = 0;
if (!ota_uart_get_staged_image(&part, &image_size)) {
send_ota_failed(41);
return;
}
if (xTaskCreate(ota_start_espnow_task, "ota_espnow", OTA_DIST_STACK, NULL,
OTA_DIST_PRIO, NULL) != pdPASS) {
ESP_LOGE(TAG, "failed to create ota_start_espnow task");
send_ota_failed(43);
}
} }
void cmd_ota_register(void) { void cmd_ota_register(void) {

View File

@ -9,13 +9,29 @@ static void handle_ota_slave_progress(const uint8_t *data, size_t len) {
alox_UartMessage uart_msg; alox_UartMessage uart_msg;
uint32_t filter = 0; uint32_t filter = 0;
if (uart_cmd_decode(data, len, &uart_msg) == ESP_OK) { if (len > 0) {
if (uart_cmd_decode(data, len, &uart_msg) != ESP_OK) {
ESP_LOGW(TAG, "decode failed");
alox_UartMessage response;
uart_cmd_init_response(
&response, alox_MessageType_OTA_SLAVE_PROGRESS,
alox_UartMessage_ota_slave_progress_response_tag);
uart_cmd_send(&response, TAG);
return;
}
const alox_OtaSlaveProgressRequest *req = const alox_OtaSlaveProgressRequest *req =
UART_CMD_REQ(&uart_msg, alox_UartMessage_ota_slave_progress_request_tag, UART_CMD_REQ(&uart_msg, alox_UartMessage_ota_slave_progress_request_tag,
ota_slave_progress_request); ota_slave_progress_request);
if (req != NULL) { if (req == NULL) {
filter = req->client_id; ESP_LOGW(TAG, "missing ota_slave_progress_request");
alox_UartMessage response;
uart_cmd_init_response(
&response, alox_MessageType_OTA_SLAVE_PROGRESS,
alox_UartMessage_ota_slave_progress_response_tag);
uart_cmd_send(&response, TAG);
return;
} }
filter = req->client_id;
} }
alox_UartMessage response; alox_UartMessage response;

View File

@ -39,12 +39,20 @@ static void handle_tap_notify(const uint8_t *data, size_t len) {
alox_UartMessage uart_msg; alox_UartMessage uart_msg;
alox_TapNotifyRequest req = alox_TapNotifyRequest_init_zero; alox_TapNotifyRequest req = alox_TapNotifyRequest_init_zero;
if (uart_cmd_decode(data, len, &uart_msg) == ESP_OK) { if (len > 0) {
if (uart_cmd_decode(data, len, &uart_msg) != ESP_OK) {
ESP_LOGW(TAG, "decode failed");
reply(0, false, 0, false, false, false);
return;
}
const alox_TapNotifyRequest *req_ptr = UART_CMD_REQ( const alox_TapNotifyRequest *req_ptr = UART_CMD_REQ(
&uart_msg, alox_UartMessage_tap_notify_request_tag, tap_notify_request); &uart_msg, alox_UartMessage_tap_notify_request_tag, tap_notify_request);
if (req_ptr != NULL) { if (req_ptr == NULL) {
req = *req_ptr; ESP_LOGW(TAG, "missing tap_notify_request");
reply(0, false, 0, false, false, false);
return;
} }
req = *req_ptr;
} }
if (req.write) { if (req.write) {

File diff suppressed because it is too large Load Diff

172
main/esp_now_core.c Normal file
View File

@ -0,0 +1,172 @@
#include "esp_now_core.h"
#include "esp_now_proto.h"
#include "esp_err.h"
#include "esp_event.h"
#include "esp_log.h"
#include "esp_mac.h"
#include "esp_netif.h"
#include "esp_now.h"
#include "esp_wifi.h"
#include "freertos/FreeRTOS.h"
#include "freertos/idf_additions.h"
#include <stdio.h>
#include <string.h>
static const char *TAG = "[ESPNOW_CORE]";
static const uint8_t ESPNOW_BCAST[ESP_NOW_ETH_ALEN] = {0xff, 0xff, 0xff,
0xff, 0xff, 0xff};
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 bool s_send_cb_ready;
static uint8_t network_to_channel(uint8_t network) {
if (network < 1 || network > 13) {
return 1;
}
return 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;
if (s_send_done != NULL) {
xSemaphoreGive(s_send_done);
}
}
void esp_now_core_store_config(const app_config_t *config) {
if (config == NULL) {
return;
}
memset(&s_config, 0, sizeof(s_config));
memcpy(&s_config, config, sizeof(s_config));
s_wifi_channel = network_to_channel(config->network);
}
const app_config_t *esp_now_core_get_config(void) { return &s_config; }
bool esp_now_core_is_master(void) { return s_config.master; }
uint8_t esp_now_core_network(void) { return s_config.network; }
uint8_t esp_now_core_wifi_channel(void) { return s_wifi_channel; }
const uint8_t *esp_now_core_own_mac(void) { return s_own_mac; }
uint32_t esp_now_core_now_ms(void) {
return (uint32_t)(xTaskGetTickCount() * portTICK_PERIOD_MS);
}
bool esp_now_core_mac_equal(const uint8_t *a, const uint8_t *b) {
return memcmp(a, b, ESP_NOW_ETH_ALEN) == 0;
}
void esp_now_core_mac_to_str(const uint8_t *mac, char *out, size_t out_len) {
snprintf(out, out_len, "%02x:%02x:%02x:%02x:%02x:%02x", mac[0], mac[1],
mac[2], mac[3], mac[4], mac[5]);
}
esp_err_t esp_now_core_ensure_peer(const uint8_t *mac) {
if (esp_now_is_peer_exist(mac)) {
return ESP_OK;
}
esp_now_peer_info_t peer = {0};
memcpy(peer.peer_addr, mac, ESP_NOW_ETH_ALEN);
peer.channel = s_wifi_channel;
peer.ifidx = WIFI_IF_STA;
peer.encrypt = false;
esp_err_t err = esp_now_add_peer(&peer);
if (err != ESP_OK) {
ESP_LOGW(TAG, "add peer failed: %s", esp_err_to_name(err));
}
return err;
}
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) {
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) {
ESP_LOGW(TAG, "encode failed");
return err;
}
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);
return ESP_ERR_INVALID_SIZE;
}
if (esp_now_core_ensure_peer(dest_mac) != ESP_OK) {
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) {
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);
}
}
return err;
}
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);
}
esp_err_t esp_now_core_init_radio(uint8_t channel) {
ESP_ERROR_CHECK(esp_netif_init());
ESP_ERROR_CHECK(esp_event_loop_create_default());
wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT();
ESP_ERROR_CHECK(esp_wifi_init(&cfg));
wifi_config_t wifi_config = {0};
wifi_config.sta.channel = channel;
wifi_config.sta.scan_method = WIFI_ALL_CHANNEL_SCAN;
wifi_config.sta.sort_method = WIFI_CONNECT_AP_BY_SIGNAL;
ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA));
ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &wifi_config));
ESP_ERROR_CHECK(esp_wifi_start());
ESP_ERROR_CHECK(esp_wifi_set_ps(WIFI_PS_NONE));
ESP_ERROR_CHECK(esp_wifi_set_channel(channel, WIFI_SECOND_CHAN_NONE));
ESP_ERROR_CHECK(esp_read_mac(s_own_mac, ESP_MAC_WIFI_STA));
s_wifi_channel = channel;
return ESP_OK;
}
void esp_now_core_init_send_done(void) {
s_send_done = xSemaphoreCreateBinary();
if (s_send_done != NULL &&
esp_now_register_send_cb(espnow_send_done_cb) == ESP_OK) {
s_send_cb_ready = true;
} else {
ESP_LOGW(TAG, "send-done callback unavailable (OTA may drop packets)");
}
}

32
main/esp_now_core.h Normal file
View File

@ -0,0 +1,32 @@
#ifndef ESP_NOW_CORE_H
#define ESP_NOW_CORE_H
#include "app_config.h"
#include "esp_err.h"
#include "esp_now_messages.pb.h"
#include <stdbool.h>
#include <stdint.h>
void esp_now_core_store_config(const app_config_t *config);
const app_config_t *esp_now_core_get_config(void);
bool esp_now_core_is_master(void);
uint8_t esp_now_core_network(void);
uint8_t esp_now_core_wifi_channel(void);
const uint8_t *esp_now_core_own_mac(void);
uint32_t esp_now_core_now_ms(void);
bool esp_now_core_mac_equal(const uint8_t *a, const uint8_t *b);
void esp_now_core_mac_to_str(const uint8_t *mac, char *out, size_t out_len);
esp_err_t esp_now_core_ensure_peer(const uint8_t *mac);
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);
esp_err_t esp_now_core_send_wait(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);
#endif

484
main/esp_now_master.c Normal file
View File

@ -0,0 +1,484 @@
#include "esp_now_master.h"
#include "client_registry.h"
#include "esp_now_comm.h"
#include "esp_now_core.h"
#include "esp_now_proto.h"
#include "board_input.h"
#include "ota_espnow.h"
#include "ota_uart.h"
#include "esp_log.h"
#include "freertos/FreeRTOS.h"
#include "freertos/idf_additions.h"
#include <string.h>
static const uint8_t ESPNOW_BCAST[ESP_NOW_ETH_ALEN] = {0xff, 0xff, 0xff,
0xff, 0xff, 0xff};
#define ESPNOW_DISCOVER_INTERVAL_MS 500
#define ESPNOW_HEARTBEAT_INTERVAL_MS 1000
#define ESPNOW_HEARTBEAT_MISS_COUNT 3
#define ESPNOW_CLIENT_TIMEOUT_MS \
(ESPNOW_HEARTBEAT_INTERVAL_MS * ESPNOW_HEARTBEAT_MISS_COUNT)
#define ESPNOW_BATTERY_INTERVAL_MS 30000
static const char *TAG = "[ESPNOW_M]";
static esp_err_t send_accel_stream(const uint8_t *dest_mac, uint32_t client_id,
bool enable) {
alox_EspNowMessage msg = alox_EspNowMessage_init_zero;
msg.type = alox_EspNowMessageType_ESPNOW_SET_ACCEL_STREAM;
msg.which_payload = alox_EspNowMessage_accel_stream_tag;
msg.payload.accel_stream.enable = enable;
msg.payload.accel_stream.client_id = client_id;
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;
msg.type = alox_EspNowMessageType_ESPNOW_SET_ACCEL_DEADZONE;
msg.which_payload = alox_EspNowMessage_accel_deadzone_tag;
msg.payload.accel_deadzone.deadzone = deadzone;
msg.payload.accel_deadzone.client_id = client_id;
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;
msg.which_payload = alox_EspNowMessage_unicast_test_tag;
msg.payload.unicast_test.seq = seq;
return esp_now_core_send(dest_mac, &msg);
}
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;
msg.which_payload = alox_EspNowMessage_find_me_tag;
msg.payload.find_me.client_id = client_id;
return esp_now_core_send(dest_mac, &msg);
}
static esp_err_t send_led_ring(const uint8_t *dest_mac, uint32_t client_id,
const alox_LedRingProgressRequest *req) {
if (req == NULL) {
return ESP_ERR_INVALID_ARG;
}
alox_EspNowMessage msg = alox_EspNowMessage_init_zero;
msg.type = alox_EspNowMessageType_ESPNOW_LED_RING;
msg.which_payload = alox_EspNowMessage_led_ring_tag;
msg.payload.led_ring.client_id = client_id;
msg.payload.led_ring.mode = req->mode;
msg.payload.led_ring.progress = req->progress;
msg.payload.led_ring.digit = req->digit;
msg.payload.led_ring.r = req->r;
msg.payload.led_ring.g = req->g;
msg.payload.led_ring.b = req->b;
msg.payload.led_ring.intensity = req->intensity;
msg.payload.led_ring.blink_ms = req->blink_ms;
msg.payload.led_ring.blink_count = req->blink_count;
return esp_now_core_send(dest_mac, &msg);
}
static esp_err_t send_restart(const uint8_t *dest_mac, uint32_t client_id) {
alox_EspNowMessage msg = alox_EspNowMessage_init_zero;
msg.type = alox_EspNowMessageType_ESPNOW_RESTART;
msg.which_payload = alox_EspNowMessage_restart_tag;
msg.payload.restart.client_id = client_id;
return esp_now_core_send(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;
msg.type = alox_EspNowMessageType_ESPNOW_SET_TAP_NOTIFY;
msg.which_payload = alox_EspNowMessage_tap_notify_tag;
msg.payload.tap_notify.client_id = client_id;
msg.payload.tap_notify.single = single;
msg.payload.tap_notify.double_tap = double_tap;
msg.payload.tap_notify.triple = triple;
return esp_now_core_send(dest_mac, &msg);
}
static esp_err_t send_ota_start(const uint8_t *dest_mac, uint32_t total_size) {
alox_EspNowMessage msg = alox_EspNowMessage_init_zero;
msg.type = alox_EspNowMessageType_ESPNOW_OTA_START;
msg.which_payload = alox_EspNowMessage_ota_start_tag;
msg.payload.ota_start.total_size = total_size;
return esp_now_core_send_wait(dest_mac, &msg);
}
static esp_err_t send_ota_payload(const uint8_t *dest_mac, uint32_t seq,
const uint8_t *data, size_t len) {
if (data == NULL || len == 0 || len > OTA_UART_HOST_CHUNK_SIZE) {
return ESP_ERR_INVALID_ARG;
}
alox_EspNowMessage msg = alox_EspNowMessage_init_zero;
msg.type = alox_EspNowMessageType_ESPNOW_OTA_PAYLOAD;
msg.which_payload = alox_EspNowMessage_ota_payload_tag;
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);
}
static esp_err_t send_ota_end(const uint8_t *dest_mac) {
alox_EspNowMessage msg = alox_EspNowMessage_init_zero;
msg.type = alox_EspNowMessageType_ESPNOW_OTA_END;
msg.which_payload = alox_EspNowMessage_ota_end_tag;
return esp_now_core_send_wait(dest_mac, &msg);
}
esp_err_t esp_now_comm_send_ota_start(const uint8_t mac[CLIENT_MAC_LEN],
uint32_t total_size) {
if (mac == NULL || !esp_now_core_is_master()) {
return ESP_ERR_INVALID_STATE;
}
return send_ota_start(mac, total_size);
}
esp_err_t esp_now_comm_send_ota_payload(const uint8_t mac[CLIENT_MAC_LEN],
uint32_t seq, const uint8_t *data,
size_t len) {
if (mac == NULL || !esp_now_core_is_master()) {
return ESP_ERR_INVALID_STATE;
}
return send_ota_payload(mac, seq, data, len);
}
esp_err_t esp_now_comm_send_ota_end(const uint8_t mac[CLIENT_MAC_LEN]) {
if (mac == NULL || !esp_now_core_is_master()) {
return ESP_ERR_INVALID_STATE;
}
return send_ota_end(mac);
}
esp_err_t esp_now_comm_send_restart(const uint8_t mac[CLIENT_MAC_LEN],
uint32_t client_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_restart(mac, client_id);
if (err == ESP_OK) {
ESP_LOGI(TAG, "unicast RESTART to %s client_id=%lu", mac_str,
(unsigned long)client_id);
} else {
ESP_LOGW(TAG, "unicast RESTART to %s failed: %s", mac_str,
esp_err_to_name(err));
}
return err;
}
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()) {
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_find_me(mac, client_id);
if (err == ESP_OK) {
ESP_LOGI(TAG, "unicast FIND_ME to %s client_id=%lu", mac_str,
(unsigned long)client_id);
} else {
ESP_LOGW(TAG, "unicast FIND_ME to %s failed: %s", mac_str,
esp_err_to_name(err));
}
return err;
}
esp_err_t esp_now_comm_send_led_ring(const uint8_t mac[CLIENT_MAC_LEN],
uint32_t client_id,
const alox_LedRingProgressRequest *req) {
if (mac == NULL || !esp_now_core_is_master() || req == NULL) {
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_led_ring(mac, client_id, req);
if (err == ESP_OK) {
ESP_LOGI(TAG, "unicast LED_RING mode %lu to %s client_id=%lu",
(unsigned long)req->mode, mac_str, (unsigned long)client_id);
} else {
ESP_LOGW(TAG, "unicast LED_RING to %s failed: %s", mac_str,
esp_err_to_name(err));
}
return err;
}
esp_err_t esp_now_comm_send_unicast_test(const uint8_t mac[CLIENT_MAC_LEN],
uint32_t seq) {
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_unicast_test(mac, seq);
if (err == ESP_OK) {
ESP_LOGI(TAG, "unicast TEST to %s seq=%lu", mac_str, (unsigned long)seq);
} else {
ESP_LOGW(TAG, "unicast TEST to %s failed: %s", mac_str, esp_err_to_name(err));
}
return err;
}
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()) {
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_accel_stream(mac, client_id, enable);
if (err == ESP_OK) {
ESP_LOGI(TAG, "unicast SET_ACCEL_STREAM to %s: %s client_id=%lu", mac_str,
enable ? "on" : "off", (unsigned long)client_id);
} else {
ESP_LOGW(TAG, "unicast SET_ACCEL_STREAM 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) {
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_tap_notify(mac, client_id, single, double_tap, triple);
if (err == ESP_OK) {
ESP_LOGI(TAG,
"unicast SET_TAP_NOTIFY to %s: single=%d double=%d triple=%d "
"client_id=%lu",
mac_str, single, double_tap, triple, (unsigned long)client_id);
} else {
ESP_LOGW(TAG, "unicast SET_TAP_NOTIFY to %s failed: %s", mac_str,
esp_err_to_name(err));
}
return err;
}
esp_err_t esp_now_comm_send_accel_deadzone(const uint8_t mac[CLIENT_MAC_LEN],
uint32_t client_id,
uint32_t deadzone) {
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_accel_deadzone(mac, client_id, deadzone);
if (err == ESP_OK) {
ESP_LOGI(TAG, "unicast SET_ACCEL_DEADZONE to %s: deadzone=%lu client_id=%lu",
mac_str, (unsigned long)deadzone, (unsigned long)client_id);
} else {
ESP_LOGW(TAG, "unicast SET_ACCEL_DEADZONE 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) {
return;
}
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) {
return;
}
if (err != ESP_OK) {
ESP_LOGW(TAG, "accel sample id mismatch from %02x:…:%02x", mac[0], mac[5]);
}
}
static void handle_tap_event(const uint8_t mac[CLIENT_MAC_LEN],
const alox_EspNowTapEvent *event) {
if (event == NULL) {
return;
}
esp_err_t err =
client_registry_update_tap(mac, event->slave_id, event->kind);
if (err == ESP_ERR_NOT_FOUND) {
return;
}
if (err != ESP_OK) {
ESP_LOGW(TAG, "tap event id=%lu kind=%lu rejected from %02x:…:%02x",
(unsigned long)event->slave_id, (unsigned long)event->kind, mac[0],
mac[5]);
}
}
static void handle_battery_report(const uint8_t mac[CLIENT_MAC_LEN],
const alox_EspNowBatteryReport *report) {
if (report == NULL) {
return;
}
esp_err_t err = client_registry_update_battery(
mac, report->client_id, report->lipo1_valid, report->lipo1_mv,
report->lipo2_valid, report->lipo2_mv);
if (err == ESP_ERR_NOT_FOUND) {
ESP_LOGW(TAG, "battery report from unregistered slave id=%lu",
(unsigned long)report->client_id);
return;
}
if (err != ESP_OK) {
ESP_LOGW(TAG, "battery report id=%lu rejected: %s",
(unsigned long)report->client_id, esp_err_to_name(err));
return;
}
ESP_LOGI(TAG, "battery cached id=%lu L1=%s %lu mV L2=%s %lu mV",
(unsigned long)report->client_id,
report->lipo1_valid ? "ok" : "n/a",
(unsigned long)report->lipo1_mv, report->lipo2_valid ? "ok" : "n/a",
(unsigned long)report->lipo2_mv);
}
static void handle_client_presence(const alox_EspNowSlavePresence *presence,
const uint8_t mac[CLIENT_MAC_LEN]) {
if (presence->network != esp_now_core_network()) {
return;
}
esp_now_core_ensure_peer(mac);
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);
if (err != ESP_OK) {
ESP_LOGW(TAG, "client registry full");
return;
}
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);
} else if (reactivated) {
ESP_LOGI(TAG, "client reconnected id=%lu mac=%s",
(unsigned long)presence->slave_id, mac_str);
}
}
void esp_now_master_on_recv(const esp_now_recv_info_t *info, const uint8_t *data,
int len) {
if (info == NULL || data == NULL || len <= 0) {
return;
}
alox_EspNowMessage msg = alox_EspNowMessage_init_zero;
if (esp_now_proto_decode(data, (size_t)len, &msg) != ESP_OK) {
ESP_LOGW(TAG, "decode failed (%d bytes)", len);
return;
}
if (ota_espnow_distribution_active()) {
if (msg.which_payload == alox_EspNowMessage_ota_status_tag) {
esp_now_core_ensure_peer(info->src_addr);
ota_espnow_master_on_status(info->src_addr, &msg.payload.ota_status);
}
return;
}
if (msg.which_payload == alox_EspNowMessage_ota_status_tag) {
esp_now_core_ensure_peer(info->src_addr);
ota_espnow_master_on_status(info->src_addr, &msg.payload.ota_status);
return;
}
if (msg.which_payload == alox_EspNowMessage_accel_sample_tag) {
esp_now_core_ensure_peer(info->src_addr);
handle_accel_sample(info->src_addr, &msg.payload.accel_sample);
return;
}
if (msg.which_payload == alox_EspNowMessage_tap_event_tag) {
esp_now_core_ensure_peer(info->src_addr);
handle_tap_event(info->src_addr, &msg.payload.tap_event);
return;
}
if (msg.which_payload == alox_EspNowMessage_battery_report_tag) {
esp_now_core_ensure_peer(info->src_addr);
handle_battery_report(info->src_addr, &msg.payload.battery_report);
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);
}
const alox_EspNowSlavePresence *presence = esp_now_proto_get_presence(&msg);
if (presence != NULL) {
esp_now_core_ensure_peer(info->src_addr);
handle_client_presence(presence, info->src_addr);
}
}
static void discover_task(void *param) {
(void)param;
alox_EspNowMessage msg = alox_EspNowMessage_init_zero;
msg.type = alox_EspNowMessageType_ESPNOW_DISCOVER;
msg.which_payload = alox_EspNowMessage_discover_tag;
msg.payload.discover.network = esp_now_core_network();
ESP_LOGI(TAG, "discover on network %u ch %u", (unsigned)esp_now_core_network(),
(unsigned)esp_now_core_wifi_channel());
while (1) {
esp_now_core_send(ESPNOW_BCAST, &msg);
vTaskDelay(pdMS_TO_TICKS(ESPNOW_DISCOVER_INTERVAL_MS));
}
}
static void monitor_task(void *param) {
(void)param;
uint32_t last_local_battery_ms = 0;
ESP_LOGI(TAG, "monitor (client timeout %u ms)",
(unsigned)ESPNOW_CLIENT_TIMEOUT_MS);
board_lipo_reading_t reading;
board_input_read_lipo(&reading);
client_registry_set_master_battery(&reading);
last_local_battery_ms = esp_now_core_now_ms();
while (1) {
vTaskDelay(pdMS_TO_TICKS(ESPNOW_HEARTBEAT_INTERVAL_MS));
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) {
board_input_read_lipo(&reading);
client_registry_set_master_battery(&reading);
last_local_battery_ms = t;
}
}
}
esp_err_t esp_now_master_start(void) {
ESP_ERROR_CHECK(esp_now_core_ensure_broadcast_peer());
if (xTaskCreate(discover_task, "espnow_disc", 4096, NULL, 4, NULL) != pdPASS) {
ESP_LOGE(TAG, "failed to create discover task");
return ESP_FAIL;
}
if (xTaskCreate(monitor_task, "espnow_mon", 4096, NULL, 4, NULL) != pdPASS) {
ESP_LOGE(TAG, "failed to create monitor task");
return ESP_FAIL;
}
return ESP_OK;
}

11
main/esp_now_master.h Normal file
View File

@ -0,0 +1,11 @@
#ifndef ESP_NOW_MASTER_H
#define ESP_NOW_MASTER_H
#include "esp_err.h"
#include "esp_now.h"
esp_err_t esp_now_master_start(void);
void esp_now_master_on_recv(const esp_now_recv_info_t *info, const uint8_t *data,
int len);
#endif

579
main/esp_now_slave.c Normal file
View File

@ -0,0 +1,579 @@
#include "esp_now_slave.h"
#include "bosch456.h"
#include "cmd_led_ring.h"
#include "esp_now_comm.h"
#include "esp_now_core.h"
#include "esp_now_proto.h"
#include "board_input.h"
#include "led_ring.h"
#include "ota_espnow.h"
#include "ota_uart.h"
#include "pod_reboot.h"
#include "pod_settings.h"
#include "esp_log.h"
#include "freertos/FreeRTOS.h"
#include "freertos/idf_additions.h"
#include <string.h>
#ifndef POWERPOD_FW_VERSION
#define POWERPOD_FW_VERSION 1u
#endif
#define ESPNOW_HEARTBEAT_INTERVAL_MS 1000
#define SLAVE_MASTER_LOST_MS (ESPNOW_HEARTBEAT_INTERVAL_MS * 5)
#define ESPNOW_ACCEL_INTERVAL_MS 16
#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_accel_stream_enabled;
static bool s_tap_notify_single;
static bool s_tap_notify_double;
static bool s_tap_notify_triple;
static uint8_t s_master_mac[ESP_NOW_ETH_ALEN];
static uint32_t s_last_discover_ms;
typedef enum {
SLAVE_TX_SLAVE_INFO = 1,
SLAVE_TX_BATTERY,
} slave_tx_op_t;
static QueueHandle_t s_tx_queue;
static bool from_joined_master(const uint8_t *master_mac) {
return s_joined && esp_now_core_mac_equal(master_mac, s_master_mac);
}
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->available = true;
presence->used = false;
esp_now_proto_setup_presence_encode(presence, own);
}
static void send_presence(const uint8_t *dest_mac, alox_EspNowMessageType type) {
alox_EspNowMessage msg = alox_EspNowMessage_init_zero;
alox_EspNowSlavePresence *presence = NULL;
msg.type = type;
if (type == alox_EspNowMessageType_ESPNOW_SLAVE_INFO) {
msg.which_payload = alox_EspNowMessage_slave_info_tag;
presence = &msg.payload.slave_info;
} else {
msg.which_payload = alox_EspNowMessage_heartbeat_tag;
presence = &msg.payload.heartbeat;
}
fill_presence(presence);
esp_now_core_send(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;
msg.type = alox_EspNowMessageType_ESPNOW_ACCEL_SAMPLE;
msg.which_payload = alox_EspNowMessage_accel_sample_tag;
msg.payload.accel_sample.slave_id = 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);
}
static esp_err_t send_tap_event(const uint8_t *dest_mac, uint32_t slave_id,
uint32_t kind) {
alox_EspNowMessage msg = alox_EspNowMessage_init_zero;
msg.type = alox_EspNowMessageType_ESPNOW_TAP_EVENT;
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);
}
static esp_err_t send_battery_report(const uint8_t *dest_mac,
const alox_EspNowBatteryReport *report) {
if (report == NULL) {
return ESP_ERR_INVALID_ARG;
}
alox_EspNowMessage msg = alox_EspNowMessage_init_zero;
msg.type = alox_EspNowMessageType_ESPNOW_BATTERY_REPORT;
msg.which_payload = alox_EspNowMessage_battery_report_tag;
msg.payload.battery_report = *report;
return esp_now_core_send(dest_mac, &msg);
}
static void reset_join(void) {
s_joined = false;
s_accel_stream_enabled = false;
memset(s_master_mac, 0, sizeof(s_master_mac));
s_last_discover_ms = 0;
if (s_tx_queue != NULL) {
xQueueReset(s_tx_queue);
}
}
static void queue_tx(slave_tx_op_t op) {
if (s_tx_queue == NULL) {
return;
}
if (xQueueSend(s_tx_queue, &op, 0) != pdTRUE) {
ESP_LOGW(TAG, "tx queue full (op=%d)", (int)op);
}
}
static void send_battery_to_master(void) {
if (!s_joined) {
return;
}
board_lipo_reading_t reading;
board_input_read_lipo(&reading);
alox_EspNowBatteryReport report = alox_EspNowBatteryReport_init_zero;
report.client_id = esp_now_core_own_mac()[5];
report.lipo1_valid = reading.lipo1_valid;
report.lipo2_valid = reading.lipo2_valid;
report.lipo1_mv = reading.lipo1_mv;
report.lipo2_mv = reading.lipo2_mv;
esp_err_t err = send_battery_report(s_master_mac, &report);
if (err != ESP_OK) {
ESP_LOGW(TAG, "battery report send failed id=%lu: %s",
(unsigned long)report.client_id, esp_err_to_name(err));
} else {
ESP_LOGI(TAG, "battery report sent id=%lu L1=%s %lu mV L2=%s %lu mV",
(unsigned long)report.client_id,
report.lipo1_valid ? "ok" : "n/a",
(unsigned long)report.lipo1_mv,
report.lipo2_valid ? "ok" : "n/a",
(unsigned long)report.lipo2_mv);
}
}
esp_err_t esp_now_comm_send_ota_status(const uint8_t master_mac[CLIENT_MAC_LEN],
uint32_t status, uint32_t bytes_written,
uint32_t error) {
if (master_mac == NULL || esp_now_core_is_master()) {
return ESP_ERR_INVALID_STATE;
}
alox_EspNowMessage msg = alox_EspNowMessage_init_zero;
msg.type = alox_EspNowMessageType_ESPNOW_OTA_STATUS;
msg.which_payload = alox_EspNowMessage_ota_status_tag;
msg.payload.ota_status.status = status;
msg.payload.ota_status.bytes_written = bytes_written;
msg.payload.ota_status.error = error;
return esp_now_core_send_wait(master_mac, &msg);
}
bool esp_now_comm_get_master_mac(uint8_t mac_out[CLIENT_MAC_LEN]) {
return esp_now_slave_get_master_mac(mac_out);
}
bool esp_now_slave_get_master_mac(uint8_t mac_out[CLIENT_MAC_LEN]) {
if (mac_out == NULL || !s_joined) {
return false;
}
memcpy(mac_out, s_master_mac, CLIENT_MAC_LEN);
return true;
}
static void tx_task(void *param) {
(void)param;
slave_tx_op_t op;
ESP_LOGI(TAG, "deferred tx task ready");
while (1) {
if (xQueueReceive(s_tx_queue, &op, portMAX_DELAY) != pdTRUE) {
continue;
}
if (!s_joined) {
continue;
}
switch (op) {
case SLAVE_TX_SLAVE_INFO:
send_presence(s_master_mac, alox_EspNowMessageType_ESPNOW_SLAVE_INFO);
break;
case SLAVE_TX_BATTERY:
vTaskDelay(pdMS_TO_TICKS(SLAVE_BATTERY_AFTER_JOIN_MS));
send_battery_to_master();
break;
default:
break;
}
}
}
static void handle_unicast_test(const uint8_t *master_mac,
const alox_EspNowUnicastTest *test) {
char mac_str[18];
esp_now_core_mac_to_str(master_mac, mac_str, sizeof(mac_str));
ESP_LOGI(TAG, "UNICAST TEST OK from master %s seq=%lu (joined=%d)", mac_str,
(unsigned long)test->seq, (int)s_joined);
}
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) {
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, "RESTART from master %s (id=%lu)", mac_str, (unsigned long)my_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) {
return;
}
if (s_joined && !esp_now_core_mac_equal(master_mac, s_master_mac)) {
return;
}
send_battery_to_master();
}
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) {
return;
}
if (s_joined && !esp_now_core_mac_equal(master_mac, s_master_mac)) {
return;
}
alox_LedRingProgressRequest req = alox_LedRingProgressRequest_init_zero;
req.mode = msg->mode;
req.progress = msg->progress;
req.digit = msg->digit;
req.r = msg->r;
req.g = msg->g;
req.b = msg->b;
req.intensity = msg->intensity;
req.blink_ms = msg->blink_ms;
req.blink_count = msg->blink_count;
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);
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) {
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, "FIND_ME from master %s (id=%lu)", mac_str, (unsigned long)my_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) {
return;
}
if (s_joined && !esp_now_core_mac_equal(master_mac, s_master_mac)) {
return;
}
s_accel_stream_enabled = cfg->enable;
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);
}
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) {
return;
}
if (s_joined && !esp_now_core_mac_equal(master_mac, s_master_mac)) {
return;
}
s_tap_notify_single = cfg->single;
s_tap_notify_double = cfg->double_tap;
s_tap_notify_triple = cfg->triple;
char mac_str[18];
esp_now_core_mac_to_str(master_mac, mac_str, sizeof(mac_str));
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);
}
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) {
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,
"accel deadzone from master %s: %lu LSB id=%lu (sensor %s)", mac_str,
(unsigned long)cfg->deadzone, (unsigned long)my_id,
bma456_is_ready() ? "ok" : "not installed");
bma456_set_accel_deadzone(cfg->deadzone);
if (pod_settings_save_accel_deadzone(cfg->deadzone) != ESP_OK) {
ESP_LOGW(TAG, "deadzone %lu applied but not saved to NVS",
(unsigned long)cfg->deadzone);
}
}
static void handle_discover(const uint8_t *sender_mac,
const alox_EspNowDiscover *discover) {
if (discover->network != esp_now_core_network()) {
return;
}
uint32_t now = esp_now_core_now_ms();
if (s_joined) {
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;
return;
}
ESP_LOGW(TAG, "master lost, rejoining");
reset_join();
}
memcpy(s_master_mac, sender_mac, ESP_NOW_ETH_ALEN);
s_joined = true;
s_last_discover_ms = 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);
queue_tx(SLAVE_TX_SLAVE_INFO);
queue_tx(SLAVE_TX_BATTERY);
}
static void check_master_timeout(void) {
if (!s_joined || s_last_discover_ms == 0) {
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));
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);
while (1) {
vTaskDelay(pdMS_TO_TICKS(ESPNOW_ACCEL_INTERVAL_MS));
if (!s_joined || !s_accel_stream_enabled || !bma456_is_ready()) {
continue;
}
int16_t x = 0;
int16_t y = 0;
int16_t z = 0;
if (bma456_read_accel(&x, &y, &z) != ESP_OK) {
continue;
}
(void)send_accel_sample(s_master_mac, own[5], x, y, z);
}
}
static void on_bma456_tap(bma456_tap_kind_t kind, void *ctx) {
(void)ctx;
if (!s_joined) {
return;
}
bool enabled = false;
switch (kind) {
case BMA456_TAP_SINGLE:
enabled = s_tap_notify_single;
break;
case BMA456_TAP_DOUBLE:
enabled = s_tap_notify_double;
break;
case BMA456_TAP_TRIPLE:
enabled = s_tap_notify_triple;
break;
default:
return;
}
if (!enabled) {
return;
}
(void)send_tap_event(s_master_mac, esp_now_core_own_mac()[5], (uint32_t)kind);
}
static void heartbeat_task(void *param) {
(void)param;
uint32_t last_battery_ms = 0;
ESP_LOGI(TAG, "heartbeat task (interval %u ms)",
(unsigned)ESPNOW_HEARTBEAT_INTERVAL_MS);
while (1) {
vTaskDelay(pdMS_TO_TICKS(ESPNOW_HEARTBEAT_INTERVAL_MS));
check_master_timeout();
if (!s_joined) {
last_battery_ms = 0;
continue;
}
send_presence(s_master_mac, alox_EspNowMessageType_ESPNOW_HEARTBEAT);
uint32_t now = esp_now_core_now_ms();
if (last_battery_ms == 0 ||
(now - last_battery_ms) >= ESPNOW_BATTERY_INTERVAL_MS) {
send_battery_to_master();
last_battery_ms = now;
}
}
}
void esp_now_slave_on_recv(const esp_now_recv_info_t *info, const uint8_t *data,
int len) {
if (info == NULL || data == NULL || len <= 0) {
return;
}
alox_EspNowMessage msg = alox_EspNowMessage_init_zero;
if (esp_now_proto_decode(data, (size_t)len, &msg) != ESP_OK) {
ESP_LOGW(TAG, "decode failed (%d bytes)", len);
return;
}
if (from_joined_master(info->src_addr)) {
esp_now_core_ensure_peer(info->src_addr);
}
if (ota_uart_is_active()) {
switch (msg.which_payload) {
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;
}
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) {
ota_espnow_slave_on_payload(info->src_addr, &msg.payload.ota_payload);
} else {
ota_espnow_slave_on_end(info->src_addr);
}
break;
default:
break;
}
return;
}
switch (msg.which_payload) {
case alox_EspNowMessage_discover_tag:
handle_discover(info->src_addr, &msg.payload.discover);
break;
case alox_EspNowMessage_unicast_test_tag:
if (from_joined_master(info->src_addr)) {
handle_unicast_test(info->src_addr, &msg.payload.unicast_test);
}
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_accel_stream_tag:
if (from_joined_master(info->src_addr)) {
handle_accel_stream(info->src_addr, &msg.payload.accel_stream);
}
break;
case alox_EspNowMessage_tap_notify_tag:
if (from_joined_master(info->src_addr)) {
handle_tap_notify(info->src_addr, &msg.payload.tap_notify);
}
break;
case alox_EspNowMessage_battery_query_tag:
if (from_joined_master(info->src_addr)) {
handle_battery_query(info->src_addr, &msg.payload.battery_query);
}
break;
case alox_EspNowMessage_led_ring_tag:
if (from_joined_master(info->src_addr)) {
handle_led_ring(info->src_addr, &msg.payload.led_ring);
}
break;
case alox_EspNowMessage_find_me_tag:
if (from_joined_master(info->src_addr)) {
handle_find_me(info->src_addr, &msg.payload.find_me);
}
break;
case alox_EspNowMessage_restart_tag:
if (from_joined_master(info->src_addr)) {
handle_restart(info->src_addr, &msg.payload.restart);
}
break;
default:
ESP_LOGW(TAG, "unhandled which=%u type=%u", msg.which_payload,
(unsigned)msg.type);
break;
}
}
esp_err_t esp_now_slave_start(void) {
reset_join();
s_tx_queue = xQueueCreate(4, sizeof(slave_tx_op_t));
if (s_tx_queue == NULL) {
ESP_LOGE(TAG, "failed to create tx queue");
return ESP_ERR_NO_MEM;
}
if (xTaskCreate(tx_task, "espnow_stx", 4096, NULL, 5, NULL) != pdPASS) {
ESP_LOGE(TAG, "failed to create tx task");
return ESP_FAIL;
}
if (xTaskCreate(heartbeat_task, "espnow_hb", 4096, NULL, 4, NULL) != pdPASS) {
ESP_LOGE(TAG, "failed to create heartbeat task");
return ESP_FAIL;
}
if (xTaskCreate(accel_stream_task, "espnow_accel", 4096, NULL, 5, NULL) !=
pdPASS) {
ESP_LOGE(TAG, "failed to create accel stream task");
return ESP_FAIL;
}
ota_espnow_slave_init();
bma456_set_tap_handler(on_bma456_tap, NULL);
return ESP_OK;
}

14
main/esp_now_slave.h Normal file
View File

@ -0,0 +1,14 @@
#ifndef ESP_NOW_SLAVE_H
#define ESP_NOW_SLAVE_H
#include "client_registry.h"
#include "esp_err.h"
#include "esp_now.h"
esp_err_t esp_now_slave_start(void);
void esp_now_slave_on_recv(const esp_now_recv_info_t *info, const uint8_t *data,
int len);
bool esp_now_slave_get_master_mac(uint8_t mac_out[CLIENT_MAC_LEN]);
#endif

View File

@ -35,7 +35,28 @@ static const char *TAG = "[OTA_ESPNOW]";
#define OTA_MAX_TARGETS CLIENT_REGISTRY_MAX #define OTA_MAX_TARGETS CLIENT_REGISTRY_MAX
#define OTA_SLAVE_WORK_QUEUE_LEN 12
#define OTA_SLAVE_WORK_STACK 8192
#define OTA_SLAVE_WORK_PRIO 5
typedef enum {
OTA_SLAVE_WORK_STATUS = 1,
OTA_SLAVE_WORK_PAYLOAD,
OTA_SLAVE_WORK_END,
} ota_slave_work_op_t;
typedef struct {
ota_slave_work_op_t op;
uint8_t master_mac[6];
uint32_t status;
uint32_t bytes_written;
uint32_t error;
alox_EspNowOtaPayload payload;
} ota_slave_work_t;
static EventGroupHandle_t s_eg; static EventGroupHandle_t s_eg;
static QueueHandle_t s_slave_work_queue;
static bool s_distribution_active;
typedef struct { typedef struct {
uint8_t count; uint8_t count;
@ -152,56 +173,37 @@ static bool wait_target_bits(uint32_t want_bits, uint32_t timeout_ms) {
return (got & want_bits) == want_bits; return (got & want_bits) == want_bits;
} }
bool ota_espnow_distribution_active(void) { return s_distribution_active; }
static void send_slave_status(const uint8_t master_mac[6], uint32_t status, static void send_slave_status(const uint8_t master_mac[6], uint32_t status,
uint32_t bytes_written, uint32_t error) { uint32_t bytes_written, uint32_t error) {
esp_now_comm_send_ota_status(master_mac, status, bytes_written, error); esp_now_comm_send_ota_status(master_mac, status, bytes_written, error);
} }
static void ota_slave_prepare_task(void *param) { static bool queue_slave_work(const ota_slave_work_t *work) {
uint32_t total_size = (uint32_t)(uintptr_t)param; if (work == NULL || s_slave_work_queue == NULL) {
uint8_t master_mac[6]; return false;
if (!esp_now_comm_get_master_mac(master_mac)) {
vTaskDelete(NULL);
return;
} }
if (xQueueSend(s_slave_work_queue, work, 0) != pdTRUE) {
send_slave_status(master_mac, OTA_ST_PREPARING, 0, 0); ESP_LOGW(TAG, "slave OTA work queue full (op=%d)", (int)work->op);
return false;
int slot = ota_uart_prepare(total_size);
if (slot < 0) {
send_slave_status(master_mac, OTA_ST_FAILED, 0, 1);
vTaskDelete(NULL);
return;
} }
return true;
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);
vTaskDelete(NULL);
} }
void ota_espnow_slave_on_start(const uint8_t master_mac[6], static void queue_slave_status(const uint8_t master_mac[6], uint32_t status,
const alox_EspNowOtaStart *start) { uint32_t bytes_written, uint32_t error) {
if (start == NULL || start->total_size == 0) { ota_slave_work_t work = {
return; .op = OTA_SLAVE_WORK_STATUS,
} .status = status,
.bytes_written = bytes_written,
ESP_LOGI(TAG, "ESP-NOW OTA_START (%lu bytes)", (unsigned long)start->total_size); .error = error,
};
if (ota_uart_is_active()) { memcpy(work.master_mac, master_mac, 6);
send_slave_status(master_mac, OTA_ST_FAILED, 0, 4); (void)queue_slave_work(&work);
return;
}
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) {
send_slave_status(master_mac, OTA_ST_FAILED, 0, 5);
}
} }
void ota_espnow_slave_on_payload(const uint8_t master_mac[6], static void process_slave_payload(const uint8_t master_mac[6],
const alox_EspNowOtaPayload *payload) { const alox_EspNowOtaPayload *payload) {
if (payload == NULL || payload->data.size == 0) { if (payload == NULL || payload->data.size == 0) {
send_slave_status(master_mac, OTA_ST_FAILED, 0, 11); send_slave_status(master_mac, OTA_ST_FAILED, 0, 11);
@ -246,7 +248,7 @@ void ota_espnow_slave_on_payload(const uint8_t master_mac[6],
} }
} }
void ota_espnow_slave_on_end(const uint8_t master_mac[6]) { static void process_slave_end(const uint8_t master_mac[6]) {
ESP_LOGI(TAG, "ESP-NOW OTA_END"); ESP_LOGI(TAG, "ESP-NOW OTA_END");
if (!ota_uart_is_active()) { if (!ota_uart_is_active()) {
send_slave_status(master_mac, OTA_ST_FAILED, 0, 20); send_slave_status(master_mac, OTA_ST_FAILED, 0, 20);
@ -268,6 +270,113 @@ void ota_espnow_slave_on_end(const uint8_t master_mac[6]) {
(unsigned long)written); (unsigned long)written);
} }
static void ota_slave_work_task(void *param) {
(void)param;
ota_slave_work_t work;
while (1) {
if (xQueueReceive(s_slave_work_queue, &work, portMAX_DELAY) != pdTRUE) {
continue;
}
switch (work.op) {
case OTA_SLAVE_WORK_STATUS:
send_slave_status(work.master_mac, work.status, work.bytes_written,
work.error);
break;
case OTA_SLAVE_WORK_PAYLOAD:
process_slave_payload(work.master_mac, &work.payload);
break;
case OTA_SLAVE_WORK_END:
process_slave_end(work.master_mac);
break;
default:
break;
}
}
}
void ota_espnow_slave_init(void) {
if (s_slave_work_queue != NULL) {
return;
}
s_slave_work_queue = xQueueCreate(OTA_SLAVE_WORK_QUEUE_LEN, sizeof(ota_slave_work_t));
if (s_slave_work_queue == NULL) {
ESP_LOGE(TAG, "failed to create slave OTA work queue");
return;
}
if (xTaskCreate(ota_slave_work_task, "ota_slave_wrk", OTA_SLAVE_WORK_STACK, NULL,
OTA_SLAVE_WORK_PRIO, NULL) != pdPASS) {
ESP_LOGE(TAG, "failed to create slave OTA work task");
}
}
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)) {
vTaskDelete(NULL);
return;
}
send_slave_status(master_mac, OTA_ST_PREPARING, 0, 0);
int slot = ota_uart_prepare(total_size);
if (slot < 0) {
send_slave_status(master_mac, OTA_ST_FAILED, 0, 1);
vTaskDelete(NULL);
return;
}
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);
vTaskDelete(NULL);
}
void ota_espnow_slave_on_start(const uint8_t master_mac[6],
const alox_EspNowOtaStart *start) {
if (start == NULL || start->total_size == 0) {
return;
}
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);
return;
}
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) {
queue_slave_status(master_mac, OTA_ST_FAILED, 0, 5);
}
}
void ota_espnow_slave_on_payload(const uint8_t master_mac[6],
const alox_EspNowOtaPayload *payload) {
if (payload == NULL) {
queue_slave_status(master_mac, OTA_ST_FAILED, 0, 11);
return;
}
ota_slave_work_t work = {.op = OTA_SLAVE_WORK_PAYLOAD, .payload = *payload};
memcpy(work.master_mac, master_mac, 6);
if (!queue_slave_work(&work)) {
queue_slave_status(master_mac, OTA_ST_FAILED, 0, 14);
}
}
void ota_espnow_slave_on_end(const uint8_t master_mac[6]) {
ota_slave_work_t work = {.op = OTA_SLAVE_WORK_END};
memcpy(work.master_mac, master_mac, 6);
if (!queue_slave_work(&work)) {
queue_slave_status(master_mac, OTA_ST_FAILED, 0, 15);
}
}
void ota_espnow_master_on_status(const uint8_t slave_mac[6], void ota_espnow_master_on_status(const uint8_t slave_mac[6],
const alox_EspNowOtaStatus *status) { const alox_EspNowOtaStatus *status) {
if (status == NULL || s_eg == NULL) { if (status == NULL || s_eg == NULL) {
@ -342,6 +451,8 @@ static esp_err_t distribute_image(const esp_partition_t *partition,
} }
} }
s_distribution_active = true;
memset(&s_dist.progress, 0, sizeof(s_dist.progress)); memset(&s_dist.progress, 0, sizeof(s_dist.progress));
if (progress != NULL) { if (progress != NULL) {
s_dist.progress = *progress; s_dist.progress = *progress;
@ -362,6 +473,7 @@ static esp_err_t distribute_image(const esp_partition_t *partition,
ESP_LOGW(TAG, "OTA_START to slave %lu failed", ESP_LOGW(TAG, "OTA_START to slave %lu failed",
(unsigned long)s_dist.id[i]); (unsigned long)s_dist.id[i]);
prog_end(); prog_end();
s_distribution_active = false;
return err; return err;
} }
} }
@ -369,6 +481,7 @@ static esp_err_t distribute_image(const esp_partition_t *partition,
if (!wait_target_bits(target_mask, OTA_PREPARE_TIMEOUT_MS)) { if (!wait_target_bits(target_mask, OTA_PREPARE_TIMEOUT_MS)) {
ESP_LOGE(TAG, "timeout waiting for slave OTA ready"); ESP_LOGE(TAG, "timeout waiting for slave OTA ready");
prog_end(); prog_end();
s_distribution_active = false;
return ESP_ERR_TIMEOUT; return ESP_ERR_TIMEOUT;
} }
@ -392,6 +505,7 @@ static esp_err_t distribute_image(const esp_partition_t *partition,
ESP_LOGE(TAG, "partition read @%lu failed: %s", (unsigned long)offset, ESP_LOGE(TAG, "partition read @%lu failed: %s", (unsigned long)offset,
esp_err_to_name(err)); esp_err_to_name(err));
prog_end(); prog_end();
s_distribution_active = false;
return err; return err;
} }
@ -407,6 +521,7 @@ static esp_err_t distribute_image(const esp_partition_t *partition,
block_buf + sent, chunk); block_buf + sent, chunk);
if (err != ESP_OK) { if (err != ESP_OK) {
prog_end(); prog_end();
s_distribution_active = false;
return err; return err;
} }
} }
@ -424,6 +539,7 @@ static esp_err_t distribute_image(const esp_partition_t *partition,
ESP_LOGE(TAG, "timeout block ack @%lu bytes", ESP_LOGE(TAG, "timeout block ack @%lu bytes",
(unsigned long)s_dist.expected_bytes); (unsigned long)s_dist.expected_bytes);
prog_end(); prog_end();
s_distribution_active = false;
return ESP_ERR_TIMEOUT; return ESP_ERR_TIMEOUT;
} }
ESP_LOGI(TAG, "block ack @%lu/%lu (%lu%%)", ESP_LOGI(TAG, "block ack @%lu/%lu (%lu%%)",
@ -445,6 +561,7 @@ static esp_err_t distribute_image(const esp_partition_t *partition,
err = esp_now_comm_send_ota_end(s_dist.mac[i]); err = esp_now_comm_send_ota_end(s_dist.mac[i]);
if (err != ESP_OK) { if (err != ESP_OK) {
prog_end(); prog_end();
s_distribution_active = false;
return err; return err;
} }
} }
@ -452,11 +569,13 @@ static esp_err_t distribute_image(const esp_partition_t *partition,
if (!wait_target_bits(target_mask, OTA_END_TIMEOUT_MS)) { if (!wait_target_bits(target_mask, OTA_END_TIMEOUT_MS)) {
ESP_LOGE(TAG, "timeout waiting for slave OTA success"); ESP_LOGE(TAG, "timeout waiting for slave OTA success");
prog_end(); prog_end();
s_distribution_active = false;
return ESP_ERR_TIMEOUT; return ESP_ERR_TIMEOUT;
} }
prog_set_aggregate(size); prog_set_aggregate(size);
prog_end(); prog_end();
s_distribution_active = false;
ESP_LOGI(TAG, "ESP-NOW OTA complete for %u slave(s)", (unsigned)s_dist.count); ESP_LOGI(TAG, "ESP-NOW OTA complete for %u slave(s)", (unsigned)s_dist.count);
return ESP_OK; return ESP_OK;
} }

View File

@ -37,4 +37,10 @@ void ota_espnow_slave_on_end(const uint8_t master_mac[6]);
void ota_espnow_progress_query(uint32_t filter_client_id, void ota_espnow_progress_query(uint32_t filter_client_id,
alox_OtaSlaveProgressResponse *out); alox_OtaSlaveProgressResponse *out);
/** True while master is pushing a staged image to slaves. */
bool ota_espnow_distribution_active(void);
/** Slave: work queue for OTA (no esp_now_send from recv callback). */
void ota_espnow_slave_init(void);
#endif #endif

25
main/ota_session.c Normal file
View File

@ -0,0 +1,25 @@
#include "ota_session.h"
#include "ota_espnow.h"
#include "ota_uart.h"
#include "uart_messages.pb.h"
bool ota_session_busy(void) {
return ota_uart_is_active() || ota_espnow_distribution_active();
}
bool ota_session_uart_cmd_allowed(uint16_t msg_id) {
if (!ota_session_busy()) {
return true;
}
switch ((alox_MessageType)msg_id) {
case alox_MessageType_OTA_START:
case alox_MessageType_OTA_PAYLOAD:
case alox_MessageType_OTA_END:
case alox_MessageType_OTA_START_ESPNOW:
case alox_MessageType_OTA_SLAVE_PROGRESS:
return true;
default:
return false;
}
}

13
main/ota_session.h Normal file
View File

@ -0,0 +1,13 @@
#ifndef OTA_SESSION_H
#define OTA_SESSION_H
#include <stdbool.h>
#include <stdint.h>
/** UART upload or ESP-NOW slave distribution in progress. */
bool ota_session_busy(void);
/** During OTA only UART OTA-related commands are accepted on the master. */
bool ota_session_uart_cmd_allowed(uint16_t msg_id);
#endif