feat(espnow): Pi bridge client, binary wire, and espnow-sender firmware
Replace serial/Wi-Fi driver transport paths with WebSocket bridge client, binary espnow_wire delivery, device announce registry, and restructured espnow-sender (AP + broadcast passthrough). Includes docs and tests. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,9 +1,11 @@
|
||||
# led-controller
|
||||
|
||||
LED controller web app for managing profiles, **zones**, presets, and colour palettes, and sending commands to LED devices. Outbound paths include:
|
||||
LED controller web app for managing profiles, **zones**, presets, and colour palettes, and sending commands to LED devices over **ESP-NOW** (binary wire format).
|
||||
|
||||
- **Serial → ESP-NOW bridge**: JSON lines over UART to an ESP32 that forwards ESP-NOW frames (configure `serial_port` and baud in `settings.json` / Settings model).
|
||||
- **Wi-Fi LED drivers**: TCP JSON lines (default port **8765** on the Pi; drivers discover the controller via **UDP 8766** broadcast).
|
||||
- **Bridge ESP32**: runs a WebSocket server; the Pi connects as client (`bridge_ws_url` in `settings.json`, e.g. `ws://192.168.4.1/ws`).
|
||||
- **LED drivers**: announce on boot via ESP-NOW broadcast; the controller registers them and pushes group membership.
|
||||
- Architecture (diagrams): [docs/espnow-architecture.md](docs/espnow-architecture.md)
|
||||
- Wire format (byte layouts): [docs/espnow-binary-protocol.md](docs/espnow-binary-protocol.md) (≤250 bytes per frame, no JSON on the wire)
|
||||
|
||||
## Run
|
||||
|
||||
|
||||
162
docs/espnow-architecture.md
Normal file
162
docs/espnow-architecture.md
Normal file
@@ -0,0 +1,162 @@
|
||||
# ESP-NOW transport architecture
|
||||
|
||||
This document describes how **led-controller**, the **bridge ESP32**, and **led-driver** devices work together. Wire-level byte layouts are in [espnow-binary-protocol.md](espnow-binary-protocol.md).
|
||||
|
||||
**On the wire:** binary only (no JSON) for ESP-NOW and Pi↔bridge WebSocket. The Pi web UI and `db/*.json` still use JSON internally.
|
||||
|
||||
## System overview
|
||||
|
||||

|
||||
|
||||
| Component | Firmware / path | Role |
|
||||
|-----------|-----------------|------|
|
||||
| **led-controller** | Raspberry Pi, `src/` | Web app; WebSocket **client** to bridge; device registry; builds binary commands |
|
||||
| **Bridge** | [`espnow-sender/`](../espnow-sender/) | WebSocket **server** `/ws`; relays binary ↔ ESP-NOW; max **20** peers (LRU) |
|
||||
| **led-driver** | [`led-driver/`](../led-driver/) submodule | Boot **ANNOUNCE** broadcast; applies **GROUPS**, **CMD**, **GROUP_CMD** |
|
||||
|
||||
Configure the Pi in `settings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"bridge_ws_url": "ws://192.168.4.1/ws",
|
||||
"wifi_channel": 6
|
||||
}
|
||||
```
|
||||
|
||||
Connect the Pi to the **bridge access point** (SSID = bridge `name` in `/settings.json`, default IP **192.168.4.1**). All nodes must use the same 2.4 GHz **channel** (Pi sends `BRIDGE_CH` on connect; bridge updates AP + ESP-NOW STA).
|
||||
|
||||
---
|
||||
|
||||
## Boot and registration
|
||||
|
||||

|
||||
|
||||
1. Driver powers on and sends **ANNOUNCE** to broadcast MAC `ff:ff:ff:ff:ff:ff`.
|
||||
2. Bridge receives it and forwards a **WebSocket uplink** frame to the Pi (peer MAC + packet).
|
||||
3. Pi **upserts** the device in `db/device.json` (key = 12-char hex MAC).
|
||||
4. Pi scans `db/group.json` and builds a **GROUPS** packet.
|
||||
5. Pi sends **GROUPS** unicast to that MAC via the bridge.
|
||||
6. Driver stores group ids in RAM for **GROUP_CMD** filtering.
|
||||
|
||||
If the Pi or bridge is not up yet, the driver re-sends **ANNOUNCE** periodically until **GROUPS** arrives.
|
||||
|
||||
---
|
||||
|
||||
## Sending presets and commands
|
||||
|
||||

|
||||
|
||||
1. UI or API triggers a send (e.g. `POST /presets/send`).
|
||||
2. Pi builds one or more **CMD** packets (v2 binary envelope, chunked to ≤250 bytes).
|
||||
3. Each packet is wrapped in a **WebSocket downlink** frame (unicast MAC or broadcast).
|
||||
4. Bridge forwards on ESP-NOW.
|
||||
5. Driver parses and applies (presets, select, brightness, device_config, etc.).
|
||||
|
||||
For a **group**, Pi may send **GROUP_CMD** on broadcast once per chunk; only drivers that belong to that group apply the payload.
|
||||
|
||||
---
|
||||
|
||||
## Packet layers
|
||||
|
||||

|
||||
|
||||
### Layer A — WebSocket frame (Pi ↔ bridge)
|
||||
|
||||
| Offset | Size | Field |
|
||||
|--------|------|--------|
|
||||
| 0 | 1 | `flags` — bit0 = broadcast (`ff:ff:…`); peer ignored if set |
|
||||
| 1 | 6 | `peer` — destination MAC (raw bytes) |
|
||||
| 7 | … | Full ESP-NOW packet (layer B) |
|
||||
|
||||
**Uplink** (bridge → Pi): same layout; `flags = 0`, `peer` = sender.
|
||||
|
||||
**Ack** (bridge → Pi after downlink): 1 byte — `0x01` ok, `0x00` error.
|
||||
|
||||
### Layer B — ESP-NOW packet (on air)
|
||||
|
||||
| Offset | Size | Field |
|
||||
|--------|------|--------|
|
||||
| 0 | 1 | Magic `0x4C` (`'L'`) |
|
||||
| 1 | 1 | Message type |
|
||||
| 2 | … | Body (≤248 bytes so total ≤250) |
|
||||
|
||||

