diff --git a/.gitignore b/.gitignore index c787ba34..ac389827 100644 --- a/.gitignore +++ b/.gitignore @@ -26,8 +26,12 @@ compile_commands.json /build/ /build_output/ +/.npm-cache/ /node_modules/ +/tools/__pycache__/ +/tools/discovered_wled_hosts.txt + /wled00/extLibs /wled00/LittleFS /wled00/my_config.h diff --git a/docs/rfp-esp32s3-wroom1-n16r8-3x106.md b/docs/rfp-esp32s3-wroom1-n16r8-3x106.md index 7327ec02..dcc20733 100644 --- a/docs/rfp-esp32s3-wroom1-n16r8-3x106.md +++ b/docs/rfp-esp32s3-wroom1-n16r8-3x106.md @@ -6,11 +6,22 @@ Build target: - `rfp_esp32s3_wroom1_n16r8_3x106` +Firmware release name: + +- `RFP_N16R8_NODE3x106_V20260511E` + Default output pins: - Output 1: `GPIO4` - Output 2: `GPIO5` - 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: @@ -37,17 +48,55 @@ Build only with the helper script: .\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: -- Keep SSID and password in the ignored file `wled00/my_config.h`. -- If the file does not exist yet, create it with your local values: - -```cpp -#pragma once - -#define CLIENT_SSID "your-ssid" -#define CLIENT_PASS "your-password" -``` +- SSID `RFPLicht` and the RFP password are compiled into the tracked RFP node target. +- A full `erase_flash` removes saved runtime settings, but the firmware defaults can still join the show Wi-Fi. Important: diff --git a/docs/rfp-infinity-controller-plan.md b/docs/rfp-infinity-controller-plan.md new file mode 100644 index 00000000..73a8d4b9 --- /dev/null +++ b/docs/rfp-infinity-controller-plan.md @@ -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. diff --git a/docs/rfp-local-visualizer.md b/docs/rfp-local-visualizer.md new file mode 100644 index 00000000..bfe8e738 --- /dev/null +++ b/docs/rfp-local-visualizer.md @@ -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. diff --git a/docs/rfp-node-flashing.md b/docs/rfp-node-flashing.md index 082aa656..2176e585 100644 --- a/docs/rfp-node-flashing.md +++ b/docs/rfp-node-flashing.md @@ -1,28 +1,42 @@ # RFP Infinity Flashing -This document covers flashing for both the Infinity master and the ESP32-S3 render nodes. +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` -- Conservative master cold-boot target: `rfp_esp32s3_wroom1_n16r8_master_coldboot` -- Standard node target: `rfp_esp32s3_wroom1_n16r8_3x106` -- Conservative cold-boot test target: `rfp_esp32s3_wroom1_n16r8_3x106_coldboot` +- Node target: `rfp_esp32s3_wroom1_n16r8_3x106` -Use a cold-boot target when a board only starts reliably after pressing `RESET` -following a long power loss. +## 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 now compile the show Wi-Fi as firmware defaults: +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 any runtime-only +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. @@ -33,66 +47,13 @@ also encoded as firmware defaults. - 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 one dummy WLED pixel on `GPIO21` so the regular WLED UI remains valid - Keeps the real WLED status pixel exclusively on `GPIO48` - - Repairs old master `cfg.json` LED-bus entries so `GPIO48` is not reused as - a normal LED output - Nodes: - Usually `192.168.178.11` to `192.168.178.16` - Render the LED output locally - Receive Infinity Sync from the master - -The flash procedure is similar for both roles, but the PlatformIO target and `firmware.bin` are different. - -## WLED Backup Mode - -The regular WLED UI is intentionally kept available as a fallback. - -Important behavior: - -- The master uses the WLED UI only for a dummy backup pixel on `GPIO21`; the - actual onboard status pixel remains WLED's normal status pixel on `GPIO48`. -- The master does not render the show LEDs directly. -- The nodes can still be controlled through their regular WLED UI. -- If Infinity Sync is enabled, the master sends scene state about every `100 ms`. -- While those packets arrive, the node UI may appear to ignore changes because - the next Infinity packet overwrites the local WLED state. - -Use regular WLED control as backup in one of these ways: - -1. Preferred: open `/infinity` on the master and use the mode button in the - top bar: - - `Show Mode: ON` means Infinity Sync is active. - - `WLED Backup: ON` means Infinity Sync is stopped and regular WLED control - can be used. - -2. Or 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}' -``` - -3. Or disable Infinity on one node for local testing: - -```bash -curl -X POST http://192.168.178.11/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}' -``` - -For hotspot testing, replace the IPs with the current addresses, for example -`10.42.0.213`. + - Use three LED outputs: `GPIO4/5/6`, each with `106` LEDs ## Build: Master @@ -113,33 +74,11 @@ Master firmware output: ```text .pio/build/rfp_esp32s3_wroom1_n16r8_master/firmware.bin -``` - -Conservative master cold-boot build: - -```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_coldboot -``` - -Master cold-boot firmware output: - -```text -.pio/build/rfp_esp32s3_wroom1_n16r8_master_coldboot/firmware.bin +build_output/release/WLEDMM_14.7.2-mdev_RFP_N16R8_MASTER_V20260511E.bin ``` ## Build: Nodes -Standard build: - ```bash cd /home/jan/Documents/RFP/WLED-MM/repo @@ -153,10 +92,74 @@ PLATFORMIO_BUILD_CACHE_DIR=$PWD/.piohome/buildcache \ .venv/bin/python -m platformio run -e rfp_esp32s3_wroom1_n16r8_3x106 ``` -Cold-boot test build: +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 \ @@ -165,154 +168,13 @@ 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_coldboot -``` +.venv/bin/python -m platformio run -e "$ENV" -Node firmware outputs: - -```text -.pio/build/rfp_esp32s3_wroom1_n16r8_3x106/firmware.bin -.pio/build/rfp_esp32s3_wroom1_n16r8_3x106_coldboot/firmware.bin -``` - -## WLAN Flash: Master - -Flash the master by OTA: - -```bash -cd /home/jan/Documents/RFP/WLED-MM/repo - -.venv/bin/python tools/rfp_network_flash.py flash \ - --targets 192.168.178.10 \ - --firmware .pio/build/rfp_esp32s3_wroom1_n16r8_master/firmware.bin -``` - -## WLAN Flash: Single Node - -Standard firmware to one 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 -``` - -## WLAN Flash: Group - -Flash all six nodes in order: - -```bash -cd /home/jan/Documents/RFP/WLED-MM/repo - -.venv/bin/python tools/rfp_network_flash.py flash \ - --targets 192.168.178.11,192.168.178.12,192.168.178.13,192.168.178.14,192.168.178.15,192.168.178.16 \ - --start-from 192.168.178.11 \ - --firmware .pio/build/rfp_esp32s3_wroom1_n16r8_3x106/firmware.bin -``` - -Resume a failed OTA run from a specific node: - -```bash -cd /home/jan/Documents/RFP/WLED-MM/repo - -.venv/bin/python tools/rfp_network_flash.py flash \ - --targets 192.168.178.11,192.168.178.12,192.168.178.13,192.168.178.14,192.168.178.15,192.168.178.16 \ - --start-from 192.168.178.14 \ - --firmware .pio/build/rfp_esp32s3_wroom1_n16r8_3x106/firmware.bin -``` - -## WLAN Flash: Full Installation - -Build standard OTA firmware locally, flash all six nodes sequentially, then flash -the master last: - -```bash -cd /home/jan/Documents/RFP/WLED-MM/repo - -.venv/bin/python tools/rfp_update_all_ota.py -``` - -Dry-run without building or flashing: - -```bash -cd /home/jan/Documents/RFP/WLED-MM/repo - -.venv/bin/python tools/rfp_update_all_ota.py --dry-run --no-build -``` - -Resume after an interrupted run: - -```bash -cd /home/jan/Documents/RFP/WLED-MM/repo - -.venv/bin/python tools/rfp_update_all_ota.py --start-from 192.168.178.14 -``` - -Useful variants: - -```bash -.venv/bin/python tools/rfp_update_all_ota.py --nodes-only -.venv/bin/python tools/rfp_update_all_ota.py --master-only -.venv/bin/python tools/rfp_update_all_ota.py --no-build -.venv/bin/python tools/rfp_update_all_ota.py --subnet 192.168.178.0/24 -``` - -The full-update helper is only for standard OTA builds: - -- Nodes: `rfp_esp32s3_wroom1_n16r8_3x106` -- Master: `rfp_esp32s3_wroom1_n16r8_master` - -Cold-boot targets remain USB clean-flash targets because OTA does not rewrite -the bootloader/flash-mode layout. - -Notes: - -- OTA only works when the laptop and nodes are already in the same IP network. -- The OTA helper flashes sequentially, verifies reboot, and then continues to the next node. -- The cold-boot test target should be flashed by USB, not by OTA. -- Reason: - - it changes flash/boot related build settings (`flash_mode`, `memory_type`) - - it also uses a different release name for validation - - OTA only updates the application image, not the full USB-style flash layout -- If you try the cold-boot target by OTA, the usual symptom is exactly this: - - upload looks "uncertain" - - device stays reachable - - uptime keeps increasing - - reboot cannot be proven -- Reboot verification is now strict: - - it records firmware version and uptime before upload - - it waits for the node to disappear from the network - - it waits for the node to come back - - if no offline transition is seen, it requires a clear uptime reset before declaring success -- This avoids false positives where a node stays reachable and the script would previously print `OK` too early. - -## USB Flash: Master - -Recommended clean-flash helper: - -```bash -cd /home/jan/Documents/RFP/WLED-MM/repo -./flash_master.sh -``` - -Check the serial port: - -```bash -ls /dev/ttyACM* /dev/ttyUSB* 2>/dev/null -``` - -Flash the master via USB: - -```bash -cd /home/jan/Documents/RFP/WLED-MM/repo - -sudo .venv/bin/python .piohome/packages/tool-esptoolpy/esptool.py \ +.venv/bin/python .piohome/packages/tool-esptoolpy/esptool.py \ --chip esp32s3 \ - --port /dev/ttyACM0 \ + --port "$PORT" \ --baud 460800 \ - --before no_reset \ + --before default_reset \ --after hard_reset \ write_flash -z \ --flash_mode qio \ @@ -321,88 +183,7 @@ sudo .venv/bin/python .piohome/packages/tool-esptoolpy/esptool.py \ 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 .pio/build/rfp_esp32s3_wroom1_n16r8_master/firmware.bin -``` - -Conservative master cold-boot firmware: - -```bash -cd /home/jan/Documents/RFP/WLED-MM/repo - -sudo .venv/bin/python .piohome/packages/tool-esptoolpy/esptool.py \ - --chip esp32s3 \ - --port /dev/ttyACM0 \ - --baud 460800 \ - --before no_reset \ - --after hard_reset \ - write_flash -z \ - --flash_mode qio \ - --flash_freq 80m \ - --flash_size 16MB \ - 0x0 .pio/build/rfp_esp32s3_wroom1_n16r8_master_coldboot/bootloader.bin \ - 0x8000 .pio/build/rfp_esp32s3_wroom1_n16r8_master_coldboot/partitions.bin \ - 0xe000 .piohome/packages/framework-arduinoespressif32/tools/partitions/boot_app0.bin \ - 0x10000 .pio/build/rfp_esp32s3_wroom1_n16r8_master_coldboot/firmware.bin -``` - -Use USB for the cold-boot master target. OTA is not sufficient for this test -because the fix changes bootloader/flash-mode related build settings. - -## USB Flash: Single Node - -Recommended clean-flash helper: - -```bash -cd /home/jan/Documents/RFP/WLED-MM/repo -./flash_node.sh -``` - -Check the serial port: - -```bash -ls /dev/ttyACM* /dev/ttyUSB* 2>/dev/null -``` - -Standard firmware: - -```bash -cd /home/jan/Documents/RFP/WLED-MM/repo - -sudo .venv/bin/python .piohome/packages/tool-esptoolpy/esptool.py \ - --chip esp32s3 \ - --port /dev/ttyACM0 \ - --baud 460800 \ - --before no_reset \ - --after hard_reset \ - write_flash -z \ - --flash_mode qio \ - --flash_freq 80m \ - --flash_size 16MB \ - 0x0 .pio/build/rfp_esp32s3_wroom1_n16r8_3x106/bootloader.bin \ - 0x8000 .pio/build/rfp_esp32s3_wroom1_n16r8_3x106/partitions.bin \ - 0xe000 .piohome/packages/framework-arduinoespressif32/tools/partitions/boot_app0.bin \ - 0x10000 .pio/build/rfp_esp32s3_wroom1_n16r8_3x106/firmware.bin -``` - -Cold-boot test firmware: - -```bash -cd /home/jan/Documents/RFP/WLED-MM/repo - -sudo .venv/bin/python .piohome/packages/tool-esptoolpy/esptool.py \ - --chip esp32s3 \ - --port /dev/ttyACM0 \ - --baud 460800 \ - --before no_reset \ - --after hard_reset \ - write_flash -z \ - --flash_mode dio \ - --flash_freq 80m \ - --flash_size 16MB \ - 0x0 .pio/build/rfp_esp32s3_wroom1_n16r8_3x106_coldboot/bootloader.bin \ - 0x8000 .pio/build/rfp_esp32s3_wroom1_n16r8_3x106_coldboot/partitions.bin \ - 0xe000 .piohome/packages/framework-arduinoespressif32/tools/partitions/boot_app0.bin \ - 0x10000 .pio/build/rfp_esp32s3_wroom1_n16r8_3x106_coldboot/firmware.bin + 0x10000 build_output/release/WLEDMM_14.7.2-mdev_RFP_N16R8_MASTER_V20260511E.bin ``` If upload does not start immediately: @@ -410,94 +191,284 @@ If upload does not start immediately: 1. Hold `BOOT` 2. Tap `RESET` 3. Release `BOOT` +4. Run the upload again -Use USB for the first flash of `rfp_esp32s3_wroom1_n16r8_3x106_coldboot`. -After that, if you go back to the normal node firmware, OTA is fine again with the standard node target. +## USB Clean Flash: Node -## USB Flash: Group - -USB flashing always happens physically one board after another unless several boards are connected at the same time. - -Recommended workflow: - -1. Build the desired target once. -2. Plug in node 1 and flash it. -3. Unplug node 1, plug in node 2, repeat. -4. Continue until node 6 is done. - -If the same serial path is reused each time, the single-node USB command above is the repeatable group-flash procedure. - -## Quick Difference: Master vs Node - -- Master: - - Target: `rfp_esp32s3_wroom1_n16r8_master` - - Typical IP: `192.168.178.10` - - Binary: `.pio/build/rfp_esp32s3_wroom1_n16r8_master/firmware.bin` -- Node: - - Target: `rfp_esp32s3_wroom1_n16r8_3x106` - - Typical IPs: `192.168.178.11` to `192.168.178.16` - - Binary: `.pio/build/rfp_esp32s3_wroom1_n16r8_3x106/firmware.bin` -- Cold-boot test node: - - Target: `rfp_esp32s3_wroom1_n16r8_3x106_coldboot` - - Binary: `.pio/build/rfp_esp32s3_wroom1_n16r8_3x106_coldboot/firmware.bin` -- Cold-boot test master: - - Target: `rfp_esp32s3_wroom1_n16r8_master_coldboot` - - Binary: `.pio/build/rfp_esp32s3_wroom1_n16r8_master_coldboot/firmware.bin` - -## Recommended Order - -1. Test the affected board with its cold-boot target: - - master: `rfp_esp32s3_wroom1_n16r8_master_coldboot` - - node: `rfp_esp32s3_wroom1_n16r8_3x106_coldboot` -2. Remove power for at least 20 to 30 seconds -3. Verify whether it now boots without pressing `RESET` -4. If it works, roll the same target to the remaining nodes -5. If it still fails, inspect the hardware power-up path on `EN`, 3.3V rail, and any external loads - -## Hand-off für andere (teamfähig) - -Use this section when you want to provide the procedure to other people without hard-coding your local paths. - -### Quickstart Template +Recommended helper: ```bash -export RFP_REPO="/path/to/WLED-MM/repo" -export NODE_BIN="/path/to/node/bin" # optional if node is already in PATH - -cd "$RFP_REPO" - -PATH="$NODE_BIN:$PATH" \ -NPM_CONFIG_CACHE="$RFP_REPO/.npm-cache" \ -PLATFORMIO_CORE_DIR="$RFP_REPO/.piohome" \ -PLATFORMIO_PACKAGES_DIR="$RFP_REPO/.piohome/packages" \ -PLATFORMIO_PLATFORMS_DIR="$RFP_REPO/.piohome/platforms" \ -PLATFORMIO_CACHE_DIR="$RFP_REPO/.piohome/.cache" \ -PLATFORMIO_BUILD_CACHE_DIR="$RFP_REPO/.piohome/buildcache" \ -.venv/bin/python -m platformio run -e rfp_esp32s3_wroom1_n16r8_3x106 +cd /home/jan/Documents/RFP/WLED-MM/repo +./flash_node.sh ``` -### OTA group-flash template +Manual flow: ```bash -cd "$RFP_REPO" +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 ,,,,, \ - --start-from \ - --firmware .pio/build/rfp_esp32s3_wroom1_n16r8_3x106/firmware.bin + --targets 192.168.178.11 \ + --firmware .pio/build/rfp_esp32s3_wroom1_n16r8_3x106/firmware.bin \ + --expect-release RFP_N16R8_NODE3x106_V20260511E \ + --preflight-only ``` -### Minimal hand-off checklist +## OTA Update: Whole Installation From RFP Wi-Fi -1. Confirm all nodes are reachable in the same network. -2. Build once, then flash. -3. Validate reboot behavior on each node. -4. If one node fails, resume with `--start-from` from that node. -5. Document which target was used (`standard` vs `coldboot`). +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. -### Rollback / removal plan for shared instructions +```bash +cd /home/jan/Documents/RFP/WLED-MM/repo +.venv/bin/python tools/rfp_update_all_ota.py +``` -1. Revert this doc section in git: - - `git checkout -- docs/rfp-node-flashing.md` -2. Or remove only the hand-off section manually if the rest should stay. -3. If a deployment should be rolled back, flash the previous known-good firmware bin with the same OTA/USB commands. +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. diff --git a/flash_master.sh b/flash_master.sh new file mode 100755 index 00000000..bd187899 --- /dev/null +++ b/flash_master.sh @@ -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}" diff --git a/flash_node.sh b/flash_node.sh new file mode 100755 index 00000000..a70639a8 --- /dev/null +++ b/flash_node.sh @@ -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 diff --git a/platformio.ini b/platformio.ini index c6f49061..09f82d77 100644 --- a/platformio.ini +++ b/platformio.ini @@ -2441,7 +2441,14 @@ extends = env:esp32S3_8MB_PSRAM_M_opi [env:rfp_esp32s3_wroom1_n16r8_3x106] ;; 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 +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 @@ -2453,7 +2460,14 @@ build_unflags = ${env:esp32S3_8MB_PSRAM_M_opi.build_unflags} -D IRPIN=-1 -D AUDIOPIN=-1 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 DATA_PINS=4,5,6 -D PIXEL_COUNTS=106,106,106 @@ -2465,6 +2479,54 @@ build_flags = ${env:esp32S3_8MB_PSRAM_M_opi.build_flags} -D IRPIN=-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] ;; MM for ESP32-S3 boards - FASTPATH + optimize for speed; ; HUB75 support included (may still have pin conflicts) extends = esp32_4MB_V4_M_base diff --git a/tools/infinity_visualizer_server.py b/tools/infinity_visualizer_server.py index 395696ee..bdbab0da 100644 --- a/tools/infinity_visualizer_server.py +++ b/tools/infinity_visualizer_server.py @@ -1,8 +1,9 @@ #!/usr/bin/env python3 -"""Local Infinity visualizer for the exact Global-2D layer. +"""Local Infinity visualizer for the Infinity Global-2D layer. -The browser does not reimplement WLED effects. This server proxies the master -state and renders only the Infinity Global-2D layer for the 6 x 3 x 106 layout. +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 @@ -24,28 +25,43 @@ 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"] +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"] -BLEND_NAMES = ["Replace", "Add", "Multiply Mask", "Palette Tint"] +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""" Infinity Local Visualizer
-

