Files
ChuckandClaude Opus 5.5 8a0cce1aaf fix(web): mask the Config Editor's secrets; keep disabled plugins' rotation slot and Vegas exclusion; restore only missing plugins (#743)
* fix(web): mask the Config Editor's secrets like GET /config/secrets

The Config Editor tab (/partials/raw-json) filled its config_secrets.json
editor with the file as it is on disk. GET /api/v3/config/secrets masks every
value because the interface is reachable without a login by default, but
this page handed the same credentials (GitHub token, Home Assistant token,
plugin API keys) to anyone who loaded it. The masked-save path in
save_raw_secrets_config was written for a masked editor and never got one.

_load_raw_json_partial now masks the section with mask_all_secret_values
after strip_auth_section, exactly as the GET does. Saving it back is safe:
save_raw_secrets_config drops the masks (strip_masked_values) and merges the
rest onto the stored file (deep_merge), so an untouched secret stays as it
is and a replaced mask is the only value that changes.

The config.json editor is left as it is. Its save (save_raw_main_config)
writes the posted object verbatim, with no mask stripping or merge, so a
masked main editor would write the bullets over any credential it holds.
Masking it needs a merge-on-save of its own first.

Tests: TestConfigEditorRoundTrip renders the partial over a real
ConfigManager, checks no real value is in the editor, and posts the editor
back unchanged (the file is identical) and with one mask replaced (only that
value changes).

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

* fix(web): keep disabled plugins in the saved rotation order and Vegas exclusions

PluginOrderList draws one row per enabled plugin and, once drawn, rewrites
its hidden inputs (plugin_rotation_order, vegas_plugin_order,
vegas_excluded_plugins) from those rows. A disabled plugin has no row, so
merely opening the Display or Rotation & Durations tab took it out of the
inputs, and the next save of that form stored the lists without it. Exclude
Clock from Vegas, disable it, change the brightness, re-enable it: Clock was
scrolling in Vegas again and had moved to the end of the rotation.

syncInputs now keeps the saved ids that have no row. In the order, each one
keeps its saved slot and the rows fill the other slots in their current
order, with rows not in the saved order last, as before. In the exclusions
they follow the unchecked rows. Only string ids are carried over, once each:
/config/main refuses a list holding anything else, which would block every
later save of the tab.

Tests: test/js/unit/test_plugin_order_list.js runs the shipped widget in a vm
with a fake DOM (draw, reorder, include/exclude, the rotation list, junk ids)
and is in run_all.js and the README. The durations DOM suite now reads only
its own rows' ids from the input, since a rig's saved order can hold others.

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

* fix(web): a restore reinstalls only the plugins that are missing

POST /backup/restore with reinstall_plugins (the "Reinstall missing plugins"
box) passed every plugin in the backup's plugins.json to
install_plugin(). That replaces an installed copy with a fresh download, so
a restore onto the same device re-downloaded every plugin inside the
request. A plugin installed from its own URL is not in the registry, so its
install returned False, plugins_failed set success to False, and the restore
answered 500 "Restore incomplete ... plugins not reinstalled: <id>" (shown
as "Restore failed") with the plugin still installed and the config
restored.

Each plugin is now looked up first with the store's _existing_install, the
same lookup install_plugin makes to decide a copy exists: the id, or an id
the registry proves is the same plugin (aliases, the plugin_path name), and
never a bare ledmatrix-<id> folder (#686). One that is installed is recorded
in result.skipped as "plugin:<id> (installed)", which the page lists under
Skipped; a missing one is installed as before. The list_installed_plugins
docstring said every listed plugin is reinstalled and now says otherwise.

Tests: TestInstalledPluginsAreNotReinstalled, with a mocked store (installed
skipped, missing installed; an installed plugin the store can't install is
not a failure) and with a real PluginStoreManager (a registry alias and a
third-party install are skipped, a missing plugin installed).

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

* fix(web): /config/main answers malformed JSON with a 400

save_main_config read a JSON body with request.get_json(), which raises
Werkzeug's BadRequest for a body that does not parse (or an empty one sent as
application/json). That happened inside the handler's try, so the
catch-all answered 500 CONFIG_SAVE_FAILED with "Check file permissions on
config directory" among its suggested fixes and logged a traceback at
ERROR, for what was the caller's mistake.

It now reads with get_json(silent=True), as save_raw_main_config does, and
answers a sent-but-unparseable body with the same 400
{"status": "error", "message": "Invalid JSON in request body"}. An empty
JSON body falls through to the existing 400 "No data provided". The change
is limited to the lines that read the body.

Tests: TestMalformedBody in test_api_v3_partial_main_save.py (the 400 and its
shape, identical to /config/raw/main's, and nothing saved; the empty body).

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

* fix(web): a restore that brings back fonts clears the font catalog cache

GET /api/v3/fonts/catalog caches its answer as fonts_catalog for five
minutes. Font upload and delete clear that entry (fonts.py), but
POST /backup/restore copies user fonts into assets/fonts without touching
it, so restored fonts were missing from the Fonts tab and every font picker
until the cache expired.

backup_restore now clears fonts_catalog when the result lists restored fonts
(restore_backup records them as "fonts (<count>)"). A restore that restored
no fonts leaves the cache alone.

Tests: TestFontsCatalogCache in test_api_v3_backup_restore.py.

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

* fix(web): drop uninstalled plugins from the carried-over order and exclusions

2b34f254 made the plugin order list keep every saved id that has no row,
so a disabled plugin keeps its rotation slot and Vegas exclusion. That
also kept the ids of plugins that have since been uninstalled: they stayed
in plugin_rotation_order and vegas_excluded_plugins for good, where before
the next save of the tab dropped them.

The widget already fetches /api/v3/plugins/installed, every installed plugin
with its enabled flag, and draws only the enabled ones. It now keeps that
response's full id set and carries over only saved ids that are installed
but have no row (disabled). An id outside the set is dropped, as before.
With no list, nothing is dropped: a failed request draws no rows and leaves
the inputs as saved, and the carry-over keeps everything if the set was
never filled.

Tests: test/js/unit/test_plugin_order_list.js adds a disabled plugin kept
while an uninstalled one is dropped (order and exclusions; fails on
2b34f254), and a failed plugin list leaving both inputs as saved. The
CHANGELOG bullet and the README row say so.

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

* test(js): register the order-list suite apart from other branches' suites

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 22:30:28 -04:00
..

LEDMatrix Widget Development Guide

Widgets are the controls the web UI draws for fields in a plugin's config_schema.json. A field picks one with "x-widget": "<name>". This directory holds the built-in widgets, the registry they register with, and the loader for widgets a plugin ships itself.

The page loads every file here as one bundle, /assets/widgets.js, built by web_interface/widget_bundle.py in the order set by BUNDLE_ORDER there.

Built-in widgets

x-widget Field type What it draws
text-input string Text field with optional length limits
textarea string Multi-line text
email-input string Email field with format check
url-input string URL field with format check
password-input string Password field with show/hide toggle
select-dropdown string Dropdown for an enum
radio-group string Radio buttons for an enum
date-picker string Date input
time-picker string Time input, HH:MM (24-hour)
color-picker string or array Colour picker; hex string, or [r, g, b] on an array field
font-selector string Font from assets/fonts/ (TTF and BDF), fetched from the API
timezone-selector string IANA timezone, grouped by region
file-upload-single string One image upload; stores the uploaded file's relative path
google-oauth string Step 2 of the calendar plugin's Google sign-in
plugin-file-manager null Inline file manager driven by the plugin's web_ui_actions
json-file-manager null JSON data-file manager driven by web_ui_actions
toggle-switch boolean On/off switch
slider integer / number Range slider using minimum / maximum
number-input integer / number Number field with min/max check
file-upload array Multi-image upload with preview, delete and scheduling
checkbox-group array Checkboxes for an array of enum items
day-selector array Days of the week
custom-feeds array RSS feed table with per-feed logo upload
array-table array Table editor for an array of objects
google-calendar-picker array Calendars from the user's Google account
schedule-picker object Enable toggle, global/per-day mode and times
time-range object Start and end time pair
style-editor object One row per display element: font, size, colour, alignment, offsets

Other files here:

File Purpose
registry.js window.LEDMatrixWidgets: register(), get()
base-widget.js Shared helpers (escapeHtml, sanitizeId) other widgets use
notification.js Toast notifications; owns window.showNotification
plugin-order-list.js Drag-and-drop plugin order list used by the Display and Durations tabs (window.PluginOrderList)
plugin-loader.js Loads a plugin-supplied widget on demand
example-color-picker.js Example custom widget. Not bundled: it registers color-picker and would replace the real one

Each widget file's header comment gives its schema options. The sections below cover the ones that need more than a line.

file-upload

{
  "type": "array",
  "x-widget": "file-upload",
  "x-upload-config": {
    "plugin_id": "my-plugin",
    "max_files": 10,
    "max_size_mb": 5,
    "allowed_types": ["image/png", "image/jpeg", "image/bmp", "image/gif"]
  }
}

file-upload-single

Uploads one image to the plugin's asset folder (assets/plugins/<plugin_id>/uploads/) and stores the returned relative path in a string field. plugin_id is filled in from the page; don't put it in the schema. Use it for per-row images inside an array-table.

{
  "image_path": {
    "type": "string",
    "x-widget": "file-upload-single",
    "x-upload-config": {
      "allowed_types": ["image/png", "image/jpeg", "image/bmp", "image/gif"],
      "max_size_mb": 5
    }
  }
}

checkbox-group

{
  "type": "array",
  "x-widget": "checkbox-group",
  "items": {"type": "string", "enum": ["option1", "option2", "option3"]},
  "x-options": {"labels": {"option1": "Option 1 Label", "option2": "Option 2 Label"}}
}

custom-feeds

{
  "type": "array",
  "x-widget": "custom-feeds",
  "items": {
    "type": "object",
    "properties": {
      "name": {"type": "string"},
      "url": {"type": "string", "format": "uri"},
      "enabled": {"type": "boolean"},
      "logo": {"type": "object"}
    }
  },
  "maxItems": 50
}

plugin-file-manager

A card grid, upload zone, create/delete dialogs and a table editor for the plugin's data files, rendered inline. File operations call /api/v3/plugins/action as soon as the user acts; they are not part of Save Configuration. plugin_id is filled in from the page.

{
  "file_manager": {
    "type": "null",
    "title": "Data Files",
    "x-widget": "plugin-file-manager",
    "x-widget-config": {
      "actions": {
        "list": "list-files",
        "get": "get-file",
        "save": "save-file",
        "upload": "upload-file",
        "delete": "delete-file",
        "create": "create-file",
        "toggle": "toggle-category"
      },
      "upload_hint": "JSON files with day numbers 1–365 as keys",
      "directory_label": "my_data/",
      "create_fields": [
        {"key": "category_name", "label": "Category Name",
         "pattern": "^[a-z0-9_]+$", "hint": "Lowercase letters, numbers, underscores"},
        {"key": "display_name", "label": "Display Name", "hint": "Optional"}
      ]
    }
  }
}

The action ids refer to entries in the plugin's web_ui_actions (docs/PLUGIN_WEB_UI_ACTIONS.md). list is required: without it the widget stays on its loading state. Leave out any other action to hide its control. The editor shows a table when a file is an object of objects with the same keys, otherwise a JSON text area.

Schema keywords the form understands

These work on any field, with or without a widget.

Option labels: x-options.labels

A plain enum renders as a dropdown whose option text is the value with underscores replaced and title case applied (day_first → "Day First"). x-options.labels sets the visible text instead:

{
  "date_format": {
    "type": "string",
    "enum": ["abbrev", "numeric", "day_first"],
    "default": "abbrev",
    "x-options": {"labels": {"abbrev": "Sep 19", "numeric": "9/19", "day_first": "19 Sep"}}
  }
}

Labels are display only; the stored value is still the enum value. The map may be partial. Older cores ignore x-options and show the fallback text. array-table columns accept the same x-options.labels, but their fallback is the raw value (so a ticker symbol aapl stays aapl).

Advanced settings: x-advanced

"x-advanced": true on a top-level, non-object property moves it into a collapsed Advanced Settings section at the bottom of the plugin's page. Use it for settings most users never change (timeouts, cache TTLs, styling overrides); keep anything needed to get the plugin working in the main form. The settings search still finds and expands advanced fields. It is ignored on object properties and by older cores.

Hidden fields: x-display: "hidden"

"x-display": "hidden" keeps a property in the schema without drawing a control, for a deprecated key that existing configs still carry or an internal value such as a generated row id.

  • Not rendered at any depth: top level, inside an object section, or as a column or row-editor field of an array of objects. Hidden fields are left out of Advanced Settings and the settings search.
  • Saving the form never changes a hidden value. Array rows carry it through; a new row gets no value.
  • A JSON POST /api/v3/plugins/config can still set it.
  • Older cores ignore the flag and render the field.

Creating a custom widget

1. Write the widget

Put it in your plugin's widgets/ directory as widgets/<name>.js. That directory is the only place the core serves plugin widgets from.

(function () {
    'use strict';
    if (typeof window.LEDMatrixWidgets === 'undefined') {
        console.error('LEDMatrixWidgets registry not found');
        return;
    }

    const sanitizeId = (id) => String(id).replace(/[^a-zA-Z0-9_-]/g, '_');
    // The page's shared escaper covers HTML content and quoted attribute
    // values. A textContent/innerHTML round trip leaves quotes alone, so it
    // is not safe inside value="...".
    const escapeHtml = (text) => window.LEDEscape.html(text);

    window.LEDMatrixWidgets.register('my-custom-widget', {
        name: 'My Custom Widget',
        version: '1.0.0',

        render: function (container, config, value, options) {
            const fieldId = options.fieldId || container.id;
            const safeId = sanitizeId(fieldId);
            container.innerHTML = `
                <input type="text" id="${safeId}_input"
                       value="${escapeHtml(value || '')}"
                       class="w-full px-3 py-2 border border-gray-300 rounded">`;
            const input = container.querySelector(`#${safeId}_input`);
            input.addEventListener('change', (e) => {
                this.handlers.onChange(fieldId, e.target.value);
            });
        },

        getValue: function (fieldId) {
            const input = document.querySelector(`#${sanitizeId(fieldId)}_input`);
            return input ? input.value : null;
        },

        setValue: function (fieldId, value) {
            const input = document.querySelector(`#${sanitizeId(fieldId)}_input`);
            if (input) input.value = value || '';
        },

        handlers: {
            onChange: function (fieldId, value) {
                document.dispatchEvent(new CustomEvent('widget-change', {
                    detail: { fieldId, value }, bubbles: true
                }));
            }
        }
    });
})();

example-color-picker.js is a longer example.

2. Reference it in the schema

{
  "properties": {
    "my_field": {"type": "string", "x-widget": "my-custom-widget", "default": ""}
  }
}

3. Declare it in manifest.json

The manifest is the allowlist: a widget is served only if the plugin declares it.

{
  "widgets": [
    {"name": "my-custom-widget", "script": "my-custom-widget.js",
     "description": "What this widget is for"}
  ]
}

name is what x-widget uses. script is optional, defaults to <name>.js, and must be a plain filename directly inside widgets/. Both are validated against schema/manifest_schema.json.

4. How it loads

When the config form reaches a field whose x-widget is not a built-in:

  1. If the name is already registered, that widget renders the field.
  2. Otherwise the page fetches /static/plugin-widgets/<plugin-id>/<name>.js (serve_plugin_widget in web_interface/blueprints/pages_v3.py), which serves the declared script from the plugin's widgets/ directory.
  3. The widget's render() draws the field.

The fetch is a dynamic import(), so the file must parse as an ES module. An IIFE does; modules are strict mode, and a return outside a function is a syntax error.

If the widget fails to load (not declared, file missing, script throws, or it never calls register), the field falls back to a plain text input holding the current value, so a broken widget never costs the user their setting.

Limitation: only string fields without an enum take this path. plugin_config.html renders object, array, boolean, integer, number and enum fields with its own branches, which only know the built-in names, so a plugin's own widget on one of those is ignored.

Widget API

{
    name: string,        // human-readable name
    version: string,
    render: function,    // required: render(container, config, value, options)
    getValue: function,  // optional: getValue(fieldId) -> value
    setValue: function,  // optional: setValue(fieldId, value)
    handlers: object     // optional: e.g. onChange(fieldId, value)
}

render() arguments:

  • container — element to render into
  • config — the field's schema, including x-widget-config / x-options
  • value — current value
  • options — fieldId, pluginId, fullKey (dotted path of the field)

Guidelines

  • Escape values before putting them in HTML (textContent or an escapeHtml helper).
  • Sanitise fieldId before using it in an id, getElementById() or a CSS selector: allow only [A-Za-z0-9_-]. BaseWidget has sanitizeId().
  • Associate labels with inputs and keep the widget usable from the keyboard.
  • Debounce events that fire on every keystroke.
  • Style with the Tailwind utility classes the core UI already uses (px-3, border-gray-300, rounded, text-sm, ...). static/v3/tailwind.css is generated from the core templates and JS, so it only contains classes core uses, plus everything the hand-written stylesheet defined before the build. A rarer class a plugin widget needs belongs in a <style> the widget injects, since core can't scan plugin repos.

Troubleshooting

Widget not loading

  • Check the browser console.
  • The widget must be declared in manifest.json and live in widgets/.
  • The name passed to register() must match x-widget.
  • The field must be a non-enum string (see the limitation above).

Value not saving

  • Fire a widget-change event on change.
  • getValue() must return the type the schema expects.
  • Check the field name matches the schema property.