Skip to content

Usermod system improvements (static UI support)#5742

Open
willmmiles wants to merge 12 commits into
wled:mainfrom
willmmiles:feat/usermod-settings
Open

Usermod system improvements (static UI support)#5742
willmmiles wants to merge 12 commits into
wled:mainfrom
willmmiles:feat/usermod-settings

Conversation

@willmmiles

@willmmiles willmmiles commented Jul 18, 2026

Copy link
Copy Markdown
Member

This PR includes a number of improvements to managing usermods and their user interfaces. Specifically, this PR:

  • Updates the interal documentation to direct usermod authors (especially AIs!) to the out-of-tree template as the preferred way to contribute external functionality.
  • Cleans up and expands cdata.js to integrate fully with SCons, so the UI is only built when changed.
  • Adds support for a cdata.json manifest in usermods that allows them to submit their own UI pages for minification and zipping.
  • Permits usermods to use handleStaticContent() to serve their own zipped UI components.
  • Adopts the Usermod::_name member from the WLED-MM design.
  • Unrolls usermod settings to the top level settings page (inspired by the WLED-MM approach). Each usermod now has a static URL /settings/um/module_name for its own setting page, backed by a compatible default. That URL can be captued by handleStaticContent() to allow usermods to supply their own custom settings pages.

A sample cdata.json module is available in the external-ui branch of the wled-usermod-example repo.

The next step after this is to expand the new cdata.json manifest to support additional operations to integrate usermod UI fragments directly in to injection points in the main UI files.

Summary by CodeRabbit

  • New Features
    • Added dedicated usermod settings pages (including Hardware Interface) with per-usermod navigation.
    • Added support for named usermods and cdata.json manifests, including multi-header outputs.
  • Improvements
    • UI web-header generation is now incremental and waits reliably for generated assets.
    • Updated usermod documentation and the recommended out-of-tree workflow.
  • Bug Fixes
    • Improved settings page URL handling for reverse proxies, PIN-gated routing, and clearer unknown-user-mod behavior.
    • Enhanced saving/parsing of nested objects and array fields in usermod pages.

willmmiles and others added 6 commits July 18, 2026 17:16
Remove the in-tree example in favor of the out-of-tree example instead.
Encourage people to start their project out-of-tree instead of here.
Use SCons dependencies to only run npm if we need it.
Usermods can now provide a cdata.json manifest alongside their source,
declaring HTML/JS/CSS assets to be compiled into a gzipped C header at
build time.  The file schema is described in `tools/cdata.schema.json`

These manifests are processed concordantly with SCons build rules, so
the build system can naturally track dependencies and rebuild only when
a manifest or its source files change.

Includes numerous error handling fixes inside `cdata.js`.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Removes the static linkage from handleStaticContent() in wled_server.cpp
and adds a forward declaration in fcn_declare.h, making it available to
usermods that want to serve their own gzip-compressed static assets via
the same ETag/cache/gzip mechanism the core pages use.

This is intentionally minimal — the full server API refactor (wled::serve
namespace, header decomposition) remains deferred.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds a _name member (const char*, PROGMEM ok) to the Usermod protected
section, with getName().  Field names are intentionally compatible with
MoonModules' fork to allow MM usermods to work with minimal changes.

For mods that don't pass a name to the constructor, probe addToConfig()
with a small stack-local JsonDocument and take the first top-level key as
the name.  (H/T MoonModules for this idea.)

Key differences from MM's approach:
- No base class _enabled: there's no practical solution for backwards
  compatibility with usermods that don't provide their own handling.
- Explicit getter rather than direct field access

