Commit Graph

1374 Commits

Author SHA1 Message Date
Chuck
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
Chuck
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
Chuck
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
Chuck
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
Chuck
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
Chuck
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
Chuck
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
Chuck
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
Chuck
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
Chuck
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
Chuck
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
5ymb01
31ed854d4e fix(config): deduplicate uniqueItems arrays before schema validation (#292)
* fix(config): deduplicate uniqueItems arrays before schema validation

When saving plugin config via the web UI, the form data is merged with
the existing stored config. If a user adds an item that already exists
(e.g. adding stock symbol "FNMA" when it's already in the list), the
merged array contains duplicates. Schemas with `uniqueItems: true`
then reject the config, making it impossible to save.

Add a recursive dedup pass that runs after normalization/filtering but
before validation. It walks the schema tree, finds arrays with the
uniqueItems constraint, and removes duplicates while preserving order.

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

* fix: recurse into array items and add tests for uniqueItems dedup

Address CodeRabbit review: _dedup_unique_arrays now also recurses into
array elements whose items schema is an object, so nested uniqueItems
constraints inside arrays-of-objects are enforced.

Add 11 unit tests covering:
- flat arrays with/without duplicates
- order preservation
- arrays without uniqueItems left untouched
- nested objects (feeds.stock_symbols pattern)
- arrays of objects with inner uniqueItems arrays
- edge cases (empty array, missing keys, integers)
- real-world stock-news plugin config shape

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

* refactor: extract dedup_unique_arrays to shared validators module

Move _dedup_unique_arrays from an inline closure in save_plugin_config
to src/web_interface/validators.dedup_unique_arrays so tests import
and exercise the production code path instead of a duplicated copy.

Addresses CodeRabbit review: tests now validate the real function,
preventing regressions from diverging copies.

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

---------

Co-authored-by: 5ymb01 <5ymb01@users.noreply.github.com>
Co-authored-by: 5ymb01 <noreply@github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 15:48:26 -04:00
Chuck
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
Chuck
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
5ymb01
f3e7c639ba fix: narrow bare except blocks to specific exception types (#282)
Replace 6 bare `except:` blocks with targeted exception types:
- logo_downloader.py: OSError for file removal, (OSError, IOError) for font loading
- layout_manager.py: (ValueError, TypeError, KeyError, IndexError) for format string
- app.py: (OSError, ValueError) for CPU temp, (SubprocessError, OSError) for systemctl, (KeyError, TypeError, ValueError) for config parsing

Co-authored-by: 5ymb01 <5ymb01@users.noreply.github.com>
Co-authored-by: 5ymb01 <noreply@github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 15:00:12 -04:00
5ymb01
178dfb0c2a fix(perf): cache fonts in sport base classes to avoid disk I/O per frame (#285)
* fix(perf): cache fonts in sport base classes to avoid disk I/O per frame

Replace 7 ImageFont.truetype() calls in display methods with cached
self.fonts['detail'] lookups. The 4x6-font.ttf at size 6 is already
loaded once in _load_fonts() — loading it again on every display()
call causes unnecessary disk I/O on each render frame (~30-50 FPS).

Files: sports.py (2), football.py (1), hockey.py (2), basketball.py (1), baseball.py (1)

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

* chore: trigger CodeRabbit review

---------

Co-authored-by: 5ymb01 <5ymb01@users.noreply.github.com>
Co-authored-by: 5ymb01 <noreply@github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 09:59:58 -04:00
5ymb01
76c5bf5781 fix(security): mask secret fields in API responses and extract helpers (#276)
* fix(security): mask secret fields in API responses and extract helpers

GET /config/secrets returned raw API keys in plaintext to the browser.
GET /plugins/config returned merged config including deep-merged secrets.
POST /plugins/config could overwrite existing secrets with empty strings
when the GET endpoint returned masked values that were sent back unchanged.

Changes:
- Add src/web_interface/secret_helpers.py with reusable functions:
  find_secret_fields, separate_secrets, mask_secret_fields,
  mask_all_secret_values, remove_empty_secrets
- GET /config/secrets: mask all values with '••••••••'
- GET /plugins/config: mask x-secret fields with ''
- POST /plugins/config: filter empty-string secrets before saving
- pages_v3: mask secrets before rendering plugin config templates
- Remove three duplicated inline find_secret_fields/separate_secrets
  definitions in api_v3.py (replaced by single imported module)

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

* fix(security): harden secret masking against CodeRabbit findings

- Fail-closed: return 500 when schema unavailable instead of leaking secrets
- Fix falsey masking: use `is not None and != ''` instead of truthiness check
  so values like 0 or False are still redacted
- Add array-item secret support: recurse into `type: array` items schema
  to detect and mask secrets like accounts[].token
- pages_v3: fail-closed when schema properties missing

Addresses CodeRabbit findings on PR #276:
- Critical: fail-closed bypass when schema_mgr/schema missing
- Major: falsey values not masked (0, False leak through)
- Major: pages_v3 fail-open when schema absent
- Major: array-item secrets unsupported

Co-Authored-By: 5ymb01 <5ymb01@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: 5ymb01 <5ymb01@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 20:41:18 -04:00
Chuck
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
Chuck
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
Chuck
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
Chuck
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
Chuck
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
Chuck
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
Chuck
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
Chuck
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
Chuck
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
Chuck
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
Chuck
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
Chuck
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
Chuck
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
Chuck
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
Chuck
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
Chuck
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
Chuck
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
Chuck
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
Chuck
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
Chuck
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
Chuck
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
Chuck
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
Chuck
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
Chuck
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
Chuck
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
Chuck
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
Chuck
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
Chuck
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
Chuck
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
Chuck
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
Chuck
f7d72f88b5 fix(plugins): Remove compatible_versions requirement from single plugin install (#169)
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.

Co-authored-by: Chuck <chuck@example.com>
2026-01-03 15:54:37 -05:00
Chuck
a13bd971b3 fix(plugins): Fix GitHub install and update functionality for plugins installed from URLs (#167)
* fix(plugins): Fix GitHub install button for single plugin installation

- Clone install button before attaching event listener to prevent duplicate handlers
- Add safety checks for pluginStatusDiv element
- Move installFromCustomRegistry function definition earlier in file
- Add error logging when button/elements not found
- Ensure consistent button reference usage in event handlers

Fixes issue where Install button in 'Install Single Plugin' section
was not working properly.

* fix(plugins): Add button type and better logging for install button

- Add type='button' to install button to prevent form submission
- Add console logging to debug click handler attachment
- Add preventDefault and stopPropagation to click handler
- Improve error logging for debugging

* fix(plugins): Re-attach install button handler when section is shown

- Extract install button handler to separate function
- Re-attach handler when GitHub install section is toggled visible
- Add data attribute to prevent duplicate handler attachments
- Add comprehensive logging for debugging
- Handler now attaches even if section starts hidden

* fix(plugins): Add comprehensive logging to debug install button handler

- Add logging at function entry points
- Add logging when section is shown and handler re-attached
- Add logging before and after calling attachInstallButtonHandler
- Helps diagnose why handler isn't being attached

* fix(plugins): Expose GitHub install handlers globally and add fallback

- Expose setupGitHubInstallHandlers and attachInstallButtonHandler to window object
- Add fallback handler attachment after page load delay
- Fix typo in getElementById call
- Allows manual testing from browser console
- Ensures handlers are accessible even if IIFE scope issues occur

* fix(plugins): Add fallback handler attachment after page load

* fix(plugins): Ensure GitHub install handlers are set up even if already initialized

- Add check to verify setupGitHubInstallHandlers exists before calling
- Call setupGitHubInstallHandlers even if initializePlugins was already called
- Add comprehensive logging to track function execution
- Helps diagnose why handlers aren't being attached

* fix(plugins): Add more prominent logging markers for easier debugging

* fix(plugins): Add simple standalone handler for GitHub plugin installation

- Create handleGitHubPluginInstall() function defined early and globally
- Add inline onclick handler to button as fallback
- Bypasses complex initialization flow and IIFE scope issues
- Direct approach that works immediately without dependencies
- Provides clear error messages and logging

* chore: Update 7-segment-clock plugin submodule

- Update to latest version with scaling support
- Includes compatible_versions field fix for plugin store installation

* fix(plugins): Add update and uninstall handling to global event delegation fallback

- Add 'update' action handling in handleGlobalPluginAction fallback
- Add 'uninstall' action handling with confirmation dialog
- Fixes issue where update/uninstall buttons did nothing
- Buttons now work even if handlePluginAction isn't available yet

* fix(plugins): Improve error message for plugin updates from GitHub URLs

- Check if plugin is a git repository before checking registry
- Provide more accurate error messages for plugins installed from URLs
- Fixes misleading 'Plugin not found in registry' error for git-based plugins
- Update should work for plugins installed from GitHub URLs even if not in registry

* fix(plugins): Add detailed logging for plugin update failures

- Log git command that failed and return code
- Add logging before/after update attempt
- Log whether plugin is detected as git repository
- Helps diagnose why updates fail for plugins installed from URLs

* fix(plugins): Add better logging for plugin update detection

- Log when plugin is detected as git repository
- Log when plugin is not a git repository
- Provide helpful message for ZIP-installed plugins
- Helps diagnose why updates fail for plugins installed from URLs

* fix(plugins): Enable updates for plugins installed from GitHub URLs

- Get git remote URL from plugin directory even if .git is missing
- If plugin not in registry but has remote URL, reinstall as git repo
- Allows updating plugins installed from URLs even if git clone failed initially
- Falls back to reinstalling from original URL to enable future updates

* fix(plugins): Reinstall from git remote URL if plugin not in registry

- When plugin is not a git repo and not in registry, check for git remote URL
- If remote URL exists, reinstall plugin from that URL to enable future updates
- Handles case where plugin was installed from URL but git clone failed initially

* fix(plugins): Improve git update error handling and logging

- Make git fetch non-fatal (log warning but continue)
- Make git checkout non-fatal (log warning but continue)
- Add detailed error messages for common git failures
- Log which git command failed and return code
- Better handling of authentication, merge conflicts, and unrelated histories

* fix(plugins): Add detailed exception logging to update endpoint

- Log full traceback when update fails
- Log exception details in catch block
- Helps diagnose update failures from API endpoint

* fix(plugins): Handle untracked files during plugin update

- Remove .dependencies_installed marker file before pull (safe to regenerate)
- Stash untracked files using 'git stash -u' if they can't be removed
- Prevents 'untracked files would be overwritten' errors during update
- Fixes issue where .dependencies_installed blocks git pull

* chore: Update 7-segment-clock submodule with improved clone instructions

---------

Co-authored-by: Chuck <chuck@example.com>
2026-01-03 09:44:51 -05:00
Chuck
67197635c9 Feature/on demand plugin filtering (#166)
* fix(web): Resolve font display and config API error handling issues

- Fix font catalog display error where path.startsWith fails
  (path is object, not string)
- Update save_main_config to use error_response() helper
- Improve save_raw_main_config error handling consistency
- Add proper error codes and traceback details to API responses

* fix(web): Prevent fontCatalog redeclaration error on HTMX reload

- Use window object to store global font variables
- Check if script has already loaded before declaring variables
- Update both window properties and local references on assignment
- Fixes 'Identifier fontCatalog has already been declared' error

* fix(web): Wrap fonts script in IIFE to prevent all redeclaration errors

- Wrap entire script in IIFE that only runs once
- Check if script already loaded before declaring variables/functions
- Expose initializeFontsTab to window for re-initialization
- Prevents 'Identifier has already been declared' errors on HTMX reload

* fix(web): Exempt config save API endpoints from CSRF protection

- Exempt save_raw_main_config, save_raw_secrets_config, and save_main_config from CSRF
- These endpoints are called via fetch from JavaScript and don't include CSRF tokens
- Fixes 500 error when saving config via raw JSON editor

* fix(web): Exempt system action endpoint from CSRF protection

- Exempt execute_system_action from CSRF
- Fixes 500 error when using system action buttons (restart display, restart Pi, etc.)
- These endpoints are called via HTMX and don't include CSRF tokens

* fix(web): Exempt all API v3 endpoints from CSRF protection

- Add before_request handler to exempt all api_v3.* endpoints
- All API endpoints are programmatic (HTMX/fetch) and don't include CSRF tokens
- Prevents future CSRF errors on any API endpoint
- Cleaner than exempting individual endpoints

* refactor(web): Remove CSRF protection for local-only application

- CSRF is designed for internet-facing apps to prevent cross-site attacks
- For local-only Raspberry Pi app, threat model is different
- All endpoints were exempted anyway, so it wasn't protecting anything
- Forms use HTMX without CSRF tokens
- If exposing to internet later, can re-enable with proper token implementation

* fix(web): Fix font path double-prefixing in font catalog display

- Only prefix with 'assets/fonts/' if path is a bare filename
- If path starts with '/' (absolute) or 'assets/' (already prefixed), use as-is
- Fixes double-prefixing when get_fonts_catalog returns relative paths like 'assets/fonts/press_start.ttf'

* fix(web): Remove fontsTabInitialized guard to allow re-initialization on HTMX reload

- Remove fontsTabInitialized check that prevented re-initialization on HTMX content swap
- The window._fontsScriptLoaded guard is sufficient to prevent function redeclaration
- Allow initializeFontsTab() to run on each HTMX swap to attach listeners to new DOM elements
- Fixes fonts UI breaking after HTMX reload (buttons, upload dropzone, etc. not working)

* fix(api): Preserve empty strings for optional string fields in plugin config

- Add _is_field_required() helper to check if fields are required in schema
- Update _parse_form_value_with_schema() to preserve empty strings for optional string fields
- Fixes 400 error when saving MQTT plugin config with empty username/password
- Resolves validation error: 'Expected type string, got NoneType'

* fix(config): Add defaults to schemas and fix None value handling

- Updated merge_with_defaults to replace None values with defaults
- Fixed form processing to skip empty optional fields without defaults
- Added script to automatically add defaults to all plugin config schemas
- Added defaults to 89 fields across 10 plugin schemas
- Prevents validation errors from None values in configs

Changes:
- schema_manager.py: Enhanced merge_with_defaults to replace None with defaults
- api_v3.py: Added _SKIP_FIELD sentinel to skip optional fields without defaults
- add_defaults_to_schemas.py: Script to add sensible defaults to schemas
- Plugin schemas: Added defaults for number, boolean, and array fields

* fix(config): Fix save button spinner by checking HTTP status code

- Fixed handleConfigSave to check xhr.status instead of event.detail.successful
- With hx-swap="none", HTMX doesn't set event.detail.successful
- Now properly detects successful saves (status 200-299) and stops spinner
- Improved error message extraction from API responses
- Also fixed handleToggleResponse for consistency

* fix(web-ui): Resolve GitHub token warning persistence after save

- Made checkGitHubAuthStatus() return Promise for proper async handling
- Clear sessionStorage dismissal flag when token is saved
- Add delay before status check to ensure backend token reload
- Wait for status check completion before hiding settings panel

Fixes issue where GitHub token warnings and pop-ups would not
disappear after successfully saving a token in the web UI.

* fix(web-ui): Add token validation and improve GitHub token warning behavior

- Add token validation to backend API endpoint to check if token is valid/expired
- Implement _validate_github_token() method in PluginStoreManager with caching
- Update frontend to show warning only when token is missing or invalid
- Keep settings panel accessible (collapsible) when token is configured
- Collapse settings panel content after successful token save instead of hiding
- Display specific error messages for invalid/expired tokens
- Clear sessionStorage dismissal flag when token becomes valid

Fixes issue where GitHub token warnings and settings panel would not
properly hide/show based on token status. Now validates token validity
and provides better UX with collapsible settings panel.

* fix(web-ui): Fix CSS/display issue for GitHub token warning and settings

- Update all hide/show operations to use both classList and style.display
- Fix checkGitHubAuthStatus() to properly hide/show warning and settings
- Fix dismissGithubWarning() to use both methods
- Fix toggleGithubTokenSettings() with improved state checking
- Fix collapse button handler with improved state checking
- Fix saveGithubToken() to properly show/collapse settings panel

This ensures elements actually hide/show when status changes, matching
the pattern used elsewhere in the codebase (like toggleSection). All
buttons (dismiss, close, collapse) should now work correctly.

* fix(web-ui): Fix GitHub token expand button functionality

- Convert collapse button handler to named function (toggleGithubTokenContent)
- Improve state checking using class, inline style, and computed style
- Re-attach event listener after saving token to ensure it works
- Add console logging for debugging
- Make function globally accessible for better reliability

Fixes issue where expand button didn't work after saving token.

* fix(web-ui): Remove X button and improve GitHub token panel behavior

- Remove X (close) button from GitHub token configuration panel
- Replace toggleGithubTokenSettings() with openGithubTokenSettings() that only opens
- Auto-collapse panel when token is valid (user must click expand to edit)
- Auto-detect token status on page load (no need to click save)
- Simplify saveGithubToken() to rely on checkGitHubAuthStatus() for UI updates
- Ensure expand button works correctly with proper event listener attachment

The panel now remains visible but collapsed when a token is configured,
allowing users to expand it when needed without the ability to completely hide it.

* refactor(web-ui): Improve GitHub token collapse button code quality

- Update comment to reflect actual behavior (prevent parent click handlers)
- Use empty string for display to defer to CSS instead of hard-coding block/none
- Extract duplicate clone-and-attach logic into attachGithubTokenCollapseHandler() helper
- Make helper function globally accessible for reuse in checkGitHubAuthStatus()

Improves maintainability and makes code more future-proof for layout changes.

* fix(web-ui): Fix collapse/expand button by using removeProperty for display

- Use style.removeProperty('display') instead of style.display = ''
- This properly removes inline styles and defers to CSS classes
- Fixes issue where collapse/expand button stopped working after refactor

* fix(web-ui): Make display handling consistent for token collapse

- Use removeProperty('display') consistently in all places
- Fix checkGitHubAuthStatus() to use removeProperty instead of inline style
- Simplify state checking to rely on hidden class with computed style fallback
- Ensures collapse/expand button works correctly by deferring to CSS classes

* fix(web-ui): Fix token collapse button and simplify state detection

- Simplify state checking to rely on hidden class only (element has class='block')
- Only remove inline display style if it exists (check before removing)
- Add console logging to debug handler attachment
- Ensure collapse/expand works by relying on CSS classes

Fixes issues where:
- Collapse button did nothing
- Auto-detection of token status wasn't working

* debug(web-ui): Add extensive debugging for token collapse button

- Add console logs to track function calls and element detection
- Improve state detection to use computed style as fallback
- Add wrapper function for click handler to ensure it's called
- Better error messages to identify why handler might not attach

This will help identify why the collapse button isn't working.

* debug(web-ui): Add comprehensive debugging for GitHub token features

- Add console logs to checkGitHubAuthStatus() to track execution
- Re-attach collapse handler after plugin store is rendered
- Add error stack traces for better debugging
- Ensure handler is attached when content is dynamically loaded

This will help identify why:
- Auto-detection of token status isn't working
- Collapse button isn't functioning

* fix(web-ui): Move checkGitHubAuthStatus before IIFE to fix scope issue

- Move checkGitHubAuthStatus function definition before IIFE starts
- Function was defined after IIFE but called inside it, causing it to be undefined
- Now function is available when called during initialization
- This should fix auto-detection of token status on page load

* debug(web-ui): Add extensive logging to GitHub token functions

- Add logging when checkGitHubAuthStatus is defined
- Add logging when function is called during initialization
- Add logging in attachGithubTokenCollapseHandler
- Add logging in store render callback
- This will help identify why functions aren't executing

* fix(web-ui): Move GitHub token functions outside IIFE for availability

- Move attachGithubTokenCollapseHandler and toggleGithubTokenContent outside IIFE
- These functions need to be available when store renders, before IIFE completes
- Add logging to initializePlugins to track when it's called
- This should fix the 'undefined' error when store tries to attach handlers

* fix(web-ui): Fix GitHub token content collapse/expand functionality

- Element has 'block' class in HTML which conflicts with 'hidden' class
- When hiding: add 'hidden', remove 'block', set display:none inline
- When showing: remove 'hidden', add 'block', remove inline display
- This ensures proper visibility toggle for the GitHub API Configuration section

* feat(display): Implement on-demand plugin filtering with restart

- Add on-demand plugin filtering to DisplayController initialization
  - Filters available_modes to only include on-demand plugin's modes
  - Allows plugin internal rotation (e.g., NFL upcoming, NCAA FB Recent)
  - Prevents rotation to other plugins
- Implement restart mechanism for on-demand activation/clear
  - _restart_with_on_demand_filter() saves state and restarts with filter
  - _restart_without_on_demand_filter() restores normal operation
  - Supports both systemd service and direct process execution
- Add state preservation across restarts
  - Saves/restores rotation position from cache
  - Restores on-demand config from cache after restart
- Add service detection method
  - Detects if running as systemd service
  - Uses file-based approach for environment variable passing
- Update API endpoints with restart flow comments
- Update systemd service file with on-demand support notes
- Add comprehensive error handling for edge cases

* perf(web-ui): Optimize GitHub token detection speed

- Call checkGitHubAuthStatus immediately when script loads (if elements exist)
- Call it early in initPluginsPage (before full initialization completes)
- Use requestAnimationFrame instead of setTimeout(100ms) for store render callback
- Reduce save token delay from 300ms to 100ms
- Token detection now happens in parallel with other initialization tasks
- This makes token status visible much faster on page load

* fix(ui): Move on-demand modal to base.html for always-available access

- Move on-demand modal from plugins.html to base.html
- Ensures modal is always in DOM when Run On-Demand button is clicked
- Fixes issue where button in plugin_config.html couldn't find modal
- Modal is now available regardless of which tab is active

* fix(ui): Initialize on-demand modal unconditionally on page load

- Create initializeOnDemandModal() function that runs regardless of plugins tab
- Modal is in base.html so it should always be available
- Call initialization on DOMContentLoaded and with timeout
- Fixes 'On-demand modal elements not found' error when clicking button
- Modal setup now happens even if plugins tab hasn't been loaded yet

* fix(ui): Add safety check for updatePluginTabStates function

- Check if updatePluginTabStates exists before calling
- Prevents TypeError when function is not available
- Fixes error when clicking plugin tabs

* fix(ui): Add safety checks for all updatePluginTabStates calls

- Add safety check in Alpine component tab button handler
- Add safety check in Alpine  callback
- Prevents TypeError when function is not available in all contexts

* fix(ui): Add safety check in Alpine  callback for updatePluginTabStates

* debug(ui): Add console logging to trace on-demand modal opening

- Add logging to runPluginOnDemand function
- Add logging to __openOnDemandModalImpl function
- Log plugin lookup, modal element checks, and display changes
- Helps diagnose why modal doesn't open when button is clicked

* debug(ui): Add logging for modal display change

* debug(ui): Add more explicit modal visibility settings and computed style logging

- Set visibility and opacity explicitly when showing modal
- Force reflow to ensure styles are applied
- Log computed styles to diagnose CSS issues
- Helps identify if modal is hidden by CSS rules

* debug(ui): Increase modal z-index and add bounding rect check

- Set z-index to 9999 to ensure modal is above all other elements
- Add bounding rect check to verify modal is in viewport
- Helps diagnose if modal is positioned off-screen or behind other elements

* debug(display): Add detailed logging for on-demand restart flow

- Log when polling finds requests
- Log service detection result
- Log file writing and systemctl commands
- Log restart command execution and results
- Helps diagnose why on-demand restart isn't working

* debug(display): Add logging for on-demand request polling

- Log request_id comparison to diagnose why requests aren't being processed
- Helps identify if request_id matching is preventing processing

* fix(ui): Force modal positioning with !important to override any conflicting styles

- Use cssText with !important flags to ensure modal is always visible
- Remove all inline styles first to start fresh
- Ensure modal is positioned at top:0, left:0 with fixed positioning
- Fixes issue where modal was still positioned off-screen (top: 2422px)

* debug(ui): Add logging to on-demand form submission

- Log form submission events
- Log payload being sent
- Log response status and data
- Helps diagnose why on-demand requests aren't being processed

* fix(display): Remove restart-based on-demand activation

- Replace restart-based activation with immediate mode switch
- On-demand now activates without restarting the service
- Saves rotation state for restoration when on-demand ends
- Fixes infinite restart loop issue
- On-demand now works when display is already running

* docs: Add comprehensive guide for on-demand cache management

- Document all on-demand cache keys and their purposes
- Explain when manual clearing is needed
- Clarify what clearing from cache management tab does/doesn't do
- Provide troubleshooting steps and best practices

* fix(display): Ensure on-demand takes priority over live priority

- Move on-demand check BEFORE live priority check
- Add explicit logging when on-demand overrides live priority
- Improve request_id checking with both instance and persisted checks
- Add debug logging to trace why requests aren't being processed
- Fixes issue where on-demand didn't interrupt live NHL game

* fix(display): Ensure on-demand takes priority over live priority

- Move on-demand check BEFORE live priority check in main loop
- Add explicit logging when on-demand overrides live priority
- Fixes issue where on-demand didn't interrupt live NHL game

* fix(display): Improve on-demand request processing and priority

- Add persistent processed_id check to prevent duplicate processing
- Mark request as processed BEFORE processing to prevent race conditions
- Improve logging to trace request processing
- Ensure on-demand takes priority over live priority (already fixed in previous commit)

* fix(display): Remove duplicate action line

* fix(display): Fix live priority and ensure on-demand overrides it

- Fix live priority to properly set active_mode when live content is detected
- Ensure on-demand check happens before live priority check
- Add debug logging to trace on-demand vs live priority
- Fix live priority to stay on live mode instead of rotating

* fix(display): Add debug logging for on-demand priority check

* fix(display): Add better logging for on-demand request processing

- Add logging to show when requests are blocked by processed_id check
- Add logging to show on-demand state after activation
- Helps debug why on-demand requests aren't being processed

* fix(display): Add detailed logging for on-demand activation and checking

- Log on-demand state after activation to verify it's set correctly
- Add debug logging in main loop to trace on-demand check
- Helps identify why on-demand isn't overriding live priority

* fix(display): Add debug logging for on-demand check in main loop

* fix(display): Remove restart logic from _clear_on_demand and fix cache delete

- Replace cache_manager.delete() with cache_manager.clear_cache()
- Remove restart logic from _clear_on_demand - now clears immediately
- Restore rotation state immediately without restarting
- Fixes AttributeError: 'CacheManager' object has no attribute 'delete'

* fix(display): Remove restart logic from _clear_on_demand

- Remove restart logic - now clears on-demand state immediately
- Restore rotation state immediately without restarting
- Use clear_cache instead of delete (already fixed in previous commit)
- Fixes error when stopping on-demand mode

* feat(display): Clear display before activating on-demand mode

- Clear display and reset state before activating on-demand
- Reset dynamic mode state to ensure clean transition
- Mimics the behavior of manually stopping display first
- Should fix issue where on-demand only works after manual stop

* feat(display): Stop display service before starting on-demand mode

- Stop the display service first if it's running
- Wait 1.5 seconds for clean shutdown
- Then start the service with on-demand request in cache
- Mimics the manual workflow of stopping display first
- Should fix issue where on-demand only works after manual stop

* feat(display): Filter plugins during initialization for on-demand mode

- Check cache for on-demand requests during initialization
- Only load the on-demand plugin if on-demand request is found
- Prevents loading background services for other plugins
- Fixes issue where Hockey/Football data loads even when only Clock is requested

* fix(display): Use filtered enabled_plugins list instead of discovered_plugins

- Use enabled_plugins list which is already filtered for on-demand mode
- Prevents loading all plugins when on-demand mode is active
- Fixes issue where all plugins were loaded even in on-demand mode

* fix(display): Fix on-demand stop request processing and expiration check

- Always process stop requests, even if request_id was seen before
- Fix expiration check to handle cases where on-demand is not active
- Add better logging for stop requests and expiration
- Fixes issue where stop button does nothing and timer doesn't expire

* fix(display): Fix on-demand stop processing, expiration, and plugin filtering

- Fix stop request processing to always process stop requests, bypassing request_id checks
- Fix expiration check logic to properly check on_demand_active and expires_at separately
- Store display_on_demand_config cache key in _activate_on_demand for plugin filtering
- Clear display before switching to on-demand mode to prevent visual artifacts
- Clear display_on_demand_config cache key in _clear_on_demand to prevent stale data
- Implement plugin filtering during initialization based on display_on_demand_config

Fixes issues where:
- Stop button did nothing (stop requests were blocked by request_id check)
- Expiration timer didn't work (logic issue with or condition)
- Plugin filtering didn't work on restart (config cache key never set)
- Display showed artifacts when switching to on-demand (display not cleared)
- All plugins loaded even in on-demand mode (filtering not implemented)

* fix(web): Allow on-demand to work with disabled plugins

- Remove frontend checks that blocked disabled plugins from on-demand
- Backend already supports temporarily enabling disabled plugins during on-demand
- Update UI messages to indicate plugin will be temporarily enabled
- Remove disabled attribute from Run On-Demand button

Fixes issue where disabled plugins couldn't use on-demand feature even
though the backend implementation supports it.

* fix(display): Resolve plugin_id when sent as mode in on-demand requests

- Detect when mode parameter is actually a plugin_id and resolve to first display mode
- Handle case where frontend sends plugin_id as mode (e.g., 'football-scoreboard')
- Add fallback to use first available display mode if provided mode is invalid
- Add logging for mode resolution debugging

Fixes issue where on-demand requests with mode=plugin_id failed with 'invalid-mode' error

* feat(display): Rotate through all plugin modes in on-demand mode

- Store all modes for on-demand plugin instead of locking to single mode
- Rotate through available modes (live, recent, upcoming) when on-demand active
- Skip modes that return False (no content) and move to next mode
- Prioritize live modes if they have content, otherwise skip them
- Add on_demand_modes list and on_demand_mode_index for rotation tracking

Fixes issue where on-demand mode stayed on one mode (e.g., football_recent)
and didn't rotate through other available modes (football_live, football_upcoming).
Now properly rotates through all modes, skipping empty ones.

* fix(display): Improve on-demand stop request handling

- Always process stop requests if on-demand is active, even if same request_id
- Add better logging when stop is requested but on-demand is not active
- Improve logging in _clear_on_demand to show which mode rotation resumes to
- Ensure stop requests are properly acknowledged

Fixes issue where stop button shows as completed but display doesn't resume
normal rotation. Stop requests now properly clear on-demand state and resume.

* security(web): Fix XSS vulnerability in GitHub auth error display

Replace innerHTML usage with safe DOM manipulation:
- Use textContent to clear element and create text nodes
- Create <strong> element via createElement instead of string HTML
- Add safe fallback ('Unknown error') for error messages
- Ensure authData.error/authData.message are treated as plain text
- Avoid trusting backend-provided data as HTML

Fixes XSS vulnerability where malicious HTML in error messages could
be injected into the DOM.

* style(api): Remove unnecessary str() in f-string for error message

Remove explicit str(e) call in error_response f-string since f-strings
automatically convert exceptions to strings. This matches the style used
elsewhere in the file.

Changed: f"Error saving configuration: {str(e)}"
To:      f"Error saving configuration: {e}"

* fix(store): Skip caching for rate-limited 403 responses

When a 403 response indicates a rate limit (detected by checking if
'rate limit' is in response.text.lower()), return the error result but
do NOT cache it in _token_validation_cache. Rate limits are temporary
and should be retried, so caching would incorrectly mark the token as
invalid.

Continue to cache 403 responses that indicate missing token permissions,
as these are persistent issues that should be cached.

This prevents rate-limited responses from being incorrectly cached as
invalid tokens, allowing the system to retry after the rate limit
resets.

* fix(display): Prevent ZeroDivisionError when on_demand_modes is empty

Add guards to check if on_demand_modes is non-empty before performing
any rotation/index math operations. When on_demand_active is True but
on_demand_modes is empty, clear on-demand mode instead of attempting
division by zero.

Fixed in three locations:
1. Mode selection logic (line ~1081): Check before accessing modes
2. Skip to next mode when no content (line ~1190): Guard before modulo
3. Rotate to next mode (line ~1561): Guard before modulo

This prevents ZeroDivisionError when a plugin has no available display
modes or when on_demand_modes becomes empty unexpectedly.

* fix(display): Improve guard for empty on_demand_modes in rotation skip

Refine the guard around lines 1195-1209 to:
- Check if on_demand_modes is empty before any modulo/index operations
- Log warning and debug trace when no modes are configured
- Skip rotation (continue) instead of clearing on-demand mode
- Only perform modulo and index operations when modes are available
- Only log rotation message when next_mode is valid

This prevents ZeroDivisionError and ensures all logging only occurs
when next_mode is valid, providing better traceability.

* fix(display): Populate on_demand_modes when restoring on-demand state from cache

When restoring on-demand state from cache during initialization (around
lines 163-197), the code sets on_demand_active, on_demand_plugin_id and
related fields but does not populate self.on_demand_modes, causing the
run loop to see an empty modes list after restart.

Fix by:
1. Adding _populate_on_demand_modes_from_plugin() method that retrieves
   the plugin's display modes from plugin_display_modes and builds the
   ordered modes list (prioritizing live modes with content, same logic
   as _activate_on_demand)
2. Calling this method after plugin loading completes (around line 296)
   when on_demand_active and on_demand_plugin_id are set
3. Setting on_demand_mode_index to match the restored mode if available,
   otherwise starting at index 0

This ensures on_demand_modes is populated after restart, preventing
empty modes list errors in the run loop.

* docs: Update on-demand documentation to reflect current implementation

Replace obsolete log message reference with current log messages:
- Old: 'Activating on-demand mode... restarting display controller'
- New: 'Processing on-demand start request for plugin' and 'Activated on-demand for plugin'

Update Scenario 2 to reflect immediate mode switching:
- Changed title from 'Infinite Restart Loop' to 'On-Demand Mode Switching Issues'
- Updated symptoms to describe mode switching issues instead of restart loops
- Added note that on-demand now switches modes immediately without restarting
- Updated solution to include display_on_demand_state key

This reflects the current implementation where on-demand activates
immediately without restarting the service.

* fix(api): Fix undefined logger and service stop logic in start_on_demand_display

- Add module-level logger to avoid NameError when logging disabled plugin
- Only stop display service when start_service is True (prevents stopping
  service without restarting when start_service is False)
- Remove unused stop_result variable
- Clean up f-strings that don't need formatting
- Improve code formatting for logger.info call

Fixes issue where logger.info() would raise NameError and where the
service would be stopped even when start_service=False, leaving the
service stopped without restarting it.

---------

Signed-off-by: Chuck <33324927+ChuckBuilds@users.noreply.github.com>
Co-authored-by: Chuck <chuck@example.com>
2026-01-01 18:27:58 -05:00