From 33f76b489576a9f21f21b610ef3527a4194d1cb9 Mon Sep 17 00:00:00 2001 From: Chuck <33324927+ChuckBuilds@users.noreply.github.com> Date: Mon, 18 May 2026 17:42:19 -0400 Subject: [PATCH] feat(pi5): RP1 backend UI, gpio slowdown guidance, and hardware init error banner (#337) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 * 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 * 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 --------- Co-authored-by: Chuck Co-authored-by: Claude Sonnet 4.6 --- plugin-repos/march-madness/requirements.txt | 2 +- src/display_manager.py | 41 +++++++++++++++++-- web_interface/blueprints/api_v3.py | 27 +++++++++++- .../templates/v3/partials/display.html | 36 ++++++++++++++-- 4 files changed, 97 insertions(+), 9 deletions(-) diff --git a/plugin-repos/march-madness/requirements.txt b/plugin-repos/march-madness/requirements.txt index 9f2bf55d..d1d90878 100644 --- a/plugin-repos/march-madness/requirements.txt +++ b/plugin-repos/march-madness/requirements.txt @@ -1,5 +1,5 @@ requests>=2.33.0 -urllib3>=2.2.2 +urllib3>=2.6.3 Pillow>=12.2.0 pytz>=2022.1 numpy>=1.24.0 diff --git a/src/display_manager.py b/src/display_manager.py index 49623c57..9519a4c7 100644 --- a/src/display_manager.py +++ b/src/display_manager.py @@ -1,4 +1,6 @@ +import json import os +import tempfile if os.getenv("EMULATOR", "false") == "true": from RGBMatrixEmulator import RGBMatrix, RGBMatrixOptions else: @@ -58,6 +60,7 @@ class DisplayManager: def _setup_matrix(self): """Initialize the RGB matrix with configuration settings.""" + _init_error_str = None try: # Allow callers (e.g., web UI) to force non-hardware fallback mode if getattr(self, '_force_fallback', False): @@ -87,7 +90,7 @@ class DisplayManager: options.disable_hardware_pulsing = hardware_config.get('disable_hardware_pulsing', 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.gpio_slowdown = runtime_config.get('gpio_slowdown', 2) + options.gpio_slowdown = runtime_config.get('gpio_slowdown', 3) # 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 @@ -141,6 +144,7 @@ class DisplayManager: self._draw_test_pattern() except Exception as e: + _init_error_str = str(e) logger.error(f"Failed to initialize RGB Matrix: {e}", exc_info=True) # Create a fallback image for web preview using configured dimensions when available self.matrix = None @@ -164,9 +168,38 @@ class DisplayManager: except Exception: # nosec B110 - best-effort fallback visualization; drawing errors must not crash startup # Best-effort; ignore drawing errors in fallback pass - logger.error(f"Matrix initialization failed, using fallback mode with size {fallback_width}x{fallback_height}. Error: {e}") + logger.error( + 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 2–3 is typical for Pi 5 PIO mode." + ) # 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 def width(self): """Get the display width.""" @@ -747,8 +780,8 @@ class DisplayManager: try: self.image = Image.new('RGB', (self.width, self.height)) self.draw = ImageDraw.Draw(self.image) - except Exception: # nosec B110 - best-effort canvas reset during cleanup; non-critical - pass + except (OSError, RuntimeError, ValueError, MemoryError): + logger.debug("Canvas reset during cleanup failed", exc_info=True) # Reset the singleton state when cleaning up DisplayManager._instance = None DisplayManager._initialized = False diff --git a/web_interface/blueprints/api_v3.py b/web_interface/blueprints/api_v3.py index d3291009..58c4fe68 100644 --- a/web_interface/blueprints/api_v3.py +++ b/web_interface/blueprints/api_v3.py @@ -699,7 +699,7 @@ def save_main_config(): # Handle display settings display_fields = ['rows', 'cols', 'chain_length', 'parallel', 'brightness', 'hardware_mapping', - 'gpio_slowdown', 'scan_mode', 'disable_hardware_pulsing', 'inverse_colors', 'show_refresh_rate', + 'gpio_slowdown', 'rp1_rio', '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', 'max_dynamic_duration_seconds', 'led_rgb_sequence', 'multiplexing', 'panel_type'] @@ -747,6 +747,14 @@ def save_main_config(): # Handle runtime settings if 'gpio_slowdown' in data: 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 for checkbox in ['disable_hardware_pulsing', 'inverse_colors', 'show_refresh_rate']: @@ -1527,6 +1535,23 @@ def execute_system_action(): print(error_details) 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']) def get_display_current(): """Get current display state""" diff --git a/web_interface/templates/v3/partials/display.html b/web_interface/templates/v3/partials/display.html index dba6f219..a7642ff1 100644 --- a/web_interface/templates/v3/partials/display.html +++ b/web_interface/templates/v3/partials/display.html @@ -4,6 +4,25 @@

Configure LED matrix hardware settings and display options.

+ + +
-
+
-

GPIO slowdown factor (0-5)

+

Pi 3: 1–2 · Pi 4: 2–4 · Pi 5 PIO: 1–3. Increase if display shows garbage; in RIO mode higher values may improve performance.

+
+ +
+ + +

Pi 5 RP1 coprocessor mode. Ignored on Pi 3/4.