Also adds UsermodManager::lookup(const char*) to find a usermod by name.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Registers GET and POST handlers for /settings/um/*, placed before the
/settings catch-all so the more-specific prefix wins.

`settings_um.htm` is updated to support a per-usermod endpoint, so only
the one usermod's fields appear.  The global I2C/SPI bus section is
moved to `/settings/hw/if`.  The original `/settings/um` page is
also preserved for backwards compatibility with old UI integrations.

The global settings panel is expanded to generate links to the per-
usermod page.

GET /settings/mod/<name>:
  - Looks up the mod by name via UsermodManager::lookup(const char*)
  - Returns 404 if the mod isn't found
  - Otherwise serves settings_um.htm (JS uses window.location.pathname
    to filter the display to the named mod — implemented in next commit)
  - Respects the PIN gate by delegating to serveSettings() when locked

Usermods that register server.on("/settings/mod/mymod") during setup()
shadow this catch-all for their specific path via first-registered-wins
ordering.
@coderabbitai

coderabbitai Bot commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 0ef8111d-ec87-40c4-b041-6e2011cfca78

📥 Commits

Reviewing files that changed from the base of the PR and between 6f0c389 and 412165f.

📒 Files selected for processing (3)
  • wled00/fcn_declare.h
  • wled00/wled_server.cpp
  • wled00/xml.cpp
🚧 Files skipped from review as they are similar to previous changes (2)
  • wled00/wled_server.cpp
  • wled00/fcn_declare.h

Walkthrough

The PR adds incremental, depfile-driven UI generation with usermod manifests, exposes named usermod settings routes and navigation, updates per-usermod configuration saving, and revises usermod documentation toward out-of-tree templates.

Changes

Usermod UI integration

Layer / File(s) Summary
Manifest-driven UI generation
tools/cdata.js, tools/cdata.schema.json, tools/cdata-test.js
UI outputs use validated jobs with stale-input detection, depfile emission, CLI controls, and integration coverage.
Incremental PlatformIO wiring
pio-scripts/build_ui.py, pio-scripts/load_usermods.py, .gitignore
SCons registers cached UI commands, installs Node packages on demand, tracks generated-header dependencies, and receives usermod manifests.
Named usermod discovery and routes
wled00/fcn_declare.h, wled00/um_manager.cpp, wled00/wled_server.cpp
Usermods gain name storage and lookup support, while usermod and hardware-interface routes are added with PIN checks.
Dynamic settings navigation and saving
wled00/data/settings.htm, wled00/data/settings_um.htm, wled00/xml.cpp
Named usermod buttons, filtered settings views, nested configuration reconstruction, and JSON configuration posts are implemented.
Usermod template and repository guidance
AGENTS.md, usermods/readme.md, usermods/EXAMPLE/readme.md
Documentation moves toward out-of-tree templates and updated activation guidance, with the example pointing to a standalone repository.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant PlatformIO
  participant CdataCLI
  participant GeneratedHeaders
  participant Firmware
  PlatformIO->>CdataCLI: register depfile-driven UI build
  CdataCLI->>GeneratedHeaders: generate stale UI and usermod headers
  GeneratedHeaders->>Firmware: provide headers before compilation
Loading

Possibly related PRs

  • wled/WLED#4266: Both modify the HTML inlining and compression path in tools/cdata.js.
  • wled/WLED#4609: Both modify the usermod settings frontend and /json/cfg save flow.
  • wled/WLED#4669: Both modify the wrapped_ConfigureProjectLibBuilder flow in load_usermods.py.

Suggested labels: usermod, enhancement

Suggested reviewers: netmindz, softhack007

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 51.92% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title is concise and clearly matches the PR’s main theme of usermod system and static UI support improvements.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 9

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@AGENTS.md`:
- Around line 183-198: Update the MyUsermod example to initialize the base
Usermod name through its constructor using MyUsermod::_name, while preserving
the existing static name declaration and registration. Ensure instances have a
valid base name for named lookup and per-usermod settings routes.

In `@pio-scripts/build_ui.py`:
- Around line 45-68: Invalidate cached dependency graphs when directory contents
or manifest membership changes: in pio-scripts/build_ui.py lines 45-68, update
_depfile_structure_current or configuration flow to regenerate the depfile
unconditionally or compare a signature of manifest paths and directory entries;
in tools/cdata.js lines 528-558, make missing declared inputs mark the scan
stale or fail clearly instead of reusing the old header; in tools/cdata-test.js
lines 164-200, add consecutive-scan coverage for added, deleted, and renamed
HTML resources plus manifest-set changes. Include a small software FMEA/test
matrix covering stale UI artifacts, failure modes, mitigations, mtime/clock
causes, and typical WLED builds.

In `@tools/cdata.js`:
- Around line 658-664: Replace the NODE_ENV-based CLI guard around main() in
tools/cdata.js lines 658-664 with require.main === module so direct execution
still runs while imports remain side-effect-free. In tools/cdata-test.js lines
12-20, remove the global NODE_ENV mutation and the production-only child-process
environment override.
- Around line 501-523: Synchronize executable manifest validation with the
schema: in tools/cdata.js lines 501-523, validate operation and spec keys,
required fields, types, enums, and apply documented defaults before generation;
in tools/cdata.schema.json lines 27-80, ensure optional/default fields describe
output the generator can produce, including valid plaintext delimiters; in
tools/cdata-test.js lines 270-299, add coverage for omitted defaults, unknown
nested keys, invalid methods and filters, symbols, and plaintext delimiters.
- Around line 528-540: Update tools/cdata.js in jobInputs to include
package-lock.json for every job; update pio-scripts/build_ui.py in the UI build
dependency setup to use a lockfile digest stamp instead of the node_modules
mtime heuristic, or run npm ci on every UI command, ensuring npm ci precedes UI
and firmware builds; update tools/cdata-test.js to assert every depfile rule
contains package-lock.json.
- Around line 513-523: Constrain manifest paths to the usermod directory: in
tools/cdata.js lines 513-523, validate resolved output, srcDir, and spec.file
paths and reject absolute paths or paths escaping the permitted directory; in
tools/cdata.schema.json lines 32-40, require non-traversing relative srcDir and
output values, and in lines 55-58 constrain source filenames to remain under
srcDir; in tools/cdata-test.js lines 270-299, add rejection coverage for
traversal and absolute paths.

In `@wled00/data/settings_um.htm`:
- Around line 319-325: Update the per-mod save request in the modFilter branch
to send the complete current um configuration object rather than only the
selected modFilter section. Reuse the existing configuration state used by the
page so UsermodManager::readFromConfig() receives all usermod sections and
unrelated settings remain intact.

In `@wled00/um_manager.cpp`:
- Around line 21-24: In UsermodManager::setup, call each usermod’s setup()
before checking getName() and invoking probeNameFromConfig(). Preserve probing
only for usermods whose name remains null after initialization, ensuring
addToConfig() runs after setup-established state and dependencies are available.

In `@wled00/xml.cpp`:
- Around line 219-232: Update the per-mod name handling in the UsermodManager
loop to copy the result of mod->getName() from PROGMEM into a RAM buffer using
the appropriate platform-safe _P helper before scanning it. Preserve the
existing quote/backslash escaping and addUMBtn output, but iterate over the
copied RAM string rather than dereferencing the original pointer.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 007f8d10-673f-45b5-b320-1a57d57febbd

📥 Commits

Reviewing files that changed from the base of the PR and between a962116 and 2f7b323.

📒 Files selected for processing (17)
  • .gitignore
  • AGENTS.md
  • pio-scripts/build_ui.py
  • pio-scripts/load_usermods.py
  • tools/cdata-test.js
  • tools/cdata.js
  • tools/cdata.schema.json
  • usermods/EXAMPLE/library.json
  • usermods/EXAMPLE/readme.md
  • usermods/EXAMPLE/usermod_v2_example.cpp
  • usermods/readme.md
  • wled00/data/settings.htm
  • wled00/data/settings_um.htm
  • wled00/fcn_declare.h
  • wled00/um_manager.cpp
  • wled00/wled_server.cpp
  • wled00/xml.cpp
💤 Files with no reviewable changes (2)
  • usermods/EXAMPLE/library.json
  • usermods/EXAMPLE/usermod_v2_example.cpp

Comment thread AGENTS.md
Comment thread pio-scripts/build_ui.py Outdated
Comment thread tools/cdata.js
Comment thread tools/cdata.js Outdated
Comment thread tools/cdata.js Outdated
Comment thread tools/cdata.js
Comment on lines +658 to +664
// Don't run the build when imported by the test harness.
if (process.env.NODE_ENV !== 'test') {
main().catch((err) => {
console.error(err);
process.exitCode = 1;
});
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Use module identity—not NODE_ENV—to guard CLI execution.

An ambient NODE_ENV=test makes the build command exit successfully without generating anything. Use require.main === module so imports remain side-effect-free without disabling legitimate CLI builds.

  • tools/cdata.js#L658-L664: replace the environment check with if (require.main === module).
  • tools/cdata-test.js#L12-L20: remove the global environment mutation and production-only child override.
📍 Affects 2 files
  • tools/cdata.js#L658-L664 (this comment)
  • tools/cdata-test.js#L12-L20
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tools/cdata.js` around lines 658 - 664, Replace the NODE_ENV-based CLI guard
around main() in tools/cdata.js lines 658-664 with require.main === module so
direct execution still runs while imports remain side-effect-free. In
tools/cdata-test.js lines 12-20, remove the global NODE_ENV mutation and the
production-only child-process environment override.

Comment thread wled00/data/settings_um.htm
Comment thread wled00/um_manager.cpp
Comment on lines +21 to +24
void UsermodManager::setup() {
for (auto mod = DYNARRAY_BEGIN(usermods); mod < DYNARRAY_END(usermods); ++mod) {
if ((*mod)->getName() == nullptr) (*mod)->probeNameFromConfig(); // set _name from addToConfig() key if not provided by constructor
(*mod)->setup();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Probe the name only after the usermod is initialized.

probeNameFromConfig() calls the derived addToConfig() before setup(). Usermods that initialize configuration state or dependencies in setup() can crash or generate an invalid name.

Proposed fix
   for (auto mod = DYNARRAY_BEGIN(usermods); mod < DYNARRAY_END(usermods); ++mod) {
-    if ((*mod)->getName() == nullptr) (*mod)->probeNameFromConfig();
     (*mod)->setup();
+    if ((*mod)->getName() == nullptr) (*mod)->probeNameFromConfig();
   }

As per path instructions, verify weak lifecycle assumptions in apparently generated C++ code.

Also applies to: 118-122

🧰 Tools
🪛 Cppcheck (2.21.0)

[error] 22-22: Comparing pointers that point to different objects

(comparePointers)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@wled00/um_manager.cpp` around lines 21 - 24, In UsermodManager::setup, call
each usermod’s setup() before checking getName() and invoking
probeNameFromConfig(). Preserve probing only for usermods whose name remains
null after initialization, ensuring addToConfig() runs after setup-established
state and dependencies are available.

Source: Path instructions

Comment thread wled00/xml.cpp
Comment thread wled00/um_manager.cpp
const char* key = it->key().c_str();
if (!key || !key[0]) return;
char* buf = new char[strlen(key) + 1];
if (buf) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't this pointless? You are not using (std::nothrow).
Or did I get nothrow entirely wrong.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, nothrow is missing there.

willmmiles and others added 5 commits July 20, 2026 01:23
Remove redundant depfile analysis and ensure directory dependencies are
handled correctly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The minified/gzipped bytes the build emits depend on the exact versions
of clean-css, html-minifier-terser and web-resource-inliner, which are
pinned in package-lock.json. An `npm install` that bumps one of them
without touching package.json would previously reinstall node_modules
(build_ui.py already watches the lock for that) but leave every generated
header untouched, so stale output survived.

Add package-lock.json alongside package.json in jobInputs so it feeds
both the in-node staleness check and the emitted depfile, invalidating
all headers when the locked toolchain changes. A missing lock file stays
tolerated: isJobStale ignores absent inputs and the build system drops
non-existent depfile sources.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A cdata.json is a third-party build input, but its output/srcDir/spec
`file` fields were joined onto the usermod directory with no bounds
check, so a `..` sequence (or an absolute path) could read from, or
write to, anywhere on the build machine -- e.g. output "../../../../etc/x"
or file "../../../secret". That is a build-time path-traversal / arbitrary
file write, a supply-chain hazard when compiling someone else's usermod.

Add resolveWithin(), which resolves a manifest path against a base dir
(using path.resolve so absolute paths are caught, not silently
reinterpreted) and fails loudly if it escapes. Apply it to output and
srcDir (against the usermod folder) and to each spec file (against
srcDir). Spec objects and their `file` string are validated first so the
resolution has something well-formed to check.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Manifest spec fields reached codegen unvalidated, so a bad value became
invalid C++ instead of a clear error:

- A plaintext spec that omitted the raw-string delimiters emitted a bare
  `R"..."`, which has no parentheses and does not compile. specToChunk
  now fills in a safe matching delimiter pair (=====(...)=====) when the
  spec supplies neither, so plaintext "just works"; manifestToJobs
  rejects supplying only one of prepend/append (an unbalanced delimiter).
- 'name' is emitted verbatim as a C identifier, so a value with spaces or
  punctuation could break -- or inject into -- the header. It must now
  match a strict identifier.
- 'method' must be one of plaintext/gzip/binary and 'filter', if present,
  one of minify()'s filters, validated up front with manifest context
  rather than surfacing as an "Unknown method/filter" throw mid-build.

(Note: an omitted 'filter' is fine -- minify()'s default parameter makes
it "plain"; it does not reach minify() as undefined.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
tools/cdata.js (2)

168-170: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Use path.join for robust path construction.

While string concatenation with "/" usually works in Node.js on all platforms, using path.join is more idiomatic and ensures cross-platform consistency. jobInputs already correctly uses path.join for this same path.

💡 Proposed refactor
 async function specToChunk(srcDir, s) {
-  const buf = fs.readFileSync(srcDir + "/" + s.file);
+  const buf = fs.readFileSync(path.join(srcDir, s.file));
   let chunk = `\n// Autogenerated from ${srcDir}/${s.file}, do not edit!!\n`
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tools/cdata.js` around lines 168 - 170, Update specToChunk to construct the
input file path with path.join, matching the existing jobInputs approach, and
reuse that path for both fs.readFileSync and the autogenerated source comment
instead of concatenating srcDir and s.file with "/".

621-622: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Resolve package manifests relative to __dirname.

Currently, package.json and package-lock.json are resolved relative to the current working directory (process.cwd()). This works correctly when the script is invoked by SCons from the repository root. However, if a developer runs this script manually from a different directory (like a usermod subfolder), the staleness check will look for these files in that working directory, fail to find them, and silently ignore them due to the ENOENT catch in isJobStale.

Using __dirname ensures these files are always accurately monitored.

💡 Proposed refactor
 function jobInputs(job) {
-  const files = [__filename, 'package.json', 'package-lock.json'];
+  const files = [__filename, path.join(__dirname, '..', 'package.json'), path.join(__dirname, '..', 'package-lock.json')];
   const dirs = [];
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tools/cdata.js` around lines 621 - 622, Update jobInputs to construct
package.json and package-lock.json paths relative to __dirname, while preserving
__filename and the existing staleness-check behavior. Use the path utility
already available in the file rather than relying on process.cwd().
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@tools/cdata.js`:
- Around line 168-170: Update specToChunk to construct the input file path with
path.join, matching the existing jobInputs approach, and reuse that path for
both fs.readFileSync and the autogenerated source comment instead of
concatenating srcDir and s.file with "/".
- Around line 621-622: Update jobInputs to construct package.json and
package-lock.json paths relative to __dirname, while preserving __filename and
the existing staleness-check behavior. Use the path utility already available in
the file rather than relying on process.cwd().

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 2385046d-5fba-41a4-b336-c0791b6ab575

📥 Commits

Reviewing files that changed from the base of the PR and between 2f7b323 and 6f0c389.

📒 Files selected for processing (4)
  • AGENTS.md
  • pio-scripts/build_ui.py
  • tools/cdata-test.js
  • tools/cdata.js
🚧 Files skipped from review as they are similar to previous changes (1)
  • AGENTS.md

@netmindz

Copy link
Copy Markdown
Member

I'm not sure about the spec format. Do we really need users to define things like gzip and mini for each page individually? https://github.com/wled/wled-usermod-example/blob/9ccb6b30a8b049de7c493d0aa3a642bdbe8a7ad5/cdata.json#L10

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants