59 Commits

Author SHA1 Message Date
Chuck
5bb9a59162 fix(starlark): logging improvements and code quality fixes
Logging improvements (pages_v3.py):
- Add logging import and create module logger
- Replace print() calls with logger.warning() with "[Pages V3]" prefix
- Use logger.exception() for outer try/catch with exc_info=True
- Narrow exception handling to OSError, json.JSONDecodeError for file operations

API improvements (api_v3.py):
- Remove unnecessary f-strings (Ruff F541) from ImportError messages
- Narrow upload exception handling to ValueError, OSError, IOError
- Use logger.exception() with context for better debugging
- Remove early return in get_starlark_status() to allow standalone mode fallback
- Sanitize error messages returned to client (don't expose internal details)

Benefits:
- Better log context with consistent prefixes
- More specific exception handling prevents masking unexpected errors
- Standalone/web-service-only mode now works for status endpoint
- Stack traces preserved for debugging without exposing to clients

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-19 21:44:39 -05:00
Chuck
92fb0989ce fix(starlark): critical path traversal and exception handling fixes
Path traversal security fixes (CRITICAL):
- Add _validate_starlark_app_path() helper to check for path traversal attacks
- Validate app_id in get_starlark_app(), uninstall_starlark_app(),
  get_starlark_app_config(), and update_starlark_app_config()
- Check for '..' and path separators before any filesystem access
- Verify resolved paths are within _STARLARK_APPS_DIR using Path.relative_to()
- Prevents unauthorized file access via crafted app_id like '../../../etc/passwd'

Exception handling improvements (tronbyte_repository.py):
- Replace broad "except Exception" with specific types
- _make_request: catch requests.Timeout, requests.RequestException, json.JSONDecodeError
- _fetch_raw_file: catch requests.Timeout, requests.RequestException separately
- download_app_assets: narrow to OSError, ValueError
- Add "[Tronbyte Repo]" context prefix to all log messages
- Use exc_info=True for better stack traces

API improvements:
- Narrow exception catches to OSError, json.JSONDecodeError in config loading
- Remove duplicate path traversal checks (now centralized in helper)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-19 21:38:37 -05:00
Chuck
36da426c29 fix(starlark): critical bug fixes and code quality improvements
Critical fixes:
- Fix stack overflow in safeLocalStorage (was recursively calling itself)
- Fix duplicate event listeners on Starlark grid (added sentinel check)
- Fix JSON validation to fail fast on malformed data instead of silently passing

Error handling improvements:
- Narrow exception catches to specific types (OSError, json.JSONDecodeError, ValueError)
- Use logger.exception() with exc_info=True for better stack traces
- Replace generic "except Exception" with specific exception types

Logging improvements:
- Add "[Starlark Pixlet]" context tags to pixlet_renderer logs
- Redact sensitive config values from debug logs (API keys, etc.)
- Add file_path context to schema parsing warnings

Documentation:
- Fix markdown lint issues (add language tags to code blocks)
- Fix time unit spacing: "(5min)" -> "(5 min)"

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-19 21:32:45 -05:00
Chuck
6a60a57421 fix(starlark): security and race condition improvements
Security fixes:
- Add path traversal validation for output_path in download_star_file
- Remove XSS-vulnerable inline onclick handlers, use delegated events
- Add type hints to helper functions for better type safety

Race condition fixes:
- Lock manifest file BEFORE creating temp file in _save_manifest
- Hold exclusive lock for entire read-modify-write cycle in _update_manifest_safe
- Prevent concurrent writers from racing on manifest updates

Other improvements:
- Fix pages_v3.py standalone mode to load config.json from disk
- Improve error handling with proper logging in cleanup blocks
- Add explicit type annotations to Starlark helper functions

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-19 19:37:09 -05:00
Chuck
aafb238ac9 fix(starlark): restore Starlark JS functionality lost in merge
During the merge with main, all Starlark-specific JavaScript (104 lines)
was removed from plugins_manager.js, including:
- starlarkFilterState and filtering logic
- loadStarlarkApps() function
- Starlark app install/uninstall handlers
- Starlark section collapse/expand logic
- Pagination and sorting for Starlark apps

Restored from commit 942663ab and re-applied safeLocalStorage wrapper
from our code review fixes.

Fixes: Starlark Apps section non-functional in web UI

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-19 17:12:31 -05:00
Chuck
b8564c952c fix(starlark): restore Starlark Apps section in plugins.html
The Starlark Apps UI section was lost during merge conflict resolution
with main branch. Restored from commit 942663ab which had the complete
implementation with filtering, sorting, and pagination.

Fixes: Starlark section not visible on plugin manager page

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-19 17:09:50 -05:00
Chuck
e64caccae6 fix(starlark): convert Path to str in spec_from_file_location calls
The module import helpers were passing Path objects directly to
spec_from_file_location(), which caused spec to be None. This broke
the Starlark app store browser.

- Convert module_path to string in both _get_tronbyte_repository_class
  and _get_pixlet_renderer_class
- Add None checks with clear error messages for debugging

Fixes: spec not found for the module 'tronbyte_repository'

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-19 17:02:57 -05:00
Chuck
441b3c56e9 fix(starlark): code review fixes - security, robustness, and schema parsing
## Security Fixes
- manager.py: Check _update_manifest_safe return values to prevent silent failures
- manager.py: Improve temp file cleanup in _save_manifest to prevent leaks
- manager.py: Fix uninstall order (manifest → memory → disk) for consistency
- api_v3.py: Add path traversal validation in uninstall endpoint
- api_v3.py: Implement atomic writes for manifest files with temp + rename
- pixlet_renderer.py: Relax config validation to only block dangerous shell metacharacters

## Frontend Robustness
- plugins_manager.js: Add safeLocalStorage wrapper for restricted contexts (private browsing)
- starlark_config.html: Scope querySelector to container to prevent modal conflicts

## Schema Parsing Improvements
- pixlet_renderer.py: Indentation-aware get_schema() extraction (handles nested functions)
- pixlet_renderer.py: Handle quoted defaults with commas (e.g., "New York, NY")
- tronbyte_repository.py: Validate file_name is string before path traversal checks

## Dependencies
- requirements.txt: Update Pillow (10.4.0), PyYAML (6.0.2), requests (2.32.0)

## Documentation
- docs/STARLARK_APPS_GUIDE.md: Comprehensive guide explaining:
  - How Starlark apps work
  - That apps come from Tronbyte (not LEDMatrix)
  - Installation, configuration, troubleshooting
  - Links to upstream projects

All changes improve security, reliability, and user experience.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-19 16:58:22 -05:00
Chuck
5d213b5747 Merge branch 'main' into feat/starlark-apps
Resolved conflicts by accepting upstream plugin store filtering changes.
The plugin store filtering/sorting feature from main is compatible with
the starlark apps functionality in this branch.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-19 16:44:24 -05:00
Chuck
8d1579a51b feat(starlark): implement schema extraction, asset download, and config persistence
## Schema Extraction
- Replace broken `pixlet serve --print-schema` with regex-based source parser
- Extract schema by parsing `get_schema()` function from .star files
- Support all field types: Location, Text, Toggle, Dropdown, Color, DateTime
- Handle variable-referenced dropdown options (e.g., `options = teamOptions`)
- Gracefully handle complex/unsupported field types (OAuth2, PhotoSelect, etc.)
- Extract schema for 90%+ of Tronbyte apps

## Asset Download
- Add `download_app_assets()` to fetch images/, sources/, fonts/ directories
- Download assets in binary mode for proper image/font handling
- Validate all paths to prevent directory traversal attacks
- Copy asset directories during app installation
- Enable apps like AnalogClock that require image assets

## Config Persistence
- Create config.json file during installation with schema defaults
- Update both config.json and manifest when saving configuration
- Load config from config.json (not manifest) for consistency with plugin
- Separate timing keys (render_interval, display_duration) from app config
- Fix standalone web service mode to read/write config.json

## Pixlet Command Fix
- Fix Pixlet CLI invocation: config params are positional, not flags
- Change from `pixlet render file.star -c key=value` to `pixlet render file.star key=value -o output`
- Properly handle JSON config values (e.g., location objects)
- Enable config to be applied during rendering

## Security & Reliability
- Add threading.Lock for cache operations to prevent race conditions
- Reduce ThreadPoolExecutor workers from 20 to 5 for Raspberry Pi
- Add path traversal validation in download_star_file()
- Add YAML error logging in manifest fetching
- Add file size validation (5MB limit) for .star uploads
- Use sanitized app_id consistently in install endpoints
- Use atomic manifest updates to prevent race conditions
- Add missing Optional import for type hints

## Web UI
- Fix standalone mode schema loading in config partial
- Schema-driven config forms now render correctly for all apps
- Location fields show lat/lng/timezone inputs
- Dropdown, toggle, text, color, and datetime fields all supported

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-19 16:42:45 -05:00
Chuck
a8609aea18 fix(starlark): load schema from schema.json in standalone mode
The standalone API endpoint was returning schema: null because it didn't
load the schema.json file. Now reads schema from disk when returning
app details via web service.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-19 11:35:16 -05:00
Chuck
0dc1a8f6f4 fix(starlark): add List to typing imports for schema parser
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-19 11:33:49 -05:00
Chuck
d876679b9f feat(starlark): implement source code parser for schema extraction
Pixlet CLI doesn't support schema extraction (--print-schema flag doesn't exist),
so apps were being installed without schemas even when they have them.

Implemented regex-based .star file parser that:
- Extracts get_schema() function from source code
- Parses schema.Schema(version, fields) structure
- Handles variable-referenced dropdown options (e.g., options = dialectOptions)
- Supports Location, Text, Toggle, Dropdown, Color, DateTime fields
- Gracefully handles unsupported fields (OAuth2, LocationBased, etc.)
- Returns formatted JSON matching web UI template expectations

Coverage: 90%+ of Tronbyte apps (static schemas + variable references)

Changes:
- Replace extract_schema() to parse .star files directly instead of using Pixlet CLI
- Add 6 helper methods for parsing schema structure
- Handle nested parentheses and brackets properly
- Resolve variable references for dropdown options

Tested with:
- analog_clock.star (Location field) ✓
- Multi-field test (Text + Dropdown + Toggle) ✓
- Variable-referenced options ✓

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-19 11:32:37 -05:00
Chuck
6a04e882c1 feat(starlark): extract schema during standalone install
The standalone install function (_install_star_file) wasn't extracting
schema from .star files, so apps installed via the web service had no
schema.json and the config panel couldn't render schema-driven forms.

Now uses PixletRenderer to extract schema during standalone install,
same as the plugin does.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-19 09:47:08 -05:00
Chuck
45f6e7c20e fix(starlark): use correct 'fileName' field from manifest (camelCase)
The Tronbyte manifest uses 'fileName' (camelCase), not 'filename' (lowercase).
This caused the download to fall back to {app_id}.star which doesn't exist
for apps like analogclock (which has analog_clock.star).

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-19 09:30:25 -05:00
Chuck
a821060084 fix(starlark): reload tronbyte_repository module to pick up code changes
The web service caches imported modules in sys.modules. When deploying
code updates, the old cached version was still being used.

Now uses importlib.reload() when module is already loaded.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-19 09:29:19 -05:00
Chuck
c584f227c1 fix(starlark): use manifest filename field for .star downloads
Tronbyte apps don't always name their .star file to match the directory.
For example, the "analogclock" app has "analog_clock.star" (with underscore).

The manifest.yaml contains a "filename" field with the correct name.

Changes:
- download_star_file() now accepts optional filename parameter
- Install endpoint passes metadata['filename'] to download_star_file()
- Falls back to {app_id}.star if filename not in manifest

