4 Commits

Author SHA1 Message Date
Chuck
c53e4995c4 fix(systemd): wait for network connectivity before starting services
Change After=network.target to After=network-online.target + Wants=network-
online.target in both service templates and install_web_service.sh.

network.target only means NetworkManager has started — it does NOT mean the
device has an active internet connection. On boot, the LED matrix service was
starting within seconds of the network interface appearing, before WiFi
association and DHCP completed, causing all first-update API calls to fail
with "Network is unreachable" or DNS resolution errors.

network-online.target waits for a confirmed network route before the service
starts. On Raspberry Pi OS this is provided by NetworkManager-wait-online.
The tradeoff is a few extra seconds at boot, which is acceptable for a
display device.

Applied live to /etc/systemd/system/ledmatrix.service on devpi via
systemctl daemon-reload (no restart required for the config change to take
effect on next boot).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 15:14:50 -04:00
Chuck
a0f19d8972 fix: deterministic submodule install + guard rp1_rio for older rgbmatrix
first_time_install.sh: remove --remote from both git submodule update
calls so first-time installs check out the pinned commit recorded in the
repo rather than whatever upstream master happens to be at install time.
The branch = master config in .gitmodules reserves --remote for an
explicit maintainer upgrade (git submodule update --remote).

display_manager.py: guard rp1_rio assignment with hasattr() so setting
the option in config does not cause an AttributeError and silently fall
through to emulator mode when running against RGBMatrixEmulator or an
older rgbmatrix build that predates the Pi 5 property. Emit a warning
instead so the operator knows the value was ignored.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 14:09:25 -04:00
Chuck
4f126d6133 chore(deps): update rpi-rgb-led-matrix install for new scikit-build-core system
The library migrated from 'make build-python' + 'pip install bindings/python'
to a scikit-build-core + cmake build where the entire repo root is pip-
installable via 'pip install .'. Update first_time_install.sh accordingly:
- Remove the 'make build-python' step (target no longer exists)
- Install directly from the repo root instead of bindings/python
- Replace build deps: remove cython3/scons/python3-dev, add python-dev-is-python3

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 11:29:33 -04:00
Chuck
5dde1125e9 chore(deps): update rpi-rgb-led-matrix to latest upstream for Pi 5 support
Configure submodule to track upstream master branch (branch = master in
.gitmodules) so future updates are a single 'git submodule update --remote'
rather than manual SHA management.

Update first_time_install.sh to use --remote flag so fresh installs always
pull the current upstream master, not the commit recorded at clone time.

Current upstream HEAD (8907235) brings:
- PR #1886: Raspberry Pi 5 support — new RP1 PIO and RIO backends. The
  library auto-detects Pi 5 hardware at runtime; no config change required
  for basic operation. adafruit-hat-pwm is confirmed supported on Pi 5.
- PR #1833: setup.py migrated from distutils → setuptools, fixing Python
  3.12+ build failure (Pi runs Python 3.13). Previous version could not
  build the bindings at all on current Pi OS.

Expose new rp1_rio option in display_manager.py and config.template.json:
  0 (default) = PIO mode — uses Pi 5 RP1 coprocessor, minimal CPU usage
  1 = RIO mode — Registered IO, faster throughput, higher CPU; note that
      gpio_slowdown has inverted effect in this mode

No API changes to RGBMatrix, RGBMatrixOptions, or FrameCanvas. Pi 4 and
earlier hardware is unaffected — rp1_rio is silently ignored on non-Pi-5.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 11:22:33 -04:00
14 changed files with 67 additions and 303 deletions

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

@@ -1,5 +1,5 @@
requests>=2.33.0 requests>=2.33.0
urllib3>=2.6.3 urllib3>=1.26.0
Pillow>=12.2.0 Pillow>=12.2.0
pytz>=2022.1 pytz>=2022.1
numpy>=1.24.0 numpy>=1.24.0

View File

@@ -235,6 +235,8 @@ class DisplayHelper:
PIL Image with no data message PIL Image with no data message
""" """
img = self.create_base_image((0, 0, 0)) img = self.create_base_image((0, 0, 0))
draw = ImageDraw.Draw(img)
font = ImageFont.load_default() font = ImageFont.load_default()
self._draw_centered_text(message, font, (0, 0, 0), (150, 150, 150)) self._draw_centered_text(message, font, (0, 0, 0), (150, 150, 150))

