Commit Graph
100 Commits
Author SHA1 Message Date
05b3fa56cb fix: Codacy security fixes, CVE dependency bumps, and code quality cleanup (#331)
* fix(deps): bump minimum versions to address CVEs

Pillow 10.4.0 → 12.2.0: CVE-2026-40192 (DoS via FITS decompression bomb),
CVE-2026-25990 (OOB write via PSD image), CVE-2026-42311/42308/42310

requests 2.32.0 → 2.33.0: CVE-2026-25645 (temp file security bypass),
CVE-2024-47081 (.netrc credentials leak)

werkzeug 3.0.0 → 3.1.6: CVE-2023-46136, CVE-2024-49766/49767,
CVE-2025-66221, CVE-2026-21860/27199 (DoS, path traversal, safe_join bypass)

Flask 3.0.0 → 3.1.3: CVE-2026-27205 (session data caching info disclosure)

spotipy 2.24.0 → 2.25.2: CVE-2025-27154, CVE-2025-66040

python-socketio 5.11.0 → 5.14.0: CVE-2025-61765

pytest 7.4.0 → 9.0.3: CVE-2025-71176 (insecure temp dir handling)

Updated in requirements.txt, web_interface/requirements.txt,
plugin-repos/starlark-apps/requirements.txt, and
plugin-repos/march-madness/requirements.txt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: resolve Pylint errors in executor, data service, and odds call

Rename TimeoutError to PluginTimeoutError in plugin_executor.py to
avoid shadowing the built-in; no external callers affected.

Remove dead try/except in BackgroundDataService.shutdown: executor.shutdown()
never accepted a timeout kwarg so the try branch always raised TypeError.
Simplify to a direct shutdown(wait=wait) call.

Remove is_live kwarg from odds_manager.get_odds() call in sports.py;
BaseOddsManager.get_odds() has no such parameter. The live update interval
is already encoded in the update_interval_seconds argument passed alongside.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: MD5→SHA-256, shellcheck warnings, and broken doc links

config_service.py: replace MD5 with SHA-256 for config change detection;
same semantics (equality comparison), no stored hashes affected.

Shell scripts — shellcheck warnings:
- diagnose_web_interface.sh: remove useless cat (SC2002)
- dev_plugin_setup.sh: restructure A&&B||C into if/then (SC2015)
- fix_assets_permissions.sh: remove unused REAL_HOME block (SC2034)
- install_web_service.sh: remove unused USER_HOME assignment (SC2034)
- diagnose_web_ui.sh: remove unused SUDO assignments (SC2034)
- diagnose_plugin_permissions.sh: remove unused BLUE color var (SC2034)
- first_time_install.sh: remove unused CLEAR var, PACKAGE_NAME
  assignment, and replace loop variable with _ (SC2034)

docs/PLUGIN_ARCHITECTURE_SPEC.md: fix 10 broken TOC anchor links to
include section numbers matching the actual headings (MD051).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: remove unused imports and bare exception aliases (pyflakes F401/F841)

Remove unused imports across 86 files in src/, web_interface/, test/,
and scripts/ using autoflake. No logic changes — only dead import
statements and unused names in from-imports are removed.

Also remove bare exception aliases where the variable is never
referenced in the handler body:
- src/cache/disk_cache.py: except (IOError, OSError, PermissionError) as e
- src/cache_manager.py: except (OSError, IOError, PermissionError) as perm_error
- src/plugin_system/resource_monitor.py: except Exception as e
- web_interface/app.py: except Exception as read_err

86 files changed, 205 lines removed, 18 pre-existing test failures unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: remove unused local variable assignments (pyflakes F841)

Dead assignments removed across src/ and web_interface/:

- background_data_service: drop future= on fire-and-forget executor.submit
- base_classes/baseball: drop font= (all rendering uses self.fonts['time'])
- base_classes/hockey: drop status_short= (never referenced after assignment)
- common/cli: drop game_helper=/config_helper= bindings in import-test block;
  constructors called for instantiation-only validation
- common/display_helper: drop text_width= (x_position uses display_width
  directly); drop draw= in create_error_image (uses _draw_centered_text)
- config_manager: remove dead secrets_content loading block in migration path
  (comment already noted save_config_atomic handles secrets internally)
- display_manager: drop setup_start= (timing was never completed or read)
- font_manager: drop target_path= (catalog uses font_file_path directly);
  drop face=/font= bindings in validate_font (validation by construction —
  TypeError on failure is the signal, not the return value)
- font_test_manager: drop width=/height= (draw_text uses display_manager directly)
- plugin_system/state_reconciliation: drop manager= (only config/disk/state_mgr used)
- plugin_system/store_manager: drop result= on pip install subprocess.run
  (check=True raises on failure; stdout unused)
- web_interface/blueprints/pages_v3: drop main_config_path=""/secrets_config_path=""
  (render_template uses config_manager.get_*_path() inline)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(js): resolve ESLint no-undef warnings across 6 JS files

Three distinct patterns:

1. Vendor library globals — htmx is injected by <script> before these
   extension files load; ESLint lints files in isolation and doesn't know.
   Fix: add /* global htmx */ to htmx-sse.js and htmx-json-enc.js.

2. Cross-file globals — showNotification is defined as window.showNotification
   in app.js/notification.js but called bare in app.js and error_handler.js.
   ESLint doesn't connect window.X = Y with a bare call to X.
   Fix: add /* global showNotification */ to app.js and error_handler.js.

3. Forward-reference window.* functions — in array-table.js, checkbox-group.js,
   and custom-feeds.js, functions like removeArrayTableRow are called early
   inside event-handler closures but assigned to window.* later in the file.
   At runtime this works (the handler fires after the assignment), but ESLint
   sees the bare name at the call site.
   Fix: change bare calls to window.removeArrayTableRow(this) etc. so the
   reference is explicit and ESLint-safe.

Also guard the updateSystemStats call in app.js reconnectSSE: the function
is called but defined nowhere in the codebase. Guard with typeof check so
it won't throw ReferenceError if the reconnect path is hit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(js): resolve Biome lint warnings across 9 JS files

noUnusedVariables (catch bindings → optional catch syntax):
- app.js, file-upload.js, timezone-selector.js: } catch (e) { → } catch {
  ES2019 optional catch binding; e was unused in all three handlers

noUnusedVariables (dead assignments):
- app.js: remove const data= in display SSE stub (handler does nothing yet)
- api_client.js: remove const timeoutId= (setTimeout ID never used to cancel)
- custom-feeds.js: remove const oldIndex= (getAttribute result never read)
- schedule-picker.js: remove const compactMode= (never used in HTML build)
- select-dropdown.js: remove const icons= (icons not yet rendered in options)

noPrototypeBuiltins:
- day-selector.js: DAY_LABELS.hasOwnProperty(x) →
  Object.prototype.hasOwnProperty.call(DAY_LABELS, x)
  Safe form that works even on null-prototype objects

useIterableCallbackReturn:
- file-upload.js, notification.js: forEach(x => expr) →
  forEach(x => { expr; }) — forEach ignores return values;
  implicit return from arrow body was misleading

htmx-sse.js is a vendor extension file with old-style var/== patterns
that are correct for it; 18 Biome issues suppressed via Codacy API
rather than modifying the vendor source.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(security): escape user input in raw HTML responses in pages_v3.py

plugin_id comes directly from the URL path
(/partials/plugin-config/<plugin_id>) and was interpolated into an HTML
fragment without escaping. A crafted URL like
/partials/plugin-config/<script>alert(1)</script> would inject that
tag into the DOM via the HTMX partial response.

Fix: wrap all user-controlled values in markupsafe.escape() before
embedding in raw HTML strings. Affects the plugin-not-found 404
response and both error 500 responses in the plugin config partial.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: address Bandit B108/B110 across production code

B110 (try/except/pass):
- display_controller.py: narrow 'except Exception' to 'except AttributeError'
  for get_offset_frame() — plugins not having this optional method is the
  expected case, not all exceptions
- config_manager.py: B110 already resolved by the earlier removal of the
  dead secrets-loading block (the except/pass was inside it)
- All other except/pass blocks in src/ and web_interface/ are intentional
  (last-resort recovery, best-effort fallbacks, non-critical startup probes).
  Annotated each with # nosec B110 and a brief inline reason so the decision
  is explicit for future reviewers.
- Test files and plugin-repos B110 suppressed via Codacy API (not prod code).

B108 (/tmp usage):
- permission_utils.py: /tmp listed to PREVENT permission changes on it — not
  used as a temp path. Annotated # nosec B108.
- display_manager.py: fixed snapshot path is intentional (web UI reads same
  path); path-check guard also annotated.
- wifi_manager.py: named /tmp files match the sudoers allowlist installed with
  the system (the paths are hard-coded in both places by design). Annotated
  all six open/cp references # nosec B108.
- scripts/render_plugin.py: dev script default overridable by user. Annotated.
- web_interface/app.py: reads the same fixed path written by display_manager.
  Annotated # nosec B108.
- Test files suppressed via Codacy API.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: address remaining Codacy security findings

Flask debug=True (real fix):
- web_interface/app.py: debug=True in __main__ block exposes the Werkzeug
  interactive debugger (arbitrary code execution). Changed to
  os.environ.get('FLASK_DEBUG', '0') == '1' — off by default, opt-in
  via environment variable for local development.

nosec annotations (accepted risk with documented rationale):
- disk_cache.py: os.chmod(0o660) is intentional — web UI and LED matrix
  service share a group, 660 gives group write while denying world access
  (B103 + Semgrep insecure-file-permissions suppressed in Codacy)
- wifi_manager.py: urlopen to hardcoded connectivity-check.ubuntu.com URL
  (B310 — no user input involved)
- font_manager.py: urlretrieve URL comes from user's own config file on
  their local device (B310)
- start_web_conditionally.py: os.execvp with both sys.executable and a
  fixed PROJECT_DIR-relative constant (B606)

Confirmed false positives suppressed via Codacy API (15 issues):
- SSRF (3x): client-side JS fetch — SSRF is server-side; browser fetch
  is CORS-restricted to same origin
- B105 (3x): test fixtures use dummy secrets by design; store_manager
  checks for the placeholder string, it is not itself a secret
- PMD numeric literal (2x): 10000000 is within Number.MAX_SAFE_INTEGER
- Prototype pollution (1x): read-only schema traversal, no writes
- no-unsanitized_method (1x): dynamic import() is CORS-restricted
- detect-unsafe-regex (1x): operates on server-controlled config values
- plugin-repos B103 (1x): vendor code chmod on executable
- Semgrep insecure-file-permissions (3x): same disk_cache 0o660 as above

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: remove unnecessary f prefix from f-strings without placeholders (F541)

Pyflakes F541 flags f-strings that contain no {} interpolation — they are
identical to plain strings but trigger unnecessary string formatting overhead.

Fixed in production code:
- src/base_classes/data_sources.py (2 debug log calls)
- src/logo_downloader.py (1 error log)
- src/plugin_system/store_manager.py (5 strings across 3 log calls)
- src/web_interface/validators.py (1 return value)
- src/wifi_manager.py (4 log/message strings)
- web_interface/start.py (1 print)

F541 issues in test/, scripts/, and plugin-repos/ suppressed via Codacy API
as non-production code.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore(dev): add Pillow compatibility smoke test script

Covers all Pillow APIs used in LEDMatrix — image creation, drawing,
font metrics, LANCZOS resampling, paste/alpha_composite, and PNG I/O.
Run after any Pillow version bump to catch regressions before deploy.

    python3 scripts/dev/test_pillow_compat.py

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: resolve 8 new Codacy issues introduced by PR changes

shellcheck SC2034:
- first_time_install.sh: 'type' loop variable also unused in the wifi
  status loop (we previously fixed 'device' → '_' but left 'type').
  Changed to '_ _ state' since neither device nor type is referenced.

ESLint no-undef:
- app.js: typeof guards don't satisfy no-undef; added updateSystemStats
  to the /* global */ declaration alongside showNotification.

nosec annotation:
- web_interface/app.py: app.run(host='0.0.0.0') line changed when we
  fixed debug=True, giving it a new issue ID. Re-added # nosec B104.

pyflakes F401:
- scripts/dev/test_pillow_compat.py: ImageFilter was imported but never
  used in the smoke test. Removed from the import.

Codacy API suppressions (false positives on changed lines):
- disk_cache.py 0o660 chmod (2x): lines changed when # nosec B103 was
  added, producing new Semgrep issue IDs. Re-suppressed.
- pages_v3.py raw-html-concat: Semgrep does not recognise escape() as
  a sanitizer; the escape() call IS the correct fix.
- app.py flask 0.0.0.0: same line as B104 above; Semgrep rule also
  re-suppressed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: address PR review findings

Fix (10 of 15 findings):

plugin-repos/march-madness/requirements.txt:
  Add urllib3>=1.26.0 — manager.py directly imports from urllib3; it was
  an undeclared transitive dependency via requests.

scripts/dev/dev_plugin_setup.sh:
  Restore subshell form (cd "$target_dir" && git pull --rebase) || true
  so the shell's working directory is not permanently changed after the
  if-cd block. Previous fix for SC2015 leaked cwd into the remainder of
  the script.

src/base_classes/sports.py:
  Narrow 'except Exception' to 'except RuntimeError as e' and log via
  self.logger.debug — Path.home() raises only RuntimeError for service
  users; other exceptions should not be silently swallowed.

src/config_service.py:
  Fix stale "MD5 checksum" in ConfigVersion.__init__ docstring (line 40);
  the implementation uses SHA-256 since the Codacy fix.

src/wifi_manager.py:
  Log the last-resort AP enable failure with exc_info=True instead of
  silently passing — failure here means the device may be unreachable.

web_interface/blueprints/pages_v3.py:
  Log the outer metadata pre-load exception at debug level instead of
  swallowing it silently; schema still loads fully below.

src/background_data_service.py:
  Remove unused 'timeout' parameter from shutdown() — executor.shutdown()
  does not accept timeout; update __del__ caller accordingly.

src/font_manager.py:
  Validate URL scheme before urlretrieve — reject non-http/https schemes
  (e.g. file://) to prevent reading local files from config-supplied URLs.

src/plugin_system/plugin_executor.py:
  Simplify redundant except tuple: (PluginTimeoutError, PluginError,
  Exception) → Exception, which already covers the others.

test/test_display_controller.py:
  Mark empty test_plugin_discovery_and_loading as @pytest.mark.skip with
  reason. Move duplicate 'from datetime import datetime' to module header
  and remove the stray mid-module copy.

Skip (5 of 15 findings, with reasons):
  - pytest 9.0.3 concerns: full suite already verified (467 pass, 18 pre-existing)
  - Pillow 12.2.0 API concerns: no deprecated APIs in codebase; tests + Pi smoke test pass
  - diagnose_web_ui.sh sudo validation: set -e already ensures fail-fast on any sudo failure
  - app.py request-logging except: must stay silent (recursive logging risk); annotated
  - app.py SSE file-read except: genuinely transient I/O; annotated

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 10:19:55 -04:00
452afacd12 fix(news): custom RSS feed save fails with validation error when no logo (#329)
_set_missing_booleans_to_false was unconditionally creating an empty
dict for every nested-object sub-property when processing array items.
For the news plugin's custom_feeds, this produced logo:{} on every feed
item that had no logo uploaded. jsonschema then validated that empty
object against logo's required:["id","path"] constraint and failed.

Fix: skip recursion into a sub-object when it isn't already present in
the array item. There's no reason to create an optional object like
logo just to look for boolean fields inside it.

Also extend _filter_config_by_schema to recurse into array items when
the items schema has properties. Previously arrays were passed through
unchanged, so any stray field on a feed item (legacy data, migration
artifacts) would survive to validation where additionalProperties:false
would reject it.

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 15:51:17 -04:00
3b45a75f75 fix: pixlet install false-failure, force render in web service, web UI perf (#328)
* fix: pixlet install false-failure, force render in web service, web UI perf

Fixes three user-reported issues:

1. Pixlet install reported failure even on success — `set -e` in
   download_pixlet.sh caused the script to exit non-zero when
   `((success_count++))` evaluated the post-increment old value (0),
   which bash treats as a failed command. Fixed with shell arithmetic
   assignment instead.

2. Force render returned 503 "plugin not loaded in web service" — the
   web service runs its own PluginManager with display_manager=None,
   so the starlark-apps plugin can never be loaded there. Added a
   standalone render path (_standalone_render_starlark_app) that calls
   pixlet directly from the web service, writing to cached_render.webp.
   The main-service plugin path is preserved and tried first.

3. Web UI sluggishness — the SSE /stream/stats generator was blocking
   a Flask thread for 1s per tick via psutil.cpu_percent(interval=1).
   Switched to non-blocking interval=None (primed at startup). Also
   cached the per-client ledmatrix systemctl check (15s TTL), raised
   the AP mode check TTL from 5s to 30s, and halved the display
   preview poll rate from 0.5s to 1s.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor(starlark): surface config errors, deduplicate pixlet lookup, uniform response shape

- _standalone_render_starlark_app: split silent except into separate
  json.JSONDecodeError and OSError handlers that return (False, message)
  with the file path and parse error, so callers know when config.json
  is unreadable rather than silently rendering with empty config

- get_starlark_status: replace 14-line inline platform/shutil pixlet
  lookup with _find_pixlet_binary(), which also checks the user-
  configured starlark-apps.pixlet_path — the old code never consulted
  that setting so the status endpoint could wrongly report pixlet
  unavailable even when a custom path was configured

- render_starlark_app standalone branch: add frame_count: 0 to success
  and error responses so both branches return the same payload shape;
  0 is accurate since standalone render writes cached_render.webp but
  does not extract frames into memory

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(starlark): safe chmod fallback in pixlet lookup, semantic HTTP codes in standalone render

_find_pixlet_binary: bundled.chmod(0o755) could raise OSError (e.g.
file owned by root) and abort the entire resolution chain, skipping
the PATH fallback. Now checks os.access first; if chmod is needed,
wraps it in try/except OSError, logs a warning, and falls through to
shutil.which so PATH is always tried.

_standalone_render_starlark_app: expanded return type from
(bool, str) to (bool, int, str) so each failure mode carries a
semantic HTTP status code:
  app/star file missing  → 404
  invalid/unreadable config.json → 400
  pixlet binary missing  → 503
  pixlet non-zero exit   → 502
  subprocess timeout     → 504
  unexpected exception   → 500
  success                → 200
Call site updated to unpack (success, status_code, error) and forward
status_code directly in both success and error responses.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(starlark): validate config.json is a dict before iterating items

json.load succeeds on valid JSON that isn't an object (e.g. an array
or bare string), leaving app_config as a non-dict and causing an
AttributeError on the subsequent .items() call. Added isinstance check
immediately after json.load; non-dict values return (False, 400, ...)
with the actual type name in the message.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(starlark): harden pixlet binary check and manifest shape validation

_find_pixlet_binary: switched bundled.exists() to bundled.is_file() so
a directory named pixlet-linux-arm64 (traversable, hence X_OK) is no
longer returned as a binary; re-check os.access after chmod succeeds
so we only return the path when executability is confirmed, with a
separate warning if it still isn't executable after chmod.

_standalone_render_starlark_app: added isinstance guards on the
manifest and apps values returned by _read_starlark_manifest(); a
manifest.json containing valid non-object JSON (e.g. an array) would
previously raise AttributeError on .get(); invalid shape now returns
(False, 400, ...) instead.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 14:14:48 -04:00
1a0f1c8015 fix: service control buttons and AP-mode SSH lockout post-install (#326)
* fix: service control buttons and AP-mode SSH lockout post-install

Two user-reported issues after fresh install:

1. All service buttons (Start/Stop/Restart Display, Restart Web Service)
   failed silently — only Reboot worked.

   Root cause: sudoers rules use `ledmatrix.service` (with suffix) but
   api_v3.py called `sudo systemctl start ledmatrix` (no suffix). sudo
   does exact string matching, so every service action was rejected with
   returncode=1. Also missing from sudoers: ledmatrix-web, journalctl,
   and is-active entries.

   Fix:
   - Add `.service` suffix to all 8 sudo systemctl call sites in
     api_v3.py (_ensure_display_service_running, _stop_display_service,
     and all execute_system_action branches).
   - Add timeout=15 to all subprocess.run calls in execute_system_action
     (previously could hang indefinitely).
   - Add missing sudoers rules to first_time_install.sh and
     configure_web_sudo.sh: ledmatrix-web.service start/stop/restart,
     is-active for both name forms, and journalctl -u/-t ledmatrix rules.

2. SSH and web UI became inaccessible after ~1 hour even though the
   display kept running.

   Root cause: wifi_monitor_daemon restarts NetworkManager after 5
   consecutive internet failures (~2.5 min). Each NM restart drops WiFi
   briefly. During that window check_and_manage_ap_mode() increments
   _disconnected_checks but the daemon never reset it after the restart.
   After 3 such NM-restart cycles, _disconnected_checks reached 3 and
   AP mode activated — changing the Pi from WiFi client to hotspot
   (192.168.4.1) and killing SSH on the old IP.

   Fix:
   - Reset wifi_manager._disconnected_checks = 0 in the daemon
     immediately after a successful NM restart so the brief drop it
     causes doesn't count toward AP-mode activation.
   - Increase _disconnected_checks_required from 3 to 6 (90s → 3min)
     as an additional buffer against transient network flaps.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* revert: restore AP-mode grace period to 90s (3 checks)

The counter reset after NM restart already fully prevents the SSH-lockout
cascade: _disconnected_checks can never accumulate across NM restarts
because it is reset to 0 before the next daemon iteration runs.

The 3→6 increase provided no additional fix for the described problem and
caused a UX regression: fresh Pi devices with no WiFi configured would
wait 3 minutes instead of 90 seconds for the LEDMatrix-Setup hotspot to
appear.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: address five valid review findings; skip two

Fixed:
- march-madness/requirements.txt: Pillow>=10.3.0 (patches CVE-2024-28219;
  10.3.0 is the actual fix version — reviewer cited 12.2.0 but that risks
  breaking API changes without test coverage)
- wifi_monitor_daemon.py: add missing `import subprocess`; subprocess.run
  and CalledProcessError would NameError at runtime on the NM restart path
- wifi_manager.py: validate ap_idle_timeout_minutes before arithmetic —
  coerce to int, clamp 1–1440, fall back to 15 on bad config values
- wifi_manager.py: call _remove_nm_dnsmasq_captive_conf() on all three
  rollback paths in _enable_ap_mode_nmcli_hotspot() and in the top-level
  except block so stale dnsmasq drop-ins are never left behind
- api_v3.py: fix wrong_password prefix strip — removeprefix("wrong_password:")
  then lstrip() handles both "wrong_password: msg" and "wrong_password:msg"
- plugins_manager.js: add .catch() to loadInstalledPlugins().then() to
  surface failures instead of silently dropping unhandled rejections

Skipped:
- WiFiManager AP state persistence: architectural overhaul; _is_ap_mode_active()
  already derives from live system state, not in-memory variables
- Absolute subprocess paths in api_v3.py: paths vary by distro (/usr/bin vs
  /bin); web service has a normal PATH; sudoers already use resolved paths

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: address five review findings (NM retry loop, start_display message, code quality)

- wifi_monitor_daemon: reset _consecutive_internet_failures = 0 in both
  NM-restart exception handlers; previously both left the counter at threshold,
  causing an immediate retry on the next iteration instead of waiting another
  full backoff period

- api_v3: fix start_display failure message — when mode is set and systemctl
  returns non-zero, message now includes the failure reason and a hint rather
  than always reporting success phrasing

- wifi_manager: move _redirect_backend from class variable to instance variable
  in __init__ alongside _ap_enabled_at; class-level default shadowed correctly
  in practice (single instance) but was misleading

- wifi_manager: narrow broad except Exception in _check_internet_connectivity
  to (subprocess.SubprocessError, OSError) for ping and OSError for HTTP
  (urllib.error.URLError is an OSError subclass in Python 3)

- wifi_manager: remove redundant local 'import re as _re' in _validate_ap_config;
  re is already imported at module level (line 37)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: address five review findings (Pillow CVEs, daemon exception narrowing, timeout handling, plugin store)

- march-madness/requirements.txt: Pillow>=12.2.0 (patches CVE-2026-42308
  and CVE-2026-42310; previous floor of 10.3.0 was insufficient)

- wifi_monitor_daemon: narrow final except Exception to
  (subprocess.SubprocessError, OSError) so programming errors in the NM
  restart block are no longer silently swallowed

- api_v3/execute_system_action: add explicit subprocess.TimeoutExpired
  handler before the generic Exception catch; returns action-specific
  message with 'status','message','returncode','stdout','stderr' fields
  so the UI receives a precise, actionable payload instead of the generic
  'Failed to execute system action' string

- plugins_manager.js: move searchPluginStore into .finally() so the
  plugin store renders regardless of whether loadInstalledPlugins succeeds
  or fails; .catch() still logs the error

- first_time_install.sh: add safe_plugin_rm.sh NOPASSWD rule to the
  /tmp/ledmatrix_web_sudoers block; configure_web_sudo.sh had this rule
  but the standalone installer never granted it, leaving plugin removal
  broken after first-time install

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor(api): resolve sudo/systemctl/reboot/poweroff paths at startup

Use shutil.which() with safe fallbacks for the four privileged binaries
instead of relying on bare names being resolved by the subprocess shell
search. Resolves paths once at module load rather than per-call.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 17:58:51 -04:00
ceb4c4105f fix(wifi): reliable open AP with captive portal — tested on Trixie Pi (#320)
* fix(wifi): create truly open AP via nmcli connection add; add captive portal to nmcli path

nmcli device wifi hotspot always attaches a WPA2 PSK on Bookworm/Trixie
and silently ignores post-creation security modifications, causing users
to be prompted for an unknown password. Switch to nmcli connection add
with 802-11-wireless.mode ap and no security section — NM cannot auto-add
a password to a profile that has no 802-11-wireless-security block.

Also:
- Remove dead DEFAULT_AP_PASSWORD / ap_password config field (stored but
  never passed to hostapd or nmcli, causing user confusion)
- Add iptables port 80→5000 redirect to the nmcli AP path so captive portal
  auto-popup works on phones without hostapd (previously only worked on
  the hostapd path)
- Clean up iptables rules on disable for the nmcli path
- Improve LED message on AP enable: show SSID, "No password", and IP:port
  on both paths so users know exactly how to connect
- Fix systemd template: replace hardcoded /home/ledpi/LEDMatrix/ with
  __PROJECT_ROOT_DIR__ placeholder (install script already writes correct path)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(wifi): address Codacy review findings in AP mode implementation

- Validate ap_ssid/ap_channel from config before passing to subprocess
  (printable ASCII ≤32 chars; channel 1-14) to prevent command injection

- Fix INPUT iptables rule: PREROUTING redirects port 80→5000 so the INPUT
  chain sees dport=5000, not 80. Old INPUT rule on port 80 was a no-op.

- Refactor iptables setup/teardown into _setup_iptables_redirect() and
  _teardown_iptables_redirect() helpers, eliminating duplicate logic in
  the hostapd and nmcli paths

- Save/restore ip_forward state (via /tmp/ledmatrix_ip_forward_saved)
  instead of forcing it to 0 on cleanup, which could break VPNs or
  bridges already relying on forwarding

- nmcli path skips ip_forward management entirely: NM's ipv4.method=shared
  already manages it for the duration of the connection

- Fix _get_ap_status_nmcli() verification: new 'connection add type wifi'
  profiles have type '802-11-wireless', not 'hotspot', so verification was
  always returning False. Now also matches by our known connection name.

- Remove SSID-based connection deletion: deleting any profile whose SSID
  matched the AP SSID could destroy a user's saved home WiFi profile.
  Now only deletes by our application-managed profile names.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(plugins): fix async race in refreshPlugins; use cache TTL to gate re-swap metadata fetch

refreshPlugins() called searchPluginStore(true) and showNotification() immediately
after refreshInstalledPlugins() without awaiting the returned Promise, so
window.installedPlugins could still be stale when the store rendered its
Installed/Reinstall badges. Chain .then() so both run only after the fetch
completes.

In initializePlugins(), the re-swap path always passed fetchCommitInfo=false to
searchPluginStore, skipping GitHub metadata even when the 5-minute cache TTL had
expired. Add storeCacheExpired() helper and compute isReswapWarm = _reswap &&
!storeCacheExpired() so fresh metadata is fetched whenever the cache is cold,
regardless of whether the render is a first load or a tab re-swap.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: address three wifi_manager and one plugins_manager review findings

wifi_manager.py:
- _create_hostapd_config: use _validate_ap_config() for ssid/channel instead
  of raw self.config values; strip newlines from SSID to prevent config-file
  injection via the generated hostapd.conf
- _setup_iptables_redirect: check return codes of sysctl ip_forward enable and
  both iptables -A calls; on any failure log the error output, call
  _teardown_iptables_redirect() to restore state, and return False instead of
  silently succeeding
- _enable_ap_mode_nmcli_hotspot: on AP verification failure roll back fully —
  tear down iptables redirect, delete the LEDMatrix-Setup-AP connection profile,
  clear the LED message — before returning False

plugins_manager.js:
- initializePlugins: chain searchPluginStore(!isReswapWarm) inside
  loadInstalledPlugins().then() so window.installedPlugins is populated before
  the store renders Installed/Reinstall badges (same pattern applied to
  refreshPlugins() in the previous commit)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(wifi): use _find_command_path for iptables/sysctl; harden ip_forward save/restore

Add _find_command_path() helper that extends _check_command()'s sbin-aware lookup to
return the absolute binary path rather than a boolean. Use it in
_setup_iptables_redirect and _teardown_iptables_redirect so iptables and sysctl are
resolved via /sbin or /usr/sbin even when those directories are absent from PATH in
systemd service environments.

Also harden the ip_forward save/restore logic:
- Read ip_forward from /proc/sys/net/ipv4/ip_forward (no subprocess, no PATH
  dependency) instead of spawning sysctl -n
- Skip the sysctl -w ip_forward=1 write when the value is already "1" to avoid
  mutating state owned by another service (VPN, NM shared mode, bridge)
- Track save success via presence of the save file: if the /proc read or file write
  fails, leave the file absent so teardown knows not to restore
- In _teardown_iptables_redirect, only restore ip_forward when the save file exists;
  if absent, leave the current value untouched rather than forcing "0"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(wifi): check _setup_iptables_redirect return; fix hostapd LED SSID; teardown on exception

- Both AP startup paths (hostapd and nmcli) now check the bool returned by
  _setup_iptables_redirect() and treat False as a hard failure: the hostapd
  path stops hostapd/dnsmasq and returns an error tuple; the nmcli path brings
  down and deletes the LEDMatrix-Setup-AP profile and clears the LED message

- _enable_ap_mode_hostapd's LED message now calls _validate_ap_config() to get
  the same sanitized SSID that _create_hostapd_config() uses, so the displayed
  name always matches the AP actually broadcast by hostapd

- _setup_iptables_redirect's outer except block now calls
  _teardown_iptables_redirect() before returning False so partial iptables/
  ip_forward state is always cleaned up on unexpected exceptions; cleanup
  exceptions are caught and logged separately

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(wifi): add unit tests for AP mode — open network, iptables, LED, cleanup ordering

Six pytest unit tests covering the five review scenarios. All subprocess and
filesystem side-effects are mocked so the tests run without root, hardware, or
a Pi OS environment.

1. test_nmcli_ap_profile_has_no_security_params — asserts the nmcli connection
   add command has no key-mgmt / psk / WPA arguments and sets mode=ap.
2. test_iptables_nat_rules_added_on_ap_start — verifies _setup_iptables_redirect
   emits a PREROUTING REDIRECT 80→5000 rule and an INPUT ACCEPT rule for port
   5000 (not 80, which never hits INPUT after PREROUTING rewrites it).
3. test_iptables_rules_and_ip_forward_reverted_on_teardown — verifies the -D
   PREROUTING/-D INPUT calls and that sysctl restores the saved ip_forward value
   and removes the save file.
4. test_ip_forward_not_restored_when_save_file_absent — verifies teardown skips
   sysctl when the save file was never written, preventing blind ip_forward=0 on
   systems using ip_forward for VPNs or NM shared mode.
5. test_led_message_shows_ssid_no_password_and_url — asserts the LED message
   includes the SSID, 'No password', and the 192.168.4.1:5000 setup URL.
6. test_existing_ap_profiles_deleted_before_new_profile_created — asserts all
   known profile names are targeted for deletion before 'nmcli connection add'.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(wifi): adopt adsb-feeder-image hotspot patterns — DNS spoofing, connectivity check, idle timeout, wrong-password UX, watchdog escalation

Inspired by the production-proven approach in dirkhh/adsb-feeder-image.

1. DNS spoofing for automatic captive-portal popup (Change 1 — Critical)
   Write /etc/NetworkManager/dnsmasq-shared.d/ledmatrix-captive.conf with
   address=/#/192.168.4.1 before nmcli connection up so NM's built-in
   dnsmasq (ipv4.method=shared) resolves every hostname to the AP IP.
   This triggers the OS captive-portal popup automatically on iOS / Android /
   Windows / macOS — no manual navigation to 192.168.4.1:5000/setup required.
   New helpers: _write_nm_dnsmasq_captive_conf / _remove_nm_dnsmasq_captive_conf.
   New constants: NM_DNSMASQ_SHARED_DIR / NM_DNSMASQ_SHARED_CONF.

2. Real internet connectivity check (Change 2 — High)
   Add _check_internet_connectivity() (ping 8.8.8.8 + HTTP fallback).
   check_and_manage_ap_mode() now considers a device "disconnected" when nmcli
   shows connected but no real internet reachability, matching adsb-feeder's
   multi-method gateway/DNS/HTTP test approach.

3. AP idle timeout (Change 3 — Medium)
   Track _ap_enabled_at timestamp in enable_ap_mode(). Add _has_ap_clients()
   using 'iw dev <iface> station dump'. check_and_manage_ap_mode() auto-disables
   AP after ap_idle_timeout_minutes (default 15) with no associated clients.

4. Wrong-password error feedback (Change 4 — Medium)
   _connect_nmcli() detects "Secrets were required" / "authentication rejected"
   in nmcli stderr and prefixes the message with "wrong_password: ".
   The /api/v3/wifi/connect route propagates error_type="wrong_password" in the
   JSON response. captive_setup.html shows "Incorrect password — try again"
   (keeping the form active) instead of the generic failure message.

5. Escalating watchdog NM restart (Change 5 — Low)
   wifi_monitor_daemon.py tracks _consecutive_internet_failures. After
   _nm_restart_threshold (5) consecutive checks where nmcli shows connected but
   internet is unreachable, restart NetworkManager as a recovery step.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(wifi): restore safe AP-enable trigger; decouple internet check from AP logic

The previous commit introduced _check_internet_connectivity() into
check_and_manage_ap_mode(), which shared the same _disconnected_checks counter
that triggers AP enable. This created a false-positive risk: 90 seconds of
packet loss on working WiFi would enable AP mode and kick off the connection.

Fix: restore nmcli association state as the sole AP-enable trigger (original,
safe behaviour). The internet connectivity check is now used only in the daemon
watchdog for the NM-restart escalation — matching how adsb-feeder-image actually
structures the two concerns (initial setup detection vs. ongoing monitoring).

Also clarify daemon comment: the connectivity check runs once per cycle in the
watchdog block, not inside check_and_manage_ap_mode, so there is no double-call.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(wifi): remove PMF setting from open AP profile — breaks nmcli connection add on Trixie NM 1.52+

802-11-wireless-security.pmf is only valid within a security section that also
includes key-mgmt. Adding it to an open-network profile causes NM 1.52+ to
reject the connection add with 'key-mgmt: property is missing'. PMF has no
meaning for open APs (it only applies to WPA2/WPA3), so the setting is simply
removed rather than worked around.

Found by testing on devpi (Trixie, NM 1.52.1).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(wifi): add nftables fallback for port redirect; graceful degradation when neither available

Tested on devpi (Trixie, NM 1.52.1): iptables is not installed; nftables is.
The original code called _setup_iptables_redirect() and treated 'iptables not
found' as a hard failure, rolling back the entire AP setup.

Changes:
- _setup_iptables_redirect() now tries iptables first, then nftables as a
  fallback. When neither is available it logs a warning and returns True so
  the AP still comes up (DNS spoofing still triggers the captive portal popup;
  users land on port 5000 directly instead of being auto-redirected from 80).
- Split into _setup_iptables_redirect_iptables() and
  _setup_iptables_redirect_nftables() for clarity.
- Added _redirect_backend instance var ("iptables" | "nftables" | None) so
  _teardown_iptables_redirect() uses the same tool that setup used.
- nftables teardown: deletes the 'ledmatrix' table (clean, no leftover rules).
- iptables teardown: unchanged logic (ip_forward save/restore).
- Also removed the PMF workaround for Trixie: 802-11-wireless-security.pmf
  requires key-mgmt to also be set, breaking open-network creation on NM 1.52+.
  Open APs have no management frame protection by definition.
- Update teardown test to set _redirect_backend = "iptables" before calling it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(wifi): public check_internet_connectivity(); absolute systemctl path; stricter mode assertion

wifi_manager.py:
- Add public check_internet_connectivity() wrapping the private method so the
  daemon does not reach into the private API

wifi_monitor_daemon.py:
- Call wifi_manager.check_internet_connectivity() instead of the private
  _check_internet_connectivity()
- Use /usr/bin/systemctl (absolute path) instead of bare "systemctl"
- Wrap NM restart in try/except with check=True; only reset
  _consecutive_internet_failures on success — on CalledProcessError or other
  exception, log the error and leave the counter unchanged so the next cycle
  retries

test/test_wifi_manager_ap.py:
- Replace loose `assert "ap" in add_calls[0]` (list-membership check that
  could be satisfied by any element equal to "ap") with an explicit key/value
  check: locate "802-11-wireless.mode" in the command list and assert the next
  element is exactly "ap"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 08:25:20 -04:00
ChuckandGitHub e9af18cdf1 Add Codacy badge to README (#322)
Added a Codacy badge to the README for code quality.

Signed-off-by: Chuck <33324927+ChuckBuilds@users.noreply.github.com>
2026-05-01 09:41:50 -04:00
5e6c40ad55 fix(plugins): plugin manager tab doesn't always load on first visit (#319)
* fix(plugins): reset pluginsInitialized on HTMX re-swap; use refreshInstalledPlugins in refreshPlugins

HTMX's `revealed` trigger fires each time the plugins tab becomes
visible (Alpine's x-show toggles display:none which re-triggers the
IntersectionObserver). Each re-reveal fetches a fresh empty HTML
skeleton via hx-swap="innerHTML". The htmx:afterSwap handler reset
window.pluginManager.initialized/initializing but not pluginsInitialized,
so initializePlugins() hit its guard and skipped loadInstalledPlugins()
and searchPluginStore() — leaving the fresh empty DOM unpopulated.

Fix: also reset pluginsInitialized = false in the afterSwap handler.
Existing caches (3s for installed plugins, 5min for store) mean tab
revisits within the TTL render from cache instantly with no extra
API traffic.

Also change refreshPlugins() to call refreshInstalledPlugins() (which
already exists and explicitly invalidates the cache) instead of the
bare loadInstalledPlugins() call that could silently skip the fetch
if the 3-second cache happened to still be valid.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(plugins): use cached store on HTMX re-swap; reserve searchPluginStore(true) for first load

searchPluginStore(true) bypasses the isCacheValid check unconditionally,
so every tab revisit was hitting the GitHub commit-info API even within
the 5-minute cache window.

Set window.pluginManager._reswap = true in the htmx:afterSwap handler
and read it in initializePlugins() to call searchPluginStore(false) on
re-swaps (respects the 5-minute cache) vs searchPluginStore(true) on
first load (always fetches fresh). Explicit user refresh via
refreshPlugins() already calls searchPluginStore(true) directly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 11:57:26 -04:00
d6bd1ee215 fix(install): prevent weather and music from auto-installing on fresh install (#318)
* fix(install): remove weather and music credential stubs from secrets template

config_secrets.template.json shipped ledmatrix-weather and music as
top-level keys; config_manager deep-merges secrets into the main config
on load, so the reconciler treated them as plugin config entries and
auto-installed both plugins on first web UI visit after a fresh install.

Remove both keys from the template and clear the inline fallback block
in first_time_install.sh so new installs start clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(install): sync fallback secrets with template structure

The fallback block (used when config_secrets.template.json is missing)
was an empty object after the weather/music keys were removed. Mirror
the current template so youtube and github placeholders are always
present regardless of whether the template file exists.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 10:22:28 -04:00
acaf8a248e feat(web): update-available banner in web UI (#311)
* feat(web): add update-available banner to web UI

Adds a polite, dismissible banner between the header and navigation
tabs that appears when the local repo is behind origin/main. Shows
commit count and a one-click "Update Now" button that triggers the
existing git_pull action.

- New GET /api/v3/system/check-update endpoint (5-min cache, compares
  local HEAD vs origin/main SHA)
- Banner auto-checks on page load then every 30 minutes
- Dismiss persists for the browser session via sessionStorage
- Styled for both light and dark themes
- Cache invalidated after successful git_pull so banner hides immediately

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(update-banner): address review findings — lock, returncode checks, update_available logic, a11y, button state

- Add _update_check_lock (threading.Lock) around all reads/writes to
  _update_check_cache in check_for_update() and git_pull, preventing
  races on concurrent requests
- Validate returncode for git fetch, rev-parse HEAD, and rev-parse
  origin/main; raise RuntimeError on failure so errors are caught and
  returned as error payloads instead of silently producing stale/empty SHAs
- Set update_available = commits_behind > 0 (was unconditionally True
  when local_sha != remote_sha); prevents false positive when local is
  ahead of remote
- Add type="button" and aria-label="Dismiss update" to the icon-only
  dismiss button
- Restore btn.innerHTML and btn.disabled in both success and error paths
  of applyUpdate(); only hide the banner and clear sessionStorage when
  data.status === 'success'

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(update-banner): address second-round review findings

api_v3.py:
- Move all git work inside _update_check_lock so concurrent requests
  re-check cache staleness after acquiring the lock; only the first
  caller runs git fetch/rev-parse/log, subsequent callers return the
  cached result
- Check log_result.returncode and raise on failure so a broken git log
  doesn't produce a silent false-negative (commits_behind=0)
- Rename loop variable l → commit_line

base.html:
- Replace boolean _dismissed flag with SHA-scoped sessionStorage key
  'update-sha-dismissed'; dismissing for SHA X still allows the banner
  to reappear when origin/main advances to SHA Y
- Successful applyUpdate clears 'update-sha-dismissed' so the next
  update cycle can show the banner again
- Add aria-live="polite" aria-atomic="true" to #update-banner-text so
  screen readers announce content changes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-30 09:38:03 -04:00
65e3e8319b fix(plugin_manager): prevent permanent ERROR state after update timeout (#316)
* fix(plugin_manager): prevent permanent ERROR state after update timeout

When execute_update() fails (timeout or unhandled exception), the plugin
state was set to ERROR with no recovery path. can_execute() returns False
for ERROR state, so the plugin's update() was never called again, leaving
it showing stale data indefinitely.

Instead, update plugin_last_update so the plugin waits one configured
interval before retrying, and keep the state ENABLED so recovery is
automatic on the next cycle.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(plugin_manager): address PR review — failure timestamp and error context

- Use time.time() at the point of failure instead of reusing current_time
  (captured before execution), so the full retry interval always elapses
  after a timeout rather than one execution-duration shorter

- Add PluginStateManager.set_error_info() to persist structured error context
  without changing plugin state; call it in both failure branches so
  get_error_info() / get_state_info() surface recoverable errors alongside
  ERROR-state errors

- Add warning log on the success=False branch (was previously silent)

- Pass a descriptive Exception (not a generic "Plugin execution failed") to
  health_tracker.record_failure() in the timeout/executor-error path

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(plugin_manager): atomic state+error write via set_state_with_error

The two-step set_state() / set_error_info() sequence left a window where
readers could observe ENABLED state without the accompanying error context.

Add threading.RLock to PluginStateManager and a new set_state_with_error()
method that holds the lock for both the state-transition write and the
_error_info write together. The method inlines the state-transition logic
rather than calling set_state() internally to intentionally skip the
"clear _error_info for non-ERROR states" side effect — the recoverable
error dict is exactly what we want stored.

Replace both paired set_state / set_error_info call sites in
run_scheduled_updates() with the single atomic method.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(plugin_state): lock _error_info accesses and store defensive copies

Three verified issues:

- set_error_info wrote _error_info without holding _lock and stored the
  caller's dict by reference, allowing races and post-write mutation
- set_state_with_error stored error_info by reference (lock was already held)
- get_error_info read _error_info without _lock and returned the live
  reference, letting callers mutate the stored snapshot

Implicit fourth fix: set_state also wrote _error_info without _lock; locking
get_error_info while leaving that writer unguarded would have created a new
race, so set_state is now wrapped in _lock too for consistency.

Changes:
- set_state: wrap entire body in self._lock (covers _states, _state_history,
  and _error_info writes atomically; ERROR-path _error_info value was already
  a fresh dict literal so no copy needed)
- set_error_info: acquire self._lock + store dict(error_info) shallow copy
- set_state_with_error: store dict(error_info) shallow copy (lock already held)
- get_error_info: acquire self._lock + return dict(info) copy or None

All stored values are flat dicts of strings/floats/bools, so shallow copy
is sufficient — deepcopy is not needed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(plugin_manager): apply recovery logic to update_all_plugins; extract helper

update_all_plugins still set PluginState.ERROR on both failure paths, leaving
it inconsistent with the run_scheduled_updates fix from the same PR.

Extract _record_update_failure(plugin_id, exc=None) to hold all shared failure
logic: capture actual failure time, build structured error_info, log the retry
warning, stamp plugin_last_update, call set_state_with_error(ENABLED), and
forward to health_tracker. Replace all four failure sites (two in
run_scheduled_updates, two in update_all_plugins) with calls to this helper.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 15:22:12 -04:00
4ef3f8cad5 feat(web): add config backup & restore UI (#310)
* feat(web): add config backup & restore UI

Adds a Backup & Restore tab to the v3 web UI that packages user config,
secrets, WiFi, user-uploaded fonts, plugin image uploads, and the installed
plugin list into a single ZIP for safe reinstall recovery. Restore extracts
the bundle, snapshots current state via the existing atomic config manager
(so rollback stays available), reapplies the selected sections, and
optionally reinstalls missing plugins from the store.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(backup): address PR review findings

- backup_manager: read plugin state from "states" key (not "plugins") to
  match the actual plugin_state.json format written by state_manager
- backup_manager: stream ZIP directly to a temp file instead of building
  it in an io.BytesIO buffer to avoid OOM on Raspberry Pi
- backup_manager: tighten plugin-uploads path validation in validate_backup
  and restore_backup to require "/uploads/" in the path, rejecting any
  non-uploads files smuggled under assets/plugins/
- api_v3: enforce 200 MB upload limit by streaming in chunks rather than
  relying on validate_file_upload (which only checks the filename)
- api_v3: replace bool() with _coerce_to_bool() for RestoreOptions fields
  so string "false" is not treated as truthy
- api_v3: capture and log _save_config_atomic return value instead of
  discarding it; log rather than silence font-cache and config-reload errors
- backup_restore.html: track inspectedFile so runRestore always applies to
  the file the user inspected, not a subsequently selected file; clear on
  input change or clearRestore()
- backup_restore.html: throw on non-success restore payload so errors are
  surfaced via the error notification path instead of yellow "warnings"
- test: update fixture to use correct "states" key structure; import
  SCHEMA_VERSION constant instead of hardcoding 1; rename unused err -> _err

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(backup): address second round of PR review findings

- api_v3: guard opts_dict with isinstance check after json.loads so a
  non-object JSON payload (null, array, etc.) returns a 400 instead of a
  500 AttributeError
- backup_manager: wrap tmp ZIP creation and os.replace in try/except so
  the .zip.tmp temp file is always removed on any failure
- backup_manager: replace hardcoded Path("/tmp/_zip_check") sentinel in
  validate_backup with a proper tempfile.TemporaryDirectory() so path
  traversal checks are portable and leave no artifacts
- backup_restore.html: detect partial-success responses (plugins_failed or
  errors non-empty) even when status is 'success' and render yellow/warning
  styling and notify instead of green

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(backup): add post-install steps for restored plugins; conditional restart hint

- api_v3: after a successful plugin reinstall during restore, run the same
  post-install sequence used by the normal /plugins/install flow:
  invalidate schema cache, discover_plugins()/load_plugin(), and
  set_plugin_installed() so restored plugins are immediately available
- backup_restore.html: only show the "restart the display service" hint
  when at least one item was restored or at least one plugin was installed

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(backup): address Codacy findings

- api_v3: replace 'fonts' in ' '.join(result.restored) substring check
  with any(r.startswith("fonts") for r in result.restored) to avoid
  fragile joined-string membership testing
- api_v3: replace deprecated datetime.utcnow() and utcfromtimestamp()
  with datetime.now(timezone.utc) and fromtimestamp(..., timezone.utc);
  add timezone to import
- test: remove unused import io (backup_manager no longer uses BytesIO)
- src/backup_manager.py hardcoded /tmp sentinel was already fixed in a
  prior commit (tempfile.TemporaryDirectory)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 17:05:27 -04:00
338bdc44cb fix(web): list calendar endpoint works without a running plugin instance (#314)
* fix(web): list calendar endpoint works without a running plugin instance

The web service (ledmatrix-web) and display service (ledmatrix) run in
separate processes. The web process discovers plugins but never
instantiates them — only the display process does. Consequently
api_v3.plugin_manager.get_plugin('calendar') always returns None in the
web process, and /api/v3/plugins/calendar/list-calendars responded with
404 "Calendar plugin is not running. Enable it and save config first."
even when the plugin was enabled, saved, and authenticated.

Fall back to reading token.pickle + credentials.json directly from the
calendar plugin directory and calling the Google Calendar API from the
web process. A live plugin instance is still preferred when available
(e.g. local dev where web and display share a process).

Also surface clearer, actionable error messages for missing/expired
tokens so users know to re-run the authentication step.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(web): address review feedback on calendar list endpoint

- Remove unused creds_path local
- Prefer google-auth JSON token (token.json) via Credentials.from_authorized_user_file; keep pickle fallback for backward compat, guarded by a size check and a comment describing the trust boundary (owner-only file inside the plugin dir, not user-supplied input)
- Do NOT persist refreshed credentials from the web request path; the display service owns token.pickle and concurrent writes could corrupt it. Refresh happens in memory only for the duration of the request
- Add explicit timeouts to token refresh (via Request(timeout=...)) and to the Calendar API list call (num_retries=1), and return a retryable user-facing message on socket.timeout / TimeoutError
- Import socket at module scope

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(web): apply real socket timeout to Google Calendar API calls

googleapiclient's execute() does not accept a timeout kwarg — the
timeout comes from the httplib2.Http the service was built with.
Build an AuthorizedHttp wrapping httplib2.Http(timeout=15) so
calendarList().list().execute() cannot hang the Flask worker on
flaky connectivity. Disable discovery doc caching to avoid the
default file-cache warning in this ephemeral request path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: ChuckBuilds <ChuckBuilds@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 13:28:49 -04:00
73c00140df fix(web): resolve ReferenceError in single-file upload handler (#313)
The finally block in handleSingleFileUpload referenced an undefined
fileInput variable left over from an earlier refactor, causing an
"Unhandled promise rejection: ReferenceError" after every single-file
upload (e.g. OAuth credentials.json for the calendar plugin) even when
the upload itself succeeded.

Resolve the file input by id inside the finally block so it can be
cleared when present, tolerating the drop-zone-only case.

Co-authored-by: ChuckBuilds <ChuckBuilds@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 10:34:09 -04:00
68a38c39f7 fix(web-ui-info): syntax error and init order crash on fresh install (#312)
Two bugs that prevented the web-ui-info default plugin from loading:

1. Orphaned `except Exception` at line 203 with no matching `try` —
   caused a SyntaxError preventing the module from importing at all.
   Removed the orphan; the outer try/except already covers the block.

2. `_get_local_ip()` called in __init__ before `_ap_mode_cache_time`
   was initialized, causing AttributeError. Moved AP mode cache field
   initialization above the `_get_local_ip()` call.

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-19 11:56:05 -04:00
941291561a fix(web): expose GitHub install handlers, simplify Alpine loader, explicit Flask threading (#305)
A user reported that buttons in the v3 web UI were unresponsive in Safari
after a fresh install. The screenshots showed Alpine.js actually running
fine end-to-end — the real issues are a narrow handler-exposure bug and
some latent brittleness worth cleaning up at the same time.

plugins_manager.js: attachInstallButtonHandler and setupGitHubInstallHandlers
were declared inside the main IIFE, but the typeof guards that tried to
expose them on window ran *outside* the IIFE, so typeof always evaluated
to 'undefined' and the assignments were silently skipped. The GitHub
"Install from URL" button therefore had no click handler and the console
printed [FALLBACK] attachInstallButtonHandler not available on window on
every load. Fixed by assigning window.attachInstallButtonHandler and
window.setupGitHubInstallHandlers *inside* the IIFE just before it closes,
and removing the dead outside-the-IIFE guards.

base.html: the Alpine.js loader was a 50-line dynamic-script + deferLoadingAlpine
+ isAPMode branching block. script.defer = true on a dynamically-inserted
<script> is a no-op (dynamic scripts are always async), the
deferLoadingAlpine wrapper was cargo-culted, and the AP-mode branching
reached out to unpkg unnecessarily on LAN installs even though
alpinejs.min.js already ships in web_interface/static/v3/js/. Replaced
with a single <script defer src="..."> tag pointing at the local file plus
a small window-load rescue that only pulls the CDN copy if window.Alpine
is still undefined.

start.py / app.py: app.run() has defaulted to threaded=True since Flask
1.0 so this is not a behavior change, but the two long-lived
/api/v3/stream/* SSE endpoints would starve every other request under a
single-threaded server. Setting threaded=True explicitly makes the
intent self-documenting and guards against future regressions.

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 13:17:03 -04:00
39ccdcf00d fix(plugins): stop reconciliation install loop, slow plugin list, uninstall resurrection (#309)
* fix(plugins): stop reconciliation install loop, slow plugin list, and uninstall resurrection

Three interacting bugs reported by a user (Discord/ericepe) on a fresh install:

1. The state reconciler retried failed auto-repairs on every HTTP request,
   pegging CPU and flooding logs with "Plugin not found in registry: github
   / youtube". Root cause: ``_run_startup_reconciliation`` reset
   ``_reconciliation_started`` to False on any unresolved inconsistency, so
   ``@app.before_request`` re-fired the entire pass on the next request.
   Fix: run reconciliation exactly once per process; cache per-plugin
   unrecoverable failures inside the reconciler so even an explicit
   re-trigger stays cheap; add a registry pre-check to skip the expensive
   GitHub fetch when we already know the plugin is missing; expose
   ``force=True`` on ``/plugins/state/reconcile`` so users can retry after
   fixing the underlying issue.

2. Uninstalling a plugin via the UI succeeded but the plugin reappeared.
   Root cause: a race between ``store_manager.uninstall_plugin`` (removes
   files) and ``cleanup_plugin_config`` (removes config entry) — if
   reconciliation fired in the gap it saw "config entry with no files" and
   reinstalled. Fix: reorder uninstall to clean config FIRST, drop a
   short-lived "recently uninstalled" tombstone on the store manager that
   the reconciler honors, and pass ``store_manager`` to the manual
   ``/plugins/state/reconcile`` endpoint (it was previously omitted, which
   silently disabled auto-repair entirely).

3. ``GET /plugins/installed`` was very slow on a Pi4 (UI hung on
   "connecting to display" for minutes, ~98% CPU). Root causes: per-request
   ``discover_plugins()`` + manifest re-read + four ``git`` subprocesses per
   plugin (``rev-parse``, ``--abbrev-ref``, ``config``, ``log``). Fix:
   mtime-gate ``discover_plugins()`` and drop the per-plugin manifest
   re-read in the endpoint; cache ``_get_local_git_info`` keyed on
   ``.git/HEAD`` mtime so subprocesses only run when the working copy
   actually moved; bump registry cache TTL from 5 to 15 minutes and fall
   back to stale cache on transient network failure.

Tests: 16 reconciliation cases (including 5 new ones covering the
unrecoverable cache, force-reconcile path, transient-failure handling, and
recently-uninstalled tombstone) and 8 new store_manager cache tests
covering tombstone TTL, git-info mtime cache hit/miss, and the registry
stale-cache fallback. All 24 pass; the broader 288-test suite continues to
pass with no new failures.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* perf(plugins): parallelize Plugin Store browse and extend metadata cache TTLs

Follow-up to the previous commit addressing the Plugin Store browse path
specifically. Most users install plugins via the store (ZIP extraction,
no .git directory) so the git-info mtime cache from the previous commit
was a no-op for them; their pain was coming from /plugins/store/list.

Root cause. search_plugins() enriched each returned plugin with three
serial GitHub fetches: _get_github_repo_info (repo API), _get_latest_commit_info
(commits API), _fetch_manifest_from_github (raw.githubusercontent.com).
Fifteen plugins × three requests × serial HTTP = 30–45 sequential round
trips on every cold browse. On a Pi4 over WiFi that translated directly
into the "connecting to display" hang users reported. The commit and
manifest caches had a 5-minute TTL, so even a brief absence re-paid the
full cost.

Changes.

- ``search_plugins``: fan out per-plugin enrichment through a
  ``ThreadPoolExecutor`` (max 10 workers, stays well under unauthenticated
  GitHub rate limits). Apply category/tag/query filters before enrichment
  so we never waste requests on plugins that will be filtered out.
  ``executor.map`` preserves input order, which the UI depends on.
- ``commit_cache_timeout`` and ``manifest_cache_timeout``: 5 min → 30 min.
  Keeps the cache warm across a realistic session while still picking up
  upstream updates in a reasonable window.
- ``_get_github_repo_info`` and ``_get_latest_commit_info``: stale-on-error
  fallback. On a network failure or a 403 we now prefer a previously-
  cached value over the zero-default, matching the pattern already in
  ``fetch_registry``. Flaky Pi WiFi no longer causes star counts to flip
  to 0 and commit info to disappear.

Tests (5 new in test_store_manager_caches.py).

- ``test_results_preserve_registry_order`` — the parallel map must still
  return plugins in input order.
- ``test_filters_applied_before_enrichment`` — category/tag/query filters
  run first so we don't waste HTTP calls.
- ``test_enrichment_runs_concurrently`` — peak-concurrency check plus a
  wall-time bound that would fail if the code regressed to serial.
- ``test_repo_info_stale_on_network_error`` — repo info falls back to
  stale cache on RequestException.
- ``test_commit_info_stale_on_network_error`` — commit info falls back to
  stale cache on RequestException.

All 29 tests (16 reconciliation, 13 store_manager caches) pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* perf(plugins): drop redundant per-plugin manifest.json fetch in search_plugins

Benchmarking the previous parallelization commit on a real Pi4 revealed
that the 10x speedup I expected was only ~1.1x. Profiling showed two
plugins (football-scoreboard, ledmatrix-flights) each spent 5 seconds
inside _fetch_manifest_from_github — not on the initial HTTP call, but
on the three retries in _http_get_with_retries with exponential backoff
after transient DNS failures. Even with the thread pool, those 5-second
tail latencies stayed in the wave and dominated wall time.

The per-plugin manifest fetch in search_plugins is redundant anyway.
The registry's plugins.json already carries ``description`` (it is
generated from each plugin's manifest by update_registry.py at release
time), and ``last_updated`` is filled in from the commit info that we
already fetch in the same loop. Dropping the manifest fetch eliminates
one of the three per-plugin HTTPS round trips entirely, which also
eliminates the DNS-retry tail.

The _fetch_manifest_from_github helper itself is preserved — it is
still used by the install path.

Tests unchanged (the search_plugins tests mock all three helpers and
still pass); this drop only affects the hot-path call sequence.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test: lock down install/update/uninstall invariants

Regression guard for the caching and tombstone changes in this PR:

- ``install_plugin`` must not be gated by the uninstall tombstone. The
  tombstone only exists to keep the state reconciler from resurrecting a
  freshly-uninstalled plugin; explicit user-initiated installs via the
  store UI go straight to ``install_plugin()`` and must never be blocked.
  Test: mark a plugin recently uninstalled, stub out the download, call
  ``install_plugin``, and assert the download step was reached.

- ``get_plugin_info(force_refresh=True)`` must forward force_refresh
  through to both ``_get_latest_commit_info`` and ``_fetch_manifest_from_github``,
  so that install_plugin and update_plugin (both of which call
  get_plugin_info with force_refresh=True) continue to bypass the 30-min
  cache TTLs introduced in c03eb8db. Without this, bumping the commit
  cache TTL could cause users to install or update to a commit older than
  what GitHub actually has.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(plugins): address review findings — transactional uninstall, registry error propagation, payload hardening

Three real bugs surfaced by review, plus one nitpick. Each was verified
against the current code before fixing.

1. fetch_registry silently swallowed network errors, breaking the
   reconciler (CONFIRMED BUG).

   The stale-cache fallback I added in c03eb8db made fetch_registry
   return {"plugins": []} on network failure when no cache existed —
   which is exactly the state on a fresh boot with flaky WiFi. The
   reconciler's _auto_repair_missing_plugin code assumed an exception
   meant "transient, don't mark unrecoverable" and expected to never
   see a silent empty-dict result. With the silent fallback in place
   on a fresh boot, it would see "no candidates in registry" and
   mark every config-referenced plugin permanently unrecoverable.

   Fix: add ``raise_on_failure: bool = False`` to fetch_registry. UI
   callers keep the stale-cache-fallback default. The reconciler's
   _auto_repair_missing_plugin now calls it with raise_on_failure=True
   so it can distinguish a genuine registry miss from a network error.

2. Uninstall was not transactional (CONFIRMED BUG).

   Two distinct failure modes silently left the system in an
   inconsistent state:

   (a) If ``cleanup_plugin_config`` raised, the code logged a warning
       and proceeded to delete files anyway, leaving an orphan install
       with no config entry.
   (b) If ``uninstall_plugin`` returned False or raised AFTER cleanup
       had already succeeded, the config was gone but the files were
       still on disk — another orphan state.

   Fix: introduce ``_do_transactional_uninstall`` shared by both the
   queue and direct paths. Flow:
     - snapshot plugin's entries in main config + secrets
     - cleanup_plugin_config; on failure, ABORT before touching files
     - uninstall_plugin; on failure, RESTORE the snapshot, then raise
   Both queue and direct endpoints now delegate to this helper and
   surface clean errors to the user instead of proceeding past failure.

3. /plugins/state/reconcile crashed on non-object JSON bodies
   (CONFIRMED BUG).

   The previous code did ``payload.get('force', False)`` after
   ``request.get_json(silent=True) or {}``. If a client sent a bare
   string or array as the JSON body, payload would be that string or
   list and .get() would raise AttributeError. Separately,
   ``bool("false")`` is True, so string-encoded booleans were
   mis-handled.

   Fix: guard ``isinstance(payload, dict)`` and route the value
   through the existing ``_coerce_to_bool`` helper.

4. Nitpick: use ``assert_called_once_with`` in
   test_force_reconcile_clears_unrecoverable_cache. The existing test
   worked in practice (we call reset_mock right before) but the stricter
   assertion catches any future regression where force=True might
   double-fire the install.

Tests added (19 new, 48 total passing):

- TestFetchRegistryRaiseOnFailure (4): flag propagates both
  RequestException and JSONDecodeError, wins over stale cache, and
  the default behavior is unchanged for existing callers.
- test_real_store_manager_empty_registry_on_network_failure (1): the
  key regression test — uses the REAL PluginStoreManager (not a Mock)
  with ConnectionError at the HTTP helper layer, and verifies the
  reconciler does NOT poison _unrecoverable_missing_on_disk.
- TestTransactionalUninstall (4): cleanup failure aborts before file
  removal; file removal failure (both False return and raise) restores
  the config snapshot; happy path still succeeds.
- TestReconcileEndpointPayload (8): bare string / array / null JSON
  bodies, missing force key, boolean true/false, and string-encoded
  "true"/"false" all handled correctly.

All 342 tests in the broader sweep still pass (2 pre-existing
TestDottedKeyNormalization failures reproduce on main and are unrelated).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* style: address review nitpicks in store_manager + test

Four small cleanups, each verified against current code:

1. ``_git_info_cache`` type annotation was ``Dict[str, tuple]`` — too
   loose. Tightened to ``Dict[str, Tuple[float, Dict[str, str]]]`` to
   match what ``_get_local_git_info`` actually stores (mtime + the
   sha/short_sha/branch/... dict it returns). Added ``Tuple`` to the
   typing imports.

2. The ``search_plugins`` early-return condition
   ``if len(filtered) == 1 or not fetch_commit_info and len(filtered) < 4``
   parses correctly under Python's precedence (``and`` > ``or``) but is
   visually ambiguous. Added explicit parentheses to make the intent —
   "single plugin, OR small batch that doesn't need commit info" —
   obvious at a glance. Semantics unchanged.

3. Replaced a Unicode multiplication sign (×) with ASCII 'x' in the
   commit_cache_timeout comment.

4. Removed a dead ``concurrent_workers = []`` declaration from
   ``test_enrichment_runs_concurrently``. It was left over from an
   earlier sketch of the concurrency check — the final test uses only
   ``peak_lock`` and ``peak``.

All 48 tests still pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(plugins): address second review pass — cache correctness and rollback

Verified each finding against the current code. All four inline issues
were real bugs; nitpicks 5-7 were valid improvements.

1. _get_latest_commit_info overwrote a good cached value with None on
   all-branches-404 (CONFIRMED BUG).

   The final line of the branch loop unconditionally wrote
   ``self.commit_info_cache[cache_key] = (time.time(), None)``, which
   clobbered any previously-good entry on a single transient failure
   (e.g. an odd 5xx, a temporary DNS hiccup during the branches_to_try
   loop). Fix: if there's already a good prior value, bump its
   timestamp into the backoff window and return it instead. Only
   cache None when we never had a good value.

2. _get_local_git_info cache did not invalidate on fast-forward
   (CONFIRMED BUG).

   Caching on ``.git/HEAD`` mtime alone is wrong: a ``git pull`` that
   fast-forwards the current branch updates ``.git/refs/heads/<branch>``
   (or packed-refs) but leaves HEAD's contents and mtime untouched.
   The cache would then serve a stale SHA indefinitely.

   Fix: introduce ``_git_cache_signature`` which reads HEAD contents,
   resolves ``ref: refs/heads/<name>`` to the corresponding loose ref
   file, and builds a signature tuple of (head_contents, head_mtime,
   resolved_ref_mtime, packed_refs_mtime). A fast-forward bumps the
   ref file's mtime, which invalidates the signature and re-runs git.

3. test_install_plugin_is_not_blocked_by_tombstone swallowed all
   exceptions (CONFIRMED BUG in test).

   ``try: self.sm.install_plugin("bar") except Exception: pass`` could
   hide a real regression in install_plugin that happens to raise.
   Fix: the test now writes a COMPLETE valid manifest stub (id, name,
   class_name, display_modes, entry_point) and stubs _install_dependencies,
   so install_plugin runs all the way through and returns True. The
   assertion is now ``assertTrue(result)`` with no exception handling.

4. Uninstall rollback missed unload/reload (CONFIRMED BUG).

   Previous flow: cleanup → unload (outside try/except) → uninstall →
   rollback config on failure. Problem: if ``unload_plugin`` raised,
   the exception propagated without restoring config. And if
   ``uninstall_plugin`` failed after a successful unload, the rollback
   restored config but left the plugin unloaded at runtime —
   inconsistent.

   Fix: record ``was_loaded`` before touching runtime state, wrap
   ``unload_plugin`` in the same try/except that covers
   ``uninstall_plugin``, and on any failure call a ``_rollback`` local
   that (a) restores the config snapshot and (b) calls
   ``load_plugin`` to reload the plugin if it was loaded before we
   touched it.

5. Nitpick: ``_unrecoverable_missing_on_disk: set`` → ``Set[str]``.
   Matches the existing ``Dict``/``List`` style in state_reconciliation.py.

6. Nitpick: stale-cache fallbacks in _get_github_repo_info and
   _get_latest_commit_info now bump the cached entry's timestamp by a
   60s failure backoff. Without this, a cache entry whose TTL just
   expired would cause every subsequent request to re-hit the network
   until it came back, amplifying the failure. Introduced
   ``_record_cache_backoff`` helper and applied it consistently.

7. Nitpick: replaced the flaky wall-time assertion in
   test_enrichment_runs_concurrently with just the deterministic
   ``peak["count"] >= 2`` signal. ``peak["count"]`` can only exceed 1
   if two workers were inside the critical section simultaneously,
   which is definitive proof of parallelism. The wall-time check was
   tight enough (<200ms) to occasionally fail on CI / low-power boxes.

Tests (6 new, 54 total passing):

- test_cache_invalidates_on_fast_forward_of_current_branch: builds a
  loose-ref layout under a temp .git/, verifies a first call populates
  the cache, a second call with unchanged state hits the cache, and a
  simulated fast-forward (overwriting ``.git/refs/heads/main`` with a
  new SHA and mtime) correctly re-runs git.
- test_commit_info_preserves_good_cache_on_all_branches_404: seeds a
  good cached entry, mocks requests.get to always return 404, and
  verifies the cache still contains the good value afterwards.
- test_repo_info_stale_bumps_timestamp_into_backoff: seeds an expired
  cache, triggers a ConnectionError, then verifies a second lookup
  does NOT re-hit the network (proves the timestamp bump happened).
- test_repo_info_stale_on_403_also_backs_off: same for the 403 path.
- test_file_removal_failure_reloads_previously_loaded_plugin:
  plugin starts loaded, uninstall_plugin returns False, asserts
  load_plugin was called during rollback.
- test_unload_failure_restores_config_and_does_not_call_uninstall:
  unload_plugin raises, asserts uninstall_plugin was never called AND
  config was restored AND load_plugin was NOT called (runtime state
  never changed, so no reload needed).

Broader test sweep: 348/348 pass (2 pre-existing
TestDottedKeyNormalization failures reproduce on main, unrelated).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(plugins): address third review pass — cache signatures, backoff, isolation

All four findings verified as real issues against the current code.

1. _git_cache_signature was missing .git/config (CONFIRMED GAP).

   The cached ``result`` dict from _get_local_git_info includes
   ``remote_url``, which is read from ``.git/config``. But the cache
   signature only tracked HEAD + refs — so a config-only change (e.g.
   ``git remote set-url origin https://...``) would leave the stale
   URL cached indefinitely. This matters for the monorepo-migration
   detection in update_plugin.

   Fix: add ``config_contents`` and ``config_mtime`` to the signature
   tuple. Config reads use the same OSError-guarded pattern as the
   HEAD read.

2. fetch_registry stale fallback didn't bump registry_cache_time
   (CONFIRMED BUG).

   The other caches already had the failure-backoff pattern added in
   the previous review pass (via ``_record_cache_backoff``), but the
   registry cache's stale-fallback branches silently returned the
   cached payload without updating ``registry_cache_time``. Next
   request saw the same expired TTL, re-hit the network, failed
   again — amplifying the original transient failure.

   Fix: bump ``self.registry_cache_time`` forward by the existing
   ``self._failure_backoff_seconds`` (reused — no new constant
   needed) in both the RequestException and JSONDecodeError stale
   branches. Kept the ``raise_on_failure=True`` path untouched so the
   reconciler still gets the exception.

3. _make_client() in the uninstall/reconcile test helper leaked
   MagicMocks into the api_v3 singleton (CONFIRMED RISK).

   Every test call replaced api_v3.config_manager, .plugin_manager,
   .plugin_store_manager, etc. with MagicMocks and never restored them.
   If any later test in the same pytest run imported api_v3 expecting
   original state (or None), it would see the leftover mocks.

   Fix: _make_client now snapshots the original attributes (with a
   sentinel to distinguish "didn't exist" from "was None") and returns
   a cleanup callable. Both setUp methods call self.addCleanup(cleanup)
   so state is restored even if the test raises. On cleanup, sentinel
   entries trigger delattr rather than setattr to preserve the
   "attribute was never set" case.

4. Snapshot helpers used broad ``except Exception`` (CONFIRMED).

   _snapshot_plugin_config caught any exception from
   get_raw_file_content, which could hide programmer errors (TypeError,
   AttributeError) behind the "best-effort snapshot" fallback. The
   legitimate failure modes are filesystem errors (covered by OSError;
   FileNotFoundError is a subclass, IOError is an alias in Python 3)
   and ConfigError (what config_manager wraps all load failures in).

   Fix: narrow to ``(OSError, ConfigError)`` in both snapshot blocks.
   ConfigError was already imported at line 20 of api_v3.py.

Tests added (4 new, 58 total passing):

- test_cache_invalidates_on_git_config_change: builds a realistic
  loose-ref layout, writes .git/config with an "old" remote URL,
  exercises _get_local_git_info, then rewrites .git/config with a
  "new" remote URL + new mtime, calls again, and asserts the cache
  invalidated and returned the new URL.
- test_stale_fallback_bumps_timestamp_into_backoff: seeds an expired
  registry cache, triggers ConnectionError, verifies first call
  serves stale, then asserts a second call makes ZERO new HTTP
  requests (proves registry_cache_time was bumped forward).
- test_snapshot_survives_config_read_error: raises ConfigError from
  get_raw_file_content and asserts the uninstall still completes
  successfully — the narrow exception list still catches this case.
- test_snapshot_does_not_swallow_programmer_errors: raises a
  TypeError from get_raw_file_content (not in the narrow list) and
  asserts it propagates up to a 500, AND that uninstall_plugin was
  never called (proves the exception was caught at the right level).

Broader test sweep: 352/352 pass (2 pre-existing
TestDottedKeyNormalization failures reproduce on main, unrelated).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 12:33:54 -04:00
781224591f fix: post-audit follow-up code fixes (cache, fonts, icons, dev script) (#307)
* fix: post-audit follow-up code fixes (cache, fonts, icons, dev script, CI)

The docs refresh effort (#306, ledmatrix-plugins#92) surfaced seven
code bugs that were intentionally left out of the docs PRs because
they required code changes rather than doc fixes. This PR addresses
the six that belong in LEDMatrix (the seventh — a lacrosse-scoreboard
mode rename — lives in the plugins repo).

Bug 1: cache_manager.delete() AttributeError
  src/common/api_helper.py:287 and
  src/plugin_system/resource_monitor.py:343 both call
  cache_manager.delete(key), which doesn't exist — only
  clear_cache(key=None). Added a delete() alias method on
  CacheManager that forwards to clear_cache(key). Reverts the
  "There is no delete() method" wording in DEVELOPER_QUICK_REFERENCE,
  .cursorrules so the docs match the new shim.

Bug 2: dev_plugin_setup.sh PROJECT_ROOT resolution
  scripts/dev/dev_plugin_setup.sh:9 set PROJECT_ROOT to SCRIPT_DIR
  instead of walking up two levels to the repo root, so PLUGINS_DIR
  resolved to scripts/dev/plugins/ and created symlinks under the
  script's own directory. Fixed the path and removed the stray
  scripts/dev/plugins/of-the-day symlink left by earlier runs.

Bug 3: plugin custom icons regressed from v2 to v3
  web_interface/blueprints/api_v3.py built the /plugins/installed
  response without including the manifest's "icon" field, and
  web_interface/templates/v3/base.html hardcoded
  fas fa-puzzle-piece in all three plugin-tab render sites. Pass
  the icon through the API and read it from the templates with a
  puzzle-piece fallback. Reverts the "currently broken" banners in
  docs/PLUGIN_CUSTOM_ICONS.md and docs/PLUGIN_CUSTOM_ICONS_FEATURE.md.

Bug 4: register_plugin_fonts was never wired up
  src/font_manager.py:150 defines register_plugin_fonts(plugin_id,
  font_manifest) but nothing called it, so plugin manifests with a
  "fonts" block were silently no-ops. Wired the call into
  PluginManager.load_plugin() right after plugin_loader.load_plugin
  returns. Reverts the "not currently wired" warning in
  docs/FONT_MANAGER.md's "For Plugin Developers" section.

Bug 5: dead web_interface_v2 import pattern (LEDMatrix half)
  src/base_odds_manager.py had a try/except importing
  web_interface_v2.increment_api_counter, falling back to a no-op
  stub. The module doesn't exist anywhere in the v3 codebase and
  no API metrics dashboard reads it. Deleted the import block and
  the single call site; the plugins-repo half of this cleanup lands
  in ledmatrix-plugins#<next>.

Bug 7: no CI test workflow
  .github/workflows/ only contained security-audit.yml; pytest ran
  locally but was not gated on PRs. Added
  .github/workflows/tests.yml running pytest against Python 3.10,
  3.11, 3.12 in EMULATOR=true mode, skipping tests marked hardware
  or slow. Updated docs/HOW_TO_RUN_TESTS.md to reflect that the
  workflow now exists.

Verification done locally:
  - CacheManager.delete(key) round-trips with set/get
  - base_odds_manager imports without the v2 module present
  - dev_plugin_setup.sh PROJECT_ROOT resolves to repo root
  - api_v3 and plugin_manager compile clean
  - tests.yml YAML parses
  - Script syntax check on dev_plugin_setup.sh

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address CodeRabbit review comments on #307

- src/cache_manager.py: clear_cache(key) treated empty string as
  "wipe all" because of `if key:`. Switched to `key is None`
  branching, made delete(key) and clear_cache(key) reject empty
  strings and None outright with ValueError, and updated both
  docstrings to make the contract explicit. Verified locally
  with a round-trip test that clear_cache() (no arg) still
  wipes everything but clear_cache("") and delete("") raise.

- src/plugin_system/plugin_manager.py: was reaching for the
  font manager via getattr(self.display_manager, 'font_manager',
  None). PluginManager already takes a dedicated font_manager
  parameter (line 54) and stores it as self.font_manager
  (line 69), so the old path was both wrong and could miss the
  font manager entirely when the host injects them separately.
  Switched to self.font_manager directly with the same try/except
  warning behavior.

- web_interface/templates/v3/base.html: in the full plugin-tab
  renderer, the icon was injected with
  `<i class="${escapeHtml(plugin.icon)}">` — but escapeHtml only
  escapes <, >, and &, not double quotes, so a manifest with a
  quote in its icon string could break out of the class
  attribute. Replaced the innerHTML template with createElement
  for the <i> tag, set className from plugin.icon directly
  (no string interpolation), and used a text node for the
  label. Same fix shape would also harden the two stub-renderer
  sites at line 515 / 774, but those already escape `"` to
  &quot; and CodeRabbit only flagged this site, so leaving them
  for now.

- docs/FONT_MANAGER.md: clarified that the Manual Font Overrides
  *workflow* (set_override / remove_override / font_overrides.json)
  is the supported override path today, and only the Fonts tab
  in the web UI is the placeholder. Previous wording conflated
  the two and made it sound like overrides themselves were
  broken.

- docs/HOW_TO_RUN_TESTS.md: replaced the vague "see the PR
  adding it" with a concrete link to #307 and a note that the
  workflow file itself is held back pending the workflow scope.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 09:25:12 -04:00
601fedb9b4 fix(logos): register NCAA lacrosse + women's hockey in logo downloader (#308)
The lacrosse-scoreboard plugin renders broken on hardware: school
logos never appear, and SportsRecent/SportsUpcoming
_draw_scorebug_layout() falls into its "Logo Error" fallback
branch instead of drawing the normal logo-centric scorebug.

Root cause: src/logo_downloader.py LOGO_DIRECTORIES and
API_ENDPOINTS were missing entries for ncaam_lacrosse and
ncaaw_lacrosse, even though the plugin's manager files set those
exact sport_key values (ncaam_lacrosse_managers.py:29,
ncaaw_lacrosse_managers.py:29). The plugin's vendored sports.py
asks the main LogoDownloader to resolve sport_key →
on-disk directory the same way every other sports plugin does
(football, basketball, baseball, hockey), and
get_logo_directory() fell through to the safe fallback
f'assets/sports/{league}_logos' = 'assets/sports/ncaam_lacrosse_logos',
a directory that does not exist. Logo loads then failed for
every team and the scorebug layout collapsed to "Logo Error".

Adding the two lacrosse rows (and the missing ncaaw_hockey row
in API_ENDPOINTS, while we're here) makes lacrosse a first-class
peer to the other NCAA sports — same shared assets/sports/ncaa_logos
directory, same canonical ESPN team-list endpoint pattern. No
plugin-side change is needed because the plugin already imports
the main LogoDownloader.

Existing NCAA football/hockey schools that also play lacrosse
(DUKE, UVA, MD, NAVY, ARMY, YALE, SYR, …) get picked up
immediately on first render. Lacrosse-specific schools (JHU,
Loyola, Princeton, Cornell, Stony Brook, …) lazily download
into the shared directory via download_missing_logo() the first
time they appear in a scoreboard payload — verified locally
with both the team_id fallback path (ESPN sports.core.api) and
the direct logo_url path used by the plugin at runtime.

Verification (all from a clean clone):

  python3 -c "
  from src.logo_downloader import LogoDownloader
  d = LogoDownloader()
  for k in ('ncaam_lacrosse','ncaaw_lacrosse','ncaam_hockey','ncaaw_hockey'):
      print(k, '->', d.get_logo_directory(k))
  "
  # All four print .../assets/sports/ncaa_logos

  python3 -c "
  from pathlib import Path
  from src.logo_downloader import download_missing_logo
  ok = download_missing_logo(
      'ncaam_lacrosse', team_id='120', team_abbreviation='JHU',
      logo_path=Path('assets/sports/ncaa_logos/_jhu_test.png'),
      logo_url='https://a.espncdn.com/i/teamlogos/ncaa/500/120.png',
  )
  print('downloaded:', ok)  # True, ~40KB PNG
  "

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 09:07:37 -04:00
6812dfe7a6 docs: refresh and correct stale documentation across repo (#306)
* docs: refresh and correct stale documentation across repo

Walked the README and docs/ tree against current code and fixed several
real bugs and many stale references. Highlights:

User-facing
- README.md: web interface install instructions referenced
  install_web_service.sh at the repo root, but it actually lives at
  scripts/install/install_web_service.sh.
- docs/GETTING_STARTED.md: every web UI port reference said 5050, but
  the real server in web_interface/start.py:123 binds 5000. Same bug
  was duplicated in docs/TROUBLESHOOTING.md (17 occurrences). Fixed
  both.
- docs/GETTING_STARTED.md: rewrote tab-by-tab instructions. The doc
  referenced "Plugin Store", "Plugin Management", "Sports Configuration",
  "Durations", and "Font Management" tabs - none of which exist. Real
  tabs (verified in web_interface/templates/v3/base.html) are: Overview,
  General, WiFi, Schedule, Display, Config Editor, Fonts, Logs, Cache,
  Operation History, Plugin Manager (+ per-plugin tabs).
- docs/GETTING_STARTED.md: removed references to a "Test Display"
  button (doesn't exist) and "Show Now" / "Stop" plugin buttons. Real
  controls are "Run On-Demand" / "Stop On-Demand" inside each plugin's
  tab (partials/plugin_config.html:792).
- docs/TROUBLESHOOTING.md: removed dead reference to
  troubleshoot_weather.sh (doesn't exist anywhere in the repo); weather
  is now a plugin in ledmatrix-plugins.

Developer-facing
- docs/PLUGIN_API_REFERENCE.md: documented draw_image() doesn't exist
  on DisplayManager. Real plugins paste onto display_manager.image
  directly (verified in src/base_classes/{baseball,basketball,football,
  hockey}.py). Replaced with the canonical pattern.
- docs/PLUGIN_API_REFERENCE.md: documented cache_manager.delete() doesn't
  exist. Real method is clear_cache(key=None). Updated the section.
- docs/PLUGIN_API_REFERENCE.md: added 10 missing BasePlugin methods that
  the doc never mentioned: dynamic-duration hooks, live-priority hooks,
  and the full Vegas-mode interface.
- docs/PLUGIN_DEVELOPMENT_GUIDE.md: same draw_image fix.
- docs/DEVELOPMENT.md: corrected the "Plugin Submodules" section. Plugins
  are NOT git submodules - .gitmodules only contains
  rpi-rgb-led-matrix-master. Plugins are installed at runtime into the
  plugins directory configured by plugin_system.plugins_directory
  (default plugin-repos/). Both internal links in this doc were also
  broken (missing relative path adjustment).
- docs/HOW_TO_RUN_TESTS.md: removed pytest-timeout from install line
  (not in requirements.txt) and corrected the test/integration/ path
  (real integration tests are at test/web_interface/integration/).
  Replaced the fictional file structure diagram with the real one.
- docs/EMULATOR_SETUP_GUIDE.md: clone URL was a placeholder; default
  pixel_size was documented as 16 but emulator_config.json ships with 5.

Index
- docs/README.md: rewrote. Old index claimed "16-17 files after
  consolidation" but docs/ actually has 38 .md files. Four were missing
  from the index entirely (CONFIG_DEBUGGING, DEV_PREVIEW,
  PLUGIN_ERROR_HANDLING, STARLARK_APPS_GUIDE). Trimmed the navel-gazing
  consolidation/statistics sections.

Out of scope but worth flagging:
- src/plugin_system/resource_monitor.py:343 and src/common/api_helper.py:287
  call cache_manager.delete(key) but no such method exists on
  CacheManager. Both call sites would AttributeError at runtime if hit.
  Not fixed in this docs PR - either add a delete() shim or convert
  callers to clear_cache().

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* docs: fix WEB_INTERFACE_GUIDE and WIFI_NETWORK_SETUP

WEB_INTERFACE_GUIDE.md
- Web UI port: 5050 -> 5000 (4 occurrences)
- Tab list was almost entirely fictional. Documented tabs:
  General Settings, Display Settings, Durations, Sports Configuration,
  Plugin Management, Plugin Store, Font Management. None of these
  exist. Real tabs (verified in web_interface/templates/v3/base.html:
  935-1000): Overview, General, WiFi, Schedule, Display, Config Editor,
  Fonts, Logs, Cache, Operation History, plus Plugin Manager and
  per-plugin tabs in the second nav row. Rewrote the navigation
  section, the General/Display/Plugin sections, and the Common Tasks
  walkthroughs to match.
- Quick Actions list referenced "Test Display" button (doesn't exist).
  Replaced with the real button list verified in
  partials/overview.html:88-152: Start/Stop Display, Restart Display
  Service, Restart Web Service, Update Code, Reboot, Shutdown.
- API endpoints used /api/* paths. The api_v3 blueprint mounts at
  /api/v3 (web_interface/app.py:144), so the real paths are
  /api/v3/config/main, /api/v3/system/status, etc. Fixed.
- Removed bogus "Sports Configuration tab" walkthrough; sports
  favorites live inside each scoreboard plugin's own tab now.
- Plugin directory listed as /plugins/. Real default is plugin-repos/
  (verified in config/config.template.json:130 and
  display_controller.py:132); plugins/ is a fallback.
- Removed "Swipe navigation between tabs" mobile claim (not implemented).

WIFI_NETWORK_SETUP.md
- 21 occurrences of port 5050 -> 5000.
- All /api/wifi/* curl examples used the wrong path. The real wifi
  API routes are at /api/v3/wifi/* (api_v3.py:6367-6609). Fixed.
- ap_password default was documented as "" (empty/open network) but
  config/wifi_config.json ships with "ledmatrix123". Updated the
  Quick Start, Configuration table, AP Mode Settings section, and
  Security Recommendations to match. Also clarified that setting
  ap_password to "" is the way to make it an open network.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* docs: fix ADVANCED_FEATURES and REST_API_REFERENCE

REST_API_REFERENCE.md
- Wrong path: /fonts/delete/<font_family> -> /fonts/<font_family>
  (verified the real DELETE route in
  web_interface/blueprints/api_v3.py).
- Diffed the documented routes against the real api_v3 blueprint
  (92 routes vs the 71 documented). Added missing sections:
  - Error tracking (/errors/summary, /errors/plugin/<id>, /errors/clear)
  - Health (/health)
  - Schedule dim/power (/config/dim-schedule GET/POST)
  - Plugin-specific endpoints (calendar/list-calendars,
    of-the-day/json/upload+delete, plugins/<id>/static/<path>)
  - Starlark Apps (12 endpoints: status, install-pixlet, apps CRUD,
    repository browse/install, upload)
  - Font preview (/fonts/preview)
- Updated table of contents with the new sections.
- Added a footer note that the API blueprint mounts at /api/v3
  (app.py:144) and that SSE stream endpoints are defined directly on
  the Flask app at app.py:607-615.

ADVANCED_FEATURES.md
- Vegas Scroll Mode section was actually accurate (verified all
  config keys match src/vegas_mode/config.py:15-30).

- On-Demand Display section had multiple bugs:
  - 5 occurrences of port 5050 -> 5000
  - All API paths missing /v3 (e.g. /api/display/on-demand/start
    should be /api/v3/display/on-demand/start)
  - "Settings -> Plugin Management -> Show Now Button" UI flow doesn't
    exist. Real flow: open the plugin's tab in the second nav row,
    click Run On-Demand / Stop On-Demand.
  - "Python API Methods" section showed
    controller.show_on_demand() / clear_on_demand() /
    is_on_demand_active() / get_on_demand_info() — none of these
    methods exist on DisplayController. The on-demand machinery is
    all internal (_set_on_demand_*, _activate_on_demand, etc) and
    is driven through the cache_manager. Replaced the section with
    a note pointing to the REST API.
  - All Use Case Examples used the same fictional Python calls.
    Replaced with curl examples against the real API.

- Cache Management section claimed "On-demand display uses Redis cache
  keys". LEDMatrix doesn't use Redis — verified with grep that
  src/cache_manager.py has no redis import. The cache is file-based,
  managed by CacheManager (file at /var/cache/ledmatrix/ or fallback
  paths). Rewrote the manual recovery section:
  - Removed redis-cli commands
  - Replaced cache.delete() Python calls with cache.clear_cache()
    (the real public method per the same bug already flagged in
    PLUGIN_API_REFERENCE.md)
  - Replaced "Settings -> Cache Management" with the real Cache tab
  - Documented the actual cache directory candidates

- Background Data Service section:
  - Used "nfl_scoreboard" as the plugin id in the example.
    The real plugin is "football-scoreboard" (handles both NFL and
    NCAA). Fixed.
  - "Implementation Status: Phase 1 NFL only / Phase 2 planned"
    section was severely outdated. The background service is now
    used by all sports scoreboards (football, hockey, baseball,
    basketball, soccer, lacrosse, F1, UFC), the odds ticker, and
    the leaderboard plugin. Replaced with a current "Plugins using
    the background service" note.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* docs: fix plugin config + store + dependency docs

PLUGIN_STORE_GUIDE.md
- 19 occurrences of port 5050 -> 5000
- All API paths missing /v3 (e.g. /api/plugins/install ->
  /api/v3/plugins/install). Bulk fix.

PLUGIN_REGISTRY_SETUP_GUIDE.md
- Same port + /api/v3 fixes (3 occurrences each)
- "Go to Plugin Store tab" -> "Open the Plugin Manager tab and scroll
  to the Install from GitHub section" (the real flow for registry
  setup is the GitHub install section, not the Plugin Store search)

PLUGIN_CONFIG_QUICK_START.md
- Port 5001 -> 5000 (5001 is the dev_server.py default, not the web UI)
- "Plugin Store tab" install flow -> real Plugin Manager + Plugin Store
  section + per-plugin tab in second nav row
- Removed reference to PLUGIN_CONFIG_TABS_SUMMARY.md (archived doc)

PLUGIN_CONFIGURATION_TABS.md
- "Plugin Management vs Configuration" section confusingly described
  a "Plugins Tab" that doesn't exist as a single thing. Rewrote to
  describe the real two-piece structure: Plugin Manager tab (browse,
  install, toggle) vs per-plugin tabs (configure individual plugins).

PLUGIN_DEPENDENCY_GUIDE.md
- Port 5001 -> 5000

PLUGIN_DEPENDENCY_TROUBLESHOOTING.md
- Wrong port (8080) and wrong UI nav ("Plugin Store or Plugin
  Management"). Fixed to the real flow.

PLUGIN_QUICK_REFERENCE.md
- "Plugin Location: ./plugins/ directory" -> default is plugin-repos/
  (verified in config/config.template.json:130 and
  display_controller.py:132). plugins/ is a fallback.
- File structure diagram showed plugins/ -> plugin-repos/.
- Web UI install flow: "Plugin Store tab" -> "Plugin Manager tab ->
  Plugin Store section". Also fixed Configure ⚙️ button (doesn't
  exist) and "Drag and drop reorder" (not implemented).
- API examples: replaced ad-hoc Python pseudocode with real curl
  examples against /api/v3/plugins/* endpoints. Pointed at
  REST_API_REFERENCE.md for the full list.
- "Migration Path Phase 1-5" was a roadmap written before the plugin
  system shipped. The plugin system is now stable and live. Removed
  the migration phases as they're history, not a roadmap.
- "Quick Migration" section called scripts/migrate_to_plugins.py
  which doesn't exist anywhere in the repo. Removed.
- "Plugin Registry Structure" referenced
  ChuckBuilds/ledmatrix-plugin-registry which doesn't exist. The
  real registry is ChuckBuilds/ledmatrix-plugins. Fixed.
- "Next Steps" / "Questions to Resolve" sections were
  pre-implementation planning notes. Replaced with a "Known
  Limitations" section that documents the actually-real gaps
  (sandboxing, resource limits, ratings, auto-updates).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* docs: fix misc remaining docs (architecture, dev quickref, sub-dir READMEs)

PLUGIN_ARCHITECTURE_SPEC.md
- Added a banner at the top noting this is a historical design doc
  written before the plugin system shipped. The doc is ~1900 lines
  with 13 stale /api/plugins/* paths (real is /api/v3/plugins/*),
  references to web_interface_v2.py (current is app.py), and a
  Migration Strategy / Implementation Roadmap that's now history.
  Banner points readers at the current docs
  (PLUGIN_DEVELOPMENT_GUIDE, PLUGIN_API_REFERENCE,
  REST_API_REFERENCE) without needing to retrofit every section.

PLUGIN_CONFIG_ARCHITECTURE.md
- 10 occurrences of /api/plugins/* missing /v3 prefix. Bulk fixed.

DEVELOPER_QUICK_REFERENCE.md
- cache_manager.delete("key") -> cache_manager.clear_cache("key")
  with comment noting delete() doesn't exist. Same bug already
  documented in PLUGIN_API_REFERENCE.md.

SSH_UNAVAILABLE_AFTER_INSTALL.md
- 4 occurrences of port 5001 -> 5000 in AP-mode and Ethernet/WiFi
  recovery instructions.

PLUGIN_CUSTOM_ICONS_FEATURE.md
- Port 5001 -> 5000.

CONFIG_DEBUGGING.md
- Documented /api/v3/config/plugin/<id> and /api/v3/config/validate
  endpoints don't exist. Replaced with the real endpoints:
  /api/v3/config/main, /api/v3/plugins/schema?plugin_id=,
  /api/v3/plugins/config?plugin_id=. Added a note that validation
  runs server-side automatically on POST.

STARLARK_APPS_GUIDE.md
- "Plugins -> Starlark Apps" UI navigation path doesn't exist (5
  occurrences). Replaced with the real path: Plugin Manager tab,
  then the per-plugin Starlark Apps tab in the second nav row.
- "Navigate to Plugins" install step -> Plugin Manager tab.

web_interface/README.md
- Documented several endpoints that don't exist in the api_v3
  blueprint:
  - GET /api/v3/plugins (list) -> /api/v3/plugins/installed
  - GET /api/v3/plugins/<id> -> doesn't exist
  - POST /api/v3/plugins/<id>/config -> POST /api/v3/plugins/config
  - GET /api/v3/plugins/<id>/enable + /disable -> POST /api/v3/plugins/toggle
  - GET /api/v3/store/plugins -> /api/v3/plugins/store/list
  - POST /api/v3/store/install/<id> -> POST /api/v3/plugins/install
  - POST /api/v3/store/uninstall/<id> -> POST /api/v3/plugins/uninstall
  - POST /api/v3/store/update/<id> -> POST /api/v3/plugins/update
  - POST /api/v3/display/start/stop/restart -> POST /api/v3/system/action
  - GET /api/v3/display/status -> GET /api/v3/system/status
- Also fixed config/secrets.json -> config/config_secrets.json
- Replaced the per-section endpoint duplication with a current real
  endpoint list and a pointer to docs/REST_API_REFERENCE.md.
- Documented that SSE stream endpoints are defined directly on the
  Flask app at app.py:607-615, not in the api_v3 blueprint.

scripts/install/README.md
- Was missing 3 of the 9 install scripts in the directory:
  one-shot-install.sh, configure_wifi_permissions.sh, and
  debug_install.sh. Added them with brief descriptions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* docs: clarify plugin paths and fix systemd manual install bug

PLUGIN_DEVELOPMENT_GUIDE.md
- Added a "Plugin directory note" callout near the top explaining
  the plugins/ vs plugin-repos/ split:
  - Dev workflow uses plugins/ (where dev_plugin_setup.sh creates
    symlinks)
  - Production / Plugin Store uses plugin-repos/ (the configurable
    default per config.template.json:130)
  - The plugin loader falls back to plugins/ so dev symlinks are
    picked up automatically (schema_manager.py:77)
  - User can set plugins_directory to "plugins" in the General tab
    if they want both to share a directory

CLAUDE.md
- The Project Structure section had plugins/ and plugin-repos/
  exactly reversed:
  - Old: "plugins/ - Installed plugins directory (gitignored)"
         "plugin-repos/ - Development symlinks to monorepo plugin dirs"
  - Real: plugin-repos/ is the canonical Plugin Store install
    location and is not gitignored. plugins/* IS gitignored
    (verified in .gitignore) and is the legacy/dev location used by
    scripts/dev/dev_plugin_setup.sh.
  Reversed the descriptions and added line refs.

systemd/README.md
- "Manual Installation" section told users to copy the unit file
  directly to /etc/systemd/system/. Verified the unit file in
  systemd/ledmatrix.service contains __PROJECT_ROOT_DIR__
  placeholders that the install scripts substitute at install time.
  A user following the manual steps would get a service that fails
  to start with "WorkingDirectory=__PROJECT_ROOT_DIR__" errors.
  Added a clear warning and a sed snippet that substitutes the
  placeholder before installing.

src/common/README.md
- Was missing 2 of the 11 utility modules in the directory
  (verified with ls): permission_utils.py and cli.py. Added brief
  descriptions for both.

Out-of-scope code bug found while auditing (flagged but not fixed):
- scripts/dev/dev_plugin_setup.sh:9 sets PROJECT_ROOT="$SCRIPT_DIR"
  which resolves to scripts/dev/, not the project root. This means
  the script's PLUGINS_DIR resolves to scripts/dev/plugins/ instead
  of the project's plugins/ — confirmed by the existence of
  scripts/dev/plugins/of-the-day/ from prior runs. Real fix is to
  set PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)". Not fixing in
  this docs PR.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* docs: flag aspirational/regressed features in plugin docs

These docs describe features that exist as documented in the doc but
either never wired up or regressed when v3 shipped. Each gets a clear
status banner so plugin authors don't waste time chasing features that
don't actually work.

FONT_MANAGER.md
- The "For Plugin Developers / Plugin Font Registration" section
  documents adding a "fonts" block to manifest.json that gets
  registered via FontManager.register_plugin_fonts(). The method
  exists at src/font_manager.py:150 but is **never called from
  anywhere** in the codebase (verified: zero callers). A plugin
  shipping a manifest "fonts" block has its fonts silently ignored.
  Added a status warning and a note about how to actually ship plugin
  fonts (regular files in the plugin dir, loaded directly).

PLUGIN_IMPLEMENTATION_SUMMARY.md
- Added a top-level status banner.
- Architecture diagram referenced src/plugin_system/registry_manager.py
  (which doesn't exist) and listed plugins/ as the install location.
  Replaced with the real file list (plugin_loader, schema_manager,
  health_monitor, operation_queue, state_manager) and pointed at
  plugin-repos/ as the default install location.
- "Dependency Management: Virtual Environments" — verified there's no
  per-plugin venv. Removed the bullet and added a note that plugin
  Python deps install into the system Python environment, with no
  conflict resolution.
- "Permission System: File Access Control / Network Access /
  Resource Limits / CPU and memory constraints" — none of these
  exist. There's a resource_monitor.py and health_monitor.py for
  metrics/warnings, but no hard caps or sandboxing. Replaced the
  section with what's actually implemented and a clear note that
  plugins run in the same process with full file/network access.

PLUGIN_CUSTOM_ICONS.md and PLUGIN_CUSTOM_ICONS_FEATURE.md
- The custom-icon feature was implemented in the v2 web interface
  via a getPluginIcon() helper in templates/index_v2.html that read
  the manifest "icon" field. When the v3 web interface was built,
  that helper wasn't ported. Verified in
  web_interface/templates/v3/base.html:515 and :774, plugin tab
  icons are hardcoded to `fas fa-puzzle-piece`. The "icon" field in
  plugin manifests is currently silently ignored (verified with grep
  across web_interface/ and src/plugin_system/ — zero non-action-
  related reads of plugin.icon or manifest.icon).
- Added a status banner to both docs noting the regression so plugin
  authors don't think their custom icons are broken in their own
  plugin code.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* docs: fix .cursor/ helper docs

The .cursor/ directory holds the dev-side helper docs that Cursor and
contributors using AI tooling rely on to bootstrap plugin development.
Several of them had the same bug patterns as the user-facing docs.

.cursor/plugin_templates/QUICK_START.md
- "Adding Image Rendering" section showed
  display_manager.draw_image(image, x=0, y=0). That method doesn't
  exist on DisplayManager (same bug as PLUGIN_API_REFERENCE.md and
  PLUGIN_DEVELOPMENT_GUIDE.md). Replaced with the canonical
  display_manager.image.paste((x,y)) pattern, including the
  transparency-mask form.

.cursor/plugins_guide.md
- 10 occurrences of ./dev_plugin_setup.sh — the script lives at
  scripts/dev/dev_plugin_setup.sh, so anyone copy-pasting these
  examples gets "command not found". Bulk fixed via sed.
- "Test with emulator: python run.py --emulator" — there's no
  --emulator flag. Replaced with the real options:
  EMULATOR=true python3 run.py for the full display, or
  scripts/dev_server.py for the dev preview.
- Secrets management section showed a fictional
  "config_secrets": { "api_key": "my-plugin.api_key" } reference
  field. Verified in src/config_manager.py:162-172 that secrets are
  loaded by deep-merging config_secrets.json into the main config.
  There is no separate reference field — just put the secret under
  the same plugin namespace and read it from the merged config.
  Rewrote the section with the real pattern.
- "ssh pi@raspberrypi" -> "ssh ledpi@your-pi-ip" (consistent with
  the rest of LEDMatrix docs which use ledpi as the default user)

.cursor/README.md
- Same ./dev_plugin_setup.sh -> ./scripts/dev/dev_plugin_setup.sh
  fix (×6 occurrences via replace_all).
- Same "python run.py --emulator" -> "EMULATOR=true python3 run.py"
  fix. Also added a pointer to scripts/dev_server.py for previewing
  plugins without running the full display.
- "Example Plugins: plugins/hockey-scoreboard/" — the canonical
  source is the ledmatrix-plugins repo. Installed copies land in
  plugin-repos/ or plugins/. Updated the line to point at the
  ledmatrix-plugins repo and explain both local locations.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* docs: fix .cursorrules — the file Cursor auto-loads to learn the API

This is the file that Cursor reads to learn how plugin development
works. Stale entries here directly mislead AI-assisted plugin authors
on every new plugin. Several of the same bug patterns I've been
fixing in the user-facing docs were here too.

Display Manager section (highest impact)
- "draw_image(image, x, y): Draw PIL Image" — that method doesn't
  exist on DisplayManager. Same bug already fixed in
  PLUGIN_API_REFERENCE.md, PLUGIN_DEVELOPMENT_GUIDE.md,
  ledmatrix-stocks/README.md, and .cursor/plugin_templates/QUICK_START.md.
  Removed the bullet and replaced it with a paragraph explaining the
  real pattern: paste onto display_manager.image directly, then
  update_display(). Includes the transparency-mask form.
- Added the small_font/centered args to draw_text() since they're
  the ones that matter most for new plugin authors
- Added draw_weather_icon since it's commonly used

Cache Manager section
- "delete(key): Remove cached value" — there's no delete() method
  on CacheManager. The real method is clear_cache(key=None) (also
  removes everything when called without args). Same bug as before.
- Added get_cached_data_with_strategy and get_background_cached_data
  since contributors will hit these when working on sports plugins

Plugin System Overview
- "loaded from the plugins/ directory" — clarified that the default
  is plugin-repos/ (per config.template.json:130) with plugins/ as
  the dev fallback used by scripts/dev/dev_plugin_setup.sh

Plugin Development Workflow
- ./dev_plugin_setup.sh -> ./scripts/dev/dev_plugin_setup.sh (×2)
- Manual setup step "Create directory in plugins/<plugin-id>/" ->
  plugin-repos/<plugin-id>/ as the canonical location
- "Use emulator: python run.py --emulator or ./run_emulator.sh"
  — the --emulator flag doesn't exist; ./run_emulator.sh isn't at
  root (it lives at scripts/dev/run_emulator.sh). Replaced with the
  real options: scripts/dev_server.py for dev preview, or
  EMULATOR=true python3 run.py for the full emulator path.

Configuration Management
- "Reference secrets via config_secrets key in main config" — this
  is the same fictional reference syntax I just fixed in
  .cursor/plugins_guide.md. Verified in src/config_manager.py:162-172
  that secrets are deep-merged into the main config; there's no
  separate reference field. Replaced with a clear explanation of
  the deep-merge approach.

Code Organization
- "plugins/<plugin-id>/" -> the canonical location is
  plugin-repos/<plugin-id>/ (or its dev-time symlink in plugins/)
- "see plugins/hockey-scoreboard/ as reference" — the canonical
  source for example plugins is the ledmatrix-plugins repo. Updated
  the pointer.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Add LICENSE (GPL-3.0) and CONTRIBUTING.md

LICENSE
- The repository previously had no LICENSE file. The README and every
  downstream plugin README already reference GPL-3.0 ("same as
  LEDMatrix project"), but the canonical license text was missing —
  contributors had no formal record of what they were contributing
  under, and GitHub couldn't auto-detect the license for the repo
  banner.
- Added the canonical GPL-3.0 text from
  https://www.gnu.org/licenses/gpl-3.0.txt (verbatim, 674 lines).
- Compatibility verified: rpi-rgb-led-matrix is GPL-2.0-or-later
  (per its COPYING file and README; the "or any later version" clause
  in lib/*.h headers makes GPL-3.0 distribution legal).

CONTRIBUTING.md
- The repository had no CONTRIBUTING file. New contributors had to
  reconstruct the dev setup from DEVELOPMENT.md, PLUGIN_DEVELOPMENT_GUIDE.md,
  SUBMISSION.md, and the root README.
- Added a single page covering: dev environment setup (preview
  server, emulator, hardware), running tests, PR submission flow,
  commit message convention, plugin contribution pointer, and the
  license terms contributors are agreeing to.

> Note for the maintainer: I (the AI assistant doing this audit) am
> selecting GPL-3.0 because every reference in the existing
> documentation already says GPL-3.0 — this commit just makes that
> declaration legally binding by adding the actual file. Please
> confirm during PR review that GPL-3.0 is what you want; if you
> prefer a different license, revert this commit before merging.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Add CODE_OF_CONDUCT, SECURITY, PR template; link them from README

Tier 1 organizational files that any open-source project at
LEDMatrix's maturity is expected to have. None of these existed
before. They're additive — no existing content was rewritten.

CODE_OF_CONDUCT.md
- Contributor Covenant 2.1 (the de facto standard for open-source
  projects). Mentions both the Discord and the GitHub Security
  Advisories channel for reporting violations.

SECURITY.md
- Private vulnerability disclosure flow with two channels: GitHub
  Security Advisories (preferred) and Discord DM.
- Documents the project's known security model as intentional
  rather than vulnerabilities: no web UI auth, plugins run
  unsandboxed, display service runs as root for GPIO access,
  config_secrets.json is plaintext. These match the limitations
  already called out in PLUGIN_QUICK_REFERENCE.md and the audit
  flagging from earlier in this PR.
- Out-of-scope section points users at upstream
  (rpi-rgb-led-matrix, third-party plugins) so reports land in the
  right place.

.github/PULL_REQUEST_TEMPLATE.md
- 10-line checklist that prompts for the things that would have
  caught the bugs in this very PR: did you load the changed plugin
  once, did you update docs alongside code, are there any plugin
  compatibility implications.
- Linked from CONTRIBUTING.md for the full flow.

README.md
- Added a License section near the bottom (the README previously
  said nothing about the license despite the project being GPL-3.0).
- Added a Contributing section pointing at CONTRIBUTING.md and
  SECURITY.md.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Customize bug report template for LEDMatrix hardware

The bug_report.md template was the GitHub default and asked
"Desktop (OS/Browser/Version)" and "Smartphone (Device/OS)" — neither
of which is relevant for a project that runs on a Raspberry Pi with
hardware LED panels. A user filing a bug under the old template was
giving us none of the information we'd actually need to triage it.

Replaced with a LEDMatrix-aware template that prompts for:
- Pi model, OS/kernel, panel type, HAT/Bonnet, PWM jumper status,
  display chain dimensions
- LEDMatrix git commit / release tag
- Plugin id and version (if the bug is plugin-related)
- Relevant config snippet (with redaction reminder for API keys)
- journalctl log excerpt with the exact command to capture it
- Optional photo of the actual display for visual issues

Kept feature_request.md as-is — generic content there is fine.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* docs: fix bare /api/plugins paths in PLUGIN_CONFIGURATION_TABS

Found 5 more bare /api/plugins/* paths in PLUGIN_CONFIGURATION_TABS.md
that I missed in the round 2 sweep — they're inside data flow diagrams
and prose ("loaded via /api/plugins/installed", etc.) so the earlier
grep over Markdown code blocks didn't catch them. Fixed all 5 to use
/api/v3/plugins/* (the api_v3 blueprint mount path verified at
web_interface/app.py:144).

Also added a status banner noting that the "Implementation Details"
section references the pre-v3 file layout (web_interface_v2.py,
templates/index_v2.html) which no longer exists. The current
implementation is in web_interface/app.py, blueprints/api_v3.py, and
templates/v3/. Same kind of historical drift I flagged in
PLUGIN_ARCHITECTURE_SPEC.md and the PLUGIN_CUSTOM_ICONS_FEATURE doc.
The user-facing parts of the doc (Overview, Features, Form Generation
Process) are still accurate.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* docs(widgets): list the 20 undocumented built-in widgets

The widget registry README documented 3 widgets (file-upload,
checkbox-group, custom-feeds) but the directory contains 23 registered
widgets total. A plugin author reading this doc would think those 3
were the only built-in options and either reach for a custom widget
unnecessarily or settle for a generic text input.

Verified the actual list with:
  grep -h "register('" web_interface/static/v3/js/widgets/*.js \
    | sed -E "s|.*register\\('([^']+)'.*|\\1|" | sort -u

Added an "Other Built-in Widgets" section after the 3 detailed
sections, listing the remaining 20 with one-line descriptions
organized by category:
- Inputs (6): text-input, textarea, number-input, email-input,
  url-input, password-input
- Selectors (7): select-dropdown, radio-group, toggle-switch,
  slider, color-picker, font-selector, timezone-selector
- Date/time/scheduling (4): date-picker, day-selector, time-range,
  schedule-picker
- Composite/data-source (2): array-table, google-calendar-picker
- Internal (2): notification, base-widget

Pointed at the .js source files as the canonical source for each
widget's exact schema and options — keeps this list low-maintenance
since I'm not duplicating each widget's full options table.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* docs: fix README_NBA_LOGOS and PLUGIN_CONFIGURATION_GUIDE

scripts/README_NBA_LOGOS.md
- "python download_nba_logos.py" — wrong on two counts. The script
  is at scripts/download_nba_logos.py (not the project root), and
  "python" is Python 2 on most systems. Replaced all 4 occurrences
  with "python3 scripts/download_nba_logos.py".
- The doc framed itself as the way to set up "the NBA leaderboard".
  The basketball/leaderboard functionality is now in the
  basketball-scoreboard and ledmatrix-leaderboard plugins (in the
  ledmatrix-plugins repo), which auto-download logos on first run.
  Reframed the script as a pre-population utility for offline / dev
  use cases.
- Bumped the documented Python minimum from 3.7 to 3.9 to match
  the rest of the project.

docs/PLUGIN_CONFIGURATION_GUIDE.md
- The "Plugin Manifest" example was missing 3 fields the plugin
  loader actually requires: id, entry_point, and class_name. A
  contributor copying this manifest verbatim would get
  PluginError("No class_name in manifest") at load time — the same
  loader bug already found in stock-news. Added all three.
- The same example showed config_schema as an inline object. The
  loader expects config_schema to be a file path string (e.g.
  "config_schema.json") with the actual schema in a separate JSON
  file — verified earlier in this audit. Fixed.
- Added a paragraph explaining the loader's required fields and
  the case-sensitivity rule on class_name (the bug that broke
  hello-world's manifest before this PR fixed it).
- "Plugin Manager Class" example had the wrong constructor
  signature: (config, display_manager, cache_manager, font_manager).
  The real BasePlugin.__init__ at base_plugin.py:53-60 takes
  (plugin_id, config, display_manager, cache_manager, plugin_manager).
  A copy-pasted example would TypeError on instantiation. Fixed,
  including a comment noting which attributes BasePlugin sets up.
- Renamed the example class from MyPluginManager to MyPlugin to
  match the project convention (XxxPlugin / XxxScoreboardPlugin
  in actual plugins).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* docs(requirements): document optional dependencies (scipy, psutil, Flask-Limiter)

A doc-vs-code crosscheck of every Python import in src/ and
web_interface/ against requirements.txt found 3 packages that the
code uses but requirements.txt doesn't list. Verified with grep that
all 3 are wrapped in try/except blocks with documented fallback
paths, so they're optional features rather than missing required
deps:

- scipy           src/common/scroll_helper.py:26
                  → from scipy.ndimage import shift; HAS_SCIPY flag.
                  Used for sub-pixel interpolation in scrolling.
                  Falls back to a simpler shift algorithm without it.

- psutil          src/plugin_system/resource_monitor.py:15
                  → import psutil; PSUTIL_AVAILABLE flag. Used for
                  per-plugin CPU/memory monitoring. Silently no-ops
                  without it.

- flask-limiter   web_interface/app.py:42-43
                  → from flask_limiter import Limiter; wrapped at the
                  caller. Used for accidental-abuse rate limiting on
                  the web interface (not security). Web interface
                  starts without rate limiting when missing.

These were latent in two ways:
1. A user reading requirements.txt thinks they have the full feature
   set after `pip install -r requirements.txt`, but they don't get
   smoother scrolling, plugin resource monitoring, or rate limiting.
2. A contributor who deletes one of the packages from their dev env
   wouldn't know which feature they just lost — the fallbacks are
   silent.

Added an "Optional dependencies" section at the bottom of
requirements.txt with the version constraint, the file:line where
each is used, the feature it enables, and the install command. The
comment-only format means `pip install -r requirements.txt` still
gives the minimal-feature install (preserving current behavior),
while users who want the full feature set can copy the explicit
pip install commands.

Other findings from the same scan that came back as false positives
or known issues:
- web_interface_v2: dead pattern flagged in earlier iteration
  (still no real implementation; affects 11+ plugins via the same
  try/except dead-fallback pattern)
- urllib3: comes with `requests` transitively
- All 'src.', 'web_interface.', 'rgbmatrix', 'RGBMatrixEmulator'
  imports: internal modules
- base_plugin / plugin_manager / store_manager / mocks /
  visual_display_manager: relative imports to local modules
- freetype: false positive (freetype-py is in requirements.txt
  under the package name)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* docs: fix broken file references found by path-existence crosscheck

Ran a doc-vs-filesystem crosscheck: extracted every backtick-quoted
path with a file extension or known directory prefix from docs/*.md
and verified each exists. After filtering false positives
(placeholder paths, config keys mistaken for paths, paths inside
docs that already have historical-status banners), found 4 real
broken references — 3 fixed in docs, 1 fixed by creating the missing
file:

docs/HOW_TO_RUN_TESTS.md:339
- Claimed ".github/workflows/tests.yml" exists and runs pytest on
  multiple Python versions in CI. There is no such workflow.
  The only GitHub Actions file is security-audit.yml (bandit + semgrep).
- Pytest runs locally but is NOT gated on PRs.
- Replaced the fictional CI section with the actual state and a
  note explaining how someone could contribute a real test workflow.

docs/MIGRATION_GUIDE.md:92
- Referenced scripts/fix_perms/README.md "(if exists)" — the
  hedge betrays that the writer wasn't sure. The README didn't
  exist. The 6 scripts in scripts/fix_perms/ were never documented.
- Created the missing scripts/fix_perms/README.md from scratch
  with one-line descriptions of all 6 scripts (fix_assets,
  fix_cache, fix_plugin, fix_web, fix_nhl_cache, safe_plugin_rm)
  + when-to-use-each guidance + usage examples.
- Updated MIGRATION_GUIDE link to drop the "(if exists)" hedge
  since the file now exists.

docs/FONT_MANAGER.md:376
- "See test/font_manager_example.py for a complete working example"
  — that file does not exist. Verified by listing test/ directory.
- Replaced with a pointer to src/font_manager.py itself and the
  existing scoreboard base classes in src/base_classes/ that
  actually use the font manager API in production.

Path-existence check methodology:
- Walked docs/ recursively, regex-extracted backtick-quoted paths
  matching either /\.(py|sh|json|yml|yaml|md|txt|service|html|js|css|ttf|bdf|png)/
  or paths starting with known directory prefixes (scripts/, src/,
  config/, web_interface/, systemd/, assets/, docs/, test/, etc.)
- Filtered out URLs, absolute paths (placeholders), and paths
  without slashes (likely not relative refs).
- Checked existence relative to project root.
- Out of 80 unique relative paths in docs/, 32 didn't exist on
  disk. Most were false positives (configkeys mistaken for paths,
  example placeholders like 'assets/myfont.ttf', historical
  references inside docs that already have status banners). The 4
  above were genuine broken refs.

This pattern is reusable for future iterations and worth wiring
into CI (link checker like lychee, scoped to fenced code paths
rather than just markdown links, would catch the same class).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* docs: address CodeRabbit review comments on #306

Reviewed all 12 CodeRabbit comments on PR #306, verified each against
the current code, and fixed the 11 valid ones. The 12th finding is a
real code bug (cache_manager.delete() calls in api_helper.py and
resource_monitor.py) that's already in the planned follow-up code-fix
PR, so it stays out of this docs PR.

Fixed:

.cursor/plugins_guide.md, .cursor/README.md, .cursorrules
- I claimed "there is no --emulator flag" in 3 places. Verified in
  run.py:19-20 that the -e/--emulator flag is defined and functional
  (it sets os.environ["EMULATOR"]="true" before the display imports).
  Other docs I didn't touch (.cursor/plugin_templates/QUICK_START.md,
  docs/PLUGIN_DEVELOPMENT_GUIDE.md) already use the flag correctly.
  Replaced all 3 wrong statements with accurate guidance that
  both forms work and explains the CLI flag's relationship to the
  env var.

.cursorrules, docs/GETTING_STARTED.md, docs/WEB_INTERFACE_GUIDE.md,
docs/PLUGIN_DEVELOPMENT_GUIDE.md
- Four places claimed "the plugin loader also falls back to plugins/".
  Verified that PluginManager.discover_plugins()
  (src/plugin_system/plugin_manager.py:154) only scans the
  configured directory — no fallback. The fallback to plugins/
  exists only in two narrower places: store_manager.py:1700-1718
  (store install/update/uninstall operations) and
  schema_manager.py:70-80 (schema lookup for the web UI form
  generator). Rewrote all four mentions with the precise scope.
  Added a recommendation to set plugin_system.plugins_directory
  to "plugins" for the smoothest dev workflow with
  dev_plugin_setup.sh symlinks.

docs/FONT_MANAGER.md
- The "Status" warning told plugin authors to use
  display_manager.font_manager.resolve_font(...) as a workaround for
  loading plugin fonts. Verified in src/font_manager.py that
  resolve_font() takes a family name, not a file path — so the
  workaround as written doesn't actually work. Rewrote to tell
  authors to load the font directly with PIL or freetype-py in their
  plugin.
- The same section said "the user-facing font override system in the
  Fonts tab still works for any element that's been registered via
  register_manager_font()". Verified in
  web_interface/blueprints/api_v3.py:5404-5428 that
  /api/v3/fonts/overrides is a placeholder implementation that
  returns empty arrays and contains "would integrate with the actual
  font system" comments — the Fonts tab does not have functional
  integration with register_manager_font() or the override system.
  Removed the false claim and added an explicit note that the tab
  is a placeholder.

docs/ADVANCED_FEATURES.md:523
- The on-demand section said REST/UI calls write a request "into the
  cache manager (display_on_demand_config key)". Wrong — verified
  via grep that api_v3.py:1622 and :1687 write to
  display_on_demand_request, and display_on_demand_config is only
  written by the controller during activation
  (display_controller.py:1195, cleared at :1221). Corrected the key
  name and added controller file:line references so future readers
  can verify.

docs/ADVANCED_FEATURES.md:803
- "Plugins using the background service" paragraph listed all
  scoreboard plugins but an orphaned " MLB (baseball)" bullet
  remained below from the old version of the section. Removed the
  orphan and added "baseball/MLB" to the inline list for clarity.

web_interface/README.md
- The POST /api/v3/system/action action list was incomplete. Verified
  in web_interface/app.py:1383,1386 that enable_autostart and
  disable_autostart are valid actions. Added both.
- The Plugin Store section was missing
  GET /api/v3/plugins/store/github-status (verified at
  api_v3.py:3296). Added it.
- The SSE line-range reference was app.py:607-615 but line 619
  contains the "Exempt SSE streams from CSRF and add rate limiting"
  block that's semantically part of the same feature. Extended the
  range to 607-619.

docs/GETTING_STARTED.md
- Rows/Columns step said "Columns: 64 or 96 (match your hardware)".
  The web UI's validation accepts any integer in 16-128. Clarified
  that 64 and 96 are the common bundled-hardware values but the
  valid range is wider.

Not addressed (out of scope for docs PR):

- .cursorrules:184 CodeRabbit comment flagged the non-existent
  cache_manager.delete() calls in src/common/api_helper.py:287 and
  src/plugin_system/resource_monitor.py:343. These are real CODE
  bugs, not doc bugs, and they're the first item in the planned
  post-docs-refresh code-cleanup PR (see
  /home/chuck/.claude/plans/warm-imagining-river.md). The docs in
  this PR correctly state that delete() doesn't exist on
  CacheManager — the fix belongs in the follow-up code PR that
  either adds a delete() shim or updates the two callers.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 21:04:25 -04:00
efe6b1fe23 fix: reduce CPU usage, fix Vegas refresh, throttle high-FPS ticks (#304)
* fix: reduce CPU usage, fix Vegas mid-cycle refresh, and throttle high-FPS plugin ticks

Web UI Info plugin was causing 90%+ CPU on RPi4 due to frequent subprocess
calls and re-rendering. Fixed by: trying socket-based IP detection first
(zero subprocess overhead), caching AP mode checks with 60s TTL, reducing
IP refresh from 30s to 5m, caching rendered display images, and loading
fonts once at init.

Vegas mode was not updating the display mid-cycle because hot_swap_content()
reset the scroll position to 0 on every recomposition. Now saves and
restores scroll position for mid-cycle updates.

High-FPS display loop was calling _tick_plugin_updates() 125x/sec with no
benefit. Added throttled wrapper that limits to 1 call/sec.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address PR review — respect plugin update_interval, narrow exception handlers

Make _tick_plugin_updates_throttled default to no-throttle (min_interval=0)
so plugin-configured update_interval values are never silently capped.
The high-FPS call site passes an explicit 1.0s interval.

Narrow _load_font exception handler from bare Exception to
FileNotFoundError | OSError so unexpected errors surface.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(vegas): scale scroll position proportionally on mid-cycle hot-swap

When content width changes during a mid-cycle recomposition (e.g., a
plugin gains or loses items), blindly restoring the old scroll_position
and total_distance_scrolled could overshoot the new total_scroll_width
and trigger immediate false completion. Scale both values proportionally
to the new width and clamp scroll_position to stay in bounds.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 08:46:52 -04:00
5ea2acd897 fix(web): array-table Add Item button creates rows with input fields (#302) (#303)
The data-item-properties attribute on the Add Item button was serialized
inside double-quoted HTML using {{ item_properties|tojson|e }}. Jinja2's
|tojson returns Markup (marked safe), making |e a no-op — the JSON
double quotes were not escaped to &quot;. The browser truncated the
attribute at the first " in the JSON, so addArrayTableRow() parsed an
empty object and created rows with only a trash icon.

Fix: switch to single-quote attribute delimiters (JSON only uses double
quotes internally) and filter item_properties to only the display
columns, avoiding large nested objects in the attribute value.

Closes #302

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 16:31:09 -04:00
68a0fe1182 fix(web): resolve plugin settings tabs not loading (#301)
* fix(web): resolve plugin settings tabs not loading due to enhancement race

Two co-occurring bugs prevented plugin setting tabs from loading:

1. Both stub-to-full app() enhancement paths (tryEnhance and
   requestAnimationFrame) could fire independently, with the second
   overwriting installedPlugins back to [] after init() already fetched
   them. Added a guard flag (_appEnhanced) and runtime state preservation
   to prevent this race.

2. Plugin config x-init only loaded content if window.htmx was available
   at that exact moment, with no retry or fallback. Added retry loop
   (up to 3s) and fetch() fallback for resilience.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(web): use runtime default tab and add Alpine.initTree to fetch fallback

- Replace hard-coded 'overview' comparison with runtime defaultTab
  (isAPMode ? 'wifi' : 'overview') in both enhancement paths, so
  activeTab is preserved correctly in AP mode
- Add Alpine.initTree(el) call in the plugin config fetch() fallback
  so Alpine directives in the injected HTML are initialized, matching
  the pattern used by loadOverviewDirect and loadWifiDirect

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 12:34:00 -04:00
7afc2c0670 fix(web): increase chain_length max from 8 to 32 (#300)
* fix(web): increase chain_length max from 8 to 32

The web UI form input capped chain_length at 8 panels, preventing
users with larger displays (e.g. 16-panel setups) from configuring
their hardware through the UI. The backend API had no such limit.

Changed max="8" to max="32" to support large display configurations.
Added panel count example to the help text.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(web): add server-side bounds validation for display hardware fields

The API endpoint at /api/v3/config/main accepted any integer value for
display hardware fields (chain_length, rows, cols, brightness, etc.)
without bounds checking. Only the HTML form had min/max attributes,
which are trivially bypassed by direct API calls.

Added _int_field_limits dict with bounds for all integer hardware fields:
  chain_length: 1-32, parallel: 1-4, brightness: 1-100,
  rows: 8-128, cols: 16-128, scan_mode: 0-1, pwm_bits: 1-11,
  pwm_dither_bits: 0-2, pwm_lsb_nanoseconds: 50-500,
  limit_refresh_rate_hz: 0-1000, gpio_slowdown: 0-5

Out-of-bounds or non-integer values now return 400 with a clear error
message (e.g. "Invalid chain_length value 99. Must be between 1 and 32.")
before any config is persisted. Follows the same inline validation
pattern already used for led_rgb_sequence, panel_type, and multiplexing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(api): strict int validation and add max_dynamic_duration_seconds bounds

Reject bool/float types in _int_field_limits validation loop to prevent
silent coercion, and add max_dynamic_duration_seconds to the validation
map so it gets proper bounds checking instead of a raw int() call.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 20:17:14 -04:00
ee4149dc49 fix(vegas): refresh scroll buffer on live score updates (#299)
* fix(vegas): refresh scroll buffer when plugins report live data updates

should_recompose() only checked for cycle completion or staging buffer
content, but plugin updates go to _pending_updates — not the staging
buffer. The scroll display kept showing the old pre-rendered image
until the full cycle ended, even though fresh scores were already
fetched and logged.

Add has_pending_updates() check so hot_swap_content() triggers
immediately when plugins have new data.

Fixes #230

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(vegas): scope hot-swap to visible segments; use monotonic clock

1. Replace has_pending_updates() with has_pending_updates_for_visible_segments()
   so hot_swap_content() only fires when a pending update affects a plugin that
   is actually in the active scroll buffer (with images). Avoids unnecessary
   recomposition when non-visible plugins report updates.

2. Switch all display-loop timing (start_time, elapsed, _next_live_priority_check)
   from time.time() to time.monotonic() to prevent clock-stepping issues from
   NTP adjustments on Raspberry Pi.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 13:18:05 -04:00
5ddf8b1aea fix: live priority now interrupts long display durations (#196) (#298)
* fix: check live priority during display loops to interrupt long durations (#196)

_check_live_priority() was only called once per main loop iteration,
before entering the display duration loop. With dynamic duration enabled,
the loop could run for 60-120+ seconds without ever checking if a
favorite team's live game started — so the display stayed on leaderboard,
weather, etc. while the live game played.

Now both the high-FPS and normal FPS display loops check for live
priority every ~30 seconds (throttled to avoid overhead). When live
content is detected, the loop breaks immediately and switches to the
live game mode.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: update rotation index when live priority interrupts display loop

The live priority break set current_display_mode but not
current_mode_index, so the post-loop rotation logic (which checks the
old active_mode) would overwrite the live mode on the next advance.

Now both loops also set current_mode_index to match the live mode,
mirroring the existing pattern at the top of the main loop (line 1385).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: use timestamp throttle for live priority and skip post-loop rotation

Two issues fixed:

1. The modulo-based throttle (elapsed % 30.0 < display_interval) could
   miss the narrow 8ms window due to timing jitter. Replaced with an
   explicit timestamp check (_next_live_priority_check) that fires
   reliably every 30 seconds.

2. After breaking out of the display loop for live priority, the
   post-loop code (remaining-duration sleep and rotation advancement)
   would still run and overwrite the live mode. Now a continue skips
   directly to the next main loop iteration when current_display_mode
   was changed during the loop.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 20:40:06 -04:00
35df06b8e1 fix: resolve font upload "baseUrl is not defined" error (#235) (#297)
The baseUrl variable was declared inside an IIFE that skips re-execution
on HTMX reloads, so it became undefined when the fonts tab was reloaded.
Since baseUrl was just window.location.origin prepended to absolute paths
like /api/v3/fonts/upload, it was unnecessary — fetch() with a leading
slash already resolves against the current origin.

Remove baseUrl entirely and use relative URLs in all 7 fetch calls.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 15:24:29 -04:00
77e9eba294 fix: overhaul WiFi captive portal for reliable setup (#296)
* fix: overhaul WiFi captive portal for reliable device detection and fast setup

The captive portal detection endpoints were returning "success" responses
that told every OS (iOS, Android, Windows, Firefox) that internet was
working — so the portal popup never appeared. This fixes the core issue
and improves the full setup flow:

- Return portal-triggering redirects when AP mode is active; normal
  success responses when not (no false popups on connected devices)
- Add lightweight self-contained setup page (9KB, no frameworks) for
  the captive portal webview instead of the full UI
- Cache AP mode check with 5s TTL (single systemctl call vs full
  WiFiManager instantiation per request)
- Stop disabling AP mode during WiFi scans (which disconnected users);
  serve cached/pre-scanned results instead
- Pre-scan networks before enabling AP mode so captive portal has
  results immediately
- Use dnsmasq.d drop-in config instead of overwriting /etc/dnsmasq.conf
  (preserves Pi-hole and other services)
- Fix manual SSID input bug that incorrectly overwrote dropdown selection

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address review findings for WiFi captive portal

- Remove orphaned comment left over from old scan_networks() finally block
- Add sudoers rules for dnsmasq drop-in copy/remove to install script
- Combine cached-network message into single showMsg call (was overwriting)
- Return (networks, was_cached) tuple from scan_networks() so API endpoint
  derives cached flag from the scan itself instead of a redundant AP check
- Narrow exception catch in AP mode cache to SubprocessError/OSError and
  log the failure for remote debugging
- Bound checkNewIP retries to 20 attempts (60s) before showing fallback

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 14:50:33 -04:00
6eccb74415 fix: handle dotted schema keys in plugin settings save (#295)
* fix: handle dotted schema keys in plugin settings save (issue #254)

The soccer plugin uses dotted keys like "eng.1" for league identifiers.
PR #260 fixed backend helpers but the JS frontend still corrupted these
keys by naively splitting on dots. This fixes both the JS and remaining
Python code paths:

- JS getSchemaProperty(): greedy longest-match for dotted property names
- JS dotToNested(): schema-aware key grouping to preserve "eng.1" as one key
- Python fix_array_structures(): remove broken prefix re-navigation in recursion
- Python ensure_array_defaults(): same prefix navigation fix

Closes #254

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address review findings for dotted-key handling

- ensure_array_defaults: replace None nodes with {} so recursion
  proceeds into nested objects (was skipping when key existed as None)
- dotToNested: add tail-matching that checks the full remaining dotted
  tail against the current schema level before greedy intermediate
  matching, preventing leaf dotted keys from being split
- syncFormToJson: replace naive key.split('.') reconstruction with
  dotToNested(flatConfig, schema) and schema-aware getSchemaProperty()
  so the JSON tab save path produces the same correct nesting as the
  form submit path
- Add regression tests for dotted-key array normalization and None
  array default replacement

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address second round of review findings

- Tests: replace conditional `if response.status_code == 200` guards
  with unconditional `assert response.status_code == 200` so failures
  are not silently swallowed
- dotToNested: guard finalKey write with `if (i < parts.length)` to
  prevent empty-string key pollution when tail-matching consumed all
  parts
- Extract normalizeFormDataForConfig() helper from handlePluginConfigSubmit
  and call it from both handlePluginConfigSubmit and syncFormToJson so
  the JSON tab sync uses the same robust FormData processing (including
  _data JSON inputs, bracket-notation checkboxes, array-of-objects,
  file-upload widgets, checkbox DOM detection, and unchecked boolean
  handling via collectBooleanFields)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 11:12:31 -04:00
2c2fca2219 fix(web): use HTMX for Plugin Manager tab loading (#294)
* fix: auto-repair missing plugins and graceful config fallback

Plugins whose directories are missing (failed update, migration, etc.)
now get automatically reinstalled from the store on startup. The config
endpoint no longer returns a hard 500 when a schema is unavailable —
it falls back to conservative key-name-based masking so the settings
page stays functional.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: handle ledmatrix- prefix in plugin updates and reconciliation

The store registry uses unprefixed IDs (e.g., 'weather') while older
installs used prefixed config keys (e.g., 'ledmatrix-weather'). Both
update_plugin() and auto-repair now try the unprefixed ID as a fallback
when the prefixed one isn't found in the registry.

Also filters system config keys (schedule, display, etc.) from
reconciliation to avoid false positives.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: address code review findings for plugin auto-repair

- Move backup-folder filter from _get_config_state to _get_disk_state
  where the artifact actually lives
- Run startup reconciliation in a background thread so requests aren't
  blocked by plugin reinstallation
- Set _reconciliation_done only after success so failures allow retries
- Replace print() with proper logger in reconciliation
- Wrap load_schema in try/except so exceptions fall through to
  conservative masking instead of 500
- Handle list values in _conservative_mask_config for nested secrets
- Remove duplicate import re

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: add thread-safe locking to PluginManager and fix reconciliation retry

PluginManager thread safety:
- Add RLock protecting plugin_manifests and plugin_directories
- Build scan results locally in _scan_directory_for_plugins, then update
  shared state under lock
- Protect reads in get_plugin_info, get_all_plugin_info,
  get_plugin_directory, get_plugin_display_modes, find_plugin_for_mode
- Protect manifest mutation in reload_plugin
- Prevents races between background reconciliation thread and request
  handlers reading plugin state

Reconciliation retry:
- Clear _reconciliation_started on exception so next request retries
- Check result.reconciliation_successful before marking done
- Reset _reconciliation_started on non-success results to allow retry

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(web): use HTMX for Plugin Manager tab loading instead of custom fetch

The Plugin Manager tab was the only tab using a custom window.loadPluginsTab()
function with plain fetch() instead of HTMX. This caused a race condition where
plugins_manager.js listened for htmx:afterSwap to initialize, but that event
never fired for the custom fetch. Users had to navigate to a plugin config tab
and back to trigger initialization.

Changes:
- Switch plugins tab to hx-get/hx-trigger="revealed" matching all other tabs
- Remove ~560 lines of dead code (script extraction for a partial with no scripts,
  nested retry intervals, inline HTML card rendering fallbacks)
- Add simple loadPluginsDirect() fallback for when HTMX fails to load
- Remove typeof htmx guard on afterSwap listener so it registers unconditionally
- Tighten afterSwap target check to avoid spurious re-init from other tab swaps

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address CodeRabbit review findings across plugin system

- plugin_manager.py: clear plugin_manifests/plugin_directories before update
  to prevent ghost entries for uninstalled plugins persisting across scans
- state_reconciliation.py: remove 'enabled' key check that skipped legacy
  plugin configs, default to enabled=True matching PluginManager.load_plugin
- app.py: add threading.Lock around reconciliation start guard to prevent
  race condition spawning duplicate threads; add -> None return annotation
- store_manager.py: use resolved registry ID (alt_id) instead of original
  plugin_id when reinstalling during monorepo migration
- base.html: check Response.ok in loadPluginsDirect fallback; trigger
  fallback on tab click when HTMX unavailable; remove active-tab check
  from 5-second timeout so content preloads regardless

Skipped: api_v3.py secret redaction suggestion — the caller at line 2539
already tries schema-based mask_secret_fields() before falling back to
_conservative_mask_config, making the suggested change redundant.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: skip backup dirs in plugin discovery and fix HTMX event syntax

- plugin_manager.py: skip directories containing '.standalone-backup-'
  during discovery scan, matching state_reconciliation.py behavior and
  preventing backup manifests from overwriting live plugin entries
- base.html: fix hx-on::htmx:response-error → hx-on::response-error
  (the :: shorthand already adds the htmx: prefix, so the original
  syntax resolved to htmx:htmx:response-error making the handler dead)

Skipped findings:
- web-ui-info in _SYSTEM_CONFIG_KEYS: it's a real plugin with manifest.json
  and config entry, not a system key
- store_manager config key migration: valid feature request for handling
  ledmatrix- prefix rename, but new functionality outside this PR scope

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(web): add fetch timeout to loadPluginsDirect fallback

Add AbortController with 10s timeout so a hanging fetch doesn't leave
data-loaded set and block retries. Timer is cleared in both success
and error paths.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-26 15:21:33 -04:00
640a4c1706 fix: auto-repair missing plugins on startup (#293)
* fix: auto-repair missing plugins and graceful config fallback

Plugins whose directories are missing (failed update, migration, etc.)
now get automatically reinstalled from the store on startup. The config
endpoint no longer returns a hard 500 when a schema is unavailable —
it falls back to conservative key-name-based masking so the settings
page stays functional.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: handle ledmatrix- prefix in plugin updates and reconciliation

The store registry uses unprefixed IDs (e.g., 'weather') while older
installs used prefixed config keys (e.g., 'ledmatrix-weather'). Both
update_plugin() and auto-repair now try the unprefixed ID as a fallback
when the prefixed one isn't found in the registry.

Also filters system config keys (schedule, display, etc.) from
reconciliation to avoid false positives.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: address code review findings for plugin auto-repair

- Move backup-folder filter from _get_config_state to _get_disk_state
  where the artifact actually lives
- Run startup reconciliation in a background thread so requests aren't
  blocked by plugin reinstallation
- Set _reconciliation_done only after success so failures allow retries
- Replace print() with proper logger in reconciliation
- Wrap load_schema in try/except so exceptions fall through to
  conservative masking instead of 500
- Handle list values in _conservative_mask_config for nested secrets
- Remove duplicate import re

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: add thread-safe locking to PluginManager and fix reconciliation retry

PluginManager thread safety:
- Add RLock protecting plugin_manifests and plugin_directories
- Build scan results locally in _scan_directory_for_plugins, then update
  shared state under lock
- Protect reads in get_plugin_info, get_all_plugin_info,
  get_plugin_directory, get_plugin_display_modes, find_plugin_for_mode
- Protect manifest mutation in reload_plugin
- Prevents races between background reconciliation thread and request
  handlers reading plugin state

Reconciliation retry:
- Clear _reconciliation_started on exception so next request retries
- Check result.reconciliation_successful before marking done
- Reset _reconciliation_started on non-success results to allow retry

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 17:09:49 -04:00
48ff624a85 fix: catch ConfigError in display preview generator (#288)
* fix: catch ConfigError in display preview generator

PR #282 narrowed bare except blocks but missed ConfigError from
config_manager.load_config(), which wraps FileNotFoundError,
JSONDecodeError, and OSError. Without this, a corrupt or missing
config crashes the display preview SSE endpoint instead of falling
back to 128x64 defaults.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(security): comprehensive error handling cleanup

- Remove all traceback.format_exc() from client responses (33 remaining instances)
- Sanitize str(e) from client-facing messages, replacing with generic error messages
- Replace ~65 bare print() calls with structured logger.exception/error/warning/info/debug
- Remove ~35 redundant inline `import traceback` and `import logging` statements
- Convert logging.error/warning calls to use module-level named logger
- Fix WiFi endpoints that created redundant inline logger instances
- Add logger.exception() at all WebInterfaceError.from_exception() call sites
- Fix from_exception() in errors.py to use safe messages instead of raw str(exception)
- Apply consistent [Tag] prefixes to all logger calls for production triage

Only safe, user-input-derived str(e) kept: json.JSONDecodeError handlers (400 responses).
Subprocess template print(stdout) calls preserved (not error logging).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(security): correct error inference, remove debug log leak, consolidate config handlers

- _infer_error_code: map Config* exceptions to CONFIG_LOAD_FAILED
  (ConfigError is only raised by load_config(), so CONFIG_SAVE_FAILED
  produced wrong safe message and wrong suggested_fixes)
- Remove leftover DEBUG logs in save_main_config that dumped full
  request body and all HTTP headers (Authorization, Cookie, etc.)
- Replace dead FileNotFoundError/JSONDecodeError/IOError handlers in
  get_dim_schedule_config with single ConfigError catch (load_config
  already wraps these into ConfigError)
- Remove redundant local `from src.exceptions import ConfigError`
  imports now covered by top-level import
- Strip str(e) from client-facing error messages in dim schedule handler

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(security): fix plugin update logging and config validation leak

- update_plugin: change logger.exception to logger.error in non-except
  branch (logger.exception outside an except block logs useless
  "NoneType: None" traceback)
- update_plugin: remove duplicate logger.exception call in except block
  (was logging the same failure twice)
- save_plugin_config validation: stop logging full plugin_config dict
  (can contain API keys, passwords, tokens) and raw form_data values;
  log only keys and validation errors instead

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 12:53:45 -04:00
442638dd2c fix: add reset() alias to ScrollHelper for plugin compatibility (#290)
Multiple plugins (F1, UFC) independently called scroll_helper.reset()
instead of scroll_helper.reset_scroll(), causing AttributeError and
preventing scroll modes from displaying. Adding reset() as an alias
prevents this class of bugs going forward.

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 13:57:21 -04:00
8391832c90 fix(vegas): keep plugin data and visuals fresh during Vegas scroll mode (#291)
* fix(vegas): keep plugin data and visuals fresh during Vegas scroll mode

Plugins using ESPN APIs and other data sources were not updating during
Vegas mode because the render loop blocked for 60-600s per iteration,
starving the scheduled update tick. This adds a non-blocking background
thread that runs plugin updates every ~1s during Vegas mode, bridges
update notifications to the stream manager, and clears stale scroll
caches so all three content paths (native, scroll_helper, fallback)
reflect fresh data.

- Add background update tick thread in Vegas coordinator (non-blocking)
- Add _tick_plugin_updates_for_vegas() bridge in display controller
- Fix fallback capture to call update() instead of only update_data()
- Clear scroll_helper.cached_image on update for scroll-based plugins
- Drain background thread on Vegas stop/exit to prevent races

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(vegas): address review findings in update pipeline

- Extract _drive_background_updates() helper and call it from both the
  render loop and the static-pause wait loop so plugin data stays fresh
  during static pauses (was skipped by the early `continue`)
- Remove synchronous plugin.update() from the fallback capture path;
  the background update tick already handles API refreshes so the
  content-fetch thread should only call lightweight update_data()
- Use scroll_helper.clear_cache() instead of just clearing cached_image
  so cached_array, total_scroll_width and scroll_position are also reset

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 13:42:27 -04:00
fe5c1d0d5e feat(web): add Google Calendar picker widget for dynamic multi-calendar selection (#274)
* fix(install): add --prefer-binary to pip installs to avoid /tmp exhaustion

timezonefinder (~54 MB) includes large timezone polygon data files that pip
unpacks into /tmp during installation. On Raspberry Pi, the default tmpfs
/tmp size (often ~half of RAM) can be too small, causing the install to fail
with an out-of-space error.

Adding --prefer-binary tells pip to prefer pre-built binary wheels over
source distributions. Since timezonefinder and most other packages publish
wheels on PyPI (and piwheels.org has ARM wheels), this avoids the large
temporary /tmp extraction and speeds up installs generally.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(timezone): use America/New_York instead of EST for ESPN API date queries

EST is a fixed UTC-5 offset that does not observe daylight saving time,
causing the ESPN API date to be off by one hour during EDT (March–November).
America/New_York correctly handles DST transitions.

The ESPN scoreboard API anchors its schedule calendar to Eastern US time,
so this Eastern timezone is intentionally kept for the API date — it is not
user-configurable. Game time display is converted separately to the user's
configured timezone.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(web): add Google Calendar picker widget for dynamic calendar selection

Adds a new google-calendar-picker widget and API endpoint that lets users
load their available Google Calendars by name and check the ones they want,
instead of manually typing calendar IDs.

- GET /api/v3/plugins/calendar/list-calendars — calls plugin.get_calendars()
  and returns all accessible calendars with id, summary, and primary flag
- google-calendar-picker.js — new widget: "Load My Calendars" button renders
  a checklist; selections update a hidden comma-separated input for form submit
- plugin_config.html — handles x-widget: google-calendar-picker in array branch
- base.html — loads the new widget script

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(web): address PR review findings in google-calendar-picker

- api_v3.py: replace broad except block with specific exception handling,
  log full traceback via module logger, normalize/validate get_calendars()
  output to stable {id,summary,primary} objects, return opaque user-friendly
  error message instead of leaking str(e)
- google-calendar-picker.js: fix button label only updating to "Refresh
  Calendars" on success (restore original label on error); update summary
  paragraph via syncHiddenAndSummary() on every checkbox change so UI stays
  in sync with hidden input; pass summary element through loadCalendars and
  renderCheckboxes instead of re-querying DOM
- plugin_config.html: bound initWidget retry loop with MAX_RETRIES=40 to
  prevent infinite timers; normalize legacy comma-separated string values
  to arrays before passing to widget.render so pre-existing config populates
  correctly
- install_dependencies_apt.py: update install_via_pip docstring to document
  both --break-system-packages and --prefer-binary flags

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(web): harden list_calendar_calendars input validation

- Remove unused `as e` binding from ValueError/TypeError/KeyError except clause
- Replace hasattr(__iter__) with isinstance(list|tuple) so non-sequence returns
  are rejected before iteration
- Validate each calendar entry is a collections.abc.Mapping; skip and warn on
  malformed items rather than propagating a TypeError
- Coerce id/summary to str safely if not already strings

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(web): skip calendar entries with empty id in list_calendar_calendars

After coercing cal_id to str, check it is non-empty before appending to
the calendars list so entries with no usable id are never forwarded to
the client.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-25 18:19:32 -05:00
3e50fa5b1d fix(timezone): use America/New_York instead of EST for ESPN API date queries (#273)
* fix(install): add --prefer-binary to pip installs to avoid /tmp exhaustion

timezonefinder (~54 MB) includes large timezone polygon data files that pip
unpacks into /tmp during installation. On Raspberry Pi, the default tmpfs
/tmp size (often ~half of RAM) can be too small, causing the install to fail
with an out-of-space error.

Adding --prefer-binary tells pip to prefer pre-built binary wheels over
source distributions. Since timezonefinder and most other packages publish
wheels on PyPI (and piwheels.org has ARM wheels), this avoids the large
temporary /tmp extraction and speeds up installs generally.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(timezone): use America/New_York instead of EST for ESPN API date queries

EST is a fixed UTC-5 offset that does not observe daylight saving time,
causing the ESPN API date to be off by one hour during EDT (March–November).
America/New_York correctly handles DST transitions.

The ESPN scoreboard API anchors its schedule calendar to Eastern US time,
so this Eastern timezone is intentionally kept for the API date — it is not
user-configurable. Game time display is converted separately to the user's
configured timezone.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-25 10:55:52 -05:00
8ae82321ce fix(install): add --prefer-binary to pip installs to avoid /tmp exhaustion (#272)
timezonefinder (~54 MB) includes large timezone polygon data files that pip
unpacks into /tmp during installation. On Raspberry Pi, the default tmpfs
/tmp size (often ~half of RAM) can be too small, causing the install to fail
with an out-of-space error.

Adding --prefer-binary tells pip to prefer pre-built binary wheels over
source distributions. Since timezonefinder and most other packages publish
wheels on PyPI (and piwheels.org has ARM wheels), this avoids the large
temporary /tmp extraction and speeds up installs generally.

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-25 08:50:35 -05:00
eb143c44fa fix(web): render file-upload drop zone for string-type config fields (#271)
* feat: add March Madness plugin and tournament round logos

New dedicated March Madness plugin with scrolling tournament ticker:
- Fetches NCAA tournament data from ESPN scoreboard API
- Shows seeded matchups with team logos, live scores, and round separators
- Highlights upsets (higher seed beating lower seed) in gold
- Auto-enables during tournament window (March 10 - April 10)
- Configurable for NCAAM and NCAAW tournaments
- Vegas mode support via get_vegas_content()

Tournament round logo assets:
- MARCH_MADNESS.png, ROUND_64.png, ROUND_32.png
- SWEET_16.png, ELITE_8.png, FINAL_4.png, CHAMPIONSHIP.png

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(store): prevent bulk-update from stalling on bundled/in-repo plugins

Three related bugs caused the bulk plugin update to stall at 3/19:

1. Bundled plugins (e.g. starlark-apps, shipped with LEDMatrix rather
   than the plugin registry) had no metadata file, so update_plugin()
   returned False → API returned 500 → frontend queue halted.
   Fix: check for .plugin_metadata.json with install_type=bundled and
   return True immediately (these plugins update with LEDMatrix itself).

2. git config --get remote.origin.url (without --local) walked up the
   directory tree and found the parent LEDMatrix repo's remote URL for
   plugins that live inside plugin-repos/. This caused the store manager
   to attempt a 60-second git clone of the wrong repo for every update.
   Fix: use --local to scope the lookup to the plugin directory only.

3. hello-world manifest.json had a trailing comma causing JSON parse
   errors on every plugin discovery cycle (fixed on devpi directly).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(march-madness): address PR #263 code review findings

- Replace self.is_enabled with BasePlugin.self.enabled in update(),
  display(), and supports_dynamic_duration() so runtime toggles work
- Support quarter-based period labels for NCAAW (Q1..Q4 vs H1..H2),
  detected via league key or status_detail content
- Use live refresh interval (60s) for cache max_age during live games
  instead of hardcoded 300s
- Narrow broad except in _load_round_logos to (OSError, ValueError)
  with a fallback except Exception using logger.exception for traces
- Remove unused `situation` local variable from _parse_event()
- Add numpy>=1.24.0 to requirements.txt (imported but was missing)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(web): render file-upload drop zone for string-type config fields

String fields with x-widget: "file-upload" were falling through to a
plain text input because the template only handled the array case.
Adds a dedicated drop zone branch for string fields and corresponding
handleSingleFileSelect/handleSingleFileUpload JS handlers that POST to
the x-upload-config endpoint. Fixes credentials.json upload for the
calendar plugin.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(march-madness): address PR #271 code review findings

Inline fixes:
- manager.py: swap min_duration/max_duration if misconfigured, log warning
- manager.py: call session.close() and null session in cleanup() to prevent
  socket leaks on constrained hardware
- manager.py: remove blocking network I/O from display(); update() is the
  sole fetch path (already uses 60s live-game interval)
- manager.py: guard scroll_helper None before create_scrolling_image() in
  _create_ticker_image() to prevent crash when ScrollHelper is unavailable
- store_manager.py: replace bare "except Exception: pass" with debug log
  including plugin_id and path when reading .plugin_metadata.json
- file-upload.js: add endpoint guard (error if uploadEndpoint is falsy),
  client-side extension validation from data-allowed-extensions, and
  response.ok check before response.json() in handleSingleFileUpload
- plugin_config.html: add data-allowed-extensions attribute to single-file
  input so JS handler can read the allowed extensions list

Nitpick fixes:
- manager.py: use logger.exception() (includes traceback) instead of
  logger.error() for league fetch errors
- manager.py: remove redundant "{e}" from logger.exception() calls for
  round logo and March Madness logo load errors

Not fixed (by design):
- manifest.json repo naming: monorepo pattern is correct per project docs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(march-madness): address second round of PR #271 code review findings

Inline fixes:
- requirements.txt: bump Pillow to >=9.1.0 (required for Image.Resampling.LANCZOS)
- file-upload.js: replace all statusDiv.innerHTML assignments with safe DOM
  creation (textContent + createElement) to prevent XSS from untrusted strings
- plugin_config.html: add role="button", tabindex="0", aria-label, onkeydown
  (Enter/Space) to drop zone for keyboard accessibility; add aria-live="polite"
  to status div for screen-reader announcements
- file-upload.js: tighten handleFileDrop endpoint check to non-empty string
  (dataset.uploadEndpoint.trim() !== '') so an empty attribute falls back to
  the multi-file handler

Nitpick fixes:
- manager.py: remove redundant cached_image/cached_array reassignments after
  create_scrolling_image() which already sets them internally
- manager.py: narrow bare except in _get_team_logo to (FileNotFoundError,
  OSError, ValueError) for expected I/O errors; log unexpected exceptions
- store_manager.py: narrow except to (OSError, ValueError) when reading
  .plugin_metadata.json so unrelated exceptions propagate

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 20:12:31 -05:00
275fed402e fix(logos): support logo downloads for custom soccer leagues (#262)
* fix(logos): support logo downloads for custom soccer leagues

LogoDownloader.fetch_teams_data() and fetch_single_team() only had
hardcoded API endpoints for predefined soccer leagues. Custom leagues
(e.g., por.1, mex.1) would silently fail when the ESPN game data
didn't include a direct logo URL. Now dynamically constructs the ESPN
teams API URL for any soccer_* league not in the predefined map.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(logos): address PR review — directory, bulk download, and dedup

- get_logo_directory: custom soccer leagues now resolve to shared
  assets/sports/soccer_logos/ instead of creating per-league dirs
- download_all_missing_logos: use _resolve_api_url so custom soccer
  leagues are no longer silently skipped
- Extract _resolve_api_url helper to deduplicate dynamic URL
  construction between fetch_teams_data and fetch_single_team

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(web): preserve array item properties in _set_nested_value

When saving config with array-of-objects fields (e.g., custom_leagues),
_set_nested_value would replace existing list objects with dicts when
navigating dot-notation paths like "custom_leagues.0.name". This
destroyed any properties on array items that weren't submitted in the
form (e.g., display_modes, game_limits, filtering).

Now properly indexes into existing lists when encountering numeric path
segments, preserving all non-submitted properties on array items.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(security): address PR #262 code review security findings

- logo_downloader: validate league name against allowlist before
  constructing filesystem paths in get_logo_directory to prevent
  path traversal (reject anything not matching ^[a-z0-9_-]+$)
- logo_downloader: validate league_code against allowlist before
  interpolating into ESPN API URL in _resolve_api_url to prevent
  URL path injection; return None on invalid input
- api_v3: add MAX_LIST_EXPANSION=1000 cap to _set_nested_value list
  expansion; raise ValueError for out-of-bounds indices; replace
  silent break fallback with TypeError for unexpected traversal types

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 19:18:29 -05:00
38a9c1ed1b feat(march-madness): add NCAA tournament plugin and round logos (#263)
* feat: add March Madness plugin and tournament round logos

New dedicated March Madness plugin with scrolling tournament ticker:
- Fetches NCAA tournament data from ESPN scoreboard API
- Shows seeded matchups with team logos, live scores, and round separators
- Highlights upsets (higher seed beating lower seed) in gold
- Auto-enables during tournament window (March 10 - April 10)
- Configurable for NCAAM and NCAAW tournaments
- Vegas mode support via get_vegas_content()

Tournament round logo assets:
- MARCH_MADNESS.png, ROUND_64.png, ROUND_32.png
- SWEET_16.png, ELITE_8.png, FINAL_4.png, CHAMPIONSHIP.png

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(store): prevent bulk-update from stalling on bundled/in-repo plugins

Three related bugs caused the bulk plugin update to stall at 3/19:

1. Bundled plugins (e.g. starlark-apps, shipped with LEDMatrix rather
   than the plugin registry) had no metadata file, so update_plugin()
   returned False → API returned 500 → frontend queue halted.
   Fix: check for .plugin_metadata.json with install_type=bundled and
   return True immediately (these plugins update with LEDMatrix itself).

2. git config --get remote.origin.url (without --local) walked up the
   directory tree and found the parent LEDMatrix repo's remote URL for
   plugins that live inside plugin-repos/. This caused the store manager
   to attempt a 60-second git clone of the wrong repo for every update.
   Fix: use --local to scope the lookup to the plugin directory only.

3. hello-world manifest.json had a trailing comma causing JSON parse
   errors on every plugin discovery cycle (fixed on devpi directly).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(march-madness): address PR #263 code review findings

- Replace self.is_enabled with BasePlugin.self.enabled in update(),
  display(), and supports_dynamic_duration() so runtime toggles work
- Support quarter-based period labels for NCAAW (Q1..Q4 vs H1..H2),
  detected via league key or status_detail content
- Use live refresh interval (60s) for cache max_age during live games
  instead of hardcoded 300s
- Narrow broad except in _load_round_logos to (OSError, ValueError)
  with a fallback except Exception using logger.exception for traces
- Remove unused `situation` local variable from _parse_event()
- Add numpy>=1.24.0 to requirements.txt (imported but was missing)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 18:32:22 -05:00
23f0176c18 feat: add dev preview server and CLI render script (#264)
* fix(web): wire up "Check & Update All" plugins button

window.updateAllPlugins was never assigned, so the button always showed
"Bulk update handler unavailable." Wire it to PluginInstallManager.updateAll(),
add per-plugin progress feedback in the button text, show a summary
notification on completion, and skip redundant plugin list reloads.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add dev preview server, CLI render script, and visual test display manager

Adds local development tools for rapid plugin iteration without deploying to RPi:

- VisualTestDisplayManager: renders real pixels via PIL (same fonts/interface as production)
- Dev preview server (Flask): interactive web UI with plugin picker, auto-generated config
  forms, zoom/grid controls, and mock data support for API-dependent plugins
- CLI render script: render any plugin to PNG for AI-assisted visual feedback loops
- Updated test runner and conftest to auto-detect plugin-repos/ directory

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(dev-preview): address code review issues

- Use get_logger() from src.logging_config instead of logging.getLogger()
  in visual_display_manager.py to match project logging conventions
- Eliminate duplicate public/private weather draw methods — public draw_sun/
  draw_cloud/draw_rain/draw_snow now delegate to the private _draw_* variants
  so plugins get consistent pixel output in tests vs production
- Default install_deps=False in dev_server.py and render_plugin.py — dev
  scripts don't need to run pip install; developers are expected to have
  plugin deps installed in their venv already
- Guard plugins_dir fixture against PermissionError during directory iteration
- Fix PluginInstallManager.updateAll() to fall back to window.installedPlugins
  when PluginStateManager.installedPlugins is empty (plugins_manager.js
  populates window.installedPlugins independently of PluginStateManager)
- Remove 5 debug console.log statements from plugins_manager.js button setup
  and initialization code

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(scroll): fix scroll completion to prevent multi-pass wrapping

Change required_total_distance from total_scroll_width + display_width to
total_scroll_width alone. The scrolling image already contains display_width
pixels of blank initial padding, so reaching total_scroll_width means all
content has scrolled off-screen. The extra display_width term was causing
1-2+ unnecessary wrap-arounds, making the same games appear multiple times
and producing a black flicker between passes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(dev-preview): address PR #264 code review findings

- docs/DEV_PREVIEW.md: add bash language tag to fenced code block
- scripts/dev_server.py: add MAX/MIN_WIDTH/HEIGHT constants and validate
  width/height in render endpoint; add structured logger calls to
  discover_plugins (missing dirs, hidden entries, missing manifest,
  JSON/OS errors, duplicate ids); add type annotations to all helpers
- scripts/render_plugin.py: add MIN/MAX_DIMENSION validation after
  parse_args; replace prints with get_logger() calls; narrow broad
  Exception catches to ImportError/OSError/ValueError in plugin load
  block; add type annotations to all helpers and main(); rename unused
  module binding to _module
- scripts/run_plugin_tests.py: wrap plugins_path.iterdir() in
  try/except PermissionError with fallback to plugin-repos/
- scripts/templates/dev_preview.html: replace non-focusable div toggles
  with button role="switch" + aria-checked; add keyboard handlers
  (Enter/Space); sync aria-checked in toggleGrid/toggleAutoRefresh
- src/common/scroll_helper.py: early-guard zero total_scroll_width to
  keep scroll_position at 0 and skip completion/wrap logic
- src/plugin_system/testing/visual_display_manager.py: forward color
  arg in draw_cloud -> _draw_cloud; add color param to _draw_cloud;
  restore _scrolling_state in reset(); narrow broad Exception catches in
  _load_fonts to FileNotFoundError/OSError/ImportError; add explicit
  type annotations to draw_text
- test/plugins/test_visual_rendering.py: use context manager for
  Image.open in test_save_snapshot
- test/plugins/conftest.py: add return type hints to all fixtures

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore: add bandit and gitleaks pre-commit hooks

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 17:57:42 -05:00
9465fcda6e fix(store): fix installed status detection for plugins with path-derived IDs (#270)
The plugin registry uses short IDs (e.g. "weather", "stocks") but
plugin_path points to the actual installed directory name (e.g.
"plugins/ledmatrix-weather"). isStorePluginInstalled() was only
comparing registry IDs, causing all monorepo plugins with mismatched
IDs to show as not installed in the store UI.

- Updated isStorePluginInstalled() to also check the last segment of
  plugin_path against installed plugin IDs
- Updated all 3 call sites to pass the full plugin object instead of
  just plugin.id
- Fixed the same bug in renderCustomRegistryPlugins() which used the
  same direct ID comparison

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 17:35:08 -05:00
976c10c4ac fix(plugins): prevent module collision between plugins with shared module names (#265)
When plugins share identically-named local modules (scroll_display.py,
game_renderer.py, sports.py), the first plugin to load would populate
sys.modules with its version, and subsequent plugins would reuse it
instead of loading their own. This caused hockey-scoreboard to use
soccer-scoreboard's ScrollDisplay class, which passes unsupported kwargs
to ScrollHelper.__init__(), breaking Vegas scroll mode entirely.

Fix: evict stale bare-name module entries from sys.modules before each
plugin's exec_module, and delete bare entries after namespace isolation
so they can't leak to the next plugin.

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 17:22:55 -05:00
b92ff3dfbd fix(schedule): hot-reload config in schedule/dim checks + normalize per-day mode variant (#266)
* fix(web): handle string boolean values in schedule-picker widget

The normalizeSchedule function used strict equality (===) to check the
enabled field, which would fail if the config value was a string "true"
instead of boolean true. This could cause the checkbox to always appear
unchecked even when the setting was enabled.

Added coerceToBoolean helper that properly handles:
- Boolean true/false (returns as-is)
- String "true", "1", "on" (case-insensitive) → true
- String "false" or other values → false

Applied to both main schedule enabled and per-day enabled fields.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: trim whitespace in coerceToBoolean string handling

* fix: normalize mode value to handle per_day and per-day variants

* fix: use hot-reload config for schedule and dim schedule checks

The display controller was caching the config at startup and not picking
up changes made via the web UI. Now _check_schedule and _check_dim_schedule
read from config_service.get_config() to get the latest configuration,
allowing schedule changes to take effect without restarting the service.

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-23 17:22:39 -05:00
4c4efd614a fix(odds): use update_interval as cache TTL and fix live game cache refresh (#268)
* fix(odds): use 2-minute cache for live games instead of 30 minutes

Live game odds were being cached for 30 minutes because the cache key
didn't trigger the odds_live cache strategy. Added is_live parameter
to get_odds() and include 'live' suffix in cache key for live games,
which triggers the existing odds_live strategy (2 min TTL).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(base-odds): Use interval as TTL for cache operations

- Pass interval variable as TTL to cache_manager.set() calls
- Ensures cache expires after update interval, preventing stale data
- Removes dead code by actually using the computed interval value

* refactor(base-odds): Remove is_live parameter from base class for modularity

- Remove is_live parameter from get_odds() method signature
- Remove cache key modification logic from base class
- Remove is_live handling from get_odds_for_games()
- Keep base class minimal and generic for reuse by other plugins
- Plugin-specific is_live logic moved to odds-ticker plugin override

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-23 17:21:57 -05:00
14b6a0c6a3 fix(web): handle dotted keys in schema/config path helpers (#260)
* fix(web): handle dotted keys in schema/config path helpers

Schema property names containing dots (e.g. "eng.1" for Premier League
in soccer-scoreboard) were being incorrectly split on the dot separator
in two path-navigation helpers:

- _get_schema_property: split "leagues.eng.1.favorite_teams" into 4
  segments and looked for "eng" in leagues.properties, which doesn't
  exist (the key is literally "eng.1"). Returned None, so the field
  type was unknown and values were not parsed correctly.

- _set_nested_value: split the same path into 4 segments and created
  config["leagues"]["eng"]["1"]["favorite_teams"] instead of the
  correct config["leagues"]["eng.1"]["favorite_teams"].

Both functions now use a greedy longest-match approach: at each level
they try progressively longer dot-joined candidates first (e.g. "eng.1"
before "eng"), so dotted property names are handled transparently.

Fixes favorite_teams (and other per-league fields) not saving via the
soccer-scoreboard plugin config UI.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore: remove debug artifacts from merged branches

- Replace print() with logger.warning() for three error handlers in api_v3.py
  that bypassed the structured logging infrastructure
- Simplify dead if/else in loadInstalledPlugins() — both branches did the
  same window.installedPlugins assignment; collapse to single line
- Remove console.log registration line from schedule-picker widget that
  fired unconditionally on every page load

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 17:21:19 -05:00
ChuckandGitHub c2763d6447 Update Waveshare display information in README (#259)
Signed-off-by: Chuck <33324927+ChuckBuilds@users.noreply.github.com>
2026-02-23 16:38:49 -05:00
1f0de9b354 fix(starlark): fix Python 3.13 importlib.reload() incompatibility (#258)
* fix(starlark): fix Python 3.13 importlib.reload() incompatibility

In Python 3.13, importlib.reload() raises ModuleNotFoundError for modules
loaded via spec_from_file_location when they aren't on sys.path, because
_bootstrap._find_spec() can no longer resolve them by name.

Replace the reload-on-cache-hit pattern in _get_tronbyte_repository_class()
and _get_pixlet_renderer_class() with a simple return of the cached class —
the reload was only useful for dev-time iteration and is unnecessary in
production (the service restarts clean on each deploy).

Also broaden the exception catch in upload_starlark_app() from
(ValueError, OSError, IOError) to Exception so that any unexpected error
(ImportError, ModuleNotFoundError, etc.) returns a proper JSON response
instead of an unhandled Flask 500.

Fixes: "Install failed: spec not found for the module 'tronbyte_repository'"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(starlark): use targeted exception handlers in upload_starlark_app()

Replace the broad `except Exception` catch-all with specific handlers:
- (OSError, IOError) for temp file creation/save failures
- ImportError for module loading failures (_get_pixlet_renderer_class)
- Exception as final catch-all that logs without leaking internals

All handlers use `err` (not unused `e`) in both the log message and
the JSON response body.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 16:37:48 -05:00
ed90654bf2 fix(cache): move odds key check before live/scoreboard in get_data_type_from_key (#256)
* fix(cache): move odds key check before live/scoreboard check in get_data_type_from_key

Cache keys like odds_espn_nba_game_123_live contain 'live', so they were
matched by the generic ['live', 'current', 'scoreboard'] branch (sports_live,
30s TTL) before the 'odds' branch was ever reached. This caused live odds
to expire every 30 seconds instead of every 120 seconds, hitting the ESPN
odds API 4x more often than intended and risking rate-limiting.

Fix: move the 'odds' check above the 'live'/'current'/'scoreboard' check
so the more-specific prefix wins. No regressions: pure live_*/scoreboard_*
keys (without 'odds') still route to sports_live.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(cache): remove dead soccer branch in get_data_type_from_key

The inner `if 'soccer' in key_lower: return 'sports_live'` branch was
dead code — both the soccer and non-soccer paths returned the same
'sports_live' value. Collapse to a single return statement.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 11:54:34 -05:00
302235a357 feat: Starlark Apps Integration with Schema-Driven Config + Security Hardening (#253)
* feat: integrate Starlark/Tronbyte app support into plugin system

Add starlark-apps plugin that renders Tidbyt/Tronbyte .star apps via
Pixlet binary and integrates them into the existing Plugin Manager UI
as virtual plugins. Includes vegas scroll support, Tronbyte repository
browsing, and per-app configuration.

- Extract working starlark plugin code from starlark branch onto fresh main
- Fix plugin conventions (get_logger, VegasDisplayMode, BasePlugin)
- Add 13 starlark API endpoints to api_v3.py (CRUD, browse, install, render)
- Virtual plugin entries (starlark:<app_id>) in installed plugins list
- Starlark-aware toggle and config routing in pages_v3.py
- Tronbyte repository browser section in Plugin Store UI
- Pixlet binary download script (scripts/download_pixlet.sh)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(starlark): use bare imports instead of relative imports

Plugin loader uses spec_from_file_location without package context,
so relative imports (.pixlet_renderer) fail. Use bare imports like
all other plugins do.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(starlark): make API endpoints work standalone in web service

The web service runs as a separate process with display_manager=None,
so plugins aren't instantiated. Refactor starlark API endpoints to
read/write the manifest file directly when the plugin isn't loaded,
enabling full CRUD operations from the web UI.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(starlark): make config partial work standalone in web service

Read starlark app data from manifest file directly when the plugin
isn't loaded, matching the api_v3.py standalone pattern.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(starlark): always show editable timing settings in config panel

Render interval and display duration are now always editable in the
starlark app config panel, not just shown as read-only status text.
App-specific settings from schema still appear below when present.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(store): add sort, filter, search, and pagination to Plugin Store and Starlark Apps

Plugin Store:
- Live search with 300ms debounce (replaces Search button)
- Sort dropdown: A→Z, Z→A, Category, Author, Newest
- Installed toggle filter (All / Installed / Not Installed)
- Per-page selector (12/24/48) with pagination controls
- "Installed" badge and "Reinstall" button on already-installed plugins
- Active filter count badge + clear filters button

Starlark Apps:
- Parallel bulk manifest fetching via ThreadPoolExecutor (20 workers)
- Server-side 2-hour cache for all 500+ Tronbyte app manifests
- Auto-loads all apps when section expands (no Browse button)
- Live search, sort (A→Z, Z→A, Category, Author), author dropdown
- Installed toggle filter, per-page selector (24/48/96), pagination
- "Installed" badge on cards, "Reinstall" button variant

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(store): move storeFilterState to global scope to fix scoping bug

storeFilterState, pluginStoreCache, and related variables were declared
inside an IIFE but referenced by top-level functions, causing
ReferenceError that broke all plugin loading.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(starlark): schema-driven config forms + critical security fixes

## Schema-Driven Config UI
- Render type-appropriate form inputs from schema.json (text, dropdown, toggle, color, datetime, location)
- Pre-populate config.json with schema defaults on install
- Auto-merge schema defaults when loading existing apps (handles schema updates)
- Location fields: 3-part mini-form (lat/lng/timezone) assembles into JSON
- Toggle fields: support both boolean and string "true"/"false" values
- Unsupported field types (oauth2, photo_select) show warning banners
- Fallback to raw key/value inputs for apps without schema

## Critical Security Fixes (P0)
- **Path Traversal**: Verify path safety BEFORE mkdir to prevent TOCTOU
- **Race Conditions**: Add file locking (fcntl) + atomic writes to manifest operations
- **Command Injection**: Validate config keys/values with regex before passing to Pixlet subprocess

## Major Logic Fixes (P1)
- **Config/Manifest Separation**: Store timing keys (render_interval, display_duration) ONLY in manifest
- **Location Validation**: Validate lat [-90,90] and lng [-180,180] ranges, reject malformed JSON
- **Schema Defaults Merge**: Auto-apply new schema defaults to existing app configs on load
- **Config Key Validation**: Enforce alphanumeric+underscore format, prevent prototype pollution

## Files Changed
- web_interface/templates/v3/partials/starlark_config.html — schema-driven form rendering
- plugin-repos/starlark-apps/manager.py — file locking, path safety, config validation, schema merge
- plugin-repos/starlark-apps/pixlet_renderer.py — config value sanitization
- web_interface/blueprints/api_v3.py — timing key separation, safe manifest updates

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* fix(starlark): use manifest filename field for .star downloads

Tronbyte apps don't always name their .star file to match the directory.
For example, the "analogclock" app has "analog_clock.star" (with underscore).

The manifest.yaml contains a "filename" field with the correct name.

Changes:
- download_star_file() now accepts optional filename parameter
- Install endpoint passes metadata['filename'] to download_star_file()
- Falls back to {app_id}.star if filename not in manifest

Fixes: "Failed to download .star file for analogclock" error

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* fix(starlark): reload tronbyte_repository module to pick up code changes

The web service caches imported modules in sys.modules. When deploying
code updates, the old cached version was still being used.

Now uses importlib.reload() when module is already loaded.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* fix(starlark): use correct 'fileName' field from manifest (camelCase)

The Tronbyte manifest uses 'fileName' (camelCase), not 'filename' (lowercase).
This caused the download to fall back to {app_id}.star which doesn't exist
for apps like analogclock (which has analog_clock.star).

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* feat(starlark): extract schema during standalone install

The standalone install function (_install_star_file) wasn't extracting
schema from .star files, so apps installed via the web service had no
schema.json and the config panel couldn't render schema-driven forms.

Now uses PixletRenderer to extract schema during standalone install,
same as the plugin does.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* feat(starlark): implement source code parser for schema extraction

Pixlet CLI doesn't support schema extraction (--print-schema flag doesn't exist),
so apps were being installed without schemas even when they have them.

Implemented regex-based .star file parser that:
- Extracts get_schema() function from source code
- Parses schema.Schema(version, fields) structure
- Handles variable-referenced dropdown options (e.g., options = dialectOptions)
- Supports Location, Text, Toggle, Dropdown, Color, DateTime fields
- Gracefully handles unsupported fields (OAuth2, LocationBased, etc.)
- Returns formatted JSON matching web UI template expectations

Coverage: 90%+ of Tronbyte apps (static schemas + variable references)

Changes:
- Replace extract_schema() to parse .star files directly instead of using Pixlet CLI
- Add 6 helper methods for parsing schema structure
- Handle nested parentheses and brackets properly
- Resolve variable references for dropdown options

Tested with:
- analog_clock.star (Location field) ✓
- Multi-field test (Text + Dropdown + Toggle) ✓
- Variable-referenced options ✓

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* fix(starlark): add List to typing imports for schema parser

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* fix(starlark): load schema from schema.json in standalone mode

The standalone API endpoint was returning schema: null because it didn't
load the schema.json file. Now reads schema from disk when returning
app details via web service.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* feat(starlark): implement schema extraction, asset download, and config persistence

## Schema Extraction
- Replace broken `pixlet serve --print-schema` with regex-based source parser
- Extract schema by parsing `get_schema()` function from .star files
- Support all field types: Location, Text, Toggle, Dropdown, Color, DateTime
- Handle variable-referenced dropdown options (e.g., `options = teamOptions`)
- Gracefully handle complex/unsupported field types (OAuth2, PhotoSelect, etc.)
- Extract schema for 90%+ of Tronbyte apps

## Asset Download
- Add `download_app_assets()` to fetch images/, sources/, fonts/ directories
- Download assets in binary mode for proper image/font handling
- Validate all paths to prevent directory traversal attacks
- Copy asset directories during app installation
- Enable apps like AnalogClock that require image assets

## Config Persistence
- Create config.json file during installation with schema defaults
- Update both config.json and manifest when saving configuration
- Load config from config.json (not manifest) for consistency with plugin
- Separate timing keys (render_interval, display_duration) from app config
- Fix standalone web service mode to read/write config.json

## Pixlet Command Fix
- Fix Pixlet CLI invocation: config params are positional, not flags
- Change from `pixlet render file.star -c key=value` to `pixlet render file.star key=value -o output`
- Properly handle JSON config values (e.g., location objects)
- Enable config to be applied during rendering

## Security & Reliability
- Add threading.Lock for cache operations to prevent race conditions
- Reduce ThreadPoolExecutor workers from 20 to 5 for Raspberry Pi
- Add path traversal validation in download_star_file()
- Add YAML error logging in manifest fetching
- Add file size validation (5MB limit) for .star uploads
- Use sanitized app_id consistently in install endpoints
- Use atomic manifest updates to prevent race conditions
- Add missing Optional import for type hints

## Web UI
- Fix standalone mode schema loading in config partial
- Schema-driven config forms now render correctly for all apps
- Location fields show lat/lng/timezone inputs
- Dropdown, toggle, text, color, and datetime fields all supported

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* fix(starlark): code review fixes - security, robustness, and schema parsing

## Security Fixes
- manager.py: Check _update_manifest_safe return values to prevent silent failures
- manager.py: Improve temp file cleanup in _save_manifest to prevent leaks
- manager.py: Fix uninstall order (manifest → memory → disk) for consistency
- api_v3.py: Add path traversal validation in uninstall endpoint
- api_v3.py: Implement atomic writes for manifest files with temp + rename
- pixlet_renderer.py: Relax config validation to only block dangerous shell metacharacters

## Frontend Robustness
- plugins_manager.js: Add safeLocalStorage wrapper for restricted contexts (private browsing)
- starlark_config.html: Scope querySelector to container to prevent modal conflicts

## Schema Parsing Improvements
- pixlet_renderer.py: Indentation-aware get_schema() extraction (handles nested functions)
- pixlet_renderer.py: Handle quoted defaults with commas (e.g., "New York, NY")
- tronbyte_repository.py: Validate file_name is string before path traversal checks

## Dependencies
- requirements.txt: Update Pillow (10.4.0), PyYAML (6.0.2), requests (2.32.0)

## Documentation
- docs/STARLARK_APPS_GUIDE.md: Comprehensive guide explaining:
  - How Starlark apps work
  - That apps come from Tronbyte (not LEDMatrix)
  - Installation, configuration, troubleshooting
  - Links to upstream projects

All changes improve security, reliability, and user experience.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* fix(starlark): convert Path to str in spec_from_file_location calls

The module import helpers were passing Path objects directly to
spec_from_file_location(), which caused spec to be None. This broke
the Starlark app store browser.

- Convert module_path to string in both _get_tronbyte_repository_class
  and _get_pixlet_renderer_class
- Add None checks with clear error messages for debugging

Fixes: spec not found for the module 'tronbyte_repository'

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* fix(starlark): restore Starlark Apps section in plugins.html

The Starlark Apps UI section was lost during merge conflict resolution
with main branch. Restored from commit 942663ab which had the complete
implementation with filtering, sorting, and pagination.

Fixes: Starlark section not visible on plugin manager page

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* fix(starlark): restore Starlark JS functionality lost in merge

During the merge with main, all Starlark-specific JavaScript (104 lines)
was removed from plugins_manager.js, including:
- starlarkFilterState and filtering logic
- loadStarlarkApps() function
- Starlark app install/uninstall handlers
- Starlark section collapse/expand logic
- Pagination and sorting for Starlark apps

Restored from commit 942663ab and re-applied safeLocalStorage wrapper
from our code review fixes.

Fixes: Starlark Apps section non-functional in web UI

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* fix(starlark): security and race condition improvements

Security fixes:
- Add path traversal validation for output_path in download_star_file
- Remove XSS-vulnerable inline onclick handlers, use delegated events
- Add type hints to helper functions for better type safety

Race condition fixes:
- Lock manifest file BEFORE creating temp file in _save_manifest
- Hold exclusive lock for entire read-modify-write cycle in _update_manifest_safe
- Prevent concurrent writers from racing on manifest updates

Other improvements:
- Fix pages_v3.py standalone mode to load config.json from disk
- Improve error handling with proper logging in cleanup blocks
- Add explicit type annotations to Starlark helper functions

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* fix(starlark): critical bug fixes and code quality improvements

Critical fixes:
- Fix stack overflow in safeLocalStorage (was recursively calling itself)
- Fix duplicate event listeners on Starlark grid (added sentinel check)
- Fix JSON validation to fail fast on malformed data instead of silently passing

Error handling improvements:
- Narrow exception catches to specific types (OSError, json.JSONDecodeError, ValueError)
- Use logger.exception() with exc_info=True for better stack traces
- Replace generic "except Exception" with specific exception types

Logging improvements:
- Add "[Starlark Pixlet]" context tags to pixlet_renderer logs
- Redact sensitive config values from debug logs (API keys, etc.)
- Add file_path context to schema parsing warnings

Documentation:
- Fix markdown lint issues (add language tags to code blocks)
- Fix time unit spacing: "(5min)" -> "(5 min)"

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* fix(starlark): critical path traversal and exception handling fixes

Path traversal security fixes (CRITICAL):
- Add _validate_starlark_app_path() helper to check for path traversal attacks
- Validate app_id in get_starlark_app(), uninstall_starlark_app(),
  get_starlark_app_config(), and update_starlark_app_config()
- Check for '..' and path separators before any filesystem access
- Verify resolved paths are within _STARLARK_APPS_DIR using Path.relative_to()
- Prevents unauthorized file access via crafted app_id like '../../../etc/passwd'

Exception handling improvements (tronbyte_repository.py):
- Replace broad "except Exception" with specific types
- _make_request: catch requests.Timeout, requests.RequestException, json.JSONDecodeError
- _fetch_raw_file: catch requests.Timeout, requests.RequestException separately
- download_app_assets: narrow to OSError, ValueError
- Add "[Tronbyte Repo]" context prefix to all log messages
- Use exc_info=True for better stack traces

API improvements:
- Narrow exception catches to OSError, json.JSONDecodeError in config loading
- Remove duplicate path traversal checks (now centralized in helper)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* fix(starlark): logging improvements and code quality fixes

Logging improvements (pages_v3.py):
- Add logging import and create module logger
- Replace print() calls with logger.warning() with "[Pages V3]" prefix
- Use logger.exception() for outer try/catch with exc_info=True
- Narrow exception handling to OSError, json.JSONDecodeError for file operations

API improvements (api_v3.py):
- Remove unnecessary f-strings (Ruff F541) from ImportError messages
- Narrow upload exception handling to ValueError, OSError, IOError
- Use logger.exception() with context for better debugging
- Remove early return in get_starlark_status() to allow standalone mode fallback
- Sanitize error messages returned to client (don't expose internal details)

Benefits:
- Better log context with consistent prefixes
- More specific exception handling prevents masking unexpected errors
- Standalone/web-service-only mode now works for status endpoint
- Stack traces preserved for debugging without exposing to clients

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 19:44:12 -05:00
636d0e181c feat(plugins): add sorting, filtering, and fix Update All button (#252)
* feat(store): add sorting, filtering, and fix Update All button

Add client-side sorting and filtering to the Plugin Store:
- Sort by A-Z, Z-A, Verified First, Recently Updated, Category
- Filter by verified, new, installed status, author, and tags
- Installed/Update Available badges on store cards
- Active filter count badge with clear-all button
- Sort preference persisted to localStorage

Fix three bugs causing button unresponsiveness:
- pluginsInitialized never reset on HTMX tab navigation (root cause
  of Update All silently doing nothing on second visit)
- htmx:afterSwap condition too broad (fired on unrelated swaps)
- data-running guard tied to DOM element replaced by cloneNode

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor(store): replace tag pills with category pills, fix sort dates

- Replace tag filter pills with category filter pills (less duplication)
- Prefer per-plugin last_updated over repo-wide pushed_at for sort

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* debug: add console logging to filter/sort handlers

* fix: bump cache-buster versions for JS and CSS

* feat(plugins): add sorting to installed plugins section

Add A-Z, Z-A, and Enabled First sort options for installed plugins
with localStorage persistence. Both installed and store sections
now default to A-Z sorting.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(store): consolidate CSS, fix stale cache bug, add missing utilities, fix icon

- Consolidate .filter-pill and .category-filter-pill into shared selectors
  and scope transition to only changed properties
- Fix applyStoreFiltersAndSort ignoring fresh server-filtered results by
  accepting optional basePlugins parameter
- Add missing .py-1.5 and .rounded-full CSS utility classes
- Replace invalid fa-sparkles with fa-star (FA 6.0.0 compatible)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(store): semver-aware update badge and add missing gap-1.5 utility

- Replace naive version !== comparison with isNewerVersion() that does
  semver greater-than check, preventing false "Update" badges on
  same-version or downgrade scenarios
- Add missing .gap-1.5 CSS utility used by category pills and tag lists

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 07:38:16 -05:00
963c4d3b91 fix(web): use window.installedPlugins for bulk update button (#250)
The previous fix (#249) wired window.updateAllPlugins to
PluginInstallManager.updateAll(), but that method reads from
PluginStateManager.installedPlugins which is never populated on
page load — only after individual install/update operations.

Meanwhile, base.html already defined a working updateAllPlugins
using window.installedPlugins (reliably populated by plugins_manager.js).
The override from install_manager.js masked this working version.

Fix: revert install_manager.js changes and rewrite runUpdateAllPlugins
to iterate window.installedPlugins directly, calling the API endpoint
without any middleman. Adds per-plugin progress in button text and
a summary notification on completion.

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 15:28:51 -05:00
22c495ea7c perf(store): cache GitHub API calls and eliminate redundant requests (#251)
The plugin store was making excessive GitHub API calls causing slow
page loads (10-30s):

- Installed plugins endpoint called get_plugin_info() per plugin (3
  GitHub API calls each) just to read the `verified` field from the
  registry. Use new get_registry_info() instead (zero API calls).
- _get_latest_commit_info() had no cache — all 31 monorepo plugins
  share the same repo URL, causing 31 identical API calls. Add 5-min
  cache keyed by repo:branch.
- _fetch_manifest_from_github() also uncached — add 5-min cache.
- load_config() called inside loop per-plugin — hoist outside loop.
- Install/update operations pass force_refresh=True to bypass caches
  and always get the latest commit SHA from GitHub.

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 14:46:31 -05:00
5b0ad5ab71 fix(web): wire up "Check & Update All" plugins button (#249)
window.updateAllPlugins was never assigned, so the button always showed
"Bulk update handler unavailable." Wire it to PluginInstallManager.updateAll(),
add per-plugin progress feedback in the button text, show a summary
notification on completion, and skip redundant plugin list reloads.

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 13:06:18 -05:00
bc8568604a feat(web): add LED RGB sequence, multiplexing, and panel type settings (#248)
* feat(web): add LED RGB sequence, multiplexing, and panel type settings

Expose three rpi-rgb-led-matrix hardware options in the Display Settings
UI so users can configure non-standard panels without editing config.json
manually. All defaults match existing behavior (RGB, Direct, Standard).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(api): validate led_rgb_sequence, multiplexing, and panel_type inputs

Reject invalid values with 400 errors before writing to config: whitelist
check for led_rgb_sequence and panel_type, range + type check for multiplexing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 18:16:21 -05:00
878f339fb3 fix(logos): support logo downloads for custom soccer leagues (#247)
* fix(logos): support logo downloads for custom soccer leagues

LogoDownloader.fetch_teams_data() and fetch_single_team() only had
hardcoded API endpoints for predefined soccer leagues. Custom leagues
(e.g., por.1, mex.1) would silently fail when the ESPN game data
didn't include a direct logo URL. Now dynamically constructs the ESPN
teams API URL for any soccer_* league not in the predefined map.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(logos): address PR review — directory, bulk download, and dedup

- get_logo_directory: custom soccer leagues now resolve to shared
  assets/sports/soccer_logos/ instead of creating per-league dirs
- download_all_missing_logos: use _resolve_api_url so custom soccer
  leagues are no longer silently skipped
- Extract _resolve_api_url helper to deduplicate dynamic URL
  construction between fetch_teams_data and fetch_single_team

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 17:43:05 -05:00
51616f1bc4 fix(web): dark mode for collapsible config section headers (#246)
* fix(web): add dark mode overrides for collapsible config section headers

The collapsible section headers in plugin config schemas used bg-gray-100
and hover:bg-gray-200 which had no dark mode overrides, resulting in light
text on a light background when dark mode was active.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(web): add missing bg-gray-100 light-mode utility class

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 15:50:34 -05:00
82370a0253 Fix log viewer readability — add missing CSS utility classes (#244)
* fix(web): add missing utility classes for log viewer readability

The log viewer uses text-gray-100, text-gray-200, text-gray-300,
text-red-300, text-yellow-300, bg-gray-800, bg-red-900, bg-yellow-900,
border-gray-700, and hover:bg-gray-800 — none of which were defined in
app.css. Without definitions, log text inherited the body's dark color
(#111827) which was invisible against the dark bg-gray-900 log container
in light mode.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(web): remove dead bg-opacity classes, use proper log level colors

The bg-opacity-10/bg-opacity-30 classes set a --bg-opacity CSS variable
that no background-color rule consumed, making them dead code. Replace
the broken two-class pattern (e.g. "bg-red-900 bg-opacity-10") with
dedicated log-level-error/warning/debug classes that use rgb() with
actual alpha values.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 22:14:20 -05:00
3975940cff Add light/dark mode toggle and fix log readability (#243)
* feat(web): add light/dark mode toggle and fix log readability

Add a theme toggle button (moon/sun icon) to the header that switches
between light and dark mode. Theme preference persists in localStorage
and falls back to the OS prefers-color-scheme setting.

The implementation uses a data-theme attribute on <html> with CSS
overrides, so all 13 partial templates and 20+ widget JS files get
dark mode support without any modifications — only 3 files changed.

Also fixes log timestamp readability: text-gray-500 had ~3.5:1 contrast
ratio against the dark log background, now uses text-gray-400 (~5.3:1)
which passes WCAG AA in both light and dark mode.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(web): address dark mode review — accessibility, robustness, and code quality

- WCAG touch target: enforce 44×44px minimum on theme toggle button
  with display:inline-flex centering
- Accessibility: add type="button", aria-pressed (dynamically updated),
  aria-hidden on decorative icons, and contextual aria-label/title that
  reflects current state ("Switch to light/dark mode")
- Robustness: wrap all localStorage and matchMedia calls in try/catch
  with fallbacks for private browsing and restricted contexts; use
  addListener fallback for older browsers lacking addEventListener
- Stylelint: convert all rgba() to modern rgb(…/…%) notation across
  both light and dark theme shadows and gradients
- DRY: replace hardcoded hex values in dark mode utility overrides and
  component overrides with CSS variable references (--color-surface,
  --color-background, --color-border, --color-text-primary, etc.)
- Remove redundant [data-theme="dark"] body rule (body already uses
  CSS variables that are redefined under the dark theme selector)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 21:12:37 -05:00
158e07c82b fix(plugins): prevent root-owned files from blocking plugin updates (#242)
* fix(web): unify operation history tracking for monorepo plugin operations

The operation history UI was reading from the wrong data source
(operation_queue instead of operation_history), install/update records
lacked version details, toggle operations used a type name that didn't
match UI filters, and the Clear History button was non-functional.

- Switch GET /plugins/operation/history to read from OperationHistory
  audit log with return type hint and targeted exception handling
- Add DELETE /plugins/operation/history endpoint; wire up Clear button
- Add _get_plugin_version helper with specific exception handling
  (FileNotFoundError, PermissionError, json.JSONDecodeError) and
  structured logging with plugin_id/path context
- Record plugin version, branch, and commit details on install/update
- Record install failures in the direct (non-queue) code path
- Replace "toggle" operation type with "enable"/"disable"
- Add normalizeStatus() in JS to map completed→success, error→failed
  so status filter works regardless of server-side convention
- Truncate commit SHAs to 7 chars in details display
- Fix HTML filter options, operation type colors, duplicate JS init

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(plugins): prevent root-owned files from blocking plugin updates

The root ledmatrix service creates __pycache__ and data cache files
owned by root inside plugin directories. The web service (non-root)
cannot delete these when updating or uninstalling plugins, causing
operations to fail with "Permission denied".

Defense in depth with three layers:
- Prevent: PYTHONDONTWRITEBYTECODE=1 in systemd service + run.py
- Fallback: sudoers rules for rm on plugin directories
- Code: _safe_remove_directory() now uses sudo as last resort,
  and all bare shutil.rmtree() calls routed through it

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(security): harden sudo removal with path-validated helper script

Address code review findings:

- Replace raw rm/find sudoers wildcards with a vetted helper script
  (safe_plugin_rm.sh) that resolves symlinks and validates the target
  is a strict child of plugin-repos/ or plugins/ before deletion
- Add allow-list validation in sudo_remove_directory() that checks
  resolved paths against allowed bases before invoking sudo
- Check _safe_remove_directory() return value before shutil.move()
  in the manifest ID rename path
- Move stat import to module level in store_manager.py
- Use stat.S_IRWXU instead of 0o777 in chmod fallback stage
- Add ignore_errors=True to temp dir cleanup in finally block
- Use command -v instead of which in configure_web_sudo.sh

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(security): address code review round 2 — harden paths and error handling

- safe_plugin_rm.sh: use realpath --canonicalize-missing for ALLOWED_BASES
  so the script doesn't fail under set -e when dirs don't exist yet
- safe_plugin_rm.sh: add -- before path in rm -rf to prevent flag injection
- permission_utils.py: use shutil.which('bash') instead of hardcoded /bin/bash
  to match whatever path the sudoers BASH_PATH resolves to
- store_manager.py: check _safe_remove_directory() return before shutil.move()
  in _install_from_monorepo_zip to prevent moving into a non-removed target
- store_manager.py: catch OSError instead of PermissionError in Stage 1 removal
  to handle both EACCES and EPERM error codes
- store_manager.py: hoist sudo_remove_directory import to module level
- configure_web_sudo.sh: harden safe_plugin_rm.sh to root-owned 755 so
  the web user cannot modify the vetted helper script

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(security): validate command paths in sudoers config and use resolved paths

- configure_web_sudo.sh: validate that required commands (systemctl, bash,
  python3) resolve to non-empty paths before generating sudoers entries;
  abort with clear error if any are missing; skip optional commands
  (reboot, poweroff, journalctl) with a warning instead of emitting
  malformed NOPASSWD lines; validate helper script exists on disk
- permission_utils.py: pass the already-resolved path to the subprocess
  call and use it for the post-removal exists() check, eliminating a
  TOCTOU window between Python-side validation and shell-side execution

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 19:28:05 -05:00
9a72adbde1 fix(web): unify operation history tracking for monorepo plugin operations (#240)
The operation history UI was reading from the wrong data source
(operation_queue instead of operation_history), install/update records
lacked version details, toggle operations used a type name that didn't
match UI filters, and the Clear History button was non-functional.

- Switch GET /plugins/operation/history to read from OperationHistory
  audit log with return type hint and targeted exception handling
- Add DELETE /plugins/operation/history endpoint; wire up Clear button
- Add _get_plugin_version helper with specific exception handling
  (FileNotFoundError, PermissionError, json.JSONDecodeError) and
  structured logging with plugin_id/path context
- Record plugin version, branch, and commit details on install/update
- Record install failures in the direct (non-queue) code path
- Replace "toggle" operation type with "enable"/"disable"
- Add normalizeStatus() in JS to map completed→success, error→failed
  so status filter works regardless of server-side convention
- Truncate commit SHAs to 7 chars in details display
- Fix HTML filter options, operation type colors, duplicate JS init

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 12:11:12 -05:00
9d3bc55c18 fix: post-merge monorepo hardening and cleanup (#239)
* fix: address PR review nitpicks for monorepo hardening

- Add docstring note about regex limitation in parse_json_with_trailing_commas
- Abort on zip-slip in ZIP installer instead of skipping (consistent with API installer)
- Use _safe_remove_directory for non-git plugin reinstall path
- Use segment-wise encodeURIComponent for View button URL encoding

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: check _safe_remove_directory result before reinstalling plugin

Avoid calling install_plugin into a partially-removed directory by
checking the boolean return of _safe_remove_directory, mirroring the
guard already used in the git-remote migration path.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: normalize subpath prefix and add zip-slip guard to download installer

- Strip trailing slashes from plugin_subpath before building the tree
  filter prefix, preventing double-slash ("subpath//") that would cause
  file_entries to silently miss all matches.
- Add zip-slip protection to _install_via_download (extractall path),
  matching the guard already present in _install_from_monorepo_zip.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 11:59:23 -05:00
df3cf9bb56 Feat/monorepo migration (#238)
* feat: adapt LEDMatrix for monorepo plugin architecture

Update store_manager to fetch manifests from subdirectories within the
monorepo (plugin_path/manifest.json) instead of repo root. Remove 21
plugin submodule entries from .gitmodules, simplify workspace file to
reference the monorepo, and clean up scripts for the new layout.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: auto-reinstall plugins when registry repo URL changes

When a user clicks "Update" on a git-cloned plugin, detect if the
local git remote URL no longer matches the registry's repo URL (e.g.
after monorepo migration). Instead of pulling from the stale archived
repo, automatically remove and reinstall from the new registry source.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: plugin store "View" button links to correct monorepo subdirectory

When a plugin has a plugin_path (monorepo plugin), construct the GitHub
URL as repo/tree/main/plugin_path so users land on the specific plugin
directory. Pass plugin_path through the store API response to the
frontend.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: monorepo manifest fetch in search + version-based update detection

Fix search_plugins() to pass plugin_path when fetching manifests from
GitHub, matching the fix already in get_plugin_info(). Without this,
monorepo plugin descriptions 404 in search results.

Add version comparison for non-git plugins (monorepo installs) so
"Update All" skips plugins already at latest_version instead of blindly
reinstalling every time.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: show plugin version instead of misleading monorepo commit info

Replace commit hash, date, and stars on plugin cards with the plugin's
version number. In a monorepo all plugins share the same commit history
and star count, making those fields identical and misleading. Version
is the meaningful per-plugin signal users care about.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: add CLAUDE.md with project structure and plugin store docs

Documents plugin store architecture, monorepo install flow, version-
based update detection, and the critical version bump workflow.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* perf: extract only target plugin from monorepo ZIP instead of all files

Previously _install_from_monorepo() called extractall() on the entire
monorepo ZIP (~13MB, 600+ files) just to grab one plugin subdirectory.
Now filter zip members by the plugin prefix and extract only matching
files, reducing disk I/O by ~96% per install/update.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* perf: download only target plugin files via GitHub Trees API

Replace full monorepo ZIP download (~5MB) with targeted file downloads
(~200KB per plugin) using the GitHub Git Trees API for directory listing
and raw.githubusercontent.com for individual file content.

One API call fetches the repo tree, client filters for the target
plugin's files, then downloads each file individually. Falls back to
ZIP if the API is unavailable (rate limited, no network, etc.).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: clean up partial files between API and ZIP install fallbacks

Ensure target_path is fully removed before the ZIP fallback runs, and
before shutil.move() in the ZIP method. Prevents directory nesting if
the API method creates target_path then fails mid-download.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: harden scripts and fix monorepo URL handling

- setup_plugin_repos.py: add type hints, remove unnecessary f-string,
  wrap manifest parsing in try/except to skip malformed manifests
- update_plugin_repos.py: add 120s timeout to git pull with
  TimeoutExpired handling
- store_manager.py: fix rstrip('.zip') stripping valid branch chars,
  use removesuffix('.zip'); remove redundant import json
- plugins_manager.js: View button uses dynamic branch, disables when
  repo is missing, encodes plugin_path in URL
- CLAUDE.md: document plugin repo naming convention

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: harden monorepo install security and cleanup

- store_manager: fix temp dir leak in _install_from_monorepo_zip by
  moving cleanup to finally block
- store_manager: add zip-slip guard validating extracted paths stay
  inside temp directory
- store_manager: add 500-file sanity cap to API-based install
- store_manager: extract _normalize_repo_url as @staticmethod
- setup_plugin_repos: propagate create_symlinks() failure via sys.exit,
  narrow except to OSError

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: add path traversal guard to API-based monorepo installer

Validate that each file's resolved destination stays inside
target_path before creating directories or writing bytes, mirroring
the zip-slip guard in _install_from_monorepo_zip.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: use _safe_remove_directory for monorepo migration cleanup

Replace shutil.rmtree(ignore_errors=True) with _safe_remove_directory
which handles permission errors gracefully and returns status, preventing
install_plugin from running against a partially-removed directory.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 18:57:30 -05:00
448a15c1e6 feat(fonts): add dynamic font selection and font manager improvements (#232)
* feat(fonts): add dynamic font selection and font manager improvements

- Add font-selector widget for dynamic font selection in plugin configs
- Enhance /api/v3/fonts/catalog with filename, display_name, and type
- Add /api/v3/fonts/preview endpoint for server-side font rendering
- Add /api/v3/fonts/<family> DELETE endpoint with system font protection
- Fix /api/v3/fonts/upload to actually save uploaded font files
- Update font manager tab with dynamic dropdowns, server-side preview, and font deletion
- Add new BDF fonts: 6x10, 6x12, 6x13, 7x13, 7x14, 8x13, 9x15, 9x18, 10x20 (with bold/oblique variants)
- Add tom-thumb, helvR12, clR6x12, texgyre-27 fonts

Plugin authors can use x-widget: "font-selector" in schemas to enable
dynamic font selection that automatically shows all available fonts.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(fonts): security fixes and code quality improvements

- Fix README.md typos and add language tags to code fences
- Remove duplicate delete_font function causing Flask endpoint collision
- Add safe integer parsing for size parameter in preview endpoint
- Fix path traversal vulnerability in /fonts/preview endpoint
- Fix path traversal vulnerability in /fonts/<family> DELETE endpoint
- Fix XSS vulnerability in fonts.html by using DOM APIs instead of innerHTML
- Move baseUrl to shared scope to fix ReferenceError in multiple functions

Security improvements:
- Validate font filenames reject path separators and '..'
- Validate paths are within fonts_dir before file operations
- Use textContent and data attributes instead of inline onclick handlers
- Restrict file extensions to known font types

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(fonts): address code issues and XSS vulnerabilities

- Move `import re` to module level, remove inline imports
- Remove duplicate font_file assignment in upload_font()
- Remove redundant validation with inconsistent allowed extensions
- Remove redundant PathLib import, use already-imported Path
- Fix XSS vulnerabilities in fonts.html by using DOM APIs instead of
  innerHTML with template literals for user-controlled data

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(fonts): add size limits to font preview endpoint

Add input validation to prevent DoS via large image generation:
- MAX_TEXT_CHARS (100): Limit text input length
- MAX_TEXT_LINES (3): Limit number of newlines
- MAX_DIM (1024): Limit max width/height
- MAX_PIXELS (500000): Limit total pixel count

Validates text early before processing and checks computed
dimensions after bbox calculation but before image allocation.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(fonts): improve error handling, catalog keys, and BDF preview

- Add structured logging for cache invalidation failures instead of
  silent pass (FontUpload, FontDelete, FontCatalog contexts)
- Use filename as unique catalog key to prevent collisions when
  multiple font files share the same family_name from metadata
- Return explicit error for BDF font preview instead of showing
  misleading preview with default font

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(fonts): address nitpick issues in font management

Frontend (fonts.html):
- Remove unused escapeHtml function (dead code)
- Add max-attempts guard (50 retries) to initialization loop
- Add response.ok checks before JSON parsing in deleteFont,
  addFontOverride, deleteFontOverride, uploadSelectedFonts
- Use is_system flag from API instead of hardcoded client-side list

Backend (api_v3.py):
- Move SYSTEM_FONTS to module-level frozenset for single source of truth
- Add is_system flag to font catalog entries
- Simplify delete_font system font check using frozenset lookup

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(fonts): align frontend upload validation with backend

- Add .otf to accepted file extensions (HTML accept attribute, JS filter)
- Update validation regex to allow hyphens (matching backend)
- Preserve hyphens in auto-generated font family names
- Update UI text to reflect all supported formats

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(fonts): fix lint errors and missing variable

- Remove unused exception binding in set_cached except block
- Define font_family_lower before case-insensitive fallback loop
- Add response.ok check to font preview fetch (consistent with other handlers)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(fonts): address nitpick code quality issues

- Add return type hints to get_font_preview and delete_font endpoints
- Catch specific PIL exceptions (IOError/OSError) when loading fonts
- Replace innerHTML with DOM APIs for trash icon (consistency)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(fonts): remove unused exception bindings in cache-clearing blocks

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-11 18:21:27 -05:00
b99be88cec fix(plugins): namespace-isolate modules for safe parallel loading (#237)
* fix(plugins): prevent KeyError race condition in module cleanup

When multiple plugins have modules with the same name (e.g.,
background_data_service.py), the _clear_conflicting_modules function
could raise a KeyError if a module was removed between iteration
and deletion. This race condition caused plugin loading failures
with errors like: "Unexpected error loading plugin: 'background_data_service'"

Changes:
- Use sys.modules.pop(mod_name, None) instead of del sys.modules[mod_name]
  to safely handle already-removed modules
- Apply same fix to plugin unload in plugin_manager.py for consistency
- Fix typo in sports.py: rankself._team_rankings_cacheings ->
  self._team_rankings_cache

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(plugins): namespace-isolate plugin modules to prevent parallel loading collisions

Multiple sport plugins share identically-named Python files (scroll_display.py,
game_renderer.py, sports.py, etc.). When loaded in parallel via ThreadPoolExecutor,
bare module names collide in sys.modules causing KeyError crashes.

Replace _clear_conflicting_modules with _namespace_plugin_modules: after exec_module
loads a plugin, its bare-name sub-modules are moved to namespaced keys
(e.g. _plg_basketball_scoreboard_scroll_display) so they cannot collide.
A threading lock serializes the exec_module window where bare names temporarily exist.

Also updates unload_plugin to clean up namespaced sub-modules from sys.modules.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(plugins): address review feedback on namespace isolation

- Fix main module accidentally renamed: move before_keys snapshot to
  after sys.modules[module_name] insertion so the main entry is excluded
  from namespace renaming and error cleanup
- Use Path.is_relative_to() instead of substring matching for plugin
  directory containment checks to avoid false-matches on overlapping
  directory names
- Add try/except around exec_module to clean up partially-initialized
  modules on failure, preventing leaked bare-name entries
- Add public unregister_plugin_modules() method on PluginLoader so
  PluginManager doesn't reach into private attributes during unload
- Update stale comment referencing removed _clear_conflicting_modules

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(plugins): remove unused plugin_dir_str variable

Leftover from the old substring containment check, now replaced by
Path.is_relative_to().

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(plugins): extract shared helper for bare-module filtering

Hoist plugin_dir.resolve() out of loops and deduplicate the bare-module
filtering logic between _namespace_plugin_modules and the error cleanup
block into _iter_plugin_bare_modules().

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(plugins): keep bare-name alias to prevent lazy import duplication

Stop removing bare module names from sys.modules after namespacing.
Removing them caused lazy intra-plugin imports (deferred imports inside
methods) to re-import from disk, creating a second inconsistent module
copy. Keeping both the bare and namespaced entries pointing to the same
object avoids this. The next plugin's exec_module naturally overwrites
the bare entry with its own version.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-10 22:47:24 -05:00
4a9fc2df3a feat(web): add shutdown button to Quick Actions (#234)
Add a "Shutdown System" button to the Overview page that gracefully
powers off the Raspberry Pi. Uses sudo poweroff, consistent with the
existing reboot_system action, letting sudo's secure_path handle
binary resolution.

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-08 12:35:37 -05:00
d207e7c6dd feat(config): add led_rgb_sequence option to config template (#231)
Add the led_rgb_sequence configuration option to the matrix config template,
allowing users to specify the RGB sequence for their LED panels.

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 13:12:47 -05:00
7e98fa9bd8 fix(web): handle string booleans and mode variants in schedule-picker widget (#228)
* fix(web): handle string boolean values in schedule-picker widget

The normalizeSchedule function used strict equality (===) to check the
enabled field, which would fail if the config value was a string "true"
instead of boolean true. This could cause the checkbox to always appear
unchecked even when the setting was enabled.

Added coerceToBoolean helper that properly handles:
- Boolean true/false (returns as-is)
- String "true", "1", "on" (case-insensitive) → true
- String "false" or other values → false

Applied to both main schedule enabled and per-day enabled fields.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: trim whitespace in coerceToBoolean string handling

* fix: normalize mode value to handle per_day and per-day variants

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 16:24:16 -05:00
0d5510d8f7 Fix/plugin module namespace collision (#229)
* fix(web): handle string boolean values in schedule-picker widget

The normalizeSchedule function used strict equality (===) to check the
enabled field, which would fail if the config value was a string "true"
instead of boolean true. This could cause the checkbox to always appear
unchecked even when the setting was enabled.

Added coerceToBoolean helper that properly handles:
- Boolean true/false (returns as-is)
- String "true", "1", "on" (case-insensitive) → true
- String "false" or other values → false

Applied to both main schedule enabled and per-day enabled fields.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: trim whitespace in coerceToBoolean string handling

* fix: normalize mode value to handle per_day and per-day variants

* fix(plugins): resolve module namespace collisions between plugins

When multiple plugins have modules with the same name (e.g., data_fetcher.py),
Python's sys.modules cache would return the wrong module. This caused plugins
like ledmatrix-stocks to fail loading because it imported data_fetcher from
ledmatrix-leaderboard instead of its own.

Added _clear_conflicting_modules() to remove cached plugin modules from
sys.modules before loading each plugin, ensuring correct module resolution.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 16:24:06 -05:00
18fecd3cda fix(web): handle string boolean values in schedule-picker widget (#227)
* fix(web): handle string boolean values in schedule-picker widget

The normalizeSchedule function used strict equality (===) to check the
enabled field, which would fail if the config value was a string "true"
instead of boolean true. This could cause the checkbox to always appear
unchecked even when the setting was enabled.

Added coerceToBoolean helper that properly handles:
- Boolean true/false (returns as-is)
- String "true", "1", "on" (case-insensitive) → true
- String "false" or other values → false

Applied to both main schedule enabled and per-day enabled fields.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: trim whitespace in coerceToBoolean string handling

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 14:57:57 -05:00
1c3269c0f3 Fix/led panel permissions 224 (#226)
* fix(install): exclude rpi-rgb-led-matrix from permission normalization

The permission normalization step in first_time_install.sh was running
chmod 644 on all files, which stripped executable bits from compiled
library files (librgbmatrix.so.1) after make build-python created them.

This caused LED panels to not work after fresh installation until users
manually ran chmod on the rpi-rgb-led-matrix-master directory.

Fixes #224

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(install): resolve install script issues and speed up web UI startup

Issues addressed:
- Remove redundant python3-pillow from apt (Debian maps it to python3-pil)
- Only upgrade pip, not setuptools/wheel (they conflict with apt versions)
- Remove separate apt numpy install (pip handles it from requirements.txt)
- Install web interface deps during first-time setup, not on every startup
- Add marker file (.web_deps_installed) to skip redundant pip installs
- Add user-friendly message about wait time after installation

The web UI was taking 30-60+ seconds to start because it ran pip install
on every startup. Now it only installs dependencies on first run.

Fixes #208

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(install): prevent duplicate web dependency installation

Step 7 was installing web dependencies again even though they were
already installed in Step 5. Now Step 7 checks for the .web_deps_installed
marker file and skips the installation if it already exists.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(install): only create web deps marker on successful install

The .web_deps_installed marker file should only be created when pip
install actually succeeds. Previously it was created regardless of
the pip exit status, which could cause subsequent runs to skip
installing missing dependencies.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(install): create config files before starting services

The services were being started before config files existed, causing
the web service to fail with "config.json not found". Reordered steps
so config files are created (Step 4) before services are installed
and started (Step 4.1).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(install): remove pip upgrade step (apt version is sufficient)

The apt-installed pip cannot be upgraded because it doesn't have a
RECORD file. Since the apt version (25.1.1) is already recent enough,
we can skip the upgrade step entirely.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(install): reorder script to install services after dependencies

Moved main LED Matrix service installation (Step 7.5) to after all
Python dependencies are installed (Steps 5-7). Previously services
were being started before pip packages and rgbmatrix were ready,
causing startup failures.

New order:
- Step 5: Python pip dependencies
- Step 6: rpi-rgb-led-matrix build
- Step 7: Web interface dependencies
- Step 7.5: Main LED Matrix service (moved here)
- Step 8: Web interface service

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(install): update step list and fix setcap symlink handling

- Updated step list header to match actual step order after reordering
  (Step 4 is now "Ensure configuration files exist", added Step 7.5
  for main service, added Step 8.1 for systemd permissions)

- Fixed Python capabilities configuration:
  - Check if setcap command exists before attempting to use it
  - Resolve symlinks with readlink -f to get the real binary path
  - Only print success message when setcap actually succeeds
  - Print clear warning with helpful info when setcap fails

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 12:49:07 -05:00
ea61331d46 fix(install): resolve install issues and speed up web UI startup (#225)
* fix(install): exclude rpi-rgb-led-matrix from permission normalization

The permission normalization step in first_time_install.sh was running
chmod 644 on all files, which stripped executable bits from compiled
library files (librgbmatrix.so.1) after make build-python created them.

This caused LED panels to not work after fresh installation until users
manually ran chmod on the rpi-rgb-led-matrix-master directory.

Fixes #224

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(install): resolve install script issues and speed up web UI startup

Issues addressed:
- Remove redundant python3-pillow from apt (Debian maps it to python3-pil)
- Only upgrade pip, not setuptools/wheel (they conflict with apt versions)
- Remove separate apt numpy install (pip handles it from requirements.txt)
- Install web interface deps during first-time setup, not on every startup
- Add marker file (.web_deps_installed) to skip redundant pip installs
- Add user-friendly message about wait time after installation

The web UI was taking 30-60+ seconds to start because it ran pip install
on every startup. Now it only installs dependencies on first run.

Fixes #208

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(install): prevent duplicate web dependency installation

Step 7 was installing web dependencies again even though they were
already installed in Step 5. Now Step 7 checks for the .web_deps_installed
marker file and skips the installation if it already exists.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(install): only create web deps marker on successful install

The .web_deps_installed marker file should only be created when pip
install actually succeeds. Previously it was created regardless of
the pip exit status, which could cause subsequent runs to skip
installing missing dependencies.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 12:08:57 -05:00
8fb2800495 feat: add error detection, monitoring, and code quality improvements (#223)
* feat: add error detection, monitoring, and code quality improvements

This comprehensive update addresses automatic error detection, code
quality, and plugin development experience:

## Error Detection & Monitoring
- Add ErrorAggregator service for centralized error tracking
- Add pattern detection for recurring errors (5+ in 60 min)
- Add error dashboard API endpoints (/api/v3/errors/*)
- Integrate error recording into plugin executor

## Code Quality
- Remove 10 silent `except: pass` blocks in sports.py and football.py
- Remove hardcoded debug log paths
- Add pre-commit hooks to prevent future bare except clauses

## Validation & Type Safety
- Add warnings when plugins lack config_schema.json
- Add config key collision detection for plugins
- Improve type coercion logging in BasePlugin

## Testing
- Add test_config_validation_edge_cases.py
- Add test_plugin_loading_failures.py
- Add test_error_aggregator.py

## Documentation
- Add PLUGIN_ERROR_HANDLING.md guide
- Add CONFIG_DEBUGGING.md guide

Note: GitHub Actions CI workflow is available in the plan but requires
workflow scope to push. Add .github/workflows/ci.yml manually.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: address code review issues

- Fix GitHub issues URL in CONFIG_DEBUGGING.md
- Use RLock in error_aggregator.py to prevent deadlock in export_to_file
- Distinguish missing vs invalid schema files in plugin_manager.py
- Add assertions to test_null_value_for_required_field test
- Remove unused initial_count variable in test_plugin_load_error_recorded
- Add validation for max_age_hours in clear_old_errors API endpoint

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 10:05:09 -05:00
8912501604 fix(web): ensure unchecked checkboxes save as false in main config forms (#222)
* fix: remove plugin-specific calendar duration from config template

Plugin display durations should be added dynamically when plugins are
installed, not hardcoded in the template.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(web): ensure unchecked checkboxes save as false in main config forms

HTML checkboxes omit their key entirely when unchecked, so the backend
never received updates to set boolean values to false. This affected:

- vegas_scroll_enabled: Now uses _coerce_to_bool helper
- use_short_date_format: Now uses _coerce_to_bool helper
- Plugin system checkboxes (auto_discover, auto_load_enabled, development_mode):
  Now uses _coerce_to_bool helper
- Hardware checkboxes (disable_hardware_pulsing, inverse_colors, show_refresh_rate):
  Now uses _coerce_to_bool helper
- web_display_autostart: Now uses _coerce_to_bool helper

Added _coerce_to_bool() helper function that properly converts form string
values ("true", "on", "1", "yes") to actual Python booleans, ensuring
consistent JSON types in config and correct downstream boolean checks.

Also added value="true" to all main config checkboxes for consistent boolean
parsing (sends "true" instead of "on" when checked).

This is the same issue fixed in commit 10d70d91 for plugin configs, but
for the main configuration forms (display settings, general settings).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 21:49:29 -05:00
68c4259370 fix: reduce scroll catch-up steps to limit jitter (#219)
Reduce max_steps from 0.1s to 0.04s of catch-up time (from 5 to 2 steps
at 50 FPS). When the system lags, the previous catch-up logic allowed
jumping up to 5 pixels at once, causing visible jitter. Limiting to 2
steps provides smoother scrolling while still allowing for minor timing
corrections.

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 20:03:17 -05:00
7f5c7399fb fix: remove plugin-specific calendar duration from config template (#221)
Plugin display durations should be added dynamically when plugins are
installed, not hardcoded in the template.

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 19:58:42 -05:00
14c50f316e feat: add timezone support for schedules and dim schedule feature (#218)
* feat: add timezone support for schedules and dim schedule feature

- Fix timezone handling in _check_schedule() to use configured timezone
  instead of system time (addresses schedule offset issues)
- Add dim schedule feature for automatic brightness dimming:
  - New dim_schedule config section with brightness level and time windows
  - Smart interaction: dim schedule won't turn display on if it's off
  - Supports both global and per-day modes like on/off schedule
- Add set_brightness() and get_brightness() methods to DisplayManager
  for runtime brightness control
- Add REST API endpoints: GET/POST /api/v3/config/dim-schedule
- Add web UI for dim schedule configuration in schedule settings page

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: normalize per-day mode and validate dim_brightness input

- Normalize mode string in _check_dim_schedule to handle both "per-day"
  and "per_day" variants
- Add try/except around dim_brightness int conversion to handle invalid
  input gracefully

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor: improve error handling in brightness and dim schedule endpoints

- display_manager.py: Add fail-fast input validation, catch specific
  exceptions (AttributeError, TypeError, ValueError), add [BRIGHTNESS]
  context tags, include stack traces in error logs
- api_v3.py: Catch specific config exceptions (FileNotFoundError,
  JSONDecodeError, IOError), add [DIM SCHEDULE] context tags for
  Pi debugging, include stack traces

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 18:12:45 -05:00
ddd300a117 Docs/consolidate documentation (#217)
* docs: rename FONT_MANAGER_USAGE.md to FONT_MANAGER.md

Renamed for clearer naming convention.
Part of documentation consolidation effort.

* docs: consolidate Plugin Store guides (2→1)

Merged:
- PLUGIN_STORE_USER_GUIDE.md
- PLUGIN_STORE_QUICK_REFERENCE.md

Into: PLUGIN_STORE_GUIDE.md

- Unified writing style to professional technical
- Added Quick Reference section at top for easy access
- Removed duplicate content
- Added cross-references to related documentation
- Updated formatting to match style guidelines

* docs: create user-focused Web Interface Guide

Created WEB_INTERFACE_GUIDE.md consolidating:
- V3_INTERFACE_README.md (technical details)
- User-facing interface documentation

- Focused on end-user tasks and navigation
- Removed technical implementation details
- Added common tasks section
- Included troubleshooting
- Professional technical writing style

* docs: consolidate WiFi setup guides (4→1)

Merged:
- WIFI_SETUP.md
- OPTIMAL_WIFI_AP_FAILOVER_SETUP.md
- AP_MODE_MANUAL_ENABLE.md
- WIFI_ETHERNET_AP_MODE_FIX.md (behavior documentation)

Into: WIFI_NETWORK_SETUP.md

- Comprehensive coverage of WiFi setup and configuration
- Clear explanation of AP mode failover and grace period
- Configuration scenarios and best practices
- Troubleshooting section combining all sources
- Professional technical writing style
- Added quick reference table for behavior

* docs: consolidate troubleshooting guides (4→1)

Merged:
- TROUBLESHOOTING_QUICK_START.md
- WEB_INTERFACE_TROUBLESHOOTING.md
- CAPTIVE_PORTAL_TROUBLESHOOTING.md
- WEATHER_TROUBLESHOOTING.md

Into: TROUBLESHOOTING.md

- Organized by issue category (web, WiFi, plugins)
- Comprehensive diagnostic commands reference
- Quick diagnosis steps at the top
- Service file template preserved
- Complete diagnostic script included
- Professional technical writing style

* docs: create consolidated Advanced Features guide

Merged:
- VEGAS_SCROLL_MODE.md
- ON_DEMAND_DISPLAY_QUICK_START.md
- ON_DEMAND_DISPLAY_API.md
- ON_DEMAND_CACHE_MANAGEMENT.md
- BACKGROUND_SERVICE_README.md
- PERMISSION_MANAGEMENT_GUIDE.md

Into: ADVANCED_FEATURES.md

- Comprehensive guide covering all advanced features
- Vegas scroll mode with integration examples
- On-demand display with API reference
- Cache management troubleshooting
- Background service documentation
- Permission management patterns
- Professional technical writing style

* docs: create Getting Started guide for first-time users

Created GETTING_STARTED.md:
- Quick start guide (5 minutes)
- Initial configuration walkthrough
- Common first-time issues and solutions
- Next steps and quick reference
- User-friendly tone for beginners
- Links to detailed documentation

* docs: archive consolidated source files and ephemeral docs

Archived files that have been consolidated:
- Plugin Store guides (2 files → PLUGIN_STORE_GUIDE.md)
- Web Interface guide (V3_INTERFACE_README.md → WEB_INTERFACE_GUIDE.md)
- WiFi Setup guides (4 files → WIFI_NETWORK_SETUP.md)
- Troubleshooting guides (4 files → TROUBLESHOOTING.md)
- Advanced Features (6 files → ADVANCED_FEATURES.md)

Archived ephemeral/debug documentation:
- DEBUG_WEB_ISSUE.md
- BROWSER_ERRORS_EXPLANATION.md
- FORM_VALIDATION_FIXES.md
- WEB_UI_RELIABILITY_IMPROVEMENTS.md
- CAPTIVE_PORTAL_TESTING.md
- NEXT_STEPS_COMMANDS.md
- STATIC_IMAGE_MULTI_UPLOAD_PLAN.md
- RECONNECT_AFTER_CAPTIVE_PORTAL_TESTING.md

Archived implementation summaries:
- PLUGIN_CONFIG_TABS_SUMMARY.md
- PLUGIN_CONFIG_SYSTEM_VERIFICATION.md
- PLUGIN_SCHEMA_AUDIT_SUMMARY.md
- STARTUP_OPTIMIZATION_SUMMARY.md
- PLUGIN_DISPATCH_IMPLEMENTATION.md
- NESTED_SCHEMA_IMPLEMENTATION.md
- AP_MODE_MANUAL_ENABLE_CHANGES.md
- PLUGIN_CONFIG_SYSTEM_EXPLANATION.md

Total archived: 27 files
Preserves git history while cleaning up main docs directory

* docs: rename API_REFERENCE.md to REST_API_REFERENCE.md

Renamed for clarity - this is specifically the REST API reference
for the web interface, not a general API reference.

* docs: update README.md to reflect consolidated documentation structure

Updated documentation index:
- Reflects new consolidated guides (51 → 16-17 files)
- Updated Quick Start sections with new file names
- Added consolidation history (January 2026)
- Updated file references (API_REFERENCE → REST_API_REFERENCE)
- Documented archival of 33 files
- Added benefits of consolidation
- Updated statistics and highlights
- Removed outdated references
- Professional writing style maintained throughout

* docs: add Vegas scroll mode system architecture documentation

Added comprehensive internal architecture section for Vegas mode:
- Component overview with diagram
- VegasModeCoordinator responsibilities and main loop
- StreamManager buffering strategy and content flow
- PluginAdapter integration and fallback behavior
- RenderPipeline 125 FPS rendering process
- Component interaction flows
- Thread safety patterns
- Performance characteristics

Covers:
- How the four components work together
- Initialization and render loop flows
- Config update handling
- Frame rate management and optimization
- Memory usage and CPU characteristics

---------

Co-authored-by: Chuck <chuck@example.com>
2026-01-29 10:32:00 -05:00
7524747e44 Feature/vegas scroll mode (#215)
* feat(display): add Vegas-style continuous scroll mode

Implement an opt-in Vegas ticker mode that composes all enabled plugin
content into a single continuous horizontal scroll. Includes a modular
package (src/vegas_mode/) with double-buffered streaming, 125 FPS
render pipeline using the existing ScrollHelper, live priority
interruption support, and a web UI for configuration with drag-drop
plugin ordering.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat(vegas): add three-mode display system (SCROLL, FIXED_SEGMENT, STATIC)

Adds a flexible display mode system for Vegas scroll mode that allows
plugins to control how their content appears in the continuous scroll:

- SCROLL: Content scrolls continuously (multi-item plugins like sports)
- FIXED_SEGMENT: Fixed block that scrolls by (clock, weather)
- STATIC: Scroll pauses, plugin displays, then resumes (alerts)

Changes:
- Add VegasDisplayMode enum to base_plugin.py with backward-compatible
  mapping from legacy get_vegas_content_type()
- Add static pause handling to coordinator with scroll position save/restore
- Add mode-aware content composition to stream_manager
- Add vegas_mode info to /api/v3/plugins/installed endpoint
- Add mode indicators to Vegas settings UI
- Add comprehensive plugin developer documentation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(vegas,widgets): address validation, thread safety, and XSS issues

Vegas mode fixes:
- config.py: align validation limits with UI (scroll_speed max 200, separator_width max 128)
- coordinator.py: fix race condition by properly initializing _pending_config
- plugin_adapter.py: remove unused import
- render_pipeline.py: preserve deque type in reset() method
- stream_manager.py: fix lock handling and swap_buffers to truly swap

API fixes:
- api_v3.py: normalize boolean checkbox values, validate numeric fields, ensure JSON arrays

Widget fixes:
- day-selector.js: remove escapeHtml from JSON.stringify to prevent corruption
- password-input.js: use deterministic color class mapping for Tailwind JIT
- radio-group.js: replace inline onchange with addEventListener to prevent XSS
- select-dropdown.js: guard global registry access
- slider.js: add escapeAttr for attributes, fix null dereference in setValue

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(vegas): improve exception handling and static pause state management

coordinator.py:
- _check_live_priority: use logger.exception for full traceback
- _end_static_pause: guard scroll resume on interruption (stop/live priority)
- _update_static_mode_plugins: log errors instead of silently swallowing

render_pipeline.py:
- compose_scroll_content: use specific exceptions and logger.exception
- render_frame: use specific exceptions and logger.exception
- hot_swap_content: use specific exceptions and logger.exception

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(vegas): add interrupt mechanism and improve config/exception handling

- Add interrupt checker callback to Vegas coordinator for responsive
  handling of on-demand requests and wifi status during Vegas mode
- Fix config.py update() to include dynamic duration fields
- Fix is_plugin_included() consistency with get_ordered_plugins()
- Update _apply_pending_config to propagate config to StreamManager
- Change _fetch_plugin_content to use logger.exception for traceback
- Replace bare except in _refresh_plugin_list with specific exceptions
- Add aria-label accessibility to Vegas toggle checkbox
- Fix XSS vulnerability in plugin metadata rendering with escapeHtml

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(vegas): improve logging, validation, lock handling, and config updates

- display_controller.py: use logger.exception for Vegas errors with traceback
- base_plugin.py: validate vegas_panel_count as positive integer with warning
- coordinator.py: fix _apply_pending_config to avoid losing concurrent updates
  by clearing _pending_config while holding lock
- plugin_adapter.py: remove broad catch-all, use narrower exception types
  (AttributeError, TypeError, ValueError, OSError, RuntimeError) and
  logger.exception for traceback preservation
- api_v3.py: only update vegas_config['enabled'] when key is present in data
  to prevent incorrect disabling when checkbox is omitted

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(vegas): improve cycle advancement, logging, and accessibility

- Add advance_cycle() method to StreamManager for clearing buffer between cycles
- Call advance_cycle() in RenderPipeline.start_new_cycle() for fresh content
- Use logger.exception() for interrupt check and static pause errors (full tracebacks)
- Add id="vegas_scroll_label" to h3 for aria-labelledby reference
- Call updatePluginConfig() after rendering plugin list for proper initialization

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(vegas): add thread-safety, preserve updates, and improve logging

- display_controller.py: Use logger.exception() for Vegas import errors
- plugin_adapter.py: Add thread-safe cache lock, remove unused exception binding
- stream_manager.py: In-place merge in process_updates() preserves non-updated plugins
- api_v3.py: Change vegas_scroll_enabled default from False to True

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(vegas): add debug logging and narrow exception types

- stream_manager.py: Log when get_vegas_display_mode() is unavailable
- stream_manager.py: Narrow exception type from Exception to (AttributeError, TypeError)
- api_v3.py: Log exceptions when reading Vegas display metadata with plugin context

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(vegas): fix method call and improve exception logging

- Fix _check_vegas_interrupt() calling nonexistent _check_wifi_status(),
  now correctly calls _check_wifi_status_message()
- Update _refresh_plugin_list() exception handler to use logger.exception()
  with plugin_id and class name for remote debugging

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(web): replace complex toggle with standard checkbox for Vegas mode

The Tailwind pseudo-element toggle (after:content-[''], etc.) wasn't
rendering because these classes weren't in the CSS bundle. Replaced
with a simple checkbox that matches other form controls in the template.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* debug(vegas): add detailed logging to _refresh_plugin_list

Track why plugins aren't being found for Vegas scroll:
- Log count of loaded plugins
- Log enabled status for each plugin
- Log content_type and display_mode checks
- Log when plugin_manager lacks loaded_plugins

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(vegas): use correct attribute name for plugin manager

StreamManager and VegasModeCoordinator were checking for
plugin_manager.loaded_plugins but PluginManager stores active
plugins in plugin_manager.plugins. This caused Vegas scroll
to find zero plugins despite plugins being available.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(vegas): convert scroll_speed from px/sec to px/frame correctly

The config scroll_speed is in pixels per second, but ScrollHelper
in frame_based_scrolling mode interprets it as pixels per frame.
Previously this caused the speed to be clamped to max 5.0 regardless
of the configured value.

Now properly converts: pixels_per_frame = scroll_speed * scroll_delay

With defaults (50 px/s, 0.02s delay), this gives 1 px/frame = 50 px/s.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat(vegas): add FPS logging every 5 seconds

Logs actual FPS vs target FPS to help diagnose performance issues.
Shows frame count in each 5-second interval.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(vegas): improve plugin content capture reliability

- Call update_data() before capture to ensure fresh plugin data
- Try display() without force_clear first, fallback if TypeError
- Retry capture with force_clear=True if first attempt is blank
- Use histogram-based blank detection instead of point sampling
  (more reliable for content positioned anywhere in frame)

This should help capture content from plugins that don't implement
get_vegas_content() natively.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(vegas): handle callable width/height on display_manager

DisplayManager.width and .height may be methods or properties depending
on the implementation. Use callable() check to call them if needed,
ensuring display_width and display_height are always integers.

Fixes potential TypeError when width/height are methods.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(vegas): use logger.exception for display mode errors

Replace logger.error with logger.exception to capture full stack trace
when get_vegas_display_mode() fails on a plugin.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(vegas): protect plugin list updates with buffer lock

Move assignment of _ordered_plugins and index resets under _buffer_lock
to prevent race conditions with _prefetch_content() which reads these
variables under the same lock.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(vegas): catch all exceptions in get_vegas_display_mode

Broaden exception handling from AttributeError/TypeError to Exception
so any plugin error in get_vegas_display_mode() doesn't abort the
entire plugin list refresh. The loop continues with the default
FIXED_SEGMENT mode.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(vegas): refresh stream manager when config updates

After updating stream_manager.config, force a refresh to pick up changes
to plugin_order, excluded_plugins, and buffer_ahead settings. Also use
logger.exception to capture full stack traces on config update errors.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* debug(vegas): add detailed logging for blank image detection

* feat(vegas): extract full scroll content from plugins using ScrollHelper

Plugins like ledmatrix-stocks and odds-ticker use ScrollHelper with a
cached_image that contains their full scrolling content. Instead of
falling back to single-frame capture, now check for scroll_helper.cached_image
first to get the complete scrolling content for Vegas mode.

* debug(vegas): add comprehensive INFO-level logging for plugin content flow

- Log each plugin being processed with class name
- Log which content methods are tried (native, scroll_helper, fallback)
- Log success/failure of each method with image dimensions
- Log brightness check results for blank image detection
- Add visual separators in logs for easier debugging
- Log plugin list refresh with enabled/excluded status

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat(vegas): trigger scroll content generation when cache is empty

When a plugin has a scroll_helper but its cached_image is not yet
populated, try to trigger content generation by:
1. Calling _create_scrolling_display() if available (stocks pattern)
2. Calling display(force_clear=True) as a fallback

This allows plugins like stocks to provide their full scroll content
even when Vegas mode starts before the plugin has run its normal
display cycle.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: improve exception handling in plugin_adapter scroll content retrieval

Replace broad except Exception handlers with narrow exception types
(AttributeError, TypeError, ValueError, OSError) and use logger.exception
instead of logger.warning/info to capture full stack traces for better
diagnosability.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: narrow exception handling in coordinator and plugin_adapter

- coordinator.py: Replace broad Exception catch around get_vegas_display_mode()
  with (AttributeError, TypeError) and use logger.exception for stack traces
- plugin_adapter.py: Narrow update_data() exception handler to
  (AttributeError, RuntimeError, OSError) and use logger.exception

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: improve Vegas mode robustness and API validation

- display_controller: Guard against None plugin_manager in Vegas init
- coordinator: Restore scrolling state in resume() to match pause()
- api_v3: Validate Vegas numeric fields with range checks and 400 errors

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 10:23:56 -05:00
10d70d911a Fix unchecked boolean checkboxes not saving as false (#216)
* fix(web): ensure unchecked boolean checkboxes save as false

HTML checkboxes don't submit values when unchecked. The plugin config
save endpoint starts from existing config (for partial updates), so an
unchecked checkbox's old `true` value persists. Additionally,
merge_with_defaults fills in schema defaults for missing fields, causing
booleans with `"default": true` to always re-enable.

This affected the odds-ticker plugin where NFL/NBA leagues (default:
true) could not be disabled via the checkbox UI, while NHL (default:
false) appeared to work by coincidence.

Changes:
- Add _set_missing_booleans_to_false() that walks the schema after form
  processing and sets any boolean field absent from form data to false
- Add value="true" to boolean checkboxes so checked state sends "true"
  instead of "on" (proper boolean parsing)
- Handle "on"/"off" strings in _parse_form_value_with_schema for
  backwards compatibility with checkboxes lacking value="true"

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(web): guard on/off coercion to boolean schema types, handle arrays

- Only coerce "on"/"off" strings to booleans when the schema type is
  boolean; "true"/"false" remain unconditional
- Extend _set_missing_booleans_to_false to recurse into arrays of
  objects (e.g. custom_feeds.0.enabled) by discovering item indices
  from submitted form keys and recursing per-index

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(web): preserve array structures when setting missing booleans

_set_nested_value uses dict-style access for all path segments, which
corrupts lists when paths contain numeric array indices (e.g.
"feeds.custom_feeds.0.enabled").

Refactored _set_missing_booleans_to_false to:
- Accept an optional config_node parameter for direct array item access
- When inside an array item, set booleans directly on the item dict
- Navigate to array lists manually, preserving their list type
- Ensure array items exist as dicts before recursing

This prevents array-of-object configs (like custom_feeds) from being
converted to nested dicts with numeric string keys.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 09:15:38 -05:00
a8c85dd015 feat(widgets): add modular widget system for schedule and common inputs (#213)
* feat(widgets): add modular widget system for schedule and common inputs

Add 15 new reusable widgets following the widget registry pattern:
- schedule-picker: composite widget for enable/mode/time configuration
- day-selector: checkbox group for days of the week
- time-range: paired start/end time inputs with validation
- text-input, number-input, textarea: enhanced text inputs
- toggle-switch, radio-group, select-dropdown: selection widgets
- slider, color-picker, date-picker: specialized inputs
- email-input, url-input, password-input: validated string inputs

Refactor schedule.html to use the new schedule-picker widget instead
of inline JavaScript. Add x-widget support in plugin_config.html for
all new widgets so plugins can use them via schema configuration.

Fix form submission for checkboxes by using hidden input pattern to
ensure unchecked state is properly sent via JSON-encoded forms.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(widgets): improve security, validation, and form binding across widgets

- Fix XSS vulnerability: escapeHtml now escapes quotes in all widget fallbacks
- color-picker: validate presets with isValidHex(), use data attributes
- date-picker: add placeholder attribute support
- day-selector: use options.name for hidden input form binding
- password-input: implement requireUppercase/Number/Special validation
- radio-group: fix value injection using this.value instead of interpolation
- schedule-picker: preserve day values when disabling (don't clear times)
- select-dropdown: remove undocumented searchable/icons options
- text-input: apply patternMessage via setCustomValidity
- time-range: use options.name for hidden inputs
- toggle-switch: preserve configured color from data attribute
- url-input: combine browser and custom protocol validation
- plugin_config: add widget support for boolean/number types, pass name to day-selector
- schedule: handle null config gracefully, preserve explicit mode setting

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(widgets): validate day-selector input, consistent minLength default, escape JSON quotes

- day-selector: filter incoming selectedDays to only valid entries in DAYS array
  (prevents invalid persisted values from corrupting UI/state)
- password-input: use default minLength of 8 when not explicitly set
  (fixes inconsistency between render() and onInput() strength meter baseline)
- plugin_config.html: escape single quotes in JSON hidden input values
  (prevents broken attributes when JSON contains single quotes)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat(widgets): add global notification widget, consolidate duplicated code

- Create notification.js widget with toast-style notifications
- Support for success, error, warning, info types
- Auto-dismiss with configurable duration
- Stacking support with max notifications limit
- Accessible with aria-live and role="alert"
- Update base.html to load notification widget early
- Replace duplicate showNotification in raw_json.html
- Simplify fonts.html fallback notification
- Net reduction of ~66 lines of duplicated code

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(widgets): escape options.name in all widgets, validate day-selector format

Security fixes:
- Escape options.name attribute in all 13 widgets to prevent injection
- Affected: color-picker, date-picker, email-input, number-input,
  password-input, radio-group, select-dropdown, slider, text-input,
  textarea, toggle-switch, url-input

Defensive coding:
- day-selector: validate format option exists in DAY_LABELS before use
- Falls back to 'long' format for unsupported/invalid format values

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(plugins): add type="button" to control buttons, add debug logging

- Add type="button" attribute to refresh, update-all, and restart buttons
  to prevent potential form submission behavior
- Add console logging to diagnose button click issues:
  - Log when event listeners are attached (and whether buttons found)
  - Log when handler functions are called

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(widgets): improve security and validation across widget inputs

- color-picker.js: Add sanitizeHex() to validate hex values before HTML
  interpolation, ensuring only safe #rrggbb strings are used
- day-selector.js: Escape inputName in hidden input name attribute
- number-input.js: Sanitize and escape currentValue in input element
- password-input.js: Validate minLength as non-negative integer, clamp
  invalid values to default of 8
- slider.js: Add null check for input element before accessing value
- text-input.js: Clear custom validity before checkValidity() to avoid
  stale errors, re-check after setting pattern message
- url-input.js: Normalize allowedProtocols to array, filter to valid
  protocol strings, and escape before HTML interpolation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(widgets): add defensive fallback for DAY_LABELS lookup in day-selector

Extract labelMap with fallback before loop to ensure safe access even if
format validation somehow fails.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat(widgets): add timezone-selector widget with IANA timezone dropdown

- Create timezone-selector.js widget with comprehensive IANA timezone list
- Group timezones by region (US & Canada, Europe, Asia, etc.)
- Show current UTC offset for each timezone
- Display live time preview for selected timezone
- Update general.html to use timezone-selector instead of text input
- Add script tag to base.html for widget loading

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(ui): suppress on-demand status notification on page load

Change loadOnDemandStatus(true) to loadOnDemandStatus(false) during
initPluginsPage() to prevent the "on-demand status refreshed"
notification from appearing every time a tab is opened or the page
is navigated. The notification should only appear on explicit user
refresh.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* style(ui): soften notification close button appearance

Replace blocky FontAwesome X icon with a cleaner SVG that has rounded
stroke caps. Make the button circular, slightly transparent by default,
and add smooth hover transitions for a more polished look.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(widgets): multiple security and validation improvements

- color-picker.js: Ensure presets is always an array before map/filter
- number-input.js: Guard against undefined options parameter
- number-input.js: Sanitize and escape min/max/step HTML attributes
- text-input.js: Clear custom validity in onInput to unblock form submit
- timezone-selector.js: Replace legacy Europe/Belfast with Europe/London
- url-input.js: Use RFC 3986 scheme pattern for protocol validation
- general.html: Use |tojson filter to escape timezone value safely

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor(url-input): centralize RFC 3986 protocol validation

Extract protocol normalization into reusable normalizeProtocols()
helper function that validates against RFC 3986 scheme pattern.
Apply consistently in render, validate, and onInput to ensure
protocols like "git+ssh", "android-app" are properly handled
everywhere. Also lowercase protocol comparison in isValidUrl().

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(timezone-selector): use hidden input for form submission

Replace direct select name attribute with a hidden input pattern to
ensure timezone value is always properly serialized in form submissions.
The hidden input is synced on change and setValue calls. This matches
the pattern used by other widgets and ensures HTMX json-enc properly
captures the value.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(general): preserve timezone dropdown value after save

Add inline script to sync the timezone select with the hidden input
value after form submission. This prevents the dropdown from visually
resetting to the old value while the save has actually succeeded.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(widgets): preserve timezone selection across form submission

Use before-request handler to capture the selected timezone value
before HTMX processes the form, then restore it in after-request.
This is more robust than reading from the hidden input which may
also be affected by form state changes.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(widgets): add HTMX protection to timezone selector

Add global HTMX event listeners in the timezone-selector widget
that preserve the selected value across any form submissions.
This is more robust than form-specific handlers as it protects
the widget regardless of how/where forms are submitted.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* debug(widgets): add logging and prevent timezone widget re-init

Add debug logging and guards to prevent the timezone widget from
being re-initialized after it's already rendered. This should help
diagnose why the dropdown is reverting after save.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* debug: add console logging to timezone HTMX protection

* debug: add onChange logging to trace timezone selection

* fix(widgets): use selectedIndex to force visual update in timezone dropdown

The browser's select.value setter sometimes doesn't trigger a visual
update when optgroup elements are present. Using selectedIndex instead
forces the browser to correctly update the visible selection.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(widgets): force browser repaint on timezone dropdown restore

Adding display:none/reflow/display:'' pattern to force browser to
visually update the select element after changing selectedIndex.
Increased timeout to 50ms for reliability.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* chore(widgets): remove debug logging from timezone selector

Clean up console.log statements that were used for debugging the
timezone dropdown visual update issue.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(ui): improve HTMX after-request handler in general settings

- Parse xhr.responseText with JSON.parse in try/catch instead of
  using nonstandard responseJSON property
- Check xhr.status for 2xx success range
- Show error notification for non-2xx responses
- Default to safe fallback values if JSON parsing fails

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(widgets): add input sanitization and timezone validation

- Sanitize minLength/maxLength in text-input.js to prevent attribute
  injection (coerce to integers, validate range)
- Update Europe/Kiev to Europe/Kyiv (canonical IANA identifier)
- Validate timezone currentValue against TIMEZONE_GROUPS before rendering

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(ui): correct error message fallback in HTMX after-request handler

Initialize message to empty string so error responses can use the
fallback 'Failed to save settings' when no server message is provided.
Previously, the truthy default 'Settings saved' would always be used.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(widgets): add constraint normalization and improve value validation

- text-input: normalize minLength/maxLength so maxLength >= minLength
- timezone-selector: validate setValue input against TIMEZONE_GROUPS
- timezone-selector: sync hidden input to actual selected value
- timezone-selector: preserve empty selections across HTMX requests

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(widgets): simplify HTMX restore using select.value and dispatch change event

Replace selectedIndex manipulation with direct value assignment for cleaner
placeholder handling, and dispatch change event to refresh timezone preview.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 19:56:16 -05:00
ChuckandGitHub 0203c5c1b5 Update Discord link in README.md (#211)
Signed-off-by: Chuck <33324927+ChuckBuilds@users.noreply.github.com>
2026-01-26 15:48:28 -05:00
384ed096ff fix: prevent /tmp permission corruption breaking system updates (#209)
Issue: LEDMatrix was changing /tmp permissions from 1777 (drwxrwxrwt)
to 2775 (drwxrwsr-x), breaking apt update and other system tools.

Root cause: display_manager.py's _write_snapshot_if_due() called
ensure_directory_permissions() on /tmp when writing snapshots to
/tmp/led_matrix_preview.png. This removed the sticky bit and
world-writable permissions that /tmp requires.

Fix:
- Added PROTECTED_SYSTEM_DIRECTORIES safelist to permission_utils.py
  to prevent modifying permissions on /tmp and other system directories
- Added explicit check in display_manager.py to skip /tmp
- Defense-in-depth approach prevents similar issues in other code paths

The sticky bit (1xxx) is critical for /tmp - it prevents users from
deleting files they don't own. Without world-writable permissions,
regular users cannot create temp files.

Fixes #202

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-26 10:56:30 -05:00
ChuckandGitHub f9de9fa29e Add installation video link to README (#205)
Added installation video link for the LEDMatrix project.

Signed-off-by: Chuck <33324927+ChuckBuilds@users.noreply.github.com>
2026-01-22 17:34:00 -05:00
d0ad2031c8 fix(ui): wrap plugin tabs to new lines instead of scrolling (#201)
* fix(ui): wrap plugin tabs to new lines instead of scrolling

Change plugin tabs row from overflow-x-auto to flex-wrap so that
when many plugins are installed, tabs break to new lines instead
of becoming smaller or requiring horizontal scrolling.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(ui): use gap-x instead of space-x for proper wrapped row alignment

Switch from space-x-* to gap-x-* utilities so wrapped rows align
correctly without indentation on subsequent lines.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(ui): add missing flex-wrap and gap utilities to CSS

The project uses hand-written Tailwind-like CSS, not actual Tailwind.
Added missing utility classes needed for plugin tabs wrapping:
- flex-wrap
- gap-x-4, gap-x-6, gap-x-8, gap-y-2
- lg:gap-x-6, xl:gap-x-8 responsive variants

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(ui): apply flex-wrap to system tabs row

Apply the same wrapping behavior to the system tabs row (Overview,
General, WiFi, etc.) so they also wrap to new lines on smaller
viewports instead of scrolling.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(ui): constrain tab container width to enable flex-wrap

Add max-w-full and overflow-hidden to tab row containers to properly
constrain their width, allowing flex-wrap to work correctly.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(ui): remove overflow-hidden that was hiding tabs

Revert the max-w-full overflow-hidden approach as it was hiding
content. Keep both rows using flex-wrap with gap utilities.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat: Add custom-leagues widget support for soccer plugin

- Add server-side template rendering for x-widget="custom-leagues"
- Renders table with Name, League Code, Priority, Enabled columns
- Includes inline JavaScript for add/remove row functionality
- Uses indexed field naming for proper array serialization
- Shows common ESPN league codes as hint

This enables the soccer scoreboard plugin's custom leagues feature
to work properly in the web UI.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(ui): reduce tab gap spacing for tighter layout

Reduce horizontal gap between tabs from gap-x-4/6/8 to gap-x-2/3/4
for a more compact appearance.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor(widget): Replace custom-leagues with generic array-table widget

- Add generic array-table widget that reads columns from schema
- Support x-columns to specify which columns to display
- Auto-detect columns from items.properties if x-columns not specified
- Remove hardcoded custom-leagues implementation
- Any plugin can now use x-widget: "array-table" for array-of-objects

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(ui): use data attributes for array table button to avoid JSON escaping issues

Move JSON blobs (item_properties and display_columns) from inline onclick
to data-* attributes with proper HTML entity escaping via Jinja's |e filter.
Update addArrayTableRow() to read and parse these data attributes.

This fixes HTML attribute breakage caused by tojson emitting double quotes
inside the onclick attribute value.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(ui): update Add button state when array table rows change

Add updateAddButtonState() helper that toggles the Add button's disabled
attribute and opacity based on current row count vs maxItems.

Called after addArrayTableRow() and removeArrayTableRow(), and also on
page load to ensure correct initial state.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(ui): add try/catch for JSON parsing in addArrayTableRow

Wrap JSON.parse calls for data-item-properties and data-display-columns
in try/catch blocks with fallback to {} and [] respectively. Logs error
with raw attribute values to help debug malformed JSON.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(array-table): Fix getValue input name validation and setValue Add button state sync

- Fix getValue to use early-continue guard preventing errors on inputs without names
- Add updateAddButtonState call in setValue to refresh Add button state after repopulating rows

* fix(ui): make Configure button larger than Uninstall in plugin manager

Swapped button sizes in installed plugins section - Configure button is now
the largest (flex-2), Update is medium (flex-1), and Uninstall is smallest
(no flex class). This prioritizes the Configure action over Uninstall.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* fix(ui): correct forEach continue and plugin button flex sizing

- Replace invalid continue with return in array-table forEach callback
- Remove redundant hidden input type check in array-table getValue
- Fix plugin button sizing using inline flex styles instead of invalid flex-2 class
- Configure button now properly sized at flex: 2, Update and Uninstall at flex: 1

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* refactor(ui): reorganize plugin buttons into two-row layout

Configure button now takes full width on first row, while Update and
Uninstall buttons share the second row evenly. This makes Configure
more prominent and separates destructive actions to a second row.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* fix(ui): override inline-flex on Configure button to enable full width

The .btn class uses display: inline-flex which prevents w-full from working.
Added inline style to override with display: flex and width: 100% so the
Configure button properly takes the full width of its row.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* fix(ui): use inline styles for plugin action buttons layout

Replace Tailwind classes with explicit inline styles to ensure proper
two-row layout for plugin action buttons. Configure button on first row
at full width, Update and Uninstall sharing second row evenly.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 10:40:13 -05:00
1833e30c1d Feature/wifi setup improvements (#187)
* fix: Handle permission errors when removing plugin directories

- Added _safe_remove_directory() method to handle permission errors gracefully
- Fixes permissions on __pycache__ directories before removal
- Updates uninstall_plugin() and install methods to use safe removal
- Resolves [Errno 13] Permission denied errors during plugin install/uninstall

* feat(wifi): Add grace period for AP mode and improve setup documentation

- Add 90-second grace period (3 checks at 30s intervals) before enabling AP mode
- Change AP to open network (no password) for easier initial setup
- Add verification script for WiFi setup
- Update documentation with grace period details and open network info
- Improve WiFi monitor daemon logging and error handling

* feat(wifi): Add Trixie compatibility and dynamic interface discovery

- Add dynamic WiFi interface discovery instead of hardcoded wlan0
  - Supports traditional (wlan0), predictable (wlp2s0), and USB naming
  - Falls back gracefully if detection fails

- Add Raspberry Pi OS Trixie (Debian 13) detection and compatibility
  - Detect Netplan configuration and connection file locations
  - Disable PMF (Protected Management Frames) on Trixie for better
    client compatibility with certain WiFi adapters

- Improve nmcli hotspot setup for Trixie
  - Add explicit IP configuration (192.168.4.1/24)
  - Add channel configuration to hotspot creation
  - Handle Trixie's default 10.42.0.1 IP override

- Add dnsmasq conflict detection
  - Warn if Pi-hole or other DNS services are using dnsmasq
  - Create backup before overwriting config

- Improve error handling
  - Replace bare except clauses with specific exceptions
  - All subprocess calls now have explicit timeouts

- Document sudoers requirements in module docstring
  - List all required NOPASSWD entries for ledpi user

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor(wifi): Use NM_CONNECTIONS_PATHS constant in _detect_trixie

Replace hardcoded Path instances with references to the
NM_CONNECTIONS_PATHS constant for consistency.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(verify): Use ETH_CONNECTED and AP_ACTIVE in summary output

Add connectivity summary section that displays Ethernet and AP mode
status using the previously unused ETH_CONNECTED and AP_ACTIVE flags.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 16:43:02 -05:00
2381ead03f feat(cache): Add intelligent disk cache cleanup with retention policies (#199)
* feat(cache): Add intelligent disk cache cleanup with retention policies

- Add cleanup_expired_files() method to DiskCache class
- Implement retention policies based on cache data types:
  * Odds data: 2 days (lines move frequently)
  * Live/recent/leaderboard: 7 days (weekly updates)
  * News/stocks: 14 days
  * Upcoming/schedules/team_info/logos: 60 days (stable data)
- Add cleanup_disk_cache() orchestration in CacheManager
- Start background cleanup thread running every 24 hours
- Run cleanup on application startup
- Add disk cleanup metrics tracking
- Comprehensive logging with cleanup statistics

This prevents disk cache from accumulating indefinitely while preserving
important season data longer than volatile live game data.

* refactor(cache): improve disk cache cleanup implementation

- Implement force parameter throttle mechanism in cleanup_disk_cache
- Fix TOCTOU race condition in disk cache cleanup (getsize/remove)
- Reduce lock contention by processing files outside lock where possible
- Add CacheStrategyProtocol for better type safety (replaces Any)
- Move time import to module level in cache_metrics
- Defer initial cleanup to background thread for non-blocking startup
- Add graceful shutdown mechanism with threading.Event for cleanup thread
- Add stop_cleanup_thread() method for controlled thread termination

* fix(cache): improve disk cache cleanup initialization and error handling

- Only start cleanup thread when disk caching is enabled (cache_dir is set)
- Remove unused retention policy keys (leaderboard, live_scores, logos)
- Handle FileNotFoundError as benign race condition in cleanup
- Preserve existing OSError handling for actual file system errors

---------

Co-authored-by: Chuck <chuck@example.com>
2026-01-19 15:57:19 -05:00
bc23b7c75c fix(logos): Add ncaam/ncaaw sport key aliases for basketball plugin (#200)
The basketball scoreboard plugin uses sport_key="ncaam" and "ncaaw",
but LogoDownloader only had "ncaam_basketball" mapped. This caused
get_logo_directory() to fall back to "assets/sports/ncaam_logos"
(non-existent) instead of "assets/sports/ncaa_logos".

Added aliases to both LOGO_DIRECTORIES and API_ENDPOINTS:
- ncaam -> assets/sports/ncaa_logos
- ncaaw -> assets/sports/ncaa_logos
- ncaaw_basketball -> assets/sports/ncaa_logos

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 09:48:52 -05:00
bff16d3e00 refactor: migrate from submodules to multi-root workspace for plugins (#198)
* refactor: migrate from submodules to multi-root workspace for plugins

- Updated LEDMatrix.code-workspace to include all plugin repos as root folders
- Removed symlinks from plugin-repos/ and plugins/ directories
- Updated .gitignore to reflect new plugin management approach
- Added setup_plugin_repos.py script for managing plugin symlinks (if needed)
- Added MULTI_ROOT_WORKSPACE_SETUP.md documentation

Plugins are now managed as independent repositories via multi-root workspace,
allowing for easier development and independent updates without modifying
the LEDMatrix project structure.

* Fix MULTI_ROOT_WORKSPACE_SETUP.md and add JSON error handling

- Remove deprecated clone_plugin_repos.py command reference
- Add language tag to directory tree code fence (fixes MD040)
- Add JSONDecodeError handling in setup_plugin_repos.py with user-friendly error messages

---------

Co-authored-by: Chuck <chuck@example.com>
2026-01-18 13:06:57 -05:00
23ada60544 Fix/plugins manager syntax error (#192)
* chore: Update basketball-scoreboard submodule for odds font fix

* fix(plugins): Add missing closing brace in file-upload widget if block

Fixed syntax error where the if statement starting at line 2949 was missing
its closing brace before the else if statement. This caused 'Unexpected token
else' error at line 3257 when parsing the loadCustomHtmlWidget function.

The fix adds the missing closing brace at line 3048 to properly close the
if block before the else if chain continues.

* fix(plugins): Resolve unmatched else if syntax error in plugins_manager.js

- Fixed indentation of else if chain for custom-feeds widget (line 3203)
- Fixed indentation of final else block (line 3240)
- Added missing closing brace to properly close array handling block (line 3257)
- Resolves 'Unexpected token else' syntax error at line 3257
- Allows plugin store to load correctly

* fix: Update plugins_manager.js cache-busting version to force reload of syntax fix

---------

Co-authored-by: Chuck <chuck@example.com>
2026-01-16 15:43:18 -05:00
fadcf0f407 Fix/plugins manager syntax error (#191)
* chore: Update basketball-scoreboard submodule for odds font fix

* fix(plugins): Add missing closing brace in file-upload widget if block

Fixed syntax error where the if statement starting at line 2949 was missing
its closing brace before the else if statement. This caused 'Unexpected token
else' error at line 3257 when parsing the loadCustomHtmlWidget function.

The fix adds the missing closing brace at line 3048 to properly close the
if block before the else if chain continues.

---------

Co-authored-by: Chuck <chuck@example.com>
2026-01-16 15:16:46 -05:00
71584d4361 Feature/widget registry system (#190)
* chore: Update basketball-scoreboard submodule for odds font fix

* feat(widgets): Add widget registry system for plugin configuration forms

- Create core widget registry system (registry.js, base-widget.js)
- Extract existing widgets to separate modules:
  - file-upload.js: Image upload with drag-and-drop, preview, delete, scheduling
  - checkbox-group.js: Multi-select checkboxes for array fields
  - custom-feeds.js: Table-based RSS feed editor with logo uploads
- Implement plugin widget loading system (plugin-loader.js)
- Add comprehensive documentation (widget-guide.md, README.md)
- Include example custom widget (example-color-picker.js)
- Maintain backwards compatibility with existing plugins
- All widget handlers available globally for existing functionality

This enables:
- Reusable UI components for plugin configuration forms
- Third-party plugins to create custom widgets without modifying LEDMatrix
- Modular widget architecture for future enhancements

Existing plugins (odds-ticker, static-image, news) continue to work without changes.

* fix(widgets): Security and correctness fixes for widget system

- base-widget.js: Fix escapeHtml to always escape (coerce to string first)
- base-widget.js: Add sanitizeId helper for safe DOM ID usage
- base-widget.js: Use DOM APIs in showError instead of innerHTML
- checkbox-group.js: Normalize types in setValue for consistent comparison
- custom-feeds.js: Implement setValue with full row creation logic
- example-color-picker.js: Validate hex colors before using in style attributes
- file-upload.js: Replace innerHTML with DOM creation to prevent XSS
- file-upload.js: Preserve open schedule editors when updating image list
- file-upload.js: Normalize types when filtering deleted files
- file-upload.js: Sanitize imageId in openImageSchedule and all schedule handlers
- file-upload.js: Fix max-files check order and use allowed_types from config
- README.md: Add security guidance for ID sanitization in examples

* fix(widgets): Additional security and error handling improvements

- scripts/update_plugin_repos.py: Add explicit UTF-8 encoding and proper error handling for file operations
- scripts/update_plugin_repos.py: Fix git fetch/pull error handling with returncode checks and specific exception types
- base-widget.js: Guard notify method against undefined/null type parameter
- file-upload.js: Remove inline handlers from schedule template, use addEventListener with data attributes
- file-upload.js: Update hideUploadProgress to show dynamic file types from config instead of hardcoded list
- README.md: Update Color Picker example to use sanitized fieldId throughout

* fix(widgets): Update Slider example to use sanitized fieldId

- Add sanitizeId helper to Slider example render, getValue, and setValue methods
- Use sanitizedFieldId for all DOM IDs and query selectors
- Maintain consistency with Color Picker example pattern

* fix(plugins_manager): Move configurePlugin and togglePlugin to top of file

- Move configurePlugin and togglePlugin definitions to top level (after uninstallPlugin)
- Ensures these critical functions are available immediately when script loads
- Fixes 'Critical functions not available after 20 attempts' error
- Functions are now defined before any HTML rendering checks

* fix(plugins_manager): Fix checkbox state saving using querySelector

- Add escapeCssSelector helper function for safe CSS selector usage
- Replace form.elements[actualKey] with form.querySelector for boolean fields
- Properly handle checkbox checked state using element.checked property
- Fix both schema-based and schema-less boolean field processing
- Ensures checkboxes with dot notation names (nested fields) work correctly

Fixes issue where checkbox states were not properly saved when field names
use dot notation (e.g., 'display.scroll_enabled'). The form.elements
collection doesn't reliably handle dot notation in bracket notation access.

* fix(base.html): Fix form element lookup for dot notation field names

- Add escapeCssSelector helper function (both as method and standalone)
- Replace form.elements[key] with form.querySelector for element type detection
- Fixes element lookup failures when field names use dot notation
- Ensures checkbox and multi-select skipping logic works correctly
- Applies fix to both Alpine.js method and standalone function

This complements the fix in plugins_manager.js to ensure all form
element lookups handle nested field names (e.g., 'display.scroll_enabled')
reliably across the entire web interface.

* fix(plugins_manager): Add race condition protection to togglePlugin

- Initialize window._pluginToggleRequests map for per-plugin request tokens
- Generate unique token for each toggle request to track in-flight requests
- Disable checkbox and wrapper UI during request to prevent overlapping toggles
- Add visual feedback with opacity and pointer-events-none classes
- Verify token matches before applying response updates (both success and error)
- Ignore out-of-order responses to preserve latest user intent
- Clear token and re-enable UI after request completes

Prevents race conditions when users rapidly toggle plugins, ensuring
only the latest toggle request's response affects the UI state.

* refactor(escapeCssSelector): Use CSS.escape() for better selector safety

- Prefer CSS.escape() when available for proper CSS selector escaping
- Handles edge cases: unicode characters, leading digits, and spec compliance
- Keep regex-based fallback for older browsers without CSS.escape support
- Update all three instances: plugins_manager.js and both in base.html

CSS.escape() is the standard API for escaping CSS selectors and provides
more robust handling than custom regex, especially for unicode and edge cases.

* fix(plugins_manager): Fix syntax error - missing closing brace for file-upload if block

- Add missing closing brace before else-if for checkbox-group widget
- Fixes 'Unexpected token else' error at line 3138
- The if block for file-upload widget (line 3034) was missing its closing brace
- Now properly structured: if (file-upload) { ... } else if (checkbox-group) { ... }

* fix(plugins_manager): Fix indentation in file-upload widget if block

- Properly indent all code inside the file-upload if block
- Fix template string closing brace indentation
- Ensures proper structure: if (file-upload) { ... } else if (checkbox-group) { ... }
- Resolves syntax error at line 3138

* fix(plugins_manager): Skip checkbox-group [] inputs to prevent config leakage

- Add skip logic for keys ending with '[]' in handlePluginConfigSubmit
- Prevents checkbox-group bracket notation inputs from leaking into config
- Checkbox-group widgets emit name="...[]" checkboxes plus a _data JSON field
- The _data field is already processed correctly, so [] inputs are redundant
- Prevents schema validation failures and extra config keys

The checkbox-group widget creates:
1. Individual checkboxes with name="fullKey[]" (now skipped)
2. Hidden input with name="fullKey_data" containing JSON array (processed)
3. Sentinel hidden input with name="fullKey[]" and empty value (now skipped)

* fix(plugins_manager): Normalize string booleans when checkbox input is missing

- Fix boolean field processing to properly normalize string booleans in fallback path
- Prevents "false"/"0" from being coerced to true when checkbox element is missing
- Handles common string boolean representations: 'true', 'false', '1', '0', 'on', 'off'
- Applies to both schema-based (lines 2386-2400) and schema-less (lines 2423-2433) paths

When a checkbox element cannot be found, the fallback logic now:
1. Checks if value is a string and normalizes known boolean representations
2. Treats undefined/null as false
3. Coerces other types to boolean using Boolean()

This ensures string values like "false" or "0" are correctly converted to false
instead of being treated as truthy non-empty strings.

* fix(base.html): Improve escapeCssSelector fallback to match CSS.escape behavior

- Handle leading digits by converting to hex escape (e.g., '1' -> '\0031 ')
- Handle leading whitespace by converting to hex escape (e.g., ' ' -> '\0020 ')
- Escape internal spaces as '\ ' (preserving space in hex escapes)
- Ensures trailing space after hex escapes per CSS spec
- Applies to both Alpine.js method and standalone function

The fallback now better matches CSS.escape() behavior for older browsers:
1. Escapes leading digits (0-9) as hex escapes with trailing space
2. Escapes leading whitespace as hex escapes with trailing space
3. Escapes all special characters as before
4. Escapes internal spaces while preserving hex escape format

This prevents selector injection issues with field names starting with digits
or whitespace, matching the standard CSS.escape() API behavior.

---------

Co-authored-by: Chuck <chuck@example.com>
2026-01-16 14:09:38 -05:00
3b8910ac09 Fix/duplicate display settings (#173)
* fix(plugins): Remove compatible_versions requirement from single plugin install

Remove compatible_versions from required fields in install_from_url method
to match install_plugin behavior. This allows installing plugins from URLs
without manifest version requirements, consistent with store plugin installation.

* fix(7-segment-clock): Update submodule with separator and spacing fixes

* fix(plugins): Add onchange handlers to existing custom feed inputs

- Add onchange handlers to key and value inputs for existing patternProperties fields
- Fixes bug where editing existing custom RSS feeds didn't save changes
- Ensures hidden JSON input field is updated when users edit feed entries
- Affects all plugins using patternProperties (custom_feeds, feed_logo_map, etc.)

* Add array-of-objects widget support to web UI

- Add support for rendering arrays of objects in web UI (for custom_feeds)
- Implement add/remove/update functions for array-of-objects widgets
- Support file-upload widgets within array items
- Update form data handling to support array JSON data fields

* Update plugins_manager.js cache-busting version

Update version parameter to force browser to load new JavaScript with array-of-objects widget support.

* Fix: Move array-of-objects detection before file-upload/checkbox checks

Move the array-of-objects widget detection to the top of the array handler so it's checked before file-upload and checkbox-group widgets. This ensures custom_feeds is properly detected as an array of objects.

* Update cache-busting version for array-of-objects fix

* Remove duplicate array-of-objects check

* Update cache version again

* Add array-of-objects widget support to server-side template

Add detection and rendering for array-of-objects in the Jinja2 template (plugin_config.html).
This enables the custom_feeds widget to display properly with name, URL, enabled checkbox, and logo upload fields.

The widget is detected by checking if prop.items.type == 'object' && prop.items.properties,
and is rendered before the file-upload widget check.

* Use window. prefix for array-of-objects JavaScript functions

Explicitly use window.addArrayObjectItem, window.removeArrayObjectItem, etc.
in the template to ensure the functions are accessible from inline event handlers.
Also add safety checks to prevent errors if functions aren't loaded yet.

* Fix duplicate display settings in config

Prevent display settings from being saved at both nested (display.hardware/runtime) and root level. The save_main_config function was processing display fields twice - once correctly in the nested structure, and again in the catch-all section creating root-level duplicates.

Added display_fields to the skip list in the catch-all section to prevent root-level duplicates. All code expects the nested format, so this ensures consistency.

* fix: Recreate one-shot install script with APT permission and non-interactive fixes

Recreate one-shot install script that was deleted, with fixes for:
1. APT permission denied errors on /tmp
2. Non-interactive mode support

Fixes:
1. Fix /tmp permissions before running first_time_install.sh:
   - chmod 1777 /tmp to ensure APT can write temp files
   - Set TMPDIR=/tmp explicitly
   - Preserve TMPDIR when using sudo -E

2. Enable non-interactive mode:
   - Pass -y flag or LEDMATRIX_ASSUME_YES=1 to first_time_install.sh
   - Prevents read prompt failure at line 242 when run via curl | bash

3. Better error handling:
   - Temporarily disable errexit to capture exit code
   - Re-enable errexit after capturing
   - Added fix_tmp_permissions() function

This resolves the 'Permission denied' errors for APT temp files and the
interactive prompt failure when running via pipe.

* fix(plugins): Restore version and display_modes to required_fields and fix array object data persistence

- Restore 'version' and 'display_modes' to required_fields in store_manager.py manifest validation (both occurrences at lines 839 and 977)
- Fix updateArrayObjectData to merge input fields with existing item data to preserve non-editable properties like logo objects
- Implement handleArrayObjectFileUpload to properly upload files and store metadata in data-file-data attribute
- Implement removeArrayObjectFile to properly remove file metadata and update data structure
- Update renderArrayObjectItem to preserve file data in data-file-data attribute when rendering existing items

* fix(plugins): Remove version from required_fields, keep display_modes required

- Remove 'version' from required_fields in store_manager.py (both occurrences)
  - Some existing plugins have version: null or no version field (basketball-scoreboard, odds-ticker)
  - All code uses safe accessors (manifest.get('version')), so optional is safe
- Keep 'display_modes' as required - all plugins have it and tests expect it

* fix: Preserve exit codes in retry() and fix null handling in JSON data detection

- Fix retry() function to preserve original command exit code by capturing status immediately after command execution
- Fix JSON data detection to prevent null from overwriting config by checking jsonValue !== null before treating as object
- Both fixes prevent edge cases that could cause incorrect behavior or data corruption

* fix: Resolve merge conflict, fix array-of-objects file upload, and improve retry function

- Remove unresolved merge conflict marker in array rendering (checkbox input attributes)
- Fix array-of-objects file upload selector mismatch by adding id to wrapper element
- Fix index-based preserve corruption by using data-item-data attributes instead of array indices
- Add showNotification guards to prevent errors when notifications aren't available
- Fix retry() function to work with set -Eeuo pipefail by disabling errexit for command execution

* fix: Remove duplicate implementations, fix upload config, and add type coercion

- Remove/guard duplicate updateArrayObjectData, handleArrayObjectFileUpload, and removeArrayObjectFile stub implementations that were overwriting real implementations
- Fix hard-coded plugin ID fallback in renderArrayObjectItem - use null instead of 'ledmatrix-news'
- Fix upload config to use uploadConfig.allowed_types and uploadConfig.max_size_mb from schema instead of hard-coded values
- Store uploadConfig in data-upload-config attribute and read it in handleArrayObjectFileUpload for validation
- Add type coercion to updateArrayObjectData: coerce number inputs to Number, array inputs via JSON.parse with comma-split fallback

* fix: Use event-based element lookup in handleArrayObjectFileUpload

- Change from constructing ID to using event.target.closest('.array-object-item') to find item element
- Query fileUploadContainer from itemEl instead of using constructed ID lookup
- Remove reliance on `${fieldId}_item_${itemIndex}` which breaks after reindexing
- Add response.ok check before calling response.json() to avoid JSON parsing errors on HTTP errors
- Handle non-OK responses with proper error messages (JSON parse with fallback)

* fix: Improve HTML escaping and add pluginId validation for file uploads

- Replace manual single-quote escaping with escapeAttribute() for proper HTML escaping in array-of-objects hidden input
- Update default allowed_types to include 'image/jpg' in handleArrayObjectFileUpload
- Add explicit pluginId validation before upload to fail fast with clear error message
- Prevents XSS vulnerabilities and backend rejections from invalid uploads

* fix: Use propKey-scoped selector and harden pluginId validation

- Narrow file widget lookup to use propKey-specific selector (.file-upload-widget-inline[data-prop-key]) to target correct widget when item has multiple file widgets
- Harden pluginId validation by checking typeof pluginId === 'string' before calling trim() to prevent errors on non-string values

---------

Co-authored-by: Chuck <chuck@example.com>
2026-01-14 10:51:55 -05:00
ChuckandGitHub 94d5a38358 Update README.md (#188)
Signed-off-by: Chuck <33324927+ChuckBuilds@users.noreply.github.com>
2026-01-13 18:35:11 -05:00
4a63ff87cb Feature/soccer scroll support (#186)
* fix: Use plugin.modes instead of manifest.json for available modes

- Display controller now checks plugin_instance.modes first before falling back to manifest
- This allows plugins to dynamically provide modes based on enabled leagues
- Fixes issue where disabled leagues (WNBA, NCAAW) appeared in available modes
- Plugins can now control their available modes at runtime based on config

* fix: Handle permission errors when removing plugin directories

- Added _safe_remove_directory() method to handle permission errors gracefully
- Fixes permissions on __pycache__ directories before removal
- Updates uninstall_plugin() and install methods to use safe removal
- Resolves [Errno 13] Permission denied errors during plugin install/uninstall

* debug(display): Change FPS check logging from debug to info level

- Change FPS check log from DEBUG to INFO to help diagnose scrolling FPS issues
- Add active_mode to log message for clarity
- Helps identify if plugins are being detected for high-FPS mode

* debug(display): Add logging for display_interval in both FPS loops

- Log display_interval when entering high-FPS and normal loops
- Shows expected FPS for high-FPS mode
- Helps diagnose why news ticker shows 50 FPS despite high-FPS detection

* feat: Update soccer-scoreboard submodule with scroll display support

- Submodule now includes full feature parity with football-scoreboard
- Granular display modes for 8 leagues (24 total modes)
- Scroll display mode with game_renderer.py and scroll_display.py
- League registry system with enabled state filtering
- Modernized config_schema.json with per-league scroll settings
- League-aware logo caching to prevent collisions
- Pillow 8.x compatibility for image resampling

Submodule branch: feature/football-feature-parity
Commit: e22a16d

* style(web): Update plugin button colors and reorganize documentation

- Change update button color to yellow-600 in installed plugins section to match plugin config page
- Change refresh plugins button color to blue-600 to match restart display button
- Move DEVELOPMENT.md and MIGRATION_GUIDE.md from root to docs/ directory
- Remove IMPACT_EXPLANATION.md and MERGE_CONFLICT_RESOLUTION_PLAN.md

---------

Co-authored-by: Chuck <chuck@example.com>
2026-01-13 13:33:53 -05:00
fdf09fabd2 Debug/fps logging (#183)
* fix: Use plugin.modes instead of manifest.json for available modes

- Display controller now checks plugin_instance.modes first before falling back to manifest
- This allows plugins to dynamically provide modes based on enabled leagues
- Fixes issue where disabled leagues (WNBA, NCAAW) appeared in available modes
- Plugins can now control their available modes at runtime based on config

* fix: Handle permission errors when removing plugin directories

- Added _safe_remove_directory() method to handle permission errors gracefully
- Fixes permissions on __pycache__ directories before removal
- Updates uninstall_plugin() and install methods to use safe removal
- Resolves [Errno 13] Permission denied errors during plugin install/uninstall

* debug(display): Change FPS check logging from debug to info level

- Change FPS check log from DEBUG to INFO to help diagnose scrolling FPS issues
- Add active_mode to log message for clarity
- Helps identify if plugins are being detected for high-FPS mode

* debug(display): Add logging for display_interval in both FPS loops

- Log display_interval when entering high-FPS and normal loops
- Shows expected FPS for high-FPS mode
- Helps diagnose why news ticker shows 50 FPS despite high-FPS detection

---------

Co-authored-by: Chuck <chuck@example.com>
2026-01-13 10:55:54 -05:00
ChuckandGitHub 75a8219a29 Update README.md (#185)
Signed-off-by: Chuck <33324927+ChuckBuilds@users.noreply.github.com>
2026-01-13 10:55:18 -05:00
ChuckandGitHub a9798e1a7f Enhance README with project details and instructions (#184)
Added project overview, setup instructions, and plugin architecture details.

Signed-off-by: Chuck <33324927+ChuckBuilds@users.noreply.github.com>
2026-01-13 10:45:28 -05:00
c35769cefb Fix/checkbox save and dynamic duration (#182)
* fix: Use plugin.modes instead of manifest.json for available modes

- Display controller now checks plugin_instance.modes first before falling back to manifest
- This allows plugins to dynamically provide modes based on enabled leagues
- Fixes issue where disabled leagues (WNBA, NCAAW) appeared in available modes
- Plugins can now control their available modes at runtime based on config

* fix: Handle permission errors when removing plugin directories

- Added _safe_remove_directory() method to handle permission errors gracefully
- Fixes permissions on __pycache__ directories before removal
- Updates uninstall_plugin() and install methods to use safe removal
- Resolves [Errno 13] Permission denied errors during plugin install/uninstall

* refactor: Improve error handling in _safe_remove_directory

- Rename unused 'dirs' variable to '_dirs' to indicate intentional non-use
- Use logger.exception() instead of logger.error() to preserve stack traces
- Add comment explaining 0o777 permissions are acceptable (temporary before deletion)

* fix(install): Fix one-shot-install script reliability issues

- Install git and curl before attempting repository clone
- Add HOME variable validation to prevent path errors
- Improve git branch detection (try current branch, main, then master)
- Add validation for all directory change operations
- Improve hostname command handling in success message
- Fix edge cases for better installation success rate

* fix(install): Fix IP address display in installation completion message

- Replace unreliable pipe-to-while-read loop with direct for loop
- Filter out loopback addresses (127.0.0.1, ::1) from display
- Add proper message when no non-loopback IPs are found
- Fixes blank IP address display issue at end of installation

* fix(install): Prevent unintended merges in one-shot-install git pull logic

- Use git pull --ff-only for current branch to avoid unintended merges
- Use git fetch (not pull) for other branches to check existence without merging
- Only update current branch if fast-forward is possible
- Provide better warnings when branch updates fail but other branches exist
- Prevents risk of merging remote main/master into unrelated working branches

* fix(install): Improve IPv6 address handling in installation scripts

- Filter out IPv6 link-local addresses (fe80:) in addition to loopback
- Properly format IPv6 addresses with brackets in URLs (http://[::1]:5000)
- Filter loopback and link-local addresses when selecting IP for display
- Prevents invalid IPv6 URLs and excludes non-useful addresses
- Fixes: first_time_install.sh and one-shot-install.sh IP display logic

* fix: Fix checkbox-group saving and improve dynamic duration calculation

- Fix checkbox-group widget saving by setting values directly in plugin_config
- Fix element_gap calculation bug in ScrollHelper (was over-calculating width)
- Use actual image width instead of calculated width for scroll calculations
- Add comprehensive INFO-level logging for dynamic duration troubleshooting
- Enhanced scroll completion logging with position and percentage details

This fixes issues where checkbox-group values weren't saving correctly
and improves dynamic duration calculation accuracy for scrolling content.

---------

Co-authored-by: Chuck <chuck@example.com>
2026-01-12 18:44:06 -05:00
f1f33989b2 Fix/plugin permission errors (#181)
* fix: Use plugin.modes instead of manifest.json for available modes

- Display controller now checks plugin_instance.modes first before falling back to manifest
- This allows plugins to dynamically provide modes based on enabled leagues
- Fixes issue where disabled leagues (WNBA, NCAAW) appeared in available modes
- Plugins can now control their available modes at runtime based on config

* fix: Handle permission errors when removing plugin directories

- Added _safe_remove_directory() method to handle permission errors gracefully
- Fixes permissions on __pycache__ directories before removal
- Updates uninstall_plugin() and install methods to use safe removal
- Resolves [Errno 13] Permission denied errors during plugin install/uninstall

* refactor: Improve error handling in _safe_remove_directory

- Rename unused 'dirs' variable to '_dirs' to indicate intentional non-use
- Use logger.exception() instead of logger.error() to preserve stack traces
- Add comment explaining 0o777 permissions are acceptable (temporary before deletion)

* fix(install): Fix one-shot-install script reliability issues

- Install git and curl before attempting repository clone
- Add HOME variable validation to prevent path errors
- Improve git branch detection (try current branch, main, then master)
- Add validation for all directory change operations
- Improve hostname command handling in success message
- Fix edge cases for better installation success rate

* fix(install): Fix IP address display in installation completion message

- Replace unreliable pipe-to-while-read loop with direct for loop
- Filter out loopback addresses (127.0.0.1, ::1) from display
- Add proper message when no non-loopback IPs are found
- Fixes blank IP address display issue at end of installation

* fix(install): Prevent unintended merges in one-shot-install git pull logic

- Use git pull --ff-only for current branch to avoid unintended merges
- Use git fetch (not pull) for other branches to check existence without merging
- Only update current branch if fast-forward is possible
- Provide better warnings when branch updates fail but other branches exist
- Prevents risk of merging remote main/master into unrelated working branches

* fix(install): Improve IPv6 address handling in installation scripts

- Filter out IPv6 link-local addresses (fe80:) in addition to loopback
- Properly format IPv6 addresses with brackets in URLs (http://[::1]:5000)
- Filter loopback and link-local addresses when selecting IP for display
- Prevents invalid IPv6 URLs and excludes non-useful addresses
- Fixes: first_time_install.sh and one-shot-install.sh IP display logic

---------

Co-authored-by: Chuck <chuck@example.com>
2026-01-12 18:11:54 -05:00
f9e21c6033 Fix/plugin permission errors (#180)
* fix: Use plugin.modes instead of manifest.json for available modes

- Display controller now checks plugin_instance.modes first before falling back to manifest
- This allows plugins to dynamically provide modes based on enabled leagues
- Fixes issue where disabled leagues (WNBA, NCAAW) appeared in available modes
- Plugins can now control their available modes at runtime based on config

* fix: Handle permission errors when removing plugin directories

- Added _safe_remove_directory() method to handle permission errors gracefully
- Fixes permissions on __pycache__ directories before removal
- Updates uninstall_plugin() and install methods to use safe removal
- Resolves [Errno 13] Permission denied errors during plugin install/uninstall

* refactor: Improve error handling in _safe_remove_directory

- Rename unused 'dirs' variable to '_dirs' to indicate intentional non-use
- Use logger.exception() instead of logger.error() to preserve stack traces
- Add comment explaining 0o777 permissions are acceptable (temporary before deletion)

---------

Co-authored-by: Chuck <chuck@example.com>
2026-01-12 16:15:12 -05:00