- wifi_monitor_daemon: reset _consecutive_internet_failures = 0 in both
NM-restart exception handlers; previously both left the counter at threshold,
causing an immediate retry on the next iteration instead of waiting another
full backoff period
- api_v3: fix start_display failure message — when mode is set and systemctl
returns non-zero, message now includes the failure reason and a hint rather
than always reporting success phrasing
- wifi_manager: move _redirect_backend from class variable to instance variable
in __init__ alongside _ap_enabled_at; class-level default shadowed correctly
in practice (single instance) but was misleading
- wifi_manager: narrow broad except Exception in _check_internet_connectivity
to (subprocess.SubprocessError, OSError) for ping and OSError for HTTP
(urllib.error.URLError is an OSError subclass in Python 3)
- wifi_manager: remove redundant local 'import re as _re' in _validate_ap_config;
re is already imported at module level (line 37)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Fixed:
- march-madness/requirements.txt: Pillow>=10.3.0 (patches CVE-2024-28219;
10.3.0 is the actual fix version — reviewer cited 12.2.0 but that risks
breaking API changes without test coverage)
- wifi_monitor_daemon.py: add missing `import subprocess`; subprocess.run
and CalledProcessError would NameError at runtime on the NM restart path
- wifi_manager.py: validate ap_idle_timeout_minutes before arithmetic —
coerce to int, clamp 1–1440, fall back to 15 on bad config values
- wifi_manager.py: call _remove_nm_dnsmasq_captive_conf() on all three
rollback paths in _enable_ap_mode_nmcli_hotspot() and in the top-level
except block so stale dnsmasq drop-ins are never left behind
- api_v3.py: fix wrong_password prefix strip — removeprefix("wrong_password:")
then lstrip() handles both "wrong_password: msg" and "wrong_password:msg"
- plugins_manager.js: add .catch() to loadInstalledPlugins().then() to
surface failures instead of silently dropping unhandled rejections
Skipped:
- WiFiManager AP state persistence: architectural overhaul; _is_ap_mode_active()
already derives from live system state, not in-memory variables
- Absolute subprocess paths in api_v3.py: paths vary by distro (/usr/bin vs
/bin); web service has a normal PATH; sudoers already use resolved paths
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The counter reset after NM restart already fully prevents the SSH-lockout
cascade: _disconnected_checks can never accumulate across NM restarts
because it is reset to 0 before the next daemon iteration runs.
The 3→6 increase provided no additional fix for the described problem and
caused a UX regression: fresh Pi devices with no WiFi configured would
wait 3 minutes instead of 90 seconds for the LEDMatrix-Setup hotspot to
appear.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Two user-reported issues after fresh install:
1. All service buttons (Start/Stop/Restart Display, Restart Web Service)
failed silently — only Reboot worked.
Root cause: sudoers rules use `ledmatrix.service` (with suffix) but
api_v3.py called `sudo systemctl start ledmatrix` (no suffix). sudo
does exact string matching, so every service action was rejected with
returncode=1. Also missing from sudoers: ledmatrix-web, journalctl,
and is-active entries.
Fix:
- Add `.service` suffix to all 8 sudo systemctl call sites in
api_v3.py (_ensure_display_service_running, _stop_display_service,
and all execute_system_action branches).
- Add timeout=15 to all subprocess.run calls in execute_system_action
(previously could hang indefinitely).
- Add missing sudoers rules to first_time_install.sh and
configure_web_sudo.sh: ledmatrix-web.service start/stop/restart,
is-active for both name forms, and journalctl -u/-t ledmatrix rules.
2. SSH and web UI became inaccessible after ~1 hour even though the
display kept running.
Root cause: wifi_monitor_daemon restarts NetworkManager after 5
consecutive internet failures (~2.5 min). Each NM restart drops WiFi
briefly. During that window check_and_manage_ap_mode() increments
_disconnected_checks but the daemon never reset it after the restart.
After 3 such NM-restart cycles, _disconnected_checks reached 3 and
AP mode activated — changing the Pi from WiFi client to hotspot
(192.168.4.1) and killing SSH on the old IP.
Fix:
- Reset wifi_manager._disconnected_checks = 0 in the daemon
immediately after a successful NM restart so the brief drop it
causes doesn't count toward AP-mode activation.
- Increase _disconnected_checks_required from 3 to 6 (90s → 3min)
as an additional buffer against transient network flaps.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
pages_v3.py: plugin_id is taken directly from the URL path and was
interpolated into a returned HTML fragment without escaping. A crafted
URL like /partials/plugin-config/<script>alert(1)</script> would inject
arbitrary HTML into any page that loads this HTMX partial.
Fix: wrap with html.escape() from the stdlib.
march-madness/requirements.txt: Pillow>=9.1.0 is vulnerable to
CVE-2023-50447 (arbitrary code execution via the environment parameter).
Bump minimum to >=10.2.0 which contains the fix.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Direct .hasOwnProperty() calls on objects can be shadowed if the object
itself has a property named hasOwnProperty. Using Object.prototype.
hasOwnProperty.call(obj, key) is the safe, ESLint-compliant form.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
wifi_manager.py:
- Add public check_internet_connectivity() wrapping the private method so the
daemon does not reach into the private API
wifi_monitor_daemon.py:
- Call wifi_manager.check_internet_connectivity() instead of the private
_check_internet_connectivity()
- Use /usr/bin/systemctl (absolute path) instead of bare "systemctl"
- Wrap NM restart in try/except with check=True; only reset
_consecutive_internet_failures on success — on CalledProcessError or other
exception, log the error and leave the counter unchanged so the next cycle
retries
test/test_wifi_manager_ap.py:
- Replace loose `assert "ap" in add_calls[0]` (list-membership check that
could be satisfied by any element equal to "ap") with an explicit key/value
check: locate "802-11-wireless.mode" in the command list and assert the next
element is exactly "ap"
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Tested on devpi (Trixie, NM 1.52.1): iptables is not installed; nftables is.
The original code called _setup_iptables_redirect() and treated 'iptables not
found' as a hard failure, rolling back the entire AP setup.
Changes:
- _setup_iptables_redirect() now tries iptables first, then nftables as a
fallback. When neither is available it logs a warning and returns True so
the AP still comes up (DNS spoofing still triggers the captive portal popup;
users land on port 5000 directly instead of being auto-redirected from 80).
- Split into _setup_iptables_redirect_iptables() and
_setup_iptables_redirect_nftables() for clarity.
- Added _redirect_backend instance var ("iptables" | "nftables" | None) so
_teardown_iptables_redirect() uses the same tool that setup used.
- nftables teardown: deletes the 'ledmatrix' table (clean, no leftover rules).
- iptables teardown: unchanged logic (ip_forward save/restore).
- Also removed the PMF workaround for Trixie: 802-11-wireless-security.pmf
requires key-mgmt to also be set, breaking open-network creation on NM 1.52+.
Open APs have no management frame protection by definition.
- Update teardown test to set _redirect_backend = "iptables" before calling it.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
802-11-wireless-security.pmf is only valid within a security section that also
includes key-mgmt. Adding it to an open-network profile causes NM 1.52+ to
reject the connection add with 'key-mgmt: property is missing'. PMF has no
meaning for open APs (it only applies to WPA2/WPA3), so the setting is simply
removed rather than worked around.
Found by testing on devpi (Trixie, NM 1.52.1).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The previous commit introduced _check_internet_connectivity() into
check_and_manage_ap_mode(), which shared the same _disconnected_checks counter
that triggers AP enable. This created a false-positive risk: 90 seconds of
packet loss on working WiFi would enable AP mode and kick off the connection.
Fix: restore nmcli association state as the sole AP-enable trigger (original,
safe behaviour). The internet connectivity check is now used only in the daemon
watchdog for the NM-restart escalation — matching how adsb-feeder-image actually
structures the two concerns (initial setup detection vs. ongoing monitoring).
Also clarify daemon comment: the connectivity check runs once per cycle in the
watchdog block, not inside check_and_manage_ap_mode, so there is no double-call.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Inspired by the production-proven approach in dirkhh/adsb-feeder-image.
1. DNS spoofing for automatic captive-portal popup (Change 1 — Critical)
Write /etc/NetworkManager/dnsmasq-shared.d/ledmatrix-captive.conf with
address=/#/192.168.4.1 before nmcli connection up so NM's built-in
dnsmasq (ipv4.method=shared) resolves every hostname to the AP IP.
This triggers the OS captive-portal popup automatically on iOS / Android /
Windows / macOS — no manual navigation to 192.168.4.1:5000/setup required.
New helpers: _write_nm_dnsmasq_captive_conf / _remove_nm_dnsmasq_captive_conf.
New constants: NM_DNSMASQ_SHARED_DIR / NM_DNSMASQ_SHARED_CONF.
2. Real internet connectivity check (Change 2 — High)
Add _check_internet_connectivity() (ping 8.8.8.8 + HTTP fallback).
check_and_manage_ap_mode() now considers a device "disconnected" when nmcli
shows connected but no real internet reachability, matching adsb-feeder's
multi-method gateway/DNS/HTTP test approach.
3. AP idle timeout (Change 3 — Medium)
Track _ap_enabled_at timestamp in enable_ap_mode(). Add _has_ap_clients()
using 'iw dev <iface> station dump'. check_and_manage_ap_mode() auto-disables
AP after ap_idle_timeout_minutes (default 15) with no associated clients.
4. Wrong-password error feedback (Change 4 — Medium)
_connect_nmcli() detects "Secrets were required" / "authentication rejected"
in nmcli stderr and prefixes the message with "wrong_password: ".
The /api/v3/wifi/connect route propagates error_type="wrong_password" in the
JSON response. captive_setup.html shows "Incorrect password — try again"
(keeping the form active) instead of the generic failure message.
5. Escalating watchdog NM restart (Change 5 — Low)
wifi_monitor_daemon.py tracks _consecutive_internet_failures. After
_nm_restart_threshold (5) consecutive checks where nmcli shows connected but
internet is unreachable, restart NetworkManager as a recovery step.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Six pytest unit tests covering the five review scenarios. All subprocess and
filesystem side-effects are mocked so the tests run without root, hardware, or
a Pi OS environment.
1. test_nmcli_ap_profile_has_no_security_params — asserts the nmcli connection
add command has no key-mgmt / psk / WPA arguments and sets mode=ap.
2. test_iptables_nat_rules_added_on_ap_start — verifies _setup_iptables_redirect
emits a PREROUTING REDIRECT 80→5000 rule and an INPUT ACCEPT rule for port
5000 (not 80, which never hits INPUT after PREROUTING rewrites it).
3. test_iptables_rules_and_ip_forward_reverted_on_teardown — verifies the -D
PREROUTING/-D INPUT calls and that sysctl restores the saved ip_forward value
and removes the save file.
4. test_ip_forward_not_restored_when_save_file_absent — verifies teardown skips
sysctl when the save file was never written, preventing blind ip_forward=0 on
systems using ip_forward for VPNs or NM shared mode.
5. test_led_message_shows_ssid_no_password_and_url — asserts the LED message
includes the SSID, 'No password', and the 192.168.4.1:5000 setup URL.
6. test_existing_ap_profiles_deleted_before_new_profile_created — asserts all
known profile names are targeted for deletion before 'nmcli connection add'.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Both AP startup paths (hostapd and nmcli) now check the bool returned by
_setup_iptables_redirect() and treat False as a hard failure: the hostapd
path stops hostapd/dnsmasq and returns an error tuple; the nmcli path brings
down and deletes the LEDMatrix-Setup-AP profile and clears the LED message
- _enable_ap_mode_hostapd's LED message now calls _validate_ap_config() to get
the same sanitized SSID that _create_hostapd_config() uses, so the displayed
name always matches the AP actually broadcast by hostapd
- _setup_iptables_redirect's outer except block now calls
_teardown_iptables_redirect() before returning False so partial iptables/
ip_forward state is always cleaned up on unexpected exceptions; cleanup
exceptions are caught and logged separately
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add _find_command_path() helper that extends _check_command()'s sbin-aware lookup to
return the absolute binary path rather than a boolean. Use it in
_setup_iptables_redirect and _teardown_iptables_redirect so iptables and sysctl are
resolved via /sbin or /usr/sbin even when those directories are absent from PATH in
systemd service environments.
Also harden the ip_forward save/restore logic:
- Read ip_forward from /proc/sys/net/ipv4/ip_forward (no subprocess, no PATH
dependency) instead of spawning sysctl -n
- Skip the sysctl -w ip_forward=1 write when the value is already "1" to avoid
mutating state owned by another service (VPN, NM shared mode, bridge)
- Track save success via presence of the save file: if the /proc read or file write
fails, leave the file absent so teardown knows not to restore
- In _teardown_iptables_redirect, only restore ip_forward when the save file exists;
if absent, leave the current value untouched rather than forcing "0"
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
wifi_manager.py:
- _create_hostapd_config: use _validate_ap_config() for ssid/channel instead
of raw self.config values; strip newlines from SSID to prevent config-file
injection via the generated hostapd.conf
- _setup_iptables_redirect: check return codes of sysctl ip_forward enable and
both iptables -A calls; on any failure log the error output, call
_teardown_iptables_redirect() to restore state, and return False instead of
silently succeeding
- _enable_ap_mode_nmcli_hotspot: on AP verification failure roll back fully —
tear down iptables redirect, delete the LEDMatrix-Setup-AP connection profile,
clear the LED message — before returning False
plugins_manager.js:
- initializePlugins: chain searchPluginStore(!isReswapWarm) inside
loadInstalledPlugins().then() so window.installedPlugins is populated before
the store renders Installed/Reinstall badges (same pattern applied to
refreshPlugins() in the previous commit)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
refreshPlugins() called searchPluginStore(true) and showNotification() immediately
after refreshInstalledPlugins() without awaiting the returned Promise, so
window.installedPlugins could still be stale when the store rendered its
Installed/Reinstall badges. Chain .then() so both run only after the fetch
completes.
In initializePlugins(), the re-swap path always passed fetchCommitInfo=false to
searchPluginStore, skipping GitHub metadata even when the 5-minute cache TTL had
expired. Add storeCacheExpired() helper and compute isReswapWarm = _reswap &&
!storeCacheExpired() so fresh metadata is fetched whenever the cache is cold,
regardless of whether the render is a first load or a tab re-swap.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Validate ap_ssid/ap_channel from config before passing to subprocess
(printable ASCII ≤32 chars; channel 1-14) to prevent command injection
- Fix INPUT iptables rule: PREROUTING redirects port 80→5000 so the INPUT
chain sees dport=5000, not 80. Old INPUT rule on port 80 was a no-op.
- Refactor iptables setup/teardown into _setup_iptables_redirect() and
_teardown_iptables_redirect() helpers, eliminating duplicate logic in
the hostapd and nmcli paths
- Save/restore ip_forward state (via /tmp/ledmatrix_ip_forward_saved)
instead of forcing it to 0 on cleanup, which could break VPNs or
bridges already relying on forwarding
- nmcli path skips ip_forward management entirely: NM's ipv4.method=shared
already manages it for the duration of the connection
- Fix _get_ap_status_nmcli() verification: new 'connection add type wifi'
profiles have type '802-11-wireless', not 'hotspot', so verification was
always returning False. Now also matches by our known connection name.
- Remove SSID-based connection deletion: deleting any profile whose SSID
matched the AP SSID could destroy a user's saved home WiFi profile.
Now only deletes by our application-managed profile names.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
nmcli device wifi hotspot always attaches a WPA2 PSK on Bookworm/Trixie
and silently ignores post-creation security modifications, causing users
to be prompted for an unknown password. Switch to nmcli connection add
with 802-11-wireless.mode ap and no security section — NM cannot auto-add
a password to a profile that has no 802-11-wireless-security block.
Also:
- Remove dead DEFAULT_AP_PASSWORD / ap_password config field (stored but
never passed to hostapd or nmcli, causing user confusion)
- Add iptables port 80→5000 redirect to the nmcli AP path so captive portal
auto-popup works on phones without hostapd (previously only worked on
the hostapd path)
- Clean up iptables rules on disable for the nmcli path
- Improve LED message on AP enable: show SSID, "No password", and IP:port
on both paths so users know exactly how to connect
- Fix systemd template: replace hardcoded /home/ledpi/LEDMatrix/ with
__PROJECT_ROOT_DIR__ placeholder (install script already writes correct path)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(plugins): reset pluginsInitialized on HTMX re-swap; use refreshInstalledPlugins in refreshPlugins
HTMX's `revealed` trigger fires each time the plugins tab becomes
visible (Alpine's x-show toggles display:none which re-triggers the
IntersectionObserver). Each re-reveal fetches a fresh empty HTML
skeleton via hx-swap="innerHTML". The htmx:afterSwap handler reset
window.pluginManager.initialized/initializing but not pluginsInitialized,
so initializePlugins() hit its guard and skipped loadInstalledPlugins()
and searchPluginStore() — leaving the fresh empty DOM unpopulated.
Fix: also reset pluginsInitialized = false in the afterSwap handler.
Existing caches (3s for installed plugins, 5min for store) mean tab
revisits within the TTL render from cache instantly with no extra
API traffic.
Also change refreshPlugins() to call refreshInstalledPlugins() (which
already exists and explicitly invalidates the cache) instead of the
bare loadInstalledPlugins() call that could silently skip the fetch
if the 3-second cache happened to still be valid.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(plugins): use cached store on HTMX re-swap; reserve searchPluginStore(true) for first load
searchPluginStore(true) bypasses the isCacheValid check unconditionally,
so every tab revisit was hitting the GitHub commit-info API even within
the 5-minute cache window.
Set window.pluginManager._reswap = true in the htmx:afterSwap handler
and read it in initializePlugins() to call searchPluginStore(false) on
re-swaps (respects the 5-minute cache) vs searchPluginStore(true) on
first load (always fetches fresh). Explicit user refresh via
refreshPlugins() already calls searchPluginStore(true) directly.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(install): remove weather and music credential stubs from secrets template
config_secrets.template.json shipped ledmatrix-weather and music as
top-level keys; config_manager deep-merges secrets into the main config
on load, so the reconciler treated them as plugin config entries and
auto-installed both plugins on first web UI visit after a fresh install.
Remove both keys from the template and clear the inline fallback block
in first_time_install.sh so new installs start clean.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(install): sync fallback secrets with template structure
The fallback block (used when config_secrets.template.json is missing)
was an empty object after the weather/music keys were removed. Mirror
the current template so youtube and github placeholders are always
present regardless of whether the template file exists.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(web): add update-available banner to web UI
Adds a polite, dismissible banner between the header and navigation
tabs that appears when the local repo is behind origin/main. Shows
commit count and a one-click "Update Now" button that triggers the
existing git_pull action.
- New GET /api/v3/system/check-update endpoint (5-min cache, compares
local HEAD vs origin/main SHA)
- Banner auto-checks on page load then every 30 minutes
- Dismiss persists for the browser session via sessionStorage
- Styled for both light and dark themes
- Cache invalidated after successful git_pull so banner hides immediately
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(update-banner): address review findings — lock, returncode checks, update_available logic, a11y, button state
- Add _update_check_lock (threading.Lock) around all reads/writes to
_update_check_cache in check_for_update() and git_pull, preventing
races on concurrent requests
- Validate returncode for git fetch, rev-parse HEAD, and rev-parse
origin/main; raise RuntimeError on failure so errors are caught and
returned as error payloads instead of silently producing stale/empty SHAs
- Set update_available = commits_behind > 0 (was unconditionally True
when local_sha != remote_sha); prevents false positive when local is
ahead of remote
- Add type="button" and aria-label="Dismiss update" to the icon-only
dismiss button
- Restore btn.innerHTML and btn.disabled in both success and error paths
of applyUpdate(); only hide the banner and clear sessionStorage when
data.status === 'success'
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(update-banner): address second-round review findings
api_v3.py:
- Move all git work inside _update_check_lock so concurrent requests
re-check cache staleness after acquiring the lock; only the first
caller runs git fetch/rev-parse/log, subsequent callers return the
cached result
- Check log_result.returncode and raise on failure so a broken git log
doesn't produce a silent false-negative (commits_behind=0)
- Rename loop variable l → commit_line
base.html:
- Replace boolean _dismissed flag with SHA-scoped sessionStorage key
'update-sha-dismissed'; dismissing for SHA X still allows the banner
to reappear when origin/main advances to SHA Y
- Successful applyUpdate clears 'update-sha-dismissed' so the next
update cycle can show the banner again
- Add aria-live="polite" aria-atomic="true" to #update-banner-text so
screen readers announce content changes
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(plugin_manager): prevent permanent ERROR state after update timeout
When execute_update() fails (timeout or unhandled exception), the plugin
state was set to ERROR with no recovery path. can_execute() returns False
for ERROR state, so the plugin's update() was never called again, leaving
it showing stale data indefinitely.
Instead, update plugin_last_update so the plugin waits one configured
interval before retrying, and keep the state ENABLED so recovery is
automatic on the next cycle.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(plugin_manager): address PR review — failure timestamp and error context
- Use time.time() at the point of failure instead of reusing current_time
(captured before execution), so the full retry interval always elapses
after a timeout rather than one execution-duration shorter
- Add PluginStateManager.set_error_info() to persist structured error context
without changing plugin state; call it in both failure branches so
get_error_info() / get_state_info() surface recoverable errors alongside
ERROR-state errors
- Add warning log on the success=False branch (was previously silent)
- Pass a descriptive Exception (not a generic "Plugin execution failed") to
health_tracker.record_failure() in the timeout/executor-error path
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(plugin_manager): atomic state+error write via set_state_with_error
The two-step set_state() / set_error_info() sequence left a window where
readers could observe ENABLED state without the accompanying error context.
Add threading.RLock to PluginStateManager and a new set_state_with_error()
method that holds the lock for both the state-transition write and the
_error_info write together. The method inlines the state-transition logic
rather than calling set_state() internally to intentionally skip the
"clear _error_info for non-ERROR states" side effect — the recoverable
error dict is exactly what we want stored.
Replace both paired set_state / set_error_info call sites in
run_scheduled_updates() with the single atomic method.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(plugin_state): lock _error_info accesses and store defensive copies
Three verified issues:
- set_error_info wrote _error_info without holding _lock and stored the
caller's dict by reference, allowing races and post-write mutation
- set_state_with_error stored error_info by reference (lock was already held)
- get_error_info read _error_info without _lock and returned the live
reference, letting callers mutate the stored snapshot
Implicit fourth fix: set_state also wrote _error_info without _lock; locking
get_error_info while leaving that writer unguarded would have created a new
race, so set_state is now wrapped in _lock too for consistency.
Changes:
- set_state: wrap entire body in self._lock (covers _states, _state_history,
and _error_info writes atomically; ERROR-path _error_info value was already
a fresh dict literal so no copy needed)
- set_error_info: acquire self._lock + store dict(error_info) shallow copy
- set_state_with_error: store dict(error_info) shallow copy (lock already held)
- get_error_info: acquire self._lock + return dict(info) copy or None
All stored values are flat dicts of strings/floats/bools, so shallow copy
is sufficient — deepcopy is not needed.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(plugin_manager): apply recovery logic to update_all_plugins; extract helper
update_all_plugins still set PluginState.ERROR on both failure paths, leaving
it inconsistent with the run_scheduled_updates fix from the same PR.
Extract _record_update_failure(plugin_id, exc=None) to hold all shared failure
logic: capture actual failure time, build structured error_info, log the retry
warning, stamp plugin_last_update, call set_state_with_error(ENABLED), and
forward to health_tracker. Replace all four failure sites (two in
run_scheduled_updates, two in update_all_plugins) with calls to this helper.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(web): add config backup & restore UI
Adds a Backup & Restore tab to the v3 web UI that packages user config,
secrets, WiFi, user-uploaded fonts, plugin image uploads, and the installed
plugin list into a single ZIP for safe reinstall recovery. Restore extracts
the bundle, snapshots current state via the existing atomic config manager
(so rollback stays available), reapplies the selected sections, and
optionally reinstalls missing plugins from the store.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(backup): address PR review findings
- backup_manager: read plugin state from "states" key (not "plugins") to
match the actual plugin_state.json format written by state_manager
- backup_manager: stream ZIP directly to a temp file instead of building
it in an io.BytesIO buffer to avoid OOM on Raspberry Pi
- backup_manager: tighten plugin-uploads path validation in validate_backup
and restore_backup to require "/uploads/" in the path, rejecting any
non-uploads files smuggled under assets/plugins/
- api_v3: enforce 200 MB upload limit by streaming in chunks rather than
relying on validate_file_upload (which only checks the filename)
- api_v3: replace bool() with _coerce_to_bool() for RestoreOptions fields
so string "false" is not treated as truthy
- api_v3: capture and log _save_config_atomic return value instead of
discarding it; log rather than silence font-cache and config-reload errors
- backup_restore.html: track inspectedFile so runRestore always applies to
the file the user inspected, not a subsequently selected file; clear on
input change or clearRestore()
- backup_restore.html: throw on non-success restore payload so errors are
surfaced via the error notification path instead of yellow "warnings"
- test: update fixture to use correct "states" key structure; import
SCHEMA_VERSION constant instead of hardcoding 1; rename unused err -> _err
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(backup): address second round of PR review findings
- api_v3: guard opts_dict with isinstance check after json.loads so a
non-object JSON payload (null, array, etc.) returns a 400 instead of a
500 AttributeError
- backup_manager: wrap tmp ZIP creation and os.replace in try/except so
the .zip.tmp temp file is always removed on any failure
- backup_manager: replace hardcoded Path("/tmp/_zip_check") sentinel in
validate_backup with a proper tempfile.TemporaryDirectory() so path
traversal checks are portable and leave no artifacts
- backup_restore.html: detect partial-success responses (plugins_failed or
errors non-empty) even when status is 'success' and render yellow/warning
styling and notify instead of green
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(backup): add post-install steps for restored plugins; conditional restart hint
- api_v3: after a successful plugin reinstall during restore, run the same
post-install sequence used by the normal /plugins/install flow:
invalidate schema cache, discover_plugins()/load_plugin(), and
set_plugin_installed() so restored plugins are immediately available
- backup_restore.html: only show the "restart the display service" hint
when at least one item was restored or at least one plugin was installed
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(backup): address Codacy findings
- api_v3: replace 'fonts' in ' '.join(result.restored) substring check
with any(r.startswith("fonts") for r in result.restored) to avoid
fragile joined-string membership testing
- api_v3: replace deprecated datetime.utcnow() and utcfromtimestamp()
with datetime.now(timezone.utc) and fromtimestamp(..., timezone.utc);
add timezone to import
- test: remove unused import io (backup_manager no longer uses BytesIO)
- src/backup_manager.py hardcoded /tmp sentinel was already fixed in a
prior commit (tempfile.TemporaryDirectory)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(web): list calendar endpoint works without a running plugin instance
The web service (ledmatrix-web) and display service (ledmatrix) run in
separate processes. The web process discovers plugins but never
instantiates them — only the display process does. Consequently
api_v3.plugin_manager.get_plugin('calendar') always returns None in the
web process, and /api/v3/plugins/calendar/list-calendars responded with
404 "Calendar plugin is not running. Enable it and save config first."
even when the plugin was enabled, saved, and authenticated.
Fall back to reading token.pickle + credentials.json directly from the
calendar plugin directory and calling the Google Calendar API from the
web process. A live plugin instance is still preferred when available
(e.g. local dev where web and display share a process).
Also surface clearer, actionable error messages for missing/expired
tokens so users know to re-run the authentication step.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(web): address review feedback on calendar list endpoint
- Remove unused creds_path local
- Prefer google-auth JSON token (token.json) via Credentials.from_authorized_user_file; keep pickle fallback for backward compat, guarded by a size check and a comment describing the trust boundary (owner-only file inside the plugin dir, not user-supplied input)
- Do NOT persist refreshed credentials from the web request path; the display service owns token.pickle and concurrent writes could corrupt it. Refresh happens in memory only for the duration of the request
- Add explicit timeouts to token refresh (via Request(timeout=...)) and to the Calendar API list call (num_retries=1), and return a retryable user-facing message on socket.timeout / TimeoutError
- Import socket at module scope
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(web): apply real socket timeout to Google Calendar API calls
googleapiclient's execute() does not accept a timeout kwarg — the
timeout comes from the httplib2.Http the service was built with.
Build an AuthorizedHttp wrapping httplib2.Http(timeout=15) so
calendarList().list().execute() cannot hang the Flask worker on
flaky connectivity. Disable discovery doc caching to avoid the
default file-cache warning in this ephemeral request path.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: ChuckBuilds <ChuckBuilds@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The finally block in handleSingleFileUpload referenced an undefined
fileInput variable left over from an earlier refactor, causing an
"Unhandled promise rejection: ReferenceError" after every single-file
upload (e.g. OAuth credentials.json for the calendar plugin) even when
the upload itself succeeded.
Resolve the file input by id inside the finally block so it can be
cleared when present, tolerating the drop-zone-only case.
Co-authored-by: ChuckBuilds <ChuckBuilds@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two bugs that prevented the web-ui-info default plugin from loading:
1. Orphaned `except Exception` at line 203 with no matching `try` —
caused a SyntaxError preventing the module from importing at all.
Removed the orphan; the outer try/except already covers the block.
2. `_get_local_ip()` called in __init__ before `_ap_mode_cache_time`
was initialized, causing AttributeError. Moved AP mode cache field
initialization above the `_get_local_ip()` call.
Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
A user reported that buttons in the v3 web UI were unresponsive in Safari
after a fresh install. The screenshots showed Alpine.js actually running
fine end-to-end — the real issues are a narrow handler-exposure bug and
some latent brittleness worth cleaning up at the same time.
plugins_manager.js: attachInstallButtonHandler and setupGitHubInstallHandlers
were declared inside the main IIFE, but the typeof guards that tried to
expose them on window ran *outside* the IIFE, so typeof always evaluated
to 'undefined' and the assignments were silently skipped. The GitHub
"Install from URL" button therefore had no click handler and the console
printed [FALLBACK] attachInstallButtonHandler not available on window on
every load. Fixed by assigning window.attachInstallButtonHandler and
window.setupGitHubInstallHandlers *inside* the IIFE just before it closes,
and removing the dead outside-the-IIFE guards.
base.html: the Alpine.js loader was a 50-line dynamic-script + deferLoadingAlpine
+ isAPMode branching block. script.defer = true on a dynamically-inserted
<script> is a no-op (dynamic scripts are always async), the
deferLoadingAlpine wrapper was cargo-culted, and the AP-mode branching
reached out to unpkg unnecessarily on LAN installs even though
alpinejs.min.js already ships in web_interface/static/v3/js/. Replaced
with a single <script defer src="..."> tag pointing at the local file plus
a small window-load rescue that only pulls the CDN copy if window.Alpine
is still undefined.
start.py / app.py: app.run() has defaulted to threaded=True since Flask
1.0 so this is not a behavior change, but the two long-lived
/api/v3/stream/* SSE endpoints would starve every other request under a
single-threaded server. Setting threaded=True explicitly makes the
intent self-documenting and guards against future regressions.
Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* 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>
* 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
" 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>
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>
* 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>
* 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>
The data-item-properties attribute on the Add Item button was serialized
inside double-quoted HTML using {{ item_properties|tojson|e }}. Jinja2's
|tojson returns Markup (marked safe), making |e a no-op — the JSON
double quotes were not escaped to ". The browser truncated the
attribute at the first " in the JSON, so addArrayTableRow() parsed an
empty object and created rows with only a trash icon.
Fix: switch to single-quote attribute delimiters (JSON only uses double
quotes internally) and filter item_properties to only the display
columns, avoiding large nested objects in the attribute value.
Closes#302
Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(web): resolve plugin settings tabs not loading due to enhancement race
Two co-occurring bugs prevented plugin setting tabs from loading:
1. Both stub-to-full app() enhancement paths (tryEnhance and
requestAnimationFrame) could fire independently, with the second
overwriting installedPlugins back to [] after init() already fetched
them. Added a guard flag (_appEnhanced) and runtime state preservation
to prevent this race.
2. Plugin config x-init only loaded content if window.htmx was available
at that exact moment, with no retry or fallback. Added retry loop
(up to 3s) and fetch() fallback for resilience.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(web): use runtime default tab and add Alpine.initTree to fetch fallback
- Replace hard-coded 'overview' comparison with runtime defaultTab
(isAPMode ? 'wifi' : 'overview') in both enhancement paths, so
activeTab is preserved correctly in AP mode
- Add Alpine.initTree(el) call in the plugin config fetch() fallback
so Alpine directives in the injected HTML are initialized, matching
the pattern used by loadOverviewDirect and loadWifiDirect
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(web): increase chain_length max from 8 to 32
The web UI form input capped chain_length at 8 panels, preventing
users with larger displays (e.g. 16-panel setups) from configuring
their hardware through the UI. The backend API had no such limit.
Changed max="8" to max="32" to support large display configurations.
Added panel count example to the help text.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(web): add server-side bounds validation for display hardware fields
The API endpoint at /api/v3/config/main accepted any integer value for
display hardware fields (chain_length, rows, cols, brightness, etc.)
without bounds checking. Only the HTML form had min/max attributes,
which are trivially bypassed by direct API calls.
Added _int_field_limits dict with bounds for all integer hardware fields:
chain_length: 1-32, parallel: 1-4, brightness: 1-100,
rows: 8-128, cols: 16-128, scan_mode: 0-1, pwm_bits: 1-11,
pwm_dither_bits: 0-2, pwm_lsb_nanoseconds: 50-500,
limit_refresh_rate_hz: 0-1000, gpio_slowdown: 0-5
Out-of-bounds or non-integer values now return 400 with a clear error
message (e.g. "Invalid chain_length value 99. Must be between 1 and 32.")
before any config is persisted. Follows the same inline validation
pattern already used for led_rgb_sequence, panel_type, and multiplexing.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(api): strict int validation and add max_dynamic_duration_seconds bounds
Reject bool/float types in _int_field_limits validation loop to prevent
silent coercion, and add max_dynamic_duration_seconds to the validation
map so it gets proper bounds checking instead of a raw int() call.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* 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>
* 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>
The baseUrl variable was declared inside an IIFE that skips re-execution
on HTMX reloads, so it became undefined when the fonts tab was reloaded.
Since baseUrl was just window.location.origin prepended to absolute paths
like /api/v3/fonts/upload, it was unnecessary — fetch() with a leading
slash already resolves against the current origin.
Remove baseUrl entirely and use relative URLs in all 7 fetch calls.
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* 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>
* fix: handle dotted schema keys in plugin settings save (issue #254)
The soccer plugin uses dotted keys like "eng.1" for league identifiers.
PR #260 fixed backend helpers but the JS frontend still corrupted these
keys by naively splitting on dots. This fixes both the JS and remaining
Python code paths:
- JS getSchemaProperty(): greedy longest-match for dotted property names
- JS dotToNested(): schema-aware key grouping to preserve "eng.1" as one key
- Python fix_array_structures(): remove broken prefix re-navigation in recursion
- Python ensure_array_defaults(): same prefix navigation fix
Closes#254
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: address review findings for dotted-key handling
- ensure_array_defaults: replace None nodes with {} so recursion
proceeds into nested objects (was skipping when key existed as None)
- dotToNested: add tail-matching that checks the full remaining dotted
tail against the current schema level before greedy intermediate
matching, preventing leaf dotted keys from being split
- syncFormToJson: replace naive key.split('.') reconstruction with
dotToNested(flatConfig, schema) and schema-aware getSchemaProperty()
so the JSON tab save path produces the same correct nesting as the
form submit path
- Add regression tests for dotted-key array normalization and None
array default replacement
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: address second round of review findings
- Tests: replace conditional `if response.status_code == 200` guards
with unconditional `assert response.status_code == 200` so failures
are not silently swallowed
- dotToNested: guard finalKey write with `if (i < parts.length)` to
prevent empty-string key pollution when tail-matching consumed all
parts
- Extract normalizeFormDataForConfig() helper from handlePluginConfigSubmit
and call it from both handlePluginConfigSubmit and syncFormToJson so
the JSON tab sync uses the same robust FormData processing (including
_data JSON inputs, bracket-notation checkboxes, array-of-objects,
file-upload widgets, checkbox DOM detection, and unchecked boolean
handling via collectBooleanFields)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* 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>
* 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>
* fix(web): resolve file upload config lookup for server-rendered forms
The file upload widget's getUploadConfig() function failed to map
server-rendered field IDs (e.g., "static-image-images") back to schema
property keys ("images"), causing upload config (plugin_id, endpoint,
allowed_types) to be lost. This could prevent image uploads from
working correctly in the static-image plugin and others.
Changes:
- Add data-* attributes to the Jinja2 file-upload template so upload
config is embedded directly on the file input element
- Update getUploadConfig() in both file-upload.js and plugins_manager.js
to read config from data attributes first, falling back to schema lookup
- Remove duplicate handleFiles/handleFileDrop/handleFileSelect from
plugins_manager.js that overwrote the more robust file-upload.js versions
- Bump cache-busting version strings so browsers fetch updated JS
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(web): harden file upload functions against CodeRabbit patterns
- Add response.ok guard before response.json() in handleFiles,
deleteUploadedFile, and handleCredentialsUpload to prevent
SyntaxError on non-JSON error responses (PR #271 finding)
- Remove duplicate getUploadConfig() from plugins_manager.js;
file-upload.js now owns this function exclusively
- Replace innerHTML with textContent/DOM methods in
handleCredentialsUpload to prevent XSS (PR #271 finding)
- Fix redundant if-check in getUploadConfig data-attribute reader
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(web): address CodeRabbit findings on file upload widget
- Add data-multiple="true" discriminator on array file inputs so
handleFileDrop routes multi-file drops to handleFiles() not
handleSingleFileUpload()
- Duplicate upload config data attributes onto drop zone wrapper so
getUploadConfig() survives progress-helper DOM re-renders that
remove the file input element
- Clear file input in finally block after credentials upload to allow
re-selecting the same file on retry
- Branch deleteUploadedFile on fileType: JSON deletes remove the DOM
element directly instead of routing through updateImageList() which
renders image-specific cards (thumbnails, scheduling controls)
Addresses CodeRabbit findings on PR #279:
- Major: drag-and-drop hits single-file path for array uploaders
- Major: config lookup fails after first upload (DOM node removed)
- Minor: same-file retry silently no-ops
- Major: JSON deletes re-render list as images
Co-Authored-By: 5ymb01 <5ymb01@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(web): address CodeRabbit round-2 findings on file upload widget
- Extract getConfigSourceElement() helper so handleFileDrop,
handleSingleFileUpload, and getUploadConfig all share the same
fallback logic: file input → drop zone wrapper
- Remove pluginId gate from getUploadConfig Strategy 1 — fields with
uploadEndpoint or fileType but no pluginId now return config instead
of falling through to generic defaults
- Fix JSON delete identifier mismatch: use file.id || file.category_name
(matching the renderer at line 3202) instead of f.file_id; remove
regex sanitization on DOM id lookup (renderer doesn't sanitize)
Addresses CodeRabbit round-2 findings on PR #279:
- Major: single-file uploads bypass drop-zone config fallback
- Major: getUploadConfig gated on data-plugin-id only
- Major: JSON delete file identifier mismatch vs renderer
Co-Authored-By: 5ymb01 <5ymb01@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(web): align delete handler file identifier with renderer logic
Remove f.file_id from JSON file delete filter to match the renderer's
identifier logic (file.id || file.category_name || idx). Prevents
deleted entries from persisting in the hidden input on next save.
Co-Authored-By: 5ymb01 <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>
Co-authored-by: 5ymb01 <noreply@github.com>
* 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>
* 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>
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>
* 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>
* fix(api): use sys.executable for plugin action subprocess calls
The execute_plugin_action endpoint hardcoded 'python3' when spawning
plugin scripts via subprocess. This can fail if the system Python is
named differently or if a virtualenv is active, since 'python3' may
not point to the correct interpreter.
Changes:
- Replace 'python3' with sys.executable in the non-OAuth script
execution branch (uses the same interpreter running the web service)
- Remove redundant 'import sys' inside the oauth_flow conditional
block (sys is already imported at module level; the local import
shadows the top-level binding for the entire function scope, which
would cause UnboundLocalError if sys were referenced in the else
branch on Python 3.12+)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(api): replace all remaining hardcoded python3 with sys.executable
Fix 4 additional subprocess calls that still used 'python3' instead of
sys.executable: parameterized action wrapper (line 5150), stdin-param
wrapper (line 5211), no-param wrapper (line 5417), and OAuth auth
script (line 5524). Ensures plugin actions work in virtualenvs and
non-standard Python installations.
Addresses CodeRabbit findings on PR #277.
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>
* fix(test): repair test infrastructure and mock fixtures
- Add test/__init__.py for proper test collection
- Fix ConfigManager instantiation to use config_path parameter
- Route schedule config through config_service mock
- Update mock to match get_raw_file_content endpoint change
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(test): correct get_main_config assertion per CodeRabbit review
The endpoint calls load_config(), not get_raw_file_content('main').
Also set up load_config mock return value in the fixture so the
test's data assertions pass correctly.
Co-Authored-By: 5ymb01 <noreply@github.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(test): correct plugin config test mock structure and schema returns
- Plugin configs live at top-level keys, not under 'plugins' subkey
- Mock schema_manager.generate_default_config to return a dict
- Mock schema_manager.merge_with_defaults to merge dicts (not MagicMock)
- Fixes test_get_plugin_config returning 500 due to non-serializable MagicMock
Co-Authored-By: 5ymb01 <noreply@github.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(test): use patch.object for config_service.get_config in schedule tests
config_service.get_config is a real method, not a mock — can't set
return_value on it directly. Use patch.object context manager instead.
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: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: 5ymb01 <noreply@github.com>
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>
* fix(security): stop leaking Python tracebacks to HTTP clients
Replace 13 instances where traceback.format_exc() was sent in API
JSON responses (via `details=`, `traceback:`, or `details:` keys).
- 5 error_response(details=traceback.format_exc()) → generic message
- 6 jsonify({'traceback': traceback.format_exc()}) → removed key
- 2 jsonify({'details': error_details}) → logger.error() instead
Tracebacks in debug mode (app.py error handlers) are preserved as
they are guarded by app.debug and expected during development.
Co-Authored-By: 5ymb01 <noreply@github.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(security): sanitize str(e) from client responses, add server-side logging
Address CodeRabbit review findings:
- Replace str(e) in error_response message fields with generic messages
- Replace import logging/traceback + manual format with logger.exception()
- Add logger.exception() to 6 jsonify handlers that were swallowing errors
- All exception details now logged server-side only, not sent to clients
Co-Authored-By: 5ymb01 <noreply@github.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: remove duplicate traceback logging, sanitize secrets config error
Address CodeRabbit nitpicks:
- Remove manual import logging/traceback + logging.error() that duplicated
the logger.exception() call in save_raw_main_config
- Apply same fix to save_raw_secrets_config: replace str(e) in client
response with generic message, use logger.exception() for server-side
Co-Authored-By: 5ymb01 <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: 5ymb01 <noreply@github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* fix(security): use Path.relative_to() for path confinement check
Replace str.startswith() path check with Path.relative_to() in the
plugin file viewer endpoint. startswith() can be bypassed when a
directory name is a prefix of another (e.g., /plugins/foo vs
/plugins/foobar). relative_to() correctly validates containment.
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>
* 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>
* 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>
* fix(web): remove shadowed sys import in plugin action handler
Two `import sys` statements inside execute_plugin_action() and
authenticate_spotify() shadowed the module-level import, causing
"cannot access local variable 'sys'" errors when sys.executable
was referenced in earlier branches of the same function.
Also fixes day number validation in the of-the-day upload endpoint
to accept 366 (leap year).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(api): correct validation message from 1-365 to 1-366
The JSON structure validation message still said '1-365' while the
actual range check accepts 1-366 for leap years. Make all three
validation messages consistent.
Addresses CodeRabbit finding on PR #280.
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>
The secrets template used "weather" as the key, but the weather plugin's
ID is "ledmatrix-weather". Since ConfigManager deep-merges secrets into
the main config by key, secrets under "weather" never reached the plugin
config at config["ledmatrix-weather"], making the API key invisible to
the plugin.
Co-authored-by: 5ymb01 <5ymb01@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* fix(install): add --prefer-binary to pip installs to avoid /tmp exhaustion
timezonefinder (~54 MB) includes large timezone polygon data files that pip
unpacks into /tmp during installation. On Raspberry Pi, the default tmpfs
/tmp size (often ~half of RAM) can be too small, causing the install to fail
with an out-of-space error.
Adding --prefer-binary tells pip to prefer pre-built binary wheels over
source distributions. Since timezonefinder and most other packages publish
wheels on PyPI (and piwheels.org has ARM wheels), this avoids the large
temporary /tmp extraction and speeds up installs generally.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(timezone): use America/New_York instead of EST for ESPN API date queries
EST is a fixed UTC-5 offset that does not observe daylight saving time,
causing the ESPN API date to be off by one hour during EDT (March–November).
America/New_York correctly handles DST transitions.
The ESPN scoreboard API anchors its schedule calendar to Eastern US time,
so this Eastern timezone is intentionally kept for the API date — it is not
user-configurable. Game time display is converted separately to the user's
configured timezone.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(web): add Google Calendar picker widget for dynamic calendar selection
Adds a new google-calendar-picker widget and API endpoint that lets users
load their available Google Calendars by name and check the ones they want,
instead of manually typing calendar IDs.
- GET /api/v3/plugins/calendar/list-calendars — calls plugin.get_calendars()
and returns all accessible calendars with id, summary, and primary flag
- google-calendar-picker.js — new widget: "Load My Calendars" button renders
a checklist; selections update a hidden comma-separated input for form submit
- plugin_config.html — handles x-widget: google-calendar-picker in array branch
- base.html — loads the new widget script
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(web): address PR review findings in google-calendar-picker
- api_v3.py: replace broad except block with specific exception handling,
log full traceback via module logger, normalize/validate get_calendars()
output to stable {id,summary,primary} objects, return opaque user-friendly
error message instead of leaking str(e)
- google-calendar-picker.js: fix button label only updating to "Refresh
Calendars" on success (restore original label on error); update summary
paragraph via syncHiddenAndSummary() on every checkbox change so UI stays
in sync with hidden input; pass summary element through loadCalendars and
renderCheckboxes instead of re-querying DOM
- plugin_config.html: bound initWidget retry loop with MAX_RETRIES=40 to
prevent infinite timers; normalize legacy comma-separated string values
to arrays before passing to widget.render so pre-existing config populates
correctly
- install_dependencies_apt.py: update install_via_pip docstring to document
both --break-system-packages and --prefer-binary flags
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(web): harden list_calendar_calendars input validation
- Remove unused `as e` binding from ValueError/TypeError/KeyError except clause
- Replace hasattr(__iter__) with isinstance(list|tuple) so non-sequence returns
are rejected before iteration
- Validate each calendar entry is a collections.abc.Mapping; skip and warn on
malformed items rather than propagating a TypeError
- Coerce id/summary to str safely if not already strings
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(web): skip calendar entries with empty id in list_calendar_calendars
After coercing cal_id to str, check it is non-empty before appending to
the calendars list so entries with no usable id are never forwarded to
the client.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* 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>
timezonefinder (~54 MB) includes large timezone polygon data files that pip
unpacks into /tmp during installation. On Raspberry Pi, the default tmpfs
/tmp size (often ~half of RAM) can be too small, causing the install to fail
with an out-of-space error.
Adding --prefer-binary tells pip to prefer pre-built binary wheels over
source distributions. Since timezonefinder and most other packages publish
wheels on PyPI (and piwheels.org has ARM wheels), this avoids the large
temporary /tmp extraction and speeds up installs generally.
Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* 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>
* 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>
* 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>
* 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>
The plugin registry uses short IDs (e.g. "weather", "stocks") but
plugin_path points to the actual installed directory name (e.g.
"plugins/ledmatrix-weather"). isStorePluginInstalled() was only
comparing registry IDs, causing all monorepo plugins with mismatched
IDs to show as not installed in the store UI.
- Updated isStorePluginInstalled() to also check the last segment of
plugin_path against installed plugin IDs
- Updated all 3 call sites to pass the full plugin object instead of
just plugin.id
- Fixed the same bug in renderCustomRegistryPlugins() which used the
same direct ID comparison
Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
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>
* 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>
* 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>
* fix(web): handle dotted keys in schema/config path helpers
Schema property names containing dots (e.g. "eng.1" for Premier League
in soccer-scoreboard) were being incorrectly split on the dot separator
in two path-navigation helpers:
- _get_schema_property: split "leagues.eng.1.favorite_teams" into 4
segments and looked for "eng" in leagues.properties, which doesn't
exist (the key is literally "eng.1"). Returned None, so the field
type was unknown and values were not parsed correctly.
- _set_nested_value: split the same path into 4 segments and created
config["leagues"]["eng"]["1"]["favorite_teams"] instead of the
correct config["leagues"]["eng.1"]["favorite_teams"].
Both functions now use a greedy longest-match approach: at each level
they try progressively longer dot-joined candidates first (e.g. "eng.1"
before "eng"), so dotted property names are handled transparently.
Fixes favorite_teams (and other per-league fields) not saving via the
soccer-scoreboard plugin config UI.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* chore: remove debug artifacts from merged branches
- Replace print() with logger.warning() for three error handlers in api_v3.py
that bypassed the structured logging infrastructure
- Simplify dead if/else in loadInstalledPlugins() — both branches did the
same window.installedPlugins assignment; collapse to single line
- Remove console.log registration line from schedule-picker widget that
fired unconditionally on every page load
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(starlark): fix Python 3.13 importlib.reload() incompatibility
In Python 3.13, importlib.reload() raises ModuleNotFoundError for modules
loaded via spec_from_file_location when they aren't on sys.path, because
_bootstrap._find_spec() can no longer resolve them by name.
Replace the reload-on-cache-hit pattern in _get_tronbyte_repository_class()
and _get_pixlet_renderer_class() with a simple return of the cached class —
the reload was only useful for dev-time iteration and is unnecessary in
production (the service restarts clean on each deploy).
Also broaden the exception catch in upload_starlark_app() from
(ValueError, OSError, IOError) to Exception so that any unexpected error
(ImportError, ModuleNotFoundError, etc.) returns a proper JSON response
instead of an unhandled Flask 500.
Fixes: "Install failed: spec not found for the module 'tronbyte_repository'"
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(starlark): use targeted exception handlers in upload_starlark_app()
Replace the broad `except Exception` catch-all with specific handlers:
- (OSError, IOError) for temp file creation/save failures
- ImportError for module loading failures (_get_pixlet_renderer_class)
- Exception as final catch-all that logs without leaking internals
All handlers use `err` (not unused `e`) in both the log message and
the JSON response body.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* 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>
* feat: integrate Starlark/Tronbyte app support into plugin system
Add starlark-apps plugin that renders Tidbyt/Tronbyte .star apps via
Pixlet binary and integrates them into the existing Plugin Manager UI
as virtual plugins. Includes vegas scroll support, Tronbyte repository
browsing, and per-app configuration.
- Extract working starlark plugin code from starlark branch onto fresh main
- Fix plugin conventions (get_logger, VegasDisplayMode, BasePlugin)
- Add 13 starlark API endpoints to api_v3.py (CRUD, browse, install, render)
- Virtual plugin entries (starlark:<app_id>) in installed plugins list
- Starlark-aware toggle and config routing in pages_v3.py
- Tronbyte repository browser section in Plugin Store UI
- Pixlet binary download script (scripts/download_pixlet.sh)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(starlark): use bare imports instead of relative imports
Plugin loader uses spec_from_file_location without package context,
so relative imports (.pixlet_renderer) fail. Use bare imports like
all other plugins do.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(starlark): make API endpoints work standalone in web service
The web service runs as a separate process with display_manager=None,
so plugins aren't instantiated. Refactor starlark API endpoints to
read/write the manifest file directly when the plugin isn't loaded,
enabling full CRUD operations from the web UI.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(starlark): make config partial work standalone in web service
Read starlark app data from manifest file directly when the plugin
isn't loaded, matching the api_v3.py standalone pattern.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(starlark): always show editable timing settings in config panel
Render interval and display duration are now always editable in the
starlark app config panel, not just shown as read-only status text.
App-specific settings from schema still appear below when present.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat(store): add sort, filter, search, and pagination to Plugin Store and Starlark Apps
Plugin Store:
- Live search with 300ms debounce (replaces Search button)
- Sort dropdown: A→Z, Z→A, Category, Author, Newest
- Installed toggle filter (All / Installed / Not Installed)
- Per-page selector (12/24/48) with pagination controls
- "Installed" badge and "Reinstall" button on already-installed plugins
- Active filter count badge + clear filters button
Starlark Apps:
- Parallel bulk manifest fetching via ThreadPoolExecutor (20 workers)
- Server-side 2-hour cache for all 500+ Tronbyte app manifests
- Auto-loads all apps when section expands (no Browse button)
- Live search, sort (A→Z, Z→A, Category, Author), author dropdown
- Installed toggle filter, per-page selector (24/48/96), pagination
- "Installed" badge on cards, "Reinstall" button variant
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(store): move storeFilterState to global scope to fix scoping bug
storeFilterState, pluginStoreCache, and related variables were declared
inside an IIFE but referenced by top-level functions, causing
ReferenceError that broke all plugin loading.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat(starlark): schema-driven config forms + critical security fixes
## Schema-Driven Config UI
- Render type-appropriate form inputs from schema.json (text, dropdown, toggle, color, datetime, location)
- Pre-populate config.json with schema defaults on install
- Auto-merge schema defaults when loading existing apps (handles schema updates)
- Location fields: 3-part mini-form (lat/lng/timezone) assembles into JSON
- Toggle fields: support both boolean and string "true"/"false" values
- Unsupported field types (oauth2, photo_select) show warning banners
- Fallback to raw key/value inputs for apps without schema
## Critical Security Fixes (P0)
- **Path Traversal**: Verify path safety BEFORE mkdir to prevent TOCTOU
- **Race Conditions**: Add file locking (fcntl) + atomic writes to manifest operations
- **Command Injection**: Validate config keys/values with regex before passing to Pixlet subprocess
## Major Logic Fixes (P1)
- **Config/Manifest Separation**: Store timing keys (render_interval, display_duration) ONLY in manifest
- **Location Validation**: Validate lat [-90,90] and lng [-180,180] ranges, reject malformed JSON
- **Schema Defaults Merge**: Auto-apply new schema defaults to existing app configs on load
- **Config Key Validation**: Enforce alphanumeric+underscore format, prevent prototype pollution
## Files Changed
- web_interface/templates/v3/partials/starlark_config.html — schema-driven form rendering
- plugin-repos/starlark-apps/manager.py — file locking, path safety, config validation, schema merge
- plugin-repos/starlark-apps/pixlet_renderer.py — config value sanitization
- web_interface/blueprints/api_v3.py — timing key separation, safe manifest updates
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
* fix(starlark): use manifest filename field for .star downloads
Tronbyte apps don't always name their .star file to match the directory.
For example, the "analogclock" app has "analog_clock.star" (with underscore).
The manifest.yaml contains a "filename" field with the correct name.
Changes:
- download_star_file() now accepts optional filename parameter
- Install endpoint passes metadata['filename'] to download_star_file()
- Falls back to {app_id}.star if filename not in manifest
Fixes: "Failed to download .star file for analogclock" error
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
* fix(starlark): reload tronbyte_repository module to pick up code changes
The web service caches imported modules in sys.modules. When deploying
code updates, the old cached version was still being used.
Now uses importlib.reload() when module is already loaded.
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
* fix(starlark): use correct 'fileName' field from manifest (camelCase)
The Tronbyte manifest uses 'fileName' (camelCase), not 'filename' (lowercase).
This caused the download to fall back to {app_id}.star which doesn't exist
for apps like analogclock (which has analog_clock.star).
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
* feat(starlark): extract schema during standalone install
The standalone install function (_install_star_file) wasn't extracting
schema from .star files, so apps installed via the web service had no
schema.json and the config panel couldn't render schema-driven forms.
Now uses PixletRenderer to extract schema during standalone install,
same as the plugin does.
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
* feat(starlark): implement source code parser for schema extraction
Pixlet CLI doesn't support schema extraction (--print-schema flag doesn't exist),
so apps were being installed without schemas even when they have them.
Implemented regex-based .star file parser that:
- Extracts get_schema() function from source code
- Parses schema.Schema(version, fields) structure
- Handles variable-referenced dropdown options (e.g., options = dialectOptions)
- Supports Location, Text, Toggle, Dropdown, Color, DateTime fields
- Gracefully handles unsupported fields (OAuth2, LocationBased, etc.)
- Returns formatted JSON matching web UI template expectations
Coverage: 90%+ of Tronbyte apps (static schemas + variable references)
Changes:
- Replace extract_schema() to parse .star files directly instead of using Pixlet CLI
- Add 6 helper methods for parsing schema structure
- Handle nested parentheses and brackets properly
- Resolve variable references for dropdown options
Tested with:
- analog_clock.star (Location field) ✓
- Multi-field test (Text + Dropdown + Toggle) ✓
- Variable-referenced options ✓
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
* fix(starlark): add List to typing imports for schema parser
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
* fix(starlark): load schema from schema.json in standalone mode
The standalone API endpoint was returning schema: null because it didn't
load the schema.json file. Now reads schema from disk when returning
app details via web service.
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
* feat(starlark): implement schema extraction, asset download, and config persistence
## Schema Extraction
- Replace broken `pixlet serve --print-schema` with regex-based source parser
- Extract schema by parsing `get_schema()` function from .star files
- Support all field types: Location, Text, Toggle, Dropdown, Color, DateTime
- Handle variable-referenced dropdown options (e.g., `options = teamOptions`)
- Gracefully handle complex/unsupported field types (OAuth2, PhotoSelect, etc.)
- Extract schema for 90%+ of Tronbyte apps
## Asset Download
- Add `download_app_assets()` to fetch images/, sources/, fonts/ directories
- Download assets in binary mode for proper image/font handling
- Validate all paths to prevent directory traversal attacks
- Copy asset directories during app installation
- Enable apps like AnalogClock that require image assets
## Config Persistence
- Create config.json file during installation with schema defaults
- Update both config.json and manifest when saving configuration
- Load config from config.json (not manifest) for consistency with plugin
- Separate timing keys (render_interval, display_duration) from app config
- Fix standalone web service mode to read/write config.json
## Pixlet Command Fix
- Fix Pixlet CLI invocation: config params are positional, not flags
- Change from `pixlet render file.star -c key=value` to `pixlet render file.star key=value -o output`
- Properly handle JSON config values (e.g., location objects)
- Enable config to be applied during rendering
## Security & Reliability
- Add threading.Lock for cache operations to prevent race conditions
- Reduce ThreadPoolExecutor workers from 20 to 5 for Raspberry Pi
- Add path traversal validation in download_star_file()
- Add YAML error logging in manifest fetching
- Add file size validation (5MB limit) for .star uploads
- Use sanitized app_id consistently in install endpoints
- Use atomic manifest updates to prevent race conditions
- Add missing Optional import for type hints
## Web UI
- Fix standalone mode schema loading in config partial
- Schema-driven config forms now render correctly for all apps
- Location fields show lat/lng/timezone inputs
- Dropdown, toggle, text, color, and datetime fields all supported
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
* fix(starlark): code review fixes - security, robustness, and schema parsing
## Security Fixes
- manager.py: Check _update_manifest_safe return values to prevent silent failures
- manager.py: Improve temp file cleanup in _save_manifest to prevent leaks
- manager.py: Fix uninstall order (manifest → memory → disk) for consistency
- api_v3.py: Add path traversal validation in uninstall endpoint
- api_v3.py: Implement atomic writes for manifest files with temp + rename
- pixlet_renderer.py: Relax config validation to only block dangerous shell metacharacters
## Frontend Robustness
- plugins_manager.js: Add safeLocalStorage wrapper for restricted contexts (private browsing)
- starlark_config.html: Scope querySelector to container to prevent modal conflicts
## Schema Parsing Improvements
- pixlet_renderer.py: Indentation-aware get_schema() extraction (handles nested functions)
- pixlet_renderer.py: Handle quoted defaults with commas (e.g., "New York, NY")
- tronbyte_repository.py: Validate file_name is string before path traversal checks
## Dependencies
- requirements.txt: Update Pillow (10.4.0), PyYAML (6.0.2), requests (2.32.0)
## Documentation
- docs/STARLARK_APPS_GUIDE.md: Comprehensive guide explaining:
- How Starlark apps work
- That apps come from Tronbyte (not LEDMatrix)
- Installation, configuration, troubleshooting
- Links to upstream projects
All changes improve security, reliability, and user experience.
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
* fix(starlark): convert Path to str in spec_from_file_location calls
The module import helpers were passing Path objects directly to
spec_from_file_location(), which caused spec to be None. This broke
the Starlark app store browser.
- Convert module_path to string in both _get_tronbyte_repository_class
and _get_pixlet_renderer_class
- Add None checks with clear error messages for debugging
Fixes: spec not found for the module 'tronbyte_repository'
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
* fix(starlark): restore Starlark Apps section in plugins.html
The Starlark Apps UI section was lost during merge conflict resolution
with main branch. Restored from commit 942663ab which had the complete
implementation with filtering, sorting, and pagination.
Fixes: Starlark section not visible on plugin manager page
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
* fix(starlark): restore Starlark JS functionality lost in merge
During the merge with main, all Starlark-specific JavaScript (104 lines)
was removed from plugins_manager.js, including:
- starlarkFilterState and filtering logic
- loadStarlarkApps() function
- Starlark app install/uninstall handlers
- Starlark section collapse/expand logic
- Pagination and sorting for Starlark apps
Restored from commit 942663ab and re-applied safeLocalStorage wrapper
from our code review fixes.
Fixes: Starlark Apps section non-functional in web UI
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
* fix(starlark): security and race condition improvements
Security fixes:
- Add path traversal validation for output_path in download_star_file
- Remove XSS-vulnerable inline onclick handlers, use delegated events
- Add type hints to helper functions for better type safety
Race condition fixes:
- Lock manifest file BEFORE creating temp file in _save_manifest
- Hold exclusive lock for entire read-modify-write cycle in _update_manifest_safe
- Prevent concurrent writers from racing on manifest updates
Other improvements:
- Fix pages_v3.py standalone mode to load config.json from disk
- Improve error handling with proper logging in cleanup blocks
- Add explicit type annotations to Starlark helper functions
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
* fix(starlark): critical bug fixes and code quality improvements
Critical fixes:
- Fix stack overflow in safeLocalStorage (was recursively calling itself)
- Fix duplicate event listeners on Starlark grid (added sentinel check)
- Fix JSON validation to fail fast on malformed data instead of silently passing
Error handling improvements:
- Narrow exception catches to specific types (OSError, json.JSONDecodeError, ValueError)
- Use logger.exception() with exc_info=True for better stack traces
- Replace generic "except Exception" with specific exception types
Logging improvements:
- Add "[Starlark Pixlet]" context tags to pixlet_renderer logs
- Redact sensitive config values from debug logs (API keys, etc.)
- Add file_path context to schema parsing warnings
Documentation:
- Fix markdown lint issues (add language tags to code blocks)
- Fix time unit spacing: "(5min)" -> "(5 min)"
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
* fix(starlark): critical path traversal and exception handling fixes
Path traversal security fixes (CRITICAL):
- Add _validate_starlark_app_path() helper to check for path traversal attacks
- Validate app_id in get_starlark_app(), uninstall_starlark_app(),
get_starlark_app_config(), and update_starlark_app_config()
- Check for '..' and path separators before any filesystem access
- Verify resolved paths are within _STARLARK_APPS_DIR using Path.relative_to()
- Prevents unauthorized file access via crafted app_id like '../../../etc/passwd'
Exception handling improvements (tronbyte_repository.py):
- Replace broad "except Exception" with specific types
- _make_request: catch requests.Timeout, requests.RequestException, json.JSONDecodeError
- _fetch_raw_file: catch requests.Timeout, requests.RequestException separately
- download_app_assets: narrow to OSError, ValueError
- Add "[Tronbyte Repo]" context prefix to all log messages
- Use exc_info=True for better stack traces
API improvements:
- Narrow exception catches to OSError, json.JSONDecodeError in config loading
- Remove duplicate path traversal checks (now centralized in helper)
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
* fix(starlark): logging improvements and code quality fixes
Logging improvements (pages_v3.py):
- Add logging import and create module logger
- Replace print() calls with logger.warning() with "[Pages V3]" prefix
- Use logger.exception() for outer try/catch with exc_info=True
- Narrow exception handling to OSError, json.JSONDecodeError for file operations
API improvements (api_v3.py):
- Remove unnecessary f-strings (Ruff F541) from ImportError messages
- Narrow upload exception handling to ValueError, OSError, IOError
- Use logger.exception() with context for better debugging
- Remove early return in get_starlark_status() to allow standalone mode fallback
- Sanitize error messages returned to client (don't expose internal details)
Benefits:
- Better log context with consistent prefixes
- More specific exception handling prevents masking unexpected errors
- Standalone/web-service-only mode now works for status endpoint
- Stack traces preserved for debugging without exposing to clients
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
---------
Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* feat(store): add sorting, filtering, and fix Update All button
Add client-side sorting and filtering to the Plugin Store:
- Sort by A-Z, Z-A, Verified First, Recently Updated, Category
- Filter by verified, new, installed status, author, and tags
- Installed/Update Available badges on store cards
- Active filter count badge with clear-all button
- Sort preference persisted to localStorage
Fix three bugs causing button unresponsiveness:
- pluginsInitialized never reset on HTMX tab navigation (root cause
of Update All silently doing nothing on second visit)
- htmx:afterSwap condition too broad (fired on unrelated swaps)
- data-running guard tied to DOM element replaced by cloneNode
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor(store): replace tag pills with category pills, fix sort dates
- Replace tag filter pills with category filter pills (less duplication)
- Prefer per-plugin last_updated over repo-wide pushed_at for sort
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* debug: add console logging to filter/sort handlers
* fix: bump cache-buster versions for JS and CSS
* feat(plugins): add sorting to installed plugins section
Add A-Z, Z-A, and Enabled First sort options for installed plugins
with localStorage persistence. Both installed and store sections
now default to A-Z sorting.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(store): consolidate CSS, fix stale cache bug, add missing utilities, fix icon
- Consolidate .filter-pill and .category-filter-pill into shared selectors
and scope transition to only changed properties
- Fix applyStoreFiltersAndSort ignoring fresh server-filtered results by
accepting optional basePlugins parameter
- Add missing .py-1.5 and .rounded-full CSS utility classes
- Replace invalid fa-sparkles with fa-star (FA 6.0.0 compatible)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(store): semver-aware update badge and add missing gap-1.5 utility
- Replace naive version !== comparison with isNewerVersion() that does
semver greater-than check, preventing false "Update" badges on
same-version or downgrade scenarios
- Add missing .gap-1.5 CSS utility used by category pills and tag lists
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
The previous fix (#249) wired window.updateAllPlugins to
PluginInstallManager.updateAll(), but that method reads from
PluginStateManager.installedPlugins which is never populated on
page load — only after individual install/update operations.
Meanwhile, base.html already defined a working updateAllPlugins
using window.installedPlugins (reliably populated by plugins_manager.js).
The override from install_manager.js masked this working version.
Fix: revert install_manager.js changes and rewrite runUpdateAllPlugins
to iterate window.installedPlugins directly, calling the API endpoint
without any middleman. Adds per-plugin progress in button text and
a summary notification on completion.
Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
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>
window.updateAllPlugins was never assigned, so the button always showed
"Bulk update handler unavailable." Wire it to PluginInstallManager.updateAll(),
add per-plugin progress feedback in the button text, show a summary
notification on completion, and skip redundant plugin list reloads.
Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* 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>
* 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>
* fix(web): add dark mode overrides for collapsible config section headers
The collapsible section headers in plugin config schemas used bg-gray-100
and hover:bg-gray-200 which had no dark mode overrides, resulting in light
text on a light background when dark mode was active.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(web): add missing bg-gray-100 light-mode utility class
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* fix(web): add missing utility classes for log viewer readability
The log viewer uses text-gray-100, text-gray-200, text-gray-300,
text-red-300, text-yellow-300, bg-gray-800, bg-red-900, bg-yellow-900,
border-gray-700, and hover:bg-gray-800 — none of which were defined in
app.css. Without definitions, log text inherited the body's dark color
(#111827) which was invisible against the dark bg-gray-900 log container
in light mode.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(web): remove dead bg-opacity classes, use proper log level colors
The bg-opacity-10/bg-opacity-30 classes set a --bg-opacity CSS variable
that no background-color rule consumed, making them dead code. Replace
the broken two-class pattern (e.g. "bg-red-900 bg-opacity-10") with
dedicated log-level-error/warning/debug classes that use rgb() with
actual alpha values.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* feat(web): add light/dark mode toggle and fix log readability
Add a theme toggle button (moon/sun icon) to the header that switches
between light and dark mode. Theme preference persists in localStorage
and falls back to the OS prefers-color-scheme setting.
The implementation uses a data-theme attribute on <html> with CSS
overrides, so all 13 partial templates and 20+ widget JS files get
dark mode support without any modifications — only 3 files changed.
Also fixes log timestamp readability: text-gray-500 had ~3.5:1 contrast
ratio against the dark log background, now uses text-gray-400 (~5.3:1)
which passes WCAG AA in both light and dark mode.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(web): address dark mode review — accessibility, robustness, and code quality
- WCAG touch target: enforce 44×44px minimum on theme toggle button
with display:inline-flex centering
- Accessibility: add type="button", aria-pressed (dynamically updated),
aria-hidden on decorative icons, and contextual aria-label/title that
reflects current state ("Switch to light/dark mode")
- Robustness: wrap all localStorage and matchMedia calls in try/catch
with fallbacks for private browsing and restricted contexts; use
addListener fallback for older browsers lacking addEventListener
- Stylelint: convert all rgba() to modern rgb(…/…%) notation across
both light and dark theme shadows and gradients
- DRY: replace hardcoded hex values in dark mode utility overrides and
component overrides with CSS variable references (--color-surface,
--color-background, --color-border, --color-text-primary, etc.)
- Remove redundant [data-theme="dark"] body rule (body already uses
CSS variables that are redefined under the dark theme selector)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* 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>
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>
* 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>
* 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>
* feat(fonts): add dynamic font selection and font manager improvements
- Add font-selector widget for dynamic font selection in plugin configs
- Enhance /api/v3/fonts/catalog with filename, display_name, and type
- Add /api/v3/fonts/preview endpoint for server-side font rendering
- Add /api/v3/fonts/<family> DELETE endpoint with system font protection
- Fix /api/v3/fonts/upload to actually save uploaded font files
- Update font manager tab with dynamic dropdowns, server-side preview, and font deletion
- Add new BDF fonts: 6x10, 6x12, 6x13, 7x13, 7x14, 8x13, 9x15, 9x18, 10x20 (with bold/oblique variants)
- Add tom-thumb, helvR12, clR6x12, texgyre-27 fonts
Plugin authors can use x-widget: "font-selector" in schemas to enable
dynamic font selection that automatically shows all available fonts.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix(fonts): security fixes and code quality improvements
- Fix README.md typos and add language tags to code fences
- Remove duplicate delete_font function causing Flask endpoint collision
- Add safe integer parsing for size parameter in preview endpoint
- Fix path traversal vulnerability in /fonts/preview endpoint
- Fix path traversal vulnerability in /fonts/<family> DELETE endpoint
- Fix XSS vulnerability in fonts.html by using DOM APIs instead of innerHTML
- Move baseUrl to shared scope to fix ReferenceError in multiple functions
Security improvements:
- Validate font filenames reject path separators and '..'
- Validate paths are within fonts_dir before file operations
- Use textContent and data attributes instead of inline onclick handlers
- Restrict file extensions to known font types
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix(fonts): address code issues and XSS vulnerabilities
- Move `import re` to module level, remove inline imports
- Remove duplicate font_file assignment in upload_font()
- Remove redundant validation with inconsistent allowed extensions
- Remove redundant PathLib import, use already-imported Path
- Fix XSS vulnerabilities in fonts.html by using DOM APIs instead of
innerHTML with template literals for user-controlled data
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix(fonts): add size limits to font preview endpoint
Add input validation to prevent DoS via large image generation:
- MAX_TEXT_CHARS (100): Limit text input length
- MAX_TEXT_LINES (3): Limit number of newlines
- MAX_DIM (1024): Limit max width/height
- MAX_PIXELS (500000): Limit total pixel count
Validates text early before processing and checks computed
dimensions after bbox calculation but before image allocation.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix(fonts): improve error handling, catalog keys, and BDF preview
- Add structured logging for cache invalidation failures instead of
silent pass (FontUpload, FontDelete, FontCatalog contexts)
- Use filename as unique catalog key to prevent collisions when
multiple font files share the same family_name from metadata
- Return explicit error for BDF font preview instead of showing
misleading preview with default font
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix(fonts): address nitpick issues in font management
Frontend (fonts.html):
- Remove unused escapeHtml function (dead code)
- Add max-attempts guard (50 retries) to initialization loop
- Add response.ok checks before JSON parsing in deleteFont,
addFontOverride, deleteFontOverride, uploadSelectedFonts
- Use is_system flag from API instead of hardcoded client-side list
Backend (api_v3.py):
- Move SYSTEM_FONTS to module-level frozenset for single source of truth
- Add is_system flag to font catalog entries
- Simplify delete_font system font check using frozenset lookup
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix(fonts): align frontend upload validation with backend
- Add .otf to accepted file extensions (HTML accept attribute, JS filter)
- Update validation regex to allow hyphens (matching backend)
- Preserve hyphens in auto-generated font family names
- Update UI text to reflect all supported formats
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix(fonts): fix lint errors and missing variable
- Remove unused exception binding in set_cached except block
- Define font_family_lower before case-insensitive fallback loop
- Add response.ok check to font preview fetch (consistent with other handlers)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix(fonts): address nitpick code quality issues
- Add return type hints to get_font_preview and delete_font endpoints
- Catch specific PIL exceptions (IOError/OSError) when loading fonts
- Replace innerHTML with DOM APIs for trash icon (consistency)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix(fonts): remove unused exception bindings in cache-clearing blocks
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
* 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>
Add a "Shutdown System" button to the Overview page that gracefully
powers off the Raspberry Pi. Uses sudo poweroff, consistent with the
existing reboot_system action, letting sudo's secure_path handle
binary resolution.
Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Add the led_rgb_sequence configuration option to the matrix config template,
allowing users to specify the RGB sequence for their LED panels.
Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
* fix(web): handle string boolean values in schedule-picker widget
The normalizeSchedule function used strict equality (===) to check the
enabled field, which would fail if the config value was a string "true"
instead of boolean true. This could cause the checkbox to always appear
unchecked even when the setting was enabled.
Added coerceToBoolean helper that properly handles:
- Boolean true/false (returns as-is)
- String "true", "1", "on" (case-insensitive) → true
- String "false" or other values → false
Applied to both main schedule enabled and per-day enabled fields.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix: trim whitespace in coerceToBoolean string handling
* fix: normalize mode value to handle per_day and per-day variants
---------
Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
* 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>
* fix(web): handle string boolean values in schedule-picker widget
The normalizeSchedule function used strict equality (===) to check the
enabled field, which would fail if the config value was a string "true"
instead of boolean true. This could cause the checkbox to always appear
unchecked even when the setting was enabled.
Added coerceToBoolean helper that properly handles:
- Boolean true/false (returns as-is)
- String "true", "1", "on" (case-insensitive) → true
- String "false" or other values → false
Applied to both main schedule enabled and per-day enabled fields.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix: trim whitespace in coerceToBoolean string handling
---------
Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
* fix(install): exclude rpi-rgb-led-matrix from permission normalization
The permission normalization step in first_time_install.sh was running
chmod 644 on all files, which stripped executable bits from compiled
library files (librgbmatrix.so.1) after make build-python created them.
This caused LED panels to not work after fresh installation until users
manually ran chmod on the rpi-rgb-led-matrix-master directory.
Fixes#224
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix(install): resolve install script issues and speed up web UI startup
Issues addressed:
- Remove redundant python3-pillow from apt (Debian maps it to python3-pil)
- Only upgrade pip, not setuptools/wheel (they conflict with apt versions)
- Remove separate apt numpy install (pip handles it from requirements.txt)
- Install web interface deps during first-time setup, not on every startup
- Add marker file (.web_deps_installed) to skip redundant pip installs
- Add user-friendly message about wait time after installation
The web UI was taking 30-60+ seconds to start because it ran pip install
on every startup. Now it only installs dependencies on first run.
Fixes#208
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix(install): prevent duplicate web dependency installation
Step 7 was installing web dependencies again even though they were
already installed in Step 5. Now Step 7 checks for the .web_deps_installed
marker file and skips the installation if it already exists.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix(install): only create web deps marker on successful install
The .web_deps_installed marker file should only be created when pip
install actually succeeds. Previously it was created regardless of
the pip exit status, which could cause subsequent runs to skip
installing missing dependencies.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix(install): create config files before starting services
The services were being started before config files existed, causing
the web service to fail with "config.json not found". Reordered steps
so config files are created (Step 4) before services are installed
and started (Step 4.1).
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix(install): remove pip upgrade step (apt version is sufficient)
The apt-installed pip cannot be upgraded because it doesn't have a
RECORD file. Since the apt version (25.1.1) is already recent enough,
we can skip the upgrade step entirely.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix(install): reorder script to install services after dependencies
Moved main LED Matrix service installation (Step 7.5) to after all
Python dependencies are installed (Steps 5-7). Previously services
were being started before pip packages and rgbmatrix were ready,
causing startup failures.
New order:
- Step 5: Python pip dependencies
- Step 6: rpi-rgb-led-matrix build
- Step 7: Web interface dependencies
- Step 7.5: Main LED Matrix service (moved here)
- Step 8: Web interface service
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix(install): update step list and fix setcap symlink handling
- Updated step list header to match actual step order after reordering
(Step 4 is now "Ensure configuration files exist", added Step 7.5
for main service, added Step 8.1 for systemd permissions)
- Fixed Python capabilities configuration:
- Check if setcap command exists before attempting to use it
- Resolve symlinks with readlink -f to get the real binary path
- Only print success message when setcap actually succeeds
- Print clear warning with helpful info when setcap fails
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
---------
Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
* fix(install): exclude rpi-rgb-led-matrix from permission normalization
The permission normalization step in first_time_install.sh was running
chmod 644 on all files, which stripped executable bits from compiled
library files (librgbmatrix.so.1) after make build-python created them.
This caused LED panels to not work after fresh installation until users
manually ran chmod on the rpi-rgb-led-matrix-master directory.
Fixes#224
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix(install): resolve install script issues and speed up web UI startup
Issues addressed:
- Remove redundant python3-pillow from apt (Debian maps it to python3-pil)
- Only upgrade pip, not setuptools/wheel (they conflict with apt versions)
- Remove separate apt numpy install (pip handles it from requirements.txt)
- Install web interface deps during first-time setup, not on every startup
- Add marker file (.web_deps_installed) to skip redundant pip installs
- Add user-friendly message about wait time after installation
The web UI was taking 30-60+ seconds to start because it ran pip install
on every startup. Now it only installs dependencies on first run.
Fixes#208
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix(install): prevent duplicate web dependency installation
Step 7 was installing web dependencies again even though they were
already installed in Step 5. Now Step 7 checks for the .web_deps_installed
marker file and skips the installation if it already exists.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix(install): only create web deps marker on successful install
The .web_deps_installed marker file should only be created when pip
install actually succeeds. Previously it was created regardless of
the pip exit status, which could cause subsequent runs to skip
installing missing dependencies.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
---------
Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
* fix: remove plugin-specific calendar duration from config template
Plugin display durations should be added dynamically when plugins are
installed, not hardcoded in the template.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix(web): ensure unchecked checkboxes save as false in main config forms
HTML checkboxes omit their key entirely when unchecked, so the backend
never received updates to set boolean values to false. This affected:
- vegas_scroll_enabled: Now uses _coerce_to_bool helper
- use_short_date_format: Now uses _coerce_to_bool helper
- Plugin system checkboxes (auto_discover, auto_load_enabled, development_mode):
Now uses _coerce_to_bool helper
- Hardware checkboxes (disable_hardware_pulsing, inverse_colors, show_refresh_rate):
Now uses _coerce_to_bool helper
- web_display_autostart: Now uses _coerce_to_bool helper
Added _coerce_to_bool() helper function that properly converts form string
values ("true", "on", "1", "yes") to actual Python booleans, ensuring
consistent JSON types in config and correct downstream boolean checks.
Also added value="true" to all main config checkboxes for consistent boolean
parsing (sends "true" instead of "on" when checked).
This is the same issue fixed in commit 10d70d91 for plugin configs, but
for the main configuration forms (display settings, general settings).
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
---------
Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
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>
Plugin display durations should be added dynamically when plugins are
installed, not hardcoded in the template.
Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
* 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>
* 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>
* fix(web): ensure unchecked boolean checkboxes save as false
HTML checkboxes don't submit values when unchecked. The plugin config
save endpoint starts from existing config (for partial updates), so an
unchecked checkbox's old `true` value persists. Additionally,
merge_with_defaults fills in schema defaults for missing fields, causing
booleans with `"default": true` to always re-enable.
This affected the odds-ticker plugin where NFL/NBA leagues (default:
true) could not be disabled via the checkbox UI, while NHL (default:
false) appeared to work by coincidence.
Changes:
- Add _set_missing_booleans_to_false() that walks the schema after form
processing and sets any boolean field absent from form data to false
- Add value="true" to boolean checkboxes so checked state sends "true"
instead of "on" (proper boolean parsing)
- Handle "on"/"off" strings in _parse_form_value_with_schema for
backwards compatibility with checkboxes lacking value="true"
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix(web): guard on/off coercion to boolean schema types, handle arrays
- Only coerce "on"/"off" strings to booleans when the schema type is
boolean; "true"/"false" remain unconditional
- Extend _set_missing_booleans_to_false to recurse into arrays of
objects (e.g. custom_feeds.0.enabled) by discovering item indices
from submitted form keys and recursing per-index
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix(web): preserve array structures when setting missing booleans
_set_nested_value uses dict-style access for all path segments, which
corrupts lists when paths contain numeric array indices (e.g.
"feeds.custom_feeds.0.enabled").
Refactored _set_missing_booleans_to_false to:
- Accept an optional config_node parameter for direct array item access
- When inside an array item, set booleans directly on the item dict
- Navigate to array lists manually, preserving their list type
- Ensure array items exist as dicts before recursing
This prevents array-of-object configs (like custom_feeds) from being
converted to nested dicts with numeric string keys.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
---------
Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
* feat(widgets): add modular widget system for schedule and common inputs
Add 15 new reusable widgets following the widget registry pattern:
- schedule-picker: composite widget for enable/mode/time configuration
- day-selector: checkbox group for days of the week
- time-range: paired start/end time inputs with validation
- text-input, number-input, textarea: enhanced text inputs
- toggle-switch, radio-group, select-dropdown: selection widgets
- slider, color-picker, date-picker: specialized inputs
- email-input, url-input, password-input: validated string inputs
Refactor schedule.html to use the new schedule-picker widget instead
of inline JavaScript. Add x-widget support in plugin_config.html for
all new widgets so plugins can use them via schema configuration.
Fix form submission for checkboxes by using hidden input pattern to
ensure unchecked state is properly sent via JSON-encoded forms.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix(widgets): improve security, validation, and form binding across widgets
- Fix XSS vulnerability: escapeHtml now escapes quotes in all widget fallbacks
- color-picker: validate presets with isValidHex(), use data attributes
- date-picker: add placeholder attribute support
- day-selector: use options.name for hidden input form binding
- password-input: implement requireUppercase/Number/Special validation
- radio-group: fix value injection using this.value instead of interpolation
- schedule-picker: preserve day values when disabling (don't clear times)
- select-dropdown: remove undocumented searchable/icons options
- text-input: apply patternMessage via setCustomValidity
- time-range: use options.name for hidden inputs
- toggle-switch: preserve configured color from data attribute
- url-input: combine browser and custom protocol validation
- plugin_config: add widget support for boolean/number types, pass name to day-selector
- schedule: handle null config gracefully, preserve explicit mode setting
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix(widgets): validate day-selector input, consistent minLength default, escape JSON quotes
- day-selector: filter incoming selectedDays to only valid entries in DAYS array
(prevents invalid persisted values from corrupting UI/state)
- password-input: use default minLength of 8 when not explicitly set
(fixes inconsistency between render() and onInput() strength meter baseline)
- plugin_config.html: escape single quotes in JSON hidden input values
(prevents broken attributes when JSON contains single quotes)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* feat(widgets): add global notification widget, consolidate duplicated code
- Create notification.js widget with toast-style notifications
- Support for success, error, warning, info types
- Auto-dismiss with configurable duration
- Stacking support with max notifications limit
- Accessible with aria-live and role="alert"
- Update base.html to load notification widget early
- Replace duplicate showNotification in raw_json.html
- Simplify fonts.html fallback notification
- Net reduction of ~66 lines of duplicated code
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix(widgets): escape options.name in all widgets, validate day-selector format
Security fixes:
- Escape options.name attribute in all 13 widgets to prevent injection
- Affected: color-picker, date-picker, email-input, number-input,
password-input, radio-group, select-dropdown, slider, text-input,
textarea, toggle-switch, url-input
Defensive coding:
- day-selector: validate format option exists in DAY_LABELS before use
- Falls back to 'long' format for unsupported/invalid format values
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix(plugins): add type="button" to control buttons, add debug logging
- Add type="button" attribute to refresh, update-all, and restart buttons
to prevent potential form submission behavior
- Add console logging to diagnose button click issues:
- Log when event listeners are attached (and whether buttons found)
- Log when handler functions are called
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix(widgets): improve security and validation across widget inputs
- color-picker.js: Add sanitizeHex() to validate hex values before HTML
interpolation, ensuring only safe #rrggbb strings are used
- day-selector.js: Escape inputName in hidden input name attribute
- number-input.js: Sanitize and escape currentValue in input element
- password-input.js: Validate minLength as non-negative integer, clamp
invalid values to default of 8
- slider.js: Add null check for input element before accessing value
- text-input.js: Clear custom validity before checkValidity() to avoid
stale errors, re-check after setting pattern message
- url-input.js: Normalize allowedProtocols to array, filter to valid
protocol strings, and escape before HTML interpolation
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix(widgets): add defensive fallback for DAY_LABELS lookup in day-selector
Extract labelMap with fallback before loop to ensure safe access even if
format validation somehow fails.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* feat(widgets): add timezone-selector widget with IANA timezone dropdown
- Create timezone-selector.js widget with comprehensive IANA timezone list
- Group timezones by region (US & Canada, Europe, Asia, etc.)
- Show current UTC offset for each timezone
- Display live time preview for selected timezone
- Update general.html to use timezone-selector instead of text input
- Add script tag to base.html for widget loading
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix(ui): suppress on-demand status notification on page load
Change loadOnDemandStatus(true) to loadOnDemandStatus(false) during
initPluginsPage() to prevent the "on-demand status refreshed"
notification from appearing every time a tab is opened or the page
is navigated. The notification should only appear on explicit user
refresh.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* style(ui): soften notification close button appearance
Replace blocky FontAwesome X icon with a cleaner SVG that has rounded
stroke caps. Make the button circular, slightly transparent by default,
and add smooth hover transitions for a more polished look.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix(widgets): multiple security and validation improvements
- color-picker.js: Ensure presets is always an array before map/filter
- number-input.js: Guard against undefined options parameter
- number-input.js: Sanitize and escape min/max/step HTML attributes
- text-input.js: Clear custom validity in onInput to unblock form submit
- timezone-selector.js: Replace legacy Europe/Belfast with Europe/London
- url-input.js: Use RFC 3986 scheme pattern for protocol validation
- general.html: Use |tojson filter to escape timezone value safely
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* refactor(url-input): centralize RFC 3986 protocol validation
Extract protocol normalization into reusable normalizeProtocols()
helper function that validates against RFC 3986 scheme pattern.
Apply consistently in render, validate, and onInput to ensure
protocols like "git+ssh", "android-app" are properly handled
everywhere. Also lowercase protocol comparison in isValidUrl().
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix(timezone-selector): use hidden input for form submission
Replace direct select name attribute with a hidden input pattern to
ensure timezone value is always properly serialized in form submissions.
The hidden input is synced on change and setValue calls. This matches
the pattern used by other widgets and ensures HTMX json-enc properly
captures the value.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix(general): preserve timezone dropdown value after save
Add inline script to sync the timezone select with the hidden input
value after form submission. This prevents the dropdown from visually
resetting to the old value while the save has actually succeeded.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix(widgets): preserve timezone selection across form submission
Use before-request handler to capture the selected timezone value
before HTMX processes the form, then restore it in after-request.
This is more robust than reading from the hidden input which may
also be affected by form state changes.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix(widgets): add HTMX protection to timezone selector
Add global HTMX event listeners in the timezone-selector widget
that preserve the selected value across any form submissions.
This is more robust than form-specific handlers as it protects
the widget regardless of how/where forms are submitted.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* debug(widgets): add logging and prevent timezone widget re-init
Add debug logging and guards to prevent the timezone widget from
being re-initialized after it's already rendered. This should help
diagnose why the dropdown is reverting after save.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* debug: add console logging to timezone HTMX protection
* debug: add onChange logging to trace timezone selection
* fix(widgets): use selectedIndex to force visual update in timezone dropdown
The browser's select.value setter sometimes doesn't trigger a visual
update when optgroup elements are present. Using selectedIndex instead
forces the browser to correctly update the visible selection.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix(widgets): force browser repaint on timezone dropdown restore
Adding display:none/reflow/display:'' pattern to force browser to
visually update the select element after changing selectedIndex.
Increased timeout to 50ms for reliability.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* chore(widgets): remove debug logging from timezone selector
Clean up console.log statements that were used for debugging the
timezone dropdown visual update issue.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix(ui): improve HTMX after-request handler in general settings
- Parse xhr.responseText with JSON.parse in try/catch instead of
using nonstandard responseJSON property
- Check xhr.status for 2xx success range
- Show error notification for non-2xx responses
- Default to safe fallback values if JSON parsing fails
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix(widgets): add input sanitization and timezone validation
- Sanitize minLength/maxLength in text-input.js to prevent attribute
injection (coerce to integers, validate range)
- Update Europe/Kiev to Europe/Kyiv (canonical IANA identifier)
- Validate timezone currentValue against TIMEZONE_GROUPS before rendering
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix(ui): correct error message fallback in HTMX after-request handler
Initialize message to empty string so error responses can use the
fallback 'Failed to save settings' when no server message is provided.
Previously, the truthy default 'Settings saved' would always be used.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix(widgets): add constraint normalization and improve value validation
- text-input: normalize minLength/maxLength so maxLength >= minLength
- timezone-selector: validate setValue input against TIMEZONE_GROUPS
- timezone-selector: sync hidden input to actual selected value
- timezone-selector: preserve empty selections across HTMX requests
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix(widgets): simplify HTMX restore using select.value and dispatch change event
Replace selectedIndex manipulation with direct value assignment for cleaner
placeholder handling, and dispatch change event to refresh timezone preview.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
---------
Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
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>
The LEDMatrix project uses a plugin-based architecture. All display functionality (except core calendar) is implemented as plugins that are dynamically loaded from the `plugins/` directory.
The LEDMatrix project uses a plugin-based architecture. All display
functionality (except core calendar) is implemented as plugins that are
dynamically loaded from the directory configured by
`plugin_system.plugins_directory` in `config.json` — the default is
-`plugins.json` registry at `https://raw.githubusercontent.com/ChuckBuilds/ledmatrix-plugins/main/plugins.json`
- Store manager (`src/plugin_system/store_manager.py`) handles install/update/uninstall
- Monorepo plugins are installed via ZIP extraction (no `.git` directory)
- Update detection for monorepo plugins uses version comparison (manifest version vs registry latest_version)
- Plugin configs stored in `config/config.json`, NOT in plugin directories — safe across reinstalls
- Third-party plugins can use their own repo URL with empty `plugin_path`
## Common Pitfalls
- paho-mqtt 2.x needs `callback_api_version=mqtt.CallbackAPIVersion.VERSION1` for v1 compat
- BasePlugin uses `get_logger()` from `src.logging_config`, not standard `logging.getLogger()`
- When modifying a plugin in the monorepo, you MUST bump `version` in its `manifest.json` and run `python update_registry.py` — otherwise users won't receive the update
@@ -14,8 +14,12 @@ I'm very new to all of this and am *heavily* relying on AI development tools to
I'm trying to be open to constructive criticism and support, as long as it's a realistic ask and aligns with my priorities on this project. If you have ideas for improvements, find bugs, or want to add features to the base project, please don't hesitate to reach out on Discord or submit a pull request. Similarly, if you want to develop a plugin of your own, please do so! I'd love to see what you create.
I'm trying to be open to constructive criticism and support, as long as it's a realistic ask and aligns with my priorities on this project. If you have ideas for improvements, find bugs, or want to add features to the base project, please don't hesitate to reach out on Discord or submit a pull request. Similarly, if you want to develop a plugin of your own, please do so! I'd love to see what you create.
### Installing the LEDMatrix project on a pi video:
[](https://www.youtube.com/watch?v=bkT0f1tZI0Y)
### Setup video and feature walkthrough on Youtube (Outdated but still useful) :
### Setup video and feature walkthrough on Youtube (Outdated but still useful) :
[](https://www.youtube.com/watch?v=_HaqfJy1Y54)
[](https://www.youtube.com/watch?v=_HaqfJy1Y54)
@@ -138,7 +142,7 @@ The system supports live, recent, and upcoming game information for multiple spo
(2x in a horizontal chain is recommended)
(2x in a horizontal chain is recommended)
- [Adafruit 64×32](https://www.adafruit.com/product/2278) – designed for 128×32 but works with dynamic scaling on many displays (pixel pitch is user preference)
- [Adafruit 64×32](https://www.adafruit.com/product/2278) – designed for 128×32 but works with dynamic scaling on many displays (pixel pitch is user preference)
- [Waveshare 64×32](https://amzn.to/3Kw55jK) - Does not require E addressable pad
- [Waveshare 64×32](https://amzn.to/3Kw55jK) - Does not require E addressable pad
- [Waveshare 92×46](https://amzn.to/4bydNcv) – higher resolution, requires soldering the **E addressable pad** on the [Adafruit RGB Bonnet](https://www.adafruit.com/product/3211) to “8” **OR** toggling the DIP switch on the Adafruit Triple LED Matrix Bonnet *(no soldering required!)*
- [Waveshare 96×48](https://amzn.to/4bydNcv) – higher resolution, requires soldering the **E addressable pad** on the [Adafruit RGB Bonnet](https://www.adafruit.com/product/3211) to “8” **OR** toggling the DIP switch on the Adafruit Triple LED Matrix Bonnet *(no soldering required!)*
> Amazon Affiliate Link – ChuckBuilds receives a small commission on purchases
> Amazon Affiliate Link – ChuckBuilds receives a small commission on purchases
### Power Supply
### Power Supply
@@ -152,7 +156,7 @@ The system supports live, recent, and upcoming game information for multiple spo
## Possibly required depending on the display you are using.
## Possibly required depending on the display you are using.
- Some LED Matrix displays require an "E" addressable line to draw the display properly. The [64x32 Adafruit display](https://www.adafruit.com/product/2278) does NOT require the E addressable line, however the [92x46 Waveshare display](https://amzn.to/4pQdezE) DOES require the "E" Addressable line.
- Some LED Matrix displays require an "E" addressable line to draw the display properly. The [64x32 Adafruit display](https://www.adafruit.com/product/2278) does NOT require the E addressable line, however the [96x48 Waveshare display](https://amzn.to/4pQdezE) DOES require the "E" Addressable line.
- Various ways to enable this depending on your Bonnet / HAT.
- Various ways to enable this depending on your Bonnet / HAT.
Your display will look like it is "sort of" working but still messed up.
Your display will look like it is "sort of" working but still messed up.
@@ -778,14 +782,18 @@ The LEDMatrix system includes Web Interface that runs on port 5000 and provides
### Installing the Web Interface Service
### Installing the Web Interface Service
> The first-time installer (`first_time_install.sh`) already installs the
> web service. The steps below only apply if you need to (re)install it
These are BDF fonts, a simple bitmap font-format that can be created
by many font tools. Given that these are bitmap fonts, they will look good on
very low resolution screens such as the LED displays.
Fonts in this directory (except tom-thumb.bdf) are public domain (see the [README](./README)) and
help you to get started with the font support in the API or the `text-util`
from the utils/ directory.
Tom-Thumb.bdf is included in this directory under [MIT license](http://vt100.tarunz.org/LICENSE). Tom-thumb.bdf was created by [@robey](http://twitter.com/robey) and originally published at https://robey.lag.net/2010/01/23/tiny-monospace-font.html
The texgyre-27.bdf font was created using the [otf2bdf] tool from the TeX Gyre font.
For more information about plugins, see the [Plugin Development Guide](.cursor/plugins_guide.md) and [Plugin Architecture Specification](docs/PLUGIN_ARCHITECTURE_SPEC.md).
1. Select a plugin from the dropdown (auto-discovers from `plugins/` and `plugin-repos/`)
2. The config form auto-generates from the plugin's `config_schema.json`
3. Tweak any config value — the display preview updates automatically
4. Toggle "Auto" off for plugins with slow `update()` calls, then click "Render" manually
5. Use the zoom slider to scale the tiny display (128x32) up for detailed inspection
6. Toggle the grid overlay to see individual pixel boundaries
### Mock Data for API-dependent Plugins
Many plugins fetch data from APIs (sports scores, weather, stocks). To render these locally, expand "Mock Data" and paste a JSON object with cache keys the plugin expects.
To find the cache keys a plugin uses, search its `manager.py` for `self.cache_manager.set(` calls.
> [PLUGIN_API_REFERENCE.md](PLUGIN_API_REFERENCE.md), and
> [REST_API_REFERENCE.md](REST_API_REFERENCE.md).
## Executive Summary
## Executive Summary
This document outlines the transformation of the LEDMatrix project into a modular, plugin-based architecture that enables user-created displays. The goal is to create a flexible, extensible system similar to Home Assistant Community Store (HACS) where users can discover, install, and manage custom display managers from GitHub repositories.
This document outlines the transformation of the LEDMatrix project into a modular, plugin-based architecture that enables user-created displays. The goal is to create a flexible, extensible system similar to Home Assistant Community Store (HACS) where users can discover, install, and manage custom display managers from GitHub repositories.
@@ -9,7 +28,7 @@ This document outlines the transformation of the LEDMatrix project into a modula
1.**Gradual Migration**: Existing managers remain in core while new plugin infrastructure is built
1.**Gradual Migration**: Existing managers remain in core while new plugin infrastructure is built
2.**Migration Required**: Breaking changes with migration tools provided
2.**Migration Required**: Breaking changes with migration tools provided
3.**GitHub-Based Store**: Simple discovery system, packages served from GitHub repos
3.**GitHub-Based Store**: Simple discovery system, packages served from GitHub repos
4.**Plugin Location**: `./plugins/` directory in project root
4.**Plugin Location**: `./plugins/` directory in project root*(actual default is now `plugin-repos/`)*
> The current implementation lives in `web_interface/app.py`,
> `web_interface/blueprints/api_v3.py`, and `web_interface/templates/v3/`.
> The user-facing description (Overview, Features, Form Generation
> Process) is still accurate.
## Overview
## Overview
Each installed plugin now gets its own dedicated configuration tab in the web interface. This provides a clean, organized way to configure plugins without cluttering the main Plugins management tab.
Each installed plugin now gets its own dedicated configuration tab in the web interface. This provides a clean, organized way to configure plugins without cluttering the main Plugins management tab.
@@ -29,10 +39,14 @@ Each installed plugin now gets its own dedicated configuration tab in the web in
3. Click **Save Configuration**
3. Click **Save Configuration**
4. Restart the display service to apply changes
4. Restart the display service to apply changes
### Plugin Management vs Configuration
### Plugin Manager vs Per-Plugin Configuration
- **Plugins Tab**: Used for plugin management (install, enable/disable, update, uninstall)
- **Plugin Manager tab** (second nav row): used for browsing the
- **Plugin-Specific Tabs**: Used for configuring plugin behavior and settings
@@ -12,6 +12,21 @@ When developing plugins in separate repositories, you need a way to:
The solution uses **symbolic links** to connect plugin repositories to the `plugins/` directory, combined with a helper script to manage the linking process.
The solution uses **symbolic links** to connect plugin repositories to the `plugins/` directory, combined with a helper script to manage the linking process.
> **Plugin directory note:** the dev workflow described here puts
> symlinks in `plugins/`. The plugin loader's *production* default is
> `plugin-repos/` (set by `plugin_system.plugins_directory` in
> `config.json`). Importantly, the main discovery path
> (`PluginManager.discover_plugins()`) only scans the configured
> directory — it does **not** fall back to `plugins/`. Two narrower
> paths do: the Plugin Store install/update logic in `store_manager.py`,
> and `schema_manager.get_schema_path()` (which the web UI form
> generator uses to find `config_schema.json`). That's why plugins
> installed via the Plugin Store still work even with symlinks in
> `plugins/`, but your own dev plugin won't appear in the rotation
> until you either move it to `plugin-repos/` or change
> `plugin_system.plugins_directory` to `plugins` in the General tab
> of the web UI. The latter is the smoother dev setup.
## Quick Start
## Quick Start
### 1. Link a Plugin from GitHub
### 1. Link a Plugin from GitHub
@@ -466,7 +481,9 @@ When developing plugins, you'll need to use the APIs provided by the LEDMatrix s
curl -X POST http://localhost:5000/api/v3/errors/clear
```
### Error Patterns
When the same error occurs repeatedly (5+ times in 60 minutes), it's detected as a pattern and logged as a warning. This helps identify systemic issues.
> manager). Drift from current reality is called out inline.
This document provides a comprehensive overview of the plugin architecture implementation, consolidating details from multiple plugin-related implementation summaries.
This document provides a comprehensive overview of the plugin architecture implementation, consolidating details from multiple plugin-related implementation summaries.
## Executive Summary
## Executive Summary
@@ -14,16 +20,25 @@ The LEDMatrix plugin system transforms the project into a modular, extensible pl
Transform LEDMatrix into a modular, plugin-based system where users can create, share, and install custom displays via a GitHub-based store (similar to HACS for Home Assistant).
LEDMatrix is a modular, plugin-based system where users create, share,
and install custom displays via a GitHub-based store (similar in spirit
to HACS for Home Assistant). This page is a quick reference; for the
full design see [PLUGIN_ARCHITECTURE_SPEC.md](PLUGIN_ARCHITECTURE_SPEC.md)
and [PLUGIN_DEVELOPMENT_GUIDE.md](PLUGIN_DEVELOPMENT_GUIDE.md).
The LEDMatrix Plugin Store allows you to discover, install, and manage display plugins for your LED matrix. Install curated plugins from the official registry or add custom plugins directly from any GitHub repository.
---
## Quick Reference
### Install from Store
```bash
# Web UI: Plugin Store → Search → Click Install
# API:
curl -X POST http://your-pi-ip:5000/api/v3/plugins/install \
-H "Content-Type: application/json"\
-d '{"plugin_id": "clock-simple"}'
```
### Install from GitHub URL
```bash
# Web UI: Plugin Store → "Install from URL" → Paste URL
# API:
curl -X POST http://your-pi-ip:5000/api/v3/plugins/install-from-url \
Each plugin can have its own configuration in `config/config.json`:
```json
{
"clock-simple":{
"enabled":true,
"display_duration":15,
"color":[255,255,255],
"time_format":"12h"
},
"nhl-scores":{
"enabled":true,
"favorite_teams":["TBL","FLA"],
"show_favorite_teams_only":true
}
}
```
**Via Web Interface:**
1. Navigate to the "Plugin Manager" tab
2. Click the Configure (⚙️) button next to the plugin
3. Edit the configuration in the form
4. Save changes
5. Restart the display to apply changes
---
## Safety and Security
### Verified vs Unverified Plugins
- **Verified Plugins**: Reviewed by maintainers, follow best practices, no known security issues
- **Unverified Plugins**: User-contributed, not reviewed, install at your own risk
When installing from a custom GitHub URL, you'll see a warning about installing an unverified plugin. The plugin will have access to your display manager, cache manager, configuration files, and network access.
### Best Practices
1. Only install plugins from trusted sources
2. Review plugin code before installing (click "View on GitHub")
3. Keep plugins updated for security patches
4. Report suspicious plugins to maintainers
---
## Troubleshooting
### Plugin Won't Install
**Problem:** Installation fails with "Failed to clone or download repository"
**Solutions:**
- Check that git is installed: `which git`
- Verify the GitHub URL is correct
- Check your internet connection
- The system will automatically try ZIP download as fallback
### Plugin Won't Load
**Problem:** Plugin installed but doesn't appear in rotation
**Solutions:**
1. Check that the plugin is enabled in config: `"enabled": true`
2. Verify manifest.json exists and is valid
3. Check logs for errors: `sudo journalctl -u ledmatrix -f`
4. Restart the display service: `sudo systemctl restart ledmatrix`
Welcome to the LEDMatrix documentation! This directory contains comprehensive guides, specifications, and reference materials for the LEDMatrix project.
This directory contains guides, references, and architectural notes for the
LEDMatrix project. If you are setting up a Pi for the first time, start with
the [project root README](../README.md) — it covers hardware, OS imaging, and
the one-shot installer. The pages here go deeper.
## 📚 Documentation Overview
## I'm a new user
This documentation has been consolidated and organized to reduce redundancy while maintaining comprehensive coverage. Recent improvements include complete API references, enhanced plugin development guides, and better organization for both end users and developers.
The Starlark Apps plugin for LEDMatrix enables you to run **Tidbyt/Tronbyte community apps** on your LED matrix display without modification. This integration allows you to access hundreds of pre-built widgets and apps from the vibrant Tidbyt community ecosystem.
## Important: Third-Party Content
**⚠️ Apps are NOT managed by the LEDMatrix project**
- Starlark apps are developed and maintained by the **Tidbyt/Tronbyte community**
- LEDMatrix provides the runtime environment but does **not** create, maintain, or support these apps
- All apps originate from the [Tronbyte Apps Repository](https://github.com/tronbyt/apps)
- App quality, functionality, and security are the responsibility of individual app authors
- LEDMatrix is not affiliated with Tidbyt Inc. or the Tronbyte project
## What is Starlark?
[Starlark](https://github.com/bazelbuild/starlark) is a Python-like language originally developed by Google for the Bazel build system. Tidbyt adopted Starlark for building LED display apps because it's:
- **Sandboxed**: Apps run in a safe, restricted environment
- **Simple**: Python-like syntax that's easy to learn
- **Deterministic**: Apps produce consistent output
- **Fast**: Compiled and optimized for performance
**Disclaimer**: LEDMatrix is an independent project and is not affiliated with, endorsed by, or sponsored by Tidbyt Inc. The Starlark Apps plugin enables interoperability with Tidbyt's open-source ecosystem but does not imply any official relationship.
## Support
For issues with:
- **LEDMatrix integration**: File issues at [LEDMatrix GitHub](https://github.com/ChuckBuilds/LEDMatrix/issues)
- **Specific apps**: File issues at [Tronbyte Apps](https://github.com/tronbyt/apps/issues)
- **Pixlet rendering**: File issues at [Pixlet Repository](https://github.com/tidbyt/pixlet/issues)
---
**Ready to get started?** Install the Starlark Apps plugin and explore 974+ community apps! 🎨
The LEDMatrix web interface provides a complete control panel for managing your LED matrix display. Access all features through a modern, responsive web interface that works on desktop, tablet, and mobile devices.
---
## Quick Start
### Accessing the Interface
1. Find your Raspberry Pi's IP address:
```bash
hostname -I
```
2. Open a web browser and navigate to:
```
http://your-pi-ip:5000
```
3. The interface will load with the Overview tab displaying system stats and a live display preview.
**Note:** If the interface doesn't load, verify the web service is running:
```bash
sudo systemctl status ledmatrix-web
```
---
## Navigation
The interface uses a two-row tab layout. The system tabs are always
present:
- **Overview** — System stats, quick actions, live display preview
The LEDMatrix WiFi system provides automatic network configuration with intelligent failover to Access Point (AP) mode. When your Raspberry Pi loses network connectivity, it automatically creates a WiFi access point for easy configuration—ensuring you can always connect to your device.
### Key Features
- **Automatic AP Mode**: Creates a WiFi access point when network connection is lost
- **Intelligent Failover**: Only activates after a grace period to prevent false positives
- **Dual Connectivity**: Supports both WiFi and Ethernet with automatic priority management
- **Web Interface**: Configure WiFi through an easy-to-use web interface
- **Network Scanning**: Scan and connect to available WiFi networks
Some files were not shown because too many files have changed in this diff
Show More
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.