Fixes: "Failed to download .star file for analogclock" error

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-18 21:41:50 -05:00
Chuck
885fdeed62 feat(starlark): schema-driven config forms + critical security fixes
## Schema-Driven Config UI
- Render type-appropriate form inputs from schema.json (text, dropdown, toggle, color, datetime, location)
- Pre-populate config.json with schema defaults on install
- Auto-merge schema defaults when loading existing apps (handles schema updates)
- Location fields: 3-part mini-form (lat/lng/timezone) assembles into JSON
- Toggle fields: support both boolean and string "true"/"false" values
- Unsupported field types (oauth2, photo_select) show warning banners
- Fallback to raw key/value inputs for apps without schema

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

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

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-18 21:38:57 -05:00
Chuck
679d9cc2fe fix(store): move storeFilterState to global scope to fix scoping bug
storeFilterState, pluginStoreCache, and related variables were declared
inside an IIFE but referenced by top-level functions, causing
ReferenceError that broke all plugin loading.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 16:14:41 -05:00
Chuck
942663abfd feat(store): add sort, filter, search, and pagination to Plugin Store and Starlark Apps
Plugin Store:
- Live search with 300ms debounce (replaces Search button)
- Sort dropdown: A→Z, Z→A, Category, Author, Newest
- Installed toggle filter (All / Installed / Not Installed)
- Per-page selector (12/24/48) with pagination controls
- "Installed" badge and "Reinstall" button on already-installed plugins
- Active filter count badge + clear filters button

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 14:54:29 -05:00
Chuck
5f2daa52b0 fix(starlark): always show editable timing settings in config panel
Render interval and display duration are now always editable in the
starlark app config panel, not just shown as read-only status text.
App-specific settings from schema still appear below when present.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 13:53:45 -05:00
Chuck
13ab4f7eee fix(starlark): make config partial work standalone in web service
Read starlark app data from manifest file directly when the plugin
isn't loaded, matching the api_v3.py standalone pattern.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 13:39:22 -05:00
Chuck
4f438fc76a fix(starlark): make API endpoints work standalone in web service
The web service runs as a separate process with display_manager=None,
so plugins aren't instantiated. Refactor starlark API endpoints to
read/write the manifest file directly when the plugin isn't loaded,
enabling full CRUD operations from the web UI.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 13:37:35 -05:00
Chuck
f279e9eea5 fix(starlark): use bare imports instead of relative imports
Plugin loader uses spec_from_file_location without package context,
so relative imports (.pixlet_renderer) fail. Use bare imports like
all other plugins do.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 13:28:48 -05:00
Chuck
3ec1e987a4 feat: integrate Starlark/Tronbyte app support into plugin system
Add starlark-apps plugin that renders Tidbyt/Tronbyte .star apps via
Pixlet binary and integrates them into the existing Plugin Manager UI
as virtual plugins. Includes vegas scroll support, Tronbyte repository
browsing, and per-app configuration.

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 13:27:22 -05:00
Chuck
636d0e181c feat(plugins): add sorting, filtering, and fix Update All button (#252)
* feat(store): add sorting, filtering, and fix Update All button

Add client-side sorting and filtering to the Plugin Store:
- Sort by A-Z, Z-A, Verified First, Recently Updated, Category
- Filter by verified, new, installed status, author, and tags
- Installed/Update Available badges on store cards
- Active filter count badge with clear-all button
- Sort preference persisted to localStorage

Fix three bugs causing button unresponsiveness:
- pluginsInitialized never reset on HTMX tab navigation (root cause
  of Update All silently doing nothing on second visit)
- htmx:afterSwap condition too broad (fired on unrelated swaps)
- data-running guard tied to DOM element replaced by cloneNode

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

* refactor(store): replace tag pills with category pills, fix sort dates

- Replace tag filter pills with category filter pills (less duplication)
- Prefer per-plugin last_updated over repo-wide pushed_at for sort

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

* debug: add console logging to filter/sort handlers

* fix: bump cache-buster versions for JS and CSS

* feat(plugins): add sorting to installed plugins section

Add A-Z, Z-A, and Enabled First sort options for installed plugins
with localStorage persistence. Both installed and store sections
now default to A-Z sorting.

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

* fix(store): consolidate CSS, fix stale cache bug, add missing utilities, fix icon

- Consolidate .filter-pill and .category-filter-pill into shared selectors
  and scope transition to only changed properties
- Fix applyStoreFiltersAndSort ignoring fresh server-filtered results by
  accepting optional basePlugins parameter
- Add missing .py-1.5 and .rounded-full CSS utility classes
- Replace invalid fa-sparkles with fa-star (FA 6.0.0 compatible)

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

* fix(store): semver-aware update badge and add missing gap-1.5 utility

- Replace naive version !== comparison with isNewerVersion() that does
  semver greater-than check, preventing false "Update" badges on
  same-version or downgrade scenarios
- Add missing .gap-1.5 CSS utility used by category pills and tag lists

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

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 07:38:16 -05:00
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
154 changed files with 1074646 additions and 2019 deletions

7
.gitignore vendored
View File

@@ -40,3 +40,10 @@ htmlcov/
# See docs/MULTI_ROOT_WORKSPACE_SETUP.md for details
plugins/*
!plugins/.gitkeep
# Binary files and backups
bin/pixlet/
config/backups/
# Starlark apps runtime storage (installed .star files and cached renders)
/starlark-apps/

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"]
path = rpi-rgb-led-matrix-master
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": ".",
"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",
"name": "Plugin Registry"
"name": "Plugins (Monorepo)"
}
],
"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.
### 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) :
[![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
@@ -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
- 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/
- 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!)
-----------------------------------------------------------------------------------

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",
"location": {
"city": "Dallas",
@@ -64,15 +108,23 @@
"disable_hardware_pulsing": false,
"inverse_colors": false,
"show_refresh_rate": false,
"led_rgb_sequence": "RGB",
"limit_refresh_rate_hz": 100
},
"runtime": {
"gpio_slowdown": 3
},
"display_durations": {
"calendar": 30
},
"use_short_date_format": true
"display_durations": {},
"use_short_date_format": true,
"vegas_scroll": {
"enabled": false,
"scroll_speed": 50,
"separator_width": 32,
"plugin_order": [],
"excluded_plugins": [],
"target_fps": 125,
"buffer_ahead": 2
}
},
"plugin_system": {
"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
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
### For New Users
1. **Installation**: Follow the main [README.md](../README.md) in the project root
2. **First Setup**: Run `first_time_install.sh` for initial configuration
3. **Basic Usage**: See [TROUBLESHOOTING_QUICK_START.md](TROUBLESHOOTING_QUICK_START.md) for common issues
2. **First Setup**: See [GETTING_STARTED.md](GETTING_STARTED.md) for first-time setup guide
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
1. **Plugin System**: Read [PLUGIN_QUICK_REFERENCE.md](PLUGIN_QUICK_REFERENCE.md) for an overview
2. **Plugin Development**: See [PLUGIN_DEVELOPMENT_GUIDE.md](PLUGIN_DEVELOPMENT_GUIDE.md) for development workflow
1. **Plugin Development**: See [PLUGIN_DEVELOPMENT_GUIDE.md](PLUGIN_DEVELOPMENT_GUIDE.md) for complete guide
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
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
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
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
### 🚀 Getting Started & Setup
- [EMULATOR_SETUP_GUIDE.md](EMULATOR_SETUP_GUIDE.md) - Set up development environment with emulator
- [TRIXIE_UPGRADE_GUIDE.md](TRIXIE_UPGRADE_GUIDE.md) - Upgrade to Raspbian OS 13 "Trixie"
- [TROUBLESHOOTING_QUICK_START.md](TROUBLESHOOTING_QUICK_START.md) - Common issues and solutions
### 🚀 Getting Started & User Guides
- [GETTING_STARTED.md](GETTING_STARTED.md) - First-time setup and quick start guide
- [WEB_INTERFACE_GUIDE.md](WEB_INTERFACE_GUIDE.md) - Complete web interface user guide
- [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
- [PLUGIN_ARCHITECTURE_SPEC.md](PLUGIN_ARCHITECTURE_SPEC.md) - Complete plugin system specification
- [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
### ⚡ Advanced Features
- [ADVANCED_FEATURES.md](ADVANCED_FEATURES.md) - Vegas scroll mode, on-demand display, cache management, background services, permissions
### 🔌 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_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
- [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_TROUBLESHOOTING.md](PLUGIN_DEPENDENCY_TROUBLESHOOTING.md) - Dependency troubleshooting
### 🎮 Plugin Features
- [ON_DEMAND_DISPLAY_QUICK_START.md](ON_DEMAND_DISPLAY_QUICK_START.md) - Manual display triggering
- [PLUGIN_LIVE_PRIORITY_QUICK_START.md](PLUGIN_LIVE_PRIORITY_QUICK_START.md) - Live content priority
- [PLUGIN_LIVE_PRIORITY_API.md](PLUGIN_LIVE_PRIORITY_API.md) - Live priority API reference
- [PLUGIN_CUSTOM_ICONS_FEATURE.md](PLUGIN_CUSTOM_ICONS_FEATURE.md) - Custom plugin icons
- [PLUGIN_DISPATCH_IMPLEMENTATION.md](PLUGIN_DISPATCH_IMPLEMENTATION.md) - Plugin dispatch system
- [PLUGIN_TABS_FEATURE_COMPLETE.md](PLUGIN_TABS_FEATURE_COMPLETE.md) - Plugin tabs feature
### 🏗️ Plugin Features & Extensions
- [PLUGIN_CUSTOM_ICONS.md](PLUGIN_CUSTOM_ICONS.md) - Custom plugin icons
- [PLUGIN_CUSTOM_ICONS_FEATURE.md](PLUGIN_CUSTOM_ICONS_FEATURE.md) - Custom icons implementation
- [PLUGIN_IMPLEMENTATION_SUMMARY.md](PLUGIN_IMPLEMENTATION_SUMMARY.md) - Plugin system implementation
- [PLUGIN_REGISTRY_SETUP_GUIDE.md](PLUGIN_REGISTRY_SETUP_GUIDE.md) - Setting up plugin registry
- [PLUGIN_WEB_UI_ACTIONS.md](PLUGIN_WEB_UI_ACTIONS.md) - Web UI actions for plugins
### 📡 API Reference
- [API_REFERENCE.md](API_REFERENCE.md) - Complete REST API documentation for web interface
- [PLUGIN_API_REFERENCE.md](PLUGIN_API_REFERENCE.md) - Plugin developer API reference (Display Manager, Cache Manager, Plugin Manager)
- [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 (Display Manager, Cache Manager, Plugin Manager)
- [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
- [BACKGROUND_SERVICE_README.md](BACKGROUND_SERVICE_README.md) - Background service architecture
- [FONT_MANAGER_USAGE.md](FONT_MANAGER_USAGE.md) - Font management system
- [DEVELOPMENT.md](DEVELOPMENT.md) - Development environment setup
- [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
- [RASPBIAN_TRIXIE_COMPATIBILITY_ANALYSIS.md](RASPBIAN_TRIXIE_COMPATIBILITY_ANALYSIS.md) - Detailed Trixie compatibility analysis
- [CONFIGURATION_CLEANUP_SUMMARY.md](CONFIGURATION_CLEANUP_SUMMARY.md) - Configuration cleanup details
- [football_plugin_comparison.md](football_plugin_comparison.md) - Football plugin analysis
### 🔄 Migration & Updates
- [MIGRATION_GUIDE.md](MIGRATION_GUIDE.md) - Breaking changes and migration instructions
- [SSH_UNAVAILABLE_AFTER_INSTALL.md](SSH_UNAVAILABLE_AFTER_INSTALL.md) - SSH troubleshooting after install
### 📊 Utility & Scripts
- [README_broadcast_logo_analyzer.md](README_broadcast_logo_analyzer.md) - Broadcast logo analysis tool
- [README_soccer_logos.md](README_soccer_logos.md) - Soccer logo management
- [WEB_INTERFACE_TROUBLESHOOTING.md](WEB_INTERFACE_TROUBLESHOOTING.md) - Web interface troubleshooting
## 🔄 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
### 📚 Miscellaneous
- [widget-guide.md](widget-guide.md) - Widget development guide
- Template files:
- [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
## 🎯 Key Resources by Use Case
### I'm new to LEDMatrix
1. [Main README](../README.md) - Installation and setup
2. [EMULATOR_SETUP_GUIDE.md](EMULATOR_SETUP_GUIDE.md) - Development environment
3. [PLUGIN_QUICK_REFERENCE.md](PLUGIN_QUICK_REFERENCE.md) - Understanding the system
1. [GETTING_STARTED.md](GETTING_STARTED.md) - Start here for first-time setup
2. [WEB_INTERFACE_GUIDE.md](WEB_INTERFACE_GUIDE.md) - Learn the control panel
3. [PLUGIN_STORE_GUIDE.md](PLUGIN_STORE_GUIDE.md) - Install plugins
### I want to create a plugin
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
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
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
1. [TROUBLESHOOTING_QUICK_START.md](TROUBLESHOOTING_QUICK_START.md) - Common issues
2. [WEB_INTERFACE_TROUBLESHOOTING.md](WEB_INTERFACE_TROUBLESHOOTING.md) - Web interface problems
1. [TROUBLESHOOTING.md](TROUBLESHOOTING.md) - Comprehensive troubleshooting guide
2. [WIFI_NETWORK_SETUP.md](WIFI_NETWORK_SETUP.md) - WiFi/network 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
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
## 🔄 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
### Documentation Standards
- Use Markdown format with consistent headers
- Professional technical writing style
- Minimal emojis (1-2 per major section for navigation)
- Include code examples where helpful
- 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
1. Place in appropriate category (see sections above)
2. Update this README.md with the new document
3. Follow naming conventions (FEATURE_NAME.md)
4. Consider if content should be consolidated with existing docs
1. Consider if content should be added to existing docs first
2. Place in appropriate category (see sections above)
3. Update this README.md with the new document
4. Follow naming conventions (FEATURE_NAME.md)
5. Use consistent formatting and voice
### Consolidation Guidelines
- **Implementation Summaries**: Consolidate into feature-specific summaries
- **Quick References**: Keep if they provide unique value, otherwise merge
- **Debug Documents**: Remove after issues are resolved
- **Migration Guides**: Consolidate when migrations are complete
- **User Guides**: Consolidate by topic (WiFi, troubleshooting, etc.)
- **Developer Guides**: Keep development vs reference vs architecture separate
- **Debug Documents**: Archive after issues are resolved
- **Implementation Summaries**: Archive completed implementation details
- **Ephemeral Content**: Archive, don't keep in main docs
## 🔗 Related Documentation
- [Main Project README](../README.md) - Installation and basic usage
- [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 Discussions](https://github.com/ChuckBuilds/LEDMatrix/discussions) - Community support
## 📊 Documentation Statistics
- **Total Documents**: ~35 (after consolidation)
- **Categories**: 8 major sections (including new API Reference section)
- **Primary Languages**: English
- **Main Documents**: 16-17 files (after consolidation)
- **Archived Documents**: 33 files (in docs/archive/)
- **Categories**: 9 major sections
- **Primary Language**: English
- **Format**: Markdown (.md)
- **Last Update**: December 2025
- **Coverage**: Installation, development, troubleshooting, architecture, API references
- **Last Major Update**: January 2026
- **Coverage**: Installation, user guides, development, troubleshooting, architecture, API references
### Recent Improvements (December 2025)
- ✅ Complete REST API documentation (50+ endpoints)
### Documentation Highlights
- ✅ Comprehensive user guides for first-time setup
- ✅ Complete REST API documentation (71+ endpoints)
- ✅ Complete Plugin API reference (Display Manager, Cache Manager, Plugin Manager)
- ✅ Advanced plugin development guide with examples
- ✅ Consolidated plugin configuration documentation
-Developer quick reference guide
-Better organization for end users and developers
- ✅ Consolidated configuration documentation
-Professional technical writing throughout
-~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.*

500
docs/STARLARK_APPS_GUIDE.md Normal file
View File

@@ -0,0 +1,500 @@
# Starlark Apps Guide
## Overview
The Starlark Apps plugin for LEDMatrix enables you to run **Tidbyt/Tronbyte community apps** on your LED matrix display without modification. This integration allows you to access hundreds of pre-built widgets and apps from the vibrant Tidbyt community ecosystem.
## Important: Third-Party Content
**⚠️ Apps are NOT managed by the LEDMatrix project**
- Starlark apps are developed and maintained by the **Tidbyt/Tronbyte community**
- LEDMatrix provides the runtime environment but does **not** create, maintain, or support these apps
- All apps originate from the [Tronbyte Apps Repository](https://github.com/tronbyt/apps)
- App quality, functionality, and security are the responsibility of individual app authors
- LEDMatrix is not affiliated with Tidbyt Inc. or the Tronbyte project
## What is Starlark?
[Starlark](https://github.com/bazelbuild/starlark) is a Python-like language originally developed by Google for the Bazel build system. Tidbyt adopted Starlark for building LED display apps because it's:
- **Sandboxed**: Apps run in a safe, restricted environment
- **Simple**: Python-like syntax that's easy to learn
- **Deterministic**: Apps produce consistent output
- **Fast**: Compiled and optimized for performance
## How It Works
### Architecture
```text
┌─────────────────────────────────────────────────────────┐
│ LEDMatrix System │
│ ┌────────────────────────────────────────────────────┐ │
│ │ Starlark Apps Plugin (manager.py) │ │
│ │ • Manages app lifecycle (install/uninstall) │ │
│ │ • Handles app configuration │ │
│ │ • Schedules app rendering │ │
│ └─────────────────┬──────────────────────────────────┘ │
│ │ │
│ ┌─────────────────▼──────────────────────────────────┐ │
│ │ Pixlet Renderer (pixlet_renderer.py) │ │
│ │ • Executes .star files using Pixlet CLI │ │
│ │ • Extracts configuration schemas │ │
│ │ • Outputs WebP animations │ │
│ └─────────────────┬──────────────────────────────────┘ │
│ │ │
│ ┌─────────────────▼──────────────────────────────────┐ │
│ │ Frame Extractor (frame_extractor.py) │ │
│ │ • Decodes WebP animations into frames │ │
│ │ • Scales/centers output for display size │ │
│ │ • Manages frame timing │ │
│ └─────────────────┬──────────────────────────────────┘ │
│ │ │
│ ┌─────────────────▼──────────────────────────────────┐ │
│ │ LED Matrix Display │ │
│ │ • Renders final output to physical display │ │
│ └────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
Downloads apps from
┌───────────────────┴─────────────────────────────────────┐
│ Tronbyte Apps Repository (GitHub) │
│ • 974+ community-built apps │
│ • Weather, sports, stocks, games, clocks, etc. │
│ • https://github.com/tronbyt/apps │
└──────────────────────────────────────────────────────────┘
```
### Rendering Pipeline
1. **User installs app** from the Tronbyte repository via web UI
2. **Plugin downloads** the `.star` file (and any assets like images/fonts)
3. **Schema extraction** parses configuration options from the `.star` source
4. **User configures** the app through the web UI (timezone, location, API keys, etc.)
5. **Pixlet renders** the app with user config → produces WebP animation
6. **Frame extraction** decodes WebP → individual PIL Image frames
7. **Display scaling** adapts 64x32 Tidbyt output to your matrix size
8. **Rotation** cycles through your installed apps based on schedule
## Getting Started
### 1. Install Pixlet
Pixlet is the rendering engine that executes Starlark apps. The plugin will attempt to use:
1. **Bundled binary** (recommended): Downloaded to `bin/pixlet/pixlet-{platform}-{arch}`
2. **System installation**: If `pixlet` is available in your PATH
#### Auto-Install via Web UI
Navigate to: **Plugins → Starlark Apps → Status → Install Pixlet**
This runs the bundled installation script which downloads the appropriate binary for your platform.
#### Manual Installation
```bash
cd /path/to/LEDMatrix
bash scripts/download_pixlet.sh
```
Verify installation:
```bash
./bin/pixlet/pixlet-linux-amd64 version
# Pixlet 0.50.2 (or later)
```
### 2. Enable the Starlark Apps Plugin
1. Open the web UI
2. Navigate to **Plugins**
3. Find **Starlark Apps** in the installed plugins list
4. Enable the plugin
5. Configure settings:
- **Magnify**: Auto-calculated based on your display size (or set manually)
- **Render Interval**: How often apps re-render (default: 300s)
- **Display Duration**: How long each app shows (default: 15s)
- **Cache Output**: Enable to reduce re-rendering (recommended)
### 3. Browse and Install Apps
1. Navigate to **Plugins → Starlark Apps → App Store**
2. Browse available apps (974+ options)
3. Filter by category: Weather, Sports, Finance, Games, Clocks, etc.
4. Click **Install** on desired apps
5. Configure each app:
- Set location/timezone
- Enter API keys if required
- Customize display preferences
### 4. Configure Apps
Each app may have different configuration options:
#### Common Configuration Types
- **Location** (lat/lng/timezone): For weather, clocks, transit
- **API Keys**: For services like weather, stocks, sports scores
- **Display Preferences**: Colors, units, layouts
- **Dropdown Options**: Team selections, language, themes
- **Toggles**: Enable/disable features
Configuration is stored in `starlark-apps/{app-id}/config.json` and persists across app updates.
## App Sources and Categories
All apps are sourced from the [Tronbyte Apps Repository](https://github.com/tronbyt/apps). Popular categories include:
### 🌤️ Weather
- Analog Clock (with weather)
- Current Weather
- Weather Forecast
- Air Quality Index
### 🏈 Sports
- NFL Scores
- NBA Scores
- MLB Scores
- NHL Scores
- Soccer/Football Scores
- Formula 1 Results
### 💰 Finance
- Stock Tickers
- Cryptocurrency Prices
- Market Indices
### 🎮 Games & Fun
- Conway's Game of Life
- Pong
- Nyan Cat
- Retro Animations
### 🕐 Clocks
- Analog Clock
- Fuzzy Clock
- Binary Clock
- Word Clock
### 📰 Information
- News Headlines
- RSS Feeds
- GitHub Activity
- Reddit Feed
### 🚌 Transit & Travel
- Transit Arrivals
- Flight Tracker
- Train Schedules
## Display Size Compatibility
Tronbyte/Tidbyt apps are designed for **64×32 displays**. LEDMatrix automatically adapts content for different display sizes:
### Magnification
The plugin calculates optimal magnification based on your display:
```text
magnify = floor(min(display_width / 64, display_height / 32))
```
Examples:
- **64×32**: magnify = 1 (native, pixel-perfect)
- **128×64**: magnify = 2 (2x scaling, crisp)
- **192×64**: magnify = 2 (2x + horizontal centering)
- **256×64**: magnify = 2 (2x + centering)
### Scaling Modes
**Config → Starlark Apps → Scale Method:**
- `nearest` (default): Sharp pixels, retro look
- `bilinear`: Smooth scaling, slight blur
- `bicubic`: Higher quality smooth scaling
- `lanczos`: Best quality, most processing
**Center vs Scale:**
- `scale_output=true`: Stretch to fill display (may distort aspect ratio)
- `center_small_output=true`: Center output without stretching (preserves aspect ratio)
## Configuration Schema Extraction
LEDMatrix automatically extracts configuration schemas from Starlark apps by parsing the `get_schema()` function in the `.star` source code.
### Supported Field Types
| Starlark Type | Web UI Rendering |
|--------------|------------------|
| `schema.Location` | Lat/Lng/Timezone picker |
| `schema.Text` | Text input field |
| `schema.Toggle` | Checkbox/switch |
| `schema.Dropdown` | Select dropdown |
| `schema.Color` | Color picker |
| `schema.DateTime` | Date/time picker |
| `schema.OAuth2` | Warning message (not supported) |
| `schema.PhotoSelect` | Warning message (not supported) |
| `schema.LocationBased` | Text fallback with note |
| `schema.Typeahead` | Text fallback with note |
### Schema Coverage
- **90-95%** of apps: Full schema support
- **5%**: Partial extraction (complex/dynamic schemas)
- **<1%**: No schema (apps without configuration)
Apps without extracted schemas can still run with default settings.
## File Structure
```text
LEDMatrix/
├── plugin-repos/starlark-apps/ # Plugin source code
│ ├── manager.py # Main plugin logic
│ ├── pixlet_renderer.py # Pixlet CLI wrapper
│ ├── frame_extractor.py # WebP decoder
│ ├── tronbyte_repository.py # GitHub API client
│ └── requirements.txt # Python dependencies
├── starlark-apps/ # Installed apps (user data)
│ ├── manifest.json # App registry
│ │
│ └── analogclock/ # Example app
│ ├── analogclock.star # Starlark source
│ ├── config.json # User configuration
│ ├── schema.json # Extracted schema
│ ├── cached_render.webp # Rendered output cache
│ └── images/ # App assets (if any)
│ ├── hour_hand.png
│ └── minute_hand.png
├── bin/pixlet/ # Pixlet binaries
│ ├── pixlet-linux-amd64
│ ├── pixlet-linux-arm64
│ └── pixlet-darwin-arm64
└── scripts/
└── download_pixlet.sh # Pixlet installer
```
## API Keys and External Services
Many apps require API keys for external services:
### Common API Services
- **Weather**: OpenWeatherMap, Weather.gov, Dark Sky
- **Sports**: ESPN, The Sports DB, SportsData.io
- **Finance**: Alpha Vantage, CoinGecko, Yahoo Finance
- **Transit**: TransitLand, NextBus, local transit APIs
- **News**: NewsAPI, Reddit, RSS feeds
### Security Note
- API keys are stored in `config.json` files on disk
- The LEDMatrix web interface does NOT encrypt API keys
- Ensure your Raspberry Pi is on a trusted network
- Use read-only or limited-scope API keys when possible
- **Never commit `starlark-apps/*/config.json` to version control**
## Troubleshooting
### Pixlet Not Found
**Symptom**: "Pixlet binary not found" error
**Solutions**:
1. Run auto-installer: **Plugins → Starlark Apps → Install Pixlet**
2. Manual install: `bash scripts/download_pixlet.sh`
3. Check permissions: `chmod +x bin/pixlet/pixlet-*`
4. Verify architecture: `uname -m` matches binary name
### App Fails to Render
**Symptom**: "Rendering failed" error in logs
**Solutions**:
1. Check logs: `journalctl -u ledmatrix | grep -i pixlet`
2. Verify config: Ensure all required fields are filled
3. Test manually: `./bin/pixlet/pixlet-linux-amd64 render starlark-apps/{app-id}/{app-id}.star`
4. Missing assets: Some apps need images/fonts that may fail to download
5. API issues: Check API keys and rate limits
### Schema Not Extracted
**Symptom**: App installs but shows no configuration options
**Solutions**:
1. App may not have a `get_schema()` function (normal for some apps)
2. Schema extraction failed: Check logs for parse errors
3. Manual config: Edit `starlark-apps/{app-id}/config.json` directly
4. Report issue: File bug with app details at LEDMatrix GitHub
### Apps Show Distorted/Wrong Size
**Symptom**: Content appears stretched, squished, or cropped
**Solutions**:
1. Check magnify setting: **Plugins → Starlark Apps → Config**
2. Try `center_small_output=true` to preserve aspect ratio
3. Adjust `magnify` manually (1-8) for your display size
4. Some apps assume 64×32 - may not scale perfectly to all sizes
### App Shows Outdated Data
**Symptom**: Weather, sports scores, etc. don't update
**Solutions**:
1. Check render interval: **App Config → Render Interval** (300s default)
2. Force re-render: **Plugins → Starlark Apps → {App} → Render Now**
3. Clear cache: Restart LEDMatrix service
4. API rate limits: Some services throttle requests
5. Check app logs for API errors
## Performance Considerations
### Render Intervals
- Apps re-render on a schedule (default: 300s = 5 minutes)
- Lower intervals = more CPU/API usage
- Recommended minimums:
- Static content (clocks): 30-60s
- Weather: 300s (5min)
- Sports scores: 60-120s
- Stock tickers: 60s
### Caching
Enable caching to reduce CPU load:
- `cache_rendered_output=true` (recommended)
- `cache_ttl=300` (5 minutes)
Cached WebP files are stored in `starlark-apps/{app-id}/cached_render.webp`
### Display Rotation
Balance number of enabled apps with display duration:
- 5 apps × 15s = 75s full cycle
- 20 apps × 15s = 300s (5 min) cycle
Long cycles may cause apps to render before being displayed.
## Limitations
### Unsupported Features
- **OAuth2 Authentication**: Apps requiring OAuth login won't work
- **PhotoSelect**: Image upload from mobile device not supported
- **Push Notifications**: Apps can't receive real-time events
- **Background Jobs**: No persistent background tasks
### API Rate Limits
Many apps use free API tiers with rate limits:
- Rendering too frequently may exceed limits
- Use appropriate `render_interval` settings
- Consider paid API tiers for heavy usage
### Display Size Constraints
Apps designed for 64×32 may not utilize larger displays fully:
- Content may appear small on 128×64+ displays
- Magnification helps but doesn't add detail
- Some apps hard-code 64×32 dimensions
## Advanced Usage
### Manual App Installation
Upload custom `.star` files:
1. Navigate to **Starlark Apps → Upload**
2. Select `.star` file from disk
3. Configure app ID and metadata
4. Set render/display timing
### Custom App Development
While LEDMatrix runs Tronbyte apps, you can also create your own:
1. **Learn Starlark**: [Tidbyt Developer Docs](https://tidbyt.dev/)
2. **Write `.star` file**: Use Pixlet APIs for rendering
3. **Test locally**: `pixlet render myapp.star`
4. **Upload**: Use LEDMatrix web UI to install
5. **Share**: Contribute to [Tronbyte Apps](https://github.com/tronbyt/apps) repo
### Configuration Reference
**Plugin Config** (`config/config.json``plugins.starlark-apps`):
```json
{
"enabled": true,
"magnify": 0, // 0 = auto, 1-8 = manual
"render_timeout": 30, // Max seconds for Pixlet render
"cache_rendered_output": true, // Cache WebP files
"cache_ttl": 300, // Cache duration (seconds)
"scale_output": true, // Scale to display size
"scale_method": "nearest", // nearest|bilinear|bicubic|lanczos
"center_small_output": false, // Center instead of scale
"default_frame_delay": 50, // Frame timing (ms)
"max_frames": null, // Limit frames (null = unlimited)
"auto_refresh_apps": true // Auto re-render on interval
}
```
**App Config** (`starlark-apps/{app-id}/config.json`):
```json
{
"location": "{\"lat\":\"40.7128\",\"lng\":\"-74.0060\",\"timezone\":\"America/New_York\"}",
"units": "imperial",
"api_key": "your-api-key-here",
"render_interval": 300, // App-specific override
"display_duration": 15 // App-specific override
}
```
## Resources
### Official Documentation
- **Tidbyt Developer Docs**: https://tidbyt.dev/
- **Starlark Language**: https://github.com/bazelbuild/starlark
- **Pixlet Repository**: https://github.com/tidbyt/pixlet
- **Tronbyte Apps**: https://github.com/tronbyt/apps
### LEDMatrix Documentation
- [Plugin Development Guide](PLUGIN_DEVELOPMENT_GUIDE.md)
- [REST API Reference](REST_API_REFERENCE.md)
- [Troubleshooting Guide](TROUBLESHOOTING.md)
### Community
- **Tidbyt Community**: https://discuss.tidbyt.com/
- **Tronbyte Apps Issues**: https://github.com/tronbyt/apps/issues
- **LEDMatrix Issues**: https://github.com/ChuckBuilds/LEDMatrix/issues
## License and Legal
- **LEDMatrix**: MIT License (see project root)
- **Starlark Apps Plugin**: MIT License (part of LEDMatrix)
- **Pixlet**: Apache 2.0 License (Tidbyt Inc.)
- **Tronbyte Apps**: Various licenses (see individual app headers)
- **Starlark Language**: Apache 2.0 License (Google/Bazel)
**Disclaimer**: LEDMatrix is an independent project and is not affiliated with, endorsed by, or sponsored by Tidbyt Inc. The Starlark Apps plugin enables interoperability with Tidbyt's open-source ecosystem but does not imply any official relationship.
## Support
For issues with:
- **LEDMatrix integration**: File issues at [LEDMatrix GitHub](https://github.com/ChuckBuilds/LEDMatrix/issues)
- **Specific apps**: File issues at [Tronbyte Apps](https://github.com/tronbyt/apps/issues)
- **Pixlet rendering**: File issues at [Pixlet Repository](https://github.com/tidbyt/pixlet/issues)
---
**Ready to get started?** Install the Starlark Apps plugin and explore 974+ community apps! 🎨

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 "3. Fix assets 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 "6. Build and install rpi-rgb-led-matrix and test import"
echo "7. Install web interface dependencies"
echo "7.5. Install main LED Matrix service"
echo "8. Install web interface service"
echo "8.1. Harden systemd unit file permissions"
echo "8.5. Install WiFi monitor service"
echo "9. Configure web interface permissions"
echo "10. Configure passwordless sudo access"
@@ -271,7 +273,7 @@ apt_update
# Install required system packages
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
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 ""
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"
echo "Step 4.1: Ensuring configuration files exist..."
echo "------------------------------------------------"
echo "Step 4: Ensuring configuration files exist..."
echo "----------------------------------------------"
# Ensure config directory exists
mkdir -p "$PROJECT_ROOT_DIR/config"
@@ -661,32 +629,15 @@ CURRENT_STEP="Install project Python dependencies"
echo "Step 5: Installing Python project dependencies..."
echo "-----------------------------------------------"
# Install numpy via apt first (pre-built binary, much faster than building from source)
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
# Install main project Python dependencies (numpy will be installed via pip from requirements.txt)
cd "$PROJECT_ROOT_DIR"
if [ -f "$PROJECT_ROOT_DIR/requirements.txt" ]; then
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..."
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
TOTAL_PACKAGES=$(grep -v '^#' "$PROJECT_ROOT_DIR/requirements.txt" | grep -v '^$' | wc -l)
echo "Found $TOTAL_PACKAGES package(s) to install"
@@ -812,6 +763,22 @@ else
fi
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"
echo "Step 6: Building and installing rpi-rgb-led-matrix..."
echo "-----------------------------------------------------"
@@ -903,24 +870,82 @@ CURRENT_STEP="Install web interface dependencies"
echo "Step 7: Installing web interface dependencies..."
echo "------------------------------------------------"
# Install web interface dependencies
echo "Installing Python dependencies for web interface..."
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"
# Check if web dependencies were already installed (marker created in Step 5)
if [ -f "$PROJECT_ROOT_DIR/.web_deps_installed" ]; then
echo "✓ Web interface dependencies already installed (marker file found)"
else
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
# Install web interface dependencies
echo "Installing Python dependencies for web interface..."
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
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
# 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
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 ""
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 "--------------------------------------------------------------"
# Normalize directory permissions (exclude VCS metadata and plugin directories)
# Normalize directory permissions (exclude VCS metadata, plugin directories, and compiled libraries)
find "$PROJECT_ROOT_DIR" \
-path "$PROJECT_ROOT_DIR/plugins" -prune -o \
-path "$PROJECT_ROOT_DIR/plugin-repos" -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 \
-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" \
-path "$PROJECT_ROOT_DIR/plugins" -prune -o \
-path "$PROJECT_ROOT_DIR/plugin-repos" -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 \
-type f -exec chmod 644 {} \; 2>/dev/null || true
@@ -1541,26 +1568,31 @@ echo "=========================================="
echo "Important Notes"
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 " - Run: newgrp systemd-journal"
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 " - Verify firewall allows port 5000: sudo ufw status (if using UFW)"
echo " - Check network connectivity: ping -c 3 8.8.8.8"
echo " - If WiFi is not connected, connect to LEDMatrix-Setup AP network"
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 " - This installation script does not configure SSH credentials"
echo ""
echo "4. Useful Commands:"
echo "5. Useful Commands:"
echo " - Check service status: sudo systemctl status ledmatrix.service"
echo " - View logs: journalctl -u ledmatrix-web.service -f"
echo " - Start/stop display: sudo systemctl start/stop ledmatrix.service"
echo ""
echo "5. Configuration Files:"
echo "6. Configuration Files:"
echo " - Main config: $PROJECT_ROOT_DIR/config/config.json"
echo " - Secrets: $PROJECT_ROOT_DIR/config/config_secrets.json"
echo ""

View File

@@ -0,0 +1,7 @@
"""
Starlark Apps Plugin Package
Seamlessly import and manage Starlark (.star) widgets from the Tronbyte/Tidbyt community.
"""
__version__ = "1.0.0"

View File

@@ -0,0 +1,100 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"title": "Starlark Apps Plugin Configuration",
"description": "Configuration for managing Starlark (.star) apps",
"properties": {
"enabled": {
"type": "boolean",
"description": "Enable or disable the Starlark apps system",
"default": true
},
"pixlet_path": {
"type": "string",
"description": "Path to Pixlet binary (auto-detected if empty)",
"default": ""
},
"render_timeout": {
"type": "number",
"description": "Maximum time in seconds for rendering a .star app",
"default": 30,
"minimum": 5,
"maximum": 120
},
"cache_rendered_output": {
"type": "boolean",
"description": "Cache rendered WebP output to reduce CPU usage",
"default": true
},
"cache_ttl": {
"type": "number",
"description": "Cache time-to-live in seconds",
"default": 300,
"minimum": 60,
"maximum": 3600
},
"default_frame_delay": {
"type": "number",
"description": "Default delay between frames in milliseconds (if not specified by app)",
"default": 50,
"minimum": 16,
"maximum": 1000
},
"scale_output": {
"type": "boolean",
"description": "Scale app output to match display dimensions",
"default": true
},
"scale_method": {
"type": "string",
"enum": ["nearest", "bilinear", "bicubic", "lanczos"],
"description": "Scaling algorithm (nearest=pixel-perfect, lanczos=smoothest)",
"default": "nearest"
},
"magnify": {
"type": "integer",
"description": "Pixlet magnification factor (0=auto, 1=64x32, 2=128x64, 3=192x96, etc.)",
"default": 0,
"minimum": 0,
"maximum": 8
},
"center_small_output": {
"type": "boolean",
"description": "Center small apps on large displays instead of stretching",
"default": false
},
"background_render": {
"type": "boolean",
"description": "Render apps in background to avoid display delays",
"default": true
},
"auto_refresh_apps": {
"type": "boolean",
"description": "Automatically refresh apps at their specified intervals",
"default": true
},
"transition": {
"type": "object",
"description": "Transition settings for app display",
"properties": {
"type": {
"type": "string",
"enum": ["redraw", "fade", "slide", "wipe"],
"default": "fade"
},
"speed": {
"type": "integer",
"description": "Transition speed (1-10)",
"default": 3,
"minimum": 1,
"maximum": 10
},
"enabled": {
"type": "boolean",
"default": true
}
}
}
},
"additionalProperties": false
}

View File

@@ -0,0 +1,285 @@
"""
Frame Extractor Module for Starlark Apps
Extracts individual frames from WebP animations produced by Pixlet.
Handles both static images and animated WebP files.
"""
import logging
from typing import List, Tuple, Optional
from PIL import Image
logger = logging.getLogger(__name__)
class FrameExtractor:
"""
Extracts frames from WebP animations.
Handles:
- Static WebP images (single frame)
- Animated WebP files (multiple frames with delays)
- Frame timing and duration extraction
"""
def __init__(self, default_frame_delay: int = 50):
"""
Initialize frame extractor.
Args:
default_frame_delay: Default delay in milliseconds if not specified
"""
self.default_frame_delay = default_frame_delay
def load_webp(self, webp_path: str) -> Tuple[bool, Optional[List[Tuple[Image.Image, int]]], Optional[str]]:
"""
Load WebP file and extract all frames with their delays.
Args:
webp_path: Path to WebP file
Returns:
Tuple of:
- success: bool
- frames: List of (PIL.Image, delay_ms) tuples, or None on failure
- error: Error message, or None on success
"""
try:
with Image.open(webp_path) as img:
# Check if animated
is_animated = getattr(img, "is_animated", False)
if not is_animated:
# Static image - single frame
# Convert to RGB (LED matrix needs RGB) to match animated branch format
logger.debug(f"Loaded static WebP: {webp_path}")
rgb_img = img.convert("RGB")
return True, [(rgb_img.copy(), self.default_frame_delay)], None
# Animated WebP - extract all frames
frames = []
frame_count = getattr(img, "n_frames", 1)
logger.debug(f"Extracting {frame_count} frames from animated WebP: {webp_path}")
for frame_index in range(frame_count):
try:
img.seek(frame_index)
# Get frame duration (in milliseconds)
# WebP stores duration in milliseconds
duration = img.info.get("duration", self.default_frame_delay)
# Ensure minimum frame delay (prevent too-fast animations)
if duration < 16: # Less than ~60fps
duration = 16
# Convert frame to RGB (LED matrix needs RGB)
frame = img.convert("RGB")
frames.append((frame.copy(), duration))
except EOFError:
logger.warning(f"Reached end of frames at index {frame_index}")
break
except Exception as e:
logger.warning(f"Error extracting frame {frame_index}: {e}")
continue
if not frames:
error = "No frames extracted from WebP"
logger.error(error)
return False, None, error
logger.debug(f"Successfully extracted {len(frames)} frames")
return True, frames, None
except FileNotFoundError:
error = f"WebP file not found: {webp_path}"
logger.error(error)
return False, None, error
except Exception as e:
error = f"Error loading WebP: {e}"
logger.error(error)
return False, None, error
def scale_frames(
self,
frames: List[Tuple[Image.Image, int]],
target_width: int,
target_height: int,
method: Image.Resampling = Image.Resampling.NEAREST
) -> List[Tuple[Image.Image, int]]:
"""
Scale all frames to target dimensions.
Args:
frames: List of (image, delay) tuples
target_width: Target width in pixels
target_height: Target height in pixels
method: Resampling method (default: NEAREST for pixel-perfect scaling)
Returns:
List of scaled (image, delay) tuples
"""
scaled_frames = []
for frame, delay in frames:
try:
# Only scale if dimensions don't match
if frame.width != target_width or frame.height != target_height:
scaled_frame = frame.resize(
(target_width, target_height),
resample=method
)
scaled_frames.append((scaled_frame, delay))
else:
scaled_frames.append((frame, delay))
except Exception as e:
logger.warning(f"Error scaling frame: {e}")
# Keep original frame on error
scaled_frames.append((frame, delay))
logger.debug(f"Scaled {len(scaled_frames)} frames to {target_width}x{target_height}")
return scaled_frames
def center_frames(
self,
frames: List[Tuple[Image.Image, int]],
target_width: int,
target_height: int,
background_color: tuple = (0, 0, 0)
) -> List[Tuple[Image.Image, int]]:
"""
Center frames on a larger canvas instead of scaling.
Useful for displaying small widgets on large displays without distortion.
Args:
frames: List of (image, delay) tuples
target_width: Target canvas width
target_height: Target canvas height
background_color: RGB tuple for background (default: black)
Returns:
List of centered (image, delay) tuples
"""
centered_frames = []
for frame, delay in frames:
try:
# If frame is already the right size, no centering needed
if frame.width == target_width and frame.height == target_height:
centered_frames.append((frame, delay))
continue
# Create black canvas at target size
canvas = Image.new('RGB', (target_width, target_height), background_color)
# Calculate position to center the frame
x_offset = (target_width - frame.width) // 2
y_offset = (target_height - frame.height) // 2
# Paste frame onto canvas
canvas.paste(frame, (x_offset, y_offset))
centered_frames.append((canvas, delay))
except Exception as e:
logger.warning(f"Error centering frame: {e}")
# Keep original frame on error
centered_frames.append((frame, delay))
logger.debug(f"Centered {len(centered_frames)} frames on {target_width}x{target_height} canvas")
return centered_frames
def get_total_duration(self, frames: List[Tuple[Image.Image, int]]) -> int:
"""
Calculate total animation duration in milliseconds.
Args:
frames: List of (image, delay) tuples
Returns:
Total duration in milliseconds
"""
return sum(delay for _, delay in frames)
def optimize_frames(
self,
frames: List[Tuple[Image.Image, int]],
max_frames: Optional[int] = None,
target_duration: Optional[int] = None
) -> List[Tuple[Image.Image, int]]:
"""
Optimize frame list by reducing frame count or adjusting timing.
Args:
frames: List of (image, delay) tuples
max_frames: Maximum number of frames to keep
target_duration: Target total duration in milliseconds
Returns:
Optimized list of (image, delay) tuples
"""
if not frames:
return frames
optimized = frames.copy()
# Limit frame count if specified
if max_frames is not None and max_frames > 0 and len(optimized) > max_frames:
# Sample frames evenly
step = len(optimized) / max_frames
indices = [int(i * step) for i in range(max_frames)]
optimized = [optimized[i] for i in indices]
logger.debug(f"Reduced frames from {len(frames)} to {len(optimized)}")
# Adjust timing to match target duration
if target_duration:
current_duration = self.get_total_duration(optimized)
if current_duration > 0:
scale_factor = target_duration / current_duration
optimized = [
(frame, max(16, int(delay * scale_factor)))
for frame, delay in optimized
]
logger.debug(f"Adjusted timing: {current_duration}ms -> {target_duration}ms")
return optimized
def frames_to_gif_data(self, frames: List[Tuple[Image.Image, int]]) -> Optional[bytes]:
"""
Convert frames to GIF byte data for caching or transmission.
Args:
frames: List of (image, delay) tuples
Returns:
GIF bytes, or None on error
"""
if not frames:
return None
try:
from io import BytesIO
output = BytesIO()
# Prepare frames for PIL
images = [frame for frame, _ in frames]
durations = [delay for _, delay in frames]
# Save as GIF
images[0].save(
output,
format="GIF",
save_all=True,
append_images=images[1:],
duration=durations,
loop=0, # Infinite loop
optimize=False # Skip optimization for speed
)
return output.getvalue()
except Exception as e:
logger.error(f"Error converting frames to GIF: {e}")
return None

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,26 @@
{
"id": "starlark-apps",
"name": "Starlark Apps",
"version": "1.0.0",
"author": "LEDMatrix",
"description": "Manages and displays Starlark (.star) apps from Tronbyte/Tidbyt community. Import widgets seamlessly without modification.",
"entry_point": "manager.py",
"class_name": "StarlarkAppsPlugin",
"category": "system",
"tags": [
"starlark",
"widgets",
"tronbyte",
"tidbyt",
"apps",
"community"
],
"display_modes": [],
"update_interval": 60,
"default_duration": 15,
"dependencies": [
"Pillow>=10.0.0",
"PyYAML>=6.0",
"requests>=2.31.0"
]
}

View File

@@ -0,0 +1,659 @@
"""
Pixlet Renderer Module for Starlark Apps
Handles execution of Pixlet CLI to render .star files into WebP animations.
Supports bundled binaries and system-installed Pixlet.
"""
import json
import logging
import os
import platform
import re
import shutil
import subprocess
from pathlib import Path
from typing import Dict, Any, Optional, Tuple, List
logger = logging.getLogger(__name__)
class PixletRenderer:
"""
Wrapper for Pixlet CLI rendering.
Handles:
- Auto-detection of bundled or system Pixlet binary
- Rendering .star files with configuration
- Schema extraction from .star files
- Timeout and error handling
"""
def __init__(self, pixlet_path: Optional[str] = None, timeout: int = 30):
"""
Initialize the Pixlet renderer.
Args:
pixlet_path: Optional explicit path to Pixlet binary
timeout: Maximum seconds to wait for rendering
"""
self.timeout = timeout
self.pixlet_binary = self._find_pixlet_binary(pixlet_path)
if self.pixlet_binary:
logger.info(f"[Starlark Pixlet] Pixlet renderer initialized with binary: {self.pixlet_binary}")
else:
logger.warning("[Starlark Pixlet] Pixlet binary not found - rendering will fail")
def _find_pixlet_binary(self, explicit_path: Optional[str] = None) -> Optional[str]:
"""
Find Pixlet binary using the following priority:
1. Explicit path provided
2. Bundled binary for current architecture
3. System PATH
Args:
explicit_path: User-specified path to Pixlet
Returns:
Path to Pixlet binary, or None if not found
"""
# 1. Check explicit path
if explicit_path and os.path.isfile(explicit_path):
if os.access(explicit_path, os.X_OK):
logger.debug(f"Using explicit Pixlet path: {explicit_path}")
return explicit_path
else:
logger.warning(f"Explicit Pixlet path not executable: {explicit_path}")
# 2. Check bundled binary
try:
bundled_path = self._get_bundled_binary_path()
if bundled_path and os.path.isfile(bundled_path):
# Ensure executable
if not os.access(bundled_path, os.X_OK):
try:
os.chmod(bundled_path, 0o755)
logger.debug(f"Made bundled binary executable: {bundled_path}")
except OSError:
logger.exception(f"Could not make bundled binary executable: {bundled_path}")
if os.access(bundled_path, os.X_OK):
logger.debug(f"Using bundled Pixlet binary: {bundled_path}")
return bundled_path
except OSError:
logger.exception("Could not locate bundled binary")
# 3. Check system PATH
system_pixlet = shutil.which("pixlet")
if system_pixlet:
logger.debug(f"Using system Pixlet: {system_pixlet}")
return system_pixlet
logger.error("Pixlet binary not found in any location")
return None
def _get_bundled_binary_path(self) -> Optional[str]:
"""
Get path to bundled Pixlet binary for current architecture.
Returns:
Path to bundled binary, or None if not found
"""
try:
# Determine project root (parent of plugin-repos)
current_dir = Path(__file__).resolve().parent
project_root = current_dir.parent.parent
bin_dir = project_root / "bin" / "pixlet"
# Detect architecture
system = platform.system().lower()
machine = platform.machine().lower()
# Map architecture to binary name
if system == "linux":
if "aarch64" in machine or "arm64" in machine:
binary_name = "pixlet-linux-arm64"
elif "x86_64" in machine or "amd64" in machine:
binary_name = "pixlet-linux-amd64"
else:
logger.warning(f"Unsupported Linux architecture: {machine}")
return None
elif system == "darwin":
if "arm64" in machine:
binary_name = "pixlet-darwin-arm64"
else:
binary_name = "pixlet-darwin-amd64"
elif system == "windows":
binary_name = "pixlet-windows-amd64.exe"
else:
logger.warning(f"Unsupported system: {system}")
return None
binary_path = bin_dir / binary_name
if binary_path.exists():
return str(binary_path)
logger.debug(f"Bundled binary not found at: {binary_path}")
return None
except OSError:
logger.exception("Error finding bundled binary")
return None
def _get_safe_working_directory(self, star_file: str) -> Optional[str]:
"""
Get a safe working directory for subprocess execution.
Args:
star_file: Path to .star file
Returns:
Resolved parent directory, or None if empty or invalid
"""
try:
resolved_parent = os.path.dirname(os.path.abspath(star_file))
# Return None if empty string to avoid FileNotFoundError
if not resolved_parent:
logger.debug(f"Empty parent directory for star_file: {star_file}")
return None
return resolved_parent
except (OSError, ValueError):
logger.debug(f"Could not resolve working directory for: {star_file}")
return None
def is_available(self) -> bool:
"""
Check if Pixlet is available and functional.
Returns:
True if Pixlet can be executed
"""
if not self.pixlet_binary:
return False
try:
result = subprocess.run(
[self.pixlet_binary, "version"],
capture_output=True,
text=True,
timeout=5
)
return result.returncode == 0
except subprocess.TimeoutExpired:
logger.debug("Pixlet version check timed out")
return False
except (subprocess.SubprocessError, OSError):
logger.exception("Pixlet not available")
return False
def get_version(self) -> Optional[str]:
"""
Get Pixlet version string.
Returns:
Version string, or None if unavailable
"""
if not self.pixlet_binary:
return None
try:
result = subprocess.run(
[self.pixlet_binary, "version"],
capture_output=True,
text=True,
timeout=5
)
if result.returncode == 0:
return result.stdout.strip()
except subprocess.TimeoutExpired:
logger.debug("Pixlet version check timed out")
except (subprocess.SubprocessError, OSError):
logger.exception("Could not get Pixlet version")
return None
def render(
self,
star_file: str,
output_path: str,
config: Optional[Dict[str, Any]] = None,
magnify: int = 1
) -> Tuple[bool, Optional[str]]:
"""
Render a .star file to WebP output.
Args:
star_file: Path to .star file
output_path: Where to save WebP output
config: Configuration dictionary to pass to app
magnify: Magnification factor (default 1)
Returns:
Tuple of (success: bool, error_message: Optional[str])
"""
if not self.pixlet_binary:
return False, "Pixlet binary not found"
if not os.path.isfile(star_file):
return False, f"Star file not found: {star_file}"
try:
# Build command - config params must be POSITIONAL between star_file and flags
# Format: pixlet render <file.star> [key=value]... [flags]
cmd = [
self.pixlet_binary,
"render",
star_file
]
# Add configuration parameters as positional arguments (BEFORE flags)
if config:
for key, value in config.items():
# Validate key format (alphanumeric + underscore only)
if not re.match(r'^[a-zA-Z_][a-zA-Z0-9_]*$', key):
logger.warning(f"Skipping invalid config key: {key}")
continue
# Convert value to string for CLI
if isinstance(value, bool):
value_str = "true" if value else "false"
elif isinstance(value, str) and (value.startswith('{') or value.startswith('[')):
# JSON string - keep as-is, will be properly quoted by subprocess
value_str = value
else:
value_str = str(value)
# Validate value doesn't contain dangerous shell metacharacters
# Block: backticks, $(), pipes, redirects, semicolons, ampersands, null bytes
# Allow: most printable chars including spaces, quotes, brackets, braces
if re.search(r'[`$|<>&;\x00]|\$\(', value_str):
logger.warning(f"Skipping config value with unsafe shell characters for key {key}: {value_str}")
continue
# Add as positional argument (not -c flag)
cmd.append(f"{key}={value_str}")
# Add flags AFTER positional config arguments
cmd.extend([
"-o", output_path,
"-m", str(magnify)
])
# Build sanitized command for logging (redact sensitive values)
sanitized_cmd = [self.pixlet_binary, "render", star_file]
if config:
config_keys = list(config.keys())
sanitized_cmd.append(f"[{len(config_keys)} config entries: {', '.join(config_keys)}]")
sanitized_cmd.extend(["-o", output_path, "-m", str(magnify)])
logger.debug(f"Executing Pixlet: {' '.join(sanitized_cmd)}")
# Execute rendering
safe_cwd = self._get_safe_working_directory(star_file)
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=self.timeout,
cwd=safe_cwd # Run in .star file directory (or None if relative path)
)
if result.returncode == 0:
if os.path.isfile(output_path):
logger.debug(f"Successfully rendered: {star_file} -> {output_path}")
return True, None
else:
error = "Rendering succeeded but output file not found"
logger.error(error)
return False, error
else:
error = f"Pixlet failed (exit {result.returncode}): {result.stderr}"
logger.error(error)
return False, error
except subprocess.TimeoutExpired:
error = f"Rendering timeout after {self.timeout}s"
logger.error(error)
return False, error
except (subprocess.SubprocessError, OSError):
logger.exception("Rendering exception")
return False, "Rendering failed - see logs for details"
def extract_schema(self, star_file: str) -> Tuple[bool, Optional[Dict[str, Any]], Optional[str]]:
"""
Extract configuration schema from a .star file by parsing source code.
Supports:
- Static field definitions (location, text, toggle, dropdown, color, datetime)
- Variable-referenced dropdown options
- Graceful degradation for unsupported field types
Args:
star_file: Path to .star file
Returns:
Tuple of (success: bool, schema: Optional[Dict], error: Optional[str])
"""
if not os.path.isfile(star_file):
return False, None, f"Star file not found: {star_file}"
try:
# Read .star file
with open(star_file, 'r', encoding='utf-8') as f:
content = f.read()
# Parse schema from source
schema = self._parse_schema_from_source(content, star_file)
if schema:
field_count = len(schema.get('schema', []))
logger.debug(f"Extracted schema with {field_count} field(s) from: {star_file}")
return True, schema, None
else:
# No schema found - not an error, app just doesn't have configuration
logger.debug(f"No schema found in: {star_file}")
return True, None, None
except UnicodeDecodeError as e:
error = f"File encoding error: {e}"
logger.warning(error)
return False, None, error
except Exception as e:
logger.exception(f"Schema extraction failed for {star_file}")
return False, None, f"Schema extraction error: {str(e)}"
def _parse_schema_from_source(self, content: str, file_path: str) -> Optional[Dict[str, Any]]:
"""
Parse get_schema() function from Starlark source code.
Args:
content: .star file content
file_path: Path to file (for logging)
Returns:
Schema dict with format {"version": "1", "schema": [...]}, or None
"""
# Extract variable definitions (for dropdown options)
var_table = self._extract_variable_definitions(content)
# Extract get_schema() function body
schema_body = self._extract_get_schema_body(content)
if not schema_body:
logger.debug(f"No get_schema() function found in {file_path}")
return None
# Extract version
version_match = re.search(r'version\s*=\s*"([^"]+)"', schema_body)
version = version_match.group(1) if version_match else "1"
# Extract fields array from schema.Schema(...) - handle nested brackets
fields_start_match = re.search(r'fields\s*=\s*\[', schema_body)
if not fields_start_match:
# Empty schema or no fields
return {"version": version, "schema": []}
# Find matching closing bracket
bracket_count = 1
i = fields_start_match.end()
while i < len(schema_body) and bracket_count > 0:
if schema_body[i] == '[':
bracket_count += 1
elif schema_body[i] == ']':
bracket_count -= 1
i += 1
if bracket_count != 0:
# Unmatched brackets
logger.warning(f"Unmatched brackets in schema fields for {file_path}")
return {"version": version, "schema": []}
fields_text = schema_body[fields_start_match.end():i-1]
# Parse individual fields
schema_fields = []
# Match schema.FieldType(...) patterns
field_pattern = r'schema\.(\w+)\s*\((.*?)\)'
# Find all field definitions (handle nested parentheses)
pos = 0
while pos < len(fields_text):
match = re.search(field_pattern, fields_text[pos:], re.DOTALL)
if not match:
break
field_type = match.group(1)
field_start = pos + match.start()
field_end = pos + match.end()
# Handle nested parentheses properly
paren_count = 1
i = pos + match.start() + len(f'schema.{field_type}(')
while i < len(fields_text) and paren_count > 0:
if fields_text[i] == '(':
paren_count += 1
elif fields_text[i] == ')':
paren_count -= 1
i += 1
field_params_text = fields_text[pos + match.start() + len(f'schema.{field_type}('):i-1]
# Parse field
field_dict = self._parse_schema_field(field_type, field_params_text, var_table)
if field_dict:
schema_fields.append(field_dict)
pos = i
return {
"version": version,
"schema": schema_fields
}
def _extract_variable_definitions(self, content: str) -> Dict[str, List[Dict]]:
"""
Extract top-level variable assignments (for dropdown options).
Args:
content: .star file content
Returns:
Dict mapping variable names to their option lists
"""
var_table = {}
# Find variable definitions like: variableName = [schema.Option(...), ...]
var_pattern = r'^(\w+)\s*=\s*\[(.*?schema\.Option.*?)\]'
matches = re.finditer(var_pattern, content, re.MULTILINE | re.DOTALL)
for match in matches:
var_name = match.group(1)
options_text = match.group(2)
# Parse schema.Option entries
options = self._parse_schema_options(options_text, {})
if options:
var_table[var_name] = options
return var_table
def _extract_get_schema_body(self, content: str) -> Optional[str]:
"""
Extract get_schema() function body using indentation-aware parsing.
Args:
content: .star file content
Returns:
Function body text, or None if not found
"""
# Find def get_schema(): line
pattern = r'^(\s*)def\s+get_schema\s*\(\s*\)\s*:'
match = re.search(pattern, content, re.MULTILINE)
if not match:
return None
# Get the indentation level of the function definition
func_indent = len(match.group(1))
func_start = match.end()
# Split content into lines starting after the function definition
lines_after = content[func_start:].split('\n')
body_lines = []
for line in lines_after:
# Skip empty lines
if not line.strip():
body_lines.append(line)
continue
# Calculate indentation of current line
stripped = line.lstrip()
line_indent = len(line) - len(stripped)
# If line has same or less indentation than function def, check if it's a top-level def
if line_indent <= func_indent:
# This is a line at the same or outer level - check if it's a function
if re.match(r'def\s+\w+', stripped):
# Found next top-level function, stop here
break
# Otherwise it might be a comment or other top-level code, stop anyway
break
# Line is indented more than function def, so it's part of the body
body_lines.append(line)
if body_lines:
return '\n'.join(body_lines)
return None
def _parse_schema_field(self, field_type: str, params_text: str, var_table: Dict) -> Optional[Dict[str, Any]]:
"""
Parse individual schema field definition.
Args:
field_type: Field type (Location, Text, Toggle, etc.)
params_text: Field parameters text
var_table: Variable lookup table
Returns:
Field dict, or None if parse fails
"""
# Map Pixlet field types to JSON typeOf
type_mapping = {
'Location': 'location',
'Text': 'text',
'Toggle': 'toggle',
'Dropdown': 'dropdown',
'Color': 'color',
'DateTime': 'datetime',
'OAuth2': 'oauth2',
'PhotoSelect': 'photo_select',
'LocationBased': 'location_based',
'Typeahead': 'typeahead',
'Generated': 'generated',
}
type_of = type_mapping.get(field_type, field_type.lower())
# Skip Generated fields (invisible meta-fields)
if type_of == 'generated':
return None
field_dict = {"typeOf": type_of}
# Extract common parameters
# id
id_match = re.search(r'id\s*=\s*"([^"]+)"', params_text)
if id_match:
field_dict['id'] = id_match.group(1)
else:
# id is required, skip field if missing
return None
# name
name_match = re.search(r'name\s*=\s*"([^"]+)"', params_text)
if name_match:
field_dict['name'] = name_match.group(1)
# desc
desc_match = re.search(r'desc\s*=\s*"([^"]+)"', params_text)
if desc_match:
field_dict['desc'] = desc_match.group(1)
# icon
icon_match = re.search(r'icon\s*=\s*"([^"]+)"', params_text)
if icon_match:
field_dict['icon'] = icon_match.group(1)
# default (can be string, bool, or variable reference)
# First try to match quoted strings (which may contain commas)
default_match = re.search(r'default\s*=\s*"([^"]*)"', params_text)
if not default_match:
# Try single quotes
default_match = re.search(r"default\s*=\s*'([^']*)'", params_text)
if not default_match:
# Fall back to unquoted value (stop at comma or closing paren)
default_match = re.search(r'default\s*=\s*([^,\)]+)', params_text)
if default_match:
default_value = default_match.group(1).strip()
# Handle boolean
if default_value in ('True', 'False'):
field_dict['default'] = default_value.lower()
# Handle string literal from first two patterns (already extracted without quotes)
elif re.search(r'default\s*=\s*["\']', params_text):
# This was a quoted string, use the captured content directly
field_dict['default'] = default_value
# Handle variable reference (can't resolve, use as-is)
else:
# Try to extract just the value if it's like options[0].value
if '.' in default_value or '[' in default_value:
# Complex expression, skip default
pass
else:
field_dict['default'] = default_value
# For dropdown, extract options
if type_of == 'dropdown':
options_match = re.search(r'options\s*=\s*([^,\)]+)', params_text)
if options_match:
options_ref = options_match.group(1).strip()
# Check if it's a variable reference
if options_ref in var_table:
field_dict['options'] = var_table[options_ref]
# Or inline options
elif options_ref.startswith('['):
# Find the full options array (handle nested brackets)
# This is tricky, for now try to extract inline options
inline_match = re.search(r'options\s*=\s*(\[.*?\])', params_text, re.DOTALL)
if inline_match:
options_text = inline_match.group(1)
field_dict['options'] = self._parse_schema_options(options_text, var_table)
return field_dict
def _parse_schema_options(self, options_text: str, var_table: Dict) -> List[Dict[str, str]]:
"""
Parse schema.Option list.
Args:
options_text: Text containing schema.Option(...) entries
var_table: Variable lookup table (not currently used)
Returns:
List of {"display": "...", "value": "..."} dicts
"""
options = []
# Match schema.Option(display = "...", value = "...")
option_pattern = r'schema\.Option\s*\(\s*display\s*=\s*"([^"]+)"\s*,\s*value\s*=\s*"([^"]+)"\s*\)'
matches = re.finditer(option_pattern, options_text)
for match in matches:
options.append({
"display": match.group(1),
"value": match.group(2)
})
return options

