Compare commits
2 Commits
95137a6d65
...
mdev
| Author | SHA1 | Date | |
|---|---|---|---|
| 4bc4e1257e | |||
| ebc4498d89 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -26,8 +26,12 @@ compile_commands.json
|
|||||||
|
|
||||||
/build/
|
/build/
|
||||||
/build_output/
|
/build_output/
|
||||||
|
/.npm-cache/
|
||||||
/node_modules/
|
/node_modules/
|
||||||
|
|
||||||
|
/tools/__pycache__/
|
||||||
|
/tools/discovered_wled_hosts.txt
|
||||||
|
|
||||||
/wled00/extLibs
|
/wled00/extLibs
|
||||||
/wled00/LittleFS
|
/wled00/LittleFS
|
||||||
/wled00/my_config.h
|
/wled00/my_config.h
|
||||||
|
|||||||
@@ -6,11 +6,22 @@ Build target:
|
|||||||
|
|
||||||
- `rfp_esp32s3_wroom1_n16r8_3x106`
|
- `rfp_esp32s3_wroom1_n16r8_3x106`
|
||||||
|
|
||||||
|
Firmware release name:
|
||||||
|
|
||||||
|
- `RFP_N16R8_NODE3x106_V20260511E`
|
||||||
|
|
||||||
Default output pins:
|
Default output pins:
|
||||||
|
|
||||||
- Output 1: `GPIO4`
|
- Output 1: `GPIO4`
|
||||||
- Output 2: `GPIO5`
|
- Output 2: `GPIO5`
|
||||||
- Output 3: `GPIO6`
|
- Output 3: `GPIO6`
|
||||||
|
- No relay pin is used in the tracked node target (`RLYPIN=-1`)
|
||||||
|
|
||||||
|
Boot stabilization:
|
||||||
|
|
||||||
|
- The node target uses the production N16R8 boot settings from `platformio.ini`.
|
||||||
|
- `WLED_BOOTUPDELAY=1200` gives the power rail time to settle on cold start.
|
||||||
|
- There is no separate `coldboot` target anymore.
|
||||||
|
|
||||||
Pins intentionally avoided:
|
Pins intentionally avoided:
|
||||||
|
|
||||||
@@ -37,17 +48,55 @@ Build only with the helper script:
|
|||||||
.\tools\flash_rfp_s3.ps1 -BuildOnly
|
.\tools\flash_rfp_s3.ps1 -BuildOnly
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Linux setup (one-time, includes build toolchain + frontend dependencies):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./tools/setup_rfp_env.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Linux manual build (if setup is already done):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PATH=/home/jan/Documents/RFP/Finanz_App/node/current/bin:$PATH \
|
||||||
|
NPM_CONFIG_CACHE=$PWD/.npm-cache \
|
||||||
|
PLATFORMIO_CORE_DIR=$PWD/.piohome \
|
||||||
|
PLATFORMIO_PACKAGES_DIR=$PWD/.piohome/packages \
|
||||||
|
PLATFORMIO_PLATFORMS_DIR=$PWD/.piohome/platforms \
|
||||||
|
PLATFORMIO_CACHE_DIR=$PWD/.piohome/.cache \
|
||||||
|
PLATFORMIO_BUILD_CACHE_DIR=$PWD/.piohome/buildcache \
|
||||||
|
.venv/bin/python -m platformio run -e rfp_esp32s3_wroom1_n16r8_3x106
|
||||||
|
```
|
||||||
|
|
||||||
|
Discover all WLED nodes in the local network and save host list:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
.venv/bin/python tools/rfp_network_flash.py discover
|
||||||
|
```
|
||||||
|
|
||||||
|
Flash all discovered nodes sequentially over OTA:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
.venv/bin/python tools/rfp_network_flash.py flash \
|
||||||
|
--firmware build_output/release/WLEDMM_14.7.2-mdev_RFP_N16R8_NODE3x106_V20260511E.bin \
|
||||||
|
--targets-file tools/discovered_wled_hosts.txt \
|
||||||
|
--expect-release RFP_N16R8_NODE3x106_V20260511E \
|
||||||
|
--skip-validation
|
||||||
|
```
|
||||||
|
|
||||||
|
Alternative: discover and flash in one command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
.venv/bin/python tools/rfp_network_flash.py flash \
|
||||||
|
--discover \
|
||||||
|
--firmware build_output/release/WLEDMM_14.7.2-mdev_RFP_N16R8_NODE3x106_V20260511E.bin \
|
||||||
|
--expect-release RFP_N16R8_NODE3x106_V20260511E \
|
||||||
|
--skip-validation
|
||||||
|
```
|
||||||
|
|
||||||
Local Wi-Fi defaults:
|
Local Wi-Fi defaults:
|
||||||
|
|
||||||
- Keep SSID and password in the ignored file `wled00/my_config.h`.
|
- SSID `RFPLicht` and the RFP password are compiled into the tracked RFP node target.
|
||||||
- If the file does not exist yet, create it with your local values:
|
- A full `erase_flash` removes saved runtime settings, but the firmware defaults can still join the show Wi-Fi.
|
||||||
|
|
||||||
```cpp
|
|
||||||
#pragma once
|
|
||||||
|
|
||||||
#define CLIENT_SSID "your-ssid"
|
|
||||||
#define CLIENT_PASS "your-password"
|
|
||||||
```
|
|
||||||
|
|
||||||
Important:
|
Important:
|
||||||
|
|
||||||
|
|||||||
95
docs/rfp-infinity-controller-plan.md
Normal file
95
docs/rfp-infinity-controller-plan.md
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
# Infinity Controller auf WLED-MM-Basis
|
||||||
|
|
||||||
|
## Zielbild
|
||||||
|
|
||||||
|
Die Installation wird als dedizierter Master-ESP plus sechs WLED-MM Nodes betrieben. Die sechs Nodes rendern lokal auf je drei Ausgaengen mit je 106 LEDs. Der Master empfaengt Web-UI und DMX/grandMA-Steuerung und verteilt nur synchronisierte Szenenparameter, keine laufenden Pixelstreams.
|
||||||
|
|
||||||
|
## Bestehende Basis
|
||||||
|
|
||||||
|
- Node-Firmware-Target: `rfp_esp32s3_wroom1_n16r8_3x106`
|
||||||
|
- Node-Ausgaenge: `GPIO4`, `GPIO5`, `GPIO6`
|
||||||
|
- Pixel pro Ausgang: `106`, `106`, `106`
|
||||||
|
- Logische Node-Segmente: `top = 0-105`, `middle = 106-211`, `bottom = 212-317`
|
||||||
|
- DDP bleibt Debug/Fallback und wird nicht als Show-Sync verwendet.
|
||||||
|
|
||||||
|
## Implementierungsplan
|
||||||
|
|
||||||
|
- Node-Target beibehalten und um Infinity-Node-Rolle erweitern.
|
||||||
|
- Neues Master-Target `rfp_esp32s3_wroom1_n16r8_master` anlegen.
|
||||||
|
- Master-IP-Default als `192.168.178.10` dokumentieren und im Infinity-Modul verwenden.
|
||||||
|
- Node-IP-Defaults als `192.168.178.11` bis `192.168.178.16` verwenden.
|
||||||
|
- Physische DMX-Eingabe am Master ueber `WLED_ENABLE_DMX_INPUT` aktivieren.
|
||||||
|
- DMX-Defaultpins: `RX=16`, `TX=17`, `EN=18`, `UART=2`.
|
||||||
|
- Eigenes UDP-Protokoll `Infinity Sync v1` getrennt von WLED-Notifier und DDP implementieren.
|
||||||
|
- Web-UI unter `/infinity` und JSON API unter `/json/infinity` bereitstellen.
|
||||||
|
- DMX-Modus `DMX_MODE_INFINITY` mit 32 Kanaelen einbauen.
|
||||||
|
|
||||||
|
## Sync-Modell
|
||||||
|
|
||||||
|
Der Master sendet `ClockSync`, `SceneState` und `BeatTrigger` per UDP-Unicast. Nodes senden `NodeStatus` zurueck. Nodes rendern lokal gegen `master_time_us`, dadurch bestimmt nicht die Paketankunft den sichtbaren Frame.
|
||||||
|
|
||||||
|
SceneState enthaelt:
|
||||||
|
|
||||||
|
- Effekt-ID
|
||||||
|
- Preset-ID
|
||||||
|
- Brightness
|
||||||
|
- Speed
|
||||||
|
- Intensity
|
||||||
|
- Palette
|
||||||
|
- Primaer-/Sekundaer-/Tertiaerfarbe
|
||||||
|
- Group-Mask
|
||||||
|
- Direction
|
||||||
|
- Seed
|
||||||
|
- Phase
|
||||||
|
- Transition
|
||||||
|
- `apply_at_us`
|
||||||
|
|
||||||
|
## DMX/grandMA Personality v1
|
||||||
|
|
||||||
|
| Kanal | Funktion |
|
||||||
|
| ---: | --- |
|
||||||
|
| 1 | Dimmer |
|
||||||
|
| 2 | Enable / Blackout |
|
||||||
|
| 3 | Preset |
|
||||||
|
| 4 | Effect |
|
||||||
|
| 5 | Speed |
|
||||||
|
| 6 | Intensity |
|
||||||
|
| 7 | Palette |
|
||||||
|
| 8 | Hue |
|
||||||
|
| 9 | Saturation |
|
||||||
|
| 10 | Value |
|
||||||
|
| 11 | Direction / Flags |
|
||||||
|
| 12 | Transition |
|
||||||
|
| 13 | Group Mask |
|
||||||
|
| 14 | Top Dimmer |
|
||||||
|
| 15 | Middle Dimmer |
|
||||||
|
| 16 | Bottom Dimmer |
|
||||||
|
| 17 | Beat Trigger |
|
||||||
|
| 18 | Sync Reset |
|
||||||
|
| 19 | Custom 1 |
|
||||||
|
| 20 | Custom 2 |
|
||||||
|
| 21 | Custom 3 |
|
||||||
|
| 22 | Seed |
|
||||||
|
| 23 | Reserved |
|
||||||
|
| 24 | Safety / Fade |
|
||||||
|
| 25-32 | Reserved |
|
||||||
|
|
||||||
|
## Testplan
|
||||||
|
|
||||||
|
- Node-Target bauen.
|
||||||
|
- Master-Target bauen.
|
||||||
|
- Fresh-Config: drei Busse mit je 106 LEDs pruefen.
|
||||||
|
- Segment-Test: Top, Middle, Bottom einzeln blinken lassen.
|
||||||
|
- Sync-Test: zwei Nodes, danach sechs Nodes mit gleichem `apply_at_us`.
|
||||||
|
- DMX-Test: physisches DMX steuert Master-State und Nodes folgen ohne DDP.
|
||||||
|
- Reconnect-Test: Node rebootet und uebernimmt aktuellen SceneState.
|
||||||
|
- Failsafe-Test: Master weg, DMX weg, Netzwerk weg, Paketverlust.
|
||||||
|
- Show-Test: mindestens 8 Stunden Dauerbetrieb.
|
||||||
|
|
||||||
|
## Annahmen
|
||||||
|
|
||||||
|
- Ein siebter ESP32-S3 wird als Master verwendet.
|
||||||
|
- Node-Reihenfolge bleibt `node-01` bis `node-06`.
|
||||||
|
- Node-IPs bleiben `.11` bis `.16` im Netz `192.168.178.0/24`.
|
||||||
|
- LED-Reihenfolge bleibt `top/GPIO4`, `middle/GPIO5`, `bottom/GPIO6`.
|
||||||
|
- Lokale bestehende Aenderungen im WLED-MM-Repo werden nicht zurueckgesetzt.
|
||||||
84
docs/rfp-local-visualizer.md
Normal file
84
docs/rfp-local-visualizer.md
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
# RFP Local Infinity Visualizer
|
||||||
|
|
||||||
|
This visualizer runs on the laptop and uses the master only as a JSON source.
|
||||||
|
It is the preferred development tool when you only have the master controller
|
||||||
|
available and want to simulate the six render nodes.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
The visualizer mirrors the physical installation:
|
||||||
|
|
||||||
|
- `6` columns are the six ESP nodes, left to right: `ESP1` to `ESP6`
|
||||||
|
- `3` rows are the three outputs per ESP
|
||||||
|
- top row: `UART6`
|
||||||
|
- middle row: `UART5`
|
||||||
|
- bottom row: `UART4`
|
||||||
|
- each output is drawn as one square frame with `106` LEDs
|
||||||
|
- LED order per frame follows the drawing: top edge left-to-right, right edge
|
||||||
|
top-to-bottom, bottom edge right-to-left, left edge bottom-to-top
|
||||||
|
- edge split is `25` LEDs on top, `27` LEDs right, `27` LEDs bottom,
|
||||||
|
`27` LEDs left
|
||||||
|
|
||||||
|
## Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/jan/Documents/RFP/WLED-MM/repo
|
||||||
|
|
||||||
|
.venv/bin/python tools/infinity_visualizer_server.py \
|
||||||
|
--master 10.42.0.213 \
|
||||||
|
--port 8765
|
||||||
|
```
|
||||||
|
|
||||||
|
Open the URL printed by the tool, normally:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://127.0.0.1:8765/?master=10.42.0.213
|
||||||
|
```
|
||||||
|
|
||||||
|
If port `8765` is already in use, the tool automatically tries the next free
|
||||||
|
port and prints that URL instead, for example:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Port 8765 is busy, using 8766 instead.
|
||||||
|
Infinity visualizer: http://127.0.0.1:8766/?master=10.42.0.213
|
||||||
|
```
|
||||||
|
|
||||||
|
## Why Local
|
||||||
|
|
||||||
|
The ESP32 master webserver should stay focused on WLED and Infinity control.
|
||||||
|
The local visualizer avoids loading extra HTML, JavaScript, canvas rendering,
|
||||||
|
or polling logic from the ESP itself.
|
||||||
|
|
||||||
|
The local server proxies:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://10.42.0.213/json/infinity
|
||||||
|
```
|
||||||
|
|
||||||
|
to:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://127.0.0.1:8765/api/infinity
|
||||||
|
```
|
||||||
|
|
||||||
|
This also avoids browser CORS issues.
|
||||||
|
|
||||||
|
## Controls
|
||||||
|
|
||||||
|
- `Master IP`: change the master target without restarting the server
|
||||||
|
- `Connect`: fetch state immediately
|
||||||
|
- `Pause`: freeze polling and animation
|
||||||
|
- `Master UI`: open the master `/infinity` page
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- This is not yet a bit-exact WLED renderer. It reads `/json/eff` from the
|
||||||
|
master and maps effect names to browser preview classes.
|
||||||
|
- Currently supported preview classes include `Solid`, `Blink`, `Strobe`,
|
||||||
|
`Breathe/Fade`, `Wipe`, `Scan`, `Chase/Running/Theater`, `Rainbow/Colorloop`,
|
||||||
|
`Fire`, `Twinkle/Sparkle/Glitter`, and `Noise/Plasma/Waves/Ripple`.
|
||||||
|
- Unknown effects are shown as a generic moving color blend so they remain
|
||||||
|
visible while we decide which effects need exact custom previews.
|
||||||
|
- It is a lightweight development preview for scene state, colors, row dimmers,
|
||||||
|
effect IDs, brightness, and virtual node layout.
|
||||||
|
- The simulated layout is six nodes, each with three rows and 106 LEDs.
|
||||||
474
docs/rfp-node-flashing.md
Normal file
474
docs/rfp-node-flashing.md
Normal file
@@ -0,0 +1,474 @@
|
|||||||
|
# RFP Infinity Flashing
|
||||||
|
|
||||||
|
This document covers the two production firmware targets for the Infinity installation.
|
||||||
|
There is intentionally no separate `coldboot` firmware anymore: the reliable N16R8
|
||||||
|
boot settings are part of the normal master and node targets.
|
||||||
|
|
||||||
|
## Targets
|
||||||
|
|
||||||
|
- Master target: `rfp_esp32s3_wroom1_n16r8_master`
|
||||||
|
- Node target: `rfp_esp32s3_wroom1_n16r8_3x106`
|
||||||
|
|
||||||
|
## Firmware Release Names
|
||||||
|
|
||||||
|
These names are compiled into the firmware as `WLED_RELEASE_NAME` and show up in
|
||||||
|
WLED update metadata, OTA validation, serial boot logs, and `/json/info`.
|
||||||
|
|
||||||
|
- Master: `RFP_N16R8_MASTER_V20260511E`
|
||||||
|
- Node: `RFP_N16R8_NODE3x106_V20260511E`
|
||||||
|
|
||||||
|
Both targets use the same robust ESP32-S3-WROOM-1 N16R8 boot configuration:
|
||||||
|
|
||||||
|
- Flash: QIO / 16 MB
|
||||||
|
- Flash frequency: 80 MHz
|
||||||
|
- PSRAM: OPI / 8 MB octal
|
||||||
|
- Partition table: `tools/WLED_ESP32_16MB.csv`
|
||||||
|
- USB CDC on boot: disabled
|
||||||
|
- Boot delay: `WLED_BOOTUPDELAY=1200`
|
||||||
|
|
||||||
|
## Clean-Flash Warning
|
||||||
|
|
||||||
|
`erase_flash` removes the complete flash contents, including WLED's saved
|
||||||
|
`cfg.json`, Wi-Fi credentials, static IP settings, presets, and filesystem data.
|
||||||
|
|
||||||
|
The RFP master and node targets compile the show Wi-Fi as firmware defaults:
|
||||||
|
|
||||||
|
- SSID: `RFPLicht`
|
||||||
|
- Password: configured in the RFP build flags
|
||||||
|
|
||||||
|
After a full erase the board can therefore join Wi-Fi again, but runtime-only
|
||||||
|
settings that were stored through the WLED UI must be re-applied unless they are
|
||||||
|
also encoded as firmware defaults.
|
||||||
|
|
||||||
|
## Roles
|
||||||
|
|
||||||
|
- Master:
|
||||||
|
- Usually `192.168.178.10`
|
||||||
|
- Runs the `/infinity` web UI
|
||||||
|
- Accepts DMX and web commands
|
||||||
|
- Sends Infinity Sync packets to the nodes
|
||||||
|
- Keeps one dummy WLED pixel on `GPIO21` so the regular WLED UI remains valid
|
||||||
|
- Keeps the real WLED status pixel exclusively on `GPIO48`
|
||||||
|
- Nodes:
|
||||||
|
- Usually `192.168.178.11` to `192.168.178.16`
|
||||||
|
- Render the LED output locally
|
||||||
|
- Receive Infinity Sync from the master
|
||||||
|
- Use three LED outputs: `GPIO4/5/6`, each with `106` LEDs
|
||||||
|
|
||||||
|
## Build: Master
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/jan/Documents/RFP/WLED-MM/repo
|
||||||
|
|
||||||
|
PATH=/home/jan/Documents/RFP/Finanz_App/node/current/bin:$PATH \
|
||||||
|
NPM_CONFIG_CACHE=$PWD/.npm-cache \
|
||||||
|
PLATFORMIO_CORE_DIR=$PWD/.piohome \
|
||||||
|
PLATFORMIO_PACKAGES_DIR=$PWD/.piohome/packages \
|
||||||
|
PLATFORMIO_PLATFORMS_DIR=$PWD/.piohome/platforms \
|
||||||
|
PLATFORMIO_CACHE_DIR=$PWD/.piohome/.cache \
|
||||||
|
PLATFORMIO_BUILD_CACHE_DIR=$PWD/.piohome/buildcache \
|
||||||
|
.venv/bin/python -m platformio run -e rfp_esp32s3_wroom1_n16r8_master
|
||||||
|
```
|
||||||
|
|
||||||
|
Master firmware output:
|
||||||
|
|
||||||
|
```text
|
||||||
|
.pio/build/rfp_esp32s3_wroom1_n16r8_master/firmware.bin
|
||||||
|
build_output/release/WLEDMM_14.7.2-mdev_RFP_N16R8_MASTER_V20260511E.bin
|
||||||
|
```
|
||||||
|
|
||||||
|
## Build: Nodes
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/jan/Documents/RFP/WLED-MM/repo
|
||||||
|
|
||||||
|
PATH=/home/jan/Documents/RFP/Finanz_App/node/current/bin:$PATH \
|
||||||
|
NPM_CONFIG_CACHE=$PWD/.npm-cache \
|
||||||
|
PLATFORMIO_CORE_DIR=$PWD/.piohome \
|
||||||
|
PLATFORMIO_PACKAGES_DIR=$PWD/.piohome/packages \
|
||||||
|
PLATFORMIO_PLATFORMS_DIR=$PWD/.piohome/platforms \
|
||||||
|
PLATFORMIO_CACHE_DIR=$PWD/.piohome/.cache \
|
||||||
|
PLATFORMIO_BUILD_CACHE_DIR=$PWD/.piohome/buildcache \
|
||||||
|
.venv/bin/python -m platformio run -e rfp_esp32s3_wroom1_n16r8_3x106
|
||||||
|
```
|
||||||
|
|
||||||
|
Node firmware output:
|
||||||
|
|
||||||
|
```text
|
||||||
|
.pio/build/rfp_esp32s3_wroom1_n16r8_3x106/firmware.bin
|
||||||
|
build_output/release/WLEDMM_14.7.2-mdev_RFP_N16R8_NODE3x106_V20260511E.bin
|
||||||
|
```
|
||||||
|
|
||||||
|
## Recommended Update: Master USB First, Nodes Via Master
|
||||||
|
|
||||||
|
Use this for normal show updates. The laptop only needs USB access to the
|
||||||
|
master; it does not need to join the RFP Wi-Fi. The workflow is:
|
||||||
|
|
||||||
|
1. Build master and node firmware locally.
|
||||||
|
2. Flash the master app by USB.
|
||||||
|
3. Hard-reset the master and verify its RFP release over the serial relay.
|
||||||
|
4. Stream node OTA updates through the master to nodes `.11` to `.16`.
|
||||||
|
|
||||||
|
The master USB flash deliberately does **not** run `erase_flash` and does
|
||||||
|
**not** run `uploadfs`. This avoids rewriting the large LittleFS area during a
|
||||||
|
normal firmware update and keeps runtime config/filesystem data intact.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/jan/Documents/RFP/WLED-MM/repo
|
||||||
|
.venv/bin/python tools/rfp_update_master_usb_then_nodes.py --port /dev/ttyACM0
|
||||||
|
```
|
||||||
|
|
||||||
|
Use existing build artifacts without rebuilding:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/jan/Documents/RFP/WLED-MM/repo
|
||||||
|
.venv/bin/python tools/rfp_update_master_usb_then_nodes.py --port /dev/ttyACM0 --no-build
|
||||||
|
```
|
||||||
|
|
||||||
|
Resume node updates after a failure:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/jan/Documents/RFP/WLED-MM/repo
|
||||||
|
.venv/bin/python tools/rfp_update_master_usb_then_nodes.py --port /dev/ttyACM0 --no-build --nodes-only --start-from 192.168.178.14
|
||||||
|
```
|
||||||
|
|
||||||
|
Flash only the master with the same safe USB app-flash path:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/jan/Documents/RFP/WLED-MM/repo
|
||||||
|
./flash_master.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
`flash_master.sh` is now intentionally an app/bootloader/partition update only:
|
||||||
|
no full erase and no filesystem upload. Use a full clean flash only when the
|
||||||
|
partition table or filesystem must be deliberately reset.
|
||||||
|
|
||||||
|
## USB App Flash: Master
|
||||||
|
|
||||||
|
Recommended helper:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/jan/Documents/RFP/WLED-MM/repo
|
||||||
|
./flash_master.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Manual flow:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/jan/Documents/RFP/WLED-MM/repo
|
||||||
|
ls /dev/ttyACM* /dev/ttyUSB* 2>/dev/null
|
||||||
|
|
||||||
|
PORT=/dev/ttyACM0
|
||||||
|
ENV=rfp_esp32s3_wroom1_n16r8_master
|
||||||
|
|
||||||
|
PATH=/home/jan/Documents/RFP/Finanz_App/node/current/bin:$PATH \
|
||||||
|
NPM_CONFIG_CACHE=$PWD/.npm-cache \
|
||||||
|
PLATFORMIO_CORE_DIR=$PWD/.piohome \
|
||||||
|
PLATFORMIO_PACKAGES_DIR=$PWD/.piohome/packages \
|
||||||
|
PLATFORMIO_PLATFORMS_DIR=$PWD/.piohome/platforms \
|
||||||
|
PLATFORMIO_CACHE_DIR=$PWD/.piohome/.cache \
|
||||||
|
PLATFORMIO_BUILD_CACHE_DIR=$PWD/.piohome/buildcache \
|
||||||
|
.venv/bin/python -m platformio run -e "$ENV"
|
||||||
|
|
||||||
|
.venv/bin/python .piohome/packages/tool-esptoolpy/esptool.py \
|
||||||
|
--chip esp32s3 \
|
||||||
|
--port "$PORT" \
|
||||||
|
--baud 460800 \
|
||||||
|
--before default_reset \
|
||||||
|
--after hard_reset \
|
||||||
|
write_flash -z \
|
||||||
|
--flash_mode qio \
|
||||||
|
--flash_freq 80m \
|
||||||
|
--flash_size 16MB \
|
||||||
|
0x0 .pio/build/rfp_esp32s3_wroom1_n16r8_master/bootloader.bin \
|
||||||
|
0x8000 .pio/build/rfp_esp32s3_wroom1_n16r8_master/partitions.bin \
|
||||||
|
0xe000 .piohome/packages/framework-arduinoespressif32/tools/partitions/boot_app0.bin \
|
||||||
|
0x10000 build_output/release/WLEDMM_14.7.2-mdev_RFP_N16R8_MASTER_V20260511E.bin
|
||||||
|
```
|
||||||
|
|
||||||
|
If upload does not start immediately:
|
||||||
|
|
||||||
|
1. Hold `BOOT`
|
||||||
|
2. Tap `RESET`
|
||||||
|
3. Release `BOOT`
|
||||||
|
4. Run the upload again
|
||||||
|
|
||||||
|
## USB Clean Flash: Node
|
||||||
|
|
||||||
|
Recommended helper:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/jan/Documents/RFP/WLED-MM/repo
|
||||||
|
./flash_node.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Manual flow:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/jan/Documents/RFP/WLED-MM/repo
|
||||||
|
ls /dev/ttyACM* /dev/ttyUSB* 2>/dev/null
|
||||||
|
|
||||||
|
PORT=/dev/ttyACM0
|
||||||
|
ENV=rfp_esp32s3_wroom1_n16r8_3x106
|
||||||
|
|
||||||
|
.venv/bin/python .piohome/packages/tool-esptoolpy/esptool.py \
|
||||||
|
--chip esp32s3 \
|
||||||
|
--port "$PORT" \
|
||||||
|
erase_flash
|
||||||
|
|
||||||
|
PATH=/home/jan/Documents/RFP/Finanz_App/node/current/bin:$PATH \
|
||||||
|
NPM_CONFIG_CACHE=$PWD/.npm-cache \
|
||||||
|
PLATFORMIO_CORE_DIR=$PWD/.piohome \
|
||||||
|
PLATFORMIO_PACKAGES_DIR=$PWD/.piohome/packages \
|
||||||
|
PLATFORMIO_PLATFORMS_DIR=$PWD/.piohome/platforms \
|
||||||
|
PLATFORMIO_CACHE_DIR=$PWD/.piohome/.cache \
|
||||||
|
PLATFORMIO_BUILD_CACHE_DIR=$PWD/.piohome/buildcache \
|
||||||
|
.venv/bin/python -m platformio run -e "$ENV" -t clean
|
||||||
|
|
||||||
|
PATH=/home/jan/Documents/RFP/Finanz_App/node/current/bin:$PATH \
|
||||||
|
NPM_CONFIG_CACHE=$PWD/.npm-cache \
|
||||||
|
PLATFORMIO_CORE_DIR=$PWD/.piohome \
|
||||||
|
PLATFORMIO_PACKAGES_DIR=$PWD/.piohome/packages \
|
||||||
|
PLATFORMIO_PLATFORMS_DIR=$PWD/.piohome/platforms \
|
||||||
|
PLATFORMIO_CACHE_DIR=$PWD/.piohome/.cache \
|
||||||
|
PLATFORMIO_BUILD_CACHE_DIR=$PWD/.piohome/buildcache \
|
||||||
|
.venv/bin/python -m platformio run -e "$ENV" -t upload --upload-port "$PORT"
|
||||||
|
```
|
||||||
|
|
||||||
|
## OTA Preflight
|
||||||
|
|
||||||
|
Check whether the current devices look OTA-updatable before flashing:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/jan/Documents/RFP/WLED-MM/repo
|
||||||
|
.venv/bin/python tools/rfp_update_all_ota.py --preflight-only --no-build
|
||||||
|
```
|
||||||
|
|
||||||
|
Single node:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/jan/Documents/RFP/WLED-MM/repo
|
||||||
|
.venv/bin/python tools/rfp_network_flash.py flash \
|
||||||
|
--targets 192.168.178.11 \
|
||||||
|
--firmware .pio/build/rfp_esp32s3_wroom1_n16r8_3x106/firmware.bin \
|
||||||
|
--expect-release RFP_N16R8_NODE3x106_V20260511E \
|
||||||
|
--preflight-only
|
||||||
|
```
|
||||||
|
|
||||||
|
## OTA Update: Whole Installation From RFP Wi-Fi
|
||||||
|
|
||||||
|
This builds locally and flashes nodes `.11` to `.16` first, then the master `.10`.
|
||||||
|
The master is flashed last so the show controller stays available while the nodes
|
||||||
|
update.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/jan/Documents/RFP/WLED-MM/repo
|
||||||
|
.venv/bin/python tools/rfp_update_all_ota.py
|
||||||
|
```
|
||||||
|
|
||||||
|
If your PC is not connected to the RFP Wi-Fi and the master is already on the
|
||||||
|
right release, keep the master connected by USB and let the master relay node
|
||||||
|
OTA updates through its own Wi-Fi connection:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/jan/Documents/RFP/WLED-MM/repo
|
||||||
|
.venv/bin/python tools/rfp_update_all_ota.py --via-master-usb --port /dev/ttyACM0 --no-build
|
||||||
|
```
|
||||||
|
|
||||||
|
This node-only relay is still useful for resumes and tests. For full updates,
|
||||||
|
prefer `tools/rfp_update_master_usb_then_nodes.py` so the master is flashed and
|
||||||
|
verified first. The relay streams the node firmware through the master and does
|
||||||
|
not store the full file on the master filesystem. The default relay baud rate is
|
||||||
|
`921600` for faster node updates; opening `/dev/ttyACM0` can reset the ESP32-S3,
|
||||||
|
so the tool waits briefly before sending commands. If a flaky USB cable or hub
|
||||||
|
causes serial errors, retry with `--relay-baud 115200`.
|
||||||
|
|
||||||
|
The updater prefers the named release binaries from `build_output/release/` when
|
||||||
|
they exist and verifies the expected RFP release name after reboot:
|
||||||
|
|
||||||
|
- Nodes: `RFP_N16R8_NODE3x106_V20260511E`
|
||||||
|
- Master: `RFP_N16R8_MASTER_V20260511E`
|
||||||
|
|
||||||
|
During the controlled migration from older generic WLED-MM builds, the updater
|
||||||
|
sends WLED's `skipValidation=1` upload parameter by default. The update is still
|
||||||
|
accepted only if the target reboots and then reports the expected RFP release
|
||||||
|
name in `/json/info`.
|
||||||
|
|
||||||
|
If you explicitly want WLED's release-name validation to block mismatches before
|
||||||
|
upload, use:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/jan/Documents/RFP/WLED-MM/repo
|
||||||
|
.venv/bin/python tools/rfp_update_all_ota.py --no-skip-validation
|
||||||
|
```
|
||||||
|
|
||||||
|
Use existing build artifacts without rebuilding:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/jan/Documents/RFP/WLED-MM/repo
|
||||||
|
.venv/bin/python tools/rfp_update_all_ota.py --no-build
|
||||||
|
```
|
||||||
|
|
||||||
|
Devices that already report the selected RFP release are skipped by default.
|
||||||
|
This avoids reflashing the same binary and then trying to prove an update from
|
||||||
|
an unchanged release string. To force reflashing the same release anyway:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/jan/Documents/RFP/WLED-MM/repo
|
||||||
|
.venv/bin/python tools/rfp_update_all_ota.py --no-build --force-current-release
|
||||||
|
```
|
||||||
|
|
||||||
|
Resume from a failed device:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/jan/Documents/RFP/WLED-MM/repo
|
||||||
|
.venv/bin/python tools/rfp_update_all_ota.py --no-build --start-from 192.168.178.14
|
||||||
|
```
|
||||||
|
|
||||||
|
## OTA Update: Single Device
|
||||||
|
|
||||||
|
Master:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/jan/Documents/RFP/WLED-MM/repo
|
||||||
|
.venv/bin/python tools/rfp_network_flash.py flash \
|
||||||
|
--targets 192.168.178.10 \
|
||||||
|
--firmware build_output/release/WLEDMM_14.7.2-mdev_RFP_N16R8_MASTER_V20260511E.bin \
|
||||||
|
--expect-release RFP_N16R8_MASTER_V20260511E \
|
||||||
|
--skip-validation
|
||||||
|
```
|
||||||
|
|
||||||
|
Node:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/jan/Documents/RFP/WLED-MM/repo
|
||||||
|
.venv/bin/python tools/rfp_network_flash.py flash \
|
||||||
|
--targets 192.168.178.11 \
|
||||||
|
--firmware build_output/release/WLEDMM_14.7.2-mdev_RFP_N16R8_NODE3x106_V20260511E.bin \
|
||||||
|
--expect-release RFP_N16R8_NODE3x106_V20260511E \
|
||||||
|
--skip-validation
|
||||||
|
```
|
||||||
|
|
||||||
|
## Why OTA Can Fail After Build Success
|
||||||
|
|
||||||
|
OTA updates only the application image. It does not reliably replace bootloader,
|
||||||
|
flash mode, or partition table. If a board still has an old partition layout, do
|
||||||
|
one USB clean flash with the production target first. After that, OTA should be
|
||||||
|
usable again.
|
||||||
|
|
||||||
|
The OTA helper is intentionally strict: upload is considered successful if the
|
||||||
|
device reboot is proven by an offline transition, an uptime reset, or a WLED
|
||||||
|
transport reset during upload plus the expected RFP release name in `/json/info`.
|
||||||
|
When flashing the exact same release again, the transport-reset proof is weaker,
|
||||||
|
so the whole-installation updater skips already matching devices by default.
|
||||||
|
|
||||||
|
## Global 2D Timing Check
|
||||||
|
|
||||||
|
The `/infinity` scene controls intentionally expose only one brightness control.
|
||||||
|
It sits next to `Master Speed`, starts at `100%`, and the old per-row dimmers
|
||||||
|
are forced to full output by the controller UI.
|
||||||
|
|
||||||
|
Global 2D animations derive their beat position from Infinity master time, not
|
||||||
|
from frame arrival time on each node. `Master Speed` is BPM, with one beat equal
|
||||||
|
to one visible 2D step. For example, `Checkerd` at `240 BPM` changes pattern
|
||||||
|
`240` times per minute.
|
||||||
|
|
||||||
|
Use `Strobe` in the `/infinity` Global 2D mode selector to check sync visually.
|
||||||
|
Its `Pulse Width` control is the normal `Size` control renamed for this mode.
|
||||||
|
The strobe is rendered from the shared beat phase, so all nodes should flash
|
||||||
|
in parallel at every BPM value from `20` to `240`.
|
||||||
|
|
||||||
|
## Master Crash Capture
|
||||||
|
|
||||||
|
If the master ever restarts unexpectedly, capture the next run with a serial log.
|
||||||
|
This keeps the production partition layout unchanged while still decoding ESP32
|
||||||
|
panic backtraces.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/jan/Documents/RFP/WLED-MM/repo
|
||||||
|
|
||||||
|
mkdir -p /tmp/rfp-master-crash
|
||||||
|
|
||||||
|
PATH=/home/jan/Documents/RFP/Finanz_App/node/current/bin:$PATH \
|
||||||
|
NPM_CONFIG_CACHE=$PWD/.npm-cache \
|
||||||
|
PLATFORMIO_CORE_DIR=$PWD/.piohome \
|
||||||
|
PLATFORMIO_PACKAGES_DIR=$PWD/.piohome/packages \
|
||||||
|
PLATFORMIO_PLATFORMS_DIR=$PWD/.piohome/platforms \
|
||||||
|
PLATFORMIO_CACHE_DIR=$PWD/.piohome/.cache \
|
||||||
|
PLATFORMIO_BUILD_CACHE_DIR=$PWD/.piohome/buildcache \
|
||||||
|
.venv/bin/python -m platformio device monitor \
|
||||||
|
-e rfp_esp32s3_wroom1_n16r8_master \
|
||||||
|
--port /dev/ttyACM0 \
|
||||||
|
-b 115200 \
|
||||||
|
--filter esp32_exception_decoder \
|
||||||
|
--filter log2file
|
||||||
|
```
|
||||||
|
|
||||||
|
After a crash, save:
|
||||||
|
|
||||||
|
- The monitor log from the current directory or PlatformIO log output
|
||||||
|
- The first `rst:` line after reboot
|
||||||
|
- Any `Guru Meditation`, `panic`, or decoded backtrace lines
|
||||||
|
- The current `/json/info` fields `ver`, `release`, `e32code`, and `e32text`
|
||||||
|
|
||||||
|
Do not switch to a coredump partition shortly before the event unless a repeated
|
||||||
|
crash proves that serial logs are not enough.
|
||||||
|
|
||||||
|
## WLED Backup Mode
|
||||||
|
|
||||||
|
The regular WLED UI is intentionally kept available as a fallback.
|
||||||
|
|
||||||
|
- `Show Mode: ON` means Infinity Sync is active.
|
||||||
|
- `WLED Backup: ON` means Infinity Sync is stopped and regular WLED control can be used.
|
||||||
|
|
||||||
|
Stop Infinity on the master by API:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://192.168.178.10/json/infinity \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"enabled":false}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Re-enable later:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://192.168.178.10/json/infinity \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"enabled":true}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Coldboot Diagnosis
|
||||||
|
|
||||||
|
The firmware side is now consolidated into the normal production targets. If a
|
||||||
|
board still starts only after pressing `RESET`, capture the serial log directly
|
||||||
|
after real power-on.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/jan/Documents/RFP/WLED-MM/repo
|
||||||
|
|
||||||
|
PATH=/home/jan/Documents/RFP/Finanz_App/node/current/bin:$PATH \
|
||||||
|
NPM_CONFIG_CACHE=$PWD/.npm-cache \
|
||||||
|
PLATFORMIO_CORE_DIR=$PWD/.piohome \
|
||||||
|
PLATFORMIO_PACKAGES_DIR=$PWD/.piohome/packages \
|
||||||
|
PLATFORMIO_PLATFORMS_DIR=$PWD/.piohome/platforms \
|
||||||
|
PLATFORMIO_CACHE_DIR=$PWD/.piohome/.cache \
|
||||||
|
PLATFORMIO_BUILD_CACHE_DIR=$PWD/.piohome/buildcache \
|
||||||
|
.venv/bin/python -m platformio device monitor \
|
||||||
|
-e rfp_esp32s3_wroom1_n16r8_master \
|
||||||
|
--port /dev/ttyACM0 \
|
||||||
|
-b 115200
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected directly after power-on:
|
||||||
|
|
||||||
|
```text
|
||||||
|
rst:0x1 (POWERON_RESET)
|
||||||
|
SPI_FAST_FLASH_BOOT
|
||||||
|
```
|
||||||
|
|
||||||
|
If there is no clean boot log until the reset button is pressed, inspect hardware:
|
||||||
|
|
||||||
|
- `EN` / `CHIP_PU` must not float.
|
||||||
|
- Use a proper pull-up and reset delay, e.g. `10 kOhm` pull-up plus `1 uF` from `EN` to GND.
|
||||||
|
- If the 3.3 V rail rises slowly or external loads drag it down, use a reset supervisor.
|
||||||
|
- Check strapping pins and external DMX/LED wiring for backfeeding during power-up.
|
||||||
26
flash_master.sh
Executable file
26
flash_master.sh
Executable file
@@ -0,0 +1,26 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
cd /home/jan/Documents/RFP/WLED-MM/repo
|
||||||
|
|
||||||
|
PORT="${1:-}"
|
||||||
|
if [[ -z "$PORT" ]]; then
|
||||||
|
PORT="$(ls /dev/ttyACM* /dev/ttyUSB* 2>/dev/null | head -n1 || true)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "$PORT" ]]; then
|
||||||
|
echo "FEHLER: Kein ESP32-Port gefunden."
|
||||||
|
echo "Board einstecken oder BOOT gedrueckt halten -> RESET kurz druecken -> BOOT loslassen."
|
||||||
|
echo "Dann erneut starten: ./flash_master.sh"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "=== RFP MASTER USB APP FLASH ==="
|
||||||
|
echo "Port: $PORT"
|
||||||
|
echo "Kein erase_flash, kein uploadfs: Runtime-Konfig und LittleFS bleiben unangetastet."
|
||||||
|
echo
|
||||||
|
|
||||||
|
.venv/bin/python tools/rfp_update_master_usb_then_nodes.py \
|
||||||
|
--master-only \
|
||||||
|
--port "$PORT" \
|
||||||
|
"${@:2}"
|
||||||
69
flash_node.sh
Executable file
69
flash_node.sh
Executable file
@@ -0,0 +1,69 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
cd /home/jan/Documents/RFP/WLED-MM/repo
|
||||||
|
|
||||||
|
ENV="rfp_esp32s3_wroom1_n16r8_3x106"
|
||||||
|
|
||||||
|
export PATH="/home/jan/Documents/RFP/Finanz_App/node/current/bin:$PATH"
|
||||||
|
export NPM_CONFIG_CACHE="$PWD/.npm-cache"
|
||||||
|
export PLATFORMIO_CORE_DIR="$PWD/.piohome"
|
||||||
|
export PLATFORMIO_PACKAGES_DIR="$PWD/.piohome/packages"
|
||||||
|
export PLATFORMIO_PLATFORMS_DIR="$PWD/.piohome/platforms"
|
||||||
|
export PLATFORMIO_CACHE_DIR="$PWD/.piohome/.cache"
|
||||||
|
export PLATFORMIO_BUILD_CACHE_DIR="$PWD/.piohome/buildcache"
|
||||||
|
|
||||||
|
pio() {
|
||||||
|
.venv/bin/python -m platformio "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "=== RFP NODE FLASH ==="
|
||||||
|
echo "Env: $ENV"
|
||||||
|
echo
|
||||||
|
|
||||||
|
sudo systemctl stop ModemManager 2>/dev/null || true
|
||||||
|
|
||||||
|
PORT="$(ls /dev/ttyACM* /dev/ttyUSB* 2>/dev/null | head -n1 || true)"
|
||||||
|
|
||||||
|
if [[ -z "$PORT" ]]; then
|
||||||
|
echo "FEHLER: Kein ESP32-Port gefunden."
|
||||||
|
echo "Board einstecken oder BOOT gedrueckt halten -> RESET kurz druecken -> BOOT loslassen."
|
||||||
|
echo "Dann erneut starten: ./flash_node.sh"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Port: $PORT"
|
||||||
|
echo
|
||||||
|
|
||||||
|
echo "1/5 Flash komplett loeschen..."
|
||||||
|
.venv/bin/python .piohome/packages/tool-esptoolpy/esptool.py \
|
||||||
|
--chip esp32s3 \
|
||||||
|
--port "$PORT" \
|
||||||
|
erase_flash
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "2/5 Clean..."
|
||||||
|
pio run -e "$ENV" -t clean
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "3/5 Firmware bauen..."
|
||||||
|
pio run -e "$ENV"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "4/5 Firmware flashen..."
|
||||||
|
pio run -e "$ENV" -t upload --upload-port "$PORT"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "5/5 Filesystem/LittleFS flashen..."
|
||||||
|
if ! pio run -e "$ENV" -t uploadfs --upload-port "$PORT"; then
|
||||||
|
echo
|
||||||
|
echo "WARNUNG: uploadfs fehlgeschlagen oder nicht konfiguriert."
|
||||||
|
echo "WLED kann trotzdem starten, aber Web-/Filesystem-Daten koennen fehlen."
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Node-Flash fertig."
|
||||||
|
echo "Starte Monitor. Mit Ctrl+C beenden."
|
||||||
|
echo
|
||||||
|
|
||||||
|
pio device monitor -e "$ENV" --port "$PORT" -b 115200
|
||||||
@@ -2441,7 +2441,14 @@ extends = env:esp32S3_8MB_PSRAM_M_opi
|
|||||||
|
|
||||||
[env:rfp_esp32s3_wroom1_n16r8_3x106]
|
[env:rfp_esp32s3_wroom1_n16r8_3x106]
|
||||||
;; RFP ESP32-S3 WROOM-1 N16R8, 16MB flash / 8MB OPI PSRAM, 3 outputs x 106 pixels
|
;; RFP ESP32-S3 WROOM-1 N16R8, 16MB flash / 8MB OPI PSRAM, 3 outputs x 106 pixels
|
||||||
|
;; Flash: QIO / 16MB, PSRAM: OPI / 8MB octal.
|
||||||
|
;; USB CDC on boot is inherited disabled for stable standalone boot after real power-on.
|
||||||
extends = env:esp32S3_8MB_PSRAM_M_opi
|
extends = env:esp32S3_8MB_PSRAM_M_opi
|
||||||
|
board_build.f_cpu = 240000000L
|
||||||
|
board_build.f_flash = 80000000L
|
||||||
|
board_build.flash_mode = qio
|
||||||
|
board_build.arduino.memory_type = qio_opi
|
||||||
|
board_build.psram_type = opi
|
||||||
board_upload.flash_size = 16MB
|
board_upload.flash_size = 16MB
|
||||||
board_upload.maximum_size = 16777216
|
board_upload.maximum_size = 16777216
|
||||||
board_build.partitions = tools/WLED_ESP32_16MB.csv
|
board_build.partitions = tools/WLED_ESP32_16MB.csv
|
||||||
@@ -2453,7 +2460,14 @@ build_unflags = ${env:esp32S3_8MB_PSRAM_M_opi.build_unflags}
|
|||||||
-D IRPIN=-1
|
-D IRPIN=-1
|
||||||
-D AUDIOPIN=-1
|
-D AUDIOPIN=-1
|
||||||
build_flags = ${env:esp32S3_8MB_PSRAM_M_opi.build_flags}
|
build_flags = ${env:esp32S3_8MB_PSRAM_M_opi.build_flags}
|
||||||
-D WLED_RELEASE_NAME=RFP_ESP32S3_N16R8_3x106
|
-D WLED_RELEASE_NAME=RFP_N16R8_NODE3x106_V20260511E
|
||||||
|
-D CLIENT_SSID=\"RFPLicht\"
|
||||||
|
-D CLIENT_PASS=\"0506feier\"
|
||||||
|
-D WLED_ENABLE_INFINITY_CONTROLLER
|
||||||
|
-D WLED_INFINITY_NODE
|
||||||
|
-D WLEDMM_FORCE_ON_AT_BOOT
|
||||||
|
-D WLED_BOOTUPDELAY=1200
|
||||||
|
-D WLED_DISABLE_INFRARED
|
||||||
-D LEDPIN=4
|
-D LEDPIN=4
|
||||||
-D DATA_PINS=4,5,6
|
-D DATA_PINS=4,5,6
|
||||||
-D PIXEL_COUNTS=106,106,106
|
-D PIXEL_COUNTS=106,106,106
|
||||||
@@ -2465,6 +2479,54 @@ build_flags = ${env:esp32S3_8MB_PSRAM_M_opi.build_flags}
|
|||||||
-D IRPIN=-1
|
-D IRPIN=-1
|
||||||
-D AUDIOPIN=-1
|
-D AUDIOPIN=-1
|
||||||
|
|
||||||
|
[env:rfp_esp32s3_wroom1_n16r8_master]
|
||||||
|
;; RFP Infinity master controller, ESP32-S3-WROOM-1 N16R8.
|
||||||
|
;; Flash: QIO / 16MB, PSRAM: OPI / 8MB octal.
|
||||||
|
;; USB CDC on boot stays disabled for stable standalone boot after real power-on.
|
||||||
|
extends = env:esp32S3_8MB_PSRAM_M_opi
|
||||||
|
board_build.f_cpu = 240000000L
|
||||||
|
board_build.f_flash = 80000000L
|
||||||
|
board_build.flash_mode = qio
|
||||||
|
board_build.arduino.memory_type = qio_opi
|
||||||
|
board_build.psram_type = opi
|
||||||
|
board_upload.flash_size = 16MB
|
||||||
|
board_upload.maximum_size = 16777216
|
||||||
|
board_build.partitions = tools/WLED_ESP32_16MB.csv
|
||||||
|
build_unflags = ${env:esp32S3_8MB_PSRAM_M_opi.build_unflags}
|
||||||
|
-D WLED_RELEASE_NAME=esp32S3_8MB_PSRAM_M_opi
|
||||||
|
-D LEDPIN=21
|
||||||
|
-D BTNPIN=0
|
||||||
|
-D RLYPIN=1
|
||||||
|
-D IRPIN=-1
|
||||||
|
-D AUDIOPIN=-1
|
||||||
|
build_flags = ${env:esp32S3_8MB_PSRAM_M_opi.build_flags}
|
||||||
|
-D WLED_RELEASE_NAME=RFP_N16R8_MASTER_V20260511E
|
||||||
|
-D CLIENT_SSID=\"RFPLicht\"
|
||||||
|
-D CLIENT_PASS=\"0506feier\"
|
||||||
|
-D WLED_ENABLE_INFINITY_CONTROLLER
|
||||||
|
-D WLED_INFINITY_MASTER
|
||||||
|
-D WLED_ENABLE_DMX_INPUT
|
||||||
|
-D WLEDMM_FORCE_ON_AT_BOOT
|
||||||
|
-D WLED_BOOTUPDELAY=1200
|
||||||
|
-D WLED_DISABLE_INFRARED
|
||||||
|
-D LEDPIN=21
|
||||||
|
-D DATA_PINS=21
|
||||||
|
-D PIXEL_COUNTS=1
|
||||||
|
-D DEFAULT_LED_COUNT=1
|
||||||
|
-D STATUSPIXELPIN=48
|
||||||
|
-D STATUSPIXELCOLORORDER=COL_ORDER_GRB
|
||||||
|
-D BTNPIN=-1
|
||||||
|
-D RLYPIN=-1
|
||||||
|
-D IRPIN=-1
|
||||||
|
-D AUDIOPIN=-1
|
||||||
|
-D DMX_INPUT_RXPIN=16
|
||||||
|
-D DMX_INPUT_TXPIN=17
|
||||||
|
-D DMX_INPUT_ENABLEPIN=18
|
||||||
|
-D DMX_INPUT_PORT=2
|
||||||
|
-D DEFAULT_DMX_MODE=DMX_MODE_INFINITY
|
||||||
|
lib_deps = ${env:esp32S3_8MB_PSRAM_M_opi.lib_deps}
|
||||||
|
${common_mm.DMXin_lib_deps}
|
||||||
|
|
||||||
[env:esp32S3_8MB_S]
|
[env:esp32S3_8MB_S]
|
||||||
;; MM for ESP32-S3 boards - FASTPATH + optimize for speed; ; HUB75 support included (may still have pin conflicts)
|
;; MM for ESP32-S3 boards - FASTPATH + optimize for speed; ; HUB75 support included (may still have pin conflicts)
|
||||||
extends = esp32_4MB_V4_M_base
|
extends = esp32_4MB_V4_M_base
|
||||||
|
|||||||
696
tools/infinity_visualizer_server.py
Normal file
696
tools/infinity_visualizer_server.py
Normal file
@@ -0,0 +1,696 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Local Infinity visualizer for the Infinity Global-2D layer.
|
||||||
|
|
||||||
|
The browser proxies the master state and renders the Infinity Global-2D layer.
|
||||||
|
Effect-mask modes use a synthetic color preview so geometry, masks and timing
|
||||||
|
can be checked without reimplementing the full WLED effect engine.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import errno
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import socket
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
NODE_COUNT = 6
|
||||||
|
ROWS = 3
|
||||||
|
LEDS_PER_PANEL = 106
|
||||||
|
OUTPUT_LABELS = ["UART6", "UART5", "UART4"]
|
||||||
|
MODE_NAMES = ["Off", "Center Pulse", "Checkerd", "Arrow", "Scan", "Snake", "Wave Line", "Strobe", "Schlängeln", "Sunburst"]
|
||||||
|
VARIANT_NAMES = ["Expand / Classic / Line", "Reverse / Diagonal / Bands", "Outline / Checkerd", "Outline Reverse"]
|
||||||
|
SCHLAENGELN_VARIANT_NAMES = ["Top Left", "Top Right", "Bottom Left", "Bottom Right"]
|
||||||
|
SUNBURST_VARIANT_NAMES = ["Still", "Wobble", "Rotate"]
|
||||||
|
DIRECTION_NAMES = ["Left -> Right", "Right -> Left", "Top -> Bottom", "Bottom -> Top", "Outward", "Inward", "Ping Pong"]
|
||||||
|
BPM_MIN = 20
|
||||||
|
BPM_MAX = 240
|
||||||
|
PANEL_GAP_RATIO = 0.50 # 8 cm gap for roughly 16 cm active panel aperture.
|
||||||
|
|
||||||
|
# Keep in sync with wled00/infinity_sync.cpp. Values are:
|
||||||
|
# rotation quarter-turns clockwise, mirror X, mirror Y.
|
||||||
|
PANEL_TRANSFORMS: list[list[tuple[int, bool, bool]]] = [
|
||||||
|
[(0, False, False), (0, False, False), (0, False, False), (0, False, False), (0, False, False), (0, False, False)],
|
||||||
|
[(0, False, False), (0, False, False), (0, False, False), (0, False, False), (0, False, False), (0, False, False)],
|
||||||
|
[(0, False, False), (0, False, False), (0, False, False), (0, False, False), (0, False, False), (0, False, False)],
|
||||||
|
]
|
||||||
|
|
||||||
|
HTML = r"""<!doctype html>
|
||||||
|
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Infinity Local Visualizer</title>
|
||||||
|
<style>
|
||||||
|
:root{--bg:#0f1115;--panel:#181b22;--line:#303640;--text:#eef2f6;--muted:#9aa5b5;--good:#35d07f;--bad:#ff637d;--warn:#ffd166;--accent:#4da3ff}*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font:14px system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}main{max-width:1320px;margin:0 auto;padding:14px}header{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:12px}h1{margin:0;font-size:22px}.sub,.muted{color:var(--muted)}button,input{font:inherit;border:1px solid var(--line);background:#11151b;color:var(--text);border-radius:6px;padding:8px 10px}button{cursor:pointer;font-weight:650}.toolbar{display:flex;gap:8px;flex-wrap:wrap;align-items:center}.toolbar input{width:170px}.status{display:grid;grid-template-columns:repeat(6,minmax(0,1fr));gap:8px;margin-bottom:12px}.metric,.panel,.log{background:var(--panel);border:1px solid var(--line);border-radius:8px}.metric{padding:10px;min-width:0}.metric span{display:block;color:var(--muted);font-size:11px;text-transform:uppercase}.metric strong{display:block;margin-top:3px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.nodes{display:grid;grid-template-columns:repeat(6,minmax(128px,1fr));gap:12px}.panel{padding:8px;min-width:0}.panel-head{display:flex;justify-content:space-between;gap:8px;margin-bottom:6px;color:var(--muted);font-size:12px}.panel-head strong{color:var(--text)}canvas{width:100%;aspect-ratio:1/1;background:#05070b;border:1px solid #222b35;border-radius:6px;display:block}.log{margin-top:12px;padding:10px;min-height:38px}.ok{color:var(--good)}.bad{color:var(--bad)}.warn{color:var(--warn)}@media(max-width:900px){header{align-items:flex-start;flex-direction:column}.status{grid-template-columns:1fr}.nodes{grid-template-columns:repeat(3,minmax(92px,1fr));gap:8px}.toolbar,.toolbar input{width:100%}button{flex:1 1 auto}}
|
||||||
|
</style></head><body><main>
|
||||||
|
<header><div><h1>Infinity Local Visualizer</h1><div class="sub">Global-2D preview with synthetic effect colors and panel-orientation calibration.</div></div><div class="toolbar"><input id="master" aria-label="Master IP"><button id="apply">Connect</button><button id="pause">Pause</button><button id="calibrate">Calibrate</button><a id="masterLink" href="#" target="_blank"><button type="button">Master UI</button></a></div></header>
|
||||||
|
<section class="status" id="status"></section><section class="nodes" id="nodes"></section><div class="log" id="log">Starting...</div>
|
||||||
|
<script>
|
||||||
|
const NODE_COUNT=6, ROWS=3, LEDS=106, OUTPUT_LABELS=["UART6","UART5","UART4"];
|
||||||
|
const PANEL_TRANSFORMS=[[[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0]],[[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0]],[[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0]]];
|
||||||
|
let frame=null, paused=false, refreshInFlight=false;const q=id=>document.getElementById(id);const masterIp=()=>q("master").value.trim()||new URLSearchParams(location.search).get("master")||"10.42.0.213";
|
||||||
|
let calibration=new URLSearchParams(location.search).get("cal")==="1";
|
||||||
|
function ensureNodes(){if(q("nodes").children.length)return;q("nodes").innerHTML=Array.from({length:ROWS},(_,row)=>Array.from({length:NODE_COUNT},(_,node)=>`<article class="panel"><div class="panel-head"><strong>ESP${node+1} ${OUTPUT_LABELS[row]}</strong><span id="meta${node}_${row}">106 LEDs</span></div><canvas width="160" height="160" id="c${node}_${row}"></canvas></article>`).join("")).join("")}
|
||||||
|
function ledLocal(i){if(i<25)return [(i+.5)/25,0];if(i<52)return [1,(i-25+.5)/27];if(i<79)return [1-((i-52+.5)/27),1];return [0,1-((i-79+.5)/27)]}
|
||||||
|
function transformedLocal(i,row,node){let [x,y]=ledLocal(i);const t=PANEL_TRANSFORMS[row]?.[node]||[0,0,0];if(t[1])x=1-x;if(t[2])y=1-y;for(let r=0;r<(t[0]&3);r++){const ox=x;x=1-y;y=ox}return [x,y]}
|
||||||
|
function ledPos(i,row,node){const [x,y]=transformedLocal(i,row,node);return [24+x*112,24+y*112]}
|
||||||
|
function drawPanel(canvas, leds, row, node, panelInfo){const ctx=canvas.getContext("2d");ctx.clearRect(0,0,160,160);ctx.fillStyle="#05070b";ctx.fillRect(0,0,160,160);ctx.strokeStyle="#1e2732";ctx.lineWidth=2;ctx.strokeRect(23,23,114,114);for(let i=0;i<leds.length;i++){const [x,y]=ledPos(i,row,node);const c=leds[i];ctx.fillStyle=`rgb(${c[0]},${c[1]},${c[2]})`;ctx.beginPath();ctx.arc(x,y,2.1,0,Math.PI*2);ctx.fill()}if(calibration){ctx.fillStyle="#ff4d4d";let [sx,sy]=ledPos(0,row,node);ctx.beginPath();ctx.arc(sx,sy,5,0,Math.PI*2);ctx.fill();ctx.strokeStyle="#35d07f";ctx.lineWidth=3;ctx.beginPath();ctx.moveTo(sx,sy);const [ex,ey]=ledPos(12,row,node);ctx.lineTo(ex,ey);ctx.stroke();ctx.fillStyle="#eef2f6";ctx.font="12px system-ui";ctx.fillText(panelInfo?.label||"",30,46);ctx.fillText(panelInfo?.transform||"",30,62);ctx.strokeStyle="#ffd166";ctx.lineWidth=1;ctx.beginPath();ctx.moveTo(80,18);ctx.lineTo(80,142);ctx.moveTo(18,80);ctx.lineTo(142,80);ctx.stroke()}}
|
||||||
|
function render(){ensureNodes();const s=frame?.scene||{}, spatial=s.spatial||{};for(let row=0;row<ROWS;row++){for(let node=0;node<NODE_COUNT;node++){q(`meta${node}_${row}`).textContent=frame?.node_ips?.[node]||"virtual";drawPanel(q(`c${node}_${row}`),frame?.panels?.[row]?.[node]||Array.from({length:LEDS},()=>[0,0,0]),row,node,frame?.panel_info?.[row]?.[node]);}}q("status").innerHTML=[["Master",masterIp()],["2D Mode",frame?.mode_name||"Off"],["Variant",frame?.variant_name||"-"],["Direction",frame?.direction_name||"-"],["Layer",frame?.note||"2D preview"]].map(([k,v])=>`<div class="metric"><span>${k}</span><strong>${v}</strong></div>`).join("")}
|
||||||
|
async function refresh(){if(paused||refreshInFlight)return;refreshInFlight=true;const ip=masterIp();q("masterLink").href=`http://${ip}/infinity`;try{const response=await fetch(`/api/frame?master=${encodeURIComponent(ip)}`,{cache:"no-store"});if(!response.ok)throw new Error((await response.text()).replace(/[{}\"]/g,""));frame=await response.json();q("log").innerHTML=`<span class="ok">connected</span> ${new Date().toLocaleTimeString()} via ${ip}. <span class="warn">Synthetic effect preview; panel orientation uses shared calibration table.</span>`;render()}catch(error){q("log").innerHTML=`<span class="bad">master offline</span> ${ip} · ${error.message}`}finally{refreshInFlight=false}}
|
||||||
|
q("master").value=masterIp();q("apply").onclick=refresh;q("pause").onclick=()=>{paused=!paused;q("pause").textContent=paused?"Resume":"Pause"};q("calibrate").onclick=()=>{calibration=!calibration;q("calibrate").textContent=calibration?"Hide Cal":"Calibrate";render()};q("calibrate").textContent=calibration?"Hide Cal":"Calibrate";setInterval(refresh,250);refresh();
|
||||||
|
</script></main></body></html>"""
|
||||||
|
|
||||||
|
|
||||||
|
def clamp_byte(value: float) -> int:
|
||||||
|
return max(0, min(255, int(round(value))))
|
||||||
|
|
||||||
|
|
||||||
|
def smoothstep(edge0: float, edge1: float, x: float) -> float:
|
||||||
|
if edge0 == edge1:
|
||||||
|
return 0.0 if x < edge0 else 1.0
|
||||||
|
x = max(0.0, min(1.0, (x - edge0) / (edge1 - edge0)))
|
||||||
|
return x * x * (3.0 - 2.0 * x)
|
||||||
|
|
||||||
|
|
||||||
|
def speed_to_bpm(speed: int) -> int:
|
||||||
|
speed = max(0, min(255, int(speed)))
|
||||||
|
return round(BPM_MIN + speed * (BPM_MAX - BPM_MIN) / 255.0)
|
||||||
|
|
||||||
|
|
||||||
|
def spatial_beat_position(now_us: int, speed: int) -> float:
|
||||||
|
seconds = (now_us % 60_000_000) / 1_000_000.0
|
||||||
|
cps = speed_to_bpm(speed) / 60.0
|
||||||
|
return seconds * cps
|
||||||
|
|
||||||
|
|
||||||
|
def spatial_step_position(now_us: int, speed: int) -> float:
|
||||||
|
# Two visible animation phases make one measured on/off panel cycle match the BPM value.
|
||||||
|
return spatial_beat_position(now_us, speed) * 2.0
|
||||||
|
|
||||||
|
|
||||||
|
def spatial_beat_index(now_us: int, speed: int) -> int:
|
||||||
|
return int(math.floor(spatial_beat_position(now_us, speed)))
|
||||||
|
|
||||||
|
|
||||||
|
def spatial_step_index(now_us: int, speed: int) -> int:
|
||||||
|
return int(math.floor(spatial_step_position(now_us, speed)))
|
||||||
|
|
||||||
|
|
||||||
|
def spatial_beat_frac(now_us: int, speed: int) -> float:
|
||||||
|
beat = spatial_beat_position(now_us, speed)
|
||||||
|
return beat - math.floor(beat)
|
||||||
|
|
||||||
|
|
||||||
|
def strobe_amount(now_us: int, speed: int, pulse_width: int) -> int:
|
||||||
|
pulse_width = max(0, min(255, int(pulse_width)))
|
||||||
|
duty = 0.01 + (pulse_width / 255.0) * 0.34
|
||||||
|
phase = spatial_beat_position(now_us, speed) * 8.0
|
||||||
|
return 255 if (phase - math.floor(phase)) < duty else 0
|
||||||
|
|
||||||
|
|
||||||
|
def serpentine_panel_index(col: int, row: int) -> int:
|
||||||
|
return row * NODE_COUNT + (NODE_COUNT - 1 - col if row & 1 else col)
|
||||||
|
|
||||||
|
|
||||||
|
def mirrored_serpentine_panel_index(col: int, row: int, variant: int) -> int:
|
||||||
|
if variant in (1, 3):
|
||||||
|
col = NODE_COUNT - 1 - col
|
||||||
|
if variant in (2, 3):
|
||||||
|
row = ROWS - 1 - row
|
||||||
|
return serpentine_panel_index(col, row)
|
||||||
|
|
||||||
|
|
||||||
|
def chain_length_from_size(size: int) -> int:
|
||||||
|
size = max(0, min(255, int(size)))
|
||||||
|
if size <= 64:
|
||||||
|
return max(1, size // 16)
|
||||||
|
return min(18, 4 + ((size - 64) * 14 + 95) // 191)
|
||||||
|
|
||||||
|
|
||||||
|
def snake_length_from_size(size: int) -> int:
|
||||||
|
return min(17, 3 + max(1, max(0, min(255, int(size))) // 64))
|
||||||
|
|
||||||
|
|
||||||
|
def spatial_hash(value: int) -> int:
|
||||||
|
value &= 0xFFFFFFFF
|
||||||
|
value ^= value >> 16
|
||||||
|
value = (value * 0x7FEB352D) & 0xFFFFFFFF
|
||||||
|
value ^= value >> 15
|
||||||
|
value = (value * 0x846CA68B) & 0xFFFFFFFF
|
||||||
|
value ^= value >> 16
|
||||||
|
return value & 0xFFFFFFFF
|
||||||
|
|
||||||
|
|
||||||
|
def grid_panel_index(col: int, row: int) -> int:
|
||||||
|
return row * NODE_COUNT + col
|
||||||
|
|
||||||
|
|
||||||
|
def grid_col(index: int) -> int:
|
||||||
|
return index % NODE_COUNT
|
||||||
|
|
||||||
|
|
||||||
|
def grid_row(index: int) -> int:
|
||||||
|
return index // NODE_COUNT
|
||||||
|
|
||||||
|
|
||||||
|
def snake_body_contains(body: list[int], position: int, skip_tail: int = 0) -> bool:
|
||||||
|
end = max(0, len(body) - skip_tail)
|
||||||
|
return position in body[:end]
|
||||||
|
|
||||||
|
|
||||||
|
def snake_spawn_apple(body: list[int], seed: int, generation: int) -> int:
|
||||||
|
candidate = spatial_hash(seed ^ (generation * 0x9E3779B9)) % (NODE_COUNT * ROWS)
|
||||||
|
for _ in range(NODE_COUNT * ROWS):
|
||||||
|
if not snake_body_contains(body, candidate):
|
||||||
|
return candidate
|
||||||
|
candidate = (candidate + 7) % (NODE_COUNT * ROWS)
|
||||||
|
return body[0]
|
||||||
|
|
||||||
|
|
||||||
|
def snake_neighbors(position: int) -> list[tuple[int, int]]:
|
||||||
|
col, row = grid_col(position), grid_row(position)
|
||||||
|
out: list[tuple[int, int]] = []
|
||||||
|
if col + 1 < NODE_COUNT:
|
||||||
|
out.append((0, grid_panel_index(col + 1, row)))
|
||||||
|
if row + 1 < ROWS:
|
||||||
|
out.append((2, grid_panel_index(col, row + 1)))
|
||||||
|
if col > 0:
|
||||||
|
out.append((1, grid_panel_index(col - 1, row)))
|
||||||
|
if row > 0:
|
||||||
|
out.append((3, grid_panel_index(col, row - 1)))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def snake_distance(a: int, b: int) -> int:
|
||||||
|
return abs(grid_col(a) - grid_col(b)) + abs(grid_row(a) - grid_row(b))
|
||||||
|
|
||||||
|
|
||||||
|
def snake_reset(seed: int, epoch: int, target_length: int, generation: int) -> tuple[list[int], int, int]:
|
||||||
|
head = spatial_hash(seed ^ (epoch * 0x45D9F3B)) % (NODE_COUNT * ROWS)
|
||||||
|
body = [head] * min(target_length, 3)
|
||||||
|
generation += 1
|
||||||
|
apple = snake_spawn_apple(body, seed ^ epoch, generation)
|
||||||
|
return body, apple, generation
|
||||||
|
|
||||||
|
|
||||||
|
def snake_state_at(local_step: int, target_length: int, seed: int) -> tuple[list[int], int]:
|
||||||
|
body, apple, generation = snake_reset(seed, 0, target_length, 0)
|
||||||
|
base_order = [0, 2, 1, 3]
|
||||||
|
for step in range(local_step):
|
||||||
|
order_offset = spatial_hash(seed ^ (step * 0x9E3779B1) ^ generation) & 3
|
||||||
|
ordered = base_order[order_offset:] + base_order[:order_offset]
|
||||||
|
by_dir = dict(snake_neighbors(body[0]))
|
||||||
|
best = None
|
||||||
|
best_distance = 999
|
||||||
|
for direction in ordered:
|
||||||
|
if direction not in by_dir:
|
||||||
|
continue
|
||||||
|
nxt = by_dir[direction]
|
||||||
|
will_eat = nxt == apple
|
||||||
|
if snake_body_contains(body, nxt, 0 if will_eat else 1):
|
||||||
|
continue
|
||||||
|
distance = snake_distance(nxt, apple)
|
||||||
|
if distance < best_distance:
|
||||||
|
best_distance = distance
|
||||||
|
best = nxt
|
||||||
|
if best is None:
|
||||||
|
body, apple, generation = snake_reset(seed, step + 1, target_length, generation)
|
||||||
|
continue
|
||||||
|
ate = best == apple
|
||||||
|
new_len = min(target_length, len(body) + 1) if ate else len(body)
|
||||||
|
body = [best] + body[:new_len - 1]
|
||||||
|
if ate:
|
||||||
|
generation += 1
|
||||||
|
apple = snake_spawn_apple(body, seed ^ step, generation)
|
||||||
|
return body, apple
|
||||||
|
|
||||||
|
def triangle_step(step: int, max_index: int) -> int:
|
||||||
|
if max_index <= 0:
|
||||||
|
return 0
|
||||||
|
period = max_index * 2
|
||||||
|
phase = step % period
|
||||||
|
return period - phase if phase > max_index else phase
|
||||||
|
|
||||||
|
|
||||||
|
def schlaengeln_pingpong_position(step: int, offset: int, max_index: int) -> int:
|
||||||
|
if max_index <= 0:
|
||||||
|
return 0
|
||||||
|
period = max_index * 2
|
||||||
|
phase = (step + period - (offset % period)) % period
|
||||||
|
return period - phase if phase > max_index else phase
|
||||||
|
|
||||||
|
|
||||||
|
def panel_led_position(led: int) -> tuple[float, float, int]:
|
||||||
|
if led < 25:
|
||||||
|
return (led + 0.5) / 25.0, 0.0, 0
|
||||||
|
if led < 52:
|
||||||
|
return 1.0, (led - 25 + 0.5) / 27.0, 1
|
||||||
|
if led < 79:
|
||||||
|
return 1.0 - ((led - 52 + 0.5) / 27.0), 1.0, 2
|
||||||
|
return 0.0, 1.0 - ((led - 79 + 0.5) / 27.0), 3
|
||||||
|
|
||||||
|
|
||||||
|
def apply_panel_transform(col: int, row: int, x: float, y: float) -> tuple[float, float]:
|
||||||
|
try:
|
||||||
|
rotation, mirror_x, mirror_y = PANEL_TRANSFORMS[row][col]
|
||||||
|
except IndexError:
|
||||||
|
return x, y
|
||||||
|
if mirror_x:
|
||||||
|
x = 1.0 - x
|
||||||
|
if mirror_y:
|
||||||
|
y = 1.0 - y
|
||||||
|
for _ in range(rotation & 3):
|
||||||
|
x, y = 1.0 - y, x
|
||||||
|
return x, y
|
||||||
|
|
||||||
|
|
||||||
|
def physical_panel_led_position(col: int, row: int, led: int) -> tuple[float, float]:
|
||||||
|
lx, ly, _ = panel_led_position(led)
|
||||||
|
lx, ly = apply_panel_transform(col, row, lx, ly)
|
||||||
|
pitch = 1.0 + PANEL_GAP_RATIO
|
||||||
|
return col * pitch + lx, row * pitch + ly
|
||||||
|
|
||||||
|
|
||||||
|
def apply_sunburst_panel_transform(x: float, y: float) -> tuple[float, float]:
|
||||||
|
# Match firmware: Sunburst gets the observed 90-degree-left correction,
|
||||||
|
# while Scan and all other modes keep the neutral global transform.
|
||||||
|
for _ in range(3):
|
||||||
|
x, y = 1.0 - y, x
|
||||||
|
return x, y
|
||||||
|
|
||||||
|
|
||||||
|
def sunburst_panel_led_position(col: int, row: int, led: int) -> tuple[float, float]:
|
||||||
|
lx, ly, _ = panel_led_position(led)
|
||||||
|
lx, ly = apply_sunburst_panel_transform(lx, ly)
|
||||||
|
pitch = 1.0 + PANEL_GAP_RATIO
|
||||||
|
return col * pitch + lx, row * pitch + ly
|
||||||
|
|
||||||
|
|
||||||
|
def physical_panel_center() -> tuple[float, float]:
|
||||||
|
pitch = 1.0 + PANEL_GAP_RATIO
|
||||||
|
return ((NODE_COUNT - 1) * pitch + 1.0) * 0.5, ((ROWS - 1) * pitch + 1.0) * 0.5
|
||||||
|
|
||||||
|
|
||||||
|
def hsv_to_rgb(hue: float, sat: float = 1.0, val: float = 1.0) -> list[int]:
|
||||||
|
hue = hue % 1.0
|
||||||
|
sector = hue * 6.0
|
||||||
|
i = int(math.floor(sector))
|
||||||
|
f = sector - i
|
||||||
|
p = val * (1.0 - sat)
|
||||||
|
q = val * (1.0 - sat * f)
|
||||||
|
t = val * (1.0 - sat * (1.0 - f))
|
||||||
|
if i == 0:
|
||||||
|
r, g, b = val, t, p
|
||||||
|
elif i == 1:
|
||||||
|
r, g, b = q, val, p
|
||||||
|
elif i == 2:
|
||||||
|
r, g, b = p, val, t
|
||||||
|
elif i == 3:
|
||||||
|
r, g, b = p, q, val
|
||||||
|
elif i == 4:
|
||||||
|
r, g, b = t, p, val
|
||||||
|
else:
|
||||||
|
r, g, b = val, p, q
|
||||||
|
return [clamp_byte(r * 255), clamp_byte(g * 255), clamp_byte(b * 255)]
|
||||||
|
|
||||||
|
|
||||||
|
def center_pulse_amount(col: int, row: int, led: int, now_us: int, speed: int, variant: int) -> int:
|
||||||
|
distance = abs(row - 1.0) + abs(col - 2.5)
|
||||||
|
max_distance = 3.5
|
||||||
|
span = max_distance + 1.0
|
||||||
|
front = spatial_step_position(now_us, speed) % span
|
||||||
|
if variant in (1, 3):
|
||||||
|
front = max_distance - front
|
||||||
|
amount = 1.0 - smoothstep(0.0, 0.70, abs(distance - front))
|
||||||
|
value = clamp_byte(amount * 255.0)
|
||||||
|
if variant in (2, 3):
|
||||||
|
_, _, side = panel_led_position(led)
|
||||||
|
if row == 1 and col in (2, 3):
|
||||||
|
return value if side in (0, 2) else 0
|
||||||
|
if col == 0:
|
||||||
|
return value if side == 3 else 0
|
||||||
|
if col == 5:
|
||||||
|
return value if side == 1 else 0
|
||||||
|
if row == 0:
|
||||||
|
return value if side == 0 else 0
|
||||||
|
if row == 2:
|
||||||
|
return value if side == 2 else 0
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def checker_amount(col: int, row: int, led: int, now_us: int, speed: int, variant: int) -> int:
|
||||||
|
parity = (row + col) & 1
|
||||||
|
step = spatial_step_index(now_us, speed)
|
||||||
|
if variant in (1, 2):
|
||||||
|
x, y, _ = panel_led_position(led)
|
||||||
|
slash = variant == 2 and (step & 1)
|
||||||
|
first = y <= (1.0 - x if slash else x)
|
||||||
|
return 255 if ((((parity + step) & 1) == 0) == first) else 0
|
||||||
|
return 255 if ((parity + step) & 1) == 0 else 0
|
||||||
|
|
||||||
|
|
||||||
|
def wave_line_amount(col: int, row: int, now_us: int, speed: int, direction: int) -> int:
|
||||||
|
triangle = [0, 1, 2, 1]
|
||||||
|
step = spatial_step_index(now_us, speed)
|
||||||
|
if direction in (2, 3):
|
||||||
|
phase = step if direction == 2 else -step
|
||||||
|
target = round(triangle[(row - phase) % 4] * ((NODE_COUNT - 1) / 2.0) / 2.0)
|
||||||
|
return 255 if col == min(target, NODE_COUNT - 1) else 0
|
||||||
|
phase = -step if direction == 1 else step
|
||||||
|
target = round(triangle[(col - phase) % 4] * ((ROWS - 1) / 2.0))
|
||||||
|
return 255 if row == min(target, ROWS - 1) else 0
|
||||||
|
|
||||||
|
|
||||||
|
def arrow_amount(col: int, row: int, now_us: int, speed: int, direction: int, size: int) -> int:
|
||||||
|
horizontal = direction not in (2, 3)
|
||||||
|
major_count = NODE_COUNT if horizontal else ROWS
|
||||||
|
minor_count = ROWS if horizontal else NODE_COUNT
|
||||||
|
major = col if horizontal else row
|
||||||
|
minor = row if horizontal else col
|
||||||
|
gap = max(1, 1 + size // 86) - 1
|
||||||
|
span = 3 + gap
|
||||||
|
movement = spatial_step_index(now_us, speed)
|
||||||
|
band = 0 if abs(minor - ((minor_count - 1) / 2.0)) <= 0.55 else 1
|
||||||
|
orientation_right = direction in (0, 2, 4)
|
||||||
|
target = 1 if band == 0 else (0 if orientation_right else 2)
|
||||||
|
local = major - movement if orientation_right else major + movement
|
||||||
|
return 255 if major_count > 0 and (local % span) == target else 0
|
||||||
|
|
||||||
|
|
||||||
|
def scan_amount(col: int, row: int, led: int, now_us: int, speed: int, size: int, angle: int, option: int, direction: int) -> int:
|
||||||
|
x, y, _ = panel_led_position(led)
|
||||||
|
vertical = direction in (2, 3)
|
||||||
|
radians = math.radians((angle + (90 if vertical else 0)) % 360)
|
||||||
|
vx, vy = math.cos(radians), math.sin(radians)
|
||||||
|
progress = (col + x) * vx + (row + y) * vy
|
||||||
|
p00 = 0.0
|
||||||
|
p10 = NODE_COUNT * vx
|
||||||
|
p01 = ROWS * vy
|
||||||
|
p11 = p10 + p01
|
||||||
|
min_progress = min(p00, p10, p01, p11)
|
||||||
|
max_progress = max(p00, p10, p01, p11)
|
||||||
|
width = 0.15 + (size / 255.0) * (1.60 if option == 1 else 0.85)
|
||||||
|
travel = max_progress - min_progress
|
||||||
|
if travel <= 0.001:
|
||||||
|
return 0
|
||||||
|
phase = spatial_step_position(now_us, speed) % travel
|
||||||
|
if direction == 6:
|
||||||
|
phase = spatial_step_position(now_us, speed) % (travel * 2.0)
|
||||||
|
if phase > travel:
|
||||||
|
phase = (travel * 2.0) - phase
|
||||||
|
elif direction in (1, 3):
|
||||||
|
phase = travel - phase
|
||||||
|
center = min_progress + max(0.0, min(travel, phase))
|
||||||
|
if option == 1:
|
||||||
|
period = width * 2.0 + 0.35
|
||||||
|
d = abs(((progress - center + period * 64.0) % period) - period * 0.5)
|
||||||
|
return clamp_byte((1.0 - smoothstep(width * 0.45, width * 0.75, d)) * 255.0)
|
||||||
|
return clamp_byte((1.0 - smoothstep(width * 0.5, width * 0.5 + 0.55, abs(progress - center))) * 255.0)
|
||||||
|
|
||||||
|
|
||||||
|
def snake_sample(col: int, row: int, now_us: int, speed: int, size: int, seed: int) -> tuple[int, str]:
|
||||||
|
panel_index = grid_panel_index(col, row)
|
||||||
|
target_length = snake_length_from_size(size)
|
||||||
|
body, apple = snake_state_at(spatial_step_index(now_us, speed) % 240, target_length, seed)
|
||||||
|
for offset, position in enumerate(body):
|
||||||
|
if panel_index == position:
|
||||||
|
return 255, "primary"
|
||||||
|
return (255, "secondary") if panel_index == apple else (0, "gradient")
|
||||||
|
|
||||||
|
|
||||||
|
def schlaengeln_sample(col: int, row: int, now_us: int, speed: int, size: int, variant: int, direction: int) -> tuple[int, str]:
|
||||||
|
path_len = NODE_COUNT * ROWS
|
||||||
|
panel_index = mirrored_serpentine_panel_index(col, row, variant)
|
||||||
|
length = chain_length_from_size(size)
|
||||||
|
step = spatial_step_index(now_us, speed)
|
||||||
|
for offset in range(length):
|
||||||
|
if direction == 6:
|
||||||
|
pos = schlaengeln_pingpong_position(step, offset, path_len - 1)
|
||||||
|
elif direction == 1:
|
||||||
|
head = (path_len - 1) - (step % path_len)
|
||||||
|
pos = (head + path_len - (offset % path_len)) % path_len
|
||||||
|
else:
|
||||||
|
pos = (step + path_len - (offset % path_len)) % path_len
|
||||||
|
if panel_index == pos:
|
||||||
|
return max(55, 255 - offset * 30), "gradient"
|
||||||
|
return 0, "gradient"
|
||||||
|
|
||||||
|
|
||||||
|
def sunburst_sample(col: int, row: int, led: int, now_us: int, speed: int, variant: int, option: int) -> tuple[int, str, float]:
|
||||||
|
x, y = sunburst_panel_led_position(col, row, led)
|
||||||
|
cx, cy = physical_panel_center()
|
||||||
|
dx, dy = x - cx, y - cy
|
||||||
|
wobble = math.sin(spatial_beat_position(now_us, speed) * math.tau) * 0.18 if variant == 1 else 0.0
|
||||||
|
rotation = spatial_beat_position(now_us, speed) * (math.tau / 24.0) if variant == 2 else 0.0
|
||||||
|
angle = (math.atan2(dy, dx) + wobble - rotation) % math.tau
|
||||||
|
active = math.cos(angle * 12.0) >= 0.0
|
||||||
|
if option == 1:
|
||||||
|
return 255, "primary" if active else "secondary", 0.0
|
||||||
|
if option == 2:
|
||||||
|
hue = (angle / math.tau) + (spatial_beat_position(now_us, speed) * 8.0 / 255.0)
|
||||||
|
return (255, "rainbow", hue) if active else (255, "black", 0.0)
|
||||||
|
return (255, "gradient", 0.0) if active else (255, "black", 0.0)
|
||||||
|
|
||||||
|
|
||||||
|
def blend(primary: list[int], secondary: list[int], amount: int) -> list[int]:
|
||||||
|
return [clamp_byte(secondary[i] + (primary[i] - secondary[i]) * amount / 255.0) for i in range(3)]
|
||||||
|
|
||||||
|
|
||||||
|
def layer_sample(mode: int, col: int, row: int, led: int, now_us: int, spatial: dict[str, Any], scene: dict[str, Any]) -> tuple[int, str]:
|
||||||
|
speed = int(scene.get("speed", 128))
|
||||||
|
variant = int(spatial.get("variant", 0))
|
||||||
|
direction = int(spatial.get("direction", 0))
|
||||||
|
size = int(spatial.get("size", 64))
|
||||||
|
if mode == 1:
|
||||||
|
return center_pulse_amount(col, row, led, now_us, speed, variant), "gradient"
|
||||||
|
if mode == 2:
|
||||||
|
return checker_amount(col, row, led, now_us, speed, variant), "gradient"
|
||||||
|
if mode == 3:
|
||||||
|
return arrow_amount(col, row, now_us, speed, direction, size), "gradient"
|
||||||
|
if mode == 4:
|
||||||
|
return scan_amount(col, row, led, now_us, speed, size, int(spatial.get("angle", 0)), int(spatial.get("option", 0)), direction), "gradient"
|
||||||
|
if mode == 5:
|
||||||
|
return snake_sample(col, row, now_us, speed, size, int(scene.get("seed", 1)))
|
||||||
|
if mode == 6:
|
||||||
|
return wave_line_amount(col, row, now_us, speed, direction), "gradient"
|
||||||
|
if mode == 7:
|
||||||
|
return strobe_amount(now_us, speed, size), "gradient"
|
||||||
|
if mode == 8:
|
||||||
|
return schlaengeln_sample(col, row, now_us, speed, size, variant, direction)
|
||||||
|
if mode == 9:
|
||||||
|
amount, role, hue = sunburst_sample(col, row, led, now_us, speed, variant, int(spatial.get("option", 0)))
|
||||||
|
return amount, f"rainbow:{hue}" if role == "rainbow" else role
|
||||||
|
return 0, "gradient"
|
||||||
|
|
||||||
|
|
||||||
|
def synthetic_effect_color(col: int, row: int, led: int, now_us: int, scene: dict[str, Any]) -> list[int]:
|
||||||
|
x, y, _ = panel_led_position(led)
|
||||||
|
effect = int(scene.get("effect", 0))
|
||||||
|
palette = int(scene.get("palette", 0))
|
||||||
|
speed = int(scene.get("speed", 128))
|
||||||
|
phase = spatial_beat_position(now_us, speed)
|
||||||
|
hue = (col * 0.10 + row * 0.17 + x * 0.12 + y * 0.08 + phase * 0.07 + effect * 0.013 + palette * 0.021) % 1.0
|
||||||
|
if effect == 0:
|
||||||
|
return scene.get("primary", [255, 160, 80])[:3]
|
||||||
|
return hsv_to_rgb(hue, 0.82, 1.0)
|
||||||
|
|
||||||
|
|
||||||
|
def sample_color(primary: list[int], secondary: list[int], effect_color: list[int], amount: int, role: str) -> list[int]:
|
||||||
|
if role == "primary":
|
||||||
|
return [clamp_byte(channel * amount / 255.0) for channel in primary]
|
||||||
|
if role == "secondary":
|
||||||
|
return [clamp_byte(channel * amount / 255.0) for channel in secondary]
|
||||||
|
if role == "black":
|
||||||
|
return [0, 0, 0]
|
||||||
|
if role.startswith("rainbow:"):
|
||||||
|
try:
|
||||||
|
hue = float(role.split(":", 1)[1])
|
||||||
|
except (IndexError, ValueError):
|
||||||
|
hue = 0.0
|
||||||
|
return [clamp_byte(channel * amount / 255.0) for channel in hsv_to_rgb(hue)]
|
||||||
|
return [clamp_byte(channel * amount / 255.0) for channel in effect_color]
|
||||||
|
|
||||||
|
|
||||||
|
def is_direct_role(role: str) -> bool:
|
||||||
|
return role in ("primary", "secondary") or role.startswith("rainbow:")
|
||||||
|
|
||||||
|
|
||||||
|
def render_frame(state: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
scene = state.get("scene", {})
|
||||||
|
spatial = scene.get("spatial", {}) or {}
|
||||||
|
mode = int(spatial.get("mode", 0))
|
||||||
|
strength = int(spatial.get("strength", 180))
|
||||||
|
primary = scene.get("primary", [255, 160, 80])[:3]
|
||||||
|
secondary = scene.get("secondary", [0, 32, 255])[:3]
|
||||||
|
now_us = int(time.monotonic() * 1_000_000) + int(scene.get("phase", 0)) * 1000
|
||||||
|
speed = int(scene.get("speed", 128))
|
||||||
|
panels: list[list[list[list[int]]]] = []
|
||||||
|
for row in range(ROWS):
|
||||||
|
row_panels = []
|
||||||
|
for col in range(NODE_COUNT):
|
||||||
|
leds = []
|
||||||
|
for led in range(LEDS_PER_PANEL):
|
||||||
|
amount, role = layer_sample(mode, col, row, led, now_us, spatial, scene)
|
||||||
|
if not is_direct_role(role):
|
||||||
|
amount = clamp_byte(amount * strength / 255.0)
|
||||||
|
effect_color = synthetic_effect_color(col, row, led, now_us, scene)
|
||||||
|
leds.append(sample_color(primary, secondary, effect_color, amount, role) if amount else [0, 0, 0])
|
||||||
|
row_panels.append(leds)
|
||||||
|
panels.append(row_panels)
|
||||||
|
panel_info = [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"label": f"ESP{col + 1} {OUTPUT_LABELS[row]}",
|
||||||
|
"transform": f"r{PANEL_TRANSFORMS[row][col][0] * 90} mx{int(PANEL_TRANSFORMS[row][col][1])} my{int(PANEL_TRANSFORMS[row][col][2])}",
|
||||||
|
}
|
||||||
|
for col in range(NODE_COUNT)
|
||||||
|
]
|
||||||
|
for row in range(ROWS)
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
"scene": scene,
|
||||||
|
"node_ips": state.get("node_ips", []),
|
||||||
|
"panels": panels,
|
||||||
|
"panel_info": panel_info,
|
||||||
|
"mode_name": MODE_NAMES[mode] if 0 <= mode < len(MODE_NAMES) else "Unknown",
|
||||||
|
"variant_name": (
|
||||||
|
SCHLAENGELN_VARIANT_NAMES[int(spatial.get("variant", 0))]
|
||||||
|
if mode == 8 and int(spatial.get("variant", 0)) < len(SCHLAENGELN_VARIANT_NAMES)
|
||||||
|
else SUNBURST_VARIANT_NAMES[int(spatial.get("variant", 0))]
|
||||||
|
if mode == 9 and int(spatial.get("variant", 0)) < len(SUNBURST_VARIANT_NAMES)
|
||||||
|
else VARIANT_NAMES[int(spatial.get("variant", 0))]
|
||||||
|
if int(spatial.get("variant", 0)) < len(VARIANT_NAMES)
|
||||||
|
else "Unknown"
|
||||||
|
),
|
||||||
|
"direction_name": DIRECTION_NAMES[int(spatial.get("direction", 0))] if int(spatial.get("direction", 0)) < len(DIRECTION_NAMES) else "Unknown",
|
||||||
|
"note": "2D layer with synthetic effect-color preview",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class VisualizerServer(ThreadingHTTPServer):
|
||||||
|
allow_reuse_address = True
|
||||||
|
master: str
|
||||||
|
timeout_s: float
|
||||||
|
|
||||||
|
|
||||||
|
class Handler(BaseHTTPRequestHandler):
|
||||||
|
server: VisualizerServer
|
||||||
|
|
||||||
|
def log_message(self, fmt: str, *args: Any) -> None:
|
||||||
|
message = fmt % args
|
||||||
|
if '"GET /api/' in message and (' 502 ' in message or ' 504 ' in message):
|
||||||
|
return
|
||||||
|
sys.stderr.write("%s - %s\n" % (self.log_date_time_string(), message))
|
||||||
|
|
||||||
|
def send_bytes(self, status: int, content_type: str, body: bytes) -> None:
|
||||||
|
try:
|
||||||
|
self.send_response(status)
|
||||||
|
self.send_header("Content-Type", content_type)
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.send_header("Cache-Control", "no-store")
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
except (BrokenPipeError, ConnectionResetError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def do_GET(self) -> None:
|
||||||
|
if self.path == "/" or self.path.startswith("/?"):
|
||||||
|
self.send_bytes(200, "text/html; charset=utf-8", HTML.encode("utf-8"))
|
||||||
|
return
|
||||||
|
if self.path.startswith("/api/infinity"):
|
||||||
|
self.proxy_infinity(self.master_from_query())
|
||||||
|
return
|
||||||
|
if self.path.startswith("/api/frame"):
|
||||||
|
self.proxy_frame(self.master_from_query())
|
||||||
|
return
|
||||||
|
if self.path == "/health":
|
||||||
|
self.send_bytes(200, "application/json", b'{"ok":true}')
|
||||||
|
return
|
||||||
|
self.send_bytes(404, "text/plain; charset=utf-8", b"not found")
|
||||||
|
|
||||||
|
def master_from_query(self) -> str:
|
||||||
|
values = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)
|
||||||
|
return values.get("master", [self.server.master])[0].strip() or self.server.master
|
||||||
|
|
||||||
|
def fetch_master_state(self, master: str) -> dict[str, Any]:
|
||||||
|
url = f"http://{master}/json/infinity"
|
||||||
|
with urllib.request.urlopen(url, timeout=self.server.timeout_s) as response:
|
||||||
|
body = response.read()
|
||||||
|
return json.loads(body.decode("utf-8"))
|
||||||
|
|
||||||
|
def proxy_infinity(self, master: str) -> None:
|
||||||
|
try:
|
||||||
|
state = self.fetch_master_state(master)
|
||||||
|
except (urllib.error.URLError, socket.timeout, TimeoutError) as exc:
|
||||||
|
self.send_bytes(504, "application/json", json.dumps({"error":"master unreachable","detail":str(exc)}).encode("utf-8"))
|
||||||
|
return
|
||||||
|
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||||
|
self.send_bytes(502, "application/json", json.dumps({"error":f"invalid master JSON: {exc}"}).encode("utf-8"))
|
||||||
|
return
|
||||||
|
self.send_bytes(200, "application/json", json.dumps(state).encode("utf-8"))
|
||||||
|
|
||||||
|
def proxy_frame(self, master: str) -> None:
|
||||||
|
try:
|
||||||
|
state = self.fetch_master_state(master)
|
||||||
|
frame = render_frame(state)
|
||||||
|
except (urllib.error.URLError, socket.timeout, TimeoutError) as exc:
|
||||||
|
self.send_bytes(504, "application/json", json.dumps({"error":"master unreachable","detail":str(exc)}).encode("utf-8"))
|
||||||
|
return
|
||||||
|
except Exception as exc: # keep the operator UI alive and explicit
|
||||||
|
self.send_bytes(502, "application/json", json.dumps({"error":f"frame render failed: {exc}"}).encode("utf-8"))
|
||||||
|
return
|
||||||
|
self.send_bytes(200, "application/json", json.dumps(frame, separators=(",", ":")).encode("utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args() -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(description="Serve a local Infinity Global-2D visualizer.")
|
||||||
|
parser.add_argument("--master", default="10.42.0.213", help="Infinity master IP or hostname")
|
||||||
|
parser.add_argument("--bind", default="127.0.0.1", help="Local bind address")
|
||||||
|
parser.add_argument("--port", type=int, default=8765, help="Local HTTP port")
|
||||||
|
parser.add_argument("--no-port-fallback", action="store_true", help="Fail instead of trying the next ports when busy")
|
||||||
|
parser.add_argument("--timeout", type=float, default=1.2, help="Master request timeout in seconds")
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def bind_server(args: argparse.Namespace) -> VisualizerServer:
|
||||||
|
last_error: OSError | None = None
|
||||||
|
ports = [args.port] if args.no_port_fallback else range(args.port, args.port + 50)
|
||||||
|
for port in ports:
|
||||||
|
try:
|
||||||
|
server = VisualizerServer((args.bind, port), Handler)
|
||||||
|
if port != args.port:
|
||||||
|
print(f"Port {args.port} is busy, using {port} instead.")
|
||||||
|
return server
|
||||||
|
except OSError as exc:
|
||||||
|
last_error = exc
|
||||||
|
if exc.errno != errno.EADDRINUSE or args.no_port_fallback:
|
||||||
|
break
|
||||||
|
if last_error and last_error.errno == errno.EADDRINUSE:
|
||||||
|
raise SystemExit(f"Could not start visualizer: ports {args.port}-{args.port + 49} are busy.")
|
||||||
|
raise last_error or RuntimeError("Could not bind visualizer server")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
args = parse_args()
|
||||||
|
server = bind_server(args)
|
||||||
|
server.master = args.master
|
||||||
|
server.timeout_s = args.timeout
|
||||||
|
host, port = server.server_address[:2]
|
||||||
|
print(f"Infinity visualizer: http://{host}:{port}/?master={args.master}")
|
||||||
|
print(f"Proxying master: http://{args.master}/json/infinity")
|
||||||
|
print("Rendering Infinity Global-2D layer with synthetic effect colors and panel calibration.")
|
||||||
|
try:
|
||||||
|
server.serve_forever()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\nStopped.")
|
||||||
|
finally:
|
||||||
|
server.server_close()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
219
tools/rfp_master_usb_relay.py
Executable file
219
tools/rfp_master_usb_relay.py
Executable file
@@ -0,0 +1,219 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Relay RFP node OTA updates through the USB-connected Infinity master."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
try:
|
||||||
|
import serial
|
||||||
|
except ImportError: # pragma: no cover - depends on local PlatformIO venv
|
||||||
|
serial = None
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_BAUD = 921600
|
||||||
|
BOOTSTRAP_BAUD = 115200
|
||||||
|
DEFAULT_STARTUP_DELAY = 4.0
|
||||||
|
NODE_RELEASE = "RFP_N16R8_NODE3x106_V20260511E"
|
||||||
|
NODE_HOSTS = range(11, 17)
|
||||||
|
|
||||||
|
|
||||||
|
def targets_from_subnet(subnet: str) -> list[str]:
|
||||||
|
prefix = ".".join(subnet.split(".")[:3])
|
||||||
|
return [f"{prefix}.{host}" for host in NODE_HOSTS]
|
||||||
|
|
||||||
|
|
||||||
|
def drain_serial(ser: "serial.Serial", quiet_s: float = 0.2, max_s: float = 2.0) -> None:
|
||||||
|
"""Discard boot/debug lines before starting the command protocol."""
|
||||||
|
deadline = time.monotonic() + max_s
|
||||||
|
quiet_deadline = time.monotonic() + quiet_s
|
||||||
|
while time.monotonic() < deadline and time.monotonic() < quiet_deadline:
|
||||||
|
raw = ser.readline()
|
||||||
|
if raw:
|
||||||
|
quiet_deadline = time.monotonic() + quiet_s
|
||||||
|
|
||||||
|
|
||||||
|
def open_master_serial(port: str, baud: int, startup_delay: float) -> "serial.Serial":
|
||||||
|
if serial is None:
|
||||||
|
raise RuntimeError("pyserial is not installed in this Python environment")
|
||||||
|
ser = serial.Serial(port, BOOTSTRAP_BAUD, timeout=1.0, write_timeout=30)
|
||||||
|
if startup_delay > 0:
|
||||||
|
time.sleep(startup_delay)
|
||||||
|
drain_serial(ser)
|
||||||
|
if baud != BOOTSTRAP_BAUD:
|
||||||
|
ser.write(bytes([0xB5])) # WLED serial command: switch baud rate.
|
||||||
|
ser.flush()
|
||||||
|
time.sleep(0.25)
|
||||||
|
ser.baudrate = baud
|
||||||
|
time.sleep(0.25)
|
||||||
|
drain_serial(ser)
|
||||||
|
return ser
|
||||||
|
|
||||||
|
|
||||||
|
def read_prefixed_line(ser: "serial.Serial", prefixes: tuple[str, ...], timeout_s: float) -> tuple[str, str]:
|
||||||
|
deadline = time.monotonic() + timeout_s
|
||||||
|
seen: list[str] = []
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
raw = ser.readline()
|
||||||
|
if not raw:
|
||||||
|
continue
|
||||||
|
line = raw.decode("utf-8", errors="replace").strip()
|
||||||
|
if line:
|
||||||
|
seen.append(line)
|
||||||
|
seen = seen[-5:]
|
||||||
|
for prefix in prefixes:
|
||||||
|
if line.startswith(prefix):
|
||||||
|
return prefix, line[len(prefix) :].strip()
|
||||||
|
detail = f"; last serial lines: {' | '.join(seen)}" if seen else ""
|
||||||
|
raise TimeoutError(f"timed out waiting for one of: {', '.join(prefixes)}{detail}")
|
||||||
|
|
||||||
|
|
||||||
|
def master_info(ser: "serial.Serial", target: str, timeout_s: float = 8.0) -> dict:
|
||||||
|
command = {"target": target}
|
||||||
|
ser.write(("RFPINFO1 " + json.dumps(command, separators=(",", ":")) + "\n").encode())
|
||||||
|
ser.flush()
|
||||||
|
prefix, payload = read_prefixed_line(ser, ("RFPINFO1 ", "RFPERR1 "), timeout_s)
|
||||||
|
if prefix == "RFPERR1 ":
|
||||||
|
raise RuntimeError(payload)
|
||||||
|
return json.loads(payload)
|
||||||
|
|
||||||
|
|
||||||
|
def relay_ota(ser: "serial.Serial", target: str, firmware: Path, expected_release: str, chunk_size: int) -> None:
|
||||||
|
size = firmware.stat().st_size
|
||||||
|
command = {
|
||||||
|
"target": target,
|
||||||
|
"size": size,
|
||||||
|
"release": expected_release,
|
||||||
|
"skipValidation": True,
|
||||||
|
"ackBytes": chunk_size,
|
||||||
|
}
|
||||||
|
ser.write(("RFPOTA1 " + json.dumps(command, separators=(",", ":")) + "\n").encode())
|
||||||
|
ser.flush()
|
||||||
|
prefix, payload = read_prefixed_line(ser, ("RFPREADY1 ", "RFPERR1 "), 12.0)
|
||||||
|
if prefix == "RFPERR1 ":
|
||||||
|
raise RuntimeError(payload)
|
||||||
|
ready = json.loads(payload)
|
||||||
|
if int(ready.get("proto", 1)) < 4 or int(ready.get("ackBytes", 0)) <= 0:
|
||||||
|
raise RuntimeError(
|
||||||
|
"USB relay master firmware is too old for base64 chunk mode. "
|
||||||
|
"Flash the master first, then rerun this command."
|
||||||
|
)
|
||||||
|
print(f"{target}: master ready {payload}")
|
||||||
|
ser.write(b"RFPDATA1\n")
|
||||||
|
ser.flush()
|
||||||
|
|
||||||
|
sent = 0
|
||||||
|
started = time.monotonic()
|
||||||
|
with firmware.open("rb") as fh:
|
||||||
|
while True:
|
||||||
|
chunk = fh.read(chunk_size)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
encoded = base64.b64encode(chunk).decode("ascii")
|
||||||
|
written = ser.write(f"RFPCHUNK1 {len(chunk)} {encoded}\n".encode("ascii"))
|
||||||
|
ser.flush()
|
||||||
|
if written <= 0:
|
||||||
|
raise RuntimeError(f"{target}: serial write returned {written}")
|
||||||
|
sent += len(chunk)
|
||||||
|
while True:
|
||||||
|
prefix, payload = read_prefixed_line(ser, ("RFPACK1 ", "RFPERR1 "), 30.0)
|
||||||
|
if prefix == "RFPERR1 ":
|
||||||
|
raise RuntimeError(payload)
|
||||||
|
ack = json.loads(payload).get("bytes", 0)
|
||||||
|
if int(ack) >= sent:
|
||||||
|
break
|
||||||
|
if sent == size or sent % (256 * 1024) < len(chunk):
|
||||||
|
elapsed = max(0.1, time.monotonic() - started)
|
||||||
|
print(f"{target}: streamed {sent}/{size} bytes ({sent / elapsed / 1024:.1f} KiB/s)")
|
||||||
|
|
||||||
|
prefix, payload = read_prefixed_line(ser, ("RFPDONE1 ", "RFPERR1 "), 45.0)
|
||||||
|
if prefix == "RFPERR1 ":
|
||||||
|
raise RuntimeError(payload)
|
||||||
|
print(f"{target}: relay done {payload}")
|
||||||
|
|
||||||
|
|
||||||
|
def release_from_info(info: dict) -> str:
|
||||||
|
return str(info.get("release") or info.get("rel") or "")
|
||||||
|
|
||||||
|
|
||||||
|
def wait_for_release(ser: "serial.Serial", target: str, expected_release: str, timeout_s: float) -> dict:
|
||||||
|
deadline = time.monotonic() + timeout_s
|
||||||
|
last_error = ""
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
try:
|
||||||
|
info = master_info(ser, target, timeout_s=8.0)
|
||||||
|
if release_from_info(info) == expected_release:
|
||||||
|
return info
|
||||||
|
last_error = f"release is {release_from_info(info)!r}"
|
||||||
|
except Exception as exc: # noqa: BLE001 - keep polling while node reboots
|
||||||
|
last_error = str(exc)
|
||||||
|
time.sleep(2.0)
|
||||||
|
raise TimeoutError(f"{target}: expected release {expected_release} did not appear ({last_error})")
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args() -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(description="Update RFP nodes through the USB-connected master.")
|
||||||
|
parser.add_argument("--port", required=True, help="Master serial port, for example /dev/ttyACM0")
|
||||||
|
parser.add_argument("--firmware", required=True, type=Path, help="Node firmware .bin")
|
||||||
|
parser.add_argument("--expect-release", default=NODE_RELEASE, help=f"Expected node release (default: {NODE_RELEASE})")
|
||||||
|
parser.add_argument("--targets", help="Comma-separated node IP list")
|
||||||
|
parser.add_argument("--subnet", default="192.168.178.0/24", help="Subnet used to derive .11-.16 when --targets is omitted")
|
||||||
|
parser.add_argument("--start-from", help="Resume from this target IP")
|
||||||
|
parser.add_argument("--baud", type=int, default=DEFAULT_BAUD, help=f"Relay baud rate after startup (default: {DEFAULT_BAUD})")
|
||||||
|
parser.add_argument(
|
||||||
|
"--startup-delay",
|
||||||
|
type=float,
|
||||||
|
default=DEFAULT_STARTUP_DELAY,
|
||||||
|
help=f"Seconds to wait after opening the master serial port (default: {DEFAULT_STARTUP_DELAY})",
|
||||||
|
)
|
||||||
|
parser.add_argument("--chunk-size", type=int, default=384, help="Raw firmware bytes per base64 serial chunk")
|
||||||
|
parser.add_argument("--force-current-release", action="store_true", help="Reflash even if release already matches")
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
args = parse_args()
|
||||||
|
firmware = args.firmware
|
||||||
|
if not firmware.exists():
|
||||||
|
print(f"Firmware file not found: {firmware}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
targets = [item.strip() for item in args.targets.split(",") if item.strip()] if args.targets else targets_from_subnet(args.subnet)
|
||||||
|
if args.start_from:
|
||||||
|
if args.start_from not in targets:
|
||||||
|
print(f"--start-from target not found: {args.start_from}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
targets = targets[targets.index(args.start_from) :]
|
||||||
|
|
||||||
|
print(f"Opening master serial relay on {args.port} at {args.baud} baud")
|
||||||
|
with open_master_serial(args.port, args.baud, args.startup_delay) as ser:
|
||||||
|
for index, target in enumerate(targets, start=1):
|
||||||
|
print(f"[{index}/{len(targets)}] {target}: checking current release through master...")
|
||||||
|
try:
|
||||||
|
info = master_info(ser, target)
|
||||||
|
current_release = release_from_info(info)
|
||||||
|
print(f"[{index}/{len(targets)}] {target}: current release {current_release or '-'}")
|
||||||
|
if current_release == args.expect_release and not args.force_current_release:
|
||||||
|
print(f"[{index}/{len(targets)}] {target}: SKIP (release already matches)")
|
||||||
|
continue
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
print(f"[{index}/{len(targets)}] {target}: info warning: {exc}")
|
||||||
|
|
||||||
|
print(f"[{index}/{len(targets)}] {target}: streaming OTA via master...")
|
||||||
|
relay_ota(ser, target, firmware, args.expect_release, args.chunk_size)
|
||||||
|
info = wait_for_release(ser, target, args.expect_release, timeout_s=150.0)
|
||||||
|
print(
|
||||||
|
f"[{index}/{len(targets)}] {target}: OK "
|
||||||
|
f"(release {release_from_info(info)}, uptime {info.get('uptime', '-') }s)"
|
||||||
|
)
|
||||||
|
print("Master USB relay update completed.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
631
tools/rfp_network_flash.py
Executable file
631
tools/rfp_network_flash.py
Executable file
@@ -0,0 +1,631 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Discover WLED devices in local networks and flash them sequentially via OTA.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import ipaddress
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Iterable
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_OUTPUT_FILE = "tools/discovered_wled_hosts.txt"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class WledHost:
|
||||||
|
ip: str
|
||||||
|
name: str
|
||||||
|
version: str
|
||||||
|
release: str
|
||||||
|
arch: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class WledInfo(WledHost):
|
||||||
|
uptime_s: int
|
||||||
|
raw: dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class OtaPreflight:
|
||||||
|
info: WledInfo | None
|
||||||
|
update_status: str
|
||||||
|
update_hint: str
|
||||||
|
firmware_size: int
|
||||||
|
ota_space_hint: str
|
||||||
|
|
||||||
|
|
||||||
|
def _run(cmd: list[str]) -> str:
|
||||||
|
proc = subprocess.run(cmd, check=True, capture_output=True, text=True)
|
||||||
|
return proc.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def local_networks() -> list[ipaddress.IPv4Network]:
|
||||||
|
"""
|
||||||
|
Read active IPv4 interfaces from `ip` and return private subnets.
|
||||||
|
"""
|
||||||
|
out = _run(["ip", "-o", "-4", "addr", "show", "scope", "global"])
|
||||||
|
nets: list[ipaddress.IPv4Network] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for line in out.splitlines():
|
||||||
|
match = re.search(r"\binet\s+(\d+\.\d+\.\d+\.\d+/\d+)\b", line)
|
||||||
|
if not match:
|
||||||
|
continue
|
||||||
|
iface_cidr = match.group(1)
|
||||||
|
iface_ip = ipaddress.ip_interface(iface_cidr)
|
||||||
|
net = iface_ip.network
|
||||||
|
if not iface_ip.ip.is_private:
|
||||||
|
continue
|
||||||
|
if net.num_addresses > 2048:
|
||||||
|
# avoid accidentally scanning very large ranges by default
|
||||||
|
net = ipaddress.ip_network(f"{iface_ip.ip}/24", strict=False)
|
||||||
|
key = str(net)
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
nets.append(net)
|
||||||
|
return nets
|
||||||
|
|
||||||
|
|
||||||
|
def parse_networks(raw: Iterable[str] | None) -> list[ipaddress.IPv4Network]:
|
||||||
|
if raw:
|
||||||
|
nets: list[ipaddress.IPv4Network] = []
|
||||||
|
for item in raw:
|
||||||
|
nets.append(ipaddress.ip_network(item, strict=False))
|
||||||
|
return nets
|
||||||
|
return local_networks()
|
||||||
|
|
||||||
|
|
||||||
|
def probe_wled_info(ip: str, timeout_s: float) -> WledInfo | None:
|
||||||
|
url = f"http://{ip}/json/info"
|
||||||
|
try:
|
||||||
|
resp = requests.get(url, timeout=timeout_s)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
return None
|
||||||
|
data = resp.json()
|
||||||
|
except (requests.RequestException, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
# WLED info endpoint typically includes "name", "ver", "release"/"rel", and "arch".
|
||||||
|
ver = str(data.get("ver", "")).strip()
|
||||||
|
release = str(data.get("release") or data.get("rel") or "").strip()
|
||||||
|
name = str(data.get("name", "")).strip()
|
||||||
|
arch = str(data.get("arch", "")).strip()
|
||||||
|
if not ver:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not name:
|
||||||
|
name = "WLED"
|
||||||
|
if not arch:
|
||||||
|
arch = "-"
|
||||||
|
|
||||||
|
uptime_s = int(data.get("uptime", 0) or 0)
|
||||||
|
return WledInfo(ip=ip, name=name, version=ver, release=release, arch=arch, uptime_s=uptime_s, raw=data)
|
||||||
|
|
||||||
|
|
||||||
|
def probe_wled(ip: str, timeout_s: float) -> WledHost | None:
|
||||||
|
info = probe_wled_info(ip, timeout_s)
|
||||||
|
if info is None:
|
||||||
|
return None
|
||||||
|
return WledHost(ip=info.ip, name=info.name, version=info.version, release=info.release, arch=info.arch)
|
||||||
|
|
||||||
|
|
||||||
|
def discover_hosts(
|
||||||
|
nets: list[ipaddress.IPv4Network],
|
||||||
|
timeout_s: float,
|
||||||
|
workers: int,
|
||||||
|
) -> list[WledHost]:
|
||||||
|
candidates: list[str] = []
|
||||||
|
for net in nets:
|
||||||
|
for host in net.hosts():
|
||||||
|
candidates.append(str(host))
|
||||||
|
|
||||||
|
found: list[WledHost] = []
|
||||||
|
with ThreadPoolExecutor(max_workers=workers) as executor:
|
||||||
|
futures = [executor.submit(probe_wled, ip, timeout_s) for ip in candidates]
|
||||||
|
for fut in as_completed(futures):
|
||||||
|
result = fut.result()
|
||||||
|
if result is not None:
|
||||||
|
found.append(result)
|
||||||
|
|
||||||
|
found.sort(key=lambda h: tuple(int(part) for part in h.ip.split(".")))
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def read_targets_file(path: Path) -> list[str]:
|
||||||
|
targets: list[str] = []
|
||||||
|
for raw in path.read_text(encoding="utf-8").splitlines():
|
||||||
|
line = raw.strip()
|
||||||
|
if not line or line.startswith("#"):
|
||||||
|
continue
|
||||||
|
targets.append(line.split()[0])
|
||||||
|
return targets
|
||||||
|
|
||||||
|
|
||||||
|
def write_discovery(path: Path, hosts: list[WledHost]) -> None:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
lines = ["# ip name version release arch"]
|
||||||
|
lines += [f"{h.ip} {h.name} {h.version} {h.release or '-'} {h.arch}" for h in hosts]
|
||||||
|
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def wait_for_online(ip: str, timeout_s: float, interval_s: float) -> bool:
|
||||||
|
deadline = time.time() + timeout_s
|
||||||
|
while time.time() < deadline:
|
||||||
|
if probe_wled(ip, timeout_s=1.2) is not None:
|
||||||
|
return True
|
||||||
|
time.sleep(interval_s)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def wait_for_offline(ip: str, timeout_s: float, interval_s: float) -> bool:
|
||||||
|
deadline = time.time() + timeout_s
|
||||||
|
while time.time() < deadline:
|
||||||
|
if probe_wled(ip, timeout_s=1.2) is None:
|
||||||
|
return True
|
||||||
|
time.sleep(interval_s)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def wait_for_online_info(ip: str, timeout_s: float, interval_s: float) -> WledInfo | None:
|
||||||
|
deadline = time.time() + timeout_s
|
||||||
|
while time.time() < deadline:
|
||||||
|
info = probe_wled_info(ip, timeout_s=1.2)
|
||||||
|
if info is not None:
|
||||||
|
return info
|
||||||
|
time.sleep(interval_s)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def reboot_confirmed(
|
||||||
|
before: WledInfo | None,
|
||||||
|
after: WledInfo,
|
||||||
|
offline_seen: bool,
|
||||||
|
transport_reset_seen: bool,
|
||||||
|
expected_release: str | None,
|
||||||
|
) -> tuple[bool, str]:
|
||||||
|
if offline_seen:
|
||||||
|
return True, "offline transition observed"
|
||||||
|
if before is None:
|
||||||
|
release_ok, release_reason = release_matches(after.release, expected_release)
|
||||||
|
if transport_reset_seen and release_ok:
|
||||||
|
return True, f"transport reset during upload and {release_reason}"
|
||||||
|
return False, "device was not profiled before upload, and no offline transition was observed"
|
||||||
|
if after.uptime_s + 5 < before.uptime_s:
|
||||||
|
return True, f"uptime reset from {before.uptime_s}s to {after.uptime_s}s"
|
||||||
|
release_ok, release_reason = release_matches(after.release, expected_release)
|
||||||
|
if transport_reset_seen and release_ok:
|
||||||
|
return True, (
|
||||||
|
"transport reset during upload and expected release is present "
|
||||||
|
f"({before.uptime_s}s -> {after.uptime_s}s; weak proof when flashing the same release)"
|
||||||
|
)
|
||||||
|
return False, f"device stayed reachable and uptime did not reset ({before.uptime_s}s -> {after.uptime_s}s)"
|
||||||
|
|
||||||
|
|
||||||
|
def release_matches(actual: str, expected: str | None) -> tuple[bool, str]:
|
||||||
|
if not expected:
|
||||||
|
return True, "no expected release configured"
|
||||||
|
if actual == expected:
|
||||||
|
return True, f"release matches {expected}"
|
||||||
|
if not actual:
|
||||||
|
return False, f"release is not exposed; expected {expected}"
|
||||||
|
return False, f"release mismatch: expected {expected}, got {actual}"
|
||||||
|
|
||||||
|
|
||||||
|
def _iter_numeric_fields(value: Any, prefix: str = "") -> Iterable[tuple[str, int]]:
|
||||||
|
if isinstance(value, dict):
|
||||||
|
for key, nested in value.items():
|
||||||
|
nested_prefix = f"{prefix}.{key}" if prefix else str(key)
|
||||||
|
yield from _iter_numeric_fields(nested, nested_prefix)
|
||||||
|
elif isinstance(value, list):
|
||||||
|
for index, nested in enumerate(value):
|
||||||
|
yield from _iter_numeric_fields(nested, f"{prefix}[{index}]")
|
||||||
|
elif isinstance(value, (int, float)) and not isinstance(value, bool):
|
||||||
|
yield prefix, int(value)
|
||||||
|
|
||||||
|
|
||||||
|
def ota_space_hint(info: WledInfo | None, firmware_size: int) -> str:
|
||||||
|
if info is None:
|
||||||
|
return "unknown, /json/info was not reachable"
|
||||||
|
|
||||||
|
candidates: list[tuple[str, int]] = []
|
||||||
|
for key, value in _iter_numeric_fields(info.raw):
|
||||||
|
lowered = key.lower()
|
||||||
|
# Runtime memory fields such as totalheap/freeheap are not OTA slots.
|
||||||
|
# They can contain the substring "ota" (for example "totalheap"), so
|
||||||
|
# exclude them before looking for OTA-related names.
|
||||||
|
if any(token in lowered for token in ("heap", "psram", "ram")):
|
||||||
|
continue
|
||||||
|
if any(token in lowered for token in ("sketch", "ota", "update")) and value > 0:
|
||||||
|
candidates.append((key, value))
|
||||||
|
|
||||||
|
# WLED does not consistently expose ESP.getFreeSketchSpace() in /json/info.
|
||||||
|
if not candidates:
|
||||||
|
return "not exposed by this firmware; if OTA still fails, USB-clean-flash may be required"
|
||||||
|
|
||||||
|
best_key, best_value = max(candidates, key=lambda item: item[1])
|
||||||
|
if best_value < firmware_size:
|
||||||
|
return f"WARNING: {best_key}={best_value} bytes is smaller than firmware={firmware_size} bytes"
|
||||||
|
return f"{best_key}={best_value} bytes, firmware={firmware_size} bytes"
|
||||||
|
|
||||||
|
|
||||||
|
def probe_update_page(ip: str, timeout_s: float) -> tuple[str, str]:
|
||||||
|
url = f"http://{ip}/update"
|
||||||
|
try:
|
||||||
|
resp = requests.get(url, timeout=timeout_s)
|
||||||
|
except requests.RequestException as exc:
|
||||||
|
return "unreachable", f"GET /update failed: {exc}"
|
||||||
|
|
||||||
|
text = (resp.text or "").lower()
|
||||||
|
if resp.status_code in (401, 403):
|
||||||
|
return "blocked", f"HTTP {resp.status_code}; OTA may be locked or authentication may be required"
|
||||||
|
if resp.status_code >= 400:
|
||||||
|
return "warning", f"HTTP {resp.status_code}"
|
||||||
|
if any(token in text for token in ("ota lock", "ota locked", "locked", "forbidden", "incorrect pin")):
|
||||||
|
return "blocked", "update page suggests OTA is locked or PIN/auth is required"
|
||||||
|
if any(token in text for token in ("update", "upload", "firmware")):
|
||||||
|
return "ok", "update page reachable"
|
||||||
|
return "warning", "update page reachable, but expected upload form text was not detected"
|
||||||
|
|
||||||
|
|
||||||
|
def preflight(ip: str, firmware: Path, timeout_s: float) -> OtaPreflight:
|
||||||
|
firmware_size = firmware.stat().st_size
|
||||||
|
info = probe_wled_info(ip, timeout_s=timeout_s)
|
||||||
|
update_status, update_hint = probe_update_page(ip, timeout_s=timeout_s)
|
||||||
|
return OtaPreflight(
|
||||||
|
info=info,
|
||||||
|
update_status=update_status,
|
||||||
|
update_hint=update_hint,
|
||||||
|
firmware_size=firmware_size,
|
||||||
|
ota_space_hint=ota_space_hint(info, firmware_size),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def print_preflight(index: int, total: int, ip: str, result: OtaPreflight) -> None:
|
||||||
|
prefix = f"[{index}/{total}] {ip}"
|
||||||
|
if result.info is None:
|
||||||
|
print(f"{prefix}: /json/info unavailable")
|
||||||
|
else:
|
||||||
|
print(
|
||||||
|
f"{prefix}: current firmware {result.info.version}, release '{result.info.release or '-'}', uptime {result.info.uptime_s}s, "
|
||||||
|
f"name '{result.info.name}', arch '{result.info.arch or '-'}'"
|
||||||
|
)
|
||||||
|
print(f"{prefix}: firmware size {result.firmware_size} bytes")
|
||||||
|
print(f"{prefix}: OTA space hint: {result.ota_space_hint}")
|
||||||
|
print(f"{prefix}: /update preflight: {result.update_status} ({result.update_hint})")
|
||||||
|
|
||||||
|
|
||||||
|
def request_reboot(ip: str, timeout_s: float) -> tuple[bool, str]:
|
||||||
|
url = f"http://{ip}/json/state"
|
||||||
|
try:
|
||||||
|
resp = requests.post(url, json={"rb": True}, timeout=timeout_s)
|
||||||
|
except requests.RequestException as exc:
|
||||||
|
return False, f"reboot request failed: {exc}"
|
||||||
|
if resp.status_code >= 400:
|
||||||
|
return False, f"reboot request returned HTTP {resp.status_code}"
|
||||||
|
return True, "reboot requested via /json/state"
|
||||||
|
|
||||||
|
|
||||||
|
def classify_update_response(text: str) -> tuple[str, str]:
|
||||||
|
normalized = " ".join((text or "").strip().split())
|
||||||
|
lowered = normalized.lower()
|
||||||
|
snippet = normalized[:180]
|
||||||
|
if "update successful" in lowered or "rebooting" in lowered:
|
||||||
|
return "ok", "ok"
|
||||||
|
if "update failed" in lowered or "could not activate the firmware" in lowered:
|
||||||
|
return "failed", f"device reported update failure: {snippet or 'empty response'}"
|
||||||
|
# WLED message pages contain generic scripts and wording; unknown 200 OK
|
||||||
|
# responses are verified by the reboot/release checks instead of string
|
||||||
|
# guessing here.
|
||||||
|
return "ok", "ok"
|
||||||
|
|
||||||
|
|
||||||
|
def ota_flash(
|
||||||
|
ip: str,
|
||||||
|
firmware: Path,
|
||||||
|
connect_timeout_s: float,
|
||||||
|
read_timeout_s: float,
|
||||||
|
skip_validation: bool,
|
||||||
|
backend: str,
|
||||||
|
) -> tuple[str, str]:
|
||||||
|
# Send skipValidation both as query and multipart field. Different WLED-MM
|
||||||
|
# builds have used different request parameter paths around OTA validation.
|
||||||
|
url = f"http://{ip}/update"
|
||||||
|
if skip_validation:
|
||||||
|
url += "?skipValidation=1"
|
||||||
|
|
||||||
|
if backend == "curl":
|
||||||
|
cmd = [
|
||||||
|
"curl",
|
||||||
|
"-sS",
|
||||||
|
"--connect-timeout",
|
||||||
|
str(max(1, int(connect_timeout_s))),
|
||||||
|
"--max-time",
|
||||||
|
str(max(1, int(read_timeout_s))),
|
||||||
|
"-F",
|
||||||
|
f"update=@{firmware}",
|
||||||
|
]
|
||||||
|
if skip_validation:
|
||||||
|
cmd += ["-F", "skipValidation=1"]
|
||||||
|
cmd.append(url)
|
||||||
|
try:
|
||||||
|
proc = subprocess.run(cmd, check=False, capture_output=True, text=True)
|
||||||
|
except OSError as exc:
|
||||||
|
return "failed", f"curl unavailable or failed to start: {exc}"
|
||||||
|
combined = " ".join((proc.stdout + " " + proc.stderr).strip().split())
|
||||||
|
snippet = combined[:180]
|
||||||
|
if proc.returncode == 0:
|
||||||
|
return classify_update_response(combined)
|
||||||
|
# WLED often closes the socket or reboots before curl receives a clean
|
||||||
|
# response. Treat transport drops as uncertain and prove success later.
|
||||||
|
if proc.returncode in (52, 55, 56):
|
||||||
|
return "transport_reset", f"curl exit {proc.returncode}: {snippet or 'connection dropped during/after upload'}"
|
||||||
|
if proc.returncode == 28:
|
||||||
|
return "uncertain", f"curl exit {proc.returncode}: {snippet or 'upload timed out'}"
|
||||||
|
return "failed", f"curl exit {proc.returncode}: {snippet or 'empty response'}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
with firmware.open("rb") as fh:
|
||||||
|
resp = requests.post(
|
||||||
|
url,
|
||||||
|
data={"skipValidation": "1"} if skip_validation else None,
|
||||||
|
files={"update": (firmware.name, fh, "application/octet-stream")},
|
||||||
|
timeout=(connect_timeout_s, read_timeout_s),
|
||||||
|
)
|
||||||
|
except requests.ReadTimeout:
|
||||||
|
# Common with WLED OTA: upload is accepted but HTTP response never arrives before reboot.
|
||||||
|
return "uncertain", "read timeout after upload"
|
||||||
|
except requests.ConnectionError:
|
||||||
|
# Some devices close the socket abruptly when rebooting after successful OTA.
|
||||||
|
return "transport_reset", "connection dropped during/after upload"
|
||||||
|
except requests.RequestException as exc:
|
||||||
|
return "failed", f"request failed: {exc}"
|
||||||
|
|
||||||
|
text = resp.text or ""
|
||||||
|
snippet = " ".join(text.strip().split())[:180]
|
||||||
|
if resp.status_code >= 400:
|
||||||
|
return "failed", f"http {resp.status_code}: {snippet or 'empty response'}"
|
||||||
|
return classify_update_response(text)
|
||||||
|
|
||||||
|
|
||||||
|
def print_hosts(hosts: list[WledHost]) -> None:
|
||||||
|
if not hosts:
|
||||||
|
print("No WLED devices found.")
|
||||||
|
return
|
||||||
|
print(f"Found {len(hosts)} WLED device(s):")
|
||||||
|
print(f"{'IP':<16} {'Name':<24} {'Version':<18} {'Release':<30} {'Arch'}")
|
||||||
|
print("-" * 112)
|
||||||
|
for h in hosts:
|
||||||
|
release = h.release or "-"
|
||||||
|
print(f"{h.ip:<16} {h.name[:24]:<24} {h.version[:18]:<18} {release[:30]:<30} {h.arch}")
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_discover(args: argparse.Namespace) -> int:
|
||||||
|
nets = parse_networks(args.subnet)
|
||||||
|
if not nets:
|
||||||
|
print("No private IPv4 networks found. Pass --subnet explicitly.")
|
||||||
|
return 2
|
||||||
|
|
||||||
|
print("Scanning networks:", ", ".join(str(n) for n in nets))
|
||||||
|
hosts = discover_hosts(nets=nets, timeout_s=args.timeout, workers=args.workers)
|
||||||
|
print_hosts(hosts)
|
||||||
|
|
||||||
|
if args.output:
|
||||||
|
out = Path(args.output)
|
||||||
|
write_discovery(out, hosts)
|
||||||
|
print(f"Saved discovery list: {out}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_targets(args: argparse.Namespace) -> list[str]:
|
||||||
|
targets: list[str] = []
|
||||||
|
|
||||||
|
if args.targets:
|
||||||
|
targets.extend([t.strip() for t in args.targets.split(",") if t.strip()])
|
||||||
|
|
||||||
|
if args.targets_file:
|
||||||
|
targets.extend(read_targets_file(Path(args.targets_file)))
|
||||||
|
|
||||||
|
if args.discover:
|
||||||
|
nets = parse_networks(args.subnet)
|
||||||
|
hosts = discover_hosts(nets=nets, timeout_s=args.timeout, workers=args.workers)
|
||||||
|
targets.extend([h.ip for h in hosts])
|
||||||
|
|
||||||
|
unique: list[str] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for t in targets:
|
||||||
|
if t in seen:
|
||||||
|
continue
|
||||||
|
seen.add(t)
|
||||||
|
unique.append(t)
|
||||||
|
return unique
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_flash(args: argparse.Namespace) -> int:
|
||||||
|
firmware = Path(args.firmware)
|
||||||
|
if not firmware.exists():
|
||||||
|
print(f"Firmware file not found: {firmware}")
|
||||||
|
return 2
|
||||||
|
|
||||||
|
targets = resolve_targets(args)
|
||||||
|
if not targets:
|
||||||
|
print("No targets selected. Use --targets, --targets-file, or --discover.")
|
||||||
|
return 2
|
||||||
|
if args.start_from:
|
||||||
|
if args.start_from not in targets:
|
||||||
|
print(f"--start-from target not found in target set: {args.start_from}")
|
||||||
|
return 2
|
||||||
|
start_idx = targets.index(args.start_from)
|
||||||
|
targets = targets[start_idx:]
|
||||||
|
|
||||||
|
print(f"Flashing {len(targets)} device(s) sequentially with: {firmware}")
|
||||||
|
failures: list[str] = []
|
||||||
|
|
||||||
|
for idx, ip in enumerate(targets, start=1):
|
||||||
|
check = preflight(ip=ip, firmware=firmware, timeout_s=args.timeout)
|
||||||
|
print_preflight(idx, len(targets), ip, check)
|
||||||
|
before = check.info
|
||||||
|
if args.preflight_only:
|
||||||
|
if before is not None and args.expect_release:
|
||||||
|
release_ok, release_reason = release_matches(before.release, args.expect_release)
|
||||||
|
level = "ok" if release_ok else "warning"
|
||||||
|
print(f"[{idx}/{len(targets)}] {ip}: current release check: {level} ({release_reason})")
|
||||||
|
if before is None or check.update_status in ("blocked", "unreachable"):
|
||||||
|
print(f"[{idx}/{len(targets)}] {ip}: FAILED (preflight did not prove OTA readiness)")
|
||||||
|
failures.append(ip)
|
||||||
|
continue
|
||||||
|
if check.update_status == "blocked" and not args.ignore_preflight_warnings:
|
||||||
|
print(f"[{idx}/{len(targets)}] {ip}: FAILED (OTA preflight is blocked; use --ignore-preflight-warnings to try anyway)")
|
||||||
|
failures.append(ip)
|
||||||
|
continue
|
||||||
|
if args.skip_if_release_matches and before is not None and args.expect_release:
|
||||||
|
release_ok, release_reason = release_matches(before.release, args.expect_release)
|
||||||
|
if release_ok:
|
||||||
|
print(f"[{idx}/{len(targets)}] {ip}: SKIP ({release_reason}; use --force-same-release to reflash anyway)")
|
||||||
|
continue
|
||||||
|
|
||||||
|
print(f"[{idx}/{len(targets)}] {ip}: uploading...")
|
||||||
|
status, msg = ota_flash(
|
||||||
|
ip=ip,
|
||||||
|
firmware=firmware,
|
||||||
|
connect_timeout_s=args.connect_timeout,
|
||||||
|
read_timeout_s=args.upload_timeout,
|
||||||
|
skip_validation=args.skip_validation,
|
||||||
|
backend=args.upload_backend,
|
||||||
|
)
|
||||||
|
if status == "failed":
|
||||||
|
print(f"[{idx}/{len(targets)}] {ip}: FAILED ({msg})")
|
||||||
|
failures.append(ip)
|
||||||
|
continue
|
||||||
|
|
||||||
|
transport_reset_seen = status == "transport_reset"
|
||||||
|
if status in ("uncertain", "transport_reset"):
|
||||||
|
print(f"[{idx}/{len(targets)}] {ip}: upload response uncertain ({msg}), verifying via reboot check...")
|
||||||
|
else:
|
||||||
|
print(f"[{idx}/{len(targets)}] {ip}: uploaded, waiting {args.reboot_wait:.1f}s for reboot...")
|
||||||
|
time.sleep(args.reboot_wait)
|
||||||
|
|
||||||
|
forced_reboot_used = False
|
||||||
|
offline_seen = wait_for_offline(ip=ip, timeout_s=args.offline_timeout, interval_s=0.5)
|
||||||
|
if offline_seen:
|
||||||
|
print(f"[{idx}/{len(targets)}] {ip}: reboot detected, device went offline.")
|
||||||
|
else:
|
||||||
|
print(f"[{idx}/{len(targets)}] {ip}: warning, no offline transition observed. Checking uptime reset...")
|
||||||
|
if args.force_reboot_after_upload:
|
||||||
|
reboot_sent, reboot_msg = request_reboot(ip=ip, timeout_s=args.timeout)
|
||||||
|
print(f"[{idx}/{len(targets)}] {ip}: forced reboot fallback: {reboot_msg}")
|
||||||
|
if reboot_sent:
|
||||||
|
forced_reboot_used = True
|
||||||
|
time.sleep(args.reboot_wait)
|
||||||
|
offline_seen = wait_for_offline(ip=ip, timeout_s=args.offline_timeout, interval_s=0.5)
|
||||||
|
if offline_seen:
|
||||||
|
print(f"[{idx}/{len(targets)}] {ip}: forced reboot detected, device went offline.")
|
||||||
|
|
||||||
|
after = wait_for_online_info(ip=ip, timeout_s=args.online_timeout, interval_s=1.0)
|
||||||
|
if after is None:
|
||||||
|
print(f"[{idx}/{len(targets)}] {ip}: no online response after upload window.")
|
||||||
|
failures.append(ip)
|
||||||
|
continue
|
||||||
|
|
||||||
|
reboot_ok, reason = reboot_confirmed(
|
||||||
|
before=before,
|
||||||
|
after=after,
|
||||||
|
offline_seen=offline_seen,
|
||||||
|
transport_reset_seen=transport_reset_seen,
|
||||||
|
expected_release=args.expect_release,
|
||||||
|
)
|
||||||
|
if not reboot_ok:
|
||||||
|
print(f"[{idx}/{len(targets)}] {ip}: FAILED (could not prove reboot: {reason})")
|
||||||
|
failures.append(ip)
|
||||||
|
continue
|
||||||
|
|
||||||
|
release_ok, release_reason = release_matches(after.release, args.expect_release)
|
||||||
|
if not release_ok:
|
||||||
|
print(f"[{idx}/{len(targets)}] {ip}: FAILED ({release_reason})")
|
||||||
|
failures.append(ip)
|
||||||
|
continue
|
||||||
|
|
||||||
|
print(
|
||||||
|
f"[{idx}/{len(targets)}] {ip}: OK "
|
||||||
|
f"(now {after.version}, release '{after.release or '-'}', uptime {after.uptime_s}s, {reason}, {release_reason})"
|
||||||
|
)
|
||||||
|
if forced_reboot_used:
|
||||||
|
print(f"[{idx}/{len(targets)}] {ip}: warning, reboot was forced after an uncertain upload; verify the firmware manually in /json/info")
|
||||||
|
|
||||||
|
if failures:
|
||||||
|
print("\nFailed targets:")
|
||||||
|
for ip in failures:
|
||||||
|
print(f"- {ip}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
if args.preflight_only:
|
||||||
|
print("\nAll targets passed preflight.")
|
||||||
|
else:
|
||||||
|
print("\nAll targets flashed successfully.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
|
parser = argparse.ArgumentParser(description="Discover and OTA-flash WLED devices.")
|
||||||
|
sub = parser.add_subparsers(dest="cmd", required=True)
|
||||||
|
|
||||||
|
p_discover = sub.add_parser("discover", help="Scan network(s) for WLED devices.")
|
||||||
|
p_discover.add_argument("--subnet", action="append", help="Subnet CIDR, repeatable (example: 192.168.1.0/24).")
|
||||||
|
p_discover.add_argument("--timeout", type=float, default=0.8, help="HTTP probe timeout in seconds.")
|
||||||
|
p_discover.add_argument("--workers", type=int, default=128, help="Parallel probe workers.")
|
||||||
|
p_discover.add_argument("--output", default=DEFAULT_OUTPUT_FILE, help="Output file for discovered hosts.")
|
||||||
|
p_discover.set_defaults(func=cmd_discover)
|
||||||
|
|
||||||
|
p_flash = sub.add_parser("flash", help="Flash firmware.bin to selected hosts sequentially.")
|
||||||
|
p_flash.add_argument("--firmware", required=True, help="Path to firmware.bin")
|
||||||
|
p_flash.add_argument("--targets", help="Comma-separated IP list")
|
||||||
|
p_flash.add_argument("--targets-file", help="Text file with one IP per line")
|
||||||
|
p_flash.add_argument("--discover", action="store_true", help="Discover targets before flashing")
|
||||||
|
p_flash.add_argument("--subnet", action="append", help="Subnet CIDR for discovery mode")
|
||||||
|
p_flash.add_argument("--start-from", help="Start flashing from this IP within the resolved target list")
|
||||||
|
p_flash.add_argument("--timeout", type=float, default=0.8, help="HTTP probe timeout in seconds")
|
||||||
|
p_flash.add_argument("--workers", type=int, default=128, help="Parallel probe workers for discovery")
|
||||||
|
p_flash.add_argument("--connect-timeout", type=float, default=5.0, help="HTTP connect timeout in seconds")
|
||||||
|
p_flash.add_argument("--upload-timeout", type=float, default=90.0, help="HTTP upload timeout in seconds")
|
||||||
|
p_flash.add_argument("--reboot-wait", type=float, default=10.0, help="Sleep after upload before online check")
|
||||||
|
p_flash.add_argument("--offline-timeout", type=float, default=20.0, help="How long to wait for the device to disappear during reboot")
|
||||||
|
p_flash.add_argument("--online-timeout", type=float, default=60.0, help="How long to wait for device to come back")
|
||||||
|
p_flash.add_argument("--preflight-only", action="store_true", help="Only print /json/info and /update diagnostics, do not upload")
|
||||||
|
p_flash.add_argument("--ignore-preflight-warnings", action="store_true", help="Try upload even if /update preflight looks blocked")
|
||||||
|
p_flash.add_argument("--force-reboot-after-upload", action="store_true", help="After uncertain upload with no offline transition, request reboot via /json/state and verify uptime")
|
||||||
|
p_flash.add_argument("--expect-release", help="Require /json/info release/rel to match this value after OTA")
|
||||||
|
p_flash.add_argument("--skip-if-release-matches", action="store_true", help="Skip a target when its current release already matches --expect-release")
|
||||||
|
p_flash.add_argument("--force-same-release", dest="skip_if_release_matches", action="store_false", help="Reflash even when the current release already matches --expect-release")
|
||||||
|
p_flash.add_argument("--skip-validation", action="store_true", help="Send WLED skipValidation=1 for controlled migrations between release names")
|
||||||
|
p_flash.add_argument("--upload-backend", choices=("curl", "requests"), default="curl", help="HTTP upload implementation (default: curl, matching WLED helper scripts)")
|
||||||
|
p_flash.set_defaults(skip_if_release_matches=False)
|
||||||
|
p_flash.set_defaults(func=cmd_flash)
|
||||||
|
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = build_parser()
|
||||||
|
args = parser.parse_args()
|
||||||
|
try:
|
||||||
|
return int(args.func(args))
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\nAborted by user (Ctrl+C).")
|
||||||
|
print("Tip: rerun with --start-from <ip> to continue at a specific device.")
|
||||||
|
return 130
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
os.environ.setdefault("PYTHONUNBUFFERED", "1")
|
||||||
|
raise SystemExit(main())
|
||||||
277
tools/rfp_update_all_ota.py
Executable file
277
tools/rfp_update_all_ota.py
Executable file
@@ -0,0 +1,277 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Build and OTA-update the full RFP Infinity installation.
|
||||||
|
|
||||||
|
This script intentionally delegates flashing to tools/rfp_network_flash.py so
|
||||||
|
the actual OTA path stays the existing WLED /update workflow.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import ipaddress
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
NODE_ENV = "rfp_esp32s3_wroom1_n16r8_3x106"
|
||||||
|
MASTER_ENV = "rfp_esp32s3_wroom1_n16r8_master"
|
||||||
|
NODE_RELEASE = "RFP_N16R8_NODE3x106_V20260511E"
|
||||||
|
MASTER_RELEASE = "RFP_N16R8_MASTER_V20260511E"
|
||||||
|
NODE_FIRMWARE_FALLBACK = Path(".pio/build") / NODE_ENV / "firmware.bin"
|
||||||
|
MASTER_FIRMWARE_FALLBACK = Path(".pio/build") / MASTER_ENV / "firmware.bin"
|
||||||
|
DEFAULT_SUBNET = "192.168.178.0/24"
|
||||||
|
NODE_HOSTS = range(11, 17)
|
||||||
|
MASTER_HOST = 10
|
||||||
|
|
||||||
|
|
||||||
|
def repo_root() -> Path:
|
||||||
|
return Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
|
def python_executable(root: Path) -> str:
|
||||||
|
local_python = root / ".venv/bin/python"
|
||||||
|
return str(local_python) if local_python.exists() else sys.executable
|
||||||
|
|
||||||
|
|
||||||
|
def platformio_env(root: Path) -> dict[str, str]:
|
||||||
|
env = os.environ.copy()
|
||||||
|
node_bin = "/home/jan/Documents/RFP/Finanz_App/node/current/bin"
|
||||||
|
env["PATH"] = f"{node_bin}:{env.get('PATH', '')}"
|
||||||
|
env["NPM_CONFIG_CACHE"] = str(root / ".npm-cache")
|
||||||
|
env["PLATFORMIO_CORE_DIR"] = str(root / ".piohome")
|
||||||
|
env["PLATFORMIO_PACKAGES_DIR"] = str(root / ".piohome/packages")
|
||||||
|
env["PLATFORMIO_PLATFORMS_DIR"] = str(root / ".piohome/platforms")
|
||||||
|
env["PLATFORMIO_CACHE_DIR"] = str(root / ".piohome/.cache")
|
||||||
|
env["PLATFORMIO_BUILD_CACHE_DIR"] = str(root / ".piohome/buildcache")
|
||||||
|
return env
|
||||||
|
|
||||||
|
|
||||||
|
def targets_from_subnet(subnet: str) -> tuple[list[str], str]:
|
||||||
|
network = ipaddress.ip_network(subnet, strict=False)
|
||||||
|
if network.version != 4:
|
||||||
|
raise ValueError("Only IPv4 subnets are supported.")
|
||||||
|
octets = str(network.network_address).split(".")
|
||||||
|
if len(octets) != 4:
|
||||||
|
raise ValueError(f"Invalid IPv4 subnet: {subnet}")
|
||||||
|
prefix = ".".join(octets[:3])
|
||||||
|
return [f"{prefix}.{host}" for host in NODE_HOSTS], f"{prefix}.{MASTER_HOST}"
|
||||||
|
|
||||||
|
|
||||||
|
def run(cmd: list[str], root: Path, env: dict[str, str], dry_run: bool) -> None:
|
||||||
|
print("+ " + " ".join(cmd))
|
||||||
|
if dry_run:
|
||||||
|
return
|
||||||
|
subprocess.run(cmd, cwd=root, env=env, check=True)
|
||||||
|
|
||||||
|
|
||||||
|
def build(root: Path, env_name: str, env: dict[str, str], dry_run: bool) -> None:
|
||||||
|
run([python_executable(root), "-m", "platformio", "run", "-e", env_name], root, env, dry_run)
|
||||||
|
|
||||||
|
|
||||||
|
def release_firmware(root: Path, expected_release: str, fallback: Path) -> Path:
|
||||||
|
release_dir = root / "build_output/release"
|
||||||
|
matches = sorted(release_dir.glob(f"WLEDMM_*_{expected_release}.bin"))
|
||||||
|
if len(matches) == 1:
|
||||||
|
return matches[0].relative_to(root)
|
||||||
|
if len(matches) > 1:
|
||||||
|
names = ", ".join(str(path.relative_to(root)) for path in matches)
|
||||||
|
raise RuntimeError(f"Multiple release firmware files match {expected_release}: {names}")
|
||||||
|
return fallback
|
||||||
|
|
||||||
|
|
||||||
|
def flash_one(
|
||||||
|
root: Path,
|
||||||
|
target: str,
|
||||||
|
firmware: Path,
|
||||||
|
expected_release: str,
|
||||||
|
env: dict[str, str],
|
||||||
|
args: argparse.Namespace,
|
||||||
|
) -> None:
|
||||||
|
firmware_path = root / firmware
|
||||||
|
if not args.dry_run and not firmware_path.exists():
|
||||||
|
raise FileNotFoundError(f"Firmware does not exist: {firmware_path}")
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
python_executable(root),
|
||||||
|
"tools/rfp_network_flash.py",
|
||||||
|
"flash",
|
||||||
|
"--targets",
|
||||||
|
target,
|
||||||
|
"--firmware",
|
||||||
|
str(firmware),
|
||||||
|
"--expect-release",
|
||||||
|
expected_release,
|
||||||
|
]
|
||||||
|
if args.preflight_only:
|
||||||
|
cmd.append("--preflight-only")
|
||||||
|
if args.skip_validation and not args.preflight_only:
|
||||||
|
cmd.append("--skip-validation")
|
||||||
|
if args.force_reboot_after_upload:
|
||||||
|
cmd.append("--force-reboot-after-upload")
|
||||||
|
if args.ignore_preflight_warnings:
|
||||||
|
cmd.append("--ignore-preflight-warnings")
|
||||||
|
if args.skip_current_release and not args.preflight_only:
|
||||||
|
cmd.append("--skip-if-release-matches")
|
||||||
|
if args.upload_timeout is not None:
|
||||||
|
cmd += ["--upload-timeout", str(args.upload_timeout)]
|
||||||
|
run(cmd, root, env, args.dry_run)
|
||||||
|
|
||||||
|
|
||||||
|
def flash(
|
||||||
|
root: Path,
|
||||||
|
targets: list[str],
|
||||||
|
firmware: Path,
|
||||||
|
expected_release: str,
|
||||||
|
env: dict[str, str],
|
||||||
|
args: argparse.Namespace,
|
||||||
|
) -> None:
|
||||||
|
if not targets:
|
||||||
|
return
|
||||||
|
for target in targets:
|
||||||
|
try:
|
||||||
|
flash_one(root, target, firmware, expected_release, env, args)
|
||||||
|
except subprocess.CalledProcessError as exc:
|
||||||
|
print()
|
||||||
|
print(f"OTA failed at {target}; stopping before continuing to the remaining devices.")
|
||||||
|
print("After fixing the cause, resume with:")
|
||||||
|
print(f" {python_executable(root)} tools/rfp_update_all_ota.py --no-build --start-from {target}")
|
||||||
|
raise exc
|
||||||
|
|
||||||
|
|
||||||
|
def flash_nodes_via_master_usb(
|
||||||
|
root: Path,
|
||||||
|
targets: list[str],
|
||||||
|
firmware: Path,
|
||||||
|
expected_release: str,
|
||||||
|
env: dict[str, str],
|
||||||
|
args: argparse.Namespace,
|
||||||
|
) -> None:
|
||||||
|
if not targets:
|
||||||
|
return
|
||||||
|
cmd = [
|
||||||
|
python_executable(root),
|
||||||
|
"tools/rfp_master_usb_relay.py",
|
||||||
|
"--port",
|
||||||
|
args.port,
|
||||||
|
"--firmware",
|
||||||
|
str(firmware),
|
||||||
|
"--expect-release",
|
||||||
|
expected_release,
|
||||||
|
"--targets",
|
||||||
|
",".join(targets),
|
||||||
|
"--baud",
|
||||||
|
str(args.relay_baud),
|
||||||
|
"--startup-delay",
|
||||||
|
str(args.relay_startup_delay),
|
||||||
|
]
|
||||||
|
if args.start_from:
|
||||||
|
cmd += ["--start-from", args.start_from]
|
||||||
|
if not args.skip_current_release:
|
||||||
|
cmd.append("--force-current-release")
|
||||||
|
run(cmd, root, env, args.dry_run)
|
||||||
|
|
||||||
|
|
||||||
|
def filtered_plan(nodes: list[str], master: str, args: argparse.Namespace) -> list[tuple[str, str]]:
|
||||||
|
plan: list[tuple[str, str]] = []
|
||||||
|
if not args.master_only:
|
||||||
|
plan.extend((ip, "node") for ip in nodes)
|
||||||
|
if not args.nodes_only:
|
||||||
|
plan.append((master, "master"))
|
||||||
|
|
||||||
|
if not args.start_from:
|
||||||
|
return plan
|
||||||
|
|
||||||
|
for index, (ip, _role) in enumerate(plan):
|
||||||
|
if ip == args.start_from:
|
||||||
|
return plan[index:]
|
||||||
|
raise ValueError(f"--start-from {args.start_from} is not in the selected update plan.")
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args() -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(description="Build and OTA-update RFP Infinity nodes, then master.")
|
||||||
|
role = parser.add_mutually_exclusive_group()
|
||||||
|
role.add_argument("--nodes-only", action="store_true", help="Only build/flash nodes .11-.16")
|
||||||
|
role.add_argument("--master-only", action="store_true", help="Only build/flash master .10")
|
||||||
|
parser.add_argument("--start-from", help="Resume at this IP, for example 192.168.178.14 or 192.168.178.10")
|
||||||
|
parser.add_argument("--subnet", default=DEFAULT_SUBNET, help=f"Show subnet used to derive .10-.16 targets (default: {DEFAULT_SUBNET})")
|
||||||
|
parser.add_argument("--no-build", action="store_true", help="Skip PlatformIO builds and flash existing firmware.bin files")
|
||||||
|
parser.add_argument("--dry-run", action="store_true", help="Print build and flash steps without executing them")
|
||||||
|
parser.add_argument("--preflight-only", action="store_true", help="Only run per-device OTA diagnostics, do not upload")
|
||||||
|
parser.add_argument("--via-master-usb", action="store_true", help="Update nodes through the USB-connected master instead of direct PC-to-node HTTP")
|
||||||
|
parser.add_argument("--port", default="/dev/ttyACM0", help="Master serial port for --via-master-usb")
|
||||||
|
parser.add_argument("--relay-baud", type=int, default=921600, help="Master USB relay baud rate (default: 921600)")
|
||||||
|
parser.add_argument("--relay-startup-delay", type=float, default=4.0, help="Seconds to wait after opening master USB serial (default: 4)")
|
||||||
|
parser.add_argument("--ignore-preflight-warnings", action="store_true", help="Try OTA even if /update preflight looks blocked")
|
||||||
|
parser.add_argument("--force-reboot-after-upload", action="store_true", help="Request a reboot if upload response is uncertain and no reboot is observed")
|
||||||
|
parser.add_argument("--force-current-release", dest="skip_current_release", action="store_false", help="Reflash devices even when their current release already matches the selected firmware")
|
||||||
|
parser.add_argument("--upload-timeout", type=float, default=180.0, help="Per-device OTA upload timeout in seconds (default: 180)")
|
||||||
|
parser.add_argument("--no-skip-validation", dest="skip_validation", action="store_false", help="Do not send WLED skipValidation=1 during upload")
|
||||||
|
parser.set_defaults(skip_validation=True, skip_current_release=True)
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
args = parse_args()
|
||||||
|
root = repo_root()
|
||||||
|
env = platformio_env(root)
|
||||||
|
nodes, master = targets_from_subnet(args.subnet)
|
||||||
|
plan = filtered_plan(nodes, master, args)
|
||||||
|
node_targets = [ip for ip, role in plan if role == "node"]
|
||||||
|
master_targets = [ip for ip, role in plan if role == "master"]
|
||||||
|
|
||||||
|
if not plan:
|
||||||
|
print("Nothing selected.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
print("RFP Infinity OTA update plan:")
|
||||||
|
for ip, role in plan:
|
||||||
|
print(f"- {ip} ({role})")
|
||||||
|
print()
|
||||||
|
|
||||||
|
if not args.no_build:
|
||||||
|
if node_targets:
|
||||||
|
print(f"Building node firmware: {NODE_ENV}")
|
||||||
|
build(root, NODE_ENV, env, args.dry_run)
|
||||||
|
if master_targets:
|
||||||
|
print(f"Building master firmware: {MASTER_ENV}")
|
||||||
|
build(root, MASTER_ENV, env, args.dry_run)
|
||||||
|
|
||||||
|
node_firmware = release_firmware(root, NODE_RELEASE, NODE_FIRMWARE_FALLBACK)
|
||||||
|
master_firmware = release_firmware(root, MASTER_RELEASE, MASTER_FIRMWARE_FALLBACK)
|
||||||
|
print(f"Node firmware: {node_firmware} (expect release {NODE_RELEASE})")
|
||||||
|
print(f"Master firmware: {master_firmware} (expect release {MASTER_RELEASE})")
|
||||||
|
if args.skip_validation and not args.preflight_only:
|
||||||
|
print("WLED OTA release validation: skipValidation=1 for controlled RFP release-name migration")
|
||||||
|
if args.skip_current_release and not args.preflight_only:
|
||||||
|
print("Already matching devices: skipped by default; use --force-current-release to reflash them")
|
||||||
|
print()
|
||||||
|
|
||||||
|
if node_targets:
|
||||||
|
if args.via_master_usb:
|
||||||
|
if args.preflight_only:
|
||||||
|
print("USB master relay preflight is not separate; run without --preflight-only to query through the master.")
|
||||||
|
else:
|
||||||
|
print("Flashing nodes sequentially via USB-connected master...")
|
||||||
|
flash_nodes_via_master_usb(root, node_targets, node_firmware, NODE_RELEASE, env, args)
|
||||||
|
else:
|
||||||
|
print(("Preflighting" if args.preflight_only else "Flashing") + " nodes sequentially...")
|
||||||
|
flash(root, node_targets, node_firmware, NODE_RELEASE, env, args)
|
||||||
|
if master_targets:
|
||||||
|
if args.via_master_usb:
|
||||||
|
print(
|
||||||
|
"Skipping master firmware in --via-master-usb mode. "
|
||||||
|
"For full updates, use tools/rfp_update_master_usb_then_nodes.py "
|
||||||
|
"to flash the master by USB first and then relay node OTA."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
print(("Preflighting" if args.preflight_only else "Flashing") + " master last...")
|
||||||
|
flash(root, master_targets, master_firmware, MASTER_RELEASE, env, args)
|
||||||
|
|
||||||
|
print("OTA update plan completed.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
272
tools/rfp_update_master_usb_then_nodes.py
Executable file
272
tools/rfp_update_master_usb_then_nodes.py
Executable file
@@ -0,0 +1,272 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Flash the USB-connected master first, then update nodes through it.
|
||||||
|
|
||||||
|
This is the preferred RFP show update path when the laptop is connected to the
|
||||||
|
master by USB but does not need to join the RFP Wi-Fi. It intentionally avoids
|
||||||
|
`erase_flash` and `uploadfs`; normal updates only replace bootloader,
|
||||||
|
partition table, boot_app0 and the app image.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import glob
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
TOOLS_DIR = Path(__file__).resolve().parent
|
||||||
|
if str(TOOLS_DIR) not in sys.path:
|
||||||
|
sys.path.insert(0, str(TOOLS_DIR))
|
||||||
|
|
||||||
|
import rfp_update_all_ota as ota # noqa: E402
|
||||||
|
from rfp_master_usb_relay import ( # noqa: E402
|
||||||
|
DEFAULT_BAUD as DEFAULT_RELAY_BAUD,
|
||||||
|
DEFAULT_STARTUP_DELAY as DEFAULT_RELAY_STARTUP_DELAY,
|
||||||
|
master_info,
|
||||||
|
open_master_serial,
|
||||||
|
relay_ota,
|
||||||
|
release_from_info,
|
||||||
|
wait_for_release,
|
||||||
|
)
|
||||||
|
|
||||||
|
DEFAULT_FLASH_BAUD = 460800
|
||||||
|
BOOT_APP0 = Path(".piohome/packages/framework-arduinoespressif32/tools/partitions/boot_app0.bin")
|
||||||
|
|
||||||
|
|
||||||
|
def detect_port() -> str:
|
||||||
|
ports = sorted(glob.glob("/dev/ttyACM*") + glob.glob("/dev/ttyUSB*"))
|
||||||
|
if not ports:
|
||||||
|
raise RuntimeError("Kein /dev/ttyACM* oder /dev/ttyUSB* gefunden")
|
||||||
|
return ports[0]
|
||||||
|
|
||||||
|
|
||||||
|
def run(cmd: list[str], root: Path, env: dict[str, str], dry_run: bool) -> None:
|
||||||
|
print("+ " + " ".join(cmd))
|
||||||
|
if dry_run:
|
||||||
|
return
|
||||||
|
subprocess.run(cmd, cwd=root, env=env, check=True)
|
||||||
|
|
||||||
|
|
||||||
|
def maybe_stop_modemmanager(root: Path, env: dict[str, str], args: argparse.Namespace) -> None:
|
||||||
|
if args.no_stop_modemmanager:
|
||||||
|
return
|
||||||
|
print("Stopping ModemManager temporarily, if present...")
|
||||||
|
cmd = ["sudo", "systemctl", "stop", "ModemManager"]
|
||||||
|
print("+ " + " ".join(cmd))
|
||||||
|
if not args.dry_run:
|
||||||
|
subprocess.run(cmd, cwd=root, env=env, check=False)
|
||||||
|
|
||||||
|
|
||||||
|
def build_selected(root: Path, env: dict[str, str], args: argparse.Namespace, node_targets: list[str]) -> None:
|
||||||
|
if args.no_build:
|
||||||
|
return
|
||||||
|
if not args.nodes_only:
|
||||||
|
print(f"Building master firmware: {ota.MASTER_ENV}")
|
||||||
|
ota.build(root, ota.MASTER_ENV, env, args.dry_run)
|
||||||
|
if node_targets and not args.master_only:
|
||||||
|
print(f"Building node firmware: {ota.NODE_ENV}")
|
||||||
|
ota.build(root, ota.NODE_ENV, env, args.dry_run)
|
||||||
|
|
||||||
|
|
||||||
|
def require_file(root: Path, path: Path, label: str, dry_run: bool) -> None:
|
||||||
|
if dry_run:
|
||||||
|
return
|
||||||
|
full = root / path
|
||||||
|
if not full.exists():
|
||||||
|
raise FileNotFoundError(f"{label} fehlt: {full}")
|
||||||
|
|
||||||
|
|
||||||
|
def wait_for_port(port: str, timeout_s: float, dry_run: bool) -> None:
|
||||||
|
if dry_run:
|
||||||
|
return
|
||||||
|
deadline = time.monotonic() + timeout_s
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
if Path(port).exists():
|
||||||
|
return
|
||||||
|
time.sleep(0.25)
|
||||||
|
raise TimeoutError(f"Serial-Port {port} ist nach {timeout_s:.0f}s nicht wieder erschienen")
|
||||||
|
|
||||||
|
|
||||||
|
def flash_master_usb(root: Path, master_firmware: Path, env: dict[str, str], args: argparse.Namespace) -> None:
|
||||||
|
build_dir = Path(".pio/build") / ota.MASTER_ENV
|
||||||
|
bootloader = build_dir / "bootloader.bin"
|
||||||
|
partitions = build_dir / "partitions.bin"
|
||||||
|
|
||||||
|
require_file(root, bootloader, "Master bootloader.bin", args.dry_run)
|
||||||
|
require_file(root, partitions, "Master partitions.bin", args.dry_run)
|
||||||
|
require_file(root, BOOT_APP0, "boot_app0.bin", args.dry_run)
|
||||||
|
require_file(root, master_firmware, "Master firmware", args.dry_run)
|
||||||
|
|
||||||
|
print("Flashing master over USB without erase_flash or uploadfs...")
|
||||||
|
cmd = [
|
||||||
|
ota.python_executable(root),
|
||||||
|
".piohome/packages/tool-esptoolpy/esptool.py",
|
||||||
|
"--chip",
|
||||||
|
"esp32s3",
|
||||||
|
"--port",
|
||||||
|
args.port,
|
||||||
|
"--baud",
|
||||||
|
str(args.flash_baud),
|
||||||
|
"--before",
|
||||||
|
"default_reset",
|
||||||
|
"--after",
|
||||||
|
"hard_reset",
|
||||||
|
"write_flash",
|
||||||
|
"-z",
|
||||||
|
"--flash_mode",
|
||||||
|
"qio",
|
||||||
|
"--flash_freq",
|
||||||
|
"80m",
|
||||||
|
"--flash_size",
|
||||||
|
"16MB",
|
||||||
|
"0x0",
|
||||||
|
str(bootloader),
|
||||||
|
"0x8000",
|
||||||
|
str(partitions),
|
||||||
|
"0xe000",
|
||||||
|
str(BOOT_APP0),
|
||||||
|
"0x10000",
|
||||||
|
str(master_firmware),
|
||||||
|
]
|
||||||
|
run(cmd, root, env, args.dry_run)
|
||||||
|
wait_for_port(args.port, args.port_timeout, args.dry_run)
|
||||||
|
|
||||||
|
|
||||||
|
def wait_for_master_release(ser, master_ip: str, expected_release: str, timeout_s: float) -> dict:
|
||||||
|
deadline = time.monotonic() + timeout_s
|
||||||
|
last_error = ""
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
try:
|
||||||
|
info = master_info(ser, master_ip, timeout_s=8.0)
|
||||||
|
current = release_from_info(info)
|
||||||
|
if current == expected_release:
|
||||||
|
return info
|
||||||
|
last_error = f"release is {current!r}"
|
||||||
|
except Exception as exc: # noqa: BLE001 - keep retrying while Wi-Fi starts
|
||||||
|
last_error = str(exc)
|
||||||
|
time.sleep(2.0)
|
||||||
|
raise TimeoutError(f"Master release {expected_release} wurde nicht sichtbar ({last_error})")
|
||||||
|
|
||||||
|
|
||||||
|
def update_nodes_with_open_master(ser, targets: list[str], node_firmware: Path, args: argparse.Namespace) -> None:
|
||||||
|
if not targets:
|
||||||
|
return
|
||||||
|
firmware = ota.repo_root() / node_firmware
|
||||||
|
for index, target in enumerate(targets, start=1):
|
||||||
|
print(f"[{index}/{len(targets)}] {target}: checking current release through master...")
|
||||||
|
try:
|
||||||
|
info = master_info(ser, target, timeout_s=8.0)
|
||||||
|
current_release = release_from_info(info)
|
||||||
|
print(f"[{index}/{len(targets)}] {target}: current release {current_release or '-'}")
|
||||||
|
if current_release == ota.NODE_RELEASE and not args.force_current_release:
|
||||||
|
print(f"[{index}/{len(targets)}] {target}: SKIP (release already matches)")
|
||||||
|
continue
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
print(f"[{index}/{len(targets)}] {target}: info warning: {exc}")
|
||||||
|
|
||||||
|
print(f"[{index}/{len(targets)}] {target}: streaming OTA via master...")
|
||||||
|
relay_ota(ser, target, firmware, ota.NODE_RELEASE, args.chunk_size)
|
||||||
|
info = wait_for_release(ser, target, ota.NODE_RELEASE, timeout_s=args.node_release_timeout)
|
||||||
|
print(
|
||||||
|
f"[{index}/{len(targets)}] {target}: OK "
|
||||||
|
f"(release {release_from_info(info)}, uptime {info.get('uptime', '-')}s)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def selected_node_targets(nodes: list[str], args: argparse.Namespace) -> list[str]:
|
||||||
|
if args.master_only:
|
||||||
|
return []
|
||||||
|
if not args.start_from:
|
||||||
|
return nodes
|
||||||
|
if args.start_from not in nodes:
|
||||||
|
raise ValueError(f"--start-from {args.start_from} ist keine Node-IP in diesem Plan")
|
||||||
|
return nodes[nodes.index(args.start_from) :]
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args() -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Preferred RFP update: flash master by USB first, then update nodes via the master."
|
||||||
|
)
|
||||||
|
role = parser.add_mutually_exclusive_group()
|
||||||
|
role.add_argument("--master-only", action="store_true", help="Only flash and verify the USB-connected master")
|
||||||
|
role.add_argument("--nodes-only", action="store_true", help="Skip master flashing and only update nodes via the USB-connected master")
|
||||||
|
parser.add_argument("--port", help="Master serial port, for example /dev/ttyACM0. Auto-detected when omitted")
|
||||||
|
parser.add_argument("--subnet", default=ota.DEFAULT_SUBNET, help=f"Show subnet (default: {ota.DEFAULT_SUBNET})")
|
||||||
|
parser.add_argument("--start-from", help="Resume node updates from this IP, for example 192.168.178.14")
|
||||||
|
parser.add_argument("--no-build", action="store_true", help="Use existing build_output/release binaries")
|
||||||
|
parser.add_argument("--dry-run", action="store_true", help="Print steps without flashing")
|
||||||
|
parser.add_argument("--flash-baud", type=int, default=DEFAULT_FLASH_BAUD, help=f"USB esptool baud (default: {DEFAULT_FLASH_BAUD})")
|
||||||
|
parser.add_argument("--relay-baud", type=int, default=DEFAULT_RELAY_BAUD, help=f"Master relay baud (default: {DEFAULT_RELAY_BAUD})")
|
||||||
|
parser.add_argument(
|
||||||
|
"--relay-startup-delay",
|
||||||
|
type=float,
|
||||||
|
default=DEFAULT_RELAY_STARTUP_DELAY,
|
||||||
|
help=f"Seconds to wait after opening master serial (default: {DEFAULT_RELAY_STARTUP_DELAY})",
|
||||||
|
)
|
||||||
|
parser.add_argument("--master-release-timeout", type=float, default=90.0, help="Seconds to wait for master release after USB flash")
|
||||||
|
parser.add_argument("--node-release-timeout", type=float, default=150.0, help="Seconds to wait for each node release after OTA")
|
||||||
|
parser.add_argument("--port-timeout", type=float, default=20.0, help="Seconds to wait for serial port after USB reset")
|
||||||
|
parser.add_argument("--chunk-size", type=int, default=384, help="Raw firmware bytes per base64 relay chunk")
|
||||||
|
parser.add_argument("--force-current-release", action="store_true", help="Reflash nodes even when release already matches")
|
||||||
|
parser.add_argument("--no-stop-modemmanager", action="store_true", help="Do not stop ModemManager before USB flashing")
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
args = parse_args()
|
||||||
|
root = ota.repo_root()
|
||||||
|
env = ota.platformio_env(root)
|
||||||
|
nodes, master = ota.targets_from_subnet(args.subnet)
|
||||||
|
node_targets = selected_node_targets(nodes, args)
|
||||||
|
|
||||||
|
if not args.port:
|
||||||
|
args.port = detect_port()
|
||||||
|
|
||||||
|
print("RFP Infinity USB-master update plan:")
|
||||||
|
if not args.nodes_only:
|
||||||
|
print(f"- {master} (master via USB on {args.port})")
|
||||||
|
if node_targets:
|
||||||
|
for ip in node_targets:
|
||||||
|
print(f"- {ip} (node via master relay)")
|
||||||
|
print()
|
||||||
|
|
||||||
|
build_selected(root, env, args, node_targets)
|
||||||
|
|
||||||
|
node_firmware = ota.release_firmware(root, ota.NODE_RELEASE, ota.NODE_FIRMWARE_FALLBACK)
|
||||||
|
master_firmware = ota.release_firmware(root, ota.MASTER_RELEASE, ota.MASTER_FIRMWARE_FALLBACK)
|
||||||
|
print(f"Master firmware: {master_firmware} (expect release {ota.MASTER_RELEASE})")
|
||||||
|
print(f"Node firmware: {node_firmware} (expect release {ota.NODE_RELEASE})")
|
||||||
|
print("Master USB flash: no erase_flash, no uploadfs, hard reset after app flash")
|
||||||
|
print()
|
||||||
|
|
||||||
|
if not args.nodes_only:
|
||||||
|
maybe_stop_modemmanager(root, env, args)
|
||||||
|
flash_master_usb(root, master_firmware, env, args)
|
||||||
|
|
||||||
|
if args.master_only and not node_targets:
|
||||||
|
print("Opening master serial to verify release...")
|
||||||
|
if not args.dry_run:
|
||||||
|
with open_master_serial(args.port, args.relay_baud, args.relay_startup_delay) as ser:
|
||||||
|
info = wait_for_master_release(ser, master, ota.MASTER_RELEASE, args.master_release_timeout)
|
||||||
|
print(f"Master OK (release {release_from_info(info)}, uptime {info.get('uptime', '-')}s)")
|
||||||
|
print("Master USB update completed.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
print(f"Opening master serial relay on {args.port} at {args.relay_baud} baud")
|
||||||
|
if args.dry_run:
|
||||||
|
print("Dry run complete.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
with open_master_serial(args.port, args.relay_baud, args.relay_startup_delay) as ser:
|
||||||
|
info = wait_for_master_release(ser, master, ota.MASTER_RELEASE, args.master_release_timeout)
|
||||||
|
print(f"Master OK (release {release_from_info(info)}, uptime {info.get('uptime', '-')}s)")
|
||||||
|
update_nodes_with_open_master(ser, node_targets, node_firmware, args)
|
||||||
|
|
||||||
|
print("USB-master-first update completed.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
36
tools/setup_rfp_env.sh
Executable file
36
tools/setup_rfp_env.sh
Executable file
@@ -0,0 +1,36 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
NODE_BIN="/home/jan/Documents/RFP/Finanz_App/node/current/bin"
|
||||||
|
ENV_NAME="rfp_esp32s3_wroom1_n16r8_3x106"
|
||||||
|
|
||||||
|
cd "$ROOT_DIR"
|
||||||
|
|
||||||
|
if [[ ! -x ".venv/bin/python" ]]; then
|
||||||
|
python3 -m venv .venv
|
||||||
|
fi
|
||||||
|
|
||||||
|
.venv/bin/pip install -r requirements.txt
|
||||||
|
|
||||||
|
if [[ -x "${NODE_BIN}/node" ]]; then
|
||||||
|
export PATH="${NODE_BIN}:$PATH"
|
||||||
|
fi
|
||||||
|
|
||||||
|
export NPM_CONFIG_CACHE="$ROOT_DIR/.npm-cache"
|
||||||
|
npm ci
|
||||||
|
|
||||||
|
export PLATFORMIO_CORE_DIR="$ROOT_DIR/.piohome"
|
||||||
|
export PLATFORMIO_PACKAGES_DIR="$ROOT_DIR/.piohome/packages"
|
||||||
|
export PLATFORMIO_PLATFORMS_DIR="$ROOT_DIR/.piohome/platforms"
|
||||||
|
export PLATFORMIO_CACHE_DIR="$ROOT_DIR/.piohome/.cache"
|
||||||
|
export PLATFORMIO_BUILD_CACHE_DIR="$ROOT_DIR/.piohome/buildcache"
|
||||||
|
|
||||||
|
.venv/bin/python -m platformio run -e "$ENV_NAME"
|
||||||
|
|
||||||
|
cat <<'EOF'
|
||||||
|
Setup complete.
|
||||||
|
|
||||||
|
Firmware output:
|
||||||
|
.pio/build/rfp_esp32s3_wroom1_n16r8_3x106/firmware.bin
|
||||||
|
EOF
|
||||||
@@ -12154,6 +12154,7 @@ static const char _data_RESERVED[] PROGMEM = "RSVD";
|
|||||||
// use id==255 to find unallocated gaps (with "Reserved" data string)
|
// use id==255 to find unallocated gaps (with "Reserved" data string)
|
||||||
// if vector size() is smaller than id (single) data is appended at the end (regardless of id)
|
// if vector size() is smaller than id (single) data is appended at the end (regardless of id)
|
||||||
void WS2812FX::addEffect(uint8_t id, mode_ptr mode_fn, const char *mode_name) {
|
void WS2812FX::addEffect(uint8_t id, mode_ptr mode_fn, const char *mode_name) {
|
||||||
|
if (!rfpEffectIsAllowed(id)) return;
|
||||||
if ((id < _mode.size()) && (_modeData[id] != _data_RESERVED)) {
|
if ((id < _mode.size()) && (_modeData[id] != _data_RESERVED)) {
|
||||||
DEBUG_PRINTF("addEffect(%d) -> ", id);
|
DEBUG_PRINTF("addEffect(%d) -> ", id);
|
||||||
DEBUG_PRINTF(" already in use, finding a new slot for -> %s\n", mode_name);
|
DEBUG_PRINTF(" already in use, finding a new slot for -> %s\n", mode_name);
|
||||||
|
|||||||
93
wled00/FX.h
93
wled00/FX.h
@@ -402,6 +402,99 @@ static uint8_t strip_getPaletteBlend(); // forward declaration: little helper t
|
|||||||
#define FX_MODE_COLORCLOUDS 229
|
#define FX_MODE_COLORCLOUDS 229
|
||||||
#define MODE_COUNT 230
|
#define MODE_COUNT 230
|
||||||
|
|
||||||
|
#if defined(WLED_ENABLE_INFINITY_CONTROLLER)
|
||||||
|
inline bool rfpEffectIsAllowed(uint8_t fx) {
|
||||||
|
switch (fx) {
|
||||||
|
case FX_MODE_STATIC:
|
||||||
|
case FX_MODE_BLINK:
|
||||||
|
case FX_MODE_BREATH:
|
||||||
|
case FX_MODE_COLOR_WIPE:
|
||||||
|
case FX_MODE_COLOR_WIPE_RANDOM:
|
||||||
|
case FX_MODE_RANDOM_COLOR:
|
||||||
|
case FX_MODE_COLOR_SWEEP:
|
||||||
|
case FX_MODE_RAINBOW:
|
||||||
|
case FX_MODE_RAINBOW_CYCLE:
|
||||||
|
case FX_MODE_SCAN:
|
||||||
|
case FX_MODE_DUAL_SCAN:
|
||||||
|
case FX_MODE_FADE:
|
||||||
|
case FX_MODE_THEATER_CHASE:
|
||||||
|
case FX_MODE_THEATER_CHASE_RAINBOW:
|
||||||
|
case FX_MODE_RUNNING_LIGHTS:
|
||||||
|
case FX_MODE_SAW:
|
||||||
|
case FX_MODE_SPARKLE:
|
||||||
|
case FX_MODE_CHASE_COLOR:
|
||||||
|
case FX_MODE_CHASE_RANDOM:
|
||||||
|
case FX_MODE_COLOR_SWEEP_RANDOM:
|
||||||
|
case FX_MODE_RAIN:
|
||||||
|
case FX_MODE_LIGHTNING:
|
||||||
|
case FX_MODE_PRIDE_2015:
|
||||||
|
case FX_MODE_METEOR:
|
||||||
|
case FX_MODE_COLORCLOUDS:
|
||||||
|
return true;
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
inline uint8_t rfpEffectFallback() {
|
||||||
|
return FX_MODE_STATIC;
|
||||||
|
}
|
||||||
|
|
||||||
|
inline uint8_t rfpEffectSanitize(uint8_t fx) {
|
||||||
|
return rfpEffectIsAllowed(fx) ? fx : rfpEffectFallback();
|
||||||
|
}
|
||||||
|
|
||||||
|
inline uint8_t rfpEffectCount() {
|
||||||
|
return 25;
|
||||||
|
}
|
||||||
|
|
||||||
|
inline uint8_t rfpEffectAt(uint8_t index) {
|
||||||
|
static const uint8_t ids[] = {
|
||||||
|
FX_MODE_STATIC,
|
||||||
|
FX_MODE_BLINK,
|
||||||
|
FX_MODE_BREATH,
|
||||||
|
FX_MODE_COLOR_WIPE,
|
||||||
|
FX_MODE_COLOR_WIPE_RANDOM,
|
||||||
|
FX_MODE_RANDOM_COLOR,
|
||||||
|
FX_MODE_COLOR_SWEEP,
|
||||||
|
FX_MODE_RAINBOW,
|
||||||
|
FX_MODE_RAINBOW_CYCLE,
|
||||||
|
FX_MODE_SCAN,
|
||||||
|
FX_MODE_DUAL_SCAN,
|
||||||
|
FX_MODE_FADE,
|
||||||
|
FX_MODE_THEATER_CHASE,
|
||||||
|
FX_MODE_THEATER_CHASE_RAINBOW,
|
||||||
|
FX_MODE_RUNNING_LIGHTS,
|
||||||
|
FX_MODE_SAW,
|
||||||
|
FX_MODE_SPARKLE,
|
||||||
|
FX_MODE_CHASE_COLOR,
|
||||||
|
FX_MODE_CHASE_RANDOM,
|
||||||
|
FX_MODE_COLOR_SWEEP_RANDOM,
|
||||||
|
FX_MODE_RAIN,
|
||||||
|
FX_MODE_LIGHTNING,
|
||||||
|
FX_MODE_PRIDE_2015,
|
||||||
|
FX_MODE_METEOR,
|
||||||
|
FX_MODE_COLORCLOUDS,
|
||||||
|
};
|
||||||
|
if (index >= rfpEffectCount()) return rfpEffectFallback();
|
||||||
|
return ids[index];
|
||||||
|
}
|
||||||
|
|
||||||
|
inline uint8_t rfpEffectFromDmx(uint8_t value) {
|
||||||
|
const uint8_t count = rfpEffectCount();
|
||||||
|
if (count <= 1) return rfpEffectFallback();
|
||||||
|
const uint8_t index = (static_cast<uint16_t>(value) * (count - 1U) + 127U) / 255U;
|
||||||
|
return rfpEffectAt(index);
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
inline bool rfpEffectIsAllowed(uint8_t) { return true; }
|
||||||
|
inline uint8_t rfpEffectFallback() { return FX_MODE_STATIC; }
|
||||||
|
inline uint8_t rfpEffectSanitize(uint8_t fx) { return fx; }
|
||||||
|
inline uint8_t rfpEffectCount() { return MODE_COUNT; }
|
||||||
|
inline uint8_t rfpEffectAt(uint8_t index) { return index; }
|
||||||
|
inline uint8_t rfpEffectFromDmx(uint8_t value) { return value; }
|
||||||
|
#endif
|
||||||
|
|
||||||
typedef enum mapping1D2D {
|
typedef enum mapping1D2D {
|
||||||
M12_Pixels = 0,
|
M12_Pixels = 0,
|
||||||
M12_pBar = 1,
|
M12_pBar = 1,
|
||||||
|
|||||||
@@ -644,6 +644,7 @@ void Segment::setMode(uint8_t fx, bool loadDefaults, bool sliderDefaultsOnly) {
|
|||||||
static int16_t oldMap = -1;
|
static int16_t oldMap = -1;
|
||||||
static int16_t oldSim = -1;
|
static int16_t oldSim = -1;
|
||||||
static int16_t oldPalette = -1;
|
static int16_t oldPalette = -1;
|
||||||
|
fx = rfpEffectSanitize(fx);
|
||||||
// if we have a valid mode & is not reserved
|
// if we have a valid mode & is not reserved
|
||||||
if (fx < strip.getModeCount() && strncmp_P("RSVD", strip.getModeData(fx), 4)) {
|
if (fx < strip.getModeCount() && strncmp_P("RSVD", strip.getModeData(fx), 4)) {
|
||||||
if (fx != mode) {
|
if (fx != mode) {
|
||||||
@@ -1821,6 +1822,7 @@ void WS2812FX::finalizeInit(void)
|
|||||||
uint8_t defPin[] = {defDataPins[i]};
|
uint8_t defPin[] = {defDataPins[i]};
|
||||||
uint16_t start = prevLen;
|
uint16_t start = prevLen;
|
||||||
uint16_t count = defCounts[(i < defNumCounts) ? i : defNumCounts -1];
|
uint16_t count = defCounts[(i < defNumCounts) ? i : defNumCounts -1];
|
||||||
|
if (count == 0) continue; // allow dedicated controller builds to boot without a show bus
|
||||||
prevLen += count;
|
prevLen += count;
|
||||||
BusConfig defCfg = BusConfig(DEFAULT_LED_TYPE, defPin, start, count, DEFAULT_LED_COLOR_ORDER, false, 0, RGBW_MODE_MANUAL_ONLY);
|
BusConfig defCfg = BusConfig(DEFAULT_LED_TYPE, defPin, start, count, DEFAULT_LED_COLOR_ORDER, false, 0, RGBW_MODE_MANUAL_ONLY);
|
||||||
if (busses.add(defCfg) == -1) break;
|
if (busses.add(defCfg) == -1) break;
|
||||||
@@ -2179,6 +2181,7 @@ void WS2812FX::setTargetFps(uint8_t fps) {
|
|||||||
void WS2812FX::setMode(uint8_t segid, uint8_t m) {
|
void WS2812FX::setMode(uint8_t segid, uint8_t m) {
|
||||||
if (segid >= _segments.size()) return;
|
if (segid >= _segments.size()) return;
|
||||||
|
|
||||||
|
m = rfpEffectSanitize(m);
|
||||||
if (m >= getModeCount()) m = getModeCount() - 1;
|
if (m >= getModeCount()) m = getModeCount() - 1;
|
||||||
|
|
||||||
if (_segments[segid].mode != m) {
|
if (_segments[segid].mode != m) {
|
||||||
|
|||||||
@@ -87,6 +87,52 @@ bool deserializeConfig(JsonObject doc, bool fromFS) {
|
|||||||
|
|
||||||
JsonObject hw = doc[F("hw")];
|
JsonObject hw = doc[F("hw")];
|
||||||
|
|
||||||
|
#if defined(WLED_ENABLE_INFINITY_CONTROLLER) && defined(WLED_INFINITY_MASTER)
|
||||||
|
auto normalizeRfpMasterLedBusConfig = [](JsonObject root) -> bool {
|
||||||
|
JsonObject hwObj = root[F("hw")];
|
||||||
|
if (hwObj.isNull()) hwObj = root.createNestedObject(F("hw"));
|
||||||
|
JsonObject hwLedObj = hwObj[F("led")];
|
||||||
|
if (hwLedObj.isNull()) hwLedObj = hwObj.createNestedObject(F("led"));
|
||||||
|
|
||||||
|
const uint8_t dummyPins[] = {DATA_PINS};
|
||||||
|
const uint8_t dummyPin = dummyPins[0];
|
||||||
|
JsonArray insArray = hwLedObj["ins"];
|
||||||
|
bool needsMigration = insArray.isNull() || insArray.size() != 1;
|
||||||
|
|
||||||
|
if (!needsMigration) {
|
||||||
|
JsonObject onlyBus = insArray[0].as<JsonObject>();
|
||||||
|
JsonArray pins = onlyBus["pin"];
|
||||||
|
needsMigration = pins.isNull()
|
||||||
|
|| pins.size() != 1
|
||||||
|
|| pins[0].as<int>() != dummyPin
|
||||||
|
|| (onlyBus["start"] | 0) != 0
|
||||||
|
|| (onlyBus["len"] | 0) != 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!needsMigration) return false;
|
||||||
|
|
||||||
|
USER_PRINTLN(F("RFP master: repairing LED bus config, keeping status pixel GPIO free."));
|
||||||
|
hwLedObj.remove("ins");
|
||||||
|
JsonArray fixedIns = hwLedObj.createNestedArray("ins");
|
||||||
|
JsonObject bus = fixedIns.createNestedObject();
|
||||||
|
bus["start"] = 0;
|
||||||
|
bus["len"] = 1;
|
||||||
|
JsonArray pin = bus.createNestedArray("pin");
|
||||||
|
pin.add(dummyPin);
|
||||||
|
bus[F("order")] = COL_ORDER_GRB;
|
||||||
|
bus["rev"] = false;
|
||||||
|
bus[F("skip")] = 0;
|
||||||
|
bus["type"] = TYPE_WS2812_RGB;
|
||||||
|
bus["ref"] = false;
|
||||||
|
bus[F("rgbwm")] = RGBW_MODE_MANUAL_ONLY;
|
||||||
|
bus[F("freq")] = 0;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (fromFS && normalizeRfpMasterLedBusConfig(doc)) needsSave = true;
|
||||||
|
hw = doc[F("hw")];
|
||||||
|
#endif
|
||||||
|
|
||||||
// initialize LED pins and lengths prior to other HW (except for ethernet)
|
// initialize LED pins and lengths prior to other HW (except for ethernet)
|
||||||
JsonObject hw_led = hw["led"];
|
JsonObject hw_led = hw["led"];
|
||||||
|
|
||||||
@@ -446,6 +492,10 @@ bool deserializeConfig(JsonObject doc, bool fromFS) {
|
|||||||
CJSON(nodeListEnabled, if_nodes[F("list")]);
|
CJSON(nodeListEnabled, if_nodes[F("list")]);
|
||||||
CJSON(nodeBroadcastEnabled, if_nodes[F("bcast")]);
|
CJSON(nodeBroadcastEnabled, if_nodes[F("bcast")]);
|
||||||
|
|
||||||
|
#ifdef WLED_ENABLE_INFINITY_CONTROLLER
|
||||||
|
infinityDeserializeConfig(interfaces);
|
||||||
|
#endif
|
||||||
|
|
||||||
JsonObject if_live = interfaces["live"];
|
JsonObject if_live = interfaces["live"];
|
||||||
CJSON(receiveDirect, if_live["en"]);
|
CJSON(receiveDirect, if_live["en"]);
|
||||||
CJSON(useMainSegmentOnly, if_live[F("mso")]);
|
CJSON(useMainSegmentOnly, if_live[F("mso")]);
|
||||||
@@ -948,6 +998,10 @@ void serializeConfig() {
|
|||||||
if_nodes[F("list")] = nodeListEnabled;
|
if_nodes[F("list")] = nodeListEnabled;
|
||||||
if_nodes[F("bcast")] = nodeBroadcastEnabled;
|
if_nodes[F("bcast")] = nodeBroadcastEnabled;
|
||||||
|
|
||||||
|
#ifdef WLED_ENABLE_INFINITY_CONTROLLER
|
||||||
|
infinitySerializeConfig(interfaces);
|
||||||
|
#endif
|
||||||
|
|
||||||
JsonObject if_live = interfaces.createNestedObject("live");
|
JsonObject if_live = interfaces.createNestedObject("live");
|
||||||
if_live["en"] = receiveDirect;
|
if_live["en"] = receiveDirect;
|
||||||
if_live[F("mso")] = useMainSegmentOnly;
|
if_live[F("mso")] = useMainSegmentOnly;
|
||||||
|
|||||||
@@ -222,6 +222,7 @@
|
|||||||
#define DMX_MODE_EFFECT_SEGMENT 8 //trigger standalone effects of WLED (15 channels per segment)
|
#define DMX_MODE_EFFECT_SEGMENT 8 //trigger standalone effects of WLED (15 channels per segment)
|
||||||
#define DMX_MODE_EFFECT_SEGMENT_W 9 //trigger standalone effects of WLED (18 channels per segment)
|
#define DMX_MODE_EFFECT_SEGMENT_W 9 //trigger standalone effects of WLED (18 channels per segment)
|
||||||
#define DMX_MODE_PRESET 10 //apply presets (1 channel)
|
#define DMX_MODE_PRESET 10 //apply presets (1 channel)
|
||||||
|
#define DMX_MODE_INFINITY 11 //RFP Infinity master control (32 channels)
|
||||||
|
|
||||||
//Light capability byte (unused) 0bRCCCTTTT
|
//Light capability byte (unused) 0bRCCCTTTT
|
||||||
//bits 0/1/2/3: specifies a type of LED driver. A single "driver" may have different chip models but must have the same protocol/behavior
|
//bits 0/1/2/3: specifies a type of LED driver. A single "driver" may have different chip models but must have the same protocol/behavior
|
||||||
|
|||||||
@@ -550,9 +550,14 @@ function loadFXData(callback = null)
|
|||||||
})
|
})
|
||||||
.then((json)=>{
|
.then((json)=>{
|
||||||
fxdata = json||[];
|
fxdata = json||[];
|
||||||
// add default value for Solid
|
// RFP builds return id-keyed fxdata objects so effect IDs stay stable.
|
||||||
fxdata.shift()
|
if (Array.isArray(fxdata)) {
|
||||||
fxdata.unshift(";!;");
|
// add default value for Solid
|
||||||
|
fxdata.shift()
|
||||||
|
fxdata.unshift(";!;");
|
||||||
|
} else if (!fxdata[0]) {
|
||||||
|
fxdata[0] = ";!;";
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch((e)=>{
|
.catch((e)=>{
|
||||||
fxdata = [];
|
fxdata = [];
|
||||||
|
|||||||
@@ -167,6 +167,7 @@ DMX mode:
|
|||||||
<option value=5>Dimmer + Multi RGB</option>
|
<option value=5>Dimmer + Multi RGB</option>
|
||||||
<option value=6>Multi RGBW</option>
|
<option value=6>Multi RGBW</option>
|
||||||
<option value=10>Preset</option>
|
<option value=10>Preset</option>
|
||||||
|
<option value=11>Infinity Controller</option>
|
||||||
</select><br>
|
</select><br>
|
||||||
<a href="https://mm.kno.wled.ge/interfaces/e1.31-dmx/" target="_blank">E1.31 info</a><br>
|
<a href="https://mm.kno.wled.ge/interfaces/e1.31-dmx/" target="_blank">E1.31 info</a><br>
|
||||||
Timeout: <input name="ET" type="number" min="1" max="65000" required> ms<br>
|
Timeout: <input name="ET" type="number" min="1" max="65000" required> ms<br>
|
||||||
@@ -276,4 +277,4 @@ Netcat host Port:<br>
|
|||||||
<button type="button" onclick="B()">Back</button><button type="submit">Save</button>
|
<button type="button" onclick="B()">Back</button><button type="submit">Save</button>
|
||||||
</form>
|
</form>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -415,9 +415,14 @@ function loadFXData(callback = null)
|
|||||||
.then(json => {
|
.then(json => {
|
||||||
clearErrorToast();
|
clearErrorToast();
|
||||||
fxdata = json||[];
|
fxdata = json||[];
|
||||||
// add default value for Solid
|
// RFP builds return id-keyed fxdata objects so effect IDs stay stable.
|
||||||
fxdata.shift()
|
if (Array.isArray(fxdata)) {
|
||||||
fxdata.unshift("@;!;");
|
// add default value for Solid
|
||||||
|
fxdata.shift()
|
||||||
|
fxdata.unshift("@;!;");
|
||||||
|
} else if (!fxdata[0]) {
|
||||||
|
fxdata[0] = "@;!;";
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch(function (error) {
|
.catch(function (error) {
|
||||||
fxdata = [];
|
fxdata = [];
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ void rdmPersonalityChangedCb(dmx_port_t dmxPort, const rdm_header_t *header,
|
|||||||
|
|
||||||
if (header->cc == RDM_CC_SET_COMMAND_RESPONSE) {
|
if (header->cc == RDM_CC_SET_COMMAND_RESPONSE) {
|
||||||
const uint8_t personality = dmx_get_current_personality(dmx->inputPortNum);
|
const uint8_t personality = dmx_get_current_personality(dmx->inputPortNum);
|
||||||
DMXMode = std::min(DMX_MODE_PRESET, std::max(DMX_MODE_SINGLE_RGB, int(personality)));
|
DMXMode = std::min(DMX_MODE_INFINITY, std::max(DMX_MODE_SINGLE_RGB, int(personality)));
|
||||||
doSerializeConfig = true;
|
doSerializeConfig = true;
|
||||||
USER_PRINTF("DMX personality changed to to: %d\n", DMXMode);
|
USER_PRINTF("DMX personality changed to to: %d\n", DMXMode);
|
||||||
}
|
}
|
||||||
@@ -80,8 +80,10 @@ static dmx_config_t createConfig()
|
|||||||
config.personalities[8].footprint = std::min(512, strip.getSegmentsNum() * 18);
|
config.personalities[8].footprint = std::min(512, strip.getSegmentsNum() * 18);
|
||||||
config.personalities[9].description = "PRESET";
|
config.personalities[9].description = "PRESET";
|
||||||
config.personalities[9].footprint = 1;
|
config.personalities[9].footprint = 1;
|
||||||
|
config.personalities[10].description = "INFINITY";
|
||||||
|
config.personalities[10].footprint = 32;
|
||||||
|
|
||||||
config.personality_count = 10;
|
config.personality_count = 11;
|
||||||
// rdm personalities are numbered from 1, thus we can just set the DMXMode directly.
|
// rdm personalities are numbered from 1, thus we can just set the DMXMode directly.
|
||||||
config.current_personality = DMXMode;
|
config.current_personality = DMXMode;
|
||||||
|
|
||||||
@@ -278,4 +280,4 @@ void DMXInput::checkAndUpdateConfig()
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -389,6 +389,17 @@ void handleDMXData(uint16_t uni, uint16_t dmxChannels, uint8_t* e131_data, uint8
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case DMX_MODE_INFINITY:
|
||||||
|
{
|
||||||
|
#ifdef WLED_ENABLE_INFINITY_CONTROLLER
|
||||||
|
if (uni != e131Universe) return;
|
||||||
|
if (availDMXLen < 1) return;
|
||||||
|
infinityApplyDmx(&e131_data[dataOffset], availDMXLen);
|
||||||
|
#endif
|
||||||
|
return;
|
||||||
|
break;
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
DEBUG_PRINTLN(F("unknown E1.31 DMX mode"));
|
DEBUG_PRINTLN(F("unknown E1.31 DMX mode"));
|
||||||
return; // nothing to do
|
return; // nothing to do
|
||||||
@@ -416,6 +427,7 @@ void handleArtnetPollReply(IPAddress ipAddress) {
|
|||||||
case DMX_MODE_EFFECT_W:
|
case DMX_MODE_EFFECT_W:
|
||||||
case DMX_MODE_EFFECT_SEGMENT:
|
case DMX_MODE_EFFECT_SEGMENT:
|
||||||
case DMX_MODE_EFFECT_SEGMENT_W:
|
case DMX_MODE_EFFECT_SEGMENT_W:
|
||||||
|
case DMX_MODE_INFINITY:
|
||||||
break; // 1 universe is enough
|
break; // 1 universe is enough
|
||||||
|
|
||||||
case DMX_MODE_MULTIPLE_DRGB:
|
case DMX_MODE_MULTIPLE_DRGB:
|
||||||
|
|||||||
@@ -210,8 +210,8 @@ bool deserializeState(JsonObject root, byte callMode = CALL_MODE_DIRECT_CHANGE,
|
|||||||
void serializeSegment(JsonObject& root, Segment& seg, byte id, bool forPreset = false, bool segmentBounds = true);
|
void serializeSegment(JsonObject& root, Segment& seg, byte id, bool forPreset = false, bool segmentBounds = true);
|
||||||
void serializeState(JsonObject root, bool forPreset = false, bool includeBri = true, bool segmentBounds = true, bool selectedSegmentsOnly = false);
|
void serializeState(JsonObject root, bool forPreset = false, bool includeBri = true, bool segmentBounds = true, bool selectedSegmentsOnly = false);
|
||||||
void serializeInfo(JsonObject root);
|
void serializeInfo(JsonObject root);
|
||||||
void serializeModeNames(JsonArray arr, const char *qstring);
|
void serializeModeNames(JsonVariant root);
|
||||||
void serializeModeData(JsonObject root);
|
void serializeModeData(JsonVariant root);
|
||||||
void serveJson(AsyncWebServerRequest* request);
|
void serveJson(AsyncWebServerRequest* request);
|
||||||
#ifdef WLED_ENABLE_JSONLIVE
|
#ifdef WLED_ENABLE_JSONLIVE
|
||||||
bool serveLiveLeds(AsyncWebServerRequest* request, uint32_t wsClient = 0);
|
bool serveLiveLeds(AsyncWebServerRequest* request, uint32_t wsClient = 0);
|
||||||
@@ -548,6 +548,22 @@ String dmxProcessor(const String& var);
|
|||||||
void serveSettings(AsyncWebServerRequest* request, bool post = false);
|
void serveSettings(AsyncWebServerRequest* request, bool post = false);
|
||||||
void serveSettingsJS(AsyncWebServerRequest* request);
|
void serveSettingsJS(AsyncWebServerRequest* request);
|
||||||
|
|
||||||
|
//infinity_sync.cpp
|
||||||
|
#ifdef WLED_ENABLE_INFINITY_CONTROLLER
|
||||||
|
void infinityInit();
|
||||||
|
void infinityNetworkBegin();
|
||||||
|
void infinityLoop();
|
||||||
|
void infinityPostStripInit();
|
||||||
|
void infinityApplyDmx(uint8_t* data, uint16_t availableChannels);
|
||||||
|
void infinityHandleOverlayDraw();
|
||||||
|
void infinitySerializeJson(JsonObject root);
|
||||||
|
bool infinityDeserializeJson(JsonObject root);
|
||||||
|
void infinityDeserializeConfig(JsonObject interfaces);
|
||||||
|
void infinitySerializeConfig(JsonObject interfaces);
|
||||||
|
void serveInfinityJson(AsyncWebServerRequest* request);
|
||||||
|
void serveInfinityPage(AsyncWebServerRequest* request);
|
||||||
|
#endif
|
||||||
|
|
||||||
//ws.cpp
|
//ws.cpp
|
||||||
void handleWs();
|
void handleWs();
|
||||||
void wsEvent(AsyncWebSocket * server, AsyncWebSocketClient * client, AwsEventType type, void * arg, uint8_t *data, size_t len);
|
void wsEvent(AsyncWebSocket * server, AsyncWebSocketClient * client, AwsEventType type, void * arg, uint8_t *data, size_t len);
|
||||||
|
|||||||
2279
wled00/infinity_sync.cpp
Normal file
2279
wled00/infinity_sync.cpp
Normal file
File diff suppressed because it is too large
Load Diff
@@ -335,6 +335,7 @@ bool deserializeSegment(JsonObject elem, byte it, byte presetId)
|
|||||||
}
|
}
|
||||||
// end fix
|
// end fix
|
||||||
if (getVal(elem["fx"], &fx, 0, last)) { //load effect ('r' random, '~' inc/dec, 0-255 exact value, 5~10r pick random between 5 & 10)
|
if (getVal(elem["fx"], &fx, 0, last)) { //load effect ('r' random, '~' inc/dec, 0-255 exact value, 5~10r pick random between 5 & 10)
|
||||||
|
fx = rfpEffectSanitize(fx);
|
||||||
if (!presetId && currentPlaylist>=0) unloadPlaylist();
|
if (!presetId && currentPlaylist>=0) unloadPlaylist();
|
||||||
bool doLoadDefault = elem[F("fxdef")] == true;
|
bool doLoadDefault = elem[F("fxdef")] == true;
|
||||||
if (fx == FX_MODE_IMAGE) doLoadDefault = true; // WLEDMM quick fix: when called from PixelForge, images were always shown with blur
|
if (fx == FX_MODE_IMAGE) doLoadDefault = true; // WLEDMM quick fix: when called from PixelForge, images were always shown with blur
|
||||||
@@ -1495,29 +1496,49 @@ void serializeNodes(JsonObject root)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// deserializes mode data string into JsonArray
|
// deserializes mode data string into JsonArray
|
||||||
void serializeModeData(JsonArray fxdata)
|
void serializeModeData(JsonVariant root)
|
||||||
{
|
{
|
||||||
char lineBuffer[192] = { 0 };
|
char lineBuffer[192] = { 0 };
|
||||||
|
#if defined(WLED_ENABLE_INFINITY_CONTROLLER)
|
||||||
|
JsonObject fxdata = root.to<JsonObject>();
|
||||||
|
#else
|
||||||
|
JsonArray fxdata = root.to<JsonArray>();
|
||||||
|
#endif
|
||||||
for (size_t i = 0; i < strip.getModeCount(); i++) {
|
for (size_t i = 0; i < strip.getModeCount(); i++) {
|
||||||
|
if (!rfpEffectIsAllowed(i)) continue;
|
||||||
strncpy_P(lineBuffer, strip.getModeData(i), sizeof(lineBuffer)-1);
|
strncpy_P(lineBuffer, strip.getModeData(i), sizeof(lineBuffer)-1);
|
||||||
if (lineBuffer[0] != 0) {
|
if (lineBuffer[0] != 0) {
|
||||||
char* dataPtr = strchr(lineBuffer,'@');
|
char* dataPtr = strchr(lineBuffer,'@');
|
||||||
if (dataPtr) fxdata.add(dataPtr+1);
|
const char* value = dataPtr ? dataPtr+1 : "";
|
||||||
else fxdata.add("");
|
#if defined(WLED_ENABLE_INFINITY_CONTROLLER)
|
||||||
|
fxdata[String(i)] = value;
|
||||||
|
#else
|
||||||
|
fxdata.add(value);
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// deserializes mode names string into JsonArray
|
// deserializes mode names string into JsonArray
|
||||||
// also removes effect data extensions (@...) from deserialized names
|
// also removes effect data extensions (@...) from deserialized names
|
||||||
void serializeModeNames(JsonArray arr) {
|
void serializeModeNames(JsonVariant root) {
|
||||||
char lineBuffer[192] = { 0 };
|
char lineBuffer[192] = { 0 };
|
||||||
|
#if defined(WLED_ENABLE_INFINITY_CONTROLLER)
|
||||||
|
JsonObject arr = root.to<JsonObject>();
|
||||||
|
#else
|
||||||
|
JsonArray arr = root.to<JsonArray>();
|
||||||
|
#endif
|
||||||
for (size_t i = 0; i < strip.getModeCount(); i++) {
|
for (size_t i = 0; i < strip.getModeCount(); i++) {
|
||||||
|
if (!rfpEffectIsAllowed(i)) continue;
|
||||||
strncpy_P(lineBuffer, strip.getModeData(i), sizeof(lineBuffer)-1);
|
strncpy_P(lineBuffer, strip.getModeData(i), sizeof(lineBuffer)-1);
|
||||||
if (lineBuffer[0] != 0) {
|
if (lineBuffer[0] != 0) {
|
||||||
char* dataPtr = strchr(lineBuffer,'@');
|
char* dataPtr = strchr(lineBuffer,'@');
|
||||||
if (dataPtr) *dataPtr = 0; // terminate mode data after name
|
if (dataPtr) *dataPtr = 0; // terminate mode data after name
|
||||||
|
#if defined(WLED_ENABLE_INFINITY_CONTROLLER)
|
||||||
|
arr[String(i)] = lineBuffer;
|
||||||
|
#else
|
||||||
arr.add(lineBuffer);
|
arr.add(lineBuffer);
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1567,8 +1588,12 @@ void serveJson(AsyncWebServerRequest* request)
|
|||||||
else if (url.indexOf(F("eff")) > 0) {
|
else if (url.indexOf(F("eff")) > 0) {
|
||||||
// this serves just effect names without FX data extensions in names
|
// this serves just effect names without FX data extensions in names
|
||||||
if (requestJSONBufferLock(19)) {
|
if (requestJSONBufferLock(19)) {
|
||||||
|
#if defined(WLED_ENABLE_INFINITY_CONTROLLER)
|
||||||
|
AsyncJsonResponse* response = new AsyncJsonResponse(&doc, false); // RFP uses id-keyed objects
|
||||||
|
#else
|
||||||
AsyncJsonResponse* response = new AsyncJsonResponse(&doc, true); // array document
|
AsyncJsonResponse* response = new AsyncJsonResponse(&doc, true); // array document
|
||||||
JsonArray lDoc = response->getRoot();
|
#endif
|
||||||
|
JsonVariant lDoc = response->getRoot();
|
||||||
serializeModeNames(lDoc); // remove WLED-SR extensions from effect names
|
serializeModeNames(lDoc); // remove WLED-SR extensions from effect names
|
||||||
response->setLength();
|
response->setLength();
|
||||||
request->send(response);
|
request->send(response);
|
||||||
@@ -1596,7 +1621,11 @@ void serveJson(AsyncWebServerRequest* request)
|
|||||||
}
|
}
|
||||||
// releaseJSONBufferLock() will be called when "response" is destroyed (from AsyncWebServer)
|
// releaseJSONBufferLock() will be called when "response" is destroyed (from AsyncWebServer)
|
||||||
// make sure you delete "response" if no "request->send(response);" is made
|
// make sure you delete "response" if no "request->send(response);" is made
|
||||||
LockedJsonResponse *response = new LockedJsonResponse(&doc, subJson==JSON_PATH_FXDATA || subJson==JSON_PATH_EFFECTS); // will clear and convert JsonDocument into JsonArray if necessary
|
bool responseIsArray = subJson==JSON_PATH_FXDATA || subJson==JSON_PATH_EFFECTS;
|
||||||
|
#if defined(WLED_ENABLE_INFINITY_CONTROLLER)
|
||||||
|
if (subJson==JSON_PATH_FXDATA || subJson==JSON_PATH_EFFECTS) responseIsArray = false;
|
||||||
|
#endif
|
||||||
|
LockedJsonResponse *response = new LockedJsonResponse(&doc, responseIsArray); // will clear and convert JsonDocument into JsonArray if necessary
|
||||||
|
|
||||||
JsonVariant lDoc = response->getRoot();
|
JsonVariant lDoc = response->getRoot();
|
||||||
|
|
||||||
@@ -1614,7 +1643,7 @@ void serveJson(AsyncWebServerRequest* request)
|
|||||||
case JSON_PATH_EFFECTS:
|
case JSON_PATH_EFFECTS:
|
||||||
serializeModeNames(lDoc); break;
|
serializeModeNames(lDoc); break;
|
||||||
case JSON_PATH_FXDATA:
|
case JSON_PATH_FXDATA:
|
||||||
serializeModeData(lDoc.as<JsonArray>()); break;
|
serializeModeData(lDoc); break;
|
||||||
case JSON_PATH_NETWORKS:
|
case JSON_PATH_NETWORKS:
|
||||||
serializeNetworks(lDoc); break;
|
serializeNetworks(lDoc); break;
|
||||||
default: //all
|
default: //all
|
||||||
@@ -1624,7 +1653,11 @@ void serveJson(AsyncWebServerRequest* request)
|
|||||||
serializeInfo(info);
|
serializeInfo(info);
|
||||||
if (subJson != JSON_PATH_STATE_INFO)
|
if (subJson != JSON_PATH_STATE_INFO)
|
||||||
{
|
{
|
||||||
|
#if defined(WLED_ENABLE_INFINITY_CONTROLLER)
|
||||||
|
JsonObject effects = lDoc.createNestedObject(F("effects"));
|
||||||
|
#else
|
||||||
JsonArray effects = lDoc.createNestedArray(F("effects"));
|
JsonArray effects = lDoc.createNestedArray(F("effects"));
|
||||||
|
#endif
|
||||||
serializeModeNames(effects); // remove WLED-SR extensions from effect names
|
serializeModeNames(effects); // remove WLED-SR extensions from effect names
|
||||||
lDoc[F("palettes")] = serialized((const __FlashStringHelper*)JSON_palette_names);
|
lDoc[F("palettes")] = serialized((const __FlashStringHelper*)JSON_palette_names);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,6 +89,9 @@ void _overlayAnalogCountdown()
|
|||||||
|
|
||||||
void handleOverlayDraw() {
|
void handleOverlayDraw() {
|
||||||
usermods.handleOverlayDraw();
|
usermods.handleOverlayDraw();
|
||||||
|
#ifdef WLED_ENABLE_INFINITY_CONTROLLER
|
||||||
|
infinityHandleOverlayDraw();
|
||||||
|
#endif
|
||||||
if (overlayCurrent == 1) _overlayAnalogClock();
|
if (overlayCurrent == 1) _overlayAnalogClock();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -358,7 +358,7 @@ void handleSettingsSet(AsyncWebServerRequest *request, byte subPage)
|
|||||||
t = request->arg(F("PY")).toInt();
|
t = request->arg(F("PY")).toInt();
|
||||||
if (t >= 0 && t <= 200) e131Priority = t;
|
if (t >= 0 && t <= 200) e131Priority = t;
|
||||||
t = request->arg(F("DM")).toInt();
|
t = request->arg(F("DM")).toInt();
|
||||||
if (t >= DMX_MODE_DISABLED && t <= DMX_MODE_PRESET) DMXMode = t;
|
if (t >= DMX_MODE_DISABLED && t <= DMX_MODE_INFINITY) DMXMode = t;
|
||||||
t = request->arg(F("ET")).toInt();
|
t = request->arg(F("ET")).toInt();
|
||||||
if (t > 99 && t <= 65000) realtimeTimeoutMs = t;
|
if (t > 99 && t <= 65000) realtimeTimeoutMs = t;
|
||||||
arlsForceMaxBri = request->hasArg(F("FB"));
|
arlsForceMaxBri = request->hasArg(F("FB"));
|
||||||
|
|||||||
@@ -222,6 +222,9 @@ void WLED::loop()
|
|||||||
#endif
|
#endif
|
||||||
#ifdef WLED_ENABLE_DMX_INPUT
|
#ifdef WLED_ENABLE_DMX_INPUT
|
||||||
dmxInput.update();
|
dmxInput.update();
|
||||||
|
#endif
|
||||||
|
#ifdef WLED_ENABLE_INFINITY_CONTROLLER
|
||||||
|
infinityLoop();
|
||||||
#endif
|
#endif
|
||||||
userLoop();
|
userLoop();
|
||||||
|
|
||||||
@@ -1014,6 +1017,9 @@ void WLED::setup()
|
|||||||
#ifdef WLED_ENABLE_DMX_INPUT
|
#ifdef WLED_ENABLE_DMX_INPUT
|
||||||
dmxInput.init(dmxInputReceivePin, dmxInputTransmitPin, dmxInputEnablePin, dmxInputPort);
|
dmxInput.init(dmxInputReceivePin, dmxInputTransmitPin, dmxInputEnablePin, dmxInputPort);
|
||||||
#endif
|
#endif
|
||||||
|
#ifdef WLED_ENABLE_INFINITY_CONTROLLER
|
||||||
|
infinityInit();
|
||||||
|
#endif
|
||||||
|
|
||||||
#ifdef WLED_ENABLE_ADALIGHT
|
#ifdef WLED_ENABLE_ADALIGHT
|
||||||
if (Serial && (Serial.available() > 0) && (Serial.peek() == 'I')) handleImprovPacket();
|
if (Serial && (Serial.available() > 0) && (Serial.peek() == 'I')) handleImprovPacket();
|
||||||
@@ -1132,9 +1138,18 @@ void WLED::beginStrip()
|
|||||||
strip.fill(BLACK); // WLEDMM avoids random colors at power-on
|
strip.fill(BLACK); // WLEDMM avoids random colors at power-on
|
||||||
strip.finalizeInit(); // busses created during deserializeConfig()
|
strip.finalizeInit(); // busses created during deserializeConfig()
|
||||||
strip.makeAutoSegments();
|
strip.makeAutoSegments();
|
||||||
|
#ifdef WLED_ENABLE_INFINITY_CONTROLLER
|
||||||
|
infinityPostStripInit();
|
||||||
|
#endif
|
||||||
strip.setBrightness(0, true); // WLEDMM directly apply BLACK (no transition time)
|
strip.setBrightness(0, true); // WLEDMM directly apply BLACK (no transition time)
|
||||||
strip.setShowCallback(handleOverlayDraw);
|
strip.setShowCallback(handleOverlayDraw);
|
||||||
|
|
||||||
|
#ifdef WLEDMM_FORCE_ON_AT_BOOT
|
||||||
|
// WLEDMM: board-specific safety net for installs that must always recover to ON after power loss.
|
||||||
|
turnOnAtBoot = true;
|
||||||
|
bootPreset = 0;
|
||||||
|
#endif
|
||||||
|
|
||||||
if (turnOnAtBoot) {
|
if (turnOnAtBoot) {
|
||||||
if (briS > 0) bri = briS;
|
if (briS > 0) bri = briS;
|
||||||
else if (bri == 0) bri = 128;
|
else if (bri == 0) bri = 128;
|
||||||
@@ -1192,6 +1207,9 @@ void WLED::initAP(bool resetAP)
|
|||||||
}
|
}
|
||||||
e131.begin(false, e131Port, e131Universe, E131_MAX_UNIVERSE_COUNT);
|
e131.begin(false, e131Port, e131Universe, E131_MAX_UNIVERSE_COUNT);
|
||||||
ddp.begin(false, DDP_DEFAULT_PORT);
|
ddp.begin(false, DDP_DEFAULT_PORT);
|
||||||
|
#ifdef WLED_ENABLE_INFINITY_CONTROLLER
|
||||||
|
infinityNetworkBegin();
|
||||||
|
#endif
|
||||||
|
|
||||||
dnsServer.setErrorReplyCode(DNSReplyCode::NoError);
|
dnsServer.setErrorReplyCode(DNSReplyCode::NoError);
|
||||||
dnsServer.start(53, "*", WiFi.softAPIP());
|
dnsServer.start(53, "*", WiFi.softAPIP());
|
||||||
@@ -1461,6 +1479,9 @@ void WLED::initInterfaces()
|
|||||||
|
|
||||||
e131.begin(e131Multicast, e131Port, e131Universe, E131_MAX_UNIVERSE_COUNT);
|
e131.begin(e131Multicast, e131Port, e131Universe, E131_MAX_UNIVERSE_COUNT);
|
||||||
ddp.begin(false, DDP_DEFAULT_PORT);
|
ddp.begin(false, DDP_DEFAULT_PORT);
|
||||||
|
#ifdef WLED_ENABLE_INFINITY_CONTROLLER
|
||||||
|
infinityNetworkBegin();
|
||||||
|
#endif
|
||||||
reconnectHue();
|
reconnectHue();
|
||||||
#ifndef WLED_DISABLE_MQTT
|
#ifndef WLED_DISABLE_MQTT
|
||||||
initMqtt();
|
initMqtt();
|
||||||
|
|||||||
@@ -478,10 +478,22 @@ WLED_GLOBAL bool arlsForceMaxBri _INIT(false); // enable to f
|
|||||||
WLED_GLOBAL uint16_t e131ProxyUniverse _INIT(0); // output this E1.31 (sACN) / ArtNet universe via MAX485 (0 = disabled)
|
WLED_GLOBAL uint16_t e131ProxyUniverse _INIT(0); // output this E1.31 (sACN) / ArtNet universe via MAX485 (0 = disabled)
|
||||||
#endif
|
#endif
|
||||||
#ifdef WLED_ENABLE_DMX_INPUT
|
#ifdef WLED_ENABLE_DMX_INPUT
|
||||||
WLED_GLOBAL int dmxInputTransmitPin _INIT(0);
|
#ifndef DMX_INPUT_TXPIN
|
||||||
WLED_GLOBAL int dmxInputReceivePin _INIT(0);
|
#define DMX_INPUT_TXPIN 0
|
||||||
WLED_GLOBAL int dmxInputEnablePin _INIT(0);
|
#endif
|
||||||
WLED_GLOBAL int dmxInputPort _INIT(2);
|
#ifndef DMX_INPUT_RXPIN
|
||||||
|
#define DMX_INPUT_RXPIN 0
|
||||||
|
#endif
|
||||||
|
#ifndef DMX_INPUT_ENABLEPIN
|
||||||
|
#define DMX_INPUT_ENABLEPIN 0
|
||||||
|
#endif
|
||||||
|
#ifndef DMX_INPUT_PORT
|
||||||
|
#define DMX_INPUT_PORT 2
|
||||||
|
#endif
|
||||||
|
WLED_GLOBAL int dmxInputTransmitPin _INIT(DMX_INPUT_TXPIN);
|
||||||
|
WLED_GLOBAL int dmxInputReceivePin _INIT(DMX_INPUT_RXPIN);
|
||||||
|
WLED_GLOBAL int dmxInputEnablePin _INIT(DMX_INPUT_ENABLEPIN);
|
||||||
|
WLED_GLOBAL int dmxInputPort _INIT(DMX_INPUT_PORT);
|
||||||
WLED_GLOBAL DMXInput dmxInput;
|
WLED_GLOBAL DMXInput dmxInput;
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
@@ -489,7 +501,10 @@ WLED_GLOBAL uint16_t e131Universe _INIT(1); // settings fo
|
|||||||
WLED_GLOBAL uint16_t e131Port _INIT(5568); // DMX in port. E1.31 default is 5568, Art-Net is 6454
|
WLED_GLOBAL uint16_t e131Port _INIT(5568); // DMX in port. E1.31 default is 5568, Art-Net is 6454
|
||||||
WLED_GLOBAL byte e131Priority _INIT(0); // E1.31 port priority (if != 0 priority handling is active)
|
WLED_GLOBAL byte e131Priority _INIT(0); // E1.31 port priority (if != 0 priority handling is active)
|
||||||
WLED_GLOBAL E131Priority highPriority _INIT(3); // E1.31 highest priority tracking, init = timeout in seconds
|
WLED_GLOBAL E131Priority highPriority _INIT(3); // E1.31 highest priority tracking, init = timeout in seconds
|
||||||
WLED_GLOBAL byte DMXMode _INIT(DMX_MODE_MULTIPLE_RGB); // DMX mode (s.a.)
|
#ifndef DEFAULT_DMX_MODE
|
||||||
|
#define DEFAULT_DMX_MODE DMX_MODE_MULTIPLE_RGB
|
||||||
|
#endif
|
||||||
|
WLED_GLOBAL byte DMXMode _INIT(DEFAULT_DMX_MODE); // DMX mode (s.a.)
|
||||||
WLED_GLOBAL uint16_t DMXAddress _INIT(1); // DMX start address of fixture, a.k.a. first Channel [for E1.31 (sACN) protocol]
|
WLED_GLOBAL uint16_t DMXAddress _INIT(1); // DMX start address of fixture, a.k.a. first Channel [for E1.31 (sACN) protocol]
|
||||||
WLED_GLOBAL uint16_t DMXSegmentSpacing _INIT(0); // Number of void/unused channels between each segments DMX channels
|
WLED_GLOBAL uint16_t DMXSegmentSpacing _INIT(0); // Number of void/unused channels between each segments DMX channels
|
||||||
//WLED_GLOBAL byte e131LastSequenceNumber[E131_MAX_UNIVERSE_COUNT]; // to detect packet loss // WLEDMM move into e131.cpp - array is not used anywhere else
|
//WLED_GLOBAL byte e131LastSequenceNumber[E131_MAX_UNIVERSE_COUNT]; // to detect packet loss // WLEDMM move into e131.cpp - array is not used anywhere else
|
||||||
|
|||||||
@@ -91,6 +91,268 @@ bool canUseSerial(void) { // WLEDMM returns true if Serial can be used for deb
|
|||||||
return true;
|
return true;
|
||||||
} // WLEDMM end
|
} // WLEDMM end
|
||||||
|
|
||||||
|
#if defined(WLED_ENABLE_INFINITY_CONTROLLER) && defined(WLED_INFINITY_MASTER)
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
String rfpJsonValue(const String& json, const char* key) {
|
||||||
|
StaticJsonDocument<384> command;
|
||||||
|
DeserializationError error = deserializeJson(command, json);
|
||||||
|
if (error) return String();
|
||||||
|
return command[key] | "";
|
||||||
|
}
|
||||||
|
|
||||||
|
uint32_t rfpJsonU32(const String& json, const char* key) {
|
||||||
|
StaticJsonDocument<384> command;
|
||||||
|
DeserializationError error = deserializeJson(command, json);
|
||||||
|
if (error) return 0;
|
||||||
|
return command[key] | 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint32_t rfpParseChunkLength(const String& line, String& encoded) {
|
||||||
|
if (!line.startsWith("RFPCHUNK1 ")) return 0;
|
||||||
|
const int separator = line.indexOf(' ', 10);
|
||||||
|
if (separator < 0) return 0;
|
||||||
|
const int32_t length = line.substring(10, separator).toInt();
|
||||||
|
encoded = line.substring(separator + 1);
|
||||||
|
return length > 0 ? static_cast<uint32_t>(length) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
void rfpSerialPrintError(const __FlashStringHelper* message) {
|
||||||
|
Serial.print(F("RFPERR1 {\"error\":\""));
|
||||||
|
Serial.print(message);
|
||||||
|
Serial.println(F("\"}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
void rfpSerialPrintErrorDetail(const __FlashStringHelper* message, uint32_t a, uint32_t b) {
|
||||||
|
Serial.print(F("RFPERR1 {\"error\":\""));
|
||||||
|
Serial.print(message);
|
||||||
|
Serial.print(F("\",\"a\":"));
|
||||||
|
Serial.print(a);
|
||||||
|
Serial.print(F(",\"b\":"));
|
||||||
|
Serial.print(b);
|
||||||
|
Serial.println(F("}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
int8_t rfpBase64Value(char c) {
|
||||||
|
if (c >= 'A' && c <= 'Z') return c - 'A';
|
||||||
|
if (c >= 'a' && c <= 'z') return c - 'a' + 26;
|
||||||
|
if (c >= '0' && c <= '9') return c - '0' + 52;
|
||||||
|
if (c == '+') return 62;
|
||||||
|
if (c == '/') return 63;
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
int rfpDecodeBase64Chunk(const String& encoded, uint8_t* output, size_t outputSize) {
|
||||||
|
uint32_t accumulator = 0;
|
||||||
|
uint8_t bits = 0;
|
||||||
|
size_t written = 0;
|
||||||
|
|
||||||
|
for (uint16_t i = 0; i < encoded.length(); i++) {
|
||||||
|
const char c = encoded[i];
|
||||||
|
if (c == '=') break;
|
||||||
|
const int8_t value = rfpBase64Value(c);
|
||||||
|
if (value < 0) return -1;
|
||||||
|
|
||||||
|
accumulator = (accumulator << 6) | static_cast<uint8_t>(value);
|
||||||
|
bits += 6;
|
||||||
|
if (bits >= 8) {
|
||||||
|
bits -= 8;
|
||||||
|
if (written >= outputSize) return -1;
|
||||||
|
output[written++] = static_cast<uint8_t>((accumulator >> bits) & 0xFF);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return static_cast<int>(written);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool rfpReadHttpBody(WiFiClient& client, String& body, uint32_t timeoutMs) {
|
||||||
|
const uint32_t start = millis();
|
||||||
|
bool inBody = false;
|
||||||
|
String headerTail;
|
||||||
|
body.reserve(512);
|
||||||
|
while ((millis() - start) < timeoutMs) {
|
||||||
|
while (client.available()) {
|
||||||
|
const char c = static_cast<char>(client.read());
|
||||||
|
if (!inBody) {
|
||||||
|
headerTail += c;
|
||||||
|
if (headerTail.length() > 4) headerTail.remove(0, headerTail.length() - 4);
|
||||||
|
if (headerTail == "\r\n\r\n") inBody = true;
|
||||||
|
} else if (body.length() < 2048) {
|
||||||
|
body += c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!client.connected() && !client.available()) return inBody;
|
||||||
|
delay(1);
|
||||||
|
}
|
||||||
|
return inBody;
|
||||||
|
}
|
||||||
|
|
||||||
|
void rfpHandleInfoCommand(const String& json) {
|
||||||
|
const String target = rfpJsonValue(json, "target");
|
||||||
|
if (target.length() == 0) {
|
||||||
|
rfpSerialPrintError(F("missing target"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
WiFiClient client;
|
||||||
|
if (!client.connect(target.c_str(), 80)) {
|
||||||
|
rfpSerialPrintError(F("node connect failed"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
client.print(F("GET /json/info HTTP/1.1\r\nHost: "));
|
||||||
|
client.print(target);
|
||||||
|
client.print(F("\r\nConnection: close\r\n\r\n"));
|
||||||
|
|
||||||
|
String body;
|
||||||
|
if (!rfpReadHttpBody(client, body, 5000) || body.length() == 0) {
|
||||||
|
rfpSerialPrintError(F("node info failed"));
|
||||||
|
client.stop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
client.stop();
|
||||||
|
body.replace("\r", "");
|
||||||
|
body.replace("\n", "");
|
||||||
|
Serial.print(F("RFPINFO1 "));
|
||||||
|
Serial.println(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
void rfpHandleOtaCommand(const String& json) {
|
||||||
|
const String target = rfpJsonValue(json, "target");
|
||||||
|
const uint32_t firmwareSize = rfpJsonU32(json, "size");
|
||||||
|
const uint32_t ackBytes = rfpJsonU32(json, "ackBytes");
|
||||||
|
if (target.length() == 0 || firmwareSize == 0) {
|
||||||
|
rfpSerialPrintError(F("missing target or size"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
constexpr char boundary[] = "----RFPInfinityOtaBoundary";
|
||||||
|
const String head =
|
||||||
|
String("--") + boundary + "\r\n"
|
||||||
|
"Content-Disposition: form-data; name=\"update\"; filename=\"firmware.bin\"\r\n"
|
||||||
|
"Content-Type: application/octet-stream\r\n\r\n";
|
||||||
|
const String tail =
|
||||||
|
String("\r\n--") + boundary + "\r\n"
|
||||||
|
"Content-Disposition: form-data; name=\"skipValidation\"\r\n\r\n"
|
||||||
|
"1\r\n"
|
||||||
|
"--" + boundary + "--\r\n";
|
||||||
|
const uint32_t contentLength = head.length() + firmwareSize + tail.length();
|
||||||
|
|
||||||
|
WiFiClient client;
|
||||||
|
if (!client.connect(target.c_str(), 80)) {
|
||||||
|
rfpSerialPrintError(F("node connect failed"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
client.print(F("POST /update?skipValidation=1 HTTP/1.1\r\nHost: "));
|
||||||
|
client.print(target);
|
||||||
|
client.print(F("\r\nConnection: close\r\nContent-Type: multipart/form-data; boundary="));
|
||||||
|
client.print(boundary);
|
||||||
|
client.print(F("\r\nContent-Length: "));
|
||||||
|
client.print(contentLength);
|
||||||
|
client.print(F("\r\n\r\n"));
|
||||||
|
client.print(head);
|
||||||
|
|
||||||
|
Serial.print(F("RFPREADY1 {\"target\":\""));
|
||||||
|
Serial.print(target);
|
||||||
|
Serial.print(F("\",\"size\":"));
|
||||||
|
Serial.print(firmwareSize);
|
||||||
|
Serial.print(F(",\"ackBytes\":"));
|
||||||
|
Serial.print(ackBytes);
|
||||||
|
Serial.print(F(",\"proto\":4"));
|
||||||
|
Serial.println(F("}"));
|
||||||
|
Serial.flush();
|
||||||
|
|
||||||
|
Serial.setTimeout(20000);
|
||||||
|
String dataStart = Serial.readStringUntil('\n');
|
||||||
|
dataStart.trim();
|
||||||
|
if (dataStart != "RFPDATA1") {
|
||||||
|
client.stop();
|
||||||
|
rfpSerialPrintError(F("serial data start timeout"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint8_t buffer[768];
|
||||||
|
uint32_t remaining = firmwareSize;
|
||||||
|
uint32_t nextAck = ackBytes;
|
||||||
|
uint32_t lastDataMs = millis();
|
||||||
|
while (remaining > 0) {
|
||||||
|
String chunkHeader = Serial.readStringUntil('\n');
|
||||||
|
chunkHeader.trim();
|
||||||
|
String encoded;
|
||||||
|
const uint32_t chunkLength = rfpParseChunkLength(chunkHeader, encoded);
|
||||||
|
if (chunkLength == 0 || chunkLength > remaining) {
|
||||||
|
client.stop();
|
||||||
|
rfpSerialPrintError(F("serial chunk header invalid"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (chunkLength > sizeof(buffer) || encoded.length() == 0) {
|
||||||
|
client.stop();
|
||||||
|
rfpSerialPrintError(F("serial chunk too large"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const int decoded = rfpDecodeBase64Chunk(encoded, buffer, sizeof(buffer));
|
||||||
|
if (decoded != static_cast<int>(chunkLength)) {
|
||||||
|
client.stop();
|
||||||
|
rfpSerialPrintErrorDetail(F("serial chunk decode failed"), encoded.length(), decoded < 0 ? 0 : decoded);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastDataMs = millis();
|
||||||
|
if (client.write(buffer, decoded) != static_cast<size_t>(decoded)) {
|
||||||
|
client.stop();
|
||||||
|
rfpSerialPrintError(F("node write failed"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
remaining -= decoded;
|
||||||
|
yield();
|
||||||
|
|
||||||
|
const uint32_t received = firmwareSize - remaining;
|
||||||
|
if (ackBytes > 0 && (received >= nextAck || remaining == 0)) {
|
||||||
|
Serial.print(F("RFPACK1 {\"bytes\":"));
|
||||||
|
Serial.print(received);
|
||||||
|
Serial.println(F("}"));
|
||||||
|
nextAck += ackBytes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
client.print(tail);
|
||||||
|
String body;
|
||||||
|
rfpReadHttpBody(client, body, 30000);
|
||||||
|
client.stop();
|
||||||
|
body.replace("\r", " ");
|
||||||
|
body.replace("\n", " ");
|
||||||
|
if (body.length() > 360) body = body.substring(0, 360);
|
||||||
|
Serial.print(F("RFPDONE1 {\"target\":\""));
|
||||||
|
Serial.print(target);
|
||||||
|
Serial.print(F("\",\"bytes\":"));
|
||||||
|
Serial.print(firmwareSize);
|
||||||
|
Serial.print(F(",\"response\":\""));
|
||||||
|
for (uint16_t i = 0; i < body.length(); i++) {
|
||||||
|
const char c = body[i];
|
||||||
|
Serial.print(c == '"' || c == '\\' ? '_' : c);
|
||||||
|
}
|
||||||
|
Serial.println(F("\"}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
bool rfpHandleSerialCommandLine() {
|
||||||
|
Serial.setTimeout(2000);
|
||||||
|
String line = Serial.readStringUntil('\n');
|
||||||
|
line.trim();
|
||||||
|
if (line.startsWith("RFPINFO1 ")) {
|
||||||
|
rfpHandleInfoCommand(line.substring(9));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (line.startsWith("RFPOTA1 ")) {
|
||||||
|
rfpHandleOtaCommand(line.substring(8));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
rfpSerialPrintError(F("unknown rfp command"));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
#endif
|
||||||
|
|
||||||
void handleSerial()
|
void handleSerial()
|
||||||
{
|
{
|
||||||
#if !ARDUINO_USB_CDC_ON_BOOT
|
#if !ARDUINO_USB_CDC_ON_BOOT
|
||||||
@@ -155,6 +417,11 @@ void handleSerial()
|
|||||||
#endif
|
#endif
|
||||||
} else if (next == 'X') {
|
} else if (next == 'X') {
|
||||||
forceReconnect = true; // WLEDMM - force reconnect via Serial
|
forceReconnect = true; // WLEDMM - force reconnect via Serial
|
||||||
|
} else if (next == 'R') {
|
||||||
|
#if defined(WLED_ENABLE_INFINITY_CONTROLLER) && defined(WLED_INFINITY_MASTER)
|
||||||
|
rfpHandleSerialCommandLine();
|
||||||
|
return;
|
||||||
|
#endif
|
||||||
} else if (next == 0xB0) {updateBaudRate( 115200);
|
} else if (next == 0xB0) {updateBaudRate( 115200);
|
||||||
} else if (next == 0xB1) {updateBaudRate( 230400);
|
} else if (next == 0xB1) {updateBaudRate( 230400);
|
||||||
} else if (next == 0xB2) {updateBaudRate( 460800);
|
} else if (next == 0xB2) {updateBaudRate( 460800);
|
||||||
|
|||||||
@@ -244,6 +244,33 @@ void initServer()
|
|||||||
serveSettings(request, true);
|
serveSettings(request, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
#ifdef WLED_ENABLE_INFINITY_CONTROLLER
|
||||||
|
server.on("/infinity", HTTP_GET, [](AsyncWebServerRequest *request){
|
||||||
|
serveInfinityPage(request);
|
||||||
|
});
|
||||||
|
|
||||||
|
server.on("/json/infinity", HTTP_GET, [](AsyncWebServerRequest *request){
|
||||||
|
serveInfinityJson(request);
|
||||||
|
});
|
||||||
|
|
||||||
|
AsyncCallbackJsonWebHandler* infinityHandler = new AsyncCallbackJsonWebHandler("/json/infinity", [](AsyncWebServerRequest *request) {
|
||||||
|
if (!requestJSONBufferLock(24)) return;
|
||||||
|
|
||||||
|
DeserializationError error = deserializeJson(doc, (uint8_t*)(request->_tempObject));
|
||||||
|
JsonObject root = doc.as<JsonObject>();
|
||||||
|
if (error || root.isNull()) {
|
||||||
|
releaseJSONBufferLock();
|
||||||
|
request->send(400, "application/json", F("{\"error\":9}"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
infinityDeserializeJson(root);
|
||||||
|
releaseJSONBufferLock();
|
||||||
|
serveInfinityJson(request);
|
||||||
|
}, 4096);
|
||||||
|
server.addHandler(infinityHandler);
|
||||||
|
#endif
|
||||||
|
|
||||||
server.on("/json", HTTP_GET, [](AsyncWebServerRequest *request){
|
server.on("/json", HTTP_GET, [](AsyncWebServerRequest *request){
|
||||||
serveJson(request);
|
serveJson(request);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user