16 Commits

Author SHA1 Message Date
Chuck
8652aacf37 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>
2026-05-23 16:50:24 -04:00
Chuck
76507014ce 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>
2026-05-23 16:44:33 -04:00
Chuck
53806da8c5 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>
2026-05-23 16:33:24 -04:00
Chuck
3d4de89fd5 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>
2026-05-23 16:32:04 -04:00
Chuck
505fed70e3 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>
2026-05-23 16:30:24 -04:00
Chuck
c8d2eaeb85 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>
2026-05-23 16:18:37 -04:00
Chuck
745ba8101e 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>
2026-05-23 16:16:31 -04:00
Chuck
ddc53ff1e0 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>
2026-05-23 12:29:34 -04:00
Chuck
2cd3dbabe5 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>
2026-05-23 12:20:54 -04:00
Chuck
f4e7fea7bb 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>
2026-05-23 10:32:56 -04:00
Chuck
a5c7ef20ec 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>
2026-05-23 10:21:20 -04:00
Chuck
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>
2026-05-22 16:02:58 -04:00
Chuck
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>
2026-05-22 14:25:38 -04:00
Chuck
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>
2026-05-21 15:53:16 -04:00
Chuck
9cd2bd14ce Update README.md (#342)
Signed-off-by: Chuck <33324927+ChuckBuilds@users.noreply.github.com>
2026-05-19 20:47:34 -04:00
Chuck
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>
2026-05-19 20:00:41 -04:00
13 changed files with 565 additions and 78 deletions

View File

@@ -1,5 +1,10 @@
# LEDMatrix # LEDMatrix
[![License](https://img.shields.io/badge/license-GPL--3.0-green)](LICENSE)
[![Discord](https://img.shields.io/badge/Discord-community-5865F2?logo=discord&logoColor=white)](https://discord.gg/RdrC37rEag)
[![GitHub Stars](https://img.shields.io/github/stars/ChuckBuilds/ledmatrix?style=flat&color=yellow)](https://github.com/ChuckBuilds/ledmatrix)
[![Codacy Badge](https://app.codacy.com/project/badge/Grade/77fc9b446a5948e5b0aed7a7aaeb1bab)](https://app.codacy.com/gh/ChuckBuilds/LEDMatrix/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade) [![Codacy Badge](https://app.codacy.com/project/badge/Grade/77fc9b446a5948e5b0aed7a7aaeb1bab)](https://app.codacy.com/gh/ChuckBuilds/LEDMatrix/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade)
## Welcome to LEDMatrix! ## Welcome to LEDMatrix!
Welcome to the LEDMatrix Project! This open-source project enables you to run an information-rich display on a Raspberry Pi connected to an LED RGB Matrix panel. Whether you want to see your calendar, weather forecasts, sports scores, stock prices, or any other information at a glance, LEDMatrix brings it all together. Welcome to the LEDMatrix Project! This open-source project enables you to run an information-rich display on a Raspberry Pi connected to an LED RGB Matrix panel. Whether you want to see your calendar, weather forecasts, sports scores, stock prices, or any other information at a glance, LEDMatrix brings it all together.
@@ -127,10 +132,15 @@ The system supports live, recent, and upcoming game information for multiple spo
| This project can be finnicky! RGB LED Matrix displays are not built the same or to a high-quality standard. We have seen many displays arrive dead or partially working in our discord. Please purchase from a reputable vendor. | | This project can be finnicky! RGB LED Matrix displays are not built the same or to a high-quality standard. We have seen many displays arrive dead or partially working in our discord. Please purchase from a reputable vendor. |
### Raspberry Pi ### Raspberry Pi
- Raspberry Pi Zero's don't have enough processing power for this project and the Pi 5 is unsupported due to new GPIO output. - Raspberry Pi Zero's don't have enough processing power for this project.
- **Raspberry Pi 3B or 4 (NOT RPi 5!)** - **Raspberry Pi 3B, 4, or 5**
[Amazon Affiliate Link Raspberry Pi 4 4GB RAM](https://amzn.to/4dJixuX) [Amazon Affiliate Link Raspberry Pi 4 4GB RAM](https://amzn.to/4dJixuX)
[Amazon Affiliate Link Raspberry Pi 4 8GB RAM](https://amzn.to/4qbqY7F) [Amazon Affiliate Link Raspberry Pi 4 8GB RAM](https://amzn.to/4qbqY7F)
- **Pi 5 users**: the installer automatically detects Pi 5 and builds the `rpi-rgb-led-matrix` library with RP1 support. If you previously installed on a Pi 4 and migrated the SD card, or if you see `mmap` errors in the logs, force a fresh library build:
```bash
sudo RPI_RGB_FORCE_REBUILD=1 ./first_time_install.sh
```
- Pi 5 config: leave `rp1_rio` at `0` (PIO mode, default) and set `gpio_slowdown` to `1` or `2`.
### RGB Matrix Bonnet / HAT ### RGB Matrix Bonnet / HAT
@@ -582,7 +592,7 @@ These settings control runtime behavior and GPIO timing:
- **Critical setting**: Must match your Raspberry Pi model for stability - **Critical setting**: Must match your Raspberry Pi model for stability
- **Raspberry Pi 3**: Use 3 - **Raspberry Pi 3**: Use 3
- **Raspberry Pi 4**: Use 4 - **Raspberry Pi 4**: Use 4
- **Raspberry Pi 5**: Use 5 (or higher if needed) - **Raspberry Pi 5**: Use 12 in PIO mode (`rp1_rio: 0`, the default); start with `1` and increase if you see flickering
- **Raspberry Pi Zero/1**: Use 1-2 - **Raspberry Pi Zero/1**: Use 1-2
- Incorrect values can cause display corruption, flickering, or system instability - Incorrect values can cause display corruption, flickering, or system instability
- If you experience issues, try adjusting this value up or down by 1 - If you experience issues, try adjusting this value up or down by 1

View File

@@ -36,9 +36,17 @@ if [ -r /proc/device-tree/model ]; then
DEVICE_MODEL=$(tr -d '\0' </proc/device-tree/model) DEVICE_MODEL=$(tr -d '\0' </proc/device-tree/model)
echo "Detected device: $DEVICE_MODEL" echo "Detected device: $DEVICE_MODEL"
else else
DEVICE_MODEL=""
echo "⚠ Could not detect Raspberry Pi model (continuing anyway)" echo "⚠ Could not detect Raspberry Pi model (continuing anyway)"
fi fi
# Detect Pi 5 for hardware-specific install decisions (RP1 library verification)
IS_PI5=0
if echo "${DEVICE_MODEL:-}" | grep -qi "Raspberry Pi 5"; then
IS_PI5=1
echo "Raspberry Pi 5 detected — will verify RP1 library support."
fi
# Check OS version - must be Raspberry Pi OS Lite (Trixie) # Check OS version - must be Raspberry Pi OS Lite (Trixie)
echo "" echo ""
echo "Checking operating system requirements..." echo "Checking operating system requirements..."
@@ -783,9 +791,28 @@ CURRENT_STEP="Build and install rpi-rgb-led-matrix"
echo "Step 6: Building and installing rpi-rgb-led-matrix..." echo "Step 6: Building and installing rpi-rgb-led-matrix..."
echo "-----------------------------------------------------" echo "-----------------------------------------------------"
# If already installed and not forcing rebuild, skip expensive build # On Pi 5, also check that the installed library has rp1_rio support.
# A library built before Pi 5 support was added imports fine but maps to the
# Pi 3 peripheral bus address (0x3f000000) instead of the RP1 chip at runtime.
_HAS_RP1=0
if python3 -c 'from rgbmatrix import RGBMatrixOptions; assert hasattr(RGBMatrixOptions(), "rp1_rio")' >/dev/null 2>&1; then
_HAS_RP1=1
fi
_SKIP_BUILD=0
if python3 -c 'from rgbmatrix import RGBMatrix, RGBMatrixOptions' >/dev/null 2>&1 && [ "${RPI_RGB_FORCE_REBUILD:-0}" != "1" ]; then if python3 -c 'from rgbmatrix import RGBMatrix, RGBMatrixOptions' >/dev/null 2>&1 && [ "${RPI_RGB_FORCE_REBUILD:-0}" != "1" ]; then
echo "rgbmatrix Python package already available; skipping build (set RPI_RGB_FORCE_REBUILD=1 to force rebuild)." if [ "$IS_PI5" = "1" ] && [ "$_HAS_RP1" = "0" ]; then
echo "⚠ Pi 5 detected: installed rgbmatrix lacks rp1_rio support (older build)."
echo " Forcing rebuild to get Pi 5 RP1 support..."
else
_SKIP_BUILD=1
fi
fi
if [ "$_SKIP_BUILD" = "1" ]; then
_skip_suffix=""
if [ "$IS_PI5" = "1" ]; then _skip_suffix=" with Pi 5 RP1 support"; fi
echo "rgbmatrix already installed${_skip_suffix}; skipping build (set RPI_RGB_FORCE_REBUILD=1 to force rebuild)."
else else
# Ensure rpi-rgb-led-matrix submodule is initialized # Ensure rpi-rgb-led-matrix submodule is initialized
if [ ! -d "$PROJECT_ROOT_DIR/rpi-rgb-led-matrix-master" ]; then if [ ! -d "$PROJECT_ROOT_DIR/rpi-rgb-led-matrix-master" ]; then
@@ -852,6 +879,17 @@ except Exception as e:
PY PY
then then
echo "✓ rpi-rgb-led-matrix installed and verified" echo "✓ rpi-rgb-led-matrix installed and verified"
# Pi 5: confirm the freshly-built library has rp1_rio support
if [ "$IS_PI5" = "1" ]; then
if python3 -c 'from rgbmatrix import RGBMatrixOptions; assert hasattr(RGBMatrixOptions(), "rp1_rio")' >/dev/null 2>&1; then
echo "✓ Pi 5 RP1 (rp1_rio) support confirmed"
else
echo "⚠ rp1_rio not found after rebuild — the submodule may be an older version."
echo " Try updating the submodule and rebuilding:"
echo " git submodule update --remote rpi-rgb-led-matrix-master"
echo " sudo RPI_RGB_FORCE_REBUILD=1 ./first_time_install.sh"
fi
fi
else else
echo "✗ rpi-rgb-led-matrix import test failed" echo "✗ rpi-rgb-led-matrix import test failed"
exit 1 exit 1

View File

@@ -110,9 +110,10 @@ class DisplayManager:
options.rp1_rio = runtime_config.get('rp1_rio') options.rp1_rio = runtime_config.get('rp1_rio')
else: else:
logger.warning( logger.warning(
"rp1_rio is set in config but the current RGBMatrixOptions " "rp1_rio is set in config but the installed rgbmatrix library does "
"implementation does not support it (RGBMatrixEmulator or older " "not support it — the library was likely built without Pi 5 RP1 "
"library version) — value will be ignored" "support (mmap to 0x3f000000 instead of RP1 chip). "
"Fix: sudo RPI_RGB_FORCE_REBUILD=1 ./first_time_install.sh"
) )
logger.info(f"Initializing RGB Matrix with settings: rows={options.rows}, cols={options.cols}, chain_length={options.chain_length}, parallel={options.parallel}, hardware_mapping={options.hardware_mapping}") logger.info(f"Initializing RGB Matrix with settings: rows={options.rows}, cols={options.cols}, chain_length={options.chain_length}, parallel={options.parallel}, hardware_mapping={options.hardware_mapping}")
@@ -189,7 +190,7 @@ class DisplayManager:
json.dump(_hw_status, _f) json.dump(_hw_status, _f)
_f.flush() _f.flush()
os.fsync(_f.fileno()) os.fsync(_f.fileno())
os.chmod(_tmp_path, 0o600) os.chmod(_tmp_path, 0o644)
os.replace(_tmp_path, _status_path) os.replace(_tmp_path, _status_path)
except Exception: except Exception:
try: try:

View File

@@ -171,10 +171,24 @@ class PluginLoader:
self.logger.info("Dependencies installed successfully for %s", plugin_id) self.logger.info("Dependencies installed successfully for %s", plugin_id)
return True return True
else: else:
stderr = result.stderr or ""
# uninstall-no-record-file means the package is already present at the
# system level (e.g. installed via dnf/apt without a pip RECORD file).
# pip can't replace it, but it IS installed — write the marker so we
# don't retry on every restart.
if "uninstall-no-record-file" in stderr:
self.logger.warning(
"Dependencies for %s include system-managed packages (no pip RECORD). "
"Assuming they are satisfied: %s",
plugin_id, stderr.strip()
)
marker_path.touch()
ensure_file_permissions(marker_path, get_plugin_file_mode())
return True
self.logger.warning( self.logger.warning(
"Dependency installation returned non-zero exit code for %s: %s", "Dependency installation returned non-zero exit code for %s: %s",
plugin_id, plugin_id,
result.stderr stderr
) )
return False return False
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:

View File

@@ -10,6 +10,7 @@ import json
import stat import stat
import subprocess import subprocess
import shutil import shutil
import threading
import zipfile import zipfile
import tempfile import tempfile
import requests import requests
@@ -100,6 +101,10 @@ class PluginStoreManager:
# handlers. Bumping the cached-entry timestamp on failure serves # handlers. Bumping the cached-entry timestamp on failure serves
# the stale payload cheaply until the backoff expires. # the stale payload cheaply until the backoff expires.
self._failure_backoff_seconds = 60 self._failure_backoff_seconds = 60
# Prevents concurrent callers from each firing a network request when
# the registry cache expires. Only one thread fetches; others wait and
# then get the result from the warm cache (double-checked locking).
self._registry_fetch_lock = threading.Lock()
# Ensure plugins directory exists # Ensure plugins directory exists
self.plugins_dir.mkdir(exist_ok=True) self.plugins_dir.mkdir(exist_ok=True)
@@ -575,6 +580,15 @@ class PluginStoreManager:
(current_time - self.registry_cache_time) < self.registry_cache_timeout): (current_time - self.registry_cache_time) < self.registry_cache_timeout):
return self.registry_cache return self.registry_cache
with self._registry_fetch_lock:
# Re-check inside the lock — a concurrent caller that was waiting
# may have already populated the cache while we blocked.
current_time = time.time()
if (self.registry_cache and self.registry_cache_time and
not force_refresh and
(current_time - self.registry_cache_time) < self.registry_cache_timeout):
return self.registry_cache
try: try:
self.logger.info(f"Fetching plugin registry from {self.REGISTRY_URL}") self.logger.info(f"Fetching plugin registry from {self.REGISTRY_URL}")
response = self._http_get_with_retries(self.REGISTRY_URL, timeout=10) response = self._http_get_with_retries(self.REGISTRY_URL, timeout=10)

View File

@@ -716,6 +716,41 @@ def _run_startup_reconciliation() -> None:
"manual 'Reconcile' action to resolve.", "manual 'Reconcile' action to resolve.",
len(result.inconsistencies_manual), len(result.inconsistencies_manual),
) )
# Write status file so the web UI can surface unresolved issues as a
# banner without the user having to read journalctl. Mirrors the
# hw_status pattern (/tmp/led_matrix_hw_status.json).
import json as _json, tempfile as _tempfile, os as _os
_recon_status = {
"done": True,
"successful": result.reconciliation_successful,
"fixed_count": len(result.inconsistencies_fixed),
"unresolved": [
{
"plugin_id": inc.plugin_id,
"type": inc.inconsistency_type.value,
"description": inc.description,
}
for inc in result.inconsistencies_manual
],
}
_recon_path = _os.path.join(_tempfile.gettempdir(), "ledmatrix_reconciliation.json")
_tmp = None
try:
if not _os.path.islink(_recon_path):
_fd, _tmp = _tempfile.mkstemp(dir=_tempfile.gettempdir(), prefix=".led_recon_")
with _os.fdopen(_fd, "w") as _f:
_json.dump(_recon_status, _f)
_os.replace(_tmp, _recon_path)
_tmp = None # Rename succeeded; nothing to clean up
except (OSError, ValueError, TypeError) as _e:
_logger.warning("[Reconciliation] Could not write status file: %s", _e)
finally:
if _tmp is not None and _os.path.exists(_tmp):
try:
_os.unlink(_tmp)
except OSError:
pass
except Exception as e: except Exception as e:
_logger.error("[Reconciliation] Error: %s", e, exc_info=True) _logger.error("[Reconciliation] Error: %s", e, exc_info=True)
finally: finally:

View File

@@ -2,14 +2,17 @@ from flask import Blueprint, request, jsonify, Response
import json import json
import os import os
import re import re
import stat
import sys import sys
import subprocess import subprocess
import tempfile
import time import time
import hashlib import hashlib
import uuid import uuid
import logging import logging
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Dict, Any
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -1382,7 +1385,61 @@ def get_system_version():
version = get_git_version() version = get_git_version()
return jsonify({'status': 'success', 'data': {'version': version}}) return jsonify({'status': 'success', 'data': {'version': version}})
except Exception as e: except Exception as e:
return jsonify({'status': 'error', 'message': str(e)}), 500 logger.error("get_system_version failed: %s", e, exc_info=True)
return jsonify({'status': 'error', 'message': 'Unable to retrieve version'}), 500
_update_check_cache: Dict[str, Any] = {'result': None, 'ts': 0.0}
_UPDATE_CHECK_TTL = 300 # 5 minutes — avoids a git fetch on every page load
@api_v3.route('/system/check-update', methods=['GET'])
def check_for_update():
"""Check whether a newer LEDMatrix commit is available on origin/main."""
now = time.time()
if _update_check_cache['result'] and now - _update_check_cache['ts'] < _UPDATE_CHECK_TTL:
return jsonify(_update_check_cache['result'])
_safe: Dict[str, Any] = {'update_available': False, 'remote_sha': 'unknown', 'commits_behind': 0}
try:
cwd = str(PROJECT_ROOT)
fetch_result = subprocess.run(
['git', 'fetch', 'origin', 'main', '--quiet'],
capture_output=True, timeout=10, cwd=cwd,
)
if fetch_result.returncode != 0:
logger.warning("check-update: git fetch failed (rc=%d): %s",
fetch_result.returncode,
fetch_result.stderr.decode(errors='replace').strip())
_update_check_cache['result'] = _safe
_update_check_cache['ts'] = now
return jsonify(_safe)
local = subprocess.run(
['git', 'rev-parse', 'HEAD'],
capture_output=True, text=True, timeout=5, cwd=cwd,
).stdout.strip()
remote = subprocess.run(
['git', 'rev-parse', 'origin/main'],
capture_output=True, text=True, timeout=5, cwd=cwd,
).stdout.strip()
if not local or not remote:
return jsonify(_safe)
if local == remote:
result: Dict[str, Any] = {'update_available': False, 'remote_sha': remote, 'commits_behind': 0}
else:
count_str = subprocess.run(
['git', 'rev-list', 'HEAD..origin/main', '--count'],
capture_output=True, text=True, timeout=5, cwd=cwd,
).stdout.strip()
count = int(count_str) if count_str.isdigit() else 0
result = {'update_available': count > 0, 'remote_sha': remote, 'commits_behind': count}
_update_check_cache['result'] = result
_update_check_cache['ts'] = now
return jsonify(result)
except Exception as e:
logger.warning("check-update failed: %s", e)
return jsonify(_safe)
@api_v3.route('/system/action', methods=['POST']) @api_v3.route('/system/action', methods=['POST'])
def execute_system_action(): def execute_system_action():
@@ -1529,11 +1586,8 @@ def execute_system_action():
}) })
except Exception as e: except Exception as e:
import traceback logger.error("execute_system_action failed: %s", e, exc_info=True)
error_details = traceback.format_exc() return jsonify({'status': 'error', 'message': 'Action failed; see logs for details'}), 500
print(f"Error in execute_system_action: {str(e)}")
print(error_details)
return jsonify({'status': 'error', 'message': str(e), 'details': error_details}), 500
@api_v3.route('/hardware/status', methods=['GET']) @api_v3.route('/hardware/status', methods=['GET'])
def get_hardware_status(): def get_hardware_status():
@@ -1545,9 +1599,12 @@ def get_hardware_status():
return jsonify({"status": "success", "data": hw_data}) return jsonify({"status": "success", "data": hw_data})
except FileNotFoundError: except FileNotFoundError:
return jsonify({"status": "success", "data": {"ok": None, "error": "Display service not yet started"}}) return jsonify({"status": "success", "data": {"ok": None, "error": "Display service not yet started"}})
except (json.JSONDecodeError, PermissionError): except PermissionError:
logger.error("Failed to read hardware status file", exc_info=True) logger.warning("Permission denied reading hardware status file; display service may be running as a different user")
return jsonify({"status": "error", "message": "Unable to read hardware status"}), 500 return jsonify({"status": "success", "data": {"ok": False, "error": "Hardware status temporarily unavailable"}})
except json.JSONDecodeError:
logger.error("Failed to parse hardware status file", exc_info=True)
return jsonify({"status": "success", "data": {"ok": False, "error": "Hardware status file corrupted"}})
except Exception: except Exception:
logger.error("Unexpected error reading hardware status", exc_info=True) logger.error("Unexpected error reading hardware status", exc_info=True)
return jsonify({"status": "error", "message": "Unable to read hardware status"}), 500 return jsonify({"status": "error", "message": "Unable to read hardware status"}), 500
@@ -2433,6 +2490,28 @@ def reconcile_plugin_state():
status_code=500 status_code=500
) )
@api_v3.route('/plugins/reconciliation-status', methods=['GET'])
def get_reconciliation_status():
"""Return the result of the last startup reconciliation from /tmp status file."""
_recon_path = os.path.join(tempfile.gettempdir(), "ledmatrix_reconciliation.json")
try:
st = os.lstat(_recon_path)
except FileNotFoundError:
return jsonify({'status': 'success', 'data': {'done': False, 'unresolved': []}})
if stat.S_ISLNK(st.st_mode) or not stat.S_ISREG(st.st_mode):
logger.warning("[Reconciliation] Status file is not a regular file: %s", _recon_path)
return jsonify({'status': 'success', 'data': {'done': False, 'unresolved': []}})
try:
with open(_recon_path) as _f:
data = json.load(_f)
return jsonify({'status': 'success', 'data': data})
except json.JSONDecodeError:
logger.exception("[Reconciliation] Failed to parse status file: %s", _recon_path)
return jsonify({'status': 'success', 'data': {'done': False, 'unresolved': []}})
except PermissionError:
logger.exception("[Reconciliation] Permission denied reading status file: %s", _recon_path)
return jsonify({'status': 'success', 'data': {'done': False, 'unresolved': []}})
@api_v3.route('/plugins/config', methods=['GET']) @api_v3.route('/plugins/config', methods=['GET'])
def get_plugin_config(): def get_plugin_config():
"""Get plugin configuration""" """Get plugin configuration"""
@@ -6987,3 +7066,187 @@ def clear_old_errors():
details=str(e), details=str(e),
status_code=500 status_code=500
) )
# ---------------------------------------------------------------------------
# Backup / Restore
# ---------------------------------------------------------------------------
_BACKUP_EXPORT_DIR = PROJECT_ROOT / "config" / "backups" / "exports"
def _safe_backup_path(filename: str) -> Path:
"""Resolve a filename to an absolute path inside the export dir,
rejecting any traversal attempts. Returns None if unsafe."""
if not filename or '/' in filename or '\\' in filename or filename.startswith('.'):
return None
path = (_BACKUP_EXPORT_DIR / filename).resolve()
try:
path.relative_to(_BACKUP_EXPORT_DIR.resolve())
except ValueError:
return None
return path
@api_v3.route('/backup/preview', methods=['GET'])
def backup_preview():
"""Return a summary of what a new backup would include."""
try:
from src.backup_manager import preview_backup_contents
data = preview_backup_contents(PROJECT_ROOT)
return jsonify({'status': 'success', 'data': data})
except Exception as e:
logger.error("backup_preview failed: %s", e, exc_info=True)
return jsonify({'status': 'error', 'message': 'An internal error occurred; see logs for details'}), 500
@api_v3.route('/backup/list', methods=['GET'])
def backup_list():
"""List backup ZIPs stored in the export directory."""
try:
_BACKUP_EXPORT_DIR.mkdir(parents=True, exist_ok=True)
entries = []
for p in sorted(_BACKUP_EXPORT_DIR.iterdir(), key=lambda x: x.stat().st_mtime, reverse=True):
if not p.is_file() or p.suffix != '.zip':
continue
st = p.stat()
entries.append({
'filename': p.name,
'size': st.st_size,
'created_at': datetime.fromtimestamp(st.st_mtime).strftime('%Y-%m-%d %H:%M:%S'),
})
return jsonify({'status': 'success', 'data': entries})
except Exception as e:
logger.error("backup_list failed: %s", e, exc_info=True)
return jsonify({'status': 'error', 'message': 'An internal error occurred; see logs for details'}), 500
@api_v3.route('/backup/export', methods=['POST'])
def backup_export():
"""Create a new backup ZIP and return its filename."""
try:
from src.backup_manager import create_backup
zip_path = create_backup(PROJECT_ROOT, output_dir=_BACKUP_EXPORT_DIR)
return jsonify({'status': 'success', 'filename': zip_path.name})
except Exception as e:
logger.error("backup_export failed: %s", e, exc_info=True)
return jsonify({'status': 'error', 'message': 'An internal error occurred; see logs for details'}), 500
@api_v3.route('/backup/validate', methods=['POST'])
def backup_validate():
"""Validate an uploaded backup ZIP and return its manifest."""
try:
from src.backup_manager import validate_backup
if 'backup_file' not in request.files:
return jsonify({'status': 'error', 'message': 'No backup_file in request'}), 400
f = request.files['backup_file']
with tempfile.NamedTemporaryFile(suffix='.zip', delete=False) as tmp:
tmp_path = tmp.name
f.save(tmp_path)
try:
ok, err_msg, manifest = validate_backup(Path(tmp_path))
finally:
try:
os.unlink(tmp_path)
except OSError:
pass
if not ok:
logger.warning("Backup validation failed: %s", err_msg)
return jsonify({'status': 'error', 'message': 'Invalid or corrupted backup file'}), 400
return jsonify({'status': 'success', 'data': manifest})
except Exception as e:
logger.error("backup_validate failed: %s", e, exc_info=True)
return jsonify({'status': 'error', 'message': 'An internal error occurred; see logs for details'}), 500
@api_v3.route('/backup/restore', methods=['POST'])
def backup_restore():
"""Restore a backup ZIP with optional RestoreOptions."""
try:
from src.backup_manager import restore_backup, RestoreOptions
if 'backup_file' not in request.files:
return jsonify({'status': 'error', 'message': 'No backup_file in request'}), 400
f = request.files['backup_file']
options_raw = request.form.get('options', '{}')
try:
opts_dict = json.loads(options_raw)
except json.JSONDecodeError:
opts_dict = {}
options = RestoreOptions(
restore_config=bool(opts_dict.get('restore_config', True)),
restore_secrets=bool(opts_dict.get('restore_secrets', True)),
restore_wifi=bool(opts_dict.get('restore_wifi', True)),
restore_fonts=bool(opts_dict.get('restore_fonts', True)),
restore_plugin_uploads=bool(opts_dict.get('restore_plugin_uploads', True)),
reinstall_plugins=bool(opts_dict.get('reinstall_plugins', True)),
)
with tempfile.NamedTemporaryFile(suffix='.zip', delete=False) as tmp:
tmp_path = tmp.name
f.save(tmp_path)
try:
result = restore_backup(Path(tmp_path), PROJECT_ROOT, options)
finally:
try:
os.unlink(tmp_path)
except OSError:
pass
# Reinstall plugins if requested and store manager available
if options.reinstall_plugins and result.plugins_to_install:
psm = getattr(api_v3, 'plugin_store_manager', None) or plugin_store_manager
for plug in result.plugins_to_install:
pid = plug.get('plugin_id')
if not pid:
continue
try:
if psm and hasattr(psm, 'install_plugin'):
ok = psm.install_plugin(pid)
if ok:
result.plugins_installed.append(pid)
else:
result.plugins_failed.append({'plugin_id': pid, 'error': 'install_plugin returned False'})
else:
result.plugins_failed.append({'plugin_id': pid, 'error': 'Store manager unavailable'})
except Exception as pe:
result.plugins_failed.append({'plugin_id': pid, 'error': str(pe)})
data = result.to_dict()
if not result.success:
return jsonify({'status': 'error', 'message': 'Restore had errors', 'data': data}), 500
return jsonify({'status': 'success', 'data': data})
except Exception as e:
logger.error("backup_restore failed: %s", e, exc_info=True)
return jsonify({'status': 'error', 'message': 'An internal error occurred; see logs for details'}), 500
@api_v3.route('/backup/download/<path:filename>', methods=['GET'])
def backup_download(filename):
"""Stream a backup ZIP to the browser."""
from flask import send_from_directory
if _safe_backup_path(filename) is None:
return jsonify({'status': 'error', 'message': 'Backup not found'}), 404
try:
# send_from_directory uses werkzeug safe_join internally — CodeQL-recognized sanitizer.
return send_from_directory(_BACKUP_EXPORT_DIR, filename, as_attachment=True)
except FileNotFoundError:
return jsonify({'status': 'error', 'message': 'Backup not found'}), 404
@api_v3.route('/backup/<path:filename>', methods=['DELETE'])
def backup_delete(filename):
"""Delete a stored backup ZIP."""
safe = _safe_backup_path(filename)
if safe is None:
return jsonify({'status': 'error', 'message': 'Backup not found'}), 404
# Enumerate the export directory and match by name so the unlink target is
# a filesystem-derived path rather than one constructed from user input.
try:
for entry in _BACKUP_EXPORT_DIR.iterdir():
if entry.is_file() and entry.name == safe.name:
entry.unlink()
return jsonify({'status': 'success'})
except OSError as e:
logger.error("backup_delete failed: %s", e, exc_info=True)
return jsonify({'status': 'error', 'message': 'An internal error occurred; see logs for details'}), 500
return jsonify({'status': 'error', 'message': 'Backup not found'}), 404