View File

@@ -0,0 +1,3 @@
Pillow>=10.4.0
PyYAML>=6.0.2
requests>=2.32.0

View File

@@ -0,0 +1,601 @@
"""
Tronbyte Repository Module
Handles interaction with the Tronbyte apps repository on GitHub.
Fetches app listings, metadata, and downloads .star files.
"""
import logging
import time
import requests
import yaml
import threading
from typing import Dict, Any, Optional, List, Tuple
from pathlib import Path
from concurrent.futures import ThreadPoolExecutor, as_completed
logger = logging.getLogger(__name__)
# Module-level cache for bulk app listing (survives across requests)
_apps_cache = {'data': None, 'timestamp': 0, 'categories': [], 'authors': []}
_CACHE_TTL = 7200 # 2 hours
_cache_lock = threading.Lock()
class TronbyteRepository:
"""
Interface to the Tronbyte apps repository.
Provides methods to:
- List available apps
- Fetch app metadata
- Download .star files
- Parse manifest.yaml files
"""
REPO_OWNER = "tronbyt"
REPO_NAME = "apps"
DEFAULT_BRANCH = "main"
APPS_PATH = "apps"
def __init__(self, github_token: Optional[str] = None):
"""
Initialize repository interface.
Args:
github_token: Optional GitHub personal access token for higher rate limits
"""
self.github_token = github_token
self.base_url = "https://api.github.com"
self.raw_url = "https://raw.githubusercontent.com"
self.session = requests.Session()
if github_token:
self.session.headers.update({
'Authorization': f'token {github_token}'
})
self.session.headers.update({
'Accept': 'application/vnd.github.v3+json',
'User-Agent': 'LEDMatrix-Starlark-Plugin'
})
def _make_request(self, url: str, timeout: int = 10) -> Optional[Dict[str, Any]]:
"""
Make a request to GitHub API with error handling.
Args:
url: API URL to request
timeout: Request timeout in seconds
Returns:
JSON response or None on error
"""
try:
response = self.session.get(url, timeout=timeout)
if response.status_code == 403:
# Rate limit exceeded
logger.warning("[Tronbyte Repo] GitHub API rate limit exceeded")
return None
elif response.status_code == 404:
logger.warning(f"[Tronbyte Repo] Resource not found: {url}")
return None
elif response.status_code != 200:
logger.error(f"[Tronbyte Repo] GitHub API error: {response.status_code}")
return None
return response.json()
except requests.Timeout:
logger.error(f"[Tronbyte Repo] Request timeout: {url}")
return None
except requests.RequestException as e:
logger.error(f"[Tronbyte Repo] Request error: {e}", exc_info=True)
return None
except (json.JSONDecodeError, ValueError) as e:
logger.error(f"[Tronbyte Repo] JSON parse error for {url}: {e}", exc_info=True)
return None
def _fetch_raw_file(self, file_path: str, branch: Optional[str] = None, binary: bool = False):
"""
Fetch raw file content from repository.
Args:
file_path: Path to file in repository
branch: Branch name (default: DEFAULT_BRANCH)
binary: If True, return bytes; if False, return text
Returns:
File content as string/bytes, or None on error
"""
branch = branch or self.DEFAULT_BRANCH
url = f"{self.raw_url}/{self.REPO_OWNER}/{self.REPO_NAME}/{branch}/{file_path}"
try:
response = self.session.get(url, timeout=10)
if response.status_code == 200:
return response.content if binary else response.text
else:
logger.warning(f"[Tronbyte Repo] Failed to fetch raw file: {file_path} ({response.status_code})")
return None
except requests.Timeout:
logger.error(f"[Tronbyte Repo] Timeout fetching raw file: {file_path}")
return None
except requests.RequestException as e:
logger.error(f"[Tronbyte Repo] Network error fetching raw file {file_path}: {e}", exc_info=True)
return None
def list_apps(self) -> Tuple[bool, Optional[List[Dict[str, Any]]], Optional[str]]:
"""
List all available apps in the repository.
Returns:
Tuple of (success, apps_list, error_message)
"""
url = f"{self.base_url}/repos/{self.REPO_OWNER}/{self.REPO_NAME}/contents/{self.APPS_PATH}"
data = self._make_request(url)
if data is None:
return False, None, "Failed to fetch repository contents"
if not isinstance(data, list):
return False, None, "Invalid response format"
# Filter directories (apps)
apps = []
for item in data:
if item.get('type') == 'dir':
app_id = item.get('name')
if app_id and not app_id.startswith('.'):
apps.append({
'id': app_id,
'path': item.get('path'),
'url': item.get('url')
})
logger.info(f"Found {len(apps)} apps in repository")
return True, apps, None
def get_app_metadata(self, app_id: str) -> Tuple[bool, Optional[Dict[str, Any]], Optional[str]]:
"""
Fetch metadata for a specific app.
Reads the manifest.yaml file for the app and parses it.
Args:
app_id: App identifier
Returns:
Tuple of (success, metadata_dict, error_message)
"""
manifest_path = f"{self.APPS_PATH}/{app_id}/manifest.yaml"
content = self._fetch_raw_file(manifest_path)
if not content:
return False, None, f"Failed to fetch manifest for {app_id}"
try:
metadata = yaml.safe_load(content)
# Validate that metadata is a dict before mutating
if not isinstance(metadata, dict):
if metadata is None:
logger.warning(f"Manifest for {app_id} is empty or None, initializing empty dict")
metadata = {}
else:
logger.error(f"Manifest for {app_id} is not a dict (got {type(metadata).__name__}), skipping")
return False, None, f"Invalid manifest format: expected dict, got {type(metadata).__name__}"
# Enhance with app_id
metadata['id'] = app_id
# Parse schema if present
if 'schema' in metadata:
# Schema is already parsed from YAML
pass
return True, metadata, None
except (yaml.YAMLError, TypeError) as e:
logger.error(f"Failed to parse manifest for {app_id}: {e}")
return False, None, f"Invalid manifest format: {e}"
def list_apps_with_metadata(self, max_apps: Optional[int] = None) -> List[Dict[str, Any]]:
"""
List all apps with their metadata.
This is slower as it fetches manifest.yaml for each app.
Args:
max_apps: Optional limit on number of apps to fetch
Returns:
List of app metadata dictionaries
"""
success, apps, error = self.list_apps()
if not success:
logger.error(f"Failed to list apps: {error}")
return []
if max_apps is not None:
apps = apps[:max_apps]
apps_with_metadata = []
for app_info in apps:
app_id = app_info['id']
success, metadata, error = self.get_app_metadata(app_id)
if success and metadata:
# Merge basic info with metadata
metadata.update({
'repository_path': app_info['path']
})
apps_with_metadata.append(metadata)
else:
# Add basic info even if metadata fetch failed
apps_with_metadata.append({
'id': app_id,
'name': app_id.replace('_', ' ').title(),
'summary': 'No description available',
'repository_path': app_info['path'],
'metadata_error': error
})
return apps_with_metadata
def list_all_apps_cached(self) -> Dict[str, Any]:
"""
Fetch ALL apps with metadata, using a module-level cache.
On first call (or after cache TTL expires), fetches the directory listing
via the GitHub API (1 call) then fetches all manifests in parallel via
raw.githubusercontent.com (not rate-limited). Results are cached for 2 hours.
Returns:
Dict with keys: apps, categories, authors, count, cached
"""
global _apps_cache
now = time.time()
# Check cache with lock (read-only check)
with _cache_lock:
if _apps_cache['data'] is not None and (now - _apps_cache['timestamp']) < _CACHE_TTL:
return {
'apps': _apps_cache['data'],
'categories': _apps_cache['categories'],
'authors': _apps_cache['authors'],
'count': len(_apps_cache['data']),
'cached': True
}
# Fetch directory listing (1 GitHub API call)
success, app_dirs, error = self.list_apps()
if not success or not app_dirs:
logger.error(f"Failed to list apps for bulk fetch: {error}")
return {'apps': [], 'categories': [], 'authors': [], 'count': 0, 'cached': False}
logger.info(f"Bulk-fetching manifests for {len(app_dirs)} apps...")
def fetch_one(app_info):
"""Fetch a single app's manifest (runs in thread pool)."""
app_id = app_info['id']
manifest_path = f"{self.APPS_PATH}/{app_id}/manifest.yaml"
content = self._fetch_raw_file(manifest_path)
if content:
try:
metadata = yaml.safe_load(content)
if not isinstance(metadata, dict):
metadata = {}
metadata['id'] = app_id
metadata['repository_path'] = app_info.get('path', '')
return metadata
except (yaml.YAMLError, TypeError) as e:
logger.warning(f"Failed to parse manifest for {app_id}: {e}")
# Fallback: minimal entry
return {
'id': app_id,
'name': app_id.replace('_', ' ').replace('-', ' ').title(),
'summary': 'No description available',
'repository_path': app_info.get('path', ''),
}
# Parallel manifest fetches via raw.githubusercontent.com (high rate limit)
apps_with_metadata = []
with ThreadPoolExecutor(max_workers=5) as executor:
futures = {executor.submit(fetch_one, info): info for info in app_dirs}
for future in as_completed(futures):
try:
result = future.result(timeout=30)
if result:
apps_with_metadata.append(result)
except Exception as e:
app_info = futures[future]
logger.warning(f"Failed to fetch manifest for {app_info['id']}: {e}")
apps_with_metadata.append({
'id': app_info['id'],
'name': app_info['id'].replace('_', ' ').replace('-', ' ').title(),
'summary': 'No description available',
'repository_path': app_info.get('path', ''),
})
# Sort by name for consistent ordering
apps_with_metadata.sort(key=lambda a: (a.get('name') or a.get('id', '')).lower())
# Extract unique categories and authors
categories = sorted({a.get('category', '') for a in apps_with_metadata if a.get('category')})
authors = sorted({a.get('author', '') for a in apps_with_metadata if a.get('author')})
# Update cache with lock
with _cache_lock:
_apps_cache['data'] = apps_with_metadata
_apps_cache['timestamp'] = now
_apps_cache['categories'] = categories
_apps_cache['authors'] = authors
logger.info(f"Cached {len(apps_with_metadata)} apps ({len(categories)} categories, {len(authors)} authors)")
return {
'apps': apps_with_metadata,
'categories': categories,
'authors': authors,
'count': len(apps_with_metadata),
'cached': False
}
def download_star_file(self, app_id: str, output_path: Path, filename: Optional[str] = None) -> Tuple[bool, Optional[str]]:
"""
Download the .star file for an app.
Args:
app_id: App identifier (directory name)
output_path: Where to save the .star file
filename: Optional specific filename from manifest (e.g., "analog_clock.star")
If not provided, assumes {app_id}.star
Returns:
Tuple of (success, error_message)
"""
# Validate inputs for path traversal
if '..' in app_id or '/' in app_id or '\\' in app_id:
return False, f"Invalid app_id: contains path traversal characters"
star_filename = filename or f"{app_id}.star"
if '..' in star_filename or '/' in star_filename or '\\' in star_filename:
return False, f"Invalid filename: contains path traversal characters"
# Validate output_path to prevent path traversal
import tempfile
try:
resolved_output = output_path.resolve()
temp_dir = Path(tempfile.gettempdir()).resolve()
# Check if output_path is within the system temp directory
# Use try/except for compatibility with Python < 3.9 (is_relative_to)
try:
is_safe = resolved_output.is_relative_to(temp_dir)
except AttributeError:
# Fallback for Python < 3.9: compare string paths
is_safe = str(resolved_output).startswith(str(temp_dir) + '/')
if not is_safe:
logger.warning(f"Path traversal attempt in download_star_file: app_id={app_id}, output_path={output_path}")
return False, f"Invalid output_path for {app_id}: must be within temp directory"
except Exception as e:
logger.error(f"Error validating output_path for {app_id}: {e}")
return False, f"Invalid output_path for {app_id}"
# Use provided filename or fall back to app_id.star
star_path = f"{self.APPS_PATH}/{app_id}/{star_filename}"
content = self._fetch_raw_file(star_path)
if not content:
return False, f"Failed to download .star file for {app_id} (tried {star_filename})"
try:
output_path.parent.mkdir(parents=True, exist_ok=True)
with open(output_path, 'w', encoding='utf-8') as f:
f.write(content)
logger.info(f"Downloaded {app_id}.star to {output_path}")
return True, None
except OSError as e:
logger.exception(f"Failed to save .star file: {e}")
return False, f"Failed to save file: {e}"
def get_app_files(self, app_id: str) -> Tuple[bool, Optional[List[str]], Optional[str]]:
"""
List all files in an app directory.
Args:
app_id: App identifier
Returns:
Tuple of (success, file_list, error_message)
"""
url = f"{self.base_url}/repos/{self.REPO_OWNER}/{self.REPO_NAME}/contents/{self.APPS_PATH}/{app_id}"
data = self._make_request(url)
if not data:
return False, None, "Failed to fetch app files"
if not isinstance(data, list):
return False, None, "Invalid response format"
files = [item['name'] for item in data if item.get('type') == 'file']
return True, files, None
def download_app_assets(self, app_id: str, output_dir: Path) -> Tuple[bool, Optional[str]]:
"""
Download all asset files (images, sources, etc.) for an app.
Args:
app_id: App identifier
output_dir: Directory to save assets to
Returns:
Tuple of (success, error_message)
"""
# Validate app_id for path traversal
if '..' in app_id or '/' in app_id or '\\' in app_id:
return False, f"Invalid app_id: contains path traversal characters"
try:
# Get directory listing for the app
url = f"{self.base_url}/repos/{self.REPO_OWNER}/{self.REPO_NAME}/contents/{self.APPS_PATH}/{app_id}"
data = self._make_request(url)
if not data:
return False, f"Failed to fetch app directory listing"
if not isinstance(data, list):
return False, f"Invalid directory listing format"
# Find directories that contain assets (images, sources, etc.)
asset_dirs = []
for item in data:
if item.get('type') == 'dir':
dir_name = item.get('name')
# Common asset directory names in Tronbyte apps
if dir_name in ('images', 'sources', 'fonts', 'assets'):
asset_dirs.append((dir_name, item.get('url')))
if not asset_dirs:
# No asset directories, this is fine
return True, None
# Download each asset directory
for dir_name, dir_url in asset_dirs:
# Validate directory name for path traversal
if '..' in dir_name or '/' in dir_name or '\\' in dir_name:
logger.warning(f"Skipping potentially unsafe directory: {dir_name}")
continue
# Get files in this directory
dir_data = self._make_request(dir_url)
if not dir_data or not isinstance(dir_data, list):
logger.warning(f"Could not list files in {app_id}/{dir_name}")
continue
# Create local directory
local_dir = output_dir / dir_name
local_dir.mkdir(parents=True, exist_ok=True)
# Download each file
for file_item in dir_data:
if file_item.get('type') == 'file':
file_name = file_item.get('name')
# Ensure file_name is a non-empty string before validation
if not file_name or not isinstance(file_name, str):
logger.warning(f"Skipping file with invalid name in {dir_name}: {file_item}")
continue
# Validate filename for path traversal
if '..' in file_name or '/' in file_name or '\\' in file_name:
logger.warning(f"Skipping potentially unsafe file: {file_name}")
continue
file_path = f"{self.APPS_PATH}/{app_id}/{dir_name}/{file_name}"
content = self._fetch_raw_file(file_path, binary=True)
if content:
# Write binary content to file
output_path = local_dir / file_name
try:
with open(output_path, 'wb') as f:
f.write(content)
logger.debug(f"[Tronbyte Repo] Downloaded asset: {dir_name}/{file_name}")
except OSError as e:
logger.warning(f"[Tronbyte Repo] Failed to save {dir_name}/{file_name}: {e}", exc_info=True)
else:
logger.warning(f"Failed to download {dir_name}/{file_name}")
logger.info(f"[Tronbyte Repo] Downloaded assets for {app_id} ({len(asset_dirs)} directories)")
return True, None
except (OSError, ValueError) as e:
logger.exception(f"[Tronbyte Repo] Error downloading assets for {app_id}: {e}")
return False, f"Error downloading assets: {e}"
def search_apps(self, query: str, apps_with_metadata: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""
Search apps by name, summary, or description.
Args:
query: Search query string
apps_with_metadata: List of apps with metadata
Returns:
Filtered list of apps matching query
"""
if not query:
return apps_with_metadata
query_lower = query.lower()
results = []
for app in apps_with_metadata:
# Search in name, summary, description, author
searchable = ' '.join([
app.get('name', ''),
app.get('summary', ''),
app.get('desc', ''),
app.get('author', ''),
app.get('id', '')
]).lower()
if query_lower in searchable:
results.append(app)
return results
def filter_by_category(self, category: str, apps_with_metadata: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""
Filter apps by category.
Args:
category: Category name (or 'all' for no filtering)
apps_with_metadata: List of apps with metadata
Returns:
Filtered list of apps
"""
if not category or category.lower() == 'all':
return apps_with_metadata
category_lower = category.lower()
results = []
for app in apps_with_metadata:
app_category = app.get('category', '').lower()
if app_category == category_lower:
results.append(app)
return results
def get_rate_limit_info(self) -> Dict[str, Any]:
"""
Get current GitHub API rate limit information.
Returns:
Dictionary with rate limit info
"""
url = f"{self.base_url}/rate_limit"
data = self._make_request(url)
if data:
core = data.get('resources', {}).get('core', {})
return {
'limit': core.get('limit', 0),
'remaining': core.get('remaining', 0),
'reset': core.get('reset', 0),
'used': core.get('used', 0)
}
return {
'limit': 0,
'remaining': 0,
'reset': 0,
'used': 0
}

5
run.py
View File

@@ -4,6 +4,11 @@ import sys
import os
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)
project_dir = os.path.dirname(os.path.abspath(__file__))
if project_dir not in sys.path:

