Compare commits

..

2 Commits

Author SHA1 Message Date
jan
4bc4e1257e Backup RFP Infinity controller state before Resolume changes
Some checks failed
WLED CI / wled_build (push) Has been cancelled
2026-05-14 12:31:13 +02:00
jan
ebc4498d89 Add BPM speed control and OTA update workflow
Some checks failed
WLED CI / wled_build (push) Has been cancelled
Deploy Nightly / wled_build (push) Has been cancelled
Deploy Nightly / Deploy nightly (push) Has been cancelled
2026-04-25 22:48:13 +02:00
33 changed files with 5867 additions and 35 deletions

4
.gitignore vendored
View File

@@ -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

View File

@@ -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:

View 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.

View 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
View 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
View 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
View 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

View File

@@ -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

View 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
View 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
View 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
View 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())

View 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
View 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

View File

@@ -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);

View File

@@ -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,

View File

@@ -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) {

View File

@@ -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;

View File

@@ -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

View File

@@ -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 = [];

View File

@@ -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>

View File

@@ -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 = [];

View File

@@ -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

View File

@@ -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:

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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);
} }

View File

@@ -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();
} }

View File

@@ -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"));

View File

@@ -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();

View File

@@ -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

View File

@@ -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);

View File

@@ -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);
}); });