View File

@@ -7473,17 +7473,28 @@ setTimeout(function() {
console.log('installed-plugins-grid not found yet, will retry via event listeners'); console.log('installed-plugins-grid not found yet, will retry via event listeners');
} }
// Also try to attach install button handler after a delay (fallback) // Also try to attach install button handler after a delay (fallback).
// Only run if the install button element is already in the DOM (i.e. the
// plugins partial has been loaded); otherwise the htmx:afterSettle listener
// below handles it when the tab is first visited.
setTimeout(() => { setTimeout(() => {
if (typeof window.attachInstallButtonHandler === 'function') { if (typeof window.attachInstallButtonHandler === 'function' &&
console.log('[FALLBACK] Attempting to attach install button handler...'); document.getElementById('install-plugin-from-url')) {
window.attachInstallButtonHandler(); window.attachInstallButtonHandler();
} else {
console.warn('[FALLBACK] attachInstallButtonHandler not available on window');
} }
}, 500); }, 500);
}, 200); }, 200);
// Re-run install button wiring after HTMX settles the plugins tab content.
// Guard with element check so it only fires when the plugins partial is in the DOM,
// preventing spurious warnings on other tab loads.
document.addEventListener('htmx:afterSettle', function() {
if (document.getElementById('install-plugin-from-url') &&
typeof window.attachInstallButtonHandler === 'function') {
window.attachInstallButtonHandler();
}
});
// ─── Starlark Apps Integration ────────────────────────────────────────────── // ─── Starlark Apps Integration ──────────────────────────────────────────────
(function() { (function() {

View File

@@ -136,6 +136,7 @@
setTimeout(function() { setTimeout(function() {
if (typeof htmx !== 'undefined') { if (typeof htmx !== 'undefined') {
console.log('HTMX loaded from fallback'); console.log('HTMX loaded from fallback');
window.dispatchEvent(new Event('htmx:ready'));
// Load extensions after core loads // Load extensions after core loads
loadScript(sseSrc, isAPMode ? 'https://unpkg.com/htmx.org/dist/ext/sse.js' : '/static/v3/js/htmx-sse.js'); loadScript(sseSrc, isAPMode ? 'https://unpkg.com/htmx.org/dist/ext/sse.js' : '/static/v3/js/htmx-sse.js');
loadScript(jsonEncSrc, isAPMode ? 'https://unpkg.com/htmx.org/dist/ext/json-enc.js' : '/static/v3/js/htmx-json-enc.js'); loadScript(jsonEncSrc, isAPMode ? 'https://unpkg.com/htmx.org/dist/ext/json-enc.js' : '/static/v3/js/htmx-json-enc.js');
@@ -152,6 +153,7 @@
} }
} else { } else {
console.log('HTMX loaded successfully'); console.log('HTMX loaded successfully');
window.dispatchEvent(new Event('htmx:ready'));
// Load extensions after core loads // Load extensions after core loads
loadScript(sseSrc, isAPMode ? 'https://unpkg.com/htmx.org/dist/ext/sse.js' : '/static/v3/js/htmx-sse.js'); loadScript(sseSrc, isAPMode ? 'https://unpkg.com/htmx.org/dist/ext/sse.js' : '/static/v3/js/htmx-sse.js');
loadScript(jsonEncSrc, isAPMode ? 'https://unpkg.com/htmx.org/dist/ext/json-enc.js' : '/static/v3/js/htmx-json-enc.js'); loadScript(jsonEncSrc, isAPMode ? 'https://unpkg.com/htmx.org/dist/ext/json-enc.js' : '/static/v3/js/htmx-json-enc.js');
@@ -349,6 +351,20 @@
} }
} }
}); });
// Set data-loaded on tab containers after HTMX settles their content,
// preventing repeated re-fetches on every tab switch.
// Scoped to elements with hx-trigger="revealed" (tab containers only) so
// modals and plugin config panels that legitimately reload are unaffected.
document.body.addEventListener('htmx:afterSettle', function(event) {
if (event.detail && event.detail.target) {
var target = event.detail.target;
var trigger = target.getAttribute('hx-trigger') || '';
if (trigger.includes('revealed')) {
target.setAttribute('data-loaded', 'true');
}
}
});
} else { } else {
if (document.readyState === 'loading') { if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', setupScriptExecution); document.addEventListener('DOMContentLoaded', setupScriptExecution);
@@ -411,6 +427,9 @@
.then(html => { .then(html => {
clearTimeout(timeout); clearTimeout(timeout);
content.innerHTML = html; content.innerHTML = html;
if (typeof htmx !== 'undefined') {
htmx.process(content);
}
// Trigger full initialization chain // Trigger full initialization chain
if (window.pluginManager) { if (window.pluginManager) {
window.pluginManager.initialized = false; window.pluginManager.initialized = false;
@@ -430,7 +449,7 @@
} }
// Fallback if HTMX doesn't load within 5 seconds // Fallback if HTMX doesn't load within 5 seconds
setTimeout(() => { var _pluginsFallbackTimer = setTimeout(() => {
if (typeof htmx === 'undefined') { if (typeof htmx === 'undefined') {
console.warn('HTMX not loaded after 5 seconds, using direct fetch for plugins'); console.warn('HTMX not loaded after 5 seconds, using direct fetch for plugins');
// Load plugins tab content directly regardless of active tab, // Load plugins tab content directly regardless of active tab,
@@ -438,6 +457,7 @@
loadPluginsDirect(); loadPluginsDirect();
} }
}, 5000); }, 5000);
window.addEventListener('htmx:ready', function() { clearTimeout(_pluginsFallbackTimer); }, { once: true });
</script> </script>
<!-- Alpine.js app function - defined early so it's available when Alpine initializes --> <!-- Alpine.js app function - defined early so it's available when Alpine initializes -->
<script> <script>
@@ -1030,6 +1050,9 @@
.then(html => { .then(html => {
overviewContent.innerHTML = html; overviewContent.innerHTML = html;
overviewContent.setAttribute('data-loaded', 'true'); overviewContent.setAttribute('data-loaded', 'true');
if (typeof htmx !== 'undefined') {
htmx.process(overviewContent);
}
// Re-initialize Alpine.js for the new content // Re-initialize Alpine.js for the new content
if (window.Alpine) { if (window.Alpine) {
window.Alpine.initTree(overviewContent); window.Alpine.initTree(overviewContent);
@@ -1058,7 +1081,7 @@
}); });
// Also try direct load if HTMX doesn't load within 5 seconds // Also try direct load if HTMX doesn't load within 5 seconds
setTimeout(() => { var _overviewFallbackTimer = setTimeout(() => {
if (typeof htmx === 'undefined') { if (typeof htmx === 'undefined') {
console.warn('HTMX not loaded after 5 seconds, using direct fetch for content'); console.warn('HTMX not loaded after 5 seconds, using direct fetch for content');
const appElement = document.querySelector('[x-data="app()"]'); const appElement = document.querySelector('[x-data="app()"]');
@@ -1070,6 +1093,7 @@
} }
} }
}, 5000); }, 5000);
window.addEventListener('htmx:ready', function() { clearTimeout(_overviewFallbackTimer); }, { once: true });
</script> </script>
<!-- General tab --> <!-- General tab -->
@@ -1816,13 +1840,18 @@
htmx.trigger(contentEl, 'revealed'); htmx.trigger(contentEl, 'revealed');
} }
} else { } else {
// HTMX not available, use direct fetch // HTMX is still loading asynchronously — retry when it signals ready,
console.warn('HTMX not available, using direct fetch for tab:', tab); // or fall back to direct fetch if it fails to load entirely.
if (tab === 'overview' && typeof loadOverviewDirect === 'function') { const self = this;
loadOverviewDirect(); function onReady() { window.removeEventListener('htmx-load-failed', onFailed); self.loadTabContent(tab); }
} else if (tab === 'wifi' && typeof loadWifiDirect === 'function') { function onFailed() {
loadWifiDirect(); window.removeEventListener('htmx:ready', onReady);
if (tab === 'overview' && typeof loadOverviewDirect === 'function') loadOverviewDirect();
else if (tab === 'wifi' && typeof loadWifiDirect === 'function') loadWifiDirect();
else if (tab === 'plugins' && typeof loadPluginsDirect === 'function') loadPluginsDirect();
} }
window.addEventListener('htmx:ready', onReady, { once: true });
window.addEventListener('htmx-load-failed', onFailed, { once: true });
} }
}, },

