diff --git a/CHANGELOG.md b/CHANGELOG.md index cc9a0c8d..d8e6200b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,486 @@ accepts both, but the store flags the old spelling as deprecated ## Unreleased +### Scroll speed: a panel slower than its refresh cap is reported + +- Scroll speeds are solved against `limit_refresh_rate_hz`, so a panel that + cannot reach its cap ran every scroll slow by the shortfall, with no sign + why (one Pi 4 on a 120 Hz cap refreshed at ~110 Hz: 60 px/s ran at 55). + Once the display has measured the real rate over three windows of + scrolling, a panel more than 3% short of the cap is logged once, as a + warning from `src.common.frame_timing` that names a cap it can hold (a + multiple of 10, 5% under the measurement). The Display tab shows the same + under Limit Refresh Rate, with a button that fills it in, from the new + `GET /api/v3/config/refresh-rate`. Not checked in the emulator or on the + fallback canvas. +- The frame-stats file records `planned_refresh_hz` (additive), and the + scroll-speed advice behind the Vegas slider ignores a measurement written + under a different cap. Until now, after the cap changed, the slider kept + advising from the old rate until the display restarted. +- New in `src.common.scroll_config`: `refresh_shortfall()`, `holdable_cap()` + and `describe_refresh_shortfall()`. + +## 3.8.3 + +Fresh installs on Raspberry Pi OS Lite work again: 3.8.2's installer reported +a desktop on Lite and stopped (#780, #781). Also lighter cached ESPN +scoreboard windows (#749), a Vegas static-pause fix and a store reinstall +restart prompt (#753), and display modes a plugin computes from its config in +the web UI (#769). + +### Install + +- The installer no longer stops Raspberry Pi OS Lite with "Desktop environment + detected". Its package check searched whole `dpkg -l` lines, and `.*kde` + matched inside `libblockdev` ("bloc**kde**v"), which Lite ships; it now + matches installed package names from their start (#780). Desktop + metapackages and session managers are matched as whole names, so + `gnome-keyring` and similar standalone parts no longer count, and the list + now includes Raspberry Pi OS Trixie's `rpd-wayland-core` / `rpd-x-core` + (which replaced `raspberrypi-ui-mods`), Debian's `task-*-desktop` and + multi-arch names (#781). +- Only a **running** desktop stops the install: a display manager that + `systemctl is-active` reports (`display-manager`, lightdm, gdm, sddm, + lxdm), with directions to boot to the console instead. Desktop packages or + session files on a Pi that boots to the console print a warning and the + install continues (#781). + +### Fixed + +- A Vegas static pause no longer ends at once for a plugin whose + `display_duration` is not a number (a string such as `"20"` or `null` from + config.json, as clock-simple, calendar and countdown return it). The pause + reads the duration the way the rotation does, with the same fallbacks: 30 s + for anything that is not a number, 15 s for zero or less. The helper moved + from `display_controller._finite_seconds` to `base_plugin.finite_seconds`, + unchanged (#753). +- Reinstalling an enabled plugin from the store now asks for a restart for + plugins that install under their manifest id (Weather as + `ledmatrix-weather`, Music, Stocks, Leaderboard): the route checked the + enabled flag under the registry id (#753). +- `/display/modes`, the on-demand dialog and `on-demand/start` by mode see the + modes a plugin computes from its config (soccer-scoreboard's custom + leagues), which no manifest can list. The display records the modes it + registered in the runtime snapshot, and the web catalog prefers them while + the plugin is loaded, falling back to the manifest otherwise. No manifest or + plugin change needed (#769, fixes #668). + +### Performance + +- `BackgroundDataService` drops the parts of an ESPN `/scoreboard` response no + scoreboard reads (stat leaders, athlete cards, links, headlines, highlights, + geo broadcasts) before caching it (`src/common/espn_payload.py`, + core-internal). Measured on one Pi (hdpi), the five scoreboard windows went + from 10.6 MB to 3.0 MB of JSON and ~40 MB to ~12 MB of parsed objects. + `submit_fetch_request(slim_payload=False)` caches a response whole (#749). + +### Tooling + +- `test/test_sports_helpers.py`'s parity tests pass again with + `LEDMATRIX_PLUGINS` set. The scoreboards deleted their copies of the + `sports_helpers` bodies and constants when they adopted `SportsHelpersMixin` + (ledmatrix-plugins #563/#564), and the 19 tests still expected them. A copy + that is gone now counts as adopted when the plugin imports + `src.common.sports_helpers`, as the stage 3/4 and game-over parity tests + already do; a copy that remains must still match. (#777) +- `src/common/README.md` lists `espn_payload`, which + `test_common_readme_lists_every_module` requires (#782). + +### Dead code removed, unused plugin APIs deprecated + +An over-engineering audit of the whole tree. Every symbol below was checked +against core, the plugin monorepo and all eight third-party plugins in +`plugins.json` before it went. Nothing a plugin imports was removed; +plugin-facing methods only get `@deprecated` (see below). + +- **Deprecated for removal in 3.10.0** (warn once per process, in + `journalctl -u ledmatrix`). No plugin in core, the monorepo or the registry + calls them. `docs/DEPRECATIONS_3.8.md` is the regenerated scan, which + `scripts/plugin_api_usage.py` now runs for these owners too: + - `LogoDownloader`: the bulk-download and RGBA-conversion methods + (`fetch_teams_data`, `extract_teams_from_data`, + `download_missing_logos_for_league`, `download_all_ncaa_football_logos`, + `download_all_missing_logos`, `convert_image_to_rgba`, + `convert_all_logos_to_rgba`). `download_missing_logo()` stays. + - `ConfigManager`: `rollback_config`, `list_backups`, + `validate_config_file`, `get_secret`, `cleanup_orphaned_plugin_configs`, + `validate_all_plugin_configs`. + - `APIHelper`: `fetch_espn_scoreboard`/`_standings`/`_rankings`, + `set_cache`, `get_cache`, `set_rate_limit`, `get_request_stats`. `get()` + stays. + - `BackgroundDataService`: `get_result`, `is_request_complete`, + `get_request_status` (pass `callback=` to `submit_fetch_request()`). + - `PluginManager`: `get_all_plugins`, `get_plugin_info`, + `get_all_plugin_info`, `get_plugin_display_modes`, `find_plugin_for_mode`. + `PluginStateManager`: `is_loaded`, `is_running`, `is_error`, + `get_last_update`, `get_error_info`, `get_state_info`. + - `CacheManager.load_cache`, `CacheManager.generate_sport_cache_key`, + `FontManager.measure_text`, `FontManager.get_native_bdf_size`, + `BaseOddsManager.get_odds_for_games`, `BaseOddsManager.format_odds_summary`, + `DynamicTeamResolver.get_available_dynamic_teams`, + `DynamicTeamResolver.is_dynamic_team`, `PluginTestCase`. +- **Removed (core-internal, no caller):** + - `src/cache/cache_metrics.py` + - Vegas status/stats plumbing that nothing read (`get_status`, + `get_current_scroll_info`, `get_buffer_status`, `VegasModeConfig.to_dict`) + - the sync "new cycle" message, which no follower ever handled (followers + now ignore any message type they don't know) + - unused `OperationType` members, `PluginOperation.from_dict`, + `cancel_operation` + - the test-only `PluginCatalog` readers + - `IPC *Args.to_dict` and `client.ping()` + - `_parse_form_value` + - `CacheStrategyProtocol` + - `ErrorAggregator.on_pattern_detected` and `clear_old_records` + - the duplicate `create_error_response`/`create_success_response` +- **Web UI:** + - `json-file-manager.js` was never mounted: the schema widget renders the + plugin's own file manager in an iframe. + - `example-color-picker.js` was a docs example; `utils/error_handler.js` had + one fallback caller. + - The 29 one-line `escapeHtml` shims now call `window.LEDEscape` directly. + - Four uncalled `PluginAPI` methods are gone. + - `window.escapeHtml`, `BaseWidget` and every widget name are unchanged. +- **Scripts and dependencies:** + - One-off scripts removed: `add_defaults_to_schemas.py`, + `analyze_plugin_schemas.py`, `test_captive_portal.sh`, + `verify_wifi_before_testing.sh`, `dev/run_emulator.sh` (use + `python3 run.py -e`), `update_plugin_repos.py` (use + `git -C ../ledmatrix-plugins pull`). + - Unused pins dropped: `markupsafe` (Flask still installs it) and + `pytest-mock`. + +## 3.8.2 + +The display hands freed memory back to the OS (#774), and sports consolidation +family 6: `src.common.sports_favorites`, which the scoreboards adopt by +flooring on 3.8.2 (#775). + +### The display hands freed memory back to the OS + +The display process's resident memory climbed in steps for hours while the +data it held stayed flat: glibc keeps what Python frees in per-thread malloc +arenas and returns little of it. `src/malloc_tuning.py` (new, standard library +only, a no-op off Linux/glibc) does two things in-process, so it reaches +devices without re-running the installer: + +- **Arena cap at start-up.** `run.py` calls `mallopt(M_ARENA_MAX, 2)` before any + thread exists, the same cap as the unit's `Environment=MALLOC_ARENA_MAX=2`. + Units installed before that line never got it (systemd runs the copy in + `/etc/systemd/system`); a `MALLOC_ARENA_MAX` in the environment still wins. +- **`malloc_trim(0)` between screens**, at most every 5 minutes, from the top of + the render loop where no frame is being drawn. Measured on a Pi 4: 2-11 ms + per call. + +On ledpi (Pi 4, 192x48, Vegas on, nine plugins, a unit without +`MALLOC_ARENA_MAX`), alternated main / branch / branch / main arms of 2.5 h: +two hours in, resident memory was 551 MB on main (the second main arm was +already at 651 MB after 1 h 44 min) against 412 and 386 MB with this change, +and the 20-minute frame soaks came out at 0.147-0.165% late against main's +0.151-0.188%. + +### New modules + +- `src/common/sports_favorites.py` -- sports consolidation family 6, once the + plugins made `_is_favorite_game` (seven bodies), `_select_games_for_display` + (two) and `_select_recent_games_for_display` (three) one each: + `SportsFavoritesMixin` (`SportsCore`: `_is_favorite_game`, `_favorite_code`), + `SportsUpcomingFavoritesMixin` and `SportsRecentFavoritesMixin` (the + favourites-only picks). Each side of a game is named by the 3.5.0 + `_favorite_key` seam and compared with `favorite_teams` stripped and + upper-cased; nrl overrides the key with the ESPN team id. Only a game with an + id can be a duplicate. A plugin may inherit the mixins once it floors on + 3.8.2, and deletes its copies then. (#775) + +## 3.8.1 + +Smooth scrolling at the slower speeds, and the fixes and performance work +since 3.8.0. Highlights: the default 50 px/s and every other held-frame speed +now scroll cleanly (below), Raspberry Pi OS Bookworm is supported alongside +Trixie, updates refresh the systemd units, the display control socket gains +stages 2 and 3, the shared fetch service lands (stages 1 and 2), and a run of +web UI and Plugin Manager fixes. One new module is for plugins: +`src.common.sports_game_over` (sports family 5), which the scoreboards adopt +by flooring on 3.8.1; the other new modules are core-internal and set no +`ledmatrix_min_version` floor. + +### Scroll speed + +These two entries were the reason for this release: on 3.8.0 a slow scroll +either stepped or showed a half-pixel tear across the middle of the panel, +so only speeds of one pixel per refresh looked right. + +- The Vegas Scroll Speed slider now says what the panel will do with the speed + it is on, and offers the nearest smooth ones to click. Only speeds that advance + a whole number of pixels per refresh look smooth, and which those are depends + on the panel (`GET /api/v3/config/scroll-speed-advice`, built on + `scroll_config.speed_advice()`; it uses the refresh the display measured, not + the `limit_refresh_rate_hz` cap). The slider steps by 1 px/s instead of 5. +- The default 50 px/s no longer snaps to a stepped 48 px/s (2 px every 5 + refreshes, 24 fps) on a 120 Hz panel: `solve_crisp()` now prefers 60 or 40 px/s, + which move one pixel at a time. 100 Hz panels are unaffected. (#710) +- A held-frame scroll (one pixel every two or more refreshes, such as 50 or + 60 px/s on a 100-120 Hz panel) no longer shows a half-pixel step across the + middle of the panel. Scan-order compensation ran only at one frame per + refresh; a held frame is now presented as a sequence of swaps + (`scan_order.refresh_plan()`), so the half of the panel that scans later + steps one refresh after the rest. It is skipped when a blit takes more than + half a refresh, since the second blit has to land before the next vsync. + (#711) + +### Web UI: the Display tab is an ES-module page, with a page-visibility service (stage 4) + +- New `static/v3/js/core/visibility.js`: each page module gets + `ctx.visibility` with `whileVisible(start, stop)`, `every(ms, fn)` and + `isVisible()`. Work registered there runs only while the page's tab is the + active tab and the browser tab is visible, and ends when the page is + swapped out, with no teardown code in the page. It reads the active tab + from `window.LEDVisibility`, so it agrees with the classic partials that + still use that directly. The page registry gained a `mountContext` option + for services bound to one mounted page. +- The Display tab's inline scripts are now `static/v3/js/pages/display.js`. + The partial has no inline script, `onclick` or `onchange` any more. The + multi-display sync status poll (every 5 s) runs through + `ctx.visibility.every`; the status and scroll-speed hint requests go + through `core/api.js` with the page's abort signal, and so does the Vegas + order widget's plugin-list request. +- Behaviour differences: with sync on, opening the tab asks for the status + once instead of twice, and a Display tab loaded while not on screen waits + until it is. A login redirect during a poll no longer flashes "Sync status + unavailable". The `window.syncStatusInterval` timer id is gone (nothing + read it). A pending scroll-hint request or widget retry is dropped when + the partial is swapped out. +- `window.updateSyncUI` keeps working as a deprecated alias through + `window.LEDMatrix` (one console warning). +- New suites `test/js/dom/test_visibility_service.js` and + `dom/test_display_page.js`; `unit/test_display_partial_ids.js` imports the + module instead of slicing the template. + +### Plugins ask for the screen in-process: `request_on_demand()` / `end_on_demand()` + +The in-process way in that stage 5 of the control socket needed +(`docs/IPC_CONTROL_SOCKET.md`, "Plugins in the display process"). + +- **`BasePlugin.request_on_demand(mode=None, duration=None, pinned=False)`** + shows the plugin now, and **`BasePlugin.end_on_demand()`** gives the + screen back. Both are safe from any thread (an MQTT callback, a timer + thread): `PluginManager.request_on_demand()` / `end_on_demand()` hand the + request to `DisplayController.submit_plugin_on_demand()`, which only + queues it (at most 32) and wakes the render thread through the control + socket's flag (`ControlServer.wake()`). The render thread applies it with + the socket's commands, through the same handler as a web on-demand + request, so it lands within a frame rather than on the mailbox's + once-a-second look. Both return the request id, or `None` when no display + runs in the process (the web interface, `scripts/check_plugin.py`) or the + queue is full. +- **A plugin's stop ends only its own session.** A mailbox stop still ends + any session, whoever started it. +- **Older cores.** Plugins detect the methods with `hasattr` and write the + `display_on_demand_request` mailbox when they are missing or answer + `None`; the pattern is in `docs/PLUGIN_API_REFERENCE.md` ("On-demand + display"). The display still reads the mailbox for plugins that write it. + +### Web UI: Schedule and General are ES-module pages (stage 3) + +- The Schedule and General tabs follow stage 2 (#727): their inline + ` - diff --git a/web_interface/templates/v3/partials/display.html b/web_interface/templates/v3/partials/display.html index bb3909b6..4c3a0e8e 100644 --- a/web_interface/templates/v3/partials/display.html +++ b/web_interface/templates/v3/partials/display.html @@ -1,5 +1,5 @@ {% import 'v3/partials/_macros.html' as ui %} -
+