View File

@@ -823,7 +823,7 @@ class DisplayController:
scroll_h = getattr(plugin_instance, 'scroll_helper', None) scroll_h = getattr(plugin_instance, 'scroll_helper', None)
if scroll_h is not None: if scroll_h is not None:
follower_frame = scroll_h.get_portion_at(scroll_h.scroll_position + offset) follower_frame = scroll_h.get_portion_at(scroll_h.scroll_position + offset)
except Exception: # nosec B110 - scroll_helper.get_portion_at is optional; skip on error except Exception:
pass pass
# 3. Mirror fallback — static plugins (clock, weather) show same frame # 3. Mirror fallback — static plugins (clock, weather) show same frame

View File

@@ -1,6 +1,4 @@
import json
import os import os
import tempfile
if os.getenv("EMULATOR", "false") == "true": if os.getenv("EMULATOR", "false") == "true":
from RGBMatrixEmulator import RGBMatrix, RGBMatrixOptions from RGBMatrixEmulator import RGBMatrix, RGBMatrixOptions
else: else:
@@ -60,7 +58,6 @@ class DisplayManager:
def _setup_matrix(self): def _setup_matrix(self):
"""Initialize the RGB matrix with configuration settings.""" """Initialize the RGB matrix with configuration settings."""
_init_error_str = None
try: try:
# Allow callers (e.g., web UI) to force non-hardware fallback mode # Allow callers (e.g., web UI) to force non-hardware fallback mode
if getattr(self, '_force_fallback', False): if getattr(self, '_force_fallback', False):
@@ -90,7 +87,7 @@ class DisplayManager:
options.disable_hardware_pulsing = hardware_config.get('disable_hardware_pulsing', False) options.disable_hardware_pulsing = hardware_config.get('disable_hardware_pulsing', False)
options.show_refresh_rate = hardware_config.get('show_refresh_rate', False) options.show_refresh_rate = hardware_config.get('show_refresh_rate', False)
options.limit_refresh_rate_hz = hardware_config.get('limit_refresh_rate_hz', 90) options.limit_refresh_rate_hz = hardware_config.get('limit_refresh_rate_hz', 90)
options.gpio_slowdown = runtime_config.get('gpio_slowdown', 3) options.gpio_slowdown = runtime_config.get('gpio_slowdown', 2)
# Disable internal privilege dropping - we manage this via systemd or remain root # Disable internal privilege dropping - we manage this via systemd or remain root
# This prevents the library from dropping to 'daemon' user which breaks file permissions # This prevents the library from dropping to 'daemon' user which breaks file permissions
@@ -144,7 +141,6 @@ class DisplayManager:
self._draw_test_pattern() self._draw_test_pattern()
except Exception as e: except Exception as e:
_init_error_str = str(e)
logger.error(f"Failed to initialize RGB Matrix: {e}", exc_info=True) logger.error(f"Failed to initialize RGB Matrix: {e}", exc_info=True)
# Create a fallback image for web preview using configured dimensions when available # Create a fallback image for web preview using configured dimensions when available
self.matrix = None self.matrix = None
@@ -168,38 +164,9 @@ class DisplayManager:
except Exception: # nosec B110 - best-effort fallback visualization; drawing errors must not crash startup except Exception: # nosec B110 - best-effort fallback visualization; drawing errors must not crash startup
# Best-effort; ignore drawing errors in fallback # Best-effort; ignore drawing errors in fallback
pass pass
logger.error( logger.error(f"Matrix initialization failed, using fallback mode with size {fallback_width}x{fallback_height}. Error: {e}")
f"Matrix initialization failed — running in fallback/simulation mode "
f"(size {fallback_width}x{fallback_height}). Error: {e}. "
"On Raspberry Pi 5: ensure rpi-rgb-led-matrix was built from the latest "
"submodule (re-run first_time_install.sh). gpio_slowdown of 23 is typical for Pi 5 PIO mode."
)
# Do not raise here; allow fallback mode so web preview and non-hardware environments work # Do not raise here; allow fallback mode so web preview and non-hardware environments work
# Write hardware status file so the web UI can surface init failures
_hw_status = {"ok": self.matrix is not None, "error": _init_error_str}
_status_path = "/tmp/led_matrix_hw_status.json" # nosec B108
try:
if os.path.islink(_status_path):
logger.warning("Skipping hardware status write: %s is a symlink", _status_path)
else:
_fd, _tmp_path = tempfile.mkstemp(dir="/tmp", prefix=".led_hw_") # nosec B108
try:
with os.fdopen(_fd, "w") as _f:
json.dump(_hw_status, _f)
_f.flush()
os.fsync(_f.fileno())
os.chmod(_tmp_path, 0o600)
os.replace(_tmp_path, _status_path)
except Exception:
try:
os.unlink(_tmp_path)
except OSError:
pass
raise
except Exception:
logger.error("Failed to write hardware status file", exc_info=True)
@property @property
def width(self): def width(self):
"""Get the display width.""" """Get the display width."""
@@ -780,8 +747,8 @@ class DisplayManager:
try: try:
self.image = Image.new('RGB', (self.width, self.height)) self.image = Image.new('RGB', (self.width, self.height))
self.draw = ImageDraw.Draw(self.image) self.draw = ImageDraw.Draw(self.image)
except (OSError, RuntimeError, ValueError, MemoryError): except Exception:
logger.debug("Canvas reset during cleanup failed", exc_info=True) pass
# Reset the singleton state when cleaning up # Reset the singleton state when cleaning up
DisplayManager._instance = None DisplayManager._instance = None
DisplayManager._initialized = False DisplayManager._initialized = False

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,33 +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 = "/tmp/ledmatrix_reconciliation.json"
try:
if not _os.path.islink(_recon_path):
_fd, _tmp = _tempfile.mkstemp(dir="/tmp", prefix=".led_recon_")
with _os.fdopen(_fd, "w") as _f:
_json.dump(_recon_status, _f)
_os.replace(_tmp, _recon_path)
except Exception as _e:
_logger.warning("[Reconciliation] Could not write status file: %s", _e)
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