View File

@@ -73,7 +73,7 @@
<button hx-post="/api/v3/system/action" <button hx-post="/api/v3/system/action"
hx-vals='{"action": "start_display"}' hx-vals='{"action": "start_display"}'
hx-swap="none" hx-swap="none"
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined' && event.detail.xhr && event.detail.xhr.responseJSON) { showNotification(event.detail.xhr.responseJSON.message || 'Display started', event.detail.xhr.responseJSON.status || 'success'); }" hx-on:htmx:after-request="if (typeof showNotification !== 'undefined') { var m='Display started',s='success'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s); }"
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-green-600 hover:bg-green-700"> class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-green-600 hover:bg-green-700">
<i class="fas fa-play mr-2"></i> <i class="fas fa-play mr-2"></i>
Start Display Start Display
@@ -82,7 +82,7 @@
<button hx-post="/api/v3/system/action" <button hx-post="/api/v3/system/action"
hx-vals='{"action": "stop_display"}' hx-vals='{"action": "stop_display"}'
hx-swap="none" hx-swap="none"
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined' && event.detail.xhr && event.detail.xhr.responseJSON) { showNotification(event.detail.xhr.responseJSON.message || 'Display stopped', event.detail.xhr.responseJSON.status || 'success'); }" hx-on:htmx:after-request="if (typeof showNotification !== 'undefined') { var m='Display stopped',s='success'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s); }"
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700"> class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700">
<i class="fas fa-stop mr-2"></i> <i class="fas fa-stop mr-2"></i>
Stop Display Stop Display
@@ -91,7 +91,7 @@
<button hx-post="/api/v3/system/action" <button hx-post="/api/v3/system/action"
hx-vals='{"action": "git_pull"}' hx-vals='{"action": "git_pull"}'
hx-swap="none" hx-swap="none"
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined' && event.detail.xhr && event.detail.xhr.responseJSON) { showNotification(event.detail.xhr.responseJSON.message || 'Code update completed', event.detail.xhr.responseJSON.status || 'info'); }" hx-on:htmx:after-request="if (typeof showNotification !== 'undefined') { var m='Code update completed',s='info'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s); }"
class="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"> class="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
<i class="fas fa-download mr-2"></i> <i class="fas fa-download mr-2"></i>
Update Code Update Code
@@ -101,7 +101,7 @@
hx-vals='{"action": "reboot_system"}' hx-vals='{"action": "reboot_system"}'
hx-confirm="Are you sure you want to reboot the system?" hx-confirm="Are you sure you want to reboot the system?"
hx-swap="none" hx-swap="none"
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined' && event.detail.xhr && event.detail.xhr.responseJSON) { showNotification(event.detail.xhr.responseJSON.message || 'System rebooting...', event.detail.xhr.responseJSON.status || 'info'); }" hx-on:htmx:after-request="if (typeof showNotification !== 'undefined') { var m='System rebooting...',s='info'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s); }"
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-yellow-600 hover:bg-yellow-700"> class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-yellow-600 hover:bg-yellow-700">
<i class="fas fa-power-off mr-2"></i> <i class="fas fa-power-off mr-2"></i>
Reboot System Reboot System