Display Settings

Configure LED matrix hardware settings and display options.

@@ -168,7 +168,8 @@ class="w-full bg-gray-100 hover:bg-gray-200 px-4 py-3 flex items-center justify-between text-left transition-colors rounded-t-lg" aria-controls="display-section-advanced-hardware" aria-expanded="false" - onclick="toggleSection('display-section-advanced-hardware')"> + data-action="toggle-section" + data-section="display-section-advanced-hardware">

Advanced Hardware & Display Options (15) @@ -318,6 +319,7 @@ min="0" max="1000" class="form-control"> +

@@ -394,42 +396,6 @@
- - @@ -830,7 +796,7 @@ With this off a live game takes over the whole display with the full-screen scor
- @@ -879,262 +845,3 @@ With this off a live game takes over the whole display with the full-screen scor
- - diff --git a/web_interface/templates/v3/partials/general.html b/web_interface/templates/v3/partials/general.html index b3776426..5f4ab099 100644 --- a/web_interface/templates/v3/partials/general.html +++ b/web_interface/templates/v3/partials/general.html @@ -1,4 +1,9 @@ {% import 'v3/partials/_macros.html' as ui %} +{# No inline script: static/v3/js/pages/general.js draws the timezone picker + and runs the Security section's forms and buttons (data-action). The page + registry (static/v3/js/core/registry.js) starts it when this root appears + and stops it when the partial is swapped away. #} +

General Settings

@@ -88,7 +93,7 @@
-
+
@@ -102,32 +107,6 @@ max="200" class="form-control">
-
@@ -216,7 +195,7 @@ the password inputs keep their own labels. #} -
+ {% if web_login.enabled %}
@@ -251,7 +230,7 @@ {% if web_login.enabled %}

Turn login off

- +
created {{ (token.created_at or '')[:10] }}
@@ -289,7 +267,7 @@

No tokens yet.

{% endfor %}
- +
@@ -312,127 +290,5 @@
- {% endif %} + diff --git a/web_interface/templates/v3/partials/logs.html b/web_interface/templates/v3/partials/logs.html index b7471286..f04eef74 100644 --- a/web_interface/templates/v3/partials/logs.html +++ b/web_interface/templates/v3/partials/logs.html @@ -493,7 +493,7 @@ function updatePluginFilterOptions() { const plugins = Array.from(new Set(window._allLogs.map(log => log.plugin).filter(Boolean))).sort(); pluginFilterEl.innerHTML = '' + - plugins.map(p => ``).join(''); + plugins.map(p => ``).join(''); // Restore previous selection if it's still a valid option if (previousValue && plugins.includes(previousValue)) { @@ -517,14 +517,14 @@ function renderLogs() { const logElement = document.createElement('div'); logElement.className = `log-entry py-1 px-2 hover:bg-gray-800 rounded transition-colors duration-150 ${getLogLevelClass(log.level)}`; const pluginBadge = log.plugin - ? `${escapeHtml(log.plugin)}` + ? `${window.LEDEscape.html(log.plugin)}` : ''; logElement.innerHTML = `
- ${escapeHtml(log.timestamp)} + ${window.LEDEscape.html(log.timestamp)} ${log.level} ${pluginBadge} - ${escapeHtml(log.message)} + ${window.LEDEscape.html(log.message)}
`; if (window._logsContent) { @@ -761,13 +761,11 @@ function showEmptyState() { function showError(message) { if (window._logsContent) { - window._logsContent.innerHTML = `
${escapeHtml(message)}
`; + window._logsContent.innerHTML = `
${window.LEDEscape.html(message)}
`; window._logsContent.classList.remove('hidden'); } } -function escapeHtml(text) { return window.LEDEscape.html(text); } - function refreshCurrentPluginStatus() { fetch('/api/v3/display/current-status') .then(response => response.json()) @@ -843,11 +841,11 @@ function renderPluginErrors(summary) { 'Types'; plugins.forEach(p => { const types = Object.entries(p.types) - .map(([type, n]) => `${escapeHtml(type)} ×${escapeHtml(String(Number(n) || 0))}`) + .map(([type, n]) => `${window.LEDEscape.html(type)} ×${window.LEDEscape.html(String(Number(n) || 0))}`) .join(', '); html += '' + - `${escapeHtml(p.id)}` + - `${escapeHtml(String(p.total))}` + + `${window.LEDEscape.html(p.id)}` + + `${window.LEDEscape.html(String(p.total))}` + `${types}`; }); html += ''; @@ -862,16 +860,16 @@ function renderPluginErrors(summary) { html += '

Repeating errors

'; diff --git a/web_interface/templates/v3/partials/overview.html b/web_interface/templates/v3/partials/overview.html index 21110a95..6c81bcb5 100644 --- a/web_interface/templates/v3/partials/overview.html +++ b/web_interface/templates/v3/partials/overview.html @@ -55,19 +55,40 @@ + diff --git a/web_interface/templates/v3/partials/tools.html b/web_interface/templates/v3/partials/tools.html index c8dc8b2e..4814f281 100644 --- a/web_interface/templates/v3/partials/tools.html +++ b/web_interface/templates/v3/partials/tools.html @@ -403,14 +403,14 @@
- ${escHtml(message)} + ${window.LEDEscape.html(message)}
`; if (output) { html += `
Show output -
${escHtml(output)}
+
${window.LEDEscape.html(output)}
`; } @@ -421,10 +421,10 @@ const di = d.ok ? 'fa-check' : 'fa-times'; html += `
  • - ${escHtml(d.plugin)}`; + ${window.LEDEscape.html(d.plugin)}`; if (d.output) { html += `
    output -
    ${escHtml(d.output)}
    `; +
    ${window.LEDEscape.html(d.output)}
    `; } html += `
  • `; } @@ -435,8 +435,6 @@ el.innerHTML = html; } - function escHtml(s) { return window.LEDEscape.html(s); } - // ── main action dispatcher ──────────────────────────────────────────────── window.toolsAction = function(action, btnId, resultId, showOutput, showPluginDetails) { @@ -507,7 +505,7 @@ }) .then(d => { if (d.status === 'error') { - panel.innerHTML = `${escHtml(d.message || 'Git info unavailable.')}`; + panel.innerHTML = `${window.LEDEscape.html(d.message || 'Git info unavailable.')}`; return; } @@ -519,45 +517,45 @@ let html = `
    - ${escHtml(d.detached ? (d.version || 'detached') : (d.branch || 'unknown'))} + ${window.LEDEscape.html(d.detached ? (d.version || 'detached') : (d.branch || 'unknown'))} ${dirtyBadge}
    `; if (d.dirty && d.status) { - html += `
    ${escHtml(d.status)}
    `; + html += `
    ${window.LEDEscape.html(d.status)}
    `; } if (d.recent_commits) { html += `

    Recent commits

    -
    ${escHtml(d.recent_commits)}
    +
    ${window.LEDEscape.html(d.recent_commits)}
    `; } if (d.detached && d.current_release) { // The stable update channel sits on release tags, with no branch. - html += `

    On release ${escHtml(d.current_release)}, not a branch (stable update channel). Pull Latest follows the update channel set on the General tab.

    `; + html += `

    On release ${window.LEDEscape.html(d.current_release)}, not a branch (stable update channel). Pull Latest follows the update channel set on the General tab.

    `; } else if (d.detached) { // Detached but not on a release: say what the channel does // with it, in the General tab's words. - html += `

    Not on a branch or a release. ${escHtml(d.channel_message || '')} Pull Latest follows the update channel set on the General tab.

    `; + html += `

    Not on a branch or a release. ${window.LEDEscape.html(d.channel_message || '')} Pull Latest follows the update channel set on the General tab.

    `; } else if (d.upstream) { - html += `

    tracking ${escHtml(d.upstream)}

    `; + html += `

    tracking ${window.LEDEscape.html(d.upstream)}

    `; } else if (d.can_pull) { - html += `

    No upstream set; Pull Latest will use origin/${escHtml(d.branch || '')} and set it.

    `; + html += `

    No upstream set; Pull Latest will use origin/${window.LEDEscape.html(d.branch || '')} and set it.

    `; } else { html += `

    No upstream and no matching branch on origin — Pull Latest cannot run. Switch to a branch that exists on the remote.

    `; } if (d.remote_url) { - html += `

    ${escHtml(d.remote_url)}

    `; + html += `

    ${window.LEDEscape.html(d.remote_url)}

    `; } html += `
    `; panel.innerHTML = html; }) .catch(err => { - panel.innerHTML = `Could not load git info: ${escHtml(String(err))}`; + panel.innerHTML = `Could not load git info: ${window.LEDEscape.html(String(err))}`; }); } @@ -574,7 +572,7 @@ .then(r => r.ok ? r.json() : r.json().then(d => Promise.reject(d.message || `HTTP ${r.status}`))) .then(d => { if (d.status === 'error') { - sel.innerHTML = ``; + sel.innerHTML = ``; sel.disabled = true; return; } @@ -592,7 +590,7 @@ if (!sel.options.length) add('', 'no branches found'); }) .catch(err => { - sel.innerHTML = ``; + sel.innerHTML = ``; sel.disabled = true; }); } @@ -680,7 +678,7 @@ let html = `
    - ${escHtml(summaryText)} + ${window.LEDEscape.html(summaryText)}
    `; @@ -719,9 +717,9 @@
    -
    ${escHtml(label)}
    -
    ${escHtml(value)}
    - ${sub ? `
    ${escHtml(sub)}
    ` : ''} +
    ${window.LEDEscape.html(label)}
    +
    ${window.LEDEscape.html(value)}
    + ${sub ? `
    ${window.LEDEscape.html(sub)}
    ` : ''}
    `; @@ -781,7 +779,7 @@ 'Display Service', d.service_active ? 'Active' : 'Inactive', null); }) .catch(err => { - panel.innerHTML = `
    Diagnostics unavailable: ${escHtml(String(err))}
    `; + panel.innerHTML = `
    Diagnostics unavailable: ${window.LEDEscape.html(String(err))}
    `; }); }; @@ -925,7 +923,6 @@ }; // ── plugin health panel ────────────────────────────────────────────────── - function phEscape(s) { return window.LEDEscape.html(s); } function phFmtSecs(v) { if (typeof v !== 'number' || !isFinite(v)) return '—'; return v.toFixed(3) + 's'; @@ -968,10 +965,10 @@ const lastErr = h.degraded_reason || h.last_error || ''; const calls = (typeof m.call_count === 'number') ? m.call_count : '—'; const errCell = lastErr - ? '' + phEscape(lastErr) + '' + ? '' + window.LEDEscape.html(lastErr) + '' : '—'; rows += '' + - '' + phEscape(id) + '' + + '' + window.LEDEscape.html(id) + '' + '' + st.label + '' + '' + phFmtSecs(m.avg_execution_time) + '' + '' + phFmtSecs(m.max_execution_time) + '' + @@ -982,7 +979,7 @@ tbody.innerHTML = rows; } catch (e) { const emsg = (e && e.message) ? e.message : String(e); - tbody.innerHTML = 'Failed to load plugin health: ' + phEscape(emsg) + ''; + tbody.innerHTML = 'Failed to load plugin health: ' + window.LEDEscape.html(emsg) + ''; } } window.refreshPluginHealth = refreshPluginHealth; @@ -1042,8 +1039,8 @@ const c = data.config || {}; const field = (id, label, value, type, extra) => ` `; @@ -1093,12 +1090,18 @@ Use TLS (a password without TLS crosses the network in the clear) +

    - Saved to ${escHtml(data.config_path)}. + Saved to ${window.LEDEscape.html(data.config_path)}. Any field can also be set as - ${escHtml(data.env_override_prefix)}<KEY>, + ${window.LEDEscape.html(data.env_override_prefix)}<KEY>, which wins over the file.

    `).join('') : `

    No Starlark apps installed. Install one from the Plugins tab first - (looked in ${escHtml(appsDir)}). + (looked in ${window.LEDEscape.html(appsDir)}).

    `; body.innerHTML = active + `
    ${rows}
    `; - // Bound here rather than via an inline onclick: escHtml does not encode - // single quotes, and the id used to be interpolated into a single-quoted + // Bound here rather than via an inline onclick: the escaper used to leave + // single quotes alone, and the id used to be interpolated into a single-quoted // JS string, so an app directory containing an apostrophe could break // out of it and run script. Through dataset the value is only ever // parsed as an HTML attribute, never as JavaScript. @@ -1333,7 +1348,7 @@ }) .catch(err => { const body = document.getElementById('pixlet-editor-body'); - if (body) body.innerHTML = `

    Could not load: ${escHtml(err.message)}

    `; + if (body) body.innerHTML = `

    Could not load: ${window.LEDEscape.html(err.message)}

    `; }); }; diff --git a/web_interface/widget_bundle.py b/web_interface/widget_bundle.py index f37dbcc0..4c541aca 100644 --- a/web_interface/widget_bundle.py +++ b/web_interface/widget_bundle.py @@ -55,16 +55,10 @@ BUNDLE_ORDER = [ "password-input.js", "timezone-selector.js", "plugin-loader.js", - # Reusable JSON file manager (used via x-widget: json-file-manager) - "json-file-manager.js", ] # Widget files that must NOT be bundled, with the reason. -EXCLUDED = { - # Documentation example (docs/widget-guide.md); it registers the name - # 'color-picker' and would shadow the real color-picker.js. - "example-color-picker.js": "documentation example", -} +EXCLUDED = {} _lock = Lock() _cache = {"version": None, "body": None}