33 Commits

Author SHA1 Message Date
Chuck
963c4d3b91 fix(web): use window.installedPlugins for bulk update button (#250)
The previous fix (#249) wired window.updateAllPlugins to
PluginInstallManager.updateAll(), but that method reads from
PluginStateManager.installedPlugins which is never populated on
page load — only after individual install/update operations.

Meanwhile, base.html already defined a working updateAllPlugins
using window.installedPlugins (reliably populated by plugins_manager.js).
The override from install_manager.js masked this working version.

Fix: revert install_manager.js changes and rewrite runUpdateAllPlugins
to iterate window.installedPlugins directly, calling the API endpoint
without any middleman. Adds per-plugin progress in button text and
a summary notification on completion.

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 15:28:51 -05:00
Chuck
22c495ea7c perf(store): cache GitHub API calls and eliminate redundant requests (#251)
The plugin store was making excessive GitHub API calls causing slow
page loads (10-30s):

- Installed plugins endpoint called get_plugin_info() per plugin (3
  GitHub API calls each) just to read the `verified` field from the
  registry. Use new get_registry_info() instead (zero API calls).
- _get_latest_commit_info() had no cache — all 31 monorepo plugins
  share the same repo URL, causing 31 identical API calls. Add 5-min
  cache keyed by repo:branch.
- _fetch_manifest_from_github() also uncached — add 5-min cache.
- load_config() called inside loop per-plugin — hoist outside loop.
- Install/update operations pass force_refresh=True to bypass caches
  and always get the latest commit SHA from GitHub.

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 14:46:31 -05:00
Chuck
5b0ad5ab71 fix(web): wire up "Check & Update All" plugins button (#249)
window.updateAllPlugins was never assigned, so the button always showed
"Bulk update handler unavailable." Wire it to PluginInstallManager.updateAll(),
add per-plugin progress feedback in the button text, show a summary
notification on completion, and skip redundant plugin list reloads.

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 13:06:18 -05:00
Chuck
bc8568604a feat(web): add LED RGB sequence, multiplexing, and panel type settings (#248)
* feat(web): add LED RGB sequence, multiplexing, and panel type settings

Expose three rpi-rgb-led-matrix hardware options in the Display Settings
UI so users can configure non-standard panels without editing config.json
manually. All defaults match existing behavior (RGB, Direct, Standard).

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

* fix(api): validate led_rgb_sequence, multiplexing, and panel_type inputs

Reject invalid values with 400 errors before writing to config: whitelist
check for led_rgb_sequence and panel_type, range + type check for multiplexing.

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

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 18:16:21 -05:00
Chuck
878f339fb3 fix(logos): support logo downloads for custom soccer leagues (#247)
* fix(logos): support logo downloads for custom soccer leagues

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

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

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

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

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

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 17:43:05 -05:00
Chuck
51616f1bc4 fix(web): dark mode for collapsible config section headers (#246)
* fix(web): add dark mode overrides for collapsible config section headers

The collapsible section headers in plugin config schemas used bg-gray-100
and hover:bg-gray-200 which had no dark mode overrides, resulting in light
text on a light background when dark mode was active.

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

* fix(web): add missing bg-gray-100 light-mode utility class

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

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 15:50:34 -05:00
Chuck
82370a0253 Fix log viewer readability — add missing CSS utility classes (#244)
* fix(web): add missing utility classes for log viewer readability

The log viewer uses text-gray-100, text-gray-200, text-gray-300,
text-red-300, text-yellow-300, bg-gray-800, bg-red-900, bg-yellow-900,
border-gray-700, and hover:bg-gray-800 — none of which were defined in
app.css. Without definitions, log text inherited the body's dark color
(#111827) which was invisible against the dark bg-gray-900 log container
in light mode.

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

* fix(web): remove dead bg-opacity classes, use proper log level colors

The bg-opacity-10/bg-opacity-30 classes set a --bg-opacity CSS variable
that no background-color rule consumed, making them dead code. Replace
the broken two-class pattern (e.g. "bg-red-900 bg-opacity-10") with
dedicated log-level-error/warning/debug classes that use rgb() with
actual alpha values.

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

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 22:14:20 -05:00
Chuck
3975940cff Add light/dark mode toggle and fix log readability (#243)
* feat(web): add light/dark mode toggle and fix log readability

Add a theme toggle button (moon/sun icon) to the header that switches
between light and dark mode. Theme preference persists in localStorage
and falls back to the OS prefers-color-scheme setting.

The implementation uses a data-theme attribute on <html> with CSS
overrides, so all 13 partial templates and 20+ widget JS files get
dark mode support without any modifications — only 3 files changed.

Also fixes log timestamp readability: text-gray-500 had ~3.5:1 contrast
ratio against the dark log background, now uses text-gray-400 (~5.3:1)
which passes WCAG AA in both light and dark mode.

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

* fix(web): address dark mode review — accessibility, robustness, and code quality

- WCAG touch target: enforce 44×44px minimum on theme toggle button
  with display:inline-flex centering
- Accessibility: add type="button", aria-pressed (dynamically updated),
  aria-hidden on decorative icons, and contextual aria-label/title that
  reflects current state ("Switch to light/dark mode")
- Robustness: wrap all localStorage and matchMedia calls in try/catch
  with fallbacks for private browsing and restricted contexts; use
  addListener fallback for older browsers lacking addEventListener
- Stylelint: convert all rgba() to modern rgb(…/…%) notation across
  both light and dark theme shadows and gradients
- DRY: replace hardcoded hex values in dark mode utility overrides and
  component overrides with CSS variable references (--color-surface,
  --color-background, --color-border, --color-text-primary, etc.)
- Remove redundant [data-theme="dark"] body rule (body already uses
  CSS variables that are redefined under the dark theme selector)

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

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 21:12:37 -05:00
Chuck
158e07c82b fix(plugins): prevent root-owned files from blocking plugin updates (#242)
* fix(web): unify operation history tracking for monorepo plugin operations

The operation history UI was reading from the wrong data source
(operation_queue instead of operation_history), install/update records
lacked version details, toggle operations used a type name that didn't
match UI filters, and the Clear History button was non-functional.

- Switch GET /plugins/operation/history to read from OperationHistory
  audit log with return type hint and targeted exception handling
- Add DELETE /plugins/operation/history endpoint; wire up Clear button
- Add _get_plugin_version helper with specific exception handling
  (FileNotFoundError, PermissionError, json.JSONDecodeError) and
  structured logging with plugin_id/path context
- Record plugin version, branch, and commit details on install/update
- Record install failures in the direct (non-queue) code path
- Replace "toggle" operation type with "enable"/"disable"
- Add normalizeStatus() in JS to map completed→success, error→failed
  so status filter works regardless of server-side convention
- Truncate commit SHAs to 7 chars in details display
- Fix HTML filter options, operation type colors, duplicate JS init

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

* fix(plugins): prevent root-owned files from blocking plugin updates

The root ledmatrix service creates __pycache__ and data cache files
owned by root inside plugin directories. The web service (non-root)
cannot delete these when updating or uninstalling plugins, causing
operations to fail with "Permission denied".

Defense in depth with three layers:
- Prevent: PYTHONDONTWRITEBYTECODE=1 in systemd service + run.py
- Fallback: sudoers rules for rm on plugin directories
- Code: _safe_remove_directory() now uses sudo as last resort,
  and all bare shutil.rmtree() calls routed through it

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

* fix(security): harden sudo removal with path-validated helper script

Address code review findings:

- Replace raw rm/find sudoers wildcards with a vetted helper script
  (safe_plugin_rm.sh) that resolves symlinks and validates the target
  is a strict child of plugin-repos/ or plugins/ before deletion
- Add allow-list validation in sudo_remove_directory() that checks
  resolved paths against allowed bases before invoking sudo
- Check _safe_remove_directory() return value before shutil.move()
  in the manifest ID rename path
- Move stat import to module level in store_manager.py
- Use stat.S_IRWXU instead of 0o777 in chmod fallback stage
- Add ignore_errors=True to temp dir cleanup in finally block
- Use command -v instead of which in configure_web_sudo.sh

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

* fix(security): address code review round 2 — harden paths and error handling

- safe_plugin_rm.sh: use realpath --canonicalize-missing for ALLOWED_BASES
  so the script doesn't fail under set -e when dirs don't exist yet
- safe_plugin_rm.sh: add -- before path in rm -rf to prevent flag injection
- permission_utils.py: use shutil.which('bash') instead of hardcoded /bin/bash
  to match whatever path the sudoers BASH_PATH resolves to
- store_manager.py: check _safe_remove_directory() return before shutil.move()
  in _install_from_monorepo_zip to prevent moving into a non-removed target
- store_manager.py: catch OSError instead of PermissionError in Stage 1 removal
  to handle both EACCES and EPERM error codes
- store_manager.py: hoist sudo_remove_directory import to module level
- configure_web_sudo.sh: harden safe_plugin_rm.sh to root-owned 755 so
  the web user cannot modify the vetted helper script

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

* fix(security): validate command paths in sudoers config and use resolved paths

- configure_web_sudo.sh: validate that required commands (systemctl, bash,
  python3) resolve to non-empty paths before generating sudoers entries;
  abort with clear error if any are missing; skip optional commands
  (reboot, poweroff, journalctl) with a warning instead of emitting
  malformed NOPASSWD lines; validate helper script exists on disk
- permission_utils.py: pass the already-resolved path to the subprocess
  call and use it for the post-removal exists() check, eliminating a
  TOCTOU window between Python-side validation and shell-side execution

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

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 19:28:05 -05:00
Chuck
9a72adbde1 fix(web): unify operation history tracking for monorepo plugin operations (#240)
The operation history UI was reading from the wrong data source
(operation_queue instead of operation_history), install/update records
lacked version details, toggle operations used a type name that didn't
match UI filters, and the Clear History button was non-functional.

- Switch GET /plugins/operation/history to read from OperationHistory
  audit log with return type hint and targeted exception handling
- Add DELETE /plugins/operation/history endpoint; wire up Clear button
- Add _get_plugin_version helper with specific exception handling
  (FileNotFoundError, PermissionError, json.JSONDecodeError) and
  structured logging with plugin_id/path context
- Record plugin version, branch, and commit details on install/update
- Record install failures in the direct (non-queue) code path
- Replace "toggle" operation type with "enable"/"disable"
- Add normalizeStatus() in JS to map completed→success, error→failed
  so status filter works regardless of server-side convention
- Truncate commit SHAs to 7 chars in details display
- Fix HTML filter options, operation type colors, duplicate JS init

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 12:11:12 -05:00
Chuck
9d3bc55c18 fix: post-merge monorepo hardening and cleanup (#239)
* fix: address PR review nitpicks for monorepo hardening

- Add docstring note about regex limitation in parse_json_with_trailing_commas
- Abort on zip-slip in ZIP installer instead of skipping (consistent with API installer)
- Use _safe_remove_directory for non-git plugin reinstall path
- Use segment-wise encodeURIComponent for View button URL encoding

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

* fix: check _safe_remove_directory result before reinstalling plugin

Avoid calling install_plugin into a partially-removed directory by
checking the boolean return of _safe_remove_directory, mirroring the
guard already used in the git-remote migration path.

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

* fix: normalize subpath prefix and add zip-slip guard to download installer

- Strip trailing slashes from plugin_subpath before building the tree
  filter prefix, preventing double-slash ("subpath//") that would cause
  file_entries to silently miss all matches.
- Add zip-slip protection to _install_via_download (extractall path),
  matching the guard already present in _install_from_monorepo_zip.

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

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 11:59:23 -05:00
Chuck
df3cf9bb56 Feat/monorepo migration (#238)
* feat: adapt LEDMatrix for monorepo plugin architecture

Update store_manager to fetch manifests from subdirectories within the
monorepo (plugin_path/manifest.json) instead of repo root. Remove 21
plugin submodule entries from .gitmodules, simplify workspace file to
reference the monorepo, and clean up scripts for the new layout.

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

* fix: auto-reinstall plugins when registry repo URL changes

When a user clicks "Update" on a git-cloned plugin, detect if the
local git remote URL no longer matches the registry's repo URL (e.g.
after monorepo migration). Instead of pulling from the stale archived
repo, automatically remove and reinstall from the new registry source.

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

* fix: plugin store "View" button links to correct monorepo subdirectory

When a plugin has a plugin_path (monorepo plugin), construct the GitHub
URL as repo/tree/main/plugin_path so users land on the specific plugin
directory. Pass plugin_path through the store API response to the
frontend.

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

* fix: monorepo manifest fetch in search + version-based update detection

Fix search_plugins() to pass plugin_path when fetching manifests from
GitHub, matching the fix already in get_plugin_info(). Without this,
monorepo plugin descriptions 404 in search results.

Add version comparison for non-git plugins (monorepo installs) so
"Update All" skips plugins already at latest_version instead of blindly
reinstalling every time.

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

* feat: show plugin version instead of misleading monorepo commit info

Replace commit hash, date, and stars on plugin cards with the plugin's
version number. In a monorepo all plugins share the same commit history
and star count, making those fields identical and misleading. Version
is the meaningful per-plugin signal users care about.

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

* docs: add CLAUDE.md with project structure and plugin store docs

Documents plugin store architecture, monorepo install flow, version-
based update detection, and the critical version bump workflow.

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

* perf: extract only target plugin from monorepo ZIP instead of all files

Previously _install_from_monorepo() called extractall() on the entire
monorepo ZIP (~13MB, 600+ files) just to grab one plugin subdirectory.
Now filter zip members by the plugin prefix and extract only matching
files, reducing disk I/O by ~96% per install/update.

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

* perf: download only target plugin files via GitHub Trees API

Replace full monorepo ZIP download (~5MB) with targeted file downloads
(~200KB per plugin) using the GitHub Git Trees API for directory listing
and raw.githubusercontent.com for individual file content.

One API call fetches the repo tree, client filters for the target
plugin's files, then downloads each file individually. Falls back to
ZIP if the API is unavailable (rate limited, no network, etc.).

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

* fix: clean up partial files between API and ZIP install fallbacks

Ensure target_path is fully removed before the ZIP fallback runs, and
before shutil.move() in the ZIP method. Prevents directory nesting if
the API method creates target_path then fails mid-download.

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

* fix: harden scripts and fix monorepo URL handling

- setup_plugin_repos.py: add type hints, remove unnecessary f-string,
  wrap manifest parsing in try/except to skip malformed manifests
- update_plugin_repos.py: add 120s timeout to git pull with
  TimeoutExpired handling
- store_manager.py: fix rstrip('.zip') stripping valid branch chars,
  use removesuffix('.zip'); remove redundant import json
- plugins_manager.js: View button uses dynamic branch, disables when
  repo is missing, encodes plugin_path in URL
- CLAUDE.md: document plugin repo naming convention

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

* fix: harden monorepo install security and cleanup

- store_manager: fix temp dir leak in _install_from_monorepo_zip by
  moving cleanup to finally block
- store_manager: add zip-slip guard validating extracted paths stay
  inside temp directory
- store_manager: add 500-file sanity cap to API-based install
- store_manager: extract _normalize_repo_url as @staticmethod
- setup_plugin_repos: propagate create_symlinks() failure via sys.exit,
  narrow except to OSError

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

* fix: add path traversal guard to API-based monorepo installer

Validate that each file's resolved destination stays inside
target_path before creating directories or writing bytes, mirroring
the zip-slip guard in _install_from_monorepo_zip.

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

* fix: use _safe_remove_directory for monorepo migration cleanup

Replace shutil.rmtree(ignore_errors=True) with _safe_remove_directory
which handles permission errors gracefully and returns status, preventing
install_plugin from running against a partially-removed directory.

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

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 18:57:30 -05:00
Chuck
448a15c1e6 feat(fonts): add dynamic font selection and font manager improvements (#232)
* feat(fonts): add dynamic font selection and font manager improvements

- Add font-selector widget for dynamic font selection in plugin configs
- Enhance /api/v3/fonts/catalog with filename, display_name, and type
- Add /api/v3/fonts/preview endpoint for server-side font rendering
- Add /api/v3/fonts/<family> DELETE endpoint with system font protection
- Fix /api/v3/fonts/upload to actually save uploaded font files
- Update font manager tab with dynamic dropdowns, server-side preview, and font deletion
- Add new BDF fonts: 6x10, 6x12, 6x13, 7x13, 7x14, 8x13, 9x15, 9x18, 10x20 (with bold/oblique variants)
- Add tom-thumb, helvR12, clR6x12, texgyre-27 fonts

Plugin authors can use x-widget: "font-selector" in schemas to enable
dynamic font selection that automatically shows all available fonts.

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

* fix(fonts): security fixes and code quality improvements

- Fix README.md typos and add language tags to code fences
- Remove duplicate delete_font function causing Flask endpoint collision
- Add safe integer parsing for size parameter in preview endpoint
- Fix path traversal vulnerability in /fonts/preview endpoint
- Fix path traversal vulnerability in /fonts/<family> DELETE endpoint
- Fix XSS vulnerability in fonts.html by using DOM APIs instead of innerHTML
- Move baseUrl to shared scope to fix ReferenceError in multiple functions

Security improvements:
- Validate font filenames reject path separators and '..'
- Validate paths are within fonts_dir before file operations
- Use textContent and data attributes instead of inline onclick handlers
- Restrict file extensions to known font types

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

* fix(fonts): address code issues and XSS vulnerabilities

- Move `import re` to module level, remove inline imports
- Remove duplicate font_file assignment in upload_font()
- Remove redundant validation with inconsistent allowed extensions
- Remove redundant PathLib import, use already-imported Path
- Fix XSS vulnerabilities in fonts.html by using DOM APIs instead of
  innerHTML with template literals for user-controlled data

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

* fix(fonts): add size limits to font preview endpoint

Add input validation to prevent DoS via large image generation:
- MAX_TEXT_CHARS (100): Limit text input length
- MAX_TEXT_LINES (3): Limit number of newlines
- MAX_DIM (1024): Limit max width/height
- MAX_PIXELS (500000): Limit total pixel count

Validates text early before processing and checks computed
dimensions after bbox calculation but before image allocation.

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

* fix(fonts): improve error handling, catalog keys, and BDF preview

- Add structured logging for cache invalidation failures instead of
  silent pass (FontUpload, FontDelete, FontCatalog contexts)
- Use filename as unique catalog key to prevent collisions when
  multiple font files share the same family_name from metadata
- Return explicit error for BDF font preview instead of showing
  misleading preview with default font

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

* fix(fonts): address nitpick issues in font management

Frontend (fonts.html):
- Remove unused escapeHtml function (dead code)
- Add max-attempts guard (50 retries) to initialization loop
- Add response.ok checks before JSON parsing in deleteFont,
  addFontOverride, deleteFontOverride, uploadSelectedFonts
- Use is_system flag from API instead of hardcoded client-side list

Backend (api_v3.py):
- Move SYSTEM_FONTS to module-level frozenset for single source of truth
- Add is_system flag to font catalog entries
- Simplify delete_font system font check using frozenset lookup

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

* fix(fonts): align frontend upload validation with backend

- Add .otf to accepted file extensions (HTML accept attribute, JS filter)
- Update validation regex to allow hyphens (matching backend)
- Preserve hyphens in auto-generated font family names
- Update UI text to reflect all supported formats

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

* fix(fonts): fix lint errors and missing variable

- Remove unused exception binding in set_cached except block
- Define font_family_lower before case-insensitive fallback loop
- Add response.ok check to font preview fetch (consistent with other handlers)

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

* fix(fonts): address nitpick code quality issues

- Add return type hints to get_font_preview and delete_font endpoints
- Catch specific PIL exceptions (IOError/OSError) when loading fonts
- Replace innerHTML with DOM APIs for trash icon (consistency)

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

* fix(fonts): remove unused exception bindings in cache-clearing blocks

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

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-11 18:21:27 -05:00
Chuck
b99be88cec fix(plugins): namespace-isolate modules for safe parallel loading (#237)
* fix(plugins): prevent KeyError race condition in module cleanup

When multiple plugins have modules with the same name (e.g.,
background_data_service.py), the _clear_conflicting_modules function
could raise a KeyError if a module was removed between iteration
and deletion. This race condition caused plugin loading failures
with errors like: "Unexpected error loading plugin: 'background_data_service'"

Changes:
- Use sys.modules.pop(mod_name, None) instead of del sys.modules[mod_name]
  to safely handle already-removed modules
- Apply same fix to plugin unload in plugin_manager.py for consistency
- Fix typo in sports.py: rankself._team_rankings_cacheings ->
  self._team_rankings_cache

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

* fix(plugins): namespace-isolate plugin modules to prevent parallel loading collisions

Multiple sport plugins share identically-named Python files (scroll_display.py,
game_renderer.py, sports.py, etc.). When loaded in parallel via ThreadPoolExecutor,
bare module names collide in sys.modules causing KeyError crashes.

Replace _clear_conflicting_modules with _namespace_plugin_modules: after exec_module
loads a plugin, its bare-name sub-modules are moved to namespaced keys
(e.g. _plg_basketball_scoreboard_scroll_display) so they cannot collide.
A threading lock serializes the exec_module window where bare names temporarily exist.

Also updates unload_plugin to clean up namespaced sub-modules from sys.modules.

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

* fix(plugins): address review feedback on namespace isolation

- Fix main module accidentally renamed: move before_keys snapshot to
  after sys.modules[module_name] insertion so the main entry is excluded
  from namespace renaming and error cleanup
- Use Path.is_relative_to() instead of substring matching for plugin
  directory containment checks to avoid false-matches on overlapping
  directory names
- Add try/except around exec_module to clean up partially-initialized
  modules on failure, preventing leaked bare-name entries
- Add public unregister_plugin_modules() method on PluginLoader so
  PluginManager doesn't reach into private attributes during unload
- Update stale comment referencing removed _clear_conflicting_modules

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

* fix(plugins): remove unused plugin_dir_str variable

Leftover from the old substring containment check, now replaced by
Path.is_relative_to().

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

* fix(plugins): extract shared helper for bare-module filtering

Hoist plugin_dir.resolve() out of loops and deduplicate the bare-module
filtering logic between _namespace_plugin_modules and the error cleanup
block into _iter_plugin_bare_modules().

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

* fix(plugins): keep bare-name alias to prevent lazy import duplication

Stop removing bare module names from sys.modules after namespacing.
Removing them caused lazy intra-plugin imports (deferred imports inside
methods) to re-import from disk, creating a second inconsistent module
copy. Keeping both the bare and namespaced entries pointing to the same
object avoids this. The next plugin's exec_module naturally overwrites
the bare entry with its own version.

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

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-10 22:47:24 -05:00
Chuck
4a9fc2df3a feat(web): add shutdown button to Quick Actions (#234)
Add a "Shutdown System" button to the Overview page that gracefully
powers off the Raspberry Pi. Uses sudo poweroff, consistent with the
existing reboot_system action, letting sudo's secure_path handle
binary resolution.

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-08 12:35:37 -05:00
Chuck
d207e7c6dd feat(config): add led_rgb_sequence option to config template (#231)
Add the led_rgb_sequence configuration option to the matrix config template,
allowing users to specify the RGB sequence for their LED panels.

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 13:12:47 -05:00
Chuck
7e98fa9bd8 fix(web): handle string booleans and mode variants in schedule-picker widget (#228)
* fix(web): handle string boolean values in schedule-picker widget

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

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

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

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

* fix: trim whitespace in coerceToBoolean string handling

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

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 16:24:16 -05:00
Chuck
0d5510d8f7 Fix/plugin module namespace collision (#229)
* fix(web): handle string boolean values in schedule-picker widget

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

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

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

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

* fix: trim whitespace in coerceToBoolean string handling

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

* fix(plugins): resolve module namespace collisions between plugins

When multiple plugins have modules with the same name (e.g., data_fetcher.py),
Python's sys.modules cache would return the wrong module. This caused plugins
like ledmatrix-stocks to fail loading because it imported data_fetcher from
ledmatrix-leaderboard instead of its own.

Added _clear_conflicting_modules() to remove cached plugin modules from
sys.modules before loading each plugin, ensuring correct module resolution.

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

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 16:24:06 -05:00
Chuck
18fecd3cda fix(web): handle string boolean values in schedule-picker widget (#227)
* fix(web): handle string boolean values in schedule-picker widget

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

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

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

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

* fix: trim whitespace in coerceToBoolean string handling

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 14:57:57 -05:00
Chuck
1c3269c0f3 Fix/led panel permissions 224 (#226)
* fix(install): exclude rpi-rgb-led-matrix from permission normalization

The permission normalization step in first_time_install.sh was running
chmod 644 on all files, which stripped executable bits from compiled
library files (librgbmatrix.so.1) after make build-python created them.

This caused LED panels to not work after fresh installation until users
manually ran chmod on the rpi-rgb-led-matrix-master directory.

Fixes #224

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

* fix(install): resolve install script issues and speed up web UI startup

Issues addressed:
- Remove redundant python3-pillow from apt (Debian maps it to python3-pil)
- Only upgrade pip, not setuptools/wheel (they conflict with apt versions)
- Remove separate apt numpy install (pip handles it from requirements.txt)
- Install web interface deps during first-time setup, not on every startup
- Add marker file (.web_deps_installed) to skip redundant pip installs
- Add user-friendly message about wait time after installation

The web UI was taking 30-60+ seconds to start because it ran pip install
on every startup. Now it only installs dependencies on first run.

Fixes #208

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

* fix(install): prevent duplicate web dependency installation

Step 7 was installing web dependencies again even though they were
already installed in Step 5. Now Step 7 checks for the .web_deps_installed
marker file and skips the installation if it already exists.

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

* fix(install): only create web deps marker on successful install

The .web_deps_installed marker file should only be created when pip
install actually succeeds. Previously it was created regardless of
the pip exit status, which could cause subsequent runs to skip
installing missing dependencies.

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

* fix(install): create config files before starting services

The services were being started before config files existed, causing
the web service to fail with "config.json not found". Reordered steps
so config files are created (Step 4) before services are installed
and started (Step 4.1).

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

* fix(install): remove pip upgrade step (apt version is sufficient)

The apt-installed pip cannot be upgraded because it doesn't have a
RECORD file. Since the apt version (25.1.1) is already recent enough,
we can skip the upgrade step entirely.

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

* fix(install): reorder script to install services after dependencies

Moved main LED Matrix service installation (Step 7.5) to after all
Python dependencies are installed (Steps 5-7). Previously services
were being started before pip packages and rgbmatrix were ready,
causing startup failures.

New order:
- Step 5: Python pip dependencies
- Step 6: rpi-rgb-led-matrix build
- Step 7: Web interface dependencies
- Step 7.5: Main LED Matrix service (moved here)
- Step 8: Web interface service

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

* fix(install): update step list and fix setcap symlink handling

- Updated step list header to match actual step order after reordering
  (Step 4 is now "Ensure configuration files exist", added Step 7.5
  for main service, added Step 8.1 for systemd permissions)

- Fixed Python capabilities configuration:
  - Check if setcap command exists before attempting to use it
  - Resolve symlinks with readlink -f to get the real binary path
  - Only print success message when setcap actually succeeds
  - Print clear warning with helpful info when setcap fails

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

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 12:49:07 -05:00
Chuck
ea61331d46 fix(install): resolve install issues and speed up web UI startup (#225)
* fix(install): exclude rpi-rgb-led-matrix from permission normalization

The permission normalization step in first_time_install.sh was running
chmod 644 on all files, which stripped executable bits from compiled
library files (librgbmatrix.so.1) after make build-python created them.

This caused LED panels to not work after fresh installation until users
manually ran chmod on the rpi-rgb-led-matrix-master directory.

Fixes #224

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

* fix(install): resolve install script issues and speed up web UI startup

Issues addressed:
- Remove redundant python3-pillow from apt (Debian maps it to python3-pil)
- Only upgrade pip, not setuptools/wheel (they conflict with apt versions)
- Remove separate apt numpy install (pip handles it from requirements.txt)
- Install web interface deps during first-time setup, not on every startup
- Add marker file (.web_deps_installed) to skip redundant pip installs
- Add user-friendly message about wait time after installation

The web UI was taking 30-60+ seconds to start because it ran pip install
on every startup. Now it only installs dependencies on first run.

Fixes #208

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

* fix(install): prevent duplicate web dependency installation

Step 7 was installing web dependencies again even though they were
already installed in Step 5. Now Step 7 checks for the .web_deps_installed
marker file and skips the installation if it already exists.

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

* fix(install): only create web deps marker on successful install

The .web_deps_installed marker file should only be created when pip
install actually succeeds. Previously it was created regardless of
the pip exit status, which could cause subsequent runs to skip
installing missing dependencies.

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

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 12:08:57 -05:00
Chuck
8fb2800495 feat: add error detection, monitoring, and code quality improvements (#223)
* feat: add error detection, monitoring, and code quality improvements

This comprehensive update addresses automatic error detection, code
quality, and plugin development experience:

## Error Detection & Monitoring
- Add ErrorAggregator service for centralized error tracking
- Add pattern detection for recurring errors (5+ in 60 min)
- Add error dashboard API endpoints (/api/v3/errors/*)
- Integrate error recording into plugin executor

## Code Quality
- Remove 10 silent `except: pass` blocks in sports.py and football.py
- Remove hardcoded debug log paths
- Add pre-commit hooks to prevent future bare except clauses

## Validation & Type Safety
- Add warnings when plugins lack config_schema.json
- Add config key collision detection for plugins
- Improve type coercion logging in BasePlugin

## Testing
- Add test_config_validation_edge_cases.py
- Add test_plugin_loading_failures.py
- Add test_error_aggregator.py

## Documentation
- Add PLUGIN_ERROR_HANDLING.md guide
- Add CONFIG_DEBUGGING.md guide

Note: GitHub Actions CI workflow is available in the plan but requires
workflow scope to push. Add .github/workflows/ci.yml manually.

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

* fix: address code review issues

- Fix GitHub issues URL in CONFIG_DEBUGGING.md
- Use RLock in error_aggregator.py to prevent deadlock in export_to_file
- Distinguish missing vs invalid schema files in plugin_manager.py
- Add assertions to test_null_value_for_required_field test
- Remove unused initial_count variable in test_plugin_load_error_recorded
- Add validation for max_age_hours in clear_old_errors API endpoint

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

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 10:05:09 -05:00
Chuck
8912501604 fix(web): ensure unchecked checkboxes save as false in main config forms (#222)
* fix: remove plugin-specific calendar duration from config template

Plugin display durations should be added dynamically when plugins are
installed, not hardcoded in the template.

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

* fix(web): ensure unchecked checkboxes save as false in main config forms

HTML checkboxes omit their key entirely when unchecked, so the backend
never received updates to set boolean values to false. This affected:

- vegas_scroll_enabled: Now uses _coerce_to_bool helper
- use_short_date_format: Now uses _coerce_to_bool helper
- Plugin system checkboxes (auto_discover, auto_load_enabled, development_mode):
  Now uses _coerce_to_bool helper
- Hardware checkboxes (disable_hardware_pulsing, inverse_colors, show_refresh_rate):
  Now uses _coerce_to_bool helper
- web_display_autostart: Now uses _coerce_to_bool helper

Added _coerce_to_bool() helper function that properly converts form string
values ("true", "on", "1", "yes") to actual Python booleans, ensuring
consistent JSON types in config and correct downstream boolean checks.

Also added value="true" to all main config checkboxes for consistent boolean
parsing (sends "true" instead of "on" when checked).

This is the same issue fixed in commit 10d70d91 for plugin configs, but
for the main configuration forms (display settings, general settings).

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

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 21:49:29 -05:00
Chuck
68c4259370 fix: reduce scroll catch-up steps to limit jitter (#219)
Reduce max_steps from 0.1s to 0.04s of catch-up time (from 5 to 2 steps
at 50 FPS). When the system lags, the previous catch-up logic allowed
jumping up to 5 pixels at once, causing visible jitter. Limiting to 2
steps provides smoother scrolling while still allowing for minor timing
corrections.

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 20:03:17 -05:00
Chuck
7f5c7399fb fix: remove plugin-specific calendar duration from config template (#221)
Plugin display durations should be added dynamically when plugins are
installed, not hardcoded in the template.

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 19:58:42 -05:00
Chuck
14c50f316e feat: add timezone support for schedules and dim schedule feature (#218)
* feat: add timezone support for schedules and dim schedule feature

- Fix timezone handling in _check_schedule() to use configured timezone
  instead of system time (addresses schedule offset issues)
- Add dim schedule feature for automatic brightness dimming:
  - New dim_schedule config section with brightness level and time windows
  - Smart interaction: dim schedule won't turn display on if it's off
  - Supports both global and per-day modes like on/off schedule
- Add set_brightness() and get_brightness() methods to DisplayManager
  for runtime brightness control
- Add REST API endpoints: GET/POST /api/v3/config/dim-schedule
- Add web UI for dim schedule configuration in schedule settings page

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

* fix: normalize per-day mode and validate dim_brightness input

- Normalize mode string in _check_dim_schedule to handle both "per-day"
  and "per_day" variants
- Add try/except around dim_brightness int conversion to handle invalid
  input gracefully

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

* refactor: improve error handling in brightness and dim schedule endpoints

- display_manager.py: Add fail-fast input validation, catch specific
  exceptions (AttributeError, TypeError, ValueError), add [BRIGHTNESS]
  context tags, include stack traces in error logs
- api_v3.py: Catch specific config exceptions (FileNotFoundError,
  JSONDecodeError, IOError), add [DIM SCHEDULE] context tags for
  Pi debugging, include stack traces

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

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 18:12:45 -05:00
Chuck
ddd300a117 Docs/consolidate documentation (#217)
* docs: rename FONT_MANAGER_USAGE.md to FONT_MANAGER.md

Renamed for clearer naming convention.
Part of documentation consolidation effort.

* docs: consolidate Plugin Store guides (2→1)

Merged:
- PLUGIN_STORE_USER_GUIDE.md
- PLUGIN_STORE_QUICK_REFERENCE.md

Into: PLUGIN_STORE_GUIDE.md

- Unified writing style to professional technical
- Added Quick Reference section at top for easy access
- Removed duplicate content
- Added cross-references to related documentation
- Updated formatting to match style guidelines

* docs: create user-focused Web Interface Guide

Created WEB_INTERFACE_GUIDE.md consolidating:
- V3_INTERFACE_README.md (technical details)
- User-facing interface documentation

- Focused on end-user tasks and navigation
- Removed technical implementation details
- Added common tasks section
- Included troubleshooting
- Professional technical writing style

* docs: consolidate WiFi setup guides (4→1)

Merged:
- WIFI_SETUP.md
- OPTIMAL_WIFI_AP_FAILOVER_SETUP.md
- AP_MODE_MANUAL_ENABLE.md
- WIFI_ETHERNET_AP_MODE_FIX.md (behavior documentation)

Into: WIFI_NETWORK_SETUP.md

- Comprehensive coverage of WiFi setup and configuration
- Clear explanation of AP mode failover and grace period
- Configuration scenarios and best practices
- Troubleshooting section combining all sources
- Professional technical writing style
- Added quick reference table for behavior

* docs: consolidate troubleshooting guides (4→1)

Merged:
- TROUBLESHOOTING_QUICK_START.md
- WEB_INTERFACE_TROUBLESHOOTING.md
- CAPTIVE_PORTAL_TROUBLESHOOTING.md
- WEATHER_TROUBLESHOOTING.md

Into: TROUBLESHOOTING.md

- Organized by issue category (web, WiFi, plugins)
- Comprehensive diagnostic commands reference
- Quick diagnosis steps at the top
- Service file template preserved
- Complete diagnostic script included
- Professional technical writing style

* docs: create consolidated Advanced Features guide

Merged:
- VEGAS_SCROLL_MODE.md
- ON_DEMAND_DISPLAY_QUICK_START.md
- ON_DEMAND_DISPLAY_API.md
- ON_DEMAND_CACHE_MANAGEMENT.md
- BACKGROUND_SERVICE_README.md
- PERMISSION_MANAGEMENT_GUIDE.md

Into: ADVANCED_FEATURES.md

- Comprehensive guide covering all advanced features
- Vegas scroll mode with integration examples
- On-demand display with API reference
- Cache management troubleshooting
- Background service documentation
- Permission management patterns
- Professional technical writing style

* docs: create Getting Started guide for first-time users

Created GETTING_STARTED.md:
- Quick start guide (5 minutes)
- Initial configuration walkthrough
- Common first-time issues and solutions
- Next steps and quick reference
- User-friendly tone for beginners
- Links to detailed documentation

* docs: archive consolidated source files and ephemeral docs

Archived files that have been consolidated:
- Plugin Store guides (2 files → PLUGIN_STORE_GUIDE.md)
- Web Interface guide (V3_INTERFACE_README.md → WEB_INTERFACE_GUIDE.md)
- WiFi Setup guides (4 files → WIFI_NETWORK_SETUP.md)
- Troubleshooting guides (4 files → TROUBLESHOOTING.md)
- Advanced Features (6 files → ADVANCED_FEATURES.md)

Archived ephemeral/debug documentation:
- DEBUG_WEB_ISSUE.md
- BROWSER_ERRORS_EXPLANATION.md
- FORM_VALIDATION_FIXES.md
- WEB_UI_RELIABILITY_IMPROVEMENTS.md
- CAPTIVE_PORTAL_TESTING.md
- NEXT_STEPS_COMMANDS.md
- STATIC_IMAGE_MULTI_UPLOAD_PLAN.md
- RECONNECT_AFTER_CAPTIVE_PORTAL_TESTING.md

Archived implementation summaries:
- PLUGIN_CONFIG_TABS_SUMMARY.md
- PLUGIN_CONFIG_SYSTEM_VERIFICATION.md
- PLUGIN_SCHEMA_AUDIT_SUMMARY.md
- STARTUP_OPTIMIZATION_SUMMARY.md
- PLUGIN_DISPATCH_IMPLEMENTATION.md
- NESTED_SCHEMA_IMPLEMENTATION.md
- AP_MODE_MANUAL_ENABLE_CHANGES.md
- PLUGIN_CONFIG_SYSTEM_EXPLANATION.md

Total archived: 27 files
Preserves git history while cleaning up main docs directory

* docs: rename API_REFERENCE.md to REST_API_REFERENCE.md

Renamed for clarity - this is specifically the REST API reference
for the web interface, not a general API reference.

* docs: update README.md to reflect consolidated documentation structure

Updated documentation index:
- Reflects new consolidated guides (51 → 16-17 files)
- Updated Quick Start sections with new file names
- Added consolidation history (January 2026)
- Updated file references (API_REFERENCE → REST_API_REFERENCE)
- Documented archival of 33 files
- Added benefits of consolidation
- Updated statistics and highlights
- Removed outdated references
- Professional writing style maintained throughout

* docs: add Vegas scroll mode system architecture documentation

Added comprehensive internal architecture section for Vegas mode:
- Component overview with diagram
- VegasModeCoordinator responsibilities and main loop
- StreamManager buffering strategy and content flow
- PluginAdapter integration and fallback behavior
- RenderPipeline 125 FPS rendering process
- Component interaction flows
- Thread safety patterns
- Performance characteristics

Covers:
- How the four components work together
- Initialization and render loop flows
- Config update handling
- Frame rate management and optimization
- Memory usage and CPU characteristics

---------

Co-authored-by: Chuck <chuck@example.com>
2026-01-29 10:32:00 -05:00
Chuck
7524747e44 Feature/vegas scroll mode (#215)
* feat(display): add Vegas-style continuous scroll mode

Implement an opt-in Vegas ticker mode that composes all enabled plugin
content into a single continuous horizontal scroll. Includes a modular
package (src/vegas_mode/) with double-buffered streaming, 125 FPS
render pipeline using the existing ScrollHelper, live priority
interruption support, and a web UI for configuration with drag-drop
plugin ordering.

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

* feat(vegas): add three-mode display system (SCROLL, FIXED_SEGMENT, STATIC)

Adds a flexible display mode system for Vegas scroll mode that allows
plugins to control how their content appears in the continuous scroll:

- SCROLL: Content scrolls continuously (multi-item plugins like sports)
- FIXED_SEGMENT: Fixed block that scrolls by (clock, weather)
- STATIC: Scroll pauses, plugin displays, then resumes (alerts)

Changes:
- Add VegasDisplayMode enum to base_plugin.py with backward-compatible
  mapping from legacy get_vegas_content_type()
- Add static pause handling to coordinator with scroll position save/restore
- Add mode-aware content composition to stream_manager
- Add vegas_mode info to /api/v3/plugins/installed endpoint
- Add mode indicators to Vegas settings UI
- Add comprehensive plugin developer documentation

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

* fix(vegas,widgets): address validation, thread safety, and XSS issues

Vegas mode fixes:
- config.py: align validation limits with UI (scroll_speed max 200, separator_width max 128)
- coordinator.py: fix race condition by properly initializing _pending_config
- plugin_adapter.py: remove unused import
- render_pipeline.py: preserve deque type in reset() method
- stream_manager.py: fix lock handling and swap_buffers to truly swap

API fixes:
- api_v3.py: normalize boolean checkbox values, validate numeric fields, ensure JSON arrays

Widget fixes:
- day-selector.js: remove escapeHtml from JSON.stringify to prevent corruption
- password-input.js: use deterministic color class mapping for Tailwind JIT
- radio-group.js: replace inline onchange with addEventListener to prevent XSS
- select-dropdown.js: guard global registry access
- slider.js: add escapeAttr for attributes, fix null dereference in setValue

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

* fix(vegas): improve exception handling and static pause state management

coordinator.py:
- _check_live_priority: use logger.exception for full traceback
- _end_static_pause: guard scroll resume on interruption (stop/live priority)
- _update_static_mode_plugins: log errors instead of silently swallowing

render_pipeline.py:
- compose_scroll_content: use specific exceptions and logger.exception
- render_frame: use specific exceptions and logger.exception
- hot_swap_content: use specific exceptions and logger.exception

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

* fix(vegas): add interrupt mechanism and improve config/exception handling

- Add interrupt checker callback to Vegas coordinator for responsive
  handling of on-demand requests and wifi status during Vegas mode
- Fix config.py update() to include dynamic duration fields
- Fix is_plugin_included() consistency with get_ordered_plugins()
- Update _apply_pending_config to propagate config to StreamManager
- Change _fetch_plugin_content to use logger.exception for traceback
- Replace bare except in _refresh_plugin_list with specific exceptions
- Add aria-label accessibility to Vegas toggle checkbox
- Fix XSS vulnerability in plugin metadata rendering with escapeHtml

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

* fix(vegas): improve logging, validation, lock handling, and config updates

- display_controller.py: use logger.exception for Vegas errors with traceback
- base_plugin.py: validate vegas_panel_count as positive integer with warning
- coordinator.py: fix _apply_pending_config to avoid losing concurrent updates
  by clearing _pending_config while holding lock
- plugin_adapter.py: remove broad catch-all, use narrower exception types
  (AttributeError, TypeError, ValueError, OSError, RuntimeError) and
  logger.exception for traceback preservation
- api_v3.py: only update vegas_config['enabled'] when key is present in data
  to prevent incorrect disabling when checkbox is omitted

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

* fix(vegas): improve cycle advancement, logging, and accessibility

- Add advance_cycle() method to StreamManager for clearing buffer between cycles
- Call advance_cycle() in RenderPipeline.start_new_cycle() for fresh content
- Use logger.exception() for interrupt check and static pause errors (full tracebacks)
- Add id="vegas_scroll_label" to h3 for aria-labelledby reference
- Call updatePluginConfig() after rendering plugin list for proper initialization

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

* fix(vegas): add thread-safety, preserve updates, and improve logging

- display_controller.py: Use logger.exception() for Vegas import errors
- plugin_adapter.py: Add thread-safe cache lock, remove unused exception binding
- stream_manager.py: In-place merge in process_updates() preserves non-updated plugins
- api_v3.py: Change vegas_scroll_enabled default from False to True

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

* fix(vegas): add debug logging and narrow exception types

- stream_manager.py: Log when get_vegas_display_mode() is unavailable
- stream_manager.py: Narrow exception type from Exception to (AttributeError, TypeError)
- api_v3.py: Log exceptions when reading Vegas display metadata with plugin context

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

* fix(vegas): fix method call and improve exception logging

- Fix _check_vegas_interrupt() calling nonexistent _check_wifi_status(),
  now correctly calls _check_wifi_status_message()
- Update _refresh_plugin_list() exception handler to use logger.exception()
  with plugin_id and class name for remote debugging

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

* fix(web): replace complex toggle with standard checkbox for Vegas mode

The Tailwind pseudo-element toggle (after:content-[''], etc.) wasn't
rendering because these classes weren't in the CSS bundle. Replaced
with a simple checkbox that matches other form controls in the template.

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

* debug(vegas): add detailed logging to _refresh_plugin_list

Track why plugins aren't being found for Vegas scroll:
- Log count of loaded plugins
- Log enabled status for each plugin
- Log content_type and display_mode checks
- Log when plugin_manager lacks loaded_plugins

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

* fix(vegas): use correct attribute name for plugin manager

StreamManager and VegasModeCoordinator were checking for
plugin_manager.loaded_plugins but PluginManager stores active
plugins in plugin_manager.plugins. This caused Vegas scroll
to find zero plugins despite plugins being available.

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

* fix(vegas): convert scroll_speed from px/sec to px/frame correctly

The config scroll_speed is in pixels per second, but ScrollHelper
in frame_based_scrolling mode interprets it as pixels per frame.
Previously this caused the speed to be clamped to max 5.0 regardless
of the configured value.

Now properly converts: pixels_per_frame = scroll_speed * scroll_delay

With defaults (50 px/s, 0.02s delay), this gives 1 px/frame = 50 px/s.

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

* feat(vegas): add FPS logging every 5 seconds

Logs actual FPS vs target FPS to help diagnose performance issues.
Shows frame count in each 5-second interval.

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

* fix(vegas): improve plugin content capture reliability

- Call update_data() before capture to ensure fresh plugin data
- Try display() without force_clear first, fallback if TypeError
- Retry capture with force_clear=True if first attempt is blank
- Use histogram-based blank detection instead of point sampling
  (more reliable for content positioned anywhere in frame)

This should help capture content from plugins that don't implement
get_vegas_content() natively.

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

* fix(vegas): handle callable width/height on display_manager

DisplayManager.width and .height may be methods or properties depending
on the implementation. Use callable() check to call them if needed,
ensuring display_width and display_height are always integers.

Fixes potential TypeError when width/height are methods.

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

* fix(vegas): use logger.exception for display mode errors

Replace logger.error with logger.exception to capture full stack trace
when get_vegas_display_mode() fails on a plugin.

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

* fix(vegas): protect plugin list updates with buffer lock

Move assignment of _ordered_plugins and index resets under _buffer_lock
to prevent race conditions with _prefetch_content() which reads these
variables under the same lock.

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

* fix(vegas): catch all exceptions in get_vegas_display_mode

Broaden exception handling from AttributeError/TypeError to Exception
so any plugin error in get_vegas_display_mode() doesn't abort the
entire plugin list refresh. The loop continues with the default
FIXED_SEGMENT mode.

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

* fix(vegas): refresh stream manager when config updates

After updating stream_manager.config, force a refresh to pick up changes
to plugin_order, excluded_plugins, and buffer_ahead settings. Also use
logger.exception to capture full stack traces on config update errors.

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

* debug(vegas): add detailed logging for blank image detection

* feat(vegas): extract full scroll content from plugins using ScrollHelper

Plugins like ledmatrix-stocks and odds-ticker use ScrollHelper with a
cached_image that contains their full scrolling content. Instead of
falling back to single-frame capture, now check for scroll_helper.cached_image
first to get the complete scrolling content for Vegas mode.

* debug(vegas): add comprehensive INFO-level logging for plugin content flow

- Log each plugin being processed with class name
- Log which content methods are tried (native, scroll_helper, fallback)
- Log success/failure of each method with image dimensions
- Log brightness check results for blank image detection
- Add visual separators in logs for easier debugging
- Log plugin list refresh with enabled/excluded status

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

* feat(vegas): trigger scroll content generation when cache is empty

When a plugin has a scroll_helper but its cached_image is not yet
populated, try to trigger content generation by:
1. Calling _create_scrolling_display() if available (stocks pattern)
2. Calling display(force_clear=True) as a fallback

This allows plugins like stocks to provide their full scroll content
even when Vegas mode starts before the plugin has run its normal
display cycle.

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

* fix: improve exception handling in plugin_adapter scroll content retrieval

Replace broad except Exception handlers with narrow exception types
(AttributeError, TypeError, ValueError, OSError) and use logger.exception
instead of logger.warning/info to capture full stack traces for better
diagnosability.

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

* fix: narrow exception handling in coordinator and plugin_adapter

- coordinator.py: Replace broad Exception catch around get_vegas_display_mode()
  with (AttributeError, TypeError) and use logger.exception for stack traces
- plugin_adapter.py: Narrow update_data() exception handler to
  (AttributeError, RuntimeError, OSError) and use logger.exception

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

* fix: improve Vegas mode robustness and API validation

- display_controller: Guard against None plugin_manager in Vegas init
- coordinator: Restore scrolling state in resume() to match pause()
- api_v3: Validate Vegas numeric fields with range checks and 400 errors

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

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 10:23:56 -05:00
Chuck
10d70d911a Fix unchecked boolean checkboxes not saving as false (#216)
* fix(web): ensure unchecked boolean checkboxes save as false

HTML checkboxes don't submit values when unchecked. The plugin config
save endpoint starts from existing config (for partial updates), so an
unchecked checkbox's old `true` value persists. Additionally,
merge_with_defaults fills in schema defaults for missing fields, causing
booleans with `"default": true` to always re-enable.

This affected the odds-ticker plugin where NFL/NBA leagues (default:
true) could not be disabled via the checkbox UI, while NHL (default:
false) appeared to work by coincidence.

Changes:
- Add _set_missing_booleans_to_false() that walks the schema after form
  processing and sets any boolean field absent from form data to false
- Add value="true" to boolean checkboxes so checked state sends "true"
  instead of "on" (proper boolean parsing)
- Handle "on"/"off" strings in _parse_form_value_with_schema for
  backwards compatibility with checkboxes lacking value="true"

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

* fix(web): guard on/off coercion to boolean schema types, handle arrays

- Only coerce "on"/"off" strings to booleans when the schema type is
  boolean; "true"/"false" remain unconditional
- Extend _set_missing_booleans_to_false to recurse into arrays of
  objects (e.g. custom_feeds.0.enabled) by discovering item indices
  from submitted form keys and recursing per-index

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

* fix(web): preserve array structures when setting missing booleans

_set_nested_value uses dict-style access for all path segments, which
corrupts lists when paths contain numeric array indices (e.g.
"feeds.custom_feeds.0.enabled").

Refactored _set_missing_booleans_to_false to:
- Accept an optional config_node parameter for direct array item access
- When inside an array item, set booleans directly on the item dict
- Navigate to array lists manually, preserving their list type
- Ensure array items exist as dicts before recursing

This prevents array-of-object configs (like custom_feeds) from being
converted to nested dicts with numeric string keys.

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

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 09:15:38 -05:00
Chuck
a8c85dd015 feat(widgets): add modular widget system for schedule and common inputs (#213)
* feat(widgets): add modular widget system for schedule and common inputs

Add 15 new reusable widgets following the widget registry pattern:
- schedule-picker: composite widget for enable/mode/time configuration
- day-selector: checkbox group for days of the week
- time-range: paired start/end time inputs with validation
- text-input, number-input, textarea: enhanced text inputs
- toggle-switch, radio-group, select-dropdown: selection widgets
- slider, color-picker, date-picker: specialized inputs
- email-input, url-input, password-input: validated string inputs

Refactor schedule.html to use the new schedule-picker widget instead
of inline JavaScript. Add x-widget support in plugin_config.html for
all new widgets so plugins can use them via schema configuration.

Fix form submission for checkboxes by using hidden input pattern to
ensure unchecked state is properly sent via JSON-encoded forms.

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

* fix(widgets): improve security, validation, and form binding across widgets

- Fix XSS vulnerability: escapeHtml now escapes quotes in all widget fallbacks
- color-picker: validate presets with isValidHex(), use data attributes
- date-picker: add placeholder attribute support
- day-selector: use options.name for hidden input form binding
- password-input: implement requireUppercase/Number/Special validation
- radio-group: fix value injection using this.value instead of interpolation
- schedule-picker: preserve day values when disabling (don't clear times)
- select-dropdown: remove undocumented searchable/icons options
- text-input: apply patternMessage via setCustomValidity
- time-range: use options.name for hidden inputs
- toggle-switch: preserve configured color from data attribute
- url-input: combine browser and custom protocol validation
- plugin_config: add widget support for boolean/number types, pass name to day-selector
- schedule: handle null config gracefully, preserve explicit mode setting

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

* fix(widgets): validate day-selector input, consistent minLength default, escape JSON quotes

- day-selector: filter incoming selectedDays to only valid entries in DAYS array
  (prevents invalid persisted values from corrupting UI/state)
- password-input: use default minLength of 8 when not explicitly set
  (fixes inconsistency between render() and onInput() strength meter baseline)
- plugin_config.html: escape single quotes in JSON hidden input values
  (prevents broken attributes when JSON contains single quotes)

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

* feat(widgets): add global notification widget, consolidate duplicated code

- Create notification.js widget with toast-style notifications
- Support for success, error, warning, info types
- Auto-dismiss with configurable duration
- Stacking support with max notifications limit
- Accessible with aria-live and role="alert"
- Update base.html to load notification widget early
- Replace duplicate showNotification in raw_json.html
- Simplify fonts.html fallback notification
- Net reduction of ~66 lines of duplicated code

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

* fix(widgets): escape options.name in all widgets, validate day-selector format

Security fixes:
- Escape options.name attribute in all 13 widgets to prevent injection
- Affected: color-picker, date-picker, email-input, number-input,
  password-input, radio-group, select-dropdown, slider, text-input,
  textarea, toggle-switch, url-input

Defensive coding:
- day-selector: validate format option exists in DAY_LABELS before use
- Falls back to 'long' format for unsupported/invalid format values

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

* fix(plugins): add type="button" to control buttons, add debug logging

- Add type="button" attribute to refresh, update-all, and restart buttons
  to prevent potential form submission behavior
- Add console logging to diagnose button click issues:
  - Log when event listeners are attached (and whether buttons found)
  - Log when handler functions are called

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

* fix(widgets): improve security and validation across widget inputs

- color-picker.js: Add sanitizeHex() to validate hex values before HTML
  interpolation, ensuring only safe #rrggbb strings are used
- day-selector.js: Escape inputName in hidden input name attribute
- number-input.js: Sanitize and escape currentValue in input element
- password-input.js: Validate minLength as non-negative integer, clamp
  invalid values to default of 8
- slider.js: Add null check for input element before accessing value
- text-input.js: Clear custom validity before checkValidity() to avoid
  stale errors, re-check after setting pattern message
- url-input.js: Normalize allowedProtocols to array, filter to valid
  protocol strings, and escape before HTML interpolation

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

* fix(widgets): add defensive fallback for DAY_LABELS lookup in day-selector

Extract labelMap with fallback before loop to ensure safe access even if
format validation somehow fails.

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

* feat(widgets): add timezone-selector widget with IANA timezone dropdown

- Create timezone-selector.js widget with comprehensive IANA timezone list
- Group timezones by region (US & Canada, Europe, Asia, etc.)
- Show current UTC offset for each timezone
- Display live time preview for selected timezone
- Update general.html to use timezone-selector instead of text input
- Add script tag to base.html for widget loading

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

* fix(ui): suppress on-demand status notification on page load

Change loadOnDemandStatus(true) to loadOnDemandStatus(false) during
initPluginsPage() to prevent the "on-demand status refreshed"
notification from appearing every time a tab is opened or the page
is navigated. The notification should only appear on explicit user
refresh.

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

* style(ui): soften notification close button appearance

Replace blocky FontAwesome X icon with a cleaner SVG that has rounded
stroke caps. Make the button circular, slightly transparent by default,
and add smooth hover transitions for a more polished look.

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

* fix(widgets): multiple security and validation improvements

- color-picker.js: Ensure presets is always an array before map/filter
- number-input.js: Guard against undefined options parameter
- number-input.js: Sanitize and escape min/max/step HTML attributes
- text-input.js: Clear custom validity in onInput to unblock form submit
- timezone-selector.js: Replace legacy Europe/Belfast with Europe/London
- url-input.js: Use RFC 3986 scheme pattern for protocol validation
- general.html: Use |tojson filter to escape timezone value safely

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

* refactor(url-input): centralize RFC 3986 protocol validation

Extract protocol normalization into reusable normalizeProtocols()
helper function that validates against RFC 3986 scheme pattern.
Apply consistently in render, validate, and onInput to ensure
protocols like "git+ssh", "android-app" are properly handled
everywhere. Also lowercase protocol comparison in isValidUrl().

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

* fix(timezone-selector): use hidden input for form submission

Replace direct select name attribute with a hidden input pattern to
ensure timezone value is always properly serialized in form submissions.
The hidden input is synced on change and setValue calls. This matches
the pattern used by other widgets and ensures HTMX json-enc properly
captures the value.

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

* fix(general): preserve timezone dropdown value after save

Add inline script to sync the timezone select with the hidden input
value after form submission. This prevents the dropdown from visually
resetting to the old value while the save has actually succeeded.

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

* fix(widgets): preserve timezone selection across form submission

Use before-request handler to capture the selected timezone value
before HTMX processes the form, then restore it in after-request.
This is more robust than reading from the hidden input which may
also be affected by form state changes.

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

* fix(widgets): add HTMX protection to timezone selector

Add global HTMX event listeners in the timezone-selector widget
that preserve the selected value across any form submissions.
This is more robust than form-specific handlers as it protects
the widget regardless of how/where forms are submitted.

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

* debug(widgets): add logging and prevent timezone widget re-init

Add debug logging and guards to prevent the timezone widget from
being re-initialized after it's already rendered. This should help
diagnose why the dropdown is reverting after save.

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

* debug: add console logging to timezone HTMX protection

* debug: add onChange logging to trace timezone selection

* fix(widgets): use selectedIndex to force visual update in timezone dropdown

The browser's select.value setter sometimes doesn't trigger a visual
update when optgroup elements are present. Using selectedIndex instead
forces the browser to correctly update the visible selection.

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

* fix(widgets): force browser repaint on timezone dropdown restore

Adding display:none/reflow/display:'' pattern to force browser to
visually update the select element after changing selectedIndex.
Increased timeout to 50ms for reliability.

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

* chore(widgets): remove debug logging from timezone selector

Clean up console.log statements that were used for debugging the
timezone dropdown visual update issue.

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

* fix(ui): improve HTMX after-request handler in general settings

- Parse xhr.responseText with JSON.parse in try/catch instead of
  using nonstandard responseJSON property
- Check xhr.status for 2xx success range
- Show error notification for non-2xx responses
- Default to safe fallback values if JSON parsing fails

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

* fix(widgets): add input sanitization and timezone validation

- Sanitize minLength/maxLength in text-input.js to prevent attribute
  injection (coerce to integers, validate range)
- Update Europe/Kiev to Europe/Kyiv (canonical IANA identifier)
- Validate timezone currentValue against TIMEZONE_GROUPS before rendering

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

* fix(ui): correct error message fallback in HTMX after-request handler

Initialize message to empty string so error responses can use the
fallback 'Failed to save settings' when no server message is provided.
Previously, the truthy default 'Settings saved' would always be used.

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

* fix(widgets): add constraint normalization and improve value validation

- text-input: normalize minLength/maxLength so maxLength >= minLength
- timezone-selector: validate setValue input against TIMEZONE_GROUPS
- timezone-selector: sync hidden input to actual selected value
- timezone-selector: preserve empty selections across HTMX requests

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

* fix(widgets): simplify HTMX restore using select.value and dispatch change event

Replace selectedIndex manipulation with direct value assignment for cleaner
placeholder handling, and dispatch change event to refresh timezone preview.

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

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 19:56:16 -05:00
Chuck
0203c5c1b5 Update Discord link in README.md (#211)
Signed-off-by: Chuck <33324927+ChuckBuilds@users.noreply.github.com>
2026-01-26 15:48:28 -05:00
Chuck
384ed096ff fix: prevent /tmp permission corruption breaking system updates (#209)
Issue: LEDMatrix was changing /tmp permissions from 1777 (drwxrwxrwt)
to 2775 (drwxrwsr-x), breaking apt update and other system tools.

Root cause: display_manager.py's _write_snapshot_if_due() called
ensure_directory_permissions() on /tmp when writing snapshots to
/tmp/led_matrix_preview.png. This removed the sticky bit and
world-writable permissions that /tmp requires.

Fix:
- Added PROTECTED_SYSTEM_DIRECTORIES safelist to permission_utils.py
  to prevent modifying permissions on /tmp and other system directories
- Added explicit check in display_manager.py to skip /tmp
- Defense-in-depth approach prevents similar issues in other code paths

The sticky bit (1xxx) is critical for /tmp - it prevents users from
deleting files they don't own. Without world-writable permissions,
regular users cannot create temp files.

Fixes #202

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-26 10:56:30 -05:00
Chuck
f9de9fa29e Add installation video link to README (#205)
Added installation video link for the LEDMatrix project.

Signed-off-by: Chuck <33324927+ChuckBuilds@users.noreply.github.com>
2026-01-22 17:34:00 -05:00
143 changed files with 1068571 additions and 1855 deletions

4
.gitignore vendored
View File

@@ -40,3 +40,7 @@ htmlcov/
# See docs/MULTI_ROOT_WORKSPACE_SETUP.md for details # See docs/MULTI_ROOT_WORKSPACE_SETUP.md for details
plugins/* plugins/*
!plugins/.gitkeep !plugins/.gitkeep
# Binary files and backups
bin/pixlet/
config/backups/

63
.gitmodules vendored
View File

@@ -1,66 +1,3 @@
[submodule "plugins/odds-ticker"]
path = plugins/odds-ticker
url = https://github.com/ChuckBuilds/ledmatrix-odds-ticker.git
[submodule "plugins/clock-simple"]
path = plugins/clock-simple
url = https://github.com/ChuckBuilds/ledmatrix-clock-simple.git
[submodule "plugins/text-display"]
path = plugins/text-display
url = https://github.com/ChuckBuilds/ledmatrix-text-display.git
[submodule "rpi-rgb-led-matrix-master"] [submodule "rpi-rgb-led-matrix-master"]
path = rpi-rgb-led-matrix-master path = rpi-rgb-led-matrix-master
url = https://github.com/hzeller/rpi-rgb-led-matrix.git url = https://github.com/hzeller/rpi-rgb-led-matrix.git
[submodule "plugins/basketball-scoreboard"]
path = plugins/basketball-scoreboard
url = https://github.com/ChuckBuilds/ledmatrix-basketball-scoreboard.git
[submodule "plugins/soccer-scoreboard"]
path = plugins/soccer-scoreboard
url = https://github.com/ChuckBuilds/ledmatrix-soccer-scoreboard.git
[submodule "plugins/calendar"]
path = plugins/calendar
url = https://github.com/ChuckBuilds/ledmatrix-calendar.git
[submodule "plugins/mqtt-notifications"]
path = plugins/mqtt-notifications
url = https://github.com/ChuckBuilds/ledmatrix-mqtt-notifications.git
[submodule "plugins/olympics-countdown"]
path = plugins/olympics-countdown
url = https://github.com/ChuckBuilds/ledmatrix-olympics-countdown.git
[submodule "plugins/ledmatrix-stocks"]
path = plugins/ledmatrix-stocks
url = https://github.com/ChuckBuilds/ledmatrix-stocks.git
[submodule "plugins/ledmatrix-music"]
path = plugins/ledmatrix-music
url = https://github.com/ChuckBuilds/ledmatrix-music.git
[submodule "plugins/static-image"]
path = plugins/static-image
url = https://github.com/ChuckBuilds/ledmatrix-static-image.git
[submodule "plugins/football-scoreboard"]
path = plugins/football-scoreboard
url = https://github.com/ChuckBuilds/ledmatrix-football-scoreboard.git
[submodule "plugins/hockey-scoreboard"]
path = plugins/hockey-scoreboard
url = https://github.com/ChuckBuilds/ledmatrix-hockey-scoreboard.git
[submodule "plugins/baseball-scoreboard"]
path = plugins/baseball-scoreboard
url = https://github.com/ChuckBuilds/ledmatrix-baseball-scoreboard.git
[submodule "plugins/christmas-countdown"]
path = plugins/christmas-countdown
url = https://github.com/ChuckBuilds/ledmatrix-christmas-countdown.git
[submodule "plugins/ledmatrix-flights"]
path = plugins/ledmatrix-flights
url = https://github.com/ChuckBuilds/ledmatrix-flights.git
[submodule "plugins/ledmatrix-leaderboard"]
path = plugins/ledmatrix-leaderboard
url = https://github.com/ChuckBuilds/ledmatrix-leaderboard.git
[submodule "plugins/ledmatrix-weather"]
path = plugins/ledmatrix-weather
url = https://github.com/ChuckBuilds/ledmatrix-weather.git
[submodule "plugins/ledmatrix-news"]
path = plugins/ledmatrix-news
url = https://github.com/ChuckBuilds/ledmatrix-news.git
[submodule "plugins/ledmatrix-of-the-day"]
path = plugins/ledmatrix-of-the-day
url = https://github.com/ChuckBuilds/ledmatrix-of-the-day.git
[submodule "plugins/youtube-stats"]
path = plugins/youtube-stats
url = https://github.com/ChuckBuilds/ledmatrix-youtube-stats.git

47
.pre-commit-config.yaml Normal file
View File

@@ -0,0 +1,47 @@
# Pre-commit hooks for LEDMatrix
# Install: pip install pre-commit && pre-commit install
# Run manually: pre-commit run --all-files
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.5.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-json
- id: check-added-large-files
args: ['--maxkb=1000']
- id: check-merge-conflict
- repo: https://github.com/PyCQA/flake8
rev: 7.0.0
hooks:
- id: flake8
args: ['--select=E9,F63,F7,F82,B', '--ignore=E501']
additional_dependencies: [flake8-bugbear]
- repo: local
hooks:
- id: no-bare-except
name: Check for bare except clauses
entry: bash -c 'if grep -rn "except:\s*pass" src/; then echo "Found bare except:pass - please handle exceptions properly"; exit 1; fi'
language: system
types: [python]
pass_filenames: false
- id: no-hardcoded-paths
name: Check for hardcoded user paths
entry: bash -c 'if grep -rn "/home/chuck/" src/; then echo "Found hardcoded user paths - please use relative paths or config"; exit 1; fi'
language: system
types: [python]
pass_filenames: false
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.8.0
hooks:
- id: mypy
additional_dependencies: [types-requests, types-pytz]
args: [--ignore-missing-imports, --no-error-summary]
pass_filenames: false
files: ^src/

31
CLAUDE.md Normal file
View File

@@ -0,0 +1,31 @@
# LEDMatrix
## Project Structure
- `src/plugin_system/` — Plugin loader, manager, store manager, base plugin class
- `web_interface/` — Flask web UI (blueprints, templates, static JS)
- `config/config.json` — User plugin configuration (persists across plugin reinstalls)
- `plugins/` — Installed plugins directory (gitignored)
- `plugin-repos/` — Development symlinks to monorepo plugin dirs
## Plugin System
- Plugins inherit from `BasePlugin` in `src/plugin_system/base_plugin.py`
- Required abstract methods: `update()`, `display(force_clear=False)`
- Each plugin needs: `manifest.json`, `config_schema.json`, `manager.py`, `requirements.txt`
- Plugin instantiation args: `plugin_id, config, display_manager, cache_manager, plugin_manager`
- Config schemas use JSON Schema Draft-7
- Display dimensions: always read dynamically from `self.display_manager.matrix.width/height`
## Plugin Store Architecture
- Official plugins live in the `ledmatrix-plugins` monorepo (not individual repos)
- Plugin repo naming convention: `ledmatrix-<plugin-id>` (e.g., `ledmatrix-football-scoreboard`)
- `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

View File

@@ -4,89 +4,9 @@
"path": ".", "path": ".",
"name": "LEDMatrix (Main)" "name": "LEDMatrix (Main)"
}, },
{
"path": "../ledmatrix-odds-ticker",
"name": "Odds Ticker"
},
{
"path": "../ledmatrix-clock-simple",
"name": "Clock Simple"
},
{
"path": "../ledmatrix-text-display",
"name": "Text Display"
},
{
"path": "../ledmatrix-basketball-scoreboard",
"name": "Basketball Scoreboard"
},
{
"path": "../ledmatrix-soccer-scoreboard",
"name": "Soccer Scoreboard"
},
{
"path": "../ledmatrix-calendar",
"name": "Calendar"
},
{
"path": "../ledmatrix-olympics-countdown",
"name": "Olympics Countdown"
},
{
"path": "../ledmatrix-stocks",
"name": "Stocks"
},
{
"path": "../ledmatrix-music",
"name": "Music"
},
{
"path": "../ledmatrix-static-image",
"name": "Static Image"
},
{
"path": "../ledmatrix-football-scoreboard",
"name": "Football Scoreboard"
},
{
"path": "../ledmatrix-hockey-scoreboard",
"name": "Hockey Scoreboard"
},
{
"path": "../ledmatrix-baseball-scoreboard",
"name": "Baseball Scoreboard"
},
{
"path": "../ledmatrix-christmas-countdown",
"name": "Christmas Countdown"
},
{
"path": "../ledmatrix-flights",
"name": "Flights"
},
{
"path": "../ledmatrix-leaderboard",
"name": "Leaderboard"
},
{
"path": "../ledmatrix-weather",
"name": "Weather"
},
{
"path": "../ledmatrix-news",
"name": "News"
},
{
"path": "../ledmatrix-of-the-day",
"name": "Of The Day"
},
{
"path": "../ledmatrix-youtube-stats",
"name": "YouTube Stats"
},
{ {
"path": "../ledmatrix-plugins", "path": "../ledmatrix-plugins",
"name": "Plugin Registry" "name": "Plugins (Monorepo)"
} }
], ],
"settings": { "settings": {

View File

@@ -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:
[![Installing LEDMatrix on a Pi](https://img.youtube.com/vi/bkT0f1tZI0Y/hqdefault.jpg)](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) :
[![IMAGE ALT TEXT HERE](https://img.youtube.com/vi/_HaqfJy1Y54/0.jpg)](https://www.youtube.com/watch?v=_HaqfJy1Y54) [![Outdated Video about the project](https://img.youtube.com/vi/_HaqfJy1Y54/hqdefault.jpg)](https://www.youtube.com/watch?v=_HaqfJy1Y54)
----------------------------------------------------------------------------------- -----------------------------------------------------------------------------------
### Connect with ChuckBuilds ### Connect with ChuckBuilds
@@ -23,7 +27,7 @@ I'm trying to be open to constructive criticism and support, as long as it's a r
- Show support on Youtube: https://www.youtube.com/@ChuckBuilds - Show support on Youtube: https://www.youtube.com/@ChuckBuilds
- Check out the write-up on my website: https://www.chuck-builds.com/led-matrix/ - Check out the write-up on my website: https://www.chuck-builds.com/led-matrix/
- Stay in touch on Instagram: https://www.instagram.com/ChuckBuilds/ - Stay in touch on Instagram: https://www.instagram.com/ChuckBuilds/
- Want to chat? Reach out on the ChuckBuilds Discord: https://discord.com/invite/uW36dVAtcT - Want to chat? Reach out on the LEDMatrix Discord: [https://discord.com/invite/uW36dVAtcT](https://discord.gg/dfFwsasa6W)
- Feeling Generous? Consider sponsoring this project or sending a donation (these AI credits aren't cheap!) - Feeling Generous? Consider sponsoring this project or sending a donation (these AI credits aren't cheap!)
----------------------------------------------------------------------------------- -----------------------------------------------------------------------------------

140567
assets/fonts/10x20.bdf Normal file

File diff suppressed because it is too large Load Diff

31042
assets/fonts/6x10.bdf Normal file

File diff suppressed because it is too large Load Diff

86121
assets/fonts/6x12.bdf Normal file

File diff suppressed because it is too large Load Diff

82452
assets/fonts/6x13.bdf Normal file

File diff suppressed because it is too large Load Diff

25672
assets/fonts/6x13B.bdf Normal file

File diff suppressed because it is too large Load Diff

15432
assets/fonts/6x13O.bdf Normal file

File diff suppressed because it is too large Load Diff

64553
assets/fonts/7x13.bdf Normal file

File diff suppressed because it is too large Load Diff

20093
assets/fonts/7x13B.bdf Normal file

File diff suppressed because it is too large Load Diff

16653
assets/fonts/7x13O.bdf Normal file

File diff suppressed because it is too large Load Diff

54128
assets/fonts/7x14.bdf Normal file

File diff suppressed because it is too large Load Diff

21221
assets/fonts/7x14B.bdf Normal file

File diff suppressed because it is too large Load Diff

74092
assets/fonts/8x13.bdf Normal file

File diff suppressed because it is too large Load Diff

22852
assets/fonts/8x13B.bdf Normal file

File diff suppressed because it is too large Load Diff

25932
assets/fonts/8x13O.bdf Normal file

File diff suppressed because it is too large Load Diff

105126
assets/fonts/9x15.bdf Normal file

File diff suppressed because it is too large Load Diff

37168
assets/fonts/9x15B.bdf Normal file

File diff suppressed because it is too large Load Diff

119182
assets/fonts/9x18.bdf Normal file

File diff suppressed because it is too large Load Diff

19082
assets/fonts/9x18B.bdf Normal file

File diff suppressed because it is too large Load Diff

42
assets/fonts/AUTHORS Normal file
View File

@@ -0,0 +1,42 @@
The identity of the designer(s) of the original ASCII repertoire and
the later Latin-1 extension of the misc-fixed BDF fonts appears to
have been lost in history. (It is likely that many of these 7-bit
ASCII fonts were created in the early or mid 1980s as part of MIT's
Project Athena, or at its industrial partner, DEC.)
In 1997, Markus Kuhn at the University of Cambridge Computer
Laboratory initiated and headed a project to extend the misc-fixed BDF
fonts to as large a subset of Unicode/ISO 10646 as is feasible for
each of the available font sizes, as part of a wider effort to
encourage users of POSIX systems to migrate from ISO 8859 to UTF-8.
Robert Brady <rwb197@ecs.soton.ac.uk> and Birger Langkjer
<birger.langkjer@image.dk> contributed thousands of glyphs and made
very substantial contributions and improvements on almost all fonts.
Constantine Stathopoulos <cstath@irismedia.gr> contributed all the
Greek characters. Markus Kuhn <http://www.cl.cam.ac.uk/~mgk25/> did
most 6x13 glyphs and the italic fonts and provided many more glyphs,
coordination, and quality assurance for the other fonts. Mark Leisher
<mleisher@crl.nmsu.edu> contributed to 6x13 Armenian, Georgian, the
first version of Latin Extended Block A and some Cyrillic. Serge V.
Vakulenko <vak@crox.net.kiae.su> donated the original Cyrillic glyphs
from his 6x13 ISO 8859-5 font. Nozomi Ytow <nozomi@biol.tsukuba.ac.jp>
contributed 6x13 halfwidth Katakana. Henning Brunzel
<hbrunzel@meta-systems.de> contributed glyphs to 10x20.bdf. Theppitak
Karoonboonyanan <thep@linux.thai.net> contributed Thai for 7x13,
7x13B, 7x13O, 7x14, 7x14B, 8x13, 8x13B, 8x13O, 9x15, 9x15B, and 10x20.
Karl Koehler <koehler@or.uni-bonn.de> contributed Arabic to 9x15,
9x15B, and 10x20 and Roozbeh Pournader <roozbeh@sharif.ac.ir> and
Behdad Esfahbod revised and extended Arabic in 10x20. Raphael Finkel
<raphael@cs.uky.edu> revised Hebrew/Yiddish in 10x20. Jungshik Shin
<jshin@pantheon.yale.edu> prepared 18x18ko.bdf. Won-kyu Park
<wkpark@chem.skku.ac.kr> prepared the Hangul glyphs used in 12x13ja.
Janne V. Kujala <jvk@iki.fi> contributed 4x6. Daniel Yacob
<perl@geez.org> revised some Ethiopic glyphs. Ted Zlatanov
<tzz@lifelogs.com> did some 7x14. Mikael Öhman <micketeer@gmail.com>
worked on 6x12.
The fonts are still maintained by Markus Kuhn and the original
distribution can be found at:
http://www.cl.cam.ac.uk/~mgk25/ucs-fonts.html

369
assets/fonts/README Normal file
View File

@@ -0,0 +1,369 @@
Unicode versions of the X11 "misc-fixed-*" fonts
------------------------------------------------
Markus Kuhn <http://www.cl.cam.ac.uk/~mgk25/> -- 2008-04-21
This package contains the X Window System bitmap fonts
-Misc-Fixed-*-*-*--*-*-*-*-C-*-ISO10646-1
These are Unicode (ISO 10646-1) extensions of the classic ISO 8859-1
X11 terminal fonts that are widely used with many X11 applications
such as xterm, emacs, etc.
COVERAGE
--------
None of these fonts covers Unicode completely. Complete coverage
simply would not make much sense here. Unicode 5.1 contains over
100000 characters, and the large majority of them are
Chinese/Japanese/Korean Han ideographs (~70000) and Korean Hangul
Syllables (~11000) that cannot adequately be displayed in the small
pixel sizes of the fixed fonts. Similarly, Arabic characters are
difficult to fit nicely together with European characters into the
fixed character cells and X11 lacks the ligature substitution
mechanisms required for using Indic scripts.
Therefore these fonts primarily attempt to cover Unicode subsets that
fit together with European scripts. This includes the Latin, Greek,
Cyrillic, Armenian, Georgian, and Hebrew scripts, plus a lot of
linguistic, technical and mathematical symbols. Some of the fixed
fonts now also cover Arabic, Thai, Ethiopian, halfwidth Katakana, and
some other non-European scripts.
We have defined 3 different target character repertoires (ISO 10646-1
subsets) that the various fonts were checked against for minimal
guaranteed coverage:
TARGET1 617 characters
Covers all characters of ISO 8859 part 1-5,7-10,13-16,
CEN MES-1, ISO 6937, Microsoft CP1251/CP1252, DEC VT100
graphics symbols, and the replacement and default
character. It is intended for small bold, italic, and
proportional fonts, for which adding block graphics
characters would make little sense. This repertoire
covers the following ISO 10646-1:2000 collections
completely: 1-3, 8, 12.
TARGET2 886 characters
Adds to TARGET1 the characters of the Adobe/Microsoft
Windows Glyph List 4 (WGL4), plus a selected set of
mathematical characters (covering most of ISO 31-11
high-school level math symbols) and some combining
characters. It is intended to be covered by all normal
"fixed" fonts and covers all European IBM, Microsoft, and
Macintosh character sets. This repertoire covers the
following ISO 10646-1:2000 (including Amd 1:2002)
collections completely: 1-3, 8, 12, 33, 45.
TARGET3 3282 characters
Adds to TARGET2 all characters of all European scripts
(Latin, Greek, Cyrillic, Armenian, Georgian), all
phonetic alphabet symbols, many mathematical symbols
(including all those available in LaTeX), all typographic
punctuation, all box-drawing characters, control code
pictures, graphical shapes and some more that you would
expect in a very comprehensive Unicode 4.0 font for
European users. It is intended for some of the more
useful and more widely used normal "fixed" fonts. This
repertoire is, with two exceptions, a superset of all
graphical characters in CEN MES-3A and covers the
following ISO 10646-1:2000 (including Amd 1:2002)
collections completely: 1-12, 27, 30-31, 32 (only
graphical characters), 33-42, 44-47, 63, 65, 70 (only
graphical characters).
[The two MES-3A characters deliberately omitted are the
angle bracket characters U+2329 and U+232A. ISO and CEN
appears to have included these into collection 40 and
MES-3A by accident, because there they are the only
characters in the Unicode EastAsianWidth "wide" class.]
CURRENT STATUS:
6x13.bdf 8x13.bdf 9x15.bdf 9x18.bdf 10x20.bdf:
Complete (TARGET3 reached and checked)
5x7.bdf 5x8.bdf 6x9.bdf 6x10.bdf 6x12.bdf 7x13.bdf 7x14.bdf clR6x12.bdf:
Complete (TARGET2 reached and checked)
6x13B.bdf 7x13B.bdf 7x14B.bdf 8x13B.bdf 9x15B.bdf 9x18B.bdf:
Complete (TARGET1 reached and checked)
6x13O.bdf 7x13O.bdf 8x13O.bdf
Complete (TARGET1 minus Hebrew and block graphics)
[None of the above fonts contains any character that has in Unicode
the East Asian Width Property "W" or "F" assigned. This way, the
desired combination of "half-width" and "full-width" glyphs can be
achieved easily. Most font mechanisms display a character that is not
covered in a font by using a glyph from another font that appears
later in a priority list, which can be arranged to be a "full-width"
font.]
The supplement package
http://www.cl.cam.ac.uk/~mgk25/download/ucs-fonts-asian.tar.gz
contains the following additional square fonts with Han characters for
East Asian users:
12x13ja.bdf:
Covers TARGET2, JIS X 0208, Hangul, and a few more. This font is
primarily intended to provide Japanese full-width Hiragana,
Katakana, and Kanji for applications that take the remaining
("halfwidth") characters from 6x13.bdf. The Greek lowercase
characters in it are still a bit ugly and will need some work.
18x18ja.bdf:
Covers all JIS X 0208, JIS X 0212, GB 2312-80, KS X 1001:1992,
ISO 8859-1,2,3,4,5,7,9,10,15, CP437, CP850 and CP1252 characters,
plus a few more, where priority was given to Japanese han style
variants. This font should have everything needed to cover the
full ISO-2022-JP-2 (RFC 1554) repertoire. This font is primarily
intended to provide Japanese full-width Hiragana, Katakana, and
Kanji for applications that take the remaining ("halfwidth")
characters from 9x18.bdf.
18x18ko.bdf:
Covers the same repertoire as 18x18ja plus full coverage of all
Hangul syllables and priority was given to Hanja glyphs in the
unified CJK area as they are used for writing Korean.
The 9x18 and 6x12 fonts are recommended for use with overstriking
combining characters.
Bug reports, suggestions for improvement, and especially contributed
extensions are very welcome!
INSTALLATION
------------
You install the fonts under Unix roughly like this (details depending
on your system of course):
System-wide installation (root access required):
cd submission/
make
su
mv -b *.pcf.gz /usr/lib/X11/fonts/misc/
cd /usr/lib/X11/fonts/misc/
mkfontdir
xset fp rehash
Alternative: Installation in your private user directory:
cd submission/
make
mkdir -p ~/local/lib/X11/fonts/
mv *.pcf.gz ~/local/lib/X11/fonts/
cd ~/local/lib/X11/fonts/
mkfontdir
xset +fp ~/local/lib/X11/fonts (put this last line also in ~/.xinitrc)
Now you can have a look at say the 6x13 font with the command
xfd -fn '-misc-fixed-medium-r-semicondensed--13-120-75-75-c-60-iso10646-1'
If you want to have short names for the Unicode fonts, you can also
append the fonts.alias file to that in the directory where you install
the fonts, call "mkfontdir" and "xset fp rehash" again, and then you
can also write
xfd -fn 6x13U
Note: If you use an old version of xfontsel, you might notice that it
treats every font that contains characters >0x00ff as a Japanese JIS
font and therefore selects inappropriate sample characters for display
of ISO 10646-1 fonts. An updated xfontsel version with this bug fixed
comes with XFree86 4.0 / X11R6.8 or newer.
If you use the Exceed X server on Microsoft Windows, then you will
have to convert the BDF files into Microsoft FON files using the
"Compile Fonts" function of Exceed xconfig. See the file exceed.txt
for more information.
There is one significant efficiency problem that X11R6 has with the
sparsely populated ISO10646-1 fonts. X11 transmits and allocates 12
bytes with the XFontStruct data structure for the difference between
the lowest and the highest code value found in a font, no matter
whether the code positions in between are used for characters or not.
Even a tiny font that contains only two glyphs at positions 0x0000 and
0xfffd causes 12 bytes * 65534 codes = 786 kbytes to be requested and
stored by the client. Since all the ISO10646-1 BDF files provided in
this package contain characters in the U+00xx (ASCII) and U+ffxx
(ligatures, etc.) range, all of them would result in 786 kbyte large
XCharStruct arrays in the per_char array of the corresponding
XFontStruct (even for CharCell fonts!) when loaded by an X client.
Until this problem is fixed by extending the X11 font protocol and
implementation, non-CJK ISO10646-1 fonts that lack the (anyway not
very interesting) characters above U+31FF seem to be the best
compromise. The bdftruncate.pl program in this package can be used to
deactivate any glyphs above a threshold code value in BDF files. This
way, we get relatively memory-economic ISO10646-1 fonts that cause
"only" 150 kbyte large XCharStruct arrays to be allocated. The
deactivated glyphs are still present in the BDF files, but with an
encoding value of -1 that causes them to be ignored.
The ISO10646-1 fonts can not only be used directly by Unicode aware
software, they can also be used to create any 8-bit font. The
ucs2any.pl Perl script converts a ISO10646-1 BDF font into a BDF font
file with some different encoding. For instance the command
perl ucs2any.pl 6x13.bdf MAPPINGS/8859-7.TXT ISO8859-7
will generate the file 6x13-ISO8859-7.bdf according to the 8859-7.TXT
Latin/Greek mapping table, which available from
<ftp://ftp.unicode.org/Public/MAPPINGS/>. [The shell script
./map_fonts automatically generates a subdirectory derived-fonts/ with
many *.bdf and *.pcf.gz 8-bit versions of all the
-misc-fixed-*-iso10646-1 fonts.]
When you do a "make" in the submission/ subdirectory as suggested in
the installation instructions above, this will generate exactly the
set of fonts that have been submitted to the XFree86 project for
inclusion into XFree86 4.0. These consists of all the ISO10646-1 fonts
processed with "bdftruncate.pl U+3200" plus a selected set of derived
8-bit fonts generated with ucs2any.pl.
Every font comes with a *.repertoire-utf8 file that lists all the
characters in this font.
CONTRIBUTING
------------
If you want to help me in extending or improving the fonts, or if you
want to start your own ISO 10646-1 font project, you will have to edit
BDF font files. This is most comfortably done with the gbdfed font
editor (version 1.3 or higher), which is available from
http://crl.nmsu.edu/~mleisher/gbdfed.html
Once you are familiar with gbdfed, you will notice that it is no
problem to design up to 100 nice characters per hour (even more if
only placing accents is involved).
Information about other X11 font tools and Unicode fonts for X11 in
general can be found on
http://www.cl.cam.ac.uk/~mgk25/ucs-fonts.html
The latest version of this package is available from
http://www.cl.cam.ac.uk/~mgk25/download/ucs-fonts.tar.gz
If you want to contribute, then get the very latest version of this
package, check which glyphs are still missing or inappropriate for
your needs, and send me whatever you had the time to add and fix. Just
email me the extended BDF-files back, or even better, send me a patch
file of what you changed. The best way of preparing a patch file is
./touch_id newfile.bdf
diff -d -u -F STARTCHAR oldfile.bdf newfile.bdf >file.diff
which ensures that the patch file preserves information about which
exact version you worked on and what character each "hunk" changes.
I will try to update this packet on a daily basis. By sending me
extensions to these fonts, you agree that the resulting improved font
files will remain in the public domain for everyone's free use. Always
make sure to load the very latest version of the package immediately
before your start, and send me your results as soon as you are done,
in order to avoid revision overlaps with other contributors.
Please try to be careful with the glyphs you generate:
- Always look first at existing similar characters in order to
preserve a consistent look and feel for the entire font and
within the font family. For block graphics characters and geometric
symbols, take care of correct alignment.
- Read issues.txt, which contains some design hints for certain
characters.
- All characters of CharCell (C) fonts must strictly fit into
the pixel matrix and absolutely no out-of-box ink is allowed.
- The character cells will be displayed directly next to each other,
without any additional pixels in between. Therefore, always make
sure that at least the rightmost pixel column remains white, as
otherwise letters will stick together, except of course for
characters -- like Arabic or block graphics -- that are supposed to
stick together.
- Place accents as low as possible on the Latin characters.
- Try to keep the shape of accents consistent among each other and
with the combining characters in the U+03xx range.
- Use gbdfed only to edit the BDF file directly and do not import
the font that you want to edit from the X server. Use gbdfed 1.3
or higher.
- The glyph names should be the Adobe names for Unicode characters
defined at
http://www.adobe.com/devnet/opentype/archives/glyph.html
which gbdfed can set automatically. To make the Edit/Rename Glyphs/
Adobe Names function work, you have to download the file
http://www.adobe.com/devnet/opentype/archives/glyphlist.txt
and configure its location either in Edit/Preferences/Editing Options/
Adobe Glyph List, or as "adobe_name_file" in "~/.gbdfed".
- Be careful to not change the FONTBOUNDINGBOX box accidentally in
a patch.
You should have a copy of the ISO 10646 standard
ISO/IEC 10646:2003, Information technology -- Universal
Multiple-Octet Coded Character Set (UCS),
International Organization for Standardization, Geneva, 2003.
http://standards.iso.org/ittf/PubliclyAvailableStandards/
and/or the Unicode 5.0 book:
The Unicode Consortium: The Unicode Standard, Version 5.0,
Reading, MA, Addison-Wesley, 2006,
ISBN 9780321480910.
http://www.amazon.com/exec/obidos/ASIN/0321480910/mgk25
All these fonts are from time to time resubmitted to the X.Org
project, XFree86 (they have been in there since XFree86 4.0), and to
other X server developers for inclusion into their normal X11
distributions.
Starting with XFree86 4.0, xterm has included UTF-8 support. This
version is also available from
http://dickey.his.com/xterm/xterm.html
Please make the developer of your favourite software aware of the
UTF-8 definition in RFC 2279 and of the existence of this font
collection. For more information on how to use UTF-8, please check out
http://www.cl.cam.ac.uk/~mgk25/unicode.html
ftp://ftp.ilog.fr/pub/Users/haible/utf8/Unicode-HOWTO.html
where you will also find information on joining the
linux-utf8@nl.linux.org mailing list.
A number of UTF-8 example text files can be found in the examples/
subdirectory or on
http://www.cl.cam.ac.uk/~mgk25/ucs/examples/

72
assets/fonts/README.md Normal file
View File

@@ -0,0 +1,72 @@
## Provided fonts
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.
```bash
otf2bdf -v -o texgyre-27.bdf -r 72 -p 27 texgyreadventor-regular.otf
```
## Create your own
Fonts are in a human-readable and editable `*.bdf` format, but unless you
like reading and writing pixels in hex, generating them is probably easier :)
You can use any font-editor to generate a BDF font or use the conversion
tool [otf2bdf] to create one from some other font format.
Here is an example how you could create a 30-pixel high BDF font from some
TrueType font:
```bash
otf2bdf -v -o myfont.bdf -r 72 -p 30 /path/to/font-Bold.ttf
```
## Getting otf2bdf
Installing the tool should be fairly straightforward.
```bash
sudo apt-get install otf2bdf
```
## Compiling otf2bdf
If you like to compile otf2bdf, you might notice that the configure script
uses some old way of getting the freetype configuration. There does not seem
to be much activity on the mature code, so let's patch that first:
```bash
sudo apt-get install -y libfreetype6-dev pkg-config autoconf
git clone https://github.com/jirutka/otf2bdf.git # check it out
cd otf2bdf
patch -p1 <<"EOF"
--- a/configure.in
+++ b/configure.in
@@ -5,8 +5,8 @@ AC_INIT(otf2bdf.c)
AC_PROG_CC
OLDLIBS=$LIBS
-LIBS="$LIBS `freetype-config --libs`"
-CPPFLAGS="$CPPFLAGS `freetype-config --cflags`"
+LIBS="$LIBS `pkg-config freetype2 --libs`"
+CPPFLAGS="$CPPFLAGS `pkg-config freetype2 --cflags`"
AC_CHECK_LIB(freetype, FT_Init_FreeType, LIBS="$LIBS -lfreetype",[
AC_MSG_ERROR([Can't find Freetype library! Compile FreeType first.])])
AC_SUBST(LIBS)
EOF
autoconf # rebuild configure script
./configure # run configure
make # build the software
sudo make install # install it
```
[otf2bdf]: https://github.com/jirutka/otf2bdf

22736
assets/fonts/clR6x12.bdf Normal file

File diff suppressed because it is too large Load Diff

32869
assets/fonts/helvR12.bdf Normal file

File diff suppressed because it is too large Load Diff

30577
assets/fonts/texgyre-27.bdf Normal file

File diff suppressed because it is too large Load Diff

2365
assets/fonts/tom-thumb.bdf Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -43,6 +43,50 @@
} }
} }
}, },
"dim_schedule": {
"enabled": false,
"dim_brightness": 30,
"mode": "global",
"start_time": "20:00",
"end_time": "07:00",
"days": {
"monday": {
"enabled": true,
"start_time": "20:00",
"end_time": "07:00"
},
"tuesday": {
"enabled": true,
"start_time": "20:00",
"end_time": "07:00"
},
"wednesday": {
"enabled": true,
"start_time": "20:00",
"end_time": "07:00"
},
"thursday": {
"enabled": true,
"start_time": "20:00",
"end_time": "07:00"
},
"friday": {
"enabled": true,
"start_time": "20:00",
"end_time": "07:00"
},
"saturday": {
"enabled": true,
"start_time": "20:00",
"end_time": "07:00"
},
"sunday": {
"enabled": true,
"start_time": "20:00",
"end_time": "07:00"
}
}
},
"timezone": "America/Chicago", "timezone": "America/Chicago",
"location": { "location": {
"city": "Dallas", "city": "Dallas",
@@ -64,15 +108,23 @@
"disable_hardware_pulsing": false, "disable_hardware_pulsing": false,
"inverse_colors": false, "inverse_colors": false,
"show_refresh_rate": false, "show_refresh_rate": false,
"led_rgb_sequence": "RGB",
"limit_refresh_rate_hz": 100 "limit_refresh_rate_hz": 100
}, },
"runtime": { "runtime": {
"gpio_slowdown": 3 "gpio_slowdown": 3
}, },
"display_durations": { "display_durations": {},
"calendar": 30 "use_short_date_format": true,
}, "vegas_scroll": {
"use_short_date_format": true "enabled": false,
"scroll_speed": 50,
"separator_width": 32,
"plugin_order": [],
"excluded_plugins": [],
"target_fps": 125,
"buffer_ahead": 2
}
}, },
"plugin_system": { "plugin_system": {
"plugins_directory": "plugin-repos", "plugins_directory": "plugin-repos",

1032
docs/ADVANCED_FEATURES.md Normal file

File diff suppressed because it is too large Load Diff

306
docs/CONFIG_DEBUGGING.md Normal file
View File

@@ -0,0 +1,306 @@
# Configuration Debugging Guide
This guide helps troubleshoot configuration issues in LEDMatrix.
## Configuration Files
### Main Files
| File | Purpose |
|------|---------|
| `config/config.json` | Main configuration |
| `config/config_secrets.json` | API keys and sensitive data |
| `config/config.template.json` | Template for new installations |
### Plugin Configuration
Each plugin's configuration is a top-level key in `config.json`:
```json
{
"football-scoreboard": {
"enabled": true,
"display_duration": 30,
"nfl": {
"enabled": true,
"live_priority": false
}
},
"odds-ticker": {
"enabled": true,
"display_duration": 15
}
}
```
## Schema Validation
Plugins define their configuration schema in `config_schema.json`. This enables:
- Automatic default value population
- Configuration validation
- Web UI form generation
### Missing Schema Warning
If a plugin doesn't have `config_schema.json`, you'll see:
```
WARNING - Plugin 'my-plugin' has no config_schema.json - configuration will not be validated.
```
**Fix**: Add a `config_schema.json` to your plugin directory.
### Schema Example
```json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"enabled": {
"type": "boolean",
"default": true,
"description": "Enable or disable this plugin"
},
"display_duration": {
"type": "number",
"default": 15,
"minimum": 1,
"description": "How long to display in seconds"
},
"api_key": {
"type": "string",
"description": "API key for data access"
}
},
"required": ["api_key"]
}
```
## Common Configuration Issues
### 1. Type Mismatches
**Problem**: String value where number expected
```json
{
"display_duration": "30" // Wrong: string
}
```
**Fix**: Use correct types
```json
{
"display_duration": 30 // Correct: number
}
```
**Logged Warning**:
```
WARNING - Config display_duration has invalid string value '30', using default 15.0
```
### 2. Missing Required Fields
**Problem**: Required field not in config
```json
{
"football-scoreboard": {
"enabled": true
// Missing api_key which is required
}
}
```
**Logged Error**:
```
ERROR - Plugin football-scoreboard configuration validation failed: 'api_key' is a required property
```
### 3. Invalid Nested Objects
**Problem**: Wrong structure for nested config
```json
{
"football-scoreboard": {
"nfl": "enabled" // Wrong: should be object
}
}
```
**Fix**: Use correct structure
```json
{
"football-scoreboard": {
"nfl": {
"enabled": true
}
}
}
```
### 4. Invalid JSON Syntax
**Problem**: Malformed JSON
```json
{
"plugin": {
"enabled": true, // Trailing comma
}
}
```
**Fix**: Remove trailing commas, ensure valid JSON
```json
{
"plugin": {
"enabled": true
}
}
```
**Tip**: Validate JSON at https://jsonlint.com/
## Debugging Configuration Loading
### Enable Debug Logging
Set environment variable:
```bash
export LEDMATRIX_DEBUG=1
python run.py
```
### Check Merged Configuration
The configuration is merged with schema defaults. To see the final merged config:
1. Enable debug logging
2. Look for log entries like:
```
DEBUG - Merged config with schema defaults for football-scoreboard
```
### Configuration Load Order
1. Load `config.json`
2. Load `config_secrets.json`
3. Merge secrets into main config
4. For each plugin:
- Load plugin's `config_schema.json`
- Extract default values from schema
- Merge user config with defaults
- Validate merged config against schema
## Web Interface Issues
### Changes Not Saving
1. Check file permissions on `config/` directory
2. Check disk space
3. Look for errors in browser console
4. Check server logs for save errors
### Form Fields Not Appearing
1. Plugin may not have `config_schema.json`
2. Schema may have syntax errors
3. Check browser console for JavaScript errors
### Checkboxes Not Working
Boolean values from checkboxes should be actual booleans, not strings:
```json
{
"enabled": true, // Correct
"enabled": "true" // Wrong
}
```
## Config Key Collision Detection
LEDMatrix detects potential config key conflicts:
### Reserved Keys
These plugin IDs will trigger a warning:
- `display`, `schedule`, `timezone`, `plugin_system`
- `display_modes`, `system`, `hardware`, `debug`
- `log_level`, `emulator`, `web_interface`
**Warning**:
```
WARNING - Plugin ID 'display' conflicts with reserved config key.
```
### Case Collisions
Plugin IDs that differ only in case:
```
WARNING - Plugin ID 'Football-Scoreboard' may conflict with 'football-scoreboard' on case-insensitive file systems.
```
## Checking Configuration via API
```bash
# Get current config
curl http://localhost:5000/api/v3/config
# Get specific plugin config
curl http://localhost:5000/api/v3/config/plugin/football-scoreboard
# Validate config without saving
curl -X POST http://localhost:5000/api/v3/config/validate \
-H "Content-Type: application/json" \
-d '{"football-scoreboard": {"enabled": true}}'
```
## Backup and Recovery
### Manual Backup
```bash
cp config/config.json config/config.backup.json
```
### Automatic Backups
LEDMatrix creates backups before saves:
- Location: `config/backups/`
- Format: `config_YYYYMMDD_HHMMSS.json`
### Recovery
```bash
# List backups
ls -la config/backups/
# Restore from backup
cp config/backups/config_20240115_120000.json config/config.json
```
## Troubleshooting Checklist
- [ ] JSON syntax is valid (no trailing commas, quotes correct)
- [ ] Data types match schema (numbers are numbers, not strings)
- [ ] Required fields are present
- [ ] Nested objects have correct structure
- [ ] File permissions allow read/write
- [ ] No reserved config key collisions
- [ ] Plugin has `config_schema.json` for validation
## Getting Help
1. Check logs: `tail -f logs/ledmatrix.log`
2. Enable debug: `LEDMATRIX_DEBUG=1`
3. Check error dashboard: `/api/v3/errors/summary`
4. Validate JSON: https://jsonlint.com/
5. File an issue: https://github.com/ChuckBuilds/LEDMatrix/issues

324
docs/GETTING_STARTED.md Normal file
View File

@@ -0,0 +1,324 @@
# Getting Started with LEDMatrix
## Welcome
This guide will help you set up your LEDMatrix display for the first time and get it running in under 30 minutes.
---
## Prerequisites
**Hardware:**
- Raspberry Pi (3, 4, or 5 recommended)
- RGB LED Matrix panel (32x64 or 64x64)
- Adafruit RGB Matrix HAT or similar
- Power supply (5V, 4A minimum recommended)
- MicroSD card (16GB minimum)
**Network:**
- WiFi network (or Ethernet cable)
- Computer with web browser on same network
---
## Quick Start (5 Minutes)
### 1. First Boot
1. Insert the MicroSD card with LEDMatrix installed
2. Connect the LED matrix to your Raspberry Pi
3. Plug in the power supply
4. Wait for the Pi to boot (about 60 seconds)
**Expected Behavior:**
- LED matrix will light up
- Display will show default plugins (clock, weather, etc.)
- Pi creates WiFi network "LEDMatrix-Setup" if not connected
### 2. Connect to WiFi
**If you see "LEDMatrix-Setup" WiFi network:**
1. Connect your device to "LEDMatrix-Setup" (open network, no password)
2. Open browser to: `http://192.168.4.1:5050`
3. Navigate to the WiFi tab
4. Click "Scan" to find your WiFi network
5. Select your network, enter password
6. Click "Connect"
7. Wait for connection (LED matrix will show confirmation)
**If already connected to WiFi:**
1. Find your Pi's IP address (check your router, or run `hostname -I` on the Pi)
2. Open browser to: `http://your-pi-ip:5050`
### 3. Access the Web Interface
Once connected, access the web interface:
```
http://your-pi-ip:5050
```
You should see:
- Overview tab with system stats
- Live display preview
- Quick action buttons
---
## Initial Configuration (15 Minutes)
### Step 1: Configure Display Hardware
1. Navigate to Settings → **Display Settings**
2. Set your matrix configuration:
- **Rows**: 32 or 64 (match your hardware)
- **Columns**: 64, 128, or 256 (match your hardware)
- **Chain Length**: Number of panels chained together
- **Brightness**: 50-75% recommended for indoor use
3. Click **Save Configuration**
4. Click **Restart Display** to apply changes
**Tip:** If the display doesn't look right, try different hardware mapping options.
### Step 2: Set Timezone and Location
1. Navigate to Settings → **General Settings**
2. Set your timezone (e.g., "America/New_York")
3. Set your location (city, state, country)
4. Click **Save Configuration**
**Why it matters:** Correct timezone ensures accurate time display. Location enables weather and location-based features.
### Step 3: Install Plugins
1. Navigate to **Plugin Store** tab
2. Browse available plugins:
- **Time & Date**: Clock, calendar
- **Weather**: Weather forecasts
- **Sports**: NHL, NBA, NFL, MLB scores
- **Finance**: Stocks, crypto
- **Custom**: Community plugins
3. Click **Install** on desired plugins
4. Wait for installation to complete
5. Navigate to **Plugin Management** tab
6. Enable installed plugins (toggle switch)
7. Click **Restart Display**
**Popular First Plugins:**
- `clock-simple` - Simple digital clock
- `weather` - Weather forecast
- `nhl-scores` - NHL scores (if you're a hockey fan)
### Step 4: Configure Plugins
1. Navigate to **Plugin Management** tab
2. Find a plugin you installed
3. Click the ⚙️ **Configure** button
4. Edit settings (e.g., favorite teams, update intervals)
5. Click **Save**
6. Click **Restart Display**
**Example: Weather Plugin**
- Set your location (city, state, country)
- Add API key from OpenWeatherMap (free signup)
- Set update interval (300 seconds recommended)
---
## Testing Your Display
### Quick Test
1. Navigate to **Overview** tab
2. Click **Test Display** button
3. You should see a test pattern on your LED matrix
### Manual Plugin Trigger
1. Navigate to **Plugin Management** tab
2. Find a plugin
3. Click **Show Now** button
4. The plugin should display immediately
5. Click **Stop** to return to rotation
### Check Logs
1. Navigate to **Logs** tab
2. Watch real-time logs
3. Look for any ERROR messages
4. Normal operation shows INFO messages about plugin rotation
---
## Common First-Time Issues
### Display Not Showing Anything
**Check:**
1. Power supply connected and adequate (5V, 4A minimum)
2. LED matrix connected to GPIO pins correctly
3. Display service running: `sudo systemctl status ledmatrix`
4. Hardware configuration matches your matrix (rows/columns)
**Fix:**
1. Restart display: Settings → Overview → Restart Display
2. Or via SSH: `sudo systemctl restart ledmatrix`
### Web Interface Won't Load
**Check:**
1. Pi is connected to network: `ping your-pi-ip`
2. Web service running: `sudo systemctl status ledmatrix-web`
3. Correct port: Use `:5050` not `:5000`
4. Firewall not blocking port 5050
**Fix:**
1. Restart web service: `sudo systemctl restart ledmatrix-web`
2. Check logs: `sudo journalctl -u ledmatrix-web -n 50`
### Plugins Not Showing
**Check:**
1. Plugins are enabled (toggle switch in Plugin Management)
2. Display has been restarted after enabling
3. Plugin duration is reasonable (not too short)
4. No errors in logs for the plugin
**Fix:**
1. Enable plugin in Plugin Management
2. Restart display
3. Check logs for plugin-specific errors
### Weather Plugin Shows "No Data"
**Check:**
1. API key configured (OpenWeatherMap)
2. Location is correct (city, state, country)
3. Internet connection working
**Fix:**
1. Sign up at openweathermap.org (free)
2. Add API key to config_secrets.json or plugin config
3. Restart display
---
## Next Steps
### Customize Your Display
**Adjust Display Durations:**
- Navigate to Settings → Durations
- Set how long each plugin displays
- Save and restart
**Organize Plugin Order:**
- Use Plugin Management to enable/disable plugins
- Display cycles through enabled plugins in order
**Add More Plugins:**
- Check Plugin Store regularly for new plugins
- Install from GitHub URLs for custom/community plugins
### Enable Advanced Features
**Vegas Scroll Mode:**
- Continuous scrolling ticker display
- See [ADVANCED_FEATURES.md](ADVANCED_FEATURES.md) for details
**On-Demand Display:**
- Manually trigger specific plugins
- Pin important information
- See [ADVANCED_FEATURES.md](ADVANCED_FEATURES.md) for details
**Background Services:**
- Non-blocking data fetching
- Faster plugin rotation
- See [ADVANCED_FEATURES.md](ADVANCED_FEATURES.md) for details
### Explore Documentation
- [WEB_INTERFACE_GUIDE.md](WEB_INTERFACE_GUIDE.md) - Complete web interface guide
- [WIFI_NETWORK_SETUP.md](WIFI_NETWORK_SETUP.md) - WiFi configuration details
- [PLUGIN_STORE_GUIDE.md](PLUGIN_STORE_GUIDE.md) - Installing and managing plugins
- [TROUBLESHOOTING.md](TROUBLESHOOTING.md) - Solving common issues
- [ADVANCED_FEATURES.md](ADVANCED_FEATURES.md) - Advanced functionality
### Join the Community
- Report issues on GitHub
- Share your custom plugins
- Help others in discussions
- Contribute improvements
---
## Quick Reference
### Service Commands
```bash
# Check status
sudo systemctl status ledmatrix
sudo systemctl status ledmatrix-web
# Restart services
sudo systemctl restart ledmatrix
sudo systemctl restart ledmatrix-web
# View logs
sudo journalctl -u ledmatrix -f
sudo journalctl -u ledmatrix-web -f
```
### File Locations
```
/home/ledpi/LEDMatrix/
├── config/
│ ├── config.json # Main configuration
│ ├── config_secrets.json # API keys and secrets
│ └── wifi_config.json # WiFi settings
├── plugins/ # Installed plugins
├── cache/ # Cached data
└── web_interface/ # Web interface files
```
### Web Interface
```
Main Interface: http://your-pi-ip:5050
Tabs:
- Overview: System stats and quick actions
- General Settings: Timezone, location, autostart
- Display Settings: Hardware configuration
- Durations: Plugin display times
- Sports Configuration: Per-league settings
- Plugin Management: Enable/disable, configure
- Plugin Store: Install new plugins
- Font Management: Upload and manage fonts
- Logs: Real-time log viewing
```
### WiFi Access Point
```
Network Name: LEDMatrix-Setup
Password: (none - open network)
URL when connected: http://192.168.4.1:5050
```
---
## Congratulations!
Your LEDMatrix display is now set up and running. Explore the web interface, try different plugins, and customize it to your liking.
**Need Help?**
- Check [TROUBLESHOOTING.md](TROUBLESHOOTING.md)
- Review detailed guides for specific features
- Report issues on GitHub
- Ask questions in community discussions
Enjoy your LED matrix display!

View File

@@ -0,0 +1,243 @@
# Plugin Error Handling Guide
This guide covers best practices for error handling in LEDMatrix plugins.
## Custom Exception Hierarchy
LEDMatrix provides typed exceptions for different error categories. Use these instead of generic `Exception`:
```python
from src.exceptions import PluginError, ConfigError, CacheError, DisplayError
# Plugin-related errors
raise PluginError("Failed to fetch data", plugin_id=self.plugin_id, context={"api": "ESPN"})
# Configuration errors
raise ConfigError("Invalid API key format", field="api_key")
# Cache errors
raise CacheError("Cache write failed", cache_key="game_data")
# Display errors
raise DisplayError("Failed to render", display_mode="live")
```
### Exception Context
All LEDMatrix exceptions support a `context` dict for additional debugging info:
```python
raise PluginError(
"API request failed",
plugin_id=self.plugin_id,
context={
"url": api_url,
"status_code": response.status_code,
"retry_count": 3
}
)
```
## Logging Best Practices
### Use the Plugin Logger
Every plugin has access to `self.logger`:
```python
class MyPlugin(BasePlugin):
def update(self):
self.logger.info("Starting data fetch")
self.logger.debug("API URL: %s", api_url)
self.logger.warning("Rate limit approaching")
self.logger.error("API request failed", exc_info=True)
```
### Log Levels
- **DEBUG**: Detailed info for troubleshooting (API URLs, parsed data)
- **INFO**: Normal operation milestones (plugin loaded, data fetched)
- **WARNING**: Recoverable issues (rate limits, cache miss, fallback used)
- **ERROR**: Failures that need attention (API down, display error)
### Include exc_info for Exceptions
```python
try:
response = requests.get(url)
except requests.RequestException as e:
self.logger.error("API request failed: %s", e, exc_info=True)
```
## Error Handling Patterns
### Never Use Bare except
```python
# BAD - swallows all errors including KeyboardInterrupt
try:
self.fetch_data()
except:
pass
# GOOD - catch specific exceptions
try:
self.fetch_data()
except requests.RequestException as e:
self.logger.warning("Network error, using cached data: %s", e)
self.data = self.get_cached_data()
```
### Graceful Degradation
```python
def update(self):
try:
self.data = self.fetch_live_data()
except requests.RequestException as e:
self.logger.warning("Live data unavailable: %s", e)
# Fall back to cache
cached = self.cache_manager.get(self.cache_key)
if cached:
self.logger.info("Using cached data")
self.data = cached
else:
self.logger.error("No cached data available")
self.data = None
```
### Validate Configuration Early
```python
def validate_config(self) -> bool:
"""Validate configuration at load time."""
api_key = self.config.get("api_key")
if not api_key:
self.logger.error("api_key is required but not configured")
return False
if not isinstance(api_key, str) or len(api_key) < 10:
self.logger.error("api_key appears to be invalid")
return False
return True
```
### Handle Display Errors
```python
def display(self, force_clear: bool = False) -> bool:
if not self.data:
if force_clear:
self.display_manager.clear()
self.display_manager.update_display()
return False
try:
self._render_content()
return True
except Exception as e:
self.logger.error("Display error: %s", e, exc_info=True)
# Clear display on error to prevent stale content
self.display_manager.clear()
self.display_manager.update_display()
return False
```
## Error Aggregation
LEDMatrix automatically tracks plugin errors. Access error data via the API:
```bash
# Get error summary
curl http://localhost:5000/api/v3/errors/summary
# Get plugin-specific health
curl http://localhost:5000/api/v3/errors/plugin/my-plugin
# Clear old errors
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.
## Common Error Scenarios
### API Rate Limiting
```python
def fetch_data(self):
try:
response = requests.get(self.api_url)
if response.status_code == 429:
retry_after = int(response.headers.get("Retry-After", 60))
self.logger.warning("Rate limited, retry after %ds", retry_after)
self._rate_limited_until = time.time() + retry_after
return None
response.raise_for_status()
return response.json()
except requests.RequestException as e:
self.logger.error("API error: %s", e)
return None
```
### Timeout Handling
```python
def fetch_data(self):
try:
response = requests.get(self.api_url, timeout=10)
return response.json()
except requests.Timeout:
self.logger.warning("Request timed out, will retry next update")
return None
except requests.RequestException as e:
self.logger.error("Request failed: %s", e)
return None
```
### Missing Data Gracefully
```python
def get_team_logo(self, team_id):
logo_path = self.logos_dir / f"{team_id}.png"
if not logo_path.exists():
self.logger.debug("Logo not found for team %s, using default", team_id)
return self.default_logo
return Image.open(logo_path)
```
## Testing Error Handling
```python
def test_handles_api_error(mock_requests):
"""Test plugin handles API errors gracefully."""
mock_requests.get.side_effect = requests.RequestException("Network error")
plugin = MyPlugin(...)
plugin.update()
# Should not raise, should log warning, should have no data
assert plugin.data is None
def test_handles_invalid_json(mock_requests):
"""Test plugin handles invalid JSON response."""
mock_requests.get.return_value.json.side_effect = ValueError("Invalid JSON")
plugin = MyPlugin(...)
plugin.update()
assert plugin.data is None
```
## Checklist
- [ ] No bare `except:` clauses
- [ ] All exceptions logged with appropriate level
- [ ] `exc_info=True` for error-level logs
- [ ] Graceful degradation with cache fallbacks
- [ ] Configuration validated in `validate_config()`
- [ ] Display clears on error to prevent stale content
- [ ] Timeouts configured for all network requests

493
docs/PLUGIN_STORE_GUIDE.md Normal file
View File

@@ -0,0 +1,493 @@
# Plugin Store Guide
## Overview
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:5050/api/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:5050/api/plugins/install-from-url \
-H "Content-Type: application/json" \
-d '{"repo_url": "https://github.com/user/ledmatrix-plugin"}'
```
### Manage Plugins
```bash
# List installed
curl "http://your-pi-ip:5050/api/plugins/installed"
# Enable/disable
curl -X POST http://your-pi-ip:5050/api/plugins/toggle \
-H "Content-Type: application/json" \
-d '{"plugin_id": "clock-simple", "enabled": true}'
# Update
curl -X POST http://your-pi-ip:5050/api/plugins/update \
-H "Content-Type: application/json" \
-d '{"plugin_id": "clock-simple"}'
# Uninstall
curl -X POST http://your-pi-ip:5050/api/plugins/uninstall \
-H "Content-Type: application/json" \
-d '{"plugin_id": "clock-simple"}'
```
---
## Installation Methods
### Method 1: From Official Plugin Store (Recommended)
The official plugin store contains curated, verified plugins that have been reviewed by maintainers.
**Via Web Interface:**
1. Open the web interface at http://your-pi-ip:5050
2. Navigate to the "Plugin Store" tab
3. Browse or search for plugins
4. Click "Install" on the desired plugin
5. Wait for installation to complete
6. Restart the display to activate the plugin
**Via REST API:**
```bash
curl -X POST http://your-pi-ip:5050/api/plugins/install \
-H "Content-Type: application/json" \
-d '{"plugin_id": "clock-simple"}'
```
**Via Python:**
```python
from src.plugin_system.store_manager import PluginStoreManager
store = PluginStoreManager()
success = store.install_plugin('clock-simple')
if success:
print("Plugin installed!")
```
### Method 2: From Custom GitHub URL
Install any plugin directly from a GitHub repository, even if it's not in the official store. This method is useful for:
- Testing your own plugins during development
- Installing community plugins before they're in the official store
- Using private plugins
- Sharing plugins with specific users
**Via Web Interface:**
1. Open the web interface
2. Navigate to the "Plugin Store" tab
3. Find the "Install from URL" section
4. Paste the GitHub repository URL (e.g., `https://github.com/user/ledmatrix-my-plugin`)
5. Click "Install from URL"
6. Review the warning about unverified plugins
7. Confirm installation
8. Wait for installation to complete
9. Restart the display
**Via REST API:**
```bash
curl -X POST http://your-pi-ip:5050/api/plugins/install-from-url \
-H "Content-Type: application/json" \
-d '{"repo_url": "https://github.com/user/ledmatrix-my-plugin"}'
```
**Via Python:**
```python
from src.plugin_system.store_manager import PluginStoreManager
store = PluginStoreManager()
result = store.install_from_url('https://github.com/user/ledmatrix-my-plugin')
if result['success']:
print(f"Installed: {result['plugin_id']}")
else:
print(f"Error: {result['error']}")
```
---
## Searching for Plugins
**Via Web Interface:**
- Use the search bar to search by name, description, or author
- Filter by category (sports, weather, time, finance, etc.)
- Click on tags to filter by specific tags
**Via REST API:**
```bash
# Search by query
curl "http://your-pi-ip:5050/api/plugins/store/search?q=hockey"
# Filter by category
curl "http://your-pi-ip:5050/api/plugins/store/search?category=sports"
# Filter by tags
curl "http://your-pi-ip:5050/api/plugins/store/search?tags=nhl&tags=hockey"
```
**Via Python:**
```python
from src.plugin_system.store_manager import PluginStoreManager
store = PluginStoreManager()
# Search by query
results = store.search_plugins(query="hockey")
# Filter by category
results = store.search_plugins(category="sports")
# Filter by tags
results = store.search_plugins(tags=["nhl", "hockey"])
```
---
## Managing Installed Plugins
### List Installed Plugins
**Via Web Interface:**
- Navigate to the "Plugin Manager" tab
- View all installed plugins with their status
**Via REST API:**
```bash
curl "http://your-pi-ip:5050/api/plugins/installed"
```
**Via Python:**
```python
from src.plugin_system.store_manager import PluginStoreManager
store = PluginStoreManager()
installed = store.list_installed_plugins()
for plugin_id in installed:
info = store.get_installed_plugin_info(plugin_id)
print(f"{info['name']} (Last updated: {info.get('last_updated', 'unknown')})")
```
### Enable/Disable Plugins
**Via Web Interface:**
1. Navigate to the "Plugin Manager" tab
2. Use the toggle switch next to each plugin
3. Restart the display to apply changes
**Via REST API:**
```bash
curl -X POST http://your-pi-ip:5050/api/plugins/toggle \
-H "Content-Type: application/json" \
-d '{"plugin_id": "clock-simple", "enabled": true}'
```
### Update Plugins
**Via Web Interface:**
1. Navigate to the "Plugin Manager" tab
2. Click the "Update" button next to the plugin
3. Wait for the update to complete
4. Restart the display
**Via REST API:**
```bash
curl -X POST http://your-pi-ip:5050/api/plugins/update \
-H "Content-Type: application/json" \
-d '{"plugin_id": "clock-simple"}'
```
**Via Python:**
```python
from src.plugin_system.store_manager import PluginStoreManager
store = PluginStoreManager()
success = store.update_plugin('clock-simple')
```
### Uninstall Plugins
**Via Web Interface:**
1. Navigate to the "Plugin Manager" tab
2. Click the "Uninstall" button next to the plugin
3. Confirm removal
4. Restart the display
**Via REST API:**
```bash
curl -X POST http://your-pi-ip:5050/api/plugins/uninstall \
-H "Content-Type: application/json" \
-d '{"plugin_id": "clock-simple"}'
```
**Via Python:**
```python
from src.plugin_system.store_manager import PluginStoreManager
store = PluginStoreManager()
success = store.uninstall_plugin('clock-simple')
```
---
## Configuring Plugins
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`
### Dependencies Failed
**Problem:** "Error installing dependencies" message
**Solutions:**
- Check that pip3 is installed
- Manually install: `pip3 install --break-system-packages -r plugins/plugin-id/requirements.txt`
- Check for conflicting package versions
### Plugin Shows Errors
**Problem:** Plugin loads but shows error message on display
**Solutions:**
1. Check that the plugin configuration is correct
2. Verify API keys are set (if the plugin requires them)
3. Check plugin logs: `sudo journalctl -u ledmatrix -f | grep plugin-id`
4. Report the issue to the plugin developer on GitHub
---
## API Reference
All API endpoints return JSON with this structure:
```json
{
"status": "success" | "error",
"message": "Human-readable message",
"data": { ... }
}
```
### Endpoints
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/plugins/store/list` | List all plugins in store |
| GET | `/api/plugins/store/search` | Search for plugins |
| GET | `/api/plugins/installed` | List installed plugins |
| POST | `/api/plugins/install` | Install from registry |
| POST | `/api/plugins/install-from-url` | Install from GitHub URL |
| POST | `/api/plugins/uninstall` | Uninstall plugin |
| POST | `/api/plugins/update` | Update plugin |
| POST | `/api/plugins/toggle` | Enable/disable plugin |
| POST | `/api/plugins/config` | Update plugin config |
---
## Examples
### Example 1: Install Clock Plugin
```bash
# Install
curl -X POST http://192.168.1.100:5050/api/plugins/install \
-H "Content-Type: application/json" \
-d '{"plugin_id": "clock-simple"}'
# Configure in config/config.json
{
"clock-simple": {
"enabled": true,
"display_duration": 20,
"time_format": "24h"
}
}
# Restart display
sudo systemctl restart ledmatrix
```
### Example 2: Install Custom Plugin from GitHub
```bash
# Install your own plugin during development
curl -X POST http://192.168.1.100:5050/api/plugins/install-from-url \
-H "Content-Type: application/json" \
-d '{"repo_url": "https://github.com/myusername/ledmatrix-my-custom-plugin"}'
# Enable it
curl -X POST http://192.168.1.100:5050/api/plugins/toggle \
-H "Content-Type: application/json" \
-d '{"plugin_id": "my-custom-plugin", "enabled": true}'
# Restart
sudo systemctl restart ledmatrix
```
### Example 3: Share Plugin with Others
As a plugin developer, you can share your plugin with others even before it's in the official store:
1. Push your plugin to GitHub: `https://github.com/yourusername/ledmatrix-awesome-plugin`
2. Share the URL with users
3. Users install via:
- Open the LEDMatrix web interface
- Click "Plugin Store" tab
- Scroll to "Install from URL"
- Paste the URL
- Click "Install from URL"
---
## Command-Line Usage
For advanced users, manage plugins via command line:
```bash
# Install from registry
python3 -c "
from src.plugin_system.store_manager import PluginStoreManager
store = PluginStoreManager()
store.install_plugin('clock-simple')
"
# Install from URL
python3 -c "
from src.plugin_system.store_manager import PluginStoreManager
store = PluginStoreManager()
result = store.install_from_url('https://github.com/user/plugin')
print(result)
"
# List installed
python3 -c "
from src.plugin_system.store_manager import PluginStoreManager
store = PluginStoreManager()
for plugin_id in store.list_installed_plugins():
info = store.get_installed_plugin_info(plugin_id)
print(f'{plugin_id}: {info[\"name\"]} (Last updated: {info.get(\"last_updated\", \"unknown\")})')
"
# Uninstall
python3 -c "
from src.plugin_system.store_manager import PluginStoreManager
store = PluginStoreManager()
store.uninstall_plugin('clock-simple')
"
```
---
## FAQ
**Q: Do I need to restart the display after installing a plugin?**
A: Yes, plugins are loaded when the display controller starts.
**Q: Can I install plugins while the display is running?**
A: Yes, you can install anytime, but you must restart the display to load them.
**Q: What happens if I install a plugin with the same ID as an existing one?**
A: The existing copy will be replaced with the latest code from the repository.
**Q: Can I install multiple versions of the same plugin?**
A: No, each plugin ID maps to a single checkout of the repository's default branch.
**Q: How do I update all plugins at once?**
A: Currently, you need to update each plugin individually. Bulk update is planned for a future release.
**Q: Can plugins access my API keys from config_secrets.json?**
A: Yes, if a plugin needs API keys, it can access them like core managers do.
**Q: How much disk space do plugins use?**
A: Most plugins are small (1-5MB). Check individual plugin documentation for specific requirements.
**Q: Can I create my own plugin?**
A: Yes! See [PLUGIN_DEVELOPMENT.md](PLUGIN_DEVELOPMENT.md) for instructions.
---
## Related Documentation
- [PLUGIN_DEVELOPMENT.md](PLUGIN_DEVELOPMENT.md) - Create your own plugins
- [PLUGIN_API_REFERENCE.md](PLUGIN_API_REFERENCE.md) - Plugin API documentation
- [PLUGIN_ARCHITECTURE.md](PLUGIN_ARCHITECTURE.md) - Plugin system architecture
- [REST_API_REFERENCE.md](REST_API_REFERENCE.md) - Complete REST API reference

View File

@@ -4,174 +4,196 @@ Welcome to the LEDMatrix documentation! This directory contains comprehensive gu
## 📚 Documentation Overview ## 📚 Documentation Overview
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. This documentation has been recently consolidated (January 2026) to reduce redundancy while maintaining comprehensive coverage. We've reduced from 51 main documents to 16-17 well-organized files (~68% reduction) by merging duplicates, archiving ephemeral content, and unifying writing styles.
## 📖 Quick Start ## 📖 Quick Start
### For New Users ### For New Users
1. **Installation**: Follow the main [README.md](../README.md) in the project root 1. **Installation**: Follow the main [README.md](../README.md) in the project root
2. **First Setup**: Run `first_time_install.sh` for initial configuration 2. **First Setup**: See [GETTING_STARTED.md](GETTING_STARTED.md) for first-time setup guide
3. **Basic Usage**: See [TROUBLESHOOTING_QUICK_START.md](TROUBLESHOOTING_QUICK_START.md) for common issues 3. **Web Interface**: Use [WEB_INTERFACE_GUIDE.md](WEB_INTERFACE_GUIDE.md) to learn the control panel
4. **Troubleshooting**: Check [TROUBLESHOOTING.md](TROUBLESHOOTING.md) for common issues
### For Developers ### For Developers
1. **Plugin System**: Read [PLUGIN_QUICK_REFERENCE.md](PLUGIN_QUICK_REFERENCE.md) for an overview 1. **Plugin Development**: See [PLUGIN_DEVELOPMENT_GUIDE.md](PLUGIN_DEVELOPMENT_GUIDE.md) for complete guide
2. **Plugin Development**: See [PLUGIN_DEVELOPMENT_GUIDE.md](PLUGIN_DEVELOPMENT_GUIDE.md) for development workflow 2. **Advanced Patterns**: Read [ADVANCED_PLUGIN_DEVELOPMENT.md](ADVANCED_PLUGIN_DEVELOPMENT.md) for advanced techniques
3. **API Reference**: Check [PLUGIN_API_REFERENCE.md](PLUGIN_API_REFERENCE.md) for available methods 3. **API Reference**: Check [PLUGIN_API_REFERENCE.md](PLUGIN_API_REFERENCE.md) for available methods
4. **Configuration**: Check [PLUGIN_CONFIGURATION_GUIDE.md](PLUGIN_CONFIGURATION_GUIDE.md) 4. **Configuration**: See [PLUGIN_CONFIGURATION_GUIDE.md](PLUGIN_CONFIGURATION_GUIDE.md) for config schemas
### For API Integration ### For API Integration
1. **REST API**: See [API_REFERENCE.md](API_REFERENCE.md) for all web interface endpoints 1. **REST API**: See [REST_API_REFERENCE.md](REST_API_REFERENCE.md) for all web interface endpoints
2. **Plugin API**: See [PLUGIN_API_REFERENCE.md](PLUGIN_API_REFERENCE.md) for plugin developer APIs 2. **Plugin API**: See [PLUGIN_API_REFERENCE.md](PLUGIN_API_REFERENCE.md) for plugin developer APIs
3. **Quick Reference**: See [DEVELOPER_QUICK_REFERENCE.md](DEVELOPER_QUICK_REFERENCE.md) for common tasks 3. **Developer Reference**: See [DEVELOPER_QUICK_REFERENCE.md](DEVELOPER_QUICK_REFERENCE.md) for common tasks
## 📋 Documentation Categories ## 📋 Documentation Categories
### 🚀 Getting Started & Setup ### 🚀 Getting Started & User Guides
- [EMULATOR_SETUP_GUIDE.md](EMULATOR_SETUP_GUIDE.md) - Set up development environment with emulator - [GETTING_STARTED.md](GETTING_STARTED.md) - First-time setup and quick start guide
- [TRIXIE_UPGRADE_GUIDE.md](TRIXIE_UPGRADE_GUIDE.md) - Upgrade to Raspbian OS 13 "Trixie" - [WEB_INTERFACE_GUIDE.md](WEB_INTERFACE_GUIDE.md) - Complete web interface user guide
- [TROUBLESHOOTING_QUICK_START.md](TROUBLESHOOTING_QUICK_START.md) - Common issues and solutions - [WIFI_NETWORK_SETUP.md](WIFI_NETWORK_SETUP.md) - WiFi configuration and AP mode setup
- [PLUGIN_STORE_GUIDE.md](PLUGIN_STORE_GUIDE.md) - Installing and managing plugins
- [TROUBLESHOOTING.md](TROUBLESHOOTING.md) - Common issues and solutions
### 🏗️ Architecture & Design ### ⚡ Advanced Features
- [PLUGIN_ARCHITECTURE_SPEC.md](PLUGIN_ARCHITECTURE_SPEC.md) - Complete plugin system specification - [ADVANCED_FEATURES.md](ADVANCED_FEATURES.md) - Vegas scroll mode, on-demand display, cache management, background services, permissions
- [PLUGIN_IMPLEMENTATION_SUMMARY.md](PLUGIN_IMPLEMENTATION_SUMMARY.md) - Plugin system implementation details
- [FEATURE_IMPLEMENTATION_SUMMARY.md](FEATURE_IMPLEMENTATION_SUMMARY.md) - Major feature implementations
- [NESTED_CONFIG_SCHEMAS.md](NESTED_CONFIG_SCHEMAS.md) - Configuration schema design
- [NESTED_SCHEMA_IMPLEMENTATION.md](NESTED_SCHEMA_IMPLEMENTATION.md) - Schema implementation details
- [NESTED_SCHEMA_VISUAL_COMPARISON.md](NESTED_SCHEMA_VISUAL_COMPARISON.md) - Schema comparison visuals
### ⚙️ Configuration & Management
- [PLUGIN_CONFIGURATION_GUIDE.md](PLUGIN_CONFIGURATION_GUIDE.md) - Complete plugin configuration guide
- [PLUGIN_CONFIGURATION_TABS.md](PLUGIN_CONFIGURATION_TABS.md) - Configuration tabs feature
- [PLUGIN_CONFIG_QUICK_START.md](PLUGIN_CONFIG_QUICK_START.md) - Quick configuration guide
### 🔌 Plugin Development ### 🔌 Plugin Development
- [PLUGIN_DEVELOPMENT_GUIDE.md](PLUGIN_DEVELOPMENT_GUIDE.md) - Complete plugin development guide - [PLUGIN_DEVELOPMENT_GUIDE.md](PLUGIN_DEVELOPMENT_GUIDE.md) - Complete plugin development workflow
- [PLUGIN_QUICK_REFERENCE.md](PLUGIN_QUICK_REFERENCE.md) - Plugin development quick reference - [PLUGIN_QUICK_REFERENCE.md](PLUGIN_QUICK_REFERENCE.md) - Plugin development quick reference
- [PLUGIN_API_REFERENCE.md](PLUGIN_API_REFERENCE.md) - Complete API reference for plugin developers
- [ADVANCED_PLUGIN_DEVELOPMENT.md](ADVANCED_PLUGIN_DEVELOPMENT.md) - Advanced patterns and examples - [ADVANCED_PLUGIN_DEVELOPMENT.md](ADVANCED_PLUGIN_DEVELOPMENT.md) - Advanced patterns and examples
- [PLUGIN_REGISTRY_SETUP_GUIDE.md](PLUGIN_REGISTRY_SETUP_GUIDE.md) - Setting up plugin registry - [PLUGIN_CONFIGURATION_GUIDE.md](PLUGIN_CONFIGURATION_GUIDE.md) - Configuration schema design
- [PLUGIN_CONFIGURATION_TABS.md](PLUGIN_CONFIGURATION_TABS.md) - Configuration tabs feature
- [PLUGIN_CONFIG_QUICK_START.md](PLUGIN_CONFIG_QUICK_START.md) - Quick configuration guide
- [PLUGIN_DEPENDENCY_GUIDE.md](PLUGIN_DEPENDENCY_GUIDE.md) - Managing plugin dependencies - [PLUGIN_DEPENDENCY_GUIDE.md](PLUGIN_DEPENDENCY_GUIDE.md) - Managing plugin dependencies
- [PLUGIN_DEPENDENCY_TROUBLESHOOTING.md](PLUGIN_DEPENDENCY_TROUBLESHOOTING.md) - Dependency troubleshooting - [PLUGIN_DEPENDENCY_TROUBLESHOOTING.md](PLUGIN_DEPENDENCY_TROUBLESHOOTING.md) - Dependency troubleshooting
### 🎮 Plugin Features ### 🏗️ Plugin Features & Extensions
- [ON_DEMAND_DISPLAY_QUICK_START.md](ON_DEMAND_DISPLAY_QUICK_START.md) - Manual display triggering - [PLUGIN_CUSTOM_ICONS.md](PLUGIN_CUSTOM_ICONS.md) - Custom plugin icons
- [PLUGIN_LIVE_PRIORITY_QUICK_START.md](PLUGIN_LIVE_PRIORITY_QUICK_START.md) - Live content priority - [PLUGIN_CUSTOM_ICONS_FEATURE.md](PLUGIN_CUSTOM_ICONS_FEATURE.md) - Custom icons implementation
- [PLUGIN_LIVE_PRIORITY_API.md](PLUGIN_LIVE_PRIORITY_API.md) - Live priority API reference - [PLUGIN_IMPLEMENTATION_SUMMARY.md](PLUGIN_IMPLEMENTATION_SUMMARY.md) - Plugin system implementation
- [PLUGIN_CUSTOM_ICONS_FEATURE.md](PLUGIN_CUSTOM_ICONS_FEATURE.md) - Custom plugin icons - [PLUGIN_REGISTRY_SETUP_GUIDE.md](PLUGIN_REGISTRY_SETUP_GUIDE.md) - Setting up plugin registry
- [PLUGIN_DISPATCH_IMPLEMENTATION.md](PLUGIN_DISPATCH_IMPLEMENTATION.md) - Plugin dispatch system - [PLUGIN_WEB_UI_ACTIONS.md](PLUGIN_WEB_UI_ACTIONS.md) - Web UI actions for plugins
- [PLUGIN_TABS_FEATURE_COMPLETE.md](PLUGIN_TABS_FEATURE_COMPLETE.md) - Plugin tabs feature
### 📡 API Reference ### 📡 API Reference
- [API_REFERENCE.md](API_REFERENCE.md) - Complete REST API documentation for web interface - [REST_API_REFERENCE.md](REST_API_REFERENCE.md) - Complete REST API documentation (71+ endpoints)
- [PLUGIN_API_REFERENCE.md](PLUGIN_API_REFERENCE.md) - Plugin developer API reference (Display Manager, Cache Manager, Plugin Manager) - [PLUGIN_API_REFERENCE.md](PLUGIN_API_REFERENCE.md) - Plugin developer API (Display Manager, Cache Manager, Plugin Manager)
- [DEVELOPER_QUICK_REFERENCE.md](DEVELOPER_QUICK_REFERENCE.md) - Quick reference for common developer tasks - [DEVELOPER_QUICK_REFERENCE.md](DEVELOPER_QUICK_REFERENCE.md) - Quick reference for common developer tasks
- [ON_DEMAND_DISPLAY_API.md](ON_DEMAND_DISPLAY_API.md) - On-demand display API reference
### 🏛️ Architecture & Design
- [PLUGIN_ARCHITECTURE_SPEC.md](PLUGIN_ARCHITECTURE_SPEC.md) - Complete plugin system specification
- [PLUGIN_CONFIG_ARCHITECTURE.md](PLUGIN_CONFIG_ARCHITECTURE.md) - Configuration system architecture
- [PLUGIN_CONFIG_CORE_PROPERTIES.md](PLUGIN_CONFIG_CORE_PROPERTIES.md) - Core configuration properties
### 🛠️ Development & Tools ### 🛠️ Development & Tools
- [BACKGROUND_SERVICE_README.md](BACKGROUND_SERVICE_README.md) - Background service architecture - [DEVELOPMENT.md](DEVELOPMENT.md) - Development environment setup
- [FONT_MANAGER_USAGE.md](FONT_MANAGER_USAGE.md) - Font management system - [EMULATOR_SETUP_GUIDE.md](EMULATOR_SETUP_GUIDE.md) - Set up development environment with emulator
- [HOW_TO_RUN_TESTS.md](HOW_TO_RUN_TESTS.md) - Testing documentation
- [MULTI_ROOT_WORKSPACE_SETUP.md](MULTI_ROOT_WORKSPACE_SETUP.md) - Multi-workspace development
- [FONT_MANAGER.md](FONT_MANAGER.md) - Font management system
### 🔍 Analysis & Compatibility ### 🔄 Migration & Updates
- [RASPBIAN_TRIXIE_COMPATIBILITY_ANALYSIS.md](RASPBIAN_TRIXIE_COMPATIBILITY_ANALYSIS.md) - Detailed Trixie compatibility analysis - [MIGRATION_GUIDE.md](MIGRATION_GUIDE.md) - Breaking changes and migration instructions
- [CONFIGURATION_CLEANUP_SUMMARY.md](CONFIGURATION_CLEANUP_SUMMARY.md) - Configuration cleanup details - [SSH_UNAVAILABLE_AFTER_INSTALL.md](SSH_UNAVAILABLE_AFTER_INSTALL.md) - SSH troubleshooting after install
- [football_plugin_comparison.md](football_plugin_comparison.md) - Football plugin analysis
### 📊 Utility & Scripts ### 📚 Miscellaneous
- [README_broadcast_logo_analyzer.md](README_broadcast_logo_analyzer.md) - Broadcast logo analysis tool - [widget-guide.md](widget-guide.md) - Widget development guide
- [README_soccer_logos.md](README_soccer_logos.md) - Soccer logo management - Template files:
- [WEB_INTERFACE_TROUBLESHOOTING.md](WEB_INTERFACE_TROUBLESHOOTING.md) - Web interface troubleshooting - [plugin_registry_template.json](plugin_registry_template.json) - Plugin registry template
- [PLUGIN_WEB_UI_ACTIONS_EXAMPLE.json](PLUGIN_WEB_UI_ACTIONS_EXAMPLE.json) - Web UI actions example
## 🔄 Migration & Updates
### Recent Consolidations (October 2025)
- **Implementation Summaries**: Consolidated 7 separate implementation summaries into 2 comprehensive guides:
- `FEATURE_IMPLEMENTATION_SUMMARY.md` (AP Top 25, Plugin System, Configuration, Web Interface, Trixie Compatibility)
- `PLUGIN_IMPLEMENTATION_SUMMARY.md` (Plugin system technical details)
- **Trixie Documentation**: Merged 4 Trixie-related documents into `TRIXIE_UPGRADE_GUIDE.md`
- **Removed Redundancy**: Eliminated duplicate documents and outdated debug guides
- **Total Reduction**: 53 → 39 documents (26% reduction)
### Migration Notes
- Old implementation summary documents have been consolidated
- Trixie upgrade information is now centralized in one guide
- Deprecated manager documentation has been removed (no longer applicable)
- Very specific debug documents have been archived or removed
## 🎯 Key Resources by Use Case ## 🎯 Key Resources by Use Case
### I'm new to LEDMatrix ### I'm new to LEDMatrix
1. [Main README](../README.md) - Installation and setup 1. [GETTING_STARTED.md](GETTING_STARTED.md) - Start here for first-time setup
2. [EMULATOR_SETUP_GUIDE.md](EMULATOR_SETUP_GUIDE.md) - Development environment 2. [WEB_INTERFACE_GUIDE.md](WEB_INTERFACE_GUIDE.md) - Learn the control panel
3. [PLUGIN_QUICK_REFERENCE.md](PLUGIN_QUICK_REFERENCE.md) - Understanding the system 3. [PLUGIN_STORE_GUIDE.md](PLUGIN_STORE_GUIDE.md) - Install plugins
### I want to create a plugin ### I want to create a plugin
1. [PLUGIN_DEVELOPMENT_GUIDE.md](PLUGIN_DEVELOPMENT_GUIDE.md) - Complete development guide 1. [PLUGIN_DEVELOPMENT_GUIDE.md](PLUGIN_DEVELOPMENT_GUIDE.md) - Complete development guide
2. [PLUGIN_API_REFERENCE.md](PLUGIN_API_REFERENCE.md) - Available methods and APIs 2. [PLUGIN_API_REFERENCE.md](PLUGIN_API_REFERENCE.md) - Available methods and APIs
3. [ADVANCED_PLUGIN_DEVELOPMENT.md](ADVANCED_PLUGIN_DEVELOPMENT.md) - Advanced patterns and examples 3. [ADVANCED_PLUGIN_DEVELOPMENT.md](ADVANCED_PLUGIN_DEVELOPMENT.md) - Advanced patterns
4. [PLUGIN_CONFIGURATION_GUIDE.md](PLUGIN_CONFIGURATION_GUIDE.md) - Configuration setup 4. [PLUGIN_CONFIGURATION_GUIDE.md](PLUGIN_CONFIGURATION_GUIDE.md) - Configuration setup
5. [PLUGIN_ARCHITECTURE_SPEC.md](PLUGIN_ARCHITECTURE_SPEC.md) - Complete specification 5. [PLUGIN_ARCHITECTURE_SPEC.md](PLUGIN_ARCHITECTURE_SPEC.md) - Complete specification
### I want to upgrade to Trixie
1. [TRIXIE_UPGRADE_GUIDE.md](TRIXIE_UPGRADE_GUIDE.md) - Complete upgrade guide
2. [RASPBIAN_TRIXIE_COMPATIBILITY_ANALYSIS.md](RASPBIAN_TRIXIE_COMPATIBILITY_ANALYSIS.md) - Technical details
### I need to troubleshoot an issue ### I need to troubleshoot an issue
1. [TROUBLESHOOTING_QUICK_START.md](TROUBLESHOOTING_QUICK_START.md) - Common issues 1. [TROUBLESHOOTING.md](TROUBLESHOOTING.md) - Comprehensive troubleshooting guide
2. [WEB_INTERFACE_TROUBLESHOOTING.md](WEB_INTERFACE_TROUBLESHOOTING.md) - Web interface problems 2. [WIFI_NETWORK_SETUP.md](WIFI_NETWORK_SETUP.md) - WiFi/network issues
3. [PLUGIN_DEPENDENCY_TROUBLESHOOTING.md](PLUGIN_DEPENDENCY_TROUBLESHOOTING.md) - Dependency issues 3. [PLUGIN_DEPENDENCY_TROUBLESHOOTING.md](PLUGIN_DEPENDENCY_TROUBLESHOOTING.md) - Dependency issues
### I want to use advanced features
1. [ADVANCED_FEATURES.md](ADVANCED_FEATURES.md) - Vegas scroll, on-demand display, background services
2. [FONT_MANAGER.md](FONT_MANAGER.md) - Font management
3. [REST_API_REFERENCE.md](REST_API_REFERENCE.md) - API integration
### I want to understand the architecture ### I want to understand the architecture
1. [PLUGIN_ARCHITECTURE_SPEC.md](PLUGIN_ARCHITECTURE_SPEC.md) - System architecture 1. [PLUGIN_ARCHITECTURE_SPEC.md](PLUGIN_ARCHITECTURE_SPEC.md) - System architecture
2. [FEATURE_IMPLEMENTATION_SUMMARY.md](FEATURE_IMPLEMENTATION_SUMMARY.md) - Feature overview 2. [PLUGIN_CONFIG_ARCHITECTURE.md](PLUGIN_CONFIG_ARCHITECTURE.md) - Configuration architecture
3. [PLUGIN_IMPLEMENTATION_SUMMARY.md](PLUGIN_IMPLEMENTATION_SUMMARY.md) - Implementation details 3. [PLUGIN_IMPLEMENTATION_SUMMARY.md](PLUGIN_IMPLEMENTATION_SUMMARY.md) - Implementation details
## 🔄 Recent Consolidations (January 2026)
### Major Consolidation Effort
- **Before**: 51 main documentation files
- **After**: 16-17 well-organized files
- **Reduction**: ~68% fewer files
- **Archived**: 33 files (consolidated sources + ephemeral docs)
### New Consolidated Guides
- **GETTING_STARTED.md** - New first-time user guide
- **WEB_INTERFACE_GUIDE.md** - Consolidated web interface documentation
- **WIFI_NETWORK_SETUP.md** - Consolidated WiFi setup (5 files → 1)
- **PLUGIN_STORE_GUIDE.md** - Consolidated plugin store guides (2 files → 1)
- **TROUBLESHOOTING.md** - Consolidated troubleshooting (4 files → 1)
- **ADVANCED_FEATURES.md** - Consolidated advanced features (6 files → 1)
### What Was Archived
- Ephemeral debug documents (DEBUG_WEB_ISSUE.md, BROWSER_ERRORS_EXPLANATION.md, etc.)
- Implementation summaries (PLUGIN_CONFIG_TABS_SUMMARY.md, STARTUP_OPTIMIZATION_SUMMARY.md, etc.)
- Consolidated source files (WIFI_SETUP.md, V3_INTERFACE_README.md, etc.)
- Testing documentation (CAPTIVE_PORTAL_TESTING.md, etc.)
All archived files are preserved in `docs/archive/` with full git history.
### Benefits
- ✅ Easier to find information (fewer files to search)
- ✅ No duplicate content
- ✅ Consistent writing style (professional technical)
- ✅ Updated outdated references
- ✅ Fixed broken internal links
- ✅ Better organization for users vs developers
## 📝 Contributing to Documentation ## 📝 Contributing to Documentation
### Documentation Standards ### Documentation Standards
- Use Markdown format with consistent headers - Use Markdown format with consistent headers
- Professional technical writing style
- Minimal emojis (1-2 per major section for navigation)
- Include code examples where helpful - Include code examples where helpful
- Provide both quick start and detailed reference sections - Provide both quick start and detailed reference sections
- Keep implementation summaries focused on what was built, not how to use - Cross-reference related documentation
### Adding New Documentation ### Adding New Documentation
1. Place in appropriate category (see sections above) 1. Consider if content should be added to existing docs first
2. Update this README.md with the new document 2. Place in appropriate category (see sections above)
3. Follow naming conventions (FEATURE_NAME.md) 3. Update this README.md with the new document
4. Consider if content should be consolidated with existing docs 4. Follow naming conventions (FEATURE_NAME.md)
5. Use consistent formatting and voice
### Consolidation Guidelines ### Consolidation Guidelines
- **Implementation Summaries**: Consolidate into feature-specific summaries - **User Guides**: Consolidate by topic (WiFi, troubleshooting, etc.)
- **Quick References**: Keep if they provide unique value, otherwise merge - **Developer Guides**: Keep development vs reference vs architecture separate
- **Debug Documents**: Remove after issues are resolved - **Debug Documents**: Archive after issues are resolved
- **Migration Guides**: Consolidate when migrations are complete - **Implementation Summaries**: Archive completed implementation details
- **Ephemeral Content**: Archive, don't keep in main docs
## 🔗 Related Documentation ## 🔗 Related Documentation
- [Main Project README](../README.md) - Installation and basic usage - [Main Project README](../README.md) - Installation and basic usage
- [Web Interface README](../web_interface/README.md) - Web interface details - [Web Interface README](../web_interface/README.md) - Web interface details
- [LEDMatrix Wiki](../LEDMatrix.wiki/) - Extended documentation and guides
- [GitHub Issues](https://github.com/ChuckBuilds/LEDMatrix/issues) - Bug reports and feature requests - [GitHub Issues](https://github.com/ChuckBuilds/LEDMatrix/issues) - Bug reports and feature requests
- [GitHub Discussions](https://github.com/ChuckBuilds/LEDMatrix/discussions) - Community support - [GitHub Discussions](https://github.com/ChuckBuilds/LEDMatrix/discussions) - Community support
## 📊 Documentation Statistics ## 📊 Documentation Statistics
- **Total Documents**: ~35 (after consolidation) - **Main Documents**: 16-17 files (after consolidation)
- **Categories**: 8 major sections (including new API Reference section) - **Archived Documents**: 33 files (in docs/archive/)
- **Primary Languages**: English - **Categories**: 9 major sections
- **Primary Language**: English
- **Format**: Markdown (.md) - **Format**: Markdown (.md)
- **Last Update**: December 2025 - **Last Major Update**: January 2026
- **Coverage**: Installation, development, troubleshooting, architecture, API references - **Coverage**: Installation, user guides, development, troubleshooting, architecture, API references
### Recent Improvements (December 2025) ### Documentation Highlights
- ✅ Complete REST API documentation (50+ endpoints) - ✅ Comprehensive user guides for first-time setup
- ✅ Complete REST API documentation (71+ endpoints)
- ✅ Complete Plugin API reference (Display Manager, Cache Manager, Plugin Manager) - ✅ Complete Plugin API reference (Display Manager, Cache Manager, Plugin Manager)
- ✅ Advanced plugin development guide with examples - ✅ Advanced plugin development guide with examples
- ✅ Consolidated plugin configuration documentation - ✅ Consolidated configuration documentation
-Developer quick reference guide -Professional technical writing throughout
-Better organization for end users and developers -~68% reduction in file count while maintaining coverage
--- ---
*This documentation index was last updated: December 2025* *This documentation index was last updated: January 2026*
*For questions or suggestions about the documentation, please open an issue or start a discussion on GitHub.* *For questions or suggestions about the documentation, please open an issue or start a discussion on GitHub.*

915
docs/TROUBLESHOOTING.md Normal file
View File

@@ -0,0 +1,915 @@
# Troubleshooting Guide
## Quick Diagnosis Steps
Run these checks first to quickly identify common issues:
### 1. Check Service Status
```bash
# Check all LEDMatrix services
sudo systemctl status ledmatrix
sudo systemctl status ledmatrix-web
sudo systemctl status ledmatrix-wifi-monitor
# Check AP mode services (if using WiFi)
sudo systemctl status hostapd
sudo systemctl status dnsmasq
```
**Note:** Look for `active (running)` status and check for error messages in the output.
### 2. View Service Logs
**IMPORTANT:** The web service logs to **syslog**, NOT stdout. Use `journalctl` to view logs:
```bash
# View all recent logs
sudo journalctl -u ledmatrix -n 50
sudo journalctl -u ledmatrix-web -n 50
# Follow logs in real-time
sudo journalctl -u ledmatrix -f
# View logs from last hour
sudo journalctl -u ledmatrix-web --since "1 hour ago"
# Filter for errors only
sudo journalctl -u ledmatrix -p err
```
### 3. Run Diagnostic Scripts
```bash
# Web interface diagnostics
bash scripts/diagnose_web_interface.sh
# WiFi setup verification
./scripts/verify_wifi_setup.sh
# Weather plugin troubleshooting
./troubleshoot_weather.sh
# Captive portal troubleshooting
./scripts/troubleshoot_captive_portal.sh
```
### 4. Check Configuration
```bash
# Verify web interface autostart
cat config/config.json | grep web_display_autostart
# Check plugin enabled status
cat config/config.json | grep -A 2 "plugin-id"
# Verify API keys present
ls -l config/config_secrets.json
```
### 5. Test Manual Startup
```bash
# Test web interface manually
python3 web_interface/start.py
# If it works manually but not as a service, check systemd service file
```
---
## Common Issues by Category
### Web Interface & Service Issues
#### Service Not Running/Starting
**Symptoms:**
- Cannot access web interface at http://your-pi-ip:5050
- `systemctl status ledmatrix-web` shows `inactive (dead)`
**Solutions:**
1. **Start the service:**
```bash
sudo systemctl start ledmatrix-web
```
2. **Enable on boot:**
```bash
sudo systemctl enable ledmatrix-web
```
3. **Check why it failed:**
```bash
sudo journalctl -u ledmatrix-web -n 50
```
#### web_display_autostart is False
**Symptoms:**
- Service exists but web interface doesn't start automatically
- Logs show service starting but nothing happens
**Solution:**
```bash
# Edit config.json
nano config/config.json
# Set web_display_autostart to true
{
"web_display_autostart": true,
...
}
# Restart service
sudo systemctl restart ledmatrix-web
```
#### Import or Dependency Errors
**Symptoms:**
- Logs show `ModuleNotFoundError` or `ImportError`
- Service fails to start with Python errors
**Solutions:**
1. **Install dependencies:**
```bash
pip3 install --break-system-packages -r requirements.txt
pip3 install --break-system-packages -r web_interface/requirements.txt
```
2. **Test imports step-by-step:**
```bash
python3 -c "from src.config_manager import ConfigManager; print('OK')"
python3 -c "from src.plugin_system.plugin_manager import PluginManager; print('OK')"
python3 -c "from web_interface.app import app; print('OK')"
```
3. **Check Python path:**
```bash
python3 -c "import sys; print(sys.path)"
```
#### Port Already in Use
**Symptoms:**
- Error: `Address already in use`
- Service fails to bind to port 5050
**Solutions:**
1. **Check what's using the port:**
```bash
sudo lsof -i :5050
```
2. **Kill the conflicting process:**
```bash
sudo kill -9 <PID>
```
3. **Or change the port in start.py:**
```python
app.run(host='0.0.0.0', port=5051)
```
#### Permission Issues
**Symptoms:**
- `Permission denied` errors in logs
- Cannot read/write configuration files
**Solutions:**
```bash
# Fix ownership of LEDMatrix directory
sudo chown -R ledpi:ledpi /home/ledpi/LEDMatrix
# Fix config file permissions
sudo chmod 644 config/config.json
sudo chmod 640 config/config_secrets.json
# Verify service runs as correct user
sudo systemctl cat ledmatrix-web | grep User
```
#### Flask/Blueprint Import Errors
**Symptoms:**
- `ImportError: cannot import name 'app'`
- `ModuleNotFoundError: No module named 'blueprints'`
**Solutions:**
1. **Verify file structure:**
```bash
ls -l web_interface/app.py
ls -l web_interface/blueprints/api_v3.py
ls -l web_interface/blueprints/pages_v3.py
```
2. **Check for __init__.py files:**
```bash
ls -l web_interface/__init__.py
ls -l web_interface/blueprints/__init__.py
```
3. **Test import manually:**
```bash
cd /home/ledpi/LEDMatrix
python3 -c "from web_interface.app import app"
```
---
### WiFi & AP Mode Issues
#### AP Mode Not Activating
**Symptoms:**
- WiFi disconnected but AP mode doesn't start
- Cannot find "LEDMatrix-Setup" network
**Solutions:**
1. **Check auto-enable setting:**
```bash
cat config/wifi_config.json | grep auto_enable_ap_mode
# Should show: "auto_enable_ap_mode": true
```
2. **Verify WiFi monitor service is running:**
```bash
sudo systemctl status ledmatrix-wifi-monitor
```
3. **Wait for grace period (90 seconds):**
- AP mode requires 3 consecutive disconnected checks at 30-second intervals
- Total wait time: 90 seconds after WiFi disconnects
4. **Check if Ethernet is connected:**
```bash
nmcli device status
# If Ethernet is connected, AP mode won't activate
```
5. **Check required services:**
```bash
sudo systemctl status hostapd
sudo systemctl status dnsmasq
```
6. **Manually enable AP mode:**
```bash
# Via API
curl -X POST http://localhost:5050/api/wifi/ap/enable
# Via Python
python3 -c "
from src.wifi_manager import WiFiManager
wm = WiFiManager()
wm.enable_ap_mode()
"
```
#### Cannot Connect to AP Mode / Connection Refused
**Symptoms:**
- Can see "LEDMatrix-Setup" network but can't connect to web interface
- Browser shows "Connection Refused" or "Can't connect to server"
- AP mode active but web interface not accessible
**Solutions:**
1. **Verify web server is running:**
```bash
sudo systemctl status ledmatrix-web
# Should be active (running)
```
2. **Use correct IP address and port:**
- Correct: `http://192.168.4.1:5050`
- NOT: `http://192.168.4.1` (port 80)
- NOT: `http://192.168.4.1:5000`
3. **Check wlan0 has correct IP:**
```bash
ip addr show wlan0
# Should show: inet 192.168.4.1/24
```
4. **Verify hostapd and dnsmasq are running:**
```bash
sudo systemctl status hostapd
sudo systemctl status dnsmasq
```
5. **Test from the Pi itself:**
```bash
curl http://192.168.4.1:5050
# Should return HTML
```
#### DNS Resolution Failures
**Symptoms:**
- Captive portal doesn't redirect automatically
- DNS lookups fail when connected to AP mode
**Solutions:**
1. **Check dnsmasq status:**
```bash
sudo systemctl status dnsmasq
sudo journalctl -u dnsmasq -n 20
```
2. **Verify DNS configuration:**
```bash
cat /etc/dnsmasq.conf | grep -v "^#" | grep -v "^$"
```
3. **Test DNS resolution:**
```bash
nslookup captive.apple.com
# Should resolve to 192.168.4.1 when in AP mode
```
4. **Manual captive portal testing:**
- Try these URLs manually:
- `http://192.168.4.1:5050`
- `http://captive.apple.com`
- `http://connectivitycheck.gstatic.com/generate_204`
#### Firewall Blocking Port 5050
**Symptoms:**
- Services running but cannot connect
- Works from Pi but not from other devices
**Solutions:**
1. **Check UFW status:**
```bash
sudo ufw status
```
2. **Allow port 5050:**
```bash
sudo ufw allow 5050/tcp
```
3. **Check iptables:**
```bash
sudo iptables -L -n
```
4. **Temporarily disable firewall to test:**
```bash
sudo ufw disable
# Test if it works, then re-enable and add rule
sudo ufw enable
sudo ufw allow 5050/tcp
```
---
### Plugin Issues
#### Plugin Not Enabled
**Symptoms:**
- Plugin installed but doesn't appear in rotation
- Plugin shows in web interface but is greyed out
**Solutions:**
1. **Enable in configuration:**
```json
{
"plugin-id": {
"enabled": true,
...
}
}
```
2. **Restart display:**
```bash
sudo systemctl restart ledmatrix
```
3. **Verify in web interface:**
- Navigate to Plugin Management tab
- Toggle the switch to enable
- Restart display
#### Plugin Not Loading
**Symptoms:**
- Plugin enabled but not showing
- Errors in logs about plugin
**Solutions:**
1. **Check plugin directory exists:**
```bash
ls -ld plugins/plugin-id/
```
2. **Verify manifest.json:**
```bash
cat plugins/plugin-id/manifest.json
# Verify all required fields present
```
3. **Check dependencies installed:**
```bash
if [ -f plugins/plugin-id/requirements.txt ]; then
pip3 install --break-system-packages -r plugins/plugin-id/requirements.txt
fi
```
4. **Check logs for plugin errors:**
```bash
sudo journalctl -u ledmatrix -f | grep plugin-id
```
5. **Test plugin import:**
```bash
python3 -c "
import sys
sys.path.insert(0, 'plugins/plugin-id')
from manager import PluginClass
print('Plugin imports successfully')
"
```
#### Stale Cache Data
**Symptoms:**
- Plugin shows old data
- Data doesn't update even after restarting
- Clearing cache in web interface doesn't help
**Solutions:**
1. **Manual cache clearing:**
```bash
# Remove plugin-specific cache
rm -rf cache/plugin-id*
# Or remove all cache
rm -rf cache/*
# Restart display
sudo systemctl restart ledmatrix
```
2. **Check cache permissions:**
```bash
ls -ld cache/
sudo chown -R ledpi:ledpi cache/
```
---
### Weather Plugin Specific Issues
#### Missing or Invalid API Key
**Symptoms:**
- "No Weather Data" message on display
- Logs show API authentication errors
**Solutions:**
1. **Get OpenWeatherMap API key:**
- Sign up at https://openweathermap.org/api
- Free tier: 1,000 calls/day, 60 calls/minute
- Copy your API key
2. **Add to config_secrets.json (recommended):**
```json
{
"openweathermap_api_key": "your-api-key-here"
}
```
3. **Or add to config.json:**
```json
{
"ledmatrix-weather": {
"enabled": true,
"openweathermap_api_key": "your-api-key-here",
...
}
}
```
4. **Secure the API key file:**
```bash
chmod 640 config/config_secrets.json
```
5. **Restart display:**
```bash
sudo systemctl restart ledmatrix
```
#### API Rate Limits Exceeded
**Symptoms:**
- Weather works initially then stops
- Logs show HTTP 429 errors (Too Many Requests)
- Error message: "Rate limit exceeded"
**Solutions:**
1. **Increase update interval:**
```json
{
"ledmatrix-weather": {
"update_interval": 300,
...
}
}
```
**Note:** Minimum recommended: 300 seconds (5 minutes)
2. **Check current rate limit usage:**
- OpenWeatherMap free tier: 1,000 calls/day, 60 calls/minute
- With 300s interval: 288 calls/day (well within limits)
3. **Monitor API calls:**
```bash
sudo journalctl -u ledmatrix -f | grep "openweathermap"
```
#### Invalid Location Configuration
**Symptoms:**
- "No Weather Data" message
- Logs show location not found errors
**Solutions:**
1. **Use correct location format:**
```json
{
"ledmatrix-weather": {
"city": "Tampa",
"state": "FL",
"country": "US"
}
}
```
2. **Use ISO country codes:**
- US = United States
- GB = United Kingdom
- CA = Canada
- etc.
3. **Test API call manually:**
```bash
API_KEY="your-key-here"
curl "http://api.openweathermap.org/data/2.5/weather?q=Tampa,FL,US&appid=${API_KEY}"
```
#### Network Connectivity to OpenWeatherMap
**Symptoms:**
- Other internet features work
- Weather specifically fails
- Connection timeout errors
**Solutions:**
1. **Test connectivity:**
```bash
ping api.openweathermap.org
```
2. **Test DNS resolution:**
```bash
nslookup api.openweathermap.org
```
3. **Test API endpoint:**
```bash
curl -I https://api.openweathermap.org
# Should return HTTP 200 or 301
```
4. **Check firewall:**
```bash
# Ensure HTTPS (443) is allowed for outbound connections
sudo ufw status
```
---
## Diagnostic Commands Reference
### Service Commands
```bash
# Check status
sudo systemctl status ledmatrix
sudo systemctl status ledmatrix-web
sudo systemctl status ledmatrix-wifi-monitor
# Start service
sudo systemctl start <service-name>
# Stop service
sudo systemctl stop <service-name>
# Restart service
sudo systemctl restart <service-name>
# Enable on boot
sudo systemctl enable <service-name>
# Disable on boot
sudo systemctl disable <service-name>
# View service file
sudo systemctl cat <service-name>
# Reload systemd after editing service files
sudo systemctl daemon-reload
```
### Log Viewing Commands
```bash
# View recent logs (last 50 lines)
sudo journalctl -u ledmatrix -n 50
# Follow logs in real-time
sudo journalctl -u ledmatrix -f
# View logs from specific time
sudo journalctl -u ledmatrix --since "1 hour ago"
sudo journalctl -u ledmatrix --since "2024-01-01 10:00:00"
# View logs until specific time
sudo journalctl -u ledmatrix --until "2024-01-01 12:00:00"
# Filter by priority (errors only)
sudo journalctl -u ledmatrix -p err
# Filter by priority (warnings and errors)
sudo journalctl -u ledmatrix -p warning
# Search logs for specific text
sudo journalctl -u ledmatrix | grep "error"
sudo journalctl -u ledmatrix | grep -i "plugin"
# View logs for multiple services
sudo journalctl -u ledmatrix -u ledmatrix-web -n 50
# Export logs to file
sudo journalctl -u ledmatrix > ledmatrix.log
```
### Network Testing Commands
```bash
# Test connectivity
ping -c 4 8.8.8.8
ping -c 4 api.openweathermap.org
# Test DNS resolution
nslookup api.openweathermap.org
dig api.openweathermap.org
# Test HTTP endpoint
curl -I http://your-pi-ip:5050
curl http://192.168.4.1:5050
# Check listening ports
sudo lsof -i :5050
sudo netstat -tuln | grep 5050
# Check network interfaces
ip addr show
nmcli device status
```
### File/Directory Verification
```bash
# Check file exists
ls -l config/config.json
ls -l plugins/plugin-id/manifest.json
# Check directory structure
ls -la web_interface/
ls -la plugins/
# Check file permissions
ls -l config/config_secrets.json
# Check file contents
cat config/config.json | jq .
cat config/wifi_config.json | grep auto_enable
```
### Python Import Testing
```bash
# Test core imports
python3 -c "from src.config_manager import ConfigManager; print('OK')"
python3 -c "from src.plugin_system.plugin_manager import PluginManager; print('OK')"
python3 -c "from src.display_manager import DisplayManager; print('OK')"
# Test web interface imports
python3 -c "from web_interface.app import app; print('OK')"
python3 -c "from web_interface.blueprints.api_v3 import api_v3; print('OK')"
# Test WiFi manager
python3 -c "from src.wifi_manager import WiFiManager; print('OK')"
# Test plugin import
python3 -c "
import sys
sys.path.insert(0, 'plugins/plugin-id')
from manager import PluginClass
print('Plugin imports OK')
"
```
---
## Service File Template
If your systemd service file is corrupted or missing, use this template:
```ini
[Unit]
Description=LEDMatrix Web Interface
After=network.target
[Service]
Type=simple
User=ledpi
Group=ledpi
WorkingDirectory=/home/ledpi/LEDMatrix
Environment="PYTHONUNBUFFERED=1"
ExecStart=/usr/bin/python3 /home/ledpi/LEDMatrix/web_interface/start.py
Restart=on-failure
RestartSec=5s
StandardOutput=journal
StandardError=journal
SyslogIdentifier=ledmatrix-web
[Install]
WantedBy=multi-user.target
```
Save to `/etc/systemd/system/ledmatrix-web.service` and run:
```bash
sudo systemctl daemon-reload
sudo systemctl enable ledmatrix-web
sudo systemctl start ledmatrix-web
```
---
## Complete Diagnostic Script
Run this script for comprehensive diagnostics:
```bash
#!/bin/bash
echo "=== LEDMatrix Diagnostic Report ==="
echo ""
echo "1. Service Status:"
systemctl status ledmatrix --no-pager -n 5
systemctl status ledmatrix-web --no-pager -n 5
echo ""
echo "2. Recent Logs:"
journalctl -u ledmatrix -n 20 --no-pager
echo ""
echo "3. Configuration:"
cat config/config.json | grep -E "(web_display_autostart|enabled)"
echo ""
echo "4. Network Status:"
ip addr show | grep -E "(wlan|eth|inet )"
curl -s http://localhost:5050 > /dev/null && echo "Web interface: OK" || echo "Web interface: FAILED"
echo ""
echo "5. File Structure:"
ls -la web_interface/ | head -10
ls -la plugins/ | head -10
echo ""
echo "6. Python Imports:"
python3 -c "from src.config_manager import ConfigManager" && echo "ConfigManager: OK" || echo "ConfigManager: FAILED"
python3 -c "from web_interface.app import app" && echo "Web app: OK" || echo "Web app: FAILED"
echo ""
echo "=== End Diagnostic Report ==="
```
---
## Success Indicators
A properly functioning system should show:
1. **Services Running:**
```
● ledmatrix.service - active (running)
● ledmatrix-web.service - active (running)
```
2. **Web Interface Accessible:**
- Navigate to http://your-pi-ip:5050
- Page loads successfully
- Display preview visible
3. **Logs Show Normal Operation:**
```
INFO: Web interface started on port 5050
INFO: Loaded X plugins
INFO: Display rotation active
```
4. **Process Listening on Port:**
```bash
$ sudo lsof -i :5050
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
python3 1234 ledpi 3u IPv4 12345 0t0 TCP *:5050 (LISTEN)
```
5. **Plugins Loading:**
- Logs show plugin initialization
- Plugins appear in web interface
- Display cycles through enabled plugins
---
## Emergency Recovery
If the system is completely broken:
### 1. Git Rollback
```bash
# View recent commits
git log --oneline -10
# Rollback to previous commit
git reset --hard HEAD~1
# Or rollback to specific commit
git reset --hard <commit-hash>
# Restart all services
sudo systemctl restart ledmatrix
sudo systemctl restart ledmatrix-web
```
### 2. Fresh Service Installation
```bash
# Reinstall WiFi monitor
sudo ./scripts/install/install_wifi_monitor.sh
# Recreate service files from templates
sudo cp templates/ledmatrix.service /etc/systemd/system/
sudo cp templates/ledmatrix-web.service /etc/systemd/system/
# Reload and restart
sudo systemctl daemon-reload
sudo systemctl restart ledmatrix ledmatrix-web
```
### 3. Full System Reboot
```bash
# As a last resort
sudo reboot
```
---
## Related Documentation
- [WEB_INTERFACE_GUIDE.md](WEB_INTERFACE_GUIDE.md) - Web interface usage
- [WIFI_NETWORK_SETUP.md](WIFI_NETWORK_SETUP.md) - WiFi configuration
- [PLUGIN_STORE_GUIDE.md](PLUGIN_STORE_GUIDE.md) - Plugin installation
- [REST_API_REFERENCE.md](REST_API_REFERENCE.md) - API documentation

442
docs/WEB_INTERFACE_GUIDE.md Normal file
View File

@@ -0,0 +1,442 @@
# Web Interface Guide
## Overview
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:5050
```
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 tab-based layout for easy navigation between features:
- **Overview** - System stats, quick actions, and display preview
- **General Settings** - Timezone, location, and autostart configuration
- **Display Settings** - Hardware configuration, brightness, and display options
- **Durations** - Display rotation timing configuration
- **Sports Configuration** - Per-league settings and on-demand modes
- **Plugin Management** - Install, configure, enable/disable plugins
- **Plugin Store** - Discover and install plugins
- **Font Management** - Upload fonts, manage overrides, and preview
- **Logs** - Real-time log streaming with filtering and search
---
## Features and Usage
### Overview Tab
The Overview tab provides at-a-glance information and quick actions:
**System Stats:**
- CPU usage and temperature
- Memory usage
- Disk usage
- Network status
**Quick Actions:**
- **Start/Stop Display** - Control the display service
- **Restart Display** - Restart to apply configuration changes
- **Test Display** - Run a quick test pattern
**Display Preview:**
- Live preview of what's currently shown on the LED matrix
- Updates in real-time
- Useful for remote monitoring
### General Settings Tab
Configure basic system settings:
**Timezone:**
- Set your local timezone for accurate time display
- Auto-detects common timezones
**Location:**
- Set latitude/longitude for location-based features
- Used by weather plugins and sunrise/sunset calculations
**Autostart:**
- Enable/disable display autostart on boot
- Configure systemd service settings
**Save Changes:**
- Click "Save Configuration" to apply changes
- Restart the display for changes to take effect
### Display Settings Tab
Configure your LED matrix hardware:
**Matrix Configuration:**
- Rows: Number of LED rows (typically 32 or 64)
- Columns: Number of LED columns (typically 64, 128, or 256)
- Chain Length: Number of chained panels
- Parallel Chains: Number of parallel chains
**Display Options:**
- Brightness: Adjust LED brightness (0-100%)
- Hardware Mapping: GPIO pin mapping
- Slowdown GPIO: Timing adjustment for compatibility
**Save and Apply:**
- Changes require a display restart
- Use "Test Display" to verify configuration
### Durations Tab
Control how long each plugin displays:
**Global Settings:**
- Default Duration: Default time for plugins without specific durations
- Transition Speed: Speed of transitions between plugins
**Per-Plugin Durations:**
- Set custom display duration for each plugin
- Override global default for specific plugins
- Measured in seconds
### Sports Configuration Tab
Configure sports-specific settings:
**Per-League Settings:**
- Favorite teams
- Show favorite teams only
- Include scores/standings
- Refresh intervals
**On-Demand Modes:**
- Live Priority: Show live games immediately
- Game Day Mode: Enhanced display during game days
- Score Alerts: Highlight score changes
### Plugin Management Tab
Manage installed plugins:
**Plugin List:**
- View all installed plugins
- See plugin status (enabled/disabled)
- Check last update time
**Actions:**
- **Enable/Disable**: Toggle plugin using the switch
- **Configure**: Click ⚙️ to edit plugin settings
- **Update**: Update plugin to latest version
- **Uninstall**: Remove plugin completely
**Configuration:**
- Edit plugin-specific settings
- Changes are saved to `config/config.json`
- Restart display to apply changes
**Note:** See [PLUGIN_STORE_GUIDE.md](PLUGIN_STORE_GUIDE.md) for information on installing plugins.
### Plugin Store Tab
Discover and install new plugins:
**Browse Plugins:**
- View available plugins in the official store
- Filter by category (sports, weather, time, finance, etc.)
- Search by name, description, or author
**Install Plugins:**
- Click "Install" next to any plugin
- Wait for installation to complete
- Restart the display to activate
**Install from URL:**
- Install plugins from any GitHub repository
- Paste the repository URL in the "Install from URL" section
- Review the warning about unverified plugins
- Click "Install from URL"
**Plugin Information:**
- View plugin descriptions, ratings, and screenshots
- Check compatibility and requirements
- Read user reviews (when available)
### Font Management Tab
Manage fonts for your display:
**Upload Fonts:**
- Drag and drop font files (.ttf, .otf, .bdf)
- Upload multiple files at once
- Progress indicator shows upload status
**Font Catalog:**
- View all available fonts
- See font previews
- Check font sizes and styles
**Plugin Font Overrides:**
- Set custom fonts for specific plugins
- Override default font choices
- Preview font changes
**Delete Fonts:**
- Remove unused fonts
- Free up disk space
### Logs Tab
View real-time system logs:
**Log Viewer:**
- Streaming logs from the display service
- Auto-scroll to latest entries
- Timestamps for each log entry
**Filtering:**
- Filter by log level (INFO, WARNING, ERROR)
- Search for specific text
- Filter by plugin or component
**Actions:**
- **Clear**: Clear the current view
- **Download**: Download logs for offline analysis
- **Pause**: Pause auto-scrolling
---
## Common Tasks
### Changing Display Brightness
1. Navigate to the **Display Settings** tab
2. Adjust the **Brightness** slider (0-100%)
3. Click **Save Configuration**
4. Restart the display for changes to take effect
### Installing a New Plugin
1. Navigate to the **Plugin Store** tab
2. Browse or search for the desired plugin
3. Click **Install** next to the plugin
4. Wait for installation to complete
5. Restart the display
6. Enable the plugin in the **Plugin Management** tab
### Configuring a Plugin
1. Navigate to the **Plugin Management** tab
2. Find the plugin you want to configure
3. Click the ⚙️ **Configure** button
4. Edit the settings in the form
5. Click **Save**
6. Restart the display to apply changes
### Setting Favorite Sports Teams
1. Navigate to the **Sports Configuration** tab
2. Select the league (NHL, NBA, MLB, NFL)
3. Choose your favorite teams from the dropdown
4. Enable "Show favorite teams only" if desired
5. Click **Save Configuration**
6. Restart the display
### Troubleshooting Display Issues
1. Navigate to the **Logs** tab
2. Look for ERROR or WARNING messages
3. Filter by the problematic plugin or component
4. Check the error message for clues
5. See [TROUBLESHOOTING.md](TROUBLESHOOTING.md) for common solutions
---
## Real-Time Features
The web interface uses Server-Sent Events (SSE) for real-time updates:
**Live Updates:**
- System stats refresh automatically every few seconds
- Display preview updates in real-time
- Logs stream continuously
- No page refresh required
**Performance:**
- Minimal bandwidth usage
- Server-side rendering for fast load times
- Progressive enhancement - works without JavaScript
---
## Mobile Access
The interface is fully responsive and works on mobile devices:
**Mobile Features:**
- Touch-friendly interface
- Responsive layout adapts to screen size
- All features available on mobile
- Swipe navigation between tabs
**Tips for Mobile:**
- Use landscape mode for better visibility
- Pinch to zoom on display preview
- Long-press for context menus
---
## Keyboard Shortcuts
Use keyboard shortcuts for faster navigation:
- **Tab**: Navigate between form fields
- **Enter**: Submit forms
- **Esc**: Close modals
- **Ctrl+F**: Search in logs
---
## API Access
The web interface is built on a REST API that you can access programmatically:
**API Base URL:**
```
http://your-pi-ip:5050/api
```
**Common Endpoints:**
- `GET /api/config/main` - Get configuration
- `POST /api/config/main` - Update configuration
- `GET /api/system/status` - Get system status
- `POST /api/system/action` - Control display (start/stop/restart)
- `GET /api/plugins/installed` - List installed plugins
**Note:** See [REST_API_REFERENCE.md](REST_API_REFERENCE.md) for complete API documentation.
---
## Troubleshooting
### Interface Won't Load
**Problem:** Browser shows "Unable to connect" or "Connection refused"
**Solutions:**
1. Verify the web service is running:
```bash
sudo systemctl status ledmatrix-web
```
2. Start the service if stopped:
```bash
sudo systemctl start ledmatrix-web
```
3. Check that port 5050 is not blocked by firewall
4. Verify the Pi's IP address is correct
### Changes Not Applying
**Problem:** Configuration changes don't take effect
**Solutions:**
1. Ensure you clicked "Save Configuration"
2. Restart the display service for changes to apply:
```bash
sudo systemctl restart ledmatrix
```
3. Check logs for error messages
### Display Preview Not Updating
**Problem:** Display preview shows old content or doesn't update
**Solutions:**
1. Refresh the browser page (F5)
2. Check that the display service is running
3. Verify SSE streams are working (check browser console)
### Plugin Configuration Not Saving
**Problem:** Plugin settings revert after restart
**Solutions:**
1. Check file permissions on `config/config.json`:
```bash
ls -l config/config.json
```
2. Ensure the web service has write permissions
3. Check logs for permission errors
---
## Security Considerations
**Network Access:**
- The interface is accessible to anyone on your local network
- No authentication is currently implemented
- Recommended for trusted networks only
**Best Practices:**
1. Run on a private network (not exposed to internet)
2. Use a firewall to restrict access if needed
3. Consider VPN access for remote control
4. Keep the system updated
---
## Technical Details
### Architecture
The web interface uses modern web technologies:
- **Backend:** Flask with Blueprint-based modular design
- **Frontend:** HTMX for dynamic content, Alpine.js for reactive components
- **Styling:** Tailwind CSS for responsive design
- **Real-Time:** Server-Sent Events (SSE) for live updates
### File Locations
**Configuration:**
- Main config: `/config/config.json`
- Secrets: `/config/config_secrets.json`
- WiFi config: `/config/wifi_config.json`
**Logs:**
- Display service: `sudo journalctl -u ledmatrix -f`
- Web service: `sudo journalctl -u ledmatrix-web -f`
**Plugins:**
- Plugin directory: `/plugins/`
- Plugin config: `/config/config.json` (per-plugin sections)
---
## Related Documentation
- [PLUGIN_STORE_GUIDE.md](PLUGIN_STORE_GUIDE.md) - Installing and managing plugins
- [REST_API_REFERENCE.md](REST_API_REFERENCE.md) - Complete REST API documentation
- [TROUBLESHOOTING.md](TROUBLESHOOTING.md) - Troubleshooting common issues
- [FONT_MANAGER.md](FONT_MANAGER.md) - Font management details

631
docs/WIFI_NETWORK_SETUP.md Normal file
View File

@@ -0,0 +1,631 @@
# WiFi Network Setup Guide
## Overview
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
- **Secure Storage**: WiFi credentials stored securely
---
## Quick Start
### Accessing WiFi Setup
**If not connected to WiFi:**
1. Wait 90 seconds after boot (AP mode activation grace period)
2. Connect to WiFi network: **LEDMatrix-Setup** (open network)
3. Open browser to: `http://192.168.4.1:5050`
4. Navigate to the WiFi tab
5. Scan, select your network, and connect
**If already connected:**
1. Open browser to: `http://your-pi-ip:5050`
2. Navigate to the WiFi tab
3. Configure as needed
---
## Installation
### Prerequisites
The following packages are required:
- **hostapd** - Access point software
- **dnsmasq** - DHCP server for AP mode
- **NetworkManager** - WiFi management
### Install WiFi Monitor Service
```bash
cd /home/ledpi/LEDMatrix
sudo ./scripts/install/install_wifi_monitor.sh
```
This script will:
- Check for required packages and offer to install them
- Create the systemd service file
- Enable and start the WiFi monitor service
- Configure the service to start on boot
### Verify Installation
```bash
# Check service status
sudo systemctl status ledmatrix-wifi-monitor
# Run verification script
./scripts/verify_wifi_setup.sh
```
---
## Configuration
### Configuration File
WiFi settings are stored in `config/wifi_config.json`:
```json
{
"ap_ssid": "LEDMatrix-Setup",
"ap_password": "",
"ap_channel": 7,
"auto_enable_ap_mode": true,
"saved_networks": [
{
"ssid": "YourNetwork",
"password": "your-password",
"saved_at": 1234567890.0
}
]
}
```
### Configuration Options
| Setting | Default | Description |
|---------|---------|-------------|
| `ap_ssid` | `LEDMatrix-Setup` | Network name for AP mode |
| `ap_password` | `` (empty) | AP password (empty = open network) |
| `ap_channel` | `7` | WiFi channel (use 1, 6, or 11 for non-overlapping) |
| `auto_enable_ap_mode` | `true` | Automatically enable AP mode when disconnected |
| `saved_networks` | `[]` | Array of saved WiFi credentials |
### Auto-Enable AP Mode Behavior
**When enabled (`true` - recommended):**
- AP mode activates automatically after 90-second grace period
- Only when both WiFi AND Ethernet are disconnected
- Automatically disables when either WiFi or Ethernet connects
- Best for portable devices or unreliable network environments
**When disabled (`false`):**
- AP mode must be manually enabled through web interface
- Prevents unnecessary AP activation
- Best for devices with stable network connections
---
## Using WiFi Setup
### Connecting to a WiFi Network
**Via Web Interface:**
1. Navigate to the **WiFi** tab
2. Click **Scan** to search for networks
3. Select a network from the dropdown (or enter SSID manually)
4. Enter the WiFi password (leave empty for open networks)
5. Click **Connect**
6. System will attempt connection
7. AP mode automatically disables once connected
**Via API:**
```bash
# Scan for networks
curl "http://your-pi-ip:5050/api/wifi/scan"
# Connect to network
curl -X POST http://your-pi-ip:5050/api/wifi/connect \
-H "Content-Type: application/json" \
-d '{"ssid": "YourNetwork", "password": "your-password"}'
```
### Manual AP Mode Control
**Via Web Interface:**
- **Enable AP Mode**: Click "Enable AP Mode" button (only when WiFi/Ethernet disconnected)
- **Disable AP Mode**: Click "Disable AP Mode" button (when AP is active)
**Via API:**
```bash
# Enable AP mode
curl -X POST http://your-pi-ip:5050/api/wifi/ap/enable
# Disable AP mode
curl -X POST http://your-pi-ip:5050/api/wifi/ap/disable
```
**Note:** Manual enable still requires both WiFi and Ethernet to be disconnected.
---
## Understanding AP Mode Failover
### How the Grace Period Works
The system uses a **grace period mechanism** to prevent false positives from temporary network hiccups:
```
Check Interval: 30 seconds (default)
Required Checks: 3 consecutive
Grace Period: 90 seconds total
```
**Timeline Example:**
```
Time 0s: WiFi disconnects
Time 30s: Check 1 - Disconnected (counter = 1)
Time 60s: Check 2 - Disconnected (counter = 2)
Time 90s: Check 3 - Disconnected (counter = 3) → AP MODE ENABLED
```
If WiFi or Ethernet reconnects at any point, the counter resets to 0.
### Why Grace Period is Important
Without a grace period, AP mode would activate during:
- Brief network hiccups
- Router reboots
- Temporary signal interference
- NetworkManager reconnection attempts
The 90-second grace period ensures AP mode only activates during **sustained disconnection**.
### Connection Priority
The system checks connections in this order:
1. **WiFi Connection** (highest priority)
2. **Ethernet Connection** (fallback)
3. **AP Mode** (last resort - only when both WiFi and Ethernet disconnected)
### Behavior Summary
| WiFi Status | Ethernet Status | Auto-Enable | AP Mode Behavior |
|-------------|-----------------|-------------|------------------|
| Any | Any | `false` | Manual enable only |
| Connected | Any | `true` | Disabled |
| Disconnected | Connected | `true` | Disabled (Ethernet available) |
| Disconnected | Disconnected | `true` | Auto-enabled after 90s |
---
## Access Point Configuration
### AP Mode Settings
- **SSID**: LEDMatrix-Setup (configurable)
- **Network**: Open (no password by default)
- **IP Address**: 192.168.4.1
- **DHCP Range**: 192.168.4.2 - 192.168.4.20
- **Channel**: 7 (configurable)
### Accessing Services in AP Mode
When AP mode is active:
- Web Interface: `http://192.168.4.1:5050`
- SSH: `ssh ledpi@192.168.4.1`
- Captive portal may automatically redirect browsers
---
## Best Practices
### Security Recommendations
**1. Change AP Password (Optional):**
```json
{
"ap_password": "your-strong-password"
}
```
**Note:** The default is an open network for easy initial setup. For deployments in public areas, consider adding a password.
**2. Use Non-Overlapping WiFi Channels:**
- Channels 1, 6, 11 are non-overlapping (2.4GHz)
- Choose a channel that doesn't conflict with your primary network
- Example: If primary uses channel 1, use channel 11 for AP mode
**3. Secure WiFi Credentials:**
```bash
sudo chmod 600 config/wifi_config.json
```
### Network Configuration Tips
**Save Multiple Networks:**
```json
{
"saved_networks": [
{
"ssid": "Home-Network",
"password": "home-password"
},
{
"ssid": "Office-Network",
"password": "office-password"
}
]
}
```
**Adjust Check Interval:**
Edit the systemd service file to change grace period:
```bash
sudo systemctl edit ledmatrix-wifi-monitor
```
Add:
```ini
[Service]
ExecStart=
ExecStart=/usr/bin/python3 /path/to/LEDMatrix/scripts/utils/wifi_monitor_daemon.py --interval 20
```
**Note:** Interval affects grace period:
- 20-second interval = 60-second grace period (3 × 20)
- 30-second interval = 90-second grace period (3 × 30) ← Default
- 60-second interval = 180-second grace period (3 × 60)
---
## Configuration Scenarios
### Scenario 1: Portable Device with Auto-Failover (Recommended)
**Use Case:** Device may lose WiFi connection
**Configuration:**
```json
{
"auto_enable_ap_mode": true
}
```
**Behavior:**
- AP mode activates automatically after 90 seconds of disconnection
- Always provides a way to connect
- Best for devices that move or have unreliable WiFi
### Scenario 2: Stable Network Connection
**Use Case:** Ethernet or reliable WiFi connection
**Configuration:**
```json
{
"auto_enable_ap_mode": false
}
```
**Behavior:**
- AP mode must be manually enabled
- Prevents unnecessary activation
- Best for stationary devices with stable connections
### Scenario 3: Ethernet Primary with WiFi Backup
**Use Case:** Primary Ethernet, WiFi as backup
**Configuration:**
```json
{
"auto_enable_ap_mode": true
}
```
**Behavior:**
- Ethernet connection prevents AP mode activation
- If Ethernet disconnects, WiFi is attempted
- If both disconnect, AP mode activates after grace period
- Best for devices with both Ethernet and WiFi
---
## Troubleshooting
### AP Mode Not Activating
**Check 1: Auto-Enable Setting**
```bash
cat config/wifi_config.json | grep auto_enable_ap_mode
```
Should show `"auto_enable_ap_mode": true`
**Check 2: Service Status**
```bash
sudo systemctl status ledmatrix-wifi-monitor
```
Service should be `active (running)`
**Check 3: Grace Period**
- Wait at least 90 seconds after disconnection
- Check logs: `sudo journalctl -u ledmatrix-wifi-monitor -f`
**Check 4: Ethernet Connection**
- If Ethernet is connected, AP mode won't activate
- Verify: `nmcli device status`
- Disconnect Ethernet to test AP mode
**Check 5: Required Packages**
```bash
# Verify hostapd is installed
which hostapd
# Verify dnsmasq is installed
which dnsmasq
```
### Cannot Access AP Mode
**Check 1: AP Mode Active**
```bash
sudo systemctl status hostapd
sudo systemctl status dnsmasq
```
Both should be running
**Check 2: Network Interface**
```bash
ip addr show wlan0
```
Should show IP `192.168.4.1`
**Check 3: WiFi Interface Available**
```bash
ip link show wlan0
```
Interface should exist
**Check 4: Try Manual Enable**
- Use web interface: WiFi tab → Enable AP Mode
- Or via API: `curl -X POST http://localhost:5050/api/wifi/ap/enable`
### Cannot Connect to WiFi Network
**Check 1: Verify Credentials**
- Ensure SSID and password are correct
- Check for hidden networks (manual SSID entry required)
**Check 2: Check Logs**
```bash
# WiFi monitor logs
sudo journalctl -u ledmatrix-wifi-monitor -f
# NetworkManager logs
sudo journalctl -u NetworkManager -n 50
```
**Check 3: Network Compatibility**
- Verify network is 2.4GHz (5GHz may not be supported on all Pi models)
- Check if network requires special authentication
### AP Mode Not Disabling After WiFi Connect
**Check 1: WiFi Connection Status**
```bash
nmcli device status
```
**Check 2: Manually Disable**
- Use web interface: WiFi tab → Disable AP Mode
- Or restart service: `sudo systemctl restart ledmatrix-wifi-monitor`
**Check 3: Check Logs**
```bash
sudo journalctl -u ledmatrix-wifi-monitor -n 50
```
### AP Mode Activating Unexpectedly
**Check 1: Network Stability**
- Verify WiFi connection is stable
- Check router status
- Check signal strength
**Check 2: Disable Auto-Enable**
```bash
nano config/wifi_config.json
# Change: "auto_enable_ap_mode": false
sudo systemctl restart ledmatrix-wifi-monitor
```
**Check 3: Increase Grace Period**
- Edit service file to increase check interval
- Longer interval = longer grace period
- See "Best Practices" section above
---
## Monitoring and Diagnostics
### Check WiFi Status
**Via Python:**
```python
from src.wifi_manager import WiFiManager
wm = WiFiManager()
status = wm.get_wifi_status()
print(f'Connected: {status.connected}')
print(f'SSID: {status.ssid}')
print(f'IP Address: {status.ip_address}')
print(f'AP Mode Active: {status.ap_mode_active}')
print(f'Auto-Enable: {wm.config.get("auto_enable_ap_mode", False)}')
```
**Via NetworkManager:**
```bash
# View device status
nmcli device status
# View connections
nmcli connection show
# View available WiFi networks
nmcli device wifi list
```
### View Service Logs
```bash
# Real-time logs
sudo journalctl -u ledmatrix-wifi-monitor -f
# Recent logs (last 50 lines)
sudo journalctl -u ledmatrix-wifi-monitor -n 50
# Logs from specific time
sudo journalctl -u ledmatrix-wifi-monitor --since "1 hour ago"
```
### Run Verification Script
```bash
cd /home/ledpi/LEDMatrix
./scripts/verify_wifi_setup.sh
```
Checks:
- Required packages installed
- WiFi monitor service running
- Configuration files valid
- WiFi interface available
- Current connection status
- AP mode status
---
## Service Management
### Useful Commands
```bash
# Check service status
sudo systemctl status ledmatrix-wifi-monitor
# Start the service
sudo systemctl start ledmatrix-wifi-monitor
# Stop the service
sudo systemctl stop ledmatrix-wifi-monitor
# Restart the service
sudo systemctl restart ledmatrix-wifi-monitor
# View logs
sudo journalctl -u ledmatrix-wifi-monitor -f
# Disable service from starting on boot
sudo systemctl disable ledmatrix-wifi-monitor
# Enable service to start on boot
sudo systemctl enable ledmatrix-wifi-monitor
```
---
## API Reference
The WiFi setup feature exposes the following API endpoints:
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/wifi/status` | Get current WiFi connection status |
| GET | `/api/wifi/scan` | Scan for available WiFi networks |
| POST | `/api/wifi/connect` | Connect to a WiFi network |
| POST | `/api/wifi/ap/enable` | Enable access point mode |
| POST | `/api/wifi/ap/disable` | Disable access point mode |
| GET | `/api/wifi/ap/auto-enable` | Get auto-enable setting |
| POST | `/api/wifi/ap/auto-enable` | Set auto-enable setting |
### Example Usage
```bash
# Get WiFi status
curl "http://your-pi-ip:5050/api/wifi/status"
# Scan for networks
curl "http://your-pi-ip:5050/api/wifi/scan"
# Connect to network
curl -X POST http://your-pi-ip:5050/api/wifi/connect \
-H "Content-Type: application/json" \
-d '{"ssid": "MyNetwork", "password": "mypassword"}'
# Enable AP mode
curl -X POST http://your-pi-ip:5050/api/wifi/ap/enable
# Check auto-enable setting
curl "http://your-pi-ip:5050/api/wifi/ap/auto-enable"
# Set auto-enable
curl -X POST http://your-pi-ip:5050/api/wifi/ap/auto-enable \
-H "Content-Type: application/json" \
-d '{"auto_enable_ap_mode": true}'
```
---
## Technical Details
### WiFi Monitor Daemon
The WiFi monitor daemon (`wifi_monitor_daemon.py`) runs as a background service that:
1. Checks WiFi and Ethernet connection status every 30 seconds (configurable)
2. Maintains disconnected check counter for grace period
3. Automatically enables AP mode when:
- `auto_enable_ap_mode` is enabled AND
- Both WiFi and Ethernet disconnected AND
- Grace period elapsed (3 consecutive checks)
4. Automatically disables AP mode when WiFi or Ethernet connects
5. Logs all state changes
### WiFi Detection Methods
The WiFi manager tries multiple methods:
1. **NetworkManager (nmcli)** - Preferred method
2. **iwconfig** - Fallback for systems without NetworkManager
### Network Scanning Methods
1. **nmcli** - Fast, preferred method
2. **iwlist** - Fallback for older systems
### Access Point Implementation
- Uses `hostapd` for WiFi access point functionality
- Uses `dnsmasq` for DHCP and DNS services
- Configures wlan0 interface with IP 192.168.4.1
- Provides DHCP range: 192.168.4.2-20
- Captive portal with DNS redirection
---
## Related Documentation
- [WEB_INTERFACE_GUIDE.md](WEB_INTERFACE_GUIDE.md) - Using the web interface
- [TROUBLESHOOTING.md](TROUBLESHOOTING.md) - General troubleshooting
- [GETTING_STARTED.md](GETTING_STARTED.md) - Initial setup guide

View File

@@ -0,0 +1,388 @@
# Vegas Scroll Mode - Plugin Developer Guide
Vegas scroll mode displays content from multiple plugins in a continuous horizontal scroll, similar to the news tickers seen in Las Vegas casinos. This guide explains how to integrate your plugin with Vegas mode.
## Overview
When Vegas mode is enabled, the display controller composes content from all enabled plugins into a single continuous scroll. Each plugin can control how its content appears in the scroll using one of three **display modes**:
| Mode | Behavior | Best For |
|------|----------|----------|
| **SCROLL** | Content scrolls continuously within the stream | Multi-item plugins (sports scores, odds, news) |
| **FIXED_SEGMENT** | Fixed-width block that scrolls by | Static info (clock, weather, current temp) |
| **STATIC** | Scroll pauses, plugin displays for duration, then resumes | Important alerts, detailed views |
## Quick Start
### Minimal Integration (Zero Code Changes)
If you do nothing, your plugin will work with Vegas mode using these defaults:
- Plugins with `get_vegas_content_type() == 'multi'` use **SCROLL** mode
- Plugins with `get_vegas_content_type() == 'static'` use **FIXED_SEGMENT** mode
- Content is captured by calling your plugin's `display()` method
### Basic Integration
To provide optimized Vegas content, implement `get_vegas_content()`:
```python
from PIL import Image
class MyPlugin(BasePlugin):
def get_vegas_content(self):
"""Return content for Vegas scroll mode."""
# Return a single image for fixed content
return self._render_current_view()
# OR return multiple images for multi-item content
# return [self._render_item(item) for item in self.items]
```
### Full Integration
For complete control over Vegas behavior, implement these methods:
```python
from src.plugin_system.base_plugin import BasePlugin, VegasDisplayMode
class MyPlugin(BasePlugin):
def get_vegas_content_type(self) -> str:
"""Legacy method - determines default mode mapping."""
return 'multi' # or 'static' or 'none'
def get_vegas_display_mode(self) -> VegasDisplayMode:
"""Specify how this plugin behaves in Vegas scroll."""
return VegasDisplayMode.SCROLL
def get_supported_vegas_modes(self) -> list:
"""Return list of modes users can configure."""
return [VegasDisplayMode.SCROLL, VegasDisplayMode.FIXED_SEGMENT]
def get_vegas_content(self):
"""Return PIL Image(s) for the scroll."""
return [self._render_game(g) for g in self.games]
def get_vegas_segment_width(self) -> int:
"""For FIXED_SEGMENT: width in panels (optional)."""
return 2 # Use 2 panels width
```
## Display Modes Explained
### SCROLL Mode
Content scrolls continuously within the Vegas stream. Best for plugins with multiple items.
```python
def get_vegas_display_mode(self):
return VegasDisplayMode.SCROLL
def get_vegas_content(self):
# Return list of images - each scrolls individually
images = []
for game in self.games:
img = Image.new('RGB', (200, 32))
# ... render game info ...
images.append(img)
return images
```
**When to use:**
- Sports scores with multiple games
- Stock/odds tickers with multiple items
- News feeds with multiple headlines
### FIXED_SEGMENT Mode
Content is rendered as a fixed-width block that scrolls by with other content.
```python
def get_vegas_display_mode(self):
return VegasDisplayMode.FIXED_SEGMENT
def get_vegas_content(self):
# Return single image at your preferred width
img = Image.new('RGB', (128, 32)) # 2 panels wide
# ... render clock/weather/etc ...
return img
def get_vegas_segment_width(self):
# Optional: specify width in panels
return 2
```
**When to use:**
- Clock display
- Current weather/temperature
- System status indicators
- Any "at a glance" information
### STATIC Mode
Scroll pauses completely, your plugin displays using its normal `display()` method for its configured duration, then scroll resumes.
```python
def get_vegas_display_mode(self):
return VegasDisplayMode.STATIC
def get_display_duration(self):
# How long to pause and show this plugin
return 10.0 # 10 seconds
```
**When to use:**
- Important alerts that need attention
- Detailed information that's hard to read while scrolling
- Interactive or animated content
- Content that requires the full display
## User Configuration
Users can override the default display mode per-plugin in their config:
```json
{
"my_plugin": {
"enabled": true,
"vegas_mode": "static", // Override: "scroll", "fixed", or "static"
"vegas_panel_count": 2, // Width in panels for fixed mode
"display_duration": 10 // Duration for static mode
}
}
```
The `get_vegas_display_mode()` method checks config first, then falls back to your implementation.
## Content Rendering Guidelines
### Image Dimensions
- **Height**: Must match display height (typically 32 pixels)
- **Width**:
- SCROLL: Any width, content will scroll
- FIXED_SEGMENT: `panels × single_panel_width` (e.g., 2 × 64 = 128px)
### Color Mode
Always use RGB mode for images:
```python
img = Image.new('RGB', (width, 32), color=(0, 0, 0))
```
### Performance Tips
1. **Cache rendered images** - Don't re-render on every call
2. **Pre-render on update()** - Render images when data changes, not when Vegas requests them
3. **Keep images small** - Memory adds up with multiple plugins
```python
class MyPlugin(BasePlugin):
def __init__(self, ...):
super().__init__(...)
self._cached_vegas_images = None
self._cache_valid = False
def update(self):
# Fetch new data
self.data = self._fetch_data()
# Invalidate cache so next Vegas request re-renders
self._cache_valid = False
def get_vegas_content(self):
if not self._cache_valid:
self._cached_vegas_images = self._render_all_items()
self._cache_valid = True
return self._cached_vegas_images
```
## Fallback Behavior
If your plugin doesn't implement `get_vegas_content()`, Vegas mode will:
1. Create a temporary canvas matching display dimensions
2. Call your `display()` method
3. Capture the resulting image
4. Use that image in the scroll
This works but is less efficient than providing native Vegas content.
## Excluding from Vegas Mode
To exclude your plugin from Vegas scroll entirely:
```python
def get_vegas_content_type(self):
return 'none'
```
Or users can exclude via config:
```json
{
"display": {
"vegas_scroll": {
"excluded_plugins": ["my_plugin"]
}
}
}
```
## Complete Example
Here's a complete example of a weather plugin with full Vegas integration:
```python
from PIL import Image, ImageDraw
from src.plugin_system.base_plugin import BasePlugin, VegasDisplayMode
class WeatherPlugin(BasePlugin):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.temperature = None
self.conditions = None
self._vegas_image = None
def update(self):
"""Fetch weather data."""
data = self._fetch_weather_api()
self.temperature = data['temp']
self.conditions = data['conditions']
self._vegas_image = None # Invalidate cache
def display(self, force_clear=False):
"""Standard display for normal rotation."""
if force_clear:
self.display_manager.clear()
# Full weather display with details
self.display_manager.draw_text(
f"{self.temperature}°F",
x=10, y=8, color=(255, 255, 255)
)
self.display_manager.draw_text(
self.conditions,
x=10, y=20, color=(200, 200, 200)
)
self.display_manager.update_display()
# --- Vegas Mode Integration ---
def get_vegas_content_type(self):
"""Legacy compatibility."""
return 'static'
def get_vegas_display_mode(self):
"""Use FIXED_SEGMENT for compact weather display."""
# Allow user override via config
return super().get_vegas_display_mode()
def get_supported_vegas_modes(self):
"""Weather can work as fixed or static."""
return [VegasDisplayMode.FIXED_SEGMENT, VegasDisplayMode.STATIC]
def get_vegas_segment_width(self):
"""Weather needs 2 panels to show clearly."""
return self.config.get('vegas_panel_count', 2)
def get_vegas_content(self):
"""Render compact weather for Vegas scroll."""
if self._vegas_image is not None:
return self._vegas_image
# Create compact display (2 panels = 128px typical)
panel_width = 64 # From display.hardware.cols
panels = self.get_vegas_segment_width() or 2
width = panel_width * panels
height = 32
img = Image.new('RGB', (width, height), color=(0, 0, 40))
draw = ImageDraw.Draw(img)
# Draw compact weather
temp_text = f"{self.temperature}°"
draw.text((10, 8), temp_text, fill=(255, 255, 255))
draw.text((60, 8), self.conditions[:10], fill=(200, 200, 200))
self._vegas_image = img
return img
```
## API Reference
### VegasDisplayMode Enum
```python
from src.plugin_system.base_plugin import VegasDisplayMode
VegasDisplayMode.SCROLL # "scroll" - continuous scrolling
VegasDisplayMode.FIXED_SEGMENT # "fixed" - fixed block in scroll
VegasDisplayMode.STATIC # "static" - pause scroll to display
```
### BasePlugin Vegas Methods
| Method | Returns | Description |
|--------|---------|-------------|
| `get_vegas_content()` | `Image` or `List[Image]` or `None` | Content for Vegas scroll |
| `get_vegas_content_type()` | `str` | Legacy: 'multi', 'static', or 'none' |
| `get_vegas_display_mode()` | `VegasDisplayMode` | How plugin behaves in Vegas |
| `get_supported_vegas_modes()` | `List[VegasDisplayMode]` | Modes available for user config |
| `get_vegas_segment_width()` | `int` or `None` | Width in panels for FIXED_SEGMENT |
### Configuration Options
**Per-plugin config:**
```json
{
"plugin_id": {
"vegas_mode": "scroll|fixed|static",
"vegas_panel_count": 2,
"display_duration": 15
}
}
```
**Global Vegas config:**
```json
{
"display": {
"vegas_scroll": {
"enabled": true,
"scroll_speed": 50,
"separator_width": 32,
"plugin_order": ["clock", "weather", "sports"],
"excluded_plugins": ["debug_plugin"],
"target_fps": 125,
"buffer_ahead": 2
}
}
}
```
## Troubleshooting
### Plugin not appearing in Vegas scroll
1. Check `get_vegas_content_type()` doesn't return `'none'`
2. Verify plugin is not in `excluded_plugins` list
3. Ensure plugin is enabled
### Content looks wrong in scroll
1. Verify image height matches display height (32px typical)
2. Check image mode is 'RGB'
3. Test with `get_vegas_content()` returning a simple test image
### STATIC mode not pausing
1. Verify `get_vegas_display_mode()` returns `VegasDisplayMode.STATIC`
2. Check user hasn't overridden with `vegas_mode` in config
3. Ensure `display()` method works correctly
### Performance issues
1. Implement image caching in `get_vegas_content()`
2. Pre-render images in `update()` instead of on-demand
3. Reduce image dimensions if possible

View File

@@ -220,11 +220,13 @@ echo "1. Install system dependencies"
echo "2. Fix cache permissions" echo "2. Fix cache permissions"
echo "3. Fix assets directory permissions" echo "3. Fix assets directory permissions"
echo "3.1. Fix plugin directory permissions" echo "3.1. Fix plugin directory permissions"
echo "4. Install main LED Matrix service" echo "4. Ensure configuration files exist"
echo "5. Install Python project dependencies (requirements.txt)" echo "5. Install Python project dependencies (requirements.txt)"
echo "6. Build and install rpi-rgb-led-matrix and test import" echo "6. Build and install rpi-rgb-led-matrix and test import"
echo "7. Install web interface dependencies" echo "7. Install web interface dependencies"
echo "7.5. Install main LED Matrix service"
echo "8. Install web interface service" echo "8. Install web interface service"
echo "8.1. Harden systemd unit file permissions"
echo "8.5. Install WiFi monitor service" echo "8.5. Install WiFi monitor service"
echo "9. Configure web interface permissions" echo "9. Configure web interface permissions"
echo "10. Configure passwordless sudo access" echo "10. Configure passwordless sudo access"
@@ -271,7 +273,7 @@ apt_update
# Install required system packages # Install required system packages
echo "Installing Python packages and dependencies..." echo "Installing Python packages and dependencies..."
apt_install python3-pip python3-venv python3-dev python3-pil python3-pil.imagetk python3-pillow build-essential python3-setuptools python3-wheel cython3 scons cmake ninja-build apt_install python3-pip python3-venv python3-dev python3-pil python3-pil.imagetk build-essential python3-setuptools python3-wheel cython3 scons cmake ninja-build
# Install additional system dependencies that might be needed # Install additional system dependencies that might be needed
echo "Installing additional system dependencies..." echo "Installing additional system dependencies..."
@@ -511,43 +513,9 @@ find "$PLUGIN_REPOS_DIR" -type f -exec chmod 664 {} \;
echo "✓ Plugin-repos directory permissions fixed" echo "✓ Plugin-repos directory permissions fixed"
echo "" echo ""
CURRENT_STEP="Install main LED Matrix service"
echo "Step 4: Installing main LED Matrix service..."
echo "---------------------------------------------"
# Run the main service installation (idempotent)
# Note: install_service.sh always overwrites the service file, so it will update paths automatically
if [ -f "$PROJECT_ROOT_DIR/scripts/install/install_service.sh" ]; then
echo "Running main service installation/update..."
bash "$PROJECT_ROOT_DIR/scripts/install/install_service.sh"
echo "✓ Main LED Matrix service installed/updated"
else
echo "✗ Main service installation script not found at $PROJECT_ROOT_DIR/scripts/install/install_service.sh"
echo "Please ensure you are running this script from the project root: $PROJECT_ROOT_DIR"
exit 1
fi
# Configure Python capabilities for hardware timing
echo "Configuring Python capabilities for hardware timing..."
if [ -f "/usr/bin/python3.13" ]; then
sudo setcap 'cap_sys_nice=eip' /usr/bin/python3.13 2>/dev/null || echo "⚠ Could not set cap_sys_nice on python3.13 (may need manual setup)"
echo "✓ Python3.13 capabilities configured"
elif [ -f "/usr/bin/python3" ]; then
PYTHON_VER=$(python3 --version 2>&1 | grep -oP '(?<=Python )\d\.\d+' || echo "unknown")
if command -v setcap >/dev/null 2>&1; then
sudo setcap 'cap_sys_nice=eip' /usr/bin/python3 2>/dev/null || echo "⚠ Could not set cap_sys_nice on python3"
echo "✓ Python3 capabilities configured (version: $PYTHON_VER)"
else
echo "⚠ setcap not found, skipping capability configuration"
fi
else
echo "⚠ Python3 not found, skipping capability configuration"
fi
echo ""
CURRENT_STEP="Ensure configuration files exist" CURRENT_STEP="Ensure configuration files exist"
echo "Step 4.1: Ensuring configuration files exist..." echo "Step 4: Ensuring configuration files exist..."
echo "------------------------------------------------" echo "----------------------------------------------"
# Ensure config directory exists # Ensure config directory exists
mkdir -p "$PROJECT_ROOT_DIR/config" mkdir -p "$PROJECT_ROOT_DIR/config"
@@ -661,32 +629,15 @@ CURRENT_STEP="Install project Python dependencies"
echo "Step 5: Installing Python project dependencies..." echo "Step 5: Installing Python project dependencies..."
echo "-----------------------------------------------" echo "-----------------------------------------------"
# Install numpy via apt first (pre-built binary, much faster than building from source) # Install main project Python dependencies (numpy will be installed via pip from requirements.txt)
echo "Installing numpy via apt (pre-built binary for faster installation)..."
if ! python3 -c "import numpy" >/dev/null 2>&1; then
apt_install python3-numpy
echo "✓ numpy installed via apt"
else
NUMPY_VERSION=$(python3 -c "import numpy; print(numpy.__version__)" 2>/dev/null || echo "unknown")
echo "✓ numpy already installed (version: $NUMPY_VERSION)"
fi
echo ""
# Install main project Python dependencies
cd "$PROJECT_ROOT_DIR" cd "$PROJECT_ROOT_DIR"
if [ -f "$PROJECT_ROOT_DIR/requirements.txt" ]; then if [ -f "$PROJECT_ROOT_DIR/requirements.txt" ]; then
echo "Reading requirements from: $PROJECT_ROOT_DIR/requirements.txt" echo "Reading requirements from: $PROJECT_ROOT_DIR/requirements.txt"
# Check pip version and upgrade if needed # Check pip version (apt-installed pip is sufficient, no upgrade needed)
echo "Checking pip version..." echo "Checking pip version..."
python3 -m pip --version python3 -m pip --version
# Upgrade pip, setuptools, and wheel for better compatibility
echo "Upgrading pip, setuptools, and wheel..."
python3 -m pip install --break-system-packages --upgrade pip setuptools wheel || {
echo "⚠ Warning: Failed to upgrade pip/setuptools/wheel, continuing anyway..."
}
# Count total packages for progress # Count total packages for progress
TOTAL_PACKAGES=$(grep -v '^#' "$PROJECT_ROOT_DIR/requirements.txt" | grep -v '^$' | wc -l) TOTAL_PACKAGES=$(grep -v '^#' "$PROJECT_ROOT_DIR/requirements.txt" | grep -v '^$' | wc -l)
echo "Found $TOTAL_PACKAGES package(s) to install" echo "Found $TOTAL_PACKAGES package(s) to install"
@@ -812,6 +763,22 @@ else
fi fi
echo "" echo ""
# Install web interface dependencies
echo "Installing web interface dependencies..."
if [ -f "$PROJECT_ROOT_DIR/web_interface/requirements.txt" ]; then
if python3 -m pip install --break-system-packages -r "$PROJECT_ROOT_DIR/web_interface/requirements.txt"; then
echo "✓ Web interface dependencies installed"
# Create marker file to indicate dependencies are installed
touch "$PROJECT_ROOT_DIR/.web_deps_installed"
else
echo "⚠ Warning: Some web interface dependencies failed to install"
echo " The web interface may not work correctly until dependencies are installed"
fi
else
echo "⚠ web_interface/requirements.txt not found; skipping"
fi
echo ""
CURRENT_STEP="Build and install rpi-rgb-led-matrix" CURRENT_STEP="Build and install rpi-rgb-led-matrix"
echo "Step 6: Building and installing rpi-rgb-led-matrix..." echo "Step 6: Building and installing rpi-rgb-led-matrix..."
echo "-----------------------------------------------------" echo "-----------------------------------------------------"
@@ -903,24 +870,82 @@ CURRENT_STEP="Install web interface dependencies"
echo "Step 7: Installing web interface dependencies..." echo "Step 7: Installing web interface dependencies..."
echo "------------------------------------------------" echo "------------------------------------------------"
# Install web interface dependencies # Check if web dependencies were already installed (marker created in Step 5)
echo "Installing Python dependencies for web interface..." if [ -f "$PROJECT_ROOT_DIR/.web_deps_installed" ]; then
cd "$PROJECT_ROOT_DIR" echo "✓ Web interface dependencies already installed (marker file found)"
# Try to install dependencies using the smart installer if available
if [ -f "$PROJECT_ROOT_DIR/scripts/install_dependencies_apt.py" ]; then
echo "Using smart dependency installer..."
python3 "$PROJECT_ROOT_DIR/scripts/install_dependencies_apt.py"
else else
echo "Using pip to install dependencies..." # Install web interface dependencies
if [ -f "$PROJECT_ROOT_DIR/requirements_web_v2.txt" ]; then echo "Installing Python dependencies for web interface..."
python3 -m pip install --break-system-packages -r requirements_web_v2.txt cd "$PROJECT_ROOT_DIR"
# Try to install dependencies using the smart installer if available
if [ -f "$PROJECT_ROOT_DIR/scripts/install_dependencies_apt.py" ]; then
echo "Using smart dependency installer..."
python3 "$PROJECT_ROOT_DIR/scripts/install_dependencies_apt.py"
else else
echo "⚠ requirements_web_v2.txt not found; skipping web dependency install" echo "Using pip to install dependencies..."
if [ -f "$PROJECT_ROOT_DIR/requirements_web_v2.txt" ]; then
python3 -m pip install --break-system-packages -r requirements_web_v2.txt
else
echo "⚠ requirements_web_v2.txt not found; skipping web dependency install"
fi
fi fi
# Create marker file to indicate dependencies are installed
touch "$PROJECT_ROOT_DIR/.web_deps_installed"
echo "✓ Web interface dependencies installed"
fi
echo ""
CURRENT_STEP="Install main LED Matrix service"
echo "Step 7.5: Installing main LED Matrix service..."
echo "------------------------------------------------"
# Run the main service installation (idempotent)
# Note: install_service.sh always overwrites the service file, so it will update paths automatically
# This step runs AFTER all Python dependencies are installed (Steps 5-7)
if [ -f "$PROJECT_ROOT_DIR/scripts/install/install_service.sh" ]; then
echo "Running main service installation/update..."
bash "$PROJECT_ROOT_DIR/scripts/install/install_service.sh"
echo "✓ Main LED Matrix service installed/updated"
else
echo "✗ Main service installation script not found at $PROJECT_ROOT_DIR/scripts/install/install_service.sh"
echo "Please ensure you are running this script from the project root: $PROJECT_ROOT_DIR"
exit 1
fi fi
echo "✓ Web interface dependencies installed" # Configure Python capabilities for hardware timing
echo "Configuring Python capabilities for hardware timing..."
# Check if setcap is available first
if ! command -v setcap >/dev/null 2>&1; then
echo "⚠ setcap not found, skipping capability configuration"
echo " Install libcap2-bin if you need hardware timing capabilities"
else
# Find the Python binary and resolve symlinks to get the real binary
PYTHON_BIN=""
PYTHON_VER=""
if [ -f "/usr/bin/python3.13" ]; then
PYTHON_BIN=$(readlink -f /usr/bin/python3.13)
PYTHON_VER="3.13"
elif [ -f "/usr/bin/python3" ]; then
PYTHON_BIN=$(readlink -f /usr/bin/python3)
PYTHON_VER=$(python3 --version 2>&1 | grep -oP '(?<=Python )\d+\.\d+' || echo "unknown")
fi
if [ -n "$PYTHON_BIN" ] && [ -f "$PYTHON_BIN" ]; then
echo "Setting cap_sys_nice on $PYTHON_BIN (Python $PYTHON_VER)..."
if sudo setcap 'cap_sys_nice=eip' "$PYTHON_BIN" 2>/dev/null; then
echo "✓ Python $PYTHON_VER capabilities configured ($PYTHON_BIN)"
else
echo "⚠ Could not set cap_sys_nice on $PYTHON_BIN"
echo " This may require manual setup or running as root"
echo " The LED display may have timing issues without this capability"
fi
else
echo "⚠ Python3 not found, skipping capability configuration"
fi
fi
echo "" echo ""
CURRENT_STEP="Install web interface service" CURRENT_STEP="Install web interface service"
@@ -1212,19 +1237,21 @@ CURRENT_STEP="Normalize project file permissions"
echo "Step 11.1: Normalizing project file and directory permissions..." echo "Step 11.1: Normalizing project file and directory permissions..."
echo "--------------------------------------------------------------" echo "--------------------------------------------------------------"
# Normalize directory permissions (exclude VCS metadata and plugin directories) # Normalize directory permissions (exclude VCS metadata, plugin directories, and compiled libraries)
find "$PROJECT_ROOT_DIR" \ find "$PROJECT_ROOT_DIR" \
-path "$PROJECT_ROOT_DIR/plugins" -prune -o \ -path "$PROJECT_ROOT_DIR/plugins" -prune -o \
-path "$PROJECT_ROOT_DIR/plugin-repos" -prune -o \ -path "$PROJECT_ROOT_DIR/plugin-repos" -prune -o \
-path "$PROJECT_ROOT_DIR/scripts/dev/plugins" -prune -o \ -path "$PROJECT_ROOT_DIR/scripts/dev/plugins" -prune -o \
-path "$PROJECT_ROOT_DIR/rpi-rgb-led-matrix-master" -prune -o \
-path "*/.git*" -prune -o \ -path "*/.git*" -prune -o \
-type d -exec chmod 755 {} \; 2>/dev/null || true -type d -exec chmod 755 {} \; 2>/dev/null || true
# Set default file permissions (exclude plugin directories) # Set default file permissions (exclude plugin directories and compiled libraries)
find "$PROJECT_ROOT_DIR" \ find "$PROJECT_ROOT_DIR" \
-path "$PROJECT_ROOT_DIR/plugins" -prune -o \ -path "$PROJECT_ROOT_DIR/plugins" -prune -o \
-path "$PROJECT_ROOT_DIR/plugin-repos" -prune -o \ -path "$PROJECT_ROOT_DIR/plugin-repos" -prune -o \
-path "$PROJECT_ROOT_DIR/scripts/dev/plugins" -prune -o \ -path "$PROJECT_ROOT_DIR/scripts/dev/plugins" -prune -o \
-path "$PROJECT_ROOT_DIR/rpi-rgb-led-matrix-master" -prune -o \
-path "*/.git*" -prune -o \ -path "*/.git*" -prune -o \
-type f -exec chmod 644 {} \; 2>/dev/null || true -type f -exec chmod 644 {} \; 2>/dev/null || true
@@ -1541,26 +1568,31 @@ echo "=========================================="
echo "Important Notes" echo "Important Notes"
echo "==========================================" echo "=========================================="
echo "" echo ""
echo "1. For group changes to take effect:" echo "1. PLEASE BE PATIENT after reboot!"
echo " - The web interface may take up to 5 minutes to start on first boot"
echo " - Services need time to initialize after installation"
echo " - Wait at least 2-3 minutes before checking service status"
echo ""
echo "2. For group changes to take effect:"
echo " - Log out and log back in to your SSH session, OR" echo " - Log out and log back in to your SSH session, OR"
echo " - Run: newgrp systemd-journal" echo " - Run: newgrp systemd-journal"
echo "" echo ""
echo "2. If you cannot access the web UI:" echo "3. If you cannot access the web UI:"
echo " - Check that the web service is running: sudo systemctl status ledmatrix-web" echo " - Check that the web service is running: sudo systemctl status ledmatrix-web"
echo " - Verify firewall allows port 5000: sudo ufw status (if using UFW)" echo " - Verify firewall allows port 5000: sudo ufw status (if using UFW)"
echo " - Check network connectivity: ping -c 3 8.8.8.8" echo " - Check network connectivity: ping -c 3 8.8.8.8"
echo " - If WiFi is not connected, connect to LEDMatrix-Setup AP network" echo " - If WiFi is not connected, connect to LEDMatrix-Setup AP network"
echo "" echo ""
echo "3. SSH Access:" echo "4. SSH Access:"
echo " - SSH must be configured during initial Pi setup (via Raspberry Pi Imager or raspi-config)" echo " - SSH must be configured during initial Pi setup (via Raspberry Pi Imager or raspi-config)"
echo " - This installation script does not configure SSH credentials" echo " - This installation script does not configure SSH credentials"
echo "" echo ""
echo "4. Useful Commands:" echo "5. Useful Commands:"
echo " - Check service status: sudo systemctl status ledmatrix.service" echo " - Check service status: sudo systemctl status ledmatrix.service"
echo " - View logs: journalctl -u ledmatrix-web.service -f" echo " - View logs: journalctl -u ledmatrix-web.service -f"
echo " - Start/stop display: sudo systemctl start/stop ledmatrix.service" echo " - Start/stop display: sudo systemctl start/stop ledmatrix.service"
echo "" echo ""
echo "5. Configuration Files:" echo "6. Configuration Files:"
echo " - Main config: $PROJECT_ROOT_DIR/config/config.json" echo " - Main config: $PROJECT_ROOT_DIR/config/config.json"
echo " - Secrets: $PROJECT_ROOT_DIR/config/config_secrets.json" echo " - Secrets: $PROJECT_ROOT_DIR/config/config_secrets.json"
echo "" echo ""

5
run.py
View File

@@ -4,6 +4,11 @@ import sys
import os import os
import argparse import argparse
# Prevent Python from creating __pycache__ directories in plugin dirs.
# The root service loads plugins via importlib, and root-owned __pycache__
# files block the web service (non-root) from updating/uninstalling plugins.
sys.dont_write_bytecode = True
# Add project directory to Python path (needed before importing src modules) # Add project directory to Python path (needed before importing src modules)
project_dir = os.path.dirname(os.path.abspath(__file__)) project_dir = os.path.dirname(os.path.abspath(__file__))
if project_dir not in sys.path: if project_dir not in sys.path:

View File

@@ -0,0 +1,61 @@
#!/bin/bash
# safe_plugin_rm.sh — Safely remove a plugin directory after validating
# that the resolved path is inside an allowed base directory.
#
# This script is intended to be called via sudo from the web interface.
# It prevents path traversal attacks by resolving symlinks and verifying
# the target is a child of plugin-repos/ or plugins/.
#
# Usage: safe_plugin_rm.sh <target_path>
set -euo pipefail
if [ $# -ne 1 ]; then
echo "Usage: $0 <target_path>" >&2
exit 1
fi
TARGET="$1"
# Determine the project root (parent of scripts/fix_perms/)
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
# Allowed base directories (resolved, no trailing slash)
# Use --canonicalize-missing so this works even if the dirs don't exist yet
ALLOWED_BASES=(
"$(realpath --canonicalize-missing "$PROJECT_ROOT/plugin-repos")"
"$(realpath --canonicalize-missing "$PROJECT_ROOT/plugins")"
)
# Resolve the target path (follow symlinks)
# Use realpath --canonicalize-missing so it works even if the path
# doesn't fully exist (e.g., partially deleted directory)
RESOLVED_TARGET="$(realpath --canonicalize-missing "$TARGET")"
# Validate: resolved target must be a strict child of an allowed base
# (must not BE the base itself — only children are allowed)
ALLOWED=false
for BASE in "${ALLOWED_BASES[@]}"; do
if [[ "$RESOLVED_TARGET" == "$BASE/"* ]]; then
ALLOWED=true
break
fi
done
if [ "$ALLOWED" = false ]; then
echo "DENIED: $RESOLVED_TARGET is not inside an allowed plugin directory" >&2
echo "Allowed bases: ${ALLOWED_BASES[*]}" >&2
exit 2
fi
# Safety check: refuse to delete the base directories themselves
for BASE in "${ALLOWED_BASES[@]}"; do
if [ "$RESOLVED_TARGET" = "$BASE" ]; then
echo "DENIED: cannot remove plugin base directory itself: $BASE" >&2
exit 2
fi
done
# All checks passed — remove the target
rm -rf -- "$RESOLVED_TARGET"

View File

@@ -10,9 +10,11 @@ echo "Configuring passwordless sudo access for LED Matrix Web Interface..."
# Get the current user (should be the user running the web interface) # Get the current user (should be the user running the web interface)
WEB_USER=$(whoami) WEB_USER=$(whoami)
PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$PROJECT_DIR/../.." && pwd)"
echo "Detected web interface user: $WEB_USER" echo "Detected web interface user: $WEB_USER"
echo "Project directory: $PROJECT_DIR" echo "Project directory: $PROJECT_DIR"
echo "Project root: $PROJECT_ROOT"
# Check if running as root # Check if running as root
if [ "$EUID" -eq 0 ]; then if [ "$EUID" -eq 0 ]; then
@@ -21,50 +23,92 @@ if [ "$EUID" -eq 0 ]; then
exit 1 exit 1
fi fi
# Get the full paths to commands # Get the full paths to commands and validate each one
PYTHON_PATH=$(which python3) MISSING_CMDS=()
SYSTEMCTL_PATH=$(which systemctl)
REBOOT_PATH=$(which reboot) PYTHON_PATH=$(command -v python3) || true
POWEROFF_PATH=$(which poweroff) SYSTEMCTL_PATH=$(command -v systemctl) || true
BASH_PATH=$(which bash) REBOOT_PATH=$(command -v reboot) || true
JOURNALCTL_PATH=$(which journalctl) POWEROFF_PATH=$(command -v poweroff) || true
BASH_PATH=$(command -v bash) || true
JOURNALCTL_PATH=$(command -v journalctl) || true
SAFE_RM_PATH="$PROJECT_ROOT/scripts/fix_perms/safe_plugin_rm.sh"
# Validate required commands (systemctl, bash, python3 are essential)
for CMD_NAME in SYSTEMCTL_PATH BASH_PATH PYTHON_PATH; do
CMD_VAL="${!CMD_NAME}"
if [ -z "$CMD_VAL" ]; then
MISSING_CMDS+=("$CMD_NAME")
fi
done
if [ ${#MISSING_CMDS[@]} -gt 0 ]; then
echo "Error: Required commands not found: ${MISSING_CMDS[*]}" >&2
echo "Cannot generate valid sudoers configuration without these." >&2
exit 1
fi
# Validate helper script exists
if [ ! -f "$SAFE_RM_PATH" ]; then
echo "Error: Safe plugin removal helper not found: $SAFE_RM_PATH" >&2
exit 1
fi
echo "Command paths:" echo "Command paths:"
echo " Python: $PYTHON_PATH" echo " Python: $PYTHON_PATH"
echo " Systemctl: $SYSTEMCTL_PATH" echo " Systemctl: $SYSTEMCTL_PATH"
echo " Reboot: $REBOOT_PATH" echo " Reboot: ${REBOOT_PATH:-(not found, skipping)}"
echo " Poweroff: $POWEROFF_PATH" echo " Poweroff: ${POWEROFF_PATH:-(not found, skipping)}"
echo " Bash: $BASH_PATH" echo " Bash: $BASH_PATH"
echo " Journalctl: $JOURNALCTL_PATH" echo " Journalctl: ${JOURNALCTL_PATH:-(not found, skipping)}"
echo " Safe plugin rm: $SAFE_RM_PATH"
# Create a temporary sudoers file # Create a temporary sudoers file
TEMP_SUDOERS="/tmp/ledmatrix_web_sudoers_$$" TEMP_SUDOERS="/tmp/ledmatrix_web_sudoers_$$"
cat > "$TEMP_SUDOERS" << EOF {
# LED Matrix Web Interface passwordless sudo configuration echo "# LED Matrix Web Interface passwordless sudo configuration"
# This allows the web interface user to run specific commands without a password echo "# This allows the web interface user to run specific commands without a password"
echo ""
echo "# Allow $WEB_USER to run specific commands without a password for the LED Matrix web interface"
# Allow $WEB_USER to run specific commands without a password for the LED Matrix web interface # Optional: reboot/poweroff (non-critical — skip if not found)
$WEB_USER ALL=(ALL) NOPASSWD: $REBOOT_PATH if [ -n "$REBOOT_PATH" ]; then
$WEB_USER ALL=(ALL) NOPASSWD: $POWEROFF_PATH echo "$WEB_USER ALL=(ALL) NOPASSWD: $REBOOT_PATH"
$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH start ledmatrix.service fi
$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH stop ledmatrix.service if [ -n "$POWEROFF_PATH" ]; then
$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH restart ledmatrix.service echo "$WEB_USER ALL=(ALL) NOPASSWD: $POWEROFF_PATH"
$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH enable ledmatrix.service fi
$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH disable ledmatrix.service
$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH status ledmatrix.service # Required: systemctl
$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH is-active ledmatrix echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH start ledmatrix.service"
$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH is-active ledmatrix.service echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH stop ledmatrix.service"
$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH start ledmatrix-web echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH restart ledmatrix.service"
$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH stop ledmatrix-web echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH enable ledmatrix.service"
$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH restart ledmatrix-web echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH disable ledmatrix.service"
$WEB_USER ALL=(ALL) NOPASSWD: $JOURNALCTL_PATH -u ledmatrix.service * echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH status ledmatrix.service"
$WEB_USER ALL=(ALL) NOPASSWD: $JOURNALCTL_PATH -u ledmatrix * echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH is-active ledmatrix"
$WEB_USER ALL=(ALL) NOPASSWD: $JOURNALCTL_PATH -t ledmatrix * echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH is-active ledmatrix.service"
$WEB_USER ALL=(ALL) NOPASSWD: $PYTHON_PATH $PROJECT_DIR/display_controller.py echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH start ledmatrix-web"
$WEB_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_DIR/start_display.sh echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH stop ledmatrix-web"
$WEB_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_DIR/stop_display.sh echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH restart ledmatrix-web"
EOF
# Optional: journalctl (non-critical — skip if not found)
if [ -n "$JOURNALCTL_PATH" ]; then
echo "$WEB_USER ALL=(ALL) NOPASSWD: $JOURNALCTL_PATH -u ledmatrix.service *"
echo "$WEB_USER ALL=(ALL) NOPASSWD: $JOURNALCTL_PATH -u ledmatrix *"
echo "$WEB_USER ALL=(ALL) NOPASSWD: $JOURNALCTL_PATH -t ledmatrix *"
fi
# Required: python3, bash
echo "$WEB_USER ALL=(ALL) NOPASSWD: $PYTHON_PATH $PROJECT_DIR/display_controller.py"
echo "$WEB_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_DIR/start_display.sh"
echo "$WEB_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_DIR/stop_display.sh"
echo ""
echo "# Allow web user to remove plugin directories via vetted helper script"
echo "# The helper validates that the target path resolves inside plugin-repos/ or plugins/"
echo "$WEB_USER ALL=(ALL) NOPASSWD: $BASH_PATH $SAFE_RM_PATH *"
} > "$TEMP_SUDOERS"
echo "" echo ""
echo "Generated sudoers configuration:" echo "Generated sudoers configuration:"
@@ -81,6 +125,7 @@ echo "- View system logs via journalctl"
echo "- Run display_controller.py directly" echo "- Run display_controller.py directly"
echo "- Execute start_display.sh and stop_display.sh" echo "- Execute start_display.sh and stop_display.sh"
echo "- Reboot and shutdown the system" echo "- Reboot and shutdown the system"
echo "- Remove plugin directories (for update/uninstall when root-owned files block deletion)"
echo "" echo ""
# Ask for confirmation # Ask for confirmation
@@ -94,6 +139,15 @@ fi
# Apply the configuration using visudo # Apply the configuration using visudo
echo "Applying sudoers configuration..." echo "Applying sudoers configuration..."
# Harden the helper script: root-owned, not writable by web user
echo "Hardening safe_plugin_rm.sh ownership..."
if ! sudo chown root:root "$SAFE_RM_PATH"; then
echo "Warning: Could not set ownership on $SAFE_RM_PATH"
fi
if ! sudo chmod 755 "$SAFE_RM_PATH"; then
echo "Warning: Could not set permissions on $SAFE_RM_PATH"
fi
if sudo cp "$TEMP_SUDOERS" /etc/sudoers.d/ledmatrix_web; then if sudo cp "$TEMP_SUDOERS" /etc/sudoers.d/ledmatrix_web; then
echo "Configuration applied successfully!" echo "Configuration applied successfully!"
echo "" echo ""

View File

@@ -1,343 +0,0 @@
#!/bin/bash
# Script to normalize all plugins as git submodules
# This ensures uniform plugin management across the repository
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
PLUGINS_DIR="$PROJECT_ROOT/plugins"
GITMODULES="$PROJECT_ROOT/.gitmodules"
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
log_info() {
echo -e "${BLUE}[INFO]${NC} $1"
}
log_success() {
echo -e "${GREEN}[SUCCESS]${NC} $1"
}
log_warn() {
echo -e "${YELLOW}[WARN]${NC} $1"
}
log_error() {
echo -e "${RED}[ERROR]${NC} $1"
}
# Check if a plugin is in .gitmodules
is_in_gitmodules() {
local plugin_path="$1"
git config -f "$GITMODULES" --get-regexp "^submodule\." | grep -q "path = $plugin_path$" || return 1
}
# Get submodule URL from .gitmodules
get_submodule_url() {
local plugin_path="$1"
git config -f "$GITMODULES" "submodule.$plugin_path.url" 2>/dev/null || echo ""
}
# Check if directory is a git repo
is_git_repo() {
[[ -d "$1/.git" ]]
}
# Get git remote URL
get_git_remote() {
local plugin_dir="$1"
if is_git_repo "$plugin_dir"; then
(cd "$plugin_dir" && git remote get-url origin 2>/dev/null || echo "")
else
echo ""
fi
}
# Check if directory is a symlink
is_symlink() {
[[ -L "$1" ]]
}
# Check if plugin has GitHub repo
has_github_repo() {
local plugin_name="$1"
local url="https://github.com/ChuckBuilds/ledmatrix-$plugin_name"
local status=$(curl -s -o /dev/null -w "%{http_code}" "$url" 2>/dev/null || echo "0")
[[ "$status" == "200" ]]
}
# Update .gitignore to allow a plugin submodule
update_gitignore() {
local plugin_name="$1"
local plugin_path="plugins/$plugin_name"
local gitignore="$PROJECT_ROOT/.gitignore"
# Check if already in .gitignore exceptions
if grep -q "!plugins/$plugin_name$" "$gitignore" 2>/dev/null; then
log_info "Plugin $plugin_name already in .gitignore exceptions"
return 0
fi
# Find the line with the last plugin exception
local last_line=$(grep -n "!plugins/" "$gitignore" | tail -1 | cut -d: -f1)
if [[ -z "$last_line" ]]; then
log_warn "Could not find plugin exceptions in .gitignore"
return 1
fi
# Add exceptions after the last plugin exception
log_info "Updating .gitignore to allow $plugin_name submodule"
sed -i "${last_line}a!plugins/$plugin_name\n!plugins/$plugin_name/" "$gitignore"
log_success "Updated .gitignore for $plugin_name"
}
# Re-initialize a submodule that appears as regular directory
reinit_submodule() {
local plugin_name="$1"
local plugin_path="plugins/$plugin_name"
local plugin_dir="$PLUGINS_DIR/$plugin_name"
log_info "Re-initializing submodule: $plugin_name"
if ! is_in_gitmodules "$plugin_path"; then
log_error "Plugin $plugin_name is not in .gitmodules"
return 1
fi
local submodule_url=$(get_submodule_url "$plugin_path")
if [[ -z "$submodule_url" ]]; then
log_error "Could not find URL for $plugin_name in .gitmodules"
return 1
fi
# If it's a symlink, remove it first
if is_symlink "$plugin_dir"; then
log_warn "Removing symlink: $plugin_dir"
rm "$plugin_dir"
fi
# If it's a regular directory with .git, we need to handle it carefully
if is_git_repo "$plugin_dir"; then
local remote_url=$(get_git_remote "$plugin_dir")
if [[ "$remote_url" == "$submodule_url" ]] || [[ "$remote_url" == "${submodule_url%.git}" ]] || [[ "${submodule_url%.git}" == "$remote_url" ]]; then
log_info "Directory is already the correct git repo, re-initializing submodule..."
# Remove from git index and re-add as submodule
git rm --cached "$plugin_path" 2>/dev/null || true
rm -rf "$plugin_dir"
else
log_warn "Directory has different remote ($remote_url vs $submodule_url)"
log_warn "Backing up to ${plugin_dir}.backup"
mv "$plugin_dir" "${plugin_dir}.backup"
fi
fi
# Re-add as submodule (use -f to force if needed)
if git submodule add -f "$submodule_url" "$plugin_path" 2>/dev/null; then
log_info "Submodule added successfully"
else
log_info "Submodule already exists, updating..."
git submodule update --init "$plugin_path"
fi
log_success "Re-initialized submodule: $plugin_name"
}
# Convert standalone git repo to submodule
convert_to_submodule() {
local plugin_name="$1"
local plugin_path="plugins/$plugin_name"
local plugin_dir="$PLUGINS_DIR/$plugin_name"
log_info "Converting to submodule: $plugin_name"
if is_in_gitmodules "$plugin_path"; then
log_warn "Plugin $plugin_name is already in .gitmodules, re-initializing instead"
reinit_submodule "$plugin_name"
return 0
fi
if ! is_git_repo "$plugin_dir"; then
log_error "Plugin $plugin_name is not a git repository"
return 1
fi
local remote_url=$(get_git_remote "$plugin_dir")
if [[ -z "$remote_url" ]]; then
log_error "Plugin $plugin_name has no remote URL"
return 1
fi
# If it's a symlink, we need to handle it differently
if is_symlink "$plugin_dir"; then
local target=$(readlink -f "$plugin_dir")
log_warn "Plugin is a symlink to $target"
log_warn "Removing symlink and adding as submodule"
rm "$plugin_dir"
# Update .gitignore first
update_gitignore "$plugin_name"
# Add as submodule
if git submodule add -f "$remote_url" "$plugin_path"; then
log_success "Added submodule: $plugin_name"
return 0
else
log_error "Failed to add submodule"
return 1
fi
fi
# Backup the directory
log_info "Backing up existing directory to ${plugin_dir}.backup"
mv "$plugin_dir" "${plugin_dir}.backup"
# Remove from git index
git rm --cached "$plugin_path" 2>/dev/null || true
# Update .gitignore first
update_gitignore "$plugin_name"
# Add as submodule (use -f to force if .gitignore blocks it)
if git submodule add -f "$remote_url" "$plugin_path"; then
log_success "Converted to submodule: $plugin_name"
log_warn "Backup saved at ${plugin_dir}.backup - you can remove it after verifying"
else
log_error "Failed to add submodule"
log_warn "Restoring backup..."
mv "${plugin_dir}.backup" "$plugin_dir"
return 1
fi
}
# Add new submodule for plugin with GitHub repo
add_new_submodule() {
local plugin_name="$1"
local plugin_path="plugins/$plugin_name"
local plugin_dir="$PLUGINS_DIR/$plugin_name"
local repo_url="https://github.com/ChuckBuilds/ledmatrix-$plugin_name.git"
log_info "Adding new submodule: $plugin_name"
if is_in_gitmodules "$plugin_path"; then
log_warn "Plugin $plugin_name is already in .gitmodules"
return 0
fi
if [[ -e "$plugin_dir" ]]; then
if is_symlink "$plugin_dir"; then
log_warn "Removing symlink: $plugin_dir"
rm "$plugin_dir"
elif is_git_repo "$plugin_dir"; then
log_warn "Directory exists as git repo, converting instead"
convert_to_submodule "$plugin_name"
return 0
else
log_warn "Backing up existing directory to ${plugin_dir}.backup"
mv "$plugin_dir" "${plugin_dir}.backup"
fi
fi
# Remove from git index if it exists
git rm --cached "$plugin_path" 2>/dev/null || true
# Update .gitignore first
update_gitignore "$plugin_name"
# Add as submodule (use -f to force if .gitignore blocks it)
if git submodule add -f "$repo_url" "$plugin_path"; then
log_success "Added new submodule: $plugin_name"
else
log_error "Failed to add submodule"
if [[ -e "${plugin_dir}.backup" ]]; then
log_warn "Restoring backup..."
mv "${plugin_dir}.backup" "$plugin_dir"
fi
return 1
fi
}
# Main processing function
main() {
cd "$PROJECT_ROOT"
log_info "Normalizing all plugins as git submodules..."
echo
# Step 1: Re-initialize submodules that appear as regular directories
log_info "Step 1: Re-initializing existing submodules..."
for plugin in basketball-scoreboard calendar clock-simple odds-ticker olympics-countdown soccer-scoreboard text-display mqtt-notifications; do
if [[ -d "$PLUGINS_DIR/$plugin" ]] && is_in_gitmodules "plugins/$plugin"; then
if ! git submodule status "plugins/$plugin" >/dev/null 2>&1; then
reinit_submodule "$plugin"
else
log_info "Submodule $plugin is already properly initialized"
fi
fi
done
echo
# Step 2: Convert standalone git repos to submodules
log_info "Step 2: Converting standalone git repos to submodules..."
for plugin in baseball-scoreboard ledmatrix-stocks; do
if [[ -d "$PLUGINS_DIR/$plugin" ]] && is_git_repo "$PLUGINS_DIR/$plugin"; then
if ! is_in_gitmodules "plugins/$plugin"; then
convert_to_submodule "$plugin"
fi
fi
done
echo
# Step 2b: Convert symlinks to submodules
log_info "Step 2b: Converting symlinks to submodules..."
for plugin in christmas-countdown ledmatrix-music static-image; do
if [[ -L "$PLUGINS_DIR/$plugin" ]]; then
if ! is_in_gitmodules "plugins/$plugin"; then
convert_to_submodule "$plugin"
fi
fi
done
echo
# Step 3: Add new submodules for plugins with GitHub repos
log_info "Step 3: Adding new submodules for plugins with GitHub repos..."
for plugin in football-scoreboard hockey-scoreboard; do
if [[ -d "$PLUGINS_DIR/$plugin" ]] && has_github_repo "$plugin"; then
if ! is_in_gitmodules "plugins/$plugin"; then
add_new_submodule "$plugin"
fi
fi
done
echo
# Step 4: Report on plugins without GitHub repos
log_info "Step 4: Checking plugins without GitHub repos..."
for plugin in ledmatrix-flights ledmatrix-leaderboard ledmatrix-weather; do
if [[ -d "$PLUGINS_DIR/$plugin" ]]; then
if ! is_in_gitmodules "plugins/$plugin" && ! is_git_repo "$PLUGINS_DIR/$plugin"; then
log_warn "Plugin $plugin has no GitHub repo and is not a git repo"
log_warn " This plugin may be local-only or needs a repository created"
fi
fi
done
echo
# Final: Initialize all submodules
log_info "Finalizing: Initializing all submodules..."
git submodule update --init --recursive
log_success "Plugin normalization complete!"
log_info "Run 'git status' to see changes"
log_info "Run 'git submodule status' to verify all submodules"
}
main "$@"

View File

@@ -1,151 +1,93 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
Setup plugin repository references for multi-root workspace. Setup plugin repository symlinks for local development.
This script creates symlinks in plugin-repos/ pointing to the actual Creates symlinks in plugin-repos/ pointing to plugin directories
plugin repositories in the parent directory, allowing the system to in the ledmatrix-plugins monorepo.
find plugins without modifying the LEDMatrix project structure.
""" """
import json import json
import os import os
import re
import sys import sys
from pathlib import Path from pathlib import Path
# Paths
PROJECT_ROOT = Path(__file__).parent.parent PROJECT_ROOT = Path(__file__).parent.parent
PLUGIN_REPOS_DIR = PROJECT_ROOT / "plugin-repos" PLUGIN_REPOS_DIR = PROJECT_ROOT / "plugin-repos"
GITHUB_DIR = PROJECT_ROOT.parent MONOREPO_PLUGINS = PROJECT_ROOT.parent / "ledmatrix-plugins" / "plugins"
CONFIG_FILE = PROJECT_ROOT / "config" / "config.json"
def get_workspace_plugins(): def parse_json_with_trailing_commas(text: str) -> dict:
"""Get list of plugins from workspace file.""" """Parse JSON that may have trailing commas.
workspace_file = PROJECT_ROOT / "LEDMatrix.code-workspace"
if not workspace_file.exists():
return []
try: Note: The regex also matches commas inside string values (e.g., "hello, }").
with open(workspace_file, 'r') as f: This is fine for manifest files but may corrupt complex JSON with such patterns.
workspace = json.load(f) """
except json.JSONDecodeError as e: text = re.sub(r",\s*([}\]])", r"\1", text)
print(f"Error: Failed to parse workspace file {workspace_file}: {e}") return json.loads(text)
print("Please check that the workspace file contains valid JSON.")
return []
plugins = []
for folder in workspace.get('folders', []):
path = folder.get('path', '')
if path.startswith('../') and path != '../ledmatrix-plugins':
plugin_name = path.replace('../', '')
plugins.append({
'name': plugin_name,
'workspace_path': path,
'actual_path': GITHUB_DIR / plugin_name,
'link_path': PLUGIN_REPOS_DIR / plugin_name
})
return plugins
def create_symlinks(): def create_symlinks() -> bool:
"""Create symlinks in plugin-repos/ pointing to actual repos.""" """Create symlinks in plugin-repos/ pointing to monorepo plugin dirs."""
plugins = get_workspace_plugins() if not MONOREPO_PLUGINS.exists():
print(f"Error: Monorepo plugins directory not found: {MONOREPO_PLUGINS}")
if not plugins:
print("No plugins found in workspace configuration")
return False return False
# Ensure plugin-repos directory exists
PLUGIN_REPOS_DIR.mkdir(exist_ok=True) PLUGIN_REPOS_DIR.mkdir(exist_ok=True)
created = 0 created = 0
skipped = 0 skipped = 0
errors = 0
print(f"Setting up plugin repository links...") print("Setting up plugin symlinks...")
print(f" Source: {GITHUB_DIR}") print(f" Source: {MONOREPO_PLUGINS}")
print(f" Links: {PLUGIN_REPOS_DIR}") print(f" Links: {PLUGIN_REPOS_DIR}")
print() print()
for plugin in plugins: for plugin_dir in sorted(MONOREPO_PLUGINS.iterdir()):
actual_path = plugin['actual_path'] if not plugin_dir.is_dir():
link_path = plugin['link_path'] continue
manifest_path = plugin_dir / "manifest.json"
if not actual_path.exists(): if not manifest_path.exists():
print(f" ⚠️ {plugin['name']} - source not found: {actual_path}")
errors += 1
continue continue
# Remove existing link/file if it exists try:
with open(manifest_path, "r", encoding="utf-8") as f:
manifest = parse_json_with_trailing_commas(f.read())
except (OSError, json.JSONDecodeError) as e:
print(f" {plugin_dir.name} - failed to read {manifest_path}: {e}")
continue
plugin_id = manifest.get("id", plugin_dir.name)
link_path = PLUGIN_REPOS_DIR / plugin_id
if link_path.exists() or link_path.is_symlink(): if link_path.exists() or link_path.is_symlink():
if link_path.is_symlink(): if link_path.is_symlink():
# Check if it points to the right place
try: try:
if link_path.resolve() == actual_path.resolve(): if link_path.resolve() == plugin_dir.resolve():
print(f"{plugin['name']} - link already exists")
skipped += 1 skipped += 1
continue continue
else: else:
# Remove old symlink pointing elsewhere
link_path.unlink() link_path.unlink()
except Exception as e: except OSError:
print(f" ⚠️ {plugin['name']} - error checking link: {e}")
link_path.unlink() link_path.unlink()
else: else:
# It's a directory/file, not a symlink print(f" {plugin_id} - exists but is not a symlink, skipping")
print(f" ⚠️ {plugin['name']} - {link_path.name} exists but is not a symlink")
print(f" Skipping (manual cleanup required)")
skipped += 1 skipped += 1
continue continue
# Create symlink relative_path = os.path.relpath(plugin_dir, link_path.parent)
try: link_path.symlink_to(relative_path)
# Use relative path for symlink portability print(f" {plugin_id} - linked")
relative_path = os.path.relpath(actual_path, link_path.parent) created += 1
link_path.symlink_to(relative_path)
print(f"{plugin['name']} - linked")
created += 1
except Exception as e:
print(f"{plugin['name']} - failed to create link: {e}")
errors += 1
print() print(f"\nCreated {created} links, skipped {skipped}")
print(f"✅ Created {created} links, skipped {skipped}, errors {errors}") return True
return errors == 0
def update_config_path():
"""Update config to use absolute path to parent directory (alternative approach)."""
# This is an alternative - set plugins_directory to absolute path
# Currently not implemented as symlinks are preferred
pass
def main(): def main():
"""Main function.""" print("Setting up plugin repository symlinks from monorepo...\n")
print("🔗 Setting up plugin repository symlinks...") if not create_symlinks():
print() sys.exit(1)
if not GITHUB_DIR.exists():
print(f"Error: GitHub directory not found: {GITHUB_DIR}")
return 1
success = create_symlinks()
if success:
print()
print("✅ Plugin repository setup complete!")
print()
print("Plugins are now accessible via symlinks in plugin-repos/")
print("You can update plugins independently in their git repos.")
return 0
else:
print()
print("⚠️ Setup completed with some errors. Check output above.")
return 1
if __name__ == '__main__': if __name__ == "__main__":
sys.exit(main()) main()

View File

@@ -1,123 +1,43 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
Update all plugin repositories by pulling the latest changes. Update the ledmatrix-plugins monorepo by pulling latest changes.
This script updates all plugin repos without needing to modify
the LEDMatrix project itself.
""" """
import json
import os
import subprocess import subprocess
import sys import sys
from pathlib import Path from pathlib import Path
# Paths MONOREPO_DIR = Path(__file__).parent.parent.parent / "ledmatrix-plugins"
WORKSPACE_FILE = Path(__file__).parent.parent / "LEDMatrix.code-workspace"
GITHUB_DIR = Path(__file__).parent.parent.parent
def load_workspace_plugins():
"""Load plugin paths from workspace file."""
try:
with open(WORKSPACE_FILE, 'r', encoding='utf-8') as f:
workspace = json.load(f)
except FileNotFoundError:
print(f"Error: Workspace file not found: {WORKSPACE_FILE}")
return []
except PermissionError as e:
print(f"Error: Permission denied reading workspace file {WORKSPACE_FILE}: {e}")
return []
except json.JSONDecodeError as e:
print(f"Error: Invalid JSON in workspace file {WORKSPACE_FILE}: {e}")
return []
plugins = []
for folder in workspace.get('folders', []):
path = folder.get('path', '')
name = folder.get('name', '')
# Only process plugin folders (those starting with ../)
if path.startswith('../') and path != '../ledmatrix-plugins':
plugin_name = path.replace('../', '')
plugin_path = GITHUB_DIR / plugin_name
if plugin_path.exists():
plugins.append({
'name': plugin_name,
'display_name': name,
'path': plugin_path
})
return plugins
def update_repo(repo_path):
"""Update a git repository by pulling latest changes."""
if not (repo_path / '.git').exists():
print(f" ⚠️ {repo_path.name} is not a git repository, skipping")
return False
try:
# Fetch latest changes
fetch_result = subprocess.run(['git', 'fetch', 'origin'],
cwd=repo_path, capture_output=True, text=True)
if fetch_result.returncode != 0:
print(f" ✗ Failed to fetch {repo_path.name}: {fetch_result.stderr.strip()}")
return False
# Get current branch
branch_result = subprocess.run(['git', 'rev-parse', '--abbrev-ref', 'HEAD'],
cwd=repo_path, capture_output=True, text=True)
current_branch = branch_result.stdout.strip() if branch_result.returncode == 0 else 'main'
# Pull latest changes
pull_result = subprocess.run(['git', 'pull', 'origin', current_branch],
cwd=repo_path, capture_output=True, text=True)
if pull_result.returncode == 0:
# Check if there were actual updates
if 'Already up to date' in pull_result.stdout:
print(f"{repo_path.name} is up to date")
else:
print(f" ✓ Updated {repo_path.name}")
return True
else:
print(f" ✗ Failed to update {repo_path.name}: {pull_result.stderr.strip()}")
return False
except (subprocess.SubprocessError, OSError) as e:
print(f" ✗ Error updating {repo_path.name}: {e}")
return False
def main(): def main():
"""Main function.""" if not MONOREPO_DIR.exists():
print("🔍 Finding plugin repositories...") print(f"Error: Monorepo not found: {MONOREPO_DIR}")
plugins = load_workspace_plugins()
if not plugins:
print(" No plugin repositories found!")
return 1 return 1
print(f" Found {len(plugins)} plugin repositories") if not (MONOREPO_DIR / ".git").exists():
print(f"\n🚀 Updating plugins in {GITHUB_DIR}...") print(f"Error: {MONOREPO_DIR} is not a git repository")
print()
success_count = 0
for plugin in plugins:
print(f"Updating {plugin['name']}...")
if update_repo(plugin['path']):
success_count += 1
print()
print(f"\n✅ Updated {success_count}/{len(plugins)} plugins successfully!")
if success_count < len(plugins):
print("⚠️ Some plugins failed to update. Check the errors above.")
return 1 return 1
return 0 print(f"Updating {MONOREPO_DIR}...")
try:
result = subprocess.run(
["git", "-C", str(MONOREPO_DIR), "pull"],
capture_output=True,
text=True,
timeout=120,
)
except subprocess.TimeoutExpired:
print(f"Error: git pull timed out after 120 seconds for {MONOREPO_DIR}")
return 1
if result.returncode == 0:
print(result.stdout.strip())
return 0
else:
print(f"Error: {result.stderr.strip()}")
return 1
if __name__ == '__main__': if __name__ == "__main__":
sys.exit(main()) sys.exit(main())

View File

@@ -8,6 +8,13 @@ from pathlib import Path
PROJECT_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) PROJECT_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
CONFIG_FILE = os.path.join(PROJECT_DIR, 'config', 'config.json') CONFIG_FILE = os.path.join(PROJECT_DIR, 'config', 'config.json')
WEB_INTERFACE_SCRIPT = os.path.join(PROJECT_DIR, 'web_interface', 'start.py') WEB_INTERFACE_SCRIPT = os.path.join(PROJECT_DIR, 'web_interface', 'start.py')
# Marker file created by first_time_install.sh to indicate dependencies are installed
DEPS_MARKER = os.path.join(PROJECT_DIR, '.web_deps_installed')
def dependencies_installed():
"""Check if dependencies were installed during first-time setup."""
return os.path.exists(DEPS_MARKER)
def install_dependencies(): def install_dependencies():
"""Install required dependencies using system Python.""" """Install required dependencies using system Python."""
@@ -86,10 +93,17 @@ def main():
if is_enabled: if is_enabled:
print("Configuration 'web_display_autostart' is enabled. Starting web interface...") print("Configuration 'web_display_autostart' is enabled. Starting web interface...")
# Install dependencies # Only install dependencies if not already done during first-time setup
if not install_dependencies(): if not dependencies_installed():
print("Failed to install dependencies. Exiting.") print("First run detected: Installing dependencies...")
sys.exit(1) if not install_dependencies():
print("Failed to install dependencies. Exiting.")
sys.exit(1)
# Create marker file after successful install
Path(DEPS_MARKER).touch()
print("Dependencies installed and marker file created.")
else:
print("Dependencies already installed (marker file found). Skipping installation.")
try: try:
# Replace the current process with web_interface.py using system Python # Replace the current process with web_interface.py using system Python

View File

@@ -387,43 +387,8 @@ class FootballLive(Football, SportsLive):
main_img = main_img.convert('RGB') # Convert for display main_img = main_img.convert('RGB') # Convert for display
# Display the final image # Display the final image
# #region agent log
import json
import time
try:
with open('/home/chuck/Github/LEDMatrix/.cursor/debug.log', 'a') as f:
f.write(json.dumps({
"sessionId": "debug-session",
"runId": "run1",
"hypothesisId": "C",
"location": "football.py:390",
"message": "About to update display",
"data": {
"force_clear": force_clear,
"game": game.get('away_abbr', '') + "@" + game.get('home_abbr', '')
},
"timestamp": int(time.time() * 1000)
}) + "\n")
except: pass
# #endregion
self.display_manager.image.paste(main_img, (0, 0)) self.display_manager.image.paste(main_img, (0, 0))
self.display_manager.update_display() # Update display here for live self.display_manager.update_display() # Update display here for live
# #region agent log
try:
with open('/home/chuck/Github/LEDMatrix/.cursor/debug.log', 'a') as f:
f.write(json.dumps({
"sessionId": "debug-session",
"runId": "run1",
"hypothesisId": "C",
"location": "football.py:392",
"message": "After update display",
"data": {
"force_clear": force_clear
},
"timestamp": int(time.time() * 1000)
}) + "\n")
except: pass
# #endregion
except Exception as e: except Exception as e:
self.logger.error(f"Error displaying live Football game: {e}", exc_info=True) # Changed log prefix self.logger.error(f"Error displaying live Football game: {e}", exc_info=True) # Changed log prefix

View File

@@ -207,25 +207,6 @@ class SportsCore(ABC):
def display(self, force_clear: bool = False) -> bool: def display(self, force_clear: bool = False) -> bool:
"""Common display method for all NCAA FB managers""" # Updated docstring """Common display method for all NCAA FB managers""" # Updated docstring
# #region agent log
import json
try:
with open('/home/chuck/Github/LEDMatrix/.cursor/debug.log', 'a') as f:
f.write(json.dumps({
"sessionId": "debug-session",
"runId": "run1",
"hypothesisId": "D",
"location": "sports.py:208",
"message": "Display called",
"data": {
"force_clear": force_clear,
"has_current_game": self.current_game is not None,
"current_game": self.current_game['away_abbr'] + "@" + self.current_game['home_abbr'] if self.current_game else None
},
"timestamp": int(time.time() * 1000)
}) + "\n")
except: pass
# #endregion
if not self.is_enabled: # Check if module is enabled if not self.is_enabled: # Check if module is enabled
return False return False
@@ -248,40 +229,7 @@ class SportsCore(ABC):
return False return False
try: try:
# #region agent log
try:
with open('/home/chuck/Github/LEDMatrix/.cursor/debug.log', 'a') as f:
f.write(json.dumps({
"sessionId": "debug-session",
"runId": "run1",
"hypothesisId": "D",
"location": "sports.py:232",
"message": "About to draw scorebug",
"data": {
"force_clear": force_clear,
"game": self.current_game['away_abbr'] + "@" + self.current_game['home_abbr'] if self.current_game else None
},
"timestamp": int(time.time() * 1000)
}) + "\n")
except: pass
# #endregion
self._draw_scorebug_layout(self.current_game, force_clear) self._draw_scorebug_layout(self.current_game, force_clear)
# #region agent log
try:
with open('/home/chuck/Github/LEDMatrix/.cursor/debug.log', 'a') as f:
f.write(json.dumps({
"sessionId": "debug-session",
"runId": "run1",
"hypothesisId": "D",
"location": "sports.py:235",
"message": "After draw scorebug",
"data": {
"force_clear": force_clear
},
"timestamp": int(time.time() * 1000)
}) + "\n")
except: pass
# #endregion
# display_manager.update_display() should be called within subclass draw methods # display_manager.update_display() should be called within subclass draw methods
# or after calling display() in the main loop. Let's keep it out of the base display. # or after calling display() in the main loop. Let's keep it out of the base display.
return True return True
@@ -942,7 +890,7 @@ class SportsUpcoming(SportsCore):
away_text = '' away_text = ''
elif self.show_ranking: elif self.show_ranking:
# Show ranking only if available # Show ranking only if available
away_rank = rankself._team_rankings_cacheings.get(away_abbr, 0) away_rank = self._team_rankings_cache.get(away_abbr, 0)
if away_rank > 0: if away_rank > 0:
away_text = f"#{away_rank}" away_text = f"#{away_rank}"
else: else:
@@ -1443,48 +1391,9 @@ class SportsLive(SportsCore):
self.live_games = sorted(new_live_games, key=lambda g: g.get('start_time_utc') or datetime.now(timezone.utc)) # Sort by start time self.live_games = sorted(new_live_games, key=lambda g: g.get('start_time_utc') or datetime.now(timezone.utc)) # Sort by start time
# Reset index if current game is gone or list is new # Reset index if current game is gone or list is new
if not self.current_game or self.current_game['id'] not in new_game_ids: if not self.current_game or self.current_game['id'] not in new_game_ids:
# #region agent log
import json
try:
with open('/home/chuck/Github/LEDMatrix/.cursor/debug.log', 'a') as f:
f.write(json.dumps({
"sessionId": "debug-session",
"runId": "run1",
"hypothesisId": "B",
"location": "sports.py:1393",
"message": "Games loaded - resetting index and last_game_switch",
"data": {
"current_game_before": self.current_game['id'] if self.current_game else None,
"live_games_count": len(self.live_games),
"last_game_switch_before": self.last_game_switch,
"current_time": current_time,
"time_since_init": current_time - self.last_game_switch if self.last_game_switch > 0 else None
},
"timestamp": int(time.time() * 1000)
}) + "\n")
except: pass
# #endregion
self.current_game_index = 0 self.current_game_index = 0
self.current_game = self.live_games[0] if self.live_games else None self.current_game = self.live_games[0] if self.live_games else None
self.last_game_switch = current_time self.last_game_switch = current_time
# #region agent log
try:
with open('/home/chuck/Github/LEDMatrix/.cursor/debug.log', 'a') as f:
f.write(json.dumps({
"sessionId": "debug-session",
"runId": "run1",
"hypothesisId": "B",
"location": "sports.py:1396",
"message": "Games loaded - after setting last_game_switch",
"data": {
"current_game_after": self.current_game['id'] if self.current_game else None,
"last_game_switch_after": self.last_game_switch,
"first_game": self.current_game['away_abbr'] + "@" + self.current_game['home_abbr'] if self.current_game else None
},
"timestamp": int(time.time() * 1000)
}) + "\n")
except: pass
# #endregion
else: else:
# Find current game's new index if it still exists # Find current game's new index if it still exists
try: try:
@@ -1530,70 +1439,9 @@ class SportsLive(SportsCore):
# Handle game switching (outside test mode check) # Handle game switching (outside test mode check)
# Fix: Don't check for switching if last_game_switch is still 0 (games haven't been loaded yet) # Fix: Don't check for switching if last_game_switch is still 0 (games haven't been loaded yet)
# This prevents immediate switching when the system has been running for a while before games load # This prevents immediate switching when the system has been running for a while before games load
# #region agent log
import json
try:
with open('/home/chuck/Github/LEDMatrix/.cursor/debug.log', 'a') as f:
f.write(json.dumps({
"sessionId": "debug-session",
"runId": "run1",
"hypothesisId": "A",
"location": "sports.py:1432",
"message": "Game switch check - before condition",
"data": {
"test_mode": self.test_mode,
"live_games_count": len(self.live_games),
"current_time": current_time,
"last_game_switch": self.last_game_switch,
"time_since_switch": current_time - self.last_game_switch,
"game_display_duration": self.game_display_duration,
"current_game_index": self.current_game_index,
"will_switch": not self.test_mode and len(self.live_games) > 1 and self.last_game_switch > 0 and (current_time - self.last_game_switch) >= self.game_display_duration
},
"timestamp": int(time.time() * 1000)
}) + "\n")
except: pass
# #endregion
if not self.test_mode and len(self.live_games) > 1 and self.last_game_switch > 0 and (current_time - self.last_game_switch) >= self.game_display_duration: if not self.test_mode and len(self.live_games) > 1 and self.last_game_switch > 0 and (current_time - self.last_game_switch) >= self.game_display_duration:
# #region agent log
try:
with open('/home/chuck/Github/LEDMatrix/.cursor/debug.log', 'a') as f:
f.write(json.dumps({
"sessionId": "debug-session",
"runId": "run1",
"hypothesisId": "A",
"location": "sports.py:1433",
"message": "Game switch triggered",
"data": {
"old_index": self.current_game_index,
"old_game": self.current_game['away_abbr'] + "@" + self.current_game['home_abbr'] if self.current_game else None,
"time_since_switch": current_time - self.last_game_switch,
"last_game_switch_before": self.last_game_switch
},
"timestamp": int(time.time() * 1000)
}) + "\n")
except: pass
# #endregion
self.current_game_index = (self.current_game_index + 1) % len(self.live_games) self.current_game_index = (self.current_game_index + 1) % len(self.live_games)
self.current_game = self.live_games[self.current_game_index] self.current_game = self.live_games[self.current_game_index]
self.last_game_switch = current_time self.last_game_switch = current_time
# #region agent log
try:
with open('/home/chuck/Github/LEDMatrix/.cursor/debug.log', 'a') as f:
f.write(json.dumps({
"sessionId": "debug-session",
"runId": "run1",
"hypothesisId": "A",
"location": "sports.py:1436",
"message": "Game switch completed",
"data": {
"new_index": self.current_game_index,
"new_game": self.current_game['away_abbr'] + "@" + self.current_game['home_abbr'] if self.current_game else None,
"last_game_switch_after": self.last_game_switch
},
"timestamp": int(time.time() * 1000)
}) + "\n")
except: pass
# #endregion
self.logger.info(f"Switched live view to: {self.current_game['away_abbr']}@{self.current_game['home_abbr']}") # Changed log prefix self.logger.info(f"Switched live view to: {self.current_game['away_abbr']}@{self.current_game['home_abbr']}") # Changed log prefix
# Force display update via flag or direct call if needed, but usually let main loop handle # Force display update via flag or direct call if needed, but usually let main loop handle

View File

@@ -8,11 +8,33 @@ files that need to be accessible by both root service and web user.
import os import os
import logging import logging
import shutil as _shutil
import subprocess
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# System directories that should never have their permissions modified
# These directories have special system-level permissions that must be preserved
PROTECTED_SYSTEM_DIRECTORIES = {
'/tmp',
'/var/tmp',
'/dev',
'/proc',
'/sys',
'/run',
'/var/run',
'/etc',
'/boot',
'/var',
'/usr',
'/lib',
'/lib64',
'/bin',
'/sbin',
}
def ensure_directory_permissions(path: Path, mode: int = 0o775) -> None: def ensure_directory_permissions(path: Path, mode: int = 0o775) -> None:
""" """
@@ -23,6 +45,9 @@ def ensure_directory_permissions(path: Path, mode: int = 0o775) -> None:
raising an exception. This allows the system to work even when running raising an exception. This allows the system to work even when running
as a non-root user who cannot change permissions on existing directories. as a non-root user who cannot change permissions on existing directories.
Protected system directories (like /tmp, /etc, /var) are never modified
to prevent breaking system functionality.
Args: Args:
path: Directory path to create/ensure path: Directory path to create/ensure
mode: Permission mode (default: 0o775 for group-writable directories) mode: Permission mode (default: 0o775 for group-writable directories)
@@ -31,6 +56,19 @@ def ensure_directory_permissions(path: Path, mode: int = 0o775) -> None:
OSError: If directory creation fails or directory exists but is not usable OSError: If directory creation fails or directory exists but is not usable
""" """
try: try:
# Never modify permissions on system directories
path_str = str(path.resolve() if path.is_absolute() else path)
if path_str in PROTECTED_SYSTEM_DIRECTORIES:
logger.debug(f"Skipping permission modification on protected system directory: {path_str}")
# Verify the directory is usable
if path.exists() and os.access(path, os.R_OK | os.W_OK):
return
elif path.exists():
logger.warning(f"Protected system directory {path_str} exists but is not writable")
return
else:
raise OSError(f"Protected system directory {path_str} does not exist")
# Create directory if it doesn't exist # Create directory if it doesn't exist
path.mkdir(parents=True, exist_ok=True) path.mkdir(parents=True, exist_ok=True)
@@ -162,3 +200,90 @@ def get_cache_dir_mode() -> int:
""" """
return 0o2775 # rwxrwsr-x (setgid + group writable) return 0o2775 # rwxrwsr-x (setgid + group writable)
def sudo_remove_directory(path: Path, allowed_bases: Optional[list] = None) -> bool:
"""
Remove a directory using sudo as a last resort.
Used when normal removal fails due to root-owned files (e.g., __pycache__
directories created by the root ledmatrix service). Delegates to the
safe_plugin_rm.sh helper which validates the path is inside allowed
plugin directories.
Before invoking sudo, this function also validates that the resolved
path is a descendant of at least one allowed base directory.
Args:
path: Directory path to remove
allowed_bases: List of allowed parent directories. If None, defaults
to plugin-repos/ and plugins/ under the project root.
Returns:
True if removal succeeded, False otherwise
"""
# Determine project root (permission_utils.py is at src/common/)
project_root = Path(__file__).resolve().parent.parent.parent
if allowed_bases is None:
allowed_bases = [
project_root / "plugin-repos",
project_root / "plugins",
]
# Resolve the target path to prevent symlink/traversal tricks
try:
resolved = path.resolve()
except (OSError, ValueError) as e:
logger.error(f"Cannot resolve path {path}: {e}")
return False
# Validate the resolved path is a strict child of an allowed base
is_allowed = False
for base in allowed_bases:
try:
base_resolved = base.resolve()
if resolved != base_resolved and resolved.is_relative_to(base_resolved):
is_allowed = True
break
except (OSError, ValueError):
continue
if not is_allowed:
logger.error(
f"sudo_remove_directory DENIED: {resolved} is not inside "
f"allowed bases {[str(b) for b in allowed_bases]}"
)
return False
# Use the safe_plugin_rm.sh helper which does its own validation
helper_script = project_root / "scripts" / "fix_perms" / "safe_plugin_rm.sh"
if not helper_script.exists():
logger.error(f"Safe removal helper not found: {helper_script}")
return False
bash_path = _shutil.which('bash') or '/bin/bash'
try:
result = subprocess.run(
['sudo', '-n', bash_path, str(helper_script), str(resolved)],
capture_output=True,
text=True,
timeout=30
)
if result.returncode == 0 and not resolved.exists():
logger.info(f"Successfully removed {path} via sudo helper")
return True
else:
stderr = result.stderr.strip()
logger.error(f"sudo helper failed for {path}: {stderr}")
return False
except subprocess.TimeoutExpired:
logger.error(f"sudo helper timed out for {path}")
return False
except FileNotFoundError:
logger.error("sudo command not found on system")
return False
except Exception as e:
logger.error(f"Unexpected error during sudo helper for {path}: {e}")
return False

View File

@@ -240,7 +240,7 @@ class ScrollHelper:
# Move pixels (can move multiple steps if lag occurred, but cap to prevent huge jumps) # Move pixels (can move multiple steps if lag occurred, but cap to prevent huge jumps)
steps = int(time_since_last_step / self.scroll_delay) steps = int(time_since_last_step / self.scroll_delay)
# Cap at reasonable number to prevent huge jumps from lag # Cap at reasonable number to prevent huge jumps from lag
max_steps = max(1, int(0.1 / self.scroll_delay)) # Allow up to 0.1s of catch-up max_steps = max(1, int(0.04 / self.scroll_delay)) # Limit to 0.04s (2 steps at 50 FPS) for smoother scrolling
steps = min(steps, max_steps) steps = min(steps, max_steps)
pixels_to_move = self.scroll_speed * steps pixels_to_move = self.scroll_speed * steps
# Update last_step_time, preserving fractional delay for smooth timing # Update last_step_time, preserving fractional delay for smooth timing

View File

@@ -7,6 +7,7 @@ from pathlib import Path
from typing import Dict, Any, List, Optional from typing import Dict, Any, List, Optional
from datetime import datetime from datetime import datetime
from concurrent.futures import ThreadPoolExecutor, as_completed # pylint: disable=no-name-in-module from concurrent.futures import ThreadPoolExecutor, as_completed # pylint: disable=no-name-in-module
import pytz
# Core system imports only - all functionality now handled via plugins # Core system imports only - all functionality now handled via plugins
from src.display_manager import DisplayManager from src.display_manager import DisplayManager
@@ -18,6 +19,10 @@ from src.logging_config import get_logger
# Get logger with consistent configuration # Get logger with consistent configuration
logger = get_logger(__name__) logger = get_logger(__name__)
# Vegas mode import (lazy loaded to avoid circular imports)
_vegas_mode_imported = False
VegasModeCoordinator = None
DEFAULT_DYNAMIC_DURATION_CAP = 180.0 DEFAULT_DYNAMIC_DURATION_CAP = 180.0
# WiFi status message file path (same as used in wifi_manager.py) # WiFi status message file path (same as used in wifi_manager.py)
@@ -331,6 +336,11 @@ class DisplayController:
self.is_display_active = True self.is_display_active = True
self._was_display_active = True # Track previous state for schedule change detection self._was_display_active = True # Track previous state for schedule change detection
# Brightness state tracking for dim schedule
self.current_brightness = self.config.get('display', {}).get('hardware', {}).get('brightness', 90)
self.is_dimmed = False
self._was_dimmed = False
# Publish initial on-demand state # Publish initial on-demand state
try: try:
self._publish_on_demand_state() self._publish_on_demand_state()
@@ -343,8 +353,87 @@ class DisplayController:
self._update_modules() self._update_modules()
logger.info("Initial plugin update completed in %.3f seconds", time.time() - update_start) logger.info("Initial plugin update completed in %.3f seconds", time.time() - update_start)
# Initialize Vegas mode coordinator
self.vegas_coordinator = None
self._initialize_vegas_mode()
logger.info("DisplayController initialization completed in %.3f seconds", time.time() - start_time) logger.info("DisplayController initialization completed in %.3f seconds", time.time() - start_time)
def _initialize_vegas_mode(self):
"""Initialize Vegas mode coordinator if enabled."""
global _vegas_mode_imported, VegasModeCoordinator
vegas_config = self.config.get('display', {}).get('vegas_scroll', {})
if not vegas_config.get('enabled', False):
logger.debug("Vegas mode disabled in config")
return
if self.plugin_manager is None:
logger.warning("Vegas mode skipped: plugin_manager is None")
return
try:
# Lazy import to avoid circular imports
if not _vegas_mode_imported:
try:
from src.vegas_mode import VegasModeCoordinator as VMC
VegasModeCoordinator = VMC
_vegas_mode_imported = True
except ImportError:
logger.exception("Failed to import Vegas mode module")
return
self.vegas_coordinator = VegasModeCoordinator(
config=self.config,
display_manager=self.display_manager,
plugin_manager=self.plugin_manager
)
# Set up live priority checker
self.vegas_coordinator.set_live_priority_checker(self._check_live_priority)
# Set up interrupt checker for on-demand/wifi status
self.vegas_coordinator.set_interrupt_checker(
self._check_vegas_interrupt,
check_interval=10 # Check every 10 frames (~80ms at 125 FPS)
)
logger.info("Vegas mode coordinator initialized")
except Exception as e:
logger.error("Failed to initialize Vegas mode: %s", e, exc_info=True)
self.vegas_coordinator = None
def _is_vegas_mode_active(self) -> bool:
"""Check if Vegas mode should be running."""
if not self.vegas_coordinator:
return False
if not self.vegas_coordinator.is_enabled:
return False
if self.on_demand_active:
return False # On-demand takes priority
return True
def _check_vegas_interrupt(self) -> bool:
"""
Check if Vegas should yield control for higher priority events.
Called periodically by Vegas coordinator to allow responsive
handling of on-demand requests, wifi status, etc.
Returns:
True if Vegas should yield control, False to continue
"""
# Check for pending on-demand request
if self.on_demand_active:
return True
# Check for wifi status that needs display
if self._check_wifi_status_message():
return True
return False
def _check_schedule(self): def _check_schedule(self):
"""Check if display should be active based on schedule.""" """Check if display should be active based on schedule."""
schedule_config = self.config.get('schedule', {}) schedule_config = self.config.get('schedule', {})
@@ -363,7 +452,16 @@ class DisplayController:
logger.debug("Schedule is disabled - display always active") logger.debug("Schedule is disabled - display always active")
return return
current_time = datetime.now() # Get configured timezone, default to UTC
timezone_str = self.config.get('timezone', 'UTC')
try:
tz = pytz.timezone(timezone_str)
except pytz.UnknownTimeZoneError:
logger.warning(f"Unknown timezone '{timezone_str}', using UTC")
tz = pytz.UTC
# Use timezone-aware current time
current_time = datetime.now(tz)
current_day = current_time.strftime('%A').lower() # Get day name (monday, tuesday, etc.) current_day = current_time.strftime('%A').lower() # Get day name (monday, tuesday, etc.)
current_time_only = current_time.time() current_time_only = current_time.time()
@@ -445,6 +543,91 @@ class DisplayController:
self.is_display_active = True self.is_display_active = True
self._was_display_active = True # Track previous state for schedule change detection self._was_display_active = True # Track previous state for schedule change detection
def _check_dim_schedule(self) -> int:
"""
Check if display should be dimmed based on dim schedule.
Returns:
Target brightness level (dim_brightness if in dim period,
normal brightness otherwise)
"""
# Get normal brightness from config
normal_brightness = self.config.get('display', {}).get('hardware', {}).get('brightness', 90)
# If display is OFF via schedule, don't process dim schedule
if not self.is_display_active:
self.is_dimmed = False
return normal_brightness
dim_config = self.config.get('dim_schedule', {})
# If dim schedule doesn't exist or is disabled, use normal brightness
if not dim_config or not dim_config.get('enabled', False):
self.is_dimmed = False
return normal_brightness
# Get configured timezone
timezone_str = self.config.get('timezone', 'UTC')
try:
tz = pytz.timezone(timezone_str)
except pytz.UnknownTimeZoneError:
logger.warning(f"Unknown timezone '{timezone_str}' in dim schedule, using UTC")
tz = pytz.UTC
current_time = datetime.now(tz)
current_day = current_time.strftime('%A').lower()
current_time_only = current_time.time()
# Determine if using per-day or global dim schedule
# Normalize mode to handle both "per-day" and "per_day" variants
mode = dim_config.get('mode', 'global')
mode_normalized = mode.replace('_', '-') if mode else 'global'
days_config = dim_config.get('days')
use_per_day = mode_normalized == 'per-day' and days_config and current_day in days_config
if use_per_day:
day_config = days_config[current_day]
if not day_config.get('enabled', True):
self.is_dimmed = False
return normal_brightness
start_time_str = day_config.get('start_time', '20:00')
end_time_str = day_config.get('end_time', '07:00')
else:
start_time_str = dim_config.get('start_time', '20:00')
end_time_str = dim_config.get('end_time', '07:00')
try:
start_time = datetime.strptime(start_time_str, '%H:%M').time()
end_time = datetime.strptime(end_time_str, '%H:%M').time()
# Determine if currently in dim period
if start_time <= end_time:
# Same-day schedule (e.g., 10:00 to 18:00)
in_dim_period = start_time <= current_time_only <= end_time
else:
# Overnight schedule (e.g., 20:00 to 07:00)
in_dim_period = current_time_only >= start_time or current_time_only <= end_time
if in_dim_period:
self.is_dimmed = True
target_brightness = dim_config.get('dim_brightness', 30)
else:
self.is_dimmed = False
target_brightness = normal_brightness
# Log state changes
if self.is_dimmed and not self._was_dimmed:
logger.info(f"Dim schedule activated: brightness set to {target_brightness}%")
elif not self.is_dimmed and self._was_dimmed:
logger.info(f"Dim schedule deactivated: brightness restored to {target_brightness}%")
self._was_dimmed = self.is_dimmed
return target_brightness
except ValueError as e:
logger.warning(f"Invalid dim schedule time format: {e}")
return normal_brightness
def _update_modules(self): def _update_modules(self):
"""Update all plugin modules.""" """Update all plugin modules."""
if not self.plugin_manager: if not self.plugin_manager:
@@ -1101,6 +1284,13 @@ class DisplayController:
elif not self.on_demand_active and self.on_demand_schedule_override: elif not self.on_demand_active and self.on_demand_schedule_override:
self.on_demand_schedule_override = False self.on_demand_schedule_override = False
# Check dim schedule and apply brightness (only when display is active)
if self.is_display_active:
target_brightness = self._check_dim_schedule()
if target_brightness != self.current_brightness:
if self.display_manager.set_brightness(target_brightness):
self.current_brightness = target_brightness
if not self.is_display_active: if not self.is_display_active:
# Clear display when schedule makes it inactive to ensure blank screen # Clear display when schedule makes it inactive to ensure blank screen
# (not showing initialization screen) # (not showing initialization screen)
@@ -1152,6 +1342,23 @@ class DisplayController:
except ValueError: except ValueError:
pass pass
# Vegas scroll mode - continuous ticker across all plugins
# Priority: on-demand > wifi-status > live-priority > vegas > normal rotation
if self._is_vegas_mode_active() and not wifi_status_data:
live_mode = self._check_live_priority()
if not live_mode:
try:
# Run Vegas mode iteration
if self.vegas_coordinator.run_iteration():
# Vegas completed an iteration, continue to next loop
continue
else:
# Vegas was interrupted (live priority), fall through to normal handling
logger.debug("Vegas mode interrupted, falling back to normal rotation")
except Exception:
logger.exception("Vegas mode error")
# Fall through to normal rotation on error
if self.on_demand_active: if self.on_demand_active:
# Guard against empty on_demand_modes # Guard against empty on_demand_modes
if not self.on_demand_modes: if not self.on_demand_modes:

View File

@@ -82,6 +82,7 @@ class DisplayManager:
options.pixel_mapper_config = hardware_config.get('pixel_mapper_config', '') options.pixel_mapper_config = hardware_config.get('pixel_mapper_config', '')
options.row_address_type = hardware_config.get('row_address_type', 0) options.row_address_type = hardware_config.get('row_address_type', 0)
options.multiplexing = hardware_config.get('multiplexing', 0) options.multiplexing = hardware_config.get('multiplexing', 0)
options.panel_type = hardware_config.get('panel_type', '')
options.disable_hardware_pulsing = hardware_config.get('disable_hardware_pulsing', False) options.disable_hardware_pulsing = hardware_config.get('disable_hardware_pulsing', False)
options.show_refresh_rate = hardware_config.get('show_refresh_rate', False) options.show_refresh_rate = hardware_config.get('show_refresh_rate', False)
options.limit_refresh_rate_hz = hardware_config.get('limit_refresh_rate_hz', 90) options.limit_refresh_rate_hz = hardware_config.get('limit_refresh_rate_hz', 90)
@@ -174,6 +175,57 @@ class DisplayManager:
else: else:
return 32 # Default fallback height return 32 # Default fallback height
def set_brightness(self, brightness: int) -> bool:
"""
Set display brightness at runtime.
Args:
brightness: Brightness level (0-100)
Returns:
True if brightness was set successfully, False otherwise
"""
# Fail fast: validate input type
if not isinstance(brightness, (int, float)):
logger.error(f"[BRIGHTNESS] Invalid brightness type: {type(brightness).__name__}, expected int")
return False
if self.matrix is None:
logger.warning("[BRIGHTNESS] Cannot set brightness in fallback mode")
return False
# Clamp to valid range
brightness = max(0, min(100, int(brightness)))
try:
# RGBMatrix accepts brightness as a property
self.matrix.brightness = brightness
logger.info(f"[BRIGHTNESS] Display brightness set to {brightness}%")
return True
except AttributeError as e:
logger.error(f"[BRIGHTNESS] Matrix does not support brightness property: {e}", exc_info=True)
return False
except (TypeError, ValueError) as e:
logger.error(f"[BRIGHTNESS] Invalid brightness value rejected by hardware: {e}", exc_info=True)
return False
def get_brightness(self) -> int:
"""
Get current display brightness.
Returns:
Current brightness level (0-100), or -1 if unavailable
"""
if self.matrix is None:
logger.debug("[BRIGHTNESS] Cannot get brightness in fallback mode")
return -1
try:
return self.matrix.brightness
except AttributeError as e:
logger.warning(f"[BRIGHTNESS] Matrix does not support brightness property: {e}", exc_info=True)
return -1
def _draw_test_pattern(self): def _draw_test_pattern(self):
"""Draw a test pattern to verify the display is working.""" """Draw a test pattern to verify the display is working."""
try: try:
@@ -816,8 +868,12 @@ class DisplayManager:
get_assets_file_mode get_assets_file_mode
) )
snapshot_path_obj = Path(self._snapshot_path) snapshot_path_obj = Path(self._snapshot_path)
if snapshot_path_obj.parent: # Only ensure permissions on non-system directories
ensure_directory_permissions(snapshot_path_obj.parent, get_assets_dir_mode()) # Never modify /tmp permissions - it has special system permissions (1777)
# that must not be changed or it breaks apt and other system tools
parent_dir = snapshot_path_obj.parent
if parent_dir and str(parent_dir) != '/tmp':
ensure_directory_permissions(parent_dir, get_assets_dir_mode())
# Write atomically: temp then replace # Write atomically: temp then replace
tmp_path = f"{self._snapshot_path}.tmp" tmp_path = f"{self._snapshot_path}.tmp"
self.image.save(tmp_path, format='PNG') self.image.save(tmp_path, format='PNG')

418
src/error_aggregator.py Normal file
View File

@@ -0,0 +1,418 @@
"""
Error Aggregation Service
Provides centralized error tracking, pattern detection, and reporting
for the LEDMatrix system. Enables automatic bug detection by tracking
error frequency, patterns, and context.
This is a local-only implementation with no external dependencies.
Errors are stored in memory with optional JSON export.
"""
import threading
import traceback
import json
from collections import defaultdict
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from pathlib import Path
from typing import Dict, List, Optional, Any, Callable
import logging
from src.exceptions import LEDMatrixError
@dataclass
class ErrorRecord:
"""Record of a single error occurrence."""
error_type: str
message: str
timestamp: datetime
context: Dict[str, Any] = field(default_factory=dict)
plugin_id: Optional[str] = None
operation: Optional[str] = None
stack_trace: Optional[str] = None
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary for JSON serialization."""
return {
"error_type": self.error_type,
"message": self.message,
"timestamp": self.timestamp.isoformat(),
"context": self.context,
"plugin_id": self.plugin_id,
"operation": self.operation,
"stack_trace": self.stack_trace
}
@dataclass
class ErrorPattern:
"""Detected error pattern for automatic detection."""
error_type: str
count: int
first_seen: datetime
last_seen: datetime
affected_plugins: List[str] = field(default_factory=list)
sample_messages: List[str] = field(default_factory=list)
severity: str = "warning" # warning, error, critical
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary for JSON serialization."""
return {
"error_type": self.error_type,
"count": self.count,
"first_seen": self.first_seen.isoformat(),
"last_seen": self.last_seen.isoformat(),
"affected_plugins": list(set(self.affected_plugins)),
"sample_messages": self.sample_messages[:3], # Keep only 3 samples
"severity": self.severity
}
class ErrorAggregator:
"""
Aggregates and analyzes errors across the system.
Features:
- Error counting by type, plugin, and time window
- Pattern detection (recurring errors)
- Error rate alerting via callbacks
- Export for analytics/reporting
Thread-safe for concurrent access.
"""
def __init__(
self,
max_records: int = 1000,
pattern_threshold: int = 5,
pattern_window_minutes: int = 60,
export_path: Optional[Path] = None
):
"""
Initialize the error aggregator.
Args:
max_records: Maximum number of error records to keep in memory
pattern_threshold: Number of occurrences to detect a pattern
pattern_window_minutes: Time window for pattern detection
export_path: Optional path for JSON export (auto-export on pattern detection)
"""
self.logger = logging.getLogger(__name__)
self.max_records = max_records
self.pattern_threshold = pattern_threshold
self.pattern_window = timedelta(minutes=pattern_window_minutes)
self.export_path = export_path
self._records: List[ErrorRecord] = []
self._error_counts: Dict[str, int] = defaultdict(int)
self._plugin_error_counts: Dict[str, Dict[str, int]] = defaultdict(lambda: defaultdict(int))
self._patterns: Dict[str, ErrorPattern] = {}
self._pattern_callbacks: List[Callable[[ErrorPattern], None]] = []
self._lock = threading.RLock() # RLock allows nested acquisition for export_to_file
# Track session start for relative timing
self._session_start = datetime.now()
def record_error(
self,
error: Exception,
context: Optional[Dict[str, Any]] = None,
plugin_id: Optional[str] = None,
operation: Optional[str] = None
) -> ErrorRecord:
"""
Record an error occurrence.
Args:
error: The exception that occurred
context: Optional context dictionary with additional details
plugin_id: Optional plugin ID that caused the error
operation: Optional operation name (e.g., "update", "display")
Returns:
The created ErrorRecord
"""
with self._lock:
error_type = type(error).__name__
# Extract additional context from LEDMatrixError subclasses
error_context = context or {}
if isinstance(error, LEDMatrixError) and error.context:
error_context.update(error.context)
record = ErrorRecord(
error_type=error_type,
message=str(error),
timestamp=datetime.now(),
context=error_context,
plugin_id=plugin_id,
operation=operation,
stack_trace=traceback.format_exc()
)
# Add record (with size limit)
self._records.append(record)
if len(self._records) > self.max_records:
self._records.pop(0)
# Update counts
self._error_counts[error_type] += 1
if plugin_id:
self._plugin_error_counts[plugin_id][error_type] += 1
# Check for patterns
self._detect_pattern(record)
# Log the error
self.logger.debug(
f"Error recorded: {error_type} - {str(error)[:100]}",
extra={"plugin_id": plugin_id, "operation": operation}
)
return record
def _detect_pattern(self, record: ErrorRecord) -> None:
"""Detect recurring error patterns."""
cutoff = datetime.now() - self.pattern_window
recent_same_type = [
r for r in self._records
if r.error_type == record.error_type and r.timestamp > cutoff
]
if len(recent_same_type) >= self.pattern_threshold:
pattern_key = record.error_type
is_new_pattern = pattern_key not in self._patterns
# Determine severity based on count
count = len(recent_same_type)
if count > self.pattern_threshold * 3:
severity = "critical"
elif count > self.pattern_threshold * 2:
severity = "error"
else:
severity = "warning"
# Collect affected plugins
affected_plugins = [r.plugin_id for r in recent_same_type if r.plugin_id]
# Collect sample messages
sample_messages = list(set(r.message for r in recent_same_type[:5]))
if is_new_pattern:
pattern = ErrorPattern(
error_type=record.error_type,
count=count,
first_seen=recent_same_type[0].timestamp,
last_seen=record.timestamp,
affected_plugins=affected_plugins,
sample_messages=sample_messages,
severity=severity
)
self._patterns[pattern_key] = pattern
self.logger.warning(
f"Error pattern detected: {record.error_type} occurred "
f"{count} times in last {self.pattern_window}. "
f"Affected plugins: {set(affected_plugins) or 'unknown'}"
)
# Notify callbacks
for callback in self._pattern_callbacks:
try:
callback(pattern)
except Exception as e:
self.logger.error(f"Pattern callback failed: {e}")
# Auto-export if path configured
if self.export_path:
self._auto_export()
else:
# Update existing pattern
self._patterns[pattern_key].count = count
self._patterns[pattern_key].last_seen = record.timestamp
self._patterns[pattern_key].severity = severity
self._patterns[pattern_key].affected_plugins.extend(affected_plugins)
def on_pattern_detected(self, callback: Callable[[ErrorPattern], None]) -> None:
"""
Register a callback to be called when a new error pattern is detected.
Args:
callback: Function that takes an ErrorPattern as argument
"""
self._pattern_callbacks.append(callback)
def get_error_summary(self) -> Dict[str, Any]:
"""
Get summary of all errors for reporting.
Returns:
Dictionary with error statistics and recent errors
"""
with self._lock:
# Calculate error rate (errors per hour)
session_duration = (datetime.now() - self._session_start).total_seconds() / 3600
error_rate = len(self._records) / max(session_duration, 0.01)
return {
"session_start": self._session_start.isoformat(),
"total_errors": len(self._records),
"error_rate_per_hour": round(error_rate, 2),
"error_counts_by_type": dict(self._error_counts),
"plugin_error_counts": {
k: dict(v) for k, v in self._plugin_error_counts.items()
},
"active_patterns": {
k: v.to_dict() for k, v in self._patterns.items()
},
"recent_errors": [
r.to_dict() for r in self._records[-20:]
]
}
def get_plugin_health(self, plugin_id: str) -> Dict[str, Any]:
"""
Get health status for a specific plugin.
Args:
plugin_id: Plugin ID to check
Returns:
Dictionary with plugin error statistics
"""
with self._lock:
plugin_errors = self._plugin_error_counts.get(plugin_id, {})
recent_plugin_errors = [
r for r in self._records[-100:]
if r.plugin_id == plugin_id
]
# Determine health status
recent_count = len(recent_plugin_errors)
if recent_count == 0:
status = "healthy"
elif recent_count < 5:
status = "degraded"
else:
status = "unhealthy"
return {
"plugin_id": plugin_id,
"status": status,
"total_errors": sum(plugin_errors.values()),
"error_types": dict(plugin_errors),
"recent_error_count": recent_count,
"last_error": recent_plugin_errors[-1].to_dict() if recent_plugin_errors else None
}
def clear_old_records(self, max_age_hours: int = 24) -> int:
"""
Clear records older than specified age.
Args:
max_age_hours: Maximum age in hours
Returns:
Number of records cleared
"""
with self._lock:
cutoff = datetime.now() - timedelta(hours=max_age_hours)
original_count = len(self._records)
self._records = [r for r in self._records if r.timestamp > cutoff]
cleared = original_count - len(self._records)
if cleared > 0:
self.logger.info(f"Cleared {cleared} old error records")
return cleared
def export_to_file(self, filepath: Path) -> None:
"""
Export error data to JSON file.
Args:
filepath: Path to export file
"""
with self._lock:
data = {
"exported_at": datetime.now().isoformat(),
"summary": self.get_error_summary(),
"all_records": [r.to_dict() for r in self._records]
}
filepath.parent.mkdir(parents=True, exist_ok=True)
filepath.write_text(json.dumps(data, indent=2))
self.logger.info(f"Exported error data to {filepath}")
def _auto_export(self) -> None:
"""Auto-export on pattern detection (if export_path configured)."""
if self.export_path:
try:
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filepath = self.export_path / f"errors_{timestamp}.json"
self.export_to_file(filepath)
except Exception as e:
self.logger.error(f"Auto-export failed: {e}")
# Global singleton instance
_error_aggregator: Optional[ErrorAggregator] = None
_aggregator_lock = threading.Lock()
def get_error_aggregator(
max_records: int = 1000,
pattern_threshold: int = 5,
pattern_window_minutes: int = 60,
export_path: Optional[Path] = None
) -> ErrorAggregator:
"""
Get or create the global error aggregator instance.
Args:
max_records: Maximum records to keep (only used on first call)
pattern_threshold: Pattern detection threshold (only used on first call)
pattern_window_minutes: Pattern detection window (only used on first call)
export_path: Export path for auto-export (only used on first call)
Returns:
The global ErrorAggregator instance
"""
global _error_aggregator
with _aggregator_lock:
if _error_aggregator is None:
_error_aggregator = ErrorAggregator(
max_records=max_records,
pattern_threshold=pattern_threshold,
pattern_window_minutes=pattern_window_minutes,
export_path=export_path
)
return _error_aggregator
def record_error(
error: Exception,
context: Optional[Dict[str, Any]] = None,
plugin_id: Optional[str] = None,
operation: Optional[str] = None
) -> ErrorRecord:
"""
Convenience function to record an error to the global aggregator.
Args:
error: The exception that occurred
context: Optional context dictionary
plugin_id: Optional plugin ID
operation: Optional operation name
Returns:
The created ErrorRecord
"""
return get_error_aggregator().record_error(
error=error,
context=context,
plugin_id=plugin_id,
operation=operation
)

View File

@@ -148,7 +148,13 @@ class LogoDownloader:
def get_logo_directory(self, league: str) -> str: def get_logo_directory(self, league: str) -> str:
"""Get the logo directory for a given league.""" """Get the logo directory for a given league."""
directory = LogoDownloader.LOGO_DIRECTORIES.get(league, f'assets/sports/{league}_logos') directory = LogoDownloader.LOGO_DIRECTORIES.get(league)
if not directory:
# Custom soccer leagues share the same logo directory as predefined ones
if league.startswith('soccer_'):
directory = 'assets/sports/soccer_logos'
else:
directory = f'assets/sports/{league}_logos'
path = Path(directory) path = Path(directory)
if not path.is_absolute(): if not path.is_absolute():
project_root = Path(__file__).resolve().parents[1] project_root = Path(__file__).resolve().parents[1]
@@ -238,9 +244,18 @@ class LogoDownloader:
logger.error(f"Unexpected error downloading logo for {team_abbreviation}: {e}") logger.error(f"Unexpected error downloading logo for {team_abbreviation}: {e}")
return False return False
def _resolve_api_url(self, league: str) -> Optional[str]:
"""Resolve the ESPN API teams URL for a league, with dynamic fallback for custom soccer leagues."""
api_url = self.API_ENDPOINTS.get(league)
if not api_url and league.startswith('soccer_'):
league_code = league[len('soccer_'):]
api_url = f'https://site.api.espn.com/apis/site/v2/sports/soccer/{league_code}/teams'
logger.info(f"Using dynamic ESPN endpoint for custom soccer league: {league}")
return api_url
def fetch_teams_data(self, league: str) -> Optional[Dict]: def fetch_teams_data(self, league: str) -> Optional[Dict]:
"""Fetch team data from ESPN API for a specific league.""" """Fetch team data from ESPN API for a specific league."""
api_url = self.API_ENDPOINTS.get(league) api_url = self._resolve_api_url(league)
if not api_url: if not api_url:
logger.error(f"No API endpoint configured for league: {league}") logger.error(f"No API endpoint configured for league: {league}")
return None return None
@@ -263,7 +278,7 @@ class LogoDownloader:
def fetch_single_team(self, league: str, team_id: str) -> Optional[Dict]: def fetch_single_team(self, league: str, team_id: str) -> Optional[Dict]:
"""Fetch team data from ESPN API for a specific league.""" """Fetch team data from ESPN API for a specific league."""
api_url = self.API_ENDPOINTS.get(league) api_url = self._resolve_api_url(league)
if not api_url: if not api_url:
logger.error(f"No API endpoint configured for league: {league}") logger.error(f"No API endpoint configured for league: {league}")
return None return None
@@ -570,7 +585,7 @@ class LogoDownloader:
total_failed = 0 total_failed = 0
for league in leagues: for league in leagues:
if league not in self.API_ENDPOINTS: if not self._resolve_api_url(league):
logger.warning(f"Skipping unknown league: {league}") logger.warning(f"Skipping unknown league: {league}")
continue continue

View File

@@ -9,11 +9,35 @@ Stability: Stable - maintains backward compatibility
""" """
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from enum import Enum
from typing import Dict, Any, Optional, List from typing import Dict, Any, Optional, List
import logging import logging
from src.logging_config import get_logger from src.logging_config import get_logger
class VegasDisplayMode(Enum):
"""
Display mode for Vegas scroll integration.
Determines how a plugin's content behaves within the continuous scroll:
- SCROLL: Content scrolls continuously within the stream.
Best for multi-item plugins like sports scores, odds tickers, news feeds.
Plugin provides multiple frames via get_vegas_content().
- FIXED_SEGMENT: Content is a fixed-width block that scrolls BY with
the rest of the content. Best for static info like clock, weather.
Plugin provides a single image sized to vegas_panel_count panels.
- STATIC: Scroll pauses, plugin displays for its duration, then scroll
resumes. Best for important alerts or detailed views that need attention.
Plugin uses standard display() method during the pause.
"""
SCROLL = "scroll"
FIXED_SEGMENT = "fixed"
STATIC = "static"
class BasePlugin(ABC): class BasePlugin(ABC):
""" """
Base class that all plugins must inherit from. Base class that all plugins must inherit from.
@@ -131,27 +155,78 @@ class BasePlugin(ABC):
elif isinstance(duration, (int, float)): elif isinstance(duration, (int, float)):
if duration > 0: if duration > 0:
return float(duration) return float(duration)
else:
self.logger.debug(
"display_duration instance variable is non-positive (%s), using config fallback",
duration
)
# Try converting string representations of numbers # Try converting string representations of numbers
elif isinstance(duration, str): elif isinstance(duration, str):
try: try:
duration_float = float(duration) duration_float = float(duration)
if duration_float > 0: if duration_float > 0:
return duration_float return duration_float
else:
self.logger.debug(
"display_duration string value is non-positive (%s), using config fallback",
duration
)
except (ValueError, TypeError): except (ValueError, TypeError):
pass # Fall through to config self.logger.warning(
except (TypeError, ValueError, AttributeError): "display_duration instance variable has invalid string value '%s', using config fallback",
pass # Fall through to config duration
)
else:
self.logger.warning(
"display_duration instance variable has unexpected type %s (value: %s), using config fallback",
type(duration).__name__, duration
)
except (TypeError, ValueError, AttributeError) as e:
self.logger.warning(
"Error reading display_duration instance variable: %s, using config fallback",
e
)
# Fall back to config # Fall back to config
config_duration = self.config.get("display_duration", 15.0) config_duration = self.config.get("display_duration", 15.0)
try: try:
# Ensure config value is also a valid float # Ensure config value is also a valid float
if isinstance(config_duration, (int, float)): if isinstance(config_duration, (int, float)):
return float(config_duration) if config_duration > 0 else 15.0 if config_duration > 0:
return float(config_duration)
else:
self.logger.debug(
"Config display_duration is non-positive (%s), using default 15.0",
config_duration
)
return 15.0
elif isinstance(config_duration, str): elif isinstance(config_duration, str):
return float(config_duration) if float(config_duration) > 0 else 15.0 try:
except (ValueError, TypeError): duration_float = float(config_duration)
pass if duration_float > 0:
return duration_float
else:
self.logger.debug(
"Config display_duration string is non-positive (%s), using default 15.0",
config_duration
)
return 15.0
except ValueError:
self.logger.warning(
"Config display_duration has invalid string value '%s', using default 15.0",
config_duration
)
return 15.0
else:
self.logger.warning(
"Config display_duration has unexpected type %s (value: %s), using default 15.0",
type(config_duration).__name__, config_duration
)
except (ValueError, TypeError) as e:
self.logger.warning(
"Error processing config display_duration: %s, using default 15.0",
e
)
return 15.0 return 15.0
@@ -285,6 +360,168 @@ class BasePlugin(ABC):
return manifest.get("display_modes", [self.plugin_id]) return manifest.get("display_modes", [self.plugin_id])
return [self.plugin_id] return [self.plugin_id]
# -------------------------------------------------------------------------
# Vegas scroll mode support
# -------------------------------------------------------------------------
def get_vegas_content(self) -> Optional[Any]:
"""
Get content for Vegas-style continuous scroll mode.
Override this method to provide optimized content for continuous scrolling.
Plugins can return:
- A single PIL Image: Displayed as a static block in the scroll
- A list of PIL Images: Each image becomes a separate item in the scroll
- None: Vegas mode will fall back to capturing display() output
Multi-item plugins (sports scores, odds) should return individual game/item
images so they scroll smoothly with other plugins.
Returns:
PIL Image, list of PIL Images, or None
Example (sports plugin):
def get_vegas_content(self):
# Return individual game cards for smooth scrolling
return [self._render_game(game) for game in self.games]
Example (static plugin):
def get_vegas_content(self):
# Return current display as single block
return self._render_current_view()
"""
return None
def get_vegas_content_type(self) -> str:
"""
Indicate the type of content this plugin provides for Vegas scroll.
Override this to specify how Vegas mode should treat this plugin's content.
Returns:
'multi' - Plugin has multiple scrollable items (sports, odds, news)
'static' - Plugin is a static block (clock, weather, music)
'none' - Plugin should not appear in Vegas scroll mode
Example:
def get_vegas_content_type(self):
return 'multi' # We have multiple games to scroll
"""
return 'static'
def get_vegas_display_mode(self) -> VegasDisplayMode:
"""
Get the display mode for Vegas scroll integration.
This method determines how the plugin's content behaves within Vegas mode:
- SCROLL: Content scrolls continuously (multi-item plugins)
- FIXED_SEGMENT: Fixed block that scrolls by (clock, weather)
- STATIC: Pause scroll to display (alerts, detailed views)
Override to change default behavior. By default, reads from config
or maps legacy get_vegas_content_type() for backward compatibility.
Returns:
VegasDisplayMode enum value
Example:
def get_vegas_display_mode(self):
return VegasDisplayMode.SCROLL
"""
# Check for explicit config setting first
config_mode = self.config.get("vegas_mode")
if config_mode:
try:
return VegasDisplayMode(config_mode)
except ValueError:
self.logger.warning(
"Invalid vegas_mode '%s' for %s, using default",
config_mode, self.plugin_id
)
# Fall back to mapping legacy content_type
content_type = self.get_vegas_content_type()
if content_type == 'multi':
return VegasDisplayMode.SCROLL
elif content_type == 'static':
return VegasDisplayMode.FIXED_SEGMENT
elif content_type == 'none':
# 'none' means excluded - return FIXED_SEGMENT as default
# The exclusion is handled by checking get_vegas_content_type() separately
return VegasDisplayMode.FIXED_SEGMENT
return VegasDisplayMode.FIXED_SEGMENT
def get_supported_vegas_modes(self) -> List[VegasDisplayMode]:
"""
Return list of Vegas display modes this plugin supports.
Used by the web UI to show available mode options for user configuration.
Override to customize which modes are available for this plugin.
By default:
- 'multi' content type plugins support SCROLL and FIXED_SEGMENT
- 'static' content type plugins support FIXED_SEGMENT and STATIC
- 'none' content type plugins return empty list (excluded from Vegas)
Returns:
List of VegasDisplayMode values this plugin can use
Example:
def get_supported_vegas_modes(self):
# This plugin only makes sense as a scrolling ticker
return [VegasDisplayMode.SCROLL]
"""
content_type = self.get_vegas_content_type()
if content_type == 'none':
return []
elif content_type == 'multi':
return [VegasDisplayMode.SCROLL, VegasDisplayMode.FIXED_SEGMENT]
else: # 'static'
return [VegasDisplayMode.FIXED_SEGMENT, VegasDisplayMode.STATIC]
def get_vegas_segment_width(self) -> Optional[int]:
"""
Get the preferred width for this plugin in Vegas FIXED_SEGMENT mode.
Returns the number of panels this plugin should occupy when displayed
as a fixed segment. The actual pixel width is calculated as:
width = panels * single_panel_width
Where single_panel_width comes from display.hardware.cols in config.
Override to provide dynamic sizing based on content.
Returns None to use the default (1 panel).
Returns:
Number of panels, or None for default (1 panel)
Example:
def get_vegas_segment_width(self):
# Clock needs 2 panels to show time clearly
return 2
"""
raw_value = self.config.get("vegas_panel_count", None)
if raw_value is None:
return None
try:
panel_count = int(raw_value)
if panel_count > 0:
return panel_count
else:
self.logger.warning(
"vegas_panel_count must be positive, got %s; using default",
raw_value
)
return None
except (ValueError, TypeError):
self.logger.warning(
"Invalid vegas_panel_count value '%s'; using default",
raw_value
)
return None
def validate_config(self) -> bool: def validate_config(self) -> bool:
""" """
Validate plugin configuration against schema. Validate plugin configuration against schema.

View File

@@ -167,6 +167,13 @@ class OperationHistory:
return history[:limit] return history[:limit]
def clear_history(self) -> None:
"""Clear all operation history records."""
with self._lock:
self._history.clear()
self._save_history()
self.logger.info("Operation history cleared")
def _save_history(self) -> None: def _save_history(self) -> None:
"""Save history to file.""" """Save history to file."""
if not self.history_file: if not self.history_file:

View File

@@ -13,6 +13,7 @@ import logging
from src.exceptions import PluginError from src.exceptions import PluginError
from src.logging_config import get_logger from src.logging_config import get_logger
from src.error_aggregator import record_error
class TimeoutError(Exception): class TimeoutError(Exception):
@@ -80,12 +81,15 @@ class PluginExecutor:
if not result_container['completed']: if not result_container['completed']:
error_msg = f"{plugin_context} operation timed out after {timeout}s" error_msg = f"{plugin_context} operation timed out after {timeout}s"
self.logger.error(error_msg) self.logger.error(error_msg)
raise TimeoutError(error_msg) timeout_error = TimeoutError(error_msg)
record_error(timeout_error, plugin_id=plugin_id, operation="timeout")
raise timeout_error
if result_container['exception']: if result_container['exception']:
error = result_container['exception'] error = result_container['exception']
error_msg = f"{plugin_context} operation failed: {error}" error_msg = f"{plugin_context} operation failed: {error}"
self.logger.error(error_msg, exc_info=True) self.logger.error(error_msg, exc_info=True)
record_error(error, plugin_id=plugin_id, operation="execute")
raise PluginError(error_msg, plugin_id=plugin_id) from error raise PluginError(error_msg, plugin_id=plugin_id) from error
return result_container['value'] return result_container['value']
@@ -128,7 +132,7 @@ class PluginExecutor:
self.logger.error("Plugin %s update() timed out", plugin_id) self.logger.error("Plugin %s update() timed out", plugin_id)
return False return False
except PluginError: except PluginError:
# Already logged in execute_with_timeout # Already logged and recorded in execute_with_timeout
return False return False
except Exception as e: except Exception as e:
self.logger.error( self.logger.error(
@@ -137,6 +141,7 @@ class PluginExecutor:
e, e,
exc_info=True exc_info=True
) )
record_error(e, plugin_id=plugin_id, operation="update")
return False return False
def execute_display( def execute_display(
@@ -203,7 +208,7 @@ class PluginExecutor:
self.logger.error("Plugin %s display() timed out", plugin_id) self.logger.error("Plugin %s display() timed out", plugin_id)
return False return False
except PluginError: except PluginError:
# Already logged in execute_with_timeout # Already logged and recorded in execute_with_timeout
return False return False
except Exception as e: except Exception as e:
self.logger.error( self.logger.error(
@@ -212,6 +217,7 @@ class PluginExecutor:
e, e,
exc_info=True exc_info=True
) )
record_error(e, plugin_id=plugin_id, operation="display")
return False return False
def execute_safe( def execute_safe(

View File

@@ -10,6 +10,7 @@ import importlib
import importlib.util import importlib.util
import sys import sys
import subprocess import subprocess
import threading
from pathlib import Path from pathlib import Path
from typing import Dict, Any, Optional, Tuple, Type from typing import Dict, Any, Optional, Tuple, Type
import logging import logging
@@ -34,6 +35,15 @@ class PluginLoader:
""" """
self.logger = logger or get_logger(__name__) self.logger = logger or get_logger(__name__)
self._loaded_modules: Dict[str, Any] = {} self._loaded_modules: Dict[str, Any] = {}
self._plugin_module_registry: Dict[str, set] = {} # Maps plugin_id to set of module names
# Lock to serialize module loading when plugins share module names
# (e.g., scroll_display.py, game_renderer.py across sport plugins).
# During exec_module, bare-name sub-modules temporarily appear in
# sys.modules; the lock prevents concurrent plugins from seeing each
# other's entries. After exec_module, _namespace_plugin_modules
# moves those bare names to namespaced keys (e.g.
# _plg_basketball_scoreboard_scroll_display) so they never collide.
self._module_load_lock = threading.Lock()
def find_plugin_directory( def find_plugin_directory(
self, self,
@@ -189,6 +199,92 @@ class PluginLoader:
self.logger.error("Unexpected error installing dependencies for %s: %s", plugin_id, e, exc_info=True) self.logger.error("Unexpected error installing dependencies for %s: %s", plugin_id, e, exc_info=True)
return False return False
@staticmethod
def _iter_plugin_bare_modules(
plugin_dir: Path, before_keys: set
) -> list:
"""Return bare-name modules from plugin_dir added after before_keys.
Returns a list of (mod_name, module) tuples for modules that:
- Were added to sys.modules after before_keys snapshot
- Have bare names (no dots)
- Have a ``__file__`` inside plugin_dir
"""
resolved_dir = plugin_dir.resolve()
result = []
for key in set(sys.modules.keys()) - before_keys:
if "." in key:
continue
mod = sys.modules.get(key)
if mod is None:
continue
mod_file = getattr(mod, "__file__", None)
if not mod_file:
continue
try:
if Path(mod_file).resolve().is_relative_to(resolved_dir):
result.append((key, mod))
except (ValueError, TypeError):
continue
return result
def _namespace_plugin_modules(
self, plugin_id: str, plugin_dir: Path, before_keys: set
) -> None:
"""
Move bare-name plugin modules to namespaced keys in sys.modules.
After exec_module loads a plugin's entry point, Python will have added
the plugin's local modules (scroll_display, game_renderer, …) to
sys.modules under their bare names. This method renames them to
``_plg_<plugin_id>_<module>`` so they cannot collide with identically-
named modules from other plugins.
The plugin code keeps working because ``from scroll_display import X``
binds ``X`` to the class *object*, not to the sys.modules entry.
Args:
plugin_id: Plugin identifier
plugin_dir: Plugin directory path
before_keys: Snapshot of sys.modules keys taken *before* exec_module
"""
safe_id = plugin_id.replace("-", "_")
namespaced_names: set = set()
for mod_name, mod in self._iter_plugin_bare_modules(plugin_dir, before_keys):
namespaced = f"_plg_{safe_id}_{mod_name}"
sys.modules[namespaced] = mod
# Keep sys.modules[mod_name] as an alias to the same object.
# Removing it would cause lazy intra-plugin imports (e.g. a
# deferred ``import scroll_display`` inside a method) to
# re-import from disk and create a second, inconsistent copy
# of the module. The next plugin's exec_module will naturally
# overwrite the bare entry with its own version.
namespaced_names.add(namespaced)
self.logger.debug(
"Namespace-isolated module '%s' -> '%s' for plugin %s",
mod_name, namespaced, plugin_id,
)
# Track for cleanup during unload
self._plugin_module_registry[plugin_id] = namespaced_names
if namespaced_names:
self.logger.info(
"Namespace-isolated %d module(s) for plugin %s",
len(namespaced_names), plugin_id,
)
def unregister_plugin_modules(self, plugin_id: str) -> None:
"""Remove namespaced sub-modules and cached module for a plugin from sys.modules.
Called by PluginManager during unload to clean up all module entries
that were created when the plugin was loaded.
"""
for ns_name in self._plugin_module_registry.pop(plugin_id, set()):
sys.modules.pop(ns_name, None)
self._loaded_modules.pop(plugin_id, None)
def load_module( def load_module(
self, self,
plugin_id: str, plugin_id: str,
@@ -198,6 +294,15 @@ class PluginLoader:
""" """
Load a plugin module from file. Load a plugin module from file.
Module loading is serialized via _module_load_lock because plugins are
loaded in parallel (ThreadPoolExecutor) and multiple sport plugins
share identically-named local modules (scroll_display.py,
game_renderer.py, sports.py, etc.).
After loading, bare-name modules from the plugin directory are moved
to namespaced keys in sys.modules (e.g. ``_plg_basketball_scoreboard_scroll_display``)
so they cannot collide with other plugins.
Args: Args:
plugin_id: Plugin identifier plugin_id: Plugin identifier
plugin_dir: Plugin directory path plugin_dir: Plugin directory path
@@ -212,32 +317,51 @@ class PluginLoader:
self.logger.error(error_msg) self.logger.error(error_msg)
raise PluginError(error_msg, plugin_id=plugin_id, context={'entry_file': str(entry_file)}) raise PluginError(error_msg, plugin_id=plugin_id, context={'entry_file': str(entry_file)})
# Add plugin directory to sys.path if not already there with self._module_load_lock:
plugin_dir_str = str(plugin_dir) # Add plugin directory to sys.path if not already there
if plugin_dir_str not in sys.path: plugin_dir_str = str(plugin_dir)
sys.path.insert(0, plugin_dir_str) if plugin_dir_str not in sys.path:
self.logger.debug("Added plugin directory to sys.path: %s", plugin_dir_str) sys.path.insert(0, plugin_dir_str)
self.logger.debug("Added plugin directory to sys.path: %s", plugin_dir_str)
# Import the plugin module # Import the plugin module
module_name = f"plugin_{plugin_id.replace('-', '_')}" module_name = f"plugin_{plugin_id.replace('-', '_')}"
# Check if already loaded # Check if already loaded
if module_name in sys.modules: if module_name in sys.modules:
self.logger.debug("Module %s already loaded, reusing", module_name) self.logger.debug("Module %s already loaded, reusing", module_name)
return sys.modules[module_name] return sys.modules[module_name]
spec = importlib.util.spec_from_file_location(module_name, entry_file) spec = importlib.util.spec_from_file_location(module_name, entry_file)
if spec is None or spec.loader is None: if spec is None or spec.loader is None:
error_msg = f"Could not create module spec for {entry_file}" error_msg = f"Could not create module spec for {entry_file}"
self.logger.error(error_msg) self.logger.error(error_msg)
raise PluginError(error_msg, plugin_id=plugin_id, context={'entry_file': str(entry_file)}) raise PluginError(error_msg, plugin_id=plugin_id, context={'entry_file': str(entry_file)})
module = importlib.util.module_from_spec(spec) module = importlib.util.module_from_spec(spec)
sys.modules[module_name] = module sys.modules[module_name] = module
spec.loader.exec_module(module)
self._loaded_modules[plugin_id] = module # Snapshot AFTER inserting the main module so that
self.logger.debug("Loaded module %s for plugin %s", module_name, plugin_id) # _namespace_plugin_modules and error cleanup only target
# sub-modules, not the main module entry itself.
before_keys = set(sys.modules.keys())
try:
spec.loader.exec_module(module)
# Move bare-name plugin modules to namespaced keys so they
# cannot collide with identically-named modules from other plugins
self._namespace_plugin_modules(plugin_id, plugin_dir, before_keys)
except Exception:
# Clean up the partially-initialized main module and any
# bare-name sub-modules that were added during exec_module
# so they don't leak into subsequent plugin loads.
sys.modules.pop(module_name, None)
for key, _ in self._iter_plugin_bare_modules(plugin_dir, before_keys):
sys.modules.pop(key, None)
raise
self._loaded_modules[plugin_id] = module
self.logger.debug("Loaded module %s for plugin %s", module_name, plugin_id)
return module return module

View File

@@ -137,12 +137,23 @@ class PluginManager:
""" """
Discover all plugins in the plugins directory. Discover all plugins in the plugins directory.
Also checks for potential config key collisions and logs warnings.
Returns: Returns:
List of plugin IDs List of plugin IDs
""" """
self.logger.info("Discovering plugins in %s", self.plugins_dir) self.logger.info("Discovering plugins in %s", self.plugins_dir)
plugin_ids = self._scan_directory_for_plugins(self.plugins_dir) plugin_ids = self._scan_directory_for_plugins(self.plugins_dir)
self.logger.info("Discovered %d plugin(s)", len(plugin_ids)) self.logger.info("Discovered %d plugin(s)", len(plugin_ids))
# Check for config key collisions
collisions = self.schema_manager.detect_config_key_collisions(plugin_ids)
for collision in collisions:
self.logger.warning(
"Config collision detected: %s",
collision.get('message', str(collision))
)
return plugin_ids return plugin_ids
def _get_dependency_marker_path(self, plugin_id: str) -> Path: def _get_dependency_marker_path(self, plugin_id: str) -> Path:
@@ -288,6 +299,24 @@ class PluginManager:
else: else:
config = {} config = {}
# Check if plugin has a config schema
schema_path = self.schema_manager.get_schema_path(plugin_id)
if schema_path is None:
# Schema file doesn't exist
self.logger.warning(
f"Plugin '{plugin_id}' has no config_schema.json - configuration will not be validated. "
f"Consider adding a schema file for better error detection and user experience."
)
else:
# Schema file exists, try to load it
schema = self.schema_manager.load_schema(plugin_id)
if schema is None:
# Schema exists but couldn't be loaded (likely invalid JSON or schema)
self.logger.warning(
f"Plugin '{plugin_id}' has a config_schema.json but it could not be loaded. "
f"The schema may be invalid. Please verify the schema file at: {schema_path}"
)
# Merge config with schema defaults to ensure all defaults are applied # Merge config with schema defaults to ensure all defaults are applied
try: try:
defaults = self.schema_manager.generate_default_config(plugin_id, use_cache=True) defaults = self.schema_manager.generate_default_config(plugin_id, use_cache=True)
@@ -386,10 +415,12 @@ class PluginManager:
if plugin_id in self.plugin_last_update: if plugin_id in self.plugin_last_update:
del self.plugin_last_update[plugin_id] del self.plugin_last_update[plugin_id]
# Remove module from sys.modules if present # Remove main module from sys.modules if present
module_name = f"plugin_{plugin_id.replace('-', '_')}" module_name = f"plugin_{plugin_id.replace('-', '_')}"
if module_name in sys.modules: sys.modules.pop(module_name, None)
del sys.modules[module_name]
# Delegate sub-module and cached-module cleanup to the loader
self.plugin_loader.unregister_plugin_modules(plugin_id)
# Remove from plugin_modules # Remove from plugin_modules
self.plugin_modules.pop(plugin_id, None) self.plugin_modules.pop(plugin_id, None)

View File

@@ -445,3 +445,62 @@ class SchemaManager:
replace_none_with_defaults(merged, defaults) replace_none_with_defaults(merged, defaults)
return merged return merged
def detect_config_key_collisions(
self,
plugin_ids: List[str]
) -> List[Dict[str, Any]]:
"""
Detect config key collisions between plugins.
Checks for:
1. Plugin IDs that collide with reserved system config keys
2. Plugin IDs that might cause confusion or conflicts
Args:
plugin_ids: List of plugin identifiers to check
Returns:
List of collision warnings, each containing:
- type: 'reserved_key_collision' or 'case_collision'
- plugin_id: The plugin ID involved
- message: Human-readable warning message
"""
collisions = []
# Reserved top-level config keys that plugins should not use as IDs
reserved_keys = {
'display', 'schedule', 'timezone', 'plugin_system',
'display_modes', 'system', 'hardware', 'debug',
'log_level', 'emulator', 'web_interface'
}
# Track plugin IDs for case collision detection
lowercase_ids: Dict[str, str] = {}
for plugin_id in plugin_ids:
# Check reserved key collision
if plugin_id.lower() in {k.lower() for k in reserved_keys}:
collisions.append({
"type": "reserved_key_collision",
"plugin_id": plugin_id,
"message": f"Plugin ID '{plugin_id}' conflicts with reserved config key. "
f"This may cause configuration issues."
})
# Check for case-insensitive collisions between plugins
lower_id = plugin_id.lower()
if lower_id in lowercase_ids:
existing_id = lowercase_ids[lower_id]
if existing_id != plugin_id:
collisions.append({
"type": "case_collision",
"plugin_id": plugin_id,
"conflicting_id": existing_id,
"message": f"Plugin ID '{plugin_id}' may conflict with '{existing_id}' "
f"on case-insensitive file systems."
})
else:
lowercase_ids[lower_id] = plugin_id
return collisions

View File

@@ -7,6 +7,7 @@ from both the official registry and custom GitHub repositories.
import os import os
import json import json
import stat
import subprocess import subprocess
import shutil import shutil
import zipfile import zipfile
@@ -18,6 +19,8 @@ from pathlib import Path
from typing import List, Dict, Optional, Any from typing import List, Dict, Optional, Any
import logging import logging
from src.common.permission_utils import sudo_remove_directory
try: try:
import jsonschema import jsonschema
from jsonschema import Draft7Validator, ValidationError from jsonschema import Draft7Validator, ValidationError
@@ -51,6 +54,10 @@ class PluginStoreManager:
self.github_cache = {} # Cache for GitHub API responses self.github_cache = {} # Cache for GitHub API responses
self.cache_timeout = 3600 # 1 hour cache timeout self.cache_timeout = 3600 # 1 hour cache timeout
self.registry_cache_timeout = 300 # 5 minutes for registry cache self.registry_cache_timeout = 300 # 5 minutes for registry cache
self.commit_info_cache = {} # Cache for latest commit info: {key: (timestamp, data)}
self.commit_cache_timeout = 300 # 5 minutes (same as registry)
self.manifest_cache = {} # Cache for GitHub manifest fetches: {key: (timestamp, data)}
self.manifest_cache_timeout = 300 # 5 minutes
self.github_token = self._load_github_token() self.github_token = self._load_github_token()
self._token_validation_cache = {} # Cache for token validation results: {token: (is_valid, timestamp, error_message)} self._token_validation_cache = {} # Cache for token validation results: {token: (is_valid, timestamp, error_message)}
self._token_validation_cache_timeout = 300 # 5 minutes cache for token validation self._token_validation_cache_timeout = 300 # 5 minutes cache for token validation
@@ -560,7 +567,9 @@ class PluginStoreManager:
enhanced_plugin['last_commit_branch'] = commit_info.get('branch') enhanced_plugin['last_commit_branch'] = commit_info.get('branch')
# Fetch manifest from GitHub for additional metadata (description, etc.) # Fetch manifest from GitHub for additional metadata (description, etc.)
github_manifest = self._fetch_manifest_from_github(repo_url, branch) plugin_subpath = plugin.get('plugin_path', '')
manifest_rel = f"{plugin_subpath}/manifest.json" if plugin_subpath else "manifest.json"
github_manifest = self._fetch_manifest_from_github(repo_url, branch, manifest_rel)
if github_manifest: if github_manifest:
if 'last_updated' in github_manifest and not enhanced_plugin.get('last_updated'): if 'last_updated' in github_manifest and not enhanced_plugin.get('last_updated'):
enhanced_plugin['last_updated'] = github_manifest['last_updated'] enhanced_plugin['last_updated'] = github_manifest['last_updated']
@@ -571,13 +580,16 @@ class PluginStoreManager:
return results return results
def _fetch_manifest_from_github(self, repo_url: str, branch: str = "master") -> Optional[Dict]: def _fetch_manifest_from_github(self, repo_url: str, branch: str = "master", manifest_path: str = "manifest.json", force_refresh: bool = False) -> Optional[Dict]:
""" """
Fetch manifest.json directly from a GitHub repository. Fetch manifest.json directly from a GitHub repository.
Args: Args:
repo_url: GitHub repository URL repo_url: GitHub repository URL
branch: Branch name (default: master) branch: Branch name (default: master)
manifest_path: Path to manifest within the repo (default: manifest.json).
For monorepo plugins this will be e.g. "plugins/football-scoreboard/manifest.json".
force_refresh: If True, bypass the cache.
Returns: Returns:
Manifest data or None if not found Manifest data or None if not found
@@ -596,24 +608,38 @@ class PluginStoreManager:
owner = parts[-2] owner = parts[-2]
repo = parts[-1] repo = parts[-1]
raw_url = f"https://raw.githubusercontent.com/{owner}/{repo}/{branch}/manifest.json" # Check cache first
cache_key = f"{owner}/{repo}:{branch}:{manifest_path}"
if not force_refresh and cache_key in self.manifest_cache:
cached_time, cached_data = self.manifest_cache[cache_key]
if time.time() - cached_time < self.manifest_cache_timeout:
return cached_data
raw_url = f"https://raw.githubusercontent.com/{owner}/{repo}/{branch}/{manifest_path}"
response = self._http_get_with_retries(raw_url, timeout=10) response = self._http_get_with_retries(raw_url, timeout=10)
if response.status_code == 200: if response.status_code == 200:
return response.json() result = response.json()
self.manifest_cache[cache_key] = (time.time(), result)
return result
elif response.status_code == 404: elif response.status_code == 404:
# Try main branch instead # Try main branch instead
if branch != "main": if branch != "main":
raw_url = f"https://raw.githubusercontent.com/{owner}/{repo}/main/manifest.json" raw_url = f"https://raw.githubusercontent.com/{owner}/{repo}/main/{manifest_path}"
response = self._http_get_with_retries(raw_url, timeout=10) response = self._http_get_with_retries(raw_url, timeout=10)
if response.status_code == 200: if response.status_code == 200:
return response.json() result = response.json()
self.manifest_cache[cache_key] = (time.time(), result)
return result
# Cache negative result
self.manifest_cache[cache_key] = (time.time(), None)
except Exception as e: except Exception as e:
self.logger.debug(f"Could not fetch manifest from GitHub for {repo_url}: {e}") self.logger.debug(f"Could not fetch manifest from GitHub for {repo_url}: {e}")
return None return None
def _get_latest_commit_info(self, repo_url: str, branch: str = "main") -> Optional[Dict[str, Any]]: def _get_latest_commit_info(self, repo_url: str, branch: str = "main", force_refresh: bool = False) -> Optional[Dict[str, Any]]:
"""Return metadata about the latest commit on the given branch.""" """Return metadata about the latest commit on the given branch."""
try: try:
if 'github.com' not in repo_url: if 'github.com' not in repo_url:
@@ -630,6 +656,13 @@ class PluginStoreManager:
owner = parts[-2] owner = parts[-2]
repo = parts[-1] repo = parts[-1]
# Check cache first
cache_key = f"{owner}/{repo}:{branch}"
if not force_refresh and cache_key in self.commit_info_cache:
cached_time, cached_data = self.commit_info_cache[cache_key]
if time.time() - cached_time < self.commit_cache_timeout:
return cached_data
branches_to_try = self._distinct_sequence([branch, 'main', 'master']) branches_to_try = self._distinct_sequence([branch, 'main', 'master'])
headers = { headers = {
@@ -652,7 +685,7 @@ class PluginStoreManager:
commit_author = commit_meta.get('author', {}) commit_author = commit_meta.get('author', {})
commit_date_iso = commit_author.get('date', '') commit_date_iso = commit_author.get('date', '')
return { result = {
'branch': branch_name, 'branch': branch_name,
'sha': commit_sha_full, 'sha': commit_sha_full,
'short_sha': commit_sha_short, 'short_sha': commit_sha_short,
@@ -661,6 +694,8 @@ class PluginStoreManager:
'author': commit_author.get('name', ''), 'author': commit_author.get('name', ''),
'message': commit_meta.get('message', ''), 'message': commit_meta.get('message', ''),
} }
self.commit_info_cache[cache_key] = (time.time(), result)
return result
if response.status_code == 403 and not self.github_token: if response.status_code == 403 and not self.github_token:
self.logger.debug("GitHub commit API rate limited (403). Consider adding a token.") self.logger.debug("GitHub commit API rate limited (403). Consider adding a token.")
@@ -671,13 +706,16 @@ class PluginStoreManager:
if last_error: if last_error:
self.logger.debug(f"Unable to fetch commit info for {repo_url}: {last_error}") self.logger.debug(f"Unable to fetch commit info for {repo_url}: {last_error}")
# Cache negative result to avoid repeated failing calls
self.commit_info_cache[cache_key] = (time.time(), None)
except Exception as e: except Exception as e:
self.logger.debug(f"Error fetching latest commit metadata for {repo_url}: {e}") self.logger.debug(f"Error fetching latest commit metadata for {repo_url}: {e}")
return None return None
def get_plugin_info(self, plugin_id: str, fetch_latest_from_github: bool = True) -> Optional[Dict]: def get_plugin_info(self, plugin_id: str, fetch_latest_from_github: bool = True, force_refresh: bool = False) -> Optional[Dict]:
""" """
Get detailed information about a plugin from the registry. Get detailed information about a plugin from the registry.
@@ -687,6 +725,7 @@ class PluginStoreManager:
Args: Args:
plugin_id: Plugin identifier plugin_id: Plugin identifier
fetch_latest_from_github: If True (default), augment with GitHub commit metadata. fetch_latest_from_github: If True (default), augment with GitHub commit metadata.
force_refresh: If True, bypass caches for commit/manifest data.
Returns: Returns:
Plugin metadata or None if not found Plugin metadata or None if not found
@@ -711,7 +750,7 @@ class PluginStoreManager:
plugin_info['last_updated'] = github_info.get('last_commit_date', plugin_info.get('last_updated')) plugin_info['last_updated'] = github_info.get('last_commit_date', plugin_info.get('last_updated'))
plugin_info['last_updated_iso'] = github_info.get('last_commit_iso', plugin_info.get('last_updated_iso')) plugin_info['last_updated_iso'] = github_info.get('last_commit_iso', plugin_info.get('last_updated_iso'))
commit_info = self._get_latest_commit_info(repo_url, branch) commit_info = self._get_latest_commit_info(repo_url, branch, force_refresh=force_refresh)
if commit_info: if commit_info:
plugin_info['last_commit'] = commit_info.get('short_sha') plugin_info['last_commit'] = commit_info.get('short_sha')
plugin_info['last_commit_sha'] = commit_info.get('sha') plugin_info['last_commit_sha'] = commit_info.get('sha')
@@ -722,7 +761,9 @@ class PluginStoreManager:
plugin_info['branch'] = commit_info.get('branch', branch) plugin_info['branch'] = commit_info.get('branch', branch)
plugin_info['last_commit_branch'] = commit_info.get('branch') plugin_info['last_commit_branch'] = commit_info.get('branch')
github_manifest = self._fetch_manifest_from_github(repo_url, branch) plugin_subpath = plugin_info.get('plugin_path', '')
manifest_rel = f"{plugin_subpath}/manifest.json" if plugin_subpath else "manifest.json"
github_manifest = self._fetch_manifest_from_github(repo_url, branch, manifest_rel, force_refresh=force_refresh)
if github_manifest: if github_manifest:
if 'last_updated' in github_manifest and not plugin_info.get('last_updated'): if 'last_updated' in github_manifest and not plugin_info.get('last_updated'):
plugin_info['last_updated'] = github_manifest['last_updated'] plugin_info['last_updated'] = github_manifest['last_updated']
@@ -731,6 +772,23 @@ class PluginStoreManager:
return plugin_info return plugin_info
def get_registry_info(self, plugin_id: str) -> Optional[Dict]:
"""
Get plugin information from the registry cache only (no GitHub API calls).
Use this for lightweight lookups where only registry fields are needed
(e.g., verified status, latest_version).
Args:
plugin_id: Plugin identifier
Returns:
Plugin metadata from registry or None if not found
"""
registry = self.fetch_registry()
plugins = registry.get('plugins', []) or []
return next((p for p in plugins if p.get('id') == plugin_id), None)
def install_plugin(self, plugin_id: str, branch: Optional[str] = None) -> bool: def install_plugin(self, plugin_id: str, branch: Optional[str] = None) -> bool:
""" """
Install a plugin from the official registry. Always installs the latest commit Install a plugin from the official registry. Always installs the latest commit
@@ -745,7 +803,7 @@ class PluginStoreManager:
branch_info = f" (branch: {branch})" if branch else " (latest branch head)" branch_info = f" (branch: {branch})" if branch else " (latest branch head)"
self.logger.info(f"Installing plugin: {plugin_id}{branch_info}") self.logger.info(f"Installing plugin: {plugin_id}{branch_info}")
plugin_info = self.get_plugin_info(plugin_id, fetch_latest_from_github=True) plugin_info = self.get_plugin_info(plugin_id, fetch_latest_from_github=True, force_refresh=True)
if not plugin_info: if not plugin_info:
self.logger.error(f"Plugin not found in registry: {plugin_id}") self.logger.error(f"Plugin not found in registry: {plugin_id}")
return False return False
@@ -808,7 +866,7 @@ class PluginStoreManager:
manifest_path = plugin_path / "manifest.json" manifest_path = plugin_path / "manifest.json"
if not manifest_path.exists(): if not manifest_path.exists():
self.logger.error(f"No manifest.json found in plugin: {plugin_id}") self.logger.error(f"No manifest.json found in plugin: {plugin_id}")
shutil.rmtree(plugin_path, ignore_errors=True) self._safe_remove_directory(plugin_path)
return False return False
try: try:
@@ -819,7 +877,7 @@ class PluginStoreManager:
manifest_plugin_id = manifest.get('id') manifest_plugin_id = manifest.get('id')
if not manifest_plugin_id: if not manifest_plugin_id:
self.logger.error(f"Plugin manifest missing 'id' field") self.logger.error(f"Plugin manifest missing 'id' field")
shutil.rmtree(plugin_path, ignore_errors=True) self._safe_remove_directory(plugin_path)
return False return False
# If manifest ID doesn't match directory name, rename directory to match manifest # If manifest ID doesn't match directory name, rename directory to match manifest
@@ -831,7 +889,9 @@ class PluginStoreManager:
correct_path = self.plugins_dir / manifest_plugin_id correct_path = self.plugins_dir / manifest_plugin_id
if correct_path.exists(): if correct_path.exists():
self.logger.warning(f"Target directory {manifest_plugin_id} already exists, removing it") self.logger.warning(f"Target directory {manifest_plugin_id} already exists, removing it")
shutil.rmtree(correct_path) if not self._safe_remove_directory(correct_path):
self.logger.error(f"Failed to remove existing directory {correct_path}, cannot rename plugin")
return False
shutil.move(str(plugin_path), str(correct_path)) shutil.move(str(plugin_path), str(correct_path))
plugin_path = correct_path plugin_path = correct_path
manifest_path = plugin_path / "manifest.json" manifest_path = plugin_path / "manifest.json"
@@ -859,7 +919,7 @@ class PluginStoreManager:
if missing: if missing:
self.logger.error(f"Plugin manifest missing required fields for {plugin_id}: {', '.join(missing)}") self.logger.error(f"Plugin manifest missing required fields for {plugin_id}: {', '.join(missing)}")
shutil.rmtree(plugin_path, ignore_errors=True) self._safe_remove_directory(plugin_path)
return False return False
if 'entry_point' not in manifest: if 'entry_point' not in manifest:
@@ -873,7 +933,7 @@ class PluginStoreManager:
except Exception as manifest_error: except Exception as manifest_error:
self.logger.error(f"Failed to read/validate manifest for {plugin_id}: {manifest_error}") self.logger.error(f"Failed to read/validate manifest for {plugin_id}: {manifest_error}")
shutil.rmtree(plugin_path, ignore_errors=True) self._safe_remove_directory(plugin_path)
return False return False
if not self._install_dependencies(plugin_path): if not self._install_dependencies(plugin_path):
@@ -886,7 +946,7 @@ class PluginStoreManager:
except Exception as e: except Exception as e:
self.logger.error(f"Error installing plugin {plugin_id}: {e}", exc_info=True) self.logger.error(f"Error installing plugin {plugin_id}: {e}", exc_info=True)
if plugin_path.exists(): if plugin_path.exists():
shutil.rmtree(plugin_path, ignore_errors=True) self._safe_remove_directory(plugin_path)
return False return False
def install_from_url(self, repo_url: str, plugin_id: str = None, plugin_path: str = None, branch: Optional[str] = None) -> Dict[str, Any]: def install_from_url(self, repo_url: str, plugin_id: str = None, plugin_path: str = None, branch: Optional[str] = None) -> Dict[str, Any]:
@@ -1047,7 +1107,7 @@ class PluginStoreManager:
finally: finally:
# Cleanup temp directory if it still exists # Cleanup temp directory if it still exists
if temp_dir and temp_dir.exists(): if temp_dir and temp_dir.exists():
shutil.rmtree(temp_dir) shutil.rmtree(temp_dir, ignore_errors=True)
def _detect_class_name(self, manager_file: Path) -> Optional[str]: def _detect_class_name(self, manager_file: Path) -> Optional[str]:
""" """
@@ -1104,7 +1164,7 @@ class PluginStoreManager:
last_error = e last_error = e
self.logger.debug(f"Git clone failed for branch {try_branch}: {e}") self.logger.debug(f"Git clone failed for branch {try_branch}: {e}")
if target_path.exists(): if target_path.exists():
shutil.rmtree(target_path) self._safe_remove_directory(target_path)
# Try default branch (Git's configured default) as last resort # Try default branch (Git's configured default) as last resort
try: try:
@@ -1121,25 +1181,194 @@ class PluginStoreManager:
except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError) as e: except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError) as e:
last_error = e last_error = e
if target_path.exists(): if target_path.exists():
shutil.rmtree(target_path) self._safe_remove_directory(target_path)
self.logger.error(f"Git clone failed for all attempted branches: {last_error}") self.logger.error(f"Git clone failed for all attempted branches: {last_error}")
return None return None
def _install_from_monorepo(self, download_url: str, plugin_subpath: str, target_path: Path) -> bool: def _install_from_monorepo(self, download_url: str, plugin_subpath: str, target_path: Path) -> bool:
""" """
Install a plugin from a monorepo by downloading and extracting a subdirectory. Install a plugin from a monorepo by downloading only the target subdirectory.
Uses the GitHub Git Trees API to list files, then downloads each file
individually from raw.githubusercontent.com. Falls back to downloading
the full ZIP archive if the API approach fails.
Args: Args:
download_url: URL to download zip from download_url: URL to download zip from (used as fallback and to extract repo info)
plugin_subpath: Path within repo (e.g., "plugins/hello-world") plugin_subpath: Path within repo (e.g., "plugins/hello-world")
target_path: Target directory for plugin target_path: Target directory for plugin
Returns: Returns:
True if successful True if successful
""" """
# Try the API-based approach first (downloads only the target directory)
repo_url, branch = self._parse_monorepo_download_url(download_url)
if repo_url and branch:
result = self._install_from_monorepo_api(repo_url, branch, plugin_subpath, target_path)
if result:
return True
self.logger.info(f"API-based install failed for {plugin_subpath}, falling back to ZIP download")
# Ensure no partial files remain before ZIP fallback
if target_path.exists():
self._safe_remove_directory(target_path)
# Fallback: download full ZIP and extract subdirectory
return self._install_from_monorepo_zip(download_url, plugin_subpath, target_path)
@staticmethod
def _parse_monorepo_download_url(download_url: str):
"""Extract repo URL and branch from a GitHub archive download URL.
Example: "https://github.com/ChuckBuilds/ledmatrix-plugins/archive/refs/heads/main.zip"
Returns: ("https://github.com/ChuckBuilds/ledmatrix-plugins", "main")
"""
try: try:
self.logger.info(f"Downloading monorepo from: {download_url}") # Pattern: {repo_url}/archive/refs/heads/{branch}.zip
if '/archive/refs/heads/' in download_url:
parts = download_url.split('/archive/refs/heads/')
repo_url = parts[0]
branch = parts[1].removesuffix('.zip')
return repo_url, branch
except (IndexError, AttributeError):
pass
return None, None
@staticmethod
def _normalize_repo_url(url: str) -> str:
"""Normalize a GitHub repo URL for comparison (strip trailing / and .git)."""
url = url.rstrip('/')
if url.endswith('.git'):
url = url[:-4]
return url.lower()
def _install_from_monorepo_api(self, repo_url: str, branch: str, plugin_subpath: str, target_path: Path) -> bool:
"""
Install a plugin subdirectory using the GitHub Git Trees API.
Downloads only the files in the target subdirectory (~200KB) instead
of the entire repository ZIP (~5MB+). Uses one API call for the tree
listing, then downloads individual files from raw.githubusercontent.com.
Args:
repo_url: GitHub repository URL (e.g., "https://github.com/owner/repo")
branch: Branch name (e.g., "main")
plugin_subpath: Path within repo (e.g., "plugins/hello-world")
target_path: Target directory for plugin
Returns:
True if successful, False to trigger ZIP fallback
"""
try:
# Parse owner/repo from URL
clean_url = repo_url.rstrip('/')
if clean_url.endswith('.git'):
clean_url = clean_url[:-4]
parts = clean_url.split('/')
if len(parts) < 2:
return False
owner, repo = parts[-2], parts[-1]
# Step 1: Get the recursive tree listing (1 API call)
api_url = f"https://api.github.com/repos/{owner}/{repo}/git/trees/{branch}?recursive=true"
headers = {
'Accept': 'application/vnd.github.v3+json',
'User-Agent': 'LEDMatrix-Plugin-Manager/1.0'
}
if self.github_token:
headers['Authorization'] = f'token {self.github_token}'
tree_response = self._http_get_with_retries(api_url, timeout=15, headers=headers)
if tree_response.status_code != 200:
self.logger.debug(f"Trees API returned {tree_response.status_code} for {owner}/{repo}")
return False
tree_data = tree_response.json()
if tree_data.get('truncated'):
self.logger.debug(f"Tree response truncated for {owner}/{repo}, falling back to ZIP")
return False
# Step 2: Filter for files in the target subdirectory
prefix = f"{plugin_subpath.strip('/')}/"
file_entries = [
entry for entry in tree_data.get('tree', [])
if entry['path'].startswith(prefix) and entry['type'] == 'blob'
]
if not file_entries:
self.logger.error(f"No files found under '{plugin_subpath}' in tree for {owner}/{repo}")
return False
# Sanity check: refuse unreasonably large plugin directories
max_files = 500
if len(file_entries) > max_files:
self.logger.error(
f"Plugin {plugin_subpath} has {len(file_entries)} files (limit {max_files}), "
f"falling back to ZIP"
)
return False
self.logger.info(f"Downloading {len(file_entries)} files for {plugin_subpath} via API")
# Step 3: Create target directory and download each file
from src.common.permission_utils import (
ensure_directory_permissions,
get_plugin_dir_mode
)
ensure_directory_permissions(target_path.parent, get_plugin_dir_mode())
target_path.mkdir(parents=True, exist_ok=True)
prefix_len = len(prefix)
target_root = target_path.resolve()
for entry in file_entries:
# Relative path within the plugin directory
rel_path = entry['path'][prefix_len:]
dest_file = target_path / rel_path
# Guard against path traversal
if not dest_file.resolve().is_relative_to(target_root):
self.logger.error(
f"Path traversal detected: {entry['path']!r} resolves outside target directory"
)
if target_path.exists():
self._safe_remove_directory(target_path)
return False
# Create parent directories
dest_file.parent.mkdir(parents=True, exist_ok=True)
# Download from raw.githubusercontent.com (no API rate limit cost)
raw_url = f"https://raw.githubusercontent.com/{owner}/{repo}/{branch}/{entry['path']}"
file_response = self._http_get_with_retries(raw_url, timeout=30)
if file_response.status_code != 200:
self.logger.error(f"Failed to download {entry['path']}: HTTP {file_response.status_code}")
# Clean up partial download
if target_path.exists():
self._safe_remove_directory(target_path)
return False
dest_file.write_bytes(file_response.content)
self.logger.info(f"Successfully installed {plugin_subpath} via API ({len(file_entries)} files)")
return True
except Exception as e:
self.logger.debug(f"API-based monorepo install failed: {e}")
# Clean up partial download
if target_path.exists():
self._safe_remove_directory(target_path)
return False
def _install_from_monorepo_zip(self, download_url: str, plugin_subpath: str, target_path: Path) -> bool:
"""
Fallback: install a plugin from a monorepo by downloading the full ZIP.
Used when the API-based approach fails (rate limited, auth issues, etc.).
"""
tmp_zip_path = None
temp_extract = None
try:
self.logger.info(f"Downloading monorepo ZIP from: {download_url}")
response = self._http_get_with_retries(download_url, timeout=60, stream=True) response = self._http_get_with_retries(download_url, timeout=60, stream=True)
response.raise_for_status() response.raise_for_status()
@@ -1149,55 +1378,60 @@ class PluginStoreManager:
tmp_file.write(chunk) tmp_file.write(chunk)
tmp_zip_path = tmp_file.name tmp_zip_path = tmp_file.name
try: with zipfile.ZipFile(tmp_zip_path, 'r') as zip_ref:
# Extract zip zip_contents = zip_ref.namelist()
with zipfile.ZipFile(tmp_zip_path, 'r') as zip_ref: if not zip_contents:
zip_contents = zip_ref.namelist() return False
if not zip_contents:
return False
# GitHub zips have a root directory like "repo-main/" root_dir = zip_contents[0].split('/')[0]
root_dir = zip_contents[0].split('/')[0] plugin_prefix = f"{root_dir}/{plugin_subpath}/"
# Build path to plugin within extracted archive # Extract ONLY files under the plugin subdirectory
# e.g., "ledmatrix-plugins-main/plugins/hello-world/" plugin_members = [m for m in zip_contents if m.startswith(plugin_prefix)]
plugin_path_in_zip = f"{root_dir}/{plugin_subpath}/"
# Extract to temp location if not plugin_members:
temp_extract = Path(tempfile.mkdtemp()) self.logger.error(f"Plugin path not found in archive: {plugin_subpath}")
zip_ref.extractall(temp_extract) return False
# Find the plugin directory temp_extract = Path(tempfile.mkdtemp())
source_plugin_dir = temp_extract / root_dir / plugin_subpath temp_extract_resolved = temp_extract.resolve()
if not source_plugin_dir.exists(): for member in plugin_members:
self.logger.error(f"Plugin path not found in archive: {plugin_subpath}") # Guard against zip-slip (directory traversal)
self.logger.error(f"Expected at: {source_plugin_dir}") member_dest = (temp_extract / member).resolve()
if not member_dest.is_relative_to(temp_extract_resolved):
self.logger.error(
f"Zip-slip detected: member {member!r} resolves outside "
f"temp directory, aborting"
)
shutil.rmtree(temp_extract, ignore_errors=True) shutil.rmtree(temp_extract, ignore_errors=True)
return False return False
zip_ref.extract(member, temp_extract)
# Move plugin contents to target source_plugin_dir = temp_extract / root_dir / plugin_subpath
from src.common.permission_utils import (
ensure_directory_permissions,
get_plugin_dir_mode
)
ensure_directory_permissions(target_path.parent, get_plugin_dir_mode())
shutil.move(str(source_plugin_dir), str(target_path))
# Cleanup temp extract dir from src.common.permission_utils import (
if temp_extract.exists(): ensure_directory_permissions,
shutil.rmtree(temp_extract, ignore_errors=True) get_plugin_dir_mode
)
ensure_directory_permissions(target_path.parent, get_plugin_dir_mode())
# Ensure target doesn't exist to prevent shutil.move nesting
if target_path.exists():
if not self._safe_remove_directory(target_path):
self.logger.error(f"Cannot remove existing target {target_path} for monorepo install")
return False
shutil.move(str(source_plugin_dir), str(target_path))
return True return True
finally:
# Remove temporary zip file
if os.path.exists(tmp_zip_path):
os.remove(tmp_zip_path)
except Exception as e: except Exception as e:
self.logger.error(f"Monorepo download failed: {e}", exc_info=True) self.logger.error(f"Monorepo ZIP download failed: {e}", exc_info=True)
return False return False
finally:
if tmp_zip_path and os.path.exists(tmp_zip_path):
os.remove(tmp_zip_path)
if temp_extract and temp_extract.exists():
shutil.rmtree(temp_extract, ignore_errors=True)
def _install_via_download(self, download_url: str, target_path: Path) -> bool: def _install_via_download(self, download_url: str, target_path: Path) -> bool:
""" """
@@ -1233,8 +1467,18 @@ class PluginStoreManager:
# Find the root directory in the zip # Find the root directory in the zip
root_dir = zip_contents[0].split('/')[0] root_dir = zip_contents[0].split('/')[0]
# Extract to temp location # Extract to temp location with zip-slip protection
temp_extract = Path(tempfile.mkdtemp()) temp_extract = Path(tempfile.mkdtemp())
temp_extract_resolved = temp_extract.resolve()
for member in zip_ref.namelist():
member_dest = (temp_extract / member).resolve()
if not member_dest.is_relative_to(temp_extract_resolved):
self.logger.error(
f"Zip-slip detected: member {member!r} resolves outside "
f"temp directory, aborting"
)
shutil.rmtree(temp_extract, ignore_errors=True)
return False
zip_ref.extractall(temp_extract) zip_ref.extractall(temp_extract)
# Move contents from root_dir to target # Move contents from root_dir to target
@@ -1389,11 +1633,12 @@ class PluginStoreManager:
def _safe_remove_directory(self, path: Path) -> bool: def _safe_remove_directory(self, path: Path) -> bool:
""" """
Safely remove a directory, handling permission errors for __pycache__ directories. Safely remove a directory, handling permission errors for root-owned files.
This function attempts to remove a directory and handles permission errors Attempts removal in three stages:
gracefully, especially for __pycache__ directories that may have been created 1. Normal shutil.rmtree()
by Python with different permissions. 2. Fix permissions via os.chmod() then retry (works for same-owner files)
3. Use sudo rm -rf as last resort (works for root-owned __pycache__, etc.)
Args: Args:
path: Path to directory to remove path: Path to directory to remove
@@ -1404,55 +1649,42 @@ class PluginStoreManager:
if not path.exists(): if not path.exists():
return True # Already removed return True # Already removed
# Stage 1: Try normal removal
try: try:
# First, try normal removal
shutil.rmtree(path) shutil.rmtree(path)
return True return True
except PermissionError as e: except OSError:
# Handle permission errors, especially for __pycache__ directories self.logger.warning(f"Permission error removing {path}, attempting chmod fix...")
self.logger.warning(f"Permission error removing {path}: {e}. Attempting to fix permissions...")
try: # Stage 2: Try chmod + retry (works when we own the files)
# Try to fix permissions on __pycache__ directories recursively try:
import stat for root, _dirs, files in os.walk(path):
for root, dirs, files in os.walk(path): root_path = Path(root)
root_path = Path(root) try:
os.chmod(root_path, stat.S_IRWXU)
except (OSError, PermissionError):
pass
for file in files:
try: try:
# Make directory writable os.chmod(root_path / file, stat.S_IRWXU)
os.chmod(root_path, stat.S_IRWXU | stat.S_IRWXG | stat.S_IRWXO)
except (OSError, PermissionError): except (OSError, PermissionError):
pass pass
shutil.rmtree(path)
self.logger.info(f"Removed {path} after fixing permissions")
return True
except (PermissionError, OSError):
self.logger.warning(f"chmod fix failed for {path}, attempting sudo removal...")
# Fix file permissions # Stage 3: Use sudo rm -rf (for root-owned __pycache__, data/.cache, etc.)
for file in files: if sudo_remove_directory(path):
file_path = root_path / file return True
try:
os.chmod(file_path, stat.S_IRWXU | stat.S_IRWXG | stat.S_IRWXO)
except (OSError, PermissionError):
pass
# Try removal again after fixing permissions # Final check — maybe partial removal got everything
shutil.rmtree(path) if not path.exists():
self.logger.info(f"Successfully removed {path} after fixing permissions") return True
return True
except Exception as e2: self.logger.error(f"All removal strategies failed for {path}")
self.logger.error(f"Failed to remove {path} even after fixing permissions: {e2}") return False
# Last resort: try with ignore_errors
try:
shutil.rmtree(path, ignore_errors=True)
# Check if it actually got removed
if not path.exists():
self.logger.warning(f"Removed {path} with ignore_errors=True (some files may remain)")
return True
else:
self.logger.error(f"Could not remove {path} even with ignore_errors")
return False
except Exception as e3:
self.logger.error(f"Final removal attempt failed for {path}: {e3}")
return False
except Exception as e:
self.logger.error(f"Unexpected error removing {path}: {e}")
return False
def _find_plugin_path(self, plugin_id: str) -> Optional[Path]: def _find_plugin_path(self, plugin_id: str) -> Optional[Path]:
""" """
@@ -1538,7 +1770,7 @@ class PluginStoreManager:
# Try to get remote info from registry (optional) # Try to get remote info from registry (optional)
self.fetch_registry(force_refresh=True) self.fetch_registry(force_refresh=True)
plugin_info_remote = self.get_plugin_info(plugin_id, fetch_latest_from_github=True) plugin_info_remote = self.get_plugin_info(plugin_id, fetch_latest_from_github=True, force_refresh=True)
remote_branch = None remote_branch = None
remote_sha = None remote_sha = None
@@ -1546,6 +1778,21 @@ class PluginStoreManager:
remote_branch = plugin_info_remote.get('branch') or plugin_info_remote.get('default_branch') remote_branch = plugin_info_remote.get('branch') or plugin_info_remote.get('default_branch')
remote_sha = plugin_info_remote.get('last_commit_sha') remote_sha = plugin_info_remote.get('last_commit_sha')
# Check if the local git remote still matches the registry repo URL.
# After monorepo migration, old clones point to archived individual repos
# while the registry now points to the monorepo. Detect this and reinstall.
registry_repo = plugin_info_remote.get('repo', '')
local_remote = git_info.get('remote_url', '')
if local_remote and registry_repo and self._normalize_repo_url(local_remote) != self._normalize_repo_url(registry_repo):
self.logger.info(
f"Plugin {plugin_id} git remote ({local_remote}) differs from registry ({registry_repo}). "
f"Reinstalling from registry to migrate to new source."
)
if not self._safe_remove_directory(plugin_path):
self.logger.error(f"Failed to remove old plugin directory for {plugin_id}")
return False
return self.install_plugin(plugin_id)
# Check if already up to date # Check if already up to date
if remote_sha and local_sha and remote_sha.startswith(local_sha): if remote_sha and local_sha and remote_sha.startswith(local_sha):
self.logger.info(f"Plugin {plugin_id} already matches remote commit {remote_sha[:7]}") self.logger.info(f"Plugin {plugin_id} already matches remote commit {remote_sha[:7]}")
@@ -1795,7 +2042,7 @@ class PluginStoreManager:
# Try registry-based update # Try registry-based update
self.logger.info(f"Plugin {plugin_id} is not a git repository, checking registry...") self.logger.info(f"Plugin {plugin_id} is not a git repository, checking registry...")
self.fetch_registry(force_refresh=True) self.fetch_registry(force_refresh=True)
plugin_info_remote = self.get_plugin_info(plugin_id, fetch_latest_from_github=True) plugin_info_remote = self.get_plugin_info(plugin_id, fetch_latest_from_github=True, force_refresh=True)
# If not in registry but we have a repo URL, try reinstalling from that URL # If not in registry but we have a repo URL, try reinstalling from that URL
if not plugin_info_remote and repo_url: if not plugin_info_remote and repo_url:
@@ -1833,11 +2080,28 @@ class PluginStoreManager:
remote_sha = plugin_info_remote.get('last_commit_sha') remote_sha = plugin_info_remote.get('last_commit_sha')
remote_branch = plugin_info_remote.get('branch') or plugin_info_remote.get('default_branch') remote_branch = plugin_info_remote.get('branch') or plugin_info_remote.get('default_branch')
# If we get here, plugin is not a git repo but is in registry - reinstall # Compare local manifest version against registry latest_version
# to avoid unnecessary reinstalls for monorepo plugins
try:
local_manifest_path = plugin_path / "manifest.json"
if local_manifest_path.exists():
with open(local_manifest_path, 'r', encoding='utf-8') as f:
local_manifest = json.load(f)
local_version = local_manifest.get('version', '')
remote_version = plugin_info_remote.get('latest_version', '')
if local_version and remote_version and local_version == remote_version:
self.logger.info(f"Plugin {plugin_id} already at latest version {local_version}")
return True
except Exception as e:
self.logger.debug(f"Could not compare versions for {plugin_id}: {e}")
# Plugin is not a git repo but is in registry and has a newer version - reinstall
self.logger.info(f"Plugin {plugin_id} not installed via git; re-installing latest archive") self.logger.info(f"Plugin {plugin_id} not installed via git; re-installing latest archive")
# Remove directory and reinstall fresh # Remove directory and reinstall fresh
shutil.rmtree(plugin_path, ignore_errors=True) if not self._safe_remove_directory(plugin_path):
self.logger.error(f"Failed to remove old plugin directory for {plugin_id}")
return False
return self.install_plugin(plugin_id) return self.install_plugin(plugin_id)
except Exception as e: except Exception as e:

View File

@@ -0,0 +1,21 @@
"""
Vegas Mode - Continuous Scrolling Ticker
This package implements a Vegas-style continuous scroll mode where all enabled
plugins' content is composed into a single horizontally scrolling display.
Components:
- VegasModeCoordinator: Main orchestrator for Vegas mode
- StreamManager: Manages plugin content streaming with 1-2 ahead buffering
- RenderPipeline: Handles 125 FPS rendering with double-buffering
- PluginAdapter: Converts plugin content to scrollable images
- VegasModeConfig: Configuration management
"""
from src.vegas_mode.config import VegasModeConfig
from src.vegas_mode.coordinator import VegasModeCoordinator
__all__ = [
'VegasModeConfig',
'VegasModeCoordinator',
]

Some files were not shown because too many files have changed in this diff Show More