View File

@@ -843,6 +843,14 @@ async function updateFontPreview() {
return; return;
} }
// BDF bitmap fonts cannot be rendered server-side — skip the API call
if (family.toLowerCase().endsWith('.bdf')) {
previewImage.style.display = 'none';
loadingText.style.display = 'block';
loadingText.textContent = 'Preview not available for BDF bitmap fonts';
return;
}
// Show loading state // Show loading state
loadingText.textContent = 'Loading preview...'; loadingText.textContent = 'Loading preview...';
loadingText.style.display = 'block'; loadingText.style.display = 'block';

View File

@@ -1,3 +1,66 @@
<!-- Reconciliation warning banner: shown when startup reconciliation found stale plugin config entries -->
<div id="reconciliation-banner" class="bg-yellow-50 border border-yellow-300 rounded-lg p-4 mb-4 flex items-start" style="display:none !important" role="alert">
<div class="flex-shrink-0 mr-3 mt-0.5">
<i class="fas fa-exclamation-triangle text-yellow-500"></i>
</div>
<div class="flex-1">
<p class="text-sm font-medium text-yellow-800">Plugin Config Warning</p>
<p class="text-sm text-yellow-700 mt-1" id="reconciliation-banner-text"></p>
</div>
<button type="button" onclick="window.dismissReconciliationBanner()" class="ml-4 flex-shrink-0 text-yellow-500 hover:text-yellow-700" aria-label="Dismiss">
<i class="fas fa-times"></i>
</button>
</div>
<script>
(function () {
var DISMISS_KEY = 'ledmatrix-recon-dismissed';
var _recon_timer = null;
function checkReconciliation() {
fetch('/api/v3/plugins/reconciliation-status')
.then(function (r) { return r.json(); })
.then(function (resp) {
var d = resp.data || {};
if (!d.done) {
// Reconciliation still running — poll again shortly
_recon_timer = setTimeout(checkReconciliation, 2000);
return;
}
_recon_timer = null;
if (!d.unresolved || d.unresolved.length === 0) return;
var key = d.unresolved.map(function (i) { return i.plugin_id; }).sort().join(',');
if (sessionStorage.getItem(DISMISS_KEY) === key) return;
var ids = d.unresolved.map(function (i) { return i.plugin_id; }).join(', ');
document.getElementById('reconciliation-banner-text').textContent =
'Stale plugin config entries found: ' + ids +
'. Remove them from config.json or reinstall via the Plugin Store.';
var banner = document.getElementById('reconciliation-banner');
banner.dataset.dismissKey = key;
banner.style.setProperty('display', 'flex', 'important');
})
.catch(function () {});
}
checkReconciliation();
window.dismissReconciliationBanner = function () {
var banner = document.getElementById('reconciliation-banner');
banner.style.setProperty('display', 'none', 'important');
if (_recon_timer !== null) {
clearTimeout(_recon_timer);
_recon_timer = null;
}
// Persist dismissal immediately so the banner won't reappear on reload
// even if the background sync fetch below fails.
var key = banner.dataset.dismissKey;
if (key) {
try { sessionStorage.setItem(DISMISS_KEY, key); } catch (e) {}
}
// Background sync only — do not rely on this for DISMISS_KEY or hiding.
fetch('/api/v3/plugins/reconciliation-status').catch(function () {});
};
}());
</script>
<div class="bg-white rounded-lg shadow p-6"> <div class="bg-white rounded-lg shadow p-6">
<div class="border-b border-gray-200 pb-4 mb-6"> <div class="border-b border-gray-200 pb-4 mb-6">
<h2 class="text-lg font-semibold text-gray-900">System Overview</h2> <h2 class="text-lg font-semibold text-gray-900">System Overview</h2>
@@ -88,7 +151,7 @@
<button hx-post="/api/v3/system/action" <button hx-post="/api/v3/system/action"
hx-vals='{"action": "start_display"}' hx-vals='{"action": "start_display"}'
hx-swap="none" hx-swap="none"
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined' && event.detail.xhr && event.detail.xhr.responseJSON) { showNotification(event.detail.xhr.responseJSON.message || 'Display started', event.detail.xhr.responseJSON.status || 'success'); }" hx-on:htmx:after-request="if (typeof showNotification !== 'undefined') { var m='Display started',s='success'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s); }"
class="inline-flex items-center px-4 py-2 border border-transparent text-base font-semibold rounded-md text-white bg-green-600 hover:bg-green-700"> class="inline-flex items-center px-4 py-2 border border-transparent text-base font-semibold rounded-md text-white bg-green-600 hover:bg-green-700">
<i class="fas fa-play mr-2"></i> <i class="fas fa-play mr-2"></i>
Start Display Start Display
@@ -97,7 +160,7 @@
<button hx-post="/api/v3/system/action" <button hx-post="/api/v3/system/action"
hx-vals='{"action": "stop_display"}' hx-vals='{"action": "stop_display"}'
hx-swap="none" hx-swap="none"
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined' && event.detail.xhr && event.detail.xhr.responseJSON) { showNotification(event.detail.xhr.responseJSON.message || 'Display stopped', event.detail.xhr.responseJSON.status || 'success'); }" hx-on:htmx:after-request="if (typeof showNotification !== 'undefined') { var m='Display stopped',s='success'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s); }"
class="inline-flex items-center px-4 py-2 border border-transparent text-base font-semibold rounded-md text-white bg-red-600 hover:bg-red-700"> class="inline-flex items-center px-4 py-2 border border-transparent text-base font-semibold rounded-md text-white bg-red-600 hover:bg-red-700">
<i class="fas fa-stop mr-2"></i> <i class="fas fa-stop mr-2"></i>
Stop Display Stop Display
@@ -107,7 +170,7 @@
hx-vals='{"action": "git_pull"}' hx-vals='{"action": "git_pull"}'
hx-confirm="This will stash any local changes and update the code. Continue?" hx-confirm="This will stash any local changes and update the code. Continue?"
hx-swap="none" hx-swap="none"
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined' && event.detail.xhr && event.detail.xhr.responseJSON) { showNotification(event.detail.xhr.responseJSON.message || 'Code update completed', event.detail.xhr.responseJSON.status || 'info'); }" hx-on:htmx:after-request="if (typeof showNotification !== 'undefined') { var m='Code update completed',s='info'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s); }"
class="inline-flex items-center px-4 py-2 border border-gray-300 text-base font-semibold rounded-md text-gray-900 bg-white hover:bg-gray-50"> class="inline-flex items-center px-4 py-2 border border-gray-300 text-base font-semibold rounded-md text-gray-900 bg-white hover:bg-gray-50">
<i class="fas fa-download mr-2"></i> <i class="fas fa-download mr-2"></i>
Update Code Update Code
@@ -117,7 +180,7 @@
hx-vals='{"action": "reboot_system"}' hx-vals='{"action": "reboot_system"}'
hx-confirm="Are you sure you want to reboot the system?" hx-confirm="Are you sure you want to reboot the system?"
hx-swap="none" hx-swap="none"
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined' && event.detail.xhr && event.detail.xhr.responseJSON) { showNotification(event.detail.xhr.responseJSON.message || 'System rebooting...', event.detail.xhr.responseJSON.status || 'info'); }" hx-on:htmx:after-request="if (typeof showNotification !== 'undefined') { var m='System rebooting...',s='info'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s); }"
class="inline-flex items-center px-4 py-2 border border-transparent text-base font-semibold rounded-md text-white bg-yellow-600 hover:bg-yellow-700"> class="inline-flex items-center px-4 py-2 border border-transparent text-base font-semibold rounded-md text-white bg-yellow-600 hover:bg-yellow-700">
<i class="fas fa-power-off mr-2"></i> <i class="fas fa-power-off mr-2"></i>
Reboot System Reboot System
@@ -127,7 +190,7 @@
hx-vals='{"action": "shutdown_system"}' hx-vals='{"action": "shutdown_system"}'
hx-confirm="Are you sure you want to shut down the system? This will power off the Raspberry Pi." hx-confirm="Are you sure you want to shut down the system? This will power off the Raspberry Pi."
hx-swap="none" hx-swap="none"
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined' && event.detail.xhr && event.detail.xhr.responseJSON) { showNotification(event.detail.xhr.responseJSON.message || 'System shutting down...', event.detail.xhr.responseJSON.status || 'info'); }" hx-on:htmx:after-request="if (typeof showNotification !== 'undefined') { var m='System shutting down...',s='info'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s); }"
class="inline-flex items-center px-4 py-2 border border-transparent text-base font-semibold rounded-md text-white bg-red-800 hover:bg-red-900"> class="inline-flex items-center px-4 py-2 border border-transparent text-base font-semibold rounded-md text-white bg-red-800 hover:bg-red-900">
<i class="fas fa-power-off mr-2"></i> <i class="fas fa-power-off mr-2"></i>
Shutdown System Shutdown System
@@ -136,7 +199,7 @@
<button hx-post="/api/v3/system/action" <button hx-post="/api/v3/system/action"
hx-vals='{"action": "restart_display_service"}' hx-vals='{"action": "restart_display_service"}'
hx-swap="none" hx-swap="none"
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined' && event.detail.xhr && event.detail.xhr.responseJSON) { showNotification(event.detail.xhr.responseJSON.message || 'Display service restarted', event.detail.xhr.responseJSON.status || 'success'); }" hx-on:htmx:after-request="if (typeof showNotification !== 'undefined') { var m='Display service restarted',s='success'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s); }"
class="inline-flex items-center px-4 py-2 border border-gray-300 text-base font-semibold rounded-md text-gray-900 bg-white hover:bg-gray-50"> class="inline-flex items-center px-4 py-2 border border-gray-300 text-base font-semibold rounded-md text-gray-900 bg-white hover:bg-gray-50">
<i class="fas fa-redo mr-2"></i> <i class="fas fa-redo mr-2"></i>
Restart Display Service Restart Display Service
@@ -145,7 +208,7 @@
<button hx-post="/api/v3/system/action" <button hx-post="/api/v3/system/action"
hx-vals='{"action": "restart_web_service"}' hx-vals='{"action": "restart_web_service"}'
hx-swap="none" hx-swap="none"
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined' && event.detail.xhr && event.detail.xhr.responseJSON) { showNotification(event.detail.xhr.responseJSON.message || 'Web service restarted', event.detail.xhr.responseJSON.status || 'success'); }" hx-on:htmx:after-request="if (typeof showNotification !== 'undefined') { var m='Web service restarted',s='success'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s); }"
class="inline-flex items-center px-4 py-2 border border-gray-300 text-base font-semibold rounded-md text-gray-900 bg-white hover:bg-gray-50"> class="inline-flex items-center px-4 py-2 border border-gray-300 text-base font-semibold rounded-md text-gray-900 bg-white hover:bg-gray-50">
<i class="fas fa-redo mr-2"></i> <i class="fas fa-redo mr-2"></i>
Restart Web Service Restart Web Service

View File

@@ -9,7 +9,8 @@
{% set field_id = (plugin_id ~ '-' ~ full_key)|replace('.', '-')|replace('_', '-') %} {% set field_id = (plugin_id ~ '-' ~ full_key)|replace('.', '-')|replace('_', '-') %}
{% set label = prop.title if prop.title else key|replace('_', ' ')|title %} {% set label = prop.title if prop.title else key|replace('_', ' ')|title %}
{% set description = prop.description if prop.description else '' %} {% set description = prop.description if prop.description else '' %}
{% set field_type = prop.type if prop.type is string else (prop.type[0] if prop.type is iterable else 'string') %} {% set _pt = prop.get('type') %}
{% set field_type = _pt if (_pt is string) else ((_pt | first) if (_pt and _pt is iterable and _pt is not string) else 'string') %}
{# Handle nested objects - check for widget first #} {# Handle nested objects - check for widget first #}
{% if field_type == 'object' %} {% if field_type == 'object' %}