5 Commits

Author SHA1 Message Date
Chuck
543e1147f3 chore: remove march-madness from bundled plugin-repos
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: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 19:52:41 -04:00
Chuck
85a8030ee9 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>
2026-05-18 17:36:28 -04:00
Chuck
db86a2a55e 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>
2026-05-18 17:36:28 -04:00
Chuck
f6e9c7688d 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>
2026-05-18 17:36:28 -04:00
Chuck
c48c06ffa7 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>
2026-05-18 17:36:28 -04:00
14 changed files with 96 additions and 583 deletions

View File

@@ -1,10 +1,5 @@
# 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.
@@ -132,15 +127,10 @@ 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. - 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 3B, 4, or 5** - **Raspberry Pi 3B or 4 (NOT RPi 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
@@ -592,7 +582,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 12 in PIO mode (`rp1_rio: 0`, the default); start with `1` and increase if you see flickering - **Raspberry Pi 5**: Use 5 (or higher if needed)
- **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

@@ -1,43 +1,43 @@
{ {
"web_display_autostart": true, "web_display_autostart": true,
"schedule": { "schedule": {
"enabled": false, "enabled": true,
"mode": "per-day", "mode": "per-day",
"start_time": "07:00", "start_time": "07:00",
"end_time": "23:00", "end_time": "23:00",
"days": { "days": {
"monday": { "monday": {
"enabled": false, "enabled": true,
"start_time": "07:00", "start_time": "07:00",
"end_time": "23:00" "end_time": "23:00"
}, },
"tuesday": { "tuesday": {
"enabled": false, "enabled": true,
"start_time": "07:00", "start_time": "07:00",
"end_time": "23:00" "end_time": "23:00"
}, },
"wednesday": { "wednesday": {
"enabled": false, "enabled": true,
"start_time": "07:00", "start_time": "07:00",
"end_time": "23:00" "end_time": "23:00"
}, },
"thursday": { "thursday": {
"enabled": false, "enabled": true,
"start_time": "07:00", "start_time": "07:00",
"end_time": "23:00" "end_time": "23:00"
}, },
"friday": { "friday": {
"enabled": false, "enabled": true,
"start_time": "07:00", "start_time": "07:00",
"end_time": "23:00" "end_time": "23:00"
}, },
"saturday": { "saturday": {
"enabled": false, "enabled": true,
"start_time": "07:00", "start_time": "07:00",
"end_time": "23:00" "end_time": "23:00"
}, },
"sunday": { "sunday": {
"enabled": false, "enabled": true,
"start_time": "07:00", "start_time": "07:00",
"end_time": "23:00" "end_time": "23:00"
} }
@@ -51,46 +51,46 @@
"end_time": "07:00", "end_time": "07:00",
"days": { "days": {
"monday": { "monday": {
"enabled": false, "enabled": true,
"start_time": "20:00", "start_time": "20:00",
"end_time": "07:00" "end_time": "07:00"
}, },
"tuesday": { "tuesday": {
"enabled": false, "enabled": true,
"start_time": "20:00", "start_time": "20:00",
"end_time": "07:00" "end_time": "07:00"
}, },
"wednesday": { "wednesday": {
"enabled": false, "enabled": true,
"start_time": "20:00", "start_time": "20:00",
"end_time": "07:00" "end_time": "07:00"
}, },
"thursday": { "thursday": {
"enabled": false, "enabled": true,
"start_time": "20:00", "start_time": "20:00",
"end_time": "07:00" "end_time": "07:00"
}, },
"friday": { "friday": {
"enabled": false, "enabled": true,
"start_time": "20:00", "start_time": "20:00",
"end_time": "07:00" "end_time": "07:00"
}, },
"saturday": { "saturday": {
"enabled": false, "enabled": true,
"start_time": "20:00", "start_time": "20:00",
"end_time": "07:00" "end_time": "07:00"
}, },
"sunday": { "sunday": {
"enabled": false, "enabled": true,
"start_time": "20:00", "start_time": "20:00",
"end_time": "07:00" "end_time": "07:00"
} }
} }
}, },
"timezone": "America/New_York", "timezone": "America/Chicago",
"location": { "location": {
"city": "Tampa", "city": "Dallas",
"state": "Florida", "state": "Texas",
"country": "US" "country": "US"
}, },
"display": { "display": {

View File

@@ -36,17 +36,9 @@ 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..."
@@ -791,28 +783,9 @@ 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 "-----------------------------------------------------"
# On Pi 5, also check that the installed library has rp1_rio support. # If already installed and not forcing rebuild, skip expensive build
# 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
if [ "$IS_PI5" = "1" ] && [ "$_HAS_RP1" = "0" ]; then echo "rgbmatrix Python package already available; skipping build (set RPI_RGB_FORCE_REBUILD=1 to force rebuild)."
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
@@ -879,17 +852,6 @@ 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,10 +110,9 @@ 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 installed rgbmatrix library does " "rp1_rio is set in config but the current RGBMatrixOptions "
"not support it — the library was likely built without Pi 5 RP1 " "implementation does not support it (RGBMatrixEmulator or older "
"support (mmap to 0x3f000000 instead of RP1 chip). " "library version) — value will be ignored"
"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}")
@@ -190,7 +189,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, 0o644) os.chmod(_tmp_path, 0o600)
os.replace(_tmp_path, _status_path) os.replace(_tmp_path, _status_path)
except Exception: except Exception:
try: try:

View File

@@ -171,24 +171,10 @@ 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,
stderr result.stderr
) )
return False return False
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:

View File

@@ -10,7 +10,6 @@ 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
@@ -101,10 +100,6 @@ 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)
@@ -580,15 +575,6 @@ 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,41 +716,6 @@ 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,17 +2,14 @@ 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__)
@@ -1385,61 +1382,7 @@ 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:
logger.error("get_system_version failed: %s", e, exc_info=True) return jsonify({'status': 'error', 'message': str(e)}), 500
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():
@@ -1586,8 +1529,11 @@ def execute_system_action():
}) })
except Exception as e: except Exception as e:
logger.error("execute_system_action failed: %s", e, exc_info=True) import traceback
return jsonify({'status': 'error', 'message': 'Action failed; see logs for details'}), 500 error_details = traceback.format_exc()
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():
@@ -1599,12 +1545,9 @@ 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 PermissionError: except (json.JSONDecodeError, PermissionError):
logger.warning("Permission denied reading hardware status file; display service may be running as a different user") logger.error("Failed to read hardware status file", exc_info=True)
return jsonify({"status": "success", "data": {"ok": False, "error": "Hardware status temporarily unavailable"}}) return jsonify({"status": "error", "message": "Unable to read hardware status"}), 500
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
@@ -2490,28 +2433,6 @@ 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"""
@@ -7066,187 +6987,3 @@ 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,28 +7473,17 @@ 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') {
document.getElementById('install-plugin-from-url')) { console.log('[FALLBACK] Attempting to attach install button handler...');
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,7 +136,6 @@
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');
@@ -153,7 +152,6 @@
} }
} 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');
@@ -351,20 +349,6 @@
} }
} }
}); });
// 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);
@@ -427,9 +411,6 @@
.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;
@@ -449,7 +430,7 @@
} }
// Fallback if HTMX doesn't load within 5 seconds // Fallback if HTMX doesn't load within 5 seconds
var _pluginsFallbackTimer = setTimeout(() => { 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,
@@ -457,7 +438,6 @@
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>
@@ -1050,9 +1030,6 @@
.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);
@@ -1081,7 +1058,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
var _overviewFallbackTimer = setTimeout(() => { 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()"]');
@@ -1093,7 +1070,6 @@
} }
} }
}, 5000); }, 5000);
window.addEventListener('htmx:ready', function() { clearTimeout(_overviewFallbackTimer); }, { once: true });
</script> </script>
<!-- General tab --> <!-- General tab -->
@@ -1840,18 +1816,13 @@
htmx.trigger(contentEl, 'revealed'); htmx.trigger(contentEl, 'revealed');
} }
} else { } else {
// HTMX is still loading asynchronously — retry when it signals ready, // HTMX not available, use direct fetch
// or fall back to direct fetch if it fails to load entirely. console.warn('HTMX not available, using direct fetch for tab:', tab);
const self = this; if (tab === 'overview' && typeof loadOverviewDirect === 'function') {
function onReady() { window.removeEventListener('htmx-load-failed', onFailed); self.loadTabContent(tab); } loadOverviewDirect();
function onFailed() { } else if (tab === 'wifi' && typeof loadWifiDirect === 'function') {
window.removeEventListener('htmx:ready', onReady); loadWifiDirect();
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') { 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); }" 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'); }"
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') { 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); }" 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'); }"
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') { 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); }" 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'); }"
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') { 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); }" 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'); }"
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,14 +843,6 @@ 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,66 +1,3 @@
<!-- 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>
@@ -151,7 +88,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') { 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); }" 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'); }"
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
@@ -160,7 +97,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') { 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); }" 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'); }"
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
@@ -170,7 +107,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') { 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); }" 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'); }"
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
@@ -180,7 +117,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') { 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); }" 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'); }"
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
@@ -190,7 +127,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') { 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); }" 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'); }"
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
@@ -199,7 +136,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') { 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); }" 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'); }"
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
@@ -208,7 +145,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') { 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); }" 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'); }"
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,8 +9,7 @@
{% 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 _pt = prop.get('type') %} {% set field_type = prop.type if prop.type is string else (prop.type[0] if prop.type is iterable else 'string') %}
{% 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' %}