|
||||
|
||||
| Type | Value | Direction | Purpose |
|
||||
|------|-------|-------------|---------|
|
||||
| ANNOUNCE | `0x01` | Driver → broadcast | Boot settings |
|
||||
| GROUPS | `0x02` | Pi → driver | Group membership |
|
||||
| CMD | `0x03` | Pi → driver | Command (v2 envelope) |
|
||||
| GROUP_CMD | `0x04` | Pi → broadcast | Command scoped to one group |
|
||||
| BRIDGE_CH | `0x10` | Pi → bridge | Set STA channel 1–11 |
|
||||
|
||||
### Layer C — v2 command envelope (inside CMD / GROUP_CMD)
|
||||
|
||||
Used for presets, select, default, brightness. **No JSON.**
|
||||
|
||||
| Byte | Field |
|
||||
|------|--------|
|
||||
| 0 | Version `2` |
|
||||
| 1 | Brightness wire 0–127 (→ 0–255); `128–255` = unchanged |
|
||||
| 2 | `lp` — presets section length |
|
||||
| 3 | `ls` — select section length |
|
||||
| 4 | `ld` — default section length |
|
||||
| 5… | Presets blob (`lp` bytes) |
|
||||
| … | Select blob (`ls` bytes) |
|
||||
| … | Default blob (`ld` bytes) |
|
||||
|
||||
Optional trailing `0x01` after the envelope in **CMD** means `save` (persist to flash).
|
||||
|
||||
Implementation: [`src/util/binary_envelope.py`](../src/util/binary_envelope.py), [`src/util/espnow_wire.py`](../src/util/espnow_wire.py).
|
||||
|
||||
---
|
||||
|
||||
## Message body reference
|
||||
|
||||
### ANNOUNCE (`0x01`)
|
||||
|
||||
Sender MAC comes from ESP-NOW headers, not the body.
|
||||
|
||||
```
|
||||
name_len (u8) | name (utf-8) | num_leds (u16 LE) | color_order (u8) | startup_mode (u8) | brightness (u8) | device_type (u8)
|
||||
```
|
||||
|
||||
| `color_order` | `startup_mode` |
|
||||
|---------------|----------------|
|
||||
| 0=rgb, 1=rbg, 2=grb, 3=gbr, 4=brg, 5=bgr | 0=default, 1=last, 2=off |
|
||||
|
||||
### GROUPS (`0x02`)
|
||||
|
||||
```
|
||||
count (u8) | repeat: id_len (u8) | group_id (utf-8)
|
||||
```
|
||||
|
||||
Group ids match keys in `db/group.json` (e.g. `"5"`, `"18"`).
|
||||
|
||||
### GROUP_CMD (`0x04`)
|
||||
|
||||
```
|
||||
group_id_len (u8) | group_id (utf-8) | v2 envelope | [optional 0x01 save]
|
||||
```
|
||||
|
||||
Driver applies only if `group_id` is in its stored list.
|
||||
|
||||
---
|
||||
|
||||
## Size limits and chunking
|
||||
|
||||
- **250 bytes** max per ESP-NOW datagram.
|
||||
- Large preset libraries → multiple **CMD** packets from the Pi.
|
||||
- Bridge stores at most **20** peer MACs; oldest peer evicted (LRU) when full.
|
||||
|
||||
---
|
||||
|
||||
## Related files
|
||||
|
||||
| Topic | Location |
|
||||
|-------|----------|
|
||||
| Byte-level spec | [espnow-binary-protocol.md](espnow-binary-protocol.md) |
|
||||
| Pi wire codec | [`src/util/espnow_wire.py`](../src/util/espnow_wire.py) |
|
||||
| Pi bridge client | [`src/models/bridge_ws_client.py`](../src/models/bridge_ws_client.py) |
|
||||
| Bridge firmware | [`espnow-sender/main.py`](../espnow-sender/main.py) |
|
||||
| Driver ESP-NOW | [`led-driver/src/espnow_transport.py`](../led-driver/src/espnow_transport.py) |
|
||||
94
docs/espnow-binary-protocol.md
Normal file
94
docs/espnow-binary-protocol.md
Normal file
@@ -0,0 +1,94 @@
|
||||
# ESP-NOW binary protocol
|
||||
|
||||
**See also:** [espnow-architecture.md](espnow-architecture.md) (diagrams, flows, configuration).
|
||||
|
||||
All ESP-NOW datagrams and Pi↔bridge WebSocket frames use **binary only** (no JSON on the wire). Maximum ESP-NOW payload length: **250 bytes**.
|
||||
|
||||
## ESP-NOW packet
|
||||
|
||||
| Offset | Field |
|
||||
|--------|--------|
|
||||
| 0 | Magic `0x4C` (`'L'`) |
|
||||
| 1 | Message type |
|
||||
| 2… | Type-specific body |
|
||||
|
||||
### Message types
|
||||
|
||||
| Value | Name | Direction |
|
||||
|-------|------|-----------|
|
||||
| `0x01` | `ANNOUNCE` | Driver → broadcast |
|
||||
| `0x02` | `GROUPS` | Controller → driver |
|
||||
| `0x03` | `CMD` | Controller → driver |
|
||||
| `0x04` | `GROUP_CMD` | Controller → broadcast |
|
||||
| `0x10` | `BRIDGE_CH` | Controller → broadcast |
|
||||
|
||||
### ANNOUNCE (`0x01`)
|
||||
|
||||
Driver settings at boot. Sender MAC is taken from the ESP-NOW peer address (not repeated in the body).
|
||||
|
||||
| Field | Type |
|
||||
|-------|------|
|
||||
| name_len | u8 |
|
||||
| name | UTF-8 |
|
||||
| num_leds | u16 LE |
|
||||
| color_order | u8 enum: 0=rgb, 1=rbg, 2=grb, 3=gbr, 4=brg, 5=bgr |
|
||||
| startup_mode | u8: 0=default, 1=last, 2=off |
|
||||
| brightness | u8 0–255 |
|
||||
| device_type | u8: 0=led |
|
||||
|
||||
### GROUPS (`0x02`)
|
||||
|
||||
| Field | Type |
|
||||
|-------|------|
|
||||
| count | u8 |
|
||||
| × count | u8 id_len + UTF-8 group id |
|
||||
|
||||
### CMD (`0x03`)
|
||||
|
||||
Bytes 2… are a **v2 binary envelope** (see `src/util/binary_envelope.py`): 5-byte header + presets/select/default blobs. Total packet ≤ 250 bytes.
|
||||
|
||||
### GROUP_CMD (`0x04`)
|
||||
|
||||
| Field | Type |
|
||||
|-------|------|
|
||||
| group_id_len | u8 |
|
||||
| group_id | UTF-8 |
|
||||
| cmd_envelope | v2 binary envelope |
|
||||
|
||||
Drivers apply the nested envelope only if `group_id` is in their stored group list.
|
||||
|
||||
### BRIDGE_CH (`0x10`)
|
||||
|
||||
| Field | Type |
|
||||
|-------|------|
|
||||
| channel | u8 (1–11) |
|
||||
|
||||
Sets the bridge ESP32 STA channel (not forwarded to LED drivers as a command).
|
||||
|
||||
## Pi ↔ bridge WebSocket frame
|
||||
|
||||
Binary WebSocket messages only.
|
||||
|
||||
| Offset | Field |
|
||||
|--------|--------|
|
||||
| 0 | flags: bit0 = broadcast destination; bit1 reserved |
|
||||
| 1–6 | peer MAC (6 bytes); ignored if broadcast |
|
||||
| 7… | ESP-NOW packet (magic + type + body) |
|
||||
|
||||
Broadcast destination uses peer `ff:ff:ff:ff:ff:ff`.
|
||||
|
||||
The bridge maintains at most **20** ESP-NOW peers (LRU eviction).
|
||||
|
||||
## v2 command envelope
|
||||
|
||||
Native binary sections (no JSON). Header:
|
||||
|
||||
| Byte | Meaning |
|
||||
|------|---------|
|
||||
| 0 | Version `2` |
|
||||
| 1 | Brightness wire 0–127 (maps to 0–255); 128–255 = unchanged |
|
||||
| 2 | Presets section length |
|
||||
| 3 | Select section length |
|
||||
| 4 | Default section length |
|
||||
|
||||
See `binary_envelope.py` for blob layouts.
|
||||
57
docs/images/espnow/boot-sequence.svg
Normal file
57
docs/images/espnow/boot-sequence.svg
Normal file
@@ -0,0 +1,57 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 780 520" font-family="system-ui, Segoe UI, sans-serif">
|
||||
<defs>
|
||||
<marker id="arr" markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto">
|
||||
<path d="M0,0 L6,3 L0,6 Z" fill="#333"/>
|
||||
</marker>
|
||||
<style>
|
||||
.actor { fill: #f4f6f8; stroke: #2c3e50; stroke-width: 2; }
|
||||
.lifeline { stroke: #aaa; stroke-width: 1; stroke-dasharray: 4 4; }
|
||||
.msg { stroke: #2980b9; stroke-width: 1.5; fill: none; marker-end: url(#arr); }
|
||||
.msgret { stroke: #27ae60; stroke-width: 1.5; fill: none; marker-end: url(#arr); }
|
||||
.note { fill: #fef9e7; stroke: #d4ac0d; stroke-width: 1; }
|
||||
.t { font-size: 13px; fill: #222; }
|
||||
.h { font-size: 14px; font-weight: 700; fill: #111; }
|
||||
.s { font-size: 11px; fill: #555; }
|
||||
</style>
|
||||
</defs>
|
||||
<text x="390" y="24" text-anchor="middle" class="h" font-size="16">Boot and registration sequence</text>
|
||||
|
||||
<!-- Actors -->
|
||||
<rect class="actor" x="40" y="40" width="120" height="40" rx="6"/>
|
||||
<text x="100" y="66" text-anchor="middle" class="h">Driver</text>
|
||||
<line class="lifeline" x1="100" y1="80" x2="100" y2="480"/>
|
||||
|
||||
<rect class="actor" x="310" y="40" width="120" height="40" rx="6"/>
|
||||
<text x="370" y="66" text-anchor="middle" class="h">Bridge</text>
|
||||
<line class="lifeline" x1="370" y1="80" x2="370" y2="480"/>
|
||||
|
||||
<rect class="actor" x="580" y="40" width="140" height="40" rx="6"/>
|
||||
<text x="650" y="66" text-anchor="middle" class="h">led-controller</text>
|
||||
<line class="lifeline" x1="650" y1="80" x2="650" y2="480"/>
|
||||
|
||||
<!-- Messages -->
|
||||
<path class="msg" d="M 100 110 L 368 110"/>
|
||||
<text x="234" y="102" text-anchor="middle" class="t">ESP-NOW broadcast ANNOUNCE</text>
|
||||
<text x="234" y="128" text-anchor="middle" class="s">dest ff:ff:ff:ff:ff:ff</text>
|
||||
|
||||
<path class="msg" d="M 372 150 L 648 150"/>
|
||||
<text x="510" y="142" text-anchor="middle" class="t">WS uplink: peer MAC + packet</text>
|
||||
|
||||
<rect class="note" x="520" y="168" width="200" height="44" rx="4"/>
|
||||
<text x="620" y="188" text-anchor="middle" class="s">upsert device in</text>
|
||||
<text x="620" y="204" text-anchor="middle" class="s">db/device.json</text>
|
||||
|
||||
<path class="msgret" d="M 648 230 L 372 230"/>
|
||||
<text x="510" y="222" text-anchor="middle" class="t">WS downlink: GROUPS unicast</text>
|
||||
|
||||
<path class="msgret" d="M 368 270 L 102 270"/>
|
||||
<text x="234" y="262" text-anchor="middle" class="t">ESP-NOW unicast GROUPS</text>
|
||||
|
||||
<rect class="note" x="30" y="300" width="140" height="40" rx="4"/>
|
||||
<text x="100" y="318" text-anchor="middle" class="s">store group ids</text>
|
||||
<text x="100" y="332" text-anchor="middle" class="s">in RAM</text>
|
||||
|
||||
<text x="390" y="380" text-anchor="middle" class="s">Driver re-sends ANNOUNCE until GROUPS received if Pi/bridge late</text>
|
||||
<text x="390" y="460" text-anchor="middle" class="s">ANNOUNCE body: name, num_leds, color_order, startup_mode, brightness</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.9 KiB |
53
docs/images/espnow/command-flow.svg
Normal file
53
docs/images/espnow/command-flow.svg
Normal file
@@ -0,0 +1,53 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 780 480" font-family="system-ui, Segoe UI, sans-serif">
|
||||
<defs>
|
||||
<marker id="a" markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto">
|
||||
<path d="M0,0 L6,3 L0,6 Z" fill="#333"/>
|
||||
</marker>
|
||||
<style>
|
||||
.actor { fill: #f4f6f8; stroke: #2c3e50; stroke-width: 2; }
|
||||
.lifeline { stroke: #aaa; stroke-width: 1; stroke-dasharray: 4 4; }
|
||||
.msg { stroke: #8e44ad; stroke-width: 1.5; fill: none; marker-end: url(#a); }
|
||||
.t { font-size: 13px; fill: #222; }
|
||||
.h { font-size: 14px; font-weight: 700; }
|
||||
.s { font-size: 11px; fill: #555; }
|
||||
</style>
|
||||
</defs>
|
||||
<text x="390" y="24" text-anchor="middle" class="h" font-size="16">Preset / command delivery</text>
|
||||
|
||||
<rect class="actor" x="30" y="44" width="90" height="36" rx="6"/>
|
||||
<text x="75" y="68" text-anchor="middle" class="h">UI</text>
|
||||
<line class="lifeline" x1="75" y1="80" x2="75" y2="440"/>
|
||||
|
||||
<rect class="actor" x="200" y="44" width="120" height="36" rx="6"/>
|
||||
<text x="260" y="68" text-anchor="middle" class="h">Pi</text>
|
||||
<line class="lifeline" x1="260" y1="80" x2="260" y2="440"/>
|
||||
|
||||
<rect class="actor" x="400" y="44" width="100" height="36" rx="6"/>
|
||||
<text x="450" y="68" text-anchor="middle" class="h">Bridge</text>
|
||||
<line class="lifeline" x1="450" y1="80" x2="450" y2="440"/>
|
||||
|
||||
<rect class="actor" x="580" y="44" width="100" height="36" rx="6"/>
|
||||
<text x="630" y="68" text-anchor="middle" class="h">Driver</text>
|
||||
<line class="lifeline" x1="630" y1="80" x2="630" y2="440"/>
|
||||
|
||||
<path class="msg" d="M 77 110 L 258 110"/>
|
||||
<text x="168" y="102" text-anchor="middle" class="t">POST /presets/send (JSON)</text>
|
||||
|
||||
<text x="260" y="145" text-anchor="middle" class="s">build v2 envelope</text>
|
||||
<text x="260" y="162" text-anchor="middle" class="s">pack CMD (d250 B)</text>
|
||||
|
||||
<path class="msg" d="M 262 190 L 448 190"/>
|
||||
<text x="355" y="182" text-anchor="middle" class="t">WS downlink + CMD</text>
|
||||
|
||||
<path class="msg" d="M 452 230 L 628 230"/>
|
||||
<text x="540" y="222" text-anchor="middle" class="t">ESP-NOW unicast / broadcast</text>
|
||||
|
||||
<text x="630" y="275" text-anchor="middle" class="s">parse CMD</text>
|
||||
<text x="630" y="292" text-anchor="middle" class="s">apply presets / select</text>
|
||||
|
||||
<rect x="140" y="320" width="500" height="90" fill="#f0f0f0" stroke="#999" rx="6"/>
|
||||
<text x="390" y="345" text-anchor="middle" class="t">GROUP_CMD: one broadcast per group id only members apply</text>
|
||||
<text x="390" y="368" text-anchor="middle" class="s">Large libraries ’ multiple CMD chunks from Pi</text>
|
||||
<text x="390" y="390" text-anchor="middle" class="s">Optional trailing 0x01 on CMD = save to flash</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.7 KiB |
42
docs/images/espnow/message-types.svg
Normal file
42
docs/images/espnow/message-types.svg
Normal file
@@ -0,0 +1,42 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 320" font-family="system-ui, Segoe UI, sans-serif">
|
||||
<text x="320" y="28" text-anchor="middle" font-size="16" font-weight="700" fill="#111">ESP-NOW message types (byte 1 after 0x4C)</text>
|
||||
|
||||
<rect fill="#2c3e50" x="40" y="48" width="560" height="28" rx="4"/>
|
||||
<text x="70" y="67" fill="#fff" font-size="12" font-weight="600">Value</text>
|
||||
<text x="150" y="67" fill="#fff" font-size="12" font-weight="600">Name</text>
|
||||
<text x="280" y="67" fill="#fff" font-size="12" font-weight="600">Direction</text>
|
||||
<text x="460" y="67" fill="#fff" font-size="12" font-weight="600">Purpose</text>
|
||||
|
||||
<rect fill="#fff" stroke="#ddd" x="40" y="76" width="560" height="32"/>
|
||||
<text x="70" y="97" font-size="12">0x01</text>
|
||||
<text x="150" y="97" font-size="12" font-weight="600">ANNOUNCE</text>
|
||||
<text x="280" y="97" font-size="12">Driver ? broadcast</text>
|
||||
<text x="460" y="97" font-size="12">Boot settings</text>
|
||||
|
||||
<rect fill="#f8f9fa" stroke="#ddd" x="40" y="108" width="560" height="32"/>
|
||||
<text x="70" y="129" font-size="12">0x02</text>
|
||||
<text x="150" y="129" font-size="12" font-weight="600">GROUPS</text>
|
||||
<text x="280" y="129" font-size="12">Pi ? driver</text>
|
||||
<text x="460" y="129" font-size="12">Group membership</text>
|
||||
|
||||
<rect fill="#fff" stroke="#ddd" x="40" y="140" width="560" height="32"/>
|
||||
<text x="70" y="161" font-size="12">0x03</text>
|
||||
<text x="150" y="161" font-size="12" font-weight="600">CMD</text>
|
||||
<text x="280" y="161" font-size="12">Pi ? driver</text>
|
||||
<text x="460" y="161" font-size="12">v2 command envelope</text>
|
||||
|
||||
<rect fill="#f8f9fa" stroke="#ddd" x="40" y="172" width="560" height="32"/>
|
||||
<text x="70" y="193" font-size="12">0x04</text>
|
||||
<text x="150" y="193" font-size="12" font-weight="600">GROUP_CMD</text>
|
||||
<text x="280" y="193" font-size="12">Pi ? broadcast</text>
|
||||
<text x="460" y="193" font-size="12">Filtered by group id</text>
|
||||
|
||||
<rect fill="#fff" stroke="#ddd" x="40" y="204" width="560" height="32"/>
|
||||
<text x="70" y="225" font-size="12">0x10</text>
|
||||
<text x="150" y="225" font-size="12" font-weight="600">BRIDGE_CH</text>
|
||||
<text x="280" y="225" font-size="12">Pi ? bridge</text>
|
||||
<text x="460" y="225" font-size="12">Wi-Fi channel 1–11</text>
|
||||
|
||||
<text x="320" y="270" text-anchor="middle" font-size="12" fill="#555">Every packet: [0x4C magic][type][body…] total ? 250 bytes</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.4 KiB |
62
docs/images/espnow/packet-layers.svg
Normal file
62
docs/images/espnow/packet-layers.svg
Normal file
@@ -0,0 +1,62 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 720 480" font-family="ui-monospace, monospace">
|
||||
<defs>
|
||||
<style>
|
||||
.layer { stroke: #2c3e50; stroke-width: 2; }
|
||||
.ws { fill: #e8f4fc; }
|
||||
.esp { fill: #fef9e7; }
|
||||
.env { fill: #eafaf1; }
|
||||
.lbl { font-family: system-ui, sans-serif; font-size: 14px; font-weight: 700; fill: #111; }
|
||||
.byte { font-size: 12px; fill: #333; }
|
||||
.title { font-family: system-ui, sans-serif; font-size: 17px; font-weight: 700; }
|
||||
</style>
|
||||
</defs>
|
||||
<text x="360" y="28" text-anchor="middle" class="title">Packet layers (outside ’ inside)</text>
|
||||
|
||||
<!-- WS layer -->
|
||||
<rect class="layer ws" x="60" y="50" width="600" height="70" rx="6"/>
|
||||
<text x="80" y="78" class="lbl">WebSocket frame (Pi ” bridge)</text>
|
||||
<rect x="80" y="88" width="50" height="24" fill="#fff" stroke="#666"/>
|
||||
<text x="105" y="104" text-anchor="middle" class="byte">flags</text>
|
||||
<rect x="138" y="88" width="120" height="24" fill="#fff" stroke="#666"/>
|
||||
<text x="198" y="104" text-anchor="middle" class="byte">peer MAC ×6</text>
|
||||
<rect x="268" y="88" width="380" height="24" fill="#fff" stroke="#666"/>
|
||||
<text x="458" y="104" text-anchor="middle" class="byte">ESP-NOW packet (below)</text>
|
||||
|
||||
<!-- ESP layer -->
|
||||
<rect class="layer esp" x="100" y="140" width="520" height="70" rx="6"/>
|
||||
<text x="120" y="168" class="lbl">ESP-NOW datagram (d250 bytes)</text>
|
||||
<rect x="120" y="178" width="40" height="24" fill="#fff" stroke="#666"/>
|
||||
<text x="140" y="194" text-anchor="middle" class="byte">4C</text>
|
||||
<rect x="168" y="178" width="50" height="24" fill="#fff" stroke="#666"/>
|
||||
<text x="193" y="194" text-anchor="middle" class="byte">type</text>
|
||||
<rect x="230" y="178" width="370" height="24" fill="#fff" stroke="#666"/>
|
||||
<text x="415" y="194" text-anchor="middle" class="byte">body (ANNOUNCE / GROUPS / CMD / &)</text>
|
||||
|
||||
<!-- CMD + envelope -->
|
||||
<rect class="layer env" x="140" y="230" width="440" height="120" rx="6"/>
|
||||
<text x="160" y="258" class="lbl">Inside CMD (0x03) v2 command envelope</text>
|
||||
<rect x="160" y="268" width="28" height="22" fill="#fff" stroke="#666"/>
|
||||
<text x="174" y="283" text-anchor="middle" class="byte">02</text>
|
||||
<rect x="194" y="268" width="28" height="22" fill="#fff" stroke="#666"/>
|
||||
<text x="208" y="283" text-anchor="middle" class="byte">br</text>
|
||||
<rect x="228" y="268" width="28" height="22" fill="#fff" stroke="#666"/>
|
||||
<text x="242" y="283" text-anchor="middle" class="byte">lp</text>
|
||||
<rect x="262" y="268" width="28" height="22" fill="#fff" stroke="#666"/>
|
||||
<text x="276" y="283" text-anchor="middle" class="byte">ls</text>
|
||||
<rect x="296" y="268" width="28" height="22" fill="#fff" stroke="#666"/>
|
||||
<text x="310" y="283" text-anchor="middle" class="byte">ld</text>
|
||||
<rect x="334" y="268" width="110" height="22" fill="#fff" stroke="#666"/>
|
||||
<text x="389" y="283" text-anchor="middle" class="byte">presets</text>
|
||||
<rect x="450" y="268" width="60" height="22" fill="#fff" stroke="#666"/>
|
||||
<text x="480" y="283" text-anchor="middle" class="byte">select</text>
|
||||
<rect x="516" y="268" width="54" height="22" fill="#fff" stroke="#666"/>
|
||||
<text x="543" y="283" text-anchor="middle" class="byte">def</text>
|
||||
<rect x="160" y="300" width="60" height="22" fill="#ffeaa7" stroke="#666"/>
|
||||
<text x="190" y="315" text-anchor="middle" class="byte">save?</text>
|
||||
<text x="360" y="335" text-anchor="middle" class="byte" font-family="system-ui">optional 0x01 after envelope</text>
|
||||
|
||||
<text x="360" y="400" text-anchor="middle" font-family="system-ui" font-size="12" fill="#555">
|
||||
Pi REST/UI uses JSON · conversion to binary happens at bridge boundary
|
||||
</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.6 KiB |
65
docs/images/espnow/system-overview.svg
Normal file
65
docs/images/espnow/system-overview.svg
Normal file
@@ -0,0 +1,65 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 820 420" font-family="system-ui, Segoe UI, sans-serif">
|
||||
<defs>
|
||||
<marker id="arrow" markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto">
|
||||
<path d="M0,0 L6,3 L0,6 Z" fill="#333"/>
|
||||
</marker>
|
||||
<style>
|
||||
.box { fill: #f4f6f8; stroke: #2c3e50; stroke-width: 2; rx: 8; }
|
||||
.title { font-size: 16px; font-weight: 700; fill: #1a1a1a; }
|
||||
.label { font-size: 13px; fill: #333; }
|
||||
.small { font-size: 11px; fill: #555; }
|
||||
.line { stroke: #333; stroke-width: 1.5; fill: none; marker-end: url(#arrow); }
|
||||
.dashed { stroke-dasharray: 6 4; }
|
||||
</style>
|
||||
</defs>
|
||||
<text x="410" y="28" text-anchor="middle" class="title" font-size="18">ESP-NOW LED system three nodes</text>
|
||||
|
||||
<!-- Pi -->
|
||||
<rect class="box" x="40" y="60" width="220" height="300"/>
|
||||
<text x="150" y="88" text-anchor="middle" class="title">led-controller</text>
|
||||
<text x="150" y="108" text-anchor="middle" class="small">Raspberry Pi</text>
|
||||
<rect x="60" y="125" width="180" height="36" fill="#fff" stroke="#888" rx="4"/>
|
||||
<text x="150" y="148" text-anchor="middle" class="label">Web UI / REST (JSON)</text>
|
||||
<rect x="60" y="170" width="180" height="36" fill="#fff" stroke="#888" rx="4"/>
|
||||
<text x="150" y="193" text-anchor="middle" class="label">db/device.json, groups</text>
|
||||
<rect x="60" y="215" width="180" height="36" fill="#e8f4fc" stroke="#2980b9" rx="4"/>
|
||||
<text x="150" y="238" text-anchor="middle" class="label">espnow_wire + binary</text>
|
||||
<rect x="60" y="260" width="180" height="36" fill="#e8f4fc" stroke="#2980b9" rx="4"/>
|
||||
<text x="150" y="283" text-anchor="middle" class="label">bridge_ws_client</text>
|
||||
<text x="150" y="330" text-anchor="middle" class="small">WS client ’ bridge</text>
|
||||
|
||||
<!-- Bridge -->
|
||||
<rect class="box" x="300" y="100" width="220" height="220"/>
|
||||
<text x="410" y="128" text-anchor="middle" class="title">Bridge ESP32</text>
|
||||
<text x="410" y="148" text-anchor="middle" class="small">espnow-sender</text>
|
||||
<rect x="320" y="165" width="180" height="36" fill="#fff" stroke="#888" rx="4"/>
|
||||
<text x="410" y="188" text-anchor="middle" class="label">WebSocket server /ws</text>
|
||||
<rect x="320" y="210" width="180" height="36" fill="#fef9e7" stroke="#d4ac0d" rx="4"/>
|
||||
<text x="410" y="233" text-anchor="middle" class="label">ESP-NOW relay</text>
|
||||
<text x="410" y="275" text-anchor="middle" class="small">max 20 peers (LRU)</text>
|
||||
|
||||
<!-- Drivers -->
|
||||
<rect class="box" x="560" y="60" width="220" height="300"/>
|
||||
<text x="670" y="88" text-anchor="middle" class="title">led-driver × N</text>
|
||||
<text x="670" y="108" text-anchor="middle" class="small">ESP32 LED strips</text>
|
||||
<rect x="580" y="140" width="180" height="32" fill="#eafaf1" stroke="#27ae60" rx="4"/>
|
||||
<text x="670" y="161" text-anchor="middle" class="label">boot ANNOUNCE</text>
|
||||
<rect x="580" y="182" width="180" height="32" fill="#fff" stroke="#888" rx="4"/>
|
||||
<text x="670" y="203" text-anchor="middle" class="label">store GROUPS</text>
|
||||
<rect x="580" y="224" width="180" height="32" fill="#fff" stroke="#888" rx="4"/>
|
||||
<text x="670" y="245" text-anchor="middle" class="label">apply CMD / GROUP_CMD</text>
|
||||
<text x="670" y="320" text-anchor="middle" class="small">binary only on air</text>
|
||||
|
||||
<!-- Arrows -->
|
||||
<path class="line" d="M 260 278 L 298 200"/>
|
||||
<text x="268" y="235" class="small">binary WS</text>
|
||||
<path class="line" d="M 520 230 L 558 200"/>
|
||||
<text x="528" y="218" class="small">ESP-NOW</text>
|
||||
<path class="line dashed" d="M 520 260 L 558 280"/>
|
||||
<text x="528" y="278" class="small">broadcast</text>
|
||||
<path class="line dashed" d="M 558 160 L 520 175"/>
|
||||
<text x="530" y="158" class="small">ANNOUNCE</text>
|
||||
|
||||
<text x="410" y="400" text-anchor="middle" class="small">d250 bytes per ESP-NOW frame · no JSON on wire</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.8 KiB |
@@ -1,7 +1,54 @@
|
||||
# espnow-sender
|
||||
# espnow-sender (ESP-NOW bridge)
|
||||
|
||||
Minimal MicroPython project for receiving JSON over Microdot WebSocket.
|
||||
ESP32 firmware that relays **binary** ESP-NOW packets to/from led-controller over WebSocket.
|
||||
|
||||
- WebSocket endpoint: `/ws`
|
||||
- Entry point: `main.py`
|
||||
- Message template: `msg.json`
|
||||
Layout matches **led-driver** so you deploy with **led-tool** from this directory:
|
||||
|
||||
```
|
||||
espnow-sender/
|
||||
src/ # uploaded to device root via --src
|
||||
main.py
|
||||
wifi_ap.py
|
||||
util.py
|
||||
espnow_wire.py
|
||||
lib/ # uploaded to /lib via --lib
|
||||
aioespnow.py
|
||||
microdot/
|
||||
```
|
||||
|
||||
## Deploy with led-tool
|
||||
|
||||
```bash
|
||||
cd espnow-sender
|
||||
python ../led-tool/cli.py -p /dev/ttyUSB0 --src --lib -r -f
|
||||
```
|
||||
|
||||
| Flag | Effect |
|
||||
|------|--------|
|
||||
| `--src` | Upload `./src` → device `:/` (`main.py`, `util.py`, `espnow_wire.py`) |
|
||||
| `--lib` | Upload `./lib` → device `/lib` (aioespnow, Microdot) |
|
||||
| `-r` | Reset after upload |
|
||||
| `-f` | Follow serial output |
|
||||
|
||||
From **led-controller** root:
|
||||
|
||||
```bash
|
||||
python led-tool/cli.py -p /dev/ttyUSB0 --src --lib -r -f
|
||||
```
|
||||
|
||||
(run with `cwd` = `espnow-sender`, or `cd espnow-sender` first)
|
||||
|
||||
Optional: `--force-upload` to ignore `file_hashes.json` on the device.
|
||||
|
||||
## Runtime
|
||||
|
||||
- **Wi-Fi access point** (default IP **192.168.4.1**): connect the Pi to the bridge SSID (`name` in `/settings.json`, e.g. `bridge-aabbccddeeff`)
|
||||
- WebSocket server: `/ws` on port **80** — set Pi `bridge_ws_url` to `ws://192.168.4.1/ws` (or the printed IP)
|
||||
- Optional `ap_password` in `/settings.json` (empty = open network)
|
||||
- Default Wi-Fi channel: **6** (Pi sends `BRIDGE_CH` on connect; updates AP + ESP-NOW STA)
|
||||
- Max **20** ESP-NOW peers (LRU eviction)
|
||||
|
||||
## Protocol
|
||||
|
||||
- [docs/espnow-architecture.md](../docs/espnow-architecture.md)
|
||||
- [docs/espnow-binary-protocol.md](../docs/espnow-binary-protocol.md)
|
||||
|
||||
28
espnow-sender/lib/aioespnow.py
Normal file
28
espnow-sender/lib/aioespnow.py
Normal file
@@ -0,0 +1,28 @@
|
||||
# aioespnow module for MicroPython on ESP32 and ESP8266
|
||||
# MIT license; Copyright (c) 2022 Glenn Moloney @glenn20
|
||||
# Vendored from micropython-lib/micropython/aioespnow
|
||||
|
||||
import asyncio
|
||||
import espnow
|
||||
|
||||
|
||||
class AIOESPNow(espnow.ESPNow):
|
||||
async def arecv(self):
|
||||
yield asyncio.core._io_queue.queue_read(self)
|
||||
return self.recv(0)
|
||||
|
||||
async def airecv(self):
|
||||
yield asyncio.core._io_queue.queue_read(self)
|
||||
return self.irecv(0)
|
||||
|
||||
async def asend(self, mac, msg=None, sync=None):
|
||||
if msg is None:
|
||||
msg, mac = mac, None
|
||||
yield asyncio.core._io_queue.queue_write(self)
|
||||
return self.send(mac, msg, sync)
|
||||
|
||||
def __aiter__(self):
|
||||
return self
|
||||
|
||||
async def __anext__(self):
|
||||
return await self.airecv()
|
||||
2
espnow-sender/lib/microdot/__init__.py
Normal file
2
espnow-sender/lib/microdot/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from microdot.microdot import Microdot, Request, Response, abort, redirect, \
|
||||
send_file # noqa: F401
|
||||
8
espnow-sender/lib/microdot/helpers.py
Normal file
8
espnow-sender/lib/microdot/helpers.py
Normal file
@@ -0,0 +1,8 @@
|
||||
try:
|
||||
from functools import wraps
|
||||
except ImportError: # pragma: no cover
|
||||
# MicroPython does not currently implement functools.wraps
|
||||
def wraps(wrapped):
|
||||
def _(wrapper):
|
||||
return wrapper
|
||||
return _
|
||||
1450
espnow-sender/lib/microdot/microdot.py
Normal file
1450
espnow-sender/lib/microdot/microdot.py
Normal file
File diff suppressed because it is too large
Load Diff
225
espnow-sender/lib/microdot/session.py
Normal file
225
espnow-sender/lib/microdot/session.py
Normal file
@@ -0,0 +1,225 @@
|
||||
try:
|
||||
import jwt
|
||||
HAS_JWT = True
|
||||
except ImportError:
|
||||
HAS_JWT = False
|
||||
try:
|
||||
import ubinascii
|
||||
except ImportError:
|
||||
import binascii as ubinascii
|
||||
try:
|
||||
import uhashlib as hashlib
|
||||
except ImportError:
|
||||
import hashlib
|
||||
try:
|
||||
import uhmac as hmac
|
||||
except ImportError:
|
||||
try:
|
||||
import hmac
|
||||
except ImportError:
|
||||
hmac = None
|
||||
import json
|
||||
|
||||
from microdot.microdot import invoke_handler
|
||||
from microdot.helpers import wraps
|
||||
|
||||
|
||||
class SessionDict(dict):
|
||||
"""A session dictionary.
|
||||
|
||||
The session dictionary is a standard Python dictionary that has been
|
||||
extended with convenience ``save()`` and ``delete()`` methods.
|
||||
"""
|
||||
def __init__(self, request, session_dict):
|
||||
super().__init__(session_dict)
|
||||
self.request = request
|
||||
|
||||
def save(self):
|
||||
"""Update the session cookie."""
|
||||
self.request.app._session.update(self.request, self)
|
||||
|
||||
def delete(self):
|
||||
"""Delete the session cookie."""
|
||||
self.request.app._session.delete(self.request)
|
||||
|
||||
|
||||
class Session:
|
||||
"""Session handling
|
||||
|
||||
:param app: The application instance.
|
||||
:param secret_key: The secret key, as a string or bytes object.
|
||||
:param cookie_options: A dictionary with cookie options to pass as
|
||||
arguments to :meth:`Response.set_cookie()
|
||||
<microdot.Response.set_cookie>`.
|
||||
"""
|
||||
secret_key = None
|
||||
|
||||
def __init__(self, app=None, secret_key=None, cookie_options=None):
|
||||
self.secret_key = secret_key
|
||||
self.cookie_options = cookie_options or {}
|
||||
if app is not None:
|
||||
self.initialize(app)
|
||||
|
||||
def initialize(self, app, secret_key=None, cookie_options=None):
|
||||
if secret_key is not None:
|
||||
self.secret_key = secret_key
|
||||
if cookie_options is not None:
|
||||
self.cookie_options = cookie_options
|
||||
if 'path' not in self.cookie_options:
|
||||
self.cookie_options['path'] = '/'
|
||||
if 'http_only' not in self.cookie_options:
|
||||
self.cookie_options['http_only'] = True
|
||||
app._session = self
|
||||
|
||||
def get(self, request):
|
||||
"""Retrieve the user session.
|
||||
|
||||
:param request: The client request.
|
||||
|
||||
The return value is a session dictionary with the data stored in the
|
||||
user's session, or ``{}`` if the session data is not available or
|
||||
invalid.
|
||||
"""
|
||||
if not self.secret_key:
|
||||
raise ValueError('The session secret key is not configured')
|
||||
if hasattr(request.g, '_session'):
|
||||
return request.g._session
|
||||
session = request.cookies.get('session')
|
||||
if session is None:
|
||||
request.g._session = SessionDict(request, {})
|
||||
return request.g._session
|
||||
request.g._session = SessionDict(request, self.decode(session))
|
||||
return request.g._session
|
||||
|
||||
def update(self, request, session):
|
||||
"""Update the user session.
|
||||
|
||||
:param request: The client request.
|
||||
:param session: A dictionary with the update session data for the user.
|
||||
|
||||
Applications would normally not call this method directly, instead they
|
||||
would use the :meth:`SessionDict.save` method on the session
|
||||
dictionary, which calls this method. For example::
|
||||
|
||||
@app.route('/')
|
||||
@with_session
|
||||
def index(request, session):
|
||||
session['foo'] = 'bar'
|
||||
session.save()
|
||||
return 'Hello, World!'
|
||||
|
||||
Calling this method adds a cookie with the updated session to the
|
||||
request currently being processed.
|
||||
"""
|
||||
if not self.secret_key:
|
||||
raise ValueError('The session secret key is not configured')
|
||||
|
||||
encoded_session = self.encode(session)
|
||||
|
||||
@request.after_request
|
||||
def _update_session(request, response):
|
||||
response.set_cookie('session', encoded_session,
|
||||
**self.cookie_options)
|
||||
return response
|
||||
|
||||
def delete(self, request):
|
||||
"""Remove the user session.
|
||||
|
||||
:param request: The client request.
|
||||
|
||||
Applications would normally not call this method directly, instead they
|
||||
would use the :meth:`SessionDict.delete` method on the session
|
||||
dictionary, which calls this method. For example::
|
||||
|
||||
@app.route('/')
|
||||
@with_session
|
||||
def index(request, session):
|
||||
session.delete()
|
||||
return 'Hello, World!'
|
||||
|
||||
Calling this method adds a cookie removal header to the request
|
||||
currently being processed.
|
||||
"""
|
||||
@request.after_request
|
||||
def _delete_session(request, response):
|
||||
response.delete_cookie('session', **self.cookie_options)
|
||||
return response
|
||||
|
||||
def encode(self, payload, secret_key=None):
|
||||
"""Encode session data using JWT if available, otherwise use simple HMAC."""
|
||||
if HAS_JWT:
|
||||
return jwt.encode(payload, secret_key or self.secret_key,
|
||||
algorithm='HS256')
|
||||
else:
|
||||
# Simple encoding for MicroPython: base64(json) + HMAC signature
|
||||
key = (secret_key or self.secret_key).encode() if isinstance(secret_key or self.secret_key, str) else (secret_key or self.secret_key)
|
||||
payload_json = json.dumps(payload)
|
||||
payload_b64 = ubinascii.b2a_base64(payload_json.encode()).decode().strip()
|
||||
|
||||
# Create HMAC signature
|
||||
if hmac:
|
||||
# Use hmac module if available
|
||||
h = hmac.new(key, payload_json.encode(), hashlib.sha256)
|
||||
else:
|
||||
# Fallback: simple SHA256(key + message)
|
||||
h = hashlib.sha256(key + payload_json.encode())
|
||||
signature = ubinascii.b2a_base64(h.digest()).decode().strip()
|
||||
|
||||
return f"{payload_b64}.{signature}"
|
||||
|
||||
def decode(self, session, secret_key=None):
|
||||
"""Decode session data using JWT if available, otherwise use simple HMAC."""
|
||||
if HAS_JWT:
|
||||
try:
|
||||
payload = jwt.decode(session, secret_key or self.secret_key,
|
||||
algorithms=['HS256'])
|
||||
except jwt.exceptions.PyJWTError: # pragma: no cover
|
||||
return {}
|
||||
return payload
|
||||
else:
|
||||
try:
|
||||
# Simple decoding for MicroPython
|
||||
if '.' not in session:
|
||||
return {}
|
||||
|
||||
payload_b64, signature = session.rsplit('.', 1)
|
||||
payload_json = ubinascii.a2b_base64(payload_b64).decode()
|
||||
|
||||
# Verify HMAC signature
|
||||
key = (secret_key or self.secret_key).encode() if isinstance(secret_key or self.secret_key, str) else (secret_key or self.secret_key)
|
||||
if hmac:
|
||||
# Use hmac module if available
|
||||
h = hmac.new(key, payload_json.encode(), hashlib.sha256)
|
||||
else:
|
||||
# Fallback: simple SHA256(key + message)
|
||||
h = hashlib.sha256(key + payload_json.encode())
|
||||
expected_signature = ubinascii.b2a_base64(h.digest()).decode().strip()
|
||||
|
||||
if signature != expected_signature:
|
||||
return {}
|
||||
|
||||
return json.loads(payload_json)
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def with_session(f):
|
||||
"""Decorator that passes the user session to the route handler.
|
||||
|
||||
The session dictionary is passed to the decorated function as an argument
|
||||
after the request object. Example::
|
||||
|
||||
@app.route('/')
|
||||
@with_session
|
||||
def index(request, session):
|
||||
return 'Hello, World!'
|
||||
|
||||
Note that the decorator does not save the session. To update the session,
|
||||
call the :func:`session.save() <microdot.session.SessionDict.save>` method.
|
||||
"""
|
||||
@wraps(f)
|
||||
async def wrapper(request, *args, **kwargs):
|
||||
return await invoke_handler(
|
||||
f, request, request.app._session.get(request), *args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
70
espnow-sender/lib/microdot/utemplate.py
Normal file
70
espnow-sender/lib/microdot/utemplate.py
Normal file
@@ -0,0 +1,70 @@
|
||||
from utemplate import recompile
|
||||
|
||||
_loader = None
|
||||
|
||||
|
||||
class Template:
|
||||
"""A template object.
|
||||
|
||||
:param template: The filename of the template to render, relative to the
|
||||
configured template directory.
|
||||
"""
|
||||
@classmethod
|
||||
def initialize(cls, template_dir='templates',
|
||||
loader_class=recompile.Loader):
|
||||
"""Initialize the templating subsystem.
|
||||
|
||||
:param template_dir: the directory where templates are stored. This
|
||||
argument is optional. The default is to load
|
||||
templates from a *templates* subdirectory.
|
||||
:param loader_class: the ``utemplate.Loader`` class to use when loading
|
||||
templates. This argument is optional. The default
|
||||
is the ``recompile.Loader`` class, which
|
||||
automatically recompiles templates when they
|
||||
change.
|
||||
"""
|
||||
global _loader
|
||||
_loader = loader_class(None, template_dir)
|
||||
|
||||
def __init__(self, template):
|
||||
if _loader is None: # pragma: no cover
|
||||
self.initialize()
|
||||
#: The name of the template
|
||||
self.name = template
|
||||
self.template = _loader.load(template)
|
||||
|
||||
def generate(self, *args, **kwargs):
|
||||
"""Return a generator that renders the template in chunks, with the
|
||||
given arguments."""
|
||||
return self.template(*args, **kwargs)
|
||||
|
||||
def render(self, *args, **kwargs):
|
||||
"""Render the template with the given arguments and return it as a
|
||||
string."""
|
||||
return ''.join(self.generate(*args, **kwargs))
|
||||
|
||||
def generate_async(self, *args, **kwargs):
|
||||
"""Return an asynchronous generator that renders the template in
|
||||
chunks, using the given arguments."""
|
||||
class sync_to_async_iter():
|
||||
def __init__(self, iter):
|
||||
self.iter = iter
|
||||
|
||||
def __aiter__(self):
|
||||
return self
|
||||
|
||||
async def __anext__(self):
|
||||
try:
|
||||
return next(self.iter)
|
||||
except StopIteration:
|
||||
raise StopAsyncIteration
|
||||
|
||||
return sync_to_async_iter(self.generate(*args, **kwargs))
|
||||
|
||||
async def render_async(self, *args, **kwargs):
|
||||
"""Render the template with the given arguments asynchronously and
|
||||
return it as a string."""
|
||||
response = ''
|
||||
async for chunk in self.generate_async(*args, **kwargs):
|
||||
response += chunk
|
||||
return response
|
||||
231
espnow-sender/lib/microdot/websocket.py
Normal file
231
espnow-sender/lib/microdot/websocket.py
Normal file
@@ -0,0 +1,231 @@
|
||||
import binascii
|
||||
import hashlib
|
||||
from microdot import Request, Response
|
||||
from microdot.microdot import MUTED_SOCKET_ERRORS, print_exception
|
||||
from microdot.helpers import wraps
|
||||
|
||||
|
||||
class WebSocketError(Exception):
|
||||
"""Exception raised when an error occurs in a WebSocket connection."""
|
||||
pass
|
||||
|
||||
|
||||
class WebSocket:
|
||||
"""A WebSocket connection object.
|
||||
|
||||
An instance of this class is sent to handler functions to manage the
|
||||
WebSocket connection.
|
||||
"""
|
||||
CONT = 0
|
||||
TEXT = 1
|
||||
BINARY = 2
|
||||
CLOSE = 8
|
||||
PING = 9
|
||||
PONG = 10
|
||||
|
||||
#: Specify the maximum message size that can be received when calling the
|
||||
#: ``receive()`` method. Messages with payloads that are larger than this
|
||||
#: size will be rejected and the connection closed. Set to 0 to disable
|
||||
#: the size check (be aware of potential security issues if you do this),
|
||||
#: or to -1 to use the value set in
|
||||
#: ``Request.max_body_length``. The default is -1.
|
||||
#:
|
||||
#: Example::
|
||||
#:
|
||||
#: WebSocket.max_message_length = 4 * 1024 # up to 4KB messages
|
||||
max_message_length = -1
|
||||
|
||||
def __init__(self, request):
|
||||
self.request = request
|
||||
self.closed = False
|
||||
|
||||
async def handshake(self):
|
||||
response = self._handshake_response()
|
||||
await self.request.sock[1].awrite(
|
||||
b'HTTP/1.1 101 Switching Protocols\r\n')
|
||||
await self.request.sock[1].awrite(b'Upgrade: websocket\r\n')
|
||||
await self.request.sock[1].awrite(b'Connection: Upgrade\r\n')
|
||||
await self.request.sock[1].awrite(
|
||||
b'Sec-WebSocket-Accept: ' + response + b'\r\n\r\n')
|
||||
|
||||
async def receive(self):
|
||||
"""Receive a message from the client."""
|
||||
while True:
|
||||
opcode, payload = await self._read_frame()
|
||||
send_opcode, data = self._process_websocket_frame(opcode, payload)
|
||||
if send_opcode: # pragma: no cover
|
||||
await self.send(data, send_opcode)
|
||||
elif data: # pragma: no branch
|
||||
return data
|
||||
|
||||
async def send(self, data, opcode=None):
|
||||
"""Send a message to the client.
|
||||
|
||||
:param data: the data to send, given as a string or bytes.
|
||||
:param opcode: a custom frame opcode to use. If not given, the opcode
|
||||
is ``TEXT`` or ``BINARY`` depending on the type of the
|
||||
data.
|
||||
"""
|
||||
frame = self._encode_websocket_frame(
|
||||
opcode or (self.TEXT if isinstance(data, str) else self.BINARY),
|
||||
data)
|
||||
await self.request.sock[1].awrite(frame)
|
||||
|
||||
async def close(self):
|
||||
"""Close the websocket connection."""
|
||||
if not self.closed: # pragma: no cover
|
||||
self.closed = True
|
||||
await self.send(b'', self.CLOSE)
|
||||
|
||||
def _handshake_response(self):
|
||||
connection = False
|
||||
upgrade = False
|
||||
websocket_key = None
|
||||
for header, value in self.request.headers.items():
|
||||
h = header.lower()
|
||||
if h == 'connection':
|
||||
connection = True
|
||||
if 'upgrade' not in value.lower():
|
||||
return self.request.app.abort(400)
|
||||
elif h == 'upgrade':
|
||||
upgrade = True
|
||||
if not value.lower() == 'websocket':
|
||||
return self.request.app.abort(400)
|
||||
elif h == 'sec-websocket-key':
|
||||
websocket_key = value
|
||||
if not connection or not upgrade or not websocket_key:
|
||||
return self.request.app.abort(400)
|
||||
d = hashlib.sha1(websocket_key.encode())
|
||||
d.update(b'258EAFA5-E914-47DA-95CA-C5AB0DC85B11')
|
||||
return binascii.b2a_base64(d.digest())[:-1]
|
||||
|
||||
@classmethod
|
||||
def _parse_frame_header(cls, header):
|
||||
fin = header[0] & 0x80
|
||||
opcode = header[0] & 0x0f
|
||||
if fin == 0 or opcode == cls.CONT: # pragma: no cover
|
||||
raise WebSocketError('Continuation frames not supported')
|
||||
has_mask = header[1] & 0x80
|
||||
length = header[1] & 0x7f
|
||||
if length == 126:
|
||||
length = -2
|
||||
elif length == 127:
|
||||
length = -8
|
||||
return fin, opcode, has_mask, length
|
||||
|
||||
def _process_websocket_frame(self, opcode, payload):
|
||||
if opcode == self.TEXT:
|
||||
payload = payload.decode()
|
||||
elif opcode == self.BINARY:
|
||||
pass
|
||||
elif opcode == self.CLOSE:
|
||||
raise WebSocketError('Websocket connection closed')
|
||||
elif opcode == self.PING:
|
||||
return self.PONG, payload
|
||||
elif opcode == self.PONG: # pragma: no branch
|
||||
return None, None
|
||||
return None, payload
|
||||
|
||||
@classmethod
|
||||
def _encode_websocket_frame(cls, opcode, payload):
|
||||
frame = bytearray()
|
||||
frame.append(0x80 | opcode)
|
||||
if opcode == cls.TEXT:
|
||||
payload = payload.encode()
|
||||
if len(payload) < 126:
|
||||
frame.append(len(payload))
|
||||
elif len(payload) < (1 << 16):
|
||||
frame.append(126)
|
||||
frame.extend(len(payload).to_bytes(2, 'big'))
|
||||
else:
|
||||
frame.append(127)
|
||||
frame.extend(len(payload).to_bytes(8, 'big'))
|
||||
frame.extend(payload)
|
||||
return frame
|
||||
|
||||
async def _read_frame(self):
|
||||
header = await self.request.sock[0].read(2)
|
||||
if len(header) != 2: # pragma: no cover
|
||||
raise WebSocketError('Websocket connection closed')
|
||||
fin, opcode, has_mask, length = self._parse_frame_header(header)
|
||||
if length == -2:
|
||||
length = await self.request.sock[0].read(2)
|
||||
length = int.from_bytes(length, 'big')
|
||||
elif length == -8:
|
||||
length = await self.request.sock[0].read(8)
|
||||
length = int.from_bytes(length, 'big')
|
||||
max_allowed_length = Request.max_body_length \
|
||||
if self.max_message_length == -1 else self.max_message_length
|
||||
if length > max_allowed_length:
|
||||
raise WebSocketError('Message too large')
|
||||
if has_mask: # pragma: no cover
|
||||
mask = await self.request.sock[0].read(4)
|
||||
payload = await self.request.sock[0].read(length)
|
||||
if has_mask: # pragma: no cover
|
||||
payload = bytes(x ^ mask[i % 4] for i, x in enumerate(payload))
|
||||
return opcode, payload
|
||||
|
||||
|
||||
async def websocket_upgrade(request):
|
||||
"""Upgrade a request handler to a websocket connection.
|
||||
|
||||
This function can be called directly inside a route function to process a
|
||||
WebSocket upgrade handshake, for example after the user's credentials are
|
||||
verified. The function returns the websocket object::
|
||||
|
||||
@app.route('/echo')
|
||||
async def echo(request):
|
||||
if not authenticate_user(request):
|
||||
abort(401)
|
||||
ws = await websocket_upgrade(request)
|
||||
while True:
|
||||
message = await ws.receive()
|
||||
await ws.send(message)
|
||||
"""
|
||||
ws = WebSocket(request)
|
||||
await ws.handshake()
|
||||
|
||||
@request.after_request
|
||||
async def after_request(request, response):
|
||||
return Response.already_handled
|
||||
|
||||
return ws
|
||||
|
||||
|
||||
def websocket_wrapper(f, upgrade_function):
|
||||
@wraps(f)
|
||||
async def wrapper(request, *args, **kwargs):
|
||||
ws = await upgrade_function(request)
|
||||
try:
|
||||
await f(request, ws, *args, **kwargs)
|
||||
except OSError as exc:
|
||||
if exc.errno not in MUTED_SOCKET_ERRORS: # pragma: no cover
|
||||
raise
|
||||
except WebSocketError:
|
||||
pass
|
||||
except Exception as exc:
|
||||
print_exception(exc)
|
||||
finally: # pragma: no cover
|
||||
try:
|
||||
await ws.close()
|
||||
except Exception:
|
||||
pass
|
||||
return Response.already_handled
|
||||
return wrapper
|
||||
|
||||
|
||||
def with_websocket(f):
|
||||
"""Decorator to make a route a WebSocket endpoint.
|
||||
|
||||
This decorator is used to define a route that accepts websocket
|
||||
connections. The route then receives a websocket object as a second
|
||||
argument that it can use to send and receive messages::
|
||||
|
||||
@app.route('/echo')
|
||||
@with_websocket
|
||||
async def echo(request, ws):
|
||||
while True:
|
||||
message = await ws.receive()
|
||||
await ws.send(message)
|
||||
"""
|
||||
return websocket_wrapper(f, websocket_upgrade)
|
||||
@@ -1,120 +0,0 @@
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
from microdot import Microdot
|
||||
from microdot.websocket import WebSocketError, with_websocket
|
||||
|
||||
import espnow
|
||||
import network
|
||||
from util import format_mac, parse_mac
|
||||
|
||||
|
||||
app = Microdot()
|
||||
_esp = None
|
||||
_known_peers = set()
|
||||
_ws_clients = set()
|
||||
|
||||
|
||||
def _init_espnow():
|
||||
global _esp
|
||||
sta = network.WLAN(network.STA_IF)
|
||||
sta.active(True)
|
||||
_esp = espnow.ESPNow()
|
||||
_esp.active(True)
|
||||
|
||||
|
||||
def _validate_envelope(obj):
|
||||
if obj.get("v") != "1":
|
||||
raise ValueError("message.v must be '1'")
|
||||
devices = obj["devices"]
|
||||
for address in devices.keys():
|
||||
parse_mac(address)
|
||||
return obj
|
||||
|
||||
|
||||
def _send_espnow(address, payload):
|
||||
if _esp is None:
|
||||
raise ValueError("espnow is not initialized")
|
||||
mac = parse_mac(address)
|
||||
msg = json.dumps(payload, separators=(",", ":")).encode("utf-8")
|
||||
if mac not in _known_peers:
|
||||
_esp.add_peer(mac)
|
||||
_known_peers.add(mac)
|
||||
_esp.send(mac, msg)
|
||||
return mac, len(msg)
|
||||
|
||||
|
||||
async def _broadcast_ws(obj):
|
||||
text = json.dumps(obj)
|
||||
dead = []
|
||||
for client in list(_ws_clients):
|
||||
try:
|
||||
await client.send(text)
|
||||
except Exception:
|
||||
dead.append(client)
|
||||
for client in dead:
|
||||
_ws_clients.discard(client)
|
||||
|
||||
|
||||
async def _espnow_receive_loop():
|
||||
while True:
|
||||
host, msg = _esp.recv(0)
|
||||
if not host:
|
||||
await asyncio.sleep(0.01)
|
||||
continue
|
||||
await _broadcast_ws(
|
||||
{
|
||||
"from": format_mac(host),
|
||||
"payload": msg.decode("utf-8"),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@app.route("/ws")
|
||||
@with_websocket
|
||||
async def ws(request, ws):
|
||||
_ws_clients.add(ws)
|
||||
while True:
|
||||
try:
|
||||
raw = await ws.receive()
|
||||
except WebSocketError:
|
||||
break
|
||||
|
||||
if not raw:
|
||||
break
|
||||
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
env = _validate_envelope(parsed)
|
||||
sent = []
|
||||
for address, payload in env["devices"].items():
|
||||
mac, payload_size = _send_espnow(address, payload)
|
||||
sent.append(
|
||||
{
|
||||
"address": format_mac(mac),
|
||||
"bytes": payload_size,
|
||||
}
|
||||
)
|
||||
except (ValueError, TypeError) as e:
|
||||
await ws.send(json.dumps({"ok": False, "error": str(e)}))
|
||||
continue
|
||||
|
||||
await ws.send(
|
||||
json.dumps(
|
||||
{
|
||||
"ok": True,
|
||||
"sent": sent,
|
||||
}
|
||||
)
|
||||
)
|
||||
_ws_clients.discard(ws)
|
||||
|
||||
|
||||
async def main(port=80):
|
||||
_init_espnow()
|
||||
asyncio.create_task(_espnow_receive_loop())
|
||||
await app.start_server(host="0.0.0.0", port=port)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main(port=80))
|
||||
28
espnow-sender/src/espnow_wire.py
Normal file
28
espnow-sender/src/espnow_wire.py
Normal file
@@ -0,0 +1,28 @@
|
||||
"""ESP-NOW / WebSocket framing (MicroPython). See docs/espnow-binary-protocol.md."""
|
||||
|
||||
WIRE_MAGIC = 0x4C
|
||||
MSG_BRIDGE_CH = 0x10
|
||||
BROADCAST_MAC = b"\xff\xff\xff\xff\xff\xff"
|
||||
WS_FLAG_BROADCAST = 0x01
|
||||
MAX_PEERS = 20
|
||||
|
||||
|
||||
def parse_ws_downlink(frame):
|
||||
"""Return (peer_bytes, espnow_packet, is_broadcast)."""
|
||||
if not frame or len(frame) < 8:
|
||||
raise ValueError("frame too short")
|
||||
flags = frame[0]
|
||||
peer = frame[1:7]
|
||||
pkt = frame[7:]
|
||||
broadcast = bool(flags & WS_FLAG_BROADCAST) or peer == BROADCAST_MAC
|
||||
return peer, pkt, broadcast
|
||||
|
||||
|
||||
def pack_ws_uplink(peer, espnow_packet):
|
||||
return bytes([0]) + peer + espnow_packet
|
||||
|
||||
|
||||
def parse_bridge_channel(pkt):
|
||||
if len(pkt) >= 3 and pkt[0] == WIRE_MAGIC and pkt[1] == MSG_BRIDGE_CH:
|
||||
return pkt[2]
|
||||
return None
|
||||
76
espnow-sender/src/main.py
Normal file
76
espnow-sender/src/main.py
Normal file
@@ -0,0 +1,76 @@
|
||||
import asyncio
|
||||
import time
|
||||
|
||||
from microdot import Microdot
|
||||
from microdot.websocket import WebSocketError, with_websocket
|
||||
|
||||
import aioespnow
|
||||
import machine
|
||||
import network
|
||||
from settings import Settings
|
||||
|
||||
|
||||
wdt = machine.WDT(timeout=10000)
|
||||
wdt.feed()
|
||||
settings = Settings()
|
||||
print(settings)
|
||||
|
||||
app = Microdot()
|
||||
|
||||
ap_if = network.WLAN(network.AP_IF)
|
||||
ap_if.active(True)
|
||||
ap_if.config(ssid=settings.get("name"), password=settings.get("ap_password"))
|
||||
print(ap_if.ifconfig())
|
||||
|
||||
sta_if = network.WLAN(network.STA_IF)
|
||||
sta_if.active(True)
|
||||
print(sta_if.config("channel"))
|
||||
|
||||
esp = aioespnow.AIOESPNow()
|
||||
esp.active(True)
|
||||
esp.add_peer(b"\xff\xff\xff\xff\xff\xff")
|
||||
|
||||
clients = set()
|
||||
|
||||
@app.route("/ws")
|
||||
@with_websocket
|
||||
async def ws(request, ws):
|
||||
clients.add(ws)
|
||||
while True:
|
||||
|
||||
try:
|
||||
raw = await ws.receive()
|
||||
except WebSocketError as err:
|
||||
print(err)
|
||||
break
|
||||
if not raw:
|
||||
break
|
||||
try:
|
||||
await esp.asend(b"\xff\xff\xff\xff\xff\xff", raw)
|
||||
print(raw)
|
||||
except Exception as err:
|
||||
print(err)
|
||||
break
|
||||
ws.close()
|
||||
clients.discard(ws)
|
||||
|
||||
async def _espnow_receive_loop():
|
||||
async for host, msg in esp.airecv():
|
||||
print(host, msg)
|
||||
for client in clients:
|
||||
await client.send(msg)
|
||||
|
||||
|
||||
async def _wdt_feed_loop():
|
||||
while True:
|
||||
await asyncio.sleep(1)
|
||||
wdt.feed()
|
||||
|
||||
async def main():
|
||||
asyncio.create_task(_wdt_feed_loop())
|
||||
asyncio.create_task(_espnow_receive_loop())
|
||||
await app.start_server(host="0.0.0.0", port=80)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
73
espnow-sender/src/settings.py
Normal file
73
espnow-sender/src/settings.py
Normal file
@@ -0,0 +1,73 @@
|
||||
import json
|
||||
import time
|
||||
import ubinascii
|
||||
import network
|
||||
|
||||
|
||||
def _sta_mac_hex():
|
||||
"""Read STA MAC without leaving the radio up (wifi_ap owns bring-up)."""
|
||||
sta = network.WLAN(network.STA_IF)
|
||||
was_on = False
|
||||
try:
|
||||
was_on = sta.active()
|
||||
except Exception:
|
||||
pass
|
||||
if not was_on:
|
||||
try:
|
||||
sta.active(True)
|
||||
time.sleep_ms(50)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
mac = ubinascii.hexlify(sta.config("mac")).decode().lower()
|
||||
except Exception:
|
||||
mac = "000000000000"
|
||||
if not was_on:
|
||||
try:
|
||||
sta.active(False)
|
||||
except Exception:
|
||||
pass
|
||||
return mac
|
||||
|
||||
|
||||
class Settings(dict):
|
||||
SETTINGS_FILE = "/settings.json"
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.load()
|
||||
|
||||
def set_defaults(self):
|
||||
mac = _sta_mac_hex()
|
||||
self["name"] = "bridge-" + mac
|
||||
self["wifi_channel"] = 6
|
||||
self["ap_password"] = ""
|
||||
self["ap_ip"] = "192.168.4.1"
|
||||
self["ws_port"] = 80
|
||||
self["max_peers"] = 20
|
||||
|
||||
def save(self):
|
||||
try:
|
||||
with open(self.SETTINGS_FILE, "w") as file:
|
||||
file.write(json.dumps(self))
|
||||
except Exception as e:
|
||||
print("Error saving settings:", e)
|
||||
|
||||
def load(self):
|
||||
try:
|
||||
with open(self.SETTINGS_FILE, "r") as file:
|
||||
loaded = json.load(file)
|
||||
if not isinstance(loaded, dict):
|
||||
raise ValueError("settings.json is not an object")
|
||||
except Exception:
|
||||
print("Error loading settings")
|
||||
self.clear()
|
||||
self.set_defaults()
|
||||
self.save()
|
||||
return
|
||||
self.clear()
|
||||
self.set_defaults()
|
||||
for k, v in loaded.items():
|
||||
self[k] = v
|
||||
|
||||
|
||||
49
espnow-sender/src/util.py
Normal file
49
espnow-sender/src/util.py
Normal file
@@ -0,0 +1,49 @@
|
||||
def parse_mac(value):
|
||||
raw = value.strip().lower().replace(":", "").replace("-", "")
|
||||
if len(raw) != 12:
|
||||
raise ValueError("address must be 12 hex chars or aa:bb:cc:dd:ee:ff")
|
||||
try:
|
||||
return bytes.fromhex(raw)
|
||||
except ValueError:
|
||||
raise ValueError("address contains non-hex characters")
|
||||
|
||||
|
||||
def format_mac(mac_bytes):
|
||||
return ":".join("{:02x}".format(b) for b in mac_bytes)
|
||||
|
||||
|
||||
def print_bridge_ip(ws_port=80):
|
||||
import network
|
||||
|
||||
try:
|
||||
port = int(ws_port)
|
||||
except (TypeError, ValueError):
|
||||
port = 80
|
||||
|
||||
ips = []
|
||||
try:
|
||||
sta = network.WLAN(network.STA_IF)
|
||||
if sta.active():
|
||||
ip = sta.ifconfig()[0]
|
||||
if ip and ip != "0.0.0.0":
|
||||
ips.append(("STA", ip))
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
ap = network.WLAN(network.AP_IF)
|
||||
if ap.active():
|
||||
ip = ap.ifconfig()[0]
|
||||
if ip and ip != "0.0.0.0":
|
||||
ips.append(("AP", ip))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not ips:
|
||||
print("bridge IP: (AP not up)")
|
||||
return
|
||||
|
||||
# Prefer AP address — Pi joins the bridge access point.
|
||||
ips.sort(key=lambda x: 0 if x[0] == "AP" else 1)
|
||||
label, ip = ips[0]
|
||||
print("bridge IP (%s):" % label, ip)
|
||||
print("bridge_ws_url: ws://%s:%s/ws" % (ip, port))
|
||||
66
espnow-sender/src/wifi_ap.py
Normal file
66
espnow-sender/src/wifi_ap.py
Normal file
@@ -0,0 +1,66 @@
|
||||
"""Bridge Wi-Fi: AP for Pi WebSocket client, STA for ESP-NOW (ESP32-C3: AP first)."""
|
||||
|
||||
import time
|
||||
|
||||
import network
|
||||
|
||||
|
||||
def _wait_active(wlan, timeout_ms=1000):
|
||||
for _ in range(timeout_ms // 20):
|
||||
if wlan.active():
|
||||
return True
|
||||
time.sleep_ms(20)
|
||||
return bool(wlan.active())
|
||||
|
||||
|
||||
def _boot_channel(settings):
|
||||
try:
|
||||
return max(1, min(11, int(settings.get("wifi_channel", 6))))
|
||||
except (TypeError, ValueError):
|
||||
return 6
|
||||
|
||||
|
||||
def init_bridge_network(settings):
|
||||
"""Bring up AP (Pi) then STA (ESP-NOW). Channel set on AP at boot only."""
|
||||
ch = _boot_channel(settings)
|
||||
sta = network.WLAN(network.STA_IF)
|
||||
ap = network.WLAN(network.AP_IF)
|
||||
|
||||
try:
|
||||
sta.active(False)
|
||||
ap.active(False)
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep_ms(100)
|
||||
|
||||
essid = settings.get("name") or "espnow-bridge"
|
||||
password = settings.get("ap_password") or ""
|
||||
|
||||
ap.active(True)
|
||||
if not _wait_active(ap):
|
||||
raise RuntimeError("AP did not become active")
|
||||
|
||||
if password:
|
||||
ap.config(essid=essid, password=password, channel=ch)
|
||||
else:
|
||||
ap.config(essid=essid, channel=ch)
|
||||
|
||||
ap_ip = settings.get("ap_ip") or "192.168.4.1"
|
||||
try:
|
||||
ap.ifconfig((ap_ip, "255.255.255.0", ap_ip, "8.8.8.8"))
|
||||
except Exception as e:
|
||||
print("ap ifconfig:", e)
|
||||
|
||||
sta.active(True)
|
||||
if not _wait_active(sta):
|
||||
raise RuntimeError("STA did not become active")
|
||||
try:
|
||||
sta.config(pm=network.WLAN.PM_NONE)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
actual = ap.config("channel")
|
||||
except Exception:
|
||||
actual = ch
|
||||
print("bridge AP:", essid, "channel=", actual, "ip=", ap.ifconfig()[0])
|
||||
@@ -1,12 +0,0 @@
|
||||
def parse_mac(value):
|
||||
raw = value.strip().lower().replace(":", "").replace("-", "")
|
||||
if len(raw) != 12:
|
||||
raise ValueError("address must be 12 hex chars or aa:bb:cc:dd:ee:ff")
|
||||
try:
|
||||
return bytes.fromhex(raw)
|
||||
except ValueError:
|
||||
raise ValueError("address contains non-hex characters")
|
||||
|
||||
|
||||
def format_mac(mac_bytes):
|
||||
return ":".join("{:02x}".format(b) for b in mac_bytes)
|
||||
Submodule led-driver updated: 85490a3bd0...3e718f7432
@@ -10,12 +10,8 @@ from models.group import Group
|
||||
from models.transport import get_current_sender
|
||||
from settings import get_settings
|
||||
from util.brightness_combine import effective_brightness_for_mac
|
||||
from models.wifi_ws_clients import (
|
||||
normalize_tcp_peer_ip,
|
||||
send_json_line_to_ip,
|
||||
tcp_client_connected,
|
||||
)
|
||||
from util.driver_patterns import driver_patterns_dir
|
||||
from util.binary_driver_messages import v1_dict_to_cmd_packet
|
||||
from util.espnow_message import build_message
|
||||
import asyncio
|
||||
import json
|
||||
@@ -81,17 +77,8 @@ _pi_settings = get_settings()
|
||||
|
||||
|
||||
def _device_live_connected(dev_dict):
|
||||
"""
|
||||
Wi-Fi: whether the controller has an outbound WebSocket to this device's IP.
|
||||
ESP-NOW: None (no Wi-Fi session on the Pi for that transport).
|
||||
"""
|
||||
tr = (dev_dict.get("transport") or "espnow").strip().lower()
|
||||
if tr != "wifi":
|
||||
return None
|
||||
ip = normalize_tcp_peer_ip(dev_dict.get("address") or "")
|
||||
if not ip:
|
||||
return False
|
||||
return tcp_client_connected(ip)
|
||||
"""ESP-NOW has no live session flag on the Pi."""
|
||||
return None
|
||||
|
||||
|
||||
def _device_json_with_live_status(dev_dict):
|
||||
@@ -155,14 +142,13 @@ def _http_post_pattern_source(ip, filename, code_text, reload_patterns=True, tim
|
||||
return b" 2" in first_line
|
||||
|
||||
|
||||
async def _identify_send_off_after_delay(sender, transport, wifi_ip, dev_id, name):
|
||||
async def _identify_send_off_after_delay(sender, dev_id, name):
|
||||
try:
|
||||
await asyncio.sleep(IDENTIFY_OFF_DELAY_S)
|
||||
off_msg = build_message(select={name: ["off"]})
|
||||
if transport == "wifi":
|
||||
await send_json_line_to_ip(wifi_ip, off_msg)
|
||||
else:
|
||||
await sender.send(off_msg, addr=dev_id)
|
||||
pkt = v1_dict_to_cmd_packet(
|
||||
{"v": "1", "select": {name: ["off"]}},
|
||||
)
|
||||
await sender.send(pkt, addr=dev_id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -184,27 +170,20 @@ async def send_identify_to_device(dev_id: str) -> tuple[int, str]:
|
||||
if not name:
|
||||
return 400, "Device must have a name to identify"
|
||||
|
||||
transport = dev.get("transport") or "espnow"
|
||||
wifi_ip = None
|
||||
if transport == "wifi":
|
||||
wifi_ip = dev.get("address")
|
||||
if not wifi_ip:
|
||||
return 400, "Device has no IP address"
|
||||
|
||||
try:
|
||||
msg = _compact_v1_json(
|
||||
presets={_IDENTIFY_PRESET_KEY: dict(_IDENTIFY_DRIVER_PRESET)},
|
||||
select={name: [_IDENTIFY_PRESET_KEY]},
|
||||
pkt = v1_dict_to_cmd_packet(
|
||||
{
|
||||
"v": "1",
|
||||
"presets": {_IDENTIFY_PRESET_KEY: dict(_IDENTIFY_DRIVER_PRESET)},
|
||||
"select": {name: [_IDENTIFY_PRESET_KEY]},
|
||||
}
|
||||
)
|
||||
if transport == "wifi":
|
||||
ok = await send_json_line_to_ip(wifi_ip, msg)
|
||||
if not ok:
|
||||
return 503, "Wi-Fi driver not connected"
|
||||
else:
|
||||
await sender.send(msg, addr=dev_id)
|
||||
ok = await sender.send(pkt, addr=dev_id)
|
||||
if not ok:
|
||||
return 503, "Send failed"
|
||||
|
||||
asyncio.create_task(
|
||||
_identify_send_off_after_delay(sender, transport, wifi_ip, dev_id, name)
|
||||
_identify_send_off_after_delay(sender, dev_id, name)
|
||||
)
|
||||
except Exception as e:
|
||||
return 503, str(e)
|
||||
@@ -236,11 +215,6 @@ async def send_identify_to_group_devices(macs: list[str]) -> tuple[int, list[dic
|
||||
if not name:
|
||||
errors.append({"mac": dev_id, "error": "Device must have a name to identify"})
|
||||
continue
|
||||
transport = (dev.get("transport") or "espnow").strip().lower()
|
||||
if transport == "wifi":
|
||||
if not dev.get("address"):
|
||||
errors.append({"mac": dev_id, "error": "Device has no IP address"})
|
||||
continue
|
||||
merged_select[name] = [_IDENTIFY_PRESET_KEY]
|
||||
valid_macs.append(dev_id)
|
||||
|
||||
@@ -259,10 +233,8 @@ async def send_identify_to_group_devices(macs: list[str]) -> tuple[int, list[dic
|
||||
for dev_id in valid_macs:
|
||||
dev = devices.read(dev_id) or {}
|
||||
name = str(dev.get("name") or "").strip()
|
||||
transport = (dev.get("transport") or "espnow").strip().lower()
|
||||
wifi_ip = dev.get("address") if transport == "wifi" else None
|
||||
asyncio.create_task(
|
||||
_identify_send_off_after_delay(sender, transport, wifi_ip, dev_id, name)
|
||||
_identify_send_off_after_delay(sender, dev_id, name)
|
||||
)
|
||||
|
||||
return len(valid_macs), errors
|
||||
@@ -476,30 +448,20 @@ async def push_device_output_brightness(request, id):
|
||||
zone_brightness=zb,
|
||||
)
|
||||
|
||||
msg = _brightness_save_message_json(b_val)
|
||||
transport = (dev.get("transport") or "espnow").strip().lower()
|
||||
|
||||
if transport == "wifi":
|
||||
ip = normalize_tcp_peer_ip(str(dev.get("address") or ""))
|
||||
if not ip:
|
||||
return json.dumps({"error": "Device has no IP address"}), 400, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
ok = await send_json_line_to_ip(ip, msg)
|
||||
pkt = v1_dict_to_cmd_packet({"v": "1", "b": b_val, "save": True})
|
||||
sender = get_current_sender()
|
||||
if not sender:
|
||||
return json.dumps({"error": "Transport not configured"}), 503, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
try:
|
||||
ok = await sender.send(pkt, addr=id)
|
||||
if not ok:
|
||||
return json.dumps({"error": "Wi-Fi driver not connected"}), 503, {
|
||||
return json.dumps({"error": "Send failed"}), 503, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
else:
|
||||
sender = get_current_sender()
|
||||
if not sender:
|
||||
return json.dumps({"error": "Transport not configured"}), 503, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
try:
|
||||
await sender.send(msg, addr=id)
|
||||
except Exception as e:
|
||||
return json.dumps({"error": str(e)}), 503, {"Content-Type": "application/json"}
|
||||
except Exception as e:
|
||||
return json.dumps({"error": str(e)}), 503, {"Content-Type": "application/json"}
|
||||
|
||||
return json.dumps({"message": "brightness sent", "brightness": b_val}), 200, {
|
||||
"Content-Type": "application/json",
|
||||
@@ -509,7 +471,7 @@ async def push_device_output_brightness(request, id):
|
||||
@controller.post("/<id>/driver-config")
|
||||
async def push_driver_config(request, id):
|
||||
"""
|
||||
Push ``device_config`` to a Wi‑Fi LED driver over WebSocket.
|
||||
Push ``device_config`` to an ESP-NOW LED driver.
|
||||
Body JSON: optional ``name``, ``num_leds``, ``color_order``, ``startup_mode`` (default|last|off).
|
||||
"""
|
||||
dev = devices.read(id)
|
||||
@@ -517,13 +479,9 @@ async def push_driver_config(request, id):
|
||||
return json.dumps({"error": "Device not found"}), 404, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
if (dev.get("transport") or "").lower() != "wifi":
|
||||
return json.dumps({"error": "driver-config is only for Wi-Fi devices"}), 400, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
wifi_ip = str(dev.get("address") or "").strip()
|
||||
if not wifi_ip:
|
||||
return json.dumps({"error": "Device has no IP address"}), 400, {
|
||||
sender = get_current_sender()
|
||||
if not sender:
|
||||
return json.dumps({"error": "Transport not configured"}), 503, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
body = request.json or {}
|
||||
@@ -551,12 +509,10 @@ async def push_driver_config(request, id):
|
||||
"error": "Provide at least one of name, num_leds, color_order, startup_mode"
|
||||
}
|
||||
), 400, {"Content-Type": "application/json"}
|
||||
msg = json.dumps(
|
||||
{"v": "1", "device_config": dc, "save": True}, separators=(",", ":")
|
||||
)
|
||||
ok = await send_json_line_to_ip(wifi_ip, msg)
|
||||
pkt = v1_dict_to_cmd_packet({"v": "1", "device_config": dc, "save": True})
|
||||
ok = await sender.send(pkt, addr=id)
|
||||
if not ok:
|
||||
return json.dumps({"error": "Wi-Fi driver not connected"}), 503, {
|
||||
return json.dumps({"error": "Send failed"}), 503, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
return json.dumps({"message": "driver-config sent"}), 200, {
|
||||
@@ -567,71 +523,13 @@ async def push_driver_config(request, id):
|
||||
@controller.post("/<id>/patterns/push")
|
||||
async def push_patterns_ota(request, id):
|
||||
"""
|
||||
Push all local pattern files directly to a Wi-Fi LED driver over HTTP upload.
|
||||
Pattern OTA over HTTP is not available for ESP-NOW drivers.
|
||||
"""
|
||||
dev = devices.read(id)
|
||||
if not dev:
|
||||
return json.dumps({"error": "Device not found"}), 404, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
if (dev.get("transport") or "").lower() != "wifi":
|
||||
return json.dumps({"error": "Pattern OTA push is only supported for Wi-Fi devices"}), 400, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
wifi_ip = str(dev.get("address") or "").strip()
|
||||
if not wifi_ip:
|
||||
return json.dumps({"error": "Device has no IP address"}), 400, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
base_dir = driver_patterns_dir()
|
||||
try:
|
||||
names = sorted(os.listdir(base_dir))
|
||||
except OSError as e:
|
||||
return json.dumps({"error": str(e)}), 500, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
files = [n for n in names if _safe_pattern_filename(n) and n != "__init__.py"]
|
||||
if not files:
|
||||
return json.dumps({"error": "No pattern files found"}), 404, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
sent = []
|
||||
failed = []
|
||||
total = len(files)
|
||||
for idx, filename in enumerate(files):
|
||||
path = os.path.join(base_dir, filename)
|
||||
try:
|
||||
with open(path, "r") as f:
|
||||
code = f.read()
|
||||
except OSError:
|
||||
failed.append(filename)
|
||||
continue
|
||||
reload_patterns = idx == (total - 1)
|
||||
ok = _http_post_pattern_source(
|
||||
wifi_ip,
|
||||
filename,
|
||||
code,
|
||||
reload_patterns=reload_patterns,
|
||||
timeout_s=10.0,
|
||||
)
|
||||
if ok:
|
||||
sent.append(filename)
|
||||
else:
|
||||
failed.append(filename)
|
||||
|
||||
if not sent:
|
||||
return json.dumps({"error": "Wi-Fi driver did not accept pattern uploads", "failed": failed}), 503, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
return json.dumps({
|
||||
"message": "Pattern files uploaded",
|
||||
"sent_count": len(sent),
|
||||
"sent": sent,
|
||||
"failed": failed,
|
||||
}), 200, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
return json.dumps(
|
||||
{"error": "Pattern OTA push is not supported for ESP-NOW devices"}
|
||||
), 400, {"Content-Type": "application/json"}
|
||||
|
||||
@@ -4,7 +4,8 @@ import asyncio
|
||||
from models.group import Group
|
||||
from models.device import Device
|
||||
from models.transport import get_current_sender
|
||||
from models.wifi_ws_clients import normalize_tcp_peer_ip, send_json_line_to_ip
|
||||
from util.binary_driver_messages import v1_dict_to_cmd_packet
|
||||
from util.espnow_registry import push_groups_for_group_devices
|
||||
from settings import get_settings
|
||||
from util.brightness_combine import effective_brightness_for_mac
|
||||
import json
|
||||
@@ -101,6 +102,9 @@ async def create_group(request, session):
|
||||
cur = get_current_profile_id(session)
|
||||
if cur:
|
||||
groups.update(group_id, {"profile_id": str(cur)})
|
||||
g = groups.read(group_id)
|
||||
if g:
|
||||
await push_groups_for_group_devices(g)
|
||||
return json.dumps(groups.read(group_id)), 201, {"Content-Type": "application/json"}
|
||||
except Exception as e:
|
||||
return json.dumps({"error": str(e)}), 400
|
||||
@@ -119,6 +123,7 @@ async def update_group(request, session, id):
|
||||
if groups.update(id, data):
|
||||
g = groups.read(id)
|
||||
if g:
|
||||
await push_groups_for_group_devices(g)
|
||||
return json.dumps(g), 200, {"Content-Type": "application/json"}
|
||||
return json.dumps({"error": "Group not found"}), 404
|
||||
except Exception as e:
|
||||
@@ -135,7 +140,9 @@ async def delete_group(request, session, id):
|
||||
|
||||
if not _group_doc_visible_for_profile(g, get_current_profile_id(session)):
|
||||
return json.dumps({"error": "Group not found"}), 404
|
||||
macs = list(g.get("devices") or []) if isinstance(g, dict) else []
|
||||
if groups.delete(id):
|
||||
await push_groups_for_group_devices({"devices": macs})
|
||||
return json.dumps({"message": "Group deleted successfully"}), 200
|
||||
return json.dumps({"error": "Group not found"}), 404
|
||||
|
||||
@@ -184,7 +191,7 @@ def _read_group_for_session(session, id):
|
||||
@with_session
|
||||
async def push_group_driver_config(request, session, id):
|
||||
"""
|
||||
Push group Wi‑Fi defaults to every Wi‑Fi device listed in the group (TCP WebSocket).
|
||||
Push group driver defaults to every ESP-NOW device listed in the group.
|
||||
Uses stored ``wifi_*`` fields on the group; optional JSON body may override for this send only.
|
||||
"""
|
||||
gdoc = _read_group_for_session(session, id)
|
||||
@@ -211,11 +218,10 @@ async def push_group_driver_config(request, session, id):
|
||||
mac_list = gdoc.get("devices") if isinstance(gdoc.get("devices"), list) else []
|
||||
sent = 0
|
||||
errors = []
|
||||
msg = json.dumps(
|
||||
{"v": "1", "device_config": dc, "save": True}, separators=(",", ":")
|
||||
)
|
||||
tasks = []
|
||||
meta_macs = []
|
||||
sender = get_current_sender()
|
||||
if not sender:
|
||||
return json.dumps({"error": "Transport not configured"}), 503
|
||||
pkt = v1_dict_to_cmd_packet({"v": "1", "device_config": dc, "save": True})
|
||||
for mac in mac_list:
|
||||
m = str(mac).strip().lower().replace(":", "").replace("-", "")
|
||||
if len(m) != 12:
|
||||
@@ -224,23 +230,13 @@ async def push_group_driver_config(request, session, id):
|
||||
if not dev:
|
||||
errors.append({"mac": m, "error": "not in registry"})
|
||||
continue
|
||||
if (dev.get("transport") or "").lower() != "wifi":
|
||||
continue
|
||||
ip = normalize_tcp_peer_ip(str(dev.get("address") or ""))
|
||||
if not ip:
|
||||
errors.append({"mac": m, "error": "no IP"})
|
||||
continue
|
||||
tasks.append(send_json_line_to_ip(ip, msg))
|
||||
meta_macs.append(m)
|
||||
if tasks:
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
for m, r in zip(meta_macs, results):
|
||||
if r is True:
|
||||
try:
|
||||
if await sender.send(pkt, addr=m):
|
||||
sent += 1
|
||||
elif isinstance(r, Exception):
|
||||
errors.append({"mac": m, "error": str(r)})
|
||||
else:
|
||||
errors.append({"mac": m, "error": "driver not connected"})
|
||||
errors.append({"mac": m, "error": "send failed"})
|
||||
except Exception as e:
|
||||
errors.append({"mac": m, "error": str(e)})
|
||||
|
||||
return json.dumps(
|
||||
{"message": "driver-config sent", "sent": sent, "errors": errors}
|
||||
@@ -275,19 +271,14 @@ async def push_group_output_brightness(request, session, id):
|
||||
m,
|
||||
zone_brightness=None,
|
||||
)
|
||||
msg = _brightness_save_message_json(b_val)
|
||||
transport = (dev.get("transport") or "espnow").strip().lower()
|
||||
if transport == "wifi":
|
||||
ip = normalize_tcp_peer_ip(str(dev.get("address") or ""))
|
||||
if not ip:
|
||||
return m, False, "no IP"
|
||||
ok = await send_json_line_to_ip(ip, msg)
|
||||
return m, bool(ok), None if ok else "driver not connected"
|
||||
pkt = v1_dict_to_cmd_packet(
|
||||
{"v": "1", "b": b_val, "save": True},
|
||||
)
|
||||
if not sender:
|
||||
return m, False, "transport not configured"
|
||||
try:
|
||||
await sender.send(msg, addr=m)
|
||||
return m, True, None
|
||||
ok = await sender.send(pkt, addr=m)
|
||||
return m, bool(ok), None if ok else "send failed"
|
||||
except Exception as e:
|
||||
return m, False, str(e)
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ from models.device import Device, normalize_mac
|
||||
from models.transport import get_current_sender
|
||||
from util.driver_delivery import deliver_json_messages, deliver_preset_broadcast_then_per_device
|
||||
from util.espnow_message import build_message, build_preset_dict
|
||||
from util.binary_driver_messages import build_preset_cmd_chunks
|
||||
from util.profile_bundle import export_preset_bundle, import_preset_bundle
|
||||
import json
|
||||
|
||||
@@ -225,39 +226,13 @@ async def send_presets(request, session):
|
||||
if not sender:
|
||||
return json.dumps({"error": "Transport not configured"}), 503, {'Content-Type': 'application/json'}
|
||||
|
||||
MAX_BYTES = 240
|
||||
send_delay_s = 0.1
|
||||
entries = list(presets_by_name.items())
|
||||
total_presets = len(entries)
|
||||
|
||||
batch = {}
|
||||
chunk_messages = []
|
||||
for name, preset_obj in entries:
|
||||
test_batch = dict(batch)
|
||||
test_batch[name] = preset_obj
|
||||
test_msg = build_message(presets=test_batch, save=save_flag, default=default_id)
|
||||
size = len(test_msg)
|
||||
|
||||
if size <= MAX_BYTES or not batch:
|
||||
batch = test_batch
|
||||
else:
|
||||
chunk_messages.append(
|
||||
build_message(
|
||||
presets=dict(batch),
|
||||
save=False,
|
||||
default=None,
|
||||
)
|
||||
)
|
||||
batch = {name: preset_obj}
|
||||
|
||||
if batch:
|
||||
chunk_messages.append(
|
||||
build_message(
|
||||
presets=dict(batch),
|
||||
save=save_flag,
|
||||
default=default_id,
|
||||
)
|
||||
)
|
||||
total_presets = len(presets_by_name)
|
||||
chunk_messages = build_preset_cmd_chunks(
|
||||
presets_by_name,
|
||||
save=save_flag,
|
||||
default=str(default_id) if default_id is not None else None,
|
||||
)
|
||||
|
||||
target_list = None
|
||||
raw_targets = data.get("targets")
|
||||
|
||||
@@ -3,7 +3,6 @@ import json
|
||||
|
||||
from microdot import Microdot, send_file
|
||||
|
||||
from models import wifi_ws_clients
|
||||
from settings import get_settings
|
||||
|
||||
controller = Microdot()
|
||||
@@ -108,13 +107,6 @@ async def update_settings(request):
|
||||
else:
|
||||
settings[key] = value
|
||||
settings.save()
|
||||
if global_brightness_changed:
|
||||
try:
|
||||
asyncio.get_running_loop().create_task(
|
||||
wifi_ws_clients.broadcast_global_brightness_to_tcp_drivers()
|
||||
)
|
||||
except RuntimeError:
|
||||
pass
|
||||
return json.dumps({"message": "Settings updated successfully"}), 200, {'Content-Type': 'application/json'}
|
||||
except ValueError as e:
|
||||
return json.dumps({"error": str(e)}), 400
|
||||
|
||||
238
src/main.py
238
src/main.py
@@ -4,9 +4,6 @@ import json
|
||||
import os
|
||||
import secrets
|
||||
import signal
|
||||
import socket
|
||||
import threading
|
||||
import traceback
|
||||
from microdot import Microdot, send_file
|
||||
from microdot.websocket import with_websocket
|
||||
from microdot.session import Session
|
||||
@@ -24,171 +21,36 @@ import controllers.settings as settings_controller
|
||||
import controllers.device as device_controller
|
||||
import controllers.led_tool as led_tool_controller
|
||||
from models.transport import get_sender, set_sender, get_current_sender
|
||||
from models.device import Device, normalize_mac
|
||||
from models import wifi_ws_clients as tcp_client_registry
|
||||
from util.device_status_broadcaster import (
|
||||
broadcast_device_tcp_snapshot_to,
|
||||
broadcast_device_tcp_status,
|
||||
register_device_status_ws,
|
||||
unregister_device_status_ws,
|
||||
)
|
||||
from models.device import Device
|
||||
from models.bridge_ws_client import init_bridge_client
|
||||
from util.espnow_registry import handle_espnow_announce
|
||||
from util.binary_driver_messages import v1_dict_to_cmd_packet
|
||||
from util.audio_detector import AudioBeatDetector
|
||||
|
||||
_tcp_device_lock = threading.Lock()
|
||||
|
||||
DISCOVERY_UDP_PORT = 8766
|
||||
|
||||
|
||||
def _live_reload_enabled() -> bool:
|
||||
v = os.environ.get("LED_CONTROLLER_LIVE_RELOAD", "").strip().lower()
|
||||
return v not in ("", "0", "false", "no")
|
||||
|
||||
|
||||
def _register_udp_device_sync(
|
||||
device_name: str, peer_ip: str, mac, device_type=None
|
||||
) -> None:
|
||||
with _tcp_device_lock:
|
||||
try:
|
||||
d = Device()
|
||||
did, persisted = d.upsert_wifi_tcp_client(
|
||||
device_name, peer_ip, mac, device_type=device_type
|
||||
)
|
||||
if did and persisted:
|
||||
print(
|
||||
f"UDP device registered: mac={did} name={device_name!r} ip={peer_ip!r}"
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"UDP device registry failed: {e}")
|
||||
traceback.print_exception(type(e), e, e.__traceback__)
|
||||
|
||||
|
||||
async def _handle_udp_discovery(sock, udp_holder=None) -> None:
|
||||
while True:
|
||||
try:
|
||||
data, addr = await asyncio.get_running_loop().sock_recvfrom(sock, 2048)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except OSError as e:
|
||||
if udp_holder and udp_holder.get("closing"):
|
||||
break
|
||||
print(f"[UDP] recv failed: {e!r}")
|
||||
continue
|
||||
except Exception as e:
|
||||
print(f"[UDP] recv failed: {e!r}")
|
||||
continue
|
||||
peer_ip = addr[0] if addr else ""
|
||||
line = data.split(b"\n", 1)[0].strip()
|
||||
if line:
|
||||
try:
|
||||
parsed = json.loads(line.decode("utf-8"))
|
||||
if isinstance(parsed, dict):
|
||||
dns = str(parsed.get("device_name") or "").strip()
|
||||
mac = parsed.get("mac") or parsed.get("device_mac") or parsed.get(
|
||||
"sta_mac"
|
||||
)
|
||||
device_type = parsed.get("type") or parsed.get("device_type")
|
||||
if dns and normalize_mac(mac):
|
||||
_register_udp_device_sync(dns, peer_ip, mac, device_type)
|
||||
if str(parsed.get("v") or "") == "1":
|
||||
tcp_client_registry.ensure_driver_connection(peer_ip)
|
||||
except (UnicodeError, ValueError, TypeError):
|
||||
pass
|
||||
try:
|
||||
await asyncio.get_running_loop().sock_sendto(sock, data, addr)
|
||||
except Exception as e:
|
||||
print(f"[UDP] echo send failed: {e!r}")
|
||||
|
||||
|
||||
def _prime_wifi_outbound_driver_connections() -> None:
|
||||
"""On boot, dial each registered Wi-Fi driver (same 4-attempt limit as UDP hello)."""
|
||||
n = 0
|
||||
try:
|
||||
dev = Device()
|
||||
for mac_key, doc in list(dev.items()):
|
||||
if not isinstance(doc, dict):
|
||||
continue
|
||||
if doc.get("transport") != "wifi":
|
||||
continue
|
||||
ip = _ipv4_address(str(doc.get("address") or ""))
|
||||
if not ip:
|
||||
continue
|
||||
tcp_client_registry.ensure_driver_connection(ip)
|
||||
n += 1
|
||||
except Exception as e:
|
||||
print(f"[startup] Wi-Fi driver connection prime failed: {e!r}")
|
||||
traceback.print_exception(type(e), e, e.__traceback__)
|
||||
return
|
||||
if n:
|
||||
print(f"[startup] primed outbound WebSocket for {n} Wi-Fi driver(s)")
|
||||
|
||||
|
||||
def _ipv4_address(addr: str) -> str | None:
|
||||
"""Return dotted IPv4 string or None (hostnames skipped for UDP nudge)."""
|
||||
s = (addr or "").strip()
|
||||
if not s:
|
||||
return None
|
||||
parts = s.split(".")
|
||||
if len(parts) != 4:
|
||||
return None
|
||||
try:
|
||||
nums = [int(p) for p in parts]
|
||||
except ValueError:
|
||||
return None
|
||||
if not all(0 <= n <= 255 for n in nums):
|
||||
return None
|
||||
return s
|
||||
|
||||
|
||||
async def _run_udp_discovery_server(udp_holder=None) -> None:
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
sock.setblocking(False)
|
||||
try:
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
except (AttributeError, OSError):
|
||||
pass
|
||||
try:
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
|
||||
except (AttributeError, OSError):
|
||||
pass
|
||||
sock.bind(("0.0.0.0", DISCOVERY_UDP_PORT))
|
||||
if udp_holder is not None:
|
||||
udp_holder["sock"] = sock
|
||||
print(f"UDP discovery listening on 0.0.0.0:{DISCOVERY_UDP_PORT}")
|
||||
try:
|
||||
await _handle_udp_discovery(sock, udp_holder)
|
||||
finally:
|
||||
if udp_holder is not None:
|
||||
udp_holder.pop("sock", None)
|
||||
try:
|
||||
sock.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def _send_bridge_wifi_channel(settings, sender):
|
||||
"""Tell the serial ESP32 bridge to set STA channel (settings wifi_channel); not forwarded as ESP-NOW."""
|
||||
try:
|
||||
ch = int(settings.get("wifi_channel", 6))
|
||||
except (TypeError, ValueError):
|
||||
ch = 6
|
||||
ch = max(1, min(11, ch))
|
||||
payload = json.dumps({"m": "bridge", "ch": ch}, separators=(",", ":"))
|
||||
try:
|
||||
await sender.send(payload, addr="ffffffffffff")
|
||||
print(f"[startup] bridge Wi-Fi channel -> {ch}")
|
||||
except Exception as e:
|
||||
print(f"[startup] bridge channel message failed: {e}")
|
||||
|
||||
|
||||
async def main(port=80):
|
||||
settings = get_settings()
|
||||
print(settings)
|
||||
print("Starting")
|
||||
|
||||
# Initialize transport (serial to ESP32 bridge)
|
||||
sender = get_sender(settings)
|
||||
set_sender(sender)
|
||||
|
||||
bridge_url = str(settings.get("bridge_ws_url") or "").strip()
|
||||
if bridge_url:
|
||||
try:
|
||||
ch = int(settings.get("wifi_channel", 6))
|
||||
except (TypeError, ValueError):
|
||||
ch = 6
|
||||
bridge = init_bridge_client(bridge_url, wifi_channel=ch)
|
||||
bridge.set_uplink_handler(handle_espnow_announce)
|
||||
bridge.start()
|
||||
|
||||
app = Microdot()
|
||||
audio_detector = AudioBeatDetector()
|
||||
try:
|
||||
@@ -243,9 +105,6 @@ async def main(port=80):
|
||||
app.mount(device_controller.controller, '/devices')
|
||||
app.mount(led_tool_controller.controller, '/led-tool')
|
||||
|
||||
tcp_client_registry.set_settings(settings)
|
||||
tcp_client_registry.set_tcp_status_broadcaster(broadcast_device_tcp_status)
|
||||
|
||||
live_reload = _live_reload_enabled()
|
||||
dev_build_id = secrets.token_hex(12) if live_reload else None
|
||||
if live_reload:
|
||||
@@ -408,56 +267,35 @@ async def main(port=80):
|
||||
@app.route('/ws')
|
||||
@with_websocket
|
||||
async def ws(request, ws):
|
||||
await register_device_status_ws(ws)
|
||||
await broadcast_device_tcp_snapshot_to(ws)
|
||||
try:
|
||||
while True:
|
||||
data = await ws.receive()
|
||||
print(data)
|
||||
if data:
|
||||
try:
|
||||
parsed = json.loads(data)
|
||||
print("WS received JSON:", parsed)
|
||||
# Optional "to": 12-char hex MAC; rest is payload (sent with that address).
|
||||
addr = parsed.pop("to", None)
|
||||
payload = json.dumps(parsed) if parsed else data
|
||||
await sender.send(payload, addr=addr)
|
||||
except json.JSONDecodeError:
|
||||
# Not JSON: send raw with default address
|
||||
try:
|
||||
await sender.send(data)
|
||||
except Exception:
|
||||
try:
|
||||
await ws.send(json.dumps({"error": "Send failed"}))
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
try:
|
||||
await ws.send(json.dumps({"error": "Send failed"}))
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
if not data:
|
||||
break
|
||||
finally:
|
||||
await unregister_device_status_ws(ws)
|
||||
try:
|
||||
if isinstance(data, (bytes, bytearray)):
|
||||
await sender.send(bytes(data))
|
||||
continue
|
||||
parsed = json.loads(data)
|
||||
addr = parsed.pop("to", None)
|
||||
pkt = v1_dict_to_cmd_packet(parsed)
|
||||
await sender.send(pkt, addr=addr)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
except Exception:
|
||||
try:
|
||||
await ws.send(json.dumps({"error": "Send failed"}))
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
|
||||
# Touch Device singleton early so db/device.json exists before first UDP hello.
|
||||
Device()
|
||||
await _send_bridge_wifi_channel(settings, sender)
|
||||
_prime_wifi_outbound_driver_connections()
|
||||
|
||||
udp_holder = {"closing": False, "shutting_down": False}
|
||||
loop = asyncio.get_running_loop()
|
||||
server_tasks: list[asyncio.Task] = []
|
||||
|
||||
def _graceful_shutdown(*_args):
|
||||
if udp_holder.get("shutting_down"):
|
||||
raise SystemExit(0)
|
||||
udp_holder["shutting_down"] = True
|
||||
print("[server] shutting down...")
|
||||
udp_holder["closing"] = True
|
||||
try:
|
||||
audio_detector.stop()
|
||||
except Exception:
|
||||
@@ -472,13 +310,6 @@ async def main(port=80):
|
||||
t.cancel()
|
||||
except Exception:
|
||||
pass
|
||||
u = udp_holder.get("sock")
|
||||
if u is not None:
|
||||
try:
|
||||
u.close()
|
||||
except OSError:
|
||||
pass
|
||||
tcp_client_registry.cancel_all_driver_tasks()
|
||||
if getattr(app, "server", None) is not None:
|
||||
try:
|
||||
app.shutdown()
|
||||
@@ -497,15 +328,11 @@ async def main(port=80):
|
||||
except (NotImplementedError, RuntimeError):
|
||||
pass
|
||||
|
||||
# Await HTTP + UDP discovery; bind failures (e.g. port 80 in use) surface here.
|
||||
try:
|
||||
server_tasks[:] = [
|
||||
asyncio.create_task(
|
||||
app.start_server(host="0.0.0.0", port=port), name="http"
|
||||
),
|
||||
asyncio.create_task(
|
||||
_run_udp_discovery_server(udp_holder), name="udp"
|
||||
),
|
||||
]
|
||||
await asyncio.gather(*server_tasks)
|
||||
except asyncio.CancelledError:
|
||||
@@ -534,7 +361,6 @@ async def main(port=80):
|
||||
app.server = None
|
||||
except Exception:
|
||||
pass
|
||||
udp_holder["closing"] = True
|
||||
for t in list(server_tasks):
|
||||
if not t.done():
|
||||
t.cancel()
|
||||
|
||||
142
src/models/bridge_ws_client.py
Normal file
142
src/models/bridge_ws_client.py
Normal file
@@ -0,0 +1,142 @@
|
||||
"""Persistent WebSocket client to the ESP-NOW bridge (binary frames)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Awaitable, Callable, Optional
|
||||
|
||||
import websockets
|
||||
from websockets.exceptions import ConnectionClosed
|
||||
|
||||
from util.espnow_wire import (
|
||||
MSG_ANNOUNCE,
|
||||
WIRE_MAGIC,
|
||||
pack_bridge_channel,
|
||||
pack_ws_downlink,
|
||||
parse_ws_frame,
|
||||
wire_msg_type,
|
||||
)
|
||||
|
||||
UplinkHandler = Callable[[bytes, bytes], Awaitable[None]]
|
||||
|
||||
|
||||
class BridgeWsClient:
|
||||
def __init__(self, url: str, *, wifi_channel: int = 6):
|
||||
self._url = url.strip()
|
||||
self._wifi_channel = wifi_channel
|
||||
self._ws: Optional[websockets.WebSocketClientProtocol] = None
|
||||
self._send_lock = asyncio.Lock()
|
||||
self._uplink_handler: Optional[UplinkHandler] = None
|
||||
self._task: Optional[asyncio.Task] = None
|
||||
self._connected = asyncio.Event()
|
||||
self._ack_waiter: Optional[asyncio.Future] = None
|
||||
|
||||
def set_uplink_handler(self, handler: Optional[UplinkHandler]) -> None:
|
||||
self._uplink_handler = handler
|
||||
|
||||
async def run_forever(self) -> None:
|
||||
while True:
|
||||
try:
|
||||
await self._connect_once()
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as e:
|
||||
print(f"[bridge] connection error: {e!r}")
|
||||
self._connected.clear()
|
||||
self._ws = None
|
||||
await asyncio.sleep(2.0)
|
||||
|
||||
async def _reader_loop(self) -> None:
|
||||
ws = self._ws
|
||||
if ws is None:
|
||||
return
|
||||
try:
|
||||
async for message in ws:
|
||||
if isinstance(message, str):
|
||||
continue
|
||||
if len(message) == 1:
|
||||
fut = self._ack_waiter
|
||||
if fut is not None and not fut.done():
|
||||
fut.set_result(message[0] == 0x01)
|
||||
continue
|
||||
try:
|
||||
peer, pkt, _bcast = parse_ws_frame(message)
|
||||
except ValueError:
|
||||
continue
|
||||
if wire_msg_type(pkt) == MSG_ANNOUNCE and self._uplink_handler:
|
||||
await self._uplink_handler(peer, pkt)
|
||||
except ConnectionClosed:
|
||||
pass
|
||||
|
||||
async def _connect_once(self) -> None:
|
||||
print(f"[bridge] connecting to {self._url}")
|
||||
async with websockets.connect(self._url, ping_interval=20, ping_timeout=20) as ws:
|
||||
self._ws = ws
|
||||
ch_pkt = pack_bridge_channel(self._wifi_channel)
|
||||
await ws.send(pack_ws_downlink(ch_pkt, broadcast=True))
|
||||
self._connected.set()
|
||||
print("[bridge] connected")
|
||||
reader = asyncio.create_task(self._reader_loop())
|
||||
try:
|
||||
while True:
|
||||
await asyncio.sleep(3600)
|
||||
finally:
|
||||
reader.cancel()
|
||||
try:
|
||||
await reader
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
async def wait_connected(self, timeout: float = 30.0) -> bool:
|
||||
try:
|
||||
await asyncio.wait_for(self._connected.wait(), timeout=timeout)
|
||||
return True
|
||||
except asyncio.TimeoutError:
|
||||
return False
|
||||
|
||||
async def send_frame(self, frame: bytes) -> bool:
|
||||
await self._connected.wait()
|
||||
ws = self._ws
|
||||
if ws is None:
|
||||
return False
|
||||
async with self._send_lock:
|
||||
loop = asyncio.get_running_loop()
|
||||
self._ack_waiter = loop.create_future()
|
||||
try:
|
||||
await ws.send(frame)
|
||||
return bool(await asyncio.wait_for(self._ack_waiter, timeout=5.0))
|
||||
except (ConnectionClosed, asyncio.TimeoutError, OSError) as e:
|
||||
print(f"[bridge] send failed: {e!r}")
|
||||
return False
|
||||
finally:
|
||||
self._ack_waiter = None
|
||||
|
||||
async def send_espnow(
|
||||
self,
|
||||
packet: bytes,
|
||||
*,
|
||||
peer_mac: Optional[str] = None,
|
||||
broadcast: bool = False,
|
||||
) -> bool:
|
||||
if not packet or packet[0] != WIRE_MAGIC:
|
||||
raise ValueError("packet must be espnow wire format")
|
||||
frame = pack_ws_downlink(packet, peer_mac=peer_mac, broadcast=broadcast)
|
||||
return await self.send_frame(frame)
|
||||
|
||||
def start(self) -> asyncio.Task:
|
||||
if self._task is None or self._task.done():
|
||||
self._task = asyncio.create_task(self.run_forever())
|
||||
return self._task
|
||||
|
||||
|
||||
_client: Optional[BridgeWsClient] = None
|
||||
|
||||
|
||||
def get_bridge_client() -> Optional[BridgeWsClient]:
|
||||
return _client
|
||||
|
||||
|
||||
def init_bridge_client(url: str, *, wifi_channel: int = 6) -> BridgeWsClient:
|
||||
global _client
|
||||
_client = BridgeWsClient(url, wifi_channel=wifi_channel)
|
||||
return _client
|
||||
@@ -256,6 +256,68 @@ class Device(Model):
|
||||
def list(self):
|
||||
return list(self.keys())
|
||||
|
||||
def upsert_espnow_announced(
|
||||
self,
|
||||
mac,
|
||||
device_name,
|
||||
*,
|
||||
device_type="led",
|
||||
num_leds=None,
|
||||
color_order=None,
|
||||
startup_mode=None,
|
||||
brightness=None,
|
||||
):
|
||||
"""
|
||||
Register or update an ESP-NOW device from a binary ANNOUNCE.
|
||||
|
||||
Returns ``(mac_hex | None, persisted)``.
|
||||
"""
|
||||
mac_hex = normalize_mac(mac)
|
||||
if not mac_hex:
|
||||
return None, False
|
||||
name = (device_name or "").strip()
|
||||
if not name:
|
||||
return None, False
|
||||
resolved_type = validate_device_type(device_type)
|
||||
meta = {}
|
||||
if num_leds is not None:
|
||||
meta["num_leds"] = int(num_leds)
|
||||
if color_order is not None:
|
||||
meta["color_order"] = str(color_order)
|
||||
if startup_mode is not None:
|
||||
meta["startup_mode"] = str(startup_mode)
|
||||
if brightness is not None:
|
||||
meta["brightness"] = int(brightness)
|
||||
|
||||
if mac_hex in self:
|
||||
prev = self[mac_hex]
|
||||
merged = dict(prev)
|
||||
merged["name"] = name
|
||||
merged["type"] = resolved_type
|
||||
merged["transport"] = "espnow"
|
||||
merged["address"] = mac_hex
|
||||
merged["id"] = mac_hex
|
||||
merged.update({k: v for k, v in meta.items() if v is not None})
|
||||
if merged == prev:
|
||||
return mac_hex, False
|
||||
self[mac_hex] = merged
|
||||
self.save()
|
||||
return mac_hex, True
|
||||
|
||||
row = {
|
||||
"id": mac_hex,
|
||||
"name": name,
|
||||
"type": resolved_type,
|
||||
"transport": "espnow",
|
||||
"address": mac_hex,
|
||||
"default_pattern": None,
|
||||
"zones": [],
|
||||
}
|
||||
row.update({k: v for k, v in meta.items() if v is not None})
|
||||
self[mac_hex] = row
|
||||
self.save()
|
||||
return mac_hex, True
|
||||
|
||||
def upsert_wifi_tcp_client(self, device_name, peer_ip, mac, device_type=None):
|
||||
"""
|
||||
Register or update a Wi-Fi client by **MAC** (storage id). Updates **name**,
|
||||
|
||||
@@ -1,59 +1,53 @@
|
||||
"""Transport to LED drivers via ESP-NOW bridge WebSocket."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from typing import Optional, Union
|
||||
|
||||
from models.bridge_ws_client import get_bridge_client
|
||||
from util.espnow_wire import WIRE_MAGIC, pack_ws_downlink
|
||||
|
||||
BROADCAST_MAC_HEX = "ffffffffffff"
|
||||
|
||||
|
||||
# Default: broadcast (6 bytes). Pi always sends 6-byte address + payload to ESP32.
|
||||
BROADCAST_MAC = bytes.fromhex("ffffffffffff")
|
||||
|
||||
|
||||
def _encode_payload(data):
|
||||
if isinstance(data, str):
|
||||
return data.encode()
|
||||
if isinstance(data, dict):
|
||||
return json.dumps(data).encode()
|
||||
return data
|
||||
|
||||
|
||||
def _parse_mac(addr):
|
||||
"""Convert 12-char hex string or 6-byte bytes to 6-byte MAC."""
|
||||
if addr is None or addr == b"":
|
||||
return BROADCAST_MAC
|
||||
def _parse_mac(addr) -> Optional[bytes]:
|
||||
if addr is None or addr == "":
|
||||
return None
|
||||
if isinstance(addr, bytes) and len(addr) == 6:
|
||||
return addr
|
||||
if isinstance(addr, str) and len(addr) == 12:
|
||||
return bytes.fromhex(addr)
|
||||
return BROADCAST_MAC
|
||||
|
||||
|
||||
async def _to_thread(func, *args):
|
||||
to_thread = getattr(asyncio, "to_thread", None)
|
||||
if to_thread:
|
||||
return await to_thread(func, *args)
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(None, func, *args)
|
||||
if isinstance(addr, str):
|
||||
s = addr.strip().lower().replace(":", "").replace("-", "")
|
||||
if len(s) == 12:
|
||||
return bytes.fromhex(s)
|
||||
return None
|
||||
|
||||
|
||||
class NullSender:
|
||||
"""Used when no ESP-NOW UART bridge is configured or the port cannot be opened."""
|
||||
"""No bridge configured."""
|
||||
|
||||
async def send(self, data, addr=None):
|
||||
return True
|
||||
|
||||
|
||||
class SerialSender:
|
||||
def __init__(self, port, baudrate, default_addr=None):
|
||||
import serial
|
||||
class BridgeWsSender:
|
||||
"""Send binary ESP-NOW packets via bridge WebSocket client."""
|
||||
|
||||
self._serial = serial.Serial(port, baudrate=baudrate, timeout=1)
|
||||
self._default_addr = _parse_mac(default_addr)
|
||||
self._write_lock = asyncio.Lock()
|
||||
|
||||
async def send(self, data, addr=None):
|
||||
mac = _parse_mac(addr) if addr is not None else self._default_addr
|
||||
payload = _encode_payload(data)
|
||||
async with self._write_lock:
|
||||
await _to_thread(self._serial.write, mac + payload)
|
||||
return True
|
||||
async def send(self, data: Union[bytes, str, dict], addr=None) -> bool:
|
||||
client = get_bridge_client()
|
||||
if client is None:
|
||||
return False
|
||||
if isinstance(data, (bytes, bytearray)):
|
||||
packet = bytes(data)
|
||||
else:
|
||||
return False
|
||||
if not packet or packet[0] != WIRE_MAGIC:
|
||||
return False
|
||||
peer = _parse_mac(addr)
|
||||
broadcast = peer is None or addr == BROADCAST_MAC_HEX
|
||||
return await client.send_espnow(
|
||||
packet,
|
||||
peer_mac=peer,
|
||||
broadcast=broadcast,
|
||||
)
|
||||
|
||||
|
||||
_current_sender = None
|
||||
@@ -69,22 +63,11 @@ def get_current_sender():
|
||||
|
||||
|
||||
def get_sender(settings):
|
||||
# Serial ESP-NOW bridge is opt-in (serial_enabled true); default off for dev / Wi-Fi-only.
|
||||
if not settings.get("serial_enabled"):
|
||||
print("[startup] serial bridge disabled (set serial_enabled true in settings.json to enable)")
|
||||
return NullSender()
|
||||
port = settings.get("serial_port", "/dev/ttyS0")
|
||||
raw_port = str(port).strip() if port is not None else ""
|
||||
if not raw_port or raw_port.lower() in ("none", "off"):
|
||||
print("[startup] serial bridge disabled (empty serial_port)")
|
||||
return NullSender()
|
||||
baudrate = settings.get("serial_baudrate", 912000)
|
||||
default_addr = settings.get("serial_destination_mac", "ffffffffffff")
|
||||
try:
|
||||
return SerialSender(raw_port, baudrate, default_addr=default_addr)
|
||||
except Exception as e:
|
||||
url = str(settings.get("bridge_ws_url") or "").strip()
|
||||
if not url:
|
||||
print(
|
||||
f"[startup] serial open failed ({raw_port!r}): {e}; "
|
||||
"continuing without ESP-NOW bridge (Wi-Fi drivers unchanged)"
|
||||
"[startup] bridge disabled (set bridge_ws_url in settings.json, e.g. ws://192.168.4.1/ws)"
|
||||
)
|
||||
return NullSender()
|
||||
print(f"[startup] ESP-NOW via bridge WebSocket {url!r}")
|
||||
return BridgeWsSender()
|
||||
|
||||
@@ -52,31 +52,9 @@ class Settings(dict):
|
||||
# ESP-NOW STA channel (2.4 GHz) for LED drivers / bridge alignment; 1–11
|
||||
if 'wifi_channel' not in self:
|
||||
self['wifi_channel'] = 6
|
||||
# Wi-Fi LED drivers: controller opens WebSocket to device (firmware serves /ws)
|
||||
if 'wifi_driver_ws_port' not in self:
|
||||
self['wifi_driver_ws_port'] = 80
|
||||
if 'wifi_driver_ws_path' not in self:
|
||||
self['wifi_driver_ws_path'] = '/ws'
|
||||
# Legacy (unused): periodic UDP nudges removed; connect only on driver hello.
|
||||
if 'wifi_driver_hello_interval_s' not in self:
|
||||
self['wifi_driver_hello_interval_s'] = 0
|
||||
if 'wifi_driver_connect_retry_window_s' not in self:
|
||||
self['wifi_driver_connect_retry_window_s'] = 120.0
|
||||
# Spread outbound dials 0..N s by device IP so six+ drivers do not all hit the AP at once.
|
||||
if 'wifi_driver_connect_stagger_max_s' not in self:
|
||||
self['wifi_driver_connect_stagger_max_s'] = 2.5
|
||||
# TCP/WebSocket open timeout per attempt (seconds).
|
||||
if 'wifi_driver_ws_open_timeout' not in self:
|
||||
self['wifi_driver_ws_open_timeout'] = 45.0
|
||||
# Pause between outbound WebSocket dial attempts (seconds).
|
||||
if 'wifi_driver_connect_retry_interval_s' not in self:
|
||||
self['wifi_driver_connect_retry_interval_s'] = 2.0
|
||||
# Outbound WebSocket dial attempts per driver UDP hello (then wait for next hello).
|
||||
if 'wifi_driver_initial_connect_attempts' not in self:
|
||||
self['wifi_driver_initial_connect_attempts'] = 4
|
||||
# UART to ESP32 ESP-NOW bridge; default off (Wi-Fi drivers need no serial).
|
||||
if 'serial_enabled' not in self:
|
||||
self['serial_enabled'] = False
|
||||
# WebSocket URL of ESP-NOW bridge (Pi is client), e.g. ws://192.168.4.1/ws
|
||||
if 'bridge_ws_url' not in self:
|
||||
self['bridge_ws_url'] = ''
|
||||
# Zone UI global brightness (0–255); shared across browsers/devices.
|
||||
if 'global_brightness' not in self:
|
||||
self['global_brightness'] = 255
|
||||
@@ -91,9 +69,10 @@ class Settings(dict):
|
||||
|
||||
def save(self):
|
||||
try:
|
||||
j = json.dumps(self)
|
||||
j = json.dumps(self, indent=2, sort_keys=True)
|
||||
with open(self.SETTINGS_FILE, 'w') as file:
|
||||
file.write(j)
|
||||
file.write("\n")
|
||||
if not getattr(self, "_quiet", False):
|
||||
print("Settings saved successfully.")
|
||||
except Exception as e:
|
||||
|
||||
62
src/util/binary_driver_messages.py
Normal file
62
src/util/binary_driver_messages.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""Build binary ESP-NOW CMD / GROUP_CMD packets from preset/select data."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from util.binary_envelope import pack_binary_envelope_v2
|
||||
from util.espnow_wire import MAX_ESPNOW_PAYLOAD, pack_cmd, pack_group_cmd
|
||||
|
||||
|
||||
def v1_dict_to_cmd_packet(body: Dict[str, Any]) -> bytes:
|
||||
save = bool(body.get("save"))
|
||||
kw: Dict[str, Any] = {}
|
||||
if "presets" in body:
|
||||
kw["presets"] = body["presets"]
|
||||
if "select" in body:
|
||||
kw["select"] = body["select"]
|
||||
if "default" in body:
|
||||
kw["default"] = body["default"]
|
||||
kw["default_targets"] = body.get("targets")
|
||||
if "b" in body:
|
||||
kw["brightness_0_255"] = int(body["b"])
|
||||
return pack_cmd(pack_binary_envelope_v2(**kw), save=save)
|
||||
|
||||
|
||||
def build_preset_cmd_chunks(
|
||||
presets_by_name: Dict[str, Any],
|
||||
*,
|
||||
save: bool = False,
|
||||
default: Optional[str] = None,
|
||||
max_payload: int = MAX_ESPNOW_PAYLOAD,
|
||||
) -> List[bytes]:
|
||||
"""Chunk presets into CMD packets each ≤ max_payload bytes."""
|
||||
entries = list(presets_by_name.items())
|
||||
chunks: List[bytes] = []
|
||||
batch: Dict[str, Any] = {}
|
||||
|
||||
def _packet_for(presets_map: Dict[str, Any], *, final_save: bool, def_id: Optional[str]):
|
||||
kw: Dict[str, Any] = {"presets": presets_map}
|
||||
if def_id is not None:
|
||||
kw["default"] = def_id
|
||||
return pack_cmd(pack_binary_envelope_v2(**kw), save=final_save)
|
||||
|
||||
for name, preset_obj in entries:
|
||||
trial = dict(batch)
|
||||
trial[name] = preset_obj
|
||||
try:
|
||||
pkt = _packet_for(trial, final_save=False, def_id=None)
|
||||
except ValueError:
|
||||
pkt = b"\xff\xff"
|
||||
if len(pkt) <= max_payload or not batch:
|
||||
batch = trial
|
||||
else:
|
||||
chunks.append(_packet_for(batch, final_save=False, def_id=None))
|
||||
batch = {name: preset_obj}
|
||||
|
||||
if batch:
|
||||
chunks.append(
|
||||
_packet_for(batch, final_save=save, def_id=str(default) if default else None),
|
||||
)
|
||||
|
||||
return [c for c in chunks if c and c[0] == 0x4C]
|
||||
@@ -1,52 +1,22 @@
|
||||
"""Push Wi-Fi driver connect/disconnect updates to browser WebSocket clients."""
|
||||
"""Device status WebSocket broadcasts (ESP-NOW has no live TCP session)."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import threading
|
||||
from typing import Any, Set
|
||||
|
||||
# Threading lock: safe across asyncio tasks and avoids binding asyncio.Lock to the wrong loop.
|
||||
_clients_lock = threading.Lock()
|
||||
_clients: Set[Any] = set()
|
||||
_ws_clients: set = set()
|
||||
|
||||
|
||||
async def register_device_status_ws(ws: Any) -> None:
|
||||
with _clients_lock:
|
||||
_clients.add(ws)
|
||||
async def register_device_status_ws(ws):
|
||||
_ws_clients.add(ws)
|
||||
|
||||
|
||||
async def unregister_device_status_ws(ws: Any) -> None:
|
||||
with _clients_lock:
|
||||
_clients.discard(ws)
|
||||
async def unregister_device_status_ws(ws):
|
||||
_ws_clients.discard(ws)
|
||||
|
||||
|
||||
async def broadcast_device_tcp_status(ip: str, connected: bool) -> None:
|
||||
from models.wifi_ws_clients import normalize_tcp_peer_ip
|
||||
|
||||
ip = normalize_tcp_peer_ip(ip)
|
||||
if not ip:
|
||||
return
|
||||
msg = json.dumps({"type": "device_tcp", "ip": ip, "connected": bool(connected)})
|
||||
with _clients_lock:
|
||||
targets = list(_clients)
|
||||
dead = []
|
||||
for ws in targets:
|
||||
try:
|
||||
await ws.send(msg)
|
||||
except Exception as exc:
|
||||
dead.append(ws)
|
||||
print(f"[device_status_broadcaster] ws.send failed: {exc!r}")
|
||||
if dead:
|
||||
with _clients_lock:
|
||||
for ws in dead:
|
||||
_clients.discard(ws)
|
||||
async def broadcast_device_tcp_snapshot_to(ws):
|
||||
await ws.send(json.dumps({"type": "device_tcp_snapshot", "devices": {}}))
|
||||
|
||||
|
||||
async def broadcast_device_tcp_snapshot_to(ws: Any) -> None:
|
||||
from models import wifi_ws_clients as tcp
|
||||
|
||||
ips = tcp.list_connected_ips()
|
||||
msg = json.dumps({"type": "device_tcp_snapshot", "connected_ips": ips})
|
||||
try:
|
||||
await ws.send(msg)
|
||||
except Exception as exc:
|
||||
print(f"[device_status_broadcaster] snapshot send failed: {exc!r}")
|
||||
async def broadcast_device_tcp_status(mac: str, connected: bool):
|
||||
pass
|
||||
|
||||
@@ -1,70 +1,73 @@
|
||||
"""Deliver driver JSON messages over serial (ESP-NOW) and/or WebSocket (Wi-Fi drivers)."""
|
||||
"""Deliver binary ESP-NOW messages via bridge WebSocket."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from typing import List, Optional, Union
|
||||
|
||||
from models.device import normalize_mac
|
||||
from models.wifi_ws_clients import send_json_line_to_ip
|
||||
from util.binary_driver_messages import build_preset_cmd_chunks, v1_dict_to_cmd_packet
|
||||
from util.espnow_wire import BROADCAST_MAC, pack_group_cmd
|
||||
|
||||
# Serial bridge (ESP32): broadcast MAC + this envelope → firmware unicasts ``body`` to each peer.
|
||||
_SPLIT_MODE = "split"
|
||||
_BROADCAST_MAC_HEX = "ffffffffffff"
|
||||
_BROADCAST_HEX = "ffffffffffff"
|
||||
|
||||
|
||||
def _split_serial_envelope(inner_json_str, peer_hex_list):
|
||||
"""One UART frame: broadcast dest + JSON {m:split, peers:[hex,...], body:<object>}."""
|
||||
body = json.loads(inner_json_str)
|
||||
env = {"m": _SPLIT_MODE, "peers": list(peer_hex_list), "body": body}
|
||||
return json.dumps(env, separators=(",", ":"))
|
||||
async def deliver_binary_packets(
|
||||
sender,
|
||||
packets: List[bytes],
|
||||
target_macs: Optional[List[str]] = None,
|
||||
*,
|
||||
delay_s: float = 0.1,
|
||||
) -> int:
|
||||
"""Send binary CMD packets unicast per MAC or broadcast when no targets."""
|
||||
if not packets:
|
||||
return 0
|
||||
deliveries = 0
|
||||
if not target_macs:
|
||||
for pkt in packets:
|
||||
if await sender.send(pkt, addr=_BROADCAST_HEX):
|
||||
deliveries += 1
|
||||
await asyncio.sleep(delay_s)
|
||||
return deliveries
|
||||
|
||||
seen = set()
|
||||
ordered: List[str] = []
|
||||
for raw in target_macs:
|
||||
m = normalize_mac(str(raw)) if raw else None
|
||||
if not m or m in seen:
|
||||
continue
|
||||
seen.add(m)
|
||||
ordered.append(m)
|
||||
|
||||
for pkt in packets:
|
||||
for mac in ordered:
|
||||
if await sender.send(pkt, addr=mac):
|
||||
deliveries += 1
|
||||
await asyncio.sleep(delay_s)
|
||||
return deliveries
|
||||
|
||||
|
||||
def _wifi_message_for_device(msg, device_name):
|
||||
"""
|
||||
For Wi-Fi WebSocket fanout, narrow a v1 select map to a single device name.
|
||||
Returns the original message when no narrowing applies.
|
||||
"""
|
||||
if not device_name:
|
||||
return msg
|
||||
try:
|
||||
body = json.loads(msg)
|
||||
except Exception:
|
||||
return msg
|
||||
if not isinstance(body, dict):
|
||||
return msg
|
||||
select = body.get("select")
|
||||
if not isinstance(select, dict):
|
||||
return msg
|
||||
if device_name not in select:
|
||||
return msg
|
||||
body["select"] = {device_name: select[device_name]}
|
||||
return json.dumps(body, separators=(",", ":"))
|
||||
async def deliver_group_binary_packets(
|
||||
sender,
|
||||
group_id: str,
|
||||
packets: List[bytes],
|
||||
*,
|
||||
delay_s: float = 0.1,
|
||||
) -> int:
|
||||
"""Broadcast GROUP_CMD packets (one ESP-NOW send per packet)."""
|
||||
from util.espnow_wire import parse_cmd
|
||||
|
||||
|
||||
def _combine_preset_chunks_for_wifi(chunk_messages):
|
||||
"""Merge chunked v1 preset messages into one v1 JSON string for Wi-Fi."""
|
||||
merged_presets = {}
|
||||
save_flag = False
|
||||
default_id = None
|
||||
for msg in chunk_messages:
|
||||
deliveries = 0
|
||||
for pkt in packets:
|
||||
env, save = parse_cmd(pkt)
|
||||
if env is None:
|
||||
continue
|
||||
try:
|
||||
body = json.loads(msg)
|
||||
except Exception:
|
||||
g_pkt = pack_group_cmd(str(group_id), env, save=save)
|
||||
except ValueError:
|
||||
continue
|
||||
if not isinstance(body, dict):
|
||||
continue
|
||||
presets = body.get("presets")
|
||||
if isinstance(presets, dict):
|
||||
merged_presets.update(presets)
|
||||
if body.get("save"):
|
||||
save_flag = True
|
||||
if body.get("default") is not None:
|
||||
default_id = body.get("default")
|
||||
out = {"v": "1", "presets": merged_presets}
|
||||
if save_flag:
|
||||
out["save"] = True
|
||||
if default_id is not None:
|
||||
out["default"] = default_id
|
||||
return json.dumps(out, separators=(",", ":"))
|
||||
if await sender.send(g_pkt, addr=_BROADCAST_HEX):
|
||||
deliveries += 1
|
||||
await asyncio.sleep(delay_s)
|
||||
return deliveries
|
||||
|
||||
|
||||
async def deliver_preset_broadcast_then_per_device(
|
||||
@@ -76,11 +79,24 @@ async def deliver_preset_broadcast_then_per_device(
|
||||
delay_s=0.1,
|
||||
):
|
||||
"""
|
||||
Send preset definition chunks: ESP-NOW broadcast once per chunk; same chunk to each
|
||||
Wi-Fi driver over WebSocket. If default_id is set, send a per-target default message
|
||||
(unicast serial or WebSocket) with targets=[device name] for each registry entry.
|
||||
chunk_messages: list of v1 JSON strings OR binary CMD bytes.
|
||||
Converts JSON strings to binary when needed.
|
||||
"""
|
||||
if not chunk_messages:
|
||||
packets: List[bytes] = []
|
||||
for msg in chunk_messages:
|
||||
if isinstance(msg, (bytes, bytearray)):
|
||||
packets.append(bytes(msg))
|
||||
else:
|
||||
import json
|
||||
|
||||
try:
|
||||
body = json.loads(msg)
|
||||
except Exception:
|
||||
continue
|
||||
if isinstance(body, dict):
|
||||
packets.append(v1_dict_to_cmd_packet(body))
|
||||
|
||||
if not packets:
|
||||
return 0
|
||||
|
||||
seen = set()
|
||||
@@ -92,30 +108,9 @@ async def deliver_preset_broadcast_then_per_device(
|
||||
seen.add(m)
|
||||
ordered.append(m)
|
||||
|
||||
wifi_ips = []
|
||||
for mac in ordered:
|
||||
doc = devices_model.read(mac)
|
||||
if doc and doc.get("transport") == "wifi" and doc.get("address"):
|
||||
wifi_ips.append(str(doc["address"]).strip())
|
||||
|
||||
deliveries = 0
|
||||
wifi_combined_msg = _combine_preset_chunks_for_wifi(chunk_messages)
|
||||
for msg in chunk_messages:
|
||||
tasks = [sender.send(msg, addr=_BROADCAST_MAC_HEX)]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
if results and results[0] is True:
|
||||
deliveries += 1
|
||||
await asyncio.sleep(delay_s)
|
||||
|
||||
for ip in wifi_ips:
|
||||
if not ip:
|
||||
continue
|
||||
try:
|
||||
if await send_json_line_to_ip(ip, wifi_combined_msg):
|
||||
deliveries += 1
|
||||
except Exception as e:
|
||||
print(f"[driver_delivery] Wi-Fi preset send failed: {e!r}")
|
||||
await asyncio.sleep(delay_s)
|
||||
deliveries = await deliver_binary_packets(
|
||||
sender, packets, ordered, delay_s=delay_s
|
||||
)
|
||||
|
||||
if default_id:
|
||||
did = str(default_id)
|
||||
@@ -123,20 +118,9 @@ async def deliver_preset_broadcast_then_per_device(
|
||||
doc = devices_model.read(mac) or {}
|
||||
name = str(doc.get("name") or "").strip() or mac
|
||||
body = {"v": "1", "default": did, "save": True, "targets": [name]}
|
||||
out = json.dumps(body, separators=(",", ":"))
|
||||
if doc.get("transport") == "wifi" and doc.get("address"):
|
||||
ip = str(doc["address"]).strip()
|
||||
try:
|
||||
if await send_json_line_to_ip(ip, out):
|
||||
deliveries += 1
|
||||
except Exception as e:
|
||||
print(f"[driver_delivery] default Wi-Fi send failed: {e!r}")
|
||||
else:
|
||||
try:
|
||||
await sender.send(out, addr=mac)
|
||||
deliveries += 1
|
||||
except Exception as e:
|
||||
print(f"[driver_delivery] default serial failed: {e!r}")
|
||||
pkt = v1_dict_to_cmd_packet(body)
|
||||
if await sender.send(pkt, addr=mac):
|
||||
deliveries += 1
|
||||
await asyncio.sleep(delay_s)
|
||||
|
||||
return deliveries
|
||||
@@ -144,26 +128,29 @@ async def deliver_preset_broadcast_then_per_device(
|
||||
|
||||
async def deliver_json_messages(sender, messages, target_macs, devices_model, delay_s=0.1):
|
||||
"""
|
||||
Send each message string to the bridge and/or Wi-Fi WebSocket clients.
|
||||
|
||||
If target_macs is None or empty: one serial send per message (default/broadcast address).
|
||||
Otherwise: Wi-Fi uses WebSocket in parallel. Multiple ESP-NOW peers are sent in **one** serial
|
||||
write to the ESP32 (broadcast + split envelope); the bridge unicasts ``body`` to each
|
||||
peer. A single ESP-NOW peer still uses one unicast serial frame. Wi-Fi and serial
|
||||
tasks run together in one asyncio.gather.
|
||||
|
||||
Returns (delivery_count, chunk_count) where chunk_count is len(messages).
|
||||
Convert v1 JSON message strings to binary CMD packets and deliver.
|
||||
Returns (delivery_count, chunk_count).
|
||||
"""
|
||||
if not messages:
|
||||
packets: List[bytes] = []
|
||||
import json
|
||||
|
||||
for msg in messages:
|
||||
if isinstance(msg, (bytes, bytearray)):
|
||||
packets.append(bytes(msg))
|
||||
continue
|
||||
try:
|
||||
body = json.loads(msg)
|
||||
except Exception:
|
||||
continue
|
||||
if isinstance(body, dict):
|
||||
packets.append(v1_dict_to_cmd_packet(body))
|
||||
|
||||
if not packets:
|
||||
return 0, 0
|
||||
|
||||
if not target_macs:
|
||||
deliveries = 0
|
||||
for msg in messages:
|
||||
await sender.send(msg)
|
||||
deliveries += 1
|
||||
await asyncio.sleep(delay_s)
|
||||
return deliveries, len(messages)
|
||||
n = await deliver_binary_packets(sender, packets, None, delay_s=delay_s)
|
||||
return n, len(packets)
|
||||
|
||||
seen = set()
|
||||
ordered_macs = []
|
||||
@@ -174,51 +161,5 @@ async def deliver_json_messages(sender, messages, target_macs, devices_model, de
|
||||
seen.add(m)
|
||||
ordered_macs.append(m)
|
||||
|
||||
deliveries = 0
|
||||
for msg in messages:
|
||||
wifi_tasks = []
|
||||
espnow_hex = []
|
||||
for mac in ordered_macs:
|
||||
doc = devices_model.read(mac)
|
||||
if doc and doc.get("transport") == "wifi":
|
||||
ip = doc.get("address")
|
||||
if ip:
|
||||
name = str(doc.get("name") or "").strip()
|
||||
wifi_msg = _wifi_message_for_device(msg, name)
|
||||
wifi_tasks.append(send_json_line_to_ip(ip, wifi_msg))
|
||||
else:
|
||||
espnow_hex.append(mac)
|
||||
|
||||
tasks = []
|
||||
espnow_peer_count = 0
|
||||
if len(espnow_hex) > 1:
|
||||
tasks.append(
|
||||
sender.send(
|
||||
_split_serial_envelope(msg, espnow_hex),
|
||||
addr=_BROADCAST_MAC_HEX,
|
||||
)
|
||||
)
|
||||
espnow_peer_count = len(espnow_hex)
|
||||
elif len(espnow_hex) == 1:
|
||||
tasks.append(sender.send(msg, addr=espnow_hex[0]))
|
||||
espnow_peer_count = 1
|
||||
|
||||
tasks.extend(wifi_tasks)
|
||||
|
||||
if tasks:
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
n_serial = len(tasks) - len(wifi_tasks)
|
||||
for i, r in enumerate(results):
|
||||
if i < n_serial:
|
||||
if r is True:
|
||||
deliveries += espnow_peer_count
|
||||
elif isinstance(r, Exception):
|
||||
print(f"[driver_delivery] serial delivery failed: {r!r}")
|
||||
else:
|
||||
if r is True:
|
||||
deliveries += 1
|
||||
elif isinstance(r, Exception):
|
||||
print(f"[driver_delivery] Wi-Fi delivery failed: {r!r}")
|
||||
|
||||
await asyncio.sleep(delay_s)
|
||||
return deliveries, len(messages)
|
||||
n = await deliver_binary_packets(sender, packets, ordered_macs, delay_s=delay_s)
|
||||
return n, len(packets)
|
||||
|
||||
70
src/util/espnow_registry.py
Normal file
70
src/util/espnow_registry.py
Normal file
@@ -0,0 +1,70 @@
|
||||
"""Handle ESP-NOW ANNOUNCE uplink and push GROUPS to drivers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from models.device import Device, normalize_mac # noqa: F401 — re-export for callers
|
||||
from models.group import Group
|
||||
from models.bridge_ws_client import get_bridge_client
|
||||
from util.espnow_wire import mac_bytes_to_hex, pack_groups, parse_announce
|
||||
from util.groups_for_device import groups_for_mac
|
||||
|
||||
|
||||
async def handle_espnow_announce(peer_mac: bytes, packet: bytes) -> None:
|
||||
info = parse_announce(packet)
|
||||
if not info:
|
||||
return
|
||||
mac_hex = mac_bytes_to_hex(peer_mac)
|
||||
if not mac_hex:
|
||||
return
|
||||
|
||||
devices = Device()
|
||||
did, persisted = devices.upsert_espnow_announced(
|
||||
mac_hex,
|
||||
info["name"],
|
||||
device_type=info.get("device_type", "led"),
|
||||
num_leds=info.get("num_leds"),
|
||||
color_order=info.get("color_order"),
|
||||
startup_mode=info.get("startup_mode"),
|
||||
brightness=info.get("brightness"),
|
||||
)
|
||||
if not did:
|
||||
return
|
||||
if persisted:
|
||||
print(f"[espnow] registered mac={did} name={info['name']!r}")
|
||||
|
||||
groups = Group()
|
||||
gids = groups_for_mac(did, groups)
|
||||
groups_pkt = pack_groups(gids)
|
||||
|
||||
client = get_bridge_client()
|
||||
if client is None:
|
||||
print("[espnow] bridge client not configured; groups not sent")
|
||||
return
|
||||
ok = await client.send_espnow(groups_pkt, peer_mac=peer_mac)
|
||||
if ok:
|
||||
print(f"[espnow] groups -> {did}: {gids}")
|
||||
else:
|
||||
print(f"[espnow] groups send failed for {did}")
|
||||
|
||||
|
||||
async def push_groups_for_group_devices(gdoc: dict) -> None:
|
||||
"""Refresh GROUPS on every MAC listed on a group document."""
|
||||
mac_list = gdoc.get("devices") if isinstance(gdoc.get("devices"), list) else []
|
||||
for mac in mac_list:
|
||||
m = normalize_mac(str(mac))
|
||||
if m:
|
||||
await push_groups_to_mac(m)
|
||||
|
||||
|
||||
async def push_groups_to_mac(mac_hex: str) -> bool:
|
||||
"""Re-send GROUPS packet to one device (after group membership change)."""
|
||||
mac = normalize_mac(mac_hex)
|
||||
if not mac:
|
||||
return False
|
||||
client = get_bridge_client()
|
||||
if client is None:
|
||||
return False
|
||||
groups = Group()
|
||||
gids = groups_for_mac(mac, groups)
|
||||
pkt = pack_groups(gids)
|
||||
return await client.send_espnow(pkt, peer_mac=bytes.fromhex(mac))
|
||||
291
src/util/espnow_wire.py
Normal file
291
src/util/espnow_wire.py
Normal file
@@ -0,0 +1,291 @@
|
||||
"""
|
||||
ESP-NOW wire format: magic header + message types, Pi↔bridge WebSocket framing.
|
||||
|
||||
See docs/espnow-binary-protocol.md.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import struct
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from util.binary_envelope import (
|
||||
BINARY_ENVELOPE_VERSION_2,
|
||||
pack_binary_envelope_v2,
|
||||
parse_binary_envelope_v2,
|
||||
)
|
||||
|
||||
WIRE_MAGIC = 0x4C
|
||||
MAX_ESPNOW_PAYLOAD = 250
|
||||
|
||||
MSG_ANNOUNCE = 0x01
|
||||
MSG_GROUPS = 0x02
|
||||
MSG_CMD = 0x03
|
||||
MSG_GROUP_CMD = 0x04
|
||||
MSG_BRIDGE_CH = 0x10
|
||||
|
||||
BROADCAST_MAC = bytes.fromhex("ffffffffffff")
|
||||
|
||||
WS_FLAG_BROADCAST = 0x01
|
||||
|
||||
COLOR_ORDER_TO_ENUM = {
|
||||
"rgb": 0,
|
||||
"rbg": 1,
|
||||
"grb": 2,
|
||||
"gbr": 3,
|
||||
"brg": 4,
|
||||
"bgr": 5,
|
||||
}
|
||||
ENUM_TO_COLOR_ORDER = {v: k for k, v in COLOR_ORDER_TO_ENUM.items()}
|
||||
|
||||
STARTUP_MODE_TO_ENUM = {"default": 0, "last": 1, "off": 2}
|
||||
ENUM_TO_STARTUP_MODE = {v: k for k, v in STARTUP_MODE_TO_ENUM.items()}
|
||||
|
||||
|
||||
def normalize_mac_bytes(mac: Any) -> Optional[bytes]:
|
||||
if mac is None:
|
||||
return None
|
||||
if isinstance(mac, (bytes, bytearray)) and len(mac) == 6:
|
||||
return bytes(mac)
|
||||
s = str(mac).strip().lower().replace(":", "").replace("-", "")
|
||||
if len(s) == 12 and all(c in "0123456789abcdef" for c in s):
|
||||
return bytes.fromhex(s)
|
||||
return None
|
||||
|
||||
|
||||
def mac_bytes_to_hex(mac: bytes) -> str:
|
||||
return mac.hex()
|
||||
|
||||
|
||||
def _pack_header(msg_type: int, body: bytes) -> bytes:
|
||||
pkt = bytes([WIRE_MAGIC, msg_type]) + body
|
||||
if len(pkt) > MAX_ESPNOW_PAYLOAD:
|
||||
raise ValueError(f"ESP-NOW packet {len(pkt)} exceeds {MAX_ESPNOW_PAYLOAD}")
|
||||
return pkt
|
||||
|
||||
|
||||
def pack_announce(
|
||||
*,
|
||||
name: str,
|
||||
num_leds: int,
|
||||
color_order: str = "rgb",
|
||||
startup_mode: str = "default",
|
||||
brightness: int = 32,
|
||||
device_type: int = 0,
|
||||
) -> bytes:
|
||||
name_b = name.encode("utf-8")
|
||||
if len(name_b) > 250:
|
||||
raise ValueError("name too long")
|
||||
co = COLOR_ORDER_TO_ENUM.get(str(color_order).lower(), 0)
|
||||
sm = STARTUP_MODE_TO_ENUM.get(str(startup_mode).lower(), 0)
|
||||
body = (
|
||||
bytes([len(name_b)])
|
||||
+ name_b
|
||||
+ struct.pack("<H", max(0, min(65535, int(num_leds))))
|
||||
+ bytes([co & 7, sm & 3, max(0, min(255, int(brightness))), device_type & 255])
|
||||
)
|
||||
return _pack_header(MSG_ANNOUNCE, body)
|
||||
|
||||
|
||||
def parse_announce(payload: bytes) -> Optional[Dict[str, Any]]:
|
||||
"""Parse full ESP-NOW packet or body-only after type byte."""
|
||||
if len(payload) >= 2 and payload[0] == WIRE_MAGIC:
|
||||
if payload[1] != MSG_ANNOUNCE:
|
||||
return None
|
||||
body = payload[2:]
|
||||
else:
|
||||
body = payload
|
||||
off = 0
|
||||
if off + 1 > len(body):
|
||||
return None
|
||||
nl = body[off]
|
||||
off += 1
|
||||
if off + nl + 5 > len(body):
|
||||
return None
|
||||
name = body[off : off + nl].decode("utf-8")
|
||||
off += nl
|
||||
num_leds = struct.unpack_from("<H", body, off)[0]
|
||||
off += 2
|
||||
co, sm, br, dt = body[off], body[off + 1], body[off + 2], body[off + 3]
|
||||
return {
|
||||
"name": name,
|
||||
"num_leds": num_leds,
|
||||
"color_order": ENUM_TO_COLOR_ORDER.get(co, "rgb"),
|
||||
"startup_mode": ENUM_TO_STARTUP_MODE.get(sm, "default"),
|
||||
"brightness": br,
|
||||
"device_type": "led" if dt == 0 else str(dt),
|
||||
}
|
||||
|
||||
|
||||
def pack_groups(group_ids: List[str]) -> bytes:
|
||||
parts = [bytes([min(255, len(group_ids))])]
|
||||
for gid in group_ids[:255]:
|
||||
gb = str(gid).encode("utf-8")
|
||||
if len(gb) > 250:
|
||||
raise ValueError("group id too long")
|
||||
parts.append(bytes([len(gb)]))
|
||||
parts.append(gb)
|
||||
return _pack_header(MSG_GROUPS, b"".join(parts))
|
||||
|
||||
|
||||
def parse_groups(payload: bytes) -> Optional[List[str]]:
|
||||
if len(payload) >= 2 and payload[0] == WIRE_MAGIC:
|
||||
if payload[1] != MSG_GROUPS:
|
||||
return None
|
||||
body = payload[2:]
|
||||
else:
|
||||
body = payload
|
||||
if not body:
|
||||
return []
|
||||
off = 0
|
||||
if off + 1 > len(body):
|
||||
return None
|
||||
count = body[off]
|
||||
off += 1
|
||||
out: List[str] = []
|
||||
for _ in range(count):
|
||||
if off + 1 > len(body):
|
||||
return None
|
||||
gl = body[off]
|
||||
off += 1
|
||||
if off + gl > len(body):
|
||||
return None
|
||||
out.append(body[off : off + gl].decode("utf-8"))
|
||||
off += gl
|
||||
return out
|
||||
|
||||
|
||||
def cmd_envelope_size(envelope: bytes) -> int:
|
||||
from util.binary_envelope import HEADER_LEN
|
||||
|
||||
if len(envelope) < HEADER_LEN:
|
||||
return len(envelope)
|
||||
lp, ls, ld = envelope[2], envelope[3], envelope[4]
|
||||
return HEADER_LEN + lp + ls + ld
|
||||
|
||||
|
||||
def pack_cmd(envelope: bytes, *, save: bool = False) -> bytes:
|
||||
if envelope and envelope[0] != BINARY_ENVELOPE_VERSION_2:
|
||||
raise ValueError("CMD envelope must be v2 binary")
|
||||
need = cmd_envelope_size(envelope)
|
||||
body = envelope[:need]
|
||||
if save:
|
||||
body = body + bytes([1])
|
||||
if len(body) + 2 > MAX_ESPNOW_PAYLOAD:
|
||||
raise ValueError("CMD envelope too large for ESP-NOW")
|
||||
return _pack_header(MSG_CMD, body)
|
||||
|
||||
|
||||
def pack_cmd_from_kwargs(*, save: bool = False, **kwargs: Any) -> bytes:
|
||||
return pack_cmd(pack_binary_envelope_v2(**kwargs), save=save)
|
||||
|
||||
|
||||
def parse_cmd(payload: bytes) -> Tuple[Optional[bytes], bool]:
|
||||
"""Return (v2 envelope bytes, save_flag) inside CMD packet."""
|
||||
if len(payload) < 2 or payload[0] != WIRE_MAGIC or payload[1] != MSG_CMD:
|
||||
return None, False
|
||||
env = payload[2:]
|
||||
if not env:
|
||||
return None, False
|
||||
need = cmd_envelope_size(env)
|
||||
if need > len(env):
|
||||
return None, False
|
||||
save = len(env) > need and env[need] == 1
|
||||
return bytes(env[:need]), save
|
||||
|
||||
|
||||
def parse_cmd_as_v1_dict(payload: bytes) -> Optional[Dict[str, Any]]:
|
||||
env, save = parse_cmd(payload)
|
||||
if env is None:
|
||||
return None
|
||||
data = parse_binary_envelope_v2(env)
|
||||
if data is None:
|
||||
return None
|
||||
if save:
|
||||
data["save"] = True
|
||||
return data
|
||||
|
||||
|
||||
def pack_group_cmd(group_id: str, envelope: bytes, *, save: bool = False) -> bytes:
|
||||
if envelope and envelope[0] != BINARY_ENVELOPE_VERSION_2:
|
||||
raise ValueError("GROUP_CMD envelope must be v2 binary")
|
||||
gid_b = str(group_id).encode("utf-8")
|
||||
if len(gid_b) > 250:
|
||||
raise ValueError("group id too long")
|
||||
need = cmd_envelope_size(envelope)
|
||||
env = envelope[:need]
|
||||
if save:
|
||||
env = env + bytes([1])
|
||||
body = bytes([len(gid_b)]) + gid_b + env
|
||||
return _pack_header(MSG_GROUP_CMD, body)
|
||||
|
||||
|
||||
def pack_group_cmd_from_kwargs(group_id: str, **kwargs: Any) -> bytes:
|
||||
return pack_group_cmd(group_id, pack_binary_envelope_v2(**kwargs))
|
||||
|
||||
|
||||
def parse_group_cmd(payload: bytes) -> Optional[Tuple[str, bytes]]:
|
||||
if len(payload) < 2 or payload[0] != WIRE_MAGIC or payload[1] != MSG_GROUP_CMD:
|
||||
return None
|
||||
body = payload[2:]
|
||||
if not body:
|
||||
return None
|
||||
gl = body[0]
|
||||
if 1 + gl > len(body):
|
||||
return None
|
||||
gid = body[1 : 1 + gl].decode("utf-8")
|
||||
env = body[1 + gl :]
|
||||
return gid, bytes(env)
|
||||
|
||||
|
||||
def pack_bridge_channel(channel: int) -> bytes:
|
||||
ch = max(1, min(11, int(channel)))
|
||||
return _pack_header(MSG_BRIDGE_CH, bytes([ch]))
|
||||
|
||||
|
||||
def parse_bridge_channel(payload: bytes) -> Optional[int]:
|
||||
if len(payload) < 3 or payload[0] != WIRE_MAGIC or payload[1] != MSG_BRIDGE_CH:
|
||||
return None
|
||||
return int(payload[2])
|
||||
|
||||
|
||||
def wire_msg_type(payload: bytes) -> Optional[int]:
|
||||
if len(payload) >= 2 and payload[0] == WIRE_MAGIC:
|
||||
return int(payload[1])
|
||||
return None
|
||||
|
||||
|
||||
def pack_ws_downlink(
|
||||
espnow_packet: bytes,
|
||||
*,
|
||||
peer_mac: Optional[Any] = None,
|
||||
broadcast: bool = False,
|
||||
) -> bytes:
|
||||
flags = WS_FLAG_BROADCAST if broadcast else 0
|
||||
if broadcast:
|
||||
peer = BROADCAST_MAC
|
||||
else:
|
||||
peer = normalize_mac_bytes(peer_mac)
|
||||
if peer is None:
|
||||
raise ValueError("peer MAC required for unicast downlink")
|
||||
return bytes([flags]) + peer + espnow_packet
|
||||
|
||||
|
||||
def pack_ws_uplink(peer_mac: bytes, espnow_packet: bytes) -> bytes:
|
||||
peer = normalize_mac_bytes(peer_mac)
|
||||
if peer is None:
|
||||
raise ValueError("invalid peer MAC")
|
||||
return bytes([0]) + peer + espnow_packet
|
||||
|
||||
|
||||
def parse_ws_frame(frame: bytes) -> Tuple[bytes, bytes, bool]:
|
||||
"""
|
||||
Returns (peer_mac_6bytes, espnow_packet, is_broadcast_dest).
|
||||
"""
|
||||
if len(frame) < 8:
|
||||
raise ValueError("WS frame too short")
|
||||
flags = frame[0]
|
||||
peer = frame[1:7]
|
||||
pkt = frame[7:]
|
||||
broadcast = bool(flags & WS_FLAG_BROADCAST) or peer == BROADCAST_MAC
|
||||
return peer, pkt, broadcast
|
||||
23
src/util/groups_for_device.py
Normal file
23
src/util/groups_for_device.py
Normal file
@@ -0,0 +1,23 @@
|
||||
"""Resolve group membership for a device MAC."""
|
||||
|
||||
from models.device import normalize_mac
|
||||
|
||||
|
||||
def groups_for_mac(mac_hex: str, groups_model) -> list[str]:
|
||||
"""Return group ids (string keys) that list this device MAC."""
|
||||
mac = normalize_mac(mac_hex)
|
||||
if not mac:
|
||||
return []
|
||||
out: list[str] = []
|
||||
for gid in groups_model.list():
|
||||
doc = groups_model.read(gid)
|
||||
if not isinstance(doc, dict):
|
||||
continue
|
||||
devs = doc.get("devices")
|
||||
if not isinstance(devs, list):
|
||||
continue
|
||||
for d in devs:
|
||||
if normalize_mac(str(d)) == mac:
|
||||
out.append(str(gid))
|
||||
break
|
||||
return out
|
||||
224
tests/bridge_broadcast_test.py
Normal file
224
tests/bridge_broadcast_test.py
Normal file
@@ -0,0 +1,224 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Send binary ESP-NOW packets via the bridge (broadcast passthrough).
|
||||
|
||||
The simplified ``espnow-sender`` forwards each WebSocket **binary** message
|
||||
unchanged to ESP-NOW ``ff:ff:ff:ff:ff:ff``. No ``pack_ws_downlink`` wrapper
|
||||
and no 1-byte ack — raw wire packets only (see ``docs/espnow-binary-protocol.md``).
|
||||
|
||||
Group membership is expected to be configured on each **led-driver**; this
|
||||
script only broadcasts **CMD** (and optional **GROUPS** / **GROUP_CMD** for
|
||||
manual testing).
|
||||
|
||||
Examples::
|
||||
|
||||
pipenv run python tests/bridge_broadcast_test.py
|
||||
|
||||
pipenv run python tests/bridge_broadcast_test.py --url ws://192.168.4.1/ws
|
||||
|
||||
pipenv run python tests/bridge_broadcast_test.py --brightness 200
|
||||
|
||||
pipenv run python tests/bridge_broadcast_test.py --select led-abc --state on
|
||||
|
||||
pipenv run python tests/bridge_broadcast_test.py --groups 5,18 --group-cmd 18 --brightness 64
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(PROJECT_ROOT / "src"))
|
||||
|
||||
from util.espnow_wire import ( # noqa: E402
|
||||
pack_cmd_from_kwargs,
|
||||
pack_group_cmd_from_kwargs,
|
||||
pack_groups,
|
||||
wire_msg_type,
|
||||
)
|
||||
|
||||
MSG_TYPE_NAMES = {
|
||||
0x01: "ANNOUNCE",
|
||||
0x02: "GROUPS",
|
||||
0x03: "CMD",
|
||||
0x04: "GROUP_CMD",
|
||||
0x10: "BRIDGE_CH",
|
||||
}
|
||||
|
||||
|
||||
def _load_bridge_url(explicit: str | None) -> str:
|
||||
if explicit and explicit.strip():
|
||||
return explicit.strip()
|
||||
path = PROJECT_ROOT / "settings.json"
|
||||
if path.is_file():
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
url = str(data.get("bridge_ws_url") or "").strip()
|
||||
if url:
|
||||
return url
|
||||
except (OSError, json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
return "ws://192.168.4.1/ws"
|
||||
|
||||
|
||||
def _describe_packet(pkt: bytes) -> str:
|
||||
if len(pkt) < 2:
|
||||
return f"{len(pkt)} B"
|
||||
name = MSG_TYPE_NAMES.get(pkt[1], f"0x{pkt[1]:02x}")
|
||||
return f"{name} {len(pkt)} B"
|
||||
|
||||
|
||||
async def _send_packets(url: str, packets: list[bytes], delay_s: float) -> None:
|
||||
import websockets
|
||||
|
||||
print(f"connecting to {url}")
|
||||
async with websockets.connect(url, ping_interval=20, ping_timeout=20) as ws:
|
||||
print("connected (broadcast passthrough)")
|
||||
for i, pkt in enumerate(packets):
|
||||
print(f" send [{i + 1}/{len(packets)}] {_describe_packet(pkt)}")
|
||||
await ws.send(pkt)
|
||||
if delay_s > 0 and i + 1 < len(packets):
|
||||
await asyncio.sleep(delay_s)
|
||||
print("done")
|
||||
|
||||
|
||||
def _build_packets(args: argparse.Namespace) -> list[bytes]:
|
||||
packets: list[bytes] = []
|
||||
|
||||
if args.groups:
|
||||
gids = [g.strip() for g in args.groups.split(",") if g.strip()]
|
||||
if gids:
|
||||
packets.append(pack_groups(gids))
|
||||
|
||||
if args.group_cmd:
|
||||
packets.append(
|
||||
pack_group_cmd_from_kwargs(
|
||||
args.group_cmd,
|
||||
brightness_0_255=args.brightness,
|
||||
select={args.select: [args.state]} if args.select else None,
|
||||
save=args.save,
|
||||
)
|
||||
)
|
||||
|
||||
if args.brightness is not None and not args.group_cmd:
|
||||
packets.append(
|
||||
pack_cmd_from_kwargs(brightness_0_255=args.brightness, save=args.save)
|
||||
)
|
||||
|
||||
if args.select:
|
||||
packets.append(
|
||||
pack_cmd_from_kwargs(
|
||||
select={args.select: [args.state]},
|
||||
save=args.save,
|
||||
)
|
||||
)
|
||||
|
||||
if args.off:
|
||||
if args.select:
|
||||
packets.append(
|
||||
pack_cmd_from_kwargs(select={args.select: ["off"]}, save=args.save)
|
||||
)
|
||||
else:
|
||||
packets.append(pack_cmd_from_kwargs(select={"all": ["off"]}, save=args.save))
|
||||
|
||||
if not packets:
|
||||
packets.append(pack_cmd_from_kwargs(brightness_0_255=128))
|
||||
packets.append(pack_cmd_from_kwargs(select={"all": ["on"]}))
|
||||
packets.append(pack_cmd_from_kwargs(select={"all": ["off"]}))
|
||||
|
||||
for pkt in packets:
|
||||
if wire_msg_type(pkt) is None:
|
||||
raise ValueError("built packet is not valid wire format")
|
||||
return packets
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Broadcast binary ESP-NOW packets through the bridge WebSocket.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--url",
|
||||
default=None,
|
||||
help="Bridge WebSocket URL (default: settings.json bridge_ws_url or ws://192.168.4.1/ws)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--delay",
|
||||
type=float,
|
||||
default=0.5,
|
||||
help="Seconds between packets (default: 0.5)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--brightness",
|
||||
"-b",
|
||||
type=int,
|
||||
default=None,
|
||||
metavar="0-255",
|
||||
help="Broadcast CMD: global brightness",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--select",
|
||||
metavar="DEVICE_NAME",
|
||||
help="Broadcast CMD: device name in select map (must match driver settings name)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--state",
|
||||
default="on",
|
||||
help="Pattern/state for --select (default: on)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--off",
|
||||
action="store_true",
|
||||
help="After other commands, send select off (all devices if --select omitted)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--groups",
|
||||
metavar="ID,ID",
|
||||
help="Optional GROUPS broadcast (normally configured on device instead)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--group-cmd",
|
||||
metavar="GROUP_ID",
|
||||
help="Optional GROUP_CMD broadcast (driver must list this group locally)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--save",
|
||||
action="store_true",
|
||||
help="Set save flag on CMD / GROUP_CMD envelopes",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="Print packets only; do not connect",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
url = _load_bridge_url(args.url)
|
||||
try:
|
||||
packets = _build_packets(args)
|
||||
except ValueError as e:
|
||||
print(f"error: {e}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print(f"url={url!r} packets={len(packets)}")
|
||||
for pkt in packets:
|
||||
print(f" {_describe_packet(pkt)} hex={pkt.hex()}")
|
||||
|
||||
if args.dry_run:
|
||||
return 0
|
||||
|
||||
try:
|
||||
asyncio.run(_send_packets(url, packets, args.delay))
|
||||
except KeyboardInterrupt:
|
||||
print("interrupted")
|
||||
return 130
|
||||
except Exception as e:
|
||||
print(f"failed: {e!r}", file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -150,6 +150,27 @@ def test_device_duplicate_names_allowed():
|
||||
assert devices.read(a1)["name"] == devices.read(a2)["name"] == "alpha"
|
||||
|
||||
|
||||
def test_upsert_espnow_announced():
|
||||
devices = _fresh_device()
|
||||
m = "e8f60a16dad0"
|
||||
i1, p1 = devices.upsert_espnow_announced(
|
||||
m,
|
||||
"led-test",
|
||||
num_leds=120,
|
||||
color_order="grb",
|
||||
startup_mode="last",
|
||||
brightness=70,
|
||||
)
|
||||
assert i1 == m and p1 is True
|
||||
d = devices.read(m)
|
||||
assert d["transport"] == "espnow"
|
||||
assert d["address"] == m
|
||||
assert d["name"] == "led-test"
|
||||
assert d["num_leds"] == 120
|
||||
i2, p2 = devices.upsert_espnow_announced(m, "led-test")
|
||||
assert i2 == m and p2 is False
|
||||
|
||||
|
||||
def test_device_duplicate_mac_rejected():
|
||||
devices = _fresh_device()
|
||||
devices.create("one", address="aa:bb:cc:dd:ee:ff")
|
||||
@@ -163,6 +184,7 @@ def test_device_duplicate_mac_rejected():
|
||||
if __name__ == "__main__":
|
||||
test_device()
|
||||
test_upsert_wifi_tcp_client()
|
||||
test_upsert_espnow_announced()
|
||||
test_device_can_change_address()
|
||||
test_device_duplicate_names_allowed()
|
||||
test_device_duplicate_mac_rejected()
|
||||
|
||||
110
tests/test_espnow_wire.py
Normal file
110
tests/test_espnow_wire.py
Normal file
@@ -0,0 +1,110 @@
|
||||
"""Tests for ESP-NOW binary wire format."""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(PROJECT_ROOT / "src"))
|
||||
|
||||
from util.binary_envelope import pack_binary_envelope_v2 # noqa: E402
|
||||
from util.espnow_wire import ( # noqa: E402
|
||||
BROADCAST_MAC,
|
||||
MAX_ESPNOW_PAYLOAD,
|
||||
MSG_ANNOUNCE,
|
||||
MSG_CMD,
|
||||
MSG_GROUPS,
|
||||
WIRE_MAGIC,
|
||||
pack_announce,
|
||||
pack_bridge_channel,
|
||||
pack_cmd,
|
||||
pack_cmd_from_kwargs,
|
||||
pack_group_cmd_from_kwargs,
|
||||
pack_groups,
|
||||
pack_ws_downlink,
|
||||
pack_ws_uplink,
|
||||
parse_announce,
|
||||
parse_cmd_as_v1_dict,
|
||||
parse_group_cmd,
|
||||
parse_groups,
|
||||
parse_ws_frame,
|
||||
wire_msg_type,
|
||||
)
|
||||
|
||||
|
||||
def test_announce_round_trip():
|
||||
raw = pack_announce(
|
||||
name="led-abc123",
|
||||
num_leds=119,
|
||||
color_order="grb",
|
||||
startup_mode="last",
|
||||
brightness=70,
|
||||
)
|
||||
assert len(raw) <= MAX_ESPNOW_PAYLOAD
|
||||
assert raw[0] == WIRE_MAGIC
|
||||
assert raw[1] == MSG_ANNOUNCE
|
||||
d = parse_announce(raw)
|
||||
assert d["name"] == "led-abc123"
|
||||
assert d["num_leds"] == 119
|
||||
assert d["color_order"] == "grb"
|
||||
assert d["startup_mode"] == "last"
|
||||
assert d["brightness"] == 70
|
||||
|
||||
|
||||
def test_groups_round_trip():
|
||||
raw = pack_groups(["5", "18", "test"])
|
||||
assert wire_msg_type(raw) == MSG_GROUPS
|
||||
assert parse_groups(raw) == ["5", "18", "test"]
|
||||
|
||||
|
||||
def test_cmd_envelope_round_trip():
|
||||
env = pack_binary_envelope_v2(brightness_0_255=128)
|
||||
raw = pack_cmd(env, save=True)
|
||||
assert wire_msg_type(raw) == MSG_CMD
|
||||
assert len(raw) <= MAX_ESPNOW_PAYLOAD
|
||||
d = parse_cmd_as_v1_dict(raw)
|
||||
assert d == {"v": "1", "b": 128, "save": True}
|
||||
|
||||
|
||||
def test_cmd_from_kwargs():
|
||||
raw = pack_cmd_from_kwargs(
|
||||
select={"dev1": ["on"]},
|
||||
brightness_0_255=64,
|
||||
)
|
||||
d = parse_cmd_as_v1_dict(raw)
|
||||
assert d["select"]["dev1"] == ["on"]
|
||||
assert d["b"] == 64
|
||||
|
||||
|
||||
def test_group_cmd_round_trip():
|
||||
raw = pack_group_cmd_from_kwargs("18", brightness_0_255=32)
|
||||
gid, env = parse_group_cmd(raw)
|
||||
assert gid == "18"
|
||||
d = parse_cmd_as_v1_dict(bytes([WIRE_MAGIC, MSG_CMD]) + env)
|
||||
assert d["b"] == 32
|
||||
|
||||
|
||||
def test_ws_frame_round_trip():
|
||||
pkt = pack_announce(name="led-x", num_leds=10)
|
||||
peer = bytes.fromhex("e8f60a16dad0")
|
||||
up = pack_ws_uplink(peer, pkt)
|
||||
p2, pkt2, bcast = parse_ws_frame(up)
|
||||
assert p2 == peer
|
||||
assert pkt2 == pkt
|
||||
assert not bcast
|
||||
|
||||
down = pack_ws_downlink(pkt, peer_mac=peer)
|
||||
p3, pkt3, bcast3 = parse_ws_frame(down)
|
||||
assert p3 == peer
|
||||
assert pkt3 == pkt
|
||||
assert not bcast3
|
||||
|
||||
bdown = pack_ws_downlink(pkt, broadcast=True)
|
||||
_, pkt4, bcast4 = parse_ws_frame(bdown)
|
||||
assert pkt4 == pkt
|
||||
assert bcast4
|
||||
|
||||
|
||||
def test_bridge_channel():
|
||||
raw = pack_bridge_channel(6)
|
||||
assert len(raw) == 3
|
||||
assert raw[1] == 0x10
|
||||
Reference in New Issue
Block a user