Compare commits

...

11 Commits

Author SHA1 Message Date
jan
ebc4498d89 Add BPM speed control and OTA update workflow
Some checks failed
WLED CI / wled_build (push) Has been cancelled
Deploy Nightly / wled_build (push) Has been cancelled
Deploy Nightly / Deploy nightly (push) Has been cancelled
2026-04-25 22:48:13 +02:00
95137a6d65 Clean up RFP ESP32-S3 target and tooling
Some checks failed
WLED CI / wled_build (push) Has been cancelled
Deploy Nightly / wled_build (push) Has been cancelled
Deploy Nightly / Deploy nightly (push) Has been cancelled
2026-04-17 01:00:37 +02:00
Frank Möhle
3a01c00635 hiding reference to contributing.md from AI
contributing.md makes reference out to this file again
2026-04-08 21:36:34 +02:00
Frank Möhle
e4c9fd5c62 Fix formatting in CI/CD workflows section 2026-04-07 19:38:40 +02:00
Frank Möhle
08529a744e Add code style summary to agent build instructions
Added basic style guidelines for C++, Web UI, and CI/CD workflows.
2026-04-07 19:36:33 +02:00
Copilot
64529bbd84 chore: move coding guidelines from .github to docs/ (#358)
* Reorganized repository documentation and updated internal configuration to reference the new docs location.
Documentation
* Updated contributor and instruction guides to point to the relocated documentation files so links and references remain correct.
---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: softhack007 <91616163+softhack007@users.noreply.github.com>
2026-04-07 19:02:20 +02:00
Frank
e4c8e349da minor clarifications 2026-04-07 17:56:41 +02:00
Frank Möhle
a718caf4f7 Update FreeRTOS task management instructions
Clarify usage of delay() and yield() in FreeRTOS tasks, emphasizing the differences between ESP32 and ESP8266. Update instructions on task management and watchdog behavior.
2026-04-07 17:18:30 +02:00
Copilot
6c9922d072 docs(esp-idf): add millis/micros internals, precision-wait pattern, PDM 16-bit note, ESP_ERROR_CHECK_WITHOUT_ABORT (#357)
* Clarified PDM microphone behavior: data unit width is effectively 16-bit in PDM mode
* Updated microsecond timing with Arduino-ESP32 note about direct timer usage
* Added "Precision waiting" subsection: coarse delay then busy-spin for microsecond accuracy
* Expanded error-handling docs with non-aborting check example
* Adjusted logging example presentation for human readers

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: softhack007 <91616163+softhack007@users.noreply.github.com>
2026-04-06 19:32:02 +02:00
Frank
231373acac small update to AI instructions
upstream sync
2026-04-06 18:07:48 +02:00
Frank
eb2352774d align AI instructions with upstream
some updates coming from ongoing work
2026-04-06 17:14:40 +02:00
20 changed files with 3486 additions and 107 deletions

View File

@@ -3,10 +3,10 @@
# CodeRabbit configuration — references existing guideline files to avoid
# duplicating conventions. See:
# .github/copilot-instructions.md — project overview & general rules
# .github/cpp.instructions.md — C++ coding conventions
# .github/web.instructions.md — Web UI coding conventions
# .github/cicd.instructions.md — GitHub Actions / CI-CD conventions
# .github/esp-idf.instructions.md — ESP-IDF / chip-specific coding guidelines
# docs/cpp.instructions.md — C++ coding conventions
# docs/web.instructions.md — Web UI coding conventions
# docs/cicd.instructions.md — GitHub Actions / CI-CD conventions
# docs/esp-idf.instructions.md — ESP-IDF / chip-specific coding guidelines
# (apply when code directly uses ESP-IDF APIs:
# esp_idf_*, I2S, RMT, ADC, GPIO, heap_caps, etc.)
#
@@ -20,11 +20,11 @@ reviews:
path_instructions:
- path: "**/*.{cpp,h,hpp,ino}"
instructions: >
Follow the C++ coding conventions documented in .github/cpp.instructions.md
Follow the C++ coding conventions documented in docs/cpp.instructions.md
and the general project guidelines in .github/copilot-instructions.md.
If the code under review directly uses ESP-IDF APIs (e.g. heap_caps_malloc,
I2S, RMT, ADC, GPIO, esp_timer, or any esp_idf_* / soc_* symbols), also
apply the guidelines in .github/esp-idf.instructions.md.
apply the guidelines in docs/esp-idf.instructions.md.
Key rules: 2-space indentation (no tabs), camelCase functions/variables,
PascalCase classes, UPPER_CASE macros. Mark WLED-MM-specific changes with
@@ -36,7 +36,7 @@ reviews:
- path: "wled00/data/**"
instructions: >
Follow the web UI conventions documented in .github/web.instructions.md.
Follow the web UI conventions documented in docs/web.instructions.md.
Key rules: indent HTML and JavaScript with tabs, CSS with tabs or spaces.
Files here are built into wled00/html_*.h by tools/cdata.js — never
@@ -54,11 +54,11 @@ reviews:
Each usermod lives in its own directory under usermods/ and is implemented
as a .h file that is pulled in by wled00/usermods_list.cpp (guarded by
#ifdef). Usermods do not use library.json. Follow the same C++ conventions
as the core firmware (.github/cpp.instructions.md).
as the core firmware (docs/cpp.instructions.md).
- path: ".github/workflows/*.{yml,yaml}"
instructions: >
Follow the CI/CD conventions documented in .github/cicd.instructions.md.
Follow the CI/CD conventions documented in docs/cicd.instructions.md.
Key rules: 2-space indentation, descriptive name: on every workflow/job/step.
Third-party actions must be pinned to a specific version tag — branch pins
@@ -67,7 +67,7 @@ reviews:
into run: steps — pass them through an env: variable to prevent script
injection. Do not use pull_request_target unless fully justified.
- path: ".github/*.instructions.md"
- path: "**/*.instructions.md"
instructions: |
This file contains both AI-facing rules and human-only reference sections.
Human-only sections are enclosed in `<!-- HUMAN_ONLY_START -->` /
@@ -81,4 +81,4 @@ reviews:
2. Flag any HUMAN_ONLY section whose content has drifted from the surrounding
AI-facing rules due to edits introduced in this PR.
3. If new AI-facing rules were added without updating a related HUMAN_ONLY
reference section, note this as a suggestion (not a required fix).
reference section, note this as a suggestion (not a required fix).

View File

@@ -11,7 +11,7 @@ Use these timeout values when running builds:
| Command | Typical Time | Minimum Timeout | Notes |
|---|---|---|---|
| `npm run build` | ~3 s | 30 s | Web UI → `wled00/html_*.h` headers |
| `npm run build` | ~3 s | 30 s | Web UI → `wled00/html_*.h` `wled00/js_*.h` headers |
| `npm test` | ~40 s | 2 min | Validates build system |
| `npm run dev` | continuous | — | Watch mode, auto-rebuilds on changes |
| `pio run -e <env>` | 1520 min | 30 min | First build downloads toolchains; subsequent builds are faster |
@@ -20,16 +20,21 @@ Use these timeout values when running builds:
## Development Workflow
### Code Style Summary
- **C++** files in `wled00/` and `usermods/`: 2-space indentation (no tabs), camelCase functions/variables, PascalCase classes, UPPER_CASE macros. No C++ exceptions — use return codes and debug macros.
- **Web UI** files in `wled00/data`: indent HTML and JavaScript with tabs, CSS with tabs.
- **CI/CD workflows** in `.github/workflows`: 2-space indentation, descriptive `name:` on every workflow/job/step. Third-party actions must be pinned to a specific version tag — branch pins such as `@main` or `@master` are not allowed. SHA pinning recommended.
### Web UI Changes
1. Edit files in `wled00/data/`
2. Run `npm run build` to regenerate `wled00/html_*.h` headers
2. Run `npm run build` to regenerate `wled00/html_*.h` `wled00/js_*.h` headers
3. Test with local HTTP server (see Manual Testing below)
4. Run `npm test` to validate
### Firmware Changes
1. Edit files in `wled00/` (but **never** `html_*.h` files)
1. Edit files in `wled00/` (but **never** `html_*.h` and `js_*.h` files)
2. Ensure web UI is built first: `npm run build`
3. Build firmware: `pio run -e esp32_4MB_V4_M` (set timeout ≥ 30 min)
4. Flash to device: `pio run -e [target] --target upload`
@@ -85,8 +90,8 @@ Test these scenarios after every web UI change:
### Recovery Steps
- **Force web UI rebuild**: `npm run build -- -f`
- **Clear generated files**: `rm -f wled00/html_*.h` then `npm run build`
- **Clean PlatformIO cache**: `pio run --target clean`
- **Clear generated files**: `rm -f wled00/html_*.h wled00/js_*.h` then `npm run build`
- **Clean PlatformIO build artifacts**: `pio run --target clean`
- **Reinstall Node deps**: `rm -rf node_modules && npm ci`
## CI/CD Validation
@@ -106,7 +111,8 @@ Match this workflow in local development to catch failures before pushing.
## Important Reminders
- **Never edit or commit** `wled00/html_*.h` — auto-generated from `wled00/data/`
- Always **commit source code**
- **Never edit or commit** `wled00/html_*.h` and `wled00/js_*.h` — auto-generated from `wled00/data/`
- Web UI rebuild is part of the PlatformIO firmware compilation pipeline
- Common firmware environments: `esp32_4MB_V4_M`, `esp32_16MB_V4_S_HUB75`, `esp32S3_8MB_PSRAM_M_qspi`, `esp32_16MB_V4_M_eth`, `esp8266_4MB_S` (deprecated), `esp32dev_compat`
- List all PlatformIO targets: `pio run --list-targets`

View File

@@ -33,29 +33,28 @@ Always reference these instructions first and fallback to search or bash command
| Command | Purpose | Typical Time |
|---|---|---|
| `npm run build` | Build web UI → generates `wled00/html_*.h` headers | ~3 s |
| `npm run build` | Build web UI → generates `wled00/html_*.h` and `wled00/js_*.h` headers | ~3 s |
| `npm test` | Run test suite | ~40 s |
| `npm run dev` | Watch mode — auto-rebuilds web UI on file changes | — |
| `pio run -e <env>` | Build firmware for a hardware target | 1520 min |
<!-- HUMAN_ONLY_END -->
**Always run `npm ci; npm run build` before `pio run`.** The web UI build generates `wled00/html_*.h` header files required by firmware compilation.
**Build firmware to validate code changes**: `pio run -e esp32_4MB_V4_M` — must succeed, never skip this step.
Common firmware environments: `esp32_4MB_V4_M`, `esp32_16MB_V4_S_HUB75`, `esp32S3_8MB_PSRAM_M_qspi`, `esp32_16MB_V4_M_eth`, `esp32dev_compat`, `esp8266_4MB_S` (deprecated)
- **Always run `npm run build` before any `pio run`** (and run `npm ci` first on fresh clones or when lockfile/dependencies change).
- The web UI build generates required `wled00/html_*.h` and `wled00/js_*.h` headers for firmware compilation.
- **Build firmware to validate code changes**: `pio run -e esp32_4MB_V4_M` — must succeed, never skip this step.
- Common firmware environments: `esp32_4MB_V4_M`, `esp32_16MB_V4_S_HUB75`, `esp32S3_8MB_PSRAM_M_qspi`, `esp32_16MB_V4_M_eth`, `esp32dev_compat`, `esp8266_4MB_S` (deprecated)
For detailed build timeouts, development workflows, troubleshooting, and validation steps, see [agent-build.instructions.md](agent-build.instructions.md).
## Repository Structure
tl;dr:
* Firmware source: `wled00/` (C++).
* Build targets: `platformio.ini`.
* Web UI source: `wled00/data/`.
* Auto-generated headers: `wled00/html_*.h`**never edit or commit**.
* ArduinoJSON + AsyncJSON: `wled00/src/dependencies/json`
* Firmware source: `wled00/` (C++). Web UI source: `wled00/data/`. Build targets: `platformio.ini`.
* Auto-generated headers: `wled00/html_*.h` and `wled00/js_*.h`**never edit or commit**.
* ArduinoJSON + AsyncJSON: `wled00/src/dependencies/json` (included via `wled.h`). CI/CD: `.github/workflows/`.
* Usermods: `usermods/` (`.h` files, included via `usermods_list.cpp`).
* CI/CD: `.github/workflows/`.
* Contributor docs: `docs/` (coding guidelines, design docs).
Main development trunk: `mdev` branch. Make PRs against this branch.
@@ -79,29 +78,33 @@ tools/ # Build tools (Node.js), partition files, and generi
tools/cdata.js # Web UI → header build script
tools/cdata-test.js # Test suite
package.json # Node.js scripts and release ID
docs/ # Contributor docs: coding guidelines and design documentation
.github/workflows/ # CI/CD pipelines
```
<!-- HUMAN_ONLY_END -->
<!-- HUMAN_ONLY_END -->
## General Guidelines
- **Never edit or commit** `wled00/html_*.h` — auto-generated from `wled00/data/`.
- **Repository language is English.** Suggest translations for non-English content.
- **Use VS Code with PlatformIO extension** for best development experience.
- **Never edit or commit** `wled00/html_*.h` and `wled00/js_*.h` — auto-generated from `wled00/data/`.
- If updating Web UI files in `wled00/data/`, **make use of common functions in `wled00/data/common.js` whenever possible**.
- **When unsure, say so.** Gather more information rather than guessing.
- **Acknowledge good patterns** when you see them. Summarize good practices as part of your review - positive feedback always helps.
- **Provide references** when making analyses or recommendations. Base them on the correct branch or PR.
- **Look for user-visible breaking changes and ripple effects**. Ask for confirmation that these were introduced intentionally.
- **Highlight user-visible breaking changes and ripple effects**. Ask for confirmation that these were introduced intentionally.
- **Unused / dead code must be justified or removed**. This helps to keep the codebase clean, maintainable and readable.
- **C++ formatting available**: `clang-format` is installed but not in CI
- No automated linting is configured — match existing code style in files you edit. See `cpp.instructions.md` and `web.instructions.md` for language-specific conventions, and `cicd.instructions.md` for GitHub Actions workflows.
- No automated linting is configured — match existing code style in files you edit.
See `docs/cpp.instructions.md`, `docs/esp-idf.instructions.md` and `docs/web.instructions.md` for language-specific conventions, and `docs/cicd.instructions.md` for GitHub Actions workflows.
### Attribution for AI-generated code
Using AI-generated code can hide the source of the inspiration / knowledge / sources it used.
- Document attribution of inspiration / knowledge / sources used in the code, e.g. link to GitHub repositories or other websites describing the principles / algorithms used.
- When a larger block of code is generated by an AI tool, mark it with an `// AI: below section was generated by an AI` comment (see C++ guidelines).
- Every non-trivial AI-generated function should have a brief comment describing what it does. Explain parameters when their names alone are not self-explanatory.
- AI-generated code must be well documented; comment-to-code ratio > 15% is expected. Do not rephrase source code, but explain the concepts/logic behind the code.
- AI-generated code must be well documented with meaningful comments that explain intent, assumptions, and non-obvious logic. Do not rephrase source code; explain concepts and reasoning.
### Pull Request Expectations

3
.gitignore vendored
View File

@@ -5,8 +5,11 @@
.gitignore
.idea
.pio
.piohome
.pioenvs
.piolibdeps
.tools
.venv
.vscode
.vscode/extensions.json

View File

@@ -76,9 +76,9 @@ When in doubt, it is easiest to replicate the code style you find in the files y
Our review bot (coderabbit) has learned lots of detailed guides and hints - it will suggest them automatically when you submit a PR for review.
If you are curious, these are the detailed guides:
* [C++ Coding](https://github.com/MoonModules/WLED-MM/blob/mdev/.github/cpp.instructions.md)
* [WebUi: HTML, JS, CSS](https://github.com/MoonModules/WLED-MM/blob/mdev/.github/web.instructions.md)
* [Using ESP-IDF directly](https://github.com/MoonModules/WLED-MM/blob/mdev/.github/esp-idf.instructions.md)
* [C++ Coding](https://github.com/MoonModules/WLED-MM/blob/mdev/docs/cpp.instructions.md)
* [WebUi: HTML, JS, CSS](https://github.com/MoonModules/WLED-MM/blob/mdev/docs/web.instructions.md)
* [Using ESP-IDF directly](https://github.com/MoonModules/WLED-MM/blob/mdev/docs/esp-idf.instructions.md)
Below are the main rules used the WLED-MM repository.

View File

@@ -71,6 +71,8 @@ schedule:
## Security
Important: Several current workflows still violate parts of the baseline below - migration is in progress.
### Permissions — Least Privilege
Declare explicit `permissions:` blocks. The default token permissions are broad; scope them to the minimum required:

View File

@@ -8,7 +8,10 @@ applyTo: "**/*.cpp,**/*.h,**/*.hpp,**/*.ino"
> contributor reference material. Do **not** use that content as actionable review
> criteria — treat it as background context only.
<!-- HUMAN_ONLY_START -->
<!-- hiding this reference, to avoid cyclic "include" loops -->
See also: [CONTRIBUTING.md](../CONTRIBUTING.md) for general style guidelines that apply to all contributors.
<!-- HUMAN_ONLY_END -->
## Formatting
@@ -18,13 +21,13 @@ See also: [CONTRIBUTING.md](../CONTRIBUTING.md) for general style guidelines tha
- Space between keyword and parenthesis: `if (...)`, `for (...)`. No space between function name and parenthesis: `doStuff(a)`
- No enforced line-length limit; wrap when a line exceeds your editor width
<!-- HUMAN_ONLY_START -->
## Naming
- **camelCase** for functions and variables: `setValuesFromMainSeg()`, `effectCurrent`
- **PascalCase** for classes and structs: `PinManagerClass`, `BusConfig`
- **UPPER_CASE** for macros and constants: `WLED_MAX_USERMODS`, `DEFAULT_CLIENT_SSID`
<!-- HUMAN_ONLY_START -->
## Header Guards
Most headers use `#ifndef` / `#define` guards. Some newer headers add `#pragma once` before the guard:
@@ -64,6 +67,7 @@ void calculateCRC(const uint8_t* data, size_t len) {
Single-line AI-assisted edits do not need the marker — use it when the AI produced a contiguous block that a human did not write line-by-line.
<!-- HUMAN_ONLY_START -->
<!-- hidden from AI for now, as it created too many "please add a description" review findings in my first tests -->
- **Function & feature comments:** Every non-trivial function should have a brief comment above it describing what it does. Include a note about each parameter when the names alone are not self-explanatory:
```cpp
@@ -77,7 +81,6 @@ uint8_t gammaCorrect(uint8_t value, float gamma);
```
<!-- HUMAN_ONLY_END -->
Short accessor-style functions (getters/setters, one-liners) may skip this if their purpose is obvious from the name.
## Preprocessor & Feature Flags
@@ -106,12 +109,38 @@ uint8_t gammaCorrect(uint8_t value, float gamma);
- **PSRAM-aware allocation**: use `d_malloc()` (prefer DRAM), `p_malloc()` (prefer PSRAM) from `util.h`
- **Avoid Variable Length Arrays (VLAs)**: FreeRTOS task stacks are typically 28 KB. A runtime-sized VLA can silently exhaust the stack. Use fixed-size arrays or heap allocation (`d_malloc` / `p_malloc`). Any VLA must be explicitly justified in source or PR.
<!-- HUMAN_ONLY_START -->
GCC/Clang support VLAs as an extension (they are not part of the C++ standard), so they look like a legitimate feature — but they are allocated on the stack at runtime. On ESP32/ESP8266, a VLA whose size depends on a runtime parameter (segment dimensions, pixel counts, etc.) can silently exhaust the stack and cause the program to behave in unexpected ways or crash.
GCC/Clang support VLAs as an extension (they are not part of the C++ standard), so they look like a legitimate feature — but they are allocated on the stack at runtime. On ESP32/ESP8266, a VLA whose size depends on a runtime parameter (segment dimensions, pixel counts, etc.) can silently exhaust the stack and cause the program to behave in unexpected ways or crash.
<!-- HUMAN_ONLY_END -->
- **Larger buffers** (LED data, JSON documents) should use PSRAM when available and technically feasible
- **Hot-path**: some data should stay in DRAM or IRAM for performance reasons
- Memory efficiency matters, but is less critical on boards with PSRAM
Heap fragmentation is a concern:
<!-- HUMAN_ONLY_START -->
- Fragmentation can lead to crashes, even when the overall amount of available heap is still good. The C++ runtime doesn't do any "garbage collection".
<!-- HUMAN_ONLY_END -->
- Avoid frequent `d_malloc` and `d_free` inside a function, especially for small sizes.
- Avoid frequent creation / destruction of objects.
- Allocate buffers early, and try to re-use them.
- Instead of incrementally appending to a `String`, reserve the expected max buffer upfront by using the `reserve()` method.
<!-- HUMAN_ONLY_START -->
```cpp
String result;
result.reserve(65); // pre-allocate to avoid realloc fragmentation
```
```cpp
// prefer DRAM; falls back gracefully and enforces MIN_HEAP_SIZE guard
_ledsDirty = (byte*) d_malloc(getBitArrayBytes(_len));
```
```cpp
_mode.reserve(_modeCount); // allocate memory to prevent initial fragmentation - does not increase size()
_modeData.reserve(_modeCount); // allocate memory to prevent initial fragmentation - does not increase size()
```
<!-- HUMAN_ONLY_END -->
## `const` and `constexpr`
Add `const` to cached locals in hot-path code (helps the compiler keep values in registers). Pass and store objects by `const&` to avoid copies in loops.
@@ -121,12 +150,13 @@ This pattern enables optimizations and makes intent clear to reviewers.
### `const` locals
Adding `const` to a local variable that is only assigned once is not necessary — but it **is** required when the variable is passed to a function that takes a `const` parameter (pointer or reference). In hot-path code, `const` on cached locals helps the compiler keep values in registers:
* Adding `const` to a local variable that is only assigned once is optional, but *not* strictly necessary.
* In hot-path code, `const` on cached locals may help the compiler keep values in registers:
```cpp
const uint_fast16_t cols = vWidth();
const uint_fast16_t rows = vHeight();
```
```cpp
const uint_fast16_t cols = virtualWidth();
const uint_fast16_t rows = virtualHeight();
```
<!-- HUMAN_ONLY_END -->
### `const` references to avoid copies
@@ -175,6 +205,12 @@ static_assert(WLED_MAX_BUSSES <= 32, "WLED_MAX_BUSSES exceeds hard limit");
#error "WLED_MAX_BUSSES exceeds hard limit"
#endif
```
```cpp
// using static_assert() to validate enumerated types (zero cost at runtime)
static_assert(0u == static_cast<uint8_t>(PinOwner::None),
"PinOwner::None must be zero, so default array initialization works as expected");
```
<!-- HUMAN_ONLY_END -->
Prefer `constexpr` over `#define` for typed constants (scope-safe, debuggable). Use `static_assert` instead of `#if … #error` for compile-time validation.
@@ -486,22 +522,28 @@ Always pair every `esp32SemTake` with a matching `esp32SemGive`. Choose a timeou
**Important**: Not every shared resource needs a mutex. Some synchronization is guaranteed by the overall control flow. For example, `volatile bool` flags like `suspendStripService`, `doInitBusses`, `loadLedmap`, and `OTAisRunning` (declared in `wled.h`) are checked sequentially in the main loop (`wled.cpp`), so they serialize access without requiring a semaphore. Use mutexes when true concurrent access from multiple FreeRTOS tasks is possible and race-conditions can lead to unexpected behaviour. Rely on control-flow ordering when operations are sequenced within the same loop iteration.
### `delay()` vs `yield()` in FreeRTOS Tasks
<!-- HUMAN_ONLY_START -->
* On ESP32, `delay(ms)` calls `vTaskDelay(ms / portTICK_PERIOD_MS)`, which **suspends only the calling task**. The FreeRTOS scheduler immediately runs all other ready tasks.
* The Arduino `loop()` function runs inside `loopTask`. Calling `delay()` there does *not* block the network stack, audio FFT, LED DMA, nor any other FreeRTOS task.
* This differs from ESP8266, where `delay()` stalled the entire system unless `yield()` was called inside.
<!-- HUMAN_ONLY_END -->
On ESP32, `delay(ms)` calls `vTaskDelay(ms / portTICK_PERIOD_MS)`, which **suspends only the calling task**. The FreeRTOS scheduler immediately runs all other ready tasks. This differs from ESP8266, where `delay()` stalled the entire system unless `yield()` was called inside.
- On ESP32, `delay()` is generally allowed, as it helps to efficiently manage CPU usage of all tasks.
- On ESP8266, only use `delay()` and `yield()` in the main `loop()` context. If not sure, protect with `if (can_yield()) ...`.
- Do *not* use `delay()` in effects (FX.cpp) or in the hot pixel path.
- `delay()` on ``busses`` level is allowed, it might be needed to achieve exact timing in LED drivers.
- **`yield()` is a no-op in WLED-MM on ESP32.** `WLEDMM_FASTPATH` redefines `yield()` to an empty macro.
```cpp
#define yield() {} // WLEDMM: yield() is completely unnecessary on ESP32
```
**`delay()` in `loopTask` is allowed.** The Arduino `loop()` function runs inside `loopTask`. Calling `delay()` there does not block the network stack, audio FFT, LED DMA, or any other FreeRTOS task.
### IDLE Watchdog and Custom Tasks on ESP32
**`yield()` is a no-op in WLED-MM on ESP32.** `WLEDMM_FASTPATH` redefines `yield()` to an empty macro:
- **Do NOT use `yield()` to pace ESP32 tasks or assume it feeds any watchdog**.
```cpp
#define yield() {} // WLEDMM: yield() is completely unnecessary on ESP32
```
Even in stock arduino-esp32, `yield()` calls `vTaskDelay(0)`, which only switches to tasks at equal or higher priority — the IDLE task (priority 0) is never reached.
**Do not use `yield()` to pace ESP32 tasks or assume it feeds any watchdog**.
**Custom `xTaskCreate()` tasks must call `delay(1)` in their loop, not `yield()`.** Without a real blocking call, the IDLE task is starved. The IDLE watchdog panic is the first visible symptom — but the damage starts earlier: deleted task memory leaks, software timers stop firing, light sleep is disabled, and Wi-Fi/BT idle hooks don't run. See `esp-idf.instructions.md` for a full explanation of what IDLE does. Structure custom tasks like this:
- Even in stock arduino-esp32, `yield()` calls `vTaskDelay(0)`, which only switches to tasks at equal or higher priority — the IDLE task (priority 0) is never reached.
- **Custom `xTaskCreate()` tasks must call `delay(1)` in their loop, not `yield()`.** Without a real blocking call, the IDLE task is starved. The IDLE watchdog panic is the first visible symptom — but the damage starts earlier: deleted task memory leaks, software timers stop firing, light sleep is disabled, and Wi-Fi/BT idle hooks don't run. See `esp-idf.instructions.md` for a full explanation of what IDLE does. Structure custom tasks like this:
```cpp
// WRONG — IDLE task is never scheduled; yield() does not feed the idle task watchdog.
void myTask(void*) {
@@ -520,9 +562,8 @@ void myTask(void*) {
}
```
Prefer blocking FreeRTOS primitives (`xQueueReceive`, `ulTaskNotifyTake`, `vTaskDelayUntil`) over `delay(1)` polling where precise timing or event-driven behaviour is needed.
**Watchdog note.** WLED-MM disables the Task Watchdog by default (`WLED_WATCHDOG_TIMEOUT 0` in `wled.h`). When enabled, `esp_task_wdt_reset()` is called at the end of each `loop()` iteration. Long blocking operations inside `loop()` — such as OTA downloads or slow file I/O — must call `esp_task_wdt_reset()` periodically, or be restructured so the main loop is not blocked for longer than the configured timeout.
- Prefer blocking FreeRTOS primitives (`xQueueReceive`, `ulTaskNotifyTake`, `vTaskDelayUntil`) over `delay(1)` polling where precise timing or event-driven behaviour is needed.
- **Watchdog note.** WLED-MM disables the Task Watchdog by default (`WLED_WATCHDOG_TIMEOUT 0` in `wled.h`). When enabled, `esp_task_wdt_reset()` is called at the end of each `loop()` iteration. Long blocking operations inside `loop()` — such as OTA downloads or slow file I/O — must call `esp_task_wdt_reset()` periodically, or be restructured so the main loop is not blocked for longer than the configured timeout.
## General
@@ -530,7 +571,8 @@ Prefer blocking FreeRTOS primitives (`xQueueReceive`, `ulTaskNotifyTake`, `vTask
- If possible, use `static` for local (C-style) variables and functions (keeps the global namespace clean)
- Avoid unexplained "magic numbers". Prefer named constants (`constexpr`) or C-style `#define` constants for repeated numbers that have the same meaning
- Include `"wled.h"` as the primary project header where needed
- **Float-to-unsigned conversion is undefined behavior when the value is out of range.** Converting a negative `float` directly to an unsigned integer type (`uint8_t`, `uint16_t`, …) is UB per the C++ standard — the Xtensa (ESP32) toolchain may silently wrap, but RISC-V (ESP32-C3/C6) can produce different results due to clamping. Cast through a signed integer first:
- **Float-to-unsigned conversion is undefined behavior when the value is out of range.** Converting a negative `float` directly to an unsigned integer type (`uint8_t`, `uint16_t`, …) is UB per the C++ standard — the Xtensa (ESP32) toolchain may silently wrap, but RISC-V (ESP32-C3/C5/C6/P4) can produce different results due to clamping. Cast through a signed integer first:
```cpp
// Undefined behavior — avoid:
uint8_t angle = 40.74f * atan2f(dy, dx); // negative float → uint8_t is UB

View File

@@ -499,6 +499,9 @@ The ESP32 has an audio PLL for precise sample rates. Rules:
- Not supported on ESP32-C3 (`SOC_I2S_SUPPORTS_PDM_RX` not defined).
- ESP32-S3 PDM has known issues: sample rate at 50% of expected, very low amplitude.
- **16-bit data width**: Espressif's IDF documentation states that in PDM mode the data unit width is always 16 bits, regardless of the configured `bits_per_sample`.
- See [espressif/esp-idf#8660](https://github.com/espressif/esp-idf/issues/8660) for the upstream issue.
- **Flag `bits_per_sample = I2S_BITS_PER_SAMPLE_32BIT` in PDM mode** — this causes the S3 low-amplitude symptom.
- No clock pin (`I2S_CKPIN = -1`) triggers PDM mode in WLED-MM.
---
@@ -579,13 +582,21 @@ if (!pinManager.allocatePin(myPin, true, PinOwner::UM_MyUsermod)) {
### Microsecond timing
For high-resolution timing, prefer `esp_timer_get_time()` (microsecond resolution, 64-bit) over `millis()` or `micros()`:
For high-resolution timing, prefer `esp_timer_get_time()` (microsecond resolution, 64-bit) over `millis()` or `micros()`.
<!-- HUMAN_ONLY_START -->
```cpp
#include <esp_timer.h>
int64_t now_us = esp_timer_get_time(); // monotonic, not affected by NTP
```
> **Note**: In arduino-esp32, both `millis()` and `micros()` are thin wrappers around `esp_timer_get_time()` — they share the same monotonic clock source. Prefer the direct call when you need the full 64-bit value or ISR-safe access without truncation:
> ```cpp
> // arduino-esp32 internals (cores/esp32/esp32-hal-misc.c):
> // unsigned long micros() { return (unsigned long)(esp_timer_get_time()); }
> // unsigned long millis() { return (unsigned long)(esp_timer_get_time() / 1000ULL); }
> ```
<!-- HUMAN_ONLY_END -->
<!-- HUMAN_ONLY_START -->
### Periodic timers
@@ -606,6 +617,27 @@ esp_timer_start_periodic(timer, 1000); // 1 ms period
Always prefer `ESP_TIMER_TASK` dispatch over `ESP_TIMER_ISR` unless you need ISR-level latency — ISR callbacks have severe restrictions (no logging, no heap allocation, no FreeRTOS API calls).
### Precision waiting: coarse delay then spin-poll
When waiting for a precise future deadline (e.g., FPS limiting, protocol timing), avoid spinning the entire duration — that wastes CPU and starves other tasks. Instead, yield to FreeRTOS while time allows, then spin only for the final window.
<!-- HUMAN_ONLY_START -->
```cpp
// Wait until 'target_us' (a micros() / esp_timer_get_time() timestamp)
long time_to_wait = (long)(target_us - micros());
// Coarse phase: yield to FreeRTOS while we have more than ~2 ms remaining.
// vTaskDelay(1) suspends the task for one RTOS tick, letting other task run freely.
while (time_to_wait > 2000) {
vTaskDelay(1);
time_to_wait = (long)(target_us - micros());
}
// Fine phase: busy-poll the last ≤2 ms for microsecond accuracy.
// micros() wraps esp_timer_get_time() so this is low-overhead.
while ((long)(target_us - micros()) > 0) { /* spin */ }
```
<!-- HUMAN_ONLY_END -->
> The threshold (2000 µs as an example) should be at least one RTOS tick (default 1 ms on ESP32) plus some margin. A value of 15003000 µs works well in practice.
---
## ADC Best Practices
@@ -672,14 +704,15 @@ RMT drives NeoPixel LED output (via NeoPixelBus) and IR receiver input. Both use
- New chips (C6, P4) have different RMT channel counts — use `SOC_RMT_TX_CANDIDATES_PER_GROUP` to check availability.
- The new RMT API requires an "encoder" object (`rmt_encoder_t`) to translate data formats — this is more flexible but requires more setup code.
<!-- HUMAN_ONLY_END -->
---
## Espressif Best Practices (from official examples)
### Error handling
Always check `esp_err_t` return values. Use `ESP_ERROR_CHECK()` in initialization code, but handle errors gracefully in runtime code:
Always check `esp_err_t` return values. Use `ESP_ERROR_CHECK()` in initialization code, but handle errors gracefully in runtime code.
<!-- HUMAN_ONLY_START -->
```cpp
// Initialization — crash early on failure
ESP_ERROR_CHECK(i2s_driver_install(I2S_NUM_0, &config, 0, nullptr));
@@ -693,6 +726,17 @@ if (err != ESP_OK) {
```
<!-- HUMAN_ONLY_END -->
For situations between these two extremes — where you want the `ESP_ERROR_CHECK` formatted log message (file, line, error name) but must not abort — use `ESP_ERROR_CHECK_WITHOUT_ABORT()`.
<!-- HUMAN_ONLY_START -->
```cpp
// Logs in the same format as ESP_ERROR_CHECK, but returns the error code instead of aborting.
// Useful for non-fatal driver calls where you want visibility without crashing.
esp_err_t err = ESP_ERROR_CHECK_WITHOUT_ABORT(i2s_set_clk(AR_I2S_PORT, rate, bits, ch));
if (err != ESP_OK) return; // handle as needed
```
<!-- HUMAN_ONLY_END -->
### Logging
WLED-MM uses its own logging macros — **not** `ESP_LOGx()`. For application-level code, always use the WLED-MM macros defined in `wled.h`:
@@ -706,13 +750,14 @@ All of these wrap `Serial` output through the `DEBUGOUT` / `DEBUGOUTLN` / `DEBUG
**Exception — low-level driver code**: When writing code that interacts directly with ESP-IDF APIs (e.g., I2S initialization, RMT setup), use `ESP_LOGx()` macros instead. They support tag-based filtering and compile-time log level control:
<!-- HUMAN_ONLY_START -->
```cpp
static const char* TAG = "my_module";
ESP_LOGI(TAG, "Initialized with %d buffers", count);
ESP_LOGW(TAG, "PSRAM not available, falling back to DRAM");
ESP_LOGE(TAG, "Failed to allocate %u bytes", size);
```
<!-- HUMAN_ONLY_END -->
### Task creation and pinning
<!-- HUMAN_ONLY_START -->

View File

@@ -0,0 +1,64 @@
# RFP ESP32-S3 WROOM-1 N16R8 (3 x 106)
This repository includes a tracked PlatformIO target for the RFP ESP32-S3 WROOM-1 N16R8 nodes with three LED outputs and 106 pixels per output.
Build target:
- `rfp_esp32s3_wroom1_n16r8_3x106`
Default output pins:
- Output 1: `GPIO4`
- Output 2: `GPIO5`
- Output 3: `GPIO6`
Pins intentionally avoided:
- `GPIO0`, `GPIO3`, `GPIO45`, `GPIO46` for boot / strapping
- `GPIO19`, `GPIO20` for USB
- `GPIO33` to `GPIO37` because they are reserved by octal PSRAM / flash on `N16R8`
- `GPIO48` because it is used as the onboard status pixel
Build:
```powershell
python -m platformio run -e rfp_esp32s3_wroom1_n16r8_3x106
```
Build and upload:
```powershell
.\tools\flash_rfp_s3.ps1 -ComPort COM7
```
Build only with the helper script:
```powershell
.\tools\flash_rfp_s3.ps1 -BuildOnly
```
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"
```
Important:
- The `3 x 106` bus layout is used as the default when the device has no saved `cfg.json`.
- If a board already has a saved WLED config, do a factory reset or erase settings once so the default bus layout is recreated.
Onboard status pixel on `GPIO48`:
- `green blinking`: DDP realtime active
- `green solid`: network connected
- `blue blinking`: AP / setup mode active
- `red fast blinking`: Wi-Fi configured but currently disconnected
- `amber fast blinking`: network connected, MQTT configured, but MQTT not connected
- `off`: idle / no status to show

503
docs/rfp-node-flashing.md Normal file
View File

@@ -0,0 +1,503 @@
# RFP Infinity Flashing
This document covers flashing for both the Infinity master and the ESP32-S3 render nodes.
## 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`
Use a cold-boot target when a board only starts reliably after pressing `RESET`
following a long power loss.
## 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:
- 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
settings that were stored through the WLED UI must be re-applied unless they are
also encoded as firmware defaults.
## Roles
- Master:
- Usually `192.168.178.10`
- Runs the `/infinity` web UI
- Accepts DMX and web commands
- Sends Infinity Sync packets to the nodes
- Keeps one dummy WLED pixel on `GPIO21` so the regular WLED UI remains
valid
- Keeps the real WLED status pixel exclusively on `GPIO48`
- 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`.
## Build: Master
```bash
cd /home/jan/Documents/RFP/WLED-MM/repo
PATH=/home/jan/Documents/RFP/Finanz_App/node/current/bin:$PATH \
NPM_CONFIG_CACHE=$PWD/.npm-cache \
PLATFORMIO_CORE_DIR=$PWD/.piohome \
PLATFORMIO_PACKAGES_DIR=$PWD/.piohome/packages \
PLATFORMIO_PLATFORMS_DIR=$PWD/.piohome/platforms \
PLATFORMIO_CACHE_DIR=$PWD/.piohome/.cache \
PLATFORMIO_BUILD_CACHE_DIR=$PWD/.piohome/buildcache \
.venv/bin/python -m platformio run -e rfp_esp32s3_wroom1_n16r8_master
```
Master firmware output:
```text
.pio/build/rfp_esp32s3_wroom1_n16r8_master/firmware.bin
```
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: Nodes
Standard 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_3x106
```
Cold-boot test 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_3x106_coldboot
```
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 \
--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/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
```
If upload does not start immediately:
1. Hold `BOOT`
2. Tap `RESET`
3. Release `BOOT`
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 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
```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
```
### OTA group-flash template
```bash
cd "$RFP_REPO"
.venv/bin/python tools/rfp_network_flash.py flash \
--targets <ip1>,<ip2>,<ip3>,<ip4>,<ip5>,<ip6> \
--start-from <ip1> \
--firmware .pio/build/rfp_esp32s3_wroom1_n16r8_3x106/firmware.bin
```
### Minimal hand-off checklist
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`).
### Rollback / removal plan for shared instructions
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.

View File

@@ -24,5 +24,6 @@ applyTo: "wled00/data/**"
## Build Integration
Files in this directory are processed by `tools/cdata.js` into `wled00/html_*.h` headers.
Run `npm run build` after any change. **Never edit the generated `html_*.h` files directly.**
Files in this directory are processed by `tools/cdata.js` into generated headers
(`wled00/html_*.h`, `wled00/js_*.h`).
Run `npm run build` after any change. **Never edit generated headers directly.**

View File

@@ -107,6 +107,7 @@ default_envs =
;; === esp32-S3 === with 16MB flash
esp32S3_16MB_PSRAM_M_HUB75 ;; for S3 with 16MB flash, HUB75 supported (MOONHUB HUB75 adapter board)
esp32S3_WROOM-2_M ;; for S3 WROOM-2; HUB75 supported
; rfp_esp32s3_wroom1_n16r8_3x106 ;; RFP ESP32-S3 WROOM-1 N16R8, 3x106 pixels on GPIO 4/5/6
;;
;; === esp32-S2 boards ===
esp32s2_PSRAM_S ;; OTA-compatible with upstream
@@ -2438,6 +2439,32 @@ build_flags = ${env:esp32S3_8MB_PSRAM_M_opi.build_flags}
[env:esp32S3_8MB_PSRAM_M] ;; legacy alias
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
extends = env:esp32S3_8MB_PSRAM_M_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_ESP32S3_N16R8_3x106
-D LEDPIN=4
-D DATA_PINS=4,5,6
-D PIXEL_COUNTS=106,106,106
-D DEFAULT_LED_COUNT=106
-D STATUSPIXELPIN=48
-D STATUSPIXELCOLORORDER=COL_ORDER_GRB
-D BTNPIN=-1
-D RLYPIN=-1
-D IRPIN=-1
-D AUDIOPIN=-1
[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

44
tools/flash_rfp_s3.ps1 Normal file
View File

@@ -0,0 +1,44 @@
param(
[string]$ComPort,
[switch]$BuildOnly
)
$ErrorActionPreference = "Stop"
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$repoRoot = Split-Path -Parent $scriptDir
$envName = "rfp_esp32s3_wroom1_n16r8_3x106"
$venvPython = Join-Path $repoRoot ".venv\Scripts\python.exe"
if (-not $BuildOnly -and [string]::IsNullOrWhiteSpace($ComPort)) {
throw "ComPort is required unless -BuildOnly is used."
}
if (Test-Path $venvPython) {
$pythonCommand = $venvPython
$pythonArgs = @()
} elseif (Get-Command py -ErrorAction SilentlyContinue) {
$pythonCommand = "py"
$pythonArgs = @("-3")
} elseif (Get-Command python -ErrorAction SilentlyContinue) {
$pythonCommand = "python"
$pythonArgs = @()
} else {
throw "No Python runtime found. Install Python or create .venv first."
}
$pioHome = Join-Path $repoRoot ".piohome"
$env:PLATFORMIO_CORE_DIR = $pioHome
$env:PLATFORMIO_PACKAGES_DIR = Join-Path $pioHome "packages"
$env:PLATFORMIO_PLATFORMS_DIR = Join-Path $pioHome "platforms"
$env:PLATFORMIO_CACHE_DIR = Join-Path $pioHome ".cache"
$env:PLATFORMIO_BUILD_CACHE_DIR = Join-Path $pioHome "buildcache"
$args = @("-m", "platformio", "run", "-e", $envName)
if (-not $BuildOnly) {
$args += @("-t", "upload", "--upload-port", $ComPort)
}
& $pythonCommand @pythonArgs @args
exit $LASTEXITCODE

View File

@@ -0,0 +1,361 @@
#!/usr/bin/env python3
"""Local Infinity visualizer for the exact 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.
"""
from __future__ import annotations
import argparse
import errno
import json
import math
import socket
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Any
NODE_COUNT = 6
ROWS = 3
LEDS_PER_PANEL = 106
OUTPUT_LABELS = ["UART6", "UART5", "UART4"]
MODE_NAMES = ["Off", "Center Pulse", "Checkerd", "Arrow", "Scan", "Snake", "Wave Line"]
VARIANT_NAMES = ["Expand / Classic / Line", "Reverse / Diagonal / Bands", "Outline / Checkerd", "Outline Reverse"]
BLEND_NAMES = ["Replace", "Add", "Multiply Mask", "Palette Tint"]
DIRECTION_NAMES = ["Left -> Right", "Right -> Left", "Top -> Bottom", "Bottom -> Top", "Outward", "Inward", "Ping Pong"]
BPM_MIN = 20
BPM_MAX = 240
HTML = r"""<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Infinity Local Visualizer</title>
<style>
:root{--bg:#0f1115;--panel:#181b22;--line:#303640;--text:#eef2f6;--muted:#9aa5b5;--good:#35d07f;--bad:#ff637d;--warn:#ffd166;--accent:#4da3ff}*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font:14px system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}main{max-width:1320px;margin:0 auto;padding:14px}header{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:12px}h1{margin:0;font-size:22px}.sub,.muted{color:var(--muted)}button,input{font:inherit;border:1px solid var(--line);background:#11151b;color:var(--text);border-radius:6px;padding:8px 10px}button{cursor:pointer;font-weight:650}.toolbar{display:flex;gap:8px;flex-wrap:wrap;align-items:center}.toolbar input{width:170px}.status{display:grid;grid-template-columns:repeat(6,minmax(0,1fr));gap:8px;margin-bottom:12px}.metric,.panel,.log{background:var(--panel);border:1px solid var(--line);border-radius:8px}.metric{padding:10px;min-width:0}.metric span{display:block;color:var(--muted);font-size:11px;text-transform:uppercase}.metric strong{display:block;margin-top:3px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.nodes{display:grid;grid-template-columns:repeat(6,minmax(128px,1fr));gap:12px}.panel{padding:8px;min-width:0}.panel-head{display:flex;justify-content:space-between;gap:8px;margin-bottom:6px;color:var(--muted);font-size:12px}.panel-head strong{color:var(--text)}canvas{width:100%;aspect-ratio:1/1;background:#05070b;border:1px solid #222b35;border-radius:6px;display:block}.log{margin-top:12px;padding:10px;min-height:38px}.ok{color:var(--good)}.bad{color:var(--bad)}.warn{color:var(--warn)}@media(max-width:900px){header{align-items:flex-start;flex-direction:column}.status{grid-template-columns:1fr}.nodes{grid-template-columns:repeat(3,minmax(92px,1fr));gap:8px}.toolbar,.toolbar input{width:100%}button{flex:1 1 auto}}
</style></head><body><main>
<header><div><h1>Infinity Local Visualizer</h1><div class="sub">Exact Global-2D layer preview. WLED base effects are not simulated.</div></div><div class="toolbar"><input id="master" aria-label="Master IP"><button id="apply">Connect</button><button id="pause">Pause</button><a id="masterLink" href="#" target="_blank"><button type="button">Master UI</button></a></div></header>
<section class="status" id="status"></section><section class="nodes" id="nodes"></section><div class="log" id="log">Starting...</div>
<script>
const NODE_COUNT=6, ROWS=3, LEDS=106, OUTPUT_LABELS=["UART6","UART5","UART4"];
let frame=null, paused=false, refreshInFlight=false;const q=id=>document.getElementById(id);const masterIp=()=>q("master").value.trim()||new URLSearchParams(location.search).get("master")||"10.42.0.213";
function ensureNodes(){if(q("nodes").children.length)return;q("nodes").innerHTML=Array.from({length:ROWS},(_,row)=>Array.from({length:NODE_COUNT},(_,node)=>`<article class="panel"><div class="panel-head"><strong>ESP${node+1} ${OUTPUT_LABELS[row]}</strong><span id="meta${node}_${row}">106 LEDs</span></div><canvas width="160" height="160" id="c${node}_${row}"></canvas></article>`).join("")).join("")}
function drawPanel(canvas, leds){const ctx=canvas.getContext("2d");ctx.clearRect(0,0,160,160);ctx.fillStyle="#05070b";ctx.fillRect(0,0,160,160);ctx.strokeStyle="#1e2732";ctx.lineWidth=2;ctx.strokeRect(23,23,114,114);for(let i=0;i<leds.length;i++){let x=0,y=0;if(i<25){x=26+i*(108/24);y=24}else if(i<52){x=136;y=26+(i-25)*(108/26)}else if(i<79){x=136-(i-52)*(108/26);y=136}else{x=24;y=136-(i-79)*(108/26)}const c=leds[i];ctx.fillStyle=`rgb(${c[0]},${c[1]},${c[2]})`;ctx.beginPath();ctx.arc(x,y,2.1,0,Math.PI*2);ctx.fill()}}
function render(){ensureNodes();const s=frame?.scene||{}, spatial=s.spatial||{};for(let row=0;row<ROWS;row++){for(let node=0;node<NODE_COUNT;node++){q(`meta${node}_${row}`).textContent=frame?.node_ips?.[node]||"virtual";drawPanel(q(`c${node}_${row}`),frame?.panels?.[row]?.[node]||Array.from({length:LEDS},()=>[0,0,0]));}}q("status").innerHTML=[["Master",masterIp()],["2D Mode",frame?.mode_name||"Off"],["Variant",frame?.variant_name||"-"],["Direction",frame?.direction_name||"-"],["Blend",frame?.blend_name||"-"],["Layer",frame?.note||"2D layer exact"]].map(([k,v])=>`<div class="metric"><span>${k}</span><strong>${v}</strong></div>`).join("")}
async function refresh(){if(paused||refreshInFlight)return;refreshInFlight=true;const ip=masterIp();q("masterLink").href=`http://${ip}/infinity`;try{const response=await fetch(`/api/frame?master=${encodeURIComponent(ip)}`,{cache:"no-store"});if(!response.ok)throw new Error((await response.text()).replace(/[{}\"]/g,""));frame=await response.json();q("log").innerHTML=`<span class="ok">connected</span> ${new Date().toLocaleTimeString()} via ${ip}. <span class="warn">WLED base not simulated; showing exact Infinity 2D layer only.</span>`;render()}catch(error){q("log").innerHTML=`<span class="bad">master offline</span> ${ip} · ${error.message}`}finally{refreshInFlight=false}}
q("master").value=masterIp();q("apply").onclick=refresh;q("pause").onclick=()=>{paused=!paused;q("pause").textContent=paused?"Resume":"Pause"};setInterval(refresh,250);refresh();
</script></main></body></html>"""
def clamp_byte(value: float) -> int:
return max(0, min(255, int(round(value))))
def smoothstep(edge0: float, edge1: float, x: float) -> float:
if edge0 == edge1:
return 0.0 if x < edge0 else 1.0
x = max(0.0, min(1.0, (x - edge0) / (edge1 - edge0)))
return x * x * (3.0 - 2.0 * x)
def speed_to_bpm(speed: int) -> int:
speed = max(0, min(255, int(speed)))
return round(BPM_MIN + speed * (BPM_MAX - BPM_MIN) / 255.0)
def spatial_phase(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 panel_led_position(led: int) -> tuple[float, float, int]:
if led < 25:
return (led + 0.5) / 25.0, 0.0, 0
if led < 52:
return 1.0, (led - 25 + 0.5) / 27.0, 1
if led < 79:
return 1.0 - ((led - 52 + 0.5) / 27.0), 1.0, 2
return 0.0, 1.0 - ((led - 79 + 0.5) / 27.0), 3
def 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
if variant in (1, 3):
front = max_distance - front
amount = 1.0 - smoothstep(0.0, 0.70, abs(distance - front))
value = clamp_byte(amount * 255.0)
if variant in (2, 3):
_, _, side = panel_led_position(led)
if row == 1 and col in (2, 3):
return value if side in (0, 2) else 0
if col == 0:
return value if side == 3 else 0
if col == 5:
return value if side == 1 else 0
if row == 0:
return value if side == 0 else 0
if row == 2:
return value if side == 2 else 0
return value
def checker_amount(col: int, row: int, led: int, now_us: int, speed: int, variant: int) -> int:
parity = (row + col) & 1
step = int(math.floor(spatial_phase(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 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)))
if direction in (2, 3):
phase = step if direction == 2 else -step
target = round(triangle[(row - phase) % 4] * ((NODE_COUNT - 1) / 2.0) / 2.0)
return 255 if col == min(target, NODE_COUNT - 1) else 0
phase = -step if direction == 1 else step
target = round(triangle[(col - phase) % 4] * ((ROWS - 1) / 2.0))
return 255 if row == min(target, ROWS - 1) else 0
def arrow_amount(col: int, row: int, now_us: int, speed: int, direction: int, size: int) -> int:
horizontal = direction not in (2, 3)
major_count = NODE_COUNT if horizontal else ROWS
minor_count = ROWS if horizontal else NODE_COUNT
major = col if horizontal else row
minor = row if horizontal else col
gap = max(1, 1 + size // 86) - 1
span = 3 + gap
movement = int(math.floor(spatial_phase(now_us, speed)))
band = 0 if abs(minor - ((minor_count - 1) / 2.0)) <= 0.55 else 1
orientation_right = direction in (0, 2, 4)
target = 1 if band == 0 else (0 if orientation_right else 2)
local = major - movement if orientation_right else major + movement
return 255 if major_count > 0 and (local % span) == target else 0
def scan_amount(col: int, row: int, led: int, now_us: int, speed: int, size: int, angle: int, option: int, direction: int) -> int:
x, y, _ = panel_led_position(led)
vertical = direction in (2, 3)
radians = math.radians((angle + (90 if vertical else 0)) % 360)
vx, vy = math.cos(radians), math.sin(radians)
progress = (col + x) * vx + (row + y) * vy
min_progress, max_progress = -3.0, 8.0
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
if direction == 6:
phase = (spatial_phase(now_us, speed) * travel) % (travel * 2.0)
if phase > travel:
phase = (travel * 2.0) - phase
elif direction in (1, 3):
phase = travel - phase
center = min_progress + phase
if option == 1:
period = width * 2.0 + 0.35
d = abs(((progress - center + period * 64.0) % period) - period * 0.5)
return clamp_byte((1.0 - smoothstep(width * 0.45, width * 0.75, d)) * 255.0)
return clamp_byte((1.0 - smoothstep(width * 0.5, width * 0.5 + 0.55, abs(progress - center))) * 255.0)
def snake_amount(col: int, row: int, now_us: int, speed: int, size: int, seed: int) -> int:
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)
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
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:
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)
if mode == 2:
return checker_amount(col, row, led, now_us, speed, variant)
if mode == 3:
return arrow_amount(col, row, now_us, speed, direction, size)
if mode == 4:
return scan_amount(col, row, led, now_us, speed, size, int(spatial.get("angle", 0)), int(spatial.get("option", 0)), direction)
if mode == 5:
return snake_amount(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
def render_frame(state: dict[str, Any]) -> dict[str, Any]:
scene = state.get("scene", {})
spatial = scene.get("spatial", {}) or {}
mode = int(spatial.get("mode", 0))
strength = int(spatial.get("strength", 180))
primary = scene.get("primary", [255, 160, 80])[:3]
secondary = scene.get("secondary", [0, 32, 255])[:3]
now_us = int(time.monotonic() * 1_000_000) + int(scene.get("phase", 0)) * 1000
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])
row_panels.append(leds)
panels.append(row_panels)
return {
"scene": scene,
"node_ips": state.get("node_ips", []),
"panels": panels,
"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",
"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",
}
class VisualizerServer(ThreadingHTTPServer):
allow_reuse_address = True
master: str
timeout_s: float
class Handler(BaseHTTPRequestHandler):
server: VisualizerServer
def log_message(self, fmt: str, *args: Any) -> None:
message = fmt % args
if '"GET /api/' in message and (' 502 ' in message or ' 504 ' in message):
return
sys.stderr.write("%s - %s\n" % (self.log_date_time_string(), message))
def send_bytes(self, status: int, content_type: str, body: bytes) -> None:
try:
self.send_response(status)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(body)
except (BrokenPipeError, ConnectionResetError):
pass
def do_GET(self) -> None:
if self.path == "/" or self.path.startswith("/?"):
self.send_bytes(200, "text/html; charset=utf-8", HTML.encode("utf-8"))
return
if self.path.startswith("/api/infinity"):
self.proxy_infinity(self.master_from_query())
return
if self.path.startswith("/api/frame"):
self.proxy_frame(self.master_from_query())
return
if self.path == "/health":
self.send_bytes(200, "application/json", b'{"ok":true}')
return
self.send_bytes(404, "text/plain; charset=utf-8", b"not found")
def master_from_query(self) -> str:
values = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)
return values.get("master", [self.server.master])[0].strip() or self.server.master
def fetch_master_state(self, master: str) -> dict[str, Any]:
url = f"http://{master}/json/infinity"
with urllib.request.urlopen(url, timeout=self.server.timeout_s) as response:
body = response.read()
return json.loads(body.decode("utf-8"))
def proxy_infinity(self, master: str) -> None:
try:
state = self.fetch_master_state(master)
except (urllib.error.URLError, socket.timeout, TimeoutError) as exc:
self.send_bytes(504, "application/json", json.dumps({"error":"master unreachable","detail":str(exc)}).encode("utf-8"))
return
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
self.send_bytes(502, "application/json", json.dumps({"error":f"invalid master JSON: {exc}"}).encode("utf-8"))
return
self.send_bytes(200, "application/json", json.dumps(state).encode("utf-8"))
def proxy_frame(self, master: str) -> None:
try:
state = self.fetch_master_state(master)
frame = render_frame(state)
except (urllib.error.URLError, socket.timeout, TimeoutError) as exc:
self.send_bytes(504, "application/json", json.dumps({"error":"master unreachable","detail":str(exc)}).encode("utf-8"))
return
except Exception as exc: # keep the operator UI alive and explicit
self.send_bytes(502, "application/json", json.dumps({"error":f"frame render failed: {exc}"}).encode("utf-8"))
return
self.send_bytes(200, "application/json", json.dumps(frame, separators=(",", ":")).encode("utf-8"))
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Serve a local Infinity Global-2D visualizer.")
parser.add_argument("--master", default="10.42.0.213", help="Infinity master IP or hostname")
parser.add_argument("--bind", default="127.0.0.1", help="Local bind address")
parser.add_argument("--port", type=int, default=8765, help="Local HTTP port")
parser.add_argument("--no-port-fallback", action="store_true", help="Fail instead of trying the next ports when busy")
parser.add_argument("--timeout", type=float, default=1.2, help="Master request timeout in seconds")
return parser.parse_args()
def bind_server(args: argparse.Namespace) -> VisualizerServer:
last_error: OSError | None = None
ports = [args.port] if args.no_port_fallback else range(args.port, args.port + 50)
for port in ports:
try:
server = VisualizerServer((args.bind, port), Handler)
if port != args.port:
print(f"Port {args.port} is busy, using {port} instead.")
return server
except OSError as exc:
last_error = exc
if exc.errno != errno.EADDRINUSE or args.no_port_fallback:
break
if last_error and last_error.errno == errno.EADDRINUSE:
raise SystemExit(f"Could not start visualizer: ports {args.port}-{args.port + 49} are busy.")
raise last_error or RuntimeError("Could not bind visualizer server")
def main() -> int:
args = parse_args()
server = bind_server(args)
server.master = args.master
server.timeout_s = args.timeout
host, port = server.server_address[:2]
print(f"Infinity visualizer: http://{host}:{port}/?master={args.master}")
print(f"Proxying master: http://{args.master}/json/infinity")
print("Rendering exact Infinity Global-2D layer; WLED base effects are not simulated.")
try:
server.serve_forever()
except KeyboardInterrupt:
print("\nStopped.")
finally:
server.server_close()
return 0
if __name__ == "__main__":
raise SystemExit(main())

388
tools/rfp_network_flash.py Executable file
View File

@@ -0,0 +1,388 @@
#!/usr/bin/env python3
"""
Discover WLED devices in local networks and flash them sequentially via OTA.
"""
from __future__ import annotations
import argparse
import ipaddress
import os
import re
import subprocess
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable
import requests
DEFAULT_OUTPUT_FILE = "tools/discovered_wled_hosts.txt"
@dataclass
class WledHost:
ip: str
name: str
version: str
arch: str
@dataclass
class WledInfo(WledHost):
uptime_s: int
def _run(cmd: list[str]) -> str:
proc = subprocess.run(cmd, check=True, capture_output=True, text=True)
return proc.stdout
def local_networks() -> list[ipaddress.IPv4Network]:
"""
Read active IPv4 interfaces from `ip` and return private subnets.
"""
out = _run(["ip", "-o", "-4", "addr", "show", "scope", "global"])
nets: list[ipaddress.IPv4Network] = []
seen: set[str] = set()
for line in out.splitlines():
match = re.search(r"\binet\s+(\d+\.\d+\.\d+\.\d+/\d+)\b", line)
if not match:
continue
iface_cidr = match.group(1)
iface_ip = ipaddress.ip_interface(iface_cidr)
net = iface_ip.network
if not iface_ip.ip.is_private:
continue
if net.num_addresses > 2048:
# avoid accidentally scanning very large ranges by default
net = ipaddress.ip_network(f"{iface_ip.ip}/24", strict=False)
key = str(net)
if key in seen:
continue
seen.add(key)
nets.append(net)
return nets
def parse_networks(raw: Iterable[str] | None) -> list[ipaddress.IPv4Network]:
if raw:
nets: list[ipaddress.IPv4Network] = []
for item in raw:
nets.append(ipaddress.ip_network(item, strict=False))
return nets
return local_networks()
def probe_wled_info(ip: str, timeout_s: float) -> WledInfo | None:
url = f"http://{ip}/json/info"
try:
resp = requests.get(url, timeout=timeout_s)
if resp.status_code != 200:
return None
data = resp.json()
except (requests.RequestException, ValueError):
return None
# WLED info endpoint typically includes "name", "ver", and "arch"
ver = str(data.get("ver", "")).strip()
name = str(data.get("name", "")).strip()
arch = str(data.get("arch", "")).strip()
if not ver:
return None
if not name:
name = "WLED"
if not arch:
arch = "-"
uptime_s = int(data.get("uptime", 0) or 0)
return WledInfo(ip=ip, name=name, version=ver, arch=arch, uptime_s=uptime_s)
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)
def discover_hosts(
nets: list[ipaddress.IPv4Network],
timeout_s: float,
workers: int,
) -> list[WledHost]:
candidates: list[str] = []
for net in nets:
for host in net.hosts():
candidates.append(str(host))
found: list[WledHost] = []
with ThreadPoolExecutor(max_workers=workers) as executor:
futures = [executor.submit(probe_wled, ip, timeout_s) for ip in candidates]
for fut in as_completed(futures):
result = fut.result()
if result is not None:
found.append(result)
found.sort(key=lambda h: tuple(int(part) for part in h.ip.split(".")))
return found
def read_targets_file(path: Path) -> list[str]:
targets: list[str] = []
for raw in path.read_text(encoding="utf-8").splitlines():
line = raw.strip()
if not line or line.startswith("#"):
continue
targets.append(line.split()[0])
return targets
def write_discovery(path: Path, hosts: list[WledHost]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
lines = ["# ip name version arch"]
lines += [f"{h.ip} {h.name} {h.version} {h.arch}" for h in hosts]
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
def wait_for_online(ip: str, timeout_s: float, interval_s: float) -> bool:
deadline = time.time() + timeout_s
while time.time() < deadline:
if probe_wled(ip, timeout_s=1.2) is not None:
return True
time.sleep(interval_s)
return False
def wait_for_offline(ip: str, timeout_s: float, interval_s: float) -> bool:
deadline = time.time() + timeout_s
while time.time() < deadline:
if probe_wled(ip, timeout_s=1.2) is None:
return True
time.sleep(interval_s)
return False
def wait_for_online_info(ip: str, timeout_s: float, interval_s: float) -> WledInfo | None:
deadline = time.time() + timeout_s
while time.time() < deadline:
info = probe_wled_info(ip, timeout_s=1.2)
if info is not None:
return info
time.sleep(interval_s)
return None
def reboot_confirmed(before: WledInfo | None, after: WledInfo, offline_seen: bool) -> tuple[bool, str]:
if offline_seen:
return True, "offline transition observed"
if before is None:
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"
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]:
url = f"http://{ip}/update"
try:
with firmware.open("rb") as fh:
resp = requests.post(
url,
files={"update": (firmware.name, fh, "application/octet-stream")},
timeout=(connect_timeout_s, read_timeout_s),
)
except requests.ReadTimeout:
# Common with WLED OTA: upload is accepted but HTTP response never arrives before reboot.
return "uncertain", "read timeout after upload"
except requests.ConnectionError:
# Some devices close the socket abruptly when rebooting after successful OTA.
return "uncertain", "connection dropped during/after upload"
except requests.RequestException as exc:
return "failed", f"request failed: {exc}"
text = (resp.text or "").lower()
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"
def print_hosts(hosts: list[WledHost]) -> None:
if not hosts:
print("No WLED devices found.")
return
print(f"Found {len(hosts)} WLED device(s):")
print(f"{'IP':<16} {'Name':<24} {'Version':<18} {'Arch'}")
print("-" * 80)
for h in hosts:
print(f"{h.ip:<16} {h.name[:24]:<24} {h.version[:18]:<18} {h.arch}")
def cmd_discover(args: argparse.Namespace) -> int:
nets = parse_networks(args.subnet)
if not nets:
print("No private IPv4 networks found. Pass --subnet explicitly.")
return 2
print("Scanning networks:", ", ".join(str(n) for n in nets))
hosts = discover_hosts(nets=nets, timeout_s=args.timeout, workers=args.workers)
print_hosts(hosts)
if args.output:
out = Path(args.output)
write_discovery(out, hosts)
print(f"Saved discovery list: {out}")
return 0
def resolve_targets(args: argparse.Namespace) -> list[str]:
targets: list[str] = []
if args.targets:
targets.extend([t.strip() for t in args.targets.split(",") if t.strip()])
if args.targets_file:
targets.extend(read_targets_file(Path(args.targets_file)))
if args.discover:
nets = parse_networks(args.subnet)
hosts = discover_hosts(nets=nets, timeout_s=args.timeout, workers=args.workers)
targets.extend([h.ip for h in hosts])
unique: list[str] = []
seen: set[str] = set()
for t in targets:
if t in seen:
continue
seen.add(t)
unique.append(t)
return unique
def cmd_flash(args: argparse.Namespace) -> int:
firmware = Path(args.firmware)
if not firmware.exists():
print(f"Firmware file not found: {firmware}")
return 2
targets = resolve_targets(args)
if not targets:
print("No targets selected. Use --targets, --targets-file, or --discover.")
return 2
if args.start_from:
if args.start_from not in targets:
print(f"--start-from target not found in target set: {args.start_from}")
return 2
start_idx = targets.index(args.start_from)
targets = targets[start_idx:]
print(f"Flashing {len(targets)} device(s) sequentially with: {firmware}")
failures: list[str] = []
for idx, ip in enumerate(targets, start=1):
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}'"
)
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,
)
if status == "failed":
print(f"[{idx}/{len(targets)}] {ip}: FAILED ({msg})")
failures.append(ip)
continue
if status == "uncertain":
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)
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...")
after = wait_for_online_info(ip=ip, timeout_s=args.online_timeout, interval_s=1.0)
if after is None:
print(f"[{idx}/{len(targets)}] {ip}: no online response after upload window.")
failures.append(ip)
continue
reboot_ok, reason = reboot_confirmed(before=before, after=after, offline_seen=offline_seen)
if not reboot_ok:
print(f"[{idx}/{len(targets)}] {ip}: FAILED (could not prove reboot: {reason})")
failures.append(ip)
continue
print(
f"[{idx}/{len(targets)}] {ip}: OK "
f"(now {after.version}, uptime {after.uptime_s}s, {reason})"
)
if failures:
print("\nFailed targets:")
for ip in failures:
print(f"- {ip}")
return 1
print("\nAll targets flashed successfully.")
return 0
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Discover and OTA-flash WLED devices.")
sub = parser.add_subparsers(dest="cmd", required=True)
p_discover = sub.add_parser("discover", help="Scan network(s) for WLED devices.")
p_discover.add_argument("--subnet", action="append", help="Subnet CIDR, repeatable (example: 192.168.1.0/24).")
p_discover.add_argument("--timeout", type=float, default=0.8, help="HTTP probe timeout in seconds.")
p_discover.add_argument("--workers", type=int, default=128, help="Parallel probe workers.")
p_discover.add_argument("--output", default=DEFAULT_OUTPUT_FILE, help="Output file for discovered hosts.")
p_discover.set_defaults(func=cmd_discover)
p_flash = sub.add_parser("flash", help="Flash firmware.bin to selected hosts sequentially.")
p_flash.add_argument("--firmware", required=True, help="Path to firmware.bin")
p_flash.add_argument("--targets", help="Comma-separated IP list")
p_flash.add_argument("--targets-file", help="Text file with one IP per line")
p_flash.add_argument("--discover", action="store_true", help="Discover targets before flashing")
p_flash.add_argument("--subnet", action="append", help="Subnet CIDR for discovery mode")
p_flash.add_argument("--start-from", help="Start flashing from this IP within the resolved target list")
p_flash.add_argument("--timeout", type=float, default=0.8, help="HTTP probe timeout in seconds")
p_flash.add_argument("--workers", type=int, default=128, help="Parallel probe workers for discovery")
p_flash.add_argument("--connect-timeout", type=float, default=5.0, help="HTTP connect timeout in seconds")
p_flash.add_argument("--upload-timeout", type=float, default=90.0, help="HTTP upload timeout in seconds")
p_flash.add_argument("--reboot-wait", type=float, default=10.0, help="Sleep after upload before online check")
p_flash.add_argument("--offline-timeout", type=float, default=20.0, help="How long to wait for the device to disappear during reboot")
p_flash.add_argument("--online-timeout", type=float, default=60.0, help="How long to wait for device to come back")
p_flash.set_defaults(func=cmd_flash)
return parser
def main() -> int:
parser = build_parser()
args = parser.parse_args()
try:
return int(args.func(args))
except KeyboardInterrupt:
print("\nAborted by user (Ctrl+C).")
print("Tip: rerun with --start-from <ip> to continue at a specific device.")
return 130
if __name__ == "__main__":
os.environ.setdefault("PYTHONUNBUFFERED", "1")
raise SystemExit(main())

159
tools/rfp_update_all_ota.py Executable file
View File

@@ -0,0 +1,159 @@
#!/usr/bin/env python3
"""Build and OTA-update the full RFP Infinity installation.
This script intentionally delegates flashing to tools/rfp_network_flash.py so
the actual OTA path stays the existing WLED /update workflow.
"""
from __future__ import annotations
import argparse
import ipaddress
import os
import subprocess
import sys
from pathlib import Path
NODE_ENV = "rfp_esp32s3_wroom1_n16r8_3x106"
MASTER_ENV = "rfp_esp32s3_wroom1_n16r8_master"
NODE_FIRMWARE = Path(".pio/build") / NODE_ENV / "firmware.bin"
MASTER_FIRMWARE = Path(".pio/build") / MASTER_ENV / "firmware.bin"
DEFAULT_SUBNET = "192.168.178.0/24"
NODE_HOSTS = range(11, 17)
MASTER_HOST = 10
def repo_root() -> Path:
return Path(__file__).resolve().parents[1]
def python_executable(root: Path) -> str:
local_python = root / ".venv/bin/python"
return str(local_python) if local_python.exists() else sys.executable
def platformio_env(root: Path) -> dict[str, str]:
env = os.environ.copy()
node_bin = "/home/jan/Documents/RFP/Finanz_App/node/current/bin"
env["PATH"] = f"{node_bin}:{env.get('PATH', '')}"
env["NPM_CONFIG_CACHE"] = str(root / ".npm-cache")
env["PLATFORMIO_CORE_DIR"] = str(root / ".piohome")
env["PLATFORMIO_PACKAGES_DIR"] = str(root / ".piohome/packages")
env["PLATFORMIO_PLATFORMS_DIR"] = str(root / ".piohome/platforms")
env["PLATFORMIO_CACHE_DIR"] = str(root / ".piohome/.cache")
env["PLATFORMIO_BUILD_CACHE_DIR"] = str(root / ".piohome/buildcache")
return env
def targets_from_subnet(subnet: str) -> tuple[list[str], str]:
network = ipaddress.ip_network(subnet, strict=False)
if network.version != 4:
raise ValueError("Only IPv4 subnets are supported.")
octets = str(network.network_address).split(".")
if len(octets) != 4:
raise ValueError(f"Invalid IPv4 subnet: {subnet}")
prefix = ".".join(octets[:3])
return [f"{prefix}.{host}" for host in NODE_HOSTS], f"{prefix}.{MASTER_HOST}"
def run(cmd: list[str], root: Path, env: dict[str, str], dry_run: bool) -> None:
print("+ " + " ".join(cmd))
if dry_run:
return
subprocess.run(cmd, cwd=root, env=env, check=True)
def build(root: Path, env_name: str, env: dict[str, str], dry_run: bool) -> None:
run([python_executable(root), "-m", "platformio", "run", "-e", env_name], root, env, dry_run)
def flash(root: Path, targets: list[str], firmware: Path, env: dict[str, str], dry_run: bool) -> 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,
)
def filtered_plan(nodes: list[str], master: str, args: argparse.Namespace) -> list[tuple[str, str]]:
plan: list[tuple[str, str]] = []
if not args.master_only:
plan.extend((ip, "node") for ip in nodes)
if not args.nodes_only:
plan.append((master, "master"))
if not args.start_from:
return plan
for index, (ip, _role) in enumerate(plan):
if ip == args.start_from:
return plan[index:]
raise ValueError(f"--start-from {args.start_from} is not in the selected update plan.")
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Build and OTA-update RFP Infinity nodes, then master.")
role = parser.add_mutually_exclusive_group()
role.add_argument("--nodes-only", action="store_true", help="Only build/flash nodes .11-.16")
role.add_argument("--master-only", action="store_true", help="Only build/flash master .10")
parser.add_argument("--start-from", help="Resume at this IP, for example 192.168.178.14 or 192.168.178.10")
parser.add_argument("--subnet", default=DEFAULT_SUBNET, help=f"Show subnet used to derive .10-.16 targets (default: {DEFAULT_SUBNET})")
parser.add_argument("--no-build", action="store_true", help="Skip PlatformIO builds and flash existing firmware.bin files")
parser.add_argument("--dry-run", action="store_true", help="Print build and flash steps without executing them")
return parser.parse_args()
def main() -> int:
args = parse_args()
root = repo_root()
env = platformio_env(root)
nodes, master = targets_from_subnet(args.subnet)
plan = filtered_plan(nodes, master, args)
node_targets = [ip for ip, role in plan if role == "node"]
master_targets = [ip for ip, role in plan if role == "master"]
if not plan:
print("Nothing selected.")
return 0
print("RFP Infinity OTA update plan:")
for ip, role in plan:
print(f"- {ip} ({role})")
print()
if not args.no_build:
if node_targets:
print(f"Building node firmware: {NODE_ENV}")
build(root, NODE_ENV, env, args.dry_run)
if master_targets:
print(f"Building master firmware: {MASTER_ENV}")
build(root, MASTER_ENV, env, args.dry_run)
if node_targets:
print("Flashing nodes sequentially...")
flash(root, node_targets, NODE_FIRMWARE, env, args.dry_run)
if master_targets:
print("Flashing master last...")
flash(root, master_targets, MASTER_FIRMWARE, env, args.dry_run)
print("OTA update plan completed.")
return 0
if __name__ == "__main__":
raise SystemExit(main())

1634
wled00/infinity_sync.cpp Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -168,6 +168,9 @@ String PinManagerClass::getPinSpecialText(int gpio) { // special purpose PIN in
#endif
#if defined(STATUSPIXELPIN)
if (gpio == STATUSPIXELPIN) return(F("WLED Status Pixel"));
#endif
#if defined(STATUSLED)
if (gpio == STATUSLED) return(F("WLED Status LED"));
#endif

View File

@@ -71,6 +71,73 @@
#endif
// WLEDMM end
#if defined(STATUSLED) || defined(STATUSPIXELPIN)
static inline void writeStatusIndicator(uint32_t color) {
#if defined(STATUSPIXELPIN)
if (statusPixelBus != nullptr) {
if (statusPixelBus->canShow()) {
statusPixelBus->setPixelColor(0, color);
statusPixelBus->show();
}
return;
}
#endif
#if defined(STATUSLED)
#if STATUSLED >= 0
#ifdef STATUSLEDINVERTED
digitalWrite(STATUSLED, color ? LOW : HIGH);
#else
digitalWrite(STATUSLED, color ? HIGH : LOW);
#endif
#else
busses.setStatusPixel(color);
#endif
#endif
}
#endif
#if defined(STATUSPIXELPIN)
#ifndef STATUSPIXELCOLORORDER
#define STATUSPIXELCOLORORDER COL_ORDER_GRB
#endif
static void initStatusPixelBus() {
if (statusPixelBus != nullptr) return;
if (pinManager.isPinAllocated(STATUSPIXELPIN)) {
USER_PRINTF("Skipping status pixel on GPIO %u because the pin is already in use.\n", STATUSPIXELPIN);
return;
}
#if defined(CONFIG_IDF_TARGET_ESP32S3) || defined(CONFIG_IDF_TARGET_ESP32S2)
constexpr uint8_t maxStatusPixelBusses = 4;
#elif defined(CONFIG_IDF_TARGET_ESP32C3)
constexpr uint8_t maxStatusPixelBusses = 2;
#else
constexpr uint8_t maxStatusPixelBusses = 8;
#endif
uint8_t busIndex = busses.getNumBusses();
if (busIndex >= maxStatusPixelBusses) {
USER_PRINTLN(F("Skipping status pixel because no free hardware LED channel is left."));
return;
}
uint8_t pins[] = {STATUSPIXELPIN};
BusConfig statusCfg(TYPE_WS2812_RGB, pins, 0, 1, STATUSPIXELCOLORORDER, false, 0, RGBW_MODE_MANUAL_ONLY);
statusPixelBus = new BusDigital(statusCfg, busIndex, busses.getColorOrderMap());
if (statusPixelBus == nullptr || !statusPixelBus->isOk()) {
delete statusPixelBus;
statusPixelBus = nullptr;
USER_PRINTLN(F("Failed to initialize onboard status pixel."));
return;
}
statusPixelBus->setBrightness(255, true);
writeStatusIndicator(0);
}
#endif
#if INCLUDE_xTaskGetHandle && defined(ARDUINO_ARCH_ESP32) && (defined(WLED_DEBUG) || defined(WLED_DEBUG_HEAP))
// WLEDMM stack debug tool - find async_tcp task, and queries it's free stack
@@ -883,6 +950,9 @@ void WLED::setup()
DEBUG_PRINTLN(F("Initializing strip"));
beginStrip();
#if defined(STATUSPIXELPIN)
initStatusPixelBus();
#endif
DEBUG_PRINT(F("heap ")); DEBUG_PRINTLN(getFreeHeapSize());
USER_PRINTLN(F("\nUsermods setup ..."));
@@ -1568,58 +1638,79 @@ void WLED::handleConnection()
}
// If status LED pin is allocated for other uses, does nothing
// else blink at 1Hz when WLED_CONNECTED is false (no WiFi, ?? no Ethernet ??)
// else blink at 2Hz when MQTT is enabled but not connected
// else turn the status LED off
// green blink = DDP realtime active
// blue blink = AP active
// green solid = network connected
// amber fast blink = MQTT configured but disconnected
// red fast blink = WiFi configured but currently disconnected
// off = idle / no status to show
void WLED::handleStatusLED()
{
#if defined(STATUSLED)
[[maybe_unused]] uint32_t c = 0;
#if defined(STATUSLED) || defined(STATUSPIXELPIN)
uint32_t c = 0;
uint8_t nextType = 0;
uint16_t blinkIntervalMs = 0;
#if STATUSLED>=0
#if defined(STATUSLED) && STATUSLED>=0
if (pinManager.isPinAllocated(STATUSLED)) {
return; //lower priority if something else uses the same pin
}
#endif
if (WLED_CONNECTED) {
if (realtimeMode == REALTIME_MODE_DDP) {
c = RGBW32(0,255,0,0);
ledStatusType = 2;
} else if (WLED_MQTT_CONNECTED) {
c = RGBW32(0,128,0,0);
ledStatusType = 4;
nextType = 2;
blinkIntervalMs = 250;
} else if (apActive) {
c = RGBW32(0,0,255,0);
ledStatusType = 1;
}
if (ledStatusType) {
if (millis() - ledStatusLastMillis >= (1000/ledStatusType)) {
ledStatusLastMillis = millis();
#if 1
// WLEDMM un-comment this to stop the blinking
if ((ledStatusType != 2) && (ledStatusType != 4))
ledStatusState = !ledStatusState;
else
ledStatusState = HIGH;
#else
ledStatusState = !ledStatusState;
#endif
#if STATUSLED>=0
digitalWrite(STATUSLED, ledStatusState);
#else
busses.setStatusPixel(ledStatusState ? c : 0);
#endif
}
} else {
#if STATUSLED>=0
#ifdef STATUSLEDINVERTED
digitalWrite(STATUSLED, HIGH);
#else
digitalWrite(STATUSLED, LOW);
#endif
#else
busses.setStatusPixel(0);
nextType = 2;
blinkIntervalMs = 500;
} else if (WLED_CONNECTED) {
#ifndef WLED_DISABLE_MQTT
if (mqttEnabled && mqttServer[0] != 0 && !WLED_MQTT_CONNECTED) {
c = RGBW32(255,96,0,0);
nextType = 3;
blinkIntervalMs = 250;
} else
#endif
{
c = RGBW32(0,255,0,0);
nextType = 1;
}
} else if (WLED_WIFI_CONFIGURED) {
c = RGBW32(255,0,0,0);
nextType = 3;
blinkIntervalMs = 250;
}
if (nextType != ledStatusType) {
ledStatusType = nextType;
ledStatusLastMillis = millis();
ledStatusState = (nextType == 1);
writeStatusIndicator(ledStatusState ? c : 0);
return;
}
if (nextType == 0) {
if (ledStatusState) {
ledStatusState = false;
writeStatusIndicator(0);
}
return;
}
if (nextType == 1) {
if (!ledStatusState) {
ledStatusState = true;
writeStatusIndicator(c);
}
return;
}
if (millis() - ledStatusLastMillis >= blinkIntervalMs) {
ledStatusLastMillis = millis();
ledStatusState = !ledStatusState;
writeStatusIndicator(ledStatusState ? c : 0);
}
#endif
}

View File

@@ -751,12 +751,15 @@ WLED_GLOBAL bool doSerializeConfig _INIT(false); // flag to initiate savi
WLED_GLOBAL bool doReboot _INIT(false); // flag to initiate reboot from async handlers
WLED_GLOBAL bool doPublishMqtt _INIT(false);
// status led
#if defined(STATUSLED)
// status led / status pixel
#if defined(STATUSLED) || defined(STATUSPIXELPIN)
WLED_GLOBAL unsigned long ledStatusLastMillis _INIT(0);
WLED_GLOBAL uint8_t ledStatusType _INIT(0); // current status type - corresponds to number of blinks per second
WLED_GLOBAL uint8_t ledStatusType _INIT(0); // 0=off, 1=solid, 2=slow blink, 3=fast blink
WLED_GLOBAL bool ledStatusState _INIT(false); // the current LED state
#endif
#if defined(STATUSPIXELPIN)
WLED_GLOBAL BusDigital* statusPixelBus _INIT(nullptr);
#endif
// server library objects
WLED_GLOBAL AsyncWebServer server _INIT_N(((80)));