139
scripts/download_pixlet.sh Executable file
View File

@@ -0,0 +1,139 @@
#!/bin/bash
#
# Download Pixlet binaries for bundled distribution
#
# This script downloads Pixlet binaries from the Tronbyte fork
# for multiple architectures to support various platforms.
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
BIN_DIR="$PROJECT_ROOT/bin/pixlet"
# Pixlet version to download (use 'latest' to auto-detect)
PIXLET_VERSION="${PIXLET_VERSION:-latest}"
# GitHub repository (Tronbyte fork)
REPO="tronbyt/pixlet"
echo "========================================"
echo "Pixlet Binary Download Script"
echo "========================================"
# Auto-detect latest version if needed
if [ "$PIXLET_VERSION" = "latest" ]; then
echo "Detecting latest version..."
PIXLET_VERSION=$(curl -s "https://api.github.com/repos/${REPO}/releases/latest" | grep '"tag_name"' | sed -E 's/.*"([^"]+)".*/\1/')
if [ -z "$PIXLET_VERSION" ]; then
echo "Failed to detect latest version, using fallback"
PIXLET_VERSION="v0.50.2"
fi
fi
echo "Version: $PIXLET_VERSION"
echo "Target directory: $BIN_DIR"
echo ""
# Create bin directory if it doesn't exist
mkdir -p "$BIN_DIR"
# New naming convention: pixlet_v0.50.2_linux-arm64.tar.gz
# Only download ARM64 Linux binary for Raspberry Pi
declare -A ARCHITECTURES=(
["linux-arm64"]="pixlet_${PIXLET_VERSION}_linux-arm64.tar.gz"
)
download_binary() {
local arch="$1"
local archive_name="$2"
local binary_name="pixlet-${arch}"
local output_path="$BIN_DIR/$binary_name"
# Skip if already exists
if [ -f "$output_path" ]; then
echo "$binary_name already exists, skipping..."
return 0
fi
echo "→ Downloading $arch..."
# Construct download URL
local url="https://github.com/${REPO}/releases/download/${PIXLET_VERSION}/${archive_name}"
# Download to temp directory (use project-local temp to avoid /tmp permission issues)
local temp_dir
temp_dir=$(mktemp -d -p "$PROJECT_ROOT" -t pixlet_download.XXXXXXXXXX)
local temp_file="$temp_dir/$archive_name"
if ! curl -L -o "$temp_file" "$url" 2>/dev/null; then
echo "✗ Failed to download $arch"
rm -rf "$temp_dir"
return 1
fi
# Extract binary
echo " Extracting..."
if ! tar -xzf "$temp_file" -C "$temp_dir"; then
echo "✗ Failed to extract archive: $temp_file"
rm -rf "$temp_dir"
return 1
fi
# Find the pixlet binary in extracted files
local extracted_binary
extracted_binary=$(find "$temp_dir" -name "pixlet" | head -n 1)
if [ -z "$extracted_binary" ]; then
echo "✗ Binary not found in archive"
rm -rf "$temp_dir"
return 1
fi
# Move to final location
mv "$extracted_binary" "$output_path"
# Make executable
chmod +x "$output_path"
# Clean up
rm -rf "$temp_dir"
# Verify
local size
size=$(stat -f%z "$output_path" 2>/dev/null || stat -c%s "$output_path" 2>/dev/null || echo "unknown")
if [ "$size" = "unknown" ]; then
echo "✓ Downloaded $binary_name"
else
echo "✓ Downloaded $binary_name ($(numfmt --to=iec-i --suffix=B $size 2>/dev/null || echo "${size} bytes"))"
fi
return 0
}
# Download binaries for each architecture
success_count=0
total_count=${#ARCHITECTURES[@]}
for arch in "${!ARCHITECTURES[@]}"; do
if download_binary "$arch" "${ARCHITECTURES[$arch]}"; then
((success_count++))
fi
done
echo ""
echo "========================================"
echo "Download complete: $success_count/$total_count succeeded"
echo "========================================"
# List downloaded binaries
echo ""
echo "Installed binaries:"
if compgen -G "$BIN_DIR/*" > /dev/null 2>&1; then
ls -lh "$BIN_DIR"/*
else
echo "No binaries found"
fi
exit 0

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)
WEB_USER=$(whoami)
PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$PROJECT_DIR/../.." && pwd)"
echo "Detected web interface user: $WEB_USER"
echo "Project directory: $PROJECT_DIR"
echo "Project root: $PROJECT_ROOT"
# Check if running as root
if [ "$EUID" -eq 0 ]; then
@@ -21,50 +23,92 @@ if [ "$EUID" -eq 0 ]; then
exit 1
fi
# Get the full paths to commands
PYTHON_PATH=$(which python3)
SYSTEMCTL_PATH=$(which systemctl)
REBOOT_PATH=$(which reboot)
POWEROFF_PATH=$(which poweroff)
BASH_PATH=$(which bash)
JOURNALCTL_PATH=$(which journalctl)
# Get the full paths to commands and validate each one
MISSING_CMDS=()
PYTHON_PATH=$(command -v python3) || true
SYSTEMCTL_PATH=$(command -v systemctl) || true
REBOOT_PATH=$(command -v reboot) || true
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 " Python: $PYTHON_PATH"
echo " Systemctl: $SYSTEMCTL_PATH"
echo " Reboot: $REBOOT_PATH"
echo " Poweroff: $POWEROFF_PATH"
echo " Reboot: ${REBOOT_PATH:-(not found, skipping)}"
echo " Poweroff: ${POWEROFF_PATH:-(not found, skipping)}"
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
TEMP_SUDOERS="/tmp/ledmatrix_web_sudoers_$$"
cat > "$TEMP_SUDOERS" << EOF
# LED Matrix Web Interface passwordless sudo configuration
# This allows the web interface user to run specific commands without a password
{
echo "# LED Matrix Web Interface passwordless sudo configuration"
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
$WEB_USER ALL=(ALL) NOPASSWD: $REBOOT_PATH
$WEB_USER ALL=(ALL) NOPASSWD: $POWEROFF_PATH
$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH start ledmatrix.service
$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH stop ledmatrix.service
$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH restart ledmatrix.service
$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH enable ledmatrix.service
$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH disable ledmatrix.service
$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH status ledmatrix.service
$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH is-active ledmatrix
$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH is-active ledmatrix.service
$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH start ledmatrix-web
$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH stop ledmatrix-web
$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH restart ledmatrix-web
$WEB_USER ALL=(ALL) NOPASSWD: $JOURNALCTL_PATH -u ledmatrix.service *
$WEB_USER ALL=(ALL) NOPASSWD: $JOURNALCTL_PATH -u ledmatrix *
$WEB_USER ALL=(ALL) NOPASSWD: $JOURNALCTL_PATH -t ledmatrix *
$WEB_USER ALL=(ALL) NOPASSWD: $PYTHON_PATH $PROJECT_DIR/display_controller.py
$WEB_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_DIR/start_display.sh
$WEB_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_DIR/stop_display.sh
EOF
# Optional: reboot/poweroff (non-critical — skip if not found)
if [ -n "$REBOOT_PATH" ]; then
echo "$WEB_USER ALL=(ALL) NOPASSWD: $REBOOT_PATH"
fi
if [ -n "$POWEROFF_PATH" ]; then
echo "$WEB_USER ALL=(ALL) NOPASSWD: $POWEROFF_PATH"
fi
# Required: systemctl
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH start ledmatrix.service"
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH stop ledmatrix.service"
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH restart ledmatrix.service"
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH enable ledmatrix.service"
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH disable ledmatrix.service"
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH status ledmatrix.service"
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH is-active ledmatrix"
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH is-active ledmatrix.service"
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH start ledmatrix-web"
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH stop ledmatrix-web"
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH restart ledmatrix-web"
# 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 "Generated sudoers configuration:"
@@ -81,6 +125,7 @@ echo "- View system logs via journalctl"
echo "- Run display_controller.py directly"
echo "- Execute start_display.sh and stop_display.sh"
echo "- Reboot and shutdown the system"
echo "- Remove plugin directories (for update/uninstall when root-owned files block deletion)"
echo ""
# Ask for confirmation
@@ -94,6 +139,15 @@ fi
# Apply the configuration using visudo
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
echo "Configuration applied successfully!"
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
"""
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
plugin repositories in the parent directory, allowing the system to
find plugins without modifying the LEDMatrix project structure.
Creates symlinks in plugin-repos/ pointing to plugin directories
in the ledmatrix-plugins monorepo.
"""
import json
import os
import re
import sys
from pathlib import Path
# Paths
PROJECT_ROOT = Path(__file__).parent.parent
PLUGIN_REPOS_DIR = PROJECT_ROOT / "plugin-repos"
GITHUB_DIR = PROJECT_ROOT.parent
CONFIG_FILE = PROJECT_ROOT / "config" / "config.json"
MONOREPO_PLUGINS = PROJECT_ROOT.parent / "ledmatrix-plugins" / "plugins"
def get_workspace_plugins():
"""Get list of plugins from workspace file."""
workspace_file = PROJECT_ROOT / "LEDMatrix.code-workspace"
if not workspace_file.exists():
return []
try:
with open(workspace_file, 'r') as f:
workspace = json.load(f)
except json.JSONDecodeError as e:
print(f"Error: Failed to parse workspace file {workspace_file}: {e}")
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 parse_json_with_trailing_commas(text: str) -> dict:
"""Parse JSON that may have trailing commas.
Note: The regex also matches commas inside string values (e.g., "hello, }").
This is fine for manifest files but may corrupt complex JSON with such patterns.
"""
text = re.sub(r",\s*([}\]])", r"\1", text)
return json.loads(text)
def create_symlinks():
"""Create symlinks in plugin-repos/ pointing to actual repos."""
plugins = get_workspace_plugins()
if not plugins:
print("No plugins found in workspace configuration")
def create_symlinks() -> bool:
"""Create symlinks in plugin-repos/ pointing to monorepo plugin dirs."""
if not MONOREPO_PLUGINS.exists():
print(f"Error: Monorepo plugins directory not found: {MONOREPO_PLUGINS}")
return False
# Ensure plugin-repos directory exists
PLUGIN_REPOS_DIR.mkdir(exist_ok=True)
created = 0
skipped = 0
errors = 0
print(f"Setting up plugin repository links...")
print(f" Source: {GITHUB_DIR}")
print("Setting up plugin symlinks...")
print(f" Source: {MONOREPO_PLUGINS}")
print(f" Links: {PLUGIN_REPOS_DIR}")
print()
for plugin in plugins:
actual_path = plugin['actual_path']
link_path = plugin['link_path']
if not actual_path.exists():
print(f" ⚠️ {plugin['name']} - source not found: {actual_path}")
errors += 1
for plugin_dir in sorted(MONOREPO_PLUGINS.iterdir()):
if not plugin_dir.is_dir():
continue
# Remove existing link/file if it exists
manifest_path = plugin_dir / "manifest.json"
if not manifest_path.exists():
continue
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.is_symlink():
# Check if it points to the right place
try:
if link_path.resolve() == actual_path.resolve():
print(f"{plugin['name']} - link already exists")
if link_path.resolve() == plugin_dir.resolve():
skipped += 1
continue
else:
# Remove old symlink pointing elsewhere
link_path.unlink()
except Exception as e:
print(f" ⚠️ {plugin['name']} - error checking link: {e}")
except OSError:
link_path.unlink()
else:
# It's a directory/file, not a symlink
print(f" ⚠️ {plugin['name']} - {link_path.name} exists but is not a symlink")
print(f" Skipping (manual cleanup required)")
print(f" {plugin_id} - exists but is not a symlink, skipping")
skipped += 1
continue
# Create symlink
try:
# Use relative path for symlink portability
relative_path = os.path.relpath(actual_path, link_path.parent)
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"✅ Created {created} links, skipped {skipped}, errors {errors}")
return errors == 0
relative_path = os.path.relpath(plugin_dir, link_path.parent)
link_path.symlink_to(relative_path)
print(f" {plugin_id} - linked")
created += 1
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
print(f"\nCreated {created} links, skipped {skipped}")
return True
def main():
"""Main function."""
print("🔗 Setting up plugin repository symlinks...")
print()
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
print("Setting up plugin repository symlinks from monorepo...\n")
if not create_symlinks():
sys.exit(1)
if __name__ == '__main__':
sys.exit(main())
if __name__ == "__main__":
main()

View File

@@ -1,123 +1,43 @@
#!/usr/bin/env python3
"""
Update all plugin repositories by pulling the latest changes.
This script updates all plugin repos without needing to modify
the LEDMatrix project itself.
Update the ledmatrix-plugins monorepo by pulling latest changes.
"""
import json
import os
import subprocess
import sys
from pathlib import Path
# Paths
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
MONOREPO_DIR = Path(__file__).parent.parent.parent / "ledmatrix-plugins"
def main():
"""Main function."""
print("🔍 Finding plugin repositories...")
plugins = load_workspace_plugins()
if not plugins:
print(" No plugin repositories found!")
if not MONOREPO_DIR.exists():
print(f"Error: Monorepo not found: {MONOREPO_DIR}")
return 1
print(f" Found {len(plugins)} plugin repositories")
print(f"\n🚀 Updating plugins in {GITHUB_DIR}...")
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.")
if not (MONOREPO_DIR / ".git").exists():
print(f"Error: {MONOREPO_DIR} is not a git repository")
return 1
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
return 0
if __name__ == '__main__':
if __name__ == "__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__))))
CONFIG_FILE = os.path.join(PROJECT_DIR, 'config', 'config.json')
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():
"""Install required dependencies using system Python."""
@@ -85,11 +92,18 @@ def main():
if is_enabled:
print("Configuration 'web_display_autostart' is enabled. Starting web interface...")
# Install dependencies
if not install_dependencies():
print("Failed to install dependencies. Exiting.")
sys.exit(1)
# Only install dependencies if not already done during first-time setup
if not dependencies_installed():
print("First run detected: Installing dependencies...")
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:
# 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
# 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.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:
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:
"""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
return False
@@ -248,40 +229,7 @@ class SportsCore(ABC):
return False
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)
# #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
# or after calling display() in the main loop. Let's keep it out of the base display.
return True
@@ -942,7 +890,7 @@ class SportsUpcoming(SportsCore):
away_text = ''
elif self.show_ranking:
# 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:
away_text = f"#{away_rank}"
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
# 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:
# #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 = self.live_games[0] if self.live_games else None
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:
# Find current game's new index if it still exists
try:
@@ -1530,70 +1439,9 @@ class SportsLive(SportsCore):
# 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)
# 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:
# #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 = self.live_games[self.current_game_index]
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
# Force display update via flag or direct call if needed, but usually let main loop handle

View File

@@ -8,32 +8,70 @@ files that need to be accessible by both root service and web user.
import os
import logging
import shutil as _shutil
import subprocess
from pathlib import Path
from typing import Optional
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:
"""
Create directory and set permissions.
If the directory already exists and we cannot change its permissions,
we check if it's usable (readable/writable). If so, we continue without
raising an exception. This allows the system to work even when running
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:
path: Directory path to create/ensure
mode: Permission mode (default: 0o775 for group-writable directories)
Raises:
OSError: If directory creation fails or directory exists but is not usable
"""
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
path.mkdir(parents=True, exist_ok=True)
# Try to set permissions
try:
os.chmod(path, mode)
@@ -156,9 +194,96 @@ def get_plugin_dir_mode() -> int:
def get_cache_dir_mode() -> int:
"""
Return permission mode for cache directories.
Returns:
Permission mode: 0o2775 (rwxrwxr-x + sticky bit) for group-writable cache directories
"""
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)
steps = int(time_since_last_step / self.scroll_delay)
# 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)
pixels_to_move = self.scroll_speed * steps
# 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 datetime import datetime
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
from src.display_manager import DisplayManager
@@ -18,6 +19,10 @@ from src.logging_config import get_logger
# Get logger with consistent configuration
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
# WiFi status message file path (same as used in wifi_manager.py)
@@ -330,7 +335,12 @@ class DisplayController:
# Schedule management
self.is_display_active = True
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
try:
self._publish_on_demand_state()
@@ -343,8 +353,87 @@ class DisplayController:
self._update_modules()
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)
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):
"""Check if display should be active based on schedule."""
schedule_config = self.config.get('schedule', {})
@@ -362,8 +451,17 @@ class DisplayController:
self._was_display_active = True # Track previous state for schedule change detection
logger.debug("Schedule is disabled - display always active")
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_time_only = current_time.time()
@@ -440,11 +538,96 @@ class DisplayController:
self._was_display_active = self.is_display_active
except ValueError as e:
logger.warning("Invalid schedule format for %s schedule: %s (start: %s, end: %s). Defaulting to active.",
logger.warning("Invalid schedule format for %s schedule: %s (start: %s, end: %s). Defaulting to active.",
schedule_type, e, start_time_str, end_time_str)
self.is_display_active = True
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):
"""Update all plugin modules."""
if not self.plugin_manager:
@@ -1101,6 +1284,13 @@ class DisplayController:
elif not self.on_demand_active and self.on_demand_schedule_override:
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:
# Clear display when schedule makes it inactive to ensure blank screen
# (not showing initialization screen)
@@ -1152,6 +1342,23 @@ class DisplayController:
except ValueError:
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:
# Guard against empty 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.row_address_type = hardware_config.get('row_address_type', 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.show_refresh_rate = hardware_config.get('show_refresh_rate', False)
options.limit_refresh_rate_hz = hardware_config.get('limit_refresh_rate_hz', 90)
@@ -174,6 +175,57 @@ class DisplayManager:
else:
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):
"""Draw a test pattern to verify the display is working."""
try:
@@ -816,8 +868,12 @@ class DisplayManager:
get_assets_file_mode
)
snapshot_path_obj = Path(self._snapshot_path)
if snapshot_path_obj.parent:
ensure_directory_permissions(snapshot_path_obj.parent, get_assets_dir_mode())
# Only ensure permissions on non-system directories
# 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
tmp_path = f"{self._snapshot_path}.tmp"
self.image.save(tmp_path, format='PNG')

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