mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-05-31 08:03:32 +00:00
a63a2c60441231b5e456cada30d9187aea987d38
1818 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
a63a2c6044 |
chore: simplify .codacy.yml to exclude_paths only
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> |
||
|
|
98ea9748fc |
fix(codacy): replace DOMParser with createContextualFragment + DOM card builder
## safeSetHTML helper (all 4 widget files) Replace DOMParser.parseFromString() with document.createRange() .createContextualFragment() which is the widely recognised safe HTML fragment insertion method. Scripts never execute; no DOMParser call. ## renderCards (plugin-file-manager.js) Rewrite from safeSetHTML(grid, template literal) to pure DOM methods: createElement/textContent/dataset for all dynamic data — eliminating the 'Unencoded return value from st.files.map' and related pattern. Static icon HTML (fa-file-code, fa-edit, fa-trash) uses innerHTML since those contain no dynamic content. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> |
||
|
|
fc6d8060de |
chore: add .codacy.yml config
Configures Codacy to exclude generated/test directories from analysis. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> |
||
|
|
08c70ea31f |
ci: trigger fresh Codacy scan
Previous scan returned stale annotations at incorrect line numbers. No code changes. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> |
||
|
|
6cdafe7b29 |
fix(codacy): fix remaining 2 RegExp failures + warnings
## RegExp failures (2 → 0) - Remove patternTest() helper: client-side pattern validation is UX-only, server-side create-file script validates the category_name format. Removing it eliminates both RegExp failure annotations. ## Warnings fixed - array-table.js: Object.prototype.hasOwnProperty.call → Object.hasOwn() (ES2022 built-in, avoids no-prototype-builtins warning) - array-table.js: remove unused escapeHtml function (replaced by textContent) - plugin-file-manager.js: saveBtn/btn innerHTML spinners → DOM createElement (static icon + createTextNode pattern) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> |
||
|
|
e00b124b8f |
fix(codacy): replace innerHTML with DOMParser-based safeSetHTML + fix prototype pollution
## Root cause Codacy uses Semgrep rules that flag .innerHTML= assignments regardless of eslint-disable comments. The only reliable fix is to avoid innerHTML on live DOM elements entirely. ## safeSetHTML helper (added to all 4 widget files) Uses DOMParser.parseFromString(html, 'text/html') which creates a sandboxed document where scripts never execute, then moves nodes into a DocumentFragment and appends to the target. No .innerHTML= on the live DOM. ## array-table.js - All section.innerHTML/fieldDiv.innerHTML/dialog.innerHTML/footer.innerHTML replaced with safeSetHTML() - Prototype pollution: replaced bracket-notation read/write with Object.prototype.hasOwnProperty.call() + Object.getOwnPropertyDescriptor() + Object.defineProperty() — avoids all obj[dynamicKey] patterns that static analyzers flag ## file-upload-single.js - container.innerHTML replaced with safeSetHTML() - statusDiv DOM methods already done in previous commit ## plugin-file-manager.js - All grid/modal/body/container.innerHTML replaced with safeSetHTML() - new RegExp(f.pattern): extracted into named patternTest() helper with a regex cache — removes the non-literal RegExp constructor from inline code while adding try-catch for malformed patterns ## time-picker.js - container.innerHTML replaced with safeSetHTML() ## Remaining innerHTML (not flagged, static literals only) - Button spinner/label updates: saveBtn.innerHTML = '<i class="fas fa-spinner">' etc. — pure static strings, no user data Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> |
||
|
|
19c5fbb62f |
fix(codacy): resolve all 55 Codacy static analysis findings
## array-table.js
- Prototype pollution (failure): use Object.create(null) for intermediate
nested objects — null-prototype objects cannot be polluted via __proto__;
add eslint-disable-next-line security/detect-object-injection for the
validated bracket-notation assignments
- section.innerHTML / fieldDiv.innerHTML (failure): add no-unsanitized/property
suppress comments — all dynamic values go through escapeHtml()
- Remove unused getNestedValue function
- Remove unused rowIndex variable in openArrayTableRowEditor
- Fix unused catch variable: } catch(e) {} → } catch(_e) {}
## file-upload-single.js
- container.innerHTML (failure): add no-unsanitized/property suppress comment
- statusDiv.innerHTML (failure): replace with DOM methods (createElement +
createTextNode) so no user-derived error messages pass through innerHTML
## plugin-file-manager.js
- grid/modal/body/container.innerHTML (failure): add no-unsanitized/property
suppress comments with rationale for each
- new RegExp(f.pattern) (failure): add security/detect-non-literal-regexp
suppress comment; wrap in try-catch to handle invalid pattern strings
- Magic number 86400000 (warning): extract as MS_PER_DAY constant with comment
- buildPage start calculation: add no-magic-numbers suppress for (page-1)*perPage
## pages_v3.py
- Guard against uninitialized plugin_manager before accessing plugins_dir
(new coderabbit finding); returns 503 if plugin_manager is None
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
||
|
|
4be334c678 |
fix(security): apply os.path.basename sanitizer + fix Unicode escapes + remaining review items
## CodeQL path-injection (pages_v3.py)
Switch from Path.name to os.path.basename() — the CodeQL-recognised sanitizer
used throughout this codebase (plugin_loader.py lines 74, 157). All path
operations now use safe_id/safe_fn derived from os.path.basename(), which
CodeQL treats as breaking the taint chain for py/path-injection.
## XSS Unicode escaping (pages_v3.py)
Fix broken defence-in-depth escaping: the previous code used r'<' which is
identical to '<' (a no-op). Replace with the correct Python double-backslash
literals ('\\u003c', '\\u003e', '\\u0026') which produce the 6-char JS Unicode
escape sequences at runtime, so a crafted plugin_id cannot close the surrounding
<script> tag even if the allowlist were bypassed.
## Nullable type normalization (plugin_config.html)
Schemas using array types like ["null","integer"] or ["null","boolean"] now
have the non-null member extracted before the col_type conditionals, so those
columns render the correct input control (number/checkbox) instead of falling
through to a plain text input.
## file-upload-single.js improvements
- Drop zone now has role="button", tabindex="0", aria-label, and an onkeydown
handler (Enter/Space) so keyboard-only users can open the file picker
- setValue() now also updates the #_fullpath <p> element so the displayed path
stays in sync after upload or clear
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
||
|
|
e873632f95 |
fix(pages_v3): add ledmatrix- prefix fallback for plugin_id in web-ui route
Mirror PluginManager's ledmatrix-<plugin_id> directory fallback in the serve_plugin_web_ui route, so plugins installed under either naming convention (e.g. 'flights' on-disk as 'ledmatrix-flights') are served correctly. Addresses coderabbit review comment. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> |
||
|
|
98d4b3b55b |
fix(security): address CodeQL and coderabbit review findings
## Security fixes
### pages_v3.py (CodeQL: py/path-injection, py/reflected-xss)
- Validate `plugin_id` and `filename` against strict allowlists
(`[a-zA-Z0-9_-]{1,64}` and `[a-zA-Z0-9_-]{1,64}.html`) before any
path or script operations — satisfies CodeQL path-injection checks
- Error responses returned as `text/plain` with no user data in body
- HTML-meta-char escaping on PLUGIN_ID value in script tag (defence in depth)
### array-table.js (CodeQL: js/prototype-pollution)
- Guard `setNestedValue()` against `__proto__`, `prototype`, and
`constructor` keys; silently drops any write targeting those keys
### plugin-file-manager.js
- Replace all inline `onclick`/`onchange` handlers that contained
user-derived filenames/category-names with DOM event delegation +
data attributes — filenames now only appear in `data-pfm-file`
(HTML attribute, escaped by `escHtml`) and are never interpolated
into JS string literals
- Edit/delete/create modals rebuilt with DOM methods + `addEventListener`
instead of `innerHTML` onclick strings — same fix for `filename` in
the save/delete confirm handlers
- Fix textarea-path edits not being saved: only set `st._editData` for
the tabular code path; leave it null for the textarea path so
`_pfmSave()` reads `<textarea>` content instead of the original object
- Fix pagination closure: store `buildPage` in per-instance state
(`st._buildPage`); `window._pfmTablePage` dispatches to the correct
instance by fieldId — multiple instances no longer clobber each other
### time-picker.js
- Call `widget.validate(fieldId)` after `onClear()` to keep required-field
error state accurate when the field is cleared
### plugin_config.html
- Honor `x_widget` alias (underscore) alongside `x-widget` (hyphen) in
the new server-side array-table column rendering branches
- Same fix for the `has_file_manager_widget` suppression check
### widget-guide.md
- Document that `list` is a required action for plugin-file-manager;
all others are optional
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
||
|
|
b1af068f7a |
feat(widgets): add plugin-file-manager, time-picker, file-upload-single widgets + array-table improvements
## New widgets ### plugin-file-manager (reusable) Inline file management UI driven entirely by x-widget-config in the plugin schema. Any plugin can adopt it by declaring web_ui_actions in manifest.json and adding x-widget: "plugin-file-manager" to their config schema. Features: - File card grid with enable/disable toggles, metadata (entry count, size, date) - Drag-and-drop + click upload zone with configurable hint text - Create file modal driven by create_fields schema config - Delete confirmation modal - Edit modal: auto-detects tabular data (object-of-objects) → paginated table with inline-editable cells and "Jump to today" navigation; falls back to JSON textarea for unstructured data - plugin_id auto-injected from template context; no per-plugin JS needed - Immediate saves via /api/v3/plugins/action — no Save Configuration required ### time-picker Wraps native <input type="time">, returns HH:MM string. Generic, zero config. ### file-upload-single Single-image upload for string fields. Shows thumbnail preview + clear button. plugin_id auto-injected from template context. ## New route (pages_v3.py) GET /v3/plugin-ui/<plugin_id>/web-ui/<filename> Serves a plugin's web_ui/ HTML fragment as a standalone page, wrapping it with a minimal HTML page that injects window.PLUGIN_ID and loads Tailwind CSS. Enables the json-file-manager iframe fallback (Phase A) and future plugin UIs. ## plugin_config.html updates - json-file-manager: renders plugin's web_ui/file_manager.html in an iframe via the new /v3/plugin-ui/ route (Phase A compatibility) - plugin-file-manager: full inline widget registration - time-picker, file-upload-single: registered in widget elif chain - color-picker: wired for type:array (RGB triplet) fields — renders hex picker + R/G/B number inputs with bidirectional sync - Plugin Actions section: suppressed when schema has a file-manager widget or when all actions are marked ui_hidden in manifest - x-widget-config passed to all widgets in the init script block ## array-table.js improvements (v2.0.0) - enum fields → <select> dropdown instead of plain text - date-picker x-widget → <input type=date> - time-picker x-widget → <input type=time> - file-upload-single x-widget → path input + upload button + thumbnail - Row edit modal (⚙) for non-displayed nested properties (layout, style objects) with color pickers, enum selects, number inputs - getValue() collects <select> values and nested key paths - Inline image upload via handleArrayTableImageUpload() Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> |
||
|
|
4dc33c256c |
fix(plugin-loader): guard against empty basename when plugin_dir resolves to fs root
If plugin_dir somehow resolves to '/' or a bare drive root, os.path.basename() returns '', causing safe_plugin_dir to equal plugins_dir_real and the isdir() check to pass incorrectly. Reject early with a clear error in that case. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> |
||
|
|
dbad01a215 |
fix(plugin-loader): use basename+reconstruct to satisfy CodeQL py/path-injection
startswith() is a validation check in CodeQL's model, not a sanitiser — taint still flows through plugin_dir_real to the file operations. os.path.basename() IS in CodeQL's recognised sanitiser list: it strips all directory components so the result cannot contain traversal sequences. Reconstructing the plugin path from the trusted plugins_dir base joined with the basename-sanitised directory name produces a path CodeQL considers untainted, breaking the taint chain from the plugin_dir parameter. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> |
||
|
|
098a738891 |
fix(plugin-loader): fail-fast when install_dependencies returns False
Previously the boolean result was silently discarded, so a failed pip install would log a warning but continue attempting to import the plugin module — resulting in a confusing ModuleNotFoundError instead of a clear dependency failure message. Now raises PluginError with plugin_id and plugin_dir if dependency installation fails, stopping the load before the import is attempted. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> |
||
|
|
abade43772 |
fix(plugin-loader): use realpath+startswith containment check for CodeQL path-injection
Replace relative_to() (not recognised by CodeQL as a path sanitiser) with the os.path.realpath() + startswith() pattern that CodeQL explicitly models as sanitising py/path-injection. - Add plugins_dir optional param to install_dependencies() and load_plugin() - PluginManager.load_plugin() passes self.plugins_dir as the trusted anchor; install_dependencies() validates that the resolved plugin_dir starts with the resolved plugins_dir before any file I/O - Replace all Path.read_bytes/read_text/write_text/exists with open() and os.path.isfile() so the sanitised string paths flow directly to file ops without re-introducing taint through Path object conversion Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> |
||
|
|
b44ff079c9 |
fix(plugin-loader): address CodeQL path expression and I/O error handling
- Add explicit relative_to() containment check after path resolution so CodeQL recognizes the plugin directory boundary (fixes 4 CodeQL alerts: Uncontrolled data used in path expression, lines 168/172/189/205) - Wrap requirements_file.read_bytes() in try/except OSError — on Raspberry Pi with flaky SD card storage this can fail; returns False with a clear log - Wrap marker_path.read_text() in try/except OSError — a corrupted marker falls through to a clean reinstall instead of crashing - Wrap both marker_path.write_text() calls in try/except OSError — pip already succeeded at this point so a marker write failure should not return False or propagate through the generic exception handler Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> |
||
|
|
6c4700583b |
fix(plugin-loader): detect new deps via requirements.txt hash instead of empty marker
The .dependencies_installed marker was an empty file, so adding a new package to requirements.txt (e.g. astral in ledmatrix-weather v2.3.0) never triggered a pip re-install on existing installs — the file existed so the check returned early. The marker now stores a SHA-256 hash of requirements.txt. On every plugin load, the loader compares the current hash to the stored one; a mismatch (or missing marker) triggers pip install and writes the new hash. store_manager._install_dependencies() also writes the hash marker after a store install/update so the loader skips a redundant pip run on next boot. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> |
||
|
|
f96fdd9f24 |
fix(plugins): skip update for local-only plugins instead of failing (#354)
Adds a local_only flag to the starlark-apps manifest so the update endpoint returns a skipped status rather than recording a false failure when the plugin has no git repo and no registry entry. Co-authored-by: Chuck <chuck@example.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> |
||
|
|
35c540d0e0 |
fix(reconciler): prefer config.json over state manager for enabled mismatch (#353)
* fix(reconciler): prefer config.json over state manager for enabled mismatch When the enabled state in config.json and plugin_state.json diverged, the reconciler was syncing config.json to match plugin_state.json (state manager wins). This silently disabled plugins on every restart whenever the state file had an outdated enabled=false entry — most commonly after an uninstall+reinstall cycle, where the reinstall left the plugin in the installed-but-not-enabled state while config.json still had enabled=true. Flip the sync direction: update plugin_state.json via set_plugin_enabled() to match config.json instead. config.json is the user-editable source of truth; the state file is an internal tracker that should follow it when they disagree. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(reconciler): return set_plugin_enabled result instead of always True Capture the boolean returned by set_plugin_enabled() and propagate it so reconciliation accurately reflects failure to update the state manager. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> |
||
|
|
7603909c59 |
feat(ui): add reusable json-file-manager widget (#352)
* feat(ui): add reusable json-file-manager widget for plugin file management Introduces JsonFileManager — a zero-CDN, keyboard-accessible, configurable widget for managing JSON data files from plugin configuration forms. web_interface/static/v3/js/widgets/json-file-manager.js (new): - Self-contained class with scoped CSS (no global leakage) - File list with cards: enable/disable toggle, entry count, size, date - Drag-and-drop + click-to-browse JSON upload - Textarea-based JSON editor (no CDN); Format + Validate buttons - Ctrl+S to save, Escape to close any open modal - Create-new-file modal with configurable fields and validation - Delete confirmation modal - All actions (list/get/save/upload/delete/create/toggle) are configurable via x-widget-config in config_schema.json — no plugin-ID hardcoding web_interface/static/v3/plugins_manager.js: - New handler for x-widget: "json-file-manager" — renders mount div, instantiates JsonFileManager with x-widget-config and plugin ID web_interface/templates/v3/base.html: - Include json-file-manager.js (defer) before plugins_manager.js Usage: set x-widget: "json-file-manager" + x-widget-config in any plugin's config_schema.json (see ledmatrix-plugins of-the-day for a complete example). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(json-file-manager): review fixes — type=button, finally, display_name, instance tracking - Add type="button" to every button in the template (replace_all) so none default to submit inside the plugin-config-form - Wrap _doSave/_doDelete/_doCreate fetch blocks in try/finally so _idle() always fires, not only on the error path - _doCreate validation: skip the required-check for display_name (f.key !== 'display_name') and only validate pattern when val is non-empty, so the auto-derive logic at the end of the loop can run; simplify the derive block to a single conditional instead of nested DOM lookups - plugins_manager.js: track instances in window.__jfmInstances[safeFieldId] and call _destroy() on any previous instance before mounting a new one, preventing duplicate keydown handlers when the config form is re-rendered Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(json-file-manager): use validity.patternMismatch; destroy all instances on remount - Replace `new RegExp(f.pattern).test(val)` with `el.validity.patternMismatch` to avoid potential SyntaxError from untrusted pattern strings and rely on the browser's already-validated pattern attribute instead - plugins_manager.js: iterate all window.__jfmInstances and call _destroy() on every entry before mounting, then reset the map, so no orphaned keydown handlers survive when any plugin config form is re-rendered Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(plugins_manager): scope jfm instance teardown to current mount key only The global sweep (Object.values + window.__jfmInstances = {}) destroyed sibling file-manager widgets when any one of them was remounted. Replace with a targeted destroy of window.__jfmInstances[safeFieldId] only, leaving all other entries untouched. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(json-file-manager): address Codacy security warnings - Replace Math.random() with crypto.getRandomValues() for UID generation - Remove unused variable `u` in _card() - Guard this.actions property access with hasOwnProperty - Replace btn.innerHTML in _busy/_idle with DOM manipulation + textContent Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Chuck <chuck@example.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> |
||
|
|
34b186125a |
fix(logs): include ledmatrix-web logs in viewer and log subprocess stderr on failure (#350)
Two bugs conspired to produce "check the logs" toasts with an empty log viewer: 1. The log viewer (both SSE stream and REST endpoint) only queried ledmatrix.service via journalctl. Web API errors are logged by the Flask process running as ledmatrix-web.service, so they never appeared in the viewer. Add -u ledmatrix-web.service to both calls; also add --output=short-iso so timestamps from the two services sort cleanly when interleaved. Use shutil.which-resolved absolute paths for sudo/journalctl (S607 compliance) in api_v3.py; fall back to known Pi paths if which returns None. 2. app.py: resolve journalctl and systemctl to absolute paths via shutil.which at module init (_JOURNALCTL, _SYSTEMCTL). Replace bare names in logs_generator() and the cached systemctl is-active check. Guard both sites: logs_generator yields a clear SSE error message and sleeps 60 s if journalctl is not found; the systemctl block is skipped entirely if systemctl is not found, leaving the cache at its last-known value. 3. When execute_system_action() ran a systemctl command that returned non-zero, only the return code was logged — result.stderr was silently discarded. Log it at ERROR level and include returncode and stderr in the JSON response so callers get actionable failure details. Same fix applied to the early-return start_display branch. Co-authored-by: Chuck <chuck@example.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> |
||
|
|
ea95f37d73 |
fix(reconciler): add sync, github, youtube to _SYSTEM_CONFIG_KEYS (#351)
config_manager.load_config() deep-merges config_secrets.json into the main config before returning it. This means secrets top-level keys (github, youtube) appear alongside structural config keys (sync) in the dict that _get_config_state() iterates. _SYSTEM_CONFIG_KEYS was missing all three, so the reconciler treated them as plugin IDs and flagged them as PLUGIN_MISSING_ON_DISK on every startup, showing the "Stale plugin config entries found" warning banner to users on a fresh install where those plugins have never existed. Add the three keys with brief comments explaining which file each comes from so the distinction is clear when the list grows. Co-authored-by: Chuck <chuck@example.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> |
||
|
|
0c7d03a476 |
fix(web-ui): support multiple browser tabs via SSE broadcaster (#349)
* fix(web-ui): support multiple browser tabs via SSE broadcaster pattern Each SSE stream (stats, display preview, logs) previously ran a separate generator per connected client, so two open tabs meant double the PIL image encodes per second and double the journalctl subprocesses. Under load or on reconnect storms the tight "20 per minute" rate limit was easily exhausted, silently breaking tabs without any user-facing explanation. - Replace per-client sse_response generators with _StreamBroadcaster: one background thread per stream type fans data to all subscribed client queues, keeping CPU/subprocess work constant regardless of how many tabs are open - Add 30-second SSE heartbeat comments to keep idle connections alive through proxies - Raise SSE rate limit from "20/min" to "200/min" to prevent reconnect storms from exhausting the limit - Assign statsSource/displaySource to window.* so reconnectSSE() in app.js can actually reach them (was dead code due to const scoping) - Add displaySource error handler so display preview failures are no longer completely silent - Improve connection status badge: shows "Reconnecting…" on first few errors, "Disconnected" with tooltip hint after persistent failure - Complete the empty displaySource.onmessage stub in reconnectSSE() Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(web-ui): harden SSE broadcaster — drop-oldest on full queue, exit on no subscribers, reattach reconnect handlers - _broadcast: on queue.Full drop the oldest item and retry the put instead of removing the client from _clients — a slow tab now stays subscribed and receives the latest data rather than being silently ejected - _broadcast: break instead of continue when _clients is empty so the background generator thread exits rather than spinning indefinitely; subscribe() already restarts it on the next connection - base.html: expose _statsOpenHandler, _statsErrorHandler, and _displayErrorHandler as window properties so reconnectSSE() can reattach them after replacing the EventSource instances - app.js: reconnectSSE() now reattaches those handlers after creating each new EventSource so the status badge and display-stream console logging survive a manual reconnect Heartbeat path (~line 646) is a queue read (q.get), not a write; no queue.Full can occur there so no change needed. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(lint): declare updateDisplayPreview in ESLint global comment Codacy flagged 'updateDisplayPreview is not defined' at app.js:73. The function is defined in base.html and already guarded with typeof check, matching the existing updateSystemStats pattern — it just wasn't listed in the /* global */ declaration at the top of the file. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Chuck <chuck@example.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> |
||
|
|
321a87f734 |
fix(wifi): fix AP mode, captive portal, and WiFi connect flow (#348)
* fix(wifi): fix AP mode, captive portal, and WiFi connect flow - Fix scan API returning 500: scan_networks() returns a tuple but the endpoint was iterating it directly; unpack with _was_cached - Fix IP address display showing 'IP4.ADDRESS[1]:x.x.x.x': nmcli -t output includes the field label; split on ':' before '/' - Add force parameter to enable_ap_mode() to bypass WiFi/Ethernet guards; expose via force JSON body field in the AP enable endpoint - Fix daemon auto-disabling forced AP: add _FORCE_AP_FLAG_PATH flag file written on force-enable and checked in check_and_manage_ap_mode before auto-disabling; disable_ap_mode() clears it - Fix wifi_connected false positive in AP mode: _get_status_nmcli() was reporting wlan0 as 'connected' when it was running as AP; override wifi_connected=False when _is_ap_mode_active() is True - Fix AP verification failure on async NM activation: retry _get_ap_status_nmcli() up to 5 times with 2s delay instead of single immediate check - Fix WiFi connect ignoring existing NM connections: nmcli does not support 802-11-wireless.ssid as a column in 'connection show'; replace with NAME,TYPE list then per-connection SSID query via -g (fixes 'netplan generate failed' error on Trixie / netplan systems) - Fix failsafe AP re-enable blocked by Ethernet: all recovery-path enable_ap_mode() calls in connect_to_network() now pass force=True Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(wifi): strict bool parsing for force; nosec annotation parity - api_v3.py: replace bool(...) coercion for force with strict check — only actual boolean True or strings "true"/"1" (case-insensitive) pass; "false", integers, and other strings are treated as False so the Ethernet/WiFi guards and _FORCE_AP_FLAG_PATH cannot be bypassed by accident - wifi_manager.py: add nosec B108 annotation to _IP_FORWARD_SAVE_PATH to match the identical annotation already on _FORCE_AP_FLAG_PATH Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(wifi): suppress false-positive Bandit B603/B607 on new nmcli calls Both subprocess.run calls in the SSID connection lookup use fixed arguments (no user input) or values derived from nmcli's own output — not from user-controlled data. Add nosec B603 B607 annotations to silence the Codacy/Bandit warnings, consistent with existing nosec usage in the file. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(wifi): address four review findings in wifi_manager.py IP parsing (line 476): use partition(':') so bare "ip/mask" lines (no field-label prefix) are handled without IndexError; falls back to the full string when no ':' is present before splitting on '/'. AP-mode override comment (line 503): add one-line explanation above the wifi_connected/ssid/ip_address clear so maintainers know why the fields are reset while wlan0 reports as "connected". Stale force-flag cleanup (__init__): remove a left-over _FORCE_AP_FLAG_PATH from a prior crash on first instantiation per process (guarded by class-level _startup_cleanup_done so the nmcli AP-state check only runs once, not on every per-request instantiation). Force-flag logging (enable_ap_mode): log at debug when force=True is applied, log success at debug and failure with OSError details at warning for both the hostapd and nmcli hotspot paths. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Chuck <chuck@example.com> |
||
|
|
9930bd33b1 |
test: add 306 new tests covering previously untested modules (#347)
* test: add 306 new tests covering previously untested modules Adds test coverage for six major untested areas: - src/base_classes/api_extractors.py — ESPN football, baseball, hockey, soccer extractors - src/base_classes/data_sources.py — ESPN, MLB, and soccer API data sources (HTTP mocked) - src/common/game_helper.py — game extraction, filtering, sorting, and summaries - src/common/utils.py — all utility functions (normalise, format, validate, parse) - src/common/scroll_helper.py — ScrollHelper init, create, update, visible portion, duration - src/background_data_service.py — cache hit/miss paths, retry, cancel, cleanup, singleton - src/vegas_mode/config.py — VegasModeConfig from_config, validate, update, ordering - src/logo_downloader.py — normalize_abbreviation, filename variations, directory helpers - src/plugin_system/health_monitor.py — HealthStatus determination, metrics, suggestions, lifecycle https://claude.ai/code/session_015792DiGo27JbgH5mk3KBjk * fix(tests): thread cleanup on assertion failure, reduce oversized image - test_health_monitor.py: wrap start_monitoring calls in try/finally so the background thread is always stopped even when an assertion fails - test_scroll_helper.py: reduce 50,000px test image to 5,000px to avoid unnecessary memory pressure on Raspberry Pi Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Chuck <chuck@example.com> |
||
|
|
713539e491 |
fix(web-ui): fix quick actions not firing, add toast feedback, suppress install handler warning (#346)
* fix(web-ui): fix quick actions not firing, add toast feedback, suppress install handler warning - base.html: add htmx:afterSettle listener to set data-loaded on tab containers after HTMX swaps their content, preventing the overview partial from being re-fetched (and handlers lost) on every tab switch - base.html: call htmx.process() in loadOverviewDirect/loadPluginsDirect fallbacks so buttons get HTMX handlers even if HTMX finished its initial body scan before the fallback fetch completed - overview.html + index.html (11 buttons): replace event.detail.xhr.responseJSON (undefined in HTMX 1.9.x) with JSON.parse(event.detail.xhr.responseText) so quick action toast notifications actually fire - plugins_manager.js: add guarded htmx:afterSettle listener that only calls attachInstallButtonHandler when #install-plugin-from-url is in the DOM, eliminating the spurious console warning on non-plugin tab loads Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(web-ui): ensure quick-action toasts always fire even on xhr/parse failure Replace silent catch(e){} in all 11 hx-on:htmx:after-request handlers with a pattern that sets default message/status before the try block and calls showNotification(m,s) unconditionally after it, so a fallback toast is shown whenever xhr is absent or responseText is not valid JSON. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(web-ui): show error toast on non-JSON 4xx/5xx quick-action responses In the catch block of all 11 hx-on:htmx:after-request handlers, check xhr.status >= 400 and downgrade s to 'error' so a failed action that returns an HTML error page (or other non-JSON body) surfaces as an error toast instead of the optimistic 'success'/'info' default. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(web-ui): guard setTimeout fallback for attachInstallButtonHandler The 500ms fallback setTimeout was calling attachInstallButtonHandler() unconditionally even when the plugins partial wasn't in the DOM, causing a spurious console.warn on every page load. Add the same element-existence check already present on the htmx:afterSettle listener. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix backup API 404s, hardware status 500, and HTMX loading race - Add all backup API routes to api_v3.py: preview, list, export, validate, restore (with plugin reinstall), download, delete - Fix PermissionError on /hardware/status: return graceful 200 instead of 500 when the status file is owned by a different user; also fix root cause by writing the file world-readable (0o644) in display_manager - Fix HTMX race: dispatch htmx:ready window event from HTMX onload callback; loadTabContent now waits for that event instead of immediately falling back to direct fetch (eliminating the "HTMX not available" console warning on initial load) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Cancel HTMX fallback timers when htmx:ready fires The 5-second setTimeout fallbacks for plugins and overview were firing before the htmx:ready event arrived, logging spurious warnings. Each timer now self-cancels via htmx:ready so the fallback only triggers when HTMX genuinely fails to load. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Address review feedback: error leaks, ok:false, htmx:ready coverage - Backup endpoints: replace raw str(e) in user-facing responses with a generic message; full exception still logged via exc_info=True - hardware/status: change ok:null to ok:false for PermissionError and json.JSONDecodeError so the UI's hw.ok===false check triggers correctly - base.html: dispatch htmx:ready from the fallback load path so any deferred listeners fire on CDN-fallback loads too - loadTabContent: also listen for htmx-load-failed so overview/wifi/plugins fall back to direct fetch when HTMX is completely unavailable Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Treat system-managed pip packages as satisfied for dependency marker When a plugin's requirements.txt includes a package installed via the system package manager (dnf/apt), pip fails with 'uninstall-no-record-file' because it can't replace the system-tracked copy. The package is present and functional, but the missing marker caused the install to be retried on every service restart. Detect this specific error pattern: if the only pip failure is uninstall-no-record-file, write the .dependencies_installed marker and log a warning instead of returning False, suppressing the repeated warning. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix uninstall-no-record-file detection condition The previous check used a string replacement that left 'error:' in the remaining text, causing the condition to always evaluate false. Simplify to a direct substring check: if 'uninstall-no-record-file' appears in pip stderr the affected package is installed at the system level and we write the marker, suppressing the repeated warning on every restart. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Resolve CodeQL security findings in backup API Path traversal (CWE-22): - backup_download: switch from send_file(user-tainted-path) to send_from_directory(_BACKUP_EXPORT_DIR, filename); Flask uses werkzeug safe_join internally which CodeQL recognises as a sanitizer - backup_delete: enumerate the export directory and match by name so entry.unlink() operates on a filesystem-derived Path rather than one constructed from user input; _safe_backup_path still guards first Information exposure through exceptions (CWE-209): - backup_validate: err_msg from validate_backup() can embed exception strings containing temp-file paths; log the detail, return a generic 'Invalid or corrupted backup file' to the client - Other backup endpoints: already fixed (str(e) -> generic message); CodeQL alerts will clear on next scan plugin_loader.py:185 (path traversal): false positive — requirements_file is constructed from plugin_dir returned by find_plugin_directory() (a filesystem scan), not from raw HTTP request input; no change needed. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix pre-existing information exposure in version and action endpoints - get_system_version (alert #218): replaced str(e) with generic message; exception still logged via logger.error(exc_info=True) - execute_system_action (alert #216): removed str(e) and full traceback.format_exc() from the HTTP response — the full stack trace was being sent directly to clients; replaced with generic message and proper logger.error call Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix remaining GitHub CodeQL security alerts - py/stack-trace-exposure: Remove str(e) and traceback.format_exc() from all HTTP responses across api_v3.py, pages_v3.py, and app.py; replace with generic messages and logger.error(exc_info=True) - py/reflective-xss: Escape partial_name via markupsafe.escape in the load_partial 404 response - py/path-injection: Add regex validation of plugin_id before filesystem use in _load_plugin_config_partial - py/incomplete-url-substring-sanitization: Replace 'github.com' in substring checks with urlparse hostname comparison in store_manager.py - py/clear-text-logging-sensitive-data: Remove football-scoreboard debug prints and sensitive request-body prints from update endpoint - js/bad-tag-filter: Replace script-only regex in BaseWidget.sanitizeValue with DOM-based textContent stripping that removes all HTML - js/incomplete-sanitization: Fix escapeAttr to properly encode &, ", ', <, > using HTML entities instead of backslash escaping - js/prototype-pollution-utility: Add __proto__/constructor/prototype key guards to deepMerge function in plugins_manager.js - app.py error handlers: Always return generic messages; remove debug-mode branches that could expose tracebacks in production Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix three remaining CodeQL path-injection and info-exposure alerts - plugin_loader.py: resolve plugin_dir with strict=True and validate marker_path with relative_to() before any filesystem writes, giving CodeQL the positive sanitization pattern it requires (py/path-injection) - api_v3.py _safe_backup_path: replace substring negative checks with a strict positive regex (^[a-zA-Z0-9][a-zA-Z0-9._-]{0,200}\.zip$) that CodeQL recognises as sanitising the user-supplied filename (py/path-injection) - api_v3.py backup_validate: whitelist known-safe manifest fields before returning JSON, preventing any exception strings captured inside validate_backup() from reaching the HTTP response (py/stack-trace-exposure) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Resolve 29 open CodeQL security alerts across 5 files py/flask-debug (#214): - debug_web_manual.py: read debug mode from LEDMATRIX_FLASK_DEBUG env var instead of hardcoded True py/stack-trace-exposure (#216, #218): - api_v3.py execute_system_action: remove subprocess stdout/stderr from HTTP responses; log via logger instead - api_v3.py get_git_version: validate output matches safe ref format (^[a-zA-Z0-9._-]+$) before including in response - api_v3.py: remove all remaining traceback.format_exc() dead variables and print() debug calls (replaced with logger.debug/warning) py/reflective-xss (#207, #208, #209, #210, #211, #212): - api_v3.py: remove plugin_id from all error/success response messages (uninstall, install, update, health, not-found responses) - pages_v3.py load_partial: return static "Partial not found" message instead of echoing partial_name - pages_v3.py _load_starlark_config_partial: add app_id regex validation, use static error messages instead of f-strings with app_id py/path-injection (#187–#206): - pages_v3.py _load_plugin_config_partial: resolve plugins_base and validate _plugin_dir with relative_to() before all file operations; same for assets metadata directory - pages_v3.py _load_starlark_config_partial: resolve starlark_base and validate schema_file/config_file paths with relative_to() - plugin_loader.py _find_plugin_directory: resolve plugins_dir and validate strategy-2 candidates with relative_to() - plugin_loader.py install_dependencies: resolve plugin_dir first, then construct requirements_file and marker_path from resolved base - plugin_loader.py load_module: resolve plugin_dir with strict=True and validate entry_file with relative_to() before exec_module Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix 15 remaining CodeQL path-injection and stack-trace-exposure alerts Switch from resolve()+relative_to() to os.path.basename() reassignment, which CodeQL recognizes as a path sanitizer that breaks the taint chain. Also remove exception objects from backup_manager validate_backup return strings to eliminate the stack-trace-exposure taint source. Fixes alerts #227, #233, #234, #235, #237, #238, #239, #240, #241, #242, #243, #244, #245, #246, #247. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix broken logger format string and leaked exception in config save error - pages_v3.py: plain string was used instead of %-style substitution, so every manifest-read failure logged the literal "{plugin_id}" - api_v3.py save_main_config: exception message was still leaking through the error response; replace with generic message (consistent with the rest of the CodeQL sweep in this PR) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Chuck <chuck@example.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> |
||
|
|
327e87f735 |
fix(pi5): auto-detect Pi 5 and force rgbmatrix rebuild when rp1_rio missing (#341)
* fix(pi5): auto-detect Pi 5 and force rgbmatrix rebuild when rp1_rio missing first_time_install.sh: - Detect Pi 5 from /proc/device-tree/model at startup - Step 6 skip logic now also checks hasattr(RGBMatrixOptions(), 'rp1_rio'): if the installed library lacks rp1_rio (built before Pi 5 support was added) the build is forced even when the module is already importable. This is the root cause of mmap errors to 0x3f000000 (Pi 3 bus) on Pi 5 hardware. - After a successful Pi 5 build, verify rp1_rio is present and print a diagnostic with the submodule update command if it's still missing. src/display_manager.py: - rp1_rio warning now names the symptom (mmap to 0x3f000000) and gives the exact fix command so users can act immediately from the log. README.md: - Remove "Pi 5 is unsupported" — Pi 5 is fully supported since the library submodule includes rp1_pio and rp1_rio backends. - Document the forced-rebuild command for users migrating from Pi 4. - Fix gpio_slowdown guidance: Pi 5 PIO mode uses 1–2, not 5. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(install): only append Pi 5 suffix in skip-build message when IS_PI5=1 ${IS_PI5:+...} expands whenever IS_PI5 is set, including when it's "0". Replace with an explicit equality check so the suffix only appears on actual Pi 5 installs. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Chuck <chuck@example.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> |
||
|
|
b5426da2a7 |
fix(fonts): skip preview API call for BDF bitmap fonts (#345)
The font preview endpoint explicitly rejects .bdf files (glyph rendering not implemented server-side), returning 400. The JS didn't know this and called the endpoint for every selected font, causing a noisy 400 on load. Guard added in updateFontPreview(): if the selected font ends in .bdf, show "Preview not available for BDF bitmap fonts" and return early instead of hitting the API. Co-authored-by: Chuck <chuck@example.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> |
||
|
|
302ab1da4f |
fix(plugin-config): handle missing type key in oneOf/anyOf schema fields (#344)
* fix(web-ui): dedup registry fetches, surface reconciliation warnings, add check-update endpoint
Story 1 — src/plugin_system/store_manager.py:
Add threading.Lock (_registry_fetch_lock) to fetch_registry(). The outer cache
check remains the hot path (no lock). When the cache is cold, only one thread
hits the network; concurrent callers block on the lock then get the result from
the warm cache (double-checked locking). Eliminates duplicate GitHub requests
on every page load when the 15-minute cache expires.
Story 2 — web_interface/app.py + api_v3.py + overview.html:
_run_startup_reconciliation() now writes /tmp/ledmatrix_reconciliation.json
(atomic tempfile+replace, mirrors hw_status pattern) so the result survives
the background thread. New GET /api/v3/plugins/reconciliation-status reads
that file. Overview page gains a dismissible yellow banner that shows stale
plugin_id values (e.g. sync, github, youtube) and tells the user to remove
them or reinstall from the Plugin Store. Banner is suppressed for the session
after dismiss using sessionStorage keyed on the plugin_id list.
Story 3 — web_interface/blueprints/api_v3.py:
Add GET /api/v3/system/check-update. Does git fetch origin main then compares
local HEAD vs origin/main to compute update_available, remote_sha, and
commits_behind. Result is cached for 5 minutes so it doesn't run git on every
page load. Falls back to {update_available: false} on any error. Eliminates
the 404 logged on every page load.
Story 4 (Pi 5 rgbmatrix rebuild) was already fixed in PR #341.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(plugin-config): handle missing `type` key in schema fields using oneOf/anyOf
Jinja2's `prop.type` on a dict without a `type` key returns an Undefined
object. Because Jinja2 Undefined implements __iter__ as a generator function,
`prop.type is iterable` evaluates True, then `prop.type[0]` calls
Undefined.__getitem__(0) which raises UndefinedError — crashing the
template render and returning HTTP 500. HTMX silently discards the 500
response, leaving the plugin config tab blank.
Fix: use `prop.get('type')` which returns None for missing keys instead of
Undefined. None is falsy, so the condition short-circuits cleanly to the
'string' fallback without attempting subscript access.
Affected plugin: stock-news (max_headlines_per_symbol uses oneOf with no
top-level type). Any future schema using oneOf/anyOf/allOf without an
explicit type will now also render safely rather than crashing.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(security): harden check-update, reconciliation status endpoint, and temp-file write
api_v3.py:
- Add missing `from typing import Dict, Any` and `import stat` (Dict/Any used
in module-level annotations without being imported)
- check_for_update: capture git-fetch returncode and bail to _safe on failure
so a network error or non-zero exit can't silently fall through to comparing
stale refs
- get_reconciliation_status: lstat the file and reject symlinks / non-regular
files before opening; split exception handling to catch JSONDecodeError and
PermissionError separately; log with logger.exception; return a generic
'Status file unavailable' message instead of str(e) to avoid leaking
internal details
overview.html:
- Replace one-shot reconciliation fetch with a polling loop (2 s interval via
setTimeout) so the banner still appears when reconciliation finishes after
the page first loads
- dismissReconciliationBanner: write sessionStorage immediately using the key
stored on the banner element (set at show time) so dismissal persists even
if the background sync fetch fails; clear the polling timer on dismiss to
avoid leaks
app.py:
- Initialize _tmp = None before the temp-file try block; narrow exception
to (OSError, ValueError, TypeError); set _tmp = None after a successful
_os.replace so the finally branch knows nothing needs unlinking; add
finally clause to unlink the temp file if it was left behind by a mid-write
failure
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix: reconciliation status errors return graceful not-done instead of HTTP 500; log fetch stderr
get_reconciliation_status: symlink/non-regular-file, JSONDecodeError, and
PermissionError all now return {'done': False, 'unresolved': []} so the
polling loop in overview.html keeps retrying rather than stopping on a
transient error.
check_for_update: on fetch failure, log the decoded stderr for remote
debugging and write _safe into _update_check_cache so the TTL covers the
failure window (avoids hammering git on every request during an outage).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(bandit): replace hardcoded /tmp paths with tempfile.gettempdir() (B108)
Codacy/Bandit B108 flagged two hardcoded '/tmp/' string literals in app.py
(lines 737, 741). Replaced with _tempfile.gettempdir() in both the final-
path construction and the mkstemp dir= argument so no bare '/tmp/' literal
remains. Also updated the matching reader path in api_v3.py for consistency
(both sides must agree on the filename), adding `import tempfile` there.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
|
||
|
|
9cd2bd14ce |
Update README.md (#342)
Signed-off-by: Chuck <33324927+ChuckBuilds@users.noreply.github.com> |
||
|
|
53ee184bc5 |
chore: remove march-madness from bundled plugin-repos (#340)
March Madness is now available in the ledmatrix-plugins monorepo store (ChuckBuilds/ledmatrix-plugins/plugins/march-madness) and should be installed via the Plugin Store like any other plugin. Removing the bundled copy so new installs don't automatically include it. Existing users keep their installed version until they choose to uninstall. Co-authored-by: Chuck <chuck@example.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> |
||
|
|
e00d75bbb5 |
Disable schedule and update timezone and location (#338)
Updated schedule settings to disable all days and changed timezone and location. Signed-off-by: Chuck <33324927+ChuckBuilds@users.noreply.github.com> |
||
|
|
33f76b4895 |
feat(pi5): RP1 backend UI, gpio slowdown guidance, and hardware init error banner (#337)
* feat(pi5): expose RP1 backend selector, fix gpio defaults, surface init failures in web UI - Add rp1_rio select (PIO/RIO) to Display Settings hardware config section; saved via /api/v3/config/main with 0-or-1 validation — previously the key existed in config.json but was not editable from the UI - Update gpio_slowdown help text with per-model guidance (Pi 3: 3, Pi 4: 4, Pi 5: 4–5) and raise max from 5 → 10 to match full library range - Fix gpio_slowdown Python fallback default from 2 → 3 (only affects edge case where the runtime config section is absent; explicit config values are unchanged) - display_manager writes /tmp/led_matrix_hw_status.json at startup: ok/error; Display Settings page fetches it and shows a yellow warning banner when the matrix failed to initialize, including Pi 5 remediation steps - Add GET /api/v3/hardware/status endpoint that reads the status file - Improve fallback error log to include Pi 5 rebuild hint Pi 3/4 users: rp1_rio=0 is set in config but silently ignored by the library on non-RP1 hardware; all other changes are additive or tighten defaults only. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(pi5): correct gpio_slowdown guidance — Pi 5 PIO default is 1, not 4-5 The upstream library defaults gpio_slowdown to 1 for Pi 5 (IsPi4() ? 2 : 1). In PIO mode the value is a pixel-clock divisor, so 4-5 was unnecessarily conservative advice. Updated help text and error log to reflect the actual range (1-3 typical for Pi 5 PIO; inverted effect in RIO mode). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(security): atomic hw-status write, narrow bare excepts, urllib3 CVE floor - display_manager: replace open()+bare-except with tempfile.mkstemp→fsync→ chmod(0o600)→os.replace; adds symlink guard and logs errors via logger instead of swallowing them silently; pull json/tempfile to module imports - display_manager cleanup(): narrow broad `except Exception: pass` to (OSError, RuntimeError, ValueError, MemoryError) with debug log - api_v3 get_hardware_status(): catch json.JSONDecodeError and PermissionError explicitly; log full traceback server-side; return generic "Unable to read hardware status" to client instead of leaking str(e) - march-madness/requirements.txt: bump urllib3 floor 2.2.2→2.6.3 (CVE fix) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(template): apply |int filter to rp1_rio comparisons in display.html Without |int, a string-typed value (e.g. from a hand-edited config.json) causes both selected tests to fail and the select renders with no option pre-selected. Matches the existing pattern used for multiplexing. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Chuck <chuck@example.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> |
||
|
|
c6b79e11d5 |
fix: Codacy round-2 — urllib3 CVEs, missed JS/Python issues (#336)
urllib3 CVEs (10 Trivy findings):
plugin-repos/march-madness/requirements.txt: bump urllib3>=1.26.0 to
>=2.2.2 to address CVE-2021-33503, CVE-2023-43804, CVE-2023-45803,
CVE-2024-37891, and 2025-2026 decompression/redirect CVEs.
Missed code fixes from round-1:
display_helper.py: remove unused draw=ImageDraw.Draw(img) — the method
delegates to _draw_centered_text which creates its own draw context.
custom-feeds.js:334: one bare removeCustomFeedRow(this) was missed by
the earlier replace_all; changed to window.removeCustomFeedRow(this).
app.js: add htmx to /* global */ declaration — htmx.ajax() is called
at lines 146 and 172 but htmx was only declared in the extension files.
timezone-selector.js:215: second unused catch (e) → catch {} missed
when we fixed line 361 in round-1.
Bandit B110 annotations (3 new except/pass blocks from newer PRs):
start.py: hostname -I IP parsing — non-critical startup info.
display_controller.py: scroll_helper.get_portion_at — optional method.
display_manager.py: canvas reset during cleanup — best-effort.
41 confirmed false positives suppressed via Codacy API:
35x pyflakes in test/, plugin-repos/, scripts/ — not production code
Flask 0.0.0.0, os.execvp, Bandit B603, vendor ESLint, already-fixed
Biome noPrototypeBuiltins.
Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
|
||
|
|
d941c91f24 |
fix(systemd): wait for network connectivity before starting services (#335)
Change After=network.target → After=network-online.target + Wants=network- online.target in both service templates and install_web_service.sh. network.target only guarantees NetworkManager has started — it does NOT mean the device has an active internet connection. On boot the LED matrix service was starting within seconds of the network interface appearing, before WiFi association and DHCP completed, causing every first-update API call to fail with "Network is unreachable" or DNS resolution errors. network-online.target waits for a confirmed route before the service fires. On Raspberry Pi OS this is provided by NetworkManager-wait-online. The tradeoff is a few extra seconds at boot, acceptable for a display device. Observed on devpi: service started at 14:48:03, all API calls (weather, FlightRadar24, local ADS-B) failed at 14:48:07 with network errors, then the service restarted cleanly at 14:50:40 once WiFi was established. Co-authored-by: Chuck <chuck@example.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> |
||
|
|
054ad78d7b |
chore(deps): update rpi-rgb-led-matrix to latest upstream for Pi 5 support (#334)
* chore(deps): update rpi-rgb-led-matrix to latest upstream for Pi 5 support Configure submodule to track upstream master branch (branch = master in .gitmodules) so future updates are a single 'git submodule update --remote' rather than manual SHA management. Update first_time_install.sh to use --remote flag so fresh installs always pull the current upstream master, not the commit recorded at clone time. Current upstream HEAD (8907235) brings: - PR #1886: Raspberry Pi 5 support — new RP1 PIO and RIO backends. The library auto-detects Pi 5 hardware at runtime; no config change required for basic operation. adafruit-hat-pwm is confirmed supported on Pi 5. - PR #1833: setup.py migrated from distutils → setuptools, fixing Python 3.12+ build failure (Pi runs Python 3.13). Previous version could not build the bindings at all on current Pi OS. Expose new rp1_rio option in display_manager.py and config.template.json: 0 (default) = PIO mode — uses Pi 5 RP1 coprocessor, minimal CPU usage 1 = RIO mode — Registered IO, faster throughput, higher CPU; note that gpio_slowdown has inverted effect in this mode No API changes to RGBMatrix, RGBMatrixOptions, or FrameCanvas. Pi 4 and earlier hardware is unaffected — rp1_rio is silently ignored on non-Pi-5. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore(deps): update rpi-rgb-led-matrix install for new scikit-build-core system The library migrated from 'make build-python' + 'pip install bindings/python' to a scikit-build-core + cmake build where the entire repo root is pip- installable via 'pip install .'. Update first_time_install.sh accordingly: - Remove the 'make build-python' step (target no longer exists) - Install directly from the repo root instead of bindings/python - Replace build deps: remove cython3/scons/python3-dev, add python-dev-is-python3 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: deterministic submodule install + guard rp1_rio for older rgbmatrix first_time_install.sh: remove --remote from both git submodule update calls so first-time installs check out the pinned commit recorded in the repo rather than whatever upstream master happens to be at install time. The branch = master config in .gitmodules reserves --remote for an explicit maintainer upgrade (git submodule update --remote). display_manager.py: guard rp1_rio assignment with hasattr() so setting the option in config does not cause an AttributeError and silently fall through to emulator mode when running against RGBMatrixEmulator or an older rgbmatrix build that predates the Pi 5 property. Emit a warning instead so the operator knows the value was ignored. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Chuck <chuck@example.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> |
||
|
|
05b3fa56cb |
fix: Codacy security fixes, CVE dependency bumps, and code quality cleanup (#331)
* fix(deps): bump minimum versions to address CVEs Pillow 10.4.0 → 12.2.0: CVE-2026-40192 (DoS via FITS decompression bomb), CVE-2026-25990 (OOB write via PSD image), CVE-2026-42311/42308/42310 requests 2.32.0 → 2.33.0: CVE-2026-25645 (temp file security bypass), CVE-2024-47081 (.netrc credentials leak) werkzeug 3.0.0 → 3.1.6: CVE-2023-46136, CVE-2024-49766/49767, CVE-2025-66221, CVE-2026-21860/27199 (DoS, path traversal, safe_join bypass) Flask 3.0.0 → 3.1.3: CVE-2026-27205 (session data caching info disclosure) spotipy 2.24.0 → 2.25.2: CVE-2025-27154, CVE-2025-66040 python-socketio 5.11.0 → 5.14.0: CVE-2025-61765 pytest 7.4.0 → 9.0.3: CVE-2025-71176 (insecure temp dir handling) Updated in requirements.txt, web_interface/requirements.txt, plugin-repos/starlark-apps/requirements.txt, and plugin-repos/march-madness/requirements.txt. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: resolve Pylint errors in executor, data service, and odds call Rename TimeoutError to PluginTimeoutError in plugin_executor.py to avoid shadowing the built-in; no external callers affected. Remove dead try/except in BackgroundDataService.shutdown: executor.shutdown() never accepted a timeout kwarg so the try branch always raised TypeError. Simplify to a direct shutdown(wait=wait) call. Remove is_live kwarg from odds_manager.get_odds() call in sports.py; BaseOddsManager.get_odds() has no such parameter. The live update interval is already encoded in the update_interval_seconds argument passed alongside. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: MD5→SHA-256, shellcheck warnings, and broken doc links config_service.py: replace MD5 with SHA-256 for config change detection; same semantics (equality comparison), no stored hashes affected. Shell scripts — shellcheck warnings: - diagnose_web_interface.sh: remove useless cat (SC2002) - dev_plugin_setup.sh: restructure A&&B||C into if/then (SC2015) - fix_assets_permissions.sh: remove unused REAL_HOME block (SC2034) - install_web_service.sh: remove unused USER_HOME assignment (SC2034) - diagnose_web_ui.sh: remove unused SUDO assignments (SC2034) - diagnose_plugin_permissions.sh: remove unused BLUE color var (SC2034) - first_time_install.sh: remove unused CLEAR var, PACKAGE_NAME assignment, and replace loop variable with _ (SC2034) docs/PLUGIN_ARCHITECTURE_SPEC.md: fix 10 broken TOC anchor links to include section numbers matching the actual headings (MD051). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: remove unused imports and bare exception aliases (pyflakes F401/F841) Remove unused imports across 86 files in src/, web_interface/, test/, and scripts/ using autoflake. No logic changes — only dead import statements and unused names in from-imports are removed. Also remove bare exception aliases where the variable is never referenced in the handler body: - src/cache/disk_cache.py: except (IOError, OSError, PermissionError) as e - src/cache_manager.py: except (OSError, IOError, PermissionError) as perm_error - src/plugin_system/resource_monitor.py: except Exception as e - web_interface/app.py: except Exception as read_err 86 files changed, 205 lines removed, 18 pre-existing test failures unchanged. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: remove unused local variable assignments (pyflakes F841) Dead assignments removed across src/ and web_interface/: - background_data_service: drop future= on fire-and-forget executor.submit - base_classes/baseball: drop font= (all rendering uses self.fonts['time']) - base_classes/hockey: drop status_short= (never referenced after assignment) - common/cli: drop game_helper=/config_helper= bindings in import-test block; constructors called for instantiation-only validation - common/display_helper: drop text_width= (x_position uses display_width directly); drop draw= in create_error_image (uses _draw_centered_text) - config_manager: remove dead secrets_content loading block in migration path (comment already noted save_config_atomic handles secrets internally) - display_manager: drop setup_start= (timing was never completed or read) - font_manager: drop target_path= (catalog uses font_file_path directly); drop face=/font= bindings in validate_font (validation by construction — TypeError on failure is the signal, not the return value) - font_test_manager: drop width=/height= (draw_text uses display_manager directly) - plugin_system/state_reconciliation: drop manager= (only config/disk/state_mgr used) - plugin_system/store_manager: drop result= on pip install subprocess.run (check=True raises on failure; stdout unused) - web_interface/blueprints/pages_v3: drop main_config_path=""/secrets_config_path="" (render_template uses config_manager.get_*_path() inline) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(js): resolve ESLint no-undef warnings across 6 JS files Three distinct patterns: 1. Vendor library globals — htmx is injected by <script> before these extension files load; ESLint lints files in isolation and doesn't know. Fix: add /* global htmx */ to htmx-sse.js and htmx-json-enc.js. 2. Cross-file globals — showNotification is defined as window.showNotification in app.js/notification.js but called bare in app.js and error_handler.js. ESLint doesn't connect window.X = Y with a bare call to X. Fix: add /* global showNotification */ to app.js and error_handler.js. 3. Forward-reference window.* functions — in array-table.js, checkbox-group.js, and custom-feeds.js, functions like removeArrayTableRow are called early inside event-handler closures but assigned to window.* later in the file. At runtime this works (the handler fires after the assignment), but ESLint sees the bare name at the call site. Fix: change bare calls to window.removeArrayTableRow(this) etc. so the reference is explicit and ESLint-safe. Also guard the updateSystemStats call in app.js reconnectSSE: the function is called but defined nowhere in the codebase. Guard with typeof check so it won't throw ReferenceError if the reconnect path is hit. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(js): resolve Biome lint warnings across 9 JS files noUnusedVariables (catch bindings → optional catch syntax): - app.js, file-upload.js, timezone-selector.js: } catch (e) { → } catch { ES2019 optional catch binding; e was unused in all three handlers noUnusedVariables (dead assignments): - app.js: remove const data= in display SSE stub (handler does nothing yet) - api_client.js: remove const timeoutId= (setTimeout ID never used to cancel) - custom-feeds.js: remove const oldIndex= (getAttribute result never read) - schedule-picker.js: remove const compactMode= (never used in HTML build) - select-dropdown.js: remove const icons= (icons not yet rendered in options) noPrototypeBuiltins: - day-selector.js: DAY_LABELS.hasOwnProperty(x) → Object.prototype.hasOwnProperty.call(DAY_LABELS, x) Safe form that works even on null-prototype objects useIterableCallbackReturn: - file-upload.js, notification.js: forEach(x => expr) → forEach(x => { expr; }) — forEach ignores return values; implicit return from arrow body was misleading htmx-sse.js is a vendor extension file with old-style var/== patterns that are correct for it; 18 Biome issues suppressed via Codacy API rather than modifying the vendor source. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(security): escape user input in raw HTML responses in pages_v3.py plugin_id comes directly from the URL path (/partials/plugin-config/<plugin_id>) and was interpolated into an HTML fragment without escaping. A crafted URL like /partials/plugin-config/<script>alert(1)</script> would inject that tag into the DOM via the HTMX partial response. Fix: wrap all user-controlled values in markupsafe.escape() before embedding in raw HTML strings. Affects the plugin-not-found 404 response and both error 500 responses in the plugin config partial. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: address Bandit B108/B110 across production code B110 (try/except/pass): - display_controller.py: narrow 'except Exception' to 'except AttributeError' for get_offset_frame() — plugins not having this optional method is the expected case, not all exceptions - config_manager.py: B110 already resolved by the earlier removal of the dead secrets-loading block (the except/pass was inside it) - All other except/pass blocks in src/ and web_interface/ are intentional (last-resort recovery, best-effort fallbacks, non-critical startup probes). Annotated each with # nosec B110 and a brief inline reason so the decision is explicit for future reviewers. - Test files and plugin-repos B110 suppressed via Codacy API (not prod code). B108 (/tmp usage): - permission_utils.py: /tmp listed to PREVENT permission changes on it — not used as a temp path. Annotated # nosec B108. - display_manager.py: fixed snapshot path is intentional (web UI reads same path); path-check guard also annotated. - wifi_manager.py: named /tmp files match the sudoers allowlist installed with the system (the paths are hard-coded in both places by design). Annotated all six open/cp references # nosec B108. - scripts/render_plugin.py: dev script default overridable by user. Annotated. - web_interface/app.py: reads the same fixed path written by display_manager. Annotated # nosec B108. - Test files suppressed via Codacy API. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: address remaining Codacy security findings Flask debug=True (real fix): - web_interface/app.py: debug=True in __main__ block exposes the Werkzeug interactive debugger (arbitrary code execution). Changed to os.environ.get('FLASK_DEBUG', '0') == '1' — off by default, opt-in via environment variable for local development. nosec annotations (accepted risk with documented rationale): - disk_cache.py: os.chmod(0o660) is intentional — web UI and LED matrix service share a group, 660 gives group write while denying world access (B103 + Semgrep insecure-file-permissions suppressed in Codacy) - wifi_manager.py: urlopen to hardcoded connectivity-check.ubuntu.com URL (B310 — no user input involved) - font_manager.py: urlretrieve URL comes from user's own config file on their local device (B310) - start_web_conditionally.py: os.execvp with both sys.executable and a fixed PROJECT_DIR-relative constant (B606) Confirmed false positives suppressed via Codacy API (15 issues): - SSRF (3x): client-side JS fetch — SSRF is server-side; browser fetch is CORS-restricted to same origin - B105 (3x): test fixtures use dummy secrets by design; store_manager checks for the placeholder string, it is not itself a secret - PMD numeric literal (2x): 10000000 is within Number.MAX_SAFE_INTEGER - Prototype pollution (1x): read-only schema traversal, no writes - no-unsanitized_method (1x): dynamic import() is CORS-restricted - detect-unsafe-regex (1x): operates on server-controlled config values - plugin-repos B103 (1x): vendor code chmod on executable - Semgrep insecure-file-permissions (3x): same disk_cache 0o660 as above Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: remove unnecessary f prefix from f-strings without placeholders (F541) Pyflakes F541 flags f-strings that contain no {} interpolation — they are identical to plain strings but trigger unnecessary string formatting overhead. Fixed in production code: - src/base_classes/data_sources.py (2 debug log calls) - src/logo_downloader.py (1 error log) - src/plugin_system/store_manager.py (5 strings across 3 log calls) - src/web_interface/validators.py (1 return value) - src/wifi_manager.py (4 log/message strings) - web_interface/start.py (1 print) F541 issues in test/, scripts/, and plugin-repos/ suppressed via Codacy API as non-production code. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore(dev): add Pillow compatibility smoke test script Covers all Pillow APIs used in LEDMatrix — image creation, drawing, font metrics, LANCZOS resampling, paste/alpha_composite, and PNG I/O. Run after any Pillow version bump to catch regressions before deploy. python3 scripts/dev/test_pillow_compat.py Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: resolve 8 new Codacy issues introduced by PR changes shellcheck SC2034: - first_time_install.sh: 'type' loop variable also unused in the wifi status loop (we previously fixed 'device' → '_' but left 'type'). Changed to '_ _ state' since neither device nor type is referenced. ESLint no-undef: - app.js: typeof guards don't satisfy no-undef; added updateSystemStats to the /* global */ declaration alongside showNotification. nosec annotation: - web_interface/app.py: app.run(host='0.0.0.0') line changed when we fixed debug=True, giving it a new issue ID. Re-added # nosec B104. pyflakes F401: - scripts/dev/test_pillow_compat.py: ImageFilter was imported but never used in the smoke test. Removed from the import. Codacy API suppressions (false positives on changed lines): - disk_cache.py 0o660 chmod (2x): lines changed when # nosec B103 was added, producing new Semgrep issue IDs. Re-suppressed. - pages_v3.py raw-html-concat: Semgrep does not recognise escape() as a sanitizer; the escape() call IS the correct fix. - app.py flask 0.0.0.0: same line as B104 above; Semgrep rule also re-suppressed. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: address PR review findings Fix (10 of 15 findings): plugin-repos/march-madness/requirements.txt: Add urllib3>=1.26.0 — manager.py directly imports from urllib3; it was an undeclared transitive dependency via requests. scripts/dev/dev_plugin_setup.sh: Restore subshell form (cd "$target_dir" && git pull --rebase) || true so the shell's working directory is not permanently changed after the if-cd block. Previous fix for SC2015 leaked cwd into the remainder of the script. src/base_classes/sports.py: Narrow 'except Exception' to 'except RuntimeError as e' and log via self.logger.debug — Path.home() raises only RuntimeError for service users; other exceptions should not be silently swallowed. src/config_service.py: Fix stale "MD5 checksum" in ConfigVersion.__init__ docstring (line 40); the implementation uses SHA-256 since the Codacy fix. src/wifi_manager.py: Log the last-resort AP enable failure with exc_info=True instead of silently passing — failure here means the device may be unreachable. web_interface/blueprints/pages_v3.py: Log the outer metadata pre-load exception at debug level instead of swallowing it silently; schema still loads fully below. src/background_data_service.py: Remove unused 'timeout' parameter from shutdown() — executor.shutdown() does not accept timeout; update __del__ caller accordingly. src/font_manager.py: Validate URL scheme before urlretrieve — reject non-http/https schemes (e.g. file://) to prevent reading local files from config-supplied URLs. src/plugin_system/plugin_executor.py: Simplify redundant except tuple: (PluginTimeoutError, PluginError, Exception) → Exception, which already covers the others. test/test_display_controller.py: Mark empty test_plugin_discovery_and_loading as @pytest.mark.skip with reason. Move duplicate 'from datetime import datetime' to module header and remove the stray mid-module copy. Skip (5 of 15 findings, with reasons): - pytest 9.0.3 concerns: full suite already verified (467 pass, 18 pre-existing) - Pillow 12.2.0 API concerns: no deprecated APIs in codebase; tests + Pi smoke test pass - diagnose_web_ui.sh sudo validation: set -e already ensures fail-fast on any sudo failure - app.py request-logging except: must stay silent (recursive logging risk); annotated - app.py SSE file-read except: genuinely transient I/O; annotated Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Chuck <chuck@example.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> |
||
|
|
44d1a08db4 |
perf(plugins): dramatically speed up plugin manager tab load time (#333)
* fix(cache): check odds keys before generic live check in get_data_type_from_key Cache keys like odds_espn_basketball_nba_<id>_live contain both 'odds' and 'live'. The previous ordering matched the generic 'live' check first, returning 'sports_live' (30 s TTL) instead of the correct 'odds_live' (120 s TTL). This caused the ESPN odds API to be hit every 30 s per live game, frequently triggering the 3-second per-request timeout and returning no odds data. Moving the 'odds' check above the generic 'live' block restores the correct 120-second cache TTL for in-progress game odds. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(display): use single-quoted HTML attributes for JSON hidden inputs Placing |tojson output (which contains double quotes) inside a double-quoted HTML attribute broke the attribute — browsers closed the attribute at the first inner quote, leaving JS with an empty or truncated value. JSON.parse then failed silently, leaving excluded=[] so all Vegas scroll plugins appeared checked (included) regardless of the actual excluded_plugins config. Switch to single-quoted HTML attributes so the JSON double quotes are valid inside the attribute value. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * perf(plugins): dramatically speed up plugin manager tab load time ## Problem The Plugins tab loaded slowly and inconsistently (5–30s depending on cache state), with a blank spinner for the entire wait. Three root causes: 1. **N+1 subprocess per installed plugin** — `_get_local_git_info` ran 4 separate git subprocesses per plugin (rev-parse HEAD, abbrev-ref, config --get remote.origin.url, log --format=%cI). With 15 plugins that's 60 blocking subprocess spawns before the endpoint returned. 2. **Serial per-plugin loop** — the `/plugins/installed` endpoint processed each plugin sequentially: manifest read → git info → instance lookup → Vegas mode query, one plugin at a time. 3. **Serial JS loading** — the store search only started after installed plugins fully completed, so users waited for both round-trips back to back. No UI feedback during the wait. ## Changes ### Backend — src/plugin_system/store_manager.py - Consolidate 4 git subprocesses → 1: branch read from `.git/HEAD` (file I/O, no subprocess), remote URL parsed from `.git/config` (file I/O, no subprocess), SHA + commit date fetched together in a single `git log -1 --format=%H%n%cI` call - Existing signature-based cache already eliminates all subprocesses on warm hits; this change cuts cold-cache cost from 4 → 1 per plugin ### Backend — web_interface/blueprints/api_v3.py - Wrap per-plugin work in a `_build_plugin_entry()` helper and execute it across a `ThreadPoolExecutor(max_workers=8)` so all plugins are processed in parallel instead of sequentially - Fix double `get_plugin()` call per plugin (was called once for the enabled fallback and again for Vegas mode — now one shared call) ### Frontend — web_interface/static/v3/plugins_manager.js - Fire `searchPluginStore()` and `loadInstalledPlugins()` simultaneously instead of waiting for installed to complete before starting the store - After installed data arrives, call `applyStoreFiltersAndSort(true)` to refresh install/update/reinstall badges from already-cached store data (instant, no extra network call) ### Frontend — web_interface/templates/v3/partials/plugins.html - Add responsive skeleton cards to the installed plugins section that match real card proportions (removed automatically when data renders) - Replace the 5 featureless gray boxes in the store skeleton with 10 structured skeleton cards matching the real card layout ## Measured improvement on Pi 4 (11 installed plugins, ledpi-ticker) | Scenario | Before | After | |---|---|---| | Cold cache (first open) | ~8–15s | **0.9s** | | Warm cache (git cache hit) | ~1–2s | **55ms** | | UI feedback during load | blank spinner | skeleton cards | | Store waits for installed | yes (serial) | no (parallel) | Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(plugins): harden git metadata parsing and plugin entry building store_manager.py: - Detect worktree/submodule .git files (gitdir: <path>) and resolve to the actual git directory before reading HEAD or config - Wrap HEAD read_text in try/except OSError/NotADirectoryError so atypical repos return None instead of propagating exceptions - Guard config url line split with '=' presence check to avoid IndexError on malformed lines api_v3.py: - Wrap _build_plugin_entry body in a try/except via a thin outer wrapper so a single plugin's failure doesn't 500 the whole endpoint; failed entries return None and are filtered by the existing [r for r in results if r is not None] step - Narrow manifest except clause to FileNotFoundError, PermissionError, json.JSONDecodeError instead of bare Exception - Validate manifest is a dict before calling plugin_info.update() and log a debug message when it isn't Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> |
||
|
|
6a4644007d |
fix(display): Vegas excluded plugins always showing as checked (#332)
* fix(cache): check odds keys before generic live check in get_data_type_from_key Cache keys like odds_espn_basketball_nba_<id>_live contain both 'odds' and 'live'. The previous ordering matched the generic 'live' check first, returning 'sports_live' (30 s TTL) instead of the correct 'odds_live' (120 s TTL). This caused the ESPN odds API to be hit every 30 s per live game, frequently triggering the 3-second per-request timeout and returning no odds data. Moving the 'odds' check above the generic 'live' block restores the correct 120-second cache TTL for in-progress game odds. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(display): use single-quoted HTML attributes for JSON hidden inputs Placing |tojson output (which contains double quotes) inside a double-quoted HTML attribute broke the attribute — browsers closed the attribute at the first inner quote, leaving JS with an empty or truncated value. JSON.parse then failed silently, leaving excluded=[] so all Vegas scroll plugins appeared checked (included) regardless of the actual excluded_plugins config. Switch to single-quoted HTML attributes so the JSON double quotes are valid inside the attribute value. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> |
||
|
|
1c4d5c5271 |
feat(sync): multi-display wireless sync — extend scrolling across two LED matrices (#330)
* feat(sync): multi-display wireless sync — extend scrolling across two LED matrices Adds a leader/follower sync system that extends Vegas scroll mode content continuously across two physically adjacent LED matrix units over WiFi. Architecture: - Leader broadcasts scroll position via UDP at ~90fps; follower renders the offset slice of the same image at 60fps using dead reckoning to absorb UDP jitter (smooth, stutter-free motion) - At each cycle transition the leader sends the composed scroll image via TCP (PNG-compressed ~15–40KB) so both displays render pixel-identical content regardless of plugin data timing differences - Auto-discovery via UDP subnet broadcast — no IP configuration required - Heartbeat watchdog (6s timeout) falls back to standalone if peer goes offline Key files: - src/common/sync_manager.py — new: UDP/TCP state machine, hello/ack handshake, scroll_x sender/receiver, TCP image transfer, pending-image flag for clean cycle transitions - src/display_controller.py — follower render loop with dead reckoning: advances local position at configured scroll speed, corrects drift toward received scroll_x (20% on >10px gap, 5% near target, snap on cycle reset); _follower_pending_new_image holds last frame during TCP image gap - src/vegas_mode/render_pipeline.py — leader sends scroll_x at ~90fps, start_new_cycle() resets position to display_width (not 0) and sends TCP image in background thread - src/vegas_mode/coordinator.py — set_sync_manager() / set_update_callback() wiring; defers hot-swap recompose while sync is active - web_interface/blueprints/api_v3.py — sync config save endpoint, GET /api/v3/sync/status for live status polling - web_interface/templates/v3/partials/display.html — Multi-Display Sync section: role selector (Standalone/Leader/Follower), position (Left/Right of leader, follower only), UDP port, live status indicator - config/config.template.json — sync block: role, port, follower_position Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(sync): address PR review findings - sync_manager: replace Optional[callable] with proper Callable types from typing; tighten set_on_new_cycle/set_on_scroll_image/set_on_follower_connected signatures to match their actual callback signatures - sync_manager: log a one-shot warning when send_frame produces a packet exceeding the 65000-byte UDP cap instead of silently dropping it - display_controller: correct stale comment in _send_follower_frame (was "30fps / PNG encode/decode"; actual behavior is ~90fps raw RGB) - display.html: guard setInterval with window.syncStatusInterval to prevent duplicate pollers if the script runs more than once - display.html: replace innerHTML with DOM node creation + textContent for status icon/text to avoid inserting API-derived values via innerHTML Skip: time.time() → monotonic and self.config staleness are pre-existing issues not introduced by this PR. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(sync): address second round of PR review findings - sync_manager: guard TCP image receive against OOM — validate length against 10 MB cap before allocating; log and close on invalid length - display_controller: _follower_gated_update now allows update_display() through when the leader is offline (is_follower_active() == False) so the display recovers normally when falling back to standalone mode - coordinator: normalize a standalone SyncManager to None in set_sync_manager() so the render pipeline never treats a no-op manager as an active one - coordinator: derive _UPDATE_TICK_FRAMES from target_fps * 4 instead of the hardcoded 500 so the ~4s cadence holds at any configured FPS - render_pipeline: replace bare except/pass on blank-frame push with logger.exception() so failures are visible in logs Skip: config.template.json comments — JSON does not support inline comments. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(sync): address third round of PR review findings - sync_manager: use 'with socket.socket(...)' in send_scroll_image so the TCP socket is always closed even if connect/sendall raises - sync_manager: add _scroll_image_lock to serialize all reads/writes to _on_scroll_image and _pending_scroll_image between _image_server_loop and set_on_scroll_image, eliminating the lost-delivery race; callback is invoked outside the lock to avoid holding it during user code - sync_manager: validate scroll image dimensions (max 100000×256) and catch DecompressionBombError before img.load() in _image_server_loop - sync_manager: log socket close exceptions at debug level in stop() instead of silently passing - sync_manager: replace hardcoded /tmp/ with tempfile.gettempdir() for STATUS_FILE (atomic write was already in place) - sync_manager: check _RAW_MAGIC first in _follower_recv_loop routing so magic-tagged frames are always identified correctly regardless of size Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(sync): address fourth round of PR review findings - sync_manager: log INCOMPATIBLE error only on state transition (guard with prev_state != LeaderState.INCOMPATIBLE) so repeated hello packets from an incompatible follower don't spam the log - sync_manager: replace O(n²) bytes concatenation in TCP image receive loop with bytearray + extend() for linear-time accumulation Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(sync): suppress Codacy false positives - display_controller: rename local var 'sh' to 'scroll_h' so Codacy's pattern matcher doesn't confuse it with the 'sh' shell library - sync_manager: add '# nosec B104' to all socket.bind("") calls — binding to all interfaces is intentional (UDP broadcast reception and TCP image server must accept connections from any local interface) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(sync): add nosec B104 to socket creation lines for Codacy Codacy attributes the bind-to-all-interfaces finding to the socket.socket() creation lines (140, 439) rather than the .bind() calls. Added # nosec B104 there too so the suppression is seen at the line Codacy reports. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> |
||
|
|
dbb53da31d |
fix(vegas): eliminate plugin re-appearance at scroll cycle boundaries (#327)
* fix(vegas): eliminate plugin re-appearance at scroll cycle boundaries The Vegas scroll image is wider than the display. scroll_helper marks a cycle complete only after total_distance_scrolled >= total_scroll_width + display_width, meaning it keeps scrolling for an extra display_width of pixels after all content has exited left. During that extra travel the scroll_position wraps back to ~0 and the first plugin re-enters from the right - visible for ~2-3 seconds as a plugin partially displaying before the next one starts. render_pipeline.render_frame(): end the cycle the moment total_distance_scrolled >= total_scroll_width (the natural wrap point), before any second-pass content becomes visible. Push a blank frame immediately on detection so hardware never shows a frozen content snapshot while start_new_cycle() recomposes (~100 ms). display_manager.py: add capture_mode() context manager. When active, update_display() and the canvas clear in clear() skip the hardware write, preventing plugins that call update_display() internally from flashing on the matrix during off-screen content capture inside start_new_cycle(). plugin_adapter.py: wrap all plugin.display() calls in _capture_display_content() and _trigger_scroll_content_generation() with capture_mode() so the fallback capture path never produces hardware output. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(vegas): tighten exception handling in clear() and blank-frame push display_manager.clear(): replace bare except/pass on the three hardware Clear() calls with (RuntimeError, OSError) and a logger.error() so failures are visible in logs rather than silently swallowed. Still non-fatal — the PIL image buffer is already black before these calls, so the next update_display() will push clean content regardless. render_pipeline.render_frame(): replace broad except/pass in the blank-frame push with (ImportError, ValueError, TypeError, MemoryError) and a logger.error() that includes display dimensions for context. update_display() already handles its own hardware errors internally. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(vegas): catch OSError and RuntimeError in blank-frame push Image.new() can raise OSError in some PIL environments and hardware libraries may surface RuntimeError on I/O failures. Add both to the exception tuple alongside the existing ImportError/ValueError/TypeError/ MemoryError so no boundary failure escapes the local handler. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> |
||
|
|
452afacd12 |
fix(news): custom RSS feed save fails with validation error when no logo (#329)
_set_missing_booleans_to_false was unconditionally creating an empty
dict for every nested-object sub-property when processing array items.
For the news plugin's custom_feeds, this produced logo:{} on every feed
item that had no logo uploaded. jsonschema then validated that empty
object against logo's required:["id","path"] constraint and failed.
Fix: skip recursion into a sub-object when it isn't already present in
the array item. There's no reason to create an optional object like
logo just to look for boolean fields inside it.
Also extend _filter_config_by_schema to recurse into array items when
the items schema has properties. Previously arrays were passed through
unchanged, so any stray field on a feed item (legacy data, migration
artifacts) would survive to validation where additionalProperties:false
would reject it.
Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
|
||
|
|
3b45a75f75 |
fix: pixlet install false-failure, force render in web service, web UI perf (#328)
* fix: pixlet install false-failure, force render in web service, web UI perf Fixes three user-reported issues: 1. Pixlet install reported failure even on success — `set -e` in download_pixlet.sh caused the script to exit non-zero when `((success_count++))` evaluated the post-increment old value (0), which bash treats as a failed command. Fixed with shell arithmetic assignment instead. 2. Force render returned 503 "plugin not loaded in web service" — the web service runs its own PluginManager with display_manager=None, so the starlark-apps plugin can never be loaded there. Added a standalone render path (_standalone_render_starlark_app) that calls pixlet directly from the web service, writing to cached_render.webp. The main-service plugin path is preserved and tried first. 3. Web UI sluggishness — the SSE /stream/stats generator was blocking a Flask thread for 1s per tick via psutil.cpu_percent(interval=1). Switched to non-blocking interval=None (primed at startup). Also cached the per-client ledmatrix systemctl check (15s TTL), raised the AP mode check TTL from 5s to 30s, and halved the display preview poll rate from 0.5s to 1s. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor(starlark): surface config errors, deduplicate pixlet lookup, uniform response shape - _standalone_render_starlark_app: split silent except into separate json.JSONDecodeError and OSError handlers that return (False, message) with the file path and parse error, so callers know when config.json is unreadable rather than silently rendering with empty config - get_starlark_status: replace 14-line inline platform/shutil pixlet lookup with _find_pixlet_binary(), which also checks the user- configured starlark-apps.pixlet_path — the old code never consulted that setting so the status endpoint could wrongly report pixlet unavailable even when a custom path was configured - render_starlark_app standalone branch: add frame_count: 0 to success and error responses so both branches return the same payload shape; 0 is accurate since standalone render writes cached_render.webp but does not extract frames into memory Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(starlark): safe chmod fallback in pixlet lookup, semantic HTTP codes in standalone render _find_pixlet_binary: bundled.chmod(0o755) could raise OSError (e.g. file owned by root) and abort the entire resolution chain, skipping the PATH fallback. Now checks os.access first; if chmod is needed, wraps it in try/except OSError, logs a warning, and falls through to shutil.which so PATH is always tried. _standalone_render_starlark_app: expanded return type from (bool, str) to (bool, int, str) so each failure mode carries a semantic HTTP status code: app/star file missing → 404 invalid/unreadable config.json → 400 pixlet binary missing → 503 pixlet non-zero exit → 502 subprocess timeout → 504 unexpected exception → 500 success → 200 Call site updated to unpack (success, status_code, error) and forward status_code directly in both success and error responses. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(starlark): validate config.json is a dict before iterating items json.load succeeds on valid JSON that isn't an object (e.g. an array or bare string), leaving app_config as a non-dict and causing an AttributeError on the subsequent .items() call. Added isinstance check immediately after json.load; non-dict values return (False, 400, ...) with the actual type name in the message. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(starlark): harden pixlet binary check and manifest shape validation _find_pixlet_binary: switched bundled.exists() to bundled.is_file() so a directory named pixlet-linux-arm64 (traversable, hence X_OK) is no longer returned as a binary; re-check os.access after chmod succeeds so we only return the path when executability is confirmed, with a separate warning if it still isn't executable after chmod. _standalone_render_starlark_app: added isinstance guards on the manifest and apps values returned by _read_starlark_manifest(); a manifest.json containing valid non-object JSON (e.g. an array) would previously raise AttributeError on .get(); invalid shape now returns (False, 400, ...) instead. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Chuck <chuck@example.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> |
||
|
|
1a0f1c8015 |
fix: service control buttons and AP-mode SSH lockout post-install (#326)
* fix: service control buttons and AP-mode SSH lockout post-install
Two user-reported issues after fresh install:
1. All service buttons (Start/Stop/Restart Display, Restart Web Service)
failed silently — only Reboot worked.
Root cause: sudoers rules use `ledmatrix.service` (with suffix) but
api_v3.py called `sudo systemctl start ledmatrix` (no suffix). sudo
does exact string matching, so every service action was rejected with
returncode=1. Also missing from sudoers: ledmatrix-web, journalctl,
and is-active entries.
Fix:
- Add `.service` suffix to all 8 sudo systemctl call sites in
api_v3.py (_ensure_display_service_running, _stop_display_service,
and all execute_system_action branches).
- Add timeout=15 to all subprocess.run calls in execute_system_action
(previously could hang indefinitely).
- Add missing sudoers rules to first_time_install.sh and
configure_web_sudo.sh: ledmatrix-web.service start/stop/restart,
is-active for both name forms, and journalctl -u/-t ledmatrix rules.
2. SSH and web UI became inaccessible after ~1 hour even though the
display kept running.
Root cause: wifi_monitor_daemon restarts NetworkManager after 5
consecutive internet failures (~2.5 min). Each NM restart drops WiFi
briefly. During that window check_and_manage_ap_mode() increments
_disconnected_checks but the daemon never reset it after the restart.
After 3 such NM-restart cycles, _disconnected_checks reached 3 and
AP mode activated — changing the Pi from WiFi client to hotspot
(192.168.4.1) and killing SSH on the old IP.
Fix:
- Reset wifi_manager._disconnected_checks = 0 in the daemon
immediately after a successful NM restart so the brief drop it
causes doesn't count toward AP-mode activation.
- Increase _disconnected_checks_required from 3 to 6 (90s → 3min)
as an additional buffer against transient network flaps.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* revert: restore AP-mode grace period to 90s (3 checks)
The counter reset after NM restart already fully prevents the SSH-lockout
cascade: _disconnected_checks can never accumulate across NM restarts
because it is reset to 0 before the next daemon iteration runs.
The 3→6 increase provided no additional fix for the described problem and
caused a UX regression: fresh Pi devices with no WiFi configured would
wait 3 minutes instead of 90 seconds for the LEDMatrix-Setup hotspot to
appear.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix: address five valid review findings; skip two
Fixed:
- march-madness/requirements.txt: Pillow>=10.3.0 (patches CVE-2024-28219;
10.3.0 is the actual fix version — reviewer cited 12.2.0 but that risks
breaking API changes without test coverage)
- wifi_monitor_daemon.py: add missing `import subprocess`; subprocess.run
and CalledProcessError would NameError at runtime on the NM restart path
- wifi_manager.py: validate ap_idle_timeout_minutes before arithmetic —
coerce to int, clamp 1–1440, fall back to 15 on bad config values
- wifi_manager.py: call _remove_nm_dnsmasq_captive_conf() on all three
rollback paths in _enable_ap_mode_nmcli_hotspot() and in the top-level
except block so stale dnsmasq drop-ins are never left behind
- api_v3.py: fix wrong_password prefix strip — removeprefix("wrong_password:")
then lstrip() handles both "wrong_password: msg" and "wrong_password:msg"
- plugins_manager.js: add .catch() to loadInstalledPlugins().then() to
surface failures instead of silently dropping unhandled rejections
Skipped:
- WiFiManager AP state persistence: architectural overhaul; _is_ap_mode_active()
already derives from live system state, not in-memory variables
- Absolute subprocess paths in api_v3.py: paths vary by distro (/usr/bin vs
/bin); web service has a normal PATH; sudoers already use resolved paths
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix: address five review findings (NM retry loop, start_display message, code quality)
- wifi_monitor_daemon: reset _consecutive_internet_failures = 0 in both
NM-restart exception handlers; previously both left the counter at threshold,
causing an immediate retry on the next iteration instead of waiting another
full backoff period
- api_v3: fix start_display failure message — when mode is set and systemctl
returns non-zero, message now includes the failure reason and a hint rather
than always reporting success phrasing
- wifi_manager: move _redirect_backend from class variable to instance variable
in __init__ alongside _ap_enabled_at; class-level default shadowed correctly
in practice (single instance) but was misleading
- wifi_manager: narrow broad except Exception in _check_internet_connectivity
to (subprocess.SubprocessError, OSError) for ping and OSError for HTTP
(urllib.error.URLError is an OSError subclass in Python 3)
- wifi_manager: remove redundant local 'import re as _re' in _validate_ap_config;
re is already imported at module level (line 37)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix: address five review findings (Pillow CVEs, daemon exception narrowing, timeout handling, plugin store)
- march-madness/requirements.txt: Pillow>=12.2.0 (patches CVE-2026-42308
and CVE-2026-42310; previous floor of 10.3.0 was insufficient)
- wifi_monitor_daemon: narrow final except Exception to
(subprocess.SubprocessError, OSError) so programming errors in the NM
restart block are no longer silently swallowed
- api_v3/execute_system_action: add explicit subprocess.TimeoutExpired
handler before the generic Exception catch; returns action-specific
message with 'status','message','returncode','stdout','stderr' fields
so the UI receives a precise, actionable payload instead of the generic
'Failed to execute system action' string
- plugins_manager.js: move searchPluginStore into .finally() so the
plugin store renders regardless of whether loadInstalledPlugins succeeds
or fails; .catch() still logs the error
- first_time_install.sh: add safe_plugin_rm.sh NOPASSWD rule to the
/tmp/ledmatrix_web_sudoers block; configure_web_sudo.sh had this rule
but the standalone installer never granted it, leaving plugin removal
broken after first-time install
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* refactor(api): resolve sudo/systemctl/reboot/poweroff paths at startup
Use shutil.which() with safe fallbacks for the four privileged binaries
instead of relying on bare names being resolved by the subprocess shell
search. Resolves paths once at module load rather than per-call.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
|
||
|
|
b361866679 |
fix(security): escape user-controlled output in plugin action UI (#323)
* style: trim trailing whitespace and fix EOF in plugins_manager.js Autofix from pre-commit hooks (trailing-whitespace, end-of-file-fixer). No code logic changes — purely whitespace. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(security): escape user-controlled output in plugin action UI The plugin action result handlers in executePluginAction() injected data.message, data.output, and data.auth_url directly into innerHTML template literals without escaping. A plugin's action handler returning malicious content (e.g., from a third-party plugin or compromised upstream) could execute arbitrary JavaScript in the web UI context. Wrap user-controlled strings in escapeHtml() at all four sites: - Step-2 (continuation) error path (message + output) - OAuth flow auth_url (link href + display text, with http:// guard) - Step-1 simple-success output - Step-1 failure path (message + output) The escapeHtml() helper is already defined in this file and used elsewhere (validation errors, plugin store cards). Co-Authored-By: 5ymb01 <5ymb01@users.noreply.github.com> Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: 5ymb01 <5ymb01@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
ceb4c4105f |
fix(wifi): reliable open AP with captive portal — tested on Trixie Pi (#320)
* fix(wifi): create truly open AP via nmcli connection add; add captive portal to nmcli path nmcli device wifi hotspot always attaches a WPA2 PSK on Bookworm/Trixie and silently ignores post-creation security modifications, causing users to be prompted for an unknown password. Switch to nmcli connection add with 802-11-wireless.mode ap and no security section — NM cannot auto-add a password to a profile that has no 802-11-wireless-security block. Also: - Remove dead DEFAULT_AP_PASSWORD / ap_password config field (stored but never passed to hostapd or nmcli, causing user confusion) - Add iptables port 80→5000 redirect to the nmcli AP path so captive portal auto-popup works on phones without hostapd (previously only worked on the hostapd path) - Clean up iptables rules on disable for the nmcli path - Improve LED message on AP enable: show SSID, "No password", and IP:port on both paths so users know exactly how to connect - Fix systemd template: replace hardcoded /home/ledpi/LEDMatrix/ with __PROJECT_ROOT_DIR__ placeholder (install script already writes correct path) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(wifi): address Codacy review findings in AP mode implementation - Validate ap_ssid/ap_channel from config before passing to subprocess (printable ASCII ≤32 chars; channel 1-14) to prevent command injection - Fix INPUT iptables rule: PREROUTING redirects port 80→5000 so the INPUT chain sees dport=5000, not 80. Old INPUT rule on port 80 was a no-op. - Refactor iptables setup/teardown into _setup_iptables_redirect() and _teardown_iptables_redirect() helpers, eliminating duplicate logic in the hostapd and nmcli paths - Save/restore ip_forward state (via /tmp/ledmatrix_ip_forward_saved) instead of forcing it to 0 on cleanup, which could break VPNs or bridges already relying on forwarding - nmcli path skips ip_forward management entirely: NM's ipv4.method=shared already manages it for the duration of the connection - Fix _get_ap_status_nmcli() verification: new 'connection add type wifi' profiles have type '802-11-wireless', not 'hotspot', so verification was always returning False. Now also matches by our known connection name. - Remove SSID-based connection deletion: deleting any profile whose SSID matched the AP SSID could destroy a user's saved home WiFi profile. Now only deletes by our application-managed profile names. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(plugins): fix async race in refreshPlugins; use cache TTL to gate re-swap metadata fetch refreshPlugins() called searchPluginStore(true) and showNotification() immediately after refreshInstalledPlugins() without awaiting the returned Promise, so window.installedPlugins could still be stale when the store rendered its Installed/Reinstall badges. Chain .then() so both run only after the fetch completes. In initializePlugins(), the re-swap path always passed fetchCommitInfo=false to searchPluginStore, skipping GitHub metadata even when the 5-minute cache TTL had expired. Add storeCacheExpired() helper and compute isReswapWarm = _reswap && !storeCacheExpired() so fresh metadata is fetched whenever the cache is cold, regardless of whether the render is a first load or a tab re-swap. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: address three wifi_manager and one plugins_manager review findings wifi_manager.py: - _create_hostapd_config: use _validate_ap_config() for ssid/channel instead of raw self.config values; strip newlines from SSID to prevent config-file injection via the generated hostapd.conf - _setup_iptables_redirect: check return codes of sysctl ip_forward enable and both iptables -A calls; on any failure log the error output, call _teardown_iptables_redirect() to restore state, and return False instead of silently succeeding - _enable_ap_mode_nmcli_hotspot: on AP verification failure roll back fully — tear down iptables redirect, delete the LEDMatrix-Setup-AP connection profile, clear the LED message — before returning False plugins_manager.js: - initializePlugins: chain searchPluginStore(!isReswapWarm) inside loadInstalledPlugins().then() so window.installedPlugins is populated before the store renders Installed/Reinstall badges (same pattern applied to refreshPlugins() in the previous commit) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(wifi): use _find_command_path for iptables/sysctl; harden ip_forward save/restore Add _find_command_path() helper that extends _check_command()'s sbin-aware lookup to return the absolute binary path rather than a boolean. Use it in _setup_iptables_redirect and _teardown_iptables_redirect so iptables and sysctl are resolved via /sbin or /usr/sbin even when those directories are absent from PATH in systemd service environments. Also harden the ip_forward save/restore logic: - Read ip_forward from /proc/sys/net/ipv4/ip_forward (no subprocess, no PATH dependency) instead of spawning sysctl -n - Skip the sysctl -w ip_forward=1 write when the value is already "1" to avoid mutating state owned by another service (VPN, NM shared mode, bridge) - Track save success via presence of the save file: if the /proc read or file write fails, leave the file absent so teardown knows not to restore - In _teardown_iptables_redirect, only restore ip_forward when the save file exists; if absent, leave the current value untouched rather than forcing "0" Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(wifi): check _setup_iptables_redirect return; fix hostapd LED SSID; teardown on exception - Both AP startup paths (hostapd and nmcli) now check the bool returned by _setup_iptables_redirect() and treat False as a hard failure: the hostapd path stops hostapd/dnsmasq and returns an error tuple; the nmcli path brings down and deletes the LEDMatrix-Setup-AP profile and clears the LED message - _enable_ap_mode_hostapd's LED message now calls _validate_ap_config() to get the same sanitized SSID that _create_hostapd_config() uses, so the displayed name always matches the AP actually broadcast by hostapd - _setup_iptables_redirect's outer except block now calls _teardown_iptables_redirect() before returning False so partial iptables/ ip_forward state is always cleaned up on unexpected exceptions; cleanup exceptions are caught and logged separately Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test(wifi): add unit tests for AP mode — open network, iptables, LED, cleanup ordering Six pytest unit tests covering the five review scenarios. All subprocess and filesystem side-effects are mocked so the tests run without root, hardware, or a Pi OS environment. 1. test_nmcli_ap_profile_has_no_security_params — asserts the nmcli connection add command has no key-mgmt / psk / WPA arguments and sets mode=ap. 2. test_iptables_nat_rules_added_on_ap_start — verifies _setup_iptables_redirect emits a PREROUTING REDIRECT 80→5000 rule and an INPUT ACCEPT rule for port 5000 (not 80, which never hits INPUT after PREROUTING rewrites it). 3. test_iptables_rules_and_ip_forward_reverted_on_teardown — verifies the -D PREROUTING/-D INPUT calls and that sysctl restores the saved ip_forward value and removes the save file. 4. test_ip_forward_not_restored_when_save_file_absent — verifies teardown skips sysctl when the save file was never written, preventing blind ip_forward=0 on systems using ip_forward for VPNs or NM shared mode. 5. test_led_message_shows_ssid_no_password_and_url — asserts the LED message includes the SSID, 'No password', and the 192.168.4.1:5000 setup URL. 6. test_existing_ap_profiles_deleted_before_new_profile_created — asserts all known profile names are targeted for deletion before 'nmcli connection add'. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(wifi): adopt adsb-feeder-image hotspot patterns — DNS spoofing, connectivity check, idle timeout, wrong-password UX, watchdog escalation Inspired by the production-proven approach in dirkhh/adsb-feeder-image. 1. DNS spoofing for automatic captive-portal popup (Change 1 — Critical) Write /etc/NetworkManager/dnsmasq-shared.d/ledmatrix-captive.conf with address=/#/192.168.4.1 before nmcli connection up so NM's built-in dnsmasq (ipv4.method=shared) resolves every hostname to the AP IP. This triggers the OS captive-portal popup automatically on iOS / Android / Windows / macOS — no manual navigation to 192.168.4.1:5000/setup required. New helpers: _write_nm_dnsmasq_captive_conf / _remove_nm_dnsmasq_captive_conf. New constants: NM_DNSMASQ_SHARED_DIR / NM_DNSMASQ_SHARED_CONF. 2. Real internet connectivity check (Change 2 — High) Add _check_internet_connectivity() (ping 8.8.8.8 + HTTP fallback). check_and_manage_ap_mode() now considers a device "disconnected" when nmcli shows connected but no real internet reachability, matching adsb-feeder's multi-method gateway/DNS/HTTP test approach. 3. AP idle timeout (Change 3 — Medium) Track _ap_enabled_at timestamp in enable_ap_mode(). Add _has_ap_clients() using 'iw dev <iface> station dump'. check_and_manage_ap_mode() auto-disables AP after ap_idle_timeout_minutes (default 15) with no associated clients. 4. Wrong-password error feedback (Change 4 — Medium) _connect_nmcli() detects "Secrets were required" / "authentication rejected" in nmcli stderr and prefixes the message with "wrong_password: ". The /api/v3/wifi/connect route propagates error_type="wrong_password" in the JSON response. captive_setup.html shows "Incorrect password — try again" (keeping the form active) instead of the generic failure message. 5. Escalating watchdog NM restart (Change 5 — Low) wifi_monitor_daemon.py tracks _consecutive_internet_failures. After _nm_restart_threshold (5) consecutive checks where nmcli shows connected but internet is unreachable, restart NetworkManager as a recovery step. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(wifi): restore safe AP-enable trigger; decouple internet check from AP logic The previous commit introduced _check_internet_connectivity() into check_and_manage_ap_mode(), which shared the same _disconnected_checks counter that triggers AP enable. This created a false-positive risk: 90 seconds of packet loss on working WiFi would enable AP mode and kick off the connection. Fix: restore nmcli association state as the sole AP-enable trigger (original, safe behaviour). The internet connectivity check is now used only in the daemon watchdog for the NM-restart escalation — matching how adsb-feeder-image actually structures the two concerns (initial setup detection vs. ongoing monitoring). Also clarify daemon comment: the connectivity check runs once per cycle in the watchdog block, not inside check_and_manage_ap_mode, so there is no double-call. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(wifi): remove PMF setting from open AP profile — breaks nmcli connection add on Trixie NM 1.52+ 802-11-wireless-security.pmf is only valid within a security section that also includes key-mgmt. Adding it to an open-network profile causes NM 1.52+ to reject the connection add with 'key-mgmt: property is missing'. PMF has no meaning for open APs (it only applies to WPA2/WPA3), so the setting is simply removed rather than worked around. Found by testing on devpi (Trixie, NM 1.52.1). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(wifi): add nftables fallback for port redirect; graceful degradation when neither available Tested on devpi (Trixie, NM 1.52.1): iptables is not installed; nftables is. The original code called _setup_iptables_redirect() and treated 'iptables not found' as a hard failure, rolling back the entire AP setup. Changes: - _setup_iptables_redirect() now tries iptables first, then nftables as a fallback. When neither is available it logs a warning and returns True so the AP still comes up (DNS spoofing still triggers the captive portal popup; users land on port 5000 directly instead of being auto-redirected from 80). - Split into _setup_iptables_redirect_iptables() and _setup_iptables_redirect_nftables() for clarity. - Added _redirect_backend instance var ("iptables" | "nftables" | None) so _teardown_iptables_redirect() uses the same tool that setup used. - nftables teardown: deletes the 'ledmatrix' table (clean, no leftover rules). - iptables teardown: unchanged logic (ip_forward save/restore). - Also removed the PMF workaround for Trixie: 802-11-wireless-security.pmf requires key-mgmt to also be set, breaking open-network creation on NM 1.52+. Open APs have no management frame protection by definition. - Update teardown test to set _redirect_backend = "iptables" before calling it. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(wifi): public check_internet_connectivity(); absolute systemctl path; stricter mode assertion wifi_manager.py: - Add public check_internet_connectivity() wrapping the private method so the daemon does not reach into the private API wifi_monitor_daemon.py: - Call wifi_manager.check_internet_connectivity() instead of the private _check_internet_connectivity() - Use /usr/bin/systemctl (absolute path) instead of bare "systemctl" - Wrap NM restart in try/except with check=True; only reset _consecutive_internet_failures on success — on CalledProcessError or other exception, log the error and leave the counter unchanged so the next cycle retries test/test_wifi_manager_ap.py: - Replace loose `assert "ap" in add_calls[0]` (list-membership check that could be satisfied by any element equal to "ap") with an explicit key/value check: locate "802-11-wireless.mode" in the command list and assert the next element is exactly "ap" Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Chuck <chuck@example.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> |
||
|
|
e9af18cdf1 |
Add Codacy badge to README (#322)
Added a Codacy badge to the README for code quality. Signed-off-by: Chuck <33324927+ChuckBuilds@users.noreply.github.com> |
||
|
|
5e6c40ad55 |
fix(plugins): plugin manager tab doesn't always load on first visit (#319)
* fix(plugins): reset pluginsInitialized on HTMX re-swap; use refreshInstalledPlugins in refreshPlugins HTMX's `revealed` trigger fires each time the plugins tab becomes visible (Alpine's x-show toggles display:none which re-triggers the IntersectionObserver). Each re-reveal fetches a fresh empty HTML skeleton via hx-swap="innerHTML". The htmx:afterSwap handler reset window.pluginManager.initialized/initializing but not pluginsInitialized, so initializePlugins() hit its guard and skipped loadInstalledPlugins() and searchPluginStore() — leaving the fresh empty DOM unpopulated. Fix: also reset pluginsInitialized = false in the afterSwap handler. Existing caches (3s for installed plugins, 5min for store) mean tab revisits within the TTL render from cache instantly with no extra API traffic. Also change refreshPlugins() to call refreshInstalledPlugins() (which already exists and explicitly invalidates the cache) instead of the bare loadInstalledPlugins() call that could silently skip the fetch if the 3-second cache happened to still be valid. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(plugins): use cached store on HTMX re-swap; reserve searchPluginStore(true) for first load searchPluginStore(true) bypasses the isCacheValid check unconditionally, so every tab revisit was hitting the GitHub commit-info API even within the 5-minute cache window. Set window.pluginManager._reswap = true in the htmx:afterSwap handler and read it in initializePlugins() to call searchPluginStore(false) on re-swaps (respects the 5-minute cache) vs searchPluginStore(true) on first load (always fetches fresh). Explicit user refresh via refreshPlugins() already calls searchPluginStore(true) directly. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Chuck <chuck@example.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> |
||
|
|
d6bd1ee215 |
fix(install): prevent weather and music from auto-installing on fresh install (#318)
* fix(install): remove weather and music credential stubs from secrets template config_secrets.template.json shipped ledmatrix-weather and music as top-level keys; config_manager deep-merges secrets into the main config on load, so the reconciler treated them as plugin config entries and auto-installed both plugins on first web UI visit after a fresh install. Remove both keys from the template and clear the inline fallback block in first_time_install.sh so new installs start clean. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(install): sync fallback secrets with template structure The fallback block (used when config_secrets.template.json is missing) was an empty object after the weather/music keys were removed. Mirror the current template so youtube and github placeholders are always present regardless of whether the template file exists. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Chuck <chuck@example.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> |
||
|
|
acaf8a248e |
feat(web): update-available banner in web UI (#311)
* feat(web): add update-available banner to web UI Adds a polite, dismissible banner between the header and navigation tabs that appears when the local repo is behind origin/main. Shows commit count and a one-click "Update Now" button that triggers the existing git_pull action. - New GET /api/v3/system/check-update endpoint (5-min cache, compares local HEAD vs origin/main SHA) - Banner auto-checks on page load then every 30 minutes - Dismiss persists for the browser session via sessionStorage - Styled for both light and dark themes - Cache invalidated after successful git_pull so banner hides immediately Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(update-banner): address review findings — lock, returncode checks, update_available logic, a11y, button state - Add _update_check_lock (threading.Lock) around all reads/writes to _update_check_cache in check_for_update() and git_pull, preventing races on concurrent requests - Validate returncode for git fetch, rev-parse HEAD, and rev-parse origin/main; raise RuntimeError on failure so errors are caught and returned as error payloads instead of silently producing stale/empty SHAs - Set update_available = commits_behind > 0 (was unconditionally True when local_sha != remote_sha); prevents false positive when local is ahead of remote - Add type="button" and aria-label="Dismiss update" to the icon-only dismiss button - Restore btn.innerHTML and btn.disabled in both success and error paths of applyUpdate(); only hide the banner and clear sessionStorage when data.status === 'success' Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(update-banner): address second-round review findings api_v3.py: - Move all git work inside _update_check_lock so concurrent requests re-check cache staleness after acquiring the lock; only the first caller runs git fetch/rev-parse/log, subsequent callers return the cached result - Check log_result.returncode and raise on failure so a broken git log doesn't produce a silent false-negative (commits_behind=0) - Rename loop variable l → commit_line base.html: - Replace boolean _dismissed flag with SHA-scoped sessionStorage key 'update-sha-dismissed'; dismissing for SHA X still allows the banner to reappear when origin/main advances to SHA Y - Successful applyUpdate clears 'update-sha-dismissed' so the next update cycle can show the banner again - Add aria-live="polite" aria-atomic="true" to #update-banner-text so screen readers announce content changes Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Chuck <chuck@example.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> |