Compare commits
57
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f90dfe82f | ||
|
|
141176a824 | ||
|
|
9046677460 | ||
|
|
73f48c8002 | ||
|
|
7f28a48a10 | ||
|
|
c3b20887bf | ||
|
|
1adaddd6c8 | ||
|
|
178ab46cec | ||
|
|
ae8ef40820 | ||
|
|
567dab6d0a | ||
|
|
af20635047 | ||
|
|
aa67edfe94 | ||
|
|
92a13d7fd0 | ||
|
|
d7441cb85f | ||
|
|
f006f8b912 | ||
|
|
be18b758ed | ||
|
|
490e0ee61f | ||
|
|
ac223ada72 | ||
|
|
35ce1476d8 | ||
|
|
f89ea3cbe3 | ||
|
|
99956e3362 | ||
|
|
ab1844ac32 | ||
|
|
e4ce18edd8 | ||
|
|
0eea27a876 | ||
|
|
0cbc4d0644 | ||
|
|
35b39fce46 | ||
|
|
41a66d4417 | ||
|
|
498b89d7ba | ||
|
|
31e539052a | ||
|
|
a85d48320e | ||
|
|
f512936d97 | ||
|
|
a8d4d42920 | ||
|
|
3cb0b5bbe9 | ||
|
|
eb67a46158 | ||
|
|
47c75110c9 | ||
|
|
ba20544762 | ||
|
|
16c521f71c | ||
|
|
95d5a9747a | ||
|
|
a9e08107b4 | ||
|
|
5c3cf65bca | ||
|
|
2e88358c53 | ||
|
|
efd6260201 | ||
|
|
8931912583 | ||
|
|
508b684fdf | ||
|
|
a0f4a81a55 | ||
|
|
5a948a5c8c | ||
|
|
9b7bda8551 | ||
|
|
4bf43d8a5e | ||
|
|
59ca269407 | ||
|
|
1ad527119d | ||
|
|
80fb9cf55e | ||
|
|
85aeab85c0 | ||
|
|
caf1b8d0d8 | ||
|
|
c4696657a7 | ||
|
|
0299ba44fd | ||
|
|
d24b0cb5c3 | ||
|
|
a8ae65d9dc |
@@ -6,25 +6,36 @@ GOTOOL := $(GOTOOL_DIR)/gotool
|
|||||||
GOTOOL_RUN := cd $(GOTOOL_DIR) && go run . -port $(PORT)
|
GOTOOL_RUN := cd $(GOTOOL_DIR) && go run . -port $(PORT)
|
||||||
|
|
||||||
.PHONY: default proto_generate proto_generate_uart proto_generate_espnow \
|
.PHONY: default proto_generate proto_generate_uart proto_generate_espnow \
|
||||||
gotool-build gotool-proto gotool-tidy \
|
gotool-build gotool-proto gotool-tidy gotool-test-units \
|
||||||
gotool-version gotool-clients gotool-unicast-test gotool-deadzone-get gotool-deadzone-set
|
gotool-version gotool-clients gotool-unicast-test gotool-deadzone-get gotool-deadzone-set \
|
||||||
|
gotool-test gotool-serve
|
||||||
|
|
||||||
|
TEST_CONFIG ?= example-lab
|
||||||
|
SERVE_ADDR ?= :8080
|
||||||
|
TEST_SCENARIO ?= smoke
|
||||||
|
|
||||||
default:
|
default:
|
||||||
@echo "Targets: proto_generate gotool-build gotool-clients gotool-version …"
|
@echo "Targets: proto_generate gotool-build gotool-clients gotool-version …"
|
||||||
@echo "Set PORT=$(PORT) (current) for goTool targets."
|
@echo "Set PORT=$(PORT) (current) for goTool targets."
|
||||||
|
|
||||||
proto_generate_uart:
|
|
||||||
python libs/nanopb/generator/nanopb_generator.py main/proto/uart_messages.proto
|
|
||||||
|
|
||||||
proto_generate_espnow:
|
proto_generate_espnow:
|
||||||
python libs/nanopb/generator/nanopb_generator.py main/proto/esp_now_messages.proto
|
cd main/proto && python3 ../../libs/nanopb/generator/nanopb_generator.py \
|
||||||
|
-I . -I ../../libs/nanopb/generator/proto esp_now_messages.proto
|
||||||
|
|
||||||
proto_generate: proto_generate_uart proto_generate_espnow
|
proto_generate: proto_generate_uart proto_generate_espnow
|
||||||
|
|
||||||
gotool-proto:
|
gotool-proto:
|
||||||
cd $(GOTOOL_DIR) && protoc --go_out=./pb --go_opt=paths=source_relative \
|
cd $(GOTOOL_DIR) && protoc --go_out=./pb --go_opt=paths=source_relative \
|
||||||
--go_opt=Muart_messages.proto=powerpod/gotool/pb \
|
--go_opt=Muart_messages.proto=powerpod/gotool/pb \
|
||||||
-I ../main/proto ../main/proto/uart_messages.proto
|
--go_opt=Mnanopb.proto=powerpod/gotool/pb/nanopb \
|
||||||
|
-I ../main/proto \
|
||||||
|
-I ../libs/nanopb/generator/proto \
|
||||||
|
../main/proto/uart_messages.proto
|
||||||
|
@sed -i '/powerpod\/gotool\/pb\/nanopb/d' $(GOTOOL_DIR)/pb/uart_messages.pb.go
|
||||||
|
|
||||||
|
proto_generate_uart:
|
||||||
|
cd main/proto && python3 ../../libs/nanopb/generator/nanopb_generator.py \
|
||||||
|
-I . -I ../../libs/nanopb/generator/proto uart_messages.proto
|
||||||
|
|
||||||
gotool-tidy:
|
gotool-tidy:
|
||||||
cd $(GOTOOL_DIR) && go mod tidy
|
cd $(GOTOOL_DIR) && go mod tidy
|
||||||
@@ -51,4 +62,14 @@ gotool-deadzone-set: $(GOTOOL)
|
|||||||
@test -n "$(DEADZONE)" || (echo "Usage: make gotool-deadzone-set DEADZONE=100 [CLIENT=16]"; exit 1)
|
@test -n "$(DEADZONE)" || (echo "Usage: make gotool-deadzone-set DEADZONE=100 [CLIENT=16]"; exit 1)
|
||||||
$(GOTOOL) -port $(PORT) deadzone -set -value $(DEADZONE) -client $(or $(CLIENT),0)
|
$(GOTOOL) -port $(PORT) deadzone -set -value $(DEADZONE) -client $(or $(CLIENT),0)
|
||||||
|
|
||||||
|
gotool-test-units:
|
||||||
|
cd $(GOTOOL_DIR) && go test ./...
|
||||||
|
|
||||||
|
# CONFIG=example-lab SCENARIO=smoke
|
||||||
|
gotool-test: $(GOTOOL)
|
||||||
|
$(GOTOOL) -port $(PORT) test -config $(TEST_CONFIG) -scenario $(TEST_SCENARIO)
|
||||||
|
|
||||||
|
gotool-serve: $(GOTOOL)
|
||||||
|
$(GOTOOL) -port $(PORT) serve -addr $(SERVE_ADDR)
|
||||||
|
|
||||||
$(GOTOOL): gotool-build
|
$(GOTOOL): gotool-build
|
||||||
|
|||||||
@@ -0,0 +1,422 @@
|
|||||||
|
# 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 1–8
|
||||||
|
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 Host-Transport + Registry |
|
||||||
|
| `network` | IO-Expander 8er-DIP-Bank, High-Nibble PCB-reversed; DIP1–7 one-hot | 1–7 → WiFi/ESP-NOW-Kanal |
|
||||||
|
| `host_ble` | Expander DIP8 (nach Reverse+Invert) | `false`=UART, `true`=BLE (Boot only) |
|
||||||
|
| `running_partition` | `esp_ota_get_running_partition()` | Aktives OTA-Label (`ota_0` / `ota_1`) |
|
||||||
|
|
||||||
|
**Dateien:** `main/app_config.h`, `main/powerpod.c`, `main/powerpod.h` (Pins).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Schichtenmodell
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Befehlshandler (main/cmd/cmd_*.c) │
|
||||||
|
│ Decode/Encode: uart_cmd.c, Antwort: uart_proto.c │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ Dispatch: cmd_handler.c (Queue + vCmdDispatcherTask) │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ Host transport: uart.c / ble_host.c (Framing via host_transport) │
|
||||||
|
│ ESP-NOW: esp_now_comm │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ Protokoll: uart_messages.proto │ esp_now_messages.proto │
|
||||||
|
│ Codec: nanopb (proto/*.pb.c) │ esp_now_proto.c │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ Domäne: client_registry, bosch456, led_ring, ota_*, board │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ ESP-IDF: UART, NimBLE, WiFi, ESP-NOW, I2C, ADC, OTA, NVS │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Host UART vs BLE:** Nur einer aktiv (DIP8). Gleiches Framing (`0xAA`…`0xCC`) und dieselbe `cmd_queue`. BLE: NUS-ähnliche GATT-Char, Write=RX, Notify=TX; Frames größer als ATT MTU-3 werden in aufeinanderfolgende Notifies gestückelt (Host-Parser reassembliert). `gotool serve` verbindet UART oder BLE über Dashboard/REST/WS (`managedHost`); **OTA** über UART oder BLE (ATT MTU ≥ ~219, preferred 517). Bei BLE-Host und aktivem Accel-Stream pusht `ble_stream` periodisch `BLE_STREAM_TICK` (10–16 ms, Default 10; pausiert während OTA) mit `esp_timestamp_us` (Live-Pfad bevorzugt Tick statt CACHE_STATUS-Poll).
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Datenfluss: Commands (Befehle)
|
||||||
|
|
||||||
|
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_espnow_echo_ping.c`, `cmd/cmd_ota.c` (OTA + `ota_espnow.c`).
|
||||||
|
|
||||||
|
**Nur Master / nur Cache (kein Slave-Roundtrip):** `cmd_client_info.c`, `cmd_battery.c`, `cmd_cache_status.c`, `cmd_version.c`, `cmd_set_log_level.c`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Datenfluss: UART
|
||||||
|
|
||||||
|
### 5.1 Rahmenformat (Transport)
|
||||||
|
|
||||||
|
Unabhängig von Protobuf — reines Bytestream-Framing:
|
||||||
|
|
||||||
|
| Feld | Wert |
|
||||||
|
|------|------|
|
||||||
|
| Start | `0xAA` |
|
||||||
|
| Länge | 2 Bytes little-endian (1–1024) |
|
||||||
|
| Payload | `length` Bytes |
|
||||||
|
| Prüfsumme | XOR aller Payload-Bytes |
|
||||||
|
| Stopp | `0xCC` |
|
||||||
|
|
||||||
|
**Parameter:** `main/uart.h` — `UART_NUM_1`, `921600` Baud, TX GPIO **2**, RX GPIO **3**, `MAX_PAYLOAD_SIZE` 1024.
|
||||||
|
|
||||||
|
### 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` | `accel_stream_task` poll 10 ms + deadzone (nur bei Änderung), 1 ms slots via Discover `master_time_us` | `client_registry_update_accel()` |
|
||||||
|
| `ESPNOW_TAP_EVENT` | BMA456-IRQ → `on_bma456_tap` | `client_registry_update_tap()` |
|
||||||
|
| `ESPNOW_BATTERY_REPORT` | Heartbeat + nach Join | `client_registry_update_battery()` |
|
||||||
|
| `ESPNOW_SLAVE_INFO` / `HEARTBEAT` | `slave_tx_task` / Heartbeat-Task | `client_registry_heartbeat()` |
|
||||||
|
| `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. Anschließend Reboot; Bootloader führt neues Image als `PENDING_VERIFY`, `ota_confirm` bestätigt nach stabilem Boot.
|
||||||
|
|
||||||
|
Gemeinsamer Flash-Puffer: `ota_uart.c` (4 KiB Blockgröße).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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 (10 ms Cache) | `ESPNOW_TAP_EVENT` | `CACHE_STATUS` |
|
||||||
|
| Batterie | `ESPNOW_BATTERY_REPORT` + Master-ADC | `BATTERY_STATUS` |
|
||||||
|
| Konfig-Flags | Handler + ESP-NOW | `CLIENT_INFO`, diverse GET |
|
||||||
|
|
||||||
|
**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 | Accel poll 10 ms / TX bei Deadzone-Änderung, ID-Slot 1 ms wenn Stream an |
|
||||||
|
| `bma456_poll` | Beide | 10 Hz (`bosch456.c`) |
|
||||||
|
| `vTaskLedRing` | Beide | LED-Ring-Befehle |
|
||||||
|
|
||||||
|
`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 |
|
||||||
|
| OTA Confirm | `main/ota_confirm.c` | Nach ~8 s `mark_app_valid` (Rollback) |
|
||||||
|
| Einstellungen NVS | `main/pod_settings.c` | Accel-Deadzone persistent |
|
||||||
|
| Board | `main/board_input.c` | Taster, LiPo-ADC |
|
||||||
|
| Protobuf Schemas | `main/proto/*.proto` | Vertrag UART / ESP-NOW |
|
||||||
|
| 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).
|
||||||
@@ -0,0 +1,583 @@
|
|||||||
|
# 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` |
|
||||||
|
| Expander DIP-Bank (8) | PCF/TCA @ `0x20` | DIP1–7 = Netzwerk 1–7 (one-hot); **DIP8 ON = BLE host**, OFF = UART. High-Nibble auf PCB vertauscht → `reverse_high_nibble_lut`. Wechsel nur nach Reboot. |
|
||||||
|
| UART1 TX / RX | **2 / 3** | `uart.h` (Adapter: ESP-TX → Host-RX); nur wenn DIP8 = UART |
|
||||||
|
| LED-Ring (WS2812) | 7 | `led_ring.c` |
|
||||||
|
| BMA456 Interrupt | 10 | `bosch456.c` |
|
||||||
|
| Taster | 12 | `board_input.c` |
|
||||||
|
| LiPo ADC 1 | 1 | `board_input.c` |
|
||||||
|
| LiPo ADC 2 | 11 | `board_input.c` |
|
||||||
|
|
||||||
|
**Host-Protokoll (UART oder BLE):** Gleiches Framing (`0xAA`…`0xCC`) + nanopb. **UART:** `UART_NUM_1`, **921600** Baud, 8N1. **BLE:** Gerät `Powerpod`, NUS-UUIDs `6e400001`/`6e400002` (Write=RX, Notify=TX). goTool CLI: `-port …` oder `-ble` / `-ble-name Powerpod`. **`gotool serve`** startet ohne Pflicht-Gerät; Dashboard/REST/WS verbinden UART oder BLE dynamisch (`-port`/`-ble` nur optionale Vorbelegung). **OTA** über UART oder BLE (DIP8); BLE braucht ATT MTU ≥ ~219 für 200-B-Chunks. Bei BLE + Accel-Stream: periodische `BLE_STREAM_TICK`-Notifies (`period_ms` 10–16; während OTA pausiert).
|
||||||
|
|
||||||
|
**ESP-IDF-Log (Debug):** `esp_log_*` geht auf **UART0** (Standard-Konsole, typisch USB am Dev-Board, **115200** Baud, `CONFIG_ESP_CONSOLE_UART_NUM=0`). Das ist **getrennt** vom Host-UART1 — goTool liest keine `esp_log`-Ausgabe. Ohne angeschlossenes Debug-Kabel werden aktivierte Logs trotzdem formatiert und an UART0 gesendet (CPU-Overhead bleibt); nur `ESP_LOG_NONE` / Level-Filter vermeiden die Arbeit.
|
||||||
|
|
||||||
|
**I2C:** 100 kHz, interne Pull-ups, gemeinsamer Bus für Expander und BMA456H.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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. `board_input_init_adc_only()` — LiPo-ADC
|
||||||
|
3. Bei `POWERPOD_BATTERY_UV_ENABLE`: `battery_uv_evaluate_boot()` → ggf. Energiesparmodus (nur LED + ADC, **kein** weiterer Init)
|
||||||
|
4. DIP → `app_config.master`
|
||||||
|
5. I2C + IO-Expander → `app_config.network` (1–7) + `app_config.host_ble` (DIP8)
|
||||||
|
6. `init_bma456()` — optional, bei Fehler weiter ohne Sensor
|
||||||
|
7. `pod_settings_apply_accel_deadzone()` wenn Sensor da
|
||||||
|
8. OTA-Partition → `app_config.running_partition`
|
||||||
|
9. `board_input_start_lipo_monitor()` + `board_input_init_button()`
|
||||||
|
10. `esp_now_comm_init(&app_config)` — **normaler Pfad** (Master + Slave)
|
||||||
|
11. `led_ring_init()`
|
||||||
|
12. **Nur Master:** `cmd_queue` → `init_cmdHandler` → `init_uart` **oder** `init_ble_host` → alle `cmd_*_register()`
|
||||||
|
|
||||||
|
### 4.2 Master vs. Slave
|
||||||
|
|
||||||
|
| Funktion | Master | Slave |
|
||||||
|
|----------|--------|-------|
|
||||||
|
| Host Command-Handler (UART oder BLE) | Ja | Nein |
|
||||||
|
| ESP-NOW Discover senden | Ja (Broadcast) | Nein |
|
||||||
|
| ESP-NOW auf Discover reagieren | Nein | Ja |
|
||||||
|
| `client_registry` für Fremdknoten | Ja | Nein (nur eigener Join-State) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. UART-Protokoll
|
||||||
|
|
||||||
|
*(Transport + Anwendung — Datenfluss-Diagramme in [`ARCHITECTURE.md`](ARCHITECTURE.md) §4–5.)*
|
||||||
|
|
||||||
|
### 5.1 Rahmen
|
||||||
|
|
||||||
|
| Byte | Inhalt |
|
||||||
|
|------|--------|
|
||||||
|
| 0 | `0xAA` Start |
|
||||||
|
| 1–2 | Länge N little-endian (1–1024) |
|
||||||
|
| 3…N+2 | Payload |
|
||||||
|
| N+3 | XOR-Checksum über Payload |
|
||||||
|
| N+4 | `0xCC` Stopp |
|
||||||
|
|
||||||
|
Implementierung: `uart.c` — `parse_uart_byte()`, `uart_send_framed()`.
|
||||||
|
|
||||||
|
### 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` (1–13).
|
||||||
|
- 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_TAP_CONFIG` | M→S | `tap_config` | Multitap-Parameter + NVS |
|
||||||
|
| `ESPNOW_SET_ACCEL_STREAM` | M→S | `accel_stream` | Stream poll ~10 ms, TX on deadzone change |
|
||||||
|
| `ESPNOW_ACCEL_SAMPLE` | S→M | `accel_sample` | x/y/z Roh-LSB |
|
||||||
|
| `ESPNOW_SET_TAP_NOTIFY` | M→S | `tap_notify` | Tap-Arten filtern |
|
||||||
|
| `ESPNOW_TAP_EVENT` | S→M | `tap_event` | kind 1/2/3 |
|
||||||
|
| `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_ECHO_PING` | M→S | `echo_ping` | Latenztest (Host-Timestamp + `master_time_us`) |
|
||||||
|
| `ESPNOW_ECHO_PONG` | S→M | `echo_pong` | Echo unverändert zurück zum Master |
|
||||||
|
| `ESPNOW_OTA_*` | M↔S | `ota_*` | Firmware-Verteilung |
|
||||||
|
|
||||||
|
### 6.3 Zeitkonstanten (`esp_now_comm.c`)
|
||||||
|
|
||||||
|
| 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 | Poll 10 ms; TX nur bei Deadzone-Änderung (best-effort) |
|
||||||
|
| Batterie-Report | 30 s (+ einmal 150 ms nach Join) |
|
||||||
|
| Echo-Ping Timeout (Master) | 500 ms (`ESPNOW_ECHO_PING_TIMEOUT_MS`) |
|
||||||
|
|
||||||
|
### 6.4 `EspNowSlavePresence`
|
||||||
|
|
||||||
|
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` | Host-OTA beginnen (UART oder BLE) |
|
||||||
|
| 17 | OTA_PAYLOAD | `cmd_ota.c` | Chunk ≤200 B |
|
||||||
|
| 18 | OTA_END | `cmd_ota.c` | Abschluss + ESP-NOW-Verteilung |
|
||||||
|
| 19 | OTA_STATUS | `cmd_ota.c` | Gerät → Host Status |
|
||||||
|
| 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 (+ `period_ms` für BLE-Push) |
|
||||||
|
| 26 | BATTERY_STATUS | `cmd_battery.c` | Cache LiPo Master + Slaves |
|
||||||
|
| 27 | TAP_NOTIFY | `cmd_tap_notify.c` | Tap-Weiterleitung konfigurieren |
|
||||||
|
| 29 | CACHE_STATUS | `cmd_cache_status.c` | Accel + Tap Cache (ein Round-Trip) |
|
||||||
|
| 33 | BLE_STREAM_TICK | `ble_stream.c` | Master→Host Notify (nur BLE): Snapshot + `esp_timestamp_us` |
|
||||||
|
| 30 | ESPNOW_ECHO_PING | `cmd_espnow_echo_ping.c` | Timestamp-Echo über ESP-NOW (Latenztest) |
|
||||||
|
| 31 | SET_LOG_LEVEL | `cmd_set_log_level.c` | ESP-IDF-Log-Level global (`"*"`) lesen/setzen |
|
||||||
|
| 32 | TAP_CONFIG | `cmd_tap_config.c` | BMA456 Multitap-Parameter + NVS |
|
||||||
|
|
||||||
|
### 7.1 VERSION (3)
|
||||||
|
|
||||||
|
- **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)
|
||||||
|
|
||||||
|
- Optional `period_ms` (10–16): BLE-Host-Push-Intervall für `BLE_STREAM_TICK` (UART ignoriert; Default 10).
|
||||||
|
- Master setzt `client_registry_set_accel_stream` und sendet `ESPNOW_SET_ACCEL_STREAM`.
|
||||||
|
- Slave-Task `accel_stream_task` pollt alle 10 ms; sendet `ESPNOW_ACCEL_SAMPLE` nur wenn eine Achse die Accel-Deadzone seit dem letzten gesendeten Sample überschreitet (best-effort / `send_best_effort`; Baseline-Reset beim Stream-Enable).
|
||||||
|
|
||||||
|
### 7.5 TAP_NOTIFY (27)
|
||||||
|
|
||||||
|
- Konfiguriert auf Slave welche Tap-Arten `ESPNOW_TAP_EVENT` auslösen.
|
||||||
|
- Tap-Quelle: BMA456-Interrupt → `on_bma456_tap` in `esp_now_comm.c`.
|
||||||
|
|
||||||
|
### 7.5a TAP_CONFIG (32)
|
||||||
|
|
||||||
|
- **Request:** `write`, `client_id` (0=lokal), `all_clients`, Multitap-Felder (`tap_sens_thres` … `enable_triple`).
|
||||||
|
- **Write:** `bma456_set_tap_config` + NVS `tap_cfg` lokal; Slaves via `ESPNOW_SET_TAP_CONFIG`.
|
||||||
|
- Boot: `pod_settings_apply_tap_config()` nach `init_bma456()`.
|
||||||
|
|
||||||
|
### 7.6 CACHE_STATUS (29)
|
||||||
|
|
||||||
|
- **Request:** leer.
|
||||||
|
- **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 0–100 % |
|
||||||
|
| 2 | Digit 0–10 |
|
||||||
|
| 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`.
|
||||||
|
|
||||||
|
### 7.11 ESPNOW_ECHO_PING (30)
|
||||||
|
|
||||||
|
Round-Trip-Latenztest zu einem **Slave** (`client_id` > 0, muss in der Registry sein).
|
||||||
|
|
||||||
|
**Ablauf:**
|
||||||
|
|
||||||
|
1. Host (goTool) setzt `timestamp_us` (Unix-µs) und sendet `EspNowEchoPingRequest` per UART.
|
||||||
|
2. Master (`cmd_espnow_echo_ping.c`) löst MAC aus Registry auf, ruft `esp_now_comm_echo_ping()` auf.
|
||||||
|
3. Master sendet `ESPNOW_ECHO_PING` mit `host_timestamp_us` und `master_time_us` (`esp_timer_get_time()` kurz vor Send).
|
||||||
|
4. Slave (`handle_echo_ping`) antwortet mit `ESPNOW_ECHO_PONG` (Felder unverändert).
|
||||||
|
5. Master empfängt Pong, berechnet `esp_rtt_us = esp_timer_get_time() - master_time_us`, antwortet per UART.
|
||||||
|
|
||||||
|
**Request:** `client_id`, `timestamp_us` (vom Host gesetzt).
|
||||||
|
|
||||||
|
**Response:** `success`, `client_id`, `timestamp_us` (echoed), `esp_rtt_us` (nur bei Erfolg).
|
||||||
|
|
||||||
|
| Feld | Quelle | Bedeutung |
|
||||||
|
|------|--------|-----------|
|
||||||
|
| `timestamp_us` | Host → Slave → Host | Korrelations-ID; muss mit Request übereinstimmen |
|
||||||
|
| `esp_rtt_us` | Master | Rohe µs-Differenz von `esp_timer_get_time()` (Ping-Send → Pong-Empfang im `recv_cb`) |
|
||||||
|
|
||||||
|
**Implementierung:** `main/cmd/cmd_espnow_echo_ping.c`, `esp_now_comm_echo_ping()` in `esp_now_master.c`, Slave `handle_echo_ping` in `esp_now_slave.c`.
|
||||||
|
|
||||||
|
**Host-seitig (goTool):** `rtt_ms` = volle UART-Kette (Send bis Response-Empfang); unabhängig von `esp_rtt_us`.
|
||||||
|
|
||||||
|
### 7.12 SET_LOG_LEVEL (31)
|
||||||
|
|
||||||
|
Laufzeit-Steuerung des **globalen** ESP-IDF-Log-Levels auf dem Master (`esp_log_level_set("*", …)`). Kein ESP-NOW, kein NVS — nur Master.
|
||||||
|
|
||||||
|
**Request:** `write`, `level` (`esp_log_level_t`: 0=NONE, 1=ERROR, 2=WARN, 3=INFO, 4=DEBUG, 5=VERBOSE).
|
||||||
|
|
||||||
|
| `write` | Verhalten |
|
||||||
|
|---------|-----------|
|
||||||
|
| `false` / leer | Aktuelles Level lesen (`esp_log_level_get("*")`) |
|
||||||
|
| `true` | Level setzen; ungültige Werte (>5) → `success=false` |
|
||||||
|
|
||||||
|
**Response:** `success`, `level` (aktuell bzw. gesetzt).
|
||||||
|
|
||||||
|
**Boot-Default:** `CONFIG_LOG_DEFAULT_LEVEL` in `sdkconfig` (aktuell **INFO** = 3). Kann per UART/Web-UI zur Laufzeit geändert werden; nach Reboot gilt wieder der sdkconfig-Wert.
|
||||||
|
|
||||||
|
**Ausgabe:** Logs erscheinen auf **UART0** (Debug-USB), nicht auf dem Host-UART1 (goTool).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go run . -port /dev/ttyUSB0 log-level
|
||||||
|
go run . -port /dev/ttyUSB0 log-level -set -level 3
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. OTA
|
||||||
|
|
||||||
|
### 8.1 Host-OTA (nur Master, UART oder BLE)
|
||||||
|
|
||||||
|
Implementierung: `ota_uart.c`, Steuerung `cmd/cmd_ota.c`. Gleiches Protokoll über DIP8-Host (UART oder BLE NUS).
|
||||||
|
|
||||||
|
| 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).
|
||||||
|
- BLE: ATT MTU ≥ ~219 (preferred 517); große Antworten werden über mehrere Notifies gestückelt; `BLE_STREAM_TICK` pausiert während `ota_session_busy()`.
|
||||||
|
- **BLE+ESP-NOW Coex:** Tag `wifi` auf `ESP_LOG_NONE` (WiFi-Task-Stack + Error-Logs → Overflow/Panic); während ESP-NOW-Dist `OTA_STATUS` DISTRIBUTING max. ~4/s und kürzere BLE-Notify-Retries.
|
||||||
|
- **App-Rollback** (`CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE`): neues Image bootet als `PENDING_VERIFY`. `ota_confirm` markiert nach ~8 s stabilem Lauf `mark_app_valid`; Crash/Reset davor → Bootloader rollt auf die vorherige App zurück. Während `PENDING_VERIFY` ist ein neues `OTA_START` blockiert.
|
||||||
|
|
||||||
|
### 8.2 ESP-NOW-OTA (Master → Slaves)
|
||||||
|
|
||||||
|
`ota_espnow.c` — gleiche Status-Codes wie Host-OTA.
|
||||||
|
|
||||||
|
1. `ESPNOW_OTA_START` + `total_size` → Slave `ota_espnow_slave_on_start`
|
||||||
|
2. `ESPNOW_OTA_PAYLOAD` bis 200 B → 4 KiB Buffer auf Slave
|
||||||
|
3. `ESPNOW_OTA_END` → Boot-Partition setzen + automatischer Reboot
|
||||||
|
4. Slave antwortet `ESPNOW_OTA_STATUS` (mit `send_message_ex` + Semaphore auf Master)
|
||||||
|
|
||||||
|
Während Distribution: keine Client-Timeouts in der Registry (Heartbeats werden im recv_cb verworfen). Nach Erfolg: Master und Slaves starten automatisch neu (Rollback-Confirm siehe 8.1).
|
||||||
|
|
||||||
|
### 8.3 OTA_SLAVE_PROGRESS (21)
|
||||||
|
|
||||||
|
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 0–10 in `digit_lookup[]`.
|
||||||
|
- **Kein** lokaler Demo-Loop — Anzeige nur über UART (`cmd_led_ring.c`) oder ESP-NOW `ESPNOW_LED_RING`.
|
||||||
|
- Helligkeit: `intensity` 0 → Default ~5 % (`LED_RING_DEFAULT_INTENSITY`).
|
||||||
|
- Modi: `0` clear … `5` color, `6` **battery-low** (erste 4 LEDs rot ~10 %, 5 s).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Board-Input und Batterie
|
||||||
|
|
||||||
|
`board_input.c`:
|
||||||
|
|
||||||
|
- **Taster** GPIO 12 — Logging bei Druck.
|
||||||
|
- **LiPo-ADC** GPIO 1 und GPIO 11 (Pin-Spannung in mV).
|
||||||
|
- Master: `master_monitor_task` aktualisiert Master-Batterie alle 30 s.
|
||||||
|
- Slave: `slave_send_battery_report_to_master` nach Join, Heartbeat und Query.
|
||||||
|
|
||||||
|
Spannungen in Millivolt in Registry und `BATTERY_STATUS`-UART.
|
||||||
|
|
||||||
|
### Software-UV-Schutz (`battery_uv.c`)
|
||||||
|
|
||||||
|
Aktivierbar mit `POWERPOD_BATTERY_UV_ENABLE` in `powerpod.h` (CMake-Override möglich). Runtime zusätzlich NVS `uv_en` (Default **aus**) — UART `BATTERY_UV` / Dashboard / ESP-NOW `ESPNOW_SET_BATTERY_UV`.
|
||||||
|
|
||||||
|
| Zustand | ADC-Schwelle | Logik |
|
||||||
|
|---------|--------------|-------|
|
||||||
|
| Extern / kein Pack | < 100 mV | Alle gültigen Kanäle → normaler Boot |
|
||||||
|
| UV | 100…2299 mV | Ein gültiger Kanal reicht |
|
||||||
|
| Geladen | ≥ 3000 mV | Alle nicht-absent gültigen Kanäle |
|
||||||
|
|
||||||
|
- NVS-Keys `uv_latch`, `uv_en` in `pod_settings.c`
|
||||||
|
- Boot: ADC früh, bei UV nur LED + ADC-Poll (kein ESP-NOW/WiFi/UART/I2C/Button); übersprungen wenn `uv_en=0`
|
||||||
|
- Runtime: `battery_uv_poll()` im LiPo-Monitor (2 Samples Debounce), gated by `uv_en`
|
||||||
|
- UART `BATTERY_UV`: `client_id` / `all_clients`; Disable cleart Latch
|
||||||
|
- LED-Anzeige: Mode `6` battery-low (`led_ring_show_battery_low`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Persistenz (NVS)
|
||||||
|
|
||||||
|
`pod_settings.c` — Namespace `powerpod`:
|
||||||
|
|
||||||
|
| Key | Inhalt |
|
||||||
|
|-----|--------|
|
||||||
|
| `accel_dz` | Accel-Deadzone LSB |
|
||||||
|
| `tap_cfg` | BMA456 Multitap-Config (Blob) |
|
||||||
|
| `uv_latch` | Software-UV-Zustand (`1` = Energiesparmodus) |
|
||||||
|
| `uv_en` | Runtime-Enable UV-Schutz (`0`/`1`, fehlend = aus) |
|
||||||
|
|
||||||
|
Gespeichert bei lokalem Set (UART `client_id=0`, ESP-NOW auf Slave). Geladen nach `init_bma456()`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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_config.c` | TAP_CONFIG |
|
||||||
|
| `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_espnow_echo_ping.c` | ESPNOW_ECHO_PING |
|
||||||
|
| `cmd_set_log_level.c` | SET_LOG_LEVEL |
|
||||||
|
| `cmd_ota.c` | OTA_* |
|
||||||
|
| `cmd_ota_slave_progress.c` | OTA_SLAVE_PROGRESS |
|
||||||
|
|
||||||
|
### 14.4 Domäne
|
||||||
|
|
||||||
|
| Datei | Rolle |
|
||||||
|
|-------|--------|
|
||||||
|
| `bosch456.c` | Accelerometer + Tap |
|
||||||
|
| `led_ring.c` | LED-Anzeige |
|
||||||
|
| `board_input.c` | Taster, ADC |
|
||||||
|
| `battery_uv.c` | Software-UV-Schutz, Energiesparmodus |
|
||||||
|
| `pod_settings.c` | NVS |
|
||||||
|
| `pod_reboot.c` | Verzögerter Restart |
|
||||||
|
| `ota_uart.c` | Flash-Puffer UART-OTA |
|
||||||
|
| `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
|
||||||
|
|
||||||
|
**Level zur Laufzeit:** UART `SET_LOG_LEVEL` (31) oder Dashboard „ESP Log-Level“ — steuert nur die Konsolen-Ausgabe auf UART0, nicht das Host-Protokoll.
|
||||||
|
|
||||||
|
| Tag | Modul |
|
||||||
|
|-----|--------|
|
||||||
|
| `[Main]` | powerpod.c |
|
||||||
|
| `[LOG_LVL]` | cmd_set_log_level.c |
|
||||||
|
| `[UART]` | uart.c |
|
||||||
|
| `[CMDH]` | cmd_handler.c |
|
||||||
|
| `[UART_CMD]` | uart_cmd.c |
|
||||||
|
| `[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).
|
||||||
@@ -0,0 +1,351 @@
|
|||||||
|
# Feature hinzufügen — von UART bis ESP-NOW
|
||||||
|
|
||||||
|
Dieser Guide beschreibt die **komplette Kette** am Beispiel **Find me** (Commit `efd6260`): Host sendet ein UART-Kommando an den Master, der die Aktion lokal ausführt oder per ESP-NOW an einen Slave weiterleitet.
|
||||||
|
|
||||||
|
## Architektur (Überblick)
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant Host as Host (goTool / Dashboard)
|
||||||
|
participant Master as Master (UART + ESP-NOW)
|
||||||
|
participant Slave as Slave (ESP-NOW)
|
||||||
|
|
||||||
|
Host->>Master: UART-Frame [cmd_id + UartMessage protobuf]
|
||||||
|
Master->>Master: cmd_* Handler (Queue)
|
||||||
|
alt client_id == 0
|
||||||
|
Master->>Master: z.B. led_ring_find_me()
|
||||||
|
else client_id > 0
|
||||||
|
Master->>Slave: ESP-NOW EspNowMessage (unicast)
|
||||||
|
Slave->>Slave: esp_now_recv_cb → Handler
|
||||||
|
end
|
||||||
|
Master->>Host: UART-Response (UartMessage)
|
||||||
|
```
|
||||||
|
|
||||||
|
| Schicht | Dateien | Rolle |
|
||||||
|
|---------|---------|--------|
|
||||||
|
| Schema UART | `main/proto/uart_messages.proto` | `MessageType`, Request/Response für Host ↔ Master |
|
||||||
|
| Schema ESP-NOW | `main/proto/esp_now_messages.proto` | Master ↔ Slave (ohne UART) |
|
||||||
|
| Transport UART | `main/uart.c`, `goTool/uart/` | Rahmen `0xAA … 0xCC`, Byte 0 = Command-ID |
|
||||||
|
| Dispatch | `main/cmd/cmd_handler.c`, `main/uart_cmd.c` | Queue + `uart_cmd_register()` |
|
||||||
|
| Master-Logik | `main/cmd/cmd_*.c` | Decode, Registry, ESP-NOW senden |
|
||||||
|
| ESP-NOW | `main/esp_now_comm.c` | Encode/Decode, Send, Slave-`recv_cb` |
|
||||||
|
| Geräte-Funktion | z.B. `main/led_ring.c` | Wiederverwendbare Aktion (Master + Slave) |
|
||||||
|
| Host | `goTool/cmd_*.go`, `api_serve.go`, `webui/` | CLI, HTTP, UI |
|
||||||
|
|
||||||
|
**Wichtig:** UART-Befehle laufen auf dem **Master** (nur wenn `app_config.master`). Slaves haben keinen UART-Command-Handler; sie reagieren auf **ESP-NOW**. Die eigentliche Wirkung (LED, Sensor, …) liegt in gemeinsamen Modulen (`led_ring`, `bosch456`, …).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Wann braucht man was?
|
||||||
|
|
||||||
|
| Ziel | UART (`uart_messages.proto`) | ESP-NOW (`esp_now_messages.proto`) |
|
||||||
|
|------|------------------------------|-------------------------------------|
|
||||||
|
| Nur Master (angeschlossener Pod) | Ja | Nein |
|
||||||
|
| Einen registrierten Slave ansteuern | Ja (Master leitet weiter) | Ja |
|
||||||
|
| Slave → Master Rückmeldung | Optional (UART-Status) | Ja, wenn Slave antworten soll |
|
||||||
|
|
||||||
|
**Find me:** UART `FIND_ME` mit `client_id`; `0` = Master-Ring, `>0` = Unicast `ESPNOW_FIND_ME` an die Slave-MAC aus der Registry.
|
||||||
|
|
||||||
|
Referenz für **nur ESP-NOW-Weiterleitung ohne lokale Wirkung:** `main/cmd/cmd_espnow_unicast_test.c`
|
||||||
|
Referenz für **Master lokal + Slave + `client_id`:** `main/cmd/cmd_espnow_find_me.c`, `main/cmd/cmd_accel_deadzone.c`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Schritt 1 — Protobuf (UART)
|
||||||
|
|
||||||
|
Datei: `main/proto/uart_messages.proto`
|
||||||
|
|
||||||
|
1. **Neue ID** in `enum MessageType` (freie Nummer wählen, z.B. `22`):
|
||||||
|
|
||||||
|
```protobuf
|
||||||
|
FIND_ME = 22;
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Request/Response** definieren und im `UartMessage`-`oneof` eintragen (neue Feldnummern, nicht wiederverwenden):
|
||||||
|
|
||||||
|
```protobuf
|
||||||
|
message EspNowFindMeRequest {
|
||||||
|
uint32 client_id = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message EspNowFindMeResponse {
|
||||||
|
bool success = 1;
|
||||||
|
uint32 client_id = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// in message UartMessage { oneof payload { …
|
||||||
|
EspNowFindMeRequest espnow_find_me_request = 19;
|
||||||
|
EspNowFindMeResponse espnow_find_me_response = 20;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Generieren:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make proto_generate_uart
|
||||||
|
make gotool-proto
|
||||||
|
```
|
||||||
|
|
||||||
|
Erzeugt u.a. `main/proto/uart_messages.pb.h`, `.pb.c` und `goTool/pb/uart_messages.pb.go`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Schritt 2 — Protobuf (ESP-NOW), falls Slaves betroffen
|
||||||
|
|
||||||
|
Datei: `main/proto/esp_now_messages.proto`
|
||||||
|
|
||||||
|
1. Neuer Wert in `enum EspNowMessageType`:
|
||||||
|
|
||||||
|
```protobuf
|
||||||
|
ESPNOW_FIND_ME = 10;
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Payload-Nachricht + Eintrag im `EspNowMessage`-`oneof`:
|
||||||
|
|
||||||
|
```protobuf
|
||||||
|
message EspNowFindMe {
|
||||||
|
uint32 client_id = 1; // 0 = alle; sonst nur passende slave_id
|
||||||
|
}
|
||||||
|
|
||||||
|
// EspNowMessage.oneof:
|
||||||
|
EspNowFindMe find_me = 11;
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Generieren:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make proto_generate_espnow
|
||||||
|
# oder: make proto_generate
|
||||||
|
```
|
||||||
|
|
||||||
|
**Hinweis:** Master und alle Slaves müssen dieselbe ESP-NOW-Proto-Version flashen, sobald sich `esp_now_messages.proto` ändert.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Schritt 3 — Geräte-Logik (gemeinsam)
|
||||||
|
|
||||||
|
Funktion, die auf **Master und Slave** gleich wirken soll, gehört **nicht** in den UART-Handler, sondern in ein Modul (z.B. `led_ring`).
|
||||||
|
|
||||||
|
Find me:
|
||||||
|
|
||||||
|
- `led_ring_find_me()` in `led_ring.c` — sequenz `LED_CMD_FIND_ME` (3× rot/grün/blau, volle Helligkeit)
|
||||||
|
- Optional separater UART-Pfad nur für Ring-Steuerung: `LED_RING` mode `4` in `main/cmd/cmd_led_ring.c` (ohne ESP-NOW)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Schritt 4 — UART-Command-Handler (nur Master)
|
||||||
|
|
||||||
|
Neue Dateien: `main/cmd/cmd_espnow_find_me.c`, `main/cmd/cmd_espnow_find_me.h`
|
||||||
|
|
||||||
|
Muster (gekürzt):
|
||||||
|
|
||||||
|
```c
|
||||||
|
static void reply(bool success, uint32_t client_id) {
|
||||||
|
alox_UartMessage response;
|
||||||
|
uart_cmd_init_response(&response, alox_MessageType_FIND_ME,
|
||||||
|
alox_UartMessage_espnow_find_me_response_tag);
|
||||||
|
response.payload.espnow_find_me_response.success = success;
|
||||||
|
response.payload.espnow_find_me_response.client_id = client_id;
|
||||||
|
uart_cmd_send(&response, TAG);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void handle_find_me(const uint8_t *data, size_t len) {
|
||||||
|
alox_UartMessage uart_msg;
|
||||||
|
if (uart_cmd_decode(data, len, &uart_msg) != ESP_OK) { … }
|
||||||
|
|
||||||
|
const alox_EspNowFindMeRequest *req = UART_CMD_REQ(
|
||||||
|
&uart_msg, alox_UartMessage_espnow_find_me_request_tag,
|
||||||
|
espnow_find_me_request);
|
||||||
|
if (req == NULL) { … }
|
||||||
|
|
||||||
|
if (req->client_id == 0) {
|
||||||
|
led_ring_find_me(); // lokal
|
||||||
|
reply(true, 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const client_info_t *client = client_registry_find_by_id(req->client_id);
|
||||||
|
if (client == NULL) { reply(false, req->client_id); return; }
|
||||||
|
|
||||||
|
esp_err_t err = esp_now_comm_send_find_me(client->mac, req->client_id);
|
||||||
|
reply(err == ESP_OK, req->client_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
void cmd_espnow_find_me_register(void) {
|
||||||
|
uart_cmd_register(alox_MessageType_FIND_ME, handle_find_me);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Hilfs-APIs (`uart_cmd.h`):
|
||||||
|
|
||||||
|
| API | Zweck |
|
||||||
|
|-----|--------|
|
||||||
|
| `uart_cmd_decode()` | Protobuf-Body (ohne führendes Command-Byte) dekodieren |
|
||||||
|
| `UART_CMD_REQ()` | Sicheres Lesen des `oneof`-Zweigs |
|
||||||
|
| `uart_cmd_init_response()` | Response-Typ + `which_payload` setzen |
|
||||||
|
| `uart_cmd_send()` | Antwort UART raus |
|
||||||
|
|
||||||
|
**Registrierung** in `main/powerpod.c` (nur im `if (app_config.master)`-Block):
|
||||||
|
|
||||||
|
```c
|
||||||
|
cmd_espnow_find_me_register();
|
||||||
|
```
|
||||||
|
|
||||||
|
**Build:** `main/CMakeLists.txt` → `"cmd/cmd_espnow_find_me.c"`
|
||||||
|
**Logging-Namen:** `main/cmd/cmd_handler.c` → `case alox_MessageType_FIND_ME: return "FIND_ME";`
|
||||||
|
|
||||||
|
### Ablauf UART intern
|
||||||
|
|
||||||
|
1. `uart_read_task` liest Frame, legt `msg_id = payload[0]` und Rest in Queue.
|
||||||
|
2. `vCmdDispatcherTask` ruft den registrierten Handler mit `data` = Bytes **nach** der ID auf.
|
||||||
|
3. Handler antwortet synchron über `uart_cmd_send` (die LED-Animation läuft danach im `led_task` weiter).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Schritt 5 — ESP-NOW senden (Master)
|
||||||
|
|
||||||
|
In `main/esp_now_comm.c`:
|
||||||
|
|
||||||
|
1. **Statische Sendefunktion** (wie `send_unicast_test`):
|
||||||
|
|
||||||
|
```c
|
||||||
|
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 send_message(dest_mac, &msg);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Öffentliche API** in `esp_now_comm.h` / `.c`:
|
||||||
|
|
||||||
|
```c
|
||||||
|
esp_err_t esp_now_comm_send_find_me(const uint8_t mac[CLIENT_MAC_LEN],
|
||||||
|
uint32_t client_id);
|
||||||
|
```
|
||||||
|
|
||||||
|
Prüfungen: `mac != NULL`, `s_config.master`, Peer per `ensure_peer()` (passiert in `send_message`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Schritt 6 — ESP-NOW empfangen (Slave)
|
||||||
|
|
||||||
|
Im Slave-Zweig von `espnow_recv_cb` (`!s_config.master`):
|
||||||
|
|
||||||
|
1. Handler-Funktion, typisch mit **Master-MAC-Check** und **`client_id`-Filter** (wie Deadzone):
|
||||||
|
|
||||||
|
```c
|
||||||
|
static void handle_slave_find_me(const uint8_t *master_mac,
|
||||||
|
const alox_EspNowFindMe *req) {
|
||||||
|
uint32_t my_id = s_own_mac[5];
|
||||||
|
if (req->client_id != 0 && req->client_id != my_id) return;
|
||||||
|
if (s_slave_joined && !mac_equal(master_mac, s_master_mac)) return;
|
||||||
|
led_ring_find_me();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Im `switch (msg.which_payload)`:
|
||||||
|
|
||||||
|
```c
|
||||||
|
case alox_EspNowMessage_find_me_tag:
|
||||||
|
if (!s_slave_joined || !mac_equal(info->src_addr, s_master_mac)) break;
|
||||||
|
handle_slave_find_me(info->src_addr, &msg.payload.find_me);
|
||||||
|
break;
|
||||||
|
```
|
||||||
|
|
||||||
|
Slaves führen `led_ring_init()` in `powerpod.c` ebenfalls aus — Ring-Hardware ist auf beiden Rollen vorhanden.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Schritt 7 — Host (goTool)
|
||||||
|
|
||||||
|
### CLI
|
||||||
|
|
||||||
|
`goTool/cmd_find_me.go`:
|
||||||
|
|
||||||
|
- `UartMessage` mit `Type: MessageType_FIND_ME` und `EspnowFindMeRequest`
|
||||||
|
- Payload = `[byte(MessageType_FIND_ME)] + proto.Marshal(msg)`
|
||||||
|
- `exchangePayload` → Response dekodieren
|
||||||
|
|
||||||
|
`main.go`: Command `find-me` registrieren, `-port` Pflicht.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go run . -port /dev/ttyUSB0 find-me
|
||||||
|
go run . -port /dev/ttyUSB0 find-me -client 16
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dashboard / HTTP
|
||||||
|
|
||||||
|
1. `managedSerial.FindMe(clientID)` in `client_api.go` (UART-Serial mutex wie andere Befehle).
|
||||||
|
2. `POST /api/find-me` mit `{"client_id": 0}` in `api_serve.go`.
|
||||||
|
3. Button in `goTool/webui/index.html` (Master + pro Slave-Zeile).
|
||||||
|
|
||||||
|
### Serial-Format (Host)
|
||||||
|
|
||||||
|
Entspricht `main/README.md`:
|
||||||
|
|
||||||
|
| Byte | Inhalt |
|
||||||
|
|------|--------|
|
||||||
|
| Frame | `0xAA`, Länge (uint16 LE), Payload, XOR-Checksum, `0xCC` |
|
||||||
|
| Payload[0] | `MessageType` (z.B. `22` = FIND_ME) |
|
||||||
|
| Payload[1…] | Nanopb-`UartMessage` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Schritt 8 — Dokumentation & Test
|
||||||
|
|
||||||
|
1. **`main/README.md`:** Zeile in UART-Tabelle, ggf. ESP-NOW-Tabelle, eigener Abschnitt mit Beispiel-`go run`.
|
||||||
|
2. **`goTool/README.md`:** Command-Tabelle + HTTP-API.
|
||||||
|
3. **Build:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make proto_generate
|
||||||
|
cd goTool && go build .
|
||||||
|
idf.py build
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Manuell testen:**
|
||||||
|
|
||||||
|
| Test | Erwartung |
|
||||||
|
|------|-----------|
|
||||||
|
| `find-me` (client 0) | Master-Ring blinkt RGB |
|
||||||
|
| `find-me -client <id>` | Nur dieser Slave blinkt; Log Master: `unicast FIND_ME` |
|
||||||
|
| Slave offline / unbekannte ID | `success=false` in UART-Response |
|
||||||
|
| Dashboard-Buttons | Gleiches Verhalten wie CLI |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Checkliste (Kurz)
|
||||||
|
|
||||||
|
- [ ] `uart_messages.proto`: `MessageType`, Request, Response, `oneof`
|
||||||
|
- [ ] `esp_now_messages.proto` (falls Slave): `EspNowMessageType`, Message, `oneof`
|
||||||
|
- [ ] `make proto_generate` + `make gotool-proto`
|
||||||
|
- [ ] Geräte-Modul (z.B. `led_ring_*`)
|
||||||
|
- [ ] `cmd_*.c` + `uart_cmd_register`
|
||||||
|
- [ ] `esp_now_comm`: `send_*` + Slave-`recv` case
|
||||||
|
- [ ] `CMakeLists.txt`, `powerpod.c`, `cmd/cmd_handler.c` Name
|
||||||
|
- [ ] goTool: CLI, `client_api`, optional `api_serve` + WebUI
|
||||||
|
- [ ] README aktualisieren
|
||||||
|
- [ ] Master + Slave flashen bei ESP-NOW-Proto-Änderung
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Referenz-Dateien (Find me)
|
||||||
|
|
||||||
|
| Bereich | Datei |
|
||||||
|
|---------|--------|
|
||||||
|
| UART Proto | `main/proto/uart_messages.proto` |
|
||||||
|
| ESP-NOW Proto | `main/proto/esp_now_messages.proto` |
|
||||||
|
| UART Handler | `main/cmd/cmd_espnow_find_me.c` |
|
||||||
|
| ESP-NOW | `main/esp_now_comm.c`, `main/esp_now_comm.h` |
|
||||||
|
| LED | `main/led_ring.c`, `main/led_ring.h` |
|
||||||
|
| Host CLI | `goTool/cmd_find_me.go` |
|
||||||
|
| HTTP/UI | `goTool/api_serve.go`, `goTool/webui/index.html` |
|
||||||
|
|
||||||
|
Ähnliche Features zum Abgucken:
|
||||||
|
|
||||||
|
- **Nur Master, kein ESP-NOW:** `main/cmd/cmd_version.c`, `main/cmd/cmd_led_ring.c`, `main/cmd/cmd_set_log_level.c`
|
||||||
|
- **Nur Slave per ESP-NOW (Master leitet nur durch):** `main/cmd/cmd_espnow_unicast_test.c`
|
||||||
|
- **Master + alle Slaves / Filter:** `main/cmd/cmd_accel_deadzone.c`
|
||||||
|
- **Großer ESP-NOW-Fluss mit Status:** `ota_espnow.c`, `main/cmd/cmd_ota.c`
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
win:
|
||||||
|
GOOS=windows GOARCH=386 go build .
|
||||||
+109
-3
@@ -24,18 +24,123 @@ go run . -port /dev/ttyUSB0 clients
|
|||||||
|---------|--------------|-------------|
|
|---------|--------------|-------------|
|
||||||
| `version` | `0x03` | Prints `version` and `git_hash` from firmware |
|
| `version` | `0x03` | Prints `version` and `git_hash` from firmware |
|
||||||
| `clients` | `0x04` | Lists slaves registered on the master via ESP-NOW |
|
| `clients` | `0x04` | Lists slaves registered on the master via ESP-NOW |
|
||||||
|
| `deadzone` | `0x06` | Get/set accelerometer deadzone LSB (`-set`, `-value`, `-client`, `-all`) |
|
||||||
|
| `tap-config` | `0x20` | Get/set BMA456 tap detection params (`-set`, `-client`, `-all`, `-sens`, `-max-gest`, `-shock`, `-quiet`, `-wait`, `-axis`, `-single`, `-double`, `-triple`) |
|
||||||
|
| `tap-notify` | `0x1b` | Get/set which tap kinds (single/double/triple) notify via ESP-NOW (`-set`, `-client`, `-all`, `-single`, `-double`, `-triple`) |
|
||||||
|
| `cache-status` | `0x1d` | Subscribed accel + tap cache (`CACHE_STATUS`); one UART round-trip for ~8 ms polling |
|
||||||
| `unicast-test` | `0x07` | Sends ESP-NOW unicast test to one slave (`-client`, `-seq`) |
|
| `unicast-test` | `0x07` | Sends ESP-NOW unicast test to one slave (`-client`, `-seq`) |
|
||||||
|
| `echo-ping` | `0x1e` | ESP-NOW echo round-trip to one slave (`-client`); prints `rtt_ms` (host UART chain) and `esp_rtt_us` (master ESP-NOW, raw µs) |
|
||||||
|
| `test` | — | Run an automated scenario (JSON configs under `testdata/`) |
|
||||||
|
| `serve` | — | Web dashboard at `http://localhost:8080` (WebSocket live updates) |
|
||||||
|
| `ota` | 16–19 | UART firmware upload to master; firmware then pushes to slaves via ESP-NOW |
|
||||||
|
| `ota-progress` | 21 | Query per-slave ESP-NOW OTA progress on the master (`-client N`, default all) |
|
||||||
|
| `led-ring` | 8 | LED ring: `-mode clear\|color\|progress\|digit\|blink\|find-me\|battery-low`, `-client`, `-all` |
|
||||||
|
| `find-me` | 22 | Locate pod (`-client 0` master, `>0` slave via ESP-NOW) |
|
||||||
|
| `restart` | 23 | Reboot master or slave (`-client 0` / `>0`) |
|
||||||
|
| `log-level` | `0x1f` | Get/set master ESP-IDF log level for tag `"*"` (`-set`, `-level` 0–5); output on UART0 debug, not host UART |
|
||||||
|
| `battery-uv` | `0x24` | Get/set software LiPo UV protection (`-set -enable`/`-enable=false`, `-client`, `-all`) |
|
||||||
|
| `input-send-mode` | `0x23` | Get/set slave accel/tap ESP-NOW TX path (`-set -mode 0..3`, `-all` or `-client N`): best_effort/fast/wait/reliable |
|
||||||
|
|
||||||
`clients` requires slaves to have responded to master discover broadcasts first.
|
`clients` requires slaves to have responded to master discover broadcasts first.
|
||||||
|
|
||||||
|
For adding commands end-to-end (UART, ESP-NOW, CLI, dashboard), see **[docs/adding-a-feature.md](../docs/adding-a-feature.md)** (Find me example).
|
||||||
|
|
||||||
|
### Automated tests
|
||||||
|
|
||||||
|
Bench **configs** (`testdata/configs/`) list network, MACs, and serial ports (`uart.master` for commands, `*_console` for esptool reset). **Scenarios** run UART commands plus optional `reset` steps.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go run . test -list-configs
|
||||||
|
go run . test -list-scenarios
|
||||||
|
go run . test -config example-lab -scenario smoke
|
||||||
|
go run . test -config example-lab -scenario uart_cmds
|
||||||
|
go run . test -config my-lab -scenario smoke -port /dev/ttyUSB1 -v
|
||||||
|
```
|
||||||
|
|
||||||
|
With a complete bench config, `-port` is optional for `test` (uses `uart.master` from JSON).
|
||||||
|
|
||||||
|
See [`testdata/README.md`](testdata/README.md) for the JSON schema.
|
||||||
|
|
||||||
|
### Web dashboard
|
||||||
|
|
||||||
|
Starts without a required host link. Connect UART or BLE from the UI (or pre-connect with `-port` / `-ble`). State is pushed to the browser via WebSocket (Alpine.js + Bootstrap 5). OTA works over UART or BLE (BLE needs ATT MTU ≥ 219).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go run . serve
|
||||||
|
go run . -port /dev/ttyUSB0 serve
|
||||||
|
go run . -ble serve
|
||||||
|
go run . serve -addr :8080 -interval 2s
|
||||||
|
go run . serve -api-addr :8081 -accel-interval 16ms
|
||||||
|
make gotool-serve PORT=/dev/ttyUSB0
|
||||||
|
```
|
||||||
|
|
||||||
|
Open [http://localhost:8080](http://localhost:8080) — Host-Verbindung panel (UART ports / BLE scan), master firmware info, ESP-NOW client table.
|
||||||
|
|
||||||
|
**Tap (dashboard):** two independent controls per slave:
|
||||||
|
|
||||||
|
| Column | Meaning |
|
||||||
|
|--------|---------|
|
||||||
|
| Tap-Notify (S/D/T) | Which tap kinds the **slave** sends to the master over ESP-NOW (UART `TAP_NOTIFY`) — does **not** poll UART |
|
||||||
|
| Tap (An/Aus) | Host **receive**: poll master tap cache (~8 ms) and show last tap for **≥2 s** |
|
||||||
|
|
||||||
|
Enable notify first, then turn receive on to see events. Same split as the external WebSocket API (`set_tap_notify` vs `set_tap_stream`).
|
||||||
|
|
||||||
|
If the host disconnects, `serve` keeps running; the badge shows **off** until you reconnect from the UI or via REST/WS.
|
||||||
|
|
||||||
|
### HTTP / WebSocket API
|
||||||
|
|
||||||
|
`serve` also listens on **`:8081`** for external programs (`-api-addr`, empty to disable). Same `managedHost` as the dashboard (UART or BLE).
|
||||||
|
|
||||||
|
| Doc | Content |
|
||||||
|
|-----|---------|
|
||||||
|
| **[docs/API_WEBSOCKET.md](docs/API_WEBSOCKET.md)** | `ws://…:8081/ws` commands and **`input` push stream** (accel + tap) |
|
||||||
|
| **[docs/API_REST.md](docs/API_REST.md)** | REST on `:8080` (dashboard) and `:8081` (battery, LED, service info) |
|
||||||
|
|
||||||
|
CLI:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go run . -port /dev/ttyUSB0 tap-config
|
||||||
|
go run . -port /dev/ttyUSB0 tap-config -set -sens 5 -axis 2
|
||||||
|
go run . -port /dev/ttyUSB0 tap-notify -client 16 -set -single
|
||||||
|
go run . -port /dev/ttyUSB0 cache-status
|
||||||
|
```
|
||||||
|
|
||||||
|
| UI / API | Behaviour |
|
||||||
|
|----------|-----------|
|
||||||
|
| Firmware OTA card | Same as `ota` CLI; dashboard WebSocket `ota_progress` ([REST doc](docs/API_REST.md)) |
|
||||||
|
| `POST /api/ota` | Upload `.bin` to master — slaves updated by firmware over ESP-NOW after `OTA_END` |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go run . -port /dev/ttyUSB0 ota build/powerpod.bin
|
||||||
|
go run . -ble ota build/powerpod.bin
|
||||||
|
```
|
||||||
|
|
||||||
|
Waits for **ready** after start (~30 s erase), sends 200-byte `OTA_PAYLOAD` frames, reads **block_ack** every 4 KiB, then `OTA_END`. The master then distributes to all available slaves (no extra host traffic); **success** is reported only when that finishes. Allow several minutes for large images. Reboot master and slaves to boot the new firmware.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go run . -port /dev/ttyUSB0 unicast-test -client 16 -seq 42
|
go run . -port /dev/ttyUSB0 unicast-test -client 16 -seq 42
|
||||||
```
|
```
|
||||||
|
|
||||||
On success the slave serial log should show `UNICAST TEST OK from master … seq=42`.
|
On success the slave serial log should show `UNICAST TEST OK from master … seq=42`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go run . -port /dev/ttyUSB0 echo-ping -client 16
|
||||||
|
go run . -port /dev/ttyUSB0 log-level
|
||||||
|
go run . -port /dev/ttyUSB0 log-level -set -level 3
|
||||||
|
```
|
||||||
|
|
||||||
|
`log-level` controls `esp_log_*` on the master (UART0 USB console). The host protocol UART (GPIO 2/3) is unchanged.
|
||||||
|
|
||||||
|
Measures latency to one slave. `rtt_ms` is the full host round-trip (UART + ESP-NOW + UART back). `esp_rtt_us` is the master-side ESP-NOW leg only (`esp_timer_get_time()` delta, raw microseconds from firmware).
|
||||||
|
|
||||||
Example output:
|
Example output:
|
||||||
|
|
||||||
|
```
|
||||||
|
echo ping: success=true client_id=16 rtt_ms=49.729 esp_rtt_us=18234
|
||||||
|
```
|
||||||
|
|
||||||
|
`clients` example:
|
||||||
|
|
||||||
```
|
```
|
||||||
clients (2):
|
clients (2):
|
||||||
[0] id=42 mac=aabbccddeeff ver=1 available=true used=false last_ping=250 last_success_ping=250
|
[0] id=42 mac=aabbccddeeff ver=1 available=true used=false last_ping=250 last_success_ping=250
|
||||||
@@ -43,8 +148,9 @@ clients (2):
|
|||||||
|
|
||||||
## Regenerate protobuf
|
## Regenerate protobuf
|
||||||
|
|
||||||
|
From repo root (needs `protoc`, `protoc-gen-go`, and for C also `pip install protobuf`):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
protoc --go_out=./pb --go_opt=paths=source_relative \
|
make gotool-proto # Go: goTool/pb/uart_messages.pb.go
|
||||||
--go_opt=Muart_messages.proto=powerpod/gotool/pb \
|
make proto_generate # C: main/proto/*.pb.h, *.pb.c
|
||||||
-I ../main/proto ../main/proto/uart_messages.proto
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "sync"
|
||||||
|
|
||||||
|
// accelStreamCtl tracks which slaves the host wants to poll for accel (mirrors firmware).
|
||||||
|
type accelStreamCtl struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
enabled map[uint32]struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newAccelStreamCtl() *accelStreamCtl {
|
||||||
|
return &accelStreamCtl{enabled: make(map[uint32]struct{})}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *accelStreamCtl) Set(clientID uint32, on bool) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
if on {
|
||||||
|
c.enabled[clientID] = struct{}{}
|
||||||
|
} else {
|
||||||
|
delete(c.enabled, clientID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *accelStreamCtl) Any() bool {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
return len(c.enabled) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *accelStreamCtl) SyncFromClients(clients []ClientView) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
c.enabled = make(map[uint32]struct{})
|
||||||
|
for _, cl := range clients {
|
||||||
|
if cl.AccelStream {
|
||||||
|
c.enabled[cl.ID] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"powerpod/gotool/pb"
|
||||||
|
)
|
||||||
|
|
||||||
|
type accelStreamAPIRequest struct {
|
||||||
|
Write bool `json:"write"`
|
||||||
|
Enable bool `json:"enable"`
|
||||||
|
ClientID uint32 `json:"client_id"`
|
||||||
|
AllClients bool `json:"all_clients"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type accelStreamAPIResponse struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
ClientID uint32 `json:"client_id"`
|
||||||
|
Success bool `json:"success"`
|
||||||
|
SlavesUpdated uint32 `json:"slaves_updated"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type clientAccelStreamBody struct {
|
||||||
|
Enable bool `json:"enable"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func mountAccelStreamAPI(mux *http.ServeMux, link *managedSerial, hub *wsHub, ctl *accelStreamCtl) {
|
||||||
|
mux.HandleFunc("GET /api/clients/{clientID}/accel-stream", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
clientID, err := parsePathClientID(r)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusBadRequest, accelStreamAPIResponse{Error: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
serveAccelStreamGet(w, clientID, link, hub, ctl)
|
||||||
|
})
|
||||||
|
mux.HandleFunc("PUT /api/clients/{clientID}/accel-stream", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
clientID, err := parsePathClientID(r)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusBadRequest, accelStreamAPIResponse{Error: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
serveClientAccelStreamPut(w, r, clientID, link, hub, ctl)
|
||||||
|
})
|
||||||
|
|
||||||
|
mux.HandleFunc("/api/accel-stream", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
serveAccelStreamGetQuery(w, r, link, hub, ctl)
|
||||||
|
case http.MethodPost:
|
||||||
|
serveAccelStreamPost(w, r, link, hub, ctl)
|
||||||
|
default:
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func parsePathClientID(r *http.Request) (uint32, error) {
|
||||||
|
s := r.PathValue("clientID")
|
||||||
|
if s == "" {
|
||||||
|
return 0, fmt.Errorf("client_id required")
|
||||||
|
}
|
||||||
|
v, err := strconv.ParseUint(s, 10, 32)
|
||||||
|
if err != nil || v == 0 {
|
||||||
|
return 0, fmt.Errorf("invalid client_id")
|
||||||
|
}
|
||||||
|
return uint32(v), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyAccelStreamClient(link *managedSerial, hub *wsHub, ctl *accelStreamCtl, clientID uint32, enable bool) accelStreamAPIResponse {
|
||||||
|
resp, err := link.AccelStream(&pb.AccelStreamRequest{
|
||||||
|
Write: true,
|
||||||
|
Enable: enable,
|
||||||
|
ClientId: clientID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return accelStreamAPIResponse{
|
||||||
|
ClientID: clientID,
|
||||||
|
Error: err.Error(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out := accelStreamAPIResponse{
|
||||||
|
Enabled: enable,
|
||||||
|
ClientID: resp.GetClientId(),
|
||||||
|
Success: resp.GetSuccess(),
|
||||||
|
SlavesUpdated: resp.GetSlavesUpdated(),
|
||||||
|
}
|
||||||
|
if resp.GetSuccess() {
|
||||||
|
if ctl != nil {
|
||||||
|
ctl.Set(clientID, enable)
|
||||||
|
}
|
||||||
|
if hub != nil {
|
||||||
|
hub.patchClientAccelStream(clientID, enable)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
out.Enabled = resp.GetEnabled()
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func serveAccelStreamGet(w http.ResponseWriter, clientID uint32, link *managedSerial, hub *wsHub, ctl *accelStreamCtl) {
|
||||||
|
resp, err := link.AccelStreamPoll(&pb.AccelStreamRequest{
|
||||||
|
Write: false,
|
||||||
|
ClientId: clientID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusServiceUnavailable, accelStreamAPIResponse{
|
||||||
|
ClientID: clientID,
|
||||||
|
Error: err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ctl != nil {
|
||||||
|
ctl.Set(clientID, resp.GetEnabled())
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, accelStreamAPIResponse{
|
||||||
|
Enabled: resp.GetEnabled(),
|
||||||
|
ClientID: resp.GetClientId(),
|
||||||
|
Success: resp.GetSuccess(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func serveAccelStreamGetQuery(w http.ResponseWriter, r *http.Request, link *managedSerial, hub *wsHub, ctl *accelStreamCtl) {
|
||||||
|
clientID, err := parseUintQuery(r, "client_id", 0)
|
||||||
|
if err != nil || clientID == 0 {
|
||||||
|
writeJSON(w, http.StatusBadRequest, accelStreamAPIResponse{Error: "client_id required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
serveAccelStreamGet(w, clientID, link, hub, ctl)
|
||||||
|
}
|
||||||
|
|
||||||
|
func serveClientAccelStreamPut(w http.ResponseWriter, r *http.Request, clientID uint32, link *managedSerial, hub *wsHub, ctl *accelStreamCtl) {
|
||||||
|
var body clientAccelStreamBody
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeJSON(w, http.StatusBadRequest, accelStreamAPIResponse{Error: "invalid JSON"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out := applyAccelStreamClient(link, hub, ctl, clientID, body.Enable)
|
||||||
|
status := http.StatusOK
|
||||||
|
if out.Error != "" {
|
||||||
|
status = http.StatusServiceUnavailable
|
||||||
|
} else if !out.Success {
|
||||||
|
status = http.StatusServiceUnavailable
|
||||||
|
}
|
||||||
|
writeJSON(w, status, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func serveAccelStreamPost(w http.ResponseWriter, r *http.Request, link *managedSerial, hub *wsHub, ctl *accelStreamCtl) {
|
||||||
|
var body accelStreamAPIRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeJSON(w, http.StatusBadRequest, accelStreamAPIResponse{Error: "invalid JSON"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if body.AllClients {
|
||||||
|
updated, err := applyAccelStreamAll(link, body.Enable)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusServiceUnavailable, accelStreamAPIResponse{Error: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
clients, _ := link.listClientsPoll()
|
||||||
|
for _, c := range clients {
|
||||||
|
if ctl != nil {
|
||||||
|
ctl.Set(c.GetId(), body.Enable)
|
||||||
|
}
|
||||||
|
if hub != nil {
|
||||||
|
hub.patchClientAccelStream(c.GetId(), body.Enable)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, accelStreamAPIResponse{
|
||||||
|
Enabled: body.Enable,
|
||||||
|
Success: updated > 0,
|
||||||
|
SlavesUpdated: updated,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if body.ClientID == 0 {
|
||||||
|
writeJSON(w, http.StatusBadRequest, accelStreamAPIResponse{Error: "client_id required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
out := applyAccelStreamClient(link, hub, ctl, body.ClientID, body.Enable)
|
||||||
|
status := http.StatusOK
|
||||||
|
if out.Error != "" || !out.Success {
|
||||||
|
status = http.StatusServiceUnavailable
|
||||||
|
}
|
||||||
|
writeJSON(w, status, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyAccelStreamAll(link *managedSerial, enable bool) (uint32, error) {
|
||||||
|
clients, err := link.listClients()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
var updated uint32
|
||||||
|
for _, c := range clients {
|
||||||
|
resp, err := link.AccelStream(&pb.AccelStreamRequest{
|
||||||
|
Write: true,
|
||||||
|
Enable: enable,
|
||||||
|
ClientId: c.GetId(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if resp.GetSuccess() {
|
||||||
|
updated++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(clients) == 0 {
|
||||||
|
return 0, fmt.Errorf("no slaves registered")
|
||||||
|
}
|
||||||
|
if updated == 0 {
|
||||||
|
return 0, fmt.Errorf("accel stream not applied to any slave")
|
||||||
|
}
|
||||||
|
return updated, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
func mountBatteryAPI(mux *http.ServeMux, link *managedSerial) {
|
||||||
|
mux.HandleFunc("/api/battery", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
serveBatteryGet(w, r, link)
|
||||||
|
case http.MethodPost:
|
||||||
|
serveBatteryPost(w, r, link)
|
||||||
|
default:
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func serveBatteryGet(w http.ResponseWriter, r *http.Request, link *managedSerial) {
|
||||||
|
req := batteryAPIRequest{}
|
||||||
|
if v := r.URL.Query().Get("all_clients"); v == "1" || v == "true" {
|
||||||
|
req.AllClients = true
|
||||||
|
}
|
||||||
|
if s := r.URL.Query().Get("client_id"); s != "" {
|
||||||
|
id, err := strconv.ParseUint(s, 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusBadRequest, batteryAPIResponse{Error: "invalid client_id"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.ClientID = uint32(id)
|
||||||
|
} else if !req.AllClients {
|
||||||
|
req.AllClients = true
|
||||||
|
}
|
||||||
|
out := applyBatteryStatus(link, req)
|
||||||
|
status := http.StatusOK
|
||||||
|
if out.Error != "" || !out.Success {
|
||||||
|
status = http.StatusServiceUnavailable
|
||||||
|
}
|
||||||
|
writeJSON(w, status, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func serveBatteryPost(w http.ResponseWriter, r *http.Request, link *managedSerial) {
|
||||||
|
var body batteryAPIRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeJSON(w, http.StatusBadRequest, batteryAPIResponse{Error: "invalid JSON"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !body.AllClients && body.ClientID == 0 {
|
||||||
|
body.AllClients = true
|
||||||
|
}
|
||||||
|
out := applyBatteryStatus(link, body)
|
||||||
|
status := http.StatusOK
|
||||||
|
if out.Error != "" || !out.Success {
|
||||||
|
status = http.StatusServiceUnavailable
|
||||||
|
}
|
||||||
|
writeJSON(w, status, out)
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"powerpod/gotool/pb"
|
||||||
|
)
|
||||||
|
|
||||||
|
type batteryUvAPIRequest struct {
|
||||||
|
Write bool `json:"write"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
ClientID uint32 `json:"client_id"`
|
||||||
|
AllClients bool `json:"all_clients"`
|
||||||
|
// SlavesOnly: with all_clients, push to ESP-NOW slaves only (master NVS unchanged).
|
||||||
|
SlavesOnly bool `json:"slaves_only"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type batteryUvAPIResponse struct {
|
||||||
|
ClientID uint32 `json:"client_id"`
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Latched bool `json:"latched"`
|
||||||
|
SlavesUpdated uint32 `json:"slaves_updated"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func mountBatteryUvAPI(mux *http.ServeMux, link *managedSerial) {
|
||||||
|
mux.HandleFunc("/api/battery-uv", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
serveBatteryUvGet(w, r, link)
|
||||||
|
case http.MethodPost:
|
||||||
|
serveBatteryUvPost(w, r, link)
|
||||||
|
default:
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func serveBatteryUvGet(w http.ResponseWriter, r *http.Request, link *managedSerial) {
|
||||||
|
clientID, err := parseUintQuery(r, "client_id", 0)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusBadRequest, batteryUvAPIResponse{Error: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp, err := link.BatteryUv(&pb.BatteryUvRequest{
|
||||||
|
Write: false,
|
||||||
|
ClientId: clientID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusServiceUnavailable, batteryUvAPIResponse{
|
||||||
|
ClientID: clientID,
|
||||||
|
Error: err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, batteryUvAPIResponse{
|
||||||
|
ClientID: resp.GetClientId(),
|
||||||
|
Success: resp.GetSuccess(),
|
||||||
|
Enabled: resp.GetEnabled(),
|
||||||
|
Latched: resp.GetLatched(),
|
||||||
|
SlavesUpdated: resp.GetSlavesUpdated(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func serveBatteryUvPost(w http.ResponseWriter, r *http.Request, link *managedSerial) {
|
||||||
|
var body batteryUvAPIRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeJSON(w, http.StatusBadRequest, batteryUvAPIResponse{Error: "invalid JSON"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.AllClients && body.SlavesOnly {
|
||||||
|
updated, err := applyBatteryUvToSlaves(link, body.Enabled)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusServiceUnavailable, batteryUvAPIResponse{
|
||||||
|
Enabled: body.Enabled,
|
||||||
|
Error: err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, batteryUvAPIResponse{
|
||||||
|
Success: updated > 0,
|
||||||
|
Enabled: body.Enabled,
|
||||||
|
SlavesUpdated: updated,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := link.BatteryUv(&pb.BatteryUvRequest{
|
||||||
|
Write: true,
|
||||||
|
Enabled: body.Enabled,
|
||||||
|
ClientId: body.ClientID,
|
||||||
|
AllClients: body.AllClients,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusServiceUnavailable, batteryUvAPIResponse{
|
||||||
|
ClientID: body.ClientID,
|
||||||
|
Enabled: body.Enabled,
|
||||||
|
Error: err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, batteryUvAPIResponse{
|
||||||
|
ClientID: resp.GetClientId(),
|
||||||
|
Success: resp.GetSuccess(),
|
||||||
|
Enabled: resp.GetEnabled(),
|
||||||
|
Latched: resp.GetLatched(),
|
||||||
|
SlavesUpdated: resp.GetSlavesUpdated(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyBatteryUvToSlaves(link *managedSerial, enabled bool) (uint32, error) {
|
||||||
|
clients, err := link.listClients()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
var updated uint32
|
||||||
|
for _, c := range clients {
|
||||||
|
resp, err := link.BatteryUv(&pb.BatteryUvRequest{
|
||||||
|
Write: true,
|
||||||
|
Enabled: enabled,
|
||||||
|
ClientId: c.GetId(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if resp.GetSuccess() {
|
||||||
|
updated++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(clients) == 0 {
|
||||||
|
return 0, fmt.Errorf("no slaves registered")
|
||||||
|
}
|
||||||
|
if updated == 0 {
|
||||||
|
return 0, fmt.Errorf("UV protection not applied to any slave")
|
||||||
|
}
|
||||||
|
return updated, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type bleRttAPIRequest struct {
|
||||||
|
Count *int `json:"count"`
|
||||||
|
Warmup *int `json:"warmup"`
|
||||||
|
SettleS *float64 `json:"settle_s"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type bleStreamAPIRequest struct {
|
||||||
|
ClientID *uint32 `json:"client_id"`
|
||||||
|
PeriodMs *uint32 `json:"period_ms"`
|
||||||
|
Count *int `json:"count"`
|
||||||
|
TolMs *float64 `json:"tol_ms"`
|
||||||
|
TimeoutS *float64 `json:"timeout_s"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func mountBleTestAPI(mux *http.ServeMux, link *managedHost) {
|
||||||
|
mux.HandleFunc("/api/ble-rtt", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
serveBleRtt(w, r, link)
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/api/ble-stream", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
serveBleStreamTest(w, r, link)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func requireBLEHost(w http.ResponseWriter, link *managedHost) bool {
|
||||||
|
if link.Transport() != hostTransportBLE || !link.IsConnected() {
|
||||||
|
writeJSON(w, http.StatusBadRequest, bleRttResult{
|
||||||
|
Error: "BLE host required (connect via BLE, DIP8=1)",
|
||||||
|
})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func serveBleRtt(w http.ResponseWriter, r *http.Request, link *managedHost) {
|
||||||
|
if !requireBLEHost(w, link) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body bleRttAPIRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil && !errors.Is(err, io.EOF) {
|
||||||
|
writeJSON(w, http.StatusBadRequest, bleRttResult{Error: "invalid JSON"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
count, warmup := 50, 5
|
||||||
|
settle := 0.0
|
||||||
|
if body.Count != nil {
|
||||||
|
count = *body.Count
|
||||||
|
}
|
||||||
|
if body.Warmup != nil {
|
||||||
|
warmup = *body.Warmup
|
||||||
|
}
|
||||||
|
if body.SettleS != nil {
|
||||||
|
settle = *body.SettleS
|
||||||
|
}
|
||||||
|
|
||||||
|
if !link.beginPausePoll() {
|
||||||
|
writeJSON(w, http.StatusConflict, bleRttResult{Error: "host busy"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer link.endPausePoll()
|
||||||
|
|
||||||
|
out := measureBleRtt(link, count, warmup, time.Duration(settle*float64(time.Second)))
|
||||||
|
status := http.StatusOK
|
||||||
|
if !out.Success {
|
||||||
|
status = http.StatusBadRequest
|
||||||
|
}
|
||||||
|
writeJSON(w, status, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func serveBleStreamTest(w http.ResponseWriter, r *http.Request, link *managedHost) {
|
||||||
|
if !requireBLEHost(w, link) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body bleStreamAPIRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil && !errors.Is(err, io.EOF) {
|
||||||
|
writeJSON(w, http.StatusBadRequest, bleStreamResult{Error: "invalid JSON"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
count := 50
|
||||||
|
periodMs := uint32(10)
|
||||||
|
tolMs := 2.0
|
||||||
|
timeoutS := 15.0
|
||||||
|
var clientID uint32
|
||||||
|
if body.Count != nil {
|
||||||
|
count = *body.Count
|
||||||
|
}
|
||||||
|
if body.PeriodMs != nil {
|
||||||
|
periodMs = *body.PeriodMs
|
||||||
|
}
|
||||||
|
if body.TolMs != nil {
|
||||||
|
tolMs = *body.TolMs
|
||||||
|
}
|
||||||
|
if body.TimeoutS != nil {
|
||||||
|
timeoutS = *body.TimeoutS
|
||||||
|
}
|
||||||
|
if body.ClientID != nil {
|
||||||
|
clientID = *body.ClientID
|
||||||
|
}
|
||||||
|
|
||||||
|
if !link.beginPausePoll() {
|
||||||
|
writeJSON(w, http.StatusConflict, bleStreamResult{Error: "host busy"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer link.endPausePoll()
|
||||||
|
|
||||||
|
out := measureBleStream(link, clientID, periodMs, count, tolMs, timeoutS)
|
||||||
|
status := http.StatusOK
|
||||||
|
if !out.Success {
|
||||||
|
status = http.StatusBadRequest
|
||||||
|
}
|
||||||
|
writeJSON(w, status, out)
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type hostAPIResponse struct {
|
||||||
|
Transport string `json:"transport"`
|
||||||
|
Endpoint string `json:"endpoint"`
|
||||||
|
Connected bool `json:"connected"`
|
||||||
|
BleName string `json:"ble_name,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type hostConnectRequest struct {
|
||||||
|
Transport string `json:"transport"` // uart|ble
|
||||||
|
Port string `json:"port,omitempty"`
|
||||||
|
Address string `json:"address,omitempty"`
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
Baud int `json:"baud,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type bleScanRequest struct {
|
||||||
|
TimeoutS float64 `json:"timeout_s"`
|
||||||
|
NameFilter string `json:"name_filter"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type bleScanResponse struct {
|
||||||
|
Devices []bleScanDevice `json:"devices"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type serialPortsResponse struct {
|
||||||
|
Ports []string `json:"ports"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func mountHostAPI(mux *http.ServeMux, link *managedHost) {
|
||||||
|
mux.HandleFunc("/api/host", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
st := link.HostStatus()
|
||||||
|
writeJSON(w, http.StatusOK, hostAPIResponse{
|
||||||
|
Transport: st["transport"].(string),
|
||||||
|
Endpoint: st["endpoint"].(string),
|
||||||
|
Connected: st["connected"].(bool),
|
||||||
|
BleName: st["ble_name"].(string),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
mux.HandleFunc("/api/host/connect", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body hostConnectRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeJSON(w, http.StatusBadRequest, hostAPIResponse{Error: "invalid JSON"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var err error
|
||||||
|
switch body.Transport {
|
||||||
|
case hostTransportUART:
|
||||||
|
err = link.ConnectUART(body.Port, body.Baud)
|
||||||
|
case hostTransportBLE:
|
||||||
|
err = link.ConnectBLE(body.Address, body.Name)
|
||||||
|
default:
|
||||||
|
writeJSON(w, http.StatusBadRequest, hostAPIResponse{Error: "transport must be uart or ble"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
st := link.HostStatus()
|
||||||
|
resp := hostAPIResponse{
|
||||||
|
Transport: st["transport"].(string),
|
||||||
|
Endpoint: st["endpoint"].(string),
|
||||||
|
Connected: st["connected"].(bool),
|
||||||
|
BleName: st["ble_name"].(string),
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
resp.Error = err.Error()
|
||||||
|
resp.Connected = false
|
||||||
|
writeJSON(w, http.StatusBadRequest, resp)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, resp)
|
||||||
|
})
|
||||||
|
|
||||||
|
mux.HandleFunc("/api/host/disconnect", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = link.Disconnect()
|
||||||
|
st := link.HostStatus()
|
||||||
|
writeJSON(w, http.StatusOK, hostAPIResponse{
|
||||||
|
Transport: st["transport"].(string),
|
||||||
|
Endpoint: st["endpoint"].(string),
|
||||||
|
Connected: false,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
mux.HandleFunc("/api/serial/ports", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ports, err := listSerialPorts()
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusOK, serialPortsResponse{Ports: []string{}, Error: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, serialPortsResponse{Ports: ports})
|
||||||
|
})
|
||||||
|
|
||||||
|
mux.HandleFunc("/api/ble/scan", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body bleScanRequest
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||||
|
timeout := time.Duration(body.TimeoutS * float64(time.Second))
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = 5 * time.Second
|
||||||
|
}
|
||||||
|
// Disconnect active BLE before scan (BlueZ adapter contention).
|
||||||
|
if link.Transport() == hostTransportBLE {
|
||||||
|
_ = link.Disconnect()
|
||||||
|
}
|
||||||
|
devs, err := scanBLEDevices(timeout, body.NameFilter)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusOK, bleScanResponse{Devices: []bleScanDevice{}, Error: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, bleScanResponse{Devices: devs})
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
pb "powerpod/gotool/pb"
|
||||||
|
)
|
||||||
|
|
||||||
|
type inputSendModeAPIResponse struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Mode uint32 `json:"mode"`
|
||||||
|
ModeName string `json:"mode_name"`
|
||||||
|
ClientID uint32 `json:"client_id,omitempty"`
|
||||||
|
SlavesUpdated uint32 `json:"slaves_updated,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func inputSendModeName(mode pb.InputSendMode) string {
|
||||||
|
switch mode {
|
||||||
|
case pb.InputSendMode_INPUT_SEND_BEST_EFFORT:
|
||||||
|
return "best_effort"
|
||||||
|
case pb.InputSendMode_INPUT_SEND_FAST:
|
||||||
|
return "fast"
|
||||||
|
case pb.InputSendMode_INPUT_SEND_WAIT:
|
||||||
|
return "wait"
|
||||||
|
case pb.InputSendMode_INPUT_SEND_RELIABLE:
|
||||||
|
return "reliable"
|
||||||
|
default:
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mountInputSendModeAPI(mux *http.ServeMux, link *managedSerial) {
|
||||||
|
mux.HandleFunc("/api/input-send-mode", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
serveInputSendModeGet(w, link)
|
||||||
|
case http.MethodPut, http.MethodPost:
|
||||||
|
serveInputSendModePut(w, r, link)
|
||||||
|
default:
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func serveInputSendModeGet(w http.ResponseWriter, link *managedSerial) {
|
||||||
|
resp, err := link.InputSendMode(&pb.InputSendModeRequest{Write: false})
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusBadGateway, inputSendModeAPIResponse{Error: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, inputSendModeAPIResponse{
|
||||||
|
Success: resp.GetSuccess(),
|
||||||
|
Mode: uint32(resp.GetMode()),
|
||||||
|
ModeName: inputSendModeName(resp.GetMode()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func serveInputSendModePut(w http.ResponseWriter, r *http.Request, link *managedSerial) {
|
||||||
|
var body struct {
|
||||||
|
Mode uint32 `json:"mode"`
|
||||||
|
ClientID uint32 `json:"client_id"`
|
||||||
|
AllClients bool `json:"all_clients"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeJSON(w, http.StatusBadRequest, inputSendModeAPIResponse{Error: "invalid JSON"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.Mode > uint32(pb.InputSendMode_INPUT_SEND_RELIABLE) {
|
||||||
|
writeJSON(w, http.StatusBadRequest, inputSendModeAPIResponse{Error: "mode must be 0..3"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !body.AllClients && body.ClientID == 0 {
|
||||||
|
// Dashboard default: apply to every registered slave.
|
||||||
|
body.AllClients = true
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := link.InputSendMode(&pb.InputSendModeRequest{
|
||||||
|
Write: true,
|
||||||
|
Mode: pb.InputSendMode(body.Mode),
|
||||||
|
ClientId: body.ClientID,
|
||||||
|
AllClients: body.AllClients,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusBadGateway, inputSendModeAPIResponse{Error: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
status := http.StatusOK
|
||||||
|
if !resp.GetSuccess() {
|
||||||
|
status = http.StatusBadRequest
|
||||||
|
}
|
||||||
|
writeJSON(w, status, inputSendModeAPIResponse{
|
||||||
|
Success: resp.GetSuccess(),
|
||||||
|
Mode: uint32(resp.GetMode()),
|
||||||
|
ModeName: inputSendModeName(resp.GetMode()),
|
||||||
|
ClientID: resp.GetClientId(),
|
||||||
|
SlavesUpdated: resp.GetSlavesUpdated(),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func mountLedRingAPI(mux *http.ServeMux, link *managedSerial) {
|
||||||
|
mux.HandleFunc("/api/led-ring", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
serveLedRingPost(w, r, link)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func serveLedRingPost(w http.ResponseWriter, r *http.Request, link *managedSerial) {
|
||||||
|
var body ledRingAPIRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeJSON(w, http.StatusBadRequest, ledRingAPIResponse{Error: "invalid JSON"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.Mode == "" {
|
||||||
|
writeJSON(w, http.StatusBadRequest, ledRingAPIResponse{Error: "mode required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out := applyLedRing(link, body)
|
||||||
|
status := http.StatusOK
|
||||||
|
if out.Error != "" {
|
||||||
|
status = http.StatusServiceUnavailable
|
||||||
|
} else if !out.Success {
|
||||||
|
status = http.StatusServiceUnavailable
|
||||||
|
}
|
||||||
|
writeJSON(w, status, out)
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
type liveStreamAPIResponse struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func mountLiveStreamAPI(mux *http.ServeMux, hub *wsHub) {
|
||||||
|
mux.HandleFunc("/api/live-stream", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
serveLiveStreamGet(w, hub)
|
||||||
|
case http.MethodPut:
|
||||||
|
serveLiveStreamPut(w, r, hub)
|
||||||
|
default:
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func serveLiveStreamGet(w http.ResponseWriter, hub *wsHub) {
|
||||||
|
enabled := false
|
||||||
|
if hub != nil {
|
||||||
|
enabled = hub.liveStreamEnabled()
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, liveStreamAPIResponse{Enabled: enabled, Success: true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func serveLiveStreamPut(w http.ResponseWriter, r *http.Request, hub *wsHub) {
|
||||||
|
var body struct {
|
||||||
|
Enable bool `json:"enable"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeJSON(w, http.StatusBadRequest, liveStreamAPIResponse{Error: "invalid JSON"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if hub != nil {
|
||||||
|
hub.patchLiveStream(body.Enable)
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, liveStreamAPIResponse{
|
||||||
|
Enabled: body.Enable,
|
||||||
|
Success: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,471 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"powerpod/gotool/pb"
|
||||||
|
)
|
||||||
|
|
||||||
|
const otaMaxFirmwareSize = 2 * 1024 * 1024
|
||||||
|
|
||||||
|
type deadzoneAPIResponse struct {
|
||||||
|
Deadzone uint32 `json:"deadzone"`
|
||||||
|
ClientID uint32 `json:"client_id"`
|
||||||
|
Success bool `json:"success"`
|
||||||
|
SlavesUpdated uint32 `json:"slaves_updated"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type deadzoneAPIRequest struct {
|
||||||
|
Write bool `json:"write"`
|
||||||
|
Deadzone uint32 `json:"deadzone"`
|
||||||
|
ClientID uint32 `json:"client_id"`
|
||||||
|
AllClients bool `json:"all_clients"`
|
||||||
|
// SlavesOnly: with all_clients, push to ESP-NOW slaves only (master BMA456 unchanged).
|
||||||
|
SlavesOnly bool `json:"slaves_only"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type unicastAPIRequest struct {
|
||||||
|
ClientID uint32 `json:"client_id"`
|
||||||
|
Seq uint32 `json:"seq"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type unicastAPIResponse struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Seq uint32 `json:"seq"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type echoPingAPIRequest struct {
|
||||||
|
ClientID uint32 `json:"client_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type echoPingAPIResponse struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
ClientID uint32 `json:"client_id,omitempty"`
|
||||||
|
TimestampUs uint64 `json:"timestamp_us,omitempty"`
|
||||||
|
RttMs float64 `json:"rtt_ms"`
|
||||||
|
EspRttUs uint32 `json:"esp_rtt_us"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type findMeAPIRequest struct {
|
||||||
|
ClientID uint32 `json:"client_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type findMeAPIResponse struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
ClientID uint32 `json:"client_id,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type resetIdMapAPIResponse struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type restartAPIRequest struct {
|
||||||
|
ClientID uint32 `json:"client_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type restartAPIResponse struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
ClientID uint32 `json:"client_id,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type logLevelAPIResponse struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Level uint32 `json:"level"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type logLevelAPIRequest struct {
|
||||||
|
Write bool `json:"write"`
|
||||||
|
Level uint32 `json:"level"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type otaAPIResponse struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
BytesWritten uint32 `json:"bytes_written,omitempty"`
|
||||||
|
TargetSlot uint32 `json:"target_slot,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func mountServeAPI(mux *http.ServeMux, link *managedSerial, hub *wsHub, streamCtl *accelStreamCtl, tapCtl *tapNotifyCtl) {
|
||||||
|
mountHostAPI(mux, link)
|
||||||
|
mountBleTestAPI(mux, link)
|
||||||
|
mountLiveStreamAPI(mux, hub)
|
||||||
|
mountAccelStreamAPI(mux, link, hub, streamCtl)
|
||||||
|
mountInputSendModeAPI(mux, link)
|
||||||
|
mountTapAPI(mux, link, hub, tapCtl)
|
||||||
|
mountTapConfigAPI(mux, link)
|
||||||
|
mountBatteryUvAPI(mux, link)
|
||||||
|
mountLedRingAPI(mux, link)
|
||||||
|
mountBatteryAPI(mux, link)
|
||||||
|
mux.HandleFunc("/api/deadzone", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
serveDeadzoneGet(w, r, link)
|
||||||
|
case http.MethodPost:
|
||||||
|
serveDeadzonePost(w, r, link)
|
||||||
|
default:
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/api/unicast-test", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
serveUnicastTest(w, r, link)
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/api/echo-ping", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
serveEchoPing(w, r, link)
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/api/find-me", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
serveFindMe(w, r, link)
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/api/restart", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
serveRestart(w, r, link)
|
||||||
|
})
|
||||||
|
mux.HandleFunc("POST /api/clients/reset-id-map", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
serveResetClientIdMap(w, r, link)
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/api/log-level", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
serveLogLevelGet(w, r, link)
|
||||||
|
case http.MethodPost:
|
||||||
|
serveLogLevelPost(w, r, link)
|
||||||
|
default:
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/api/ota", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
serveOTAUpload(w, r, link, hub)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func serveOTAUpload(w http.ResponseWriter, r *http.Request, link *managedSerial, hub *wsHub) {
|
||||||
|
if !link.IsConnected() {
|
||||||
|
writeJSON(w, http.StatusBadRequest, otaAPIResponse{Error: "host not connected"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := r.ParseMultipartForm(otaMaxFirmwareSize); err != nil {
|
||||||
|
writeJSON(w, http.StatusBadRequest, otaAPIResponse{Error: "invalid form"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
file, _, err := r.FormFile("firmware")
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusBadRequest, otaAPIResponse{Error: "firmware file required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
data, err := io.ReadAll(io.LimitReader(file, otaMaxFirmwareSize))
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusBadRequest, otaAPIResponse{Error: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(data) == 0 {
|
||||||
|
writeJSON(w, http.StatusBadRequest, otaAPIResponse{Error: "empty firmware"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var last OTAProgress
|
||||||
|
err = runOTAUpload(link, data, func(p OTAProgress) {
|
||||||
|
last = p
|
||||||
|
if hub != nil {
|
||||||
|
hub.broadcastRaw(p)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if hub != nil {
|
||||||
|
hub.broadcastRaw(OTAProgress{Type: "ota_progress", Phase: "error", Message: err.Error()})
|
||||||
|
}
|
||||||
|
status := http.StatusServiceUnavailable
|
||||||
|
if errors.Is(err, errOTAInProgress) {
|
||||||
|
status = http.StatusConflict
|
||||||
|
}
|
||||||
|
writeJSON(w, status, otaAPIResponse{Error: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, otaAPIResponse{
|
||||||
|
Success: true,
|
||||||
|
BytesWritten: last.Bytes,
|
||||||
|
TargetSlot: last.Slot,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func serveDeadzoneGet(w http.ResponseWriter, r *http.Request, link *managedSerial) {
|
||||||
|
clientID, err := parseUintQuery(r, "client_id", 0)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusBadRequest, deadzoneAPIResponse{Error: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp, err := link.AccelDeadzone(&pb.AccelDeadzoneRequest{
|
||||||
|
Write: false,
|
||||||
|
ClientId: clientID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusServiceUnavailable, deadzoneAPIResponse{
|
||||||
|
ClientID: clientID,
|
||||||
|
Error: err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, deadzoneAPIResponse{
|
||||||
|
Deadzone: resp.GetDeadzone(),
|
||||||
|
ClientID: resp.GetClientId(),
|
||||||
|
Success: resp.GetSuccess(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func serveDeadzonePost(w http.ResponseWriter, r *http.Request, link *managedSerial) {
|
||||||
|
var body deadzoneAPIRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeJSON(w, http.StatusBadRequest, deadzoneAPIResponse{Error: "invalid JSON"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.AllClients && body.SlavesOnly {
|
||||||
|
updated, err := applyDeadzoneToSlaves(link, body.Deadzone)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusServiceUnavailable, deadzoneAPIResponse{
|
||||||
|
Error: err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, deadzoneAPIResponse{
|
||||||
|
Deadzone: body.Deadzone,
|
||||||
|
Success: updated > 0,
|
||||||
|
SlavesUpdated: updated,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
req := &pb.AccelDeadzoneRequest{
|
||||||
|
Write: true,
|
||||||
|
Deadzone: body.Deadzone,
|
||||||
|
ClientId: body.ClientID,
|
||||||
|
AllClients: body.AllClients,
|
||||||
|
}
|
||||||
|
// client_id 0 without all_clients: master BMA456 only (same as CLI -client 0).
|
||||||
|
resp, err := link.AccelDeadzone(req)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusServiceUnavailable, deadzoneAPIResponse{
|
||||||
|
ClientID: body.ClientID,
|
||||||
|
Error: err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, deadzoneAPIResponse{
|
||||||
|
Deadzone: resp.GetDeadzone(),
|
||||||
|
ClientID: resp.GetClientId(),
|
||||||
|
Success: resp.GetSuccess(),
|
||||||
|
SlavesUpdated: resp.GetSlavesUpdated(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyDeadzoneToSlaves sets deadzone on each registered slave via per-client UART/ESP-NOW.
|
||||||
|
// Does not change the master's local BMA456 (use client_id 0 for that).
|
||||||
|
func applyDeadzoneToSlaves(link *managedSerial, deadzone uint32) (uint32, error) {
|
||||||
|
clients, err := link.listClients()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
var updated uint32
|
||||||
|
for _, c := range clients {
|
||||||
|
resp, err := link.AccelDeadzone(&pb.AccelDeadzoneRequest{
|
||||||
|
Write: true,
|
||||||
|
Deadzone: deadzone,
|
||||||
|
ClientId: c.GetId(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if resp.GetSuccess() {
|
||||||
|
updated++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(clients) == 0 {
|
||||||
|
return 0, fmt.Errorf("no slaves registered")
|
||||||
|
}
|
||||||
|
if updated == 0 {
|
||||||
|
return 0, fmt.Errorf("deadzone not applied to any slave")
|
||||||
|
}
|
||||||
|
return updated, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func serveLogLevelGet(w http.ResponseWriter, r *http.Request, link *managedSerial) {
|
||||||
|
resp, err := link.SetLogLevel(false, 0)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusServiceUnavailable, logLevelAPIResponse{Error: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, logLevelAPIResponse{
|
||||||
|
Success: resp.GetSuccess(),
|
||||||
|
Level: resp.GetLevel(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func serveLogLevelPost(w http.ResponseWriter, r *http.Request, link *managedSerial) {
|
||||||
|
var body logLevelAPIRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeJSON(w, http.StatusBadRequest, logLevelAPIResponse{Error: "invalid JSON"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !body.Write {
|
||||||
|
writeJSON(w, http.StatusBadRequest, logLevelAPIResponse{Error: "write must be true"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.Level > 5 {
|
||||||
|
writeJSON(w, http.StatusBadRequest, logLevelAPIResponse{Error: "level must be 0–5"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp, err := link.SetLogLevel(true, body.Level)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusServiceUnavailable, logLevelAPIResponse{Error: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, logLevelAPIResponse{
|
||||||
|
Success: resp.GetSuccess(),
|
||||||
|
Level: resp.GetLevel(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func serveRestart(w http.ResponseWriter, r *http.Request, link *managedSerial) {
|
||||||
|
var body restartAPIRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeJSON(w, http.StatusBadRequest, restartAPIResponse{Error: "invalid JSON"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := link.Restart(body.ClientID); err != nil {
|
||||||
|
writeJSON(w, http.StatusServiceUnavailable, restartAPIResponse{
|
||||||
|
ClientID: body.ClientID,
|
||||||
|
Error: err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, restartAPIResponse{Success: true, ClientID: body.ClientID})
|
||||||
|
}
|
||||||
|
|
||||||
|
func serveResetClientIdMap(w http.ResponseWriter, r *http.Request, link *managedSerial) {
|
||||||
|
if !link.IsConnected() {
|
||||||
|
writeJSON(w, http.StatusServiceUnavailable, resetIdMapAPIResponse{Error: "host not connected"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp, err := link.resetClientIdMap()
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusServiceUnavailable, resetIdMapAPIResponse{Error: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !resp.GetSuccess() {
|
||||||
|
writeJSON(w, http.StatusOK, resetIdMapAPIResponse{Success: false, Error: "rejected by device"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, resetIdMapAPIResponse{Success: true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func serveFindMe(w http.ResponseWriter, r *http.Request, link *managedSerial) {
|
||||||
|
var body findMeAPIRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeJSON(w, http.StatusBadRequest, findMeAPIResponse{Error: "invalid JSON"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := link.FindMe(body.ClientID); err != nil {
|
||||||
|
writeJSON(w, http.StatusServiceUnavailable, findMeAPIResponse{
|
||||||
|
ClientID: body.ClientID,
|
||||||
|
Error: err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, findMeAPIResponse{Success: true, ClientID: body.ClientID})
|
||||||
|
}
|
||||||
|
|
||||||
|
func serveUnicastTest(w http.ResponseWriter, r *http.Request, link *managedSerial) {
|
||||||
|
var body unicastAPIRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeJSON(w, http.StatusBadRequest, unicastAPIResponse{Error: "invalid JSON"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.ClientID == 0 {
|
||||||
|
writeJSON(w, http.StatusBadRequest, unicastAPIResponse{Error: "client_id required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.Seq == 0 {
|
||||||
|
body.Seq = 1
|
||||||
|
}
|
||||||
|
resp, err := link.EspnowUnicastTest(body.ClientID, body.Seq)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusServiceUnavailable, unicastAPIResponse{Error: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, unicastAPIResponse{
|
||||||
|
Success: resp.GetSuccess(),
|
||||||
|
Seq: resp.GetSeq(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func serveEchoPing(w http.ResponseWriter, r *http.Request, link *managedSerial) {
|
||||||
|
var body echoPingAPIRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeJSON(w, http.StatusBadRequest, echoPingAPIResponse{Error: "invalid JSON"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.ClientID == 0 {
|
||||||
|
writeJSON(w, http.StatusBadRequest, echoPingAPIResponse{Error: "client_id required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := link.EchoPing(body.ClientID)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusServiceUnavailable, echoPingAPIResponse{Error: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, echoPingAPIResponse{
|
||||||
|
Success: result.Success,
|
||||||
|
ClientID: result.ClientID,
|
||||||
|
TimestampUs: result.TimestampUs,
|
||||||
|
RttMs: result.RttMs,
|
||||||
|
EspRttUs: result.EspRttUs,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseUintQuery(r *http.Request, key string, def uint32) (uint32, error) {
|
||||||
|
s := r.URL.Query().Get(key)
|
||||||
|
if s == "" {
|
||||||
|
return def, nil
|
||||||
|
}
|
||||||
|
v, err := strconv.ParseUint(s, 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return uint32(v), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
_ = json.NewEncoder(w).Encode(v)
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,219 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"powerpod/gotool/pb"
|
||||||
|
)
|
||||||
|
|
||||||
|
type tapNotifyAPIRequest struct {
|
||||||
|
Write bool `json:"write"`
|
||||||
|
ClientID uint32 `json:"client_id"`
|
||||||
|
AllClients bool `json:"all_clients"`
|
||||||
|
Single bool `json:"single"`
|
||||||
|
DoubleTap bool `json:"double_tap"`
|
||||||
|
Triple bool `json:"triple"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type tapNotifyAPIResponse struct {
|
||||||
|
ClientID uint32 `json:"client_id"`
|
||||||
|
Success bool `json:"success"`
|
||||||
|
SlavesUpdated uint32 `json:"slaves_updated"`
|
||||||
|
Single bool `json:"single"`
|
||||||
|
DoubleTap bool `json:"double_tap"`
|
||||||
|
Triple bool `json:"triple"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type tapSnapshotAPIResponse struct {
|
||||||
|
Events []tapEventView `json:"events"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type tapEventView struct {
|
||||||
|
ClientID uint32 `json:"client_id"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
AgeMs uint32 `json:"age_ms"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func mountTapAPI(mux *http.ServeMux, link *managedSerial, hub *wsHub, tapCtl *tapNotifyCtl) {
|
||||||
|
mux.HandleFunc("GET /api/clients/{clientID}/tap-notify", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
clientID, err := parsePathClientID(r)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusBadRequest, tapNotifyAPIResponse{Error: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
serveTapNotifyGet(w, clientID, link)
|
||||||
|
})
|
||||||
|
mux.HandleFunc("PUT /api/clients/{clientID}/tap-notify", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
clientID, err := parsePathClientID(r)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusBadRequest, tapNotifyAPIResponse{Error: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
serveClientTapNotifyPut(w, r, clientID, link, hub, tapCtl)
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/api/tap-notify", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
serveTapNotifyGetQuery(w, r, link)
|
||||||
|
case http.MethodPost:
|
||||||
|
serveTapNotifyPost(w, r, link, hub, tapCtl)
|
||||||
|
default:
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
mux.HandleFunc("/api/tap-snapshot", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
serveTapSnapshotGet(w, r, link)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyTapNotifyClient(link *managedSerial, hub *wsHub, tapCtl *tapNotifyCtl, clientID uint32, single, doubleTap, triple bool) tapNotifyAPIResponse {
|
||||||
|
return applyTapNotifyClientWS(link, hub, tapCtl, clientID, single, doubleTap, triple)
|
||||||
|
}
|
||||||
|
|
||||||
|
func serveTapNotifyGet(w http.ResponseWriter, clientID uint32, link *managedSerial) {
|
||||||
|
resp, err := link.TapNotifyPoll(&pb.TapNotifyRequest{
|
||||||
|
Write: false,
|
||||||
|
ClientId: clientID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusServiceUnavailable, tapNotifyAPIResponse{
|
||||||
|
ClientID: clientID,
|
||||||
|
Error: err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, tapNotifyAPIResponse{
|
||||||
|
ClientID: resp.GetClientId(),
|
||||||
|
Success: resp.GetSuccess(),
|
||||||
|
Single: resp.GetSingle(),
|
||||||
|
DoubleTap: resp.GetDoubleTap(),
|
||||||
|
Triple: resp.GetTriple(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func serveTapNotifyGetQuery(w http.ResponseWriter, r *http.Request, link *managedSerial) {
|
||||||
|
clientID, err := parseUintQuery(r, "client_id", 0)
|
||||||
|
if err != nil || clientID == 0 {
|
||||||
|
writeJSON(w, http.StatusBadRequest, tapNotifyAPIResponse{Error: "client_id required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
serveTapNotifyGet(w, clientID, link)
|
||||||
|
}
|
||||||
|
|
||||||
|
type clientTapNotifyBody struct {
|
||||||
|
Single bool `json:"single"`
|
||||||
|
DoubleTap bool `json:"double_tap"`
|
||||||
|
Triple bool `json:"triple"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func serveClientTapNotifyPut(w http.ResponseWriter, r *http.Request, clientID uint32, link *managedSerial, hub *wsHub, tapCtl *tapNotifyCtl) {
|
||||||
|
var body clientTapNotifyBody
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeJSON(w, http.StatusBadRequest, tapNotifyAPIResponse{Error: "invalid JSON"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out := applyTapNotifyClient(link, hub, tapCtl, clientID, body.Single, body.DoubleTap, body.Triple)
|
||||||
|
status := http.StatusOK
|
||||||
|
if out.Error != "" || !out.Success {
|
||||||
|
status = http.StatusServiceUnavailable
|
||||||
|
}
|
||||||
|
writeJSON(w, status, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func serveTapNotifyPost(w http.ResponseWriter, r *http.Request, link *managedSerial, hub *wsHub, tapCtl *tapNotifyCtl) {
|
||||||
|
var body tapNotifyAPIRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeJSON(w, http.StatusBadRequest, tapNotifyAPIResponse{Error: "invalid JSON"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if body.AllClients {
|
||||||
|
updated, err := applyTapNotifyAll(link, hub, tapCtl, body.Single, body.DoubleTap, body.Triple)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusServiceUnavailable, tapNotifyAPIResponse{Error: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, tapNotifyAPIResponse{
|
||||||
|
Success: updated > 0,
|
||||||
|
SlavesUpdated: updated,
|
||||||
|
Single: body.Single,
|
||||||
|
DoubleTap: body.DoubleTap,
|
||||||
|
Triple: body.Triple,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if body.ClientID == 0 {
|
||||||
|
writeJSON(w, http.StatusBadRequest, tapNotifyAPIResponse{Error: "client_id required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
out := applyTapNotifyClient(link, hub, tapCtl, body.ClientID, body.Single, body.DoubleTap, body.Triple)
|
||||||
|
status := http.StatusOK
|
||||||
|
if out.Error != "" || !out.Success {
|
||||||
|
status = http.StatusServiceUnavailable
|
||||||
|
}
|
||||||
|
writeJSON(w, status, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyTapNotifyAll(link *managedSerial, hub *wsHub, tapCtl *tapNotifyCtl, single, doubleTap, triple bool) (uint32, error) {
|
||||||
|
resp, err := link.TapNotify(&pb.TapNotifyRequest{
|
||||||
|
Write: true,
|
||||||
|
AllClients: true,
|
||||||
|
Single: single,
|
||||||
|
DoubleTap: doubleTap,
|
||||||
|
Triple: triple,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if !resp.GetSuccess() {
|
||||||
|
return 0, fmt.Errorf("tap notify not applied to any slave")
|
||||||
|
}
|
||||||
|
if hub != nil || tapCtl != nil {
|
||||||
|
clients, _ := link.listClientsPoll()
|
||||||
|
for _, c := range clients {
|
||||||
|
if tapCtl != nil {
|
||||||
|
tapCtl.Set(c.GetId(), single, doubleTap, triple)
|
||||||
|
}
|
||||||
|
if hub != nil {
|
||||||
|
hub.patchClientTapNotify(c.GetId(), single, doubleTap, triple)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return resp.GetSlavesUpdated(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func serveTapSnapshotGet(w http.ResponseWriter, r *http.Request, link *managedSerial) {
|
||||||
|
clientID, err := parseUintQuery(r, "client_id", 0)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusBadRequest, tapSnapshotAPIResponse{Error: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cache, err := link.readCacheStatusPoll()
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusServiceUnavailable, tapSnapshotAPIResponse{Error: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out := tapSnapshotAPIResponse{Events: make([]tapEventView, 0)}
|
||||||
|
for _, e := range tapEventsFromCacheStatus(cache) {
|
||||||
|
if clientID != 0 && e.GetClientId() != clientID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out.Events = append(out.Events, tapEventView{
|
||||||
|
ClientID: e.GetClientId(),
|
||||||
|
Kind: tapKindLabel(e.GetKind()),
|
||||||
|
AgeMs: e.GetAgeMs(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, out)
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"powerpod/gotool/pb"
|
||||||
|
)
|
||||||
|
|
||||||
|
type tapConfigFields struct {
|
||||||
|
TapSensThres uint32 `json:"tap_sens_thres"`
|
||||||
|
MaxGestDur uint32 `json:"max_gest_dur"`
|
||||||
|
TapShockDur uint32 `json:"tap_shock_dur"`
|
||||||
|
QuiteTimeAfterGest uint32 `json:"quite_time_after_gest"`
|
||||||
|
WaitForTimeout uint32 `json:"wait_for_timeout"`
|
||||||
|
AxisSel uint32 `json:"axis_sel"`
|
||||||
|
EnableSingle bool `json:"enable_single"`
|
||||||
|
EnableDouble bool `json:"enable_double"`
|
||||||
|
EnableTriple bool `json:"enable_triple"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type tapConfigAPIResponse struct {
|
||||||
|
tapConfigFields
|
||||||
|
ClientID uint32 `json:"client_id"`
|
||||||
|
Success bool `json:"success"`
|
||||||
|
SlavesUpdated uint32 `json:"slaves_updated"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type tapConfigAPIRequest struct {
|
||||||
|
Write bool `json:"write"`
|
||||||
|
ClientID uint32 `json:"client_id"`
|
||||||
|
AllClients bool `json:"all_clients"`
|
||||||
|
// SlavesOnly: with all_clients, push to ESP-NOW slaves only (master BMA456 unchanged).
|
||||||
|
SlavesOnly bool `json:"slaves_only"`
|
||||||
|
tapConfigFields
|
||||||
|
}
|
||||||
|
|
||||||
|
func tapConfigFieldsFromPB(r *pb.TapConfigResponse) tapConfigFields {
|
||||||
|
return tapConfigFields{
|
||||||
|
TapSensThres: r.GetTapSensThres(),
|
||||||
|
MaxGestDur: r.GetMaxGestDur(),
|
||||||
|
TapShockDur: r.GetTapShockDur(),
|
||||||
|
QuiteTimeAfterGest: r.GetQuiteTimeAfterGest(),
|
||||||
|
WaitForTimeout: r.GetWaitForTimeout(),
|
||||||
|
AxisSel: r.GetAxisSel(),
|
||||||
|
EnableSingle: r.GetEnableSingle(),
|
||||||
|
EnableDouble: r.GetEnableDouble(),
|
||||||
|
EnableTriple: r.GetEnableTriple(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f tapConfigFields) toPBRequest(write bool, clientID uint32, all bool) *pb.TapConfigRequest {
|
||||||
|
return &pb.TapConfigRequest{
|
||||||
|
Write: write,
|
||||||
|
ClientId: clientID,
|
||||||
|
AllClients: all,
|
||||||
|
TapSensThres: f.TapSensThres,
|
||||||
|
MaxGestDur: f.MaxGestDur,
|
||||||
|
TapShockDur: f.TapShockDur,
|
||||||
|
QuiteTimeAfterGest: f.QuiteTimeAfterGest,
|
||||||
|
WaitForTimeout: f.WaitForTimeout,
|
||||||
|
AxisSel: f.AxisSel,
|
||||||
|
EnableSingle: f.EnableSingle,
|
||||||
|
EnableDouble: f.EnableDouble,
|
||||||
|
EnableTriple: f.EnableTriple,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mountTapConfigAPI(mux *http.ServeMux, link *managedSerial) {
|
||||||
|
mux.HandleFunc("/api/tap-config", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
serveTapConfigGet(w, r, link)
|
||||||
|
case http.MethodPost:
|
||||||
|
serveTapConfigPost(w, r, link)
|
||||||
|
default:
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func serveTapConfigGet(w http.ResponseWriter, r *http.Request, link *managedSerial) {
|
||||||
|
clientID, err := parseUintQuery(r, "client_id", 0)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusBadRequest, tapConfigAPIResponse{Error: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp, err := link.TapConfig(&pb.TapConfigRequest{
|
||||||
|
Write: false,
|
||||||
|
ClientId: clientID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusServiceUnavailable, tapConfigAPIResponse{
|
||||||
|
ClientID: clientID,
|
||||||
|
Error: err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out := tapConfigAPIResponse{
|
||||||
|
tapConfigFields: tapConfigFieldsFromPB(resp),
|
||||||
|
ClientID: resp.GetClientId(),
|
||||||
|
Success: resp.GetSuccess(),
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func serveTapConfigPost(w http.ResponseWriter, r *http.Request, link *managedSerial) {
|
||||||
|
var body tapConfigAPIRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeJSON(w, http.StatusBadRequest, tapConfigAPIResponse{Error: "invalid JSON"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.AllClients && body.SlavesOnly {
|
||||||
|
updated, err := applyTapConfigToSlaves(link, body.tapConfigFields)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusServiceUnavailable, tapConfigAPIResponse{
|
||||||
|
tapConfigFields: body.tapConfigFields,
|
||||||
|
Error: err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, tapConfigAPIResponse{
|
||||||
|
tapConfigFields: body.tapConfigFields,
|
||||||
|
Success: updated > 0,
|
||||||
|
SlavesUpdated: updated,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := link.TapConfig(body.tapConfigFields.toPBRequest(true, body.ClientID, body.AllClients))
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusServiceUnavailable, tapConfigAPIResponse{
|
||||||
|
ClientID: body.ClientID,
|
||||||
|
tapConfigFields: body.tapConfigFields,
|
||||||
|
Error: err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, tapConfigAPIResponse{
|
||||||
|
tapConfigFields: tapConfigFieldsFromPB(resp),
|
||||||
|
ClientID: resp.GetClientId(),
|
||||||
|
Success: resp.GetSuccess(),
|
||||||
|
SlavesUpdated: resp.GetSlavesUpdated(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyTapConfigToSlaves(link *managedSerial, fields tapConfigFields) (uint32, error) {
|
||||||
|
clients, err := link.listClients()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
var updated uint32
|
||||||
|
for _, c := range clients {
|
||||||
|
resp, err := link.TapConfig(fields.toPBRequest(true, c.GetId(), false))
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if resp.GetSuccess() {
|
||||||
|
updated++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(clients) == 0 {
|
||||||
|
return 0, fmt.Errorf("no slaves registered")
|
||||||
|
}
|
||||||
|
if updated == 0 {
|
||||||
|
return 0, fmt.Errorf("tap config not applied to any slave")
|
||||||
|
}
|
||||||
|
return updated, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
package autotest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UARTConfig holds serial paths for gotool commands and per-node reset (USB console).
|
||||||
|
type UARTConfig struct {
|
||||||
|
// Baud for uart.master (default 921600).
|
||||||
|
Baud uint `json:"baud,omitempty"`
|
||||||
|
// Master command UART (external adapter on GPIO2/3), e.g. /dev/ttyUSB0.
|
||||||
|
Master string `json:"master"`
|
||||||
|
// Master USB console / JTAG serial for esptool reset, e.g. /dev/ttyACM0.
|
||||||
|
MasterConsole string `json:"master_console,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config describes the bench: ESP-NOW network, master MAC, and known slaves.
|
||||||
|
type Config struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
Network uint `json:"network"`
|
||||||
|
MasterMAC string `json:"master_mac"`
|
||||||
|
UART UARTConfig `json:"uart"`
|
||||||
|
Slaves []SlaveNode `json:"slaves"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SlaveNode struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
MAC string `json:"mac"`
|
||||||
|
ClientID *uint `json:"client_id,omitempty"`
|
||||||
|
// USB console port for esptool reset (optional), e.g. /dev/ttyACM1.
|
||||||
|
Console string `json:"console,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Bench struct {
|
||||||
|
Config
|
||||||
|
slaveByID map[string]*SlaveNode
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoadConfig(path string) (*Bench, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var cfg Config
|
||||||
|
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse config: %w", err)
|
||||||
|
}
|
||||||
|
return NewBench(cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewBench(cfg Config) (*Bench, error) {
|
||||||
|
cfg.MasterMAC = normalizeMAC(cfg.MasterMAC)
|
||||||
|
if cfg.ID == "" {
|
||||||
|
return nil, fmt.Errorf("config: id is required")
|
||||||
|
}
|
||||||
|
if cfg.MasterMAC == "" {
|
||||||
|
return nil, fmt.Errorf("config %q: master_mac is required", cfg.ID)
|
||||||
|
}
|
||||||
|
if cfg.Network < 1 || cfg.Network > 8 {
|
||||||
|
return nil, fmt.Errorf("config %q: network must be 1–8 (DIP / IO expander)", cfg.ID)
|
||||||
|
}
|
||||||
|
if cfg.UART.Master == "" {
|
||||||
|
return nil, fmt.Errorf("config %q: uart.master is required (gotool command port)", cfg.ID)
|
||||||
|
}
|
||||||
|
if cfg.UART.Baud == 0 {
|
||||||
|
cfg.UART.Baud = 921600
|
||||||
|
}
|
||||||
|
|
||||||
|
b := &Bench{Config: cfg, slaveByID: make(map[string]*SlaveNode)}
|
||||||
|
for i := range cfg.Slaves {
|
||||||
|
s := &cfg.Slaves[i]
|
||||||
|
if s.ID == "" {
|
||||||
|
return nil, fmt.Errorf("config %q: slave missing id", cfg.ID)
|
||||||
|
}
|
||||||
|
if _, dup := b.slaveByID[s.ID]; dup {
|
||||||
|
return nil, fmt.Errorf("config %q: duplicate slave id %q", cfg.ID, s.ID)
|
||||||
|
}
|
||||||
|
mac, err := parseMAC(s.MAC)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("config %q slave %q: %w", cfg.ID, s.ID, err)
|
||||||
|
}
|
||||||
|
s.MAC = mac
|
||||||
|
/* client_id is master-assigned (sequential); omit until known from `gotool clients`. */
|
||||||
|
b.slaveByID[s.ID] = s
|
||||||
|
}
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bench) Slave(id string) (*SlaveNode, error) {
|
||||||
|
s, ok := b.slaveByID[id]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("unknown slave %q (config %q)", id, b.ID)
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bench) ResolveClientID(slaveID string) (uint32, error) {
|
||||||
|
if slaveID == "" || slaveID == "master" || slaveID == "local" {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
s, err := b.Slave(slaveID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if s.ClientID == nil {
|
||||||
|
return 0, fmt.Errorf("slave %q: client_id required (master-assigned; set in config after join)", slaveID)
|
||||||
|
}
|
||||||
|
return uint32(*s.ClientID), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeMAC(s string) string {
|
||||||
|
s = strings.TrimSpace(strings.ToLower(s))
|
||||||
|
s = strings.ReplaceAll(s, "-", ":")
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseMAC(s string) (string, error) {
|
||||||
|
s = normalizeMAC(s)
|
||||||
|
parts := strings.Split(s, ":")
|
||||||
|
if len(parts) != 6 {
|
||||||
|
return "", fmt.Errorf("invalid mac %q", s)
|
||||||
|
}
|
||||||
|
out := make([]byte, 6)
|
||||||
|
for i, p := range parts {
|
||||||
|
var b byte
|
||||||
|
if _, err := fmt.Sscanf(p, "%02x", &b); err != nil {
|
||||||
|
return "", fmt.Errorf("invalid mac byte %q", p)
|
||||||
|
}
|
||||||
|
out[i] = b
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%02x:%02x:%02x:%02x:%02x:%02x",
|
||||||
|
out[0], out[1], out[2], out[3], out[4], out[5]), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func MACEqual(a, b string) bool {
|
||||||
|
return normalizeMAC(a) == normalizeMAC(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func MACFromProto(mac []byte) string {
|
||||||
|
if len(mac) != 6 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%02x:%02x:%02x:%02x:%02x:%02x",
|
||||||
|
mac[0], mac[1], mac[2], mac[3], mac[4], mac[5])
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package autotest
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestNewBenchNoDefaultClientIDFromMAC(t *testing.T) {
|
||||||
|
cfg := Config{
|
||||||
|
ID: "t",
|
||||||
|
Network: 1,
|
||||||
|
MasterMAC: "aa:bb:cc:dd:ee:ff",
|
||||||
|
UART: UARTConfig{
|
||||||
|
Master: "/dev/ttyUSB0",
|
||||||
|
},
|
||||||
|
Slaves: []SlaveNode{{
|
||||||
|
ID: "pod",
|
||||||
|
MAC: "50:78:7d:18:01:10",
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
b, err := NewBench(cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s, _ := b.Slave("pod")
|
||||||
|
if s.ClientID != nil {
|
||||||
|
t.Fatalf("client_id should be unset, got %d", *s.ClientID)
|
||||||
|
}
|
||||||
|
if _, err := b.ResolveClientID("pod"); err == nil {
|
||||||
|
t.Fatal("ResolveClientID should fail without client_id")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewBenchExplicitClientID(t *testing.T) {
|
||||||
|
id := uint(3)
|
||||||
|
cfg := Config{
|
||||||
|
ID: "t",
|
||||||
|
Network: 1,
|
||||||
|
MasterMAC: "aa:bb:cc:dd:ee:ff",
|
||||||
|
UART: UARTConfig{
|
||||||
|
Master: "/dev/ttyUSB0",
|
||||||
|
},
|
||||||
|
Slaves: []SlaveNode{{
|
||||||
|
ID: "pod",
|
||||||
|
MAC: "50:78:7d:18:01:10",
|
||||||
|
ClientID: &id,
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
b, err := NewBench(cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, err := b.ResolveClientID("pod")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got != 3 {
|
||||||
|
t.Fatalf("client_id=%d want 3", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMACEqual(t *testing.T) {
|
||||||
|
if !MACEqual("50:78:7D:18:01:10", "50-78-7d-18-01-10") {
|
||||||
|
t.Fatal("MACEqual failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package autotest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
ConfigDir = "testdata/configs"
|
||||||
|
ScenarioDir = "testdata/scenarios"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ResolveConfigPath(name string) (string, error) {
|
||||||
|
return resolveJSON(name, ConfigDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ResolveScenarioPath(name string) (string, error) {
|
||||||
|
return resolveJSON(name, ScenarioDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveJSON(name, dir string) (string, error) {
|
||||||
|
if name == "" {
|
||||||
|
return "", fmt.Errorf("empty name")
|
||||||
|
}
|
||||||
|
if filepath.Ext(name) == ".json" {
|
||||||
|
if fileExists(name) {
|
||||||
|
return name, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
base := name
|
||||||
|
if filepath.Ext(base) != ".json" {
|
||||||
|
base += ".json"
|
||||||
|
}
|
||||||
|
for _, root := range configRoots() {
|
||||||
|
p := filepath.Join(root, dir, base)
|
||||||
|
if fileExists(p) {
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("not found: %s (searched %s under gotool)", base, dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
func configRoots() []string {
|
||||||
|
var roots []string
|
||||||
|
if wd, err := os.Getwd(); err == nil {
|
||||||
|
roots = append(roots, wd)
|
||||||
|
}
|
||||||
|
// When run from repo root via make.
|
||||||
|
roots = append(roots, "goTool", filepath.Join("..", "goTool"))
|
||||||
|
return roots
|
||||||
|
}
|
||||||
|
|
||||||
|
func fileExists(path string) bool {
|
||||||
|
st, err := os.Stat(path)
|
||||||
|
return err == nil && !st.IsDir()
|
||||||
|
}
|
||||||
|
|
||||||
|
func ListJSONFiles(dir string) ([]string, error) {
|
||||||
|
for _, root := range configRoots() {
|
||||||
|
p := filepath.Join(root, dir)
|
||||||
|
entries, err := os.ReadDir(p)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var names []string
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.IsDir() || filepath.Ext(e.Name()) != ".json" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
names = append(names, strings.TrimSuffix(e.Name(), ".json"))
|
||||||
|
}
|
||||||
|
if len(names) > 0 {
|
||||||
|
return names, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("directory %s not found", dir)
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
package autotest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const defaultResetWaitMS = 2000
|
||||||
|
|
||||||
|
// ResetESP32 toggles EN via esptool (USB-JTAG / ttyACM console port).
|
||||||
|
func ResetESP32(port string) error {
|
||||||
|
if port == "" {
|
||||||
|
return fmt.Errorf("empty console port")
|
||||||
|
}
|
||||||
|
|
||||||
|
tries := [][]string{
|
||||||
|
{"python", "-m", "esptool", "--chip", "esp32s3", "-p", port,
|
||||||
|
"--before", "default_reset", "--after", "hard_reset", "chip_id"},
|
||||||
|
{"esptool.py", "--chip", "esp32s3", "-p", port,
|
||||||
|
"--before", "default_reset", "--after", "hard_reset", "chip_id"},
|
||||||
|
{"esptool", "--chip", "esp32s3", "-p", port,
|
||||||
|
"--before", "default_reset", "--after", "hard_reset", "chip_id"},
|
||||||
|
}
|
||||||
|
|
||||||
|
var lastErr error
|
||||||
|
for _, argv := range tries {
|
||||||
|
if _, err := exec.LookPath(argv[0]); err != nil {
|
||||||
|
lastErr = err
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cmd := exec.Command(argv[0], argv[1:]...)
|
||||||
|
cmd.Stdout = os.Stdout
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
lastErr = err
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if lastErr != nil {
|
||||||
|
return fmt.Errorf("esptool reset on %s: %w (install ESP-IDF esptool or python -m esptool)", port, lastErr)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("esptool not found; cannot reset %s", port)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bench) resetNode(target string) (string, error) {
|
||||||
|
switch target {
|
||||||
|
case "master", "":
|
||||||
|
if b.UART.MasterConsole == "" {
|
||||||
|
return "", fmt.Errorf("uart.master_console not set in config %q", b.ID)
|
||||||
|
}
|
||||||
|
return b.UART.MasterConsole, nil
|
||||||
|
default:
|
||||||
|
s, err := b.Slave(target)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if s.Console == "" {
|
||||||
|
return "", fmt.Errorf("slave %q has no console port in config", target)
|
||||||
|
}
|
||||||
|
return s.Console, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bench) ResetTargets(targets []string, waitMS int) error {
|
||||||
|
if waitMS <= 0 {
|
||||||
|
waitMS = defaultResetWaitMS
|
||||||
|
}
|
||||||
|
if len(targets) == 0 {
|
||||||
|
targets = []string{"master"}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, t := range targets {
|
||||||
|
port, err := b.resetNode(t)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := ResetESP32(port); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
time.Sleep(time.Duration(waitMS) * time.Millisecond)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bench) ResetAll(waitMS int) error {
|
||||||
|
// Slaves first, master last — master discover runs after slaves are booting.
|
||||||
|
var targets []string
|
||||||
|
for i := range b.Slaves {
|
||||||
|
if b.Slaves[i].Console != "" {
|
||||||
|
targets = append(targets, b.Slaves[i].ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if b.UART.MasterConsole != "" {
|
||||||
|
targets = append(targets, "master")
|
||||||
|
}
|
||||||
|
if len(targets) == 0 {
|
||||||
|
return fmt.Errorf("config %q: no console ports defined for reset", b.ID)
|
||||||
|
}
|
||||||
|
return b.ResetTargets(targets, waitMS)
|
||||||
|
}
|
||||||
@@ -0,0 +1,528 @@
|
|||||||
|
package autotest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"powerpod/gotool/pb"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MasterClient talks to the powerpod master over UART (implemented by main.serialPort).
|
||||||
|
type MasterClient interface {
|
||||||
|
GetVersion() (*pb.VersionResponse, error)
|
||||||
|
ListClients() ([]*pb.ClientInfo, error)
|
||||||
|
AccelDeadzone(req *pb.AccelDeadzoneRequest) (*pb.AccelDeadzoneResponse, error)
|
||||||
|
EspnowUnicastTest(clientID, seq uint32) (*pb.EspNowUnicastTestResponse, error)
|
||||||
|
LedRing(req *pb.LedRingProgressRequest) (*pb.LedRingProgressResponse, error)
|
||||||
|
FindMe(clientID uint32) (*pb.EspNowFindMeResponse, error)
|
||||||
|
Restart(clientID uint32) (*pb.RestartResponse, error)
|
||||||
|
OtaSlaveProgress(clientID uint32) (*pb.OtaSlaveProgressResponse, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type StepResult struct {
|
||||||
|
Index int
|
||||||
|
Name string
|
||||||
|
Command string
|
||||||
|
Pass bool
|
||||||
|
Detail string
|
||||||
|
}
|
||||||
|
|
||||||
|
type RunResult struct {
|
||||||
|
ConfigID string
|
||||||
|
ScenarioID string
|
||||||
|
Steps []StepResult
|
||||||
|
Passed int
|
||||||
|
Failed int
|
||||||
|
}
|
||||||
|
|
||||||
|
func Run(bench *Bench, sc *Scenario, client MasterClient) (*RunResult, error) {
|
||||||
|
if sc.ConfigID != bench.ID {
|
||||||
|
return nil, fmt.Errorf("scenario %q expects config %q, got %q", sc.ID, sc.ConfigID, bench.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
res := &RunResult{ConfigID: bench.ID, ScenarioID: sc.ID}
|
||||||
|
for i, step := range sc.Steps {
|
||||||
|
sr := StepResult{Index: i + 1, Name: step.Name, Command: step.Command}
|
||||||
|
if step.Name == "" {
|
||||||
|
sr.Name = fmt.Sprintf("step %d", i+1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if step.DelayMS > 0 && step.Command == "" {
|
||||||
|
time.Sleep(time.Duration(step.DelayMS) * time.Millisecond)
|
||||||
|
sr.Pass = true
|
||||||
|
sr.Detail = fmt.Sprintf("delay %d ms", step.DelayMS)
|
||||||
|
res.Steps = append(res.Steps, sr)
|
||||||
|
res.Passed++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if step.Command == "" {
|
||||||
|
sr.Pass = false
|
||||||
|
sr.Detail = "step needs command or delay_ms"
|
||||||
|
res.Steps = append(res.Steps, sr)
|
||||||
|
res.Failed++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if step.DelayMS > 0 {
|
||||||
|
time.Sleep(time.Duration(step.DelayMS) * time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := runStep(bench, step, client)
|
||||||
|
if err != nil {
|
||||||
|
sr.Pass = false
|
||||||
|
sr.Detail = err.Error()
|
||||||
|
res.Failed++
|
||||||
|
} else {
|
||||||
|
sr.Pass = true
|
||||||
|
sr.Detail = "ok"
|
||||||
|
res.Passed++
|
||||||
|
}
|
||||||
|
res.Steps = append(res.Steps, sr)
|
||||||
|
}
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runStep(bench *Bench, step Step, client MasterClient) error {
|
||||||
|
cmd := strings.ToLower(strings.ReplaceAll(step.Command, "-", "_"))
|
||||||
|
switch cmd {
|
||||||
|
case "version":
|
||||||
|
return checkVersion(step, client)
|
||||||
|
case "clients", "client_info":
|
||||||
|
return checkClients(bench, step, client)
|
||||||
|
case "deadzone", "accel_deadzone":
|
||||||
|
return checkDeadzone(bench, step, client)
|
||||||
|
case "unicast_test", "unicast":
|
||||||
|
return checkUnicastTest(bench, step, client)
|
||||||
|
case "led_ring", "ledring":
|
||||||
|
return checkLedRing(step, client)
|
||||||
|
case "find_me", "findme":
|
||||||
|
return checkFindMe(bench, step, client)
|
||||||
|
case "restart":
|
||||||
|
return checkRestartCmd(bench, step, client)
|
||||||
|
case "ota_progress", "ota_slave_progress":
|
||||||
|
return checkOtaProgress(step, client)
|
||||||
|
case "reset", "reboot":
|
||||||
|
return checkReset(bench, step)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unknown command %q", step.Command)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type resetInput struct {
|
||||||
|
Target string `json:"target"`
|
||||||
|
Slave string `json:"slave"`
|
||||||
|
All bool `json:"all"`
|
||||||
|
WaitMS int `json:"wait_ms"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkReset(bench *Bench, step Step) error {
|
||||||
|
var in resetInput
|
||||||
|
if len(step.Input) > 0 {
|
||||||
|
if err := json.Unmarshal(step.Input, &in); err != nil {
|
||||||
|
return fmt.Errorf("input: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if in.All {
|
||||||
|
return bench.ResetAll(in.WaitMS)
|
||||||
|
}
|
||||||
|
|
||||||
|
target := in.Target
|
||||||
|
if in.Slave != "" {
|
||||||
|
target = in.Slave
|
||||||
|
}
|
||||||
|
if target == "" {
|
||||||
|
target = "master"
|
||||||
|
}
|
||||||
|
return bench.ResetTargets([]string{target}, in.WaitMS)
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkVersion(step Step, client MasterClient) error {
|
||||||
|
ver, err := client.GetVersion()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
e := step.Expect
|
||||||
|
if e.Version != nil && ver.GetVersion() != *e.Version {
|
||||||
|
return fmt.Errorf("version=%d want %d", ver.GetVersion(), *e.Version)
|
||||||
|
}
|
||||||
|
if e.VersionMin != nil && ver.GetVersion() < *e.VersionMin {
|
||||||
|
return fmt.Errorf("version=%d want >= %d", ver.GetVersion(), *e.VersionMin)
|
||||||
|
}
|
||||||
|
if e.GitHash != "" && ver.GetGitHash() != e.GitHash {
|
||||||
|
return fmt.Errorf("git_hash=%q want %q", ver.GetGitHash(), e.GitHash)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkClients(bench *Bench, step Step, client MasterClient) error {
|
||||||
|
clients, err := client.ListClients()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
e := step.Expect
|
||||||
|
n := len(clients)
|
||||||
|
|
||||||
|
if e.ClientCount != nil && n != *e.ClientCount {
|
||||||
|
return fmt.Errorf("client_count=%d want %d", n, *e.ClientCount)
|
||||||
|
}
|
||||||
|
if e.MinClients != nil && n < *e.MinClients {
|
||||||
|
return fmt.Errorf("client_count=%d want >= %d", n, *e.MinClients)
|
||||||
|
}
|
||||||
|
if e.MaxClients != nil && n > *e.MaxClients {
|
||||||
|
return fmt.Errorf("client_count=%d want <= %d", n, *e.MaxClients)
|
||||||
|
}
|
||||||
|
|
||||||
|
slaveNames := e.Slaves
|
||||||
|
if e.Slave != "" {
|
||||||
|
slaveNames = append(slaveNames, e.Slave)
|
||||||
|
}
|
||||||
|
for _, name := range slaveNames {
|
||||||
|
want, err := bench.Slave(name)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var found *pb.ClientInfo
|
||||||
|
for _, c := range clients {
|
||||||
|
if MACEqual(MACFromProto(c.GetMac()), want.MAC) {
|
||||||
|
found = c
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if found == nil {
|
||||||
|
return fmt.Errorf("slave %q mac %s not in client list", name, want.MAC)
|
||||||
|
}
|
||||||
|
if want.ClientID != nil && uint32(*want.ClientID) != found.GetId() {
|
||||||
|
return fmt.Errorf("slave %q id=%d want client_id=%d", name, found.GetId(), *want.ClientID)
|
||||||
|
}
|
||||||
|
if e.Available != nil && found.GetAvailable() != *e.Available {
|
||||||
|
return fmt.Errorf("slave %q available=%v want %v", name, found.GetAvailable(), *e.Available)
|
||||||
|
}
|
||||||
|
if e.MAC != "" && !MACEqual(MACFromProto(found.GetMac()), e.MAC) {
|
||||||
|
return fmt.Errorf("slave %q mac=%s want %s", name, MACFromProto(found.GetMac()), e.MAC)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type deadzoneInput struct {
|
||||||
|
Write bool `json:"write"`
|
||||||
|
Value uint `json:"value"`
|
||||||
|
Deadzone uint `json:"deadzone"`
|
||||||
|
ClientID *uint `json:"client_id"`
|
||||||
|
Client uint `json:"client"`
|
||||||
|
Slave string `json:"slave"`
|
||||||
|
AllClients bool `json:"all_clients"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkDeadzone(bench *Bench, step Step, client MasterClient) error {
|
||||||
|
var in deadzoneInput
|
||||||
|
if len(step.Input) > 0 {
|
||||||
|
if err := json.Unmarshal(step.Input, &in); err != nil {
|
||||||
|
return fmt.Errorf("input: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dz := in.Value
|
||||||
|
if dz == 0 {
|
||||||
|
dz = in.Deadzone
|
||||||
|
}
|
||||||
|
|
||||||
|
var clientID uint32
|
||||||
|
switch {
|
||||||
|
case in.AllClients:
|
||||||
|
// client_id 0 with all_clients set
|
||||||
|
case in.Slave != "":
|
||||||
|
id, err := bench.ResolveClientID(in.Slave)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
clientID = id
|
||||||
|
case in.ClientID != nil:
|
||||||
|
clientID = uint32(*in.ClientID)
|
||||||
|
default:
|
||||||
|
clientID = uint32(in.Client)
|
||||||
|
}
|
||||||
|
|
||||||
|
req := &pb.AccelDeadzoneRequest{
|
||||||
|
Write: in.Write,
|
||||||
|
Deadzone: uint32(dz),
|
||||||
|
ClientId: clientID,
|
||||||
|
AllClients: in.AllClients,
|
||||||
|
}
|
||||||
|
resp, err := client.AccelDeadzone(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
e := step.Expect
|
||||||
|
if e.Deadzone != nil && resp.GetDeadzone() != *e.Deadzone {
|
||||||
|
return fmt.Errorf("deadzone=%d want %d", resp.GetDeadzone(), *e.Deadzone)
|
||||||
|
}
|
||||||
|
if e.Success != nil && resp.GetSuccess() != *e.Success {
|
||||||
|
return fmt.Errorf("success=%v want %v", resp.GetSuccess(), *e.Success)
|
||||||
|
}
|
||||||
|
if e.SlavesUpdated != nil && resp.GetSlavesUpdated() != *e.SlavesUpdated {
|
||||||
|
return fmt.Errorf("slaves_updated=%d want %d", resp.GetSlavesUpdated(), *e.SlavesUpdated)
|
||||||
|
}
|
||||||
|
if e.SlavesUpdatedMin != nil && resp.GetSlavesUpdated() < *e.SlavesUpdatedMin {
|
||||||
|
return fmt.Errorf("slaves_updated=%d want >= %d", resp.GetSlavesUpdated(), *e.SlavesUpdatedMin)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type unicastInput struct {
|
||||||
|
Seq uint `json:"seq"`
|
||||||
|
ClientID *uint `json:"client_id"`
|
||||||
|
Client uint `json:"client"`
|
||||||
|
Slave string `json:"slave"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkUnicastTest(bench *Bench, step Step, client MasterClient) error {
|
||||||
|
var in unicastInput
|
||||||
|
if len(step.Input) > 0 {
|
||||||
|
if err := json.Unmarshal(step.Input, &in); err != nil {
|
||||||
|
return fmt.Errorf("input: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if in.Seq == 0 {
|
||||||
|
in.Seq = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
var clientID uint32
|
||||||
|
switch {
|
||||||
|
case in.Slave != "":
|
||||||
|
id, err := bench.ResolveClientID(in.Slave)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
clientID = id
|
||||||
|
case in.ClientID != nil:
|
||||||
|
clientID = uint32(*in.ClientID)
|
||||||
|
default:
|
||||||
|
clientID = uint32(in.Client)
|
||||||
|
}
|
||||||
|
if clientID == 0 {
|
||||||
|
return fmt.Errorf("unicast_test: client_id or slave required")
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := client.EspnowUnicastTest(clientID, uint32(in.Seq))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
e := step.Expect
|
||||||
|
if e.Success != nil && resp.GetSuccess() != *e.Success {
|
||||||
|
return fmt.Errorf("success=%v want %v", resp.GetSuccess(), *e.Success)
|
||||||
|
}
|
||||||
|
if e.Seq != nil && resp.GetSeq() != *e.Seq {
|
||||||
|
return fmt.Errorf("seq=%d want %d", resp.GetSeq(), *e.Seq)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type ledRingInput struct {
|
||||||
|
Mode string `json:"mode"`
|
||||||
|
Progress uint `json:"progress"`
|
||||||
|
Digit uint `json:"digit"`
|
||||||
|
R uint `json:"r"`
|
||||||
|
G uint `json:"g"`
|
||||||
|
B uint `json:"b"`
|
||||||
|
Intensity uint `json:"intensity"`
|
||||||
|
BlinkMs uint `json:"blink_ms"`
|
||||||
|
BlinkCount uint `json:"blink_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func ledRingModeValue(mode string) (uint32, error) {
|
||||||
|
switch strings.ToLower(strings.ReplaceAll(mode, "-", "_")) {
|
||||||
|
case "clear", "":
|
||||||
|
return 0, nil
|
||||||
|
case "progress":
|
||||||
|
return 1, nil
|
||||||
|
case "digit":
|
||||||
|
return 2, nil
|
||||||
|
case "blink":
|
||||||
|
return 3, nil
|
||||||
|
case "find_me", "findme":
|
||||||
|
return 4, nil
|
||||||
|
case "battery_low", "batterylow":
|
||||||
|
return 6, nil
|
||||||
|
case "color", "solid", "fill":
|
||||||
|
return 5, nil
|
||||||
|
default:
|
||||||
|
return 0, fmt.Errorf("unknown led_ring mode %q", mode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkLedRing(step Step, client MasterClient) error {
|
||||||
|
var in ledRingInput
|
||||||
|
if len(step.Input) > 0 {
|
||||||
|
if err := json.Unmarshal(step.Input, &in); err != nil {
|
||||||
|
return fmt.Errorf("input: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mode, err := ledRingModeValue(in.Mode)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if in.Mode == "" && step.Expect.Mode != nil {
|
||||||
|
mode = *step.Expect.Mode
|
||||||
|
}
|
||||||
|
|
||||||
|
req := &pb.LedRingProgressRequest{
|
||||||
|
Mode: mode,
|
||||||
|
Progress: uint32(in.Progress),
|
||||||
|
Digit: uint32(in.Digit),
|
||||||
|
R: uint32(in.R),
|
||||||
|
G: uint32(in.G),
|
||||||
|
B: uint32(in.B),
|
||||||
|
Intensity: uint32(in.Intensity),
|
||||||
|
BlinkMs: uint32(in.BlinkMs),
|
||||||
|
BlinkCount: uint32(in.BlinkCount),
|
||||||
|
}
|
||||||
|
if mode == 1 && req.GetG() == 0 && req.GetR() == 0 && req.GetB() == 0 {
|
||||||
|
req.G = 255
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := client.LedRing(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
e := step.Expect
|
||||||
|
if e.Success != nil && resp.GetSuccess() != *e.Success {
|
||||||
|
return fmt.Errorf("success=%v want %v", resp.GetSuccess(), *e.Success)
|
||||||
|
}
|
||||||
|
if e.Mode != nil && resp.GetMode() != *e.Mode {
|
||||||
|
return fmt.Errorf("mode=%d want %d", resp.GetMode(), *e.Mode)
|
||||||
|
}
|
||||||
|
if e.Progress != nil && resp.GetProgress() != *e.Progress {
|
||||||
|
return fmt.Errorf("progress=%d want %d", resp.GetProgress(), *e.Progress)
|
||||||
|
}
|
||||||
|
if e.Digit != nil && resp.GetDigit() != *e.Digit {
|
||||||
|
return fmt.Errorf("digit=%d want %d", resp.GetDigit(), *e.Digit)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type findMeInput struct {
|
||||||
|
ClientID *uint `json:"client_id"`
|
||||||
|
Client uint `json:"client"`
|
||||||
|
Slave string `json:"slave"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkFindMe(bench *Bench, step Step, client MasterClient) error {
|
||||||
|
var in findMeInput
|
||||||
|
if len(step.Input) > 0 {
|
||||||
|
if err := json.Unmarshal(step.Input, &in); err != nil {
|
||||||
|
return fmt.Errorf("input: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
clientID, err := resolveClientID(bench, in.Slave, in.ClientID, in.Client)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := client.FindMe(clientID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
e := step.Expect
|
||||||
|
if e.Success != nil && resp.GetSuccess() != *e.Success {
|
||||||
|
return fmt.Errorf("success=%v want %v", resp.GetSuccess(), *e.Success)
|
||||||
|
}
|
||||||
|
if e.Slave != "" || in.Slave != "" {
|
||||||
|
wantSlave := e.Slave
|
||||||
|
if wantSlave == "" {
|
||||||
|
wantSlave = in.Slave
|
||||||
|
}
|
||||||
|
wantID, err := bench.ResolveClientID(wantSlave)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if resp.GetClientId() != wantID {
|
||||||
|
return fmt.Errorf("client_id=%d want %d", resp.GetClientId(), wantID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type restartCmdInput struct {
|
||||||
|
ClientID *uint `json:"client_id"`
|
||||||
|
Client uint `json:"client"`
|
||||||
|
Slave string `json:"slave"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkRestartCmd(bench *Bench, step Step, client MasterClient) error {
|
||||||
|
var in restartCmdInput
|
||||||
|
if len(step.Input) > 0 {
|
||||||
|
if err := json.Unmarshal(step.Input, &in); err != nil {
|
||||||
|
return fmt.Errorf("input: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
clientID, err := resolveClientID(bench, in.Slave, in.ClientID, in.Client)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := client.Restart(clientID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
e := step.Expect
|
||||||
|
if e.Success != nil && resp.GetSuccess() != *e.Success {
|
||||||
|
return fmt.Errorf("success=%v want %v", resp.GetSuccess(), *e.Success)
|
||||||
|
}
|
||||||
|
if resp.GetClientId() != clientID {
|
||||||
|
return fmt.Errorf("client_id=%d want %d", resp.GetClientId(), clientID)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type otaProgressInput struct {
|
||||||
|
ClientID *uint `json:"client_id"`
|
||||||
|
Client uint `json:"client"`
|
||||||
|
Slave string `json:"slave"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkOtaProgress(step Step, client MasterClient) error {
|
||||||
|
var in otaProgressInput
|
||||||
|
if len(step.Input) > 0 {
|
||||||
|
if err := json.Unmarshal(step.Input, &in); err != nil {
|
||||||
|
return fmt.Errorf("input: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
clientID := uint32(in.Client)
|
||||||
|
if in.ClientID != nil {
|
||||||
|
clientID = uint32(*in.ClientID)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := client.OtaSlaveProgress(clientID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
e := step.Expect
|
||||||
|
if e.Active != nil && resp.GetActive() != *e.Active {
|
||||||
|
return fmt.Errorf("active=%v want %v", resp.GetActive(), *e.Active)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveClientID(bench *Bench, slave string, clientID *uint, client uint) (uint32, error) {
|
||||||
|
switch {
|
||||||
|
case slave != "":
|
||||||
|
return bench.ResolveClientID(slave)
|
||||||
|
case clientID != nil:
|
||||||
|
return uint32(*clientID), nil
|
||||||
|
default:
|
||||||
|
return uint32(client), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package autotest
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestLedRingModeValue(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
mode string
|
||||||
|
want uint32
|
||||||
|
}{
|
||||||
|
{"clear", 0},
|
||||||
|
{"progress", 1},
|
||||||
|
{"digit", 2},
|
||||||
|
{"blink", 3},
|
||||||
|
{"find-me", 4},
|
||||||
|
{"battery-low", 6},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
got, err := ledRingModeValue(tc.mode)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("mode %q: %v", tc.mode, err)
|
||||||
|
}
|
||||||
|
if got != tc.want {
|
||||||
|
t.Fatalf("mode %q = %d want %d", tc.mode, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package autotest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Scenario is an ordered list of steps run against a bench Config.
|
||||||
|
type Scenario struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
ConfigID string `json:"config"`
|
||||||
|
Steps []Step `json:"steps"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Step struct {
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
Command string `json:"command,omitempty"`
|
||||||
|
DelayMS int `json:"delay_ms,omitempty"`
|
||||||
|
Input json.RawMessage `json:"input,omitempty"`
|
||||||
|
Expect Expect `json:"expect,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Expect struct {
|
||||||
|
// version
|
||||||
|
Version *uint32 `json:"version,omitempty"`
|
||||||
|
VersionMin *uint32 `json:"version_min,omitempty"`
|
||||||
|
GitHash string `json:"git_hash,omitempty"`
|
||||||
|
|
||||||
|
// clients
|
||||||
|
MinClients *int `json:"min_clients,omitempty"`
|
||||||
|
MaxClients *int `json:"max_clients,omitempty"`
|
||||||
|
ClientCount *int `json:"client_count,omitempty"`
|
||||||
|
Slave string `json:"slave,omitempty"`
|
||||||
|
Slaves []string `json:"slaves,omitempty"`
|
||||||
|
Available *bool `json:"available,omitempty"`
|
||||||
|
MAC string `json:"mac,omitempty"`
|
||||||
|
|
||||||
|
// deadzone
|
||||||
|
Deadzone *uint32 `json:"deadzone,omitempty"`
|
||||||
|
Success *bool `json:"success,omitempty"`
|
||||||
|
SlavesUpdated *uint32 `json:"slaves_updated,omitempty"`
|
||||||
|
SlavesUpdatedMin *uint32 `json:"slaves_updated_min,omitempty"`
|
||||||
|
|
||||||
|
// unicast_test
|
||||||
|
Seq *uint32 `json:"seq,omitempty"`
|
||||||
|
|
||||||
|
// led_ring
|
||||||
|
Mode *uint32 `json:"mode,omitempty"`
|
||||||
|
Progress *uint32 `json:"progress,omitempty"`
|
||||||
|
Digit *uint32 `json:"digit,omitempty"`
|
||||||
|
|
||||||
|
// ota_slave_progress
|
||||||
|
Active *bool `json:"active,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoadScenario(path string) (*Scenario, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var sc Scenario
|
||||||
|
if err := json.Unmarshal(data, &sc); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse scenario: %w", err)
|
||||||
|
}
|
||||||
|
if sc.ID == "" {
|
||||||
|
return nil, fmt.Errorf("scenario: id is required")
|
||||||
|
}
|
||||||
|
if sc.ConfigID == "" {
|
||||||
|
return nil, fmt.Errorf("scenario %q: config is required", sc.ID)
|
||||||
|
}
|
||||||
|
if len(sc.Steps) == 0 {
|
||||||
|
return nil, fmt.Errorf("scenario %q: no steps", sc.ID)
|
||||||
|
}
|
||||||
|
return &sc, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"powerpod/gotool/pb"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
lipoMinMv = 3000
|
||||||
|
lipoMaxMv = 4200
|
||||||
|
)
|
||||||
|
|
||||||
|
type lipoReadingJSON struct {
|
||||||
|
Valid bool `json:"valid"`
|
||||||
|
VoltageMv uint32 `json:"voltage_mv"`
|
||||||
|
Percent int `json:"percent,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type batterySampleJSON struct {
|
||||||
|
ClientID uint32 `json:"client_id"`
|
||||||
|
Lipo1 lipoReadingJSON `json:"lipo1"`
|
||||||
|
Lipo2 lipoReadingJSON `json:"lipo2"`
|
||||||
|
AgeMs uint32 `json:"age_ms,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type batteryAPIRequest struct {
|
||||||
|
ClientID uint32 `json:"client_id"`
|
||||||
|
AllClients bool `json:"all_clients"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type batteryAPIResponse struct {
|
||||||
|
Type string `json:"type,omitempty"` // battery_status (WebSocket)
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Samples []batterySampleJSON `json:"samples,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func lipoPercent(mv uint32) int {
|
||||||
|
if mv <= lipoMinMv {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if mv >= lipoMaxMv {
|
||||||
|
return 100
|
||||||
|
}
|
||||||
|
return int((mv - lipoMinMv) * 100 / (lipoMaxMv - lipoMinMv))
|
||||||
|
}
|
||||||
|
|
||||||
|
func lipoFromPBMsg(l *pb.LipoReading) lipoReadingJSON {
|
||||||
|
if l == nil {
|
||||||
|
return lipoReadingJSON{}
|
||||||
|
}
|
||||||
|
return lipoFromPB(l.GetValid(), l.GetVoltageMv())
|
||||||
|
}
|
||||||
|
|
||||||
|
func lipoFromPB(valid bool, mv uint32) lipoReadingJSON {
|
||||||
|
out := lipoReadingJSON{Valid: valid, VoltageMv: mv}
|
||||||
|
if valid {
|
||||||
|
out.Percent = lipoPercent(mv)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func batterySamplesFromPB(samples []*pb.BatterySample) []batterySampleJSON {
|
||||||
|
out := make([]batterySampleJSON, 0, len(samples))
|
||||||
|
for _, s := range samples {
|
||||||
|
out = append(out, batterySampleJSON{
|
||||||
|
ClientID: s.GetClientId(),
|
||||||
|
Lipo1: lipoFromPBMsg(s.GetLipo1()),
|
||||||
|
Lipo2: lipoFromPBMsg(s.GetLipo2()),
|
||||||
|
AgeMs: s.GetAgeMs(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyBatteryStatus(link *managedSerial, in batteryAPIRequest) batteryAPIResponse {
|
||||||
|
resp, err := link.BatteryStatus(&pb.BatteryStatusRequest{
|
||||||
|
ClientId: in.ClientID,
|
||||||
|
AllClients: in.AllClients,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return batteryAPIResponse{Error: err.Error()}
|
||||||
|
}
|
||||||
|
samples := batterySamplesFromPB(resp.GetSamples())
|
||||||
|
out := batteryAPIResponse{
|
||||||
|
Success: resp.GetSuccess() || len(samples) > 0,
|
||||||
|
Samples: samples,
|
||||||
|
}
|
||||||
|
if len(samples) == 0 && out.Error == "" {
|
||||||
|
out.Error = "battery status unavailable"
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func findBatterySample(samples []batterySampleJSON, clientID uint32) (batterySampleJSON, bool) {
|
||||||
|
for _, s := range samples {
|
||||||
|
if s.ClientID == clientID {
|
||||||
|
return s, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return batterySampleJSON{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyBatterySamplesToState merges UART/REST battery samples into dashboard views.
|
||||||
|
func applyBatterySamplesToState(st *DashboardState, samples []batterySampleJSON) {
|
||||||
|
if st == nil || len(samples) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if m, ok := findBatterySample(samples, 0); ok {
|
||||||
|
st.Master.Lipo1 = m.Lipo1
|
||||||
|
st.Master.Lipo2 = m.Lipo2
|
||||||
|
st.Master.BatteryAgeMs = m.AgeMs
|
||||||
|
}
|
||||||
|
for i := range st.Clients {
|
||||||
|
if s, ok := findBatterySample(samples, st.Clients[i].ID); ok {
|
||||||
|
st.Clients[i].Lipo1 = s.Lipo1
|
||||||
|
st.Clients[i].Lipo2 = s.Lipo2
|
||||||
|
st.Clients[i].BatteryAgeMs = s.AgeMs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,367 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"tinygo.org/x/bluetooth"
|
||||||
|
|
||||||
|
pb "powerpod/gotool/pb"
|
||||||
|
uartframe "powerpod/gotool/uart"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
bleServiceUUID = "6e400001-b5a3-f393-e0a9-e50e24dcca9e"
|
||||||
|
bleCharUUID = "6e400002-b5a3-f393-e0a9-e50e24dcca9e"
|
||||||
|
bleDefaultName = "Powerpod"
|
||||||
|
)
|
||||||
|
|
||||||
|
type bleLink struct {
|
||||||
|
device bluetooth.Device
|
||||||
|
char bluetooth.DeviceCharacteristic
|
||||||
|
mu sync.Mutex
|
||||||
|
parserMu sync.Mutex
|
||||||
|
parser *uartframe.Parser
|
||||||
|
respCh chan []byte
|
||||||
|
streamCh chan []byte
|
||||||
|
quiet bool
|
||||||
|
attMTU int // negotiated ATT MTU (0 if unknown)
|
||||||
|
}
|
||||||
|
|
||||||
|
func openBLE(name string, settle time.Duration) (*bleLink, error) {
|
||||||
|
if name == "" {
|
||||||
|
name = bleDefaultName
|
||||||
|
}
|
||||||
|
adapter := bluetooth.DefaultAdapter
|
||||||
|
if err := adapter.Enable(); err != nil {
|
||||||
|
return nil, fmt.Errorf("enable bluetooth adapter: %w (check BlueZ / permissions)", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("BLE scanning for %q…", name)
|
||||||
|
var foundAddr bluetooth.Address
|
||||||
|
var found bool
|
||||||
|
go func() {
|
||||||
|
time.Sleep(15 * time.Second)
|
||||||
|
_ = adapter.StopScan()
|
||||||
|
}()
|
||||||
|
err := adapter.Scan(func(a *bluetooth.Adapter, result bluetooth.ScanResult) {
|
||||||
|
if result.LocalName() != name {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
foundAddr = result.Address
|
||||||
|
found = true
|
||||||
|
_ = a.StopScan()
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("scan: %w", err)
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return nil, fmt.Errorf("device %q not found (check DIP8=BLE and advertising)", name)
|
||||||
|
}
|
||||||
|
return connectBLEDevice(foundAddr, settle)
|
||||||
|
}
|
||||||
|
|
||||||
|
func openBLEAddress(addrStr string, settle time.Duration) (*bleLink, error) {
|
||||||
|
adapter := bluetooth.DefaultAdapter
|
||||||
|
if err := adapter.Enable(); err != nil {
|
||||||
|
return nil, fmt.Errorf("enable bluetooth adapter: %w (check BlueZ / permissions)", err)
|
||||||
|
}
|
||||||
|
if mac, err := bluetooth.ParseMAC(addrStr); err == nil {
|
||||||
|
var addr bluetooth.Address
|
||||||
|
addr.Set(mac.String())
|
||||||
|
return connectBLEDevice(addr, settle)
|
||||||
|
}
|
||||||
|
// Fallback: scan until address string matches
|
||||||
|
log.Printf("BLE resolving address %s via scan…", addrStr)
|
||||||
|
var foundAddr bluetooth.Address
|
||||||
|
var found bool
|
||||||
|
go func() {
|
||||||
|
time.Sleep(15 * time.Second)
|
||||||
|
_ = adapter.StopScan()
|
||||||
|
}()
|
||||||
|
err := adapter.Scan(func(a *bluetooth.Adapter, result bluetooth.ScanResult) {
|
||||||
|
if result.Address.String() != addrStr {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
foundAddr = result.Address
|
||||||
|
found = true
|
||||||
|
_ = a.StopScan()
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("scan for address: %w", err)
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return nil, fmt.Errorf("BLE address %s not found", addrStr)
|
||||||
|
}
|
||||||
|
return connectBLEDevice(foundAddr, settle)
|
||||||
|
}
|
||||||
|
|
||||||
|
func connectBLEDevice(addr bluetooth.Address, settle time.Duration) (*bleLink, error) {
|
||||||
|
svcUUID, err := bluetooth.ParseUUID(bleServiceUUID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
chrUUID, err := bluetooth.ParseUUID(bleCharUUID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
adapter := bluetooth.DefaultAdapter
|
||||||
|
log.Printf("BLE connecting to %s…", addr.String())
|
||||||
|
device, err := adapter.Connect(addr, bluetooth.ConnectionParams{})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("connect: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
services, err := device.DiscoverServices([]bluetooth.UUID{svcUUID})
|
||||||
|
if err != nil || len(services) == 0 {
|
||||||
|
_ = device.Disconnect()
|
||||||
|
return nil, fmt.Errorf("discover service: %v", err)
|
||||||
|
}
|
||||||
|
chars, err := services[0].DiscoverCharacteristics([]bluetooth.UUID{chrUUID})
|
||||||
|
if err != nil || len(chars) == 0 {
|
||||||
|
_ = device.Disconnect()
|
||||||
|
return nil, fmt.Errorf("discover characteristic: %v", err)
|
||||||
|
}
|
||||||
|
char := chars[0]
|
||||||
|
|
||||||
|
attMTU := 0
|
||||||
|
if mtu, err := char.GetMTU(); err == nil {
|
||||||
|
attMTU = int(mtu)
|
||||||
|
log.Printf("BLE ATT MTU=%d", attMTU)
|
||||||
|
if attMTU < otaMinATTMTU {
|
||||||
|
log.Printf("warning: MTU %d < %d — OTA (200 B chunks) and large stream ticks may fail",
|
||||||
|
attMTU, otaMinATTMTU)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
link := &bleLink{
|
||||||
|
device: device,
|
||||||
|
char: char,
|
||||||
|
parser: uartframe.NewParser(),
|
||||||
|
respCh: make(chan []byte, 8),
|
||||||
|
streamCh: make(chan []byte, 64),
|
||||||
|
attMTU: attMTU,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := char.EnableNotifications(func(buf []byte) {
|
||||||
|
link.onNotify(buf)
|
||||||
|
}); err != nil {
|
||||||
|
_ = device.Disconnect()
|
||||||
|
return nil, fmt.Errorf("enable notifications: %w (CCCD required before commands)", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if settle <= 0 {
|
||||||
|
settle = time.Second
|
||||||
|
}
|
||||||
|
log.Printf("BLE connected; settling %v…", settle)
|
||||||
|
time.Sleep(settle)
|
||||||
|
|
||||||
|
return link, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *bleLink) onNotify(buf []byte) {
|
||||||
|
b.parserMu.Lock()
|
||||||
|
defer b.parserMu.Unlock()
|
||||||
|
for _, by := range buf {
|
||||||
|
payload, ok, err := b.parser.Feed(by)
|
||||||
|
if err != nil || !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(payload) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if payload[0] == byte(pb.MessageType_BLE_STREAM_TICK) {
|
||||||
|
select {
|
||||||
|
case b.streamCh <- payload:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case b.respCh <- payload:
|
||||||
|
default:
|
||||||
|
// Prefer dropping the oldest queued frame so a late/stale reply
|
||||||
|
// does not block the matching response for the in-flight command.
|
||||||
|
select {
|
||||||
|
case <-b.respCh:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case b.respCh <- payload:
|
||||||
|
default:
|
||||||
|
log.Printf("BLE: response channel full, dropping frame type=0x%02x", payload[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *bleLink) resetParser() {
|
||||||
|
b.parserMu.Lock()
|
||||||
|
b.parser = uartframe.NewParser()
|
||||||
|
b.parserMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *bleLink) Close() error {
|
||||||
|
return b.device.Disconnect()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *bleLink) exchange(cmdID byte, cmdName string) ([]byte, error) {
|
||||||
|
return b.exchangePayload([]byte{cmdID}, cmdName)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *bleLink) exchangePayload(payload []byte, cmdName string) ([]byte, error) {
|
||||||
|
return b.exchangePayloadTimeout(payload, cmdName, readTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *bleLink) exchangePayloadTimeout(payload []byte, cmdName string, timeout time.Duration) ([]byte, error) {
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
|
||||||
|
if len(payload) == 0 {
|
||||||
|
return nil, fmt.Errorf("empty payload")
|
||||||
|
}
|
||||||
|
expect := payload[0]
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = readTimeout
|
||||||
|
}
|
||||||
|
|
||||||
|
frame, err := uartframe.EncodeFrame(payload)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("encode frame: %w", err)
|
||||||
|
}
|
||||||
|
if !b.quiet {
|
||||||
|
log.Printf("BLE sending %s (%d bytes)", cmdName, len(frame))
|
||||||
|
}
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-b.respCh:
|
||||||
|
default:
|
||||||
|
goto sent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sent:
|
||||||
|
if _, err := b.char.WriteWithoutResponse(frame); err != nil {
|
||||||
|
return nil, fmt.Errorf("BLE write: %w (check DIP8=BLE)", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
deadline := time.Now().Add(timeout)
|
||||||
|
for {
|
||||||
|
remain := time.Until(deadline)
|
||||||
|
if remain <= 0 {
|
||||||
|
// Drop a half-parsed frame (e.g. aborted multi-notify) so the next
|
||||||
|
// exchange does not treat a new 0xAA as payload data.
|
||||||
|
b.resetParser()
|
||||||
|
return nil, fmt.Errorf("BLE read timeout for %s (check DIP8=BLE / -ble)", cmdName)
|
||||||
|
}
|
||||||
|
timer := time.NewTimer(remain)
|
||||||
|
select {
|
||||||
|
case resp := <-b.respCh:
|
||||||
|
timer.Stop()
|
||||||
|
if len(resp) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if resp[0] != expect {
|
||||||
|
// Stale reply (e.g. late BATTERY_STATUS after a short timeout).
|
||||||
|
if !b.quiet {
|
||||||
|
log.Printf("BLE: discard type=0x%02x while waiting for 0x%02x (%s)", resp[0], expect, cmdName)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !b.quiet {
|
||||||
|
log.Printf("BLE response (%d bytes)", len(resp))
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
|
case <-timer.C:
|
||||||
|
b.resetParser()
|
||||||
|
return nil, fmt.Errorf("BLE read timeout for %s (check DIP8=BLE / -ble)", cmdName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *bleLink) NextStreamTick(timeout time.Duration) ([]byte, error) {
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = 5 * time.Second
|
||||||
|
}
|
||||||
|
timer := time.NewTimer(timeout)
|
||||||
|
defer timer.Stop()
|
||||||
|
select {
|
||||||
|
case p := <-b.streamCh:
|
||||||
|
return p, nil
|
||||||
|
case <-timer.C:
|
||||||
|
return nil, fmt.Errorf("BLE stream tick timeout")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureOTAMTU rejects OTA when the negotiated ATT MTU cannot carry 200 B chunks.
|
||||||
|
func (b *bleLink) ensureOTAMTU() error {
|
||||||
|
mtu := b.attMTU
|
||||||
|
if mtu <= 0 {
|
||||||
|
if v, err := b.char.GetMTU(); err == nil {
|
||||||
|
mtu = int(v)
|
||||||
|
b.attMTU = mtu
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if mtu < otaMinATTMTU {
|
||||||
|
return fmt.Errorf("BLE ATT MTU %d < %d required for OTA (negotiate larger MTU)", mtu, otaMinATTMTU)
|
||||||
|
}
|
||||||
|
maxWrite := mtu - 3
|
||||||
|
if maxWrite < 216 {
|
||||||
|
return fmt.Errorf("BLE max write %d < 216 needed for 200 B OTA frames", maxWrite)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *bleLink) writeMsg(msg *pb.UartMessage) error {
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
frame, err := encodeUartMessage(msg)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if b.attMTU > 0 && len(frame) > b.attMTU-3 {
|
||||||
|
return fmt.Errorf("OTA frame %d exceeds BLE max write %d (MTU=%d)",
|
||||||
|
len(frame), b.attMTU-3, b.attMTU)
|
||||||
|
}
|
||||||
|
if _, err := b.char.WriteWithoutResponse(frame); err != nil {
|
||||||
|
return fmt.Errorf("BLE write: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *bleLink) readFrame(timeout time.Duration) ([]byte, error) {
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = readTimeout
|
||||||
|
}
|
||||||
|
timer := time.NewTimer(timeout)
|
||||||
|
defer timer.Stop()
|
||||||
|
select {
|
||||||
|
case p := <-b.respCh:
|
||||||
|
return p, nil
|
||||||
|
case <-timer.C:
|
||||||
|
return nil, fmt.Errorf("BLE read timeout")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *bleLink) flushInput() {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-b.respCh:
|
||||||
|
default:
|
||||||
|
goto streams
|
||||||
|
}
|
||||||
|
}
|
||||||
|
streams:
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-b.streamCh:
|
||||||
|
default:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *bleLink) chunkPace() time.Duration { return otaHostChunkPaceBLE }
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"sort"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
pb "powerpod/gotool/pb"
|
||||||
|
|
||||||
|
"google.golang.org/protobuf/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
type latencyStats struct {
|
||||||
|
N int `json:"n"`
|
||||||
|
MinMs float64 `json:"min_ms"`
|
||||||
|
MedMs float64 `json:"median_ms"`
|
||||||
|
MeanMs float64 `json:"mean_ms"`
|
||||||
|
MaxMs float64 `json:"max_ms"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type bleRttResult struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
Warmup int `json:"warmup"`
|
||||||
|
Stats latencyStats `json:"stats"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type bleStreamResult struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Received int `json:"received"`
|
||||||
|
Requested int `json:"requested"`
|
||||||
|
PeriodMs uint32 `json:"period_ms"`
|
||||||
|
TolMs float64 `json:"tol_ms"`
|
||||||
|
OutsideTol int `json:"outside_tol"`
|
||||||
|
WindowOK bool `json:"window_ok"`
|
||||||
|
DTEsp latencyStats `json:"dt_esp_ms"`
|
||||||
|
DRPc latencyStats `json:"dr_pc_ms"`
|
||||||
|
ExtraVsFirst latencyStats `json:"extra_vs_first_ms"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func measureBleRtt(h hostConn, count, warmup int, settle time.Duration) bleRttResult {
|
||||||
|
out := bleRttResult{Count: count, Warmup: warmup}
|
||||||
|
if count < 1 {
|
||||||
|
out.Error = "count must be >= 1"
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
if warmup < 0 {
|
||||||
|
out.Error = "warmup must be >= 0"
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
if settle > 0 {
|
||||||
|
time.Sleep(settle)
|
||||||
|
}
|
||||||
|
|
||||||
|
samples := make([]float64, 0, count)
|
||||||
|
total := warmup + count
|
||||||
|
for i := 0; i < total; i++ {
|
||||||
|
t0 := time.Now()
|
||||||
|
if _, err := hostGetVersion(h); err != nil {
|
||||||
|
out.Error = fmt.Sprintf("VERSION round-trip %d: %v", i, err)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
ms := float64(time.Since(t0).Microseconds()) / 1000.0
|
||||||
|
if i >= warmup {
|
||||||
|
samples = append(samples, ms)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.Stats = makeLatencyStats(samples)
|
||||||
|
out.Success = true
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func measureBleStream(h hostConn, clientID, periodMs uint32, count int, tolMs, timeoutSec float64) bleStreamResult {
|
||||||
|
out := bleStreamResult{
|
||||||
|
Requested: count,
|
||||||
|
PeriodMs: periodMs,
|
||||||
|
TolMs: tolMs,
|
||||||
|
}
|
||||||
|
if periodMs < 10 || periodMs > 16 {
|
||||||
|
out.Error = "period_ms must be 10..16"
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
if count < 2 {
|
||||||
|
out.Error = "count must be >= 2"
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
sc, ok := h.(hostStreamConn)
|
||||||
|
if !ok {
|
||||||
|
out.Error = "BLE stream ticks require BLE host"
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
req := &pb.AccelStreamRequest{
|
||||||
|
Write: true,
|
||||||
|
Enable: true,
|
||||||
|
PeriodMs: periodMs,
|
||||||
|
}
|
||||||
|
if clientID == 0 {
|
||||||
|
req.AllClients = true
|
||||||
|
} else {
|
||||||
|
req.ClientId = clientID
|
||||||
|
}
|
||||||
|
resp, err := hostAccelStream(h, req)
|
||||||
|
if err != nil {
|
||||||
|
out.Error = err.Error()
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
if !resp.GetSuccess() {
|
||||||
|
out.Error = "accel stream enable failed"
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
type sample struct {
|
||||||
|
espUs uint64
|
||||||
|
pcT float64
|
||||||
|
}
|
||||||
|
samples := make([]sample, 0, count)
|
||||||
|
deadline := time.Now().Add(time.Duration(timeoutSec * float64(time.Second)))
|
||||||
|
if timeoutSec <= 0 {
|
||||||
|
deadline = time.Now().Add(15 * time.Second)
|
||||||
|
}
|
||||||
|
|
||||||
|
for len(samples) < count {
|
||||||
|
remain := time.Until(deadline)
|
||||||
|
if remain <= 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
payload, err := sc.NextStreamTick(remain)
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
pcT := time.Now().UnixMicro()
|
||||||
|
var msg pb.UartMessage
|
||||||
|
if err := proto.Unmarshal(payload[1:], &msg); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
tick := msg.GetBleStreamTick()
|
||||||
|
if tick == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
samples = append(samples, sample{espUs: tick.GetEspTimestampUs(), pcT: float64(pcT)})
|
||||||
|
}
|
||||||
|
|
||||||
|
off := &pb.AccelStreamRequest{Write: true, Enable: false, AllClients: req.AllClients, ClientId: req.ClientId}
|
||||||
|
_, _ = hostAccelStream(h, off)
|
||||||
|
|
||||||
|
out.Received = len(samples)
|
||||||
|
if len(samples) < 2 {
|
||||||
|
out.Error = fmt.Sprintf("need >=2 ticks, got %d (stream running / DIP8=BLE?)", len(samples))
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
var dT, dR, extra []float64
|
||||||
|
esp0 := samples[0].espUs
|
||||||
|
pc0 := samples[0].pcT
|
||||||
|
for i := 1; i < len(samples); i++ {
|
||||||
|
dT = append(dT, float64(samples[i].espUs-samples[i-1].espUs)/1000.0)
|
||||||
|
dR = append(dR, (samples[i].pcT-samples[i-1].pcT)/1000.0)
|
||||||
|
relEsp := float64(samples[i].espUs-esp0) / 1000.0
|
||||||
|
relPc := (samples[i].pcT - pc0) / 1000.0
|
||||||
|
extra = append(extra, relPc-relEsp)
|
||||||
|
}
|
||||||
|
|
||||||
|
period := float64(periodMs)
|
||||||
|
outCount := 0
|
||||||
|
for _, dt := range dT {
|
||||||
|
if math.Abs(dt-period) > tolMs {
|
||||||
|
outCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.OutsideTol = outCount
|
||||||
|
out.WindowOK = outCount*2 <= len(dT)
|
||||||
|
out.DTEsp = makeLatencyStats(dT)
|
||||||
|
out.DRPc = makeLatencyStats(dR)
|
||||||
|
out.ExtraVsFirst = makeLatencyStats(extra)
|
||||||
|
out.Success = true
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeLatencyStats(v []float64) latencyStats {
|
||||||
|
if len(v) == 0 {
|
||||||
|
return latencyStats{}
|
||||||
|
}
|
||||||
|
s := append([]float64(nil), v...)
|
||||||
|
sort.Float64s(s)
|
||||||
|
return latencyStats{
|
||||||
|
N: len(s),
|
||||||
|
MinMs: s[0],
|
||||||
|
MedMs: percentile(s, 0.5),
|
||||||
|
MeanMs: mean(s),
|
||||||
|
MaxMs: s[len(s)-1],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mean(v []float64) float64 {
|
||||||
|
if len(v) == 0 {
|
||||||
|
return math.NaN()
|
||||||
|
}
|
||||||
|
var s float64
|
||||||
|
for _, x := range v {
|
||||||
|
s += x
|
||||||
|
}
|
||||||
|
return s / float64(len(v))
|
||||||
|
}
|
||||||
|
|
||||||
|
func percentile(sorted []float64, p float64) float64 {
|
||||||
|
if len(sorted) == 0 {
|
||||||
|
return math.NaN()
|
||||||
|
}
|
||||||
|
if len(sorted) == 1 {
|
||||||
|
return sorted[0]
|
||||||
|
}
|
||||||
|
idx := p * float64(len(sorted)-1)
|
||||||
|
lo := int(idx)
|
||||||
|
hi := lo + 1
|
||||||
|
if hi >= len(sorted) {
|
||||||
|
return sorted[lo]
|
||||||
|
}
|
||||||
|
frac := idx - float64(lo)
|
||||||
|
return sorted[lo]*(1-frac) + sorted[hi]*frac
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"powerpod/gotool/pb"
|
||||||
|
|
||||||
|
"google.golang.org/protobuf/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
// accelSamplesFromCacheStatus maps combined CACHE_STATUS entries to AccelSample
|
||||||
|
// (for dashboard / WebSocket accel push).
|
||||||
|
func accelSamplesFromCacheStatus(r *pb.CacheStatusResponse) []*pb.AccelSample {
|
||||||
|
if r == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]*pb.AccelSample, 0, len(r.GetClients()))
|
||||||
|
for _, c := range r.GetClients() {
|
||||||
|
if c.GetAccel() == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
a := c.GetAccel()
|
||||||
|
out = append(out, &pb.AccelSample{
|
||||||
|
ClientId: c.GetClientId(),
|
||||||
|
Valid: a.GetValid(),
|
||||||
|
X: a.GetX(),
|
||||||
|
Y: a.GetY(),
|
||||||
|
Z: a.GetZ(),
|
||||||
|
AgeMs: a.GetAgeMs(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// tapEventsFromCacheStatus maps combined CACHE_STATUS entries to TapEvent
|
||||||
|
// (only clients with a consumed pending tap).
|
||||||
|
func tapEventsFromCacheStatus(r *pb.CacheStatusResponse) []*pb.TapEvent {
|
||||||
|
if r == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]*pb.TapEvent, 0, len(r.GetClients()))
|
||||||
|
for _, c := range r.GetClients() {
|
||||||
|
if c.GetTap() == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
t := c.GetTap()
|
||||||
|
out = append(out, &pb.TapEvent{
|
||||||
|
ClientId: c.GetClientId(),
|
||||||
|
Valid: true,
|
||||||
|
Kind: t.GetKind(),
|
||||||
|
AgeMs: t.GetAgeMs(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// cacheStatusFromBLEStreamPayload parses a framed BLE_STREAM_TICK payload
|
||||||
|
// (type byte + protobuf UartMessage) into a CacheStatusResponse-shaped view.
|
||||||
|
func cacheStatusFromBLEStreamPayload(payload []byte) (*pb.CacheStatusResponse, error) {
|
||||||
|
tick, err := parseBleStreamTickPayload(payload)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &pb.CacheStatusResponse{Clients: tick.GetClients()}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseBleStreamTickPayload(payload []byte) (*pb.BleStreamTick, error) {
|
||||||
|
if len(payload) < 2 {
|
||||||
|
return nil, fmt.Errorf("BLE stream tick too short")
|
||||||
|
}
|
||||||
|
if payload[0] != byte(pb.MessageType_BLE_STREAM_TICK) {
|
||||||
|
return nil, fmt.Errorf("unexpected BLE tick type 0x%02x", payload[0])
|
||||||
|
}
|
||||||
|
var msg pb.UartMessage
|
||||||
|
if err := proto.Unmarshal(payload[1:], &msg); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
tick := msg.GetBleStreamTick()
|
||||||
|
if tick == nil {
|
||||||
|
return nil, fmt.Errorf("missing ble_stream_tick")
|
||||||
|
}
|
||||||
|
return tick, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,783 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"google.golang.org/protobuf/proto"
|
||||||
|
|
||||||
|
"powerpod/gotool/pb"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (m *managedSerial) getVersion() (*pb.VersionResponse, error) {
|
||||||
|
payload, err := m.exchange(byte(pb.MessageType_VERSION), "VERSION")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return decodeVersionPayload(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *managedSerial) getVersionPoll() (*pb.VersionResponse, error) {
|
||||||
|
payload, err := m.exchangePoll(byte(pb.MessageType_VERSION), "VERSION")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return decodeVersionPayload(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *managedSerial) listClients() ([]*pb.ClientInfo, error) {
|
||||||
|
payload, err := m.exchange(byte(pb.MessageType_CLIENT_INFO), "CLIENT_INFO")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return decodeClientsPayload(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *managedSerial) listClientsPoll() ([]*pb.ClientInfo, error) {
|
||||||
|
payload, err := m.exchangePoll(byte(pb.MessageType_CLIENT_INFO), "CLIENT_INFO")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return decodeClientsPayload(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeBatteryStatusPayload(payload []byte) (*pb.BatteryStatusResponse, error) {
|
||||||
|
if len(payload) < 2 {
|
||||||
|
return nil, fmt.Errorf("short battery response")
|
||||||
|
}
|
||||||
|
if payload[0] != byte(pb.MessageType_BATTERY_STATUS) {
|
||||||
|
return nil, fmt.Errorf("unexpected command id 0x%02x (want 0x%02x)",
|
||||||
|
payload[0], byte(pb.MessageType_BATTERY_STATUS))
|
||||||
|
}
|
||||||
|
var msg pb.UartMessage
|
||||||
|
if err := proto.Unmarshal(payload[1:], &msg); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode: %w", err)
|
||||||
|
}
|
||||||
|
if msg.GetType() != pb.MessageType_BATTERY_STATUS {
|
||||||
|
return nil, fmt.Errorf("unexpected type %v", msg.GetType())
|
||||||
|
}
|
||||||
|
r := msg.GetBatteryStatusResponse()
|
||||||
|
if r == nil {
|
||||||
|
return nil, fmt.Errorf("missing battery_status_response")
|
||||||
|
}
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *managedSerial) BatteryStatus(req *pb.BatteryStatusRequest) (*pb.BatteryStatusResponse, error) {
|
||||||
|
return m.batteryStatusExchange(false, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *managedSerial) BatteryStatusPoll(req *pb.BatteryStatusRequest) (*pb.BatteryStatusResponse, error) {
|
||||||
|
return m.batteryStatusExchange(true, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *managedSerial) batteryStatusExchange(poll bool, req *pb.BatteryStatusRequest) (*pb.BatteryStatusResponse, error) {
|
||||||
|
msg := &pb.UartMessage{
|
||||||
|
Type: pb.MessageType_BATTERY_STATUS,
|
||||||
|
Payload: &pb.UartMessage_BatteryStatusRequest{
|
||||||
|
BatteryStatusRequest: req,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
body, err := proto.Marshal(msg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("encode: %w", err)
|
||||||
|
}
|
||||||
|
payload := append([]byte{byte(pb.MessageType_BATTERY_STATUS)}, body...)
|
||||||
|
var respPayload []byte
|
||||||
|
if poll {
|
||||||
|
respPayload, err = m.exchangePayloadMode(true, payload, "BATTERY_STATUS", batteryReadTimeout)
|
||||||
|
} else {
|
||||||
|
respPayload, err = m.exchangePayloadMode(false, payload, "BATTERY_STATUS", batteryReadTimeout)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return decodeBatteryStatusPayload(respPayload)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *serialPort) batteryStatus(req *pb.BatteryStatusRequest) (*pb.BatteryStatusResponse, error) {
|
||||||
|
msg := &pb.UartMessage{
|
||||||
|
Type: pb.MessageType_BATTERY_STATUS,
|
||||||
|
Payload: &pb.UartMessage_BatteryStatusRequest{
|
||||||
|
BatteryStatusRequest: req,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
body, err := proto.Marshal(msg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("encode: %w", err)
|
||||||
|
}
|
||||||
|
payload := append([]byte{byte(pb.MessageType_BATTERY_STATUS)}, body...)
|
||||||
|
respPayload, err := s.exchangePayloadForBattery(payload, "BATTERY_STATUS")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return decodeBatteryStatusPayload(respPayload)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *managedSerial) AccelStream(req *pb.AccelStreamRequest) (*pb.AccelStreamResponse, error) {
|
||||||
|
return m.accelStreamExchange(false, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *managedSerial) AccelStreamPoll(req *pb.AccelStreamRequest) (*pb.AccelStreamResponse, error) {
|
||||||
|
return m.accelStreamExchange(true, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetAccelStream enables or disables the ESP-NOW accel stream for one slave (master UART).
|
||||||
|
func (m *managedSerial) SetAccelStream(clientID uint32, enable bool) (*pb.AccelStreamResponse, error) {
|
||||||
|
return m.AccelStream(&pb.AccelStreamRequest{
|
||||||
|
Write: true,
|
||||||
|
Enable: enable,
|
||||||
|
ClientId: clientID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAccelStream returns whether the accel stream is enabled for a slave on the master.
|
||||||
|
func (m *managedSerial) GetAccelStream(clientID uint32) (bool, error) {
|
||||||
|
resp, err := m.AccelStreamPoll(&pb.AccelStreamRequest{
|
||||||
|
Write: false,
|
||||||
|
ClientId: clientID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if !resp.GetSuccess() {
|
||||||
|
return false, fmt.Errorf("accel stream read failed for client %d", clientID)
|
||||||
|
}
|
||||||
|
return resp.GetEnabled(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *managedSerial) TapNotify(req *pb.TapNotifyRequest) (*pb.TapNotifyResponse, error) {
|
||||||
|
return m.tapNotifyExchange(false, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *managedSerial) TapNotifyPoll(req *pb.TapNotifyRequest) (*pb.TapNotifyResponse, error) {
|
||||||
|
return m.tapNotifyExchange(true, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *managedSerial) tapNotifyExchange(poll bool, req *pb.TapNotifyRequest) (*pb.TapNotifyResponse, error) {
|
||||||
|
msg := &pb.UartMessage{
|
||||||
|
Type: pb.MessageType_TAP_NOTIFY,
|
||||||
|
Payload: &pb.UartMessage_TapNotifyRequest{TapNotifyRequest: req},
|
||||||
|
}
|
||||||
|
body, err := proto.Marshal(msg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("encode: %w", err)
|
||||||
|
}
|
||||||
|
payload := append([]byte{byte(pb.MessageType_TAP_NOTIFY)}, body...)
|
||||||
|
var respPayload []byte
|
||||||
|
if poll {
|
||||||
|
respPayload, err = m.exchangePayloadPoll(payload, "TAP_NOTIFY")
|
||||||
|
} else {
|
||||||
|
respPayload, err = m.exchangePayload(payload, "TAP_NOTIFY")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var respMsg pb.UartMessage
|
||||||
|
if err := proto.Unmarshal(respPayload[1:], &respMsg); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode: %w", err)
|
||||||
|
}
|
||||||
|
r := respMsg.GetTapNotifyResponse()
|
||||||
|
if r == nil {
|
||||||
|
return nil, fmt.Errorf("missing tap_notify_response")
|
||||||
|
}
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *managedSerial) readCacheStatusPoll() (*pb.CacheStatusResponse, error) {
|
||||||
|
payload, err := m.exchangePoll(byte(pb.MessageType_CACHE_STATUS), "CACHE_STATUS")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return decodeCacheStatusPayload(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeCacheStatusPayload(payload []byte) (*pb.CacheStatusResponse, error) {
|
||||||
|
if len(payload) < 1 {
|
||||||
|
return nil, fmt.Errorf("empty response payload")
|
||||||
|
}
|
||||||
|
if payload[0] != byte(pb.MessageType_CACHE_STATUS) {
|
||||||
|
return nil, fmt.Errorf("unexpected command id 0x%02x (want 0x%02x)",
|
||||||
|
payload[0], byte(pb.MessageType_CACHE_STATUS))
|
||||||
|
}
|
||||||
|
var msg pb.UartMessage
|
||||||
|
if err := proto.Unmarshal(payload[1:], &msg); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode: %w", err)
|
||||||
|
}
|
||||||
|
if msg.GetType() != pb.MessageType_CACHE_STATUS {
|
||||||
|
return nil, fmt.Errorf("unexpected type %v", msg.GetType())
|
||||||
|
}
|
||||||
|
r := msg.GetCacheStatusResponse()
|
||||||
|
if r == nil {
|
||||||
|
return nil, fmt.Errorf("missing cache_status_response")
|
||||||
|
}
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *managedSerial) accelStreamExchange(poll bool, req *pb.AccelStreamRequest) (*pb.AccelStreamResponse, error) {
|
||||||
|
msg := &pb.UartMessage{
|
||||||
|
Type: pb.MessageType_ACCEL_STREAM,
|
||||||
|
Payload: &pb.UartMessage_AccelStreamRequest{AccelStreamRequest: req},
|
||||||
|
}
|
||||||
|
body, err := proto.Marshal(msg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("encode: %w", err)
|
||||||
|
}
|
||||||
|
payload := append([]byte{byte(pb.MessageType_ACCEL_STREAM)}, body...)
|
||||||
|
var respPayload []byte
|
||||||
|
if poll {
|
||||||
|
respPayload, err = m.exchangePayloadPoll(payload, "ACCEL_STREAM")
|
||||||
|
} else {
|
||||||
|
respPayload, err = m.exchangePayload(payload, "ACCEL_STREAM")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var respMsg pb.UartMessage
|
||||||
|
if err := proto.Unmarshal(respPayload[1:], &respMsg); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode: %w", err)
|
||||||
|
}
|
||||||
|
r := respMsg.GetAccelStreamResponse()
|
||||||
|
if r == nil {
|
||||||
|
return nil, fmt.Errorf("missing accel_stream_response")
|
||||||
|
}
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *managedSerial) AccelDeadzone(req *pb.AccelDeadzoneRequest) (*pb.AccelDeadzoneResponse, error) {
|
||||||
|
return m.accelDeadzoneExchange(false, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *managedSerial) AccelDeadzonePoll(req *pb.AccelDeadzoneRequest) (*pb.AccelDeadzoneResponse, error) {
|
||||||
|
return m.accelDeadzoneExchange(true, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *managedSerial) TapConfig(req *pb.TapConfigRequest) (*pb.TapConfigResponse, error) {
|
||||||
|
return m.tapConfigExchange(false, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *managedSerial) TapConfigPoll(req *pb.TapConfigRequest) (*pb.TapConfigResponse, error) {
|
||||||
|
return m.tapConfigExchange(true, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *managedSerial) tapConfigExchange(poll bool, req *pb.TapConfigRequest) (*pb.TapConfigResponse, error) {
|
||||||
|
msg := &pb.UartMessage{
|
||||||
|
Type: pb.MessageType_TAP_CONFIG,
|
||||||
|
Payload: &pb.UartMessage_TapConfigRequest{TapConfigRequest: req},
|
||||||
|
}
|
||||||
|
body, err := proto.Marshal(msg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("encode: %w", err)
|
||||||
|
}
|
||||||
|
payload := append([]byte{byte(pb.MessageType_TAP_CONFIG)}, body...)
|
||||||
|
var respPayload []byte
|
||||||
|
if poll {
|
||||||
|
respPayload, err = m.exchangePayloadPoll(payload, "TAP_CONFIG")
|
||||||
|
} else {
|
||||||
|
respPayload, err = m.exchangePayload(payload, "TAP_CONFIG")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var respMsg pb.UartMessage
|
||||||
|
if err := proto.Unmarshal(respPayload[1:], &respMsg); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode: %w", err)
|
||||||
|
}
|
||||||
|
r := respMsg.GetTapConfigResponse()
|
||||||
|
if r == nil {
|
||||||
|
return nil, fmt.Errorf("missing tap_config_response")
|
||||||
|
}
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *managedSerial) BatteryUv(req *pb.BatteryUvRequest) (*pb.BatteryUvResponse, error) {
|
||||||
|
return m.batteryUvExchange(false, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *managedSerial) BatteryUvPoll(req *pb.BatteryUvRequest) (*pb.BatteryUvResponse, error) {
|
||||||
|
return m.batteryUvExchange(true, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *managedSerial) batteryUvExchange(poll bool, req *pb.BatteryUvRequest) (*pb.BatteryUvResponse, error) {
|
||||||
|
msg := &pb.UartMessage{
|
||||||
|
Type: pb.MessageType_BATTERY_UV,
|
||||||
|
Payload: &pb.UartMessage_BatteryUvRequest{BatteryUvRequest: req},
|
||||||
|
}
|
||||||
|
body, err := proto.Marshal(msg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("encode: %w", err)
|
||||||
|
}
|
||||||
|
payload := append([]byte{byte(pb.MessageType_BATTERY_UV)}, body...)
|
||||||
|
var respPayload []byte
|
||||||
|
if poll {
|
||||||
|
respPayload, err = m.exchangePayloadPoll(payload, "BATTERY_UV")
|
||||||
|
} else {
|
||||||
|
respPayload, err = m.exchangePayload(payload, "BATTERY_UV")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var respMsg pb.UartMessage
|
||||||
|
if err := proto.Unmarshal(respPayload[1:], &respMsg); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode: %w", err)
|
||||||
|
}
|
||||||
|
r := respMsg.GetBatteryUvResponse()
|
||||||
|
if r == nil {
|
||||||
|
return nil, fmt.Errorf("missing battery_uv_response")
|
||||||
|
}
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *managedSerial) accelDeadzoneExchange(poll bool, req *pb.AccelDeadzoneRequest) (*pb.AccelDeadzoneResponse, error) {
|
||||||
|
msg := &pb.UartMessage{
|
||||||
|
Type: pb.MessageType_ACCEL_DEADZONE,
|
||||||
|
Payload: &pb.UartMessage_AccelDeadzoneRequest{AccelDeadzoneRequest: req},
|
||||||
|
}
|
||||||
|
body, err := proto.Marshal(msg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("encode: %w", err)
|
||||||
|
}
|
||||||
|
payload := append([]byte{byte(pb.MessageType_ACCEL_DEADZONE)}, body...)
|
||||||
|
var respPayload []byte
|
||||||
|
if poll {
|
||||||
|
respPayload, err = m.exchangePayloadPoll(payload, "ACCEL_DEADZONE")
|
||||||
|
} else {
|
||||||
|
respPayload, err = m.exchangePayload(payload, "ACCEL_DEADZONE")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var respMsg pb.UartMessage
|
||||||
|
if err := proto.Unmarshal(respPayload[1:], &respMsg); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode: %w", err)
|
||||||
|
}
|
||||||
|
r := respMsg.GetAccelDeadzoneResponse()
|
||||||
|
if r == nil {
|
||||||
|
return nil, fmt.Errorf("missing accel_deadzone_response")
|
||||||
|
}
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *managedSerial) EspnowUnicastTest(clientID, seq uint32) (*pb.EspNowUnicastTestResponse, error) {
|
||||||
|
req := &pb.EspNowUnicastTestRequest{ClientId: clientID, Seq: seq}
|
||||||
|
msg := &pb.UartMessage{
|
||||||
|
Type: pb.MessageType_ESPNOW_UNICAST_TEST,
|
||||||
|
Payload: &pb.UartMessage_EspnowUnicastTestRequest{
|
||||||
|
EspnowUnicastTestRequest: req,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
body, err := proto.Marshal(msg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("encode: %w", err)
|
||||||
|
}
|
||||||
|
payload := append([]byte{byte(pb.MessageType_ESPNOW_UNICAST_TEST)}, body...)
|
||||||
|
respPayload, err := m.exchangePayload(payload, "ESPNOW_UNICAST_TEST")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var respMsg pb.UartMessage
|
||||||
|
if err := proto.Unmarshal(respPayload[1:], &respMsg); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode: %w", err)
|
||||||
|
}
|
||||||
|
r := respMsg.GetEspnowUnicastTestResponse()
|
||||||
|
if r == nil {
|
||||||
|
return nil, fmt.Errorf("missing espnow_unicast_test_response")
|
||||||
|
}
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeVersionPayload(payload []byte) (*pb.VersionResponse, error) {
|
||||||
|
var msg pb.UartMessage
|
||||||
|
if err := proto.Unmarshal(payload[1:], &msg); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode: %w", err)
|
||||||
|
}
|
||||||
|
if msg.GetType() != pb.MessageType_VERSION {
|
||||||
|
return nil, fmt.Errorf("unexpected type %v", msg.GetType())
|
||||||
|
}
|
||||||
|
ver := msg.GetVersionResponse()
|
||||||
|
if ver == nil {
|
||||||
|
return nil, fmt.Errorf("missing version_response")
|
||||||
|
}
|
||||||
|
return ver, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeClientsPayload(payload []byte) ([]*pb.ClientInfo, error) {
|
||||||
|
var msg pb.UartMessage
|
||||||
|
if err := proto.Unmarshal(payload[1:], &msg); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode: %w", err)
|
||||||
|
}
|
||||||
|
if msg.GetType() != pb.MessageType_CLIENT_INFO {
|
||||||
|
return nil, fmt.Errorf("unexpected type %v", msg.GetType())
|
||||||
|
}
|
||||||
|
info := msg.GetClientInfoResponse()
|
||||||
|
if info == nil {
|
||||||
|
return nil, fmt.Errorf("missing client_info_response")
|
||||||
|
}
|
||||||
|
return info.GetClients(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *serialPort) getVersion() (*pb.VersionResponse, error) {
|
||||||
|
payload, err := s.exchange(byte(pb.MessageType_VERSION), "VERSION")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return decodeVersionPayload(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *serialPort) listClients() ([]*pb.ClientInfo, error) {
|
||||||
|
payload, err := s.exchange(byte(pb.MessageType_CLIENT_INFO), "CLIENT_INFO")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return decodeClientsPayload(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *serialPort) AccelStream(req *pb.AccelStreamRequest) (*pb.AccelStreamResponse, error) {
|
||||||
|
msg := &pb.UartMessage{
|
||||||
|
Type: pb.MessageType_ACCEL_STREAM,
|
||||||
|
Payload: &pb.UartMessage_AccelStreamRequest{
|
||||||
|
AccelStreamRequest: req,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
body, err := proto.Marshal(msg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("encode: %w", err)
|
||||||
|
}
|
||||||
|
payload := append([]byte{byte(pb.MessageType_ACCEL_STREAM)}, body...)
|
||||||
|
respPayload, err := s.exchangePayload(payload, "ACCEL_STREAM")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var respMsg pb.UartMessage
|
||||||
|
if err := proto.Unmarshal(respPayload[1:], &respMsg); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode: %w", err)
|
||||||
|
}
|
||||||
|
r := respMsg.GetAccelStreamResponse()
|
||||||
|
if r == nil {
|
||||||
|
return nil, fmt.Errorf("missing accel_stream_response")
|
||||||
|
}
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *serialPort) TapNotify(req *pb.TapNotifyRequest) (*pb.TapNotifyResponse, error) {
|
||||||
|
msg := &pb.UartMessage{
|
||||||
|
Type: pb.MessageType_TAP_NOTIFY,
|
||||||
|
Payload: &pb.UartMessage_TapNotifyRequest{
|
||||||
|
TapNotifyRequest: req,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
body, err := proto.Marshal(msg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("encode: %w", err)
|
||||||
|
}
|
||||||
|
payload := append([]byte{byte(pb.MessageType_TAP_NOTIFY)}, body...)
|
||||||
|
respPayload, err := s.exchangePayload(payload, "TAP_NOTIFY")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var respMsg pb.UartMessage
|
||||||
|
if err := proto.Unmarshal(respPayload[1:], &respMsg); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode: %w", err)
|
||||||
|
}
|
||||||
|
r := respMsg.GetTapNotifyResponse()
|
||||||
|
if r == nil {
|
||||||
|
return nil, fmt.Errorf("missing tap_notify_response")
|
||||||
|
}
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *serialPort) readCacheStatus() (*pb.CacheStatusResponse, error) {
|
||||||
|
payload, err := s.exchange(byte(pb.MessageType_CACHE_STATUS), "CACHE_STATUS")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return decodeCacheStatusPayload(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *serialPort) accelDeadzone(req *pb.AccelDeadzoneRequest) (*pb.AccelDeadzoneResponse, error) {
|
||||||
|
msg := &pb.UartMessage{
|
||||||
|
Type: pb.MessageType_ACCEL_DEADZONE,
|
||||||
|
Payload: &pb.UartMessage_AccelDeadzoneRequest{
|
||||||
|
AccelDeadzoneRequest: req,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
body, err := proto.Marshal(msg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("encode: %w", err)
|
||||||
|
}
|
||||||
|
payload := append([]byte{byte(pb.MessageType_ACCEL_DEADZONE)}, body...)
|
||||||
|
respPayload, err := s.exchangePayload(payload, "ACCEL_DEADZONE")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var respMsg pb.UartMessage
|
||||||
|
if err := proto.Unmarshal(respPayload[1:], &respMsg); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode: %w", err)
|
||||||
|
}
|
||||||
|
r := respMsg.GetAccelDeadzoneResponse()
|
||||||
|
if r == nil {
|
||||||
|
return nil, fmt.Errorf("missing accel_deadzone_response")
|
||||||
|
}
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *serialPort) tapConfig(req *pb.TapConfigRequest) (*pb.TapConfigResponse, error) {
|
||||||
|
msg := &pb.UartMessage{
|
||||||
|
Type: pb.MessageType_TAP_CONFIG,
|
||||||
|
Payload: &pb.UartMessage_TapConfigRequest{
|
||||||
|
TapConfigRequest: req,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
body, err := proto.Marshal(msg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("encode: %w", err)
|
||||||
|
}
|
||||||
|
payload := append([]byte{byte(pb.MessageType_TAP_CONFIG)}, body...)
|
||||||
|
respPayload, err := s.exchangePayload(payload, "TAP_CONFIG")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var respMsg pb.UartMessage
|
||||||
|
if err := proto.Unmarshal(respPayload[1:], &respMsg); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode: %w", err)
|
||||||
|
}
|
||||||
|
r := respMsg.GetTapConfigResponse()
|
||||||
|
if r == nil {
|
||||||
|
return nil, fmt.Errorf("missing tap_config_response")
|
||||||
|
}
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *serialPort) batteryUv(req *pb.BatteryUvRequest) (*pb.BatteryUvResponse, error) {
|
||||||
|
msg := &pb.UartMessage{
|
||||||
|
Type: pb.MessageType_BATTERY_UV,
|
||||||
|
Payload: &pb.UartMessage_BatteryUvRequest{
|
||||||
|
BatteryUvRequest: req,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
body, err := proto.Marshal(msg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("encode: %w", err)
|
||||||
|
}
|
||||||
|
payload := append([]byte{byte(pb.MessageType_BATTERY_UV)}, body...)
|
||||||
|
respPayload, err := s.exchangePayload(payload, "BATTERY_UV")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var respMsg pb.UartMessage
|
||||||
|
if err := proto.Unmarshal(respPayload[1:], &respMsg); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode: %w", err)
|
||||||
|
}
|
||||||
|
r := respMsg.GetBatteryUvResponse()
|
||||||
|
if r == nil {
|
||||||
|
return nil, fmt.Errorf("missing battery_uv_response")
|
||||||
|
}
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *serialPort) espnowUnicastTest(clientID, seq uint32) (*pb.EspNowUnicastTestResponse, error) {
|
||||||
|
req := &pb.EspNowUnicastTestRequest{ClientId: clientID, Seq: seq}
|
||||||
|
msg := &pb.UartMessage{
|
||||||
|
Type: pb.MessageType_ESPNOW_UNICAST_TEST,
|
||||||
|
Payload: &pb.UartMessage_EspnowUnicastTestRequest{
|
||||||
|
EspnowUnicastTestRequest: req,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
body, err := proto.Marshal(msg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("encode: %w", err)
|
||||||
|
}
|
||||||
|
payload := append([]byte{byte(pb.MessageType_ESPNOW_UNICAST_TEST)}, body...)
|
||||||
|
respPayload, err := s.exchangePayload(payload, "ESPNOW_UNICAST_TEST")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var respMsg pb.UartMessage
|
||||||
|
if err := proto.Unmarshal(respPayload[1:], &respMsg); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode: %w", err)
|
||||||
|
}
|
||||||
|
r := respMsg.GetEspnowUnicastTestResponse()
|
||||||
|
if r == nil {
|
||||||
|
return nil, fmt.Errorf("missing espnow_unicast_test_response")
|
||||||
|
}
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EchoPingResult is the host-side round-trip for ESP-NOW echo ping.
|
||||||
|
type EchoPingResult struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
ClientID uint32 `json:"client_id"`
|
||||||
|
TimestampUs uint64 `json:"timestamp_us"`
|
||||||
|
RttMs float64 `json:"rtt_ms"` // goTool: full UART round-trip
|
||||||
|
EspRttUs uint32 `json:"esp_rtt_us"` // master: µs delta ping send → pong recv
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *serialPort) echoPing(clientID uint32) (*EchoPingResult, error) {
|
||||||
|
t0 := time.Now()
|
||||||
|
timestampUs := uint64(t0.UnixMicro())
|
||||||
|
req := &pb.EspNowEchoPingRequest{
|
||||||
|
ClientId: clientID,
|
||||||
|
TimestampUs: timestampUs,
|
||||||
|
}
|
||||||
|
msg := &pb.UartMessage{
|
||||||
|
Type: pb.MessageType_ESPNOW_ECHO_PING,
|
||||||
|
Payload: &pb.UartMessage_EspnowEchoPingRequest{
|
||||||
|
EspnowEchoPingRequest: req,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
body, err := proto.Marshal(msg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("encode: %w", err)
|
||||||
|
}
|
||||||
|
payload := append([]byte{byte(pb.MessageType_ESPNOW_ECHO_PING)}, body...)
|
||||||
|
respPayload, err := s.exchangePayload(payload, "ESPNOW_ECHO_PING")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rttMs := float64(time.Since(t0).Microseconds()) / 1000.0
|
||||||
|
|
||||||
|
var respMsg pb.UartMessage
|
||||||
|
if err := proto.Unmarshal(respPayload[1:], &respMsg); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode: %w", err)
|
||||||
|
}
|
||||||
|
r := respMsg.GetEspnowEchoPingResponse()
|
||||||
|
if r == nil {
|
||||||
|
return nil, fmt.Errorf("missing espnow_echo_ping_response")
|
||||||
|
}
|
||||||
|
if !r.GetSuccess() {
|
||||||
|
return &EchoPingResult{
|
||||||
|
Success: false,
|
||||||
|
ClientID: r.GetClientId(),
|
||||||
|
RttMs: rttMs,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
if r.GetTimestampUs() != timestampUs {
|
||||||
|
return nil, fmt.Errorf("timestamp mismatch: sent %d got %d", timestampUs, r.GetTimestampUs())
|
||||||
|
}
|
||||||
|
return &EchoPingResult{
|
||||||
|
Success: true,
|
||||||
|
ClientID: r.GetClientId(),
|
||||||
|
TimestampUs: r.GetTimestampUs(),
|
||||||
|
RttMs: rttMs,
|
||||||
|
EspRttUs: r.GetEspRttUs(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *managedSerial) FindMe(clientID uint32) error {
|
||||||
|
return hostFindMe(m, clientID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *managedSerial) SetLogLevel(write bool, level uint32) (*pb.SetLogLevelResponse, error) {
|
||||||
|
return hostSetLogLevel(m, write, level)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *managedSerial) InputSendMode(req *pb.InputSendModeRequest) (*pb.InputSendModeResponse, error) {
|
||||||
|
return hostInputSendMode(m, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *managedSerial) Restart(clientID uint32) error {
|
||||||
|
if err := hostRestart(m, clientID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if clientID == 0 {
|
||||||
|
m.recoverAfterMasterRestart()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *serialPort) ledRingProgress(req *pb.LedRingProgressRequest) (*pb.LedRingProgressResponse, error) {
|
||||||
|
msg := &pb.UartMessage{
|
||||||
|
Type: pb.MessageType_LED_RING,
|
||||||
|
Payload: &pb.UartMessage_LedRingProgressRequest{
|
||||||
|
LedRingProgressRequest: req,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
body, err := proto.Marshal(msg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("encode: %w", err)
|
||||||
|
}
|
||||||
|
payload := append([]byte{byte(pb.MessageType_LED_RING)}, body...)
|
||||||
|
respPayload, err := s.exchangePayload(payload, "LED_RING")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var respMsg pb.UartMessage
|
||||||
|
if err := proto.Unmarshal(respPayload[1:], &respMsg); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode: %w", err)
|
||||||
|
}
|
||||||
|
r := respMsg.GetLedRingProgressResponse()
|
||||||
|
if r == nil {
|
||||||
|
return nil, fmt.Errorf("missing led_ring_progress_response")
|
||||||
|
}
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *serialPort) GetVersion() (*pb.VersionResponse, error) { return s.getVersion() }
|
||||||
|
|
||||||
|
func (s *serialPort) ListClients() ([]*pb.ClientInfo, error) { return s.listClients() }
|
||||||
|
|
||||||
|
func (s *serialPort) SetAccelStream(clientID uint32, enable bool) (*pb.AccelStreamResponse, error) {
|
||||||
|
return s.AccelStream(&pb.AccelStreamRequest{
|
||||||
|
Write: true,
|
||||||
|
Enable: enable,
|
||||||
|
ClientId: clientID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *serialPort) GetAccelStream(clientID uint32) (bool, error) {
|
||||||
|
resp, err := s.AccelStream(&pb.AccelStreamRequest{
|
||||||
|
Write: false,
|
||||||
|
ClientId: clientID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if !resp.GetSuccess() {
|
||||||
|
return false, fmt.Errorf("accel stream read failed for client %d", clientID)
|
||||||
|
}
|
||||||
|
return resp.GetEnabled(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *serialPort) AccelDeadzone(req *pb.AccelDeadzoneRequest) (*pb.AccelDeadzoneResponse, error) {
|
||||||
|
return s.accelDeadzone(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *serialPort) TapConfig(req *pb.TapConfigRequest) (*pb.TapConfigResponse, error) {
|
||||||
|
return s.tapConfig(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *serialPort) BatteryUv(req *pb.BatteryUvRequest) (*pb.BatteryUvResponse, error) {
|
||||||
|
return s.batteryUv(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *serialPort) EspnowUnicastTest(clientID, seq uint32) (*pb.EspNowUnicastTestResponse, error) {
|
||||||
|
return s.espnowUnicastTest(clientID, seq)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *serialPort) EchoPing(clientID uint32) (*EchoPingResult, error) {
|
||||||
|
return s.echoPing(clientID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *managedSerial) EchoPing(clientID uint32) (*EchoPingResult, error) {
|
||||||
|
return hostEchoPing(m, clientID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *serialPort) LedRing(req *pb.LedRingProgressRequest) (*pb.LedRingProgressResponse, error) {
|
||||||
|
return s.ledRingProgress(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *managedSerial) LedRing(req *pb.LedRingProgressRequest) (*pb.LedRingProgressResponse, error) {
|
||||||
|
return hostLedRing(m, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *serialPort) FindMe(clientID uint32) (*pb.EspNowFindMeResponse, error) {
|
||||||
|
return s.espnowFindMe(clientID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *serialPort) Restart(clientID uint32) (*pb.RestartResponse, error) {
|
||||||
|
return s.restart(clientID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *serialPort) OtaSlaveProgress(clientID uint32) (*pb.OtaSlaveProgressResponse, error) {
|
||||||
|
return QueryOtaSlaveProgress(s, clientID)
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
pb "powerpod/gotool/pb"
|
||||||
|
)
|
||||||
|
|
||||||
|
func runAccelStreamCmd(h hostConn, args []string) error {
|
||||||
|
fs := flag.NewFlagSet("accel-stream", flag.ExitOnError)
|
||||||
|
clientID := fs.Uint("client", 0, "slave client id (0 with -all)")
|
||||||
|
all := fs.Bool("all", false, "all registered clients")
|
||||||
|
enable := fs.Bool("enable", true, "enable stream")
|
||||||
|
disable := fs.Bool("disable", false, "disable stream")
|
||||||
|
period := fs.Uint("period", 0, "BLE push period_ms 10–16 (0=leave/default)")
|
||||||
|
if err := fs.Parse(args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
on := *enable
|
||||||
|
if *disable {
|
||||||
|
on = false
|
||||||
|
}
|
||||||
|
if !*all && *clientID == 0 {
|
||||||
|
return fmt.Errorf("need -client ID or -all")
|
||||||
|
}
|
||||||
|
req := &pb.AccelStreamRequest{
|
||||||
|
Write: true,
|
||||||
|
Enable: on,
|
||||||
|
PeriodMs: uint32(*period),
|
||||||
|
}
|
||||||
|
if *all {
|
||||||
|
req.AllClients = true
|
||||||
|
} else {
|
||||||
|
req.ClientId = uint32(*clientID)
|
||||||
|
}
|
||||||
|
r, err := hostAccelStream(h, req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Printf("accel-stream: success=%v enabled=%v client_id=%d slaves_updated=%d\n",
|
||||||
|
r.GetSuccess(), r.GetEnabled(), r.GetClientId(), r.GetSlavesUpdated())
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"powerpod/gotool/autotest"
|
||||||
|
)
|
||||||
|
|
||||||
|
func runTest(portOverride string, baudOverride int, args []string) error {
|
||||||
|
fs := flag.NewFlagSet("test", flag.ExitOnError)
|
||||||
|
configName := fs.String("config", "", "bench config id or path (testdata/configs/)")
|
||||||
|
scenarioName := fs.String("scenario", "", "scenario id or path (testdata/scenarios/)")
|
||||||
|
listConfigs := fs.Bool("list-configs", false, "list available bench configs")
|
||||||
|
listScenarios := fs.Bool("list-scenarios", false, "list available scenarios")
|
||||||
|
verbose := fs.Bool("v", false, "verbose (log UART traffic)")
|
||||||
|
if err := fs.Parse(args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if *listConfigs {
|
||||||
|
return printList("configs", autotest.ConfigDir)
|
||||||
|
}
|
||||||
|
if *listScenarios {
|
||||||
|
return printList("scenarios", autotest.ScenarioDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
if *configName == "" || *scenarioName == "" {
|
||||||
|
return fmt.Errorf("need -config and -scenario (or -list-configs / -list-scenarios)")
|
||||||
|
}
|
||||||
|
|
||||||
|
configPath, err := autotest.ResolveConfigPath(*configName)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
scenarioPath, err := autotest.ResolveScenarioPath(*scenarioName)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
bench, err := autotest.LoadConfig(configPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("load config: %w", err)
|
||||||
|
}
|
||||||
|
sc, err := autotest.LoadScenario(scenarioPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("load scenario: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
port := portOverride
|
||||||
|
if port == "" {
|
||||||
|
port = bench.UART.Master
|
||||||
|
}
|
||||||
|
baud := baudOverride
|
||||||
|
if baud <= 0 {
|
||||||
|
baud = int(bench.UART.Baud)
|
||||||
|
}
|
||||||
|
|
||||||
|
sp, err := openSerial(port, baud)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open %s: %w", port, err)
|
||||||
|
}
|
||||||
|
registerShutdown(func() { _ = sp.Close() })
|
||||||
|
enableShutdownOnInterrupt()
|
||||||
|
defer sp.Close()
|
||||||
|
|
||||||
|
if !*verbose {
|
||||||
|
log.SetOutput(io.Discard)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("bench config %q (network %d, master %s)\n",
|
||||||
|
bench.ID, bench.Network, bench.MasterMAC)
|
||||||
|
fmt.Printf(" uart.master %s (baud %d)\n", bench.UART.Master, bench.UART.Baud)
|
||||||
|
if bench.UART.MasterConsole != "" {
|
||||||
|
fmt.Printf(" uart.master_console %s\n", bench.UART.MasterConsole)
|
||||||
|
}
|
||||||
|
for _, s := range bench.Slaves {
|
||||||
|
line := fmt.Sprintf(" slave %q mac=%s client_id=%d", s.ID, s.MAC, *s.ClientID)
|
||||||
|
if s.Console != "" {
|
||||||
|
line += fmt.Sprintf(" console=%s", s.Console)
|
||||||
|
}
|
||||||
|
fmt.Println(line)
|
||||||
|
}
|
||||||
|
fmt.Printf("scenario %q (%d steps)\n\n", sc.ID, len(sc.Steps))
|
||||||
|
|
||||||
|
result, err := autotest.Run(bench, sc, sp)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
failed := false
|
||||||
|
for _, sr := range result.Steps {
|
||||||
|
mark := "PASS"
|
||||||
|
if !sr.Pass {
|
||||||
|
mark = "FAIL"
|
||||||
|
failed = true
|
||||||
|
}
|
||||||
|
cmd := sr.Command
|
||||||
|
if cmd == "" {
|
||||||
|
cmd = "delay"
|
||||||
|
}
|
||||||
|
fmt.Printf("[%s] %s (%s) — %s\n", mark, sr.Name, cmd, sr.Detail)
|
||||||
|
}
|
||||||
|
fmt.Printf("\n%d passed, %d failed\n", result.Passed, result.Failed)
|
||||||
|
if failed {
|
||||||
|
return fmt.Errorf("scenario %q failed", sc.ID)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func printList(kind, dir string) error {
|
||||||
|
names, err := autotest.ListJSONFiles(dir)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(names) == 0 {
|
||||||
|
fmt.Printf("no %s found under %s\n", kind, dir)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
fmt.Printf("%s:\n", kind)
|
||||||
|
for _, n := range names {
|
||||||
|
fmt.Printf(" %s\n", n)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"powerpod/gotool/pb"
|
||||||
|
)
|
||||||
|
|
||||||
|
func runBatteryUv(sp *serialPort, args []string) error {
|
||||||
|
fs := flag.NewFlagSet("battery-uv", flag.ExitOnError)
|
||||||
|
write := fs.Bool("set", false, "write UV protection enable (default: read)")
|
||||||
|
enabled := fs.Bool("enable", false, "enable software UV protection")
|
||||||
|
clientID := fs.Uint("client", 0, "client id (0=local master)")
|
||||||
|
all := fs.Bool("all", false, "apply to master + all registered slaves")
|
||||||
|
if err := fs.Parse(args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
r, err := sp.batteryUv(&pb.BatteryUvRequest{
|
||||||
|
Write: *write,
|
||||||
|
Enabled: *enabled,
|
||||||
|
ClientId: uint32(*clientID),
|
||||||
|
AllClients: *all,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("client_id=%d success=%v enabled=%v latched=%v slaves_updated=%d\n",
|
||||||
|
r.GetClientId(), r.GetSuccess(), r.GetEnabled(), r.GetLatched(),
|
||||||
|
r.GetSlavesUpdated())
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func runBleRtt(h hostConn, args []string) error {
|
||||||
|
fs := flag.NewFlagSet("ble-rtt", flag.ExitOnError)
|
||||||
|
count := fs.Int("count", 50, "number of measured VERSION round-trips")
|
||||||
|
settle := fs.Float64("settle", 0, "extra settle seconds before measurement (0 = already settled on connect)")
|
||||||
|
warmup := fs.Int("warmup", 5, "discarded warm-up round-trips")
|
||||||
|
if err := fs.Parse(args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
out := measureBleRtt(h, *count, *warmup, time.Duration(*settle*float64(time.Second)))
|
||||||
|
if !out.Success {
|
||||||
|
return fmt.Errorf("%s", out.Error)
|
||||||
|
}
|
||||||
|
fmt.Printf("ble-rtt (VERSION): n=%d warmup=%d\n", out.Stats.N, out.Warmup)
|
||||||
|
fmt.Printf(" min=%.3f median=%.3f mean=%.3f max=%.3f ms\n",
|
||||||
|
out.Stats.MinMs, out.Stats.MedMs, out.Stats.MeanMs, out.Stats.MaxMs)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
func runBleStream(h hostConn, args []string) error {
|
||||||
|
fs := flag.NewFlagSet("ble-stream", flag.ExitOnError)
|
||||||
|
clientID := fs.Uint("client", 0, "slave client id (0 = all_clients)")
|
||||||
|
periodMs := fs.Uint("period", 10, "BLE push period_ms (10–16)")
|
||||||
|
count := fs.Int("count", 50, "number of BleStreamTick samples")
|
||||||
|
tolMs := fs.Float64("tol", 2.0, "tolerance ms around period for window_ok")
|
||||||
|
duration := fs.Float64("timeout", 15.0, "max seconds waiting for ticks")
|
||||||
|
if err := fs.Parse(args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("accel stream on; period_ms=%d; collecting %d ticks…\n", *periodMs, *count)
|
||||||
|
out := measureBleStream(h, uint32(*clientID), uint32(*periodMs), *count, *tolMs, *duration)
|
||||||
|
if !out.Success {
|
||||||
|
return fmt.Errorf("%s", out.Error)
|
||||||
|
}
|
||||||
|
fmt.Printf("received=%d/%d period_ms=%d tol=±%.1f ms\n", out.Received, out.Requested, out.PeriodMs, out.TolMs)
|
||||||
|
printStat("dT ESP inter-depart", out.DTEsp)
|
||||||
|
printStat("dR PC inter-arrive", out.DRPc)
|
||||||
|
printStat("extra vs first", out.ExtraVsFirst)
|
||||||
|
fmt.Printf("outside_tol=%d/%d window_ok=%s\n", out.OutsideTol, out.DTEsp.N,
|
||||||
|
map[bool]string{true: "yes", false: "no"}[out.WindowOK])
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func printStat(name string, s latencyStats) {
|
||||||
|
if s.N == 0 {
|
||||||
|
fmt.Printf("%s: (none)\n", name)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Printf("%s: n=%d min=%.3f median=%.3f mean=%.3f max=%.3f ms\n",
|
||||||
|
name, s.N, s.MinMs, s.MedMs, s.MeanMs, s.MaxMs)
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
func runCacheStatus(sp *serialPort) error {
|
||||||
|
r, err := sp.readCacheStatus()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
clients := r.GetClients()
|
||||||
|
if len(clients) == 0 {
|
||||||
|
fmt.Println("(no slaves with accel stream or tap notify enabled)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for _, c := range clients {
|
||||||
|
id := c.GetClientId()
|
||||||
|
if a := c.GetAccel(); a != nil {
|
||||||
|
if !a.GetValid() {
|
||||||
|
fmt.Printf("client %d accel: no sample yet\n", id)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("client %d accel: x=%d y=%d z=%d (age %d ms)\n",
|
||||||
|
id, a.GetX(), a.GetY(), a.GetZ(), a.GetAgeMs())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if t := c.GetTap(); t != nil {
|
||||||
|
fmt.Printf("client %d tap: %s (age %d ms)\n",
|
||||||
|
id, tapKindLabel(t.GetKind()), t.GetAgeMs())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
+11
-23
@@ -3,33 +3,13 @@ package main
|
|||||||
import (
|
import (
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"google.golang.org/protobuf/proto"
|
|
||||||
|
|
||||||
"powerpod/gotool/pb"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func runClients(sp *serialPort) error {
|
func runClients(sp *serialPort) error {
|
||||||
payload, err := sp.exchange(byte(pb.MessageType_CLIENT_INFO), "CLIENT_INFO")
|
clients, err := sp.listClients()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
var msg pb.UartMessage
|
|
||||||
if err := proto.Unmarshal(payload[1:], &msg); err != nil {
|
|
||||||
return fmt.Errorf("decode protobuf: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if msg.GetType() != pb.MessageType_CLIENT_INFO {
|
|
||||||
return fmt.Errorf("unexpected message type %v", msg.GetType())
|
|
||||||
}
|
|
||||||
|
|
||||||
info := msg.GetClientInfoResponse()
|
|
||||||
if info == nil {
|
|
||||||
return fmt.Errorf("response missing client_info_response")
|
|
||||||
}
|
|
||||||
|
|
||||||
clients := info.GetClients()
|
|
||||||
if len(clients) == 0 {
|
if len(clients) == 0 {
|
||||||
fmt.Println("no clients registered")
|
fmt.Println("no clients registered")
|
||||||
return nil
|
return nil
|
||||||
@@ -38,9 +18,17 @@ func runClients(sp *serialPort) error {
|
|||||||
fmt.Printf("clients (%d):\n", len(clients))
|
fmt.Printf("clients (%d):\n", len(clients))
|
||||||
for i, c := range clients {
|
for i, c := range clients {
|
||||||
mac := hex.EncodeToString(c.GetMac())
|
mac := hex.EncodeToString(c.GetMac())
|
||||||
fmt.Printf(" [%d] id=%d mac=%s ver=%d available=%v used=%v last_ping=%d last_success_ping=%d\n",
|
fmt.Printf(" [%d] id=%d mac=%s ver=%d available=%v used=%v last_ping=%d last_success_ping=%d tap=%s/%s/%s\n",
|
||||||
i, c.GetId(), mac, c.GetVersion(), c.GetAvailable(), c.GetUsed(),
|
i, c.GetId(), mac, c.GetVersion(), c.GetAvailable(), c.GetUsed(),
|
||||||
c.GetLastPing(), c.GetLastSuccessPing())
|
c.GetLastPing(), c.GetLastSuccessPing(),
|
||||||
|
boolFlag(c.GetTapNotifySingle()), boolFlag(c.GetTapNotifyDouble()), boolFlag(c.GetTapNotifyTriple()))
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func boolFlag(v bool) string {
|
||||||
|
if v {
|
||||||
|
return "on"
|
||||||
|
}
|
||||||
|
return "off"
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"google.golang.org/protobuf/proto"
|
||||||
|
"powerpod/gotool/pb"
|
||||||
|
)
|
||||||
|
|
||||||
|
func runClientsResetIds(sp *serialPort) error {
|
||||||
|
resp, err := sp.resetClientIdMap()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !resp.GetSuccess() {
|
||||||
|
return fmt.Errorf("client id map reset rejected")
|
||||||
|
}
|
||||||
|
fmt.Println("client id map cleared (master map + live registry; online slaves notified)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeClientIdMapResetPayload(payload []byte) (*pb.ClientIdMapResetResponse, error) {
|
||||||
|
if len(payload) < 2 {
|
||||||
|
return nil, fmt.Errorf("short client id map reset response")
|
||||||
|
}
|
||||||
|
if payload[0] != byte(pb.MessageType_CLIENT_ID_MAP_RESET) {
|
||||||
|
return nil, fmt.Errorf("unexpected command id 0x%02x (want 0x%02x)",
|
||||||
|
payload[0], byte(pb.MessageType_CLIENT_ID_MAP_RESET))
|
||||||
|
}
|
||||||
|
var respMsg pb.UartMessage
|
||||||
|
if err := proto.Unmarshal(payload[1:], &respMsg); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode: %w", err)
|
||||||
|
}
|
||||||
|
r := respMsg.GetClientIdMapResetResponse()
|
||||||
|
if r == nil {
|
||||||
|
return nil, fmt.Errorf("missing client_id_map_reset_response")
|
||||||
|
}
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *serialPort) resetClientIdMap() (*pb.ClientIdMapResetResponse, error) {
|
||||||
|
payload, err := s.exchange(byte(pb.MessageType_CLIENT_ID_MAP_RESET), "CLIENT_ID_MAP_RESET")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return decodeClientIdMapResetPayload(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *managedSerial) resetClientIdMap() (*pb.ClientIdMapResetResponse, error) {
|
||||||
|
payload, err := m.exchange(byte(pb.MessageType_CLIENT_ID_MAP_RESET), "CLIENT_ID_MAP_RESET")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return decodeClientIdMapResetPayload(payload)
|
||||||
|
}
|
||||||
+2
-27
@@ -4,8 +4,6 @@ import (
|
|||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"google.golang.org/protobuf/proto"
|
|
||||||
|
|
||||||
"powerpod/gotool/pb"
|
"powerpod/gotool/pb"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -19,39 +17,16 @@ func runDeadzone(sp *serialPort, args []string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
req := &pb.AccelDeadzoneRequest{
|
r, err := sp.accelDeadzone(&pb.AccelDeadzoneRequest{
|
||||||
Write: *write,
|
Write: *write,
|
||||||
Deadzone: uint32(*deadzone),
|
Deadzone: uint32(*deadzone),
|
||||||
ClientId: uint32(*clientID),
|
ClientId: uint32(*clientID),
|
||||||
AllClients: *all,
|
AllClients: *all,
|
||||||
}
|
})
|
||||||
msg := &pb.UartMessage{
|
|
||||||
Type: pb.MessageType_ACCEL_DEADZONE,
|
|
||||||
Payload: &pb.UartMessage_AccelDeadzoneRequest{
|
|
||||||
AccelDeadzoneRequest: req,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
body, err := proto.Marshal(msg)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("encode request: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
payload := append([]byte{byte(pb.MessageType_ACCEL_DEADZONE)}, body...)
|
|
||||||
respPayload, err := sp.exchangePayload(payload, "ACCEL_DEADZONE")
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
var respMsg pb.UartMessage
|
|
||||||
if err := proto.Unmarshal(respPayload[1:], &respMsg); err != nil {
|
|
||||||
return fmt.Errorf("decode response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
r := respMsg.GetAccelDeadzoneResponse()
|
|
||||||
if r == nil {
|
|
||||||
return fmt.Errorf("response missing accel_deadzone_response")
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("deadzone=%d client_id=%d success=%v slaves_updated=%d\n",
|
fmt.Printf("deadzone=%d client_id=%d success=%v slaves_updated=%d\n",
|
||||||
r.GetDeadzone(), r.GetClientId(), r.GetSuccess(), r.GetSlavesUpdated())
|
r.GetDeadzone(), r.GetClientId(), r.GetSuccess(), r.GetSlavesUpdated())
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
func runEchoPing(sp *serialPort, args []string) error {
|
||||||
|
fs := flag.NewFlagSet("echo-ping", flag.ExitOnError)
|
||||||
|
clientID := fs.Uint("client", 0, "slave client id from `clients`")
|
||||||
|
if err := fs.Parse(args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if *clientID == 0 {
|
||||||
|
return fmt.Errorf("client id required (see `gotool clients`)")
|
||||||
|
}
|
||||||
|
|
||||||
|
r, err := sp.echoPing(uint32(*clientID))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("echo ping: success=%v client_id=%d rtt_ms=%.3f esp_rtt_us=%d\n",
|
||||||
|
r.Success, r.ClientID, r.RttMs, r.EspRttUs)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"google.golang.org/protobuf/proto"
|
||||||
|
"powerpod/gotool/pb"
|
||||||
|
)
|
||||||
|
|
||||||
|
func runFindMe(sp *serialPort, args []string) error {
|
||||||
|
fs := flag.NewFlagSet("find-me", flag.ExitOnError)
|
||||||
|
clientID := fs.Uint("client", 0, "0=master LED ring, >0=ESP-NOW unicast to slave id")
|
||||||
|
if err := fs.Parse(args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return runFindMeClient(sp, uint32(*clientID))
|
||||||
|
}
|
||||||
|
|
||||||
|
func runFindMeClient(sp *serialPort, clientID uint32) error {
|
||||||
|
resp, err := sp.espnowFindMe(clientID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !resp.GetSuccess() {
|
||||||
|
return fmt.Errorf("find-me rejected (client_id=%d)", resp.GetClientId())
|
||||||
|
}
|
||||||
|
if clientID == 0 {
|
||||||
|
fmt.Println("find-me started on master")
|
||||||
|
} else {
|
||||||
|
fmt.Printf("find-me sent to slave %d\n", clientID)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *serialPort) espnowFindMe(clientID uint32) (*pb.EspNowFindMeResponse, error) {
|
||||||
|
msg := &pb.UartMessage{
|
||||||
|
Type: pb.MessageType_FIND_ME,
|
||||||
|
Payload: &pb.UartMessage_EspnowFindMeRequest{
|
||||||
|
EspnowFindMeRequest: &pb.EspNowFindMeRequest{ClientId: clientID},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
body, err := proto.Marshal(msg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("encode: %w", err)
|
||||||
|
}
|
||||||
|
payload := append([]byte{byte(pb.MessageType_FIND_ME)}, body...)
|
||||||
|
respPayload, err := s.exchangePayload(payload, "FIND_ME")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var respMsg pb.UartMessage
|
||||||
|
if err := proto.Unmarshal(respPayload[1:], &respMsg); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode: %w", err)
|
||||||
|
}
|
||||||
|
r := respMsg.GetEspnowFindMeResponse()
|
||||||
|
if r == nil {
|
||||||
|
return nil, fmt.Errorf("missing espnow_find_me_response")
|
||||||
|
}
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
pb "powerpod/gotool/pb"
|
||||||
|
)
|
||||||
|
|
||||||
|
func runInputSendModeCmd(h hostConn, args []string) error {
|
||||||
|
fs := flag.NewFlagSet("input-send-mode", flag.ExitOnError)
|
||||||
|
write := fs.Bool("set", false, "write mode to slave(s)")
|
||||||
|
mode := fs.Uint("mode", 0, "0=best_effort 1=fast 2=wait 3=reliable")
|
||||||
|
clientID := fs.Uint("client", 0, "slave client_id (required unless -all)")
|
||||||
|
all := fs.Bool("all", false, "apply to every registered slave")
|
||||||
|
_ = fs.Parse(args)
|
||||||
|
|
||||||
|
req := &pb.InputSendModeRequest{
|
||||||
|
Write: *write,
|
||||||
|
Mode: pb.InputSendMode(*mode),
|
||||||
|
ClientId: uint32(*clientID),
|
||||||
|
AllClients: *all,
|
||||||
|
}
|
||||||
|
if *write && !*all && *clientID == 0 {
|
||||||
|
return fmt.Errorf("need -client N or -all when using -set")
|
||||||
|
}
|
||||||
|
if *write && *mode > uint(pb.InputSendMode_INPUT_SEND_RELIABLE) {
|
||||||
|
return fmt.Errorf("mode must be 0..3")
|
||||||
|
}
|
||||||
|
|
||||||
|
r, err := hostInputSendMode(h, req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Printf("input-send-mode: success=%v mode=%d (%s) client_id=%d slaves_updated=%d\n",
|
||||||
|
r.GetSuccess(), r.GetMode(), inputSendModeName(r.GetMode()), r.GetClientId(),
|
||||||
|
r.GetSlavesUpdated())
|
||||||
|
if *write && !r.GetSuccess() {
|
||||||
|
return fmt.Errorf("input-send-mode rejected")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"powerpod/gotool/pb"
|
||||||
|
)
|
||||||
|
|
||||||
|
func runLedRing(sp *serialPort, args []string) error {
|
||||||
|
fs := flag.NewFlagSet("led-ring", flag.ExitOnError)
|
||||||
|
mode := fs.String("mode", "progress", "clear, color, progress, digit, blink, find-me, or battery-low")
|
||||||
|
clientID := fs.Uint("client", 0, "0=master ring, >0=slave via ESP-NOW")
|
||||||
|
allClients := fs.Bool("all", false, "broadcast to all slaves")
|
||||||
|
slavesOnly := fs.Bool("slaves-only", false, "with -all: do not change master ring")
|
||||||
|
progress := fs.Uint("progress", 0, "fill level 0–100 (mode=progress)")
|
||||||
|
digit := fs.Uint("digit", 0, "digit 0–10 (mode=digit)")
|
||||||
|
r := fs.Uint("r", 0, "red 0–255")
|
||||||
|
g := fs.Uint("g", 255, "green 0–255")
|
||||||
|
b := fs.Uint("b", 0, "blue 0–255")
|
||||||
|
intensity := fs.Uint("intensity", 0, "brightness 0–255 (0 = device default ~5%)")
|
||||||
|
blinkMs := fs.Uint("blink-ms", 350, "pulse length in ms (mode=blink)")
|
||||||
|
blinkCount := fs.Uint("blink-count", 1, "number of pulses (mode=blink)")
|
||||||
|
if err := fs.Parse(args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
modeVal, err := ledRingModeFromString(*mode)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := sp.ledRingProgress(&pb.LedRingProgressRequest{
|
||||||
|
Mode: modeVal,
|
||||||
|
Progress: uint32(*progress),
|
||||||
|
Digit: uint32(*digit),
|
||||||
|
R: uint32(*r),
|
||||||
|
G: uint32(*g),
|
||||||
|
B: uint32(*b),
|
||||||
|
Intensity: uint32(*intensity),
|
||||||
|
BlinkMs: uint32(*blinkMs),
|
||||||
|
BlinkCount: uint32(*blinkCount),
|
||||||
|
ClientId: uint32(*clientID),
|
||||||
|
AllClients: *allClients,
|
||||||
|
SlavesOnly: *slavesOnly,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Printf("success=%v mode=%d progress=%d digit=%d client_id=%d slaves_updated=%d\n",
|
||||||
|
resp.GetSuccess(), resp.GetMode(), resp.GetProgress(), resp.GetDigit(),
|
||||||
|
resp.GetClientId(), resp.GetSlavesUpdated())
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"google.golang.org/protobuf/proto"
|
||||||
|
"powerpod/gotool/pb"
|
||||||
|
)
|
||||||
|
|
||||||
|
func runLogLevel(sp *serialPort, args []string) error {
|
||||||
|
fs := flag.NewFlagSet("log-level", flag.ExitOnError)
|
||||||
|
write := fs.Bool("set", false, "write log level (default: read)")
|
||||||
|
level := fs.Uint("level", 0, "esp_log_level_t 0–5 (with -set)")
|
||||||
|
if err := fs.Parse(args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
resp, err := sp.setLogLevel(*write, uint32(*level))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !resp.GetSuccess() {
|
||||||
|
return fmt.Errorf("set_log_level rejected (level=%d)", resp.GetLevel())
|
||||||
|
}
|
||||||
|
fmt.Printf("log_level=%d success=%v\n", resp.GetLevel(), resp.GetSuccess())
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runSetLogLevelClient(sp *serialPort, write bool, level uint32) (*pb.SetLogLevelResponse, error) {
|
||||||
|
return sp.setLogLevel(write, level)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *serialPort) setLogLevel(write bool, level uint32) (*pb.SetLogLevelResponse, error) {
|
||||||
|
msg := &pb.UartMessage{
|
||||||
|
Type: pb.MessageType_SET_LOG_LEVEL,
|
||||||
|
Payload: &pb.UartMessage_SetLogLevelRequest{
|
||||||
|
SetLogLevelRequest: &pb.SetLogLevelRequest{
|
||||||
|
Write: write,
|
||||||
|
Level: level,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
body, err := proto.Marshal(msg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("encode: %w", err)
|
||||||
|
}
|
||||||
|
payload := append([]byte{byte(pb.MessageType_SET_LOG_LEVEL)}, body...)
|
||||||
|
respPayload, err := s.exchangePayload(payload, "SET_LOG_LEVEL")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var respMsg pb.UartMessage
|
||||||
|
if err := proto.Unmarshal(respPayload[1:], &respMsg); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode: %w", err)
|
||||||
|
}
|
||||||
|
r := respMsg.GetSetLogLevelResponse()
|
||||||
|
if r == nil {
|
||||||
|
return nil, fmt.Errorf("missing set_log_level_response")
|
||||||
|
}
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
func runOTA(t otaTransport, args []string) error {
|
||||||
|
if len(args) < 1 {
|
||||||
|
return fmt.Errorf("usage: ota <firmware.bin>")
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(args[0])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
switch x := t.(type) {
|
||||||
|
case *serialPort:
|
||||||
|
x.mu.Lock()
|
||||||
|
defer x.mu.Unlock()
|
||||||
|
case *bleLink:
|
||||||
|
if err := x.ensureOTAMTU(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return runOTAUnlocked(t, data, func(p OTAProgress) {
|
||||||
|
switch p.Phase {
|
||||||
|
case "preparing", "ready":
|
||||||
|
fmt.Println(p.Message)
|
||||||
|
case "uploading":
|
||||||
|
if p.Percent%10 == 0 {
|
||||||
|
fmt.Printf(" %s (%d%%)\n", p.Message, p.Percent)
|
||||||
|
}
|
||||||
|
case "done", "error":
|
||||||
|
fmt.Println(p.Message)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
func runOtaProgress(t otaTransport, args []string) error {
|
||||||
|
fs := flag.NewFlagSet("ota-progress", flag.ExitOnError)
|
||||||
|
clientID := fs.Uint("client", 0, "slave client id (0 = all in session)")
|
||||||
|
if err := fs.Parse(args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if sp, ok := t.(*serialPort); ok {
|
||||||
|
sp.mu.Lock()
|
||||||
|
defer sp.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
r, err := QueryOtaSlaveProgress(t, uint32(*clientID))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("active=%v total=%d aggregate=%d slaves=%d\n",
|
||||||
|
r.GetActive(), r.GetTotalBytes(), r.GetAggregateBytes(), r.GetSlaveCount())
|
||||||
|
for _, s := range r.GetSlaves() {
|
||||||
|
fmt.Printf(" slave %d: %d / %d bytes status=%d error=%d\n",
|
||||||
|
s.GetClientId(), s.GetBytesWritten(), s.GetTotalBytes(),
|
||||||
|
s.GetStatus(), s.GetError())
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"google.golang.org/protobuf/proto"
|
||||||
|
"powerpod/gotool/pb"
|
||||||
|
)
|
||||||
|
|
||||||
|
func runRestart(sp *serialPort, args []string) error {
|
||||||
|
fs := flag.NewFlagSet("restart", flag.ExitOnError)
|
||||||
|
clientID := fs.Uint("client", 0, "0=master, >0=ESP-NOW unicast to slave id")
|
||||||
|
if err := fs.Parse(args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return runRestartClient(sp, uint32(*clientID))
|
||||||
|
}
|
||||||
|
|
||||||
|
func runRestartClient(sp *serialPort, clientID uint32) error {
|
||||||
|
resp, err := sp.restart(clientID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !resp.GetSuccess() {
|
||||||
|
return fmt.Errorf("restart rejected (client_id=%d)", resp.GetClientId())
|
||||||
|
}
|
||||||
|
if clientID == 0 {
|
||||||
|
fmt.Println("restart scheduled on master")
|
||||||
|
} else {
|
||||||
|
fmt.Printf("restart sent to slave %d\n", clientID)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *serialPort) restart(clientID uint32) (*pb.RestartResponse, error) {
|
||||||
|
msg := &pb.UartMessage{
|
||||||
|
Type: pb.MessageType_RESTART,
|
||||||
|
Payload: &pb.UartMessage_RestartRequest{
|
||||||
|
RestartRequest: &pb.RestartRequest{ClientId: clientID},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
body, err := proto.Marshal(msg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("encode: %w", err)
|
||||||
|
}
|
||||||
|
payload := append([]byte{byte(pb.MessageType_RESTART)}, body...)
|
||||||
|
respPayload, err := s.exchangePayload(payload, "RESTART")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var respMsg pb.UartMessage
|
||||||
|
if err := proto.Unmarshal(respPayload[1:], &respMsg); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode: %w", err)
|
||||||
|
}
|
||||||
|
r := respMsg.GetRestartResponse()
|
||||||
|
if r == nil {
|
||||||
|
return nil, fmt.Errorf("missing restart_response")
|
||||||
|
}
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"errors"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed webui/*
|
||||||
|
var webUI embed.FS
|
||||||
|
|
||||||
|
var wsUpgrader = websocket.Upgrader{
|
||||||
|
CheckOrigin: func(r *http.Request) bool { return true },
|
||||||
|
}
|
||||||
|
|
||||||
|
type serveOpts struct {
|
||||||
|
portName string
|
||||||
|
baud int
|
||||||
|
useBLE bool
|
||||||
|
bleName string
|
||||||
|
settle time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func runServe(opts serveOpts, args []string) error {
|
||||||
|
serveFlags := flag.NewFlagSet("serve", flag.ExitOnError)
|
||||||
|
addr := serveFlags.String("addr", ":8080", "dashboard HTTP listen address")
|
||||||
|
apiAddr := serveFlags.String("api-addr", ":8081", "external API HTTP listen address (empty to disable)")
|
||||||
|
accelInterval := serveFlags.Duration("accel-interval", defaultAccelStreamInterval, "accel WebSocket sample period on API server")
|
||||||
|
interval := serveFlags.Duration("interval", 2*time.Second, "host poll interval")
|
||||||
|
if err := serveFlags.Parse(args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if opts.useBLE && opts.portName != "" {
|
||||||
|
return fmt.Errorf("use either -port or -ble, not both")
|
||||||
|
}
|
||||||
|
|
||||||
|
link := newManagedHost(opts.baud)
|
||||||
|
link.quiet = true
|
||||||
|
link.settle = opts.settle
|
||||||
|
if opts.settle <= 0 {
|
||||||
|
link.settle = time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case opts.portName != "":
|
||||||
|
if err := link.ConnectUART(opts.portName, opts.baud); err != nil {
|
||||||
|
log.Printf("initial UART connect failed (UI can retry): %v", err)
|
||||||
|
}
|
||||||
|
case opts.useBLE:
|
||||||
|
name := opts.bleName
|
||||||
|
if name == "" {
|
||||||
|
name = bleDefaultName
|
||||||
|
}
|
||||||
|
if err := link.ConnectBLE("", name); err != nil {
|
||||||
|
log.Printf("initial BLE connect failed (UI can scan/retry): %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hub := newWSHub()
|
||||||
|
streamCtl := newAccelStreamCtl()
|
||||||
|
tapCtl := newTapNotifyCtl()
|
||||||
|
stop := make(chan struct{})
|
||||||
|
|
||||||
|
var dashSrv *http.Server
|
||||||
|
var apiSrv *http.Server
|
||||||
|
registerShutdown(func() {
|
||||||
|
close(stop)
|
||||||
|
shutdownHTTPServer(dashSrv)
|
||||||
|
shutdownAPIServer(apiSrv)
|
||||||
|
if err := link.Close(); err != nil {
|
||||||
|
log.Printf("host close: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
go runPoller(link, link.Endpoint(), hub, streamCtl, tapCtl, *interval, stop)
|
||||||
|
go runBatteryPoller(link, hub, 5*time.Second, stop)
|
||||||
|
go runCacheStatusDashboardPoller(link, hub, *accelInterval, stop)
|
||||||
|
go runBLEStreamDashboardPoller(link, hub, streamCtl, stop)
|
||||||
|
|
||||||
|
if *apiAddr != "" {
|
||||||
|
apiSrv = runAPIServer(link.Endpoint(), link, *apiAddr, *accelInterval, hub, streamCtl, tapCtl, stop)
|
||||||
|
}
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mountServeAPI(mux, link, hub, streamCtl, tapCtl)
|
||||||
|
mux.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
conn, err := wsUpgrader.Upgrade(w, r, nil)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("websocket upgrade: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hub.register(conn)
|
||||||
|
defer hub.unregister(conn)
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
for {
|
||||||
|
if _, _, err := conn.ReadMessage(); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
ui, err := fs.Sub(webUI, "webui")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
mux.Handle("/", http.FileServer(http.FS(ui)))
|
||||||
|
|
||||||
|
log.Printf("dashboard http://localhost%s (transport=%s endpoint=%q poll %s, live-stream %s)",
|
||||||
|
*addr, link.Transport(), link.Endpoint(), interval.String(), accelInterval.String())
|
||||||
|
if *apiAddr == "" {
|
||||||
|
log.Printf("external API disabled (-api-addr \"\")")
|
||||||
|
} else {
|
||||||
|
log.Printf("external API http://localhost%s ws://localhost%s/ws", *apiAddr, *apiAddr)
|
||||||
|
}
|
||||||
|
|
||||||
|
dashSrv = &http.Server{Addr: *addr, Handler: mux}
|
||||||
|
go func() {
|
||||||
|
if err := dashSrv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||||
|
log.Printf("dashboard server: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
waitForShutdown()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"powerpod/gotool/pb"
|
||||||
|
)
|
||||||
|
|
||||||
|
func runTapNotify(sp *serialPort, args []string) error {
|
||||||
|
fs := flag.NewFlagSet("tap-notify", flag.ExitOnError)
|
||||||
|
write := fs.Bool("set", false, "write tap notify flags (default: read)")
|
||||||
|
clientID := fs.Uint("client", 0, "client id (>0 required for read/set one slave)")
|
||||||
|
all := fs.Bool("all", false, "apply to all registered slaves (with -set)")
|
||||||
|
single := fs.Bool("single", false, "notify on single tap")
|
||||||
|
doubleTap := fs.Bool("double", false, "notify on double tap")
|
||||||
|
triple := fs.Bool("triple", false, "notify on triple tap")
|
||||||
|
if err := fs.Parse(args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !*write && (*all || *clientID == 0) {
|
||||||
|
return fmt.Errorf("read requires -client <id>")
|
||||||
|
}
|
||||||
|
if *write && !*all && *clientID == 0 {
|
||||||
|
return fmt.Errorf("set requires -client <id> or -all")
|
||||||
|
}
|
||||||
|
|
||||||
|
r, err := sp.TapNotify(&pb.TapNotifyRequest{
|
||||||
|
Write: *write,
|
||||||
|
ClientId: uint32(*clientID),
|
||||||
|
AllClients: *all,
|
||||||
|
Single: *single,
|
||||||
|
DoubleTap: *doubleTap,
|
||||||
|
Triple: *triple,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("client_id=%d success=%v slaves_updated=%d single=%v double=%v triple=%v\n",
|
||||||
|
r.GetClientId(), r.GetSuccess(), r.GetSlavesUpdated(),
|
||||||
|
r.GetSingle(), r.GetDoubleTap(), r.GetTriple())
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func tapKindLabel(k pb.TapKind) string {
|
||||||
|
switch k {
|
||||||
|
case pb.TapKind_TAP_SINGLE:
|
||||||
|
return "single"
|
||||||
|
case pb.TapKind_TAP_DOUBLE:
|
||||||
|
return "double"
|
||||||
|
case pb.TapKind_TAP_TRIPLE:
|
||||||
|
return "triple"
|
||||||
|
default:
|
||||||
|
return "none"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"powerpod/gotool/pb"
|
||||||
|
)
|
||||||
|
|
||||||
|
func runTapConfig(sp *serialPort, args []string) error {
|
||||||
|
fs := flag.NewFlagSet("tap-config", flag.ExitOnError)
|
||||||
|
write := fs.Bool("set", false, "write tap config (default: read)")
|
||||||
|
clientID := fs.Uint("client", 0, "client id (0=local master BMA456)")
|
||||||
|
all := fs.Bool("all", false, "apply to all registered slaves (master only)")
|
||||||
|
sens := fs.Uint("sens", 5, "tap_sens_thres 0=most … 15=least")
|
||||||
|
maxGest := fs.Uint("max-gest", 100, "max_gest_dur (×5 ms)")
|
||||||
|
shock := fs.Uint("shock", 6, "tap_shock_dur (×5 ms)")
|
||||||
|
quiet := fs.Uint("quiet", 60, "quite_time_after_gest (×5 ms)")
|
||||||
|
wait := fs.Uint("wait", 0, "wait_for_timeout 0|1")
|
||||||
|
axis := fs.Uint("axis", 2, "axis_sel 0=X 1=Y 2=Z")
|
||||||
|
single := fs.Bool("single", true, "enable single tap")
|
||||||
|
doubleTap := fs.Bool("double", true, "enable double tap")
|
||||||
|
triple := fs.Bool("triple", true, "enable triple tap")
|
||||||
|
if err := fs.Parse(args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
r, err := sp.tapConfig(&pb.TapConfigRequest{
|
||||||
|
Write: *write,
|
||||||
|
ClientId: uint32(*clientID),
|
||||||
|
AllClients: *all,
|
||||||
|
TapSensThres: uint32(*sens),
|
||||||
|
MaxGestDur: uint32(*maxGest),
|
||||||
|
TapShockDur: uint32(*shock),
|
||||||
|
QuiteTimeAfterGest: uint32(*quiet),
|
||||||
|
WaitForTimeout: uint32(*wait),
|
||||||
|
AxisSel: uint32(*axis),
|
||||||
|
EnableSingle: *single,
|
||||||
|
EnableDouble: *doubleTap,
|
||||||
|
EnableTriple: *triple,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("client_id=%d success=%v slaves_updated=%d\n",
|
||||||
|
r.GetClientId(), r.GetSuccess(), r.GetSlavesUpdated())
|
||||||
|
fmt.Printf(" sens=%d max_gest=%d shock=%d quiet=%d wait=%d axis=%d\n",
|
||||||
|
r.GetTapSensThres(), r.GetMaxGestDur(), r.GetTapShockDur(),
|
||||||
|
r.GetQuiteTimeAfterGest(), r.GetWaitForTimeout(), r.GetAxisSel())
|
||||||
|
fmt.Printf(" enable single=%v double=%v triple=%v\n",
|
||||||
|
r.GetEnableSingle(), r.GetEnableDouble(), r.GetEnableTriple())
|
||||||
|
return nil
|
||||||
|
}
|
||||||
+1
-31
@@ -3,10 +3,6 @@ package main
|
|||||||
import (
|
import (
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"google.golang.org/protobuf/proto"
|
|
||||||
|
|
||||||
"powerpod/gotool/pb"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func runUnicastTest(sp *serialPort, args []string) error {
|
func runUnicastTest(sp *serialPort, args []string) error {
|
||||||
@@ -20,37 +16,11 @@ func runUnicastTest(sp *serialPort, args []string) error {
|
|||||||
return fmt.Errorf("client id required (see `gotool clients`)")
|
return fmt.Errorf("client id required (see `gotool clients`)")
|
||||||
}
|
}
|
||||||
|
|
||||||
req := &pb.EspNowUnicastTestRequest{
|
r, err := sp.espnowUnicastTest(uint32(*clientID), uint32(*seq))
|
||||||
ClientId: uint32(*clientID),
|
|
||||||
Seq: uint32(*seq),
|
|
||||||
}
|
|
||||||
msg := &pb.UartMessage{
|
|
||||||
Type: pb.MessageType_ESPNOW_UNICAST_TEST,
|
|
||||||
Payload: &pb.UartMessage_EspnowUnicastTestRequest{
|
|
||||||
EspnowUnicastTestRequest: req,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
body, err := proto.Marshal(msg)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("encode request: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
payload := append([]byte{byte(pb.MessageType_ESPNOW_UNICAST_TEST)}, body...)
|
|
||||||
respPayload, err := sp.exchangePayload(payload, "ESPNOW_UNICAST_TEST")
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
var respMsg pb.UartMessage
|
|
||||||
if err := proto.Unmarshal(respPayload[1:], &respMsg); err != nil {
|
|
||||||
return fmt.Errorf("decode response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
r := respMsg.GetEspnowUnicastTestResponse()
|
|
||||||
if r == nil {
|
|
||||||
return fmt.Errorf("response missing espnow_unicast_test_response")
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("unicast test sent: success=%v seq=%d\n", r.GetSuccess(), r.GetSeq())
|
fmt.Printf("unicast test sent: success=%v seq=%d\n", r.GetSuccess(), r.GetSeq())
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-20
@@ -2,32 +2,13 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"google.golang.org/protobuf/proto"
|
|
||||||
|
|
||||||
"powerpod/gotool/pb"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func runVersion(sp *serialPort) error {
|
func runVersion(sp *serialPort) error {
|
||||||
payload, err := sp.exchange(byte(pb.MessageType_VERSION), "VERSION")
|
ver, err := sp.getVersion()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
var msg pb.UartMessage
|
|
||||||
if err := proto.Unmarshal(payload[1:], &msg); err != nil {
|
|
||||||
return fmt.Errorf("decode protobuf: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if msg.GetType() != pb.MessageType_VERSION {
|
|
||||||
return fmt.Errorf("unexpected message type %v", msg.GetType())
|
|
||||||
}
|
|
||||||
|
|
||||||
ver := msg.GetVersionResponse()
|
|
||||||
if ver == nil {
|
|
||||||
return fmt.Errorf("response missing version_response")
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("version: %d\n", ver.GetVersion())
|
fmt.Printf("version: %d\n", ver.GetVersion())
|
||||||
fmt.Printf("git_hash: %s\n", ver.GetGitHash())
|
fmt.Printf("git_hash: %s\n", ver.GetGitHash())
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
+1005
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,517 @@
|
|||||||
|
# REST API
|
||||||
|
|
||||||
|
`go run . serve` (optional `-port` / `-ble`) starts two HTTP servers on the same `managedHost` (UART or BLE):
|
||||||
|
|
||||||
|
| Base URL | Flag | Used by |
|
||||||
|
|----------|------|---------|
|
||||||
|
| `http://localhost:8080` | `-addr` (default `:8080`) | Web dashboard + automation on the UI routes |
|
||||||
|
| `http://localhost:8081` | `-api-addr` (default `:8081`, `""` disables) | External programs; subset of routes + service info |
|
||||||
|
|
||||||
|
WebSocket streaming (accel/tap push): [`API_WEBSOCKET.md`](API_WEBSOCKET.md).
|
||||||
|
|
||||||
|
All JSON responses use `Content-Type: application/json`. On UART errors many routes return **503** with `"error"` in the body.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## External API (`:8081`)
|
||||||
|
|
||||||
|
### Service info
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /
|
||||||
|
GET /api/v1/
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "powerpod-external-api",
|
||||||
|
"version": "1",
|
||||||
|
"serial_port": "/dev/ttyUSB0",
|
||||||
|
"transport": "uart",
|
||||||
|
"endpoint": "/dev/ttyUSB0",
|
||||||
|
"connected": true,
|
||||||
|
"websocket": "/ws",
|
||||||
|
"default_interval_ms": 16,
|
||||||
|
"min_interval_ms": 1,
|
||||||
|
"max_interval_ms": 10000,
|
||||||
|
"tap_display_min_ms": 2000,
|
||||||
|
"description": "..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Battery
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/battery?all_clients=true
|
||||||
|
GET /api/battery?client_id=16
|
||||||
|
POST /api/battery
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
POST body:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"all_clients": true}
|
||||||
|
{"client_id": 0}
|
||||||
|
{"client_id": 16}
|
||||||
|
```
|
||||||
|
|
||||||
|
Response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"samples": [
|
||||||
|
{
|
||||||
|
"client_id": 16,
|
||||||
|
"lipo1": {"valid": true, "voltage_mv": 3850, "percent": 71},
|
||||||
|
"lipo2": {"valid": false},
|
||||||
|
"age_ms": 1200
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Slaves push battery to the master every **30 s**; these routes read the master cache.
|
||||||
|
|
||||||
|
WebSocket equivalent: `get_battery` on `ws://localhost:8081/ws` (reply type `battery_status`).
|
||||||
|
|
||||||
|
### LED ring
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/led-ring
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
Body:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"mode":"color","client_id":16,"r":255,"g":0,"b":0,"intensity":128}
|
||||||
|
{"mode":"digit","client_id":0,"digit":3,"r":0,"g":255,"b":0}
|
||||||
|
{"mode":"find-me","all_clients":true,"slaves_only":true}
|
||||||
|
```
|
||||||
|
|
||||||
|
| `mode` | Notes |
|
||||||
|
|--------|--------|
|
||||||
|
| `clear` | Turn off |
|
||||||
|
| `color` | Full ring RGB + `intensity` |
|
||||||
|
| `progress` | `progress` 0–100 |
|
||||||
|
| `digit` | `digit` 0–10 |
|
||||||
|
| `blink` | `blink_ms`, `blink_count` |
|
||||||
|
| `find-me` | Locate pod |
|
||||||
|
| `battery-low` | First 4 LEDs red at ~10 % for 5 s (UV indicator test) |
|
||||||
|
|
||||||
|
Use `client_id` (`0` = master) or `all_clients` (+ optional `slaves_only`) for broadcast.
|
||||||
|
|
||||||
|
Response: `success`, `slaves_updated`, optional `error`.
|
||||||
|
|
||||||
|
WebSocket: `set_led_ring` with the same fields plus `"type":"set_led_ring"` → `led_ring_status`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dashboard API (`:8080`)
|
||||||
|
|
||||||
|
Used by the web UI; safe for scripts that drive the same features.
|
||||||
|
|
||||||
|
### Host link (UART / BLE)
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/host
|
||||||
|
→ {"transport":"none|uart|ble","endpoint":"…","connected":false,"ble_name":""}
|
||||||
|
|
||||||
|
GET /api/serial/ports
|
||||||
|
→ {"ports":["/dev/ttyUSB0",…]}
|
||||||
|
|
||||||
|
POST /api/host/connect
|
||||||
|
Content-Type: application/json
|
||||||
|
{"transport":"uart","port":"/dev/ttyUSB0"}
|
||||||
|
{"transport":"ble","address":"AA:BB:…","name":"Powerpod"}
|
||||||
|
|
||||||
|
POST /api/host/disconnect
|
||||||
|
|
||||||
|
POST /api/ble/scan
|
||||||
|
{"timeout_s":5,"name_filter":"Powerpod"}
|
||||||
|
→ {"devices":[{"address":"…","name":"Powerpod","rssi":-55}]}
|
||||||
|
```
|
||||||
|
|
||||||
|
`name_filter` is optional substring match (case-insensitive); omit to list all LE advertisements. Active BLE is disconnected before scan (BlueZ adapter).
|
||||||
|
|
||||||
|
Dashboard WebSocket state includes `host_connected`, `host_transport`, `host_endpoint` (aliases `uart_connected` / `serial_port` kept).
|
||||||
|
|
||||||
|
**OTA** (`POST /api/ota`) returns **400** unless a host (UART or BLE) is connected.
|
||||||
|
|
||||||
|
### BLE latency tests (BLE host only)
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/ble-rtt
|
||||||
|
{"count":50,"warmup":5,"settle_s":0}
|
||||||
|
→ {"success":true,"count":50,"warmup":5,"stats":{"n":50,"min_ms":…,"median_ms":…,"mean_ms":…,"max_ms":…}}
|
||||||
|
|
||||||
|
POST /api/ble-stream
|
||||||
|
{"count":50,"period_ms":8,"tol_ms":2,"timeout_s":15,"client_id":0}
|
||||||
|
→ {"success":true,"received":50,"requested":50,"window_ok":true,"dt_esp_ms":{…},"dr_pc_ms":{…},…}
|
||||||
|
```
|
||||||
|
|
||||||
|
Pauses dashboard/API polling while running. Requires active BLE host (`DIP8=1`).
|
||||||
|
|
||||||
|
### Live stream (host `CACHE_STATUS` poll ~8 ms; BLE uses `BLE_STREAM_TICK`)
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/live-stream
|
||||||
|
PUT /api/live-stream
|
||||||
|
Content-Type: application/json
|
||||||
|
{"enable": true}
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"enabled": true, "success": true}
|
||||||
|
```
|
||||||
|
|
||||||
|
Enables fast UART polling for dashboard accel/tap display. Per-slave accel still requires accel-stream (below).
|
||||||
|
|
||||||
|
### Accel stream (firmware ESP-NOW, per slave)
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/clients/16/accel-stream
|
||||||
|
PUT /api/clients/16/accel-stream
|
||||||
|
Content-Type: application/json
|
||||||
|
{"enable": true}
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"enabled": true, "client_id": 16, "success": true}
|
||||||
|
```
|
||||||
|
|
||||||
|
All slaves:
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/accel-stream
|
||||||
|
Content-Type: application/json
|
||||||
|
{"write": true, "enable": true, "all_clients": true}
|
||||||
|
```
|
||||||
|
|
||||||
|
Polling on the host runs only while at least one slave has streaming enabled (here or via external WebSocket / dashboard).
|
||||||
|
|
||||||
|
### Tap notify (firmware; does not start host tap polling)
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/clients/16/tap-notify
|
||||||
|
PUT /api/clients/16/tap-notify
|
||||||
|
Content-Type: application/json
|
||||||
|
{"single": true, "double_tap": false, "triple": false}
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"client_id": 16,
|
||||||
|
"success": true,
|
||||||
|
"slaves_updated": 1,
|
||||||
|
"single": true,
|
||||||
|
"double_tap": false,
|
||||||
|
"triple": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
All slaves:
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/tap-notify
|
||||||
|
Content-Type: application/json
|
||||||
|
{"single": true, "double_tap": false, "triple": false, "all_clients": true}
|
||||||
|
```
|
||||||
|
|
||||||
|
Host tap display / external `set_tap_stream` is separate.
|
||||||
|
|
||||||
|
### Tap snapshot (one-shot, via `CACHE_STATUS`)
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/tap-snapshot?client_id=16
|
||||||
|
```
|
||||||
|
|
||||||
|
Reads the combined cache (`CACHE_STATUS`); optional `client_id` filters pending tap events. Pending taps are consumed on read.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"events": [
|
||||||
|
{"client_id": 16, "kind": "single", "age_ms": 4}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Deadzone
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/deadzone?client_id=0
|
||||||
|
POST /api/deadzone
|
||||||
|
Content-Type: application/json
|
||||||
|
{"write": true, "deadzone": 128, "client_id": 0}
|
||||||
|
```
|
||||||
|
|
||||||
|
With `all_clients` + `slaves_only`: push to ESP-NOW slaves only (master BMA456 unchanged).
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"deadzone": 128, "client_id": 0, "success": true, "slaves_updated": 2}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tap config (BMA456 multitap)
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/tap-config?client_id=0
|
||||||
|
POST /api/tap-config
|
||||||
|
Content-Type: application/json
|
||||||
|
{
|
||||||
|
"write": true,
|
||||||
|
"client_id": 0,
|
||||||
|
"tap_sens_thres": 5,
|
||||||
|
"max_gest_dur": 100,
|
||||||
|
"tap_shock_dur": 6,
|
||||||
|
"quite_time_after_gest": 60,
|
||||||
|
"wait_for_timeout": 0,
|
||||||
|
"axis_sel": 2,
|
||||||
|
"enable_single": true,
|
||||||
|
"enable_double": true,
|
||||||
|
"enable_triple": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Same `client_id` / `all_clients` / `slaves_only` semantics as deadzone. Values are applied to the sensor and persisted in NVS on each target node.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"client_id": 0,
|
||||||
|
"success": true,
|
||||||
|
"slaves_updated": 0,
|
||||||
|
"tap_sens_thres": 5,
|
||||||
|
"max_gest_dur": 100,
|
||||||
|
"tap_shock_dur": 6,
|
||||||
|
"quite_time_after_gest": 60,
|
||||||
|
"wait_for_timeout": 0,
|
||||||
|
"axis_sel": 2,
|
||||||
|
"enable_single": true,
|
||||||
|
"enable_double": true,
|
||||||
|
"enable_triple": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Unicast test
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/unicast-test
|
||||||
|
Content-Type: application/json
|
||||||
|
{"client_id": 16, "seq": 42}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Echo ping (ESP-NOW round-trip latency)
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/echo-ping
|
||||||
|
Content-Type: application/json
|
||||||
|
{"client_id": 16}
|
||||||
|
```
|
||||||
|
|
||||||
|
`client_id` must be a registered slave id (`> 0`). The host sends a microsecond timestamp; the master forwards it over ESP-NOW and the slave echoes it back unchanged.
|
||||||
|
|
||||||
|
**Flow:** Host → UART → `cmd_espnow_echo_ping` → `ESPNOW_ECHO_PING` (with `master_time_us` from `esp_timer_get_time()`) → Slave → `ESPNOW_ECHO_PONG` → Master `recv_cb` → UART response.
|
||||||
|
|
||||||
|
**Response fields:**
|
||||||
|
|
||||||
|
| Field | Unit | Meaning |
|
||||||
|
|-------|------|---------|
|
||||||
|
| `success` | — | `true` if pong received within 500 ms |
|
||||||
|
| `client_id` | — | Echo of request |
|
||||||
|
| `timestamp_us` | µs (Unix) | Echoed host timestamp; must match request on success |
|
||||||
|
| `rtt_ms` | ms | **Host-side** round-trip: goTool send → UART response (full chain incl. USB serial) |
|
||||||
|
| `esp_rtt_us` | µs | **Master-side** ESP-NOW only: `esp_timer_get_time()` delta from ping send to pong recv |
|
||||||
|
|
||||||
|
`esp_rtt_us` is the raw firmware value (microseconds, not converted). The web dashboard displays it converted to milliseconds for readability (`esp_rtt_us / 1000`, 3 decimal places). The success banner stays visible for at least 5 seconds.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"client_id": 16,
|
||||||
|
"timestamp_us": 1717654321123456,
|
||||||
|
"rtt_ms": 49.729,
|
||||||
|
"esp_rtt_us": 18234
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
On failure (`success: false`), `esp_rtt_us` is omitted; `rtt_ms` still reflects the host round-trip. HTTP 503 if UART exchange fails (e.g. timeout, client not in registry).
|
||||||
|
|
||||||
|
**CLI:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go run . -port /dev/ttyUSB0 echo-ping -client 16
|
||||||
|
```
|
||||||
|
|
||||||
|
Example output:
|
||||||
|
|
||||||
|
```
|
||||||
|
echo ping: success=true client_id=16 rtt_ms=49.729 esp_rtt_us=18234
|
||||||
|
```
|
||||||
|
|
||||||
|
### Find me
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/find-me
|
||||||
|
Content-Type: application/json
|
||||||
|
{"client_id": 16}
|
||||||
|
```
|
||||||
|
|
||||||
|
`client_id` `0` = master LED ring.
|
||||||
|
|
||||||
|
### Restart
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/restart
|
||||||
|
Content-Type: application/json
|
||||||
|
{"client_id": 16}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Log level (master ESP-IDF console)
|
||||||
|
|
||||||
|
Runtime get/set of the **global** log filter on the master (`esp_log_level_set("*", …)`). Output goes to **UART0** (USB debug, 115200), not the host protocol UART (GPIO 2/3, 921600).
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/log-level
|
||||||
|
POST /api/log-level
|
||||||
|
Content-Type: application/json
|
||||||
|
{"write": true, "level": 3}
|
||||||
|
```
|
||||||
|
|
||||||
|
| `level` | Meaning |
|
||||||
|
|---------|---------|
|
||||||
|
| 0 | None (no log output) |
|
||||||
|
| 1 | Error |
|
||||||
|
| 2 | Warn |
|
||||||
|
| 3 | Info |
|
||||||
|
| 4 | Debug |
|
||||||
|
| 5 | Verbose |
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"success": true, "level": 3}
|
||||||
|
```
|
||||||
|
|
||||||
|
**CLI:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go run . -port /dev/ttyUSB0 log-level
|
||||||
|
go run . -port /dev/ttyUSB0 log-level -set -level 3
|
||||||
|
```
|
||||||
|
|
||||||
|
Dashboard: Master card → dropdown **ESP Log-Level** with **Lesen** / **Setzen**.
|
||||||
|
|
||||||
|
### Battery UV protection
|
||||||
|
|
||||||
|
Runtime enable for software LiPo under-voltage protection (NVS `uv_en` per pod). Disable also clears `uv_latch`. `all_clients` applies to master + every registered slave; `slaves_only` with `all_clients` skips the master.
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/battery-uv?client_id=0
|
||||||
|
POST /api/battery-uv
|
||||||
|
Content-Type: application/json
|
||||||
|
{"write": true, "enabled": false, "client_id": 0}
|
||||||
|
{"write": true, "enabled": false, "all_clients": true}
|
||||||
|
{"write": true, "enabled": false, "all_clients": true, "slaves_only": true}
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"success": true, "client_id": 0, "enabled": false, "latched": false, "slaves_updated": 0}
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go run . -port /dev/ttyUSB0 battery-uv
|
||||||
|
go run . -port /dev/ttyUSB0 battery-uv -set -enable=false -client 0
|
||||||
|
go run . -port /dev/ttyUSB0 battery-uv -set -enable=false -all
|
||||||
|
```
|
||||||
|
|
||||||
|
Dashboard: Master **UV-Schutz** checkbox + **Alle an/aus**; per-slave **UV** column.
|
||||||
|
|
||||||
|
### Input send mode (slave accel/tap ESP-NOW TX path)
|
||||||
|
|
||||||
|
Debug A/B for how slaves transmit `ACCEL_SAMPLE` / `TAP_EVENT`. Default on slaves is `best_effort` (0). Master remembers the last mode for GET; write pushes via ESP-NOW.
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/input-send-mode
|
||||||
|
PUT /api/input-send-mode
|
||||||
|
Content-Type: application/json
|
||||||
|
{"mode": 2, "all_clients": true}
|
||||||
|
```
|
||||||
|
|
||||||
|
| `mode` | Path |
|
||||||
|
|--------|------|
|
||||||
|
| 0 | `best_effort` (fire-and-forget, quiet) |
|
||||||
|
| 1 | `fast` (fire-and-forget, logs failures) |
|
||||||
|
| 2 | `wait` (send-done + retries) |
|
||||||
|
| 3 | `reliable` (OTA-style retries; can stall high-rate accel) |
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"success": true, "mode": 2, "mode_name": "wait", "slaves_updated": 2}
|
||||||
|
```
|
||||||
|
|
||||||
|
**CLI:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go run . -port /dev/ttyUSB0 input-send-mode
|
||||||
|
go run . -port /dev/ttyUSB0 input-send-mode -set -mode 2 -all
|
||||||
|
```
|
||||||
|
|
||||||
|
Dashboard: ESP-NOW Slaves toolbar → **Input-Send** dropdown + **Setzen** / **Lesen**.
|
||||||
|
|
||||||
|
### OTA (master host upload, UART or BLE)
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/ota
|
||||||
|
Content-Type: multipart/form-data
|
||||||
|
```
|
||||||
|
|
||||||
|
Form field **`firmware`**: binary image, max **2 MiB**. Requires connected host (`transport` uart or ble). BLE needs ATT MTU ≥ 219 for 200 B chunks.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"success": true, "bytes_written": 123456, "target_slot": 1}
|
||||||
|
```
|
||||||
|
|
||||||
|
Firmware distributes to slaves over ESP-NOW after `OTA_END`. Progress also appears on dashboard WebSocket as `ota_progress` messages.
|
||||||
|
|
||||||
|
CLI equivalent:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go run . -port /dev/ttyUSB0 ota build/powerpod.bin
|
||||||
|
go run . -ble ota build/powerpod.bin
|
||||||
|
```
|
||||||
|
|
||||||
|
### LED ring and battery
|
||||||
|
|
||||||
|
Same as external API:
|
||||||
|
|
||||||
|
- `POST /api/led-ring`
|
||||||
|
- `GET` / `POST` `/api/battery`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dashboard vs external
|
||||||
|
|
||||||
|
| Feature | Dashboard `:8080` | External `:8081` |
|
||||||
|
|---------|-------------------|------------------|
|
||||||
|
| Client list | Via dashboard WebSocket state / CLI `clients` | WebSocket `list_clients` |
|
||||||
|
| Accel/tap **push stream** | WebSocket state when live-stream on | WebSocket `set_stream` / `set_tap_stream` |
|
||||||
|
| Accel stream enable | REST `PUT .../accel-stream` | WebSocket `set_accel_stream` |
|
||||||
|
| Tap notify | REST `PUT .../tap-notify` | WebSocket `set_tap_notify` |
|
||||||
|
| LED / battery | REST | REST + WebSocket on `:8081` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## UI mapping
|
||||||
|
|
||||||
|
| UI action | REST / CLI |
|
||||||
|
|-----------|------------|
|
||||||
|
| Nur Master deadzone | `POST /api/deadzone` `client_id: 0` or CLI `deadzone -set -client 0` |
|
||||||
|
| Einzelner Slave | `client_id: <id>` |
|
||||||
|
| Alle Slaves deadzone | `all_clients` + `slaves_only` on POST |
|
||||||
|
| Master tap config | `GET` / `POST /api/tap-config` `client_id: 0` or CLI `tap-config` |
|
||||||
|
| Tap-Cfg alle Slaves | `POST /api/tap-config` with `all_clients` + `slaves_only` |
|
||||||
|
| Unicast test | `POST /api/unicast-test` |
|
||||||
|
| Echo ping | `POST /api/echo-ping` (per-slave latency; UI shows Host ms + ESP ms) |
|
||||||
|
| Master log level | `GET` / `POST /api/log-level` or CLI `log-level` |
|
||||||
|
| UV protection | `GET` / `POST /api/battery-uv` or CLI `battery-uv` |
|
||||||
|
| Input-Send mode | `GET` / `PUT /api/input-send-mode` or CLI `input-send-mode` |
|
||||||
|
| Tap notify S/D/T | `PUT /api/clients/{id}/tap-notify` |
|
||||||
|
| Tap receive (UI) | Live stream + tap notify; see WebSocket doc for external API |
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
# WebSocket API
|
||||||
|
|
||||||
|
External API: `ws://localhost:8081/ws` (default `-api-addr`, disable with empty string).
|
||||||
|
|
||||||
|
Start with `go run . serve` (optional `-port` / `-ble` to pre-connect). Host can be opened later via `host_connect` / dashboard REST.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Connection flow
|
||||||
|
|
||||||
|
1. Connect → server sends `hello` (host status, push off; defaults and command list).
|
||||||
|
2. Optionally `ble_scan` / `list_serial_ports` then `host_connect` if not already linked.
|
||||||
|
3. Send JSON commands → one reply per message (`*_status`, `client_list`, `ble_scan_result`, …).
|
||||||
|
4. After `set_stream` with `enable: true`, server may push `input` messages without a prior command.
|
||||||
|
|
||||||
|
Commands and pushes share one socket — always branch on `type`.
|
||||||
|
|
||||||
|
On disconnect, `set_stream` state for that socket is dropped. Firmware settings (`set_input_stream`, `set_tap_notify`) stay on the master until changed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Two layers (firmware vs host)
|
||||||
|
|
||||||
|
|
||||||
|
| Layer | Commands | Effect |
|
||||||
|
| ------------------ | ------------------------------------ | ------------------------------------------------------------------ |
|
||||||
|
| Firmware (ESP-NOW) | `set_input_stream`, `set_tap_notify` | Per `client_id`: slave sends accel and/or tap events to the master |
|
||||||
|
| This connection | `set_stream` | Whether you receive push JSON on this socket |
|
||||||
|
|
||||||
|
|
||||||
|
Host polling / BLE ticks run only when at least one connection has `receive_input: true` **and** at least one slave streams input or has tap notify enabled. `set_tap_notify` alone does not enable push — you still need `set_stream`.
|
||||||
|
|
||||||
|
On **BLE**, live `input` prefers firmware `BLE_STREAM_TICK` pushes; otherwise falls back to `CACHE_STATUS` like UART.
|
||||||
|
|
||||||
|
### Push timing (per connection)
|
||||||
|
|
||||||
|
|
||||||
|
| Field | Where | Meaning |
|
||||||
|
| ------------- | -------------------------------------- | ------------------------------------------------------------ |
|
||||||
|
| `interval_ms` | `hello`, `set_stream`, `stream_status` | Minimum ms between `input` pushes on this socket (1 … 10000) |
|
||||||
|
| `pre_fetch` | `set_stream`, `stream_status` | Ms before each push when the host starts the UART cache read |
|
||||||
|
|
||||||
|
|
||||||
|
Global host poll interval = minimum `interval_ms` among all connections with push enabled.
|
||||||
|
|
||||||
|
Typical sequence:
|
||||||
|
|
||||||
|
1. `list_clients` → slave IDs
|
||||||
|
2. Per slave: `set_input_stream` and/or `set_tap_notify`
|
||||||
|
3. `set_stream` with `"enable": true`
|
||||||
|
4. Read `input` messages; filter by `client_id` in your app (no per-slave filter on the wire)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Push: `input`
|
||||||
|
|
||||||
|
Combines latest accel cache and visible tap state for every slave slot on the master.
|
||||||
|
|
||||||
|
**Success:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "input",
|
||||||
|
"t": 1716900123456789012,
|
||||||
|
"success": true,
|
||||||
|
"clients": [
|
||||||
|
{
|
||||||
|
"client_id": 16,
|
||||||
|
"valid": true,
|
||||||
|
"x": 12,
|
||||||
|
"y": -34,
|
||||||
|
"z": 16384,
|
||||||
|
"accel_age_ms": 8,
|
||||||
|
"tap_kind": "single",
|
||||||
|
"tap_age_ms": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"client_id": 42,
|
||||||
|
"valid": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
| Field | Meaning |
|
||||||
|
| -------------- | -------------------------------------------------------------------- |
|
||||||
|
| `t` | Unix timestamp in nanoseconds when the host read the cache |
|
||||||
|
| `success` | `true` if `CACHE_STATUS` succeeded |
|
||||||
|
| `clients[]` | One entry per slave slot (includes invalid/stale entries) |
|
||||||
|
| `client_id` | Same id as in `list_clients` |
|
||||||
|
| `valid` | `false` if no accel sample yet or stale; omit `x`/`y`/`z` when false |
|
||||||
|
| `x`, `y`, `z` | Raw accelerometer LSB (BMA456, ±2 g) |
|
||||||
|
| `accel_age_ms` | Ms since the master received this accel sample |
|
||||||
|
| `tap_kind` | `"single"`, `"double"`, or `"triple"`; omit when no recent tap |
|
||||||
|
| `tap_age_ms` | Ms since tap in master cache; omit with `tap_kind` |
|
||||||
|
|
||||||
|
|
||||||
|
Tap events stay visible for `tap_display_min_ms` (2000, in `hello`) after the API first saw them.
|
||||||
|
|
||||||
|
**Failure** (no `clients` array):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"type":"input","t":1716900123456789012,"success":false,"error":"uart busy"}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
One JSON object per message; field `type` selects the command.
|
||||||
|
|
||||||
|
**Errors:** Replies use the matching response `type`. On failure: `success: false` (or omitted) and `"error": "…"`. Malformed JSON or unknown `type` → `stream_status` with `error`.
|
||||||
|
|
||||||
|
### `hello` (server → client)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "hello",
|
||||||
|
"serial_port": "/dev/ttyUSB0",
|
||||||
|
"transport": "uart",
|
||||||
|
"endpoint": "/dev/ttyUSB0",
|
||||||
|
"connected": true,
|
||||||
|
"interval_ms": 16,
|
||||||
|
"pre_fetch_ms": 2,
|
||||||
|
"tap_display_min_ms": 2000,
|
||||||
|
"commands": [
|
||||||
|
"get_host", "list_serial_ports", "ble_scan", "host_connect", "host_disconnect",
|
||||||
|
"list_clients",
|
||||||
|
"set_stream", "get_stream",
|
||||||
|
"set_input_stream", "get_input_stream",
|
||||||
|
"set_tap_notify", "get_tap_notify",
|
||||||
|
"set_led_ring", "get_battery"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Host link
|
||||||
|
|
||||||
|
| Client `type` | Reply `type` | Notes |
|
||||||
|
|---------------|--------------|--------|
|
||||||
|
| `get_host` | `host_status` | `transport`, `endpoint`, `connected` |
|
||||||
|
| `list_serial_ports` | `serial_ports` | `{ports:[…]}` |
|
||||||
|
| `ble_scan` | `ble_scan_result` | optional `timeout_s`, `name_filter`; disconnects active BLE first |
|
||||||
|
| `host_connect` | `host_status` | `transport:"uart","port"` or `transport:"ble","address","name?"` |
|
||||||
|
| `host_disconnect` | `host_status` | |
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"type":"host_connect","transport":"ble","address":"AA:BB:CC:DD:EE:FF","name":"Powerpod"}
|
||||||
|
{"type":"host_status","transport":"ble","endpoint":"AA:BB:…","connected":true,"success":true}
|
||||||
|
```
|
||||||
|
|
||||||
|
If the host is not connected, other commands that need the master return their usual error in the status reply.
|
||||||
|
|
||||||
|
### `list_clients`
|
||||||
|
|
||||||
|
Request: `{"type":"list_clients"}`
|
||||||
|
|
||||||
|
Response `client_list`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "client_list",
|
||||||
|
"success": true,
|
||||||
|
"clients": [
|
||||||
|
{
|
||||||
|
"id": 16,
|
||||||
|
"mac": "aa:bb:cc:dd:ee:10",
|
||||||
|
"available": true,
|
||||||
|
"input_stream": false,
|
||||||
|
"tap_notify_single": false,
|
||||||
|
"tap_notify_double": false,
|
||||||
|
"tap_notify_triple": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Also per client: `version`, `used`, `last_ping`, `last_success_ping`.
|
||||||
|
|
||||||
|
### `set_stream` / `get_stream`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"type":"set_stream","enable":true,"interval_ms":32,"pre_fetch":2}
|
||||||
|
{"type":"get_stream"}
|
||||||
|
```
|
||||||
|
|
||||||
|
Response `stream_status`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"type":"stream_status","receive_input":true,"interval_ms":32,"pre_fetch":2,"success":true}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `set_input_stream` / `get_input_stream` (firmware)
|
||||||
|
|
||||||
|
`client_id` required (> 0).
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"type":"set_input_stream","client_id":16,"enable":true}
|
||||||
|
{"type":"get_input_stream","client_id":16}
|
||||||
|
```
|
||||||
|
|
||||||
|
Response `input_stream_status`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"type":"input_stream_status","client_id":16,"enabled":true,"success":true}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `set_tap_notify` / `get_tap_notify` (firmware)
|
||||||
|
|
||||||
|
Set requires `single`, `double_tap`, `triple` per client, or `"all_clients": true` for broadcast.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"type":"set_tap_notify","client_id":16,"single":true,"double_tap":false,"triple":false}
|
||||||
|
{"type":"get_tap_notify","client_id":16}
|
||||||
|
```
|
||||||
|
|
||||||
|
Response `tap_notify_status`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"type":"tap_notify_status","client_id":16,"success":true,"single":true,"double_tap":false,"triple":false}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `set_led_ring`
|
||||||
|
|
||||||
|
```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}
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
| Request `mode` | Notes |
|
||||||
|
| -------------- | --------------------------- |
|
||||||
|
| `clear` | Turn off |
|
||||||
|
| `color` | Full ring RGB + `intensity` |
|
||||||
|
| `progress` | `progress` 0–100 |
|
||||||
|
| `digit` | `digit` 0–10 |
|
||||||
|
| `blink` | `blink_ms`, `blink_count` |
|
||||||
|
| `find-me` | Locate pod |
|
||||||
|
| `battery-low` | UV indicator (4 LEDs, 5 s) |
|
||||||
|
|
||||||
|
|
||||||
|
Target: `client_id` (`0` = master) or `all_clients` (+ optional `slaves_only`).
|
||||||
|
|
||||||
|
Response `led_ring_status` — `mode` is numeric: 0=clear, 1=progress, 2=digit, 3=blink, 4=find-me, 5=color, 6=battery-low.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"type":"led_ring_status","success":true,"mode":5,"client_id":16,"slaves_updated":1}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `get_battery`
|
||||||
|
|
||||||
|
Slaves push battery every 30 s; this reads the master cache. Default: all clients.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"type":"get_battery","all_clients":true}
|
||||||
|
{"type":"get_battery","client_id":16}
|
||||||
|
```
|
||||||
|
|
||||||
|
Response `battery_status`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "battery_status",
|
||||||
|
"success": true,
|
||||||
|
"samples": [
|
||||||
|
{
|
||||||
|
"client_id": 16,
|
||||||
|
"lipo1": {"valid": true, "voltage_mv": 3850, "percent": 71},
|
||||||
|
"lipo2": {"valid": false},
|
||||||
|
"age_ms": 1200
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
@@ -3,11 +3,22 @@ module powerpod/gotool
|
|||||||
go 1.26.2
|
go 1.26.2
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/gorilla/websocket v1.5.3
|
||||||
go.bug.st/serial v1.6.4
|
go.bug.st/serial v1.6.4
|
||||||
google.golang.org/protobuf v1.36.11
|
google.golang.org/protobuf v1.36.11
|
||||||
|
tinygo.org/x/bluetooth v0.13.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/creack/goselect v0.1.2 // indirect
|
github.com/creack/goselect v0.1.2 // indirect
|
||||||
|
github.com/go-ole/go-ole v1.2.6 // indirect
|
||||||
|
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
||||||
|
github.com/saltosystems/winrt-go v0.0.0-20240509164145-4f7860a3bd2b // indirect
|
||||||
|
github.com/sirupsen/logrus v1.9.3 // indirect
|
||||||
|
github.com/soypat/cyw43439 v0.0.0-20250505012923-830110c8f4af // indirect
|
||||||
|
github.com/soypat/seqs v0.0.0-20250124201400-0d65bc7c1710 // indirect
|
||||||
|
github.com/tinygo-org/cbgo v0.0.4 // indirect
|
||||||
|
github.com/tinygo-org/pio v0.2.0 // indirect
|
||||||
|
golang.org/x/exp v0.0.0-20241204233417-43b7b7cde48d // indirect
|
||||||
golang.org/x/sys v0.19.0 // indirect
|
golang.org/x/sys v0.19.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,18 +1,51 @@
|
|||||||
github.com/creack/goselect v0.1.2 h1:2DNy14+JPjRBgPzAd1thbQp4BSIihxcBf0IXhQXDRa0=
|
github.com/creack/goselect v0.1.2 h1:2DNy14+JPjRBgPzAd1thbQp4BSIihxcBf0IXhQXDRa0=
|
||||||
github.com/creack/goselect v0.1.2/go.mod h1:a/NhLweNvqIYMuxcMOuWY516Cimucms3DglDzQP3hKY=
|
github.com/creack/goselect v0.1.2/go.mod h1:a/NhLweNvqIYMuxcMOuWY516Cimucms3DglDzQP3hKY=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
|
||||||
|
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||||
|
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
|
||||||
|
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
|
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
|
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
|
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/saltosystems/winrt-go v0.0.0-20240509164145-4f7860a3bd2b h1:du3zG5fd8snsFN6RBoLA7fpaYV9ZQIsyH9snlk2Zvik=
|
||||||
|
github.com/saltosystems/winrt-go v0.0.0-20240509164145-4f7860a3bd2b/go.mod h1:CIltaIm7qaANUIvzr0Vmz71lmQMAIbGJ7cvgzX7FMfA=
|
||||||
|
github.com/sirupsen/logrus v1.5.0/go.mod h1:+F7Ogzej0PZc/94MaYx/nvG9jOFMD2osvC3s+Squfpo=
|
||||||
|
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||||
|
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||||
|
github.com/soypat/cyw43439 v0.0.0-20250505012923-830110c8f4af h1:ZfFq94aH/BCSWWKd9RPUgdHOdgGKCnfl2VdvU9UksTA=
|
||||||
|
github.com/soypat/cyw43439 v0.0.0-20250505012923-830110c8f4af/go.mod h1:MUaGO5m6X7xrkHrPDmnaxCEcuCCFN/0ZFh9oie+exbU=
|
||||||
|
github.com/soypat/seqs v0.0.0-20250124201400-0d65bc7c1710 h1:Y9fBuiR/urFY/m76+SAZTxk2xAOS2n85f+H1CugajeA=
|
||||||
|
github.com/soypat/seqs v0.0.0-20250124201400-0d65bc7c1710/go.mod h1:oCVCNGCHMKoBj97Zp9znLbQ1nHxpkmOY9X+UAGzOxc8=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||||
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
||||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
|
github.com/tinygo-org/cbgo v0.0.4 h1:3D76CRYbH03Rudi8sEgs/YO0x3JIMdyq8jlQtk/44fU=
|
||||||
|
github.com/tinygo-org/cbgo v0.0.4/go.mod h1:7+HgWIHd4nbAz0ESjGlJ1/v9LDU1Ox8MGzP9mah/fLk=
|
||||||
|
github.com/tinygo-org/pio v0.2.0 h1:vo3xa6xDZ2rVtxrks/KcTZHF3qq4lyWOntvEvl2pOhU=
|
||||||
|
github.com/tinygo-org/pio v0.2.0/go.mod h1:LU7Dw00NJ+N86QkeTGjMLNkYcEYMor6wTDpTCu0EaH8=
|
||||||
go.bug.st/serial v1.6.4 h1:7FmqNPgVp3pu2Jz5PoPtbZ9jJO5gnEnZIvnI1lzve8A=
|
go.bug.st/serial v1.6.4 h1:7FmqNPgVp3pu2Jz5PoPtbZ9jJO5gnEnZIvnI1lzve8A=
|
||||||
go.bug.st/serial v1.6.4/go.mod h1:nofMJxTeNVny/m6+KaafC6vJGj3miwQZ6vW4BZUGJPI=
|
go.bug.st/serial v1.6.4/go.mod h1:nofMJxTeNVny/m6+KaafC6vJGj3miwQZ6vW4BZUGJPI=
|
||||||
|
golang.org/x/exp v0.0.0-20241204233417-43b7b7cde48d h1:0olWaB5pg3+oychR51GUVCEsGkeCU/2JxjBgIo4f3M0=
|
||||||
|
golang.org/x/exp v0.0.0-20241204233417-43b7b7cde48d/go.mod h1:qj5a5QZpwLU2NLQudwIN5koi3beDhSAlJwa67PuM98c=
|
||||||
|
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o=
|
golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o=
|
||||||
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
tinygo.org/x/bluetooth v0.13.0 h1:3pkTMcfqv71HoAxG4DBTm2n+1bm6Nqqz8eoHjSW9+5g=
|
||||||
|
tinygo.org/x/bluetooth v0.13.0/go.mod h1:YnyJRVX09i+wkFeHpXut0b+qHq+T2WwKBRRiF/scANA=
|
||||||
|
|||||||
@@ -0,0 +1,266 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"google.golang.org/protobuf/proto"
|
||||||
|
pb "powerpod/gotool/pb"
|
||||||
|
)
|
||||||
|
|
||||||
|
// hostConn is the UART or BLE host transport used by CLI commands.
|
||||||
|
type hostConn interface {
|
||||||
|
exchangePayload(payload []byte, cmdName string) ([]byte, error)
|
||||||
|
exchange(cmdID byte, cmdName string) ([]byte, error)
|
||||||
|
Close() error
|
||||||
|
}
|
||||||
|
|
||||||
|
// optional stream source (BLE only)
|
||||||
|
type hostStreamConn interface {
|
||||||
|
hostConn
|
||||||
|
// NextStreamTick waits for a BLE_STREAM_TICK payload (type byte + protobuf).
|
||||||
|
NextStreamTick(timeout time.Duration) ([]byte, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func hostGetVersion(h hostConn) (*pb.VersionResponse, error) {
|
||||||
|
payload, err := h.exchange(byte(pb.MessageType_VERSION), "VERSION")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return decodeVersionPayload(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
func hostListClients(h hostConn) ([]*pb.ClientInfo, error) {
|
||||||
|
payload, err := h.exchange(byte(pb.MessageType_CLIENT_INFO), "CLIENT_INFO")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return decodeClientsPayload(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
func hostReadCacheStatus(h hostConn) (*pb.CacheStatusResponse, error) {
|
||||||
|
payload, err := h.exchange(byte(pb.MessageType_CACHE_STATUS), "CACHE_STATUS")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return decodeCacheStatusPayload(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
func hostAccelStream(h hostConn, req *pb.AccelStreamRequest) (*pb.AccelStreamResponse, error) {
|
||||||
|
msg := &pb.UartMessage{
|
||||||
|
Type: pb.MessageType_ACCEL_STREAM,
|
||||||
|
Payload: &pb.UartMessage_AccelStreamRequest{
|
||||||
|
AccelStreamRequest: req,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
body, err := proto.Marshal(msg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("encode: %w", err)
|
||||||
|
}
|
||||||
|
payload := append([]byte{byte(pb.MessageType_ACCEL_STREAM)}, body...)
|
||||||
|
respPayload, err := h.exchangePayload(payload, "ACCEL_STREAM")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var respMsg pb.UartMessage
|
||||||
|
if err := proto.Unmarshal(respPayload[1:], &respMsg); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode: %w", err)
|
||||||
|
}
|
||||||
|
r := respMsg.GetAccelStreamResponse()
|
||||||
|
if r == nil {
|
||||||
|
return nil, fmt.Errorf("missing accel_stream_response")
|
||||||
|
}
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func hostFindMe(h hostConn, clientID uint32) error {
|
||||||
|
msg := &pb.UartMessage{
|
||||||
|
Type: pb.MessageType_FIND_ME,
|
||||||
|
Payload: &pb.UartMessage_EspnowFindMeRequest{
|
||||||
|
EspnowFindMeRequest: &pb.EspNowFindMeRequest{ClientId: clientID},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
body, err := proto.Marshal(msg)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("encode: %w", err)
|
||||||
|
}
|
||||||
|
payload := append([]byte{byte(pb.MessageType_FIND_ME)}, body...)
|
||||||
|
respPayload, err := h.exchangePayload(payload, "FIND_ME")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var respMsg pb.UartMessage
|
||||||
|
if err := proto.Unmarshal(respPayload[1:], &respMsg); err != nil {
|
||||||
|
return fmt.Errorf("decode: %w", err)
|
||||||
|
}
|
||||||
|
r := respMsg.GetEspnowFindMeResponse()
|
||||||
|
if r == nil {
|
||||||
|
return fmt.Errorf("missing espnow_find_me_response")
|
||||||
|
}
|
||||||
|
if !r.GetSuccess() {
|
||||||
|
return fmt.Errorf("find-me rejected (client_id=%d)", r.GetClientId())
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func hostRestart(h hostConn, clientID uint32) error {
|
||||||
|
msg := &pb.UartMessage{
|
||||||
|
Type: pb.MessageType_RESTART,
|
||||||
|
Payload: &pb.UartMessage_RestartRequest{
|
||||||
|
RestartRequest: &pb.RestartRequest{ClientId: clientID},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
body, err := proto.Marshal(msg)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("encode: %w", err)
|
||||||
|
}
|
||||||
|
payload := append([]byte{byte(pb.MessageType_RESTART)}, body...)
|
||||||
|
respPayload, err := h.exchangePayload(payload, "RESTART")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var respMsg pb.UartMessage
|
||||||
|
if err := proto.Unmarshal(respPayload[1:], &respMsg); err != nil {
|
||||||
|
return fmt.Errorf("decode: %w", err)
|
||||||
|
}
|
||||||
|
r := respMsg.GetRestartResponse()
|
||||||
|
if r == nil {
|
||||||
|
return fmt.Errorf("missing restart_response")
|
||||||
|
}
|
||||||
|
if !r.GetSuccess() {
|
||||||
|
return fmt.Errorf("restart rejected (client_id=%d)", r.GetClientId())
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func hostSetLogLevel(h hostConn, write bool, level uint32) (*pb.SetLogLevelResponse, error) {
|
||||||
|
msg := &pb.UartMessage{
|
||||||
|
Type: pb.MessageType_SET_LOG_LEVEL,
|
||||||
|
Payload: &pb.UartMessage_SetLogLevelRequest{
|
||||||
|
SetLogLevelRequest: &pb.SetLogLevelRequest{Write: write, Level: level},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
body, err := proto.Marshal(msg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("encode: %w", err)
|
||||||
|
}
|
||||||
|
payload := append([]byte{byte(pb.MessageType_SET_LOG_LEVEL)}, body...)
|
||||||
|
respPayload, err := h.exchangePayload(payload, "SET_LOG_LEVEL")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var respMsg pb.UartMessage
|
||||||
|
if err := proto.Unmarshal(respPayload[1:], &respMsg); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode: %w", err)
|
||||||
|
}
|
||||||
|
r := respMsg.GetSetLogLevelResponse()
|
||||||
|
if r == nil {
|
||||||
|
return nil, fmt.Errorf("missing set_log_level_response")
|
||||||
|
}
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func hostInputSendMode(h hostConn, req *pb.InputSendModeRequest) (*pb.InputSendModeResponse, error) {
|
||||||
|
msg := &pb.UartMessage{
|
||||||
|
Type: pb.MessageType_INPUT_SEND_MODE,
|
||||||
|
Payload: &pb.UartMessage_InputSendModeRequest{
|
||||||
|
InputSendModeRequest: req,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
body, err := proto.Marshal(msg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("encode: %w", err)
|
||||||
|
}
|
||||||
|
payload := append([]byte{byte(pb.MessageType_INPUT_SEND_MODE)}, body...)
|
||||||
|
respPayload, err := h.exchangePayload(payload, "INPUT_SEND_MODE")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var respMsg pb.UartMessage
|
||||||
|
if err := proto.Unmarshal(respPayload[1:], &respMsg); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode: %w", err)
|
||||||
|
}
|
||||||
|
r := respMsg.GetInputSendModeResponse()
|
||||||
|
if r == nil {
|
||||||
|
return nil, fmt.Errorf("missing input_send_mode_response")
|
||||||
|
}
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func hostLedRing(h hostConn, req *pb.LedRingProgressRequest) (*pb.LedRingProgressResponse, error) {
|
||||||
|
msg := &pb.UartMessage{
|
||||||
|
Type: pb.MessageType_LED_RING,
|
||||||
|
Payload: &pb.UartMessage_LedRingProgressRequest{
|
||||||
|
LedRingProgressRequest: req,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
body, err := proto.Marshal(msg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("encode: %w", err)
|
||||||
|
}
|
||||||
|
payload := append([]byte{byte(pb.MessageType_LED_RING)}, body...)
|
||||||
|
respPayload, err := h.exchangePayload(payload, "LED_RING")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var respMsg pb.UartMessage
|
||||||
|
if err := proto.Unmarshal(respPayload[1:], &respMsg); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode: %w", err)
|
||||||
|
}
|
||||||
|
r := respMsg.GetLedRingProgressResponse()
|
||||||
|
if r == nil {
|
||||||
|
return nil, fmt.Errorf("missing led_ring_progress_response")
|
||||||
|
}
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func hostEchoPing(h hostConn, clientID uint32) (*EchoPingResult, error) {
|
||||||
|
t0 := time.Now()
|
||||||
|
timestampUs := uint64(t0.UnixMicro())
|
||||||
|
req := &pb.EspNowEchoPingRequest{
|
||||||
|
ClientId: clientID,
|
||||||
|
TimestampUs: timestampUs,
|
||||||
|
}
|
||||||
|
msg := &pb.UartMessage{
|
||||||
|
Type: pb.MessageType_ESPNOW_ECHO_PING,
|
||||||
|
Payload: &pb.UartMessage_EspnowEchoPingRequest{
|
||||||
|
EspnowEchoPingRequest: req,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
body, err := proto.Marshal(msg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("encode: %w", err)
|
||||||
|
}
|
||||||
|
payload := append([]byte{byte(pb.MessageType_ESPNOW_ECHO_PING)}, body...)
|
||||||
|
respPayload, err := h.exchangePayload(payload, "ESPNOW_ECHO_PING")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rttMs := float64(time.Since(t0).Microseconds()) / 1000.0
|
||||||
|
|
||||||
|
var respMsg pb.UartMessage
|
||||||
|
if err := proto.Unmarshal(respPayload[1:], &respMsg); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode: %w", err)
|
||||||
|
}
|
||||||
|
r := respMsg.GetEspnowEchoPingResponse()
|
||||||
|
if r == nil {
|
||||||
|
return nil, fmt.Errorf("missing espnow_echo_ping_response")
|
||||||
|
}
|
||||||
|
if !r.GetSuccess() {
|
||||||
|
return &EchoPingResult{
|
||||||
|
Success: false,
|
||||||
|
ClientID: r.GetClientId(),
|
||||||
|
RttMs: rttMs,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
if r.GetTimestampUs() != timestampUs {
|
||||||
|
return nil, fmt.Errorf("timestamp mismatch: sent %d got %d", timestampUs, r.GetTimestampUs())
|
||||||
|
}
|
||||||
|
return &EchoPingResult{
|
||||||
|
Success: true,
|
||||||
|
ClientID: r.GetClientId(),
|
||||||
|
TimestampUs: r.GetTimestampUs(),
|
||||||
|
RttMs: rttMs,
|
||||||
|
EspRttUs: r.GetEspRttUs(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,491 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.bug.st/serial"
|
||||||
|
"tinygo.org/x/bluetooth"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
hostTransportNone = "none"
|
||||||
|
hostTransportUART = "uart"
|
||||||
|
hostTransportBLE = "ble"
|
||||||
|
)
|
||||||
|
|
||||||
|
// errHostBusy is returned when the link is held for OTA.
|
||||||
|
var errHostBusy = errUARTBusy
|
||||||
|
|
||||||
|
// managedHost is a reconnecting UART or BLE host link for serve/CLI.
|
||||||
|
type managedHost struct {
|
||||||
|
quiet bool
|
||||||
|
baud int
|
||||||
|
settle time.Duration
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
transport string // none|uart|ble
|
||||||
|
endpoint string // port path or BLE address string
|
||||||
|
bleName string // last connected BLE local name (optional)
|
||||||
|
sp *serialPort
|
||||||
|
ble *bleLink
|
||||||
|
otaActive bool
|
||||||
|
pausePoll bool // true while BLE latency tests run (pollers yield)
|
||||||
|
uartPort string // remembered UART port for reconnect
|
||||||
|
}
|
||||||
|
|
||||||
|
func newManagedHost(baud int) *managedHost {
|
||||||
|
if baud <= 0 {
|
||||||
|
baud = defaultBaud
|
||||||
|
}
|
||||||
|
return &managedHost{
|
||||||
|
baud: baud,
|
||||||
|
transport: hostTransportNone,
|
||||||
|
settle: time.Second,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// newManagedSerial keeps the old constructor name for call sites during migrate.
|
||||||
|
func newManagedSerial(portName string, baud int) *managedHost {
|
||||||
|
m := newManagedHost(baud)
|
||||||
|
if portName != "" {
|
||||||
|
m.uartPort = portName
|
||||||
|
m.endpoint = portName
|
||||||
|
m.transport = hostTransportUART
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *managedHost) Transport() string {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
if m.transport == "" {
|
||||||
|
return hostTransportNone
|
||||||
|
}
|
||||||
|
return m.transport
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *managedHost) Endpoint() string {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
return m.endpoint
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *managedHost) Close() error {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
return m.disconnectLocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *managedHost) IsConnected() bool {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
switch m.transport {
|
||||||
|
case hostTransportUART:
|
||||||
|
return m.sp != nil
|
||||||
|
case hostTransportBLE:
|
||||||
|
return m.ble != nil
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *managedHost) disconnectLocked() error {
|
||||||
|
var err error
|
||||||
|
if m.sp != nil {
|
||||||
|
err = m.sp.port.Close()
|
||||||
|
m.sp = nil
|
||||||
|
}
|
||||||
|
if m.ble != nil {
|
||||||
|
e := m.ble.Close()
|
||||||
|
if err == nil {
|
||||||
|
err = e
|
||||||
|
}
|
||||||
|
m.ble = nil
|
||||||
|
}
|
||||||
|
m.transport = hostTransportNone
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *managedHost) Disconnect() error {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
ep := m.endpoint
|
||||||
|
err := m.disconnectLocked()
|
||||||
|
if !m.quiet && ep != "" {
|
||||||
|
log.Printf("host disconnected (%s)", ep)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *managedHost) ConnectUART(port string, baud int) error {
|
||||||
|
if port == "" {
|
||||||
|
return fmt.Errorf("UART port required")
|
||||||
|
}
|
||||||
|
if baud <= 0 {
|
||||||
|
baud = m.baud
|
||||||
|
}
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
if m.otaActive {
|
||||||
|
return errHostBusy
|
||||||
|
}
|
||||||
|
_ = m.disconnectLocked()
|
||||||
|
m.baud = baud
|
||||||
|
m.uartPort = port
|
||||||
|
m.endpoint = port
|
||||||
|
m.transport = hostTransportUART
|
||||||
|
if err := m.openUARTLocked(); err != nil {
|
||||||
|
m.transport = hostTransportNone
|
||||||
|
m.endpoint = ""
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *managedHost) ConnectBLE(address string, name string) error {
|
||||||
|
if address == "" && name == "" {
|
||||||
|
return fmt.Errorf("BLE address or name required")
|
||||||
|
}
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
if m.otaActive {
|
||||||
|
return errHostBusy
|
||||||
|
}
|
||||||
|
_ = m.disconnectLocked()
|
||||||
|
|
||||||
|
var link *bleLink
|
||||||
|
var err error
|
||||||
|
settle := m.settle
|
||||||
|
m.mu.Unlock()
|
||||||
|
if address != "" {
|
||||||
|
link, err = openBLEAddress(address, settle)
|
||||||
|
} else {
|
||||||
|
link, err = openBLE(name, settle)
|
||||||
|
}
|
||||||
|
m.mu.Lock()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
link.quiet = m.quiet
|
||||||
|
m.ble = link
|
||||||
|
m.transport = hostTransportBLE
|
||||||
|
if address != "" {
|
||||||
|
m.endpoint = address
|
||||||
|
} else {
|
||||||
|
m.endpoint = name
|
||||||
|
}
|
||||||
|
m.bleName = name
|
||||||
|
if !m.quiet {
|
||||||
|
log.Printf("BLE host connected (%s)", m.endpoint)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *managedHost) openUARTLocked() error {
|
||||||
|
sp, err := openSerial(m.uartPort, m.baud)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
sp.quiet = m.quiet
|
||||||
|
m.sp = sp
|
||||||
|
if !m.quiet {
|
||||||
|
log.Printf("UART %s connected (%d baud)", m.uartPort, m.baud)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *managedHost) openLocked() error {
|
||||||
|
return m.openUARTLocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *managedHost) closeLocked() error {
|
||||||
|
return m.disconnectLocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *managedHost) invalidateLocked(reason error) {
|
||||||
|
if m.sp == nil && m.ble == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !m.quiet {
|
||||||
|
log.Printf("host %s disconnected: %v", m.endpoint, reason)
|
||||||
|
}
|
||||||
|
_ = m.disconnectLocked()
|
||||||
|
// remember transport intent for UART reconnect
|
||||||
|
if m.uartPort != "" && m.transport == hostTransportNone {
|
||||||
|
// after invalidate, leave as none; poller will try Connect again via open
|
||||||
|
m.transport = hostTransportUART
|
||||||
|
m.endpoint = m.uartPort
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *managedHost) withPort(fn func(*serialPort) error) error {
|
||||||
|
return m.withPortLocked(false, fn)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *managedHost) withPortPoll(fn func(*serialPort) error) error {
|
||||||
|
return m.withPortLocked(true, fn)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *managedHost) withPortLocked(poll bool, fn func(*serialPort) error) error {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
if m.otaActive {
|
||||||
|
return errHostBusy
|
||||||
|
}
|
||||||
|
if m.transport == hostTransportBLE {
|
||||||
|
return fmt.Errorf("operation requires UART host (DIP8=0)")
|
||||||
|
}
|
||||||
|
if m.transport != hostTransportUART && m.uartPort == "" {
|
||||||
|
return fmt.Errorf("not connected")
|
||||||
|
}
|
||||||
|
if m.transport == hostTransportNone && m.uartPort != "" {
|
||||||
|
m.transport = hostTransportUART
|
||||||
|
m.endpoint = m.uartPort
|
||||||
|
}
|
||||||
|
if m.sp == nil {
|
||||||
|
if err := m.openUARTLocked(); err != nil {
|
||||||
|
return fmt.Errorf("%s: %w", m.uartPort, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
err := fn(m.sp)
|
||||||
|
if err != nil {
|
||||||
|
m.invalidateLocked(err)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *managedHost) recoverAfterMasterRestart() {
|
||||||
|
const bootWait = 6 * time.Second
|
||||||
|
m.mu.Lock()
|
||||||
|
tr := m.transport
|
||||||
|
m.disconnectLocked()
|
||||||
|
if tr == hostTransportUART {
|
||||||
|
m.transport = hostTransportUART
|
||||||
|
m.endpoint = m.uartPort
|
||||||
|
}
|
||||||
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
log.Printf("host: master restart — waiting %s for boot", bootWait)
|
||||||
|
time.Sleep(bootWait)
|
||||||
|
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
if m.transport == hostTransportUART {
|
||||||
|
if err := m.openUARTLocked(); err != nil {
|
||||||
|
log.Printf("UART reconnect after master restart: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.sp.flushInput()
|
||||||
|
log.Printf("UART %s ready after master restart", m.uartPort)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *managedHost) exchangePayload(payload []byte, cmdName string) ([]byte, error) {
|
||||||
|
return m.exchangePayloadMode(false, payload, cmdName, readTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *managedHost) exchangePayloadPoll(payload []byte, cmdName string) ([]byte, error) {
|
||||||
|
return m.exchangePayloadMode(true, payload, cmdName, readTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *managedHost) beginPausePoll() bool {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
if m.otaActive || m.pausePoll {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
m.pausePoll = true
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *managedHost) endPausePoll() {
|
||||||
|
m.mu.Lock()
|
||||||
|
m.pausePoll = false
|
||||||
|
m.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *managedHost) PollPaused() bool {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
return m.pausePoll || m.otaActive
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *managedHost) exchangePayloadMode(poll bool, payload []byte, cmdName string, timeout time.Duration) ([]byte, error) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
if m.otaActive || (poll && m.pausePoll) {
|
||||||
|
return nil, errHostBusy
|
||||||
|
}
|
||||||
|
switch m.transport {
|
||||||
|
case hostTransportBLE:
|
||||||
|
if m.ble == nil {
|
||||||
|
return nil, fmt.Errorf("BLE not connected")
|
||||||
|
}
|
||||||
|
resp, err := m.ble.exchangePayloadTimeout(payload, cmdName, timeout)
|
||||||
|
if err != nil {
|
||||||
|
m.invalidateLocked(err)
|
||||||
|
}
|
||||||
|
return resp, err
|
||||||
|
case hostTransportUART, hostTransportNone:
|
||||||
|
if m.uartPort == "" && m.sp == nil {
|
||||||
|
return nil, fmt.Errorf("not connected")
|
||||||
|
}
|
||||||
|
if m.transport == hostTransportNone {
|
||||||
|
m.transport = hostTransportUART
|
||||||
|
m.endpoint = m.uartPort
|
||||||
|
}
|
||||||
|
if m.sp == nil {
|
||||||
|
if err := m.openUARTLocked(); err != nil {
|
||||||
|
return nil, fmt.Errorf("%s: %w", m.uartPort, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resp, err := m.sp.exchangePayloadLocked(payload, cmdName, timeout)
|
||||||
|
if err != nil {
|
||||||
|
m.invalidateLocked(err)
|
||||||
|
}
|
||||||
|
return resp, err
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("not connected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *managedHost) exchange(cmdID byte, cmdName string) ([]byte, error) {
|
||||||
|
return m.exchangePayload([]byte{cmdID}, cmdName)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *managedHost) exchangePoll(cmdID byte, cmdName string) ([]byte, error) {
|
||||||
|
return m.exchangePayloadPoll([]byte{cmdID}, cmdName)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *managedHost) exchangePayloadVia(
|
||||||
|
portFn func(func(*serialPort) error) error,
|
||||||
|
payload []byte, cmdName string,
|
||||||
|
) ([]byte, error) {
|
||||||
|
// Prefer transport-aware path (works for BLE too).
|
||||||
|
_ = portFn
|
||||||
|
return m.exchangePayload(payload, cmdName)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *managedHost) exchangeVia(
|
||||||
|
portFn func(func(*serialPort) error) error,
|
||||||
|
cmdID byte, cmdName string,
|
||||||
|
) ([]byte, error) {
|
||||||
|
_ = portFn
|
||||||
|
return m.exchange(cmdID, cmdName)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *managedHost) NextStreamTick(timeout time.Duration) ([]byte, error) {
|
||||||
|
m.mu.Lock()
|
||||||
|
ble := m.ble
|
||||||
|
tr := m.transport
|
||||||
|
m.mu.Unlock()
|
||||||
|
if tr != hostTransportBLE || ble == nil {
|
||||||
|
return nil, fmt.Errorf("BLE stream ticks require BLE host")
|
||||||
|
}
|
||||||
|
return ble.NextStreamTick(timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *managedHost) HostStatus() map[string]any {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
connected := false
|
||||||
|
switch m.transport {
|
||||||
|
case hostTransportUART:
|
||||||
|
connected = m.sp != nil
|
||||||
|
case hostTransportBLE:
|
||||||
|
connected = m.ble != nil
|
||||||
|
}
|
||||||
|
return map[string]any{
|
||||||
|
"transport": m.transportOrNone(),
|
||||||
|
"endpoint": m.endpoint,
|
||||||
|
"connected": connected,
|
||||||
|
"ble_name": m.bleName,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *managedHost) transportOrNone() string {
|
||||||
|
if m.transport == "" {
|
||||||
|
return hostTransportNone
|
||||||
|
}
|
||||||
|
return m.transport
|
||||||
|
}
|
||||||
|
|
||||||
|
func listSerialPorts() ([]string, error) {
|
||||||
|
ports, err := serial.GetPortsList()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if ports == nil {
|
||||||
|
return []string{}, nil
|
||||||
|
}
|
||||||
|
return ports, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type bleScanDevice struct {
|
||||||
|
Address string `json:"address"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
RSSI int `json:"rssi"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanBLEDevices(timeout time.Duration, nameFilter string) ([]bleScanDevice, error) {
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = 5 * time.Second
|
||||||
|
}
|
||||||
|
adapter := bluetooth.DefaultAdapter
|
||||||
|
if err := adapter.Enable(); err != nil {
|
||||||
|
return nil, fmt.Errorf("enable bluetooth: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
seen := map[string]bleScanDevice{}
|
||||||
|
var mu sync.Mutex
|
||||||
|
go func() {
|
||||||
|
time.Sleep(timeout)
|
||||||
|
_ = adapter.StopScan()
|
||||||
|
}()
|
||||||
|
filter := strings.TrimSpace(nameFilter)
|
||||||
|
_ = adapter.Scan(func(a *bluetooth.Adapter, result bluetooth.ScanResult) {
|
||||||
|
name := result.LocalName()
|
||||||
|
if filter != "" && !strings.Contains(strings.ToLower(name), strings.ToLower(filter)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
addr := result.Address.String()
|
||||||
|
mu.Lock()
|
||||||
|
seen[addr] = bleScanDevice{
|
||||||
|
Address: addr,
|
||||||
|
Name: name,
|
||||||
|
RSSI: int(result.RSSI),
|
||||||
|
}
|
||||||
|
mu.Unlock()
|
||||||
|
})
|
||||||
|
// StopScan ends Scan; ignore error and return whatever was found.
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
out := make([]bleScanDevice, 0, len(seen))
|
||||||
|
for _, d := range seen {
|
||||||
|
out = append(out, d)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func disconnectedState(portName string, err error) DashboardState {
|
||||||
|
msg := "host disconnected"
|
||||||
|
if err != nil {
|
||||||
|
msg = err.Error()
|
||||||
|
}
|
||||||
|
return DashboardState{
|
||||||
|
UpdatedAt: time.Now().Format(time.RFC3339),
|
||||||
|
SerialPort: portName,
|
||||||
|
HostEndpoint: portName,
|
||||||
|
UARTConnected: false,
|
||||||
|
HostConnected: false,
|
||||||
|
HostTransport: hostTransportNone,
|
||||||
|
SerialOK: false,
|
||||||
|
SerialError: msg,
|
||||||
|
Master: MasterView{OK: false, Error: msg},
|
||||||
|
Clients: []ClientView{},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"powerpod/gotool/pb"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
ledRingModeClear = 0
|
||||||
|
ledRingModeProgress = 1
|
||||||
|
ledRingModeDigit = 2
|
||||||
|
ledRingModeBlink = 3
|
||||||
|
ledRingModeFindMe = 4
|
||||||
|
ledRingModeColor = 5
|
||||||
|
ledRingModeBatteryLow = 6
|
||||||
|
)
|
||||||
|
|
||||||
|
type ledRingAPIRequest struct {
|
||||||
|
Mode string `json:"mode"`
|
||||||
|
ClientID uint32 `json:"client_id"`
|
||||||
|
AllClients bool `json:"all_clients"`
|
||||||
|
SlavesOnly bool `json:"slaves_only"`
|
||||||
|
Progress uint32 `json:"progress"`
|
||||||
|
Digit uint32 `json:"digit"`
|
||||||
|
R uint32 `json:"r"`
|
||||||
|
G uint32 `json:"g"`
|
||||||
|
B uint32 `json:"b"`
|
||||||
|
Intensity uint32 `json:"intensity"`
|
||||||
|
BlinkMs uint32 `json:"blink_ms"`
|
||||||
|
BlinkCount uint32 `json:"blink_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ledRingAPIResponse struct {
|
||||||
|
Type string `json:"type,omitempty"` // led_ring_status (WebSocket)
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Mode uint32 `json:"mode,omitempty"`
|
||||||
|
Progress uint32 `json:"progress,omitempty"`
|
||||||
|
Digit uint32 `json:"digit,omitempty"`
|
||||||
|
ClientID uint32 `json:"client_id,omitempty"`
|
||||||
|
SlavesUpdated uint32 `json:"slaves_updated,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func ledRingModeFromString(s string) (uint32, error) {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(s)) {
|
||||||
|
case "clear", "":
|
||||||
|
return ledRingModeClear, nil
|
||||||
|
case "color", "solid", "fill":
|
||||||
|
return ledRingModeColor, nil
|
||||||
|
case "progress":
|
||||||
|
return ledRingModeProgress, nil
|
||||||
|
case "digit":
|
||||||
|
return ledRingModeDigit, nil
|
||||||
|
case "blink":
|
||||||
|
return ledRingModeBlink, nil
|
||||||
|
case "find-me", "find_me", "findme":
|
||||||
|
return ledRingModeFindMe, nil
|
||||||
|
case "battery-low", "battery_low", "batterylow":
|
||||||
|
return ledRingModeBatteryLow, nil
|
||||||
|
default:
|
||||||
|
return 0, fmt.Errorf("unknown mode %q (clear, color, progress, digit, blink, find-me, battery-low)", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ledRingPBFromAPI(in ledRingAPIRequest) (*pb.LedRingProgressRequest, error) {
|
||||||
|
mode, err := ledRingModeFromString(in.Mode)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &pb.LedRingProgressRequest{
|
||||||
|
Mode: mode,
|
||||||
|
Progress: in.Progress,
|
||||||
|
Digit: in.Digit,
|
||||||
|
R: in.R,
|
||||||
|
G: in.G,
|
||||||
|
B: in.B,
|
||||||
|
Intensity: in.Intensity,
|
||||||
|
BlinkMs: in.BlinkMs,
|
||||||
|
BlinkCount: in.BlinkCount,
|
||||||
|
ClientId: in.ClientID,
|
||||||
|
AllClients: in.AllClients,
|
||||||
|
SlavesOnly: in.SlavesOnly,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyLedRing(link *managedSerial, in ledRingAPIRequest) ledRingAPIResponse {
|
||||||
|
req, err := ledRingPBFromAPI(in)
|
||||||
|
if err != nil {
|
||||||
|
return ledRingAPIResponse{Error: err.Error()}
|
||||||
|
}
|
||||||
|
resp, err := link.LedRing(req)
|
||||||
|
if err != nil {
|
||||||
|
return ledRingAPIResponse{Error: err.Error()}
|
||||||
|
}
|
||||||
|
out := ledRingAPIResponse{
|
||||||
|
Success: resp.GetSuccess(),
|
||||||
|
Mode: resp.GetMode(),
|
||||||
|
Progress: resp.GetProgress(),
|
||||||
|
Digit: resp.GetDigit(),
|
||||||
|
ClientID: resp.GetClientId(),
|
||||||
|
SlavesUpdated: resp.GetSlavesUpdated(),
|
||||||
|
}
|
||||||
|
if !out.Success && out.Error == "" {
|
||||||
|
out.Error = "led ring command rejected"
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
+239
-8
@@ -5,48 +5,206 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
const defaultBaud = 921600
|
const defaultBaud = 921600
|
||||||
|
|
||||||
func usage() {
|
func usage() {
|
||||||
fmt.Fprintf(os.Stderr, "usage: gotool -port /dev/ttyUSB0 <command>\n\n")
|
fmt.Fprintf(os.Stderr, "usage: gotool [-port /dev/ttyUSB0 | -ble] <command>\n")
|
||||||
|
fmt.Fprintf(os.Stderr, " DIP8 on master: 0=UART (-port), 1=BLE (-ble); reboot after DIP change\n")
|
||||||
|
fmt.Fprintf(os.Stderr, " test uses uart.master from bench config when -port is omitted\n\n")
|
||||||
fmt.Fprintf(os.Stderr, "commands:\n")
|
fmt.Fprintf(os.Stderr, "commands:\n")
|
||||||
fmt.Fprintf(os.Stderr, " version firmware version and git hash\n")
|
fmt.Fprintf(os.Stderr, " version firmware version and git hash\n")
|
||||||
fmt.Fprintf(os.Stderr, " clients registered ESP-NOW slaves on the master\n")
|
fmt.Fprintf(os.Stderr, " clients registered ESP-NOW slaves on the master\n")
|
||||||
|
fmt.Fprintf(os.Stderr, " clients-reset-ids clear master id map + live registry (notifies online slaves)\n")
|
||||||
fmt.Fprintf(os.Stderr, " deadzone get/set accelerometer deadzone (LSB)\n")
|
fmt.Fprintf(os.Stderr, " deadzone get/set accelerometer deadzone (LSB)\n")
|
||||||
fmt.Fprintf(os.Stderr, " unicast-test send ESP-NOW unicast test to one slave\n\n")
|
fmt.Fprintf(os.Stderr, " tap-config get/set BMA456 tap detection parameters\n")
|
||||||
|
fmt.Fprintf(os.Stderr, " tap-notify get/set which tap kinds notify via ESP-NOW\n")
|
||||||
|
fmt.Fprintf(os.Stderr, " cache-status subscribed accel + tap cache (one round-trip)\n")
|
||||||
|
fmt.Fprintf(os.Stderr, " accel-stream enable/disable ESP-NOW accel stream (+ BLE period)\n")
|
||||||
|
fmt.Fprintf(os.Stderr, " unicast-test send ESP-NOW unicast test to one slave\n")
|
||||||
|
fmt.Fprintf(os.Stderr, " echo-ping ESP-NOW timestamp echo round-trip to one slave\n")
|
||||||
|
fmt.Fprintf(os.Stderr, " ble-rtt Host↔Master RTT via VERSION (N samples)\n")
|
||||||
|
fmt.Fprintf(os.Stderr, " ble-stream enable stream + measure BleStreamTick window\n")
|
||||||
|
fmt.Fprintf(os.Stderr, " test run automated scenario (see testdata/)\n")
|
||||||
|
fmt.Fprintf(os.Stderr, " serve web dashboard (optional -port / -ble; connect from UI)\n")
|
||||||
|
fmt.Fprintf(os.Stderr, " ota host OTA upload (A/B partitions; UART or BLE)\n")
|
||||||
|
fmt.Fprintf(os.Stderr, " ota-progress query per-slave ESP-NOW OTA progress on master\n")
|
||||||
|
fmt.Fprintf(os.Stderr, " led-ring set LED ring progress bar (0–100%%, rgb, intensity)\n")
|
||||||
|
fmt.Fprintf(os.Stderr, " find-me blink LED ring red/green/blue (3× each, full brightness)\n")
|
||||||
|
fmt.Fprintf(os.Stderr, " restart reboot master or slave (ESP-NOW)\n")
|
||||||
|
fmt.Fprintf(os.Stderr, " log-level get/set master ESP-IDF log level (global)\n")
|
||||||
|
fmt.Fprintf(os.Stderr, " battery-uv get/set software LiPo UV protection (per pod / all)\n")
|
||||||
|
fmt.Fprintf(os.Stderr, " input-send-mode get/set slave accel/tap ESP-NOW send path\n\n")
|
||||||
flag.PrintDefaults()
|
flag.PrintDefaults()
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
portName := flag.String("port", "", "serial port (e.g. /dev/ttyUSB0)")
|
portName := flag.String("port", "", "serial port (e.g. /dev/ttyUSB0); XOR with -ble")
|
||||||
baud := flag.Int("baud", defaultBaud, "UART baud rate")
|
baud := flag.Int("baud", defaultBaud, "UART baud rate")
|
||||||
|
useBLE := flag.Bool("ble", false, "use BLE host transport (DIP8=1); XOR with -port")
|
||||||
|
bleName := flag.String("ble-name", bleDefaultName, "BLE advertised name")
|
||||||
|
bleSettle := flag.Float64("ble-settle", 1.0, "seconds to settle after BLE connect")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
if *portName == "" || flag.NArg() < 1 {
|
if flag.NArg() < 1 {
|
||||||
usage()
|
usage()
|
||||||
os.Exit(2)
|
os.Exit(2)
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd := flag.Arg(0)
|
cmd := flag.Arg(0)
|
||||||
|
|
||||||
|
var runErr error
|
||||||
|
switch cmd {
|
||||||
|
case "test", "autotest":
|
||||||
|
if *useBLE {
|
||||||
|
runErr = fmt.Errorf("autotest requires -port (UART)")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
runErr = runTest(*portName, *baud, flag.Args()[1:])
|
||||||
|
case "serve", "web", "dashboard":
|
||||||
|
if *useBLE && *portName != "" {
|
||||||
|
fmt.Fprintf(os.Stderr, "use either -port or -ble, not both\n")
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
runErr = runServe(serveOpts{
|
||||||
|
portName: *portName,
|
||||||
|
baud: *baud,
|
||||||
|
useBLE: *useBLE,
|
||||||
|
bleName: *bleName,
|
||||||
|
settle: time.Duration(*bleSettle * float64(time.Second)),
|
||||||
|
}, flag.Args()[1:])
|
||||||
|
case "version", "clients", "client-info", "clients-reset-ids", "clients_reset_ids", "deadzone", "accel-deadzone", "tap-config", "tap_config", "tap-notify", "tap_notify", "cache-status", "cache_status", "accel-stream", "accel_stream", "unicast-test", "unicast_test", "echo-ping", "echo_ping", "ble-rtt", "ble_rtt", "ble-stream", "ble_stream", "led-ring", "led_ring", "find-me", "find_me", "restart", "log-level", "log_level", "input-send-mode", "input_send_mode", "ota", "ota-progress", "ota_progress":
|
||||||
|
if *useBLE && *portName != "" {
|
||||||
|
fmt.Fprintf(os.Stderr, "use either -port or -ble, not both\n")
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
if !*useBLE && *portName == "" {
|
||||||
|
fmt.Fprintf(os.Stderr, "command %q requires -port or -ble\n\n", cmd)
|
||||||
|
usage()
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
var h hostConn
|
||||||
|
if *useBLE {
|
||||||
|
bl, err := openBLE(*bleName, time.Duration(*bleSettle*float64(time.Second)))
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("open BLE: %v", err)
|
||||||
|
}
|
||||||
|
h = bl
|
||||||
|
} else {
|
||||||
sp, err := openSerial(*portName, *baud)
|
sp, err := openSerial(*portName, *baud)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("open serial: %v", err)
|
log.Fatalf("open serial: %v", err)
|
||||||
}
|
}
|
||||||
defer sp.Close()
|
h = sp
|
||||||
|
}
|
||||||
|
registerShutdown(func() { _ = h.Close() })
|
||||||
|
enableShutdownOnInterrupt()
|
||||||
|
defer h.Close()
|
||||||
|
|
||||||
var runErr error
|
|
||||||
switch cmd {
|
switch cmd {
|
||||||
case "version":
|
case "version":
|
||||||
runErr = runVersion(sp)
|
runErr = runVersionHost(h)
|
||||||
case "clients", "client-info":
|
case "clients", "client-info":
|
||||||
runErr = runClients(sp)
|
runErr = runClientsHost(h)
|
||||||
|
case "clients-reset-ids", "clients_reset_ids":
|
||||||
|
sp, ok := h.(*serialPort)
|
||||||
|
if !ok {
|
||||||
|
runErr = fmt.Errorf("%s over BLE not wired yet; use -port", cmd)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
runErr = runClientsResetIds(sp)
|
||||||
|
case "cache-status", "cache_status":
|
||||||
|
runErr = runCacheStatusHost(h)
|
||||||
|
case "accel-stream", "accel_stream":
|
||||||
|
runErr = runAccelStreamCmd(h, flag.Args()[1:])
|
||||||
|
case "echo-ping", "echo_ping":
|
||||||
|
runErr = runEchoPingHost(h, flag.Args()[1:])
|
||||||
|
case "ble-rtt", "ble_rtt":
|
||||||
|
runErr = runBleRtt(h, flag.Args()[1:])
|
||||||
|
case "ble-stream", "ble_stream":
|
||||||
|
runErr = runBleStream(h, flag.Args()[1:])
|
||||||
case "deadzone", "accel-deadzone":
|
case "deadzone", "accel-deadzone":
|
||||||
|
sp, ok := h.(*serialPort)
|
||||||
|
if !ok {
|
||||||
|
runErr = fmt.Errorf("%s over BLE not wired yet; use -port", cmd)
|
||||||
|
break
|
||||||
|
}
|
||||||
runErr = runDeadzone(sp, flag.Args()[1:])
|
runErr = runDeadzone(sp, flag.Args()[1:])
|
||||||
|
case "tap-config", "tap_config":
|
||||||
|
sp, ok := h.(*serialPort)
|
||||||
|
if !ok {
|
||||||
|
runErr = fmt.Errorf("%s over BLE not wired yet; use -port", cmd)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
runErr = runTapConfig(sp, flag.Args()[1:])
|
||||||
|
case "battery-uv", "battery_uv":
|
||||||
|
sp, ok := h.(*serialPort)
|
||||||
|
if !ok {
|
||||||
|
runErr = fmt.Errorf("%s over BLE not wired yet; use -port", cmd)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
runErr = runBatteryUv(sp, flag.Args()[1:])
|
||||||
|
case "tap-notify", "tap_notify":
|
||||||
|
sp, ok := h.(*serialPort)
|
||||||
|
if !ok {
|
||||||
|
runErr = fmt.Errorf("%s over BLE not wired yet; use -port", cmd)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
runErr = runTapNotify(sp, flag.Args()[1:])
|
||||||
case "unicast-test", "unicast_test":
|
case "unicast-test", "unicast_test":
|
||||||
|
sp, ok := h.(*serialPort)
|
||||||
|
if !ok {
|
||||||
|
runErr = fmt.Errorf("%s over BLE not wired yet; use -port", cmd)
|
||||||
|
break
|
||||||
|
}
|
||||||
runErr = runUnicastTest(sp, flag.Args()[1:])
|
runErr = runUnicastTest(sp, flag.Args()[1:])
|
||||||
|
case "led-ring", "led_ring":
|
||||||
|
sp, ok := h.(*serialPort)
|
||||||
|
if !ok {
|
||||||
|
runErr = fmt.Errorf("%s over BLE not wired yet; use -port", cmd)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
runErr = runLedRing(sp, flag.Args()[1:])
|
||||||
|
case "find-me", "find_me":
|
||||||
|
sp, ok := h.(*serialPort)
|
||||||
|
if !ok {
|
||||||
|
runErr = fmt.Errorf("%s over BLE not wired yet; use -port", cmd)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
runErr = runFindMe(sp, flag.Args()[1:])
|
||||||
|
case "restart":
|
||||||
|
sp, ok := h.(*serialPort)
|
||||||
|
if !ok {
|
||||||
|
runErr = fmt.Errorf("%s over BLE not wired yet; use -port", cmd)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
runErr = runRestart(sp, flag.Args()[1:])
|
||||||
|
case "log-level", "log_level":
|
||||||
|
sp, ok := h.(*serialPort)
|
||||||
|
if !ok {
|
||||||
|
runErr = fmt.Errorf("%s over BLE not wired yet; use -port", cmd)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
runErr = runLogLevel(sp, flag.Args()[1:])
|
||||||
|
case "input-send-mode", "input_send_mode":
|
||||||
|
runErr = runInputSendModeCmd(h, flag.Args()[1:])
|
||||||
|
case "ota":
|
||||||
|
t, ok := h.(otaTransport)
|
||||||
|
if !ok {
|
||||||
|
runErr = fmt.Errorf("ota: unsupported host transport")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
runErr = runOTA(t, flag.Args()[1:])
|
||||||
|
case "ota-progress", "ota_progress":
|
||||||
|
t, ok := h.(otaTransport)
|
||||||
|
if !ok {
|
||||||
|
runErr = fmt.Errorf("ota-progress: unsupported host transport")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
runErr = runOtaProgress(t, flag.Args()[1:])
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
fmt.Fprintf(os.Stderr, "unknown command %q\n\n", cmd)
|
fmt.Fprintf(os.Stderr, "unknown command %q\n\n", cmd)
|
||||||
usage()
|
usage()
|
||||||
@@ -57,3 +215,76 @@ func main() {
|
|||||||
log.Fatal(runErr)
|
log.Fatal(runErr)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func runVersionHost(h hostConn) error {
|
||||||
|
ver, err := hostGetVersion(h)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Printf("version: %d\n", ver.GetVersion())
|
||||||
|
fmt.Printf("git_hash: %s\n", ver.GetGitHash())
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runClientsHost(h hostConn) error {
|
||||||
|
clients, err := hostListClients(h)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(clients) == 0 {
|
||||||
|
fmt.Println("no clients registered")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
fmt.Printf("clients (%d):\n", len(clients))
|
||||||
|
for i, c := range clients {
|
||||||
|
fmt.Printf(" [%d] id=%d ver=%d available=%v used=%v\n",
|
||||||
|
i, c.GetId(), c.GetVersion(), c.GetAvailable(), c.GetUsed())
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runCacheStatusHost(h hostConn) error {
|
||||||
|
r, err := hostReadCacheStatus(h)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
clients := r.GetClients()
|
||||||
|
if len(clients) == 0 {
|
||||||
|
fmt.Println("(no slaves with accel stream or tap notify enabled)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for _, c := range clients {
|
||||||
|
id := c.GetClientId()
|
||||||
|
if a := c.GetAccel(); a != nil {
|
||||||
|
if !a.GetValid() {
|
||||||
|
fmt.Printf("client %d accel: no sample yet\n", id)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("client %d accel: x=%d y=%d z=%d (age %d ms)\n",
|
||||||
|
id, a.GetX(), a.GetY(), a.GetZ(), a.GetAgeMs())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if t := c.GetTap(); t != nil {
|
||||||
|
fmt.Printf("client %d tap: %s (age %d ms)\n",
|
||||||
|
id, tapKindLabel(t.GetKind()), t.GetAgeMs())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runEchoPingHost(h hostConn, args []string) error {
|
||||||
|
fs := flag.NewFlagSet("echo-ping", flag.ExitOnError)
|
||||||
|
clientID := fs.Uint("client", 0, "slave client id from `clients`")
|
||||||
|
if err := fs.Parse(args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if *clientID == 0 {
|
||||||
|
return fmt.Errorf("client id required (see `gotool clients`)")
|
||||||
|
}
|
||||||
|
r, err := hostEchoPing(h, uint32(*clientID))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Printf("echo ping: success=%v client_id=%d rtt_ms=%.3f (host↔master↔slave) esp_rtt_us=%d (ESP-NOW only)\n",
|
||||||
|
r.Success, r.ClientID, r.RttMs, r.EspRttUs)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,674 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"google.golang.org/protobuf/proto"
|
||||||
|
|
||||||
|
"powerpod/gotool/pb"
|
||||||
|
uartframe "powerpod/gotool/uart"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
otaHostChunkSize = 200
|
||||||
|
otaFlashBlockSize = 4096
|
||||||
|
otaPrepareTimeout = 120 * time.Second
|
||||||
|
otaDefaultTimeout = 15 * time.Second
|
||||||
|
otaStatusPollTimeout = 3 * time.Second
|
||||||
|
otaDistReadTimeout = 400 * time.Millisecond
|
||||||
|
otaDistQueryInterval = 500 * time.Millisecond
|
||||||
|
otaDistQueryTimeout = 2 * time.Second
|
||||||
|
otaDistEmitMinInterval = 150 * time.Millisecond
|
||||||
|
// Pace host chunks so the master UART RX ring is not overrun (~20 frames/block).
|
||||||
|
otaHostChunkPaceUART = 5 * time.Millisecond
|
||||||
|
// Slightly slower for BLE WriteWithoutResponse flow control.
|
||||||
|
otaHostChunkPaceBLE = 6 * time.Millisecond
|
||||||
|
otaBlockMaxRetries = 3
|
||||||
|
// 200 B OTA_PAYLOAD → ~216 B framed; needs ATT MTU >= 219.
|
||||||
|
otaMinATTMTU = 219
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
otaStPreparing = 1
|
||||||
|
otaStReady = 2
|
||||||
|
otaStBlockAck = 3
|
||||||
|
otaStSuccess = 4
|
||||||
|
otaStFailed = 5
|
||||||
|
otaStDistributing = 6
|
||||||
|
otaDistAggregate = 0
|
||||||
|
otaDistPerSlave = 1
|
||||||
|
otaDistTimeout = 45 * time.Minute
|
||||||
|
)
|
||||||
|
|
||||||
|
// otaTransport is the host IO used by the OTA protocol (UART or BLE).
|
||||||
|
type otaTransport interface {
|
||||||
|
writeMsg(msg *pb.UartMessage) error
|
||||||
|
readFrame(timeout time.Duration) ([]byte, error)
|
||||||
|
flushInput()
|
||||||
|
chunkPace() time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// OtaSlaveDetail is per-slave ESP-NOW OTA state from OTA_SLAVE_PROGRESS.
|
||||||
|
type OtaSlaveDetail struct {
|
||||||
|
BytesWritten uint32 `json:"bytes_written"`
|
||||||
|
TotalBytes uint32 `json:"total_bytes"`
|
||||||
|
Status uint32 `json:"status"`
|
||||||
|
Error uint32 `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OTAProgress is pushed to the dashboard during web uploads.
|
||||||
|
type OTAProgress struct {
|
||||||
|
Type string `json:"type"` // always "ota_progress"
|
||||||
|
Phase string `json:"phase"`
|
||||||
|
Step string `json:"step,omitempty"` // master, slaves
|
||||||
|
Percent int `json:"percent"`
|
||||||
|
MasterPercent int `json:"master_percent,omitempty"`
|
||||||
|
MasterDone bool `json:"master_done,omitempty"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
MasterMessage string `json:"master_message,omitempty"`
|
||||||
|
Bytes uint32 `json:"bytes_written,omitempty"`
|
||||||
|
Slot uint32 `json:"target_slot,omitempty"`
|
||||||
|
Slaves uint32 `json:"slaves,omitempty"`
|
||||||
|
ImageSize uint32 `json:"image_size,omitempty"`
|
||||||
|
SlaveProgress map[uint32]uint32 `json:"slave_progress,omitempty"` // client_id -> bytes
|
||||||
|
SlaveDetails map[uint32]OtaSlaveDetail `json:"slave_details,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type otaProgressFn func(OTAProgress)
|
||||||
|
|
||||||
|
const (
|
||||||
|
otaStepMaster = "master"
|
||||||
|
otaStepSlaves = "slaves"
|
||||||
|
)
|
||||||
|
|
||||||
|
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", "Host wird für OTA vorbereitet…")
|
||||||
|
|
||||||
|
// Hold m.mu for the entire upload so dashboard/API polling cannot interleave.
|
||||||
|
m.mu.Lock()
|
||||||
|
if m.otaActive {
|
||||||
|
m.mu.Unlock()
|
||||||
|
return errOTAInProgress
|
||||||
|
}
|
||||||
|
m.otaActive = true
|
||||||
|
|
||||||
|
var t otaTransport
|
||||||
|
switch m.transport {
|
||||||
|
case hostTransportBLE:
|
||||||
|
if m.ble == nil {
|
||||||
|
m.otaActive = false
|
||||||
|
m.mu.Unlock()
|
||||||
|
err := fmt.Errorf("BLE not connected")
|
||||||
|
push("error", err.Error())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := m.ble.ensureOTAMTU(); err != nil {
|
||||||
|
m.otaActive = false
|
||||||
|
m.mu.Unlock()
|
||||||
|
push("error", err.Error())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
t = m.ble
|
||||||
|
case hostTransportUART, hostTransportNone:
|
||||||
|
if m.sp == nil {
|
||||||
|
if err := m.openLocked(); err != nil {
|
||||||
|
m.otaActive = false
|
||||||
|
m.mu.Unlock()
|
||||||
|
push("error", err.Error())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t = m.sp
|
||||||
|
default:
|
||||||
|
m.otaActive = false
|
||||||
|
m.mu.Unlock()
|
||||||
|
err := fmt.Errorf("not connected")
|
||||||
|
push("error", err.Error())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err := runOTAUnlocked(t, firmware, onProgress)
|
||||||
|
if err != nil {
|
||||||
|
m.invalidateLocked(err)
|
||||||
|
}
|
||||||
|
m.otaActive = false
|
||||||
|
m.mu.Unlock()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func runOTAUnlocked(t otaTransport, firmware []byte, onProgress otaProgressFn) error {
|
||||||
|
if len(firmware) == 0 {
|
||||||
|
return fmt.Errorf("empty firmware")
|
||||||
|
}
|
||||||
|
imageSize := len(firmware)
|
||||||
|
masterPct := 0
|
||||||
|
masterMsg := ""
|
||||||
|
notify := func(phase, step string, percent int, msg string, extra ...OTAProgress) {
|
||||||
|
if onProgress == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p := OTAProgress{
|
||||||
|
Type: "ota_progress", Phase: phase, Step: step,
|
||||||
|
Percent: percent, Message: msg,
|
||||||
|
ImageSize: uint32(imageSize),
|
||||||
|
}
|
||||||
|
if step == otaStepMaster || phase == "preparing" || phase == "ready" || phase == "uploading" {
|
||||||
|
masterPct = percent
|
||||||
|
masterMsg = msg
|
||||||
|
}
|
||||||
|
p.MasterPercent = masterPct
|
||||||
|
p.MasterMessage = masterMsg
|
||||||
|
if step == otaStepSlaves || phase == "distributing" || phase == "done" {
|
||||||
|
p.MasterDone = true
|
||||||
|
}
|
||||||
|
if len(extra) > 0 {
|
||||||
|
e := extra[0]
|
||||||
|
p.Bytes = e.Bytes
|
||||||
|
p.Slot = e.Slot
|
||||||
|
p.Slaves = e.Slaves
|
||||||
|
p.SlaveProgress = e.SlaveProgress
|
||||||
|
p.SlaveDetails = e.SlaveDetails
|
||||||
|
if e.MasterPercent > 0 {
|
||||||
|
p.MasterPercent = e.MasterPercent
|
||||||
|
}
|
||||||
|
if e.MasterMessage != "" {
|
||||||
|
p.MasterMessage = e.MasterMessage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onProgress(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
notify("preparing", otaStepMaster, 0, fmt.Sprintf("Master: OTA start (%d bytes)…", imageSize))
|
||||||
|
|
||||||
|
t.flushInput()
|
||||||
|
|
||||||
|
if err := t.writeMsg(&pb.UartMessage{
|
||||||
|
Type: pb.MessageType_OTA_START,
|
||||||
|
Payload: &pb.UartMessage_OtaStart{
|
||||||
|
OtaStart: &pb.OtaStartPayload{TotalSize: uint32(imageSize)},
|
||||||
|
},
|
||||||
|
}); err != nil {
|
||||||
|
notify("error", "", 0, err.Error())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
ready, err := waitOtaStatus(t, otaStReady, otaPrepareTimeout, func(msg string) {
|
||||||
|
notify("preparing", otaStepMaster, 2, msg)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
notify("error", "", 0, err.Error())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
notify("ready", otaStepMaster, 5, fmt.Sprintf("Master: Slot %d bereit", ready.GetTargetSlot()))
|
||||||
|
|
||||||
|
pace := t.chunkPace()
|
||||||
|
var seq uint32
|
||||||
|
for offset := 0; offset < imageSize; {
|
||||||
|
blockStart := offset
|
||||||
|
blockStartSeq := seq
|
||||||
|
|
||||||
|
sendBlock := func() (fullBlock bool, err error) {
|
||||||
|
bytesInBlock := 0
|
||||||
|
for bytesInBlock < otaFlashBlockSize && offset < imageSize {
|
||||||
|
n := otaHostChunkSize
|
||||||
|
room := otaFlashBlockSize - bytesInBlock
|
||||||
|
if n > room {
|
||||||
|
n = room
|
||||||
|
}
|
||||||
|
if offset+n > imageSize {
|
||||||
|
n = imageSize - offset
|
||||||
|
}
|
||||||
|
chunk := firmware[offset : offset+n]
|
||||||
|
|
||||||
|
if err := t.writeMsg(&pb.UartMessage{
|
||||||
|
Type: pb.MessageType_OTA_PAYLOAD,
|
||||||
|
Payload: &pb.UartMessage_OtaPayload{
|
||||||
|
OtaPayload: &pb.OtaPayload{Seq: seq, Data: chunk},
|
||||||
|
},
|
||||||
|
}); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
time.Sleep(pace)
|
||||||
|
seq++
|
||||||
|
offset += n
|
||||||
|
bytesInBlock += n
|
||||||
|
|
||||||
|
pct := offset * 100 / imageSize
|
||||||
|
if pct > 99 {
|
||||||
|
pct = 99
|
||||||
|
}
|
||||||
|
notify("uploading", otaStepMaster, pct,
|
||||||
|
fmt.Sprintf("Master: %d / %d bytes", offset, imageSize))
|
||||||
|
}
|
||||||
|
return bytesInBlock == otaFlashBlockSize, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
fullBlock, err := sendBlock()
|
||||||
|
if err != nil {
|
||||||
|
notify("error", "", 0, err.Error())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !fullBlock {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var st *pb.OtaStatusPayload
|
||||||
|
var ackErr error
|
||||||
|
for attempt := 0; attempt < otaBlockMaxRetries; attempt++ {
|
||||||
|
if attempt > 0 {
|
||||||
|
offset = blockStart
|
||||||
|
seq = blockStartSeq
|
||||||
|
if _, err := sendBlock(); err != nil {
|
||||||
|
notify("error", "", 0, err.Error())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
st, ackErr = waitOtaStatus(t, otaStBlockAck, otaDefaultTimeout, nil)
|
||||||
|
if ackErr == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ackErr != nil {
|
||||||
|
notify("error", "", 0, ackErr.Error())
|
||||||
|
return ackErr
|
||||||
|
}
|
||||||
|
|
||||||
|
pct := offset * 100 / imageSize
|
||||||
|
if pct > 99 {
|
||||||
|
pct = 99
|
||||||
|
}
|
||||||
|
notify("uploading", otaStepMaster, pct,
|
||||||
|
fmt.Sprintf("Master: Block geschrieben (%d bytes)", st.GetBytesWritten()),
|
||||||
|
OTAProgress{Bytes: st.GetBytesWritten()})
|
||||||
|
}
|
||||||
|
|
||||||
|
masterPct = 100
|
||||||
|
masterMsg = "Master: Upload abgeschlossen"
|
||||||
|
notify("uploading", otaStepMaster, 100, masterMsg)
|
||||||
|
|
||||||
|
if err := t.writeMsg(&pb.UartMessage{
|
||||||
|
Type: pb.MessageType_OTA_END,
|
||||||
|
Payload: &pb.UartMessage_OtaEnd{
|
||||||
|
OtaEnd: &pb.OtaEndPayload{},
|
||||||
|
},
|
||||||
|
}); err != nil {
|
||||||
|
notify("error", "", 0, err.Error())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
slaveBytes := make(map[uint32]uint32)
|
||||||
|
slaveDetails := make(map[uint32]OtaSlaveDetail)
|
||||||
|
|
||||||
|
emitSlaveOTA := func(msg string, aggBytes uint32, slaveCount uint32) {
|
||||||
|
if slaveCount == 0 && len(slaveDetails) > 0 {
|
||||||
|
slaveCount = uint32(len(slaveDetails))
|
||||||
|
}
|
||||||
|
notify("distributing", otaStepSlaves, 0, msg,
|
||||||
|
OTAProgress{
|
||||||
|
Bytes: aggBytes, Slaves: slaveCount,
|
||||||
|
MasterPercent: 100, MasterMessage: masterMsg,
|
||||||
|
SlaveProgress: copySlaveMap(slaveBytes),
|
||||||
|
SlaveDetails: copySlaveDetails(slaveDetails),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onDistStatus := func(st *pb.OtaStatusPayload) {
|
||||||
|
applyDistributingOtaStatus(st, imageSize, slaveBytes, slaveDetails)
|
||||||
|
}
|
||||||
|
|
||||||
|
var lastEmit, lastQuery time.Time
|
||||||
|
slaveDistMessage := func() (msg string, aggBytes, slaveCount uint32) {
|
||||||
|
slaveCount = uint32(len(slaveDetails))
|
||||||
|
for _, d := range slaveDetails {
|
||||||
|
if d.BytesWritten > aggBytes {
|
||||||
|
aggBytes = d.BytesWritten
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if slaveCount == 0 {
|
||||||
|
// Do not claim "skipped" mid-flight — details often arrive after the first query.
|
||||||
|
return "ESP-NOW: warte auf Slave-Fortschritt…", 0, 0
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("ESP-NOW: %d / %d bytes (%d Slaves)",
|
||||||
|
aggBytes, imageSize, slaveCount), aggBytes, slaveCount
|
||||||
|
}
|
||||||
|
|
||||||
|
emitSlaveThrottled := func(force bool) {
|
||||||
|
if !force && time.Since(lastEmit) < otaDistEmitMinInterval {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lastEmit = time.Now()
|
||||||
|
msg, agg, n := slaveDistMessage()
|
||||||
|
emitSlaveOTA(msg, agg, n)
|
||||||
|
}
|
||||||
|
|
||||||
|
querySlaveProgress := func() {
|
||||||
|
if time.Since(lastQuery) < otaDistQueryInterval {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lastQuery = time.Now()
|
||||||
|
prog, err := queryOtaSlaveProgress(t, 0, onDistStatus, otaDistQueryTimeout)
|
||||||
|
if err != nil {
|
||||||
|
if len(slaveDetails) > 0 {
|
||||||
|
emitSlaveThrottled(true)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mergeSlaveProgressResponse(prog, slaveBytes, slaveDetails)
|
||||||
|
emitSlaveThrottled(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
pushSlaveDist := func(st *pb.OtaStatusPayload) {
|
||||||
|
onDistStatus(st)
|
||||||
|
emitSlaveThrottled(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
onWaitTick := func() {
|
||||||
|
querySlaveProgress()
|
||||||
|
}
|
||||||
|
|
||||||
|
lastQuery = time.Time{} // first query immediately when distribution starts
|
||||||
|
querySlaveProgress()
|
||||||
|
st, err := waitOtaComplete(t, otaDistTimeout, pushSlaveDist, onWaitTick, otaDistReadTimeout)
|
||||||
|
if err != nil {
|
||||||
|
notify("error", "", 0, err.Error())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if prog, err := queryOtaSlaveProgress(t, 0, nil, otaDistQueryTimeout); err == nil {
|
||||||
|
mergeSlaveProgressResponse(prog, slaveBytes, slaveDetails)
|
||||||
|
}
|
||||||
|
notify("done", "", 100,
|
||||||
|
fmt.Sprintf("Fertig — %d bytes, Boot-Slot %d. Geräte starten neu auf die neue Firmware.",
|
||||||
|
st.GetBytesWritten(), st.GetTargetSlot()),
|
||||||
|
OTAProgress{
|
||||||
|
Bytes: st.GetBytesWritten(), Slot: st.GetTargetSlot(),
|
||||||
|
MasterPercent: 100, MasterMessage: "Master: OK",
|
||||||
|
SlaveProgress: copySlaveMap(slaveBytes),
|
||||||
|
SlaveDetails: copySlaveDetails(slaveDetails),
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// QueryOtaSlaveProgress queries the master for per-slave ESP-NOW OTA progress.
|
||||||
|
func QueryOtaSlaveProgress(t otaTransport, clientID uint32) (*pb.OtaSlaveProgressResponse, error) {
|
||||||
|
return queryOtaSlaveProgress(t, clientID, nil, otaDefaultTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
func queryOtaSlaveProgress(t otaTransport, clientID uint32,
|
||||||
|
onStatus func(*pb.OtaStatusPayload), queryTimeout time.Duration) (*pb.OtaSlaveProgressResponse, error) {
|
||||||
|
req := &pb.UartMessage{
|
||||||
|
Type: pb.MessageType_OTA_SLAVE_PROGRESS,
|
||||||
|
Payload: &pb.UartMessage_OtaSlaveProgressRequest{
|
||||||
|
OtaSlaveProgressRequest: &pb.OtaSlaveProgressRequest{
|
||||||
|
ClientId: clientID,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := t.writeMsg(req); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if queryTimeout <= 0 {
|
||||||
|
queryTimeout = otaDefaultTimeout
|
||||||
|
}
|
||||||
|
deadline := time.Now().Add(queryTimeout)
|
||||||
|
msg, err := readUartMessageUntil(t, deadline, pb.MessageType_OTA_SLAVE_PROGRESS, onStatus, otaDistReadTimeout)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
r := msg.GetOtaSlaveProgressResponse()
|
||||||
|
if r == nil {
|
||||||
|
return nil, fmt.Errorf("missing ota_slave_progress_response")
|
||||||
|
}
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyDistributingOtaStatus(st *pb.OtaStatusPayload, imageSize int,
|
||||||
|
slaveBytes map[uint32]uint32, details map[uint32]OtaSlaveDetail) {
|
||||||
|
if st == nil || st.GetStatus() != otaStDistributing {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if st.GetError() != otaDistPerSlave {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id := st.GetTargetSlot()
|
||||||
|
bw := st.GetBytesWritten()
|
||||||
|
slaveBytes[id] = bw
|
||||||
|
d := details[id]
|
||||||
|
d.BytesWritten = bw
|
||||||
|
if d.TotalBytes == 0 {
|
||||||
|
d.TotalBytes = uint32(imageSize)
|
||||||
|
}
|
||||||
|
if d.Status == 0 || d.Status == 1 || d.Status == 2 {
|
||||||
|
d.Status = 3
|
||||||
|
}
|
||||||
|
details[id] = d
|
||||||
|
}
|
||||||
|
|
||||||
|
func readUartMessageUntil(t otaTransport, deadline time.Time, want pb.MessageType,
|
||||||
|
onStatus func(*pb.OtaStatusPayload), readChunk time.Duration) (*pb.UartMessage, error) {
|
||||||
|
if readChunk <= 0 {
|
||||||
|
readChunk = otaStatusPollTimeout
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
return nil, fmt.Errorf("timeout waiting for %v", want)
|
||||||
|
}
|
||||||
|
wait := time.Until(deadline)
|
||||||
|
if wait > readChunk {
|
||||||
|
wait = readChunk
|
||||||
|
}
|
||||||
|
payload, err := t.readFrame(wait)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
msg, err := decodeUartPayload(payload)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if msg.GetType() == pb.MessageType_OTA_STATUS {
|
||||||
|
if onStatus != nil {
|
||||||
|
if st := msg.GetOtaStatus(); st != nil {
|
||||||
|
onStatus(st)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if msg.GetType() == want {
|
||||||
|
return msg, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergeSlaveProgressResponse(r *pb.OtaSlaveProgressResponse,
|
||||||
|
bytesOut map[uint32]uint32, detailsOut map[uint32]OtaSlaveDetail) {
|
||||||
|
if r == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, s := range r.GetSlaves() {
|
||||||
|
id := s.GetClientId()
|
||||||
|
bytesOut[id] = s.GetBytesWritten()
|
||||||
|
detailsOut[id] = OtaSlaveDetail{
|
||||||
|
BytesWritten: s.GetBytesWritten(),
|
||||||
|
TotalBytes: s.GetTotalBytes(),
|
||||||
|
Status: s.GetStatus(),
|
||||||
|
Error: s.GetError(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func copySlaveDetails(m map[uint32]OtaSlaveDetail) map[uint32]OtaSlaveDetail {
|
||||||
|
out := make(map[uint32]OtaSlaveDetail, len(m))
|
||||||
|
for k, v := range m {
|
||||||
|
out[k] = v
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func copySlaveMap(m map[uint32]uint32) map[uint32]uint32 {
|
||||||
|
out := make(map[uint32]uint32, len(m))
|
||||||
|
for k, v := range m {
|
||||||
|
out[k] = v
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitOtaComplete(t otaTransport, timeout time.Duration,
|
||||||
|
onDistributing func(*pb.OtaStatusPayload), onInterval func(),
|
||||||
|
readTimeout time.Duration) (*pb.OtaStatusPayload, error) {
|
||||||
|
if readTimeout <= 0 {
|
||||||
|
readTimeout = otaStatusPollTimeout
|
||||||
|
}
|
||||||
|
deadline := time.Now().Add(timeout)
|
||||||
|
for {
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
return nil, fmt.Errorf("timeout waiting for OTA success (slave distribution?)")
|
||||||
|
}
|
||||||
|
readWait := time.Until(deadline)
|
||||||
|
if readWait > readTimeout {
|
||||||
|
readWait = readTimeout
|
||||||
|
}
|
||||||
|
st, err := readOtaStatus(t, readWait)
|
||||||
|
if err != nil {
|
||||||
|
if onInterval != nil {
|
||||||
|
onInterval()
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch st.GetStatus() {
|
||||||
|
case otaStSuccess:
|
||||||
|
return st, nil
|
||||||
|
case otaStFailed:
|
||||||
|
return nil, fmt.Errorf("OTA failed (error=%d)", st.GetError())
|
||||||
|
case otaStDistributing:
|
||||||
|
if onDistributing != nil {
|
||||||
|
onDistributing(st)
|
||||||
|
}
|
||||||
|
if onInterval != nil {
|
||||||
|
onInterval()
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
// ignore other interim statuses
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitOtaStatus(t otaTransport, want uint32, timeout time.Duration, onPreparing func(string)) (*pb.OtaStatusPayload, error) {
|
||||||
|
deadline := time.Now().Add(timeout)
|
||||||
|
for {
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
return nil, fmt.Errorf("timeout waiting for OTA status %d", want)
|
||||||
|
}
|
||||||
|
readWait := time.Until(deadline)
|
||||||
|
if readWait > otaStatusPollTimeout {
|
||||||
|
readWait = otaStatusPollTimeout
|
||||||
|
}
|
||||||
|
payload, err := t.readFrame(readWait)
|
||||||
|
if err != nil {
|
||||||
|
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() {
|
||||||
|
case want:
|
||||||
|
return st, nil
|
||||||
|
case otaStPreparing:
|
||||||
|
if onPreparing != nil {
|
||||||
|
onPreparing("Partition wird vorbereitet (~30s)…")
|
||||||
|
}
|
||||||
|
case otaStFailed:
|
||||||
|
return nil, fmt.Errorf("OTA failed (error=%d)", st.GetError())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func readOtaStatus(t otaTransport, timeout time.Duration) (*pb.OtaStatusPayload, error) {
|
||||||
|
payload, err := t.readFrame(timeout)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read response: %w", err)
|
||||||
|
}
|
||||||
|
msg, err := decodeUartPayload(payload)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if msg.GetType() != pb.MessageType_OTA_STATUS {
|
||||||
|
return nil, fmt.Errorf("unexpected response type %v", msg.GetType())
|
||||||
|
}
|
||||||
|
st := msg.GetOtaStatus()
|
||||||
|
if st == nil {
|
||||||
|
return nil, fmt.Errorf("missing ota_status")
|
||||||
|
}
|
||||||
|
return st, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func encodeUartMessage(msg *pb.UartMessage) ([]byte, error) {
|
||||||
|
body, err := proto.Marshal(msg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
payload := append([]byte{byte(msg.Type)}, body...)
|
||||||
|
return uartframe.EncodeFrame(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeUartPayload(payload []byte) (*pb.UartMessage, error) {
|
||||||
|
if len(payload) == 0 {
|
||||||
|
return nil, fmt.Errorf("empty response")
|
||||||
|
}
|
||||||
|
var msg pb.UartMessage
|
||||||
|
if err := proto.Unmarshal(payload[1:], &msg); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
msg.Type = pb.MessageType(payload[0])
|
||||||
|
return &msg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- UART otaTransport ---
|
||||||
|
|
||||||
|
func (sp *serialPort) writeMsg(msg *pb.UartMessage) error {
|
||||||
|
frame, err := encodeUartMessage(msg)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = sp.port.Write(frame)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sp *serialPort) readFrame(timeout time.Duration) ([]byte, error) {
|
||||||
|
if err := sp.port.SetReadTimeout(timeout); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return uartframe.ReadFrame(sp.port, nil, timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sp *serialPort) flushInput() {
|
||||||
|
if sp == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = 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 (sp *serialPort) chunkPace() time.Duration { return otaHostChunkPaceUART }
|
||||||
+3683
-106
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,12 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "errors"
|
||||||
|
|
||||||
|
// errUARTBusy is returned when the port is held for OTA (poller should not treat as unplug).
|
||||||
|
var errUARTBusy = errors.New("uart busy (OTA in progress)")
|
||||||
|
|
||||||
|
// errOTAInProgress is returned when a second OTA upload is attempted while one is running.
|
||||||
|
var errOTAInProgress = errors.New("OTA upload already in progress")
|
||||||
|
|
||||||
|
// managedSerial is an alias for managedHost (UART-first history).
|
||||||
|
type managedSerial = managedHost
|
||||||
+42
-2
@@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"go.bug.st/serial"
|
"go.bug.st/serial"
|
||||||
@@ -11,8 +12,13 @@ import (
|
|||||||
|
|
||||||
const readTimeout = 3 * time.Second
|
const readTimeout = 3 * time.Second
|
||||||
|
|
||||||
|
// batteryReadTimeout: master may query each slave over ESP-NOW (~400 ms each).
|
||||||
|
const batteryReadTimeout = 12 * time.Second
|
||||||
|
|
||||||
type serialPort struct {
|
type serialPort struct {
|
||||||
port serial.Port
|
port serial.Port
|
||||||
|
mu sync.Mutex
|
||||||
|
quiet bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func openSerial(portName string, baud int) (*serialPort, error) {
|
func openSerial(portName string, baud int) (*serialPort, error) {
|
||||||
@@ -39,6 +45,18 @@ func (s *serialPort) Close() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *serialPort) exchangePayload(payload []byte, cmdName string) ([]byte, error) {
|
func (s *serialPort) exchangePayload(payload []byte, cmdName string) ([]byte, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return s.exchangePayloadLocked(payload, cmdName, readTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *serialPort) exchangePayloadForBattery(payload []byte, cmdName string) ([]byte, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return s.exchangePayloadLocked(payload, cmdName, batteryReadTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *serialPort) exchangePayloadLocked(payload []byte, cmdName string, timeout time.Duration) ([]byte, error) {
|
||||||
if len(payload) == 0 {
|
if len(payload) == 0 {
|
||||||
return nil, fmt.Errorf("empty payload")
|
return nil, fmt.Errorf("empty payload")
|
||||||
}
|
}
|
||||||
@@ -47,17 +65,29 @@ func (s *serialPort) exchangePayload(payload []byte, cmdName string) ([]byte, er
|
|||||||
return nil, fmt.Errorf("encode frame: %w", err)
|
return nil, fmt.Errorf("encode frame: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !s.quiet {
|
||||||
log.Printf("sending %s command (%d bytes): % x", cmdName, len(frame), frame)
|
log.Printf("sending %s command (%d bytes): % x", cmdName, len(frame), frame)
|
||||||
|
}
|
||||||
if _, err := s.port.Write(frame); err != nil {
|
if _, err := s.port.Write(frame); err != nil {
|
||||||
return nil, fmt.Errorf("write: %w", err)
|
return nil, fmt.Errorf("write: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
respPayload, err := uartframe.ReadFrame(s.port, nil)
|
if timeout <= 0 {
|
||||||
|
timeout = readTimeout
|
||||||
|
}
|
||||||
|
if err := s.port.SetReadTimeout(timeout); err != nil {
|
||||||
|
return nil, fmt.Errorf("set read timeout: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = s.port.SetReadTimeout(readTimeout) }()
|
||||||
|
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !s.quiet {
|
||||||
log.Printf("response payload (%d bytes): % x", len(respPayload), respPayload)
|
log.Printf("response payload (%d bytes): % x", len(respPayload), respPayload)
|
||||||
|
}
|
||||||
if len(respPayload) == 0 {
|
if len(respPayload) == 0 {
|
||||||
return nil, fmt.Errorf("empty response payload")
|
return nil, fmt.Errorf("empty response payload")
|
||||||
}
|
}
|
||||||
@@ -65,22 +95,32 @@ func (s *serialPort) exchangePayload(payload []byte, cmdName string) ([]byte, er
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *serialPort) exchange(cmdID byte, cmdName string) ([]byte, error) {
|
func (s *serialPort) exchange(cmdID byte, cmdName string) ([]byte, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return s.exchangeLocked(cmdID, cmdName)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *serialPort) exchangeLocked(cmdID byte, cmdName string) ([]byte, error) {
|
||||||
frame, err := uartframe.EncodeFrame([]byte{cmdID})
|
frame, err := uartframe.EncodeFrame([]byte{cmdID})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("encode frame: %w", err)
|
return nil, fmt.Errorf("encode frame: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !s.quiet {
|
||||||
log.Printf("sending %s command (%d bytes): % x", cmdName, len(frame), frame)
|
log.Printf("sending %s command (%d bytes): % x", cmdName, len(frame), frame)
|
||||||
|
}
|
||||||
if _, err := s.port.Write(frame); err != nil {
|
if _, err := s.port.Write(frame); err != nil {
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !s.quiet {
|
||||||
log.Printf("response payload (%d bytes): % x", len(payload), payload)
|
log.Printf("response payload (%d bytes): % x", len(payload), payload)
|
||||||
|
}
|
||||||
if len(payload) == 0 {
|
if len(payload) == 0 {
|
||||||
return nil, fmt.Errorf("empty response payload")
|
return nil, fmt.Errorf("empty response payload")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "sync"
|
||||||
|
|
||||||
|
// tapNotifyCtl tracks which slaves have tap notify enabled (mirrors firmware / dashboard).
|
||||||
|
type tapNotifyCtl struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
flags map[uint32]tapNotifyFlags
|
||||||
|
}
|
||||||
|
|
||||||
|
type tapNotifyFlags struct {
|
||||||
|
single bool
|
||||||
|
doubleTap bool
|
||||||
|
triple bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTapNotifyCtl() *tapNotifyCtl {
|
||||||
|
return &tapNotifyCtl{flags: make(map[uint32]tapNotifyFlags)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *tapNotifyCtl) Set(clientID uint32, single, doubleTap, triple bool) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
if !single && !doubleTap && !triple {
|
||||||
|
delete(c.flags, clientID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.flags[clientID] = tapNotifyFlags{single: single, doubleTap: doubleTap, triple: triple}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *tapNotifyCtl) Any() bool {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
return len(c.flags) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *tapNotifyCtl) SyncFromClients(clients []ClientView) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
c.flags = make(map[uint32]tapNotifyFlags)
|
||||||
|
for _, cl := range clients {
|
||||||
|
if cl.TapNotifySingle || cl.TapNotifyDouble || cl.TapNotifyTriple {
|
||||||
|
c.flags[cl.ID] = tapNotifyFlags{
|
||||||
|
single: cl.TapNotifySingle,
|
||||||
|
doubleTap: cl.TapNotifyDouble,
|
||||||
|
triple: cl.TapNotifyTriple,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+87
@@ -0,0 +1,87 @@
|
|||||||
|
# goTool autotest fixtures
|
||||||
|
|
||||||
|
## Bench config (`configs/*.json`)
|
||||||
|
|
||||||
|
Describes your hardware bench: network, MACs, and serial ports.
|
||||||
|
|
||||||
|
| Field | Meaning |
|
||||||
|
|-------|---------|
|
||||||
|
| `id` | Config name; referenced by scenarios |
|
||||||
|
| `network` | ESP-NOW network **1–8** (DIP / IO expander on all nodes) |
|
||||||
|
| `master_mac` | Master WiFi STA MAC (reference) |
|
||||||
|
| `uart.baud` | Command UART baud (default **921600**) |
|
||||||
|
| `uart.master` | **gotool** port — external UART adapter on master GPIO2/3 (e.g. `/dev/ttyUSB0`) |
|
||||||
|
| `uart.master_console` | Master USB-JTAG/console for **reset** via esptool (e.g. `/dev/ttyACM0`) |
|
||||||
|
| `slaves[].id` | Short name for scenarios (`input.slave`, `expect.slave`) |
|
||||||
|
| `slaves[].mac` | Slave STA MAC (must match `gotool clients`) |
|
||||||
|
| `slaves[].client_id` | Optional; default = last MAC byte |
|
||||||
|
| `slaves[].console` | Slave USB console for **reset** (optional, e.g. `/dev/ttyACM1`) |
|
||||||
|
|
||||||
|
Copy `example-lab.json` → `my-lab.json` and set real paths (`ls /dev/ttyUSB* /dev/ttyACM*`).
|
||||||
|
|
||||||
|
`gotool test` uses `uart.master` when `-port` is omitted. Override with `-port /dev/…`.
|
||||||
|
|
||||||
|
## Scenario (`scenarios/*.json`)
|
||||||
|
|
||||||
|
Ordered steps: UART commands, delays, or esptool reset.
|
||||||
|
|
||||||
|
| Step field | Meaning |
|
||||||
|
|------------|---------|
|
||||||
|
| `delay_ms` | Sleep only (no command) |
|
||||||
|
| `command` | See below |
|
||||||
|
| `input` | Command arguments |
|
||||||
|
| `expect` | Assertions (UART commands only) |
|
||||||
|
|
||||||
|
### Commands
|
||||||
|
|
||||||
|
**version** — `expect`: `version`, `version_min`, `git_hash`
|
||||||
|
|
||||||
|
**clients** — `expect`: `min_clients`, `max_clients`, `client_count`, `slave` / `slaves`, `available`
|
||||||
|
|
||||||
|
**deadzone** — `input`: `write`, `value`/`deadzone`, `slave` or `client`/`client_id`, `all_clients`
|
||||||
|
`expect`: `deadzone`, `success`, `slaves_updated`, `slaves_updated_min`
|
||||||
|
|
||||||
|
**unicast_test** — `input`: `slave` or `client_id`, `seq`
|
||||||
|
`expect`: `success`, `seq`
|
||||||
|
|
||||||
|
**led_ring** — `input`: `mode` (`clear`, `progress`, `digit`, `blink`, `find_me`, `battery_low`), `progress`, `digit`, `r`/`g`/`b`, `intensity`, `blink_ms`, `blink_count`
|
||||||
|
`expect`: `success`, `mode`, `progress`, `digit`
|
||||||
|
|
||||||
|
**find_me** — `input`: `client` / `client_id` or `slave` (`0` = master ring)
|
||||||
|
`expect`: `success`
|
||||||
|
|
||||||
|
**restart** — `input`: `client` / `client_id` or `slave` (prefer slave in tests so UART stays up)
|
||||||
|
`expect`: `success`
|
||||||
|
|
||||||
|
**ota_progress** — query `OTA_SLAVE_PROGRESS` (no firmware upload)
|
||||||
|
`input`: optional `client_id` / `slave`
|
||||||
|
`expect`: `active` (typically `false` when idle)
|
||||||
|
|
||||||
|
**reset** — esptool hard-reset via console port from bench config (no `expect`).
|
||||||
|
`input`: `target` (`master`) or `slave` (`pod-1`), or `all: true`; optional `wait_ms` after each reset (default 2000).
|
||||||
|
|
||||||
|
Example reset steps:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "name": "reset master", "command": "reset", "input": { "target": "master", "wait_ms": 3000 } },
|
||||||
|
{ "name": "reset all", "command": "reset", "input": { "all": true, "wait_ms": 2500 } }
|
||||||
|
```
|
||||||
|
|
||||||
|
Requires `python -m esptool` or `esptool.py` on PATH (ESP-IDF).
|
||||||
|
|
||||||
|
The `smoke` scenario resets every node with a configured `console` port, waits **10 s** for boot and ESP-NOW join, then runs a short UART smoke test.
|
||||||
|
|
||||||
|
The **`uart_cmds`** scenario covers all implemented UART commands **except OTA upload** (`OTA_START` / `OTA_PAYLOAD` / `OTA_END` / `OTA_START_ESPNOW`). It ends with a slave **restart** (master UART stays connected).
|
||||||
|
|
||||||
|
`CLIENT_INPUT` is not tested (not implemented in firmware).
|
||||||
|
|
||||||
|
### Example
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd goTool
|
||||||
|
go run . test -config example-lab -scenario smoke
|
||||||
|
go run . test -config example-lab -scenario uart_cmds
|
||||||
|
go run . test -config my-lab -scenario smoke -port /dev/ttyUSB1
|
||||||
|
go run . test -list-configs
|
||||||
|
go run . test -list-scenarios
|
||||||
|
```
|
||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"id": "example-lab",
|
||||||
|
"description": "Example bench — replace MACs, network, and serial paths",
|
||||||
|
"network": 1,
|
||||||
|
"master_mac": "50:78:7d:18:00:10",
|
||||||
|
"uart": {
|
||||||
|
"baud": 921600,
|
||||||
|
"master": "/dev/ttyUSB0",
|
||||||
|
"master_console": "/dev/ttyACM0"
|
||||||
|
},
|
||||||
|
"slaves": [
|
||||||
|
{
|
||||||
|
"id": "pod-1",
|
||||||
|
"mac": "50:78:7d:18:01:10",
|
||||||
|
"client_id": 1,
|
||||||
|
"console": "/dev/ttyACM1"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Vendored
+59
@@ -0,0 +1,59 @@
|
|||||||
|
{
|
||||||
|
"id": "smoke",
|
||||||
|
"description": "Cold-start smoke: reset all nodes, wait for join, then UART checks",
|
||||||
|
"config": "example-lab",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"name": "reset all nodes",
|
||||||
|
"command": "reset",
|
||||||
|
"input": {
|
||||||
|
"all": true,
|
||||||
|
"wait_ms": 2500
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "boot and ESP-NOW join",
|
||||||
|
"delay_ms": 10000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "master UART up",
|
||||||
|
"command": "version",
|
||||||
|
"expect": {
|
||||||
|
"version_min": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "slave registered",
|
||||||
|
"command": "clients",
|
||||||
|
"expect": {
|
||||||
|
"min_clients": 1,
|
||||||
|
"slave": "pod-1",
|
||||||
|
"available": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "unicast path",
|
||||||
|
"command": "unicast_test",
|
||||||
|
"input": {
|
||||||
|
"slave": "pod-1",
|
||||||
|
"seq": 42
|
||||||
|
},
|
||||||
|
"expect": {
|
||||||
|
"success": true,
|
||||||
|
"seq": 42
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "read local deadzone",
|
||||||
|
"command": "deadzone",
|
||||||
|
"input": {
|
||||||
|
"write": false,
|
||||||
|
"client": 0
|
||||||
|
},
|
||||||
|
"expect": {
|
||||||
|
"success": true,
|
||||||
|
"deadzone": 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+107
@@ -0,0 +1,107 @@
|
|||||||
|
{
|
||||||
|
"id": "uart_cmds",
|
||||||
|
"description": "All implemented UART commands (no OTA upload). Resets bench, joins ESP-NOW, exercises each cmd; restarts slave at end.",
|
||||||
|
"config": "example-lab",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"name": "reset all nodes",
|
||||||
|
"command": "reset",
|
||||||
|
"input": { "all": true, "wait_ms": 2500 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "boot and ESP-NOW join",
|
||||||
|
"delay_ms": 10000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "VERSION",
|
||||||
|
"command": "version",
|
||||||
|
"expect": { "version_min": 1 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "CLIENT_INFO",
|
||||||
|
"command": "clients",
|
||||||
|
"expect": {
|
||||||
|
"min_clients": 1,
|
||||||
|
"slave": "pod-1",
|
||||||
|
"available": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ACCEL_DEADZONE read master",
|
||||||
|
"command": "deadzone",
|
||||||
|
"input": { "write": false, "client": 0 },
|
||||||
|
"expect": { "success": true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ACCEL_DEADZONE write master",
|
||||||
|
"command": "deadzone",
|
||||||
|
"input": { "write": true, "client": 0, "value": 120 },
|
||||||
|
"expect": { "success": true, "deadzone": 120 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ACCEL_DEADZONE read master after write",
|
||||||
|
"command": "deadzone",
|
||||||
|
"input": { "write": false, "client": 0 },
|
||||||
|
"expect": { "success": true, "deadzone": 120 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "LED_RING clear",
|
||||||
|
"command": "led_ring",
|
||||||
|
"input": { "mode": "clear" },
|
||||||
|
"expect": { "success": true, "mode": 0 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "LED_RING progress",
|
||||||
|
"command": "led_ring",
|
||||||
|
"input": { "mode": "progress", "progress": 40, "g": 200 },
|
||||||
|
"expect": { "success": true, "mode": 1, "progress": 40 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "LED_RING digit",
|
||||||
|
"command": "led_ring",
|
||||||
|
"input": { "mode": "digit", "digit": 3, "r": 255 },
|
||||||
|
"expect": { "success": true, "mode": 2, "digit": 3 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "LED_RING blink",
|
||||||
|
"command": "led_ring",
|
||||||
|
"input": { "mode": "blink", "r": 0, "g": 255, "b": 0, "blink_count": 1 },
|
||||||
|
"expect": { "success": true, "mode": 3 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ESPNOW_UNICAST_TEST",
|
||||||
|
"command": "unicast_test",
|
||||||
|
"input": { "slave": "pod-1", "seq": 99 },
|
||||||
|
"expect": { "success": true, "seq": 99 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "FIND_ME master",
|
||||||
|
"command": "find_me",
|
||||||
|
"input": { "client": 0 },
|
||||||
|
"expect": { "success": true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "FIND_ME slave",
|
||||||
|
"command": "find_me",
|
||||||
|
"input": { "slave": "pod-1" },
|
||||||
|
"expect": { "success": true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ACCEL_DEADZONE push to slave",
|
||||||
|
"command": "deadzone",
|
||||||
|
"input": { "write": true, "slave": "pod-1", "value": 110 },
|
||||||
|
"expect": { "success": true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "OTA_SLAVE_PROGRESS idle",
|
||||||
|
"command": "ota_progress",
|
||||||
|
"expect": { "active": false }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "RESTART slave (last)",
|
||||||
|
"command": "restart",
|
||||||
|
"input": { "slave": "pod-1" },
|
||||||
|
"expect": { "success": true }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+32
-11
@@ -4,12 +4,16 @@ 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.
|
||||||
|
MaxPayload = 1024
|
||||||
|
// AA | len_lo | len_hi | payload | xor | CC
|
||||||
|
FrameOverhead = 5
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -17,14 +21,15 @@ var (
|
|||||||
ErrTimeout = errors.New("read timeout")
|
ErrTimeout = errors.New("read timeout")
|
||||||
)
|
)
|
||||||
|
|
||||||
// EncodeFrame builds a framed packet: 0xAA len payload xor(payload) 0xCC.
|
// EncodeFrame builds a framed packet: 0xAA len_lo len_hi payload xor(payload) 0xCC.
|
||||||
func EncodeFrame(payload []byte) ([]byte, error) {
|
func EncodeFrame(payload []byte) ([]byte, error) {
|
||||||
if len(payload) == 0 || len(payload) > MaxPayload {
|
if len(payload) == 0 || len(payload) > MaxPayload {
|
||||||
return nil, fmt.Errorf("payload length %d out of range 1..%d", len(payload), MaxPayload)
|
return nil, fmt.Errorf("payload length %d out of range 1..%d", len(payload), MaxPayload)
|
||||||
}
|
}
|
||||||
|
|
||||||
frame := make([]byte, 0, 4+len(payload))
|
n := len(payload)
|
||||||
frame = append(frame, StartMarker, byte(len(payload)))
|
frame := make([]byte, 0, FrameOverhead+n)
|
||||||
|
frame = append(frame, StartMarker, byte(n&0xff), byte((n>>8)&0xff))
|
||||||
var checksum byte
|
var checksum byte
|
||||||
for _, b := range payload {
|
for _, b := range payload {
|
||||||
frame = append(frame, b)
|
frame = append(frame, b)
|
||||||
@@ -37,6 +42,7 @@ func EncodeFrame(payload []byte) ([]byte, error) {
|
|||||||
type Parser struct {
|
type Parser struct {
|
||||||
state int
|
state int
|
||||||
len int
|
len int
|
||||||
|
lenLo byte
|
||||||
payload []byte
|
payload []byte
|
||||||
index int
|
index int
|
||||||
checksum byte
|
checksum byte
|
||||||
@@ -44,7 +50,8 @@ type Parser struct {
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
stateStart = iota
|
stateStart = iota
|
||||||
stateLen
|
stateLenLo
|
||||||
|
stateLenHi
|
||||||
stateData
|
stateData
|
||||||
stateChecksum
|
stateChecksum
|
||||||
stateStop
|
stateStop
|
||||||
@@ -61,14 +68,19 @@ func (p *Parser) Feed(b byte) (payload []byte, ok bool, err error) {
|
|||||||
if b == StartMarker {
|
if b == StartMarker {
|
||||||
p.index = 0
|
p.index = 0
|
||||||
p.checksum = 0
|
p.checksum = 0
|
||||||
p.state = stateLen
|
p.state = stateLenLo
|
||||||
}
|
}
|
||||||
case stateLen:
|
case stateLenLo:
|
||||||
if b == 0 || b > MaxPayload {
|
p.lenLo = b
|
||||||
|
p.state = stateLenHi
|
||||||
|
case stateLenHi:
|
||||||
|
n := int(p.lenLo) | (int(b) << 8)
|
||||||
|
if n == 0 || n > MaxPayload {
|
||||||
p.state = stateStart
|
p.state = stateStart
|
||||||
} else {
|
} else {
|
||||||
p.len = int(b)
|
p.len = n
|
||||||
p.payload = make([]byte, p.len)
|
p.payload = make([]byte, p.len)
|
||||||
|
p.index = 0
|
||||||
p.state = stateData
|
p.state = stateData
|
||||||
}
|
}
|
||||||
case stateData:
|
case stateData:
|
||||||
@@ -97,13 +109,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, 1024)
|
||||||
}
|
}
|
||||||
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++ {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+40
-5
@@ -12,17 +12,46 @@ idf_component_register(
|
|||||||
"powerpod.c"
|
"powerpod.c"
|
||||||
"led_ring.c"
|
"led_ring.c"
|
||||||
"uart.c"
|
"uart.c"
|
||||||
|
"host_transport.c"
|
||||||
|
"ble_host.c"
|
||||||
|
"ble_stream.c"
|
||||||
"uart_proto.c"
|
"uart_proto.c"
|
||||||
"uart_cmd.c"
|
"uart_cmd.c"
|
||||||
"cmd_handler.c"
|
"cmd/cmd_handler.c"
|
||||||
"cmd_version.c"
|
"cmd/cmd_version.c"
|
||||||
"cmd_client_info.c"
|
"cmd/cmd_client_info.c"
|
||||||
"cmd_accel_deadzone.c"
|
"cmd/cmd_accel_deadzone.c"
|
||||||
"cmd_espnow_unicast_test.c"
|
"cmd/cmd_accel_stream.c"
|
||||||
|
"cmd/cmd_input_send_mode.c"
|
||||||
|
"cmd/cmd_tap_notify.c"
|
||||||
|
"cmd/cmd_tap_config.c"
|
||||||
|
"cmd/cmd_cache_status.c"
|
||||||
|
"cmd/cmd_espnow_unicast_test.c"
|
||||||
|
"cmd/cmd_espnow_echo_ping.c"
|
||||||
|
"cmd/cmd_espnow_find_me.c"
|
||||||
|
"cmd/cmd_restart.c"
|
||||||
|
"cmd/cmd_client_id_map_reset.c"
|
||||||
|
"pod_reboot.c"
|
||||||
|
"cmd/cmd_led_ring.c"
|
||||||
|
"cmd/cmd_battery.c"
|
||||||
|
"cmd/cmd_battery_uv.c"
|
||||||
|
"cmd/cmd_set_log_level.c"
|
||||||
|
"cmd/cmd_ota.c"
|
||||||
|
"cmd/cmd_ota_slave_progress.c"
|
||||||
|
"ota_uart.c"
|
||||||
|
"ota_espnow.c"
|
||||||
|
"ota_session.c"
|
||||||
|
"ota_confirm.c"
|
||||||
"client_registry.c"
|
"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"
|
||||||
|
"battery_uv.c"
|
||||||
|
"pod_settings.c"
|
||||||
"proto/uart_messages.pb.c"
|
"proto/uart_messages.pb.c"
|
||||||
"proto/esp_now_messages.pb.c"
|
"proto/esp_now_messages.pb.c"
|
||||||
"proto/pb_encode.c"
|
"proto/pb_encode.c"
|
||||||
@@ -30,18 +59,24 @@ idf_component_register(
|
|||||||
"proto/pb_common.c"
|
"proto/pb_common.c"
|
||||||
INCLUDE_DIRS
|
INCLUDE_DIRS
|
||||||
"."
|
"."
|
||||||
|
"cmd"
|
||||||
"proto"
|
"proto"
|
||||||
REQUIRES
|
REQUIRES
|
||||||
esp_wifi
|
esp_wifi
|
||||||
esp_netif
|
esp_netif
|
||||||
esp_event
|
esp_event
|
||||||
nvs_flash
|
nvs_flash
|
||||||
|
bt
|
||||||
PRIV_REQUIRES
|
PRIV_REQUIRES
|
||||||
esp_driver_gpio
|
esp_driver_gpio
|
||||||
esp_driver_uart
|
esp_driver_uart
|
||||||
esp_driver_i2c
|
esp_driver_i2c
|
||||||
|
esp_adc
|
||||||
app_update
|
app_update
|
||||||
|
esp_timer
|
||||||
bma456)
|
bma456)
|
||||||
|
|
||||||
target_compile_definitions(${COMPONENT_LIB}
|
target_compile_definitions(${COMPONENT_LIB}
|
||||||
PRIVATE "POWERPOD_GIT_HASH=\"${POWERPOD_GIT_HASH}\"")
|
PRIVATE "POWERPOD_GIT_HASH=\"${POWERPOD_GIT_HASH}\"")
|
||||||
|
# Optional: disable software UV protection
|
||||||
|
# target_compile_definitions(${COMPONENT_LIB} PRIVATE POWERPOD_BATTERY_UV_ENABLE=0)
|
||||||
|
|||||||
+374
-26
@@ -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
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -50,13 +52,57 @@ Pins (`powerpod.h`):
|
|||||||
| UART TX | 3 |
|
| UART TX | 3 |
|
||||||
| UART RX | 2 |
|
| UART RX | 2 |
|
||||||
| LED ring | 7 |
|
| LED ring | 7 |
|
||||||
|
| BMA456 INT | 10 |
|
||||||
|
| Button (Taster) | 12 |
|
||||||
|
| LiPo sense 1 (ADC) | 1 |
|
||||||
|
| LiPo sense 2 (ADC) | 11 |
|
||||||
|
|
||||||
Startup order:
|
> **TODO:** GPIO assignments above are provisional; confirm pinning against the real board before release.
|
||||||
|
|
||||||
1. Read DIP + IO expander → `app_config`
|
Startup order (normal path; UV energy-save skips I2C/BMA456/ESP-NOW/UART/button — see **Software UV protection**):
|
||||||
2. `esp_now_comm_init(&app_config)` — WiFi + ESP-NOW
|
|
||||||
3. `led_ring_init()`
|
1. `pod_settings_init()` — NVS
|
||||||
4. **Master only:** command queue, UART, registered commands (e.g. VERSION)
|
2. LiPo ADC init + optional UV boot check (`battery_uv.c`)
|
||||||
|
3. Read DIP + IO expander → `app_config`
|
||||||
|
4. **I2C bus** — IO expander `0x20`; optional **BMA456H** (`init_bma456`, same bus)
|
||||||
|
5. `esp_now_comm_init(&app_config)` — WiFi + ESP-NOW
|
||||||
|
6. `led_ring_init()`
|
||||||
|
7. LiPo monitor task + button (`board_input.c`)
|
||||||
|
8. **Master only:** command queue, UART, registered commands (e.g. VERSION)
|
||||||
|
|
||||||
|
## BMA456 accelerometer (`bosch456.c`)
|
||||||
|
|
||||||
|
Powerpod uses the Bosch **BMA456H** (hearable) variant, not the generic `bma456w` examples in the vendor tree.
|
||||||
|
|
||||||
|
| Item | Value |
|
||||||
|
|------|--------|
|
||||||
|
| Project wrapper | `main/bosch456.c`, `main/bosch456.h` |
|
||||||
|
| Vendor component | `components/bma456` — only `bma4.c` + `bma456h.c` are linked |
|
||||||
|
| I2C | Shared bus with IO expander (SCL/SDA GPIO 5/6), address **0x18**, **100 kHz** |
|
||||||
|
| Interrupt | **GPIO 10**, active high, tap events (single / double / triple) |
|
||||||
|
| Polling | FreeRTOS task `bma456_poll`, **10 Hz** accel read |
|
||||||
|
|
||||||
|
**Boot:** `init_bma456(bus_handle)` runs on **master and slave** after the IO expander. If the sensor is missing or init fails, firmware logs `BMA456 init skipped` and continues (`bma456_is_ready() == false`).
|
||||||
|
|
||||||
|
**Accel logging:** Samples are printed only when any axis changes by more than the **deadzone** (raw LSB) since the last logged sample (default **100**). This is a **software** filter on top of the sensor; it does not change BMA456 hardware thresholds.
|
||||||
|
|
||||||
|
**Persistence:** The local deadzone is stored in the **`nvs`** partition (namespace `powerpod`, key `accel_dz`) via `pod_settings.c`. Each node (master or slave) keeps its own value across reboot. Loaded at boot after `init_bma456()`; saved when set locally (UART `client_id = 0`, `all_clients` on master, or ESP-NOW deadzone on a slave).
|
||||||
|
|
||||||
|
Tap detection parameters (`bma456_tap_config_t`) use the same pattern with NVS key `tap_cfg` (blob), UART `TAP_CONFIG`, and ESP-NOW `ESPNOW_SET_TAP_CONFIG`.
|
||||||
|
|
||||||
|
**Configuration paths:**
|
||||||
|
|
||||||
|
| Path | Effect |
|
||||||
|
|------|--------|
|
||||||
|
| UART `ACCEL_DEADZONE` with `client_id = 0` | Set + save local deadzone |
|
||||||
|
| ESP-NOW `SET_ACCEL_DEADZONE` | Set + save on the receiving slave |
|
||||||
|
| UART `TAP_CONFIG` with `client_id = 0` | Set + save local tap config |
|
||||||
|
| ESP-NOW `SET_TAP_CONFIG` | Set + save tap config on the receiving slave |
|
||||||
|
| `make gotool-deadzone-set DEADZONE=… CLIENT=0` | Host shortcut for local deadzone |
|
||||||
|
|
||||||
|
**Logs:** `[BMA456] ACC X=… Y=… Z=…` when deadzone exceeded; `[BMA456] tap: single|double|triple` on interrupt.
|
||||||
|
|
||||||
|
Regenerate nanopb only when changing protos; sensor code has no code generation step.
|
||||||
|
|
||||||
## ESP-NOW discovery
|
## ESP-NOW discovery
|
||||||
|
|
||||||
@@ -70,10 +116,38 @@ Schema: `proto/esp_now_messages.proto`. Encode/decode: `esp_now_proto.c`. The ES
|
|||||||
|
|
||||||
| `EspNowMessageType` | Direction | `oneof` payload |
|
| `EspNowMessageType` | Direction | `oneof` payload |
|
||||||
|---------------------|-----------|-----------------|
|
|---------------------|-----------|-----------------|
|
||||||
| `ESPNOW_DISCOVER` | Master → broadcast `FF:FF:FF:FF:FF:FF` | `EspNowDiscover` (`network`) |
|
| `ESPNOW_DISCOVER` | Master → broadcast `FF:FF:FF:FF:FF:FF` | `EspNowDiscover` (`network`, `master_time_us` for accel TX slot sync) |
|
||||||
| `ESPNOW_SLAVE_INFO` | Slave → master | `EspNowSlavePresence` |
|
| `ESPNOW_SLAVE_INFO` | Slave → master | `EspNowSlavePresence` |
|
||||||
| `ESPNOW_HEARTBEAT` | Slave → master | `EspNowSlavePresence` (same fields) |
|
| `ESPNOW_HEARTBEAT` | Slave → master | `EspNowSlavePresence` (same fields) |
|
||||||
| `ESPNOW_SET_ACCEL_DEADZONE` | Master → slave | `EspNowAccelDeadzone` (`deadzone` LSB) |
|
| `ESPNOW_SET_ACCEL_DEADZONE` | Master → slave | `EspNowAccelDeadzone` (`deadzone` LSB) |
|
||||||
|
| `ESPNOW_SET_TAP_CONFIG` | Master → slave | `EspNowTapConfig` (multitap sens/timing/axis + enable S/D/T) |
|
||||||
|
| `ESPNOW_SET_BATTERY_UV` | Master → slave | `EspNowBatteryUv` (runtime UV enable + clear latch when off) |
|
||||||
|
| `ESPNOW_UNICAST_TEST` | Master → slave | `EspNowUnicastTest` (`seq`) |
|
||||||
|
| `ESPNOW_FIND_ME` | Master → slave | `EspNowFindMe` (`client_id` filter) — LED locate sequence |
|
||||||
|
| `ESPNOW_RESTART` | Master → slave | `EspNowRestart` (`client_id` filter) — reboot slave |
|
||||||
|
| `ESPNOW_ACCEL_SAMPLE` | Slave → master | `EspNowAccelSample` (`slave_id`, `x`, `y`, `z` raw LSB) — ~every 10 ms in 1 ms slots `offset_ms=(id-1)%8` via Discover sync; TX path via `INPUT_SEND_MODE` |
|
||||||
|
| `ESPNOW_SET_INPUT_SEND_MODE` | Master → slave | `EspNowInputSendMode` (`mode` 0..3 = best_effort/fast/wait/reliable) |
|
||||||
|
| `ESPNOW_SET_TAP_NOTIFY` | Master → slave | `EspNowTapNotify` (`client_id`, `single`, `double_tap`, `triple`) — which tap kinds to forward |
|
||||||
|
| `ESPNOW_TAP_EVENT` | Slave → master | `EspNowTapEvent` (`client_id`, `kind`) — on BMA456 tap interrupt if notify enabled |
|
||||||
|
| `ESPNOW_BATTERY_REPORT` | Slave → master | `EspNowBatteryReport` (`client_id`, `lipo1/2` mV) — ~every 30 s; cached in `client_registry` |
|
||||||
|
| `ESPNOW_OTA_START` | Master → slave (unicast) | `EspNowOtaStart` (`total_size`) |
|
||||||
|
| `ESPNOW_OTA_PAYLOAD` | Master → slave | `EspNowOtaPayload` (`seq`, up to 200 B `data`) |
|
||||||
|
| `ESPNOW_OTA_END` | Master → slave | `EspNowOtaEnd` |
|
||||||
|
| `ESPNOW_OTA_STATUS` | Slave → master | `EspNowOtaStatus` (same status codes as UART OTA) |
|
||||||
|
|
||||||
|
### ESP-NOW OTA (master → slaves)
|
||||||
|
|
||||||
|
Triggered automatically after a successful UART `OTA_END` on the master (or manually via UART `OTA_START_ESPNOW` if an image is already **staged**). Implementation: `ota_espnow.c`.
|
||||||
|
|
||||||
|
| Step | Master → slave | Slave → master |
|
||||||
|
|------|----------------|----------------|
|
||||||
|
| 1 | `ESPNOW_OTA_START` + `total_size` | `ESPNOW_OTA_STATUS` preparing, then **ready** |
|
||||||
|
| 2 | `ESPNOW_OTA_PAYLOAD` (**≤200 B**, shared `seq`) | **block_ack** after each **4096 B** written to flash |
|
||||||
|
| 3 | `ESPNOW_OTA_END` | **success** or **failed** (+ `bytes_written`) |
|
||||||
|
|
||||||
|
Master reads the staged partition with `esp_partition_read` (same image just written via UART). Only **available** registry slaves are updated. The last transfer block may be **under 4096 B** — no block_ack is waited for that block; slaves flush the remainder on `ESPNOW_OTA_END`.
|
||||||
|
|
||||||
|
Status codes match UART `OtaStatusPayload` (`1`…`5`). After success, master and slaves have the boot partition set — **reboot all nodes** to run the new firmware.
|
||||||
|
|
||||||
`EspNowSlavePresence`: `network`, `mac` (6 bytes), `version`, `slave_id`, `available`, `used`.
|
`EspNowSlavePresence`: `network`, `mac` (6 bytes), `version`, `slave_id`, `available`, `used`.
|
||||||
|
|
||||||
@@ -92,7 +166,7 @@ Hardware: **UART1**, **921600** baud, **TX = GPIO3**, **RX = GPIO2** (adapter TX
|
|||||||
| Field | Value |
|
| Field | Value |
|
||||||
|-------|--------|
|
|-------|--------|
|
||||||
| Start | `0xAA` |
|
| Start | `0xAA` |
|
||||||
| Length | 1 byte, payload size 1–252 |
|
| Length | 2 bytes little-endian, payload size 1–1024 |
|
||||||
| Payload | `length` bytes |
|
| Payload | `length` bytes |
|
||||||
| Checksum | XOR of all payload bytes |
|
| Checksum | XOR of all payload bytes |
|
||||||
| Stop | `0xCC` |
|
| Stop | `0xCC` |
|
||||||
@@ -104,7 +178,7 @@ Hardware: **UART1**, **921600** baud, **TX = GPIO3**, **RX = GPIO2** (adapter TX
|
|||||||
| 0 | Command ID (`MessageType` / `msg_id`) |
|
| 0 | Command ID (`MessageType` / `msg_id`) |
|
||||||
| 1… | Arguments (handler receives bytes after ID only) |
|
| 1… | Arguments (handler receives bytes after ID only) |
|
||||||
|
|
||||||
Example VERSION request: single-byte payload `03` → frame `AA 01 03 03 CC`.
|
Example VERSION request: single-byte payload `03` → frame `AA 01 00 03 03 CC`.
|
||||||
|
|
||||||
Logging:
|
Logging:
|
||||||
|
|
||||||
@@ -113,7 +187,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
|
||||||
@@ -123,7 +197,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);
|
||||||
@@ -144,11 +219,29 @@ Host and master speak nanopb-encoded `UartMessage` inside UART frames (byte 0 =
|
|||||||
|
|
||||||
| ID | Name | Status |
|
| ID | Name | Status |
|
||||||
|----|------|--------|
|
|----|------|--------|
|
||||||
| 3 | `VERSION` | Implemented (`cmd_version.c`) |
|
| 3 | `VERSION` | Implemented (`cmd/cmd_version.c`) |
|
||||||
| 4 | `CLIENT_INFO` | Implemented (`cmd_client_info.c`) — slave list from registry |
|
| 4 | `CLIENT_INFO` | Implemented (`cmd/cmd_client_info.c`) — slave list from registry |
|
||||||
| 5 | `CLIENT_INPUT` | Planned |
|
| 5 | `CLIENT_INPUT` | Planned |
|
||||||
| 6 | `ACCEL_DEADZONE` | Implemented (`cmd_accel_deadzone.c`) — get/set accel filter LSB |
|
| 6 | `ACCEL_DEADZONE` | Implemented (`cmd/cmd_accel_deadzone.c`) — get/set accel filter LSB |
|
||||||
| 16–20 | OTA / ESP-NOW OTA | Planned |
|
| 7 | `ESPNOW_UNICAST_TEST` | Implemented (`cmd/cmd_espnow_unicast_test.c`) |
|
||||||
|
| 8 | `LED_RING` | Implemented (`cmd/cmd_led_ring.c`) — ring progress bar (0–100 %, RGB, intensity) |
|
||||||
|
| 26 | `BATTERY_STATUS` | Implemented (`cmd/cmd_battery.c`) — cached LiPo 1/2 per pod from `client_registry` (UART read, no slave round-trip) |
|
||||||
|
| 16 | `OTA_START` | Implemented (`cmd/cmd_ota.c`) — begin UART OTA on inactive slot |
|
||||||
|
| 17 | `OTA_PAYLOAD` | Implemented — up to 200 B per frame; device buffers 4 KiB |
|
||||||
|
| 18 | `OTA_END` | Implemented — flush, `esp_ota_end`, push image to slaves via ESP-NOW, set boot |
|
||||||
|
| 19 | `OTA_STATUS` | Device → host (prepare/ready/block ACK/success/failed) |
|
||||||
|
| 20 | `OTA_START_ESPNOW` | Implemented — re-distribute staged image to slaves only |
|
||||||
|
| 21 | `OTA_SLAVE_PROGRESS` | Implemented (`cmd/cmd_ota_slave_progress.c`) — query per-slave ESP-NOW OTA progress |
|
||||||
|
| 22 | `FIND_ME` | Implemented (`cmd/cmd_espnow_find_me.c`) — `client_id=0` local ring, `>0` ESP-NOW to slave |
|
||||||
|
| 23 | `RESTART` | Implemented (`cmd/cmd_restart.c`) — `client_id=0` reboot master, `>0` ESP-NOW reboot slave |
|
||||||
|
| 25 | `ACCEL_STREAM` | Implemented — enable/disable slave ESP-NOW accel stream to master |
|
||||||
|
| 35 | `INPUT_SEND_MODE` | Implemented — get/set slave accel/tap ESP-NOW send path (best_effort/fast/wait/reliable) |
|
||||||
|
| 27 | `TAP_NOTIFY` | Implemented (`cmd/cmd_tap_notify.c`) — get/set which tap kinds notify via ESP-NOW |
|
||||||
|
| 29 | `CACHE_STATUS` | Implemented (`cmd/cmd_cache_status.c`) — subscribed accel + tap cache (one UART round-trip) |
|
||||||
|
| 30 | `ESPNOW_ECHO_PING` | Implemented (`cmd/cmd_espnow_echo_ping.c`) — ESP-NOW timestamp echo (latency test) |
|
||||||
|
| 31 | `SET_LOG_LEVEL` | Implemented (`cmd/cmd_set_log_level.c`) — get/set global `esp_log_level_set("*", …)` on master |
|
||||||
|
| 32 | `TAP_CONFIG` | Implemented (`cmd/cmd_tap_config.c`) — get/set BMA456H multitap params (+ NVS) |
|
||||||
|
| 36 | `BATTERY_UV` | Implemented (`cmd/cmd_battery_uv.c`) — get/set software UV enable (+ NVS / ESP-NOW) |
|
||||||
|
|
||||||
Regenerate C code:
|
Regenerate C code:
|
||||||
|
|
||||||
@@ -172,12 +265,64 @@ Build embeds `POWERPOD_GIT_HASH` via `git rev-parse` in `main/CMakeLists.txt`.
|
|||||||
- `type = VERSION`
|
- `type = VERSION`
|
||||||
- `version_response.version` — `POWERPOD_FW_VERSION`
|
- `version_response.version` — `POWERPOD_FW_VERSION`
|
||||||
- `version_response.git_hash` — build git hash string
|
- `version_response.git_hash` — build git hash string
|
||||||
|
- `version_response.running_partition` — active OTA label (`ota_0` / `ota_1`)
|
||||||
|
|
||||||
Encoding: `uart_send_uart_message()` in `uart_proto.c`.
|
Encoding: `uart_send_uart_message()` in `uart_proto.c`.
|
||||||
|
|
||||||
|
At boot, firmware logs the running partition and OTA slot index (A/B).
|
||||||
|
|
||||||
|
### UART OTA (A/B)
|
||||||
|
|
||||||
|
**UART upload is master-only.** Slaves receive the same image afterwards via [ESP-NOW OTA](#esp-now-ota-master--slaves).
|
||||||
|
|
||||||
|
Inactive app partition is selected with `esp_ota_get_next_update_partition()`; `esp_ota_begin` erases it (can take ~30 s — host should wait).
|
||||||
|
|
||||||
|
| Step | Host → master | Master → host |
|
||||||
|
|------|---------------|---------------|
|
||||||
|
| 1 | `OTA_START` + `total_size` | `OTA_STATUS` preparing, then **ready** (+ `target_slot` 0/1) |
|
||||||
|
| 2 | `OTA_PAYLOAD` chunks (**≤200 B**, `seq` optional) | `OTA_STATUS` **block_ack** only after each **4096 B** written to flash |
|
||||||
|
| 3 | `OTA_END` | Stages image, runs ESP-NOW OTA to all available slaves, sets boot partition, then `OTA_STATUS` **success** or **failed** |
|
||||||
|
|
||||||
|
`OTA_END` can take a long time on the wire (slave flash + ESP-NOW); the host should use a generous read timeout.
|
||||||
|
|
||||||
|
During OTA the LED ring shows progress at ~5 % brightness: **blue** while the image is written (UART on master, ESP-NOW on slaves), **green** on the master while it forwards the image to slaves over ESP-NOW. On **success** the ring gives one short **green** blink; on **failure** one **red** blink and ESP-NOW distribution is not started (failed UART upload / `OTA_END` validation).
|
||||||
|
|
||||||
|
`OTA_START_ESPNOW` (type `20`): re-run ESP-NOW distribution from the last staged image without a new UART upload (no-op if nothing staged).
|
||||||
|
|
||||||
|
Implementation: `ota_uart.c` (4 KiB buffer, `esp_ota_write`), `ota_espnow.c`, `cmd/cmd_ota.c`.
|
||||||
|
|
||||||
|
Host upload:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go run . -port /dev/ttyUSB0 ota build/powerpod.bin
|
||||||
|
```
|
||||||
|
|
||||||
|
`OtaStatusPayload.status`: `1` preparing, `2` ready, `3` block_ack, `4` success, `5` failed, `6` distributing (`bytes_written` = progress, `target_slot` = slave count).
|
||||||
|
|
||||||
|
### OTA_SLAVE_PROGRESS command
|
||||||
|
|
||||||
|
**Request:** framed `15` (`0x15`) + optional `ota_slave_progress_request` (`client_id`; `0` = all slaves in the current/last distribution session).
|
||||||
|
|
||||||
|
**Response:** `ota_slave_progress_response`:
|
||||||
|
|
||||||
|
| Field | Meaning |
|
||||||
|
|-------|---------|
|
||||||
|
| `active` | ESP-NOW distribution running |
|
||||||
|
| `total_bytes` | Image size |
|
||||||
|
| `aggregate_bytes` | Overall bytes sent to all slaves |
|
||||||
|
| `slave_count` | Number of slaves in session |
|
||||||
|
| `slaves[]` | Per slave: `client_id`, `bytes_written`, `total_bytes`, `status`, `error` |
|
||||||
|
|
||||||
|
Per-slave `status`: `0` idle, `1` preparing, `2` ready, `3` block_ack/distributing, `4` success, `5` failed.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go run . -port /dev/ttyUSB0 ota-progress
|
||||||
|
go run . -port /dev/ttyUSB0 ota-progress -client 16
|
||||||
|
```
|
||||||
|
|
||||||
### ACCEL_DEADZONE command
|
### ACCEL_DEADZONE command
|
||||||
|
|
||||||
Filters BMA456 logs: a new accel line is emitted only when any axis changes by more than `deadzone` raw LSB since the last reported sample (default **100**).
|
Sets the **software** deadzone used by `bosch456.c` when logging accel (see [BMA456 accelerometer](#bma456-accelerometer-bosch456c)). Default **100** LSB.
|
||||||
|
|
||||||
**Request:** framed `06` + nanopb `UartMessage` with `accel_deadzone_request`:
|
**Request:** framed `06` + nanopb `UartMessage` with `accel_deadzone_request`:
|
||||||
|
|
||||||
@@ -190,6 +335,73 @@ Filters BMA456 logs: a new accel line is emitted only when any axis changes by m
|
|||||||
|
|
||||||
**Response:** `accel_deadzone_response` with applied `deadzone`, `success`, and `slaves_updated` (ESP-NOW count).
|
**Response:** `accel_deadzone_response` with applied `deadzone`, `success`, and `slaves_updated` (ESP-NOW count).
|
||||||
|
|
||||||
|
### TAP_CONFIG command
|
||||||
|
|
||||||
|
Get/set **hardware** BMA456H multitap parameters (`tap_sens_thres`, timing fields, `axis_sel`, enable single/double/triple). Applied via `bma456_set_tap_config()` and persisted in NVS (`tap_cfg`) on each target node.
|
||||||
|
|
||||||
|
**Request:** framed `20` (`0x20`) + `tap_config_request`:
|
||||||
|
|
||||||
|
| Field | Meaning |
|
||||||
|
|-------|---------|
|
||||||
|
| `write` | `false` = read, `true` = write |
|
||||||
|
| `tap_sens_thres` … `enable_triple` | Same fields as `bma456_tap_config_t` |
|
||||||
|
| `client_id` | `0` = local sensor; `>0` = slave id (master) |
|
||||||
|
| `all_clients` | Master: ESP-NOW unicast to every registered slave |
|
||||||
|
|
||||||
|
**Response:** `tap_config_response` with fields echo, `success`, `slaves_updated`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go run . -port /dev/ttyUSB0 tap-config
|
||||||
|
go run . -port /dev/ttyUSB0 tap-config -set -sens 5 -axis 2
|
||||||
|
go run . -port /dev/ttyUSB0 tap-config -client 16 -set -sens 7
|
||||||
|
```
|
||||||
|
|
||||||
|
### TAP_NOTIFY command
|
||||||
|
|
||||||
|
Configure which BMA456 tap kinds a **slave** forwards to the master over ESP-NOW. The slave only sends `ESPNOW_TAP_EVENT` when the matching notify flag is enabled (set locally on the slave via ESP-NOW).
|
||||||
|
|
||||||
|
**Request:** framed `1b` (`0x1b`) + `tap_notify_request`:
|
||||||
|
|
||||||
|
| Field | Meaning |
|
||||||
|
|-------|---------|
|
||||||
|
| `write` | `false` = read, `true` = write |
|
||||||
|
| `single`, `double_tap`, `triple` | Which tap kinds to notify (write) |
|
||||||
|
| `client_id` | Slave id (read/write one slave) |
|
||||||
|
| `all_clients` | Master: ESP-NOW unicast to every registered slave |
|
||||||
|
|
||||||
|
**Response:** `tap_notify_response` (`client_id`, `success`, `slaves_updated`, `single`, `double_tap`, `triple`).
|
||||||
|
|
||||||
|
Notify flags are mirrored in `ClientInfo` (`tap_notify_single/double/triple`) for the dashboard.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go run . -port /dev/ttyUSB0 tap-notify -client 16 -set -single
|
||||||
|
go run . -port /dev/ttyUSB0 tap-notify -client 16
|
||||||
|
```
|
||||||
|
|
||||||
|
### CACHE_STATUS command
|
||||||
|
|
||||||
|
Read **cached** accel and/or tap data on the **master** in one UART round-trip. Slaves send `ESPNOW_ACCEL_SAMPLE` every **8 ms** (best-effort) when streaming; tap events arrive via `ESPNOW_TAP_EVENT` and are held up to **8 ms** (`CLIENT_REGISTRY_TAP_MAX_AGE_MS`). Pending taps are **consumed** on read (like the former `TAP_SNAPSHOT`).
|
||||||
|
|
||||||
|
**Request:** framed `1d` (`0x1d`) only — no body (`CacheStatusRequest` empty).
|
||||||
|
|
||||||
|
**Response:** `cache_status_response.clients[]` — one entry per slave with `accel_stream_enabled` and/or any tap-notify flag:
|
||||||
|
|
||||||
|
| Field | When present |
|
||||||
|
|-------|----------------|
|
||||||
|
| `client_id` | Always (for listed slaves) |
|
||||||
|
| `accel` | Slave has accel stream on (`valid`, `x`/`y`/`z`, `age_ms` when sample fresh) |
|
||||||
|
| `tap` | Tap notify on **and** a pending tap was consumed (`kind`, `age_ms`) |
|
||||||
|
|
||||||
|
Unsubscribed submessages are omitted on the wire (proto3 defaults). The master walks `client_registry` once per request (`cmd/cmd_cache_status.c`).
|
||||||
|
|
||||||
|
Host tools poll this at **~8 ms** when live-stream / WebSocket receive is enabled. Tap events stay visible for **2 s** in the UI/API after first sight.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go run . -port /dev/ttyUSB0 cache-status
|
||||||
|
```
|
||||||
|
|
||||||
|
External API (`serve -api-addr :8081`) uses the same command for WebSocket `accel` / `tap` push.
|
||||||
|
|
||||||
### ESPNOW_UNICAST_TEST command
|
### ESPNOW_UNICAST_TEST command
|
||||||
|
|
||||||
Minimal master→slave ESP-NOW unicast check (no BMA456). Use this before debugging `ACCEL_DEADZONE` unicast.
|
Minimal master→slave ESP-NOW unicast check (no BMA456). Use this before debugging `ACCEL_DEADZONE` unicast.
|
||||||
@@ -200,13 +412,125 @@ Minimal master→slave ESP-NOW unicast check (no BMA456). Use this before debugg
|
|||||||
|
|
||||||
**Firmware logs:** master `unicast TEST to … seq=N`; slave `UNICAST TEST OK from master … seq=N`.
|
**Firmware logs:** master `unicast TEST to … seq=N`; slave `UNICAST TEST OK from master … seq=N`.
|
||||||
|
|
||||||
|
### FIND_ME command
|
||||||
|
|
||||||
|
Locate a pod: the LED ring blinks **3× red, 3× green, 3× blue** at full brightness.
|
||||||
|
|
||||||
|
**Request:** framed `22` + `espnow_find_me_request` (`client_id`: `0` = master only, `>0` = ESP-NOW unicast to that slave).
|
||||||
|
|
||||||
|
**Response:** `espnow_find_me_response` (`success`, `client_id`).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go run . -port /dev/ttyUSB0 find-me
|
||||||
|
go run . -port /dev/ttyUSB0 find-me -client 16
|
||||||
|
```
|
||||||
|
|
||||||
|
### SET_LOG_LEVEL command
|
||||||
|
|
||||||
|
Read or set the **global** ESP-IDF log filter on the master (`esp_log_level_set("*", level)`). Does not affect the host UART protocol (UART1); `esp_log_*` output goes to the **debug console UART0** (USB, 115200).
|
||||||
|
|
||||||
|
**Request:** framed `31` (`0x1f`) + `set_log_level_request` (`write`, `level` 0–5).
|
||||||
|
|
||||||
|
**Response:** `set_log_level_response` (`success`, `level`).
|
||||||
|
|
||||||
|
| `level` | `esp_log_level_t` |
|
||||||
|
|---------|-------------------|
|
||||||
|
| 0 | NONE |
|
||||||
|
| 1 | ERROR |
|
||||||
|
| 2 | WARN |
|
||||||
|
| 3 | INFO |
|
||||||
|
| 4 | DEBUG |
|
||||||
|
| 5 | VERBOSE |
|
||||||
|
|
||||||
|
Boot default follows `CONFIG_LOG_DEFAULT_LEVEL` in `sdkconfig` (not persisted across reboot).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go run . -port /dev/ttyUSB0 log-level
|
||||||
|
go run . -port /dev/ttyUSB0 log-level -set -level 0
|
||||||
|
```
|
||||||
|
|
||||||
|
### RESTART command
|
||||||
|
|
||||||
|
Reboot the master (`client_id=0`) or one slave via ESP-NOW (`client_id` = registry id). The device sends the UART response, then restarts after ~150 ms.
|
||||||
|
|
||||||
|
**Request:** framed `23` + `restart_request`
|
||||||
|
**Response:** `restart_response` (`success`, `client_id`)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go run . -port /dev/ttyUSB0 restart
|
||||||
|
go run . -port /dev/ttyUSB0 restart -client 16
|
||||||
|
```
|
||||||
|
|
||||||
|
### BATTERY_STATUS command
|
||||||
|
|
||||||
|
Read **cached** LiPo ADC values on the **master** (master local + one entry per registered slave). Slaves push `ESPNOW_BATTERY_REPORT` every **30 s**; the master stores them in `client_registry` (`lipo1/2_valid`, `lipo1/2_mv`, `battery_updated_at`). The master refreshes its own pack on the same interval in `master_monitor_task`.
|
||||||
|
|
||||||
|
**Request:** framed `26` + optional `battery_status_request` (`client_id`, `all_clients`).
|
||||||
|
|
||||||
|
**Response:** `battery_status_response` with `samples[]` (`client_id`, `lipo1`, `lipo2`, `age_ms`).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Host / goTool: all_clients returns master (id 0) + slaves from cache
|
||||||
|
```
|
||||||
|
|
||||||
|
### Software UV protection (LiPo)
|
||||||
|
|
||||||
|
When `POWERPOD_BATTERY_UV_ENABLE` is `1` (default in `powerpod.h`, disable via CMake), the firmware can monitor ADC pin voltages on GPIO 1 and 11. Runtime enable is NVS `uv_en` per pod (missing key → **disabled**); UART `BATTERY_UV` / dashboard toggles it on master and slaves.
|
||||||
|
|
||||||
|
| Condition | ADC threshold | Action |
|
||||||
|
|-----------|---------------|--------|
|
||||||
|
| External / absent | every valid channel < **100 mV** | Normal boot (no UV); clears latch if set |
|
||||||
|
| Under-voltage | any valid channel in **100…2299 mV** | NVS latch `uv_latch`, restart into energy-save mode |
|
||||||
|
| Charged | every non-absent valid channel ≥ **3000 mV** | Clear latch, restart into normal boot |
|
||||||
|
|
||||||
|
**Energy-save boot:** LED mode `6` (first 4 LEDs red ~10 % for 5 s), then no WiFi/ESP-NOW, UART, I2C, BMA456, or button — only LiPo ADC polling every 30 s until charged. Disabling UV while latched clears the latch (requires a reachable host — not while already in energy-save).
|
||||||
|
|
||||||
|
**UART:** `BATTERY_UV` (`0x24`) — `client_id=0` master, `>0` one slave, `all_clients` master + all slaves. Disabling also clears `uv_latch`.
|
||||||
|
|
||||||
|
**Test LED pattern from host:** `led-ring -mode battery-low` (works even when UV feature is disabled).
|
||||||
|
|
||||||
|
### LED_RING command
|
||||||
|
|
||||||
|
Control the 95-LED ring from the host. The firmware **does not** animate digits locally; only UART updates the display.
|
||||||
|
|
||||||
|
**Request:** framed `08` + `led_ring_progress_request`:
|
||||||
|
|
||||||
|
| Field | Meaning |
|
||||||
|
|-------|---------|
|
||||||
|
| `mode` | `0` = clear, `1` = progress, `2` = digit (0–10), `3` = blink, `4` = find-me, `5` = solid color (all LEDs), `6` = battery-low (first 4 LEDs red ~10 % for 5 s) |
|
||||||
|
| `progress` | 0–100 (% of ring lit, mode `1`) |
|
||||||
|
| `digit` | 0–10 (mode `2`, segment maps in `led_ring.c`) |
|
||||||
|
| `r`, `g`, `b` | Color 0–255 |
|
||||||
|
| `intensity` | Brightness 0–255 (scaled into RGB; `0` → firmware default ~5 %) |
|
||||||
|
| `blink_ms`, `blink_count` | Pulse length and count (mode `3`; defaults 350 ms, 1) |
|
||||||
|
| `client_id` | `0` = master ring only; `>0` = ESP-NOW unicast to one slave |
|
||||||
|
| `all_clients` | Broadcast to all registered slaves |
|
||||||
|
| `slaves_only` | With `all_clients`: do not change master ring |
|
||||||
|
|
||||||
|
**Response:** `led_ring_progress_response` (`success`, `mode`, `progress`, `digit`, `client_id`, `slaves_updated`).
|
||||||
|
|
||||||
|
Slaves receive the same command via ESP-NOW `ESPNOW_LED_RING` and run it locally.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go run . -port /dev/ttyUSB0 led-ring -mode progress -progress 75 -g 80 -b 255
|
||||||
|
go run . -port /dev/ttyUSB0 led-ring -mode digit -digit 7 -r 255 -g 200
|
||||||
|
go run . -port /dev/ttyUSB0 led-ring -mode clear
|
||||||
|
go run . -port /dev/ttyUSB0 led-ring -mode blink -g 255 -blink-count 2
|
||||||
|
go run . -port /dev/ttyUSB0 find-me
|
||||||
|
go run . -port /dev/ttyUSB0 find-me -client 16
|
||||||
|
go run . -port /dev/ttyUSB0 led-ring -mode find-me
|
||||||
|
go run . -port /dev/ttyUSB0 led-ring -mode battery-low -client 0
|
||||||
|
go run . -port /dev/ttyUSB0 led-ring -mode color -r 255 -g 0 -b 0 -client 16
|
||||||
|
go run . -port /dev/ttyUSB0 led-ring -mode digit -digit 5 -all
|
||||||
|
```
|
||||||
|
|
||||||
### CLIENT_INFO command
|
### CLIENT_INFO command
|
||||||
|
|
||||||
**Request:** framed payload `04` only (`MessageType.CLIENT_INFO`).
|
**Request:** framed payload `04` only (`MessageType.CLIENT_INFO`).
|
||||||
|
|
||||||
**Response:** payload `04` + nanopb `UartMessage` with `client_info_response.clients` — one `ClientInfo` per registered slave (from ESP-NOW `SLAVE_INFO`).
|
**Response:** payload `04` + nanopb `UartMessage` with `client_info_response.clients` — one `ClientInfo` per registered slave (from ESP-NOW `SLAVE_INFO`).
|
||||||
|
|
||||||
Fields per client: `id`, `mac`, `version`, `available`, `used`, `last_ping`, `last_success_ping` — **milliseconds since** the last packet / last successful heartbeat (computed when `CLIENT_INFO` is answered; typically 0–1000 while the slave is heartbeating every 1 s).
|
Fields per client: `id`, `mac`, `version`, `available`, `used`, `last_ping`, `last_success_ping`, `tap_notify_single`, `tap_notify_double`, `tap_notify_triple` — **milliseconds since** the last packet / last successful heartbeat (computed when `CLIENT_INFO` is answered; typically 0–1000 while the slave is heartbeating every 1 s).
|
||||||
|
|
||||||
## Client registry
|
## Client registry
|
||||||
|
|
||||||
@@ -217,6 +541,7 @@ Fields per client: `id`, `mac`, `version`, `available`, `used`, `last_ping`, `la
|
|||||||
| `client_registry_heartbeat(mac, id, version, …)` | Same as upsert for heartbeats; reactivates inactive clients |
|
| `client_registry_heartbeat(mac, id, version, …)` | Same as upsert for heartbeats; reactivates inactive clients |
|
||||||
| `client_registry_check_timeouts(timeout_ms)` | Mark stale clients inactive (master monitor task) |
|
| `client_registry_check_timeouts(timeout_ms)` | Mark stale clients inactive (master monitor task) |
|
||||||
| `client_registry_count()` / `client_registry_at(i)` | Iterate for UART encoding |
|
| `client_registry_count()` / `client_registry_at(i)` | Iterate for UART encoding |
|
||||||
|
| `client_registry_set_tap_notify()` / `client_registry_take_tap()` | Tap notify flags + short-lived tap cache (8 ms) |
|
||||||
|
|
||||||
Slaves register when the master receives `SLAVE_INFO` on the matching network; `HEARTBEAT` keeps them marked available. The registry **MAC is always the ESP-NOW source address** (`recv_info.src_addr`), not the optional `mac` bytes in the protobuf (used only on the wire for debugging).
|
Slaves register when the master receives `SLAVE_INFO` on the matching network; `HEARTBEAT` keeps them marked available. The registry **MAC is always the ESP-NOW source address** (`recv_info.src_addr`), not the optional `mac` bytes in the protobuf (used only on the wire for debugging).
|
||||||
|
|
||||||
@@ -271,26 +596,49 @@ 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 |
|
| `esp_now_comm.c/h` | ESP-NOW init and recv router |
|
||||||
|
| `esp_now_core.c/h` | Shared WiFi, peer, send |
|
||||||
|
| `esp_now_master.c/h` | Master discover, monitor, unicast |
|
||||||
|
| `esp_now_slave.c/h` | Slave join, heartbeat, telemetry |
|
||||||
|
| `ota_uart.c/h` | Shared 4 KiB OTA flash buffer (UART + ESP-NOW) |
|
||||||
|
| `ota_espnow.c/h` | Master: distribute staged image to slaves |
|
||||||
|
| `cmd/cmd_ota.c/h` | UART OTA command handlers (master only) |
|
||||||
| `uart.c/h` | Framed UART RX/TX |
|
| `uart.c/h` | Framed UART RX/TX |
|
||||||
| `uart_proto.c/h` | Encode/send `UartMessage` |
|
| `uart_proto.c/h` | Encode/send `UartMessage` |
|
||||||
| `cmd_handler.c/h` | Command queue and dispatch |
|
| `cmd/cmd_handler.c/h` | Command queue and dispatch |
|
||||||
| `uart_cmd.c/h` | Shared UART decode/send helpers for handlers |
|
| `uart_cmd.c/h` | Shared UART decode/send helpers for handlers |
|
||||||
| `cmd_version.c/h` | VERSION handler |
|
| `cmd/cmd_version.c/h` | VERSION handler |
|
||||||
| `cmd_client_info.c/h` | CLIENT_INFO handler |
|
| `cmd/cmd_client_info.c/h` | CLIENT_INFO handler |
|
||||||
| `client_registry.c/h` | Registered slave table |
|
| `client_registry.c/h` | Registered slave table |
|
||||||
| `led_ring.c/h` | LED digit display |
|
| `bosch456.c/h` | BMA456H I2C driver, accel poll, on-demand read, tap INT, deadzone filter |
|
||||||
|
| `cmd/cmd_tap_notify.c` | UART `TAP_NOTIFY` — ESP-NOW tap notify config |
|
||||||
|
| `cmd/cmd_tap_config.c` | UART `TAP_CONFIG` — BMA456 multitap params + NVS |
|
||||||
|
| `cmd/cmd_cache_status.c` | UART `CACHE_STATUS` — subscribed accel + tap cache poll |
|
||||||
|
| `board_input.c/h` | Taster GPIO12, LiPo ADC on GPIO1 / GPIO11 |
|
||||||
|
| `battery_uv.c/h` | Software LiPo UV latch, energy-save boot (`POWERPOD_BATTERY_UV_ENABLE`) |
|
||||||
|
| `pod_settings.c/h` | NVS persistence (accel deadzone, tap config, UV latch) |
|
||||||
|
| `led_ring.c/h` | LED ring (digit display, progress bar) |
|
||||||
|
| `cmd/cmd_led_ring.c` | UART `LED_RING` progress command |
|
||||||
|
| `cmd/cmd_battery_uv.c` | UART `BATTERY_UV` — software UV enable + NVS / ESP-NOW |
|
||||||
|
| `cmd/cmd_set_log_level.c` | UART `SET_LOG_LEVEL` — runtime ESP-IDF log level |
|
||||||
| `proto/uart_messages.proto` | UART protocol schema |
|
| `proto/uart_messages.proto` | UART protocol schema |
|
||||||
| `proto/esp_now_messages.proto` | ESP-NOW protocol schema |
|
| `proto/esp_now_messages.proto` | ESP-NOW protocol schema |
|
||||||
| `esp_now_proto.c/h` | Encode/decode `EspNowMessage` |
|
| `esp_now_proto.c/h` | Encode/decode `EspNowMessage` |
|
||||||
| `proto/*.pb.c/h` | Generated nanopb |
|
| `proto/*.pb.c/h` | Generated nanopb |
|
||||||
| `CMakeLists.txt` | Sources, `esp_wifi`, drivers, git hash |
|
| `CMakeLists.txt` | Sources, `esp_wifi`, drivers, git hash |
|
||||||
|
|
||||||
## Adding a new UART command
|
## Adding a new feature (UART → ESP-NOW)
|
||||||
|
|
||||||
1. Add or extend messages in `uart_messages.proto` and regenerate nanopb.
|
End-to-end walkthrough (protobuf, master handler, ESP-NOW unicast to slaves, goTool, dashboard) with **Find me** as the worked example:
|
||||||
2. Create `cmd_*.c` with a handler; register with `uart_cmd_register(MessageType_…, handler)`.
|
|
||||||
3. Decode with `uart_cmd_decode()` / `UART_CMD_REQ()`; reply with `uart_cmd_init_response()` + `uart_cmd_send()`.
|
**[docs/adding-a-feature.md](../docs/adding-a-feature.md)**
|
||||||
4. Extend `goTool` or another host client to send the matching frame.
|
|
||||||
|
Short checklist:
|
||||||
|
|
||||||
|
1. Add or extend messages in `uart_messages.proto` (and `esp_now_messages.proto` if slaves are involved); run `make proto_generate` and `make gotool-proto`.
|
||||||
|
2. Implement device logic in a shared module (e.g. `led_ring.c`), not only in the UART handler.
|
||||||
|
3. Create `cmd/cmd_*.c`, register with `uart_cmd_register()`; decode with `uart_cmd_decode()` / `UART_CMD_REQ()`; reply with `uart_cmd_init_response()` + `uart_cmd_send()`.
|
||||||
|
4. Master → slave: `esp_now_comm_send_*()` + slave branch in `espnow_recv_cb`.
|
||||||
|
5. Extend `goTool` (CLI, optional `/api/…` and web UI).
|
||||||
|
|
||||||
For ESP-NOW-driven PC updates later: map slave state to `ClientInfo` and send `CLIENT_INFO` over UART from the master.
|
For ESP-NOW-driven PC updates later: map slave state to `ClientInfo` and send `CLIENT_INFO` over UART from the master.
|
||||||
|
|||||||
@@ -9,7 +9,11 @@
|
|||||||
typedef struct {
|
typedef struct {
|
||||||
bool master;
|
bool master;
|
||||||
uint8_t network;
|
uint8_t network;
|
||||||
|
/** false = UART host, true = BLE host (DIP8; mutual exclusive, boot-only). */
|
||||||
|
bool host_ble;
|
||||||
char running_partition[APP_RUNNING_PARTITION_LABEL_MAX];
|
char running_partition[APP_RUNNING_PARTITION_LABEL_MAX];
|
||||||
} app_config_t;
|
} app_config_t;
|
||||||
|
|
||||||
|
const app_config_t *app_config_get(void);
|
||||||
|
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -0,0 +1,196 @@
|
|||||||
|
#include "battery_uv.h"
|
||||||
|
|
||||||
|
#if POWERPOD_BATTERY_UV_ENABLE
|
||||||
|
|
||||||
|
#include "led_ring.h"
|
||||||
|
#include "pod_reboot.h"
|
||||||
|
#include "pod_settings.h"
|
||||||
|
#include "esp_log.h"
|
||||||
|
#include "freertos/FreeRTOS.h"
|
||||||
|
#include "freertos/task.h"
|
||||||
|
|
||||||
|
static const char *TAG = "[BATTERY_UV]";
|
||||||
|
|
||||||
|
#define UV_DEBOUNCE_SAMPLES 2u
|
||||||
|
#define UV_POLL_INTERVAL_MS 30000u
|
||||||
|
|
||||||
|
static uint8_t s_uv_streak;
|
||||||
|
static uint8_t s_charge_streak;
|
||||||
|
|
||||||
|
bool battery_uv_is_latched(void) { return pod_settings_is_uv_latched(); }
|
||||||
|
|
||||||
|
static bool channel_is_absent(uint32_t mv) {
|
||||||
|
return mv < BATTERY_UV_ABSENT_ADC_MV;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool channel_is_under(uint32_t mv) {
|
||||||
|
return !channel_is_absent(mv) && mv < BATTERY_UV_ADC_MV;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void log_lipo_reading(const char *ctx, const board_lipo_reading_t *reading) {
|
||||||
|
if (reading == NULL) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ESP_LOGI(TAG,
|
||||||
|
"%s LIPO1 %s %lu mV LIPO2 %s %lu mV", ctx,
|
||||||
|
reading->lipo1_valid ? "ok" : "n/a",
|
||||||
|
(unsigned long)reading->lipo1_mv, reading->lipo2_valid ? "ok" : "n/a",
|
||||||
|
(unsigned long)reading->lipo2_mv);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool battery_uv_reading_is_external(const board_lipo_reading_t *reading) {
|
||||||
|
if (reading == NULL) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool any_valid = false;
|
||||||
|
if (reading->lipo1_valid) {
|
||||||
|
any_valid = true;
|
||||||
|
if (!channel_is_absent(reading->lipo1_mv)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (reading->lipo2_valid) {
|
||||||
|
any_valid = true;
|
||||||
|
if (!channel_is_absent(reading->lipo2_mv)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return any_valid;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool battery_uv_reading_is_under(const board_lipo_reading_t *reading) {
|
||||||
|
if (reading == NULL) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reading->lipo1_valid && channel_is_under(reading->lipo1_mv)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (reading->lipo2_valid && channel_is_under(reading->lipo2_mv)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool battery_uv_reading_is_charged(const board_lipo_reading_t *reading) {
|
||||||
|
if (reading == NULL) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool any_pack = false;
|
||||||
|
if (reading->lipo1_valid && !channel_is_absent(reading->lipo1_mv)) {
|
||||||
|
any_pack = true;
|
||||||
|
if (reading->lipo1_mv < BATTERY_CHARGE_ADC_MV) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (reading->lipo2_valid && !channel_is_absent(reading->lipo2_mv)) {
|
||||||
|
any_pack = true;
|
||||||
|
if (reading->lipo2_mv < BATTERY_CHARGE_ADC_MV) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return any_pack;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool battery_uv_evaluate_boot(void) {
|
||||||
|
if (!pod_settings_is_uv_enabled()) {
|
||||||
|
ESP_LOGI(TAG, "UV protection disabled — normal boot");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
board_lipo_reading_t reading;
|
||||||
|
board_input_read_lipo(&reading);
|
||||||
|
log_lipo_reading("boot check", &reading);
|
||||||
|
|
||||||
|
if (battery_uv_reading_is_external(&reading)) {
|
||||||
|
ESP_LOGI(TAG, "near-zero LiPo sense — external supply, normal boot");
|
||||||
|
if (pod_settings_is_uv_latched()) {
|
||||||
|
pod_settings_clear_uv_latched();
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pod_settings_is_uv_latched() && battery_uv_reading_is_charged(&reading)) {
|
||||||
|
ESP_LOGI(TAG, "charged — clearing UV latch");
|
||||||
|
pod_settings_clear_uv_latched();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pod_settings_is_uv_latched() || battery_uv_reading_is_under(&reading)) {
|
||||||
|
if (!pod_settings_is_uv_latched()) {
|
||||||
|
ESP_LOGW(TAG, "under-voltage at boot — latching UV");
|
||||||
|
} else {
|
||||||
|
ESP_LOGW(TAG, "UV latched — energy-save boot");
|
||||||
|
}
|
||||||
|
pod_settings_set_uv_latched(true);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void battery_uv_poll(const board_lipo_reading_t *reading) {
|
||||||
|
if (reading == NULL || !pod_settings_is_uv_enabled() ||
|
||||||
|
pod_settings_is_uv_latched()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (battery_uv_reading_is_external(reading)) {
|
||||||
|
s_uv_streak = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (battery_uv_reading_is_under(reading)) {
|
||||||
|
s_uv_streak++;
|
||||||
|
log_lipo_reading("under-voltage", reading);
|
||||||
|
ESP_LOGW(TAG, "under-voltage sample %u/%u", (unsigned)s_uv_streak,
|
||||||
|
(unsigned)UV_DEBOUNCE_SAMPLES);
|
||||||
|
if (s_uv_streak >= UV_DEBOUNCE_SAMPLES) {
|
||||||
|
ESP_LOGW(TAG, "UV confirmed — latching and restarting");
|
||||||
|
pod_settings_set_uv_latched(true);
|
||||||
|
pod_schedule_restart();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
s_uv_streak = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
void battery_uv_run_mode(void) {
|
||||||
|
s_charge_streak = 0;
|
||||||
|
s_uv_streak = 0;
|
||||||
|
|
||||||
|
led_ring_init();
|
||||||
|
led_ring_show_battery_low();
|
||||||
|
|
||||||
|
ESP_LOGW(TAG, "energy-save mode — ADC poll every %u ms", UV_POLL_INTERVAL_MS);
|
||||||
|
|
||||||
|
while (1) {
|
||||||
|
board_lipo_reading_t reading;
|
||||||
|
board_input_read_lipo(&reading);
|
||||||
|
log_lipo_reading("energy-save", &reading);
|
||||||
|
|
||||||
|
if (battery_uv_reading_is_external(&reading)) {
|
||||||
|
ESP_LOGI(TAG, "external supply — clearing UV latch and restarting");
|
||||||
|
pod_settings_clear_uv_latched();
|
||||||
|
pod_schedule_restart();
|
||||||
|
} else if (battery_uv_reading_is_charged(&reading)) {
|
||||||
|
s_charge_streak++;
|
||||||
|
ESP_LOGI(TAG, "charge sample %u/%u", (unsigned)s_charge_streak,
|
||||||
|
(unsigned)UV_DEBOUNCE_SAMPLES);
|
||||||
|
if (s_charge_streak >= UV_DEBOUNCE_SAMPLES) {
|
||||||
|
ESP_LOGI(TAG, "charged — clearing UV latch and restarting");
|
||||||
|
pod_settings_clear_uv_latched();
|
||||||
|
pod_schedule_restart();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
s_charge_streak = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
vTaskDelay(pdMS_TO_TICKS(UV_POLL_INTERVAL_MS));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
#ifndef BATTERY_UV_H
|
||||||
|
#define BATTERY_UV_H
|
||||||
|
|
||||||
|
#include "board_input.h"
|
||||||
|
#include "powerpod.h"
|
||||||
|
#include <stdbool.h>
|
||||||
|
|
||||||
|
#if POWERPOD_BATTERY_UV_ENABLE
|
||||||
|
|
||||||
|
/** ADC pin voltage (mV) below which UV is triggered (3.0 V pack via 33k/10k divider). */
|
||||||
|
#define BATTERY_UV_ADC_MV 2300u
|
||||||
|
/** ADC pin voltage (mV) at which charging is detected (4.0 V pack, rounded). */
|
||||||
|
#define BATTERY_CHARGE_ADC_MV 3000u
|
||||||
|
/**
|
||||||
|
* ADC readings below this are treated as no pack / external supply (not UV).
|
||||||
|
* Noise floor / open sense divider → near 0 mV.
|
||||||
|
*/
|
||||||
|
#define BATTERY_UV_ABSENT_ADC_MV 100u
|
||||||
|
|
||||||
|
bool battery_uv_is_latched(void);
|
||||||
|
bool battery_uv_reading_is_under(const board_lipo_reading_t *reading);
|
||||||
|
bool battery_uv_reading_is_charged(const board_lipo_reading_t *reading);
|
||||||
|
/** true when every valid channel is near 0 (externally powered / no LiPo). */
|
||||||
|
bool battery_uv_reading_is_external(const board_lipo_reading_t *reading);
|
||||||
|
|
||||||
|
/** true → enter UV energy-save path (does not return). */
|
||||||
|
bool battery_uv_evaluate_boot(void);
|
||||||
|
|
||||||
|
void battery_uv_poll(const board_lipo_reading_t *reading);
|
||||||
|
|
||||||
|
/** Blocks: LED indicator, then minimal ADC polling until charged. */
|
||||||
|
void battery_uv_run_mode(void);
|
||||||
|
|
||||||
|
#else
|
||||||
|
|
||||||
|
static inline bool battery_uv_is_latched(void) { return false; }
|
||||||
|
|
||||||
|
static inline bool battery_uv_reading_is_under(const board_lipo_reading_t *reading) {
|
||||||
|
(void)reading;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
static inline bool battery_uv_reading_is_charged(const board_lipo_reading_t *reading) {
|
||||||
|
(void)reading;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
static inline bool battery_uv_reading_is_external(const board_lipo_reading_t *reading) {
|
||||||
|
(void)reading;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
static inline bool battery_uv_evaluate_boot(void) { return false; }
|
||||||
|
|
||||||
|
static inline void battery_uv_poll(const board_lipo_reading_t *reading) {
|
||||||
|
(void)reading;
|
||||||
|
}
|
||||||
|
|
||||||
|
static inline void battery_uv_run_mode(void) {}
|
||||||
|
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#endif
|
||||||
+496
@@ -0,0 +1,496 @@
|
|||||||
|
#include "ble_host.h"
|
||||||
|
#include "esp_log.h"
|
||||||
|
#include "esp_timer.h"
|
||||||
|
#include "host_transport.h"
|
||||||
|
#include "nvs_flash.h"
|
||||||
|
#include "ota_espnow.h"
|
||||||
|
#include "freertos/FreeRTOS.h"
|
||||||
|
#include "freertos/semphr.h"
|
||||||
|
#include "freertos/task.h"
|
||||||
|
|
||||||
|
#include "nimble/nimble_port.h"
|
||||||
|
#include "nimble/nimble_port_freertos.h"
|
||||||
|
#include "host/ble_att.h"
|
||||||
|
#include "host/ble_hs.h"
|
||||||
|
#include "host/util/util.h"
|
||||||
|
#include "services/gap/ble_svc_gap.h"
|
||||||
|
#include "services/gatt/ble_svc_gatt.h"
|
||||||
|
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
static const char *TAG = "[BLE_HOST]";
|
||||||
|
static const char *DEVICE_NAME = "Powerpod";
|
||||||
|
|
||||||
|
#define BLE_CONN_ITVL_MIN_UNITS 6 /* 7.5 ms */
|
||||||
|
#define BLE_CONN_ITVL_MAX_UNITS 6
|
||||||
|
#define BLE_CONN_TUNE_MAX_TRIES 6
|
||||||
|
/** Prefer large ATT MTU so CLIENT_INFO / BATTERY fit in fewer notify chunks. */
|
||||||
|
#define BLE_PREFERRED_ATT_MTU 517
|
||||||
|
#define BLE_NOTIFY_CHUNK_RETRIES 24
|
||||||
|
/** During ESP-NOW OTA dist, fail notify sooner so WiFi/ESP-NOW keeps airtime. */
|
||||||
|
#define BLE_NOTIFY_CHUNK_RETRIES_OTA_DIST 4
|
||||||
|
#define BLE_NOTIFY_CHUNK_RETRY_MS 5
|
||||||
|
/** Pace multi-chunk notifies so the central parser sees contiguous frame bytes. */
|
||||||
|
#define BLE_NOTIFY_CHUNK_GAP_MS 8
|
||||||
|
#define BLE_NOTIFY_CHUNK_GAP_OTA_DIST_MS 2
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Nordic UART style:
|
||||||
|
* Service: 6e400001-b5a3-f393-e0a9-e50e24dcca9e
|
||||||
|
* Characteristic: 6e400002-b5a3-f393-e0a9-e50e24dcca9e
|
||||||
|
* Write = host→ESP (framed), Notify = ESP→host (framed).
|
||||||
|
*/
|
||||||
|
static const ble_uuid128_t ble_svc_uuid =
|
||||||
|
BLE_UUID128_INIT(0x9e, 0xca, 0xdc, 0x24, 0x0e, 0xe5, 0xa9, 0xe0, 0x93, 0xf3,
|
||||||
|
0xa3, 0xb5, 0x01, 0x00, 0x40, 0x6e);
|
||||||
|
static const ble_uuid128_t ble_chr_uuid =
|
||||||
|
BLE_UUID128_INIT(0x9e, 0xca, 0xdc, 0x24, 0x0e, 0xe5, 0xa9, 0xe0, 0x93, 0xf3,
|
||||||
|
0xa3, 0xb5, 0x02, 0x00, 0x40, 0x6e);
|
||||||
|
|
||||||
|
static uint8_t own_addr_type;
|
||||||
|
static uint16_t conn_handle = BLE_HS_CONN_HANDLE_NONE;
|
||||||
|
static uint16_t ble_chr_val_handle;
|
||||||
|
static bool notify_enabled;
|
||||||
|
static uint16_t att_mtu = 23;
|
||||||
|
static esp_timer_handle_t conn_tune_timer;
|
||||||
|
static uint8_t conn_tune_tries;
|
||||||
|
static SemaphoreHandle_t s_tx_mu;
|
||||||
|
|
||||||
|
static int ble_gap_event(struct ble_gap_event *event, void *arg);
|
||||||
|
static void ble_request_fast_conn_params(uint16_t handle);
|
||||||
|
static void ble_optimize_link(uint16_t handle);
|
||||||
|
|
||||||
|
bool ble_host_ready(void) {
|
||||||
|
return notify_enabled && conn_handle != BLE_HS_CONN_HANDLE_NONE;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint16_t ble_host_att_mtu(void) { return att_mtu; }
|
||||||
|
|
||||||
|
uint16_t ble_host_max_payload(void) {
|
||||||
|
if (att_mtu < 4) {
|
||||||
|
return 20;
|
||||||
|
}
|
||||||
|
return (uint16_t)(att_mtu - 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One ATT notification (≤ MTU-3). Retries on transient NimBLE buffer pressure.
|
||||||
|
*/
|
||||||
|
static esp_err_t ble_notify_chunk(const uint8_t *data, uint16_t len) {
|
||||||
|
const int max_retries = ota_espnow_distribution_active()
|
||||||
|
? BLE_NOTIFY_CHUNK_RETRIES_OTA_DIST
|
||||||
|
: BLE_NOTIFY_CHUNK_RETRIES;
|
||||||
|
for (int attempt = 0; attempt < max_retries; attempt++) {
|
||||||
|
struct os_mbuf *om = ble_hs_mbuf_from_flat(data, len);
|
||||||
|
if (om == NULL) {
|
||||||
|
vTaskDelay(pdMS_TO_TICKS(BLE_NOTIFY_CHUNK_RETRY_MS));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
int rc = ble_gatts_notify_custom(conn_handle, ble_chr_val_handle, om);
|
||||||
|
if (rc == 0) {
|
||||||
|
return ESP_OK;
|
||||||
|
}
|
||||||
|
/* Ownership stays with us on failure. */
|
||||||
|
os_mbuf_free_chain(om);
|
||||||
|
if (!ble_host_ready()) {
|
||||||
|
return ESP_ERR_INVALID_STATE;
|
||||||
|
}
|
||||||
|
ESP_LOGD(TAG, "notify chunk rc=%d attempt=%d len=%u", rc, attempt,
|
||||||
|
(unsigned)len);
|
||||||
|
vTaskDelay(pdMS_TO_TICKS(BLE_NOTIFY_CHUNK_RETRY_MS));
|
||||||
|
}
|
||||||
|
ESP_LOGW(TAG, "notify chunk gave up after retries len=%u", (unsigned)len);
|
||||||
|
return ESP_ERR_NO_MEM;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a full host frame. If larger than ATT MTU-3, split across consecutive
|
||||||
|
* notifies; goTool feeds every notify into the same byte parser, so the frame
|
||||||
|
* reassembles without a protocol change. Mutex keeps stream ticks from
|
||||||
|
* interleaving mid-frame.
|
||||||
|
*/
|
||||||
|
static esp_err_t ble_host_tx(const uint8_t *frame, size_t frame_len) {
|
||||||
|
if (!ble_host_ready()) {
|
||||||
|
return ESP_ERR_INVALID_STATE;
|
||||||
|
}
|
||||||
|
if (frame == NULL || frame_len == 0) {
|
||||||
|
return ESP_ERR_INVALID_ARG;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint16_t max_chunk = ble_host_max_payload();
|
||||||
|
if (max_chunk == 0) {
|
||||||
|
return ESP_ERR_INVALID_SIZE;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (s_tx_mu == NULL ||
|
||||||
|
xSemaphoreTake(s_tx_mu, pdMS_TO_TICKS(2000)) != pdTRUE) {
|
||||||
|
return ESP_ERR_TIMEOUT;
|
||||||
|
}
|
||||||
|
|
||||||
|
esp_err_t result = ESP_OK;
|
||||||
|
size_t off = 0;
|
||||||
|
while (off < frame_len) {
|
||||||
|
if (!ble_host_ready()) {
|
||||||
|
result = ESP_ERR_INVALID_STATE;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
size_t remain = frame_len - off;
|
||||||
|
uint16_t n =
|
||||||
|
(uint16_t)(remain > max_chunk ? max_chunk : remain);
|
||||||
|
result = ble_notify_chunk(&frame[off], n);
|
||||||
|
if (result != ESP_OK) {
|
||||||
|
ESP_LOGW(TAG, "TX abort at %u/%u (chunk %u, mtu_pay %u)",
|
||||||
|
(unsigned)off, (unsigned)frame_len, (unsigned)n,
|
||||||
|
(unsigned)max_chunk);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
off += n;
|
||||||
|
if (off < frame_len) {
|
||||||
|
const uint32_t gap_ms = ota_espnow_distribution_active()
|
||||||
|
? BLE_NOTIFY_CHUNK_GAP_OTA_DIST_MS
|
||||||
|
: BLE_NOTIFY_CHUNK_GAP_MS;
|
||||||
|
vTaskDelay(pdMS_TO_TICKS(gap_ms));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
xSemaphoreGive(s_tx_mu);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void ble_conn_tune_timer_cb(void *arg) {
|
||||||
|
struct ble_gap_conn_desc desc;
|
||||||
|
|
||||||
|
(void)arg;
|
||||||
|
if (conn_handle == BLE_HS_CONN_HANDLE_NONE) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (ble_gap_conn_find(conn_handle, &desc) != 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (desc.conn_itvl <= BLE_CONN_ITVL_MAX_UNITS) {
|
||||||
|
ESP_LOGI(TAG, "conn already at interval_us=%lu",
|
||||||
|
(unsigned long)desc.conn_itvl * 1250UL);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (conn_tune_tries >= BLE_CONN_TUNE_MAX_TRIES) {
|
||||||
|
ESP_LOGW(TAG, "central kept interval_us=%lu (ESP requested 7500)",
|
||||||
|
(unsigned long)desc.conn_itvl * 1250UL);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
conn_tune_tries++;
|
||||||
|
ESP_LOGI(TAG, "retry fast conn params (%u/%u); current interval_us=%lu",
|
||||||
|
conn_tune_tries, BLE_CONN_TUNE_MAX_TRIES,
|
||||||
|
(unsigned long)desc.conn_itvl * 1250UL);
|
||||||
|
ble_request_fast_conn_params(conn_handle);
|
||||||
|
esp_timer_start_once(conn_tune_timer, 750 * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void ble_schedule_conn_tune(void) {
|
||||||
|
conn_tune_tries = 0;
|
||||||
|
esp_timer_stop(conn_tune_timer);
|
||||||
|
esp_timer_start_once(conn_tune_timer, 300 * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void ble_request_fast_conn_params(uint16_t handle) {
|
||||||
|
struct ble_gap_upd_params params = {
|
||||||
|
.itvl_min = BLE_CONN_ITVL_MIN_UNITS,
|
||||||
|
.itvl_max = BLE_CONN_ITVL_MAX_UNITS,
|
||||||
|
.latency = 0,
|
||||||
|
.supervision_timeout = 400,
|
||||||
|
.min_ce_len = 0,
|
||||||
|
.max_ce_len = 24,
|
||||||
|
};
|
||||||
|
int rc = ble_gap_update_params(handle, ¶ms);
|
||||||
|
if (rc != 0) {
|
||||||
|
ESP_LOGW(TAG, "conn param update request failed: %d", rc);
|
||||||
|
} else {
|
||||||
|
ESP_LOGI(TAG, "requested conn interval 7.5 ms");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void ble_optimize_link(uint16_t handle) {
|
||||||
|
int rc;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Do NOT request 2M PHY here. With ESP-NOW/WiFi coexistence the controller
|
||||||
|
* can assert in llc_phy_upd (e.g. "BLE assert llc_phy_upd.c …") and the
|
||||||
|
* host link dies — no further responses. Stay on default 1M.
|
||||||
|
*/
|
||||||
|
ble_request_fast_conn_params(handle);
|
||||||
|
|
||||||
|
rc = ble_gap_set_data_len(handle, 251, 2120);
|
||||||
|
if (rc != 0) {
|
||||||
|
ESP_LOGW(TAG, "data len request failed: %d", rc);
|
||||||
|
}
|
||||||
|
|
||||||
|
ble_schedule_conn_tune();
|
||||||
|
}
|
||||||
|
|
||||||
|
static void ble_advertise(void) {
|
||||||
|
struct ble_gap_adv_params adv_params;
|
||||||
|
struct ble_hs_adv_fields fields;
|
||||||
|
int rc;
|
||||||
|
|
||||||
|
memset(&fields, 0, sizeof(fields));
|
||||||
|
fields.flags = BLE_HS_ADV_F_DISC_GEN | BLE_HS_ADV_F_BREDR_UNSUP;
|
||||||
|
fields.tx_pwr_lvl_is_present = 1;
|
||||||
|
fields.tx_pwr_lvl = BLE_HS_ADV_TX_PWR_LVL_AUTO;
|
||||||
|
fields.name = (uint8_t *)DEVICE_NAME;
|
||||||
|
fields.name_len = strlen(DEVICE_NAME);
|
||||||
|
fields.name_is_complete = 1;
|
||||||
|
|
||||||
|
rc = ble_gap_adv_set_fields(&fields);
|
||||||
|
if (rc != 0) {
|
||||||
|
ESP_LOGE(TAG, "adv set fields failed: %d", rc);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
memset(&adv_params, 0, sizeof(adv_params));
|
||||||
|
adv_params.conn_mode = BLE_GAP_CONN_MODE_UND;
|
||||||
|
adv_params.disc_mode = BLE_GAP_DISC_MODE_GEN;
|
||||||
|
adv_params.itvl_min = 0x20;
|
||||||
|
adv_params.itvl_max = 0x30;
|
||||||
|
|
||||||
|
rc = ble_gap_adv_start(own_addr_type, NULL, BLE_HS_FOREVER, &adv_params,
|
||||||
|
ble_gap_event, NULL);
|
||||||
|
if (rc != 0) {
|
||||||
|
ESP_LOGE(TAG, "adv start failed: %d", rc);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ESP_LOGI(TAG, "advertising as \"%s\"", DEVICE_NAME);
|
||||||
|
}
|
||||||
|
|
||||||
|
static int ble_chr_access(uint16_t conn_handle_arg, uint16_t attr_handle,
|
||||||
|
struct ble_gatt_access_ctxt *ctxt, void *arg) {
|
||||||
|
(void)conn_handle_arg;
|
||||||
|
(void)attr_handle;
|
||||||
|
(void)arg;
|
||||||
|
|
||||||
|
switch (ctxt->op) {
|
||||||
|
case BLE_GATT_ACCESS_OP_READ_CHR:
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
case BLE_GATT_ACCESS_OP_WRITE_CHR: {
|
||||||
|
uint16_t om_len = OS_MBUF_PKTLEN(ctxt->om);
|
||||||
|
uint8_t buf[512];
|
||||||
|
uint16_t copied = 0;
|
||||||
|
int rc;
|
||||||
|
|
||||||
|
if (om_len == 0 || om_len > sizeof(buf)) {
|
||||||
|
return BLE_ATT_ERR_INVALID_ATTR_VALUE_LEN;
|
||||||
|
}
|
||||||
|
|
||||||
|
rc = ble_hs_mbuf_to_flat(ctxt->om, buf, sizeof(buf), &copied);
|
||||||
|
if (rc != 0) {
|
||||||
|
return BLE_ATT_ERR_UNLIKELY;
|
||||||
|
}
|
||||||
|
|
||||||
|
host_rx_bytes(buf, copied);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return BLE_ATT_ERR_UNLIKELY;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static const struct ble_gatt_svc_def gatt_svcs[] = {
|
||||||
|
{
|
||||||
|
.type = BLE_GATT_SVC_TYPE_PRIMARY,
|
||||||
|
.uuid = &ble_svc_uuid.u,
|
||||||
|
.characteristics =
|
||||||
|
(struct ble_gatt_chr_def[]){
|
||||||
|
{
|
||||||
|
.uuid = &ble_chr_uuid.u,
|
||||||
|
.access_cb = ble_chr_access,
|
||||||
|
.val_handle = &ble_chr_val_handle,
|
||||||
|
.flags = BLE_GATT_CHR_F_READ | BLE_GATT_CHR_F_WRITE |
|
||||||
|
BLE_GATT_CHR_F_WRITE_NO_RSP | BLE_GATT_CHR_F_NOTIFY,
|
||||||
|
},
|
||||||
|
{0},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{0},
|
||||||
|
};
|
||||||
|
|
||||||
|
static int gatt_svr_init(void) {
|
||||||
|
int rc;
|
||||||
|
|
||||||
|
ble_svc_gap_init();
|
||||||
|
ble_svc_gatt_init();
|
||||||
|
|
||||||
|
rc = ble_gatts_count_cfg(gatt_svcs);
|
||||||
|
if (rc != 0) {
|
||||||
|
return rc;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ble_gatts_add_svcs(gatt_svcs);
|
||||||
|
}
|
||||||
|
|
||||||
|
static int ble_gap_event(struct ble_gap_event *event, void *arg) {
|
||||||
|
(void)arg;
|
||||||
|
|
||||||
|
switch (event->type) {
|
||||||
|
case BLE_GAP_EVENT_CONNECT:
|
||||||
|
ESP_LOGI(TAG, "connection %s; status=%d handle=%d",
|
||||||
|
event->connect.status == 0 ? "established" : "failed",
|
||||||
|
event->connect.status, event->connect.conn_handle);
|
||||||
|
if (event->connect.status != 0) {
|
||||||
|
ble_advertise();
|
||||||
|
} else {
|
||||||
|
conn_handle = event->connect.conn_handle;
|
||||||
|
ble_optimize_link(conn_handle);
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
case BLE_GAP_EVENT_DISCONNECT:
|
||||||
|
ESP_LOGI(TAG, "disconnect; reason=%d", event->disconnect.reason);
|
||||||
|
esp_timer_stop(conn_tune_timer);
|
||||||
|
conn_tune_tries = 0;
|
||||||
|
conn_handle = BLE_HS_CONN_HANDLE_NONE;
|
||||||
|
notify_enabled = false;
|
||||||
|
att_mtu = 23;
|
||||||
|
ble_advertise();
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
case BLE_GAP_EVENT_CONN_UPDATE: {
|
||||||
|
struct ble_gap_conn_desc desc;
|
||||||
|
if (ble_gap_conn_find(event->conn_update.conn_handle, &desc) == 0) {
|
||||||
|
ESP_LOGI(TAG, "conn updated; status=%d interval_us=%lu latency=%u",
|
||||||
|
event->conn_update.status,
|
||||||
|
(unsigned long)desc.conn_itvl * 1250UL, desc.conn_latency);
|
||||||
|
if (event->conn_update.status == 0 &&
|
||||||
|
desc.conn_itvl > BLE_CONN_ITVL_MAX_UNITS &&
|
||||||
|
conn_tune_tries < BLE_CONN_TUNE_MAX_TRIES) {
|
||||||
|
esp_timer_stop(conn_tune_timer);
|
||||||
|
esp_timer_start_once(conn_tune_timer, 500 * 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
case BLE_GAP_EVENT_PHY_UPDATE_COMPLETE:
|
||||||
|
ESP_LOGI(TAG, "phy update; status=%d tx=%u rx=%u",
|
||||||
|
event->phy_updated.status, event->phy_updated.tx_phy,
|
||||||
|
event->phy_updated.rx_phy);
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
case BLE_GAP_EVENT_ADV_COMPLETE:
|
||||||
|
ESP_LOGI(TAG, "advertise complete; reason=%d", event->adv_complete.reason);
|
||||||
|
ble_advertise();
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
case BLE_GAP_EVENT_SUBSCRIBE:
|
||||||
|
if (event->subscribe.attr_handle == ble_chr_val_handle) {
|
||||||
|
notify_enabled = event->subscribe.cur_notify;
|
||||||
|
ESP_LOGI(TAG, "notify %s", notify_enabled ? "enabled" : "disabled");
|
||||||
|
if (notify_enabled && conn_handle != BLE_HS_CONN_HANDLE_NONE) {
|
||||||
|
ble_request_fast_conn_params(conn_handle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
case BLE_GAP_EVENT_MTU:
|
||||||
|
att_mtu = event->mtu.value;
|
||||||
|
ESP_LOGI(TAG, "mtu update; handle=%d mtu=%d", event->mtu.conn_handle,
|
||||||
|
event->mtu.value);
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
default:
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void ble_on_sync(void) {
|
||||||
|
int rc;
|
||||||
|
|
||||||
|
rc = ble_hs_id_infer_auto(0, &own_addr_type);
|
||||||
|
if (rc != 0) {
|
||||||
|
ESP_LOGE(TAG, "ble_hs_id_infer_auto failed: %d", rc);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint8_t addr_val[6] = {0};
|
||||||
|
ble_hs_id_copy_addr(own_addr_type, addr_val, NULL);
|
||||||
|
ESP_LOGI(TAG, "device address: %02x:%02x:%02x:%02x:%02x:%02x", addr_val[5],
|
||||||
|
addr_val[4], addr_val[3], addr_val[2], addr_val[1], addr_val[0]);
|
||||||
|
|
||||||
|
ble_advertise();
|
||||||
|
}
|
||||||
|
|
||||||
|
static void ble_on_reset(int reason) {
|
||||||
|
ESP_LOGE(TAG, "nimble reset; reason=%d", reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void ble_host_task(void *param) {
|
||||||
|
(void)param;
|
||||||
|
ESP_LOGI(TAG, "nimble host task started");
|
||||||
|
nimble_port_run();
|
||||||
|
nimble_port_freertos_deinit();
|
||||||
|
}
|
||||||
|
|
||||||
|
esp_err_t init_ble_host(QueueHandle_t cmd_queue) {
|
||||||
|
esp_err_t ret;
|
||||||
|
|
||||||
|
host_transport_set_cmd_queue(cmd_queue);
|
||||||
|
host_transport_set_tx(ble_host_tx);
|
||||||
|
|
||||||
|
s_tx_mu = xSemaphoreCreateMutex();
|
||||||
|
if (s_tx_mu == NULL) {
|
||||||
|
ESP_LOGE(TAG, "failed to create TX mutex");
|
||||||
|
return ESP_ERR_NO_MEM;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* NVS may already be init via pod_settings; ignore ALREADY_INIT. */
|
||||||
|
ret = nvs_flash_init();
|
||||||
|
if (ret == ESP_ERR_NVS_NO_FREE_PAGES ||
|
||||||
|
ret == ESP_ERR_NVS_NEW_VERSION_FOUND) {
|
||||||
|
ESP_ERROR_CHECK(nvs_flash_erase());
|
||||||
|
ret = nvs_flash_init();
|
||||||
|
}
|
||||||
|
if (ret != ESP_OK && ret != ESP_ERR_INVALID_STATE) {
|
||||||
|
ESP_LOGE(TAG, "nvs_flash_init failed: %s", esp_err_to_name(ret));
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
|
||||||
|
const esp_timer_create_args_t tune_args = {
|
||||||
|
.callback = &ble_conn_tune_timer_cb,
|
||||||
|
.name = "ble_conn_tune",
|
||||||
|
};
|
||||||
|
ESP_ERROR_CHECK(esp_timer_create(&tune_args, &conn_tune_timer));
|
||||||
|
|
||||||
|
ret = nimble_port_init();
|
||||||
|
if (ret != ESP_OK) {
|
||||||
|
ESP_LOGE(TAG, "nimble_port_init failed: %s", esp_err_to_name(ret));
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
|
||||||
|
ble_hs_cfg.sync_cb = ble_on_sync;
|
||||||
|
ble_hs_cfg.reset_cb = ble_on_reset;
|
||||||
|
|
||||||
|
int rc = gatt_svr_init();
|
||||||
|
if (rc != 0) {
|
||||||
|
ESP_LOGE(TAG, "gatt_svr_init failed: %d", rc);
|
||||||
|
return ESP_FAIL;
|
||||||
|
}
|
||||||
|
|
||||||
|
rc = ble_svc_gap_device_name_set(DEVICE_NAME);
|
||||||
|
if (rc != 0) {
|
||||||
|
ESP_LOGE(TAG, "device name set failed: %d", rc);
|
||||||
|
return ESP_FAIL;
|
||||||
|
}
|
||||||
|
|
||||||
|
rc = ble_att_set_preferred_mtu(BLE_PREFERRED_ATT_MTU);
|
||||||
|
if (rc != 0) {
|
||||||
|
ESP_LOGW(TAG, "preferred MTU set failed: %d", rc);
|
||||||
|
} else {
|
||||||
|
ESP_LOGI(TAG, "preferred ATT MTU %u", (unsigned)BLE_PREFERRED_ATT_MTU);
|
||||||
|
}
|
||||||
|
|
||||||
|
nimble_port_freertos_init(ble_host_task);
|
||||||
|
ESP_LOGI(TAG, "BLE host transport started");
|
||||||
|
return ESP_OK;
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
#ifndef BLE_HOST_H
|
||||||
|
#define BLE_HOST_H
|
||||||
|
|
||||||
|
#include "esp_err.h"
|
||||||
|
#include "freertos/FreeRTOS.h"
|
||||||
|
#include "freertos/queue.h"
|
||||||
|
#include <stddef.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
|
||||||
|
/** Start NimBLE host transport (Master only). Uses same framing as UART. */
|
||||||
|
esp_err_t init_ble_host(QueueHandle_t cmd_queue);
|
||||||
|
|
||||||
|
/** True when a central is connected and CCCD notify is enabled. */
|
||||||
|
bool ble_host_ready(void);
|
||||||
|
|
||||||
|
/** Negotiated ATT MTU (default 23 until exchange). */
|
||||||
|
uint16_t ble_host_att_mtu(void);
|
||||||
|
|
||||||
|
/** Max bytes per ATT notify/write (ATT MTU - 3). Frames may span multiple notifies. */
|
||||||
|
uint16_t ble_host_max_payload(void);
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
#include "ble_stream.h"
|
||||||
|
#include "app_config.h"
|
||||||
|
#include "ble_host.h"
|
||||||
|
#include "client_registry.h"
|
||||||
|
#include "esp_log.h"
|
||||||
|
#include "esp_timer.h"
|
||||||
|
#include "freertos/FreeRTOS.h"
|
||||||
|
#include "freertos/task.h"
|
||||||
|
#include "ota_session.h"
|
||||||
|
#include "uart_cmd.h"
|
||||||
|
#include "uart_messages.pb.h"
|
||||||
|
#include "uart_proto.h"
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
static const char *TAG = "[BLE_STREAM]";
|
||||||
|
|
||||||
|
/* Min matches FreeRTOS tick (HZ=100 → 10 ms); 8 ms would delay 0 ticks. */
|
||||||
|
#define BLE_STREAM_PERIOD_MIN_MS 10u
|
||||||
|
#define BLE_STREAM_PERIOD_MAX_MS 16u
|
||||||
|
#define BLE_STREAM_PERIOD_DEFAULT_MS 10u
|
||||||
|
|
||||||
|
static uint32_t s_period_ms = BLE_STREAM_PERIOD_DEFAULT_MS;
|
||||||
|
static TaskHandle_t s_task;
|
||||||
|
static volatile bool s_running;
|
||||||
|
|
||||||
|
static bool tap_notify_any(const client_info_t *client) {
|
||||||
|
return client != NULL &&
|
||||||
|
(client->tap_notify_single || client->tap_notify_double ||
|
||||||
|
client->tap_notify_triple);
|
||||||
|
}
|
||||||
|
|
||||||
|
static alox_TapKind tap_kind_from_registry(uint32_t kind) {
|
||||||
|
switch (kind) {
|
||||||
|
case 1:
|
||||||
|
return alox_TapKind_TAP_SINGLE;
|
||||||
|
case 2:
|
||||||
|
return alox_TapKind_TAP_DOUBLE;
|
||||||
|
case 3:
|
||||||
|
return alox_TapKind_TAP_TRIPLE;
|
||||||
|
default:
|
||||||
|
return alox_TapKind_TAP_NONE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void fill_clients(alox_BleStreamTick *tick) {
|
||||||
|
tick->clients_count = 0;
|
||||||
|
size_t count = client_registry_count();
|
||||||
|
for (size_t i = 0; i < count; i++) {
|
||||||
|
const client_info_t *client = client_registry_at(i);
|
||||||
|
if (client == NULL) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const bool want_accel = client->accel_stream_enabled;
|
||||||
|
const bool want_tap = tap_notify_any(client);
|
||||||
|
if (!want_accel && !want_tap) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (tick->clients_count >=
|
||||||
|
sizeof(tick->clients) / sizeof(tick->clients[0])) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
alox_CacheClientStatus *entry = &tick->clients[tick->clients_count++];
|
||||||
|
entry->client_id = client->id;
|
||||||
|
entry->has_accel = false;
|
||||||
|
entry->has_tap = false;
|
||||||
|
|
||||||
|
if (want_accel) {
|
||||||
|
entry->has_accel = true;
|
||||||
|
entry->accel.valid = client->accel_valid;
|
||||||
|
if (client->accel_valid) {
|
||||||
|
entry->accel.x = client->accel_x;
|
||||||
|
entry->accel.y = client->accel_y;
|
||||||
|
entry->accel.z = client->accel_z;
|
||||||
|
entry->accel.age_ms =
|
||||||
|
client_registry_ms_since(client->accel_updated_at);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (want_tap) {
|
||||||
|
uint32_t kind = 0;
|
||||||
|
uint32_t age_ms = 0;
|
||||||
|
if (client_registry_take_tap(client->id, &kind, &age_ms)) {
|
||||||
|
entry->has_tap = true;
|
||||||
|
entry->tap.kind = tap_kind_from_registry(kind);
|
||||||
|
entry->tap.age_ms = age_ms;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool any_accel_stream(void) {
|
||||||
|
size_t count = client_registry_count();
|
||||||
|
for (size_t i = 0; i < count; i++) {
|
||||||
|
const client_info_t *client = client_registry_at(i);
|
||||||
|
if (client != NULL && client->accel_stream_enabled) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
static esp_err_t send_tick_truncated(alox_UartMessage *msg) {
|
||||||
|
/*
|
||||||
|
* ble_host_tx splits frames larger than ATT MTU-3 across notifies. Only
|
||||||
|
* shrink the tick if it cannot encode into MAX_PAYLOAD_SIZE.
|
||||||
|
*/
|
||||||
|
while (true) {
|
||||||
|
esp_err_t err = uart_send_uart_message(msg);
|
||||||
|
if (err == ESP_OK) {
|
||||||
|
return ESP_OK;
|
||||||
|
}
|
||||||
|
if (msg->payload.ble_stream_tick.clients_count == 0) {
|
||||||
|
ESP_LOGW(TAG, "tick too large even with 0 clients: %s",
|
||||||
|
esp_err_to_name(err));
|
||||||
|
return err;
|
||||||
|
}
|
||||||
|
msg->payload.ble_stream_tick.clients_count--;
|
||||||
|
ESP_LOGW(TAG, "truncating tick clients to %u for encode buffer",
|
||||||
|
(unsigned)msg->payload.ble_stream_tick.clients_count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void ble_stream_task(void *param) {
|
||||||
|
(void)param;
|
||||||
|
ESP_LOGI(TAG, "BLE stream task started period_ms=%lu",
|
||||||
|
(unsigned long)s_period_ms);
|
||||||
|
|
||||||
|
while (s_running) {
|
||||||
|
uint32_t period = s_period_ms;
|
||||||
|
if (period < BLE_STREAM_PERIOD_MIN_MS) {
|
||||||
|
period = BLE_STREAM_PERIOD_MIN_MS;
|
||||||
|
}
|
||||||
|
if (period > BLE_STREAM_PERIOD_MAX_MS) {
|
||||||
|
period = BLE_STREAM_PERIOD_MAX_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pause ticks during host OTA / ESP-NOW distribute so ATT bandwidth
|
||||||
|
* stays available for OTA_STATUS notifies. */
|
||||||
|
if (ble_host_ready() && any_accel_stream() && !ota_session_busy()) {
|
||||||
|
alox_UartMessage msg = alox_UartMessage_init_zero;
|
||||||
|
uart_cmd_init_response(&msg, alox_MessageType_BLE_STREAM_TICK,
|
||||||
|
alox_UartMessage_ble_stream_tick_tag);
|
||||||
|
msg.payload.ble_stream_tick.esp_timestamp_us =
|
||||||
|
(uint64_t)esp_timer_get_time();
|
||||||
|
msg.payload.ble_stream_tick.period_ms = period;
|
||||||
|
fill_clients(&msg.payload.ble_stream_tick);
|
||||||
|
|
||||||
|
esp_err_t err = send_tick_truncated(&msg);
|
||||||
|
if (err != ESP_OK) {
|
||||||
|
ESP_LOGW(TAG, "tick send failed: %s", esp_err_to_name(err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TickType_t delay_ticks = pdMS_TO_TICKS(period);
|
||||||
|
if (delay_ticks < 1) {
|
||||||
|
delay_ticks = 1;
|
||||||
|
}
|
||||||
|
vTaskDelay(delay_ticks);
|
||||||
|
}
|
||||||
|
|
||||||
|
s_task = NULL;
|
||||||
|
vTaskDelete(NULL);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ble_stream_set_period_ms(uint32_t period_ms) {
|
||||||
|
if (period_ms == 0) {
|
||||||
|
period_ms = BLE_STREAM_PERIOD_DEFAULT_MS;
|
||||||
|
}
|
||||||
|
if (period_ms < BLE_STREAM_PERIOD_MIN_MS) {
|
||||||
|
period_ms = BLE_STREAM_PERIOD_MIN_MS;
|
||||||
|
}
|
||||||
|
if (period_ms > BLE_STREAM_PERIOD_MAX_MS) {
|
||||||
|
period_ms = BLE_STREAM_PERIOD_MAX_MS;
|
||||||
|
}
|
||||||
|
s_period_ms = period_ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint32_t ble_stream_period_ms(void) { return s_period_ms; }
|
||||||
|
|
||||||
|
void ble_stream_update(void) {
|
||||||
|
const app_config_t *cfg = app_config_get();
|
||||||
|
bool want = cfg != NULL && cfg->host_ble && any_accel_stream();
|
||||||
|
|
||||||
|
if (want && !s_running) {
|
||||||
|
s_running = true;
|
||||||
|
if (xTaskCreate(ble_stream_task, "ble_stream", 8192, NULL, 5, &s_task) !=
|
||||||
|
pdPASS) {
|
||||||
|
s_running = false;
|
||||||
|
s_task = NULL;
|
||||||
|
ESP_LOGE(TAG, "failed to create ble_stream task");
|
||||||
|
}
|
||||||
|
} else if (!want && s_running) {
|
||||||
|
s_running = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
#ifndef BLE_STREAM_H
|
||||||
|
#define BLE_STREAM_H
|
||||||
|
|
||||||
|
#include <stdbool.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
|
||||||
|
/** Clamp and store BLE push period (10–16 ms; min = FreeRTOS tick). */
|
||||||
|
void ble_stream_set_period_ms(uint32_t period_ms);
|
||||||
|
|
||||||
|
uint32_t ble_stream_period_ms(void);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-evaluate whether the BLE push task should run (host_ble + any accel
|
||||||
|
* stream enabled). Safe to call from accel-stream handlers.
|
||||||
|
*/
|
||||||
|
void ble_stream_update(void);
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
#include "board_input.h"
|
||||||
|
#include "powerpod.h"
|
||||||
|
#include "client_registry.h"
|
||||||
|
#include "driver/gpio.h"
|
||||||
|
#if POWERPOD_BATTERY_UV_ENABLE
|
||||||
|
#include "battery_uv.h"
|
||||||
|
#endif
|
||||||
|
#include "esp_adc/adc_oneshot.h"
|
||||||
|
#include "esp_log.h"
|
||||||
|
#include "freertos/FreeRTOS.h"
|
||||||
|
#include "freertos/idf_additions.h"
|
||||||
|
#include "freertos/queue.h"
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
static const char *TAG_BTN = "[BTN]";
|
||||||
|
static const char *TAG_LIPO = "[LIPO]";
|
||||||
|
|
||||||
|
#define LIPO_SAMPLE_INTERVAL_MS 10000
|
||||||
|
#define BUTTON_QUEUE_LEN 4
|
||||||
|
#define BUTTON_DEBOUNCE_MS 80
|
||||||
|
#define LIPO_ADC_FULL_SCALE_MV 3300
|
||||||
|
#define LIPO_ADC_MAX_RAW 4095
|
||||||
|
|
||||||
|
static QueueHandle_t s_button_queue;
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
adc_oneshot_unit_handle_t unit;
|
||||||
|
adc_channel_t ch;
|
||||||
|
bool ok;
|
||||||
|
} lipo_adc_t;
|
||||||
|
|
||||||
|
static lipo_adc_t s_lipo1;
|
||||||
|
static lipo_adc_t s_lipo2;
|
||||||
|
|
||||||
|
static esp_err_t adc_init_gpio(int gpio, lipo_adc_t *out) {
|
||||||
|
out->unit = NULL;
|
||||||
|
out->ok = false;
|
||||||
|
|
||||||
|
adc_unit_t unit_id;
|
||||||
|
esp_err_t err = adc_oneshot_io_to_channel(gpio, &unit_id, &out->ch);
|
||||||
|
if (err != ESP_OK) {
|
||||||
|
ESP_LOGW(TAG_LIPO, "GPIO%d not an ADC channel: %s", gpio, esp_err_to_name(err));
|
||||||
|
return err;
|
||||||
|
}
|
||||||
|
|
||||||
|
adc_oneshot_unit_init_cfg_t init_cfg = {
|
||||||
|
.unit_id = unit_id,
|
||||||
|
};
|
||||||
|
err = adc_oneshot_new_unit(&init_cfg, &out->unit);
|
||||||
|
if (err != ESP_OK) {
|
||||||
|
ESP_LOGW(TAG_LIPO, "ADC unit %d init GPIO%d failed: %s", (int)unit_id, gpio,
|
||||||
|
esp_err_to_name(err));
|
||||||
|
return err;
|
||||||
|
}
|
||||||
|
|
||||||
|
adc_oneshot_chan_cfg_t chan_cfg = {
|
||||||
|
.atten = ADC_ATTEN_DB_12,
|
||||||
|
.bitwidth = ADC_BITWIDTH_DEFAULT,
|
||||||
|
};
|
||||||
|
err = adc_oneshot_config_channel(out->unit, out->ch, &chan_cfg);
|
||||||
|
if (err != ESP_OK) {
|
||||||
|
ESP_LOGW(TAG_LIPO, "ADC config GPIO%d failed: %s", gpio, esp_err_to_name(err));
|
||||||
|
adc_oneshot_del_unit(out->unit);
|
||||||
|
out->unit = NULL;
|
||||||
|
return err;
|
||||||
|
}
|
||||||
|
|
||||||
|
out->ok = true;
|
||||||
|
ESP_LOGI(TAG_LIPO, "GPIO%d ready (ADC unit %d)", gpio, (int)unit_id);
|
||||||
|
return ESP_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
static uint32_t raw_to_mv(int raw) {
|
||||||
|
if (raw < 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return (uint32_t)((raw * LIPO_ADC_FULL_SCALE_MV) / LIPO_ADC_MAX_RAW);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void sample_one_channel(const lipo_adc_t *adc, uint32_t *mv_out,
|
||||||
|
bool *valid_out) {
|
||||||
|
*valid_out = false;
|
||||||
|
*mv_out = 0;
|
||||||
|
if (adc == NULL || !adc->ok || adc->unit == NULL) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
int raw = 0;
|
||||||
|
if (adc_oneshot_read(adc->unit, adc->ch, &raw) == ESP_OK) {
|
||||||
|
*valid_out = true;
|
||||||
|
*mv_out = raw_to_mv(raw);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void board_input_read_lipo(board_lipo_reading_t *out) {
|
||||||
|
if (out == NULL) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
memset(out, 0, sizeof(*out));
|
||||||
|
sample_one_channel(&s_lipo1, &out->lipo1_mv, &out->lipo1_valid);
|
||||||
|
sample_one_channel(&s_lipo2, &out->lipo2_mv, &out->lipo2_valid);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void lipo_monitor_task(void *param) {
|
||||||
|
(void)param;
|
||||||
|
|
||||||
|
ESP_LOGI(TAG_LIPO, "monitor task (interval %d ms)", LIPO_SAMPLE_INTERVAL_MS);
|
||||||
|
|
||||||
|
while (1) {
|
||||||
|
board_lipo_reading_t reading;
|
||||||
|
board_input_read_lipo(&reading);
|
||||||
|
client_registry_set_master_battery(&reading);
|
||||||
|
|
||||||
|
ESP_LOGI(TAG_LIPO,
|
||||||
|
"LIPO1 GPIO%d %s %lu mV LIPO2 GPIO%d %s %lu mV",
|
||||||
|
V_LIPO_1_GPIO, reading.lipo1_valid ? "ok" : "n/a",
|
||||||
|
(unsigned long)reading.lipo1_mv, V_LIPO_2_GPIO,
|
||||||
|
reading.lipo2_valid ? "ok" : "n/a",
|
||||||
|
(unsigned long)reading.lipo2_mv);
|
||||||
|
|
||||||
|
#if POWERPOD_BATTERY_UV_ENABLE
|
||||||
|
battery_uv_poll(&reading);
|
||||||
|
#endif
|
||||||
|
|
||||||
|
vTaskDelay(pdMS_TO_TICKS(LIPO_SAMPLE_INTERVAL_MS));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void IRAM_ATTR button_isr(void *arg) {
|
||||||
|
(void)arg;
|
||||||
|
uint8_t one = 1;
|
||||||
|
BaseType_t wake = pdFALSE;
|
||||||
|
if (s_button_queue != NULL) {
|
||||||
|
xQueueSendFromISR(s_button_queue, &one, &wake);
|
||||||
|
if (wake) {
|
||||||
|
portYIELD_FROM_ISR();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void button_task(void *param) {
|
||||||
|
(void)param;
|
||||||
|
uint8_t evt;
|
||||||
|
|
||||||
|
while (1) {
|
||||||
|
if (xQueueReceive(s_button_queue, &evt, portMAX_DELAY) != pdTRUE) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
vTaskDelay(pdMS_TO_TICKS(BUTTON_DEBOUNCE_MS));
|
||||||
|
if (gpio_get_level(TASTER_GPIO) == 0) {
|
||||||
|
ESP_LOGI(TAG_BTN, "pressed (GPIO%d)", TASTER_GPIO);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static esp_err_t init_button(void) {
|
||||||
|
if (V_LIPO_2_GPIO == TASTER_GPIO) {
|
||||||
|
ESP_LOGW(TAG_BTN,
|
||||||
|
"GPIO%d shared with V_LIPO_2 — button only, no ADC on that pin",
|
||||||
|
TASTER_GPIO);
|
||||||
|
}
|
||||||
|
|
||||||
|
s_button_queue = xQueueCreate(BUTTON_QUEUE_LEN, sizeof(uint8_t));
|
||||||
|
if (s_button_queue == NULL) {
|
||||||
|
return ESP_ERR_NO_MEM;
|
||||||
|
}
|
||||||
|
|
||||||
|
gpio_config_t cfg = {
|
||||||
|
.pin_bit_mask = 1ULL << TASTER_GPIO,
|
||||||
|
.mode = GPIO_MODE_INPUT,
|
||||||
|
.pull_up_en = GPIO_PULLUP_ENABLE,
|
||||||
|
.pull_down_en = GPIO_PULLDOWN_DISABLE,
|
||||||
|
.intr_type = GPIO_INTR_NEGEDGE,
|
||||||
|
};
|
||||||
|
ESP_ERROR_CHECK(gpio_config(&cfg));
|
||||||
|
|
||||||
|
esp_err_t err = gpio_install_isr_service(0);
|
||||||
|
if (err != ESP_OK && err != ESP_ERR_INVALID_STATE) {
|
||||||
|
return err;
|
||||||
|
}
|
||||||
|
|
||||||
|
err = gpio_isr_handler_add(TASTER_GPIO, button_isr, NULL);
|
||||||
|
if (err != ESP_OK) {
|
||||||
|
return err;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (xTaskCreate(button_task, "btn", 2048, NULL, 2, NULL) != pdPASS) {
|
||||||
|
return ESP_ERR_NO_MEM;
|
||||||
|
}
|
||||||
|
|
||||||
|
ESP_LOGI(TAG_BTN, "ready GPIO%d (active low)", TASTER_GPIO);
|
||||||
|
return ESP_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
static esp_err_t init_lipo_adc_hw(void) {
|
||||||
|
memset(&s_lipo1, 0, sizeof(s_lipo1));
|
||||||
|
memset(&s_lipo2, 0, sizeof(s_lipo2));
|
||||||
|
|
||||||
|
adc_init_gpio(V_LIPO_1_GPIO, &s_lipo1);
|
||||||
|
|
||||||
|
if (V_LIPO_2_GPIO == TASTER_GPIO) {
|
||||||
|
ESP_LOGW(TAG_LIPO, "LIPO2 on GPIO%d skipped (button uses same pin)",
|
||||||
|
V_LIPO_2_GPIO);
|
||||||
|
} else {
|
||||||
|
adc_init_gpio(V_LIPO_2_GPIO, &s_lipo2);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!s_lipo1.ok && !s_lipo2.ok) {
|
||||||
|
return ESP_FAIL;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ESP_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
esp_err_t board_input_init_adc_only(void) { return init_lipo_adc_hw(); }
|
||||||
|
|
||||||
|
esp_err_t board_input_start_lipo_monitor(void) {
|
||||||
|
if (!s_lipo1.ok && !s_lipo2.ok) {
|
||||||
|
return ESP_FAIL;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (xTaskCreate(lipo_monitor_task, "lipo_mon", 3072, NULL, 1, NULL) != pdPASS) {
|
||||||
|
return ESP_ERR_NO_MEM;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ESP_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
esp_err_t board_input_init_button(void) { return init_button(); }
|
||||||
|
|
||||||
|
esp_err_t board_input_init(void) {
|
||||||
|
esp_err_t err = board_input_init_adc_only();
|
||||||
|
if (err != ESP_OK) {
|
||||||
|
ESP_LOGW(TAG_LIPO, "ADC init failed: %s", esp_err_to_name(err));
|
||||||
|
} else {
|
||||||
|
err = board_input_start_lipo_monitor();
|
||||||
|
if (err != ESP_OK) {
|
||||||
|
ESP_LOGW(TAG_LIPO, "monitor task failed: %s", esp_err_to_name(err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
err = board_input_init_button();
|
||||||
|
if (err != ESP_OK) {
|
||||||
|
ESP_LOGW(TAG_BTN, "init failed: %s", esp_err_to_name(err));
|
||||||
|
}
|
||||||
|
|
||||||
|
return ESP_OK;
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
#ifndef BOARD_INPUT_H
|
||||||
|
#define BOARD_INPUT_H
|
||||||
|
|
||||||
|
#include <stdbool.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
|
||||||
|
#include "esp_err.h"
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
bool lipo1_valid;
|
||||||
|
bool lipo2_valid;
|
||||||
|
uint32_t lipo1_mv;
|
||||||
|
uint32_t lipo2_mv;
|
||||||
|
} board_lipo_reading_t;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Button (log on press) and LiPo ADC sampling (background log every 10 s).
|
||||||
|
* TODO: Pin assignments come from powerpod.h and may not match final hardware yet.
|
||||||
|
*/
|
||||||
|
esp_err_t board_input_init(void);
|
||||||
|
|
||||||
|
/** LiPo ADC channels only (no button, no background task). */
|
||||||
|
esp_err_t board_input_init_adc_only(void);
|
||||||
|
|
||||||
|
/** Start 10 s LiPo monitor task (normal boot only). */
|
||||||
|
esp_err_t board_input_start_lipo_monitor(void);
|
||||||
|
|
||||||
|
/** Front-panel button debounce task. */
|
||||||
|
esp_err_t board_input_init_button(void);
|
||||||
|
|
||||||
|
/** On-demand ADC read of both LiPo sense inputs (if configured). */
|
||||||
|
void board_input_read_lipo(board_lipo_reading_t *out);
|
||||||
|
|
||||||
|
#endif
|
||||||
+357
-229
@@ -1,134 +1,99 @@
|
|||||||
|
/**
|
||||||
|
* BMA456H integration for Powerpod (ESP-IDF I2C master + Bosch SensorAPI).
|
||||||
|
*
|
||||||
|
* Polls accelerometer at 10 Hz; tap events arrive on BMA456_INT_GPIO.
|
||||||
|
* Accel log + ESP-NOW stream share a software deadzone filter (emit on change).
|
||||||
|
*/
|
||||||
|
|
||||||
#include "bosch456.h"
|
#include "bosch456.h"
|
||||||
#include "bma4.h"
|
#include "bma4.h"
|
||||||
#include "bma456h.h"
|
|
||||||
#include "bma4_defs.h"
|
#include "bma4_defs.h"
|
||||||
|
#include "bma456h.h"
|
||||||
#include "driver/gpio.h"
|
#include "driver/gpio.h"
|
||||||
#include "driver/i2c_master.h"
|
#include "driver/i2c_master.h"
|
||||||
#include "esp_err.h"
|
#include "esp_err.h"
|
||||||
#include "esp_log.h"
|
#include "esp_log.h"
|
||||||
#include "freertos/idf_additions.h"
|
#include "freertos/idf_additions.h"
|
||||||
#include "hal/gpio_types.h"
|
#include "freertos/semphr.h"
|
||||||
#include <rom/ets_sys.h>
|
#include <rom/ets_sys.h>
|
||||||
#include <stdint.h>
|
#include <string.h>
|
||||||
#include <stdio.h>
|
|
||||||
#include <stdlib.h>
|
|
||||||
|
|
||||||
static const char *TAG = "[BMA456]";
|
static const char *TAG = "[BMA456]";
|
||||||
|
|
||||||
static i2c_master_dev_handle_t bma456_dev_handle;
|
#define BMA4_READ_WRITE_LEN UINT8_C(46)
|
||||||
|
#define BMA4_I2C_MAX_WRITE (1u + BMA4_READ_WRITE_LEN)
|
||||||
|
|
||||||
|
#define SENSOR_POLL_MS 100
|
||||||
|
|
||||||
|
static i2c_master_dev_handle_t s_bma456_dev;
|
||||||
static bool s_bma456_ready;
|
static bool s_bma456_ready;
|
||||||
static struct bma4_dev bma456_struct;
|
static struct bma4_dev s_bma456;
|
||||||
static uint32_t s_accel_deadzone = BMA456_DEFAULT_ACCEL_DEADZONE;
|
static uint32_t s_accel_deadzone = BMA456_DEFAULT_ACCEL_DEADZONE;
|
||||||
static int16_t s_last_x;
|
static int16_t s_last_x;
|
||||||
static int16_t s_last_y;
|
static int16_t s_last_y;
|
||||||
static int16_t s_last_z;
|
static int16_t s_last_z;
|
||||||
static bool s_have_last_sample;
|
static bool s_have_last_sample;
|
||||||
|
static int16_t s_stream_last_x;
|
||||||
|
static int16_t s_stream_last_y;
|
||||||
|
static int16_t s_stream_last_z;
|
||||||
|
static bool s_stream_have_last;
|
||||||
|
|
||||||
volatile uint8_t interrupt_status = 0;
|
static volatile bool s_int_pending;
|
||||||
uint8_t int_line;
|
static SemaphoreHandle_t s_accel_mutex;
|
||||||
struct bma4_int_pin_config pin_config = {0};
|
static bma456_tap_handler_t s_tap_handler;
|
||||||
uint16_t int_status = 0;
|
static void *s_tap_handler_ctx;
|
||||||
|
static bma456_tap_config_t s_tap_config = BMA456_TAP_CONFIG_DEFAULT;
|
||||||
|
|
||||||
#define BMA4_READ_WRITE_LEN UINT8_C(46)
|
static esp_err_t check_bma4(const char *api_name, int8_t rslt);
|
||||||
#define BMA456W_INT_PIN 10
|
|
||||||
#define BMA456W_INT_PIN2 9
|
|
||||||
|
|
||||||
static void interrupt_callback(void *) {
|
/* Bosch SensorAPI platform hooks (intf_ptr → i2c_master_dev_handle_t *). */
|
||||||
interrupt_status = 1;
|
|
||||||
// ESP_LOGI("INTERRUPT", "STEP DETECTED");
|
|
||||||
}
|
|
||||||
|
|
||||||
/******************************************************************************/
|
|
||||||
/*! User interface functions */
|
|
||||||
|
|
||||||
/*!
|
|
||||||
* I2C read function map to ESP platform
|
|
||||||
*/
|
|
||||||
BMA4_INTF_RET_TYPE bma4_i2c_read(uint8_t reg_addr, uint8_t *reg_data,
|
BMA4_INTF_RET_TYPE bma4_i2c_read(uint8_t reg_addr, uint8_t *reg_data,
|
||||||
uint32_t len, void *intf_ptr) {
|
uint32_t len, void *intf_ptr) {
|
||||||
if (bma456_dev_handle == NULL) {
|
if (s_bma456_dev == NULL) {
|
||||||
return BMA4_E_COM_FAIL;
|
return BMA4_E_COM_FAIL;
|
||||||
}
|
}
|
||||||
esp_err_t err = i2c_master_transmit_receive(bma456_dev_handle, ®_addr, 1,
|
esp_err_t err = i2c_master_transmit_receive(s_bma456_dev, ®_addr, 1,
|
||||||
reg_data, len, -1);
|
reg_data, len, -1);
|
||||||
return (err == ESP_OK) ? BMA4_OK : BMA4_E_COM_FAIL;
|
return (err == ESP_OK) ? BMA4_OK : BMA4_E_COM_FAIL;
|
||||||
// return BMA4_OK;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/*!
|
|
||||||
* I2C write function map to ESP platform
|
|
||||||
*/
|
|
||||||
BMA4_INTF_RET_TYPE bma4_i2c_write(uint8_t reg_addr, const uint8_t *reg_data,
|
BMA4_INTF_RET_TYPE bma4_i2c_write(uint8_t reg_addr, const uint8_t *reg_data,
|
||||||
uint32_t len, void *intf_ptr) {
|
uint32_t len, void *intf_ptr) {
|
||||||
if (bma456_dev_handle == NULL) {
|
(void)intf_ptr;
|
||||||
|
if (s_bma456_dev == NULL || reg_data == NULL) {
|
||||||
|
return BMA4_E_COM_FAIL;
|
||||||
|
}
|
||||||
|
if (len > BMA4_READ_WRITE_LEN) {
|
||||||
return BMA4_E_COM_FAIL;
|
return BMA4_E_COM_FAIL;
|
||||||
}
|
}
|
||||||
uint8_t *buffer = malloc(len + 1);
|
|
||||||
if (!buffer)
|
|
||||||
return BMA4_E_NULL_PTR;
|
|
||||||
|
|
||||||
|
uint8_t buffer[BMA4_I2C_MAX_WRITE];
|
||||||
buffer[0] = reg_addr;
|
buffer[0] = reg_addr;
|
||||||
|
|
||||||
ESP_LOGI("I2CWrite", "Message Length: %d", len);
|
|
||||||
|
|
||||||
memcpy(&buffer[1], reg_data, len);
|
memcpy(&buffer[1], reg_data, len);
|
||||||
|
|
||||||
esp_err_t err = i2c_master_transmit(bma456_dev_handle, buffer, len + 1, -1);
|
esp_err_t err =
|
||||||
free(buffer);
|
i2c_master_transmit(s_bma456_dev, buffer, (size_t)(len + 1), -1);
|
||||||
|
|
||||||
return (err == ESP_OK) ? BMA4_OK : BMA4_E_COM_FAIL;
|
return (err == ESP_OK) ? BMA4_OK : BMA4_E_COM_FAIL;
|
||||||
// return BMA4_OK;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/*!
|
|
||||||
* Delay function map to ESP platform
|
|
||||||
*/
|
|
||||||
void bma4_delay_us(uint32_t period, void *intf_ptr) {
|
void bma4_delay_us(uint32_t period, void *intf_ptr) {
|
||||||
|
(void)intf_ptr;
|
||||||
uint32_t wait_ms = period / 1000;
|
uint32_t wait_ms = period / 1000;
|
||||||
uint32_t wait_us = period % 1000;
|
uint32_t wait_us = period % 1000;
|
||||||
if (wait_ms) {
|
if (wait_ms > 0) {
|
||||||
vTaskDelay(pdMS_TO_TICKS(wait_ms));
|
vTaskDelay(pdMS_TO_TICKS(wait_ms));
|
||||||
}
|
}
|
||||||
|
if (wait_us > 0) {
|
||||||
ets_delay_us(wait_us);
|
ets_delay_us(wait_us);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/*!
|
|
||||||
* @brief Prints the execution status of the APIs.
|
|
||||||
*/
|
|
||||||
void bma4_error_codes_print_result(const char api_name[], int8_t rslt) {
|
void bma4_error_codes_print_result(const char api_name[], int8_t rslt) {
|
||||||
if (rslt == BMA4_OK) {
|
if (rslt == BMA4_OK) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
ESP_LOGW(TAG, "%s failed: %d", api_name, (int)rslt);
|
||||||
ESP_LOGI("BMA4_I2C", "%s\t", api_name);
|
|
||||||
|
|
||||||
switch (rslt) {
|
|
||||||
case BMA4_E_NULL_PTR:
|
|
||||||
ESP_LOGI("BMA4_I2C", "Error [%d] : Null pointer\r\n", rslt);
|
|
||||||
break;
|
|
||||||
case BMA4_E_COM_FAIL:
|
|
||||||
ESP_LOGI("BMA4_I2C", "Error [%d] : Communication failure\r\n", rslt);
|
|
||||||
break;
|
|
||||||
case BMA4_E_CONFIG_STREAM_ERROR:
|
|
||||||
ESP_LOGI("BMA4_I2C", "Error [%d] : Invalid configuration stream\r\n", rslt);
|
|
||||||
break;
|
|
||||||
case BMA4_E_SELF_TEST_FAIL:
|
|
||||||
ESP_LOGI("BMA4_I2C", "Error [%d] : Self test failed\r\n", rslt);
|
|
||||||
break;
|
|
||||||
case BMA4_E_INVALID_SENSOR:
|
|
||||||
ESP_LOGI("BMA4_I2C", "Error [%d] : Device not found\r\n", rslt);
|
|
||||||
break;
|
|
||||||
case BMA4_E_OUT_OF_RANGE:
|
|
||||||
ESP_LOGI("BMA4_I2C", "Error [%d] : Out of Range\r\n", rslt);
|
|
||||||
break;
|
|
||||||
case BMA4_E_AVG_MODE_INVALID_CONF:
|
|
||||||
ESP_LOGI("BMA4_I2C", "Error [%d] : Invalid bandwidth/ODR combination\r\n",
|
|
||||||
rslt);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
ESP_LOGI("BMA4_I2C", "Error [%d] : Unknown error code\r\n", rslt);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
||||||
@@ -158,18 +123,152 @@ bool bma456_is_ready(void) { return s_bma456_ready; }
|
|||||||
void bma456_set_accel_deadzone(uint32_t deadzone_lsb) {
|
void bma456_set_accel_deadzone(uint32_t deadzone_lsb) {
|
||||||
s_accel_deadzone = deadzone_lsb;
|
s_accel_deadzone = deadzone_lsb;
|
||||||
s_have_last_sample = false;
|
s_have_last_sample = false;
|
||||||
|
s_stream_have_last = false;
|
||||||
if (s_bma456_ready) {
|
if (s_bma456_ready) {
|
||||||
ESP_LOGI(TAG, "accel deadzone applied: %lu LSB", (unsigned long)deadzone_lsb);
|
ESP_LOGI(TAG, "accel deadzone %lu LSB", (unsigned long)deadzone_lsb);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
uint32_t bma456_get_accel_deadzone(void) { return s_accel_deadzone; }
|
uint32_t bma456_get_accel_deadzone(void) { return s_accel_deadzone; }
|
||||||
|
|
||||||
void bma456_report_accel_if_changed(int16_t x, int16_t y, int16_t z) {
|
void bma456_accel_stream_reset_baseline(void) { s_stream_have_last = false; }
|
||||||
if (!s_bma456_ready) {
|
|
||||||
|
bool bma456_accel_stream_take_if_changed(int16_t x, int16_t y, int16_t z) {
|
||||||
|
if (s_stream_have_last) {
|
||||||
|
if (axis_delta(x, s_stream_last_x) <= (int16_t)s_accel_deadzone &&
|
||||||
|
axis_delta(y, s_stream_last_y) <= (int16_t)s_accel_deadzone &&
|
||||||
|
axis_delta(z, s_stream_last_z) <= (int16_t)s_accel_deadzone) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s_stream_last_x = x;
|
||||||
|
s_stream_last_y = y;
|
||||||
|
s_stream_last_z = z;
|
||||||
|
s_stream_have_last = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool bma456_tap_config_valid(const bma456_tap_config_t *cfg) {
|
||||||
|
if (cfg == NULL) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (cfg->tap_sens_thres > 15u) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (cfg->axis_sel > 2u) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (cfg->wait_for_timeout > 1u) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!cfg->enable_single && !cfg->enable_double && !cfg->enable_triple) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void bma456_get_tap_config(bma456_tap_config_t *cfg_out) {
|
||||||
|
if (cfg_out == NULL) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!sample_exceeds_deadzone(x, y, z)) {
|
*cfg_out = s_tap_config;
|
||||||
|
}
|
||||||
|
|
||||||
|
static esp_err_t apply_tap_parameters(const bma456_tap_config_t *cfg) {
|
||||||
|
struct bma456h_multitap_settings tap_settings = {0};
|
||||||
|
int8_t ret = bma456h_tap_get_parameter(&tap_settings, &s_bma456);
|
||||||
|
if (check_bma4("bma456h_tap_get_parameter", ret) != ESP_OK) {
|
||||||
|
return ESP_FAIL;
|
||||||
|
}
|
||||||
|
tap_settings.tap_sens_thres = cfg->tap_sens_thres;
|
||||||
|
tap_settings.max_gest_dur = cfg->max_gest_dur;
|
||||||
|
tap_settings.tap_shock_dur = cfg->tap_shock_dur;
|
||||||
|
tap_settings.quite_time_after_gest = cfg->quite_time_after_gest;
|
||||||
|
tap_settings.wait_for_timeout = cfg->wait_for_timeout;
|
||||||
|
tap_settings.axis_sel = cfg->axis_sel;
|
||||||
|
ret = bma456h_tap_set_parameter(&tap_settings, &s_bma456);
|
||||||
|
if (check_bma4("bma456h_tap_set_parameter", ret) != ESP_OK) {
|
||||||
|
return ESP_FAIL;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Disable all tap kinds first, then enable the requested set. */
|
||||||
|
uint16_t all_taps =
|
||||||
|
BMA456H_SINGLE_TAP_EN | BMA456H_DOUBLE_TAP_EN | BMA456H_TRIPLE_TAP_EN;
|
||||||
|
ret = bma456h_feature_enable(all_taps, BMA4_DISABLE, &s_bma456);
|
||||||
|
if (check_bma4("bma456h_feature_enable(disable taps)", ret) != ESP_OK) {
|
||||||
|
return ESP_FAIL;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint16_t tap_features = 0;
|
||||||
|
if (cfg->enable_single) {
|
||||||
|
tap_features |= BMA456H_SINGLE_TAP_EN;
|
||||||
|
}
|
||||||
|
if (cfg->enable_double) {
|
||||||
|
tap_features |= BMA456H_DOUBLE_TAP_EN;
|
||||||
|
}
|
||||||
|
if (cfg->enable_triple) {
|
||||||
|
tap_features |= BMA456H_TRIPLE_TAP_EN;
|
||||||
|
}
|
||||||
|
ret = bma456h_feature_enable(tap_features, BMA4_ENABLE, &s_bma456);
|
||||||
|
if (check_bma4("bma456h_feature_enable(taps)", ret) != ESP_OK) {
|
||||||
|
return ESP_FAIL;
|
||||||
|
}
|
||||||
|
|
||||||
|
ESP_LOGI(TAG,
|
||||||
|
"tap config sens=%u max_gest=%u shock=%u quiet=%u wait=%u axis=%u "
|
||||||
|
"(single=%d double=%d triple=%d)",
|
||||||
|
(unsigned)cfg->tap_sens_thres, (unsigned)cfg->max_gest_dur,
|
||||||
|
(unsigned)cfg->tap_shock_dur, (unsigned)cfg->quite_time_after_gest,
|
||||||
|
(unsigned)cfg->wait_for_timeout, (unsigned)cfg->axis_sel,
|
||||||
|
cfg->enable_single, cfg->enable_double, cfg->enable_triple);
|
||||||
|
return ESP_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
esp_err_t bma456_set_tap_config(const bma456_tap_config_t *cfg) {
|
||||||
|
if (!bma456_tap_config_valid(cfg)) {
|
||||||
|
return ESP_ERR_INVALID_ARG;
|
||||||
|
}
|
||||||
|
if (s_bma456_ready) {
|
||||||
|
esp_err_t err = apply_tap_parameters(cfg);
|
||||||
|
if (err != ESP_OK) {
|
||||||
|
return err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s_tap_config = *cfg;
|
||||||
|
return ESP_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
void bma456_set_tap_handler(bma456_tap_handler_t handler, void *ctx) {
|
||||||
|
s_tap_handler = handler;
|
||||||
|
s_tap_handler_ctx = ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
esp_err_t bma456_read_accel(int16_t *x, int16_t *y, int16_t *z) {
|
||||||
|
if (!s_bma456_ready || x == NULL || y == NULL || z == NULL) {
|
||||||
|
return ESP_ERR_INVALID_STATE;
|
||||||
|
}
|
||||||
|
if (s_accel_mutex == NULL ||
|
||||||
|
xSemaphoreTake(s_accel_mutex, pdMS_TO_TICKS(500)) != pdTRUE) {
|
||||||
|
return ESP_ERR_TIMEOUT;
|
||||||
|
}
|
||||||
|
|
||||||
|
struct bma4_accel sens_data = {0};
|
||||||
|
int8_t ret = bma4_read_accel_xyz(&sens_data, &s_bma456);
|
||||||
|
xSemaphoreGive(s_accel_mutex);
|
||||||
|
|
||||||
|
if (ret != BMA4_OK) {
|
||||||
|
bma4_error_codes_print_result("bma4_read_accel_xyz", ret);
|
||||||
|
return ESP_FAIL;
|
||||||
|
}
|
||||||
|
|
||||||
|
*x = sens_data.x;
|
||||||
|
*y = sens_data.y;
|
||||||
|
*z = sens_data.z;
|
||||||
|
return ESP_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
void bma456_report_accel_if_changed(int16_t x, int16_t y, int16_t z) {
|
||||||
|
if (!s_bma456_ready || !sample_exceeds_deadzone(x, y, z)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,165 +281,110 @@ void bma456_report_accel_if_changed(int16_t x, int16_t y, int16_t z) {
|
|||||||
(unsigned long)s_accel_deadzone);
|
(unsigned long)s_accel_deadzone);
|
||||||
}
|
}
|
||||||
|
|
||||||
static void remove_bma456_device(void) {
|
static void IRAM_ATTR bma456_int_isr(void *arg) {
|
||||||
if (bma456_dev_handle != NULL) {
|
(void)arg;
|
||||||
i2c_master_bus_rm_device(bma456_dev_handle);
|
s_int_pending = true;
|
||||||
bma456_dev_handle = NULL;
|
|
||||||
}
|
|
||||||
s_bma456_ready = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void read_sensor_task(void *params) {
|
static void handle_tap_interrupt(void) {
|
||||||
int8_t ret;
|
uint16_t int_status = 0;
|
||||||
struct bma4_accel sens_data = {0};
|
int8_t ret = bma456h_read_int_status(&int_status, &s_bma456);
|
||||||
|
if (ret != BMA4_OK) {
|
||||||
|
bma4_error_codes_print_result("bma456h_read_int_status", ret);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
struct bma456h_out_state tap_out = {0};
|
||||||
|
ret = bma456h_output_state(&tap_out, &s_bma456);
|
||||||
|
if (ret != BMA4_OK) {
|
||||||
|
bma4_error_codes_print_result("bma456h_output_state", ret);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tap_out.single_tap) {
|
||||||
|
ESP_LOGI(TAG, "tap: single");
|
||||||
|
if (s_tap_handler != NULL) {
|
||||||
|
s_tap_handler(BMA456_TAP_SINGLE, s_tap_handler_ctx);
|
||||||
|
}
|
||||||
|
} else if (tap_out.double_tap) {
|
||||||
|
ESP_LOGI(TAG, "tap: double");
|
||||||
|
if (s_tap_handler != NULL) {
|
||||||
|
s_tap_handler(BMA456_TAP_DOUBLE, s_tap_handler_ctx);
|
||||||
|
}
|
||||||
|
} else if (tap_out.triple_tap) {
|
||||||
|
ESP_LOGI(TAG, "tap: triple");
|
||||||
|
if (s_tap_handler != NULL) {
|
||||||
|
s_tap_handler(BMA456_TAP_TRIPLE, s_tap_handler_ctx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void remove_bma456_device(void) {
|
||||||
|
if (s_bma456_ready) {
|
||||||
|
gpio_isr_handler_remove(BMA456_INT_GPIO);
|
||||||
|
}
|
||||||
|
if (s_bma456_dev != NULL) {
|
||||||
|
i2c_master_bus_rm_device(s_bma456_dev);
|
||||||
|
s_bma456_dev = NULL;
|
||||||
|
}
|
||||||
|
s_bma456_ready = false;
|
||||||
|
s_int_pending = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void read_sensor_task(void *param) {
|
||||||
|
(void)param;
|
||||||
|
|
||||||
if (!s_bma456_ready) {
|
if (!s_bma456_ready) {
|
||||||
vTaskDelete(NULL);
|
vTaskDelete(NULL);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct bma4_accel sens_data = {0};
|
||||||
|
|
||||||
while (1) {
|
while (1) {
|
||||||
ret = bma4_read_accel_xyz(&sens_data, &bma456_struct);
|
bool got_sample = false;
|
||||||
|
if (s_accel_mutex != NULL &&
|
||||||
|
xSemaphoreTake(s_accel_mutex, pdMS_TO_TICKS(500)) == pdTRUE) {
|
||||||
|
int8_t ret = bma4_read_accel_xyz(&sens_data, &s_bma456);
|
||||||
|
xSemaphoreGive(s_accel_mutex);
|
||||||
if (ret == BMA4_OK) {
|
if (ret == BMA4_OK) {
|
||||||
bma456_report_accel_if_changed(sens_data.x, sens_data.y, sens_data.z);
|
got_sample = true;
|
||||||
} else {
|
} else {
|
||||||
bma4_error_codes_print_result("bma4_read_accel_xyz", ret);
|
bma4_error_codes_print_result("bma4_read_accel_xyz", ret);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (interrupt_status) {
|
|
||||||
ESP_LOGI("INTERRUPT", "Da war der Interrupt resetting");
|
|
||||||
interrupt_status = 0;
|
|
||||||
ret = bma456h_read_int_status(&int_status, &bma456_struct);
|
|
||||||
bma4_error_codes_print_result("bma456w_step_counter_output status", ret);
|
|
||||||
|
|
||||||
int8_t rslt;
|
|
||||||
struct bma456h_out_state tap_out = {0};
|
|
||||||
|
|
||||||
rslt = bma456h_output_state(&tap_out, &bma456_struct);
|
|
||||||
|
|
||||||
if (BMA4_OK == rslt) {
|
|
||||||
/* Enters only if the obtained interrupt is single-tap */
|
|
||||||
if (tap_out.single_tap) {
|
|
||||||
ESP_LOGI("INTERRUPT", "Single Tap interrupt occurred\n");
|
|
||||||
}
|
|
||||||
/* Enters only if the obtained interrupt is double-tap */
|
|
||||||
else if (tap_out.double_tap) {
|
|
||||||
ESP_LOGI("INTERRUPT", "Double Tap interrupt occurred\n");
|
|
||||||
}
|
|
||||||
/* Enters only if the obtained interrupt is triple-tap */
|
|
||||||
else if (tap_out.triple_tap) {
|
|
||||||
ESP_LOGI("INTERRUPT", "Triple Tap interrupt occurred\n");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
if (got_sample) {
|
||||||
|
bma456_report_accel_if_changed(sens_data.x, sens_data.y, sens_data.z);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ESP_LOGI("i2c", "X:%d, Y%d, Z%d", sens_data.x, sens_data.y, sens_data.z);
|
if (s_int_pending) {
|
||||||
vTaskDelay(pdMS_TO_TICKS(100));
|
s_int_pending = false;
|
||||||
|
handle_tap_interrupt();
|
||||||
|
}
|
||||||
|
|
||||||
|
vTaskDelay(pdMS_TO_TICKS(SENSOR_POLL_MS));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
esp_err_t init_bma456(i2c_master_bus_handle_t bus_handle) {
|
static esp_err_t configure_tap_interrupt(void) {
|
||||||
int8_t ret;
|
|
||||||
esp_err_t err;
|
esp_err_t err;
|
||||||
|
int8_t ret;
|
||||||
|
const uint8_t int_line = BMA4_INTR2_MAP;
|
||||||
|
|
||||||
s_bma456_ready = false;
|
if (apply_tap_parameters(&s_tap_config) != ESP_OK) {
|
||||||
bma456_dev_handle = NULL;
|
return ESP_FAIL;
|
||||||
|
|
||||||
if (bus_handle == NULL) {
|
|
||||||
return ESP_ERR_INVALID_ARG;
|
|
||||||
}
|
|
||||||
|
|
||||||
i2c_device_config_t dev_cfg_bma456 = {
|
|
||||||
.dev_addr_length = I2C_ADDR_BIT_LEN_7,
|
|
||||||
.device_address = BMA456_ADDRESS,
|
|
||||||
.scl_speed_hz = 100000,
|
|
||||||
};
|
|
||||||
|
|
||||||
err = i2c_master_bus_add_device(bus_handle, &dev_cfg_bma456, &bma456_dev_handle);
|
|
||||||
if (err != ESP_OK) {
|
|
||||||
return err;
|
|
||||||
}
|
|
||||||
|
|
||||||
bma456_struct.intf = BMA4_I2C_INTF;
|
|
||||||
bma456_struct.bus_read = bma4_i2c_read;
|
|
||||||
bma456_struct.bus_write = bma4_i2c_write;
|
|
||||||
bma456_struct.delay_us = bma4_delay_us;
|
|
||||||
bma456_struct.read_write_len = BMA4_READ_WRITE_LEN;
|
|
||||||
bma456_struct.intf_ptr = &bma456_dev_handle;
|
|
||||||
bma456_struct.chip_id = 0;
|
|
||||||
|
|
||||||
ret = bma456h_init(&bma456_struct);
|
|
||||||
if (check_bma4("bma456h_init", ret) != ESP_OK) {
|
|
||||||
remove_bma456_device();
|
|
||||||
return ESP_ERR_NOT_FOUND;
|
|
||||||
}
|
|
||||||
|
|
||||||
ESP_LOGI(TAG, "chip id 0x%02x", bma456_struct.chip_id);
|
|
||||||
|
|
||||||
ret = bma4_soft_reset(&bma456_struct);
|
|
||||||
if (check_bma4("bma4_soft_reset", ret) != ESP_OK) {
|
|
||||||
goto fail;
|
|
||||||
}
|
|
||||||
vTaskDelay(pdMS_TO_TICKS(20));
|
|
||||||
|
|
||||||
ret = bma4_set_advance_power_save(BMA4_DISABLE, &bma456_struct);
|
|
||||||
if (check_bma4("bma4_set_advance_power_save", ret) != ESP_OK) {
|
|
||||||
goto fail;
|
|
||||||
}
|
|
||||||
vTaskDelay(pdMS_TO_TICKS(10));
|
|
||||||
|
|
||||||
ret = bma456h_write_config_file(&bma456_struct);
|
|
||||||
if (check_bma4("bma456h_write_config_file", ret) != ESP_OK) {
|
|
||||||
goto fail;
|
|
||||||
}
|
|
||||||
|
|
||||||
struct bma4_accel_config accel_config;
|
|
||||||
bma4_get_accel_config(&accel_config, &bma456_struct);
|
|
||||||
accel_config.range = BMA4_ACCEL_RANGE_2G;
|
|
||||||
ret = bma4_set_accel_config(&accel_config, &bma456_struct);
|
|
||||||
if (check_bma4("bma4_set_accel_config", ret) != ESP_OK) {
|
|
||||||
goto fail;
|
|
||||||
}
|
|
||||||
|
|
||||||
ret = bma4_set_accel_enable(BMA4_ENABLE, &bma456_struct);
|
|
||||||
if (check_bma4("bma4_set_accel_enable", ret) != ESP_OK) {
|
|
||||||
goto fail;
|
|
||||||
}
|
|
||||||
|
|
||||||
struct bma456h_multitap_settings tap_settings = {0};
|
|
||||||
ret = bma456h_tap_get_parameter(&tap_settings, &bma456_struct);
|
|
||||||
if (check_bma4("bma456h_tap_get_parameter", ret) != ESP_OK) {
|
|
||||||
goto fail;
|
|
||||||
}
|
|
||||||
tap_settings.tap_sens_thres = 0;
|
|
||||||
ret = bma456h_tap_set_parameter(&tap_settings, &bma456_struct);
|
|
||||||
if (check_bma4("bma456h_tap_set_parameter", ret) != ESP_OK) {
|
|
||||||
goto fail;
|
|
||||||
}
|
|
||||||
|
|
||||||
ret = bma456h_feature_enable(
|
|
||||||
(BMA456H_SINGLE_TAP_EN | BMA456H_DOUBLE_TAP_EN | BMA456H_TRIPLE_TAP_EN),
|
|
||||||
BMA4_ENABLE, &bma456_struct);
|
|
||||||
if (check_bma4("bma456h_feature_enable", ret) != ESP_OK) {
|
|
||||||
goto fail;
|
|
||||||
}
|
|
||||||
|
|
||||||
ret = bma456h_step_counter_set_watermark(1, &bma456_struct);
|
|
||||||
if (check_bma4("bma456h_step_counter_set_watermark", ret) != ESP_OK) {
|
|
||||||
goto fail;
|
|
||||||
}
|
|
||||||
|
|
||||||
int_line = BMA4_INTR2_MAP;
|
|
||||||
|
|
||||||
ret = bma4_get_int_pin_config(&pin_config, int_line, &bma456_struct);
|
|
||||||
if (check_bma4("bma4_get_int_pin_config", ret) != ESP_OK) {
|
|
||||||
goto fail;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ret = bma456h_map_interrupt(int_line, BMA456H_TAP_OUT_INT, BMA4_ENABLE,
|
ret = bma456h_map_interrupt(int_line, BMA456H_TAP_OUT_INT, BMA4_ENABLE,
|
||||||
&bma456_struct);
|
&s_bma456);
|
||||||
if (check_bma4("bma456h_map_interrupt", ret) != ESP_OK) {
|
if (check_bma4("bma456h_map_interrupt", ret) != ESP_OK) {
|
||||||
goto fail;
|
return ESP_FAIL;
|
||||||
|
}
|
||||||
|
|
||||||
|
struct bma4_int_pin_config pin_config = {0};
|
||||||
|
ret = bma4_get_int_pin_config(&pin_config, int_line, &s_bma456);
|
||||||
|
if (check_bma4("bma4_get_int_pin_config", ret) != ESP_OK) {
|
||||||
|
return ESP_FAIL;
|
||||||
}
|
}
|
||||||
|
|
||||||
pin_config.edge_ctrl = BMA4_EDGE_TRIGGER;
|
pin_config.edge_ctrl = BMA4_EDGE_TRIGGER;
|
||||||
@@ -349,36 +393,120 @@ esp_err_t init_bma456(i2c_master_bus_handle_t bus_handle) {
|
|||||||
pin_config.od = BMA4_PUSH_PULL;
|
pin_config.od = BMA4_PUSH_PULL;
|
||||||
pin_config.input_en = BMA4_INPUT_DISABLE;
|
pin_config.input_en = BMA4_INPUT_DISABLE;
|
||||||
|
|
||||||
ret = bma4_set_int_pin_config(&pin_config, int_line, &bma456_struct);
|
ret = bma4_set_int_pin_config(&pin_config, int_line, &s_bma456);
|
||||||
if (check_bma4("bma4_set_int_pin_config", ret) != ESP_OK) {
|
if (check_bma4("bma4_set_int_pin_config", ret) != ESP_OK) {
|
||||||
goto fail;
|
return ESP_FAIL;
|
||||||
}
|
}
|
||||||
|
|
||||||
gpio_reset_pin(BMA456W_INT_PIN);
|
gpio_reset_pin(BMA456_INT_GPIO);
|
||||||
gpio_set_direction(BMA456W_INT_PIN, GPIO_MODE_INPUT);
|
gpio_set_direction(BMA456_INT_GPIO, GPIO_MODE_INPUT);
|
||||||
gpio_set_pull_mode(BMA456W_INT_PIN, GPIO_PULLDOWN_ENABLE);
|
gpio_set_pull_mode(BMA456_INT_GPIO, GPIO_PULLDOWN_ONLY);
|
||||||
gpio_set_intr_type(BMA456W_INT_PIN, GPIO_INTR_POSEDGE);
|
gpio_set_intr_type(BMA456_INT_GPIO, GPIO_INTR_POSEDGE);
|
||||||
gpio_intr_enable(BMA456W_INT_PIN);
|
|
||||||
|
|
||||||
err = gpio_install_isr_service(0);
|
err = gpio_install_isr_service(0);
|
||||||
if (err != ESP_OK && err != ESP_ERR_INVALID_STATE) {
|
if (err != ESP_OK && err != ESP_ERR_INVALID_STATE) {
|
||||||
goto fail;
|
return err;
|
||||||
}
|
}
|
||||||
|
|
||||||
err = gpio_isr_handler_add(BMA456W_INT_PIN, interrupt_callback,
|
err = gpio_isr_handler_add(BMA456_INT_GPIO, bma456_int_isr, NULL);
|
||||||
(void *)BMA456W_INT_PIN);
|
|
||||||
if (err != ESP_OK) {
|
if (err != ESP_OK) {
|
||||||
|
return err;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ESP_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
esp_err_t init_bma456(i2c_master_bus_handle_t bus_handle) {
|
||||||
|
int8_t ret;
|
||||||
|
esp_err_t err;
|
||||||
|
|
||||||
|
s_bma456_ready = false;
|
||||||
|
s_bma456_dev = NULL;
|
||||||
|
s_int_pending = false;
|
||||||
|
|
||||||
|
if (bus_handle == NULL) {
|
||||||
|
return ESP_ERR_INVALID_ARG;
|
||||||
|
}
|
||||||
|
|
||||||
|
i2c_device_config_t dev_cfg = {
|
||||||
|
.dev_addr_length = I2C_ADDR_BIT_LEN_7,
|
||||||
|
.device_address = BMA456_I2C_ADDR,
|
||||||
|
.scl_speed_hz = 100000,
|
||||||
|
};
|
||||||
|
|
||||||
|
err = i2c_master_bus_add_device(bus_handle, &dev_cfg, &s_bma456_dev);
|
||||||
|
if (err != ESP_OK) {
|
||||||
|
return err;
|
||||||
|
}
|
||||||
|
|
||||||
|
s_bma456.intf = BMA4_I2C_INTF;
|
||||||
|
s_bma456.bus_read = bma4_i2c_read;
|
||||||
|
s_bma456.bus_write = bma4_i2c_write;
|
||||||
|
s_bma456.delay_us = bma4_delay_us;
|
||||||
|
s_bma456.read_write_len = BMA4_READ_WRITE_LEN;
|
||||||
|
s_bma456.intf_ptr = &s_bma456_dev;
|
||||||
|
s_bma456.chip_id = 0;
|
||||||
|
|
||||||
|
ret = bma456h_init(&s_bma456);
|
||||||
|
if (check_bma4("bma456h_init", ret) != ESP_OK) {
|
||||||
|
remove_bma456_device();
|
||||||
|
return ESP_ERR_NOT_FOUND;
|
||||||
|
}
|
||||||
|
|
||||||
|
ESP_LOGI(TAG, "chip id 0x%02x", s_bma456.chip_id);
|
||||||
|
|
||||||
|
ret = bma4_soft_reset(&s_bma456);
|
||||||
|
if (check_bma4("bma4_soft_reset", ret) != ESP_OK) {
|
||||||
|
goto fail;
|
||||||
|
}
|
||||||
|
vTaskDelay(pdMS_TO_TICKS(20));
|
||||||
|
|
||||||
|
ret = bma4_set_advance_power_save(BMA4_DISABLE, &s_bma456);
|
||||||
|
if (check_bma4("bma4_set_advance_power_save", ret) != ESP_OK) {
|
||||||
|
goto fail;
|
||||||
|
}
|
||||||
|
vTaskDelay(pdMS_TO_TICKS(10));
|
||||||
|
|
||||||
|
ret = bma456h_write_config_file(&s_bma456);
|
||||||
|
if (check_bma4("bma456h_write_config_file", ret) != ESP_OK) {
|
||||||
goto fail;
|
goto fail;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (xTaskCreate(read_sensor_task, "READ_SENSOR", 4096, NULL, 1, NULL) !=
|
struct bma4_accel_config accel_config = {0};
|
||||||
|
ret = bma4_get_accel_config(&accel_config, &s_bma456);
|
||||||
|
if (check_bma4("bma4_get_accel_config", ret) != ESP_OK) {
|
||||||
|
goto fail;
|
||||||
|
}
|
||||||
|
accel_config.range = BMA4_ACCEL_RANGE_2G;
|
||||||
|
ret = bma4_set_accel_config(&accel_config, &s_bma456);
|
||||||
|
if (check_bma4("bma4_set_accel_config", ret) != ESP_OK) {
|
||||||
|
goto fail;
|
||||||
|
}
|
||||||
|
|
||||||
|
ret = bma4_set_accel_enable(BMA4_ENABLE, &s_bma456);
|
||||||
|
if (check_bma4("bma4_set_accel_enable", ret) != ESP_OK) {
|
||||||
|
goto fail;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (configure_tap_interrupt() != ESP_OK) {
|
||||||
|
goto fail;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (s_accel_mutex == NULL) {
|
||||||
|
s_accel_mutex = xSemaphoreCreateMutex();
|
||||||
|
if (s_accel_mutex == NULL) {
|
||||||
|
goto fail;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (xTaskCreate(read_sensor_task, "bma456_poll", 4096, NULL, 1, NULL) !=
|
||||||
pdPASS) {
|
pdPASS) {
|
||||||
gpio_isr_handler_remove(BMA456W_INT_PIN);
|
|
||||||
goto fail;
|
goto fail;
|
||||||
}
|
}
|
||||||
|
|
||||||
s_bma456_ready = true;
|
s_bma456_ready = true;
|
||||||
ESP_LOGI(TAG, "initialized");
|
ESP_LOGI(TAG, "ready (I2C 0x%02x, INT GPIO%d, poll %d ms)", BMA456_I2C_ADDR,
|
||||||
|
BMA456_INT_GPIO, SENSOR_POLL_MS);
|
||||||
return ESP_OK;
|
return ESP_OK;
|
||||||
|
|
||||||
fail:
|
fail:
|
||||||
|
|||||||
+92
-5
@@ -1,17 +1,79 @@
|
|||||||
#ifndef BOSCH456_H
|
#ifndef BOSCH456_H
|
||||||
#define BOSCH456_H
|
#define BOSCH456_H
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Powerpod driver for Bosch BMA456H (hearable variant) on the shared I2C bus.
|
||||||
|
*
|
||||||
|
* Vendor API: components/bma456 (bma4.c + bma456h.c only).
|
||||||
|
* Implementation: bosch456.c
|
||||||
|
*/
|
||||||
|
|
||||||
#include "driver/i2c_types.h"
|
#include "driver/i2c_types.h"
|
||||||
#include "esp_err.h"
|
#include "esp_err.h"
|
||||||
|
#include <stdbool.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
|
||||||
#define TOUCH_1 9
|
/** 7-bit I2C address (SDO low). */
|
||||||
#define TOUCH_2 8
|
#define BMA456_I2C_ADDR 0x18
|
||||||
|
|
||||||
#define BMA456_ADDRESS 0x18
|
/** Sensor interrupt line → ESP32 GPIO (active high, rising edge). */
|
||||||
|
#define BMA456_INT_GPIO 10
|
||||||
|
|
||||||
|
/** Software filter: emit accel only when |axis - last| > deadzone (raw LSB). */
|
||||||
#define BMA456_DEFAULT_ACCEL_DEADZONE 100u
|
#define BMA456_DEFAULT_ACCEL_DEADZONE 100u
|
||||||
|
|
||||||
/** Initialize BMA456 on the shared I2C bus. Returns ESP_OK or logs and skips sensor use. */
|
/**
|
||||||
|
* 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 for the compile-time default (overridden by NVS if set). */
|
||||||
|
#define BMA456_TAP_CONFIG_DEFAULT \
|
||||||
|
{ \
|
||||||
|
.tap_sens_thres = 5, /* sensitive; 0=max, Bosch default=9 */ \
|
||||||
|
.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, \
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True if cfg fields are within sensor limits and at least one tap kind is on. */
|
||||||
|
bool bma456_tap_config_valid(const bma456_tap_config_t *cfg);
|
||||||
|
|
||||||
|
/** Copy current runtime tap config (compile default until set/NVS-applied). */
|
||||||
|
void bma456_get_tap_config(bma456_tap_config_t *cfg_out);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply tap config to the sensor (and keep RAM copy).
|
||||||
|
* Returns ESP_ERR_INVALID_ARG if invalid. When the sensor is not ready, only
|
||||||
|
* the RAM copy is updated (ESP_OK).
|
||||||
|
*/
|
||||||
|
esp_err_t bma456_set_tap_config(const bma456_tap_config_t *cfg);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Probe and configure the sensor on bus_handle (100 kHz device).
|
||||||
|
* On failure the device is removed and ESP_ERR_NOT_FOUND / ESP_FAIL is returned;
|
||||||
|
* firmware continues without a sensor (see bma456_is_ready()).
|
||||||
|
*/
|
||||||
esp_err_t init_bma456(i2c_master_bus_handle_t bus_handle);
|
esp_err_t init_bma456(i2c_master_bus_handle_t bus_handle);
|
||||||
|
|
||||||
bool bma456_is_ready(void);
|
bool bma456_is_ready(void);
|
||||||
@@ -19,7 +81,32 @@ bool bma456_is_ready(void);
|
|||||||
void bma456_set_accel_deadzone(uint32_t deadzone_lsb);
|
void bma456_set_accel_deadzone(uint32_t deadzone_lsb);
|
||||||
uint32_t bma456_get_accel_deadzone(void);
|
uint32_t bma456_get_accel_deadzone(void);
|
||||||
|
|
||||||
/** Log accel sample only when any axis changed more than deadzone since last report. */
|
/**
|
||||||
|
* ESP-NOW stream filter: true if sample exceeds deadzone vs last streamed
|
||||||
|
* sample (or no baseline yet). On true, commits (x,y,z) as stream baseline.
|
||||||
|
* Independent of the log filter baseline.
|
||||||
|
*/
|
||||||
|
bool bma456_accel_stream_take_if_changed(int16_t x, int16_t y, int16_t z);
|
||||||
|
|
||||||
|
/** Force next stream take to accept (call when accel stream is enabled). */
|
||||||
|
void bma456_accel_stream_reset_baseline(void);
|
||||||
|
|
||||||
|
/** Log accel when any axis moved more than deadzone since last reported sample. */
|
||||||
void bma456_report_accel_if_changed(int16_t x, int16_t y, int16_t z);
|
void bma456_report_accel_if_changed(int16_t x, int16_t y, int16_t z);
|
||||||
|
|
||||||
|
/** Tap kinds from BMA456H multitap output. */
|
||||||
|
typedef enum {
|
||||||
|
BMA456_TAP_SINGLE = 1,
|
||||||
|
BMA456_TAP_DOUBLE = 2,
|
||||||
|
BMA456_TAP_TRIPLE = 3,
|
||||||
|
} bma456_tap_kind_t;
|
||||||
|
|
||||||
|
typedef void (*bma456_tap_handler_t)(bma456_tap_kind_t kind, void *ctx);
|
||||||
|
|
||||||
|
/** Optional callback invoked from sensor task on tap interrupt (may be NULL). */
|
||||||
|
void bma456_set_tap_handler(bma456_tap_handler_t handler, void *ctx);
|
||||||
|
|
||||||
|
/** On-demand read of current accel XYZ (raw LSB). Returns ESP_ERR_INVALID_STATE if sensor not ready. */
|
||||||
|
esp_err_t bma456_read_accel(int16_t *x, int16_t *y, int16_t *z);
|
||||||
|
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
+505
-4
@@ -1,4 +1,5 @@
|
|||||||
#include "client_registry.h"
|
#include "client_registry.h"
|
||||||
|
#include "pod_settings.h"
|
||||||
#include "esp_log.h"
|
#include "esp_log.h"
|
||||||
#include "freertos/FreeRTOS.h"
|
#include "freertos/FreeRTOS.h"
|
||||||
#include "freertos/idf_additions.h"
|
#include "freertos/idf_additions.h"
|
||||||
@@ -13,6 +14,67 @@ typedef struct {
|
|||||||
} client_slot_t;
|
} client_slot_t;
|
||||||
|
|
||||||
static client_slot_t s_clients[CLIENT_REGISTRY_MAX];
|
static client_slot_t s_clients[CLIENT_REGISTRY_MAX];
|
||||||
|
static pod_id_map_t s_id_map;
|
||||||
|
|
||||||
|
static struct {
|
||||||
|
board_lipo_reading_t reading;
|
||||||
|
uint32_t updated_at;
|
||||||
|
} s_master_battery;
|
||||||
|
|
||||||
|
static bool id_in_use(uint32_t id, const uint8_t *except_mac) {
|
||||||
|
for (uint8_t i = 0; i < s_id_map.count; i++) {
|
||||||
|
if (s_id_map.entries[i].id != id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (except_mac != NULL &&
|
||||||
|
memcmp(s_id_map.entries[i].mac, except_mac, CLIENT_MAC_LEN) == 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int find_map_index(const uint8_t mac[CLIENT_MAC_LEN]) {
|
||||||
|
for (uint8_t i = 0; i < s_id_map.count; i++) {
|
||||||
|
if (memcmp(s_id_map.entries[i].mac, mac, CLIENT_MAC_LEN) == 0) {
|
||||||
|
return (int)i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
static esp_err_t persist_id_map(void) {
|
||||||
|
return pod_settings_save_id_map(&s_id_map);
|
||||||
|
}
|
||||||
|
|
||||||
|
static uint32_t alloc_next_free_id(void) {
|
||||||
|
if (s_id_map.count >= POD_ID_MAP_MAX) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
uint8_t start = s_id_map.next_id;
|
||||||
|
if (start < POD_CLIENT_ID_MIN) {
|
||||||
|
start = (uint8_t)POD_CLIENT_ID_MIN;
|
||||||
|
}
|
||||||
|
for (uint32_t n = 0; n < POD_CLIENT_ID_MAX; n++) {
|
||||||
|
uint32_t id = (uint32_t)start + n;
|
||||||
|
if (id > POD_CLIENT_ID_MAX) {
|
||||||
|
id = id - POD_CLIENT_ID_MAX;
|
||||||
|
}
|
||||||
|
if (id < POD_CLIENT_ID_MIN) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!id_in_use(id, NULL)) {
|
||||||
|
uint32_t next = id + 1;
|
||||||
|
if (next > POD_CLIENT_ID_MAX) {
|
||||||
|
next = POD_CLIENT_ID_MIN;
|
||||||
|
}
|
||||||
|
s_id_map.next_id = (uint8_t)next;
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
uint32_t client_registry_now_ms(void) {
|
uint32_t client_registry_now_ms(void) {
|
||||||
return (uint32_t)(xTaskGetTickCount() * portTICK_PERIOD_MS);
|
return (uint32_t)(xTaskGetTickCount() * portTICK_PERIOD_MS);
|
||||||
@@ -77,6 +139,11 @@ static client_slot_t *alloc_slot(const uint8_t mac[CLIENT_MAC_LEN],
|
|||||||
slot->active = true;
|
slot->active = true;
|
||||||
memcpy(slot->info.mac, mac, CLIENT_MAC_LEN);
|
memcpy(slot->info.mac, mac, CLIENT_MAC_LEN);
|
||||||
slot->info.accel_deadzone = CLIENT_REGISTRY_DEFAULT_ACCEL_DEADZONE;
|
slot->info.accel_deadzone = CLIENT_REGISTRY_DEFAULT_ACCEL_DEADZONE;
|
||||||
|
{
|
||||||
|
static const bma456_tap_config_t k_tap_default = BMA456_TAP_CONFIG_DEFAULT;
|
||||||
|
slot->info.tap_config = k_tap_default;
|
||||||
|
}
|
||||||
|
slot->info.uv_protection_enabled = false;
|
||||||
if (out_is_new != NULL) {
|
if (out_is_new != NULL) {
|
||||||
*out_is_new = true;
|
*out_is_new = true;
|
||||||
}
|
}
|
||||||
@@ -86,7 +153,84 @@ static client_slot_t *alloc_slot(const uint8_t mac[CLIENT_MAC_LEN],
|
|||||||
return NULL;
|
return NULL;
|
||||||
}
|
}
|
||||||
|
|
||||||
void client_registry_init(void) { memset(s_clients, 0, sizeof(s_clients)); }
|
void client_registry_init(void) {
|
||||||
|
memset(s_clients, 0, sizeof(s_clients));
|
||||||
|
memset(&s_id_map, 0, sizeof(s_id_map));
|
||||||
|
s_id_map.next_id = (uint8_t)POD_CLIENT_ID_MIN;
|
||||||
|
(void)pod_settings_load_id_map(&s_id_map);
|
||||||
|
if (s_id_map.next_id < POD_CLIENT_ID_MIN) {
|
||||||
|
s_id_map.next_id = (uint8_t)POD_CLIENT_ID_MIN;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
esp_err_t client_registry_assign_id(const uint8_t mac[CLIENT_MAC_LEN],
|
||||||
|
uint32_t preferred_id, uint32_t *out_id) {
|
||||||
|
if (mac == NULL || out_id == NULL) {
|
||||||
|
return ESP_ERR_INVALID_ARG;
|
||||||
|
}
|
||||||
|
|
||||||
|
int idx = find_map_index(mac);
|
||||||
|
if (idx >= 0) {
|
||||||
|
*out_id = s_id_map.entries[idx].id;
|
||||||
|
return ESP_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint32_t id = 0;
|
||||||
|
if (preferred_id >= POD_CLIENT_ID_MIN && preferred_id <= POD_CLIENT_ID_MAX &&
|
||||||
|
!id_in_use(preferred_id, mac)) {
|
||||||
|
id = preferred_id;
|
||||||
|
} else {
|
||||||
|
id = alloc_next_free_id();
|
||||||
|
}
|
||||||
|
if (id == 0) {
|
||||||
|
return ESP_ERR_NO_MEM;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (s_id_map.count >= POD_ID_MAP_MAX) {
|
||||||
|
return ESP_ERR_NO_MEM;
|
||||||
|
}
|
||||||
|
|
||||||
|
memcpy(s_id_map.entries[s_id_map.count].mac, mac, CLIENT_MAC_LEN);
|
||||||
|
s_id_map.entries[s_id_map.count].id = (uint8_t)id;
|
||||||
|
s_id_map.count++;
|
||||||
|
if (id >= s_id_map.next_id) {
|
||||||
|
uint32_t next = id + 1;
|
||||||
|
if (next > POD_CLIENT_ID_MAX) {
|
||||||
|
next = POD_CLIENT_ID_MIN;
|
||||||
|
}
|
||||||
|
s_id_map.next_id = (uint8_t)next;
|
||||||
|
}
|
||||||
|
|
||||||
|
esp_err_t err = persist_id_map();
|
||||||
|
if (err != ESP_OK) {
|
||||||
|
ESP_LOGW(TAG, "persist id_map failed after assign id=%lu: %s",
|
||||||
|
(unsigned long)id, esp_err_to_name(err));
|
||||||
|
}
|
||||||
|
*out_id = id;
|
||||||
|
return ESP_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
void client_registry_clear_id_map(void) {
|
||||||
|
memset(s_clients, 0, sizeof(s_clients));
|
||||||
|
memset(&s_id_map, 0, sizeof(s_id_map));
|
||||||
|
s_id_map.next_id = (uint8_t)POD_CLIENT_ID_MIN;
|
||||||
|
esp_err_t err = pod_settings_clear_id_map();
|
||||||
|
if (err != ESP_OK) {
|
||||||
|
ESP_LOGW(TAG, "clear id_map NVS failed: %s", esp_err_to_name(err));
|
||||||
|
}
|
||||||
|
ESP_LOGI(TAG, "cleared id map and live registry");
|
||||||
|
}
|
||||||
|
|
||||||
|
void client_registry_mark_assign_sent(const uint8_t mac[CLIENT_MAC_LEN]) {
|
||||||
|
if (mac == NULL) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
client_slot_t *slot = find_slot(mac);
|
||||||
|
if (slot == NULL) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
slot->info.last_assign_sent_at = now_ms();
|
||||||
|
}
|
||||||
|
|
||||||
const client_info_t *
|
const client_info_t *
|
||||||
client_registry_find_by_mac(const uint8_t mac[CLIENT_MAC_LEN]) {
|
client_registry_find_by_mac(const uint8_t mac[CLIENT_MAC_LEN]) {
|
||||||
@@ -162,18 +306,19 @@ void client_registry_check_timeouts(uint32_t timeout_ms) {
|
|||||||
uint32_t now = now_ms();
|
uint32_t now = now_ms();
|
||||||
|
|
||||||
for (size_t i = 0; i < CLIENT_REGISTRY_MAX; i++) {
|
for (size_t i = 0; i < CLIENT_REGISTRY_MAX; i++) {
|
||||||
if (!s_clients[i].active || !s_clients[i].info.available) {
|
if (!s_clients[i].active) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
uint32_t elapsed = now - s_clients[i].info.last_success_ping_at;
|
uint32_t elapsed = now - s_clients[i].info.last_success_ping_at;
|
||||||
if (elapsed > timeout_ms) {
|
if (elapsed > timeout_ms) {
|
||||||
s_clients[i].info.available = false;
|
|
||||||
char mac_str[18];
|
char mac_str[18];
|
||||||
mac_to_str(s_clients[i].info.mac, mac_str, sizeof(mac_str));
|
mac_to_str(s_clients[i].info.mac, mac_str, sizeof(mac_str));
|
||||||
ESP_LOGW(TAG, "client inactive id=%lu mac=%s (no heartbeat for %lu ms)",
|
ESP_LOGW(TAG, "client dropped id=%lu mac=%s (no heartbeat for %lu ms)",
|
||||||
(unsigned long)s_clients[i].info.id, mac_str,
|
(unsigned long)s_clients[i].info.id, mac_str,
|
||||||
(unsigned long)elapsed);
|
(unsigned long)elapsed);
|
||||||
|
s_clients[i].active = false;
|
||||||
|
memset(&s_clients[i].info, 0, sizeof(s_clients[i].info));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -241,6 +386,362 @@ size_t client_registry_set_accel_deadzone_all(uint32_t deadzone) {
|
|||||||
return n;
|
return n;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
esp_err_t client_registry_set_tap_config(uint32_t client_id,
|
||||||
|
const bma456_tap_config_t *cfg) {
|
||||||
|
if (cfg == NULL || !bma456_tap_config_valid(cfg)) {
|
||||||
|
return ESP_ERR_INVALID_ARG;
|
||||||
|
}
|
||||||
|
const client_info_t *info = client_registry_find_by_id(client_id);
|
||||||
|
if (info == NULL) {
|
||||||
|
return ESP_ERR_NOT_FOUND;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (size_t i = 0; i < CLIENT_REGISTRY_MAX; i++) {
|
||||||
|
if (s_clients[i].active && s_clients[i].info.id == client_id) {
|
||||||
|
s_clients[i].info.tap_config = *cfg;
|
||||||
|
return ESP_OK;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ESP_ERR_NOT_FOUND;
|
||||||
|
}
|
||||||
|
|
||||||
|
esp_err_t client_registry_get_tap_config(uint32_t client_id,
|
||||||
|
bma456_tap_config_t *cfg_out) {
|
||||||
|
if (cfg_out == NULL) {
|
||||||
|
return ESP_ERR_INVALID_ARG;
|
||||||
|
}
|
||||||
|
|
||||||
|
const client_info_t *info = client_registry_find_by_id(client_id);
|
||||||
|
if (info == NULL) {
|
||||||
|
return ESP_ERR_NOT_FOUND;
|
||||||
|
}
|
||||||
|
|
||||||
|
*cfg_out = info->tap_config;
|
||||||
|
return ESP_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
size_t client_registry_set_tap_config_all(const bma456_tap_config_t *cfg) {
|
||||||
|
if (cfg == NULL || !bma456_tap_config_valid(cfg)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
size_t n = 0;
|
||||||
|
|
||||||
|
for (size_t i = 0; i < CLIENT_REGISTRY_MAX; i++) {
|
||||||
|
if (!s_clients[i].active) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
s_clients[i].info.tap_config = *cfg;
|
||||||
|
n++;
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
esp_err_t client_registry_set_uv_protection(uint32_t client_id, bool enabled) {
|
||||||
|
for (size_t i = 0; i < CLIENT_REGISTRY_MAX; i++) {
|
||||||
|
if (s_clients[i].active && s_clients[i].info.id == client_id) {
|
||||||
|
s_clients[i].info.uv_protection_enabled = enabled;
|
||||||
|
return ESP_OK;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ESP_ERR_NOT_FOUND;
|
||||||
|
}
|
||||||
|
|
||||||
|
esp_err_t client_registry_get_uv_protection(uint32_t client_id,
|
||||||
|
bool *enabled_out) {
|
||||||
|
if (enabled_out == NULL) {
|
||||||
|
return ESP_ERR_INVALID_ARG;
|
||||||
|
}
|
||||||
|
const client_info_t *info = client_registry_find_by_id(client_id);
|
||||||
|
if (info == NULL) {
|
||||||
|
return ESP_ERR_NOT_FOUND;
|
||||||
|
}
|
||||||
|
*enabled_out = info->uv_protection_enabled;
|
||||||
|
return ESP_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
size_t client_registry_set_uv_protection_all(bool enabled) {
|
||||||
|
size_t n = 0;
|
||||||
|
for (size_t i = 0; i < CLIENT_REGISTRY_MAX; i++) {
|
||||||
|
if (!s_clients[i].active) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
s_clients[i].info.uv_protection_enabled = enabled;
|
||||||
|
n++;
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void clear_client_accel(client_slot_t *slot) {
|
||||||
|
if (slot == NULL) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
slot->info.accel_valid = false;
|
||||||
|
slot->info.accel_x = 0;
|
||||||
|
slot->info.accel_y = 0;
|
||||||
|
slot->info.accel_z = 0;
|
||||||
|
slot->info.accel_updated_at = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void clear_client_tap(client_slot_t *slot) {
|
||||||
|
if (slot == NULL) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
slot->info.tap_valid = false;
|
||||||
|
slot->info.tap_kind = 0;
|
||||||
|
slot->info.tap_updated_at = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool tap_kind_enabled(const client_info_t *info, uint32_t kind) {
|
||||||
|
if (info == NULL) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
switch (kind) {
|
||||||
|
case 1:
|
||||||
|
return info->tap_notify_single;
|
||||||
|
case 2:
|
||||||
|
return info->tap_notify_double;
|
||||||
|
case 3:
|
||||||
|
return info->tap_notify_triple;
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
esp_err_t client_registry_set_accel_stream(uint32_t client_id, bool enabled) {
|
||||||
|
for (size_t i = 0; i < CLIENT_REGISTRY_MAX; i++) {
|
||||||
|
if (!s_clients[i].active || s_clients[i].info.id != client_id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
s_clients[i].info.accel_stream_enabled = enabled;
|
||||||
|
if (!enabled) {
|
||||||
|
clear_client_accel(&s_clients[i]);
|
||||||
|
}
|
||||||
|
return ESP_OK;
|
||||||
|
}
|
||||||
|
return ESP_ERR_NOT_FOUND;
|
||||||
|
}
|
||||||
|
|
||||||
|
esp_err_t client_registry_get_accel_stream(uint32_t client_id,
|
||||||
|
bool *enabled_out) {
|
||||||
|
if (enabled_out == NULL) {
|
||||||
|
return ESP_ERR_INVALID_ARG;
|
||||||
|
}
|
||||||
|
const client_info_t *info = client_registry_find_by_id(client_id);
|
||||||
|
if (info == NULL) {
|
||||||
|
return ESP_ERR_NOT_FOUND;
|
||||||
|
}
|
||||||
|
*enabled_out = info->accel_stream_enabled;
|
||||||
|
return ESP_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
size_t client_registry_set_accel_stream_all(bool enabled) {
|
||||||
|
size_t n = 0;
|
||||||
|
for (size_t i = 0; i < CLIENT_REGISTRY_MAX; i++) {
|
||||||
|
if (!s_clients[i].active) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
s_clients[i].info.accel_stream_enabled = enabled;
|
||||||
|
if (!enabled) {
|
||||||
|
clear_client_accel(&s_clients[i]);
|
||||||
|
}
|
||||||
|
n++;
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
esp_err_t client_registry_update_accel(const uint8_t mac[CLIENT_MAC_LEN],
|
||||||
|
uint32_t slave_id, int16_t x, int16_t y,
|
||||||
|
int16_t z) {
|
||||||
|
if (mac == NULL) {
|
||||||
|
return ESP_ERR_INVALID_ARG;
|
||||||
|
}
|
||||||
|
|
||||||
|
client_slot_t *slot = find_slot(mac);
|
||||||
|
if (slot == NULL) {
|
||||||
|
return ESP_ERR_NOT_FOUND;
|
||||||
|
}
|
||||||
|
if (slot->info.id != slave_id) {
|
||||||
|
return ESP_ERR_INVALID_ARG;
|
||||||
|
}
|
||||||
|
if (!slot->info.accel_stream_enabled) {
|
||||||
|
return ESP_ERR_INVALID_STATE;
|
||||||
|
}
|
||||||
|
|
||||||
|
slot->info.accel_x = x;
|
||||||
|
slot->info.accel_y = y;
|
||||||
|
slot->info.accel_z = z;
|
||||||
|
slot->info.accel_valid = true;
|
||||||
|
slot->info.accel_updated_at = now_ms();
|
||||||
|
return ESP_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
esp_err_t client_registry_set_tap_notify(uint32_t client_id, bool single,
|
||||||
|
bool double_tap, bool triple) {
|
||||||
|
for (size_t i = 0; i < CLIENT_REGISTRY_MAX; i++) {
|
||||||
|
if (!s_clients[i].active || s_clients[i].info.id != client_id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
s_clients[i].info.tap_notify_single = single;
|
||||||
|
s_clients[i].info.tap_notify_double = double_tap;
|
||||||
|
s_clients[i].info.tap_notify_triple = triple;
|
||||||
|
if (!single && !double_tap && !triple) {
|
||||||
|
clear_client_tap(&s_clients[i]);
|
||||||
|
}
|
||||||
|
return ESP_OK;
|
||||||
|
}
|
||||||
|
return ESP_ERR_NOT_FOUND;
|
||||||
|
}
|
||||||
|
|
||||||
|
esp_err_t client_registry_get_tap_notify(uint32_t client_id, bool *single_out,
|
||||||
|
bool *double_tap_out,
|
||||||
|
bool *triple_out) {
|
||||||
|
if (single_out == NULL || double_tap_out == NULL || triple_out == NULL) {
|
||||||
|
return ESP_ERR_INVALID_ARG;
|
||||||
|
}
|
||||||
|
const client_info_t *info = client_registry_find_by_id(client_id);
|
||||||
|
if (info == NULL) {
|
||||||
|
return ESP_ERR_NOT_FOUND;
|
||||||
|
}
|
||||||
|
*single_out = info->tap_notify_single;
|
||||||
|
*double_tap_out = info->tap_notify_double;
|
||||||
|
*triple_out = info->tap_notify_triple;
|
||||||
|
return ESP_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
size_t client_registry_set_tap_notify_all(bool single, bool double_tap,
|
||||||
|
bool triple) {
|
||||||
|
size_t n = 0;
|
||||||
|
for (size_t i = 0; i < CLIENT_REGISTRY_MAX; i++) {
|
||||||
|
if (!s_clients[i].active) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
s_clients[i].info.tap_notify_single = single;
|
||||||
|
s_clients[i].info.tap_notify_double = double_tap;
|
||||||
|
s_clients[i].info.tap_notify_triple = triple;
|
||||||
|
if (!single && !double_tap && !triple) {
|
||||||
|
clear_client_tap(&s_clients[i]);
|
||||||
|
}
|
||||||
|
n++;
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
esp_err_t client_registry_update_tap(const uint8_t mac[CLIENT_MAC_LEN],
|
||||||
|
uint32_t slave_id, uint32_t kind) {
|
||||||
|
if (mac == NULL || kind < 1 || kind > 3) {
|
||||||
|
return ESP_ERR_INVALID_ARG;
|
||||||
|
}
|
||||||
|
|
||||||
|
client_slot_t *slot = find_slot(mac);
|
||||||
|
if (slot == NULL) {
|
||||||
|
return ESP_ERR_NOT_FOUND;
|
||||||
|
}
|
||||||
|
if (slot->info.id != slave_id) {
|
||||||
|
return ESP_ERR_INVALID_ARG;
|
||||||
|
}
|
||||||
|
if (!tap_kind_enabled(&slot->info, kind)) {
|
||||||
|
return ESP_ERR_INVALID_STATE;
|
||||||
|
}
|
||||||
|
|
||||||
|
slot->info.tap_kind = kind;
|
||||||
|
slot->info.tap_valid = true;
|
||||||
|
slot->info.tap_updated_at = now_ms();
|
||||||
|
return ESP_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
void client_registry_expire_tap(client_info_t *info) {
|
||||||
|
if (info == NULL || !info->tap_valid) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (client_registry_ms_since(info->tap_updated_at) >
|
||||||
|
CLIENT_REGISTRY_TAP_MAX_AGE_MS) {
|
||||||
|
info->tap_valid = false;
|
||||||
|
info->tap_kind = 0;
|
||||||
|
info->tap_updated_at = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void client_registry_clear_tap(client_info_t *info) {
|
||||||
|
if (info == NULL) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
info->tap_valid = false;
|
||||||
|
info->tap_kind = 0;
|
||||||
|
info->tap_updated_at = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool client_registry_take_tap(uint32_t client_id, uint32_t *kind_out,
|
||||||
|
uint32_t *age_ms_out) {
|
||||||
|
for (size_t i = 0; i < CLIENT_REGISTRY_MAX; i++) {
|
||||||
|
if (!s_clients[i].active || s_clients[i].info.id != client_id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
client_info_t *info = &s_clients[i].info;
|
||||||
|
client_registry_expire_tap(info);
|
||||||
|
if (!info->tap_valid) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (kind_out != NULL) {
|
||||||
|
*kind_out = info->tap_kind;
|
||||||
|
}
|
||||||
|
if (age_ms_out != NULL) {
|
||||||
|
*age_ms_out = client_registry_ms_since(info->tap_updated_at);
|
||||||
|
}
|
||||||
|
client_registry_clear_tap(info);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void client_registry_set_master_battery(const board_lipo_reading_t *reading) {
|
||||||
|
if (reading == NULL) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
s_master_battery.reading = *reading;
|
||||||
|
s_master_battery.updated_at = now_ms();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool client_registry_get_master_battery(board_lipo_reading_t *reading_out,
|
||||||
|
uint32_t *age_ms_out) {
|
||||||
|
if (reading_out == NULL) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
*reading_out = s_master_battery.reading;
|
||||||
|
if (age_ms_out != NULL) {
|
||||||
|
*age_ms_out = client_registry_ms_since(s_master_battery.updated_at);
|
||||||
|
}
|
||||||
|
return s_master_battery.updated_at != 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
esp_err_t client_registry_update_battery(const uint8_t mac[CLIENT_MAC_LEN],
|
||||||
|
uint32_t slave_id, bool lipo1_valid,
|
||||||
|
uint32_t lipo1_mv, bool lipo2_valid,
|
||||||
|
uint32_t lipo2_mv) {
|
||||||
|
if (mac == NULL) {
|
||||||
|
return ESP_ERR_INVALID_ARG;
|
||||||
|
}
|
||||||
|
|
||||||
|
client_slot_t *slot = find_slot(mac);
|
||||||
|
if (slot == NULL) {
|
||||||
|
/* Ids are assigned on presence only; ignore early battery reports. */
|
||||||
|
return ESP_ERR_NOT_FOUND;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (slave_id != 0 && slot->info.id != slave_id) {
|
||||||
|
ESP_LOGW(TAG, "battery id mismatch slot=%lu report=%lu mac %02x:…:%02x",
|
||||||
|
(unsigned long)slot->info.id, (unsigned long)slave_id, mac[0],
|
||||||
|
mac[5]);
|
||||||
|
return ESP_ERR_INVALID_STATE;
|
||||||
|
}
|
||||||
|
|
||||||
|
slot->info.lipo1_valid = lipo1_valid;
|
||||||
|
slot->info.lipo2_valid = lipo2_valid;
|
||||||
|
slot->info.lipo1_mv = lipo1_mv;
|
||||||
|
slot->info.lipo2_mv = lipo2_mv;
|
||||||
|
slot->info.battery_updated_at = now_ms();
|
||||||
|
return ESP_OK;
|
||||||
|
}
|
||||||
|
|
||||||
const client_info_t *client_registry_at(size_t index) {
|
const client_info_t *client_registry_at(size_t index) {
|
||||||
size_t n = 0;
|
size_t n = 0;
|
||||||
for (size_t i = 0; i < CLIENT_REGISTRY_MAX; i++) {
|
for (size_t i = 0; i < CLIENT_REGISTRY_MAX; i++) {
|
||||||
|
|||||||
+109
-1
@@ -1,6 +1,8 @@
|
|||||||
#ifndef CLIENT_REGISTRY_H
|
#ifndef CLIENT_REGISTRY_H
|
||||||
#define CLIENT_REGISTRY_H
|
#define CLIENT_REGISTRY_H
|
||||||
|
|
||||||
|
#include "board_input.h"
|
||||||
|
#include "bosch456.h"
|
||||||
#include "esp_err.h"
|
#include "esp_err.h"
|
||||||
#include <stdbool.h>
|
#include <stdbool.h>
|
||||||
#include <stddef.h>
|
#include <stddef.h>
|
||||||
@@ -21,12 +23,59 @@ typedef struct {
|
|||||||
uint32_t version;
|
uint32_t version;
|
||||||
/** Accel deadzone in raw LSB per axis (master copy for ESP-NOW config). */
|
/** Accel deadzone in raw LSB per axis (master copy for ESP-NOW config). */
|
||||||
uint32_t accel_deadzone;
|
uint32_t accel_deadzone;
|
||||||
|
/** Tap detection config (master copy for ESP-NOW / UART read). */
|
||||||
|
bma456_tap_config_t tap_config;
|
||||||
|
/** Latest accel from slave ESP-NOW stream (master only). */
|
||||||
|
bool accel_valid;
|
||||||
|
int16_t accel_x;
|
||||||
|
int16_t accel_y;
|
||||||
|
int16_t accel_z;
|
||||||
|
uint32_t accel_updated_at;
|
||||||
|
/** Host-enabled ESP-NOW accel stream to master. */
|
||||||
|
bool accel_stream_enabled;
|
||||||
|
/** Host-enabled ESP-NOW tap notify flags. */
|
||||||
|
bool tap_notify_single;
|
||||||
|
bool tap_notify_double;
|
||||||
|
bool tap_notify_triple;
|
||||||
|
/** Latest tap from slave ESP-NOW (master only, short-lived cache). */
|
||||||
|
bool tap_valid;
|
||||||
|
uint32_t tap_kind;
|
||||||
|
uint32_t tap_updated_at;
|
||||||
|
/** Latest LiPo ADC from slave ESP-NOW battery report (~30 s). */
|
||||||
|
bool lipo1_valid;
|
||||||
|
bool lipo2_valid;
|
||||||
|
uint32_t lipo1_mv;
|
||||||
|
uint32_t lipo2_mv;
|
||||||
|
uint32_t battery_updated_at;
|
||||||
|
/** Master cache: software UV protection enabled on slave (default false). */
|
||||||
|
bool uv_protection_enabled;
|
||||||
|
/** Master: last ESPNOW_ASSIGN_ID send time (rate-limit resends). */
|
||||||
|
uint32_t last_assign_sent_at;
|
||||||
} client_info_t;
|
} client_info_t;
|
||||||
|
|
||||||
#define CLIENT_REGISTRY_DEFAULT_ACCEL_DEADZONE 100u
|
#define CLIENT_REGISTRY_DEFAULT_ACCEL_DEADZONE 100u
|
||||||
|
/** Tap events older than this are discarded (matches accel stream interval). */
|
||||||
|
#define CLIENT_REGISTRY_TAP_MAX_AGE_MS 10u
|
||||||
|
|
||||||
void client_registry_init(void);
|
void client_registry_init(void);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assign a stable logical id for mac (1…255). Honors preferred if free or
|
||||||
|
* already owned by mac; otherwise allocates the next free id. Persists the
|
||||||
|
* master id map to NVS.
|
||||||
|
*/
|
||||||
|
esp_err_t client_registry_assign_id(const uint8_t mac[CLIENT_MAC_LEN],
|
||||||
|
uint32_t preferred_id, uint32_t *out_id);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear NVS id map, RAM map, and all live registry slots. Does not notify
|
||||||
|
* slaves (caller sends CLEAR_ID_BINDING).
|
||||||
|
*/
|
||||||
|
void client_registry_clear_id_map(void);
|
||||||
|
|
||||||
|
/** Record that ASSIGN_ID was sent to this MAC (for resend rate limiting). */
|
||||||
|
void client_registry_mark_assign_sent(const uint8_t mac[CLIENT_MAC_LEN]);
|
||||||
|
|
||||||
/** Milliseconds since boot (same clock as stored ping timestamps). */
|
/** Milliseconds since boot (same clock as stored ping timestamps). */
|
||||||
uint32_t client_registry_now_ms(void);
|
uint32_t client_registry_now_ms(void);
|
||||||
|
|
||||||
@@ -47,7 +96,10 @@ esp_err_t client_registry_heartbeat(const uint8_t mac[CLIENT_MAC_LEN],
|
|||||||
uint32_t id, uint32_t version, bool used,
|
uint32_t id, uint32_t version, bool used,
|
||||||
bool *out_is_new, bool *out_reactivated);
|
bool *out_is_new, bool *out_reactivated);
|
||||||
|
|
||||||
/** Mark clients inactive when last_success_ping_at is older than timeout_ms. */
|
/**
|
||||||
|
* Drop live registry slots whose last_success_ping_at is older than
|
||||||
|
* timeout_ms (NVS id map unchanged). Offline clients disappear from CLIENT_INFO.
|
||||||
|
*/
|
||||||
void client_registry_check_timeouts(uint32_t timeout_ms);
|
void client_registry_check_timeouts(uint32_t timeout_ms);
|
||||||
|
|
||||||
size_t client_registry_count(void);
|
size_t client_registry_count(void);
|
||||||
@@ -63,4 +115,60 @@ esp_err_t client_registry_get_accel_deadzone(uint32_t client_id,
|
|||||||
/** Push deadzone to all active registry entries; returns count updated. */
|
/** Push deadzone to all active registry entries; returns count updated. */
|
||||||
size_t client_registry_set_accel_deadzone_all(uint32_t deadzone);
|
size_t client_registry_set_accel_deadzone_all(uint32_t deadzone);
|
||||||
|
|
||||||
|
esp_err_t client_registry_set_tap_config(uint32_t client_id,
|
||||||
|
const bma456_tap_config_t *cfg);
|
||||||
|
esp_err_t client_registry_get_tap_config(uint32_t client_id,
|
||||||
|
bma456_tap_config_t *cfg_out);
|
||||||
|
size_t client_registry_set_tap_config_all(const bma456_tap_config_t *cfg);
|
||||||
|
|
||||||
|
esp_err_t client_registry_set_uv_protection(uint32_t client_id, bool enabled);
|
||||||
|
esp_err_t client_registry_get_uv_protection(uint32_t client_id,
|
||||||
|
bool *enabled_out);
|
||||||
|
size_t client_registry_set_uv_protection_all(bool enabled);
|
||||||
|
|
||||||
|
/** Store latest accel sample from a slave (matched by sender MAC). */
|
||||||
|
esp_err_t client_registry_update_accel(const uint8_t mac[CLIENT_MAC_LEN],
|
||||||
|
uint32_t slave_id, int16_t x, int16_t y,
|
||||||
|
int16_t z);
|
||||||
|
|
||||||
|
esp_err_t client_registry_set_accel_stream(uint32_t client_id, bool enabled);
|
||||||
|
esp_err_t client_registry_get_accel_stream(uint32_t client_id, bool *enabled_out);
|
||||||
|
size_t client_registry_set_accel_stream_all(bool enabled);
|
||||||
|
|
||||||
|
esp_err_t client_registry_set_tap_notify(uint32_t client_id, bool single,
|
||||||
|
bool double_tap, bool triple);
|
||||||
|
esp_err_t client_registry_get_tap_notify(uint32_t client_id, bool *single_out,
|
||||||
|
bool *double_tap_out,
|
||||||
|
bool *triple_out);
|
||||||
|
size_t client_registry_set_tap_notify_all(bool single, bool double_tap,
|
||||||
|
bool triple);
|
||||||
|
|
||||||
|
/** Store tap event from slave (matched by sender MAC). kind: 1=single, 2=double, 3=triple. */
|
||||||
|
esp_err_t client_registry_update_tap(const uint8_t mac[CLIENT_MAC_LEN],
|
||||||
|
uint32_t slave_id, uint32_t kind);
|
||||||
|
|
||||||
|
/** Drop cached tap if older than CLIENT_REGISTRY_TAP_MAX_AGE_MS. */
|
||||||
|
void client_registry_expire_tap(client_info_t *info);
|
||||||
|
|
||||||
|
/** Clear cached tap after UART snapshot or expiry. */
|
||||||
|
void client_registry_clear_tap(client_info_t *info);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If client has a fresh tap (age <= CLIENT_REGISTRY_TAP_MAX_AGE_MS), copy it out
|
||||||
|
* and clear the cache. Returns true when an event was returned.
|
||||||
|
*/
|
||||||
|
bool client_registry_take_tap(uint32_t client_id, uint32_t *kind_out,
|
||||||
|
uint32_t *age_ms_out);
|
||||||
|
|
||||||
|
/** Master local LiPo (client_id 0 in UART battery responses). */
|
||||||
|
void client_registry_set_master_battery(const board_lipo_reading_t *reading);
|
||||||
|
bool client_registry_get_master_battery(board_lipo_reading_t *reading_out,
|
||||||
|
uint32_t *age_ms_out);
|
||||||
|
|
||||||
|
/** Store latest battery report from a slave (matched by sender MAC). */
|
||||||
|
esp_err_t client_registry_update_battery(const uint8_t mac[CLIENT_MAC_LEN],
|
||||||
|
uint32_t slave_id, bool lipo1_valid,
|
||||||
|
uint32_t lipo1_mv, bool lipo2_valid,
|
||||||
|
uint32_t lipo2_mv);
|
||||||
|
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
#include "cmd_accel_deadzone.h"
|
#include "cmd_accel_deadzone.h"
|
||||||
#include "esp_log.h"
|
#include "esp_log.h"
|
||||||
#include "esp_now_comm.h"
|
#include "esp_now_comm.h"
|
||||||
|
#include "pod_settings.h"
|
||||||
#include "uart_cmd.h"
|
#include "uart_cmd.h"
|
||||||
|
|
||||||
static const char *TAG = "[ACCEL_DZ]";
|
static const char *TAG = "[ACCEL_DZ]";
|
||||||
@@ -19,6 +20,14 @@ static void reply(uint32_t deadzone, uint32_t client_id, bool success,
|
|||||||
uart_cmd_send(&response, TAG);
|
uart_cmd_send(&response, TAG);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void apply_local_deadzone(uint32_t deadzone) {
|
||||||
|
bma456_set_accel_deadzone(deadzone);
|
||||||
|
if (pod_settings_save_accel_deadzone(deadzone) != ESP_OK) {
|
||||||
|
ESP_LOGW(TAG, "deadzone %lu applied but not saved to NVS",
|
||||||
|
(unsigned long)deadzone);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static esp_err_t push_deadzone_to_slave(const client_info_t *client,
|
static esp_err_t push_deadzone_to_slave(const client_info_t *client,
|
||||||
uint32_t deadzone) {
|
uint32_t deadzone) {
|
||||||
if (client == NULL) {
|
if (client == NULL) {
|
||||||
@@ -67,7 +76,7 @@ static void handle_accel_deadzone(const uint8_t *data, size_t len) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (bma456_is_ready()) {
|
if (bma456_is_ready()) {
|
||||||
bma456_set_accel_deadzone(req.deadzone);
|
apply_local_deadzone(req.deadzone);
|
||||||
}
|
}
|
||||||
|
|
||||||
ESP_LOGI(TAG, "set deadzone %lu via unicast to %u/%u slaves",
|
ESP_LOGI(TAG, "set deadzone %lu via unicast to %u/%u slaves",
|
||||||
@@ -77,7 +86,7 @@ static void handle_accel_deadzone(const uint8_t *data, size_t len) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (req.client_id == 0) {
|
if (req.client_id == 0) {
|
||||||
bma456_set_accel_deadzone(req.deadzone);
|
apply_local_deadzone(req.deadzone);
|
||||||
ESP_LOGI(TAG, "set local deadzone %lu (no ESP-NOW; use -client or -all "
|
ESP_LOGI(TAG, "set local deadzone %lu (no ESP-NOW; use -client or -all "
|
||||||
"for slaves)",
|
"for slaves)",
|
||||||
(unsigned long)req.deadzone);
|
(unsigned long)req.deadzone);
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
#include "client_registry.h"
|
||||||
|
#include "cmd_accel_stream.h"
|
||||||
|
#include "ble_stream.h"
|
||||||
|
#include "esp_log.h"
|
||||||
|
#include "esp_now_comm.h"
|
||||||
|
#include "uart_cmd.h"
|
||||||
|
|
||||||
|
static const char *TAG = "[ACCEL_STREAM]";
|
||||||
|
|
||||||
|
static void reply(bool enabled, uint32_t client_id, bool success,
|
||||||
|
uint32_t slaves_updated) {
|
||||||
|
alox_UartMessage response;
|
||||||
|
uart_cmd_init_response(&response, alox_MessageType_ACCEL_STREAM,
|
||||||
|
alox_UartMessage_accel_stream_response_tag);
|
||||||
|
response.payload.accel_stream_response.enabled = enabled;
|
||||||
|
response.payload.accel_stream_response.client_id = client_id;
|
||||||
|
response.payload.accel_stream_response.success = success;
|
||||||
|
response.payload.accel_stream_response.slaves_updated = slaves_updated;
|
||||||
|
uart_cmd_send(&response, TAG);
|
||||||
|
}
|
||||||
|
|
||||||
|
static esp_err_t push_stream_to_slave(const client_info_t *client, bool enable) {
|
||||||
|
if (client == NULL) {
|
||||||
|
return ESP_ERR_INVALID_ARG;
|
||||||
|
}
|
||||||
|
esp_err_t err = client_registry_set_accel_stream(client->id, enable);
|
||||||
|
if (err != ESP_OK) {
|
||||||
|
return err;
|
||||||
|
}
|
||||||
|
return esp_now_comm_send_accel_stream(client->mac, client->id, enable);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void handle_accel_stream(const uint8_t *data, size_t len) {
|
||||||
|
alox_UartMessage uart_msg;
|
||||||
|
alox_AccelStreamRequest req = alox_AccelStreamRequest_init_zero;
|
||||||
|
|
||||||
|
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(
|
||||||
|
&uart_msg, alox_UartMessage_accel_stream_request_tag, accel_stream_request);
|
||||||
|
if (req_ptr == NULL) {
|
||||||
|
ESP_LOGW(TAG, "missing accel_stream_request");
|
||||||
|
reply(false, 0, false, 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
req = *req_ptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.write) {
|
||||||
|
if (req.period_ms != 0) {
|
||||||
|
ble_stream_set_period_ms(req.period_ms);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.all_clients) {
|
||||||
|
size_t n = client_registry_set_accel_stream_all(req.enable);
|
||||||
|
uint32_t sent = 0;
|
||||||
|
|
||||||
|
for (size_t i = 0; i < client_registry_count(); i++) {
|
||||||
|
const client_info_t *client = client_registry_at(i);
|
||||||
|
if (client == NULL) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (esp_now_comm_send_accel_stream(client->mac, client->id,
|
||||||
|
req.enable) == ESP_OK) {
|
||||||
|
sent++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ESP_LOGI(TAG, "accel stream %s for %u/%u slaves period_ms=%lu",
|
||||||
|
req.enable ? "on" : "off", (unsigned)sent, (unsigned)n,
|
||||||
|
(unsigned long)ble_stream_period_ms());
|
||||||
|
ble_stream_update();
|
||||||
|
reply(req.enable, 0, sent > 0, sent);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.client_id == 0) {
|
||||||
|
ESP_LOGW(TAG, "client_id required (or all_clients)");
|
||||||
|
reply(req.enable, 0, false, 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const client_info_t *client = client_registry_find_by_id(req.client_id);
|
||||||
|
if (client == NULL) {
|
||||||
|
ESP_LOGW(TAG, "client id %lu not found", (unsigned long)req.client_id);
|
||||||
|
reply(req.enable, req.client_id, false, 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
esp_err_t err = push_stream_to_slave(client, req.enable);
|
||||||
|
ble_stream_update();
|
||||||
|
reply(req.enable, req.client_id, err == ESP_OK, err == ESP_OK ? 1u : 0u);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.all_clients || req.client_id == 0) {
|
||||||
|
reply(false, 0, false, 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool enabled = false;
|
||||||
|
esp_err_t err = client_registry_get_accel_stream(req.client_id, &enabled);
|
||||||
|
reply(enabled, req.client_id, err == ESP_OK, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
void cmd_accel_stream_register(void) {
|
||||||
|
uart_cmd_register(alox_MessageType_ACCEL_STREAM, handle_accel_stream);
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
#ifndef CMD_ACCEL_STREAM_H
|
||||||
|
#define CMD_ACCEL_STREAM_H
|
||||||
|
|
||||||
|
void cmd_accel_stream_register(void);
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
#include "cmd_battery.h"
|
||||||
|
#include "board_input.h"
|
||||||
|
#include "client_registry.h"
|
||||||
|
#include "esp_log.h"
|
||||||
|
#include "uart_cmd.h"
|
||||||
|
|
||||||
|
static const char *TAG = "[BATTERY]";
|
||||||
|
|
||||||
|
static void fill_lipo(alox_LipoReading *dst, bool *has_dst, bool valid,
|
||||||
|
uint32_t mv) {
|
||||||
|
if (dst == NULL || has_dst == NULL) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
*has_dst = true;
|
||||||
|
dst->valid = valid;
|
||||||
|
dst->voltage_mv = valid ? mv : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool append_battery_sample(alox_BatteryStatusResponse *resp,
|
||||||
|
uint32_t client_id, bool lipo1_valid,
|
||||||
|
uint32_t lipo1_mv, bool lipo2_valid,
|
||||||
|
uint32_t lipo2_mv, uint32_t age_ms) {
|
||||||
|
if (resp->samples_count >=
|
||||||
|
sizeof(resp->samples) / sizeof(resp->samples[0])) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
alox_BatterySample *sample = &resp->samples[resp->samples_count++];
|
||||||
|
sample->client_id = client_id;
|
||||||
|
fill_lipo(&sample->lipo1, &sample->has_lipo1, lipo1_valid, lipo1_mv);
|
||||||
|
fill_lipo(&sample->lipo2, &sample->has_lipo2, lipo2_valid, lipo2_mv);
|
||||||
|
sample->age_ms = age_ms;
|
||||||
|
return lipo1_valid || lipo2_valid;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool append_master_sample(alox_BatteryStatusResponse *resp) {
|
||||||
|
board_lipo_reading_t reading;
|
||||||
|
|
||||||
|
board_input_read_lipo(&reading);
|
||||||
|
client_registry_set_master_battery(&reading);
|
||||||
|
|
||||||
|
return append_battery_sample(resp, 0, reading.lipo1_valid, reading.lipo1_mv,
|
||||||
|
reading.lipo2_valid, reading.lipo2_mv, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool append_slave_cached(alox_BatteryStatusResponse *resp,
|
||||||
|
const client_info_t *client) {
|
||||||
|
if (client == NULL) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (client->battery_updated_at == 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return append_battery_sample(
|
||||||
|
resp, client->id, client->lipo1_valid, client->lipo1_mv,
|
||||||
|
client->lipo2_valid, client->lipo2_mv,
|
||||||
|
client_registry_ms_since(client->battery_updated_at));
|
||||||
|
}
|
||||||
|
|
||||||
|
static void handle_battery_status(const uint8_t *data, size_t len) {
|
||||||
|
alox_BatteryStatusRequest req = alox_BatteryStatusRequest_init_zero;
|
||||||
|
|
||||||
|
if (len > 0) {
|
||||||
|
alox_UartMessage uart_msg;
|
||||||
|
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(
|
||||||
|
&uart_msg, alox_UartMessage_battery_status_request_tag,
|
||||||
|
battery_status_request);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
alox_UartMessage response;
|
||||||
|
uart_cmd_init_response(&response, alox_MessageType_BATTERY_STATUS,
|
||||||
|
alox_UartMessage_battery_status_response_tag);
|
||||||
|
alox_BatteryStatusResponse *resp =
|
||||||
|
&response.payload.battery_status_response;
|
||||||
|
resp->success = false;
|
||||||
|
resp->samples_count = 0;
|
||||||
|
|
||||||
|
bool any = false;
|
||||||
|
|
||||||
|
if (req.all_clients) {
|
||||||
|
any |= append_master_sample(resp);
|
||||||
|
for (size_t i = 0; i < client_registry_count(); i++) {
|
||||||
|
const client_info_t *client = client_registry_at(i);
|
||||||
|
if (client == NULL) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
any |= append_slave_cached(resp, client);
|
||||||
|
}
|
||||||
|
ESP_LOGI(TAG, "battery cache all_clients → %u samples",
|
||||||
|
(unsigned)resp->samples_count);
|
||||||
|
} else if (req.client_id == 0) {
|
||||||
|
any = append_master_sample(resp);
|
||||||
|
ESP_LOGI(TAG, "battery cache master");
|
||||||
|
} else {
|
||||||
|
const client_info_t *client = client_registry_find_by_id(req.client_id);
|
||||||
|
if (client != NULL) {
|
||||||
|
any = append_slave_cached(resp, client);
|
||||||
|
} else {
|
||||||
|
ESP_LOGW(TAG, "client %lu not in registry", (unsigned long)req.client_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resp->success = any;
|
||||||
|
uart_cmd_send(&response, TAG);
|
||||||
|
}
|
||||||
|
|
||||||
|
void cmd_battery_register(void) {
|
||||||
|
uart_cmd_register(alox_MessageType_BATTERY_STATUS, handle_battery_status);
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
#ifndef CMD_BATTERY_H
|
||||||
|
#define CMD_BATTERY_H
|
||||||
|
|
||||||
|
void cmd_battery_register(void);
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
#include "cmd_battery_uv.h"
|
||||||
|
#include "client_registry.h"
|
||||||
|
#include "esp_log.h"
|
||||||
|
#include "esp_now_comm.h"
|
||||||
|
#include "pod_settings.h"
|
||||||
|
#include "uart_cmd.h"
|
||||||
|
|
||||||
|
static const char *TAG = "[BATTERY_UV_CMD]";
|
||||||
|
|
||||||
|
static void reply(uint32_t client_id, bool success, bool enabled, bool latched,
|
||||||
|
uint32_t slaves_updated) {
|
||||||
|
alox_UartMessage response;
|
||||||
|
uart_cmd_init_response(&response, alox_MessageType_BATTERY_UV,
|
||||||
|
alox_UartMessage_battery_uv_response_tag);
|
||||||
|
response.payload.battery_uv_response.client_id = client_id;
|
||||||
|
response.payload.battery_uv_response.success = success;
|
||||||
|
response.payload.battery_uv_response.enabled = enabled;
|
||||||
|
response.payload.battery_uv_response.latched = latched;
|
||||||
|
response.payload.battery_uv_response.slaves_updated = slaves_updated;
|
||||||
|
uart_cmd_send(&response, TAG);
|
||||||
|
}
|
||||||
|
|
||||||
|
static esp_err_t apply_local_uv(bool enabled) {
|
||||||
|
esp_err_t err = pod_settings_set_uv_enabled(enabled);
|
||||||
|
if (err != ESP_OK) {
|
||||||
|
return err;
|
||||||
|
}
|
||||||
|
if (!enabled) {
|
||||||
|
(void)pod_settings_clear_uv_latched();
|
||||||
|
}
|
||||||
|
return ESP_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
static esp_err_t push_uv_to_slave(const client_info_t *client, bool enabled) {
|
||||||
|
if (client == NULL) {
|
||||||
|
return ESP_ERR_INVALID_ARG;
|
||||||
|
}
|
||||||
|
esp_err_t err = client_registry_set_uv_protection(client->id, enabled);
|
||||||
|
if (err != ESP_OK) {
|
||||||
|
return err;
|
||||||
|
}
|
||||||
|
return esp_now_comm_send_battery_uv(client->mac, client->id, enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void handle_battery_uv(const uint8_t *data, size_t len) {
|
||||||
|
alox_UartMessage uart_msg;
|
||||||
|
alox_BatteryUvRequest req = alox_BatteryUvRequest_init_zero;
|
||||||
|
|
||||||
|
if (uart_cmd_decode(data, len, &uart_msg) != ESP_OK) {
|
||||||
|
ESP_LOGW(TAG, "decode failed");
|
||||||
|
reply(0, false, pod_settings_is_uv_enabled(), pod_settings_is_uv_latched(),
|
||||||
|
0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const alox_BatteryUvRequest *req_ptr = UART_CMD_REQ(
|
||||||
|
&uart_msg, alox_UartMessage_battery_uv_request_tag, battery_uv_request);
|
||||||
|
if (req_ptr != NULL) {
|
||||||
|
req = *req_ptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.write) {
|
||||||
|
if (req.all_clients) {
|
||||||
|
(void)client_registry_set_uv_protection_all(req.enabled);
|
||||||
|
uint32_t sent = 0;
|
||||||
|
for (size_t i = 0; i < client_registry_count(); i++) {
|
||||||
|
const client_info_t *client = client_registry_at(i);
|
||||||
|
if (client == NULL) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (esp_now_comm_send_battery_uv(client->mac, client->id,
|
||||||
|
req.enabled) == ESP_OK) {
|
||||||
|
sent++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool local_ok = apply_local_uv(req.enabled) == ESP_OK;
|
||||||
|
ESP_LOGI(TAG, "UV protection %s local=%d slaves=%u",
|
||||||
|
req.enabled ? "on" : "off", local_ok, (unsigned)sent);
|
||||||
|
reply(0, local_ok || sent > 0, req.enabled,
|
||||||
|
pod_settings_is_uv_latched(), sent);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.client_id == 0) {
|
||||||
|
esp_err_t err = apply_local_uv(req.enabled);
|
||||||
|
reply(0, err == ESP_OK, pod_settings_is_uv_enabled(),
|
||||||
|
pod_settings_is_uv_latched(), 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const client_info_t *client = client_registry_find_by_id(req.client_id);
|
||||||
|
if (client == NULL) {
|
||||||
|
ESP_LOGW(TAG, "client id %lu not found", (unsigned long)req.client_id);
|
||||||
|
reply(req.client_id, false, req.enabled, false, 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
esp_err_t err = push_uv_to_slave(client, req.enabled);
|
||||||
|
reply(req.client_id, err == ESP_OK, req.enabled, false,
|
||||||
|
err == ESP_OK ? 1 : 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.all_clients || req.client_id == 0) {
|
||||||
|
reply(0, true, pod_settings_is_uv_enabled(), pod_settings_is_uv_latched(),
|
||||||
|
0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool enabled = true;
|
||||||
|
esp_err_t err = client_registry_get_uv_protection(req.client_id, &enabled);
|
||||||
|
reply(req.client_id, err == ESP_OK, enabled, false, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
void cmd_battery_uv_register(void) {
|
||||||
|
uart_cmd_register(alox_MessageType_BATTERY_UV, handle_battery_uv);
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
#ifndef CMD_BATTERY_UV_H
|
||||||
|
#define CMD_BATTERY_UV_H
|
||||||
|
|
||||||
|
void cmd_battery_uv_register(void);
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
#include "client_registry.h"
|
||||||
|
#include "cmd_cache_status.h"
|
||||||
|
#include "uart_cmd.h"
|
||||||
|
|
||||||
|
static const char *TAG = "[CACHE_STAT]";
|
||||||
|
|
||||||
|
static bool tap_notify_any(const client_info_t *client) {
|
||||||
|
return client != NULL &&
|
||||||
|
(client->tap_notify_single || client->tap_notify_double ||
|
||||||
|
client->tap_notify_triple);
|
||||||
|
}
|
||||||
|
|
||||||
|
static alox_TapKind tap_kind_from_registry(uint32_t kind) {
|
||||||
|
switch (kind) {
|
||||||
|
case 1:
|
||||||
|
return alox_TapKind_TAP_SINGLE;
|
||||||
|
case 2:
|
||||||
|
return alox_TapKind_TAP_DOUBLE;
|
||||||
|
case 3:
|
||||||
|
return alox_TapKind_TAP_TRIPLE;
|
||||||
|
default:
|
||||||
|
return alox_TapKind_TAP_NONE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void fill_cache_status(alox_CacheStatusResponse *out) {
|
||||||
|
if (out == NULL) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
out->clients_count = 0;
|
||||||
|
|
||||||
|
size_t count = client_registry_count();
|
||||||
|
for (size_t i = 0; i < count; i++) {
|
||||||
|
const client_info_t *client = client_registry_at(i);
|
||||||
|
if (client == NULL) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bool want_accel = client->accel_stream_enabled;
|
||||||
|
const bool want_tap = tap_notify_any(client);
|
||||||
|
if (!want_accel && !want_tap) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (out->clients_count >=
|
||||||
|
sizeof(out->clients) / sizeof(out->clients[0])) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
alox_CacheClientStatus *entry = &out->clients[out->clients_count++];
|
||||||
|
entry->client_id = client->id;
|
||||||
|
entry->has_accel = false;
|
||||||
|
entry->has_tap = false;
|
||||||
|
|
||||||
|
if (want_accel) {
|
||||||
|
entry->has_accel = true;
|
||||||
|
entry->accel.valid = client->accel_valid;
|
||||||
|
if (client->accel_valid) {
|
||||||
|
entry->accel.x = client->accel_x;
|
||||||
|
entry->accel.y = client->accel_y;
|
||||||
|
entry->accel.z = client->accel_z;
|
||||||
|
entry->accel.age_ms =
|
||||||
|
client_registry_ms_since(client->accel_updated_at);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (want_tap) {
|
||||||
|
uint32_t kind = 0;
|
||||||
|
uint32_t age_ms = 0;
|
||||||
|
if (client_registry_take_tap(client->id, &kind, &age_ms)) {
|
||||||
|
entry->has_tap = true;
|
||||||
|
entry->tap.kind = tap_kind_from_registry(kind);
|
||||||
|
entry->tap.age_ms = age_ms;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void handle_cache_status(const uint8_t *data, size_t len) {
|
||||||
|
(void)data;
|
||||||
|
(void)len;
|
||||||
|
|
||||||
|
alox_UartMessage response;
|
||||||
|
uart_cmd_init_response(&response, alox_MessageType_CACHE_STATUS,
|
||||||
|
alox_UartMessage_cache_status_response_tag);
|
||||||
|
fill_cache_status(&response.payload.cache_status_response);
|
||||||
|
uart_cmd_send(&response, TAG);
|
||||||
|
}
|
||||||
|
|
||||||
|
void cmd_cache_status_register(void) {
|
||||||
|
uart_cmd_register(alox_MessageType_CACHE_STATUS, handle_cache_status);
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
void cmd_cache_status_register(void);
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
#include "cmd_client_id_map_reset.h"
|
||||||
|
#include "esp_log.h"
|
||||||
|
#include "esp_now_comm.h"
|
||||||
|
#include "esp_now_core.h"
|
||||||
|
#include "uart_cmd.h"
|
||||||
|
|
||||||
|
static const char *TAG = "[ID_MAP_RESET]";
|
||||||
|
|
||||||
|
static void reply(bool success) {
|
||||||
|
alox_UartMessage response;
|
||||||
|
uart_cmd_init_response(&response, alox_MessageType_CLIENT_ID_MAP_RESET,
|
||||||
|
alox_UartMessage_client_id_map_reset_response_tag);
|
||||||
|
response.payload.client_id_map_reset_response.success = success;
|
||||||
|
uart_cmd_send(&response, TAG);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void handle_client_id_map_reset(const uint8_t *data, size_t len) {
|
||||||
|
(void)data;
|
||||||
|
(void)len;
|
||||||
|
|
||||||
|
esp_err_t err;
|
||||||
|
if (esp_now_core_is_master()) {
|
||||||
|
ESP_LOGI(TAG, "reset master id map + notify online slaves");
|
||||||
|
err = esp_now_comm_reset_client_id_map();
|
||||||
|
} else {
|
||||||
|
ESP_LOGI(TAG, "clear local slave id binding");
|
||||||
|
err = esp_now_comm_clear_local_id_binding();
|
||||||
|
}
|
||||||
|
reply(err == ESP_OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
void cmd_client_id_map_reset_register(void) {
|
||||||
|
uart_cmd_register(alox_MessageType_CLIENT_ID_MAP_RESET,
|
||||||
|
handle_client_id_map_reset);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user