Infinity Local Visualizer

Exact Global-2D layer preview. WLED base effects are not simulated.
+

Infinity Local Visualizer

Global-2D preview with synthetic effect colors and panel-orientation calibration.
Starting...
""" @@ -65,12 +81,169 @@ def speed_to_bpm(speed: int) -> int: return round(BPM_MIN + speed * (BPM_MAX - BPM_MIN) / 255.0) -def spatial_phase(now_us: int, speed: int) -> float: +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 @@ -81,11 +254,75 @@ def panel_led_position(led: int) -> tuple[float, float, int]: 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_phase(now_us, speed) * span) % span + 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)) @@ -107,18 +344,18 @@ def center_pulse_amount(col: int, row: int, led: int, now_us: int, speed: int, v def checker_amount(col: int, row: int, led: int, now_us: int, speed: int, variant: int) -> int: parity = (row + col) & 1 - step = int(math.floor(spatial_phase(now_us, speed))) + 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 == 0) == first) else 0 + 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 = int(math.floor(spatial_phase(now_us, speed))) + 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) @@ -136,7 +373,7 @@ def arrow_amount(col: int, row: int, now_us: int, speed: int, direction: int, si minor = row if horizontal else col gap = max(1, 1 + size // 86) - 1 span = 3 + gap - movement = int(math.floor(spatial_phase(now_us, speed))) + 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) @@ -150,17 +387,24 @@ def scan_amount(col: int, row: int, led: int, now_us: int, speed: int, size: int 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 - min_progress, max_progress = -3.0, 8.0 + 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) + width - phase = (spatial_phase(now_us, speed) * travel) % travel + travel = max_progress - min_progress + if travel <= 0.001: + return 0 + phase = spatial_step_position(now_us, speed) % travel if direction == 6: - phase = (spatial_phase(now_us, speed) * travel) % (travel * 2.0) + 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 + 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) @@ -168,41 +412,111 @@ def scan_amount(col: int, row: int, led: int, now_us: int, speed: int, size: int return clamp_byte((1.0 - smoothstep(width * 0.5, width * 0.5 + 0.55, abs(progress - center))) * 255.0) -def snake_amount(col: int, row: int, now_us: int, speed: int, size: int, seed: int) -> int: +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 = row * NODE_COUNT + (NODE_COUNT - 1 - col if row & 1 else col) - step = int(math.floor(spatial_phase(now_us, speed))) - head = (step + seed % path_len) % path_len - length = 3 + max(1, size // 64) + 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 panel_index == (head + path_len - (offset % path_len)) % path_len: - return max(55, 255 - offset * 38) - apple = (seed * 17 + (step // path_len) * 11 + 7) % path_len - return 180 if panel_index == apple else 0 + 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_amount(mode: int, col: int, row: int, led: int, now_us: int, spatial: dict[str, Any], scene: dict[str, Any]) -> int: +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) + 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) + return checker_amount(col, row, led, now_us, speed, variant), "gradient" if mode == 3: - return arrow_amount(col, row, now_us, speed, direction, size) + 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) + 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_amount(col, row, now_us, speed, size, int(scene.get("seed", 1))) + 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) - return 0 + 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]: @@ -213,26 +527,47 @@ def render_frame(state: dict[str, Any]) -> dict[str, Any]: 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 = layer_amount(mode, col, row, led, now_us, spatial, scene) - amount = clamp_byte(amount * strength / 255.0) - leds.append(blend(primary, secondary, amount) if amount else [0, 0, 0]) + 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": VARIANT_NAMES[int(spatial.get("variant", 0))] if int(spatial.get("variant", 0)) < len(VARIANT_NAMES) else "Unknown", - "blend_name": BLEND_NAMES[int(spatial.get("blend", 2))] if int(spatial.get("blend", 2)) < len(BLEND_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 exact; WLED base not simulated", + "note": "2D layer with synthetic effect-color preview", } @@ -347,7 +682,7 @@ def main() -> int: 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 exact Infinity Global-2D layer; WLED base effects are not simulated.") + print("Rendering Infinity Global-2D layer with synthetic effect colors and panel calibration.") try: server.serve_forever() except KeyboardInterrupt: diff --git a/tools/rfp_master_usb_relay.py b/tools/rfp_master_usb_relay.py new file mode 100755 index 00000000..bb1c6b1c --- /dev/null +++ b/tools/rfp_master_usb_relay.py @@ -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()) diff --git a/tools/rfp_network_flash.py b/tools/rfp_network_flash.py index 368e13bf..459b6011 100755 --- a/tools/rfp_network_flash.py +++ b/tools/rfp_network_flash.py @@ -14,7 +14,7 @@ import time from concurrent.futures import ThreadPoolExecutor, as_completed from dataclasses import dataclass from pathlib import Path -from typing import Iterable +from typing import Any, Iterable import requests @@ -27,12 +27,23 @@ 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: @@ -86,8 +97,9 @@ def probe_wled_info(ip: str, timeout_s: float) -> WledInfo | None: except (requests.RequestException, ValueError): return None - # WLED info endpoint typically includes "name", "ver", and "arch" + # 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: @@ -99,14 +111,14 @@ def probe_wled_info(ip: str, timeout_s: float) -> WledInfo | None: arch = "-" uptime_s = int(data.get("uptime", 0) or 0) - return WledInfo(ip=ip, name=name, version=ver, arch=arch, uptime_s=uptime_s) + 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, arch=info.arch) + return WledHost(ip=info.ip, name=info.name, version=info.version, release=info.release, arch=info.arch) def discover_hosts( @@ -143,8 +155,8 @@ def read_targets_file(path: Path) -> list[str]: def write_discovery(path: Path, hosts: list[WledHost]) -> None: path.parent.mkdir(parents=True, exist_ok=True) - lines = ["# ip name version arch"] - lines += [f"{h.ip} {h.name} {h.version} {h.arch}" for h in hosts] + 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") @@ -176,22 +188,198 @@ def wait_for_online_info(ip: str, timeout_s: float, interval_s: float) -> WledIn return None -def reboot_confirmed(before: WledInfo | None, after: WledInfo, offline_seen: bool) -> tuple[bool, str]: +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 ota_flash(ip: str, firmware: Path, connect_timeout_s: float, read_timeout_s: float) -> tuple[str, str]: +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), ) @@ -200,16 +388,15 @@ def ota_flash(ip: str, firmware: Path, connect_timeout_s: float, read_timeout_s: return "uncertain", "read timeout after upload" except requests.ConnectionError: # Some devices close the socket abruptly when rebooting after successful OTA. - return "uncertain", "connection dropped during/after upload" + return "transport_reset", "connection dropped during/after upload" except requests.RequestException as exc: return "failed", f"request failed: {exc}" - text = (resp.text or "").lower() + text = resp.text or "" + snippet = " ".join(text.strip().split())[:180] if resp.status_code >= 400: - return "failed", f"http {resp.status_code}" - if "fail" in text or "error" in text: - return "failed", "device reported update failure" - return "ok", "ok" + return "failed", f"http {resp.status_code}: {snippet or 'empty response'}" + return classify_update_response(text) def print_hosts(hosts: list[WledHost]) -> None: @@ -217,10 +404,11 @@ def print_hosts(hosts: list[WledHost]) -> None: print("No WLED devices found.") return print(f"Found {len(hosts)} WLED device(s):") - print(f"{'IP':<16} {'Name':<24} {'Version':<18} {'Arch'}") - print("-" * 80) + print(f"{'IP':<16} {'Name':<24} {'Version':<18} {'Release':<30} {'Arch'}") + print("-" * 112) for h in hosts: - print(f"{h.ip:<16} {h.name[:24]:<24} {h.version[:18]:<18} {h.arch}") + 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: @@ -285,35 +473,64 @@ def cmd_flash(args: argparse.Namespace) -> int: failures: list[str] = [] for idx, ip in enumerate(targets, start=1): - before = probe_wled_info(ip, timeout_s=args.timeout) - if before is not None: - print( - f"[{idx}/{len(targets)}] {ip}: current firmware {before.version}, " - f"uptime {before.uptime_s}s, name '{before.name}'" - ) + 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 - if status == "uncertain": + 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: @@ -321,16 +538,30 @@ def cmd_flash(args: argparse.Namespace) -> int: failures.append(ip) continue - reboot_ok, reason = reboot_confirmed(before=before, after=after, offline_seen=offline_seen) + 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}, uptime {after.uptime_s}s, {reason})" + 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:") @@ -338,7 +569,10 @@ def cmd_flash(args: argparse.Namespace) -> int: print(f"- {ip}") return 1 - print("\nAll targets flashed successfully.") + if args.preflight_only: + print("\nAll targets passed preflight.") + else: + print("\nAll targets flashed successfully.") return 0 @@ -367,6 +601,15 @@ def build_parser() -> argparse.ArgumentParser: 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 diff --git a/tools/rfp_update_all_ota.py b/tools/rfp_update_all_ota.py index f16509b5..feb64d16 100755 --- a/tools/rfp_update_all_ota.py +++ b/tools/rfp_update_all_ota.py @@ -17,8 +17,10 @@ from pathlib import Path NODE_ENV = "rfp_esp32s3_wroom1_n16r8_3x106" MASTER_ENV = "rfp_esp32s3_wroom1_n16r8_master" -NODE_FIRMWARE = Path(".pio/build") / NODE_ENV / "firmware.bin" -MASTER_FIRMWARE = Path(".pio/build") / MASTER_ENV / "firmware.bin" +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 @@ -68,26 +70,107 @@ 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 flash(root: Path, targets: list[str], firmware: Path, env: dict[str, str], dry_run: bool) -> None: +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 - firmware_path = root / firmware - if not dry_run and not firmware_path.exists(): - raise FileNotFoundError(f"Firmware does not exist: {firmware_path}") - run( - [ - python_executable(root), - "tools/rfp_network_flash.py", - "flash", - "--targets", - ",".join(targets), - "--firmware", - str(firmware), - ], - root, - env, - dry_run, - ) + 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]]: @@ -115,6 +198,17 @@ def parse_args() -> argparse.Namespace: 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() @@ -144,12 +238,36 @@ def main() -> int: 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: - print("Flashing nodes sequentially...") - flash(root, node_targets, NODE_FIRMWARE, env, args.dry_run) + 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: - print("Flashing master last...") - flash(root, master_targets, MASTER_FIRMWARE, env, args.dry_run) + 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 diff --git a/tools/rfp_update_master_usb_then_nodes.py b/tools/rfp_update_master_usb_then_nodes.py new file mode 100755 index 00000000..ba27503f --- /dev/null +++ b/tools/rfp_update_master_usb_then_nodes.py @@ -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()) diff --git a/tools/setup_rfp_env.sh b/tools/setup_rfp_env.sh new file mode 100755 index 00000000..e468c2bd --- /dev/null +++ b/tools/setup_rfp_env.sh @@ -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 diff --git a/wled00/FX.cpp b/wled00/FX.cpp index c51ac98c..c1a7a425 100644 --- a/wled00/FX.cpp +++ b/wled00/FX.cpp @@ -12154,6 +12154,7 @@ static const char _data_RESERVED[] PROGMEM = "RSVD"; // 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) 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)) { DEBUG_PRINTF("addEffect(%d) -> ", id); DEBUG_PRINTF(" already in use, finding a new slot for -> %s\n", mode_name); diff --git a/wled00/FX.h b/wled00/FX.h index d43c47a8..52fb07e7 100644 --- a/wled00/FX.h +++ b/wled00/FX.h @@ -402,6 +402,99 @@ static uint8_t strip_getPaletteBlend(); // forward declaration: little helper t #define FX_MODE_COLORCLOUDS 229 #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(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 { M12_Pixels = 0, M12_pBar = 1, diff --git a/wled00/FX_fcn.cpp b/wled00/FX_fcn.cpp index b40376c7..f1101981 100644 --- a/wled00/FX_fcn.cpp +++ b/wled00/FX_fcn.cpp @@ -644,6 +644,7 @@ void Segment::setMode(uint8_t fx, bool loadDefaults, bool sliderDefaultsOnly) { static int16_t oldMap = -1; static int16_t oldSim = -1; static int16_t oldPalette = -1; + fx = rfpEffectSanitize(fx); // if we have a valid mode & is not reserved if (fx < strip.getModeCount() && strncmp_P("RSVD", strip.getModeData(fx), 4)) { if (fx != mode) { @@ -1821,6 +1822,7 @@ void WS2812FX::finalizeInit(void) uint8_t defPin[] = {defDataPins[i]}; uint16_t start = prevLen; 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; 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; @@ -2179,6 +2181,7 @@ void WS2812FX::setTargetFps(uint8_t fps) { void WS2812FX::setMode(uint8_t segid, uint8_t m) { if (segid >= _segments.size()) return; + m = rfpEffectSanitize(m); if (m >= getModeCount()) m = getModeCount() - 1; if (_segments[segid].mode != m) { diff --git a/wled00/cfg.cpp b/wled00/cfg.cpp index e8e75ca9..ac81e088 100644 --- a/wled00/cfg.cpp +++ b/wled00/cfg.cpp @@ -87,6 +87,52 @@ bool deserializeConfig(JsonObject doc, bool fromFS) { 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(); + JsonArray pins = onlyBus["pin"]; + needsMigration = pins.isNull() + || pins.size() != 1 + || pins[0].as() != 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) JsonObject hw_led = hw["led"]; @@ -446,6 +492,10 @@ bool deserializeConfig(JsonObject doc, bool fromFS) { CJSON(nodeListEnabled, if_nodes[F("list")]); CJSON(nodeBroadcastEnabled, if_nodes[F("bcast")]); + #ifdef WLED_ENABLE_INFINITY_CONTROLLER + infinityDeserializeConfig(interfaces); + #endif + JsonObject if_live = interfaces["live"]; CJSON(receiveDirect, if_live["en"]); CJSON(useMainSegmentOnly, if_live[F("mso")]); @@ -948,6 +998,10 @@ void serializeConfig() { if_nodes[F("list")] = nodeListEnabled; if_nodes[F("bcast")] = nodeBroadcastEnabled; + #ifdef WLED_ENABLE_INFINITY_CONTROLLER + infinitySerializeConfig(interfaces); + #endif + JsonObject if_live = interfaces.createNestedObject("live"); if_live["en"] = receiveDirect; if_live[F("mso")] = useMainSegmentOnly; diff --git a/wled00/const.h b/wled00/const.h index a422dfdd..d8f6657b 100644 --- a/wled00/const.h +++ b/wled00/const.h @@ -222,6 +222,7 @@ #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_PRESET 10 //apply presets (1 channel) +#define DMX_MODE_INFINITY 11 //RFP Infinity master control (32 channels) //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 diff --git a/wled00/data/index.js b/wled00/data/index.js index a62f75c9..34803aa1 100644 --- a/wled00/data/index.js +++ b/wled00/data/index.js @@ -550,9 +550,14 @@ function loadFXData(callback = null) }) .then((json)=>{ fxdata = json||[]; - // add default value for Solid - fxdata.shift() - fxdata.unshift(";!;"); + // RFP builds return id-keyed fxdata objects so effect IDs stay stable. + if (Array.isArray(fxdata)) { + // add default value for Solid + fxdata.shift() + fxdata.unshift(";!;"); + } else if (!fxdata[0]) { + fxdata[0] = ";!;"; + } }) .catch((e)=>{ fxdata = []; diff --git a/wled00/data/settings_sync.htm b/wled00/data/settings_sync.htm index 960f1461..2d243d91 100644 --- a/wled00/data/settings_sync.htm +++ b/wled00/data/settings_sync.htm @@ -167,6 +167,7 @@ DMX mode: +
E1.31 info
Timeout: ms
@@ -276,4 +277,4 @@ Netcat host Port:
- \ No newline at end of file + diff --git a/wled00/data/simple.js b/wled00/data/simple.js index 50d06ae1..a8fab9df 100644 --- a/wled00/data/simple.js +++ b/wled00/data/simple.js @@ -415,9 +415,14 @@ function loadFXData(callback = null) .then(json => { clearErrorToast(); fxdata = json||[]; - // add default value for Solid - fxdata.shift() - fxdata.unshift("@;!;"); + // RFP builds return id-keyed fxdata objects so effect IDs stay stable. + if (Array.isArray(fxdata)) { + // add default value for Solid + fxdata.shift() + fxdata.unshift("@;!;"); + } else if (!fxdata[0]) { + fxdata[0] = "@;!;"; + } }) .catch(function (error) { fxdata = []; diff --git a/wled00/dmx_input.cpp b/wled00/dmx_input.cpp index 3efed071..f4b62765 100644 --- a/wled00/dmx_input.cpp +++ b/wled00/dmx_input.cpp @@ -22,7 +22,7 @@ void rdmPersonalityChangedCb(dmx_port_t dmxPort, const rdm_header_t *header, if (header->cc == RDM_CC_SET_COMMAND_RESPONSE) { 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; 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[9].description = "PRESET"; 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. config.current_personality = DMXMode; @@ -278,4 +280,4 @@ void DMXInput::checkAndUpdateConfig() } } -#endif \ No newline at end of file +#endif diff --git a/wled00/e131.cpp b/wled00/e131.cpp index b332aa0b..c22daa06 100644 --- a/wled00/e131.cpp +++ b/wled00/e131.cpp @@ -389,6 +389,17 @@ void handleDMXData(uint16_t uni, uint16_t dmxChannels, uint8_t* e131_data, uint8 } 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: DEBUG_PRINTLN(F("unknown E1.31 DMX mode")); return; // nothing to do @@ -416,6 +427,7 @@ void handleArtnetPollReply(IPAddress ipAddress) { case DMX_MODE_EFFECT_W: case DMX_MODE_EFFECT_SEGMENT: case DMX_MODE_EFFECT_SEGMENT_W: + case DMX_MODE_INFINITY: break; // 1 universe is enough case DMX_MODE_MULTIPLE_DRGB: diff --git a/wled00/fcn_declare.h b/wled00/fcn_declare.h index e96efd83..1dc3de32 100644 --- a/wled00/fcn_declare.h +++ b/wled00/fcn_declare.h @@ -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 serializeState(JsonObject root, bool forPreset = false, bool includeBri = true, bool segmentBounds = true, bool selectedSegmentsOnly = false); void serializeInfo(JsonObject root); -void serializeModeNames(JsonArray arr, const char *qstring); -void serializeModeData(JsonObject root); +void serializeModeNames(JsonVariant root); +void serializeModeData(JsonVariant root); void serveJson(AsyncWebServerRequest* request); #ifdef WLED_ENABLE_JSONLIVE 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 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 void handleWs(); void wsEvent(AsyncWebSocket * server, AsyncWebSocketClient * client, AwsEventType type, void * arg, uint8_t *data, size_t len); diff --git a/wled00/infinity_sync.cpp b/wled00/infinity_sync.cpp index 960c65fd..eb4220f4 100644 --- a/wled00/infinity_sync.cpp +++ b/wled00/infinity_sync.cpp @@ -21,6 +21,7 @@ constexpr uint32_t INFINITY_STATUS_INTERVAL_MS = 1000; constexpr uint32_t INFINITY_NODE_TIMEOUT_MS = 3000; constexpr uint32_t INFINITY_APPLY_DELAY_US = 120000; constexpr uint16_t INFINITY_DMX_FOOTPRINT = 32; +constexpr uint8_t INFINITY_CUSTOM_COLOR_MAX = 24; constexpr uint8_t INFINITY_SPATIAL_OFF = 0; constexpr uint8_t INFINITY_SPATIAL_CENTER_PULSE = 1; constexpr uint8_t INFINITY_SPATIAL_CHECKER = 2; @@ -28,10 +29,9 @@ constexpr uint8_t INFINITY_SPATIAL_ARROW = 3; constexpr uint8_t INFINITY_SPATIAL_SCAN = 4; constexpr uint8_t INFINITY_SPATIAL_SNAKE = 5; constexpr uint8_t INFINITY_SPATIAL_WAVE_LINE = 6; -constexpr uint8_t INFINITY_SPATIAL_BLEND_REPLACE = 0; -constexpr uint8_t INFINITY_SPATIAL_BLEND_ADD = 1; -constexpr uint8_t INFINITY_SPATIAL_BLEND_MULTIPLY_MASK = 2; -constexpr uint8_t INFINITY_SPATIAL_BLEND_PALETTE_TINT = 3; +constexpr uint8_t INFINITY_SPATIAL_STROBE = 7; +constexpr uint8_t INFINITY_SPATIAL_SCHLAENGELN = 8; +constexpr uint8_t INFINITY_SPATIAL_SUNBURST = 9; constexpr uint8_t INFINITY_SPATIAL_VARIANT_EXPAND = 0; constexpr uint8_t INFINITY_SPATIAL_VARIANT_REVERSE = 1; constexpr uint8_t INFINITY_SPATIAL_VARIANT_OUTLINE = 2; @@ -45,8 +45,42 @@ constexpr uint8_t INFINITY_SPATIAL_DIRECTION_INWARD = 5; constexpr uint8_t INFINITY_SPATIAL_DIRECTION_PINGPONG = 6; constexpr uint8_t INFINITY_SPATIAL_OPTION_LINE = 0; constexpr uint8_t INFINITY_SPATIAL_OPTION_BANDS = 1; +constexpr uint8_t INFINITY_SPATIAL_OPTION_TWO_COLOR = 1; +constexpr uint8_t INFINITY_SPATIAL_OPTION_RAINBOW = 2; constexpr uint8_t INFINITY_BPM_MIN = 20; constexpr uint8_t INFINITY_BPM_MAX = 240; +constexpr float INFINITY_PANEL_GAP_RATIO = 0.50f; // 8 cm gap for roughly 16 cm active panel aperture. +constexpr float INFINITY_TAU = 6.28318530718f; + +struct PanelTransform { + uint8_t rotation; // clockwise quarter turns: 0, 1, 2, 3 + bool mirrorX; + bool mirrorY; +}; + +// Single source of truth for per-panel orientation used by global geometry effects. +// Keep the compact UI/visualizer grid in logical row/column order; transform only +// the local LED coordinates for radial/global calculations. +constexpr PanelTransform INFINITY_PANEL_TRANSFORMS[INFINITY_PANEL_COUNT][INFINITY_NODE_COUNT] = { + {{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}}, +}; + +enum SpatialRole : uint8_t { + SPATIAL_ROLE_GRADIENT = 0, + SPATIAL_ROLE_PRIMARY = 1, + SPATIAL_ROLE_SECONDARY = 2, + SPATIAL_ROLE_BLACK = 3, + SPATIAL_ROLE_EFFECT = 4, + SPATIAL_ROLE_RAINBOW = 5, +}; + +struct SpatialSample { + uint8_t amount; + SpatialRole role; + uint8_t hue; +}; enum InfinityPacketType : uint8_t { INFINITY_PACKET_CLOCK_SYNC = 1, @@ -82,12 +116,11 @@ struct __attribute__((packed)) InfinityScenePayload { uint16_t phase; uint64_t applyAtUs; uint8_t rowDimmer[3]; - uint8_t strobe; + uint8_t reserved0; uint8_t safetyFade; uint8_t custom[3]; uint8_t spatialMode; uint8_t spatialStrength; - uint8_t spatialBlend; uint8_t spatialVariant; uint8_t spatialDirection; uint8_t spatialSize; @@ -141,7 +174,7 @@ char infinityRuntimeNodeId[12] = "node-xx"; InfinityScenePayload infinityScene = { 0, // effectId 0, // presetId - 128, // brightness + 255, // brightness 128, // speed 128, // intensity 0, // palette @@ -161,7 +194,6 @@ InfinityScenePayload infinityScene = { {0, 0, 0}, INFINITY_SPATIAL_OFF, 180, - INFINITY_SPATIAL_BLEND_MULTIPLY_MASK, INFINITY_SPATIAL_VARIANT_EXPAND, INFINITY_SPATIAL_DIRECTION_LTR, 64, @@ -183,6 +215,17 @@ int64_t masterOffsetUs = 0; uint8_t lastAppliedPreset = 0; uint8_t lastAppliedEffect = 0; InfinityNodeStatus nodeStatuses[INFINITY_NODE_COUNT] = {}; +uint8_t infinityCustomColors[INFINITY_CUSTOM_COLOR_MAX][3] = { + {69, 57, 148}, + {61, 55, 135}, + {52, 48, 112}, + {49, 45, 101}, + {105, 153, 203}, + {0, 64, 255}, + {255, 158, 74}, + {8, 18, 46}, +}; +uint8_t infinityCustomColorCount = 8; bool isMasterBuild() { #ifdef WLED_INFINITY_MASTER @@ -214,9 +257,11 @@ uint64_t masterTimeUs() { } uint8_t clampMode(uint8_t mode) { + mode = rfpEffectSanitize(mode); const uint8_t count = strip.getModeCount(); if (count == 0) return 0; - return mode < count ? mode : mode % count; + if (mode < count && strncmp_P("RSVD", strip.getModeData(mode), 4)) return mode; + return rfpEffectFallback(); } String ipToString(const IPAddress& ip) { @@ -272,24 +317,34 @@ uint32_t scaleColor(uint32_t color, uint8_t scale) { return RGBW32(scaleByte(R(color), scale), scaleByte(G(color), scale), scaleByte(B(color), scale), scaleByte(W(color), scale)); } -uint32_t addColorCapped(uint32_t base, uint32_t add) { - return RGBW32(qadd8(R(base), R(add)), qadd8(G(base), G(add)), qadd8(B(base), B(add)), qadd8(W(base), W(add))); +bool isDirectSpatialColorRole(SpatialRole role) { + return role == SPATIAL_ROLE_PRIMARY || role == SPATIAL_ROLE_SECONDARY || role == SPATIAL_ROLE_RAINBOW; } -uint32_t applySpatialBlend(uint32_t base, uint32_t layer, uint8_t amount, uint8_t strength, uint8_t blendMode) { - const uint8_t mask = scaleByte(amount, strength); - const uint32_t scaledLayer = scaleColor(layer, mask); - switch (blendMode) { - case INFINITY_SPATIAL_BLEND_REPLACE: - return color_blend(base, layer, mask); - case INFINITY_SPATIAL_BLEND_ADD: - return addColorCapped(base, scaledLayer); - case INFINITY_SPATIAL_BLEND_PALETTE_TINT: - return color_blend(scaleColor(base, mask), layer, mask); - case INFINITY_SPATIAL_BLEND_MULTIPLY_MASK: - default: - return scaleColor(base, mask); +uint32_t composeSpatialColor(uint32_t base, uint32_t layer, uint8_t amount, uint8_t strength, SpatialRole role) { + if (isDirectSpatialColorRole(role)) { + // Direct color roles are literal output colors. Strength only dims + // effect-mask modes; it must not shift Primary/Secondary/Rainbow hues. + if (amount == 255) return layer; + return scaleColor(layer, amount); } + if (role == SPATIAL_ROLE_BLACK) return BLACK; + const uint8_t mask = scaleByte(amount, strength); + return scaleColor(base, mask); +} + +uint8_t normalizeSpatialOption(uint8_t mode, uint8_t option) { + if (mode == INFINITY_SPATIAL_SCAN) { + return option > INFINITY_SPATIAL_OPTION_BANDS ? INFINITY_SPATIAL_OPTION_LINE : option; + } + if (mode == INFINITY_SPATIAL_SUNBURST) { + return option > INFINITY_SPATIAL_OPTION_RAINBOW ? INFINITY_SPATIAL_OPTION_LINE : option; + } + return INFINITY_SPATIAL_OPTION_LINE; +} + +SpatialSample spatialSampleOf(uint8_t amount, SpatialRole role = SPATIAL_ROLE_GRADIENT, uint8_t hue = 0) { + return { amount, role, hue }; } bool parseNodeColumn(uint8_t& column) { @@ -328,12 +383,205 @@ uint8_t speedToBpm(uint8_t speed) { return INFINITY_BPM_MIN + ((static_cast(speed) * range + 127U) / 255U); } -float spatialPhase(uint64_t timeUs, uint8_t speed) { +float spatialBeatPosition(uint64_t timeUs, uint8_t speed) { const float seconds = static_cast(timeUs % 60000000ULL) / 1000000.0f; const float cyclesPerSecond = static_cast(speedToBpm(speed)) / 60.0f; return seconds * cyclesPerSecond; } +float spatialStepPosition(uint64_t timeUs, uint8_t speed) { + // Two visible animation phases make one measured on/off panel cycle match the BPM value. + return spatialBeatPosition(timeUs, speed) * 2.0f; +} + +uint32_t spatialBeatIndex(uint64_t timeUs, uint8_t speed) { + return static_cast(floorf(spatialBeatPosition(timeUs, speed))); +} + +float spatialBeatFrac(uint64_t timeUs, uint8_t speed) { + const float beat = spatialBeatPosition(timeUs, speed); + return beat - floorf(beat); +} + +uint32_t spatialStepIndex(uint64_t timeUs, uint8_t speed) { + return static_cast(floorf(spatialStepPosition(timeUs, speed))); +} + +uint8_t strobeAmount(uint64_t timeUs, uint8_t speed, uint8_t pulseWidth) { + const float duty = 0.01f + (static_cast(pulseWidth) / 255.0f) * 0.34f; + const float phase = spatialBeatPosition(timeUs, speed) * 8.0f; + return (phase - floorf(phase)) < duty ? 255 : 0; +} + +uint8_t serpentinePanelIndex(uint8_t column, uint8_t row) { + return (row & 0x01) + ? (row * INFINITY_NODE_COUNT + (INFINITY_NODE_COUNT - 1 - column)) + : (row * INFINITY_NODE_COUNT + column); +} + +uint8_t mirroredSerpentinePanelIndex(uint8_t column, uint8_t row, uint8_t variant) { + if (variant == 1 || variant == 3) column = INFINITY_NODE_COUNT - 1 - column; + if (variant == 2 || variant == 3) row = INFINITY_PANEL_COUNT - 1 - row; + return serpentinePanelIndex(column, row); +} + +uint8_t chainLengthFromSize(uint8_t size) { + if (size <= 64) return max(1, size / 16); + return min(18, 4 + ((static_cast(size - 64) * 14U + 95U) / 191U)); +} + +uint8_t snakeLengthFromSize(uint8_t size) { + return min(17, 3 + max(1, size / 64)); +} + +bool snakeContains(uint8_t position, uint8_t head, uint8_t length, uint8_t pathLen) { + for (uint8_t offset = 0; offset < length; offset++) { + if (position == (head + pathLen - (offset % pathLen)) % pathLen) return true; + } + return false; +} + +uint32_t spatialHash(uint32_t value) { + value ^= value >> 16; + value *= 0x7feb352dUL; + value ^= value >> 15; + value *= 0x846ca68bUL; + value ^= value >> 16; + return value; +} + +uint8_t spawnSnakeApple(uint32_t seed, uint16_t generation, uint16_t step, uint8_t length, uint8_t pathLen) { + const uint8_t head = (step + (seed % pathLen)) % pathLen; + uint8_t candidate = spatialHash(seed ^ (static_cast(generation) * 0x9e3779b9UL) ^ step) % pathLen; + for (uint8_t tries = 0; tries < pathLen; tries++) { + if (!snakeContains(candidate, head, length, pathLen)) return candidate; + candidate = (candidate + 5) % pathLen; + } + return (head + length + 1) % pathLen; +} + +uint8_t snakeAppleAtStep(uint32_t seed, uint16_t localStep, uint8_t length, uint8_t pathLen) { + uint16_t generation = 0; + uint8_t apple = spawnSnakeApple(seed, generation, 0, length, pathLen); + for (uint16_t step = 0; step <= localStep; step++) { + const uint8_t head = (step + (seed % pathLen)) % pathLen; + if (head == apple) { + generation++; + apple = spawnSnakeApple(seed, generation, step + 1, length, pathLen); + } + } + return apple; +} + +uint8_t gridPanelIndex(uint8_t column, uint8_t row) { + return row * INFINITY_NODE_COUNT + column; +} + +uint8_t gridColumn(uint8_t index) { + return index % INFINITY_NODE_COUNT; +} + +uint8_t gridRow(uint8_t index) { + return index / INFINITY_NODE_COUNT; +} + +bool snakeBodyContains(const uint8_t* body, uint8_t length, uint8_t position, uint8_t skipTail = 0) { + const uint8_t end = length > skipTail ? length - skipTail : 0; + for (uint8_t i = 0; i < end; i++) { + if (body[i] == position) return true; + } + return false; +} + +uint8_t snakeSpawnApple(const uint8_t* body, uint8_t length, uint32_t seed, uint16_t generation) { + uint8_t candidate = spatialHash(seed ^ (static_cast(generation) * 0x9e3779b9UL)) % (INFINITY_NODE_COUNT * INFINITY_PANEL_COUNT); + for (uint8_t tries = 0; tries < INFINITY_NODE_COUNT * INFINITY_PANEL_COUNT; tries++) { + if (!snakeBodyContains(body, length, candidate)) return candidate; + candidate = (candidate + 7) % (INFINITY_NODE_COUNT * INFINITY_PANEL_COUNT); + } + return body[0]; +} + +uint8_t snakeNeighbor(uint8_t position, uint8_t direction, bool& valid) { + const uint8_t col = gridColumn(position); + const uint8_t row = gridRow(position); + valid = true; + switch (direction) { + case 0: if (col + 1 < INFINITY_NODE_COUNT) return gridPanelIndex(col + 1, row); break; + case 1: if (col > 0) return gridPanelIndex(col - 1, row); break; + case 2: if (row + 1 < INFINITY_PANEL_COUNT) return gridPanelIndex(col, row + 1); break; + case 3: if (row > 0) return gridPanelIndex(col, row - 1); break; + } + valid = false; + return position; +} + +uint8_t snakeDistance(uint8_t a, uint8_t b) { + const int8_t dx = static_cast(gridColumn(a)) - static_cast(gridColumn(b)); + const int8_t dy = static_cast(gridRow(a)) - static_cast(gridRow(b)); + return abs(dx) + abs(dy); +} + +void snakeReset(uint8_t* body, uint8_t& length, uint8_t targetLength, uint8_t& apple, uint16_t& generation, uint32_t seed, uint16_t epoch) { + body[0] = spatialHash(seed ^ (static_cast(epoch) * 0x45d9f3bUL)) % (INFINITY_NODE_COUNT * INFINITY_PANEL_COUNT); + length = min(targetLength, 3); + for (uint8_t i = 1; i < length; i++) body[i] = body[0]; + generation++; + apple = snakeSpawnApple(body, length, seed ^ epoch, generation); +} + +void snakeStateAt(uint16_t localStep, uint8_t targetLength, uint32_t seed, uint8_t* body, uint8_t& length, uint8_t& apple) { + uint16_t generation = 0; + snakeReset(body, length, targetLength, apple, generation, seed, 0); + static const uint8_t baseOrder[4] = {0, 2, 1, 3}; // right, down, left, up + for (uint16_t step = 0; step < localStep; step++) { + uint8_t best = body[0]; + uint8_t bestDistance = 255; + const uint8_t orderOffset = spatialHash(seed ^ (static_cast(step) * 0x9e3779b1UL) ^ generation) & 0x03; + for (uint8_t i = 0; i < 4; i++) { + bool valid = false; + const uint8_t next = snakeNeighbor(body[0], baseOrder[(i + orderOffset) & 0x03], valid); + if (!valid) continue; + const bool willEat = next == apple; + if (snakeBodyContains(body, length, next, willEat ? 0 : 1)) continue; + const uint8_t distance = snakeDistance(next, apple); + if (distance < bestDistance) { + bestDistance = distance; + best = next; + } + } + if (best == body[0] && body[0] != apple) { + snakeReset(body, length, targetLength, apple, generation, seed, step + 1); + continue; + } + const bool ate = best == apple; + const uint8_t newLength = ate ? min(targetLength, length + 1) : length; + for (int8_t i = static_cast(newLength) - 1; i > 0; i--) body[i] = body[i - 1]; + body[0] = best; + length = newLength; + if (ate) { + generation++; + apple = snakeSpawnApple(body, length, seed ^ step, generation); + } + } +} + +uint16_t triangleStep(uint32_t step, uint8_t maxIndex) { + if (maxIndex == 0) return 0; + const uint16_t period = static_cast(maxIndex) * 2U; + uint16_t phase = step % period; + if (phase > maxIndex) phase = period - phase; + return phase; +} + +int16_t schlaengelnPingPongPosition(uint32_t step, uint8_t offset, uint8_t maxIndex) { + if (maxIndex == 0) return 0; + const uint16_t period = static_cast(maxIndex) * 2U; + uint16_t phase = (step + period - (offset % period)) % period; + if (phase > maxIndex) phase = period - phase; + return phase; +} + void panelLedPosition(uint16_t led, float& x, float& y, uint8_t& side) { if (led < 25) { side = 0; // top, left -> right @@ -358,13 +606,61 @@ void panelLedPosition(uint16_t led, float& x, float& y, uint8_t& side) { y = 1.0f - ((led - 79 + 0.5f) / 27.0f); } +void applyPanelTransform(uint8_t column, uint8_t row, float& x, float& y) { + if (row >= INFINITY_PANEL_COUNT || column >= INFINITY_NODE_COUNT) return; + const PanelTransform& transform = INFINITY_PANEL_TRANSFORMS[row][column]; + if (transform.mirrorX) x = 1.0f - x; + if (transform.mirrorY) y = 1.0f - y; + for (uint8_t i = 0; i < (transform.rotation & 0x03); i++) { + const float oldX = x; + x = 1.0f - y; + y = oldX; + } +} + +void physicalPanelLedPosition(uint8_t column, uint8_t row, uint16_t led, float& x, float& y) { + float lx = 0.0f, ly = 0.0f; + uint8_t side = 0; + panelLedPosition(led, lx, ly, side); + applyPanelTransform(column, row, lx, ly); + const float pitch = 1.0f + INFINITY_PANEL_GAP_RATIO; + x = static_cast(column) * pitch + lx; + y = static_cast(row) * pitch + ly; +} + +void applySunburstPanelTransform(float& x, float& y) { + // Only Sunburst needs the observed 90-degree-left correction. Keep the + // global transform neutral so Scan and the other modes stay unrotated. + for (uint8_t i = 0; i < 3; i++) { + const float oldX = x; + x = 1.0f - y; + y = oldX; + } +} + +void sunburstPanelLedPosition(uint8_t column, uint8_t row, uint16_t led, float& x, float& y) { + float lx = 0.0f, ly = 0.0f; + uint8_t side = 0; + panelLedPosition(led, lx, ly, side); + applySunburstPanelTransform(lx, ly); + const float pitch = 1.0f + INFINITY_PANEL_GAP_RATIO; + x = static_cast(column) * pitch + lx; + y = static_cast(row) * pitch + ly; +} + +void physicalPanelCenter(float& cx, float& cy) { + const float pitch = 1.0f + INFINITY_PANEL_GAP_RATIO; + cx = ((static_cast(INFINITY_NODE_COUNT - 1) * pitch) + 1.0f) * 0.5f; + cy = ((static_cast(INFINITY_PANEL_COUNT - 1) * pitch) + 1.0f) * 0.5f; +} + uint8_t centerPulseAmount(uint8_t column, uint8_t row, uint64_t timeUs, uint8_t speed, uint8_t variant) { constexpr float centerRow = 1.0f; constexpr float centerCol = 2.5f; constexpr float maxDistance = 3.5f; const float distance = fabsf(static_cast(row) - centerRow) + fabsf(static_cast(column) - centerCol); const float span = maxDistance + 1.0f; - float front = fmodf(spatialPhase(timeUs, speed) * span, span); + float front = fmodf(spatialStepPosition(timeUs, speed), span); if (variant == INFINITY_SPATIAL_VARIANT_REVERSE || variant == INFINITY_SPATIAL_VARIANT_OUTLINE_REVERSE) front = maxDistance - front; const float delta = fabsf(distance - front); return floatToAmount(1.0f - smoothstepf(0.0f, 0.70f, delta)); @@ -389,21 +685,21 @@ uint8_t centerPulseOutlineAmount(uint8_t column, uint8_t row, uint16_t led, uint uint8_t checkerdAmount(uint8_t column, uint8_t row, uint16_t led, uint64_t timeUs, uint8_t variant, uint8_t speed) { const uint8_t parity = (row + column) & 0x01; - const uint16_t step = static_cast(floorf(spatialPhase(timeUs, speed))); + const uint16_t step = static_cast(spatialStepIndex(timeUs, speed)); if (variant == 1 || variant == 2) { float x = 0.0f, y = 0.0f; uint8_t side = 0; panelLedPosition(led, x, y, side); const bool slash = variant == 2 && (step & 0x01); const bool first = slash ? (y <= 1.0f - x) : (y <= x); - return ((parity == 0) == first) ? 255 : 0; + return ((((parity + step) & 0x01) == 0) == first) ? 255 : 0; } return (((parity + step) & 0x01) == 0) ? 255 : 0; } uint8_t waveLineAmount(uint8_t column, uint8_t row, uint64_t timeUs, uint8_t direction, uint8_t speed) { static const uint8_t triangle[4] = {0, 1, 2, 1}; - const int32_t step = static_cast(floorf(spatialPhase(timeUs, speed))); + const int32_t step = static_cast(spatialStepIndex(timeUs, speed)); if (direction == INFINITY_SPATIAL_DIRECTION_TTB || direction == INFINITY_SPATIAL_DIRECTION_BTT) { const int32_t phase = direction == INFINITY_SPATIAL_DIRECTION_TTB ? step : -step; const uint8_t target = static_cast(roundf(triangle[((static_cast(row) - phase) % 4 + 4) % 4] * ((INFINITY_NODE_COUNT - 1) / 2.0f) / 2.0f)); @@ -422,7 +718,7 @@ uint8_t arrowAmount(uint8_t column, uint8_t row, uint64_t timeUs, uint8_t direct const uint8_t minor = horizontal ? row : column; const uint8_t gap = max(1, 1 + (size / 86)) - 1; const uint8_t span = 3 + gap; - const int32_t movement = static_cast(floorf(spatialPhase(timeUs, speed))); + const int32_t movement = static_cast(spatialStepIndex(timeUs, speed)); const float middleMinor = (static_cast(minorCount) - 1.0f) / 2.0f; const uint8_t band = fabsf(static_cast(minor) - middleMinor) <= 0.55f ? 0 : 1; const bool orientationRight = direction == INFINITY_SPATIAL_DIRECTION_LTR || direction == INFINITY_SPATIAL_DIRECTION_TTB || direction == INFINITY_SPATIAL_DIRECTION_OUTWARD; @@ -447,18 +743,23 @@ uint8_t scanAmount(uint8_t column, uint8_t row, uint16_t led, uint64_t timeUs, u const bool vertical = direction == INFINITY_SPATIAL_DIRECTION_TTB || direction == INFINITY_SPATIAL_DIRECTION_BTT; scanVector((angle + (vertical ? 90 : 0)) % 360, vx, vy); const float progress = (static_cast(column) + x) * vx + (static_cast(row) + y) * vy; - const float minProgress = -3.0f; - const float maxProgress = 8.0f; + const float p00 = 0.0f; + const float p10 = static_cast(INFINITY_NODE_COUNT) * vx; + const float p01 = static_cast(INFINITY_PANEL_COUNT) * vy; + const float p11 = p10 + p01; + const float minProgress = fminf(fminf(p00, p10), fminf(p01, p11)); + const float maxProgress = fmaxf(fmaxf(p00, p10), fmaxf(p01, p11)); const float width = 0.15f + (static_cast(size) / 255.0f) * (option == INFINITY_SPATIAL_OPTION_BANDS ? 1.60f : 0.85f); - const float travel = (maxProgress - minProgress) + width; - float phase = fmodf(spatialPhase(timeUs, speed) * travel, travel); + const float travel = maxProgress - minProgress; + if (travel <= 0.001f) return 0; + float phase = fmodf(spatialStepPosition(timeUs, speed), travel); if (direction == INFINITY_SPATIAL_DIRECTION_PINGPONG) { - phase = fmodf(spatialPhase(timeUs, speed) * travel, travel * 2.0f); + phase = fmodf(spatialStepPosition(timeUs, speed), travel * 2.0f); if (phase > travel) phase = (travel * 2.0f) - phase; } else if (direction == INFINITY_SPATIAL_DIRECTION_RTL || direction == INFINITY_SPATIAL_DIRECTION_BTT) { phase = travel - phase; } - const float center = minProgress + phase; + const float center = minProgress + max(0.0f, min(travel, phase)); if (option == INFINITY_SPATIAL_OPTION_BANDS) { const float period = width * 2.0f + 0.35f; const float d = fabsf(fmodf(progress - center + period * 64.0f, period) - period * 0.5f); @@ -467,45 +768,122 @@ uint8_t scanAmount(uint8_t column, uint8_t row, uint16_t led, uint64_t timeUs, u return floatToAmount(1.0f - smoothstepf(width * 0.5f, width * 0.5f + 0.55f, fabsf(progress - center))); } -uint8_t snakeAmount(uint8_t column, uint8_t row, uint64_t timeUs, uint8_t speed, uint8_t size) { - const uint8_t pathLen = INFINITY_NODE_COUNT * INFINITY_PANEL_COUNT; - const uint8_t panelIndex = (row & 0x01) ? (row * INFINITY_NODE_COUNT + (INFINITY_NODE_COUNT - 1 - column)) : (row * INFINITY_NODE_COUNT + column); - const uint32_t step = static_cast(floorf(spatialPhase(timeUs, speed))); - const uint8_t head = (step + (infinityScene.seed % pathLen)) % pathLen; - const uint8_t length = 3 + max(1, size / 64); +SpatialSample snakeSample(uint8_t column, uint8_t row, uint64_t timeUs, uint8_t speed, uint8_t size) { + const uint8_t panelIndex = gridPanelIndex(column, row); + const uint8_t targetLength = snakeLengthFromSize(size); + const uint16_t localStep = spatialStepIndex(timeUs, speed) % 240U; + uint8_t body[INFINITY_NODE_COUNT * INFINITY_PANEL_COUNT] = {0}; + uint8_t length = 0; + uint8_t apple = 0; + snakeStateAt(localStep, targetLength, infinityScene.seed, body, length, apple); for (uint8_t i = 0; i < length; i++) { - if (panelIndex == (head + pathLen - (i % pathLen)) % pathLen) return 255 - min(200, i * 38); + if (panelIndex == body[i]) { + return spatialSampleOf(255, SPATIAL_ROLE_PRIMARY); + } } - const uint8_t apple = (static_cast(infinityScene.seed * 17U + (step / pathLen) * 11U + 7U) % pathLen); - return panelIndex == apple ? 180 : 0; + return panelIndex == apple ? spatialSampleOf(255, SPATIAL_ROLE_SECONDARY) : spatialSampleOf(0); } -uint8_t spatialAmount(uint8_t column, uint8_t row, uint16_t led, uint64_t timeUs) { +SpatialSample schlaengelnSample(uint8_t column, uint8_t row, uint64_t timeUs, uint8_t speed, uint8_t size, uint8_t variant, uint8_t direction) { + const uint8_t pathLen = INFINITY_NODE_COUNT * INFINITY_PANEL_COUNT; + const uint8_t panelIndex = mirroredSerpentinePanelIndex(column, row, variant); + const uint8_t length = chainLengthFromSize(size); + const uint32_t step = spatialStepIndex(timeUs, speed); + for (uint8_t offset = 0; offset < length; offset++) { + uint8_t pos = 0; + if (direction == INFINITY_SPATIAL_DIRECTION_PINGPONG) { + pos = schlaengelnPingPongPosition(step, offset, pathLen - 1); + } else if (direction == INFINITY_SPATIAL_DIRECTION_RTL) { + const uint8_t head = (pathLen - 1) - (step % pathLen); + pos = (head + pathLen - (offset % pathLen)) % pathLen; + } else { + pos = (step + pathLen - (offset % pathLen)) % pathLen; + } + if (panelIndex == pos) return spatialSampleOf(255 - min(200, offset * 30)); + } + return spatialSampleOf(0); +} + +SpatialSample sunburstSample(uint8_t column, uint8_t row, uint16_t led, uint64_t timeUs, uint8_t speed, uint8_t variant, uint8_t option) { + float x = 0.0f, y = 0.0f, cx = 0.0f, cy = 0.0f; + sunburstPanelLedPosition(column, row, led, x, y); + physicalPanelCenter(cx, cy); + const float dx = x - cx; + const float dy = y - cy; + float wobble = 0.0f; + if (variant == 1) wobble = sinf(spatialBeatPosition(timeUs, speed) * INFINITY_TAU) * 0.18f; + const float rotation = variant == 2 ? spatialBeatPosition(timeUs, speed) * (INFINITY_TAU / 24.0f) : 0.0f; + float angle = atan2f(dy, dx) + wobble - rotation; + if (angle < 0.0f) angle += INFINITY_TAU; + while (angle >= INFINITY_TAU) angle -= INFINITY_TAU; + constexpr float rays = 12.0f; // 12 bright rays + 12 dark gaps. + const bool active = cosf(angle * rays) >= 0.0f; + if (option == INFINITY_SPATIAL_OPTION_TWO_COLOR) { + return spatialSampleOf(255, active ? SPATIAL_ROLE_PRIMARY : SPATIAL_ROLE_SECONDARY); + } + if (option == INFINITY_SPATIAL_OPTION_RAINBOW) { + const uint8_t hue = static_cast((angle / INFINITY_TAU) * 255.0f) + static_cast(spatialBeatPosition(timeUs, speed) * 8.0f); + return active ? spatialSampleOf(255, SPATIAL_ROLE_RAINBOW, hue) : spatialSampleOf(255, SPATIAL_ROLE_BLACK); + } + return active ? spatialSampleOf(255) : spatialSampleOf(255, SPATIAL_ROLE_BLACK); +} + +SpatialSample spatialSample(uint8_t column, uint8_t row, uint16_t led, uint64_t timeUs) { switch (infinityScene.spatialMode) { case INFINITY_SPATIAL_CENTER_PULSE: - return centerPulseOutlineAmount(column, row, led, centerPulseAmount(column, row, timeUs, infinityScene.speed, infinityScene.spatialVariant), infinityScene.spatialVariant); + return spatialSampleOf(centerPulseOutlineAmount(column, row, led, centerPulseAmount(column, row, timeUs, infinityScene.speed, infinityScene.spatialVariant), infinityScene.spatialVariant)); case INFINITY_SPATIAL_CHECKER: - return checkerdAmount(column, row, led, timeUs, infinityScene.spatialVariant, infinityScene.speed); + return spatialSampleOf(checkerdAmount(column, row, led, timeUs, infinityScene.spatialVariant, infinityScene.speed)); case INFINITY_SPATIAL_ARROW: - return arrowAmount(column, row, timeUs, infinityScene.spatialDirection, infinityScene.speed, infinityScene.spatialSize); + return spatialSampleOf(arrowAmount(column, row, timeUs, infinityScene.spatialDirection, infinityScene.speed, infinityScene.spatialSize)); case INFINITY_SPATIAL_SCAN: - return scanAmount(column, row, led, timeUs, infinityScene.speed, infinityScene.spatialSize, infinityScene.spatialAngle, infinityScene.spatialOption, infinityScene.spatialDirection); + return spatialSampleOf(scanAmount(column, row, led, timeUs, infinityScene.speed, infinityScene.spatialSize, infinityScene.spatialAngle, infinityScene.spatialOption, infinityScene.spatialDirection)); case INFINITY_SPATIAL_SNAKE: - return snakeAmount(column, row, timeUs, infinityScene.speed, infinityScene.spatialSize); + return snakeSample(column, row, timeUs, infinityScene.speed, infinityScene.spatialSize); case INFINITY_SPATIAL_WAVE_LINE: - return waveLineAmount(column, row, timeUs, infinityScene.spatialDirection, infinityScene.speed); + return spatialSampleOf(waveLineAmount(column, row, timeUs, infinityScene.spatialDirection, infinityScene.speed)); + case INFINITY_SPATIAL_STROBE: + return spatialSampleOf(strobeAmount(timeUs, infinityScene.speed, infinityScene.spatialSize)); + case INFINITY_SPATIAL_SCHLAENGELN: + return schlaengelnSample(column, row, timeUs, infinityScene.speed, infinityScene.spatialSize, infinityScene.spatialVariant, infinityScene.spatialDirection); + case INFINITY_SPATIAL_SUNBURST: + return sunburstSample(column, row, led, timeUs, infinityScene.speed, infinityScene.spatialVariant, infinityScene.spatialOption); default: - return 0; + return spatialSampleOf(0); } } -uint32_t spatialLayerColor(Segment& seg, uint16_t led, uint8_t amount) { +uint32_t spatialLayerColor(Segment& seg, uint16_t led, SpatialSample sample) { + if (sample.role == SPATIAL_ROLE_PRIMARY) return rgbToColor(infinityScene.primary); + if (sample.role == SPATIAL_ROLE_SECONDARY) return rgbToColor(infinityScene.secondary); + if (sample.role == SPATIAL_ROLE_BLACK) return BLACK; + if (sample.role == SPATIAL_ROLE_RAINBOW) { + uint8_t rgb[3] = {0, 0, 0}; + hsvToRgb(sample.hue, 255, 255, rgb); + return rgbToColor(rgb); + } if (infinityScene.palette > 0) { seg.setCurrentPalette(); const uint16_t idx = (static_cast(led) * 255U) / (INFINITY_LEDS_PER_PANEL - 1U); - return seg.color_from_palette(idx + ((masterTimeUs() / 20000ULL) & 0xFF), false, true, 0, amount); + return seg.color_from_palette(idx + ((masterTimeUs() / 20000ULL) & 0xFF), false, true, 0, sample.amount); } - return color_blend(rgbToColor(infinityScene.secondary), rgbToColor(infinityScene.primary), amount); + return color_blend(rgbToColor(infinityScene.secondary), rgbToColor(infinityScene.primary), sample.amount); +} + +bool spatialExactRgb(SpatialSample sample, uint8_t rgb[3]) { + if (sample.role == SPATIAL_ROLE_PRIMARY) { + memcpy(rgb, infinityScene.primary, 3); + return true; + } + if (sample.role == SPATIAL_ROLE_SECONDARY) { + memcpy(rgb, infinityScene.secondary, 3); + return true; + } + if (sample.role == SPATIAL_ROLE_RAINBOW) { + hsvToRgb(sample.hue, 255, 255, rgb); + return true; + } + return false; } void setSegmentName(Segment& seg, const char* name) { @@ -764,6 +1142,30 @@ void writeRgb(JsonArray arr, const uint8_t rgb[3]) { arr.add(rgb[2]); } +void serializeCustomColors(JsonArray arr) { + for (uint8_t i = 0; i < infinityCustomColorCount; i++) { + JsonArray color = arr.createNestedArray(); + writeRgb(color, infinityCustomColors[i]); + } +} + +void deserializeCustomColors(JsonVariant value) { + if (!value.is()) return; + JsonArray arr = value.as(); + uint8_t count = 0; + for (size_t i = 0; i < arr.size() && count < INFINITY_CUSTOM_COLOR_MAX; i++) { + if (!arr[i].is()) continue; + JsonArray color = arr[i].as(); + uint8_t rgb[3] = {0, 0, 0}; + readRgb(color, rgb); + infinityCustomColors[count][0] = rgb[0]; + infinityCustomColors[count][1] = rgb[1]; + infinityCustomColors[count][2] = rgb[2]; + count++; + } + infinityCustomColorCount = count; +} + void serializeNodeIps(JsonArray arr) { for (uint8_t i = 0; i < INFINITY_NODE_COUNT; i++) arr.add(ipToString(infinityNodeIps[i])); } @@ -799,12 +1201,10 @@ void serializeScene(JsonObject scene) { rows.add(infinityScene.rowDimmer[0]); rows.add(infinityScene.rowDimmer[1]); rows.add(infinityScene.rowDimmer[2]); - scene["strobe"] = infinityScene.strobe; scene["safety_fade"] = infinityScene.safetyFade; JsonObject spatial = scene.createNestedObject("spatial"); spatial["mode"] = infinityScene.spatialMode; spatial["strength"] = infinityScene.spatialStrength; - spatial["blend"] = infinityScene.spatialBlend; spatial["variant"] = infinityScene.spatialVariant; spatial["direction"] = infinityScene.spatialDirection; spatial["size"] = infinityScene.spatialSize; @@ -814,7 +1214,7 @@ void serializeScene(JsonObject scene) { void deserializeScene(JsonObject scene) { if (scene.isNull()) return; - infinityScene.effectId = jsonU8(scene["effect"], infinityScene.effectId); + infinityScene.effectId = rfpEffectSanitize(jsonU8(scene["effect"], infinityScene.effectId)); infinityScene.presetId = jsonU8(scene["preset"], infinityScene.presetId); infinityScene.brightness = jsonU8(scene["brightness"], infinityScene.brightness); infinityScene.speed = jsonU8(scene["speed"], infinityScene.speed); @@ -834,13 +1234,11 @@ void deserializeScene(JsonObject scene) { JsonArray rows = scene["row_dimmer"].as(); for (uint8_t i = 0; i < 3 && i < rows.size(); i++) infinityScene.rowDimmer[i] = jsonU8(rows[i], infinityScene.rowDimmer[i]); } - infinityScene.strobe = jsonU8(scene["strobe"], infinityScene.strobe); infinityScene.safetyFade = jsonU8(scene["safety_fade"], infinityScene.safetyFade); if (scene["spatial"].is()) { JsonObject spatial = scene["spatial"].as(); infinityScene.spatialMode = jsonU8(spatial["mode"], infinityScene.spatialMode); infinityScene.spatialStrength = jsonU8(spatial["strength"], infinityScene.spatialStrength); - infinityScene.spatialBlend = jsonU8(spatial["blend"], infinityScene.spatialBlend); infinityScene.spatialVariant = jsonU8(spatial["variant"], infinityScene.spatialVariant); infinityScene.spatialDirection = jsonU8(spatial["direction"], infinityScene.spatialDirection); infinityScene.spatialSize = jsonU8(spatial["size"], infinityScene.spatialSize); @@ -849,19 +1247,17 @@ void deserializeScene(JsonObject scene) { } else { infinityScene.spatialMode = jsonU8(scene["spatial_mode"], infinityScene.spatialMode); infinityScene.spatialStrength = jsonU8(scene["spatial_strength"], infinityScene.spatialStrength); - infinityScene.spatialBlend = jsonU8(scene["spatial_blend"], infinityScene.spatialBlend); infinityScene.spatialVariant = jsonU8(scene["spatial_variant"], infinityScene.spatialVariant); infinityScene.spatialDirection = jsonU8(scene["spatial_direction"], infinityScene.spatialDirection); infinityScene.spatialSize = jsonU8(scene["spatial_size"], infinityScene.spatialSize); infinityScene.spatialAngle = jsonU16(scene["spatial_angle"], infinityScene.spatialAngle); infinityScene.spatialOption = jsonU8(scene["spatial_option"], infinityScene.spatialOption); } - if (infinityScene.spatialMode > INFINITY_SPATIAL_WAVE_LINE) infinityScene.spatialMode = INFINITY_SPATIAL_OFF; - if (infinityScene.spatialBlend > INFINITY_SPATIAL_BLEND_PALETTE_TINT) infinityScene.spatialBlend = INFINITY_SPATIAL_BLEND_MULTIPLY_MASK; + if (infinityScene.spatialMode > INFINITY_SPATIAL_SUNBURST) infinityScene.spatialMode = INFINITY_SPATIAL_OFF; if (infinityScene.spatialVariant > INFINITY_SPATIAL_VARIANT_OUTLINE_REVERSE) infinityScene.spatialVariant = INFINITY_SPATIAL_VARIANT_EXPAND; if (infinityScene.spatialDirection > INFINITY_SPATIAL_DIRECTION_PINGPONG) infinityScene.spatialDirection = INFINITY_SPATIAL_DIRECTION_LTR; if (infinityScene.spatialAngle >= 360) infinityScene.spatialAngle %= 360; - if (infinityScene.spatialOption > INFINITY_SPATIAL_OPTION_BANDS) infinityScene.spatialOption = INFINITY_SPATIAL_OPTION_LINE; + infinityScene.spatialOption = normalizeSpatialOption(infinityScene.spatialMode, infinityScene.spatialOption); markSceneChanged(); } @@ -942,7 +1338,7 @@ void infinityApplyDmx(uint8_t* data, uint16_t availableChannels) { if (ch[1] == 0) infinityScene.flags &= ~0x01; else infinityScene.flags |= 0x01; infinityScene.presetId = ch[2]; - infinityScene.effectId = ch[3]; + infinityScene.effectId = rfpEffectFromDmx(ch[3]); infinityScene.speed = ch[4]; infinityScene.intensity = ch[5]; infinityScene.palette = ch[6]; @@ -957,16 +1353,23 @@ void infinityApplyDmx(uint8_t* data, uint16_t availableChannels) { infinityScene.custom[1] = ch[19]; infinityScene.custom[2] = ch[20]; infinityScene.seed = static_cast(ch[21]) * 0x01010101UL; - infinityScene.strobe = ch[22]; + infinityScene.reserved0 = 0; infinityScene.safetyFade = ch[23]; - infinityScene.spatialMode = map(ch[24], 0, 255, 0, INFINITY_SPATIAL_WAVE_LINE); + infinityScene.spatialMode = map(ch[24], 0, 255, 0, INFINITY_SPATIAL_SUNBURST); infinityScene.spatialVariant = map(ch[25], 0, 255, 0, INFINITY_SPATIAL_VARIANT_OUTLINE_REVERSE); infinityScene.spatialDirection = map(ch[26], 0, 255, 0, INFINITY_SPATIAL_DIRECTION_PINGPONG); - infinityScene.spatialBlend = map(ch[27], 0, 255, 0, INFINITY_SPATIAL_BLEND_PALETTE_TINT); + // DMX channel 27 is intentionally unused; Global-2D blend was removed. infinityScene.spatialStrength = ch[28]; infinityScene.spatialSize = ch[29]; infinityScene.spatialAngle = map(ch[30], 0, 255, 0, 359); - infinityScene.spatialOption = ch[31] > 127 ? INFINITY_SPATIAL_OPTION_BANDS : INFINITY_SPATIAL_OPTION_LINE; + if (infinityScene.spatialMode == INFINITY_SPATIAL_SUNBURST) { + infinityScene.spatialOption = map(ch[31], 0, 255, 0, INFINITY_SPATIAL_OPTION_RAINBOW); + } else if (infinityScene.spatialMode == INFINITY_SPATIAL_SCAN) { + infinityScene.spatialOption = ch[31] > 127 ? INFINITY_SPATIAL_OPTION_BANDS : INFINITY_SPATIAL_OPTION_LINE; + } else { + infinityScene.spatialOption = INFINITY_SPATIAL_OPTION_LINE; + } + infinityScene.spatialOption = normalizeSpatialOption(infinityScene.spatialMode, infinityScene.spatialOption); markSceneChanged(); @@ -983,29 +1386,38 @@ void infinityApplyDmx(uint8_t* data, uint16_t availableChannels) { void infinityHandleOverlayDraw() { if (!infinityEnabled || !isNodeBuild()) return; - if (infinityScene.spatialMode == INFINITY_SPATIAL_OFF || infinityScene.spatialStrength == 0) return; + if (infinityScene.spatialMode == INFINITY_SPATIAL_OFF) return; + const bool directSpatialMode = infinityScene.spatialMode == INFINITY_SPATIAL_SNAKE + || (infinityScene.spatialMode == INFINITY_SPATIAL_SUNBURST && infinityScene.spatialOption != INFINITY_SPATIAL_OPTION_LINE); + if (infinityScene.spatialStrength == 0 && !directSpatialMode) return; if (strip.getSegmentsNum() < INFINITY_PANEL_COUNT || strip.getLengthTotal() < INFINITY_LEDS_PER_NODE) return; uint8_t column = 0; parseNodeColumn(column); const uint64_t nowUs = masterTimeUs() + (static_cast(infinityScene.phase) * 1000ULL); - for (uint8_t row = 0; row < INFINITY_PANEL_COUNT; row++) { Segment& seg = strip.getSegment(row); if (!seg.isActive()) continue; const uint16_t len = min(seg.length(), INFINITY_LEDS_PER_PANEL); for (uint16_t led = 0; led < len; led++) { - const uint8_t amount = spatialAmount(column, row, led, nowUs); - if (amount == 0 && infinityScene.spatialBlend != INFINITY_SPATIAL_BLEND_REPLACE) { - if (infinityScene.spatialBlend == INFINITY_SPATIAL_BLEND_MULTIPLY_MASK || infinityScene.spatialBlend == INFINITY_SPATIAL_BLEND_PALETTE_TINT) { - seg.setPixelColor(led, BLACK); + const SpatialSample sample = spatialSample(column, row, led, nowUs); + if (sample.amount == 0 || sample.role == SPATIAL_ROLE_BLACK) { + seg.setPixelColor(led, BLACK); + continue; + } + const uint32_t layer = spatialLayerColor(seg, led, sample); + uint8_t exactRgb[3] = {0, 0, 0}; + if (spatialExactRgb(sample, exactRgb)) { + if (sample.amount == 255) { + seg.setPixelColor(led, exactRgb[0], exactRgb[1], exactRgb[2], 0); + } else { + seg.setPixelColor(led, scaleByte(exactRgb[0], sample.amount), scaleByte(exactRgb[1], sample.amount), scaleByte(exactRgb[2], sample.amount), 0); } continue; } const uint32_t base = seg.getPixelColor(led); - const uint32_t layer = spatialLayerColor(seg, led, amount); - seg.setPixelColor(led, applySpatialBlend(base, layer, amount, infinityScene.spatialStrength, infinityScene.spatialBlend)); + seg.setPixelColor(led, composeSpatialColor(base, layer, sample.amount, infinityScene.spatialStrength, sample.role)); } } } @@ -1024,6 +1436,7 @@ void infinitySerializeJson(JsonObject root) { root["packets_received"] = packetsReceived; root["packets_sent"] = packetsSent; serializeNodeIps(root.createNestedArray("node_ips")); + serializeCustomColors(root.createNestedArray("custom_colors")); JsonObject scene = root.createNestedObject("scene"); serializeScene(scene); @@ -1049,6 +1462,27 @@ void infinitySerializeJson(JsonObject root) { bool infinityDeserializeJson(JsonObject root) { if (root.isNull()) return false; const bool hadSceneObject = root["scene"].is(); + const bool hadNodeIps = !root["node_ips"].isNull(); + const bool hasRootScenePayload = + !root["effect"].isNull() || + !root["preset"].isNull() || + !root["brightness"].isNull() || + !root["speed"].isNull() || + !root["intensity"].isNull() || + !root["palette"].isNull() || + !root["primary"].isNull() || + !root["secondary"].isNull() || + !root["tertiary"].isNull() || + !root["group_mask"].isNull() || + !root["direction"].isNull() || + !root["flags"].isNull() || + !root["transition_ms"].isNull() || + !root["seed"].isNull() || + !root["phase"].isNull() || + !root["row_dimmer"].isNull() || + !root["safety_fade"].isNull() || + !root["spatial"].isNull() || + !root["spatial_mode"].isNull(); const bool wasEnabled = infinityEnabled; if (!root["enabled"].isNull()) infinityEnabled = root["enabled"].as(); if (root["node_id"].is()) copyNodeId(infinityConfiguredNodeId, sizeof(infinityConfiguredNodeId), root["node_id"].as()); @@ -1064,6 +1498,7 @@ bool infinityDeserializeJson(JsonObject root) { } } deserializeNodeIps(root["node_ips"]); + deserializeCustomColors(root["custom_colors"]); if (wasEnabled != infinityEnabled) { if (infinityEnabled) { infinityNetworkBegin(); @@ -1072,11 +1507,13 @@ bool infinityDeserializeJson(JsonObject root) { infinityUdpStarted = false; } } - JsonObject scene = root["scene"].is() ? root["scene"].as() : root; - deserializeScene(scene); + if (hadSceneObject || hasRootScenePayload) { + JsonObject scene = hadSceneObject ? root["scene"].as() : root; + deserializeScene(scene); + } if (isMasterBuild() && infinityUdpStarted) { sendClockSync(); - if (hadSceneObject || !root["node_ips"].isNull()) sendSceneState(); + if (hadSceneObject || hasRootScenePayload || hadNodeIps) sendSceneState(); } doSerializeConfig = true; return true; @@ -1093,6 +1530,7 @@ void infinityDeserializeConfig(JsonObject interfaces) { if (parsed.fromString(cfg["master_ip"].as())) infinityMasterIp = parsed; } deserializeNodeIps(cfg["node_ips"]); + deserializeCustomColors(cfg["custom_colors"]); } void infinitySerializeConfig(JsonObject interfaces) { @@ -1103,6 +1541,7 @@ void infinitySerializeConfig(JsonObject interfaces) { cfg["master_ip"] = ipToString(infinityMasterIp); cfg["port"] = infinityPort; serializeNodeIps(cfg.createNestedArray("node_ips")); + serializeCustomColors(cfg.createNestedArray("custom_colors")); } void serveInfinityJson(AsyncWebServerRequest* request) { @@ -1130,11 +1569,15 @@ a{color:inherit;text-decoration:none}button,input,select{font:inherit}main{width input[type=range]{width:100%;accent-color:var(--accent)}input[type=color]{width:100%;height:44px;padding:0;border-radius:14px;border:1px solid var(--line);background:#000}input[type=text],input[type=number],select{width:100%;padding:11px 12px;border-radius:14px;border:1px solid var(--line);background:#101319;color:var(--text)} select:disabled,input:disabled{opacity:.48}.hidden{display:none!important} .switch{display:flex;align-items:center;justify-content:space-between;gap:14px;padding:12px 14px;border-radius:18px;background:rgba(255,255,255,.03);border:1px solid rgba(255,255,255,.05)}.switch input{width:22px;height:22px} -.color-grid,.row-grid,.target-grid,.nodes{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px}.target-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.nodes{grid-template-columns:repeat(2,minmax(0,1fr))} +.color-grid,.target-grid,.nodes{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px}.target-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.nodes{grid-template-columns:repeat(2,minmax(0,1fr))} +.color-slot{border:1px solid rgba(255,255,255,.08);border-radius:18px;padding:10px;background:rgba(255,255,255,.025);cursor:pointer}.color-slot.active{outline:2px solid var(--accent);background:rgba(77,163,255,.12)}.color-slot input{cursor:pointer} +.palette-panel{margin-top:12px;border:1px solid rgba(255,255,255,.06);border-radius:18px;padding:12px;background:rgba(0,0,0,.14)}.palette-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;margin-bottom:10px}.palette-head strong{display:block}.palette-head span{display:block;color:var(--muted);font-size:.86rem;margin-top:2px} +.swatch-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(34px,1fr));gap:8px}.swatch{height:34px;border-radius:10px;border:1px solid rgba(255,255,255,.2);box-shadow:inset 0 0 0 1px rgba(0,0,0,.35);cursor:pointer}.swatch:hover,.swatch.selected{outline:2px solid #fff;outline-offset:1px}.swatch.custom{border-color:rgba(77,163,255,.7)} +.color-actions{display:flex;gap:8px;flex-wrap:wrap;margin-top:10px}.color-actions .btn{padding:8px 10px;border-radius:12px} .node{border:1px solid rgba(255,255,255,.05);border-radius:18px;padding:12px 13px;background:rgba(0,0,0,.18)}.node.online{outline:2px solid rgba(61,220,132,.45)}.node.offline{opacity:.88}.node h3{margin:0 0 8px;font-size:1rem}.node p{margin:4px 0;color:var(--muted);font-size:.88rem} .toolbar{position:sticky;bottom:14px;display:flex;gap:10px;justify-content:flex-end;flex-wrap:wrap;margin-top:12px} -@media (max-width:900px){.hero,.layout{grid-template-columns:1fr}.nodes,.color-grid,.row-grid{grid-template-columns:1fr 1fr}} -@media (max-width:620px){main{padding:14px 12px 120px}.topbar{flex-direction:column;align-items:flex-start}.actions{width:100%}.actions .btn{flex:1 1 auto;text-align:center}.metrics,.control-grid,.target-grid,.nodes,.color-grid,.row-grid{grid-template-columns:1fr}.panel{padding:14px;border-radius:20px}.toolbar{position:fixed;left:12px;right:12px;bottom:12px;margin:0}.toolbar .btn{flex:1 1 auto}} +@media (max-width:900px){.hero,.layout{grid-template-columns:1fr}.nodes,.color-grid{grid-template-columns:1fr 1fr}} +@media (max-width:620px){main{padding:14px 12px 120px}.topbar{flex-direction:column;align-items:flex-start}.actions{width:100%}.actions .btn{flex:1 1 auto;text-align:center}.metrics,.control-grid,.target-grid,.nodes,.color-grid{grid-template-columns:1fr}.panel{padding:14px;border-radius:20px}.toolbar{position:fixed;left:12px;right:12px;bottom:12px;margin:0}.toolbar .btn{flex:1 1 auto}}
@@ -1179,23 +1622,21 @@ select:disabled,input:disabled{opacity:.48}.hidden{display:none!important}
+

Global 2D

-
+
-
-
+
-
-
@@ -1205,15 +1646,21 @@ select:disabled,input:disabled{opacity:.48}.hidden{display:none!important}

Colors

-
-
-
+
+
+
-

Rows

-
-
-
-
+
+
+
RFP Main PaletteWipe-Random-Farben plus deine dauerhaft gespeicherten Custom-Farben.
+ Primary +
+
+
+ + + +
@@ -1241,14 +1688,14 @@ select:disabled,input:disabled{opacity:.48}.hidden{display:none!important}
-
Ready. Changes stay local until you press Apply Scene or Save Targets.
+
Ready. Scene changes apply automatically.
-