@@ -699,7 +699,7 @@ def save_main_config():
# Handle display settings # Handle display settings
display_fields = ['rows', 'cols', 'chain_length', 'parallel', 'brightness', 'hardware_mapping', display_fields = ['rows', 'cols', 'chain_length', 'parallel', 'brightness', 'hardware_mapping',
'gpio_slowdown', 'rp1_rio', 'scan_mode', 'disable_hardware_pulsing', 'inverse_colors', 'show_refresh_rate', 'gpio_slowdown', 'scan_mode', 'disable_hardware_pulsing', 'inverse_colors', 'show_refresh_rate',
'pwm_bits', 'pwm_dither_bits', 'pwm_lsb_nanoseconds', 'limit_refresh_rate_hz', 'use_short_date_format', 'pwm_bits', 'pwm_dither_bits', 'pwm_lsb_nanoseconds', 'limit_refresh_rate_hz', 'use_short_date_format',
'max_dynamic_duration_seconds', 'led_rgb_sequence', 'multiplexing', 'panel_type'] 'max_dynamic_duration_seconds', 'led_rgb_sequence', 'multiplexing', 'panel_type']
@@ -747,14 +747,6 @@ def save_main_config():
# Handle runtime settings # Handle runtime settings
if 'gpio_slowdown' in data: if 'gpio_slowdown' in data:
current_config['display']['runtime']['gpio_slowdown'] = int(data['gpio_slowdown']) current_config['display']['runtime']['gpio_slowdown'] = int(data['gpio_slowdown'])
if 'rp1_rio' in data:
try:
rp1_val = int(data['rp1_rio'])
if rp1_val not in (0, 1):
return jsonify({'status': 'error', 'message': "rp1_rio must be 0 (PIO) or 1 (RIO)"}), 400
current_config['display']['runtime']['rp1_rio'] = rp1_val
except (ValueError, TypeError):
return jsonify({'status': 'error', 'message': "rp1_rio must be 0 or 1"}), 400
# Handle checkboxes - coerce to bool to ensure proper JSON types # Handle checkboxes - coerce to bool to ensure proper JSON types
for checkbox in ['disable_hardware_pulsing', 'inverse_colors', 'show_refresh_rate']: for checkbox in ['disable_hardware_pulsing', 'inverse_colors', 'show_refresh_rate']:
@@ -1384,52 +1376,6 @@ def get_system_version():
except Exception as e: except Exception as e:
return jsonify({'status': 'error', 'message': str(e)}), 500 return jsonify({'status': 'error', 'message': str(e)}), 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)
subprocess.run(
['git', 'fetch', 'origin', 'main', '--quiet'],
capture_output=True, timeout=10, cwd=cwd,
)
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.debug("check-update: %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():
"""Execute system actions (start/stop/reboot/etc)""" """Execute system actions (start/stop/reboot/etc)"""
@@ -1581,23 +1527,6 @@ def execute_system_action():
print(error_details) print(error_details)
return jsonify({'status': 'error', 'message': str(e), 'details': error_details}), 500 return jsonify({'status': 'error', 'message': str(e), 'details': error_details}), 500
@api_v3.route('/hardware/status', methods=['GET'])
def get_hardware_status():
"""Return LED matrix hardware initialization status written by display_manager at startup."""
status_path = "/tmp/led_matrix_hw_status.json" # nosec B108
try:
with open(status_path) as f:
hw_data = json.load(f)
return jsonify({"status": "success", "data": hw_data})
except FileNotFoundError:
return jsonify({"status": "success", "data": {"ok": None, "error": "Display service not yet started"}})
except (json.JSONDecodeError, PermissionError):
logger.error("Failed to read hardware status file", exc_info=True)
return jsonify({"status": "error", "message": "Unable to read hardware status"}), 500
except Exception:
logger.error("Unexpected error reading hardware status", exc_info=True)
return jsonify({"status": "error", "message": "Unable to read hardware status"}), 500
@api_v3.route('/display/current', methods=['GET']) @api_v3.route('/display/current', methods=['GET'])
def get_display_current(): def get_display_current():
"""Get current display state""" """Get current display state"""
@@ -2479,19 +2408,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 = "/tmp/ledmatrix_reconciliation.json"
try:
with open(_recon_path) as _f:
data = json.load(_f)
return jsonify({'status': 'success', 'data': data})
except FileNotFoundError:
return jsonify({'status': 'success', 'data': {'done': False, 'unresolved': []}})
except Exception as e:
return jsonify({'status': 'error', 'message': str(e)}), 500
@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"""

View File

@@ -41,7 +41,7 @@ def get_local_ips():
ip = ip.strip() ip = ip.strip()
if ip and not ip.startswith("127.") and ip != "192.168.4.1": if ip and not ip.startswith("127.") and ip != "192.168.4.1":
ips.append(ip) ips.append(ip)
except Exception: # nosec B110 - hostname -I output parsing; non-critical startup info except Exception:
pass pass
# Fallback: try socket method # Fallback: try socket method

View File

@@ -1,4 +1,4 @@
/* global showNotification, updateSystemStats, htmx */ /* global showNotification, updateSystemStats */
// LED Matrix v3 JavaScript // LED Matrix v3 JavaScript
// Additional helpers for HTMX and Alpine.js integration // Additional helpers for HTMX and Alpine.js integration

View File

@@ -331,7 +331,7 @@
removeButton.type = 'button'; removeButton.type = 'button';
removeButton.className = 'text-red-600 hover:text-red-800 px-2 py-1'; removeButton.className = 'text-red-600 hover:text-red-800 px-2 py-1';
removeButton.addEventListener('click', function() { removeButton.addEventListener('click', function() {
window.removeCustomFeedRow(this); removeCustomFeedRow(this);
}); });
const removeIcon = document.createElement('i'); const removeIcon = document.createElement('i');
removeIcon.className = 'fas fa-trash'; removeIcon.className = 'fas fa-trash';

View File

@@ -212,7 +212,7 @@
const parts = formatter.formatToParts(now); const parts = formatter.formatToParts(now);
const offsetPart = parts.find(p => p.type === 'timeZoneName'); const offsetPart = parts.find(p => p.type === 'timeZoneName');
return offsetPart ? offsetPart.value : ''; return offsetPart ? offsetPart.value : '';
} catch { } catch (e) {
return ''; return '';
} }
} }

View File

@@ -4,25 +4,6 @@
<p class="mt-1 text-sm text-gray-600">Configure LED matrix hardware settings and display options.</p> <p class="mt-1 text-sm text-gray-600">Configure LED matrix hardware settings and display options.</p>
</div> </div>
<!-- Hardware status banner: shown when display service is in fallback/simulation mode -->
<div x-data="{ show: false, errorMsg: '' }"
x-init="fetch('/api/v3/hardware/status').then(r => r.json()).then(d => {
const hw = (d && d.data) || {};
if (hw.ok === false) { show = true; errorMsg = hw.error || 'Unknown error'; }
}).catch(() => {})"
x-show="show"
style="display:none"
class="bg-yellow-50 border border-yellow-300 rounded-lg p-4 mb-6">
<p class="font-semibold text-yellow-800"><i class="fas fa-exclamation-triangle mr-2"></i>LED matrix running in simulation mode</p>
<p class="text-sm text-yellow-700 mt-1">Hardware initialization failed: <span x-text="errorMsg" class="font-mono text-xs break-all"></span></p>
<p class="text-sm text-yellow-700 mt-2">
On Raspberry Pi 5: ensure the library was rebuilt from the latest submodule
(<code class="bg-yellow-100 px-1 rounded">first_time_install.sh</code>)
and try adjusting <strong>GPIO Slowdown</strong> (start at 3, reduce if the display looks dim or choppy).
Check the <a href="/v3/logs" class="underline font-medium">Logs tab</a> for the full error.
</p>
</div>
<form hx-post="/api/v3/config/main" <form hx-post="/api/v3/config/main"
hx-ext="json-enc" hx-ext="json-enc"
hx-headers='{"Content-Type": "application/json"}' hx-headers='{"Content-Type": "application/json"}'
@@ -168,7 +149,7 @@
</div> </div>
</div> </div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-group"> <div class="form-group">
<label for="gpio_slowdown" class="block text-sm font-medium text-gray-700">GPIO Slowdown</label> <label for="gpio_slowdown" class="block text-sm font-medium text-gray-700">GPIO Slowdown</label>
<input type="number" <input type="number"
@@ -176,20 +157,9 @@
name="gpio_slowdown" name="gpio_slowdown"
value="{{ main_config.display.runtime.gpio_slowdown or 3 }}" value="{{ main_config.display.runtime.gpio_slowdown or 3 }}"
min="0" min="0"
max="10" max="5"
class="form-control"> class="form-control">
<p class="mt-1 text-sm text-gray-600">Pi 3: 1&ndash;2 &middot; Pi 4: 2&ndash;4 &middot; Pi 5 PIO: 1&ndash;3. Increase if display shows garbage; in RIO mode higher values may improve performance.</p> <p class="mt-1 text-sm text-gray-600">GPIO slowdown factor (0-5)</p>
</div>
<div class="form-group">
<label for="rp1_rio" class="block text-sm font-medium text-gray-700">
RP1 Backend <span class="text-xs text-gray-400 font-normal">(Pi 5 only)</span>
</label>
<select id="rp1_rio" name="rp1_rio" class="form-control">
<option value="0" {% if main_config.display.get('runtime', {}).get('rp1_rio', 0)|int == 0 %}selected{% endif %}>0 &mdash; PIO (default, low CPU)</option>
<option value="1" {% if main_config.display.get('runtime', {}).get('rp1_rio', 0)|int == 1 %}selected{% endif %}>1 &mdash; RIO (higher throughput; slowdown inverted)</option>
</select>
<p class="mt-1 text-sm text-gray-600">Pi 5 RP1 coprocessor mode. Ignored on Pi 3/4.</p>
</div> </div>
<div class="form-group"> <div class="form-group">

View File

@@ -1,53 +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';
fetch('/api/v3/plugins/reconciliation-status')
.then(function (r) { return r.json(); })
.then(function (resp) {
var d = resp.data || {};
if (!d.done || !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.style.setProperty('display', 'flex', 'important');
})
.catch(function () {});
window.dismissReconciliationBanner = function () {
var banner = document.getElementById('reconciliation-banner');
banner.style.setProperty('display', 'none', 'important');
try {
fetch('/api/v3/plugins/reconciliation-status')
.then(function (r) { return r.json(); })
.then(function (resp) {
var d = resp.data || {};
if (d.unresolved && d.unresolved.length) {
var key = d.unresolved.map(function (i) { return i.plugin_id; }).sort().join(',');
sessionStorage.setItem(DISMISS_KEY, key);
}
});
} catch (e) {}
};
}());
</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>