mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-06 19:28:06 +00:00
Codebase audit: fix shipping bugs, remove verified-dead code, repair doc drift, add regression guards (#438)
* fix(web): implement delete_cached so the font catalog cache actually invalidates
api_v3.py's font upload/delete handlers import delete_cached from
web_interface.cache, but the function was never defined. The surrounding
except ImportError silently swallowed the failure, so the fonts_catalog
cache entry survived uploads/deletes and newly uploaded fonts did not
appear until the TTL expired or the service restarted.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SXb4mKcAkVaxkeTb3YnAdr
* fix(web): remove dead weather/stocks partial routes that returned 500
The partial dispatcher still routed 'weather' and 'stocks' to loaders
rendering v3/partials/weather.html and stocks.html — templates that no
longer exist since weather and stocks became store plugins. Requesting
either partial raised TemplateNotFound, which the catch-all turned into
a 500. No template or JS references these partials (the only 'weather'
hit in the front end is a plugin-store category filter option), so the
branches and both loader functions are removed; unknown partials now
fall through to the existing 404 handler.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SXb4mKcAkVaxkeTb3YnAdr
* fix(deps): align contradictory psutil/Flask-Limiter/freetype-py pins
requirements.txt's optional-install comment recommended psutil>=5.9,<6.0
while web_interface/requirements.txt hard-requires >=6.0,<7.0 — anyone
following the comment ends up with an unsatisfiable pair. The comment now
recommends the same range the web interface requires (all psutil APIs
used — Process, boot_time, cpu_percent, disk_usage, virtual_memory — are
stable in 6.x). Flask-Limiter gains the same <4.0 cap in both files and
freetype-py the same >=2.5.1 floor.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SXb4mKcAkVaxkeTb3YnAdr
* fix(config): add template keys the code already reads
display.hardware gains pixel_mapper_config, row_address_type,
multiplexing and panel_type (read at display_manager.py with these exact
fallbacks — users on non-standard panels previously had no way to
discover them from the template). vegas_scroll gains
frame_based_scrolling and scroll_delay, the only two of its 27 keys the
template omitted (read in src/vegas_mode/config.py). plugin_system gains
development_mode, which the web UI reads and writes but the template
never declared.
Every added value is byte-identical to the code-side .get() fallback, so
ConfigManager._migrate_config() merging these keys into existing user
configs cannot change behavior on any installed device.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SXb4mKcAkVaxkeTb3YnAdr
* fix(scripts): repair broken sys.path setup in utility scripts
clear_cache.py and download_nba_logos.py pointed sys.path at a 'src'
directory relative to the script's own folder (scripts/utils/src and
scripts/src — neither exists), so both crashed on import; they now insert
the project root and import via the src package like the other scripts.
debug_web_manual.py resolved 'project root' to scripts/debug/ instead of
two levels up. fix_nhl_cache.sh is removed: it used Python docstring
syntax in a bash script and invoked clear_nhl_cache.py, which does not
exist anywhere in the repo — it cannot ever have worked in its current
location.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SXb4mKcAkVaxkeTb3YnAdr
* docs: correct stale file:line references and the loader-fallback contradiction
CLAUDE.md and .cursorrules disagreed about plugin-directory fallback
behavior; the code (SchemaManager.get_schema_path) probes plugins/
BEFORE plugin-repos/, and the main discovery path has no fallback at
all — both files now describe the real behavior, preferring symbol names
over line numbers so the references rot slower. REST_API_REFERENCE.md
pointed at app.py:144/:607 for mounts that live at :199/:799 and counted
92 routes where there are 94. PLUGIN_ARCHITECTURE_SPEC.md's historical
banner gains a note that its example imports
(src/plugin_system/base_classes/*_plugin.py) never shipped — the real
base classes are src.base_classes.sports.SportsCore and
src.base_classes.hockey.Hockey.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SXb4mKcAkVaxkeTb3YnAdr
* docs: fix broken links, phantom script references, and stale CI description
Repairs every broken relative link in active docs (targets renamed or
archived long ago: PLUGIN_DEVELOPMENT.md -> PLUGIN_DEVELOPMENT_GUIDE.md,
API_REFERENCE.md -> REST_API_REFERENCE.md, PLUGIN_STORE_USER_GUIDE.md ->
PLUGIN_STORE_GUIDE.md, plugin_docs/ dir, TROUBLESHOOTING_QUICK_START.md,
and MIGRATION_GUIDE's README link that silently resolved to the docs
index instead of the project README). Replaces commands invoking scripts
that do not exist (scripts/update_stats.py, validate_registry.py,
check_updates.py, fix_permissions.sh) with the real tooling, and
rewrites HOW_TO_RUN_TESTS.md's CI section, which described a
security-audit workflow that was never committed and a pytest workflow
'queued to land' that landed long ago as test.yml.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SXb4mKcAkVaxkeTb3YnAdr
* docs: complete the docs index and refresh the web interface file tree
docs/README.md's own policy says every page must be linked from the
index, yet five weren't — including the entire skin system
(SKIN_SYSTEM.md, CREATING_SKINS.md), ADAPTIVE_LAYOUT.md,
plugin-safety-harness.md and SPORTS_UNIFICATION.md. Each is now listed
in the section it belongs to, and PLUGIN_ARCHITECTURE_SPEC.md is marked
historical in the index (the doc itself already carries the banner).
web_interface/README.md's static/v3 tree showed only app.css/app.js;
it now reflects the actual contents.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SXb4mKcAkVaxkeTb3YnAdr
* chore: remove dead modules confirmed unused in-repo and across all store plugins
- src/common/cli.py: imports a 'ledmatrix_common' package that exists
nowhere (not in this repo, any requirements file, or the plugin
monorepo), so it cannot ever have run; its README section claimed
scripts/dev/* used it, which was also untrue.
- src/web_interface/logging_config.py: zero callers — the web app uses
web_interface/logging_config.py (a different module), and nothing
imports the src copy.
- handle_errors decorator in src/web_interface/error_handler.py: zero
call sites (the module's response helpers stay — they are used).
- ConfigManager.get_clock_config(): reads a 'clock' config key that no
longer exists anywhere; only caller was its own unit test.
Deliberately kept despite zero in-repo callers: DisplayError,
src/common/config_helper.py and display_helper.py — all documented as
plugin-facing API (docs/PLUGIN_ERROR_HANDLING.md, src/common/README.md),
and third-party plugins outside the official monorepo cannot be
enumerated. Verified against a fresh clone of ledmatrix-plugins (43
plugins): zero references to any removed symbol.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SXb4mKcAkVaxkeTb3YnAdr
* chore: remove manager-era NBA test files and one-off debug scripts
The four test_nba_*.py files imported nba_managers, leaderboard_manager
and odds_manager — top-level modules deleted when sports displays became
plugins — inside try/except blocks that swallowed the ImportError, so
they passed while exercising nothing. test_nba_data_structure.py and
debug_nba_api.py (a diagnostic script living in test/) made live ESPN
API calls rather than testing repo code. None were enrolled in CI.
scripts/debug/direct_fix_imports.py and check_imports.py were one-shot
artifacts that edited/inspected a hardcoded ~/LEDMatrix/web_interface/
app.py to fix an import problem solved long ago; nothing references
them.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SXb4mKcAkVaxkeTb3YnAdr
* chore: remove generate_report.py, which aggregates artifacts of CI jobs that do not exist
The script's only function is to merge JSON artifacts
(bandit/semgrep/pip-audit/safety/gitleaks results) produced by a
security-audit workflow that was never committed —
.github/workflows/ has no such jobs, so there is nothing for it to
aggregate and no way to run it usefully. Its siblings stay:
prove_security.py and audit_plugins.py both run standalone (verified),
and .codacy.yml stays because the Codacy service (README badge) reads it
server-side without a workflow file.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SXb4mKcAkVaxkeTb3YnAdr
* fix(web): load the three widget scripts store plugins already declare
time-picker.js, file-upload-single.js and plugin-file-manager.js
register widgets that installed store plugins reference in their config
schemas (countdown uses x-widget: time-picker and file-upload-single;
of-the-day uses plugin-file-manager), but base.html never included the
scripts. plugin_config.html renders such fields as an empty container
that polls LEDMatrixWidgets.get(...) on a 50ms loop forever, so those
plugin config fields appeared permanently blank. The audit initially
flagged these files as dead code; the monorepo cross-check proved the
opposite — they were unreachable, not unused.
example-color-picker.js (the documented custom-widget example) gains an
explicit warning that including it in base.html would shadow the
built-in color-picker widget.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SXb4mKcAkVaxkeTb3YnAdr
* chore: drop the legacy youtube block from the secrets template
No code reads a top-level youtube secrets key: the youtube-stats plugin
receives its API key namespaced under its own plugin id (declared via
x-secret in its config schema), like every other store plugin. The key
survives only in state_reconciliation.py's non-plugin-key exclusion set,
which stays — existing installs still carry the key in their generated
config_secrets.json, and the exclusion prevents it from being
misclassified as a plugin config. New installs simply stop being asked
for a YouTube API key they have nowhere to use.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SXb4mKcAkVaxkeTb3YnAdr
* chore(deps): remove packages nothing imports, declare direct imports, move mypy to test deps
Removed from requirements.txt: python-socketio, python-engineio,
websockets, websocket-client — zero imports anywhere in this repo, and
the one store plugin that needs Socket.IO (ledmatrix-music) declares it
in its own requirements.txt, which the plugin store installs. Removed
the same quartet plus timezonefinder, geopy, google-auth-oauthlib,
google-auth-httplib2, google-api-python-client, unidecode, icalevents,
python-dateutil, flask-wtf and the werkzeug pin from
web_interface/requirements.txt — all leftovers from the deleted built-in
weather/calendar/music displays (flask-wtf was doubly dead: app.py
explicitly disables CSRF and sets csrf=None). scripts/
install_dependencies_apt.py, which mirrors these lists for the
first-time installer, drops the same packages.
Added: urllib3 (imported directly in four core modules), jinja2 and
markupsafe (imported directly in pages_v3.py) — previously reachable
only as transitives. mypy moves from runtime requirements to
requirements-test.txt.
Verified in a fresh venv: all four requirements files co-install, pip
check is clean, the full CI-enrolled suite (907 tests) and a Flask boot
smoke pass with the trimmed dependency set.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SXb4mKcAkVaxkeTb3YnAdr
* refactor: single canonical DateTimeEncoder
src/cache_manager.py and src/cache/disk_cache.py each defined an
identical DateTimeEncoder (datetime -> ISO-8601). The disk_cache copy is
the only one actually used for serialization; cache_manager now
re-exports it instead of defining a twin, so the two can never silently
diverge. Import compatibility is preserved — from src.cache_manager
import DateTimeEncoder still works and is the same class object.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SXb4mKcAkVaxkeTb3YnAdr
* docs(code): document deliberate duplicates instead of merging them
The audit surfaced several near-duplicate implementations that turned
out to be either deliberate forks or behaviorally different — merging
any of them would risk changing behavior on installed devices, so each
now carries an explicit comment stating the relationship:
- VisualDisplayManager: headless fork of DisplayManager; header now
lists the ~15 mirrored methods and warns that DisplayManager changes
must be mirrored.
- normalize_abbreviation: LogoDownloader's version (called directly by
nine scoreboard plugins) replaces filesystem-unsafe characters;
LogoHelper's strips spaces. Logo filenames on existing installs
depend on both behaviors staying put.
- The two PluginTestBase classes: the shipped one is plugin-author
API, the repo's own richer harness lives in test/plugins/ — now
cross-referenced.
Also verified (no change needed): ConfigManager's backup/rollback
methods genuinely delegate to AtomicConfigManager, and SportsCore
already delegates _read_bdf_native_size to FontManager.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SXb4mKcAkVaxkeTb3YnAdr
* docs: add a unified configuration reference
There was no single place documenting what lives in config.json —
display.* keys were scattered across README sections, vegas_scroll lived
in ADVANCED_FEATURES.md, and dim_schedule, display.double_sided,
sync.follower_position, plugin_system.development_mode and the four
newly-templated hardware keys were documented nowhere. CONFIG_REFERENCE.md
now lists every template key plus the code-read-only keys, each with
type, default, and the code location that reads it, and explains the
secrets file's plugin-id namespacing. Linked from the docs index.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SXb4mKcAkVaxkeTb3YnAdr
* docs: bring the README's feature tour into the plugin era
The Core Features section still presented clock/weather/sports/stocks/
music displays as built into the project, when all of them are store
plugins installed from the ledmatrix-plugins monorepo — only
starlark-apps and web-ui-info ship in this repo. The intro now says so
(the showcase itself is unchanged; those are real displays available in
the store). The display_durations reference drops its built-in-calendar
example in favor of plugin-id keys, and the Configuration section links
the new CONFIG_REFERENCE.md.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SXb4mKcAkVaxkeTb3YnAdr
* docs: archive the custom-icons status report, cross-link config docs, document assets/
PLUGIN_CUSTOM_ICONS_FEATURE.md was a 'What Was Implemented' status
report duplicating the actual guide (PLUGIN_CUSTOM_ICONS.md) — moved to
docs/archive/ per the docs index's own policy. The overlapping
plugin-config docs keep their content but PLUGIN_CONFIG_ARCHITECTURE.md
now states up front which doc is canonical for which purpose.
assets/README.md is new and load-bearing: assets/stocks, weather,
news_logos and broadcast_logos have zero references in this repo's code,
which makes them look deletable — but store plugins (ledmatrix-stocks,
ledmatrix-weather, news, odds-ticker) resolve those exact paths at
runtime against the install directory. The README records that evidence
so a future cleanup doesn't break installed plugins.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SXb4mKcAkVaxkeTb3YnAdr
* test: add regression guards for the bug classes fixed in this PR
Three lightweight static checks, all enrolled in CI's unit-test
allowlist along with the new web-cache test:
- test_template_targets.py: every literal render_template() target must
exist (would have caught the weather/stocks partial 500s at commit
time).
- test_widget_scripts.py: every widget JS file must be script-included
in base.html or explicitly allowlisted with a reason (would have
caught the unloaded time-picker/file-upload-single/plugin-file-manager
widgets), and allowlisted files must NOT be included (prevents the
example widget from shadowing the real color-picker).
- test_doc_links.py: relative markdown links in active docs must
resolve (docs/archive/ exempt).
Each guard was verified to fail against the pre-PR tree and pass now.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SXb4mKcAkVaxkeTb3YnAdr
* fix(deps): restore the werkzeug version floor
Commit 1ec22db removed the werkzeug>=3.1.6,<4.0.0 pin along with the
genuinely-unused packages, but this one was a version floor on Flask's
transitive dependency, not a phantom: Flask 3.1.3 itself only requires
werkzeug>=3.1.0, so dropping the pin let fresh installs resolve
3.1.0-3.1.5. Restored with a comment explaining why it exists.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SXb4mKcAkVaxkeTb3YnAdr
* fix(web): route pixel_mapper_config into display.hardware; guard time-picker registration
pixel_mapper_config was the only display.hardware key absent from both
the display_fields detection allowlist and the hardware write loop in
the settings save path. No form posts it today, but if one ever did the
key would fall through to the generic handler and land at the TOP level
of config.json — where state_reconciliation would mistake it for a
missing plugin id and loop auto-repair attempts (the failure class the
'github'/'youtube' exclusion comment documents). It now round-trips
into display.hardware like its siblings.
time-picker.js gains the same LEDMatrixWidgets-undefined guard its two
sibling widgets already have; correct today only via defer ordering.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SXb4mKcAkVaxkeTb3YnAdr
* chore: align installer leftovers with the dependency cleanup
first_time_install.sh's fallback secrets heredoc (used only when the
template is missing) still wrote the legacy youtube block — now matches
the template (github only). install_dependencies_apt.py drops the
IMPORT_NAME_MAP entries for packages no longer in its install lists and
a stale google-api reference in a docstring.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SXb4mKcAkVaxkeTb3YnAdr
* fix: address CodeRabbit review findings
Verified each finding against the code; fixes for the valid ones:
- install_dependencies_apt.py: the installer listed 'freetype', but the
declared dependency is freetype-py — an apt miss would pip-install the
wrong PyPI package. Now installs freetype-py with an import-name
mapping (pre-existing bug, surfaced by the review).
- api_v3.py: pixel_mapper_config is validated as a string before being
saved to display.hardware (JSON callers could previously store an
object/list the matrix library can't use).
- .cursorrules: the Plugin Loading Process and File Organization
sections still said discovery scans plugins/ — now consistent with the
corrected overview (configured directory, default plugin-repos/).
- README.md: removed the stale '(except the core calendar)' claim — no
core calendar exists in src/ — and qualified the plugin inventory
(official plugins in the monorepo; third-party from their own repos).
- CONFIG_REFERENCE.md: hardware_mapping now shows the code fallback
(adafruit-hat-pwm) alongside the template value.
- PLUGIN_REGISTRY_SETUP_GUIDE.md: check_plugin.py takes --plugin, not a
positional id.
- scripts/fix_perms/fix_*.sh: exec bits set so the documented
'sudo ./...' invocations work.
- Guard tests hardened: template guard now catches multi-line
render_template() calls; widget guard parses actual <script> src
values and fails if the widgets dir goes missing; type hints and
docstrings added per repo coding guidelines.
Skipped with reasons (noted on the PR): limit_refresh_rate_hz 100-vs-90
is documented as intentional in CONFIG_REFERENCE.md; the psutil comment
already names the enforcing manifest; docs/archive/ findings are out of
scope per the docs policy (archive may rot).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SXb4mKcAkVaxkeTb3YnAdr
* chore: remove Cursor IDE tooling, consolidate its guidance into CLAUDE.md
The maintainer no longer uses Cursor. .cursorrules, .cursorignore and the
.cursor/ tree (rules, plugin templates, a parallel 751-line plugins
guide) are removed; measurement showed near-zero literal overlap risk —
the canonical content already lives in docs/. Unique guidance worth
keeping moved before deletion:
- CLAUDE.md gains the dev workflow (dev_plugin_setup.sh, dev_server.py,
run.py -e, check_plugin.py), the plugin-secrets namespacing contract,
and the no-draw_image()/paste-onto-PIL pitfall.
- PLUGIN_DEVELOPMENT_GUIDE.md absorbs the plugin version-management
rules (pre-push hook install, SKIP_TAG, version resolution order) that
its own text previously linked out to .cursorrules for.
- The one completed plan doc (.cursor/plans/) is archived to
docs/archive/ per the docs policy rather than deleted.
One of the deleted rule files (sports-managers.mdc) targeted
src/*_managers.py globs that have matched nothing since the plugin
migration.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SXb4mKcAkVaxkeTb3YnAdr
* chore: second-pass cleanup — dead installer branch, broken script, orphaned JS, misfiled test deps
- first_time_install.sh: removed the pip fallback branch that installed
from requirements_web_v2.txt — a file that has not existed since the
v2 web interface was removed (the branch always printed its own
'not found; skipping' warning).
- scripts/remove_plugin_backups.sh deleted: its PROJECT_ROOT resolved to
the repo's PARENT directory, and its verify_submodules() checks for
plugin submodules from an era before plugins moved to the store — it
could never have worked from its current location.
- plugins_manager.js: removed three functions with zero call sites
anywhere (addKeyValuePair, formatCommit, togglePasswordVisibility) —
verified against all templates, all JS, and the dynamic window[name]
dispatch sites, which resolve widget-registry keys only. Also replaced
base.html's misleading 'Legacy ... during migration' label: the file
is deliberately loaded last and provides the LIVE implementations of
seven window.* plugin actions that shadow same-named definitions in
app.js/app-shell.js.
- pytest/pytest-cov/pytest-mock moved from runtime requirements.txt to
requirements-test.txt (CI already installs both files; the installer's
line-by-line loop simply installs three fewer packages on devices; no
store plugin declares pytest). HOW_TO_RUN_TESTS.md updated.
- scripts/add_defaults_to_schemas.py and analyze_plugin_schemas.py
scanned the empty legacy plugins/ dir — now scan plugin-repos/.
Verified: fresh venv installs all four requirements files with pip check
clean and pytest available; bash -n on the installer; node --check on
the JS; widget/cache guard tests green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SXb4mKcAkVaxkeTb3YnAdr
* docs: correct semantically stale content across the user and developer guides
A second-pass content audit checked the guides' substantive claims
against the code (the first pass only fixed mechanical drift). Fixes:
- GETTING_STARTED: described booting a prebuilt SD image and seeing
default clock/weather plugins — neither exists. Now documents the real
install (Pi OS Lite + one-shot installer / first_time_install.sh) and
that displays come from the Plugin Store. Duration and ordering
instructions moved to the Rotation tab where the controls actually
live.
- WEB_INTERFACE_GUIDE: three whole tabs were undocumented (Rotation,
Backup & Restore, Tools) and the Display tab's Vegas Scroll section
was unmentioned. Fonts overrides are per display element (not per
plugin); Logs has an Auto-scroll checkbox (not a Pause button); the
aspirational keyboard-shortcut list and no-JS claim removed.
- TROUBLESHOOTING: the hand-written service-file template (wrong user,
wrong ExecStart, dropped the autostart gate) replaced with the real
systemd/ units + install scripts; recovery steps no longer copy
placeholder units verbatim; WiFi curl endpoint corrected to /api/v3/;
cache-clearing advice now targets the real cache locations.
- ADVANCED_FEATURES: removed a false claim that CacheManager has no
delete(); fixed two example snippets that raise TypeError
(BackgroundDataService and get_config_file_mode signatures); fixed
cache paths, a 5-minute TTL that is actually 1 hour, and the vegas
table now links the complete 26-key reference.
- EMULATOR_SETUP_GUIDE: documented run.py flags that don't exist
(--plugin/--test-plugins) removed in favor of dev_server.py and
check_plugin.py; shipped emulator config values corrected (browser
adapter default on :8888, not pygame).
- PLUGIN_QUICK_REFERENCE: drag-and-drop reordering is shipped, not
'not yet supported'; discovery-fallback and registry-repo claims
corrected. PLUGIN_API_REFERENCE: get_vegas_segment_width returns
panels, not pixels. CONTRIBUTING: the repo uses flake8/mypy/bandit
pre-commit hooks, not black/ruff, and tests need requirements-test.txt.
- SKIN_SYSTEM/DEVELOPER_QUICK_REFERENCE: stale module paths.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SXb4mKcAkVaxkeTb3YnAdr
* fix: raise the two new dependency floors past their CVEs, silence a deliberate re-export
All three of these were introduced by this PR, which is what makes them
worth fixing here rather than deferring.
`urllib3` and `jinja2` were added to the requirements so that direct
imports stop relying on transitives — right call, but both floors were
set to the version that introduced the API rather than a version that is
safe to install. `urllib3>=1.26.0` sits below roughly ten CVEs including
a decompression-bomb safeguard bypass, and `jinja2>=3.1.0` below five
including two sandbox breakouts. Raised to 2.7.0 and 3.1.6, which is what
a working device already runs, so no install is disturbed. The comments
now say the floor is a security floor, since the next person to read
"imported directly" would otherwise reasonably lower it again.
This is the same reasoning the PR already applied to werkzeug; these two
just missed it.
The `DateTimeEncoder` import in cache_manager is unused on purpose — the
canonical class moved to src.cache.disk_cache and this re-export keeps
the documented import path working. flake8 cannot see intent, so it gets
an explicit `# noqa: F401` rather than being removed and quietly breaking
anything importing it from here. Verified the re-export still resolves to
the same object and still serialises datetimes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5
* fix: address CodeRabbit re-review — installer robustness and doc lint
- install_dependencies_apt.py: an import-only check let Debian
Bookworm's python3-freetype 2.3.0 satisfy the freetype-py>=2.5.1 pin.
check_package_installed() now verifies the installed freetype-py
version, and an apt install that lands below the minimum falls through
to pip instead of counting as success.
- first_time_install.sh: the .web_deps_installed marker was created even
when the smart installer failed, so re-runs skipped installation with
dependencies missing. The marker is now created only on success.
- CONTRIBUTING.md: document installing the pre-commit CLI before
'pre-commit install' (the requirements files don't provide it).
- Doc lint: fence language on the on-demand cache example (MD040),
blockquote continuation in GETTING_STARTED (MD028), and the
suppress_adapter_load_errors key removed from the emulator debug
example to match the options table.
Skipped one finding with reason (noted on the PR): the per-plugin
display_duration field in PLUGIN_QUICK_REFERENCE's example is not
obsolete — BasePlugin.get_display_duration() reads it and
PLUGIN_CONFIG_CORE_PROPERTIES.md documents it as a core property;
display.display_durations is a per-mode override, not a replacement.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SXb4mKcAkVaxkeTb3YnAdr
---------
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,145 +0,0 @@
|
||||
# Cursor Helper Files for LEDMatrix Plugin Development
|
||||
|
||||
This directory contains Cursor-specific helper files to assist with plugin development in the LEDMatrix project.
|
||||
|
||||
## Files Overview
|
||||
|
||||
### `.cursorrules`
|
||||
Comprehensive rules file that Cursor uses to understand plugin development patterns, best practices, and workflows. This file is automatically loaded by Cursor and helps guide AI-assisted development.
|
||||
|
||||
### `plugins_guide.md`
|
||||
Detailed guide covering:
|
||||
- Plugin system overview
|
||||
- Creating new plugins
|
||||
- Running plugins (emulator and hardware)
|
||||
- Loading and configuring plugins
|
||||
- Development workflow
|
||||
- Testing strategies
|
||||
- Troubleshooting
|
||||
|
||||
### `plugin_templates/`
|
||||
Template files for quick plugin creation:
|
||||
- `manifest.json.template` - Plugin metadata template
|
||||
- `manager.py.template` - Plugin class template
|
||||
- `config_schema.json.template` - Configuration schema template
|
||||
- `README.md.template` - Plugin documentation template
|
||||
- `requirements.txt.template` - Dependencies template
|
||||
- `QUICK_START.md` - Quick start guide for using templates
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### Creating a New Plugin
|
||||
|
||||
1. **Using templates** (recommended):
|
||||
```bash
|
||||
# See QUICK_START.md in plugin_templates/
|
||||
cd plugins
|
||||
mkdir my-plugin
|
||||
cd my-plugin
|
||||
cp ../../.cursor/plugin_templates/*.template .
|
||||
# Edit files, replacing PLUGIN_ID and other placeholders
|
||||
```
|
||||
|
||||
2. **Using dev_plugin_setup.sh**:
|
||||
```bash
|
||||
# Link from GitHub
|
||||
./scripts/dev/dev_plugin_setup.sh link-github my-plugin
|
||||
|
||||
# Link local repo
|
||||
./scripts/dev/dev_plugin_setup.sh link my-plugin /path/to/repo
|
||||
```
|
||||
|
||||
### Running the Display
|
||||
|
||||
```bash
|
||||
# Emulator mode (development, no hardware required)
|
||||
python3 run.py --emulator
|
||||
# (equivalent: EMULATOR=true python3 run.py)
|
||||
|
||||
# Hardware (production, requires the rpi-rgb-led-matrix submodule built)
|
||||
python3 run.py
|
||||
|
||||
# As a systemd service
|
||||
sudo systemctl start ledmatrix
|
||||
|
||||
# Dev preview server (renders plugins to a browser without running run.py)
|
||||
python3 scripts/dev_server.py # then open http://localhost:5001
|
||||
```
|
||||
|
||||
The `-e`/`--emulator` CLI flag is defined in `run.py:19-20` and
|
||||
sets `os.environ["EMULATOR"] = "true"` before any display imports,
|
||||
which `src/display_manager.py:2` then reads to switch between the
|
||||
hardware and emulator backends.
|
||||
|
||||
### Managing Plugins
|
||||
|
||||
```bash
|
||||
# List plugins
|
||||
./scripts/dev/dev_plugin_setup.sh list
|
||||
|
||||
# Check status
|
||||
./scripts/dev/dev_plugin_setup.sh status
|
||||
|
||||
# Update plugin(s)
|
||||
./scripts/dev/dev_plugin_setup.sh update [plugin-name]
|
||||
|
||||
# Unlink plugin
|
||||
./scripts/dev/dev_plugin_setup.sh unlink <plugin-name>
|
||||
```
|
||||
|
||||
## Using These Files with Cursor
|
||||
|
||||
### `.cursorrules`
|
||||
Cursor automatically reads this file to understand:
|
||||
- Plugin structure and requirements
|
||||
- Development workflows
|
||||
- Best practices
|
||||
- Common patterns
|
||||
- API reference
|
||||
|
||||
When asking Cursor to help with plugins, it will use this context to provide better assistance.
|
||||
|
||||
### Plugin Templates
|
||||
Use templates when creating new plugins:
|
||||
1. Copy templates from `.cursor/plugin_templates/`
|
||||
2. Replace placeholders (PLUGIN_ID, PluginClassName, etc.)
|
||||
3. Customize for your plugin's needs
|
||||
4. Follow the guide in `plugins_guide.md`
|
||||
|
||||
### Documentation
|
||||
Refer to `plugins_guide.md` for:
|
||||
- Detailed explanations
|
||||
- Troubleshooting steps
|
||||
- Best practices
|
||||
- Examples and patterns
|
||||
|
||||
## Plugin Development Workflow
|
||||
|
||||
1. **Plan**: Determine plugin functionality and requirements
|
||||
2. **Create**: Use templates or dev_plugin_setup.sh to create plugin structure
|
||||
3. **Develop**: Implement plugin logic following BasePlugin interface
|
||||
4. **Test**: Test with emulator first, then on hardware
|
||||
5. **Configure**: Add plugin config to config/config.json
|
||||
6. **Iterate**: Refine based on testing and feedback
|
||||
|
||||
## Resources
|
||||
|
||||
- **Plugin System**: `src/plugin_system/`
|
||||
- **Base Plugin**: `src/plugin_system/base_plugin.py`
|
||||
- **Plugin Manager**: `src/plugin_system/plugin_manager.py`
|
||||
- **Example Plugins**: see the
|
||||
[`ledmatrix-plugins`](https://github.com/ChuckBuilds/ledmatrix-plugins)
|
||||
repo for canonical sources (e.g. `plugins/hockey-scoreboard/`,
|
||||
`plugins/football-scoreboard/`). Installed plugins land in
|
||||
`plugin-repos/` (default) or `plugins/` (dev fallback).
|
||||
- **Architecture Docs**: `docs/PLUGIN_ARCHITECTURE_SPEC.md`
|
||||
- **Development Setup**: `scripts/dev/dev_plugin_setup.sh`
|
||||
|
||||
## Getting Help
|
||||
|
||||
1. Check `plugins_guide.md` for detailed documentation
|
||||
2. Review `.cursorrules` for development patterns
|
||||
3. Look at existing plugins for examples
|
||||
4. Check logs for error messages
|
||||
5. Review plugin system code in `src/plugin_system/`
|
||||
|
||||
@@ -1,247 +0,0 @@
|
||||
# Quick Start: Creating a New Plugin
|
||||
|
||||
This guide will help you create a new plugin using the templates in `.cursor/plugin_templates/`.
|
||||
|
||||
## Step 1: Create Plugin Directory
|
||||
|
||||
```bash
|
||||
cd /path/to/LEDMatrix
|
||||
mkdir -p plugins/my-plugin
|
||||
cd plugins/my-plugin
|
||||
```
|
||||
|
||||
## Step 2: Copy Templates
|
||||
|
||||
```bash
|
||||
# Copy all template files
|
||||
cp ../../.cursor/plugin_templates/manifest.json.template ./manifest.json
|
||||
cp ../../.cursor/plugin_templates/manager.py.template ./manager.py
|
||||
cp ../../.cursor/plugin_templates/config_schema.json.template ./config_schema.json
|
||||
cp ../../.cursor/plugin_templates/README.md.template ./README.md
|
||||
cp ../../.cursor/plugin_templates/requirements.txt.template ./requirements.txt
|
||||
```
|
||||
|
||||
## Step 3: Customize Files
|
||||
|
||||
### manifest.json
|
||||
|
||||
Replace placeholders:
|
||||
- `PLUGIN_ID` → `my-plugin` (lowercase, use hyphens)
|
||||
- `Plugin Name` → Your plugin's display name
|
||||
- `PluginClassName` → `MyPlugin` (PascalCase)
|
||||
- Update description, author, homepage, etc.
|
||||
|
||||
### manager.py
|
||||
|
||||
Replace placeholders:
|
||||
- `PluginClassName` → `MyPlugin` (must match manifest)
|
||||
- Implement `_fetch_data()` method
|
||||
- Implement `_render_content()` method
|
||||
- Add any custom validation in `validate_config()`
|
||||
|
||||
### config_schema.json
|
||||
|
||||
Customize:
|
||||
- Update description
|
||||
- Add/remove configuration properties
|
||||
- Set default values
|
||||
- Add validation rules
|
||||
|
||||
### README.md
|
||||
|
||||
Replace placeholders:
|
||||
- `PLUGIN_ID` → `my-plugin`
|
||||
- `Plugin Name` → Your plugin's name
|
||||
- Fill in features, installation, configuration sections
|
||||
|
||||
### requirements.txt
|
||||
|
||||
Add your plugin's dependencies:
|
||||
```txt
|
||||
requests>=2.28.0
|
||||
pillow>=9.0.0
|
||||
```
|
||||
|
||||
## Step 4: Enable Plugin
|
||||
|
||||
Edit `config/config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"my-plugin": {
|
||||
"enabled": true,
|
||||
"display_duration": 15
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Step 5: Test Plugin
|
||||
|
||||
### Test with Emulator
|
||||
|
||||
```bash
|
||||
cd /path/to/LEDMatrix
|
||||
python run.py --emulator
|
||||
```
|
||||
|
||||
### Check Plugin Loading
|
||||
|
||||
Look for logs like:
|
||||
```
|
||||
[INFO] Discovered 1 plugin(s)
|
||||
[INFO] Loaded plugin: my-plugin v1.0.0
|
||||
[INFO] Added plugin mode: my-plugin
|
||||
```
|
||||
|
||||
### Test Plugin Display
|
||||
|
||||
The plugin should appear in the display rotation. Check logs for any errors.
|
||||
|
||||
## Step 6: Develop and Iterate
|
||||
|
||||
1. Edit `manager.py` to implement your plugin logic
|
||||
2. Test with emulator: `python run.py --emulator`
|
||||
3. Check logs for errors
|
||||
4. Iterate until working correctly
|
||||
|
||||
## Step 7: Test on Hardware (Optional)
|
||||
|
||||
When ready, test on Raspberry Pi:
|
||||
|
||||
```bash
|
||||
# Deploy to Pi
|
||||
rsync -avz plugins/my-plugin/ pi@raspberrypi:/path/to/LEDMatrix/plugins/my-plugin/
|
||||
|
||||
# Or if using git
|
||||
ssh pi@raspberrypi "cd /path/to/LEDMatrix/plugins/my-plugin && git pull"
|
||||
|
||||
# Restart service
|
||||
ssh pi@raspberrypi "sudo systemctl restart ledmatrix"
|
||||
```
|
||||
|
||||
## Common Customizations
|
||||
|
||||
### Adding API Integration
|
||||
|
||||
1. Add API key to `config_schema.json`:
|
||||
```json
|
||||
{
|
||||
"api_key": {
|
||||
"type": "string",
|
||||
"description": "API key for service"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
2. Implement API call in `_fetch_data()`:
|
||||
```python
|
||||
import requests
|
||||
|
||||
def _fetch_data(self):
|
||||
response = requests.get(
|
||||
"https://api.example.com/data",
|
||||
headers={"Authorization": f"Bearer {self.api_key}"}
|
||||
)
|
||||
return response.json()
|
||||
```
|
||||
|
||||
3. Store API key in `config/config_secrets.json`:
|
||||
```json
|
||||
{
|
||||
"my-plugin": {
|
||||
"api_key": "your-secret-key"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Adding Image Rendering
|
||||
|
||||
There is no `draw_image()` helper on `DisplayManager`. To render an
|
||||
image, paste it directly onto the underlying PIL `Image`
|
||||
(`display_manager.image`) and then call `update_display()`:
|
||||
|
||||
```python
|
||||
def _render_content(self):
|
||||
# Load and paste image onto the display canvas
|
||||
image = Image.open("assets/logo.png").convert("RGB")
|
||||
self.display_manager.image.paste(image, (0, 0))
|
||||
|
||||
# Draw text overlay
|
||||
self.display_manager.draw_text(
|
||||
"Text",
|
||||
x=10, y=20,
|
||||
color=(255, 255, 255)
|
||||
)
|
||||
|
||||
self.display_manager.update_display()
|
||||
```
|
||||
|
||||
For transparency, paste with a mask:
|
||||
|
||||
```python
|
||||
icon = Image.open("assets/icon.png").convert("RGBA")
|
||||
self.display_manager.image.paste(icon, (5, 5), icon)
|
||||
```
|
||||
|
||||
|
||||
### Adding Live Priority
|
||||
|
||||
1. Enable in config:
|
||||
```json
|
||||
{
|
||||
"my-plugin": {
|
||||
"live_priority": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
2. Implement `has_live_content()`:
|
||||
```python
|
||||
def has_live_content(self) -> bool:
|
||||
return self.data and self.data.get("is_live", False)
|
||||
```
|
||||
|
||||
3. Override `get_live_modes()` if needed:
|
||||
```python
|
||||
def get_live_modes(self) -> list:
|
||||
return ["my_plugin_live_mode"]
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Plugin Not Loading
|
||||
|
||||
- Check `manifest.json` syntax (must be valid JSON)
|
||||
- Verify `entry_point` file exists
|
||||
- Ensure `class_name` matches class name in manager.py
|
||||
- Check for import errors in logs
|
||||
|
||||
### Configuration Errors
|
||||
|
||||
- Validate config against `config_schema.json`
|
||||
- Check required fields are present
|
||||
- Verify data types match schema
|
||||
|
||||
### Display Issues
|
||||
|
||||
- Check display dimensions: `display_manager.width`, `display_manager.height`
|
||||
- Verify coordinates are within bounds
|
||||
- Ensure `update_display()` is called
|
||||
- Test with emulator first
|
||||
|
||||
## Next Steps
|
||||
|
||||
- Review existing plugins for patterns:
|
||||
- `plugins/hockey-scoreboard/` - Sports scoreboard example
|
||||
- `plugins/ledmatrix-music/` - Real-time data example
|
||||
- `plugins/ledmatrix-stocks/` - Data display example
|
||||
|
||||
- Read full documentation:
|
||||
- `.cursor/plugins_guide.md` - Comprehensive guide
|
||||
- `docs/PLUGIN_ARCHITECTURE_SPEC.md` - Architecture details
|
||||
- `.cursorrules` - Development rules
|
||||
|
||||
- Check plugin system code:
|
||||
- `src/plugin_system/base_plugin.py` - Base class
|
||||
- `src/plugin_system/plugin_manager.py` - Plugin manager
|
||||
|
||||
@@ -1,156 +0,0 @@
|
||||
# Plugin Name
|
||||
|
||||
Brief description of what this plugin does.
|
||||
|
||||
## Features
|
||||
|
||||
- Feature 1
|
||||
- Feature 2
|
||||
- Feature 3
|
||||
|
||||
## Installation
|
||||
|
||||
1. Link the plugin to your LEDMatrix installation:
|
||||
|
||||
```bash
|
||||
cd /path/to/LEDMatrix
|
||||
./scripts/dev/dev_plugin_setup.sh link-github PLUGIN_ID
|
||||
```
|
||||
|
||||
Or for local development:
|
||||
|
||||
```bash
|
||||
./scripts/dev/dev_plugin_setup.sh link PLUGIN_ID /path/to/plugin/repo
|
||||
```
|
||||
|
||||
2. Install dependencies:
|
||||
|
||||
```bash
|
||||
pip install -r plugins/PLUGIN_ID/requirements.txt
|
||||
```
|
||||
|
||||
3. Configure the plugin in `config/config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"PLUGIN_ID": {
|
||||
"enabled": true,
|
||||
"display_duration": 15
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Note:** API keys and other sensitive credentials must be stored in `config/config_secrets.json`, not in `config/config.json`.
|
||||
|
||||
4. Store API keys in `config/config_secrets.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"PLUGIN_ID": {
|
||||
"api_key": "your-secret-api-key"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Required Settings
|
||||
|
||||
- `enabled` (boolean): Enable or disable the plugin
|
||||
- `api_key` (string): API key for external service (if required)
|
||||
|
||||
### Optional Settings
|
||||
|
||||
- `display_duration` (number): How long to display this plugin (default: 15 seconds)
|
||||
- `refresh_interval` (integer): How often to refresh data in seconds (default: 60)
|
||||
- `live_priority` (boolean): Enable live priority takeover (default: false)
|
||||
|
||||
## Display Modes
|
||||
|
||||
This plugin provides the following display modes:
|
||||
|
||||
- `PLUGIN_ID`: Main display mode
|
||||
|
||||
## API Requirements
|
||||
|
||||
This plugin requires:
|
||||
|
||||
- **API Name**: Description of API requirements
|
||||
- URL: https://api.example.com
|
||||
- Rate Limit: X requests per minute
|
||||
- Authentication: API key required
|
||||
|
||||
## Development
|
||||
|
||||
### Running Tests
|
||||
|
||||
```bash
|
||||
cd plugins/PLUGIN_ID
|
||||
python test_PLUGIN_ID.py
|
||||
```
|
||||
|
||||
### Testing with Emulator
|
||||
|
||||
```bash
|
||||
cd /path/to/LEDMatrix
|
||||
python run.py --emulator
|
||||
```
|
||||
|
||||
### Debugging
|
||||
|
||||
Enable debug logging in `config/config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"logging": {
|
||||
"level": "DEBUG"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Check logs:
|
||||
|
||||
```bash
|
||||
# On Raspberry Pi (if running as service)
|
||||
journalctl -u ledmatrix -f
|
||||
|
||||
# Direct execution
|
||||
python run.py
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Plugin Not Loading
|
||||
|
||||
1. Check that `manifest.json` exists and is valid
|
||||
2. Verify `entry_point` file exists
|
||||
3. Check that `class_name` matches the class in manager.py
|
||||
4. Review logs for import errors
|
||||
|
||||
### Configuration Errors
|
||||
|
||||
1. Validate config against `config_schema.json`
|
||||
2. Check required fields are present
|
||||
3. Verify data types match schema
|
||||
|
||||
### API Errors
|
||||
|
||||
1. Verify API key is correct
|
||||
2. Check API rate limits
|
||||
3. Review network connectivity
|
||||
4. Check API service status
|
||||
|
||||
## License
|
||||
|
||||
[License information]
|
||||
|
||||
## Author
|
||||
|
||||
Your Name
|
||||
|
||||
## Links
|
||||
|
||||
- GitHub: https://github.com/username/ledmatrix-PLUGIN_ID
|
||||
- Documentation: [Link to docs]
|
||||
- Issues: https://github.com/username/ledmatrix-PLUGIN_ID/issues
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"type": "object",
|
||||
"title": "Plugin Configuration Schema",
|
||||
"description": "Configuration schema for Plugin Name",
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Enable or disable this plugin"
|
||||
},
|
||||
"display_duration": {
|
||||
"type": "number",
|
||||
"default": 15,
|
||||
"minimum": 1,
|
||||
"maximum": 300,
|
||||
"description": "How long to display this plugin in seconds"
|
||||
},
|
||||
"live_priority": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Enable live priority takeover when plugin has live content"
|
||||
},
|
||||
"refresh_interval": {
|
||||
"type": "integer",
|
||||
"default": 60,
|
||||
"minimum": 1,
|
||||
"description": "How often to refresh data in seconds"
|
||||
},
|
||||
"api_key": {
|
||||
"type": "string",
|
||||
"description": "API key for external service (store in config_secrets.json)",
|
||||
"default": ""
|
||||
},
|
||||
"custom_setting": {
|
||||
"type": "string",
|
||||
"description": "Example custom setting - replace with your plugin's settings",
|
||||
"default": "default_value"
|
||||
}
|
||||
},
|
||||
"required": ["enabled"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
|
||||
@@ -1,226 +0,0 @@
|
||||
"""
|
||||
Plugin Name
|
||||
|
||||
Brief description of what this plugin does.
|
||||
|
||||
API Version: 1.0.0
|
||||
"""
|
||||
|
||||
from src.plugin_system.base_plugin import BasePlugin
|
||||
from PIL import Image
|
||||
from typing import Dict, Any, Optional
|
||||
import logging
|
||||
import time
|
||||
|
||||
|
||||
class PluginClassName(BasePlugin):
|
||||
"""
|
||||
Plugin class that inherits from BasePlugin.
|
||||
|
||||
This plugin demonstrates the basic structure and common patterns
|
||||
for LEDMatrix plugins.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
plugin_id: str,
|
||||
config: Dict[str, Any],
|
||||
display_manager,
|
||||
cache_manager,
|
||||
plugin_manager,
|
||||
):
|
||||
"""Initialize the plugin."""
|
||||
super().__init__(plugin_id, config, display_manager, cache_manager, plugin_manager)
|
||||
|
||||
# Initialize plugin-specific data
|
||||
self.data = None
|
||||
self.last_update_time = None
|
||||
|
||||
# Load configuration values
|
||||
self.api_key = config.get("api_key", "")
|
||||
self.refresh_interval = config.get("refresh_interval", 60)
|
||||
|
||||
self.logger.info(f"Plugin {plugin_id} initialized")
|
||||
|
||||
def update(self) -> None:
|
||||
"""
|
||||
Fetch/update data for this plugin.
|
||||
|
||||
This method is called periodically based on update_interval
|
||||
specified in the manifest. Use cache_manager to avoid
|
||||
excessive API calls.
|
||||
"""
|
||||
cache_key = f"{self.plugin_id}_data"
|
||||
|
||||
# Check cache first
|
||||
cached = self.cache_manager.get(cache_key, max_age=self.refresh_interval)
|
||||
if cached:
|
||||
self.data = cached
|
||||
self.logger.debug("Using cached data")
|
||||
return
|
||||
|
||||
try:
|
||||
# Fetch new data
|
||||
self.data = self._fetch_data()
|
||||
|
||||
# Cache the data
|
||||
self.cache_manager.set(cache_key, self.data, ttl=self.refresh_interval)
|
||||
self.last_update_time = time.time()
|
||||
|
||||
self.logger.info("Data updated successfully")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to update data: {e}")
|
||||
# Use cached data if available, even if expired
|
||||
# Use a very large max_age (1 year) to effectively bypass expiration for fallback
|
||||
expired_cached = self.cache_manager.get(cache_key, max_age=31536000)
|
||||
if expired_cached:
|
||||
self.data = expired_cached
|
||||
self.logger.warning("Using expired cache due to update failure")
|
||||
|
||||
def display(self, force_clear: bool = False) -> None:
|
||||
"""
|
||||
Render this plugin's display.
|
||||
|
||||
Args:
|
||||
force_clear: If True, clear display before rendering
|
||||
"""
|
||||
if force_clear:
|
||||
self.display_manager.clear()
|
||||
|
||||
# Check if we have data to display
|
||||
if not self.data:
|
||||
self._display_error("No data available")
|
||||
return
|
||||
|
||||
try:
|
||||
# Render plugin content
|
||||
self._render_content()
|
||||
|
||||
# Update the display
|
||||
self.display_manager.update_display()
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Display error: {e}")
|
||||
self._display_error("Display error")
|
||||
|
||||
def _fetch_data(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Fetch data from external source.
|
||||
|
||||
Returns:
|
||||
Dictionary containing fetched data
|
||||
"""
|
||||
# TODO: Implement data fetching logic
|
||||
# Example:
|
||||
# import requests
|
||||
# response = requests.get("https://api.example.com/data",
|
||||
# headers={"Authorization": f"Bearer {self.api_key}"})
|
||||
# return response.json()
|
||||
|
||||
# Placeholder
|
||||
return {
|
||||
"message": "Hello, World!",
|
||||
"timestamp": time.time()
|
||||
}
|
||||
|
||||
def _render_content(self) -> None:
|
||||
"""Render the plugin content on the display."""
|
||||
# Get display dimensions
|
||||
width = self.display_manager.width
|
||||
height = self.display_manager.height
|
||||
|
||||
# Example: Draw text
|
||||
text = self.data.get("message", "No data")
|
||||
x = 5
|
||||
y = height // 2
|
||||
|
||||
self.display_manager.draw_text(
|
||||
text,
|
||||
x=x,
|
||||
y=y,
|
||||
color=(255, 255, 255) # White
|
||||
)
|
||||
|
||||
# Example: Draw image
|
||||
# if hasattr(self, 'logo_image'):
|
||||
# self.display_manager.draw_image(
|
||||
# self.logo_image,
|
||||
# x=0,
|
||||
# y=0
|
||||
# )
|
||||
|
||||
def _display_error(self, message: str) -> None:
|
||||
"""Display an error message."""
|
||||
self.display_manager.clear()
|
||||
width = self.display_manager.width
|
||||
height = self.display_manager.height
|
||||
|
||||
self.display_manager.draw_text(
|
||||
message,
|
||||
x=5,
|
||||
y=height // 2,
|
||||
color=(255, 0, 0) # Red
|
||||
)
|
||||
self.display_manager.update_display()
|
||||
|
||||
def validate_config(self) -> bool:
|
||||
"""
|
||||
Validate plugin configuration.
|
||||
|
||||
Returns:
|
||||
True if config is valid, False otherwise
|
||||
"""
|
||||
# Call parent validation first
|
||||
if not super().validate_config():
|
||||
return False
|
||||
|
||||
# Add custom validation
|
||||
# Example: Check for required API key
|
||||
# if self.config.get("require_api_key", True):
|
||||
# if not self.api_key:
|
||||
# self.logger.error("API key is required but not provided")
|
||||
# return False
|
||||
|
||||
return True
|
||||
|
||||
def has_live_content(self) -> bool:
|
||||
"""
|
||||
Check if plugin has live content to display.
|
||||
|
||||
Override this method to enable live priority features.
|
||||
|
||||
Returns:
|
||||
True if plugin has live content, False otherwise
|
||||
"""
|
||||
# Example: Check if there's live data
|
||||
# return self.data and self.data.get("is_live", False)
|
||||
return False
|
||||
|
||||
def get_info(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Return plugin info for display in web UI.
|
||||
|
||||
Returns:
|
||||
Dictionary with plugin information
|
||||
"""
|
||||
info = super().get_info()
|
||||
|
||||
# Add plugin-specific info
|
||||
info.update({
|
||||
"data_available": self.data is not None,
|
||||
"last_update": self.last_update_time,
|
||||
# Add more info as needed
|
||||
})
|
||||
|
||||
return info
|
||||
|
||||
def cleanup(self) -> None:
|
||||
"""Cleanup resources when plugin is unloaded."""
|
||||
# Clean up any resources (threads, connections, etc.)
|
||||
# Example:
|
||||
# if hasattr(self, 'api_client'):
|
||||
# self.api_client.close()
|
||||
|
||||
super().cleanup()
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
{
|
||||
"id": "PLUGIN_ID",
|
||||
"name": "Plugin Name",
|
||||
"version": "1.0.0",
|
||||
"author": "Your Name",
|
||||
"description": "Brief description of what this plugin does",
|
||||
"homepage": "https://github.com/username/ledmatrix-PLUGIN_ID",
|
||||
"entry_point": "manager.py",
|
||||
"class_name": "PluginClassName",
|
||||
"category": "custom",
|
||||
"tags": ["custom", "example"],
|
||||
"icon": "fas fa-icon-name",
|
||||
"compatible_versions": [">=2.0.0"],
|
||||
"min_ledmatrix_version": "2.0.0",
|
||||
"max_ledmatrix_version": "3.0.0",
|
||||
"requires": {
|
||||
"python": ">=3.9",
|
||||
"display_size": {
|
||||
"min_width": 64,
|
||||
"min_height": 32
|
||||
}
|
||||
},
|
||||
"config_schema": "config_schema.json",
|
||||
"assets": {
|
||||
"logos": "Optional: Description of asset requirements"
|
||||
},
|
||||
"update_interval": 60,
|
||||
"default_duration": 15,
|
||||
"display_modes": [
|
||||
"PLUGIN_ID"
|
||||
],
|
||||
"api_requirements": [
|
||||
{
|
||||
"name": "API Name",
|
||||
"required": false,
|
||||
"description": "Description of API requirements",
|
||||
"url": "https://api.example.com",
|
||||
"rate_limit": "Rate limit information"
|
||||
}
|
||||
],
|
||||
"download_url_template": "https://github.com/username/ledmatrix-PLUGIN_ID/archive/refs/tags/v{version}.zip",
|
||||
"versions": [
|
||||
{
|
||||
"released": "2025-01-01",
|
||||
"version": "1.0.0",
|
||||
"ledmatrix_min_version": "2.0.0"
|
||||
}
|
||||
],
|
||||
"last_updated": "2025-01-01",
|
||||
"stars": 0,
|
||||
"downloads": 0,
|
||||
"verified": false,
|
||||
"screenshot": ""
|
||||
}
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
# Plugin Dependencies
|
||||
# Add your plugin's Python dependencies here
|
||||
|
||||
# Example dependencies (uncomment and modify as needed):
|
||||
# requests>=2.28.0
|
||||
# pillow>=9.0.0
|
||||
# python-dateutil>=2.8.0
|
||||
|
||||
# Note: Core LEDMatrix dependencies are already available:
|
||||
# - PIL/Pillow (for image handling)
|
||||
# - Core plugin system classes
|
||||
# - Display manager, cache manager, config manager
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
"""
|
||||
Test file for Plugin Name plugin.
|
||||
|
||||
This file provides example unit tests for your plugin.
|
||||
Run tests with: python -m pytest test_manager.py
|
||||
Or: python test_manager.py
|
||||
"""
|
||||
|
||||
import unittest
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Add project root to path
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from src.plugin_system.testing import PluginTestCase
|
||||
from manager import PluginClassName
|
||||
|
||||
|
||||
class TestPluginClassName(PluginTestCase):
|
||||
"""Test cases for PluginClassName plugin."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test fixtures."""
|
||||
super().setUp()
|
||||
|
||||
# Update plugin_id to match the plugin being tested
|
||||
self.plugin_id = 'PLUGIN_ID'
|
||||
|
||||
# Create plugin instance
|
||||
self.plugin = self.create_plugin_instance(
|
||||
PluginClassName,
|
||||
plugin_id='PLUGIN_ID',
|
||||
config=self.get_mock_config()
|
||||
)
|
||||
|
||||
def test_plugin_initialization(self):
|
||||
"""Test that plugin initializes correctly."""
|
||||
self.assert_plugin_initialized(self.plugin)
|
||||
self.assertTrue(self.plugin.enabled)
|
||||
|
||||
def test_config_validation(self):
|
||||
"""Test configuration validation."""
|
||||
# Valid config should pass
|
||||
self.assertTrue(self.plugin.validate_config())
|
||||
|
||||
# Test with invalid config if applicable
|
||||
# invalid_config = self.get_mock_config(enabled='not-a-boolean')
|
||||
# invalid_plugin = self.create_plugin_instance(
|
||||
# PluginClassName,
|
||||
# config=invalid_config
|
||||
# )
|
||||
# self.assertFalse(invalid_plugin.validate_config())
|
||||
|
||||
def test_update_method(self):
|
||||
"""Test the update() method."""
|
||||
# Reset mocks
|
||||
self.cache_manager.reset()
|
||||
|
||||
# Call update
|
||||
self.plugin.update()
|
||||
|
||||
# Assertions
|
||||
# Example: Check that cache was used
|
||||
# self.assert_cache_get('PLUGIN_ID_data')
|
||||
|
||||
# Example: Check that data was fetched and cached
|
||||
# self.assert_cache_set('PLUGIN_ID_data')
|
||||
|
||||
def test_display_method(self):
|
||||
"""Test the display() method."""
|
||||
# Ensure plugin has data (call update first if needed)
|
||||
# self.plugin.update()
|
||||
|
||||
# Call display
|
||||
self.plugin.display(force_clear=True)
|
||||
|
||||
# Assertions
|
||||
self.assert_display_cleared()
|
||||
self.assert_display_updated()
|
||||
|
||||
# Example: Check that text was drawn
|
||||
# self.assert_text_drawn("Expected Text")
|
||||
|
||||
# Example: Check that image was drawn
|
||||
# self.assert_image_drawn()
|
||||
|
||||
def test_display_without_data(self):
|
||||
"""Test display() behavior when no data is available."""
|
||||
# Clear any cached data
|
||||
self.cache_manager.reset()
|
||||
|
||||
# Call display
|
||||
self.plugin.display()
|
||||
|
||||
# Should handle gracefully (no exceptions)
|
||||
# May show error message or fallback content
|
||||
self.assert_display_updated()
|
||||
|
||||
def test_get_display_duration(self):
|
||||
"""Test display duration configuration."""
|
||||
duration = self.plugin.get_display_duration()
|
||||
self.assertIsInstance(duration, (int, float))
|
||||
self.assertGreater(duration, 0)
|
||||
|
||||
# Test with custom duration
|
||||
custom_config = self.get_mock_config(display_duration=30.0)
|
||||
custom_plugin = self.create_plugin_instance(
|
||||
PluginClassName,
|
||||
config=custom_config
|
||||
)
|
||||
self.assertEqual(custom_plugin.get_display_duration(), 30.0)
|
||||
|
||||
def test_enable_disable(self):
|
||||
"""Test plugin enable/disable functionality."""
|
||||
self.assertTrue(self.plugin.enabled)
|
||||
|
||||
self.plugin.on_disable()
|
||||
self.assertFalse(self.plugin.enabled)
|
||||
|
||||
self.plugin.on_enable()
|
||||
self.assertTrue(self.plugin.enabled)
|
||||
|
||||
def test_config_change(self):
|
||||
"""Test configuration change handling."""
|
||||
new_config = self.get_mock_config(display_duration=20.0)
|
||||
self.plugin.on_config_change(new_config)
|
||||
|
||||
self.assertEqual(self.plugin.config.get('display_duration'), 20.0)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
||||
@@ -1,751 +0,0 @@
|
||||
# LEDMatrix Plugin Development Guide
|
||||
|
||||
This guide provides comprehensive instructions for creating, running, and loading plugins in the LEDMatrix project.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Plugin System Overview](#plugin-system-overview)
|
||||
2. [Creating a New Plugin](#creating-a-new-plugin)
|
||||
3. [Running Plugins](#running-plugins)
|
||||
4. [Loading Plugins](#loading-plugins)
|
||||
5. [Plugin Development Workflow](#plugin-development-workflow)
|
||||
6. [Testing Plugins](#testing-plugins)
|
||||
7. [Troubleshooting](#troubleshooting)
|
||||
|
||||
---
|
||||
|
||||
## Plugin System Overview
|
||||
|
||||
The LEDMatrix project uses a plugin-based architecture where all display functionality (except core calendar) is implemented as plugins. Plugins are dynamically loaded from the `plugins/` directory and integrated into the display rotation.
|
||||
|
||||
### Plugin Architecture
|
||||
|
||||
```
|
||||
LEDMatrix Core
|
||||
├── Plugin Manager (discovers, loads, manages plugins)
|
||||
├── Display Manager (handles LED matrix rendering)
|
||||
├── Cache Manager (data persistence)
|
||||
├── Config Manager (configuration management)
|
||||
└── Plugins/ (plugin directory)
|
||||
├── plugin-1/
|
||||
├── plugin-2/
|
||||
└── ...
|
||||
```
|
||||
|
||||
### Plugin Lifecycle
|
||||
|
||||
1. **Discovery**: PluginManager scans `plugins/` for directories with `manifest.json`
|
||||
2. **Loading**: Plugin module is imported and class is instantiated
|
||||
3. **Configuration**: Plugin config is loaded from `config/config.json`
|
||||
4. **Validation**: `validate_config()` is called to verify configuration
|
||||
5. **Registration**: Plugin is added to available display modes
|
||||
6. **Execution**: `update()` is called periodically, `display()` is called during rotation
|
||||
|
||||
---
|
||||
|
||||
## Creating a New Plugin
|
||||
|
||||
### Method 1: Using dev_plugin_setup.sh (Recommended)
|
||||
|
||||
This method is best for plugins stored in separate Git repositories.
|
||||
|
||||
#### From GitHub Repository
|
||||
|
||||
```bash
|
||||
# Link a plugin from GitHub (auto-detects URL)
|
||||
./scripts/dev/dev_plugin_setup.sh link-github <plugin-name>
|
||||
|
||||
# Example: Link hockey-scoreboard plugin
|
||||
./scripts/dev/dev_plugin_setup.sh link-github hockey-scoreboard
|
||||
|
||||
# With custom URL
|
||||
./scripts/dev/dev_plugin_setup.sh link-github <plugin-name> https://github.com/user/repo.git
|
||||
```
|
||||
|
||||
The script will:
|
||||
- Clone the repository to `~/.ledmatrix-dev-plugins/` (or configured directory)
|
||||
- Create a symlink in `plugins/<plugin-name>/` pointing to the cloned repo
|
||||
- Validate the plugin structure
|
||||
|
||||
#### From Local Repository
|
||||
|
||||
```bash
|
||||
# Link a local plugin repository
|
||||
./scripts/dev/dev_plugin_setup.sh link <plugin-name> <path-to-repo>
|
||||
|
||||
# Example: Link a local plugin
|
||||
./scripts/dev/dev_plugin_setup.sh link my-plugin ../ledmatrix-my-plugin
|
||||
```
|
||||
|
||||
### Method 2: Manual Plugin Creation
|
||||
|
||||
1. **Create Plugin Directory**
|
||||
|
||||
```bash
|
||||
mkdir -p plugins/my-plugin
|
||||
cd plugins/my-plugin
|
||||
```
|
||||
|
||||
2. **Create manifest.json**
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "my-plugin",
|
||||
"name": "My Plugin",
|
||||
"version": "1.0.0",
|
||||
"author": "Your Name",
|
||||
"description": "Description of what this plugin does",
|
||||
"entry_point": "manager.py",
|
||||
"class_name": "MyPlugin",
|
||||
"category": "custom",
|
||||
"tags": ["custom", "example"],
|
||||
"display_modes": ["my_plugin"],
|
||||
"update_interval": 60,
|
||||
"default_duration": 15,
|
||||
"requires": {
|
||||
"python": ">=3.9"
|
||||
},
|
||||
"config_schema": "config_schema.json"
|
||||
}
|
||||
```
|
||||
|
||||
3. **Create manager.py**
|
||||
|
||||
```python
|
||||
from src.plugin_system.base_plugin import BasePlugin
|
||||
from PIL import Image
|
||||
import logging
|
||||
|
||||
class MyPlugin(BasePlugin):
|
||||
"""My custom plugin implementation."""
|
||||
|
||||
def update(self):
|
||||
"""Fetch/update data for this plugin."""
|
||||
# Fetch data from API, files, etc.
|
||||
# Use self.cache_manager for caching
|
||||
cache_key = f"{self.plugin_id}_data"
|
||||
cached = self.cache_manager.get(cache_key, max_age=3600)
|
||||
if cached:
|
||||
self.data = cached
|
||||
return
|
||||
|
||||
# Fetch new data
|
||||
self.data = self._fetch_data()
|
||||
self.cache_manager.set(cache_key, self.data)
|
||||
|
||||
def display(self, force_clear=False):
|
||||
"""Render this plugin's display."""
|
||||
if force_clear:
|
||||
self.display_manager.clear()
|
||||
|
||||
# Render content using display_manager
|
||||
self.display_manager.draw_text(
|
||||
"Hello, World!",
|
||||
x=10, y=15,
|
||||
color=(255, 255, 255)
|
||||
)
|
||||
|
||||
self.display_manager.update_display()
|
||||
|
||||
def _fetch_data(self):
|
||||
"""Fetch data from external source."""
|
||||
# Implement your data fetching logic
|
||||
return {"message": "Hello, World!"}
|
||||
|
||||
def validate_config(self):
|
||||
"""Validate plugin configuration."""
|
||||
# Check required config fields
|
||||
if not super().validate_config():
|
||||
return False
|
||||
|
||||
# Add custom validation
|
||||
required_fields = ['api_key'] # Example
|
||||
for field in required_fields:
|
||||
if field not in self.config:
|
||||
self.logger.error(f"Missing required field: {field}")
|
||||
return False
|
||||
|
||||
return True
|
||||
```
|
||||
|
||||
4. **Create config_schema.json**
|
||||
|
||||
```json
|
||||
{
|
||||
"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 this plugin (seconds)"
|
||||
},
|
||||
"api_key": {
|
||||
"type": "string",
|
||||
"description": "API key for external service"
|
||||
}
|
||||
},
|
||||
"required": ["enabled"]
|
||||
}
|
||||
```
|
||||
|
||||
5. **Create requirements.txt** (if needed)
|
||||
|
||||
```
|
||||
requests>=2.28.0
|
||||
pillow>=9.0.0
|
||||
```
|
||||
|
||||
6. **Create README.md**
|
||||
|
||||
Document your plugin's functionality, configuration options, and usage.
|
||||
|
||||
---
|
||||
|
||||
## Running Plugins
|
||||
|
||||
### Development Mode (Emulator)
|
||||
|
||||
Run the LEDMatrix system with emulator for plugin testing:
|
||||
|
||||
```bash
|
||||
# Using run.py
|
||||
python run.py --emulator
|
||||
|
||||
# Using emulator script
|
||||
./run_emulator.sh
|
||||
```
|
||||
|
||||
The emulator will:
|
||||
- Load all enabled plugins
|
||||
- Display plugin content in a window (simulating LED matrix)
|
||||
- Show logs for plugin loading and execution
|
||||
- Allow testing without Raspberry Pi hardware
|
||||
|
||||
### Production Mode (Raspberry Pi)
|
||||
|
||||
Run on actual Raspberry Pi hardware:
|
||||
|
||||
```bash
|
||||
# Direct execution
|
||||
python run.py
|
||||
|
||||
# As systemd service
|
||||
sudo systemctl start ledmatrix
|
||||
sudo systemctl status ledmatrix
|
||||
sudo journalctl -u ledmatrix -f # View logs
|
||||
```
|
||||
|
||||
### Plugin-Specific Testing
|
||||
|
||||
Test individual plugin loading:
|
||||
|
||||
```python
|
||||
# test_my_plugin.py
|
||||
from src.plugin_system.plugin_manager import PluginManager
|
||||
from src.config_manager import ConfigManager
|
||||
from src.display_manager import DisplayManager
|
||||
from src.cache_manager import CacheManager
|
||||
|
||||
# Initialize managers
|
||||
config_manager = ConfigManager()
|
||||
config = config_manager.load_config()
|
||||
display_manager = DisplayManager(config)
|
||||
cache_manager = CacheManager()
|
||||
|
||||
# Initialize plugin manager
|
||||
plugin_manager = PluginManager(
|
||||
plugins_dir="plugins",
|
||||
config_manager=config_manager,
|
||||
display_manager=display_manager,
|
||||
cache_manager=cache_manager
|
||||
)
|
||||
|
||||
# Discover and load plugin
|
||||
plugins = plugin_manager.discover_plugins()
|
||||
print(f"Discovered plugins: {plugins}")
|
||||
|
||||
if "my-plugin" in plugins:
|
||||
if plugin_manager.load_plugin("my-plugin"):
|
||||
plugin = plugin_manager.get_plugin("my-plugin")
|
||||
plugin.update()
|
||||
plugin.display()
|
||||
print("Plugin loaded and displayed successfully!")
|
||||
else:
|
||||
print("Failed to load plugin")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Loading Plugins
|
||||
|
||||
### Enabling Plugins
|
||||
|
||||
Plugins are enabled/disabled in `config/config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"my-plugin": {
|
||||
"enabled": true,
|
||||
"display_duration": 15,
|
||||
"api_key": "your-api-key-here"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Plugin Configuration Structure
|
||||
|
||||
Each plugin has its own section in `config/config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"<plugin-id>": {
|
||||
"enabled": true, // Enable/disable plugin
|
||||
"display_duration": 15, // Display duration in seconds
|
||||
"live_priority": false, // Enable live priority takeover
|
||||
"high_performance_transitions": false, // Use 120 FPS transitions
|
||||
"transition": { // Transition configuration
|
||||
"type": "redraw", // Transition type
|
||||
"speed": 2, // Transition speed
|
||||
"enabled": true // Enable transitions
|
||||
},
|
||||
// ... plugin-specific configuration
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Secrets Management
|
||||
|
||||
Store sensitive data (API keys, tokens) in `config/config_secrets.json`
|
||||
under the same plugin id you use in `config/config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"my-plugin": {
|
||||
"api_key": "secret-api-key-here"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
At load time, the config manager deep-merges `config_secrets.json` into
|
||||
the main config (verified at `src/config_manager.py:162-172`). So in
|
||||
your plugin's code:
|
||||
|
||||
```python
|
||||
class MyPlugin(BasePlugin):
|
||||
def __init__(self, plugin_id, config, display_manager, cache_manager, plugin_manager):
|
||||
super().__init__(plugin_id, config, display_manager, cache_manager, plugin_manager)
|
||||
self.api_key = config.get("api_key") # already merged from secrets
|
||||
```
|
||||
|
||||
There is no separate `config_secrets` reference field — just put the
|
||||
secret value under the same plugin namespace and read it from the
|
||||
merged config.
|
||||
|
||||
### Plugin Discovery
|
||||
|
||||
Plugins are automatically discovered when:
|
||||
- Directory exists in `plugins/`
|
||||
- Directory contains `manifest.json`
|
||||
- Manifest has required fields (`id`, `entry_point`, `class_name`)
|
||||
|
||||
Check discovered plugins:
|
||||
|
||||
```bash
|
||||
# Using dev_plugin_setup.sh
|
||||
./scripts/dev/dev_plugin_setup.sh list
|
||||
|
||||
# Output shows:
|
||||
# ✓ plugin-name (symlink)
|
||||
# → /path/to/repo
|
||||
# ✓ Git repo is clean (branch: main)
|
||||
```
|
||||
|
||||
### Plugin Status
|
||||
|
||||
Check plugin status and git information:
|
||||
|
||||
```bash
|
||||
./scripts/dev/dev_plugin_setup.sh status
|
||||
|
||||
# Output shows:
|
||||
# ✓ plugin-name
|
||||
# Path: /path/to/repo
|
||||
# Branch: main
|
||||
# Remote: https://github.com/user/repo.git
|
||||
# Status: Clean and up to date
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Plugin Development Workflow
|
||||
|
||||
### 1. Initial Setup
|
||||
|
||||
```bash
|
||||
# Create or clone plugin repository
|
||||
git clone https://github.com/user/ledmatrix-my-plugin.git
|
||||
cd ledmatrix-my-plugin
|
||||
|
||||
# Link to LEDMatrix project
|
||||
cd /path/to/LEDMatrix
|
||||
./scripts/dev/dev_plugin_setup.sh link my-plugin ../ledmatrix-my-plugin
|
||||
```
|
||||
|
||||
### 2. Development Cycle
|
||||
|
||||
1. **Edit plugin code** in linked repository
|
||||
2. **Test with the dev preview server**:
|
||||
`python3 scripts/dev_server.py` (then open `http://localhost:5001`).
|
||||
Or run the full display in emulator mode with
|
||||
`python3 run.py --emulator` (or equivalently
|
||||
`EMULATOR=true python3 run.py`). The `-e`/`--emulator` CLI flag is
|
||||
defined in `run.py:19-20` and sets the same `EMULATOR` environment
|
||||
variable internally.
|
||||
3. **Check logs** for errors or warnings
|
||||
4. **Update configuration** in `config/config.json` if needed
|
||||
5. **Iterate** until plugin works correctly
|
||||
|
||||
### 3. Testing on Hardware
|
||||
|
||||
```bash
|
||||
# Deploy to Raspberry Pi
|
||||
rsync -avz plugins/my-plugin/ ledpi@your-pi-ip:/path/to/LEDMatrix/plugins/my-plugin/
|
||||
|
||||
# Or if using git, pull on Pi
|
||||
ssh ledpi@your-pi-ip "cd /path/to/LEDMatrix/plugins/my-plugin && git pull"
|
||||
|
||||
# Restart service
|
||||
ssh ledpi@your-pi-ip "sudo systemctl restart ledmatrix"
|
||||
```
|
||||
|
||||
### 4. Updating Plugins
|
||||
|
||||
```bash
|
||||
# Update single plugin from git
|
||||
./scripts/dev/dev_plugin_setup.sh update my-plugin
|
||||
|
||||
# Update all linked plugins
|
||||
./scripts/dev/dev_plugin_setup.sh update
|
||||
```
|
||||
|
||||
### 5. Unlinking Plugins
|
||||
|
||||
```bash
|
||||
# Remove symlink (preserves repository)
|
||||
./scripts/dev/dev_plugin_setup.sh unlink my-plugin
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing Plugins
|
||||
|
||||
### Unit Testing
|
||||
|
||||
Create test files in plugin directory:
|
||||
|
||||
```python
|
||||
# plugins/my-plugin/test_my_plugin.py
|
||||
import unittest
|
||||
from unittest.mock import Mock, MagicMock
|
||||
from manager import MyPlugin
|
||||
|
||||
class TestMyPlugin(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.config = {"enabled": True}
|
||||
self.display_manager = Mock()
|
||||
self.cache_manager = Mock()
|
||||
self.plugin_manager = Mock()
|
||||
|
||||
self.plugin = MyPlugin(
|
||||
plugin_id="my-plugin",
|
||||
config=self.config,
|
||||
display_manager=self.display_manager,
|
||||
cache_manager=self.cache_manager,
|
||||
plugin_manager=self.plugin_manager
|
||||
)
|
||||
|
||||
def test_plugin_initialization(self):
|
||||
self.assertEqual(self.plugin.plugin_id, "my-plugin")
|
||||
self.assertTrue(self.plugin.enabled)
|
||||
|
||||
def test_config_validation(self):
|
||||
self.assertTrue(self.plugin.validate_config())
|
||||
|
||||
def test_update(self):
|
||||
self.cache_manager.get.return_value = None
|
||||
self.plugin.update()
|
||||
# Assert data was fetched and cached
|
||||
|
||||
def test_display(self):
|
||||
self.plugin.display()
|
||||
self.display_manager.draw_text.assert_called()
|
||||
self.display_manager.update_display.assert_called()
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
```
|
||||
|
||||
Run tests:
|
||||
|
||||
```bash
|
||||
cd plugins/my-plugin
|
||||
python -m pytest test_my_plugin.py
|
||||
# or
|
||||
python test_my_plugin.py
|
||||
```
|
||||
|
||||
### Integration Testing
|
||||
|
||||
Test plugin with actual managers:
|
||||
|
||||
```python
|
||||
# test_plugin_integration.py
|
||||
from src.plugin_system.plugin_manager import PluginManager
|
||||
from src.config_manager import ConfigManager
|
||||
from src.display_manager import DisplayManager
|
||||
from src.cache_manager import CacheManager
|
||||
|
||||
def test_plugin_loading():
|
||||
config_manager = ConfigManager()
|
||||
config = config_manager.load_config()
|
||||
display_manager = DisplayManager(config)
|
||||
cache_manager = CacheManager()
|
||||
|
||||
plugin_manager = PluginManager(
|
||||
plugins_dir="plugins",
|
||||
config_manager=config_manager,
|
||||
display_manager=display_manager,
|
||||
cache_manager=cache_manager
|
||||
)
|
||||
|
||||
plugins = plugin_manager.discover_plugins()
|
||||
assert "my-plugin" in plugins
|
||||
|
||||
assert plugin_manager.load_plugin("my-plugin")
|
||||
plugin = plugin_manager.get_plugin("my-plugin")
|
||||
assert plugin is not None
|
||||
assert plugin.enabled
|
||||
|
||||
plugin.update()
|
||||
plugin.display()
|
||||
```
|
||||
|
||||
### Emulator Testing
|
||||
|
||||
Test plugin rendering visually:
|
||||
|
||||
```bash
|
||||
# Run with emulator
|
||||
python run.py --emulator
|
||||
|
||||
# Plugin should appear in display rotation
|
||||
# Check logs for plugin loading and execution
|
||||
```
|
||||
|
||||
### Hardware Testing
|
||||
|
||||
1. Deploy plugin to Raspberry Pi
|
||||
2. Enable in `config/config.json`
|
||||
3. Restart LEDMatrix service
|
||||
4. Observe LED matrix display
|
||||
5. Check logs: `journalctl -u ledmatrix -f`
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Plugin Not Loading
|
||||
|
||||
**Symptoms**: Plugin doesn't appear in available modes, no logs about plugin
|
||||
|
||||
**Solutions**:
|
||||
1. Check plugin directory exists: `ls plugins/my-plugin/`
|
||||
2. Verify `manifest.json` exists and is valid JSON
|
||||
3. Check manifest has required fields: `id`, `entry_point`, `class_name`
|
||||
4. Verify entry_point file exists: `ls plugins/my-plugin/manager.py`
|
||||
5. Check class name matches: `grep "class.*Plugin" plugins/my-plugin/manager.py`
|
||||
6. Review logs for import errors
|
||||
|
||||
### Plugin Loading but Not Displaying
|
||||
|
||||
**Symptoms**: Plugin loads successfully but doesn't appear in rotation
|
||||
|
||||
**Solutions**:
|
||||
1. Check plugin is enabled: `config/config.json` has `"enabled": true`
|
||||
2. Verify display_modes in manifest match config
|
||||
3. Check plugin is in rotation schedule
|
||||
4. Review `display()` method for errors
|
||||
5. Check logs for runtime errors
|
||||
|
||||
### Configuration Errors
|
||||
|
||||
**Symptoms**: Plugin fails to load, validation errors in logs
|
||||
|
||||
**Solutions**:
|
||||
1. Validate config against `config_schema.json`
|
||||
2. Check required fields are present
|
||||
3. Verify data types match schema
|
||||
4. Check for typos in config keys
|
||||
5. Review `validate_config()` method
|
||||
|
||||
### Import Errors
|
||||
|
||||
**Symptoms**: ModuleNotFoundError or ImportError in logs
|
||||
|
||||
**Solutions**:
|
||||
1. Install plugin dependencies: `pip install -r plugins/my-plugin/requirements.txt`
|
||||
2. Check Python path includes plugin directory
|
||||
3. Verify relative imports are correct
|
||||
4. Check for circular import issues
|
||||
5. Ensure all dependencies are in requirements.txt
|
||||
|
||||
### Display Issues
|
||||
|
||||
**Symptoms**: Plugin renders incorrectly or not at all
|
||||
|
||||
**Solutions**:
|
||||
1. Check display dimensions: `display_manager.width`, `display_manager.height`
|
||||
2. Verify coordinates are within display bounds
|
||||
3. Check color values are valid (0-255)
|
||||
4. Ensure `update_display()` is called after rendering
|
||||
5. Test with emulator first to debug rendering
|
||||
|
||||
### Performance Issues
|
||||
|
||||
**Symptoms**: Slow display updates, high CPU usage
|
||||
|
||||
**Solutions**:
|
||||
1. Use `cache_manager` to avoid excessive API calls
|
||||
2. Implement background data fetching
|
||||
3. Optimize rendering code
|
||||
4. Consider using `high_performance_transitions`
|
||||
5. Profile plugin code to identify bottlenecks
|
||||
|
||||
### Git/Symlink Issues
|
||||
|
||||
**Symptoms**: Plugin changes not appearing, broken symlinks
|
||||
|
||||
**Solutions**:
|
||||
1. Check symlink: `ls -la plugins/my-plugin`
|
||||
2. Verify target exists: `readlink -f plugins/my-plugin`
|
||||
3. Update plugin: `./scripts/dev/dev_plugin_setup.sh update my-plugin`
|
||||
4. Re-link plugin if needed: `./scripts/dev/dev_plugin_setup.sh unlink my-plugin && ./scripts/dev/dev_plugin_setup.sh link my-plugin <path>`
|
||||
5. Check git status: `cd plugins/my-plugin && git status`
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Code Organization
|
||||
|
||||
- Keep plugin code in `plugins/<plugin-id>/` directory
|
||||
- Use descriptive class and method names
|
||||
- Follow existing plugin patterns
|
||||
- Place shared utilities in `src/common/` if reusable
|
||||
|
||||
### Configuration
|
||||
|
||||
- Always use `config_schema.json` for validation
|
||||
- Store secrets in `config_secrets.json`
|
||||
- Provide sensible defaults
|
||||
- Document all configuration options in README
|
||||
|
||||
### Error Handling
|
||||
|
||||
- Use plugin logger for all logging
|
||||
- Handle API failures gracefully
|
||||
- Provide fallback displays when data unavailable
|
||||
- Cache data to avoid excessive requests
|
||||
|
||||
### Performance
|
||||
|
||||
- Cache API responses appropriately
|
||||
- Use background data fetching for long operations
|
||||
- Optimize rendering for Pi's limited resources
|
||||
- Test performance on actual hardware
|
||||
|
||||
### Testing
|
||||
|
||||
- Write unit tests for core logic
|
||||
- Test with emulator before hardware
|
||||
- Test on Raspberry Pi before deploying
|
||||
- Test with other plugins enabled
|
||||
|
||||
### Documentation
|
||||
|
||||
- Document plugin functionality in README
|
||||
- Include configuration examples
|
||||
- Document API requirements and rate limits
|
||||
- Provide usage examples
|
||||
|
||||
---
|
||||
|
||||
## Resources
|
||||
|
||||
- **Plugin System Documentation**: `docs/PLUGIN_ARCHITECTURE_SPEC.md`
|
||||
- **Base Plugin Class**: `src/plugin_system/base_plugin.py`
|
||||
- **Plugin Manager**: `src/plugin_system/plugin_manager.py`
|
||||
- **Example Plugins**:
|
||||
- `plugins/hockey-scoreboard/` - Sports scoreboard example
|
||||
- `plugins/football-scoreboard/` - Complex multi-league example
|
||||
- `plugins/ledmatrix-music/` - Real-time data example
|
||||
- **Development Setup**: `dev_plugin_setup.sh`
|
||||
- **Example Config**: `dev_plugins.json.example`
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### Common Commands
|
||||
|
||||
```bash
|
||||
# Link plugin from GitHub
|
||||
./scripts/dev/dev_plugin_setup.sh link-github <name>
|
||||
|
||||
# Link local plugin
|
||||
./scripts/dev/dev_plugin_setup.sh link <name> <path>
|
||||
|
||||
# List all plugins
|
||||
./scripts/dev/dev_plugin_setup.sh list
|
||||
|
||||
# Check plugin status
|
||||
./scripts/dev/dev_plugin_setup.sh status
|
||||
|
||||
# Update plugin(s)
|
||||
./scripts/dev/dev_plugin_setup.sh update [name]
|
||||
|
||||
# Unlink plugin
|
||||
./scripts/dev/dev_plugin_setup.sh unlink <name>
|
||||
|
||||
# Run with emulator
|
||||
python run.py --emulator
|
||||
|
||||
# Run on Pi
|
||||
python run.py
|
||||
```
|
||||
|
||||
### Plugin File Structure
|
||||
|
||||
```
|
||||
plugins/my-plugin/
|
||||
├── manifest.json # Required: Plugin metadata
|
||||
├── manager.py # Required: Plugin class
|
||||
├── config_schema.json # Required: Config validation
|
||||
├── requirements.txt # Optional: Dependencies
|
||||
├── README.md # Optional: Documentation
|
||||
└── ... # Plugin-specific files
|
||||
```
|
||||
|
||||
### Required Manifest Fields
|
||||
|
||||
- `id`: Plugin identifier
|
||||
- `entry_point`: Python file (usually "manager.py")
|
||||
- `class_name`: Plugin class name
|
||||
- `display_modes`: Array of mode names
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
---
|
||||
globs: *.py
|
||||
---
|
||||
|
||||
# Python Coding Standards
|
||||
|
||||
## Code Quality Principles
|
||||
- **Simplicity First**: Prefer clear, readable code over clever optimizations
|
||||
- **Explicit over Implicit**: Make intentions clear through naming and structure
|
||||
- **Fail Fast**: Validate inputs and handle errors early
|
||||
- **Documentation**: Use docstrings for classes and complex functions
|
||||
|
||||
## Naming Conventions
|
||||
- **Classes**: PascalCase (e.g., `NHLRecentManager`)
|
||||
- **Functions/Variables**: snake_case (e.g., `fetch_game_data`)
|
||||
- **Constants**: UPPER_SNAKE_CASE (e.g., `ESPN_NHL_SCOREBOARD_URL`)
|
||||
- **Private methods**: Leading underscore (e.g., `_fetch_data`)
|
||||
|
||||
## Error Handling
|
||||
- **Logging**: Use structured logging with context (e.g., `[NHL Recent]`)
|
||||
- **Exceptions**: Catch specific exceptions, not bare `except:`
|
||||
- **User-friendly messages**: Explain what went wrong and potential solutions
|
||||
- **Graceful degradation**: Continue operation when non-critical features fail
|
||||
|
||||
## Manager Pattern
|
||||
All sports managers should follow this structure:
|
||||
```python
|
||||
class BaseManager:
|
||||
def __init__(self, config, display_manager, cache_manager)
|
||||
def update(self) # Fetch and process data
|
||||
def display(self, force_clear=False) # Render to display
|
||||
```
|
||||
|
||||
## Configuration Management
|
||||
- **Type hints**: Use for function parameters and return values
|
||||
- **Configuration validation**: Check required fields on initialization
|
||||
- **Default values**: Provide sensible defaults in code, not config
|
||||
- **Environment awareness**: Handle different deployment contexts
|
||||
@@ -1,42 +0,0 @@
|
||||
---
|
||||
globs: config/*.json,src/*.py
|
||||
---
|
||||
|
||||
# Configuration Management
|
||||
|
||||
## Configuration Structure
|
||||
- **Main config**: [config/config.json](mdc:config/config.json) - Primary configuration
|
||||
- **Secrets**: [config/config_secrets.json](mdc:config/config_secrets.json) - API keys and sensitive data
|
||||
- **Templates**: [config/config.template.json](mdc:config/config.template.json) - Default values
|
||||
|
||||
## Configuration Principles
|
||||
- **Validation**: Check required fields and data types on startup
|
||||
- **Defaults**: Provide sensible defaults in code, not just config
|
||||
- **Environment awareness**: Handle development vs production differences
|
||||
- **Security**: Never commit secrets to version control
|
||||
|
||||
## Manager Configuration Pattern
|
||||
```python
|
||||
def __init__(self, config, display_manager, cache_manager):
|
||||
self.mode_config = config.get("sport_scoreboard", {})
|
||||
self.favorite_teams = self.mode_config.get("favorite_teams", [])
|
||||
self.show_favorite_only = self.mode_config.get("show_favorite_teams_only", False)
|
||||
```
|
||||
|
||||
## Required Configuration Sections
|
||||
- **Display settings**: Update intervals, display durations
|
||||
- **API settings**: Timeouts, retry logic, rate limiting
|
||||
- **Background service**: Threading, caching, priority settings
|
||||
- **Team preferences**: Favorite teams, filtering options
|
||||
|
||||
## Configuration Validation
|
||||
- **Type checking**: Ensure numeric values are numbers, lists are lists
|
||||
- **Range validation**: Check that intervals are reasonable
|
||||
- **Dependency checking**: Verify required services are available
|
||||
- **Fallback values**: Provide defaults when config is missing or invalid
|
||||
|
||||
## Best Practices
|
||||
- **Documentation**: Comment complex configuration options
|
||||
- **Examples**: Provide working examples in templates
|
||||
- **Migration**: Handle configuration changes between versions
|
||||
- **Testing**: Validate configuration in test environments
|
||||
@@ -1,50 +0,0 @@
|
||||
---
|
||||
globs: src/*.py
|
||||
---
|
||||
|
||||
# Error Handling and Logging
|
||||
|
||||
## Logging Standards
|
||||
- **Structured prefixes**: Use consistent tags like `[NHL Recent]`, `[NFL Live]`
|
||||
- **Context information**: Include relevant details (team names, game status, dates)
|
||||
- **Appropriate levels**:
|
||||
- `info`: Normal operations and status updates
|
||||
- `debug`: Detailed information for troubleshooting
|
||||
- `warning`: Non-critical issues that should be noted
|
||||
- `error`: Problems that need attention
|
||||
|
||||
## Error Handling Patterns
|
||||
```python
|
||||
try:
|
||||
data = self._fetch_data()
|
||||
if not data or 'events' not in data:
|
||||
self.logger.warning("[Manager] No events found in API response")
|
||||
return
|
||||
except requests.exceptions.RequestException as e:
|
||||
self.logger.error(f"[Manager] API error: {e}")
|
||||
return None
|
||||
```
|
||||
|
||||
## User-Friendly Messages
|
||||
- **Explain the situation**: "No games available during off-season"
|
||||
- **Provide context**: "NHL season typically runs October-June"
|
||||
- **Suggest solutions**: "Check back when season starts"
|
||||
- **Distinguish issues**: API problems vs no data vs filtering results
|
||||
|
||||
## Graceful Degradation
|
||||
- **Fallback content**: Show alternative games when favorites unavailable
|
||||
- **Cached data**: Use cached data when API fails
|
||||
- **Service continuity**: Continue operation when non-critical features fail
|
||||
- **Clear communication**: Explain what's happening to users
|
||||
|
||||
## Debugging Support
|
||||
- **Comprehensive logging**: Log API responses, filtering results, display updates
|
||||
- **State tracking**: Log current state and transitions
|
||||
- **Performance monitoring**: Track timing and resource usage
|
||||
- **Error context**: Include stack traces for debugging
|
||||
|
||||
## Off-Season Awareness
|
||||
- **Seasonal messaging**: Different messages for different times of year
|
||||
- **Helpful context**: Explain why no games are available
|
||||
- **Future planning**: Mention when season starts
|
||||
- **Realistic expectations**: Set appropriate expectations during off-season
|
||||
@@ -1,51 +0,0 @@
|
||||
---
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Git Workflow and Branching
|
||||
|
||||
## Branch Naming Conventions
|
||||
- **Features**: `feature/description-of-feature` (e.g., `feature/weather-forecast-improvements`)
|
||||
- **Bug fixes**: `fix/description-of-bug` (e.g., `fix/nhl-manager-improvements`)
|
||||
- **Hotfixes**: `hotfix/critical-issue-description`
|
||||
- **Refactoring**: `refactor/description-of-refactor`
|
||||
|
||||
## Commit Message Format
|
||||
```
|
||||
type(scope): description
|
||||
|
||||
[optional body]
|
||||
|
||||
[optional footer]
|
||||
```
|
||||
|
||||
**Types**: feat, fix, docs, style, refactor, test, chore
|
||||
**Examples**:
|
||||
- `feat(nhl): Add enhanced logging for data visibility`
|
||||
- `fix(display): Resolve rendering performance issue`
|
||||
- `docs(api): Update ESPN API integration guide`
|
||||
|
||||
## Pull Request Guidelines
|
||||
- **Self-review**: Review your own PR before requesting review
|
||||
- **Testing**: Test thoroughly on Raspberry Pi hardware
|
||||
- **Documentation**: Update relevant documentation if needed
|
||||
- **Clean history**: Squash commits if necessary for clean history
|
||||
|
||||
## Code Review Checklist
|
||||
- **Code Quality**: Proper error handling, logging, type hints
|
||||
- **Architecture**: Follows project patterns, doesn't break existing functionality
|
||||
- **Performance**: No negative impact on display performance
|
||||
- **Testing**: Works on Raspberry Pi hardware
|
||||
- **Documentation**: Comments added for complex logic
|
||||
|
||||
## Merge Strategies
|
||||
- **Squash and Merge**: Preferred for feature branches and bug fixes
|
||||
- **Merge Commit**: For complex features with multiple logical commits
|
||||
- **Rebase and Merge**: For simple, single-commit changes
|
||||
|
||||
## Best Practices
|
||||
- **Keep branches small and focused**
|
||||
- **Commit frequently with meaningful messages**
|
||||
- **Update branch regularly with main**
|
||||
- **Test changes incrementally**
|
||||
- **Delete feature branches after merge**
|
||||
@@ -1,213 +0,0 @@
|
||||
---
|
||||
description: GitHub branching and pull request best practices for LEDMatrix project
|
||||
globs: ["**/*.py", "**/*.md", "**/*.json", "**/*.sh"]
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# GitHub Branching and Pull Request Guidelines
|
||||
|
||||
## Branch Naming Conventions
|
||||
|
||||
### Feature Branches
|
||||
- **Format**: `feature/description-of-feature`
|
||||
- **Examples**:
|
||||
- `feature/weather-forecast-improvements`
|
||||
- `feature/stock-api-integration`
|
||||
- `feature/nba-live-scores`
|
||||
|
||||
### Bug Fix Branches
|
||||
- **Format**: `fix/description-of-bug`
|
||||
- **Examples**:
|
||||
- `fix/leaderboard-scrolling-performance`
|
||||
- `fix/weather-api-timeout`
|
||||
- `fix/display-rendering-issue`
|
||||
|
||||
### Hotfix Branches
|
||||
- **Format**: `hotfix/critical-issue-description`
|
||||
- **Examples**:
|
||||
- `hotfix/display-crash-fix`
|
||||
- `hotfix/api-rate-limit-fix`
|
||||
|
||||
### Refactoring Branches
|
||||
- **Format**: `refactor/description-of-refactor`
|
||||
- **Examples**:
|
||||
- `refactor/sports-manager-architecture`
|
||||
- `refactor/cache-management-system`
|
||||
|
||||
## Branch Management Rules
|
||||
|
||||
### Main Branch Protection
|
||||
- **`main`** branch is protected and requires PR reviews
|
||||
- Never commit directly to `main`
|
||||
- All changes must go through pull requests
|
||||
|
||||
### Branch Lifecycle
|
||||
1. **Create** branch from `main` when starting work
|
||||
2. **Keep** branch up-to-date with `main` regularly
|
||||
3. **Test** thoroughly before creating PR
|
||||
4. **Delete** branch after successful merge
|
||||
|
||||
### Branch Updates
|
||||
```bash
|
||||
# Before starting new work
|
||||
git checkout main
|
||||
git pull origin main
|
||||
|
||||
# Create new branch
|
||||
git checkout -b feature/your-feature-name
|
||||
|
||||
# Keep branch updated during development
|
||||
git checkout main
|
||||
git pull origin main
|
||||
git checkout feature/your-feature-name
|
||||
git merge main
|
||||
```
|
||||
|
||||
## Pull Request Guidelines
|
||||
|
||||
### PR Title Format
|
||||
- **Feature**: `feat: Add weather forecast improvements`
|
||||
- **Fix**: `fix: Resolve leaderboard scrolling performance issue`
|
||||
- **Refactor**: `refactor: Improve sports manager architecture`
|
||||
- **Docs**: `docs: Update API integration guide`
|
||||
- **Test**: `test: Add unit tests for weather manager`
|
||||
|
||||
### PR Description Template
|
||||
```markdown
|
||||
## Description
|
||||
Brief description of changes and motivation.
|
||||
|
||||
## Type of Change
|
||||
- [ ] Bug fix (non-breaking change)
|
||||
- [ ] New feature (non-breaking change)
|
||||
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
|
||||
- [ ] Documentation update
|
||||
- [ ] Performance improvement
|
||||
- [ ] Refactoring
|
||||
|
||||
## Testing
|
||||
- [ ] Tested on Raspberry Pi hardware
|
||||
- [ ] Verified display rendering works correctly
|
||||
- [ ] Checked API integration functionality
|
||||
- [ ] Tested error handling scenarios
|
||||
|
||||
## Screenshots/Videos
|
||||
(If applicable, add screenshots or videos of the changes)
|
||||
|
||||
## Checklist
|
||||
- [ ] Code follows project style guidelines
|
||||
- [ ] Self-review completed
|
||||
- [ ] Comments added for complex logic
|
||||
- [ ] No hardcoded values or API keys
|
||||
- [ ] Error handling implemented
|
||||
- [ ] Logging added where appropriate
|
||||
```
|
||||
|
||||
### PR Review Requirements
|
||||
|
||||
#### For Reviewers
|
||||
- **Code Quality**: Check for proper error handling, logging, and type hints
|
||||
- **Architecture**: Ensure changes follow project patterns and don't break existing functionality
|
||||
- **Performance**: Verify changes don't negatively impact display performance
|
||||
- **Testing**: Confirm changes work on Raspberry Pi hardware
|
||||
- **Documentation**: Check if documentation needs updates
|
||||
|
||||
#### For Authors
|
||||
- **Self-Review**: Review your own PR before requesting review
|
||||
- **Testing**: Test thoroughly on Pi hardware before submitting
|
||||
- **Documentation**: Update relevant documentation if needed
|
||||
- **Clean History**: Squash commits if necessary for clean history
|
||||
|
||||
## Commit Message Guidelines
|
||||
|
||||
### Format
|
||||
```
|
||||
type(scope): description
|
||||
|
||||
[optional body]
|
||||
|
||||
[optional footer]
|
||||
```
|
||||
|
||||
### Types
|
||||
- **feat**: New feature
|
||||
- **fix**: Bug fix
|
||||
- **docs**: Documentation changes
|
||||
- **style**: Code style changes (formatting, etc.)
|
||||
- **refactor**: Code refactoring
|
||||
- **test**: Adding or updating tests
|
||||
- **chore**: Maintenance tasks
|
||||
|
||||
### Examples
|
||||
```
|
||||
feat(weather): Add hourly forecast display
|
||||
fix(nba): Resolve live score update issue
|
||||
docs(api): Update ESPN API integration guide
|
||||
refactor(sports): Improve base class architecture
|
||||
```
|
||||
|
||||
## Merge Strategies
|
||||
|
||||
### Squash and Merge (Preferred)
|
||||
- Use for feature branches and bug fixes
|
||||
- Creates clean, linear history
|
||||
- Combines all commits into single commit
|
||||
|
||||
### Merge Commit
|
||||
- Use for complex features with multiple logical commits
|
||||
- Preserves commit history
|
||||
- Use when commit messages are meaningful
|
||||
|
||||
### Rebase and Merge
|
||||
- Use sparingly for simple, single-commit changes
|
||||
- Creates linear history without merge commits
|
||||
|
||||
## Release Management
|
||||
|
||||
### Version Tags
|
||||
- Use semantic versioning: `v1.2.3`
|
||||
- Tag releases on `main` branch
|
||||
- Create release notes with technical details
|
||||
|
||||
### Release Branches
|
||||
- **Format**: `release/v1.2.3`
|
||||
- Use for release preparation
|
||||
- Include version bumps and final testing
|
||||
|
||||
## Emergency Procedures
|
||||
|
||||
### Hotfix Process
|
||||
1. Create `hotfix/` branch from `main`
|
||||
2. Make minimal fix
|
||||
3. Test thoroughly
|
||||
4. Create PR with expedited review
|
||||
5. Merge to `main` and tag release
|
||||
6. Cherry-pick to other branches if needed
|
||||
|
||||
### Rollback Process
|
||||
1. Identify last known good commit
|
||||
2. Create revert PR if possible
|
||||
3. Use `git revert` for clean rollback
|
||||
4. Tag rollback release
|
||||
5. Document issue and resolution
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Before Creating PR
|
||||
- [ ] Run all tests locally
|
||||
- [ ] Test on Raspberry Pi hardware
|
||||
- [ ] Check for linting errors
|
||||
- [ ] Update documentation if needed
|
||||
- [ ] Ensure commit messages are clear
|
||||
|
||||
### During Development
|
||||
- [ ] Keep branches small and focused
|
||||
- [ ] Commit frequently with meaningful messages
|
||||
- [ ] Update branch regularly with main
|
||||
- [ ] Test changes incrementally
|
||||
|
||||
### After PR Approval
|
||||
- [ ] Delete feature branch after merge
|
||||
- [ ] Update local main branch
|
||||
- [ ] Verify changes work in production
|
||||
- [ ] Update any related documentation
|
||||
@@ -1,23 +0,0 @@
|
||||
---
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# LEDMatrix Project Structure
|
||||
|
||||
## Core Architecture
|
||||
- **Main entry point**: [run.py](mdc:run.py) - Primary application launcher
|
||||
- **Configuration**: [config/config.json](mdc:config/config.json) - Main configuration file
|
||||
- **Display management**: [src/display_controller.py](mdc:src/display_controller.py) - Core display logic
|
||||
- **Web interface**: [web_interface_v2.py](mdc:web_interface_v2.py) - Modern web UI
|
||||
|
||||
## Source Code Organization
|
||||
- **Managers**: [src/](mdc:src/) - All sports/weather/stock managers
|
||||
- **Assets**: [assets/](mdc:assets/) - Logos, fonts, and static resources
|
||||
- **Tests**: [test/](mdc:test/) - Unit and integration tests
|
||||
- **Documentation**: [LEDMatrix.wiki/](mdc:LEDMatrix.wiki/) - Comprehensive guides
|
||||
|
||||
## Key Design Principles
|
||||
- **Single Responsibility**: Each manager handles one sport/domain
|
||||
- **Consistent Patterns**: All managers follow similar structure
|
||||
- **Configuration-Driven**: Behavior controlled via [config/config.json](mdc:config/config.json)
|
||||
- **Raspberry Pi Focus**: Optimized for Pi hardware, not Windows development
|
||||
@@ -1,41 +0,0 @@
|
||||
---
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Raspberry Pi Development Guidelines
|
||||
|
||||
## Hardware Constraints
|
||||
- **Pi-only execution**: Code must run on Raspberry Pi, not Windows development machine
|
||||
- **LED matrix library**: Uses [rpi-rgb-led-matrix-master/](mdc:rpi-rgb-led-matrix-master/) for hardware control
|
||||
- **Memory limitations**: Optimize for Pi's limited RAM
|
||||
- **Performance**: Consider Pi's CPU capabilities in design
|
||||
|
||||
## Development Workflow
|
||||
- **Local development**: Write and test code on Windows
|
||||
- **Pi deployment**: Deploy and test on actual Pi hardware
|
||||
- **SSH access**: Use SSH for Pi-based testing and debugging
|
||||
- **Service management**: Use systemd services for production deployment
|
||||
|
||||
## Testing Strategy
|
||||
- **Unit tests**: Test logic without hardware dependencies
|
||||
- **Integration tests**: Test with mock display managers
|
||||
- **Hardware tests**: Validate on actual Pi with LED matrix
|
||||
- **Performance tests**: Monitor memory and CPU usage
|
||||
|
||||
## Deployment Considerations
|
||||
- **Service files**: [ledmatrix.service](mdc:ledmatrix.service), [ledmatrix-web.service](mdc:ledmatrix-web.service)
|
||||
- **Installation scripts**: [first_time_install.sh](mdc:first_time_install.sh), [install_service.sh](mdc:install_service.sh)
|
||||
- **Dependencies**: [requirements.txt](mdc:requirements.txt) for Pi environment
|
||||
- **Permissions**: Handle file permissions for Pi user
|
||||
|
||||
## Performance Optimization
|
||||
- **Caching**: Use [src/cache_manager.py](mdc:src/cache_manager.py) for data persistence
|
||||
- **Background services**: Non-blocking data fetching
|
||||
- **Memory management**: Clean up resources regularly
|
||||
- **Display optimization**: Minimize unnecessary redraws
|
||||
|
||||
## Debugging on Pi
|
||||
- **Logging**: Comprehensive logging for remote debugging
|
||||
- **Error reporting**: Clear error messages for troubleshooting
|
||||
- **Status monitoring**: Health checks and status reporting
|
||||
- **Remote access**: Web interface for configuration and monitoring
|
||||
@@ -1,42 +0,0 @@
|
||||
---
|
||||
globs: src/*_managers.py
|
||||
---
|
||||
|
||||
# Sports Manager Development
|
||||
|
||||
## Manager Architecture
|
||||
All sports managers inherit from base classes and follow consistent patterns:
|
||||
- **Base classes**: [src/nhl_managers.py](mdc:src/nhl_managers.py), [src/nfl_managers.py](mdc:src/nfl_managers.py)
|
||||
- **Common functionality**: Data fetching, caching, display rendering
|
||||
- **Configuration-driven**: Behavior controlled via config sections
|
||||
|
||||
## Required Methods
|
||||
```python
|
||||
def __init__(self, config, display_manager, cache_manager)
|
||||
def update(self) # Fetch fresh data
|
||||
def display(self, force_clear=False) # Render current data
|
||||
```
|
||||
|
||||
## Data Flow Pattern
|
||||
1. **Fetch**: Get data from API (with caching)
|
||||
2. **Process**: Extract relevant game information
|
||||
3. **Filter**: Apply favorite team preferences
|
||||
4. **Display**: Render to LED matrix
|
||||
|
||||
## Logging Standards
|
||||
- **Structured prefixes**: `[NHL Recent]`, `[NFL Live]`, etc.
|
||||
- **Context information**: Include team names, game status, dates
|
||||
- **Debug levels**: Use appropriate log levels (info, debug, warning, error)
|
||||
- **User-friendly messages**: Explain what's happening and why
|
||||
|
||||
## Error Handling
|
||||
- **API failures**: Log and continue with cached data if available
|
||||
- **No data scenarios**: Distinguish between API issues vs no games available
|
||||
- **Off-season awareness**: Provide helpful context during non-active periods
|
||||
- **Fallback behavior**: Show alternative content when preferred content unavailable
|
||||
|
||||
## Configuration Integration
|
||||
- **Required settings**: Validate on initialization
|
||||
- **Optional settings**: Provide sensible defaults
|
||||
- **Background service**: Use for non-blocking data fetching
|
||||
- **Caching strategy**: Implement intelligent cache management
|
||||
@@ -1,51 +0,0 @@
|
||||
---
|
||||
globs: test/*.py,src/*.py
|
||||
---
|
||||
|
||||
# Testing Standards
|
||||
|
||||
## Test Organization
|
||||
- **Test directory**: [test/](mdc:test/) - All test files
|
||||
- **Unit tests**: Test individual components in isolation
|
||||
- **Integration tests**: Test component interactions
|
||||
- **Hardware tests**: Validate on Raspberry Pi with actual LED matrix
|
||||
|
||||
## Testing Principles
|
||||
- **Test behavior, not implementation**: Focus on what the code does, not how
|
||||
- **Mock external dependencies**: Use mocks for APIs, display managers, cache
|
||||
- **Test edge cases**: Empty data, API failures, configuration errors
|
||||
- **Pi-specific testing**: Validate hardware integration
|
||||
|
||||
## Test Structure
|
||||
```python
|
||||
def test_manager_initialization():
|
||||
"""Test that manager initializes with valid config"""
|
||||
config = {"sport_scoreboard": {"enabled": True}}
|
||||
manager = ManagerClass(config, mock_display, mock_cache)
|
||||
assert manager.enabled == True
|
||||
|
||||
def test_api_failure_handling():
|
||||
"""Test graceful handling of API failures"""
|
||||
# Test that system continues when API fails
|
||||
# Verify fallback to cached data
|
||||
# Check appropriate error logging
|
||||
```
|
||||
|
||||
## Mock Patterns
|
||||
- **Display Manager**: Mock for testing without hardware
|
||||
- **Cache Manager**: Mock for testing data persistence
|
||||
- **API responses**: Mock for consistent test data
|
||||
- **Configuration**: Use test-specific configs
|
||||
|
||||
## Test Categories
|
||||
- **Unit tests**: Individual manager methods
|
||||
- **Integration tests**: Manager interactions with services
|
||||
- **Configuration tests**: Validate config loading and validation
|
||||
- **Error handling tests**: API failures, invalid data, edge cases
|
||||
|
||||
## Testing Best Practices
|
||||
- **Descriptive names**: Test names should explain what they test
|
||||
- **Single responsibility**: Each test should verify one thing
|
||||
- **Independent tests**: Tests should not depend on each other
|
||||
- **Clean setup/teardown**: Reset state between tests
|
||||
- **Pi compatibility**: Ensure tests work in Pi environment
|
||||
@@ -1 +0,0 @@
|
||||
# Add directories or file patterns to ignore during indexing (e.g. foo/ or *.csv)
|
||||
-364
@@ -1,364 +0,0 @@
|
||||
# LEDMatrix Plugin Development Rules
|
||||
|
||||
## Plugin System Overview
|
||||
|
||||
The LEDMatrix project uses a plugin-based architecture. All display
|
||||
functionality (except core calendar) is implemented as plugins that are
|
||||
dynamically loaded from the directory configured by
|
||||
`plugin_system.plugins_directory` in `config.json` — the default is
|
||||
`plugin-repos/` (per `config/config.template.json:130`).
|
||||
|
||||
> **Fallback note (scoped):** `PluginManager.discover_plugins()`
|
||||
> (`src/plugin_system/plugin_manager.py:154`) only scans the
|
||||
> configured directory — there is no fallback to `plugins/` in the
|
||||
> main discovery path. A fallback to `plugins/` does exist in two
|
||||
> narrower places:
|
||||
> - `store_manager.py:1700-1718` — store operations (install/update/
|
||||
> uninstall) check `plugins/` if the plugin isn't found in the
|
||||
> configured directory, so plugin-store flows work even when your
|
||||
> dev symlinks live in `plugins/`.
|
||||
> - `schema_manager.py:70-80` — `get_schema_path()` probes both
|
||||
> `plugins/` and `plugin-repos/` for `config_schema.json` so the
|
||||
> web UI form generation finds the schema regardless of where the
|
||||
> plugin lives.
|
||||
>
|
||||
> The dev workflow in `scripts/dev/dev_plugin_setup.sh` creates
|
||||
> symlinks under `plugins/`, which is why the store and schema
|
||||
> fallbacks exist. For day-to-day development, set
|
||||
> `plugin_system.plugins_directory` to `plugins` so the main
|
||||
> discovery path picks up your symlinks.
|
||||
|
||||
## Plugin Structure
|
||||
|
||||
### Required Files
|
||||
- **manifest.json**: Plugin metadata, entry point, class name, dependencies
|
||||
- **manager.py**: Main plugin class (must inherit from `BasePlugin`)
|
||||
- **config_schema.json**: JSON schema for plugin configuration validation
|
||||
- **requirements.txt**: Python dependencies (if any)
|
||||
- **README.md**: Plugin documentation
|
||||
|
||||
### Plugin Class Requirements
|
||||
- Must inherit from `src.plugin_system.base_plugin.BasePlugin`
|
||||
- Must implement `update()` method for data fetching
|
||||
- Must implement `display()` method for rendering
|
||||
- Should implement `validate_config()` for configuration validation
|
||||
- Optional: Override `has_live_content()` for live priority features
|
||||
|
||||
## Plugin Development Workflow
|
||||
|
||||
### 1. Creating a New Plugin
|
||||
|
||||
**Option A: Use dev_plugin_setup.sh (Recommended)**
|
||||
```bash
|
||||
# Link from GitHub
|
||||
./scripts/dev/dev_plugin_setup.sh link-github <plugin-name>
|
||||
|
||||
# Link local repository
|
||||
./scripts/dev/dev_plugin_setup.sh link <plugin-name> <path-to-repo>
|
||||
```
|
||||
|
||||
**Option B: Manual Setup**
|
||||
1. Create directory in `plugin-repos/<plugin-id>/` (or `plugins/<plugin-id>/`
|
||||
if you're using the dev fallback location)
|
||||
2. Add `manifest.json` with required fields
|
||||
3. Create `manager.py` with plugin class
|
||||
4. Add `config_schema.json` for configuration
|
||||
5. Enable plugin in `config/config.json` under `"<plugin-id>": {"enabled": true}`
|
||||
|
||||
### 2. Plugin Configuration
|
||||
|
||||
Plugins are configured in `config/config.json`:
|
||||
```json
|
||||
{
|
||||
"<plugin-id>": {
|
||||
"enabled": true,
|
||||
"display_duration": 15,
|
||||
"live_priority": false,
|
||||
"high_performance_transitions": false,
|
||||
"transition": {
|
||||
"type": "redraw",
|
||||
"speed": 2,
|
||||
"enabled": true
|
||||
},
|
||||
// ... plugin-specific config
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Testing Plugins
|
||||
|
||||
**On Development Machine:**
|
||||
- Run the dev preview server: `python3 scripts/dev_server.py` (then
|
||||
open `http://localhost:5001`) — renders plugins in the browser
|
||||
without running the full display loop
|
||||
- Or run the full display in emulator mode:
|
||||
`python3 run.py --emulator` (or equivalently
|
||||
`EMULATOR=true python3 run.py`, or `./scripts/dev/run_emulator.sh`).
|
||||
The `-e`/`--emulator` CLI flag is defined in `run.py:19-20`.
|
||||
- Test plugin loading: Check logs for plugin discovery and loading
|
||||
- Validate configuration: Ensure config matches `config_schema.json`
|
||||
|
||||
**On Raspberry Pi:**
|
||||
- Deploy and test on actual hardware
|
||||
- Monitor logs: `journalctl -u ledmatrix -f` (if running as service)
|
||||
- Check plugin status in web interface
|
||||
|
||||
### 4. Plugin Development Best Practices
|
||||
|
||||
**Code Organization:**
|
||||
- Keep plugin code in `plugin-repos/<plugin-id>/` (or its dev-time
|
||||
symlink in `plugins/<plugin-id>/`)
|
||||
- Use shared assets from `assets/` directory when possible
|
||||
- Follow existing plugin patterns — canonical sources live in the
|
||||
[`ledmatrix-plugins`](https://github.com/ChuckBuilds/ledmatrix-plugins)
|
||||
repo (`plugins/hockey-scoreboard/`, `plugins/football-scoreboard/`,
|
||||
`plugins/clock-simple/`, etc.)
|
||||
- Place shared utilities in `src/common/` if reusable across plugins
|
||||
|
||||
**Configuration Management:**
|
||||
- Use `config_schema.json` for validation
|
||||
- Store secrets in `config/config_secrets.json` under the same plugin
|
||||
id namespace as the main config — they're deep-merged into the main
|
||||
config at load time (`src/config_manager.py:162-172`), so plugin
|
||||
code reads them directly from `config.get(...)` like any other key
|
||||
- There is no separate `config_secrets` reference field
|
||||
- Validate all required fields in `validate_config()`
|
||||
|
||||
**Error Handling:**
|
||||
- Use plugin's logger: `self.logger.info/error/warning()`
|
||||
- Handle API failures gracefully
|
||||
- Cache data to avoid excessive API calls
|
||||
- Provide fallback displays when data unavailable
|
||||
|
||||
**Performance:**
|
||||
- Use `cache_manager` for API response caching
|
||||
- Implement background data fetching if needed
|
||||
- Use `high_performance_transitions` for smoother animations
|
||||
- Optimize rendering for Pi's limited resources
|
||||
|
||||
**Display Rendering:**
|
||||
- Use `display_manager` for all drawing operations
|
||||
- Support different display sizes (check `display_manager.width/height`)
|
||||
- Use `apply_transition()` for smooth transitions between displays
|
||||
- Clear display before rendering: `display_manager.clear()`
|
||||
- Always call `display_manager.update_display()` after rendering
|
||||
|
||||
## Plugin API Reference
|
||||
|
||||
### BasePlugin Class
|
||||
Located in: `src/plugin_system/base_plugin.py`
|
||||
|
||||
**Required Methods:**
|
||||
- `update()`: Fetch/update data (called based on `update_interval` in manifest)
|
||||
- `display(force_clear=False)`: Render plugin content
|
||||
|
||||
**Optional Methods:**
|
||||
- `validate_config()`: Validate plugin configuration
|
||||
- `has_live_content()`: Return True if plugin has live/urgent content
|
||||
- `get_live_modes()`: Return list of modes for live priority
|
||||
- `cleanup()`: Clean up resources on unload
|
||||
- `on_config_change(new_config)`: Handle config updates
|
||||
- `on_enable()`: Called when plugin enabled
|
||||
- `on_disable()`: Called when plugin disabled
|
||||
|
||||
**Available Properties:**
|
||||
- `self.plugin_id`: Plugin identifier
|
||||
- `self.config`: Plugin configuration dict
|
||||
- `self.display_manager`: Display manager instance
|
||||
- `self.cache_manager`: Cache manager instance
|
||||
- `self.plugin_manager`: Plugin manager reference
|
||||
- `self.logger`: Plugin-specific logger
|
||||
- `self.enabled`: Boolean enabled status
|
||||
- `self.transition_manager`: Transition system (if available)
|
||||
|
||||
### Display Manager
|
||||
Located in: `src/display_manager.py`
|
||||
|
||||
**Key Methods:**
|
||||
- `clear()`: Clear the display
|
||||
- `draw_text(text, x, y, color, font, small_font, centered)`: Draw text
|
||||
- `update_display()`: Push the buffer to the physical display
|
||||
- `draw_weather_icon(condition, x, y, size)`: Draw a weather icon
|
||||
- `width`, `height`: Display dimensions
|
||||
|
||||
**Image rendering**: there is no `draw_image()` helper. Paste directly
|
||||
onto the underlying PIL Image:
|
||||
```python
|
||||
self.display_manager.image.paste(pil_image, (x, y))
|
||||
self.display_manager.update_display()
|
||||
```
|
||||
For transparency, paste with a mask: `image.paste(rgba, (x, y), rgba)`.
|
||||
|
||||
### Cache Manager
|
||||
Located in: `src/cache_manager.py`
|
||||
|
||||
**Key Methods:**
|
||||
- `get(key, max_age=300)`: Get cached value (returns None if missing/stale)
|
||||
- `set(key, value, ttl=None)`: Cache a value
|
||||
- `delete(key)` / `clear_cache(key=None)`: Remove a single cache entry,
|
||||
or (for `clear_cache` with no argument) every cached entry. `delete`
|
||||
is an alias for `clear_cache(key)`.
|
||||
- `get_cached_data_with_strategy(key, data_type)`: Cache get with
|
||||
data-type-aware TTL strategy
|
||||
- `get_background_cached_data(key, sport_key)`: Cache get for the
|
||||
background-fetch service path
|
||||
|
||||
## Plugin Manifest Schema
|
||||
|
||||
Required fields in `manifest.json`:
|
||||
- `id`: Unique plugin identifier (matches directory name)
|
||||
- `name`: Human-readable plugin name
|
||||
- `version`: Semantic version (e.g., "1.0.0")
|
||||
- `entry_point`: Python file (usually "manager.py")
|
||||
- `class_name`: Plugin class name (must match class in entry_point)
|
||||
- `display_modes`: Array of mode names this plugin provides
|
||||
|
||||
Common optional fields:
|
||||
- `description`: Plugin description
|
||||
- `author`: Plugin author
|
||||
- `homepage`: Plugin homepage URL
|
||||
- `category`: Plugin category (e.g., "sports", "weather")
|
||||
- `tags`: Array of tags
|
||||
- `update_interval`: Seconds between update() calls (default: 60)
|
||||
- `default_duration`: Default display duration (default: 15)
|
||||
- `requires`: Python version, display size requirements
|
||||
- `config_schema`: Path to config schema file
|
||||
- `api_requirements`: API dependencies and rate limits
|
||||
|
||||
## Plugin Loading Process
|
||||
|
||||
1. **Discovery**: PluginManager scans `plugins/` directory for directories containing `manifest.json`
|
||||
2. **Validation**: Validates manifest structure and required fields
|
||||
3. **Loading**: Imports plugin module and instantiates plugin class
|
||||
4. **Configuration**: Loads plugin config from `config/config.json`
|
||||
5. **Validation**: Calls `validate_config()` on plugin instance
|
||||
6. **Registration**: Adds plugin to available modes and stores instance
|
||||
7. **Enablement**: Calls `on_enable()` if plugin is enabled
|
||||
|
||||
## Common Plugin Patterns
|
||||
|
||||
### Sports Scoreboard Plugin
|
||||
- Use `background_data_service.py` pattern for API fetching
|
||||
- Implement live/recent/upcoming game modes
|
||||
- Use `scoreboard_renderer.py` for consistent rendering
|
||||
- Support team filtering and game filtering
|
||||
- Use shared sports logos from `assets/sports/`
|
||||
|
||||
### Data Display Plugin
|
||||
- Fetch data in `update()` method
|
||||
- Cache API responses using `cache_manager`
|
||||
- Render in `display()` method
|
||||
- Handle API errors gracefully
|
||||
- Provide configuration for refresh intervals
|
||||
|
||||
### Real-time Content Plugin
|
||||
- Implement `has_live_content()` for live priority
|
||||
- Use `get_live_modes()` to specify which modes are live
|
||||
- Set `live_priority: true` in config to enable live takeover
|
||||
- Update data frequently when live content exists
|
||||
|
||||
## Debugging Plugins
|
||||
|
||||
**Check Plugin Loading:**
|
||||
- Review logs for plugin discovery messages
|
||||
- Verify manifest.json syntax is valid JSON
|
||||
- Check that class_name matches actual class name
|
||||
- Ensure entry_point file exists and is importable
|
||||
|
||||
**Check Plugin Execution:**
|
||||
- Add logging statements in `update()` and `display()`
|
||||
- Use `self.logger` for plugin-specific logging
|
||||
- Check cache_manager for cached data
|
||||
- Verify display_manager is rendering correctly
|
||||
|
||||
**Common Issues:**
|
||||
- Import errors: Check Python path and dependencies
|
||||
- Config errors: Validate against config_schema.json
|
||||
- Display issues: Check display dimensions and coordinate calculations
|
||||
- Performance: Monitor CPU/memory usage on Pi
|
||||
|
||||
## Plugin Testing
|
||||
|
||||
**Unit Tests:**
|
||||
- Test plugin class instantiation
|
||||
- Test `update()` data fetching logic
|
||||
- Test `display()` rendering logic
|
||||
- Test `validate_config()` with various configs
|
||||
- Mock `display_manager` and `cache_manager` for testing
|
||||
|
||||
**Integration Tests:**
|
||||
- Test plugin loading via PluginManager
|
||||
- Test plugin with actual config
|
||||
- Test plugin with emulator display
|
||||
- Test plugin with cache_manager
|
||||
|
||||
**Hardware Tests:**
|
||||
- Test on Raspberry Pi with LED matrix
|
||||
- Verify display rendering on actual hardware
|
||||
- Test performance under load
|
||||
- Test with other plugins enabled
|
||||
|
||||
## File Organization
|
||||
|
||||
```
|
||||
plugins/
|
||||
<plugin-id>/
|
||||
manifest.json # Plugin metadata
|
||||
manager.py # Main plugin class
|
||||
config_schema.json # Config validation schema
|
||||
requirements.txt # Python dependencies
|
||||
README.md # Plugin documentation
|
||||
# Plugin-specific files
|
||||
data_manager.py
|
||||
renderer.py
|
||||
etc.
|
||||
```
|
||||
|
||||
## Git Workflow for Plugins
|
||||
|
||||
**Plugin Development:**
|
||||
- Plugins are typically separate repositories
|
||||
- Use `dev_plugin_setup.sh` to link plugins for development
|
||||
- Symlinks are used to connect plugin repos to `plugins/` directory
|
||||
- Plugin repos follow naming: `ledmatrix-<plugin-name>`
|
||||
|
||||
**Branching:**
|
||||
- Develop plugins in feature branches
|
||||
- Follow project branching conventions
|
||||
- Test plugins before merging to main
|
||||
|
||||
**Automatic Version Bumping:**
|
||||
- **Automatic Version Management**: Version bumping is handled automatically via the pre-push git hook - no manual version bumping is required for normal development workflows
|
||||
- **GitHub as Source of Truth**: Plugin store always fetches latest versions from GitHub (releases/tags/manifest/commit)
|
||||
- **Pre-Push Hook**: Automatically bumps patch version and creates git tags when pushing code changes
|
||||
- The hook is self-contained (no external dependencies) and works on any dev machine
|
||||
- Installation: Copy the hook from LEDMatrix repo to your plugin repo:
|
||||
```bash
|
||||
# From your plugin repository directory
|
||||
cp /path/to/LEDMatrix/scripts/git-hooks/pre-push-plugin-version .git/hooks/pre-push
|
||||
chmod +x .git/hooks/pre-push
|
||||
```
|
||||
- Or use the installer script from the main LEDMatrix repo (one-time setup)
|
||||
- The hook automatically:
|
||||
1. Bumps the patch version (x.y.Z) in manifest.json when code changes are detected
|
||||
2. Creates a git tag (v{version}) for the new version
|
||||
3. Stages manifest.json for commit
|
||||
- Skip auto-tagging: Set `SKIP_TAG=1` environment variable before pushing
|
||||
- **Manual Version Bumping (Edge Cases Only)**: Manual version bumps are only needed in rare circumstances:
|
||||
- CI/CD pipelines that bypass git hooks
|
||||
- Forked repositories without the pre-push hook installed
|
||||
- Major/minor version bumps (hook only handles patch versions)
|
||||
- When skipping auto-tagging but still needing a version bump
|
||||
- For manual bumps, use the standalone script: `scripts/bump_plugin_version.py`
|
||||
- **Registry**: The plugin registry (plugins.json) stores only metadata (name, description, repo URL) - no versions
|
||||
- **Version Priority**: Plugin store checks versions in this order: GitHub Releases → GitHub Tags → Manifest from branch → Git commit hash
|
||||
|
||||
## Resources
|
||||
|
||||
- Plugin System Docs: `docs/PLUGIN_ARCHITECTURE_SPEC.md`
|
||||
- Plugin Examples: `plugins/hockey-scoreboard/`, `plugins/football-scoreboard/`
|
||||
- Base Plugin: `src/plugin_system/base_plugin.py`
|
||||
- Plugin Manager: `src/plugin_system/plugin_manager.py`
|
||||
- Development Setup: `dev_plugin_setup.sh`
|
||||
- Example Config: `dev_plugins.json.example`
|
||||
|
||||
@@ -82,4 +82,8 @@ jobs:
|
||||
test/test_install_preserves_existing.py \
|
||||
test/test_core_owned_config_keys.py \
|
||||
test/test_async_plugin_updates.py \
|
||||
test/test_plugin_update_reservation.py
|
||||
test/test_plugin_update_reservation.py \
|
||||
test/test_template_targets.py \
|
||||
test/test_widget_scripts.py \
|
||||
test/test_doc_links.py \
|
||||
test/web_interface/test_cache.py
|
||||
|
||||
@@ -6,12 +6,16 @@
|
||||
- `config/config.json` — User plugin configuration (persists across plugin reinstalls)
|
||||
- `plugin-repos/` — **Default** plugin install directory used by the
|
||||
Plugin Store, set by `plugin_system.plugins_directory` in
|
||||
`config.json` (default per `config/config.template.json:130`).
|
||||
`config.json` (default per `config/config.template.json:167`).
|
||||
Not gitignored.
|
||||
- `plugins/` — Legacy/dev plugin location. Gitignored (`plugins/*`).
|
||||
Used by `scripts/dev/dev_plugin_setup.sh` for symlinks. The plugin
|
||||
loader falls back to it when something isn't found in `plugin-repos/`
|
||||
(`src/plugin_system/schema_manager.py:77`).
|
||||
loader does NOT fall back to it — `PluginManager.discover_plugins()`
|
||||
(`src/plugin_system/plugin_manager.py`) scans only the configured
|
||||
directory. Fallbacks exist in two narrower places: store operations
|
||||
(`StoreManager._find_plugin_path()` in `store_manager.py`) and schema
|
||||
lookup (`SchemaManager.get_schema_path()` in `schema_manager.py`,
|
||||
which probes `plugins/` *before* `plugin-repos/`).
|
||||
|
||||
## Plugin System
|
||||
- Plugins inherit from `BasePlugin` in `src/plugin_system/base_plugin.py`
|
||||
@@ -20,6 +24,16 @@
|
||||
- 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`
|
||||
- Secrets: namespaced by plugin id in `config/config_secrets.json`, declared
|
||||
via `"x-secret": true` in the plugin's config schema, and deep-merged into
|
||||
the plugin's config dict at load time — plugins read them with plain
|
||||
`config.get(...)`, never a separate accessor
|
||||
|
||||
## Dev Workflow
|
||||
- Link a plugin for development: `./scripts/dev/dev_plugin_setup.sh link-github <name>` (or `link <name> <path>`); symlinks land in `plugins/` — set `plugin_system.plugins_directory` to `plugins` so discovery picks them up
|
||||
- Browser preview without the display loop: `python3 scripts/dev_server.py` → http://localhost:5001
|
||||
- Full display in emulator mode: `python3 run.py -e` (or `EMULATOR=true python3 run.py`)
|
||||
- Validate one plugin headlessly: `python3 scripts/check_plugin.py --plugin <id>`
|
||||
|
||||
## Plugin Store Architecture
|
||||
- Official plugins live in the `ledmatrix-plugins` monorepo (not individual repos)
|
||||
@@ -33,7 +47,7 @@
|
||||
|
||||
## Skin System (visual overlays for sports scoreboards)
|
||||
- Skins live in `skins/<skin-id>/` (skin.json + skin.py), NOT in plugin dirs — plugin reinstall deletes plugin dirs
|
||||
- Core: `src/skin_system/` (ScoreboardSkin, SkinContext, runtime); hook: `SportsCore._render_game()` in `src/base_classes/sports.py`
|
||||
- Core: `src/skin_system/` (ScoreboardSkin, SkinContext, runtime); hook: `SportsCore._render_game()` in `src/base_classes/sports/core.py`
|
||||
- Skins render onto `ctx.canvas` only; fallback to built-in renderer on `False`/exception (3 strikes disables for session)
|
||||
- View-model guaranteed keys are frozen (see `test/test_skin_system.py::TestViewModelContract`) — renaming keys in `_extract_game_details_common` or sport extractors breaks published skins
|
||||
- Validate skins headlessly: `python scripts/validate_skin.py --skin <id>`; docs: `docs/SKIN_SYSTEM.md`, `docs/CREATING_SKINS.md`
|
||||
@@ -42,4 +56,7 @@
|
||||
## 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()`
|
||||
- `DisplayManager` has no `draw_image()` — paste onto the PIL image directly:
|
||||
`self.display_manager.image.paste(img, (x, y))` then `update_display()`
|
||||
(use a mask for transparency: `image.paste(rgba, (x, y), rgba)`)
|
||||
- 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
|
||||
|
||||
+8
-4
@@ -40,7 +40,7 @@ improvements, and code changes.
|
||||
## Running the tests
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
pip install -r requirements.txt -r requirements-test.txt
|
||||
pytest
|
||||
```
|
||||
|
||||
@@ -57,9 +57,13 @@ integration tests.
|
||||
`docs/<short-description>`.
|
||||
3. **Keep PRs focused.** One conceptual change per PR. If you find
|
||||
adjacent bugs while working, fix them in a separate PR.
|
||||
4. **Follow the existing code style.** Python code uses standard
|
||||
`black`/`ruff` conventions; HTML/JS in `web_interface/` follows the
|
||||
patterns already in `templates/v3/` and `static/v3/`.
|
||||
4. **Follow the existing code style.** The pre-commit hooks run
|
||||
`flake8` (E9, F63, F7, F82 plus bugbear `B` checks), `mypy` on
|
||||
`src/`, `bandit`, and `gitleaks` — install the CLI with
|
||||
`python -m pip install pre-commit`, then run
|
||||
`pre-commit install` so they run on every commit; HTML/JS in
|
||||
`web_interface/` follows the patterns already in `templates/v3/`
|
||||
and `static/v3/`.
|
||||
5. **Update documentation** alongside code changes. If you add a
|
||||
config key, document it in the relevant `*.md` file (or, for
|
||||
plugins, in `config_schema.json` so the form is auto-generated).
|
||||
|
||||
@@ -50,7 +50,15 @@ I'm trying to be open to constructive criticism and support, as long as it's a r
|
||||
|
||||
<details>
|
||||
<summary>Core Features</summary>
|
||||
The following plugins are available inside of the LEDMatrix project. These modular, rotating Displays that can be individually enabled or disabled per the user's needs with some configuration around display durations, teams, stocks, weather, timezones, and more. Displays include:
|
||||
LEDMatrix is a plugin platform: the displays below are plugins installed
|
||||
from the built-in Plugin Store (web interface → Plugins), where each can be
|
||||
individually enabled, ordered, and configured — display durations, teams,
|
||||
stocks, weather, timezones, and more. The core repo ships with just two
|
||||
bundled plugins (`starlark-apps` and `web-ui-info`); the official plugins
|
||||
live in the [ledmatrix-plugins](https://github.com/ChuckBuilds/ledmatrix-plugins)
|
||||
monorepo and install with one click, and third-party plugins can be
|
||||
installed from their own GitHub repositories. Displays available in the
|
||||
store include:
|
||||
|
||||
### Time and Weather
|
||||
- Real-time clock display (2x 64x32 Displays 4mm Pixel Pitch)
|
||||
@@ -372,6 +380,10 @@ This single script installs services, dependencies, configures permissions and s
|
||||
|
||||
### Initial Setup
|
||||
|
||||
For a complete list of every key in `config.json` and
|
||||
`config_secrets.json`, see
|
||||
[docs/CONFIG_REFERENCE.md](docs/CONFIG_REFERENCE.md).
|
||||
|
||||
For most settings I recommend using the web interface:
|
||||
Edit the project via the web interface at http://[IP ADDRESS or HOSTNAME]:5000 or http://ledpi:5000 .
|
||||
|
||||
@@ -417,7 +429,7 @@ I recommend using the web-ui "Quick Actions" to control the Display.
|
||||
## Plugins
|
||||
|
||||
<details>
|
||||
LEDMatrix uses a plugin-based architecture where all display functionality (except the core calendar) is implemented as plugins. All managers that were previously built into the core system are now available as plugins through the Plugin Store.
|
||||
LEDMatrix uses a plugin-based architecture where all display functionality is implemented as plugins. All managers that were previously built into the core system are now available as plugins through the Plugin Store.
|
||||
|
||||
### Plugin Store
|
||||
See the [Plugin Store documentation](https://github.com/ChuckBuilds/ledmatrix-plugins) for detailed installation instructions.
|
||||
@@ -610,12 +622,7 @@ These settings control runtime behavior and GPIO timing:
|
||||
|
||||
### Display Durations (`display.display_durations`)
|
||||
|
||||
Controls how long each display module stays visible in seconds before switching to the next one.
|
||||
|
||||
- **`calendar`** (integer, default: 30)
|
||||
- Duration in seconds for the calendar display
|
||||
- Increase for more time to read dates/events
|
||||
- Decrease to cycle through other displays faster
|
||||
Controls how long each installed plugin stays visible in seconds before switching to the next one, keyed by plugin id.
|
||||
|
||||
- **Plugin-specific durations**
|
||||
- Each plugin can have its own duration setting
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# assets/
|
||||
|
||||
Static assets bundled with LEDMatrix. **Do not delete these directories** —
|
||||
several look unused from core code alone but are resolved at runtime by
|
||||
installed store plugins.
|
||||
|
||||
| Directory | Used by |
|
||||
|---|---|
|
||||
| `fonts/` | Core (`FontManager`, `DisplayManager`) and most plugins |
|
||||
| `sports/` | Core logo tooling (`src/logo_downloader.py`) and the sports scoreboard plugins; team logos are downloaded here on demand |
|
||||
| `stocks/` | `ledmatrix-stocks` plugin (`crypto_icons/`, `ticker_icons/`) |
|
||||
| `weather/` | `ledmatrix-weather` plugin (weather icons) |
|
||||
| `news_logos/` | `news` plugin |
|
||||
| `broadcast_logos/` | `news` and `odds-ticker` plugins |
|
||||
| `static_images/` | Legacy examples referenced in the `static-image` plugin's docs; the plugin itself stores uploads under `assets/plugins/<plugin-id>/uploads/` |
|
||||
| `plugins/` | Per-plugin uploaded files (`assets/plugins/<plugin-id>/uploads/`), served by the web interface |
|
||||
|
||||
Plugins resolve these paths relative to the LEDMatrix install directory, so
|
||||
the directories are part of the de-facto plugin API even where no file in
|
||||
this repo references them. New plugins should bundle their own assets or
|
||||
use the per-plugin upload directory instead of adding top-level
|
||||
directories here.
|
||||
@@ -110,7 +110,11 @@
|
||||
"inverse_colors": false,
|
||||
"show_refresh_rate": false,
|
||||
"led_rgb_sequence": "RGB",
|
||||
"limit_refresh_rate_hz": 100
|
||||
"limit_refresh_rate_hz": 100,
|
||||
"pixel_mapper_config": "",
|
||||
"row_address_type": 0,
|
||||
"multiplexing": 0,
|
||||
"panel_type": ""
|
||||
},
|
||||
"runtime": {
|
||||
"gpio_slowdown": 3,
|
||||
@@ -149,7 +153,9 @@
|
||||
"overflow_mode": "rotate",
|
||||
"dynamic_duration_enabled": true,
|
||||
"min_cycle_duration": 60,
|
||||
"max_cycle_duration": 240
|
||||
"max_cycle_duration": 240,
|
||||
"frame_based_scrolling": true,
|
||||
"scroll_delay": 0.02
|
||||
}
|
||||
},
|
||||
"sync": {
|
||||
@@ -160,7 +166,8 @@
|
||||
"plugin_system": {
|
||||
"plugins_directory": "plugin-repos",
|
||||
"auto_discover": true,
|
||||
"auto_load_enabled": true
|
||||
"auto_load_enabled": true,
|
||||
"development_mode": false
|
||||
},
|
||||
"web-ui-info": {
|
||||
"enabled": true,
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
{
|
||||
"youtube": {
|
||||
"api_key": "YOUR_YOUTUBE_API_KEY",
|
||||
"channel_id": "YOUR_YOUTUBE_CHANNEL_ID"
|
||||
},
|
||||
"github": {
|
||||
"api_token": "YOUR_GITHUB_PERSONAL_ACCESS_TOKEN"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+48
-27
@@ -47,6 +47,11 @@ Enable Vegas mode in `config/config.json`:
|
||||
}
|
||||
```
|
||||
|
||||
Vegas mode can also be configured entirely from the web UI — the
|
||||
**Display** tab has a Vegas Scroll Mode section (enable toggle, scroll
|
||||
speed, separator width, dynamic duration, and more), so hand-editing
|
||||
JSON is optional.
|
||||
|
||||
**Configuration Options:**
|
||||
|
||||
| Setting | Default | Description |
|
||||
@@ -57,7 +62,11 @@ Enable Vegas mode in `config/config.json`:
|
||||
| `plugin_order` | `[]` | Plugin display order (empty = auto) |
|
||||
| `excluded_plugins` | `[]` | Plugins to exclude from Vegas mode |
|
||||
| `target_fps` | `125` | Target frame rate |
|
||||
| `buffer_ahead` | `2` | Number of panels to render ahead |
|
||||
| `buffer_ahead` | `2` | Number of plugins buffered ahead |
|
||||
|
||||
This table is a subset — `display.vegas_scroll` supports 26 keys in
|
||||
total. See the full list in
|
||||
[CONFIG_REFERENCE.md](CONFIG_REFERENCE.md#displayvegas_scroll--continuous-scroll-mode).
|
||||
|
||||
### Per-Plugin Configuration
|
||||
|
||||
@@ -79,9 +88,13 @@ Override Vegas behavior for specific plugins:
|
||||
| Setting | Values | Description |
|
||||
|---------|--------|-------------|
|
||||
| `vegas_mode` | `scroll`, `fixed`, `static` | Display mode for this plugin |
|
||||
| `vegas_panel_count` | `1-10` | Width in panels (1 panel = display width) |
|
||||
| `vegas_panel_count` | any positive integer | Width in panels (1 panel = display width) |
|
||||
| `display_duration` | seconds | Pause duration for STATIC mode |
|
||||
|
||||
Plugins may also set `vegas_overflow` and `vegas_max_width_screens` in
|
||||
their config section to control how oversized content is handled (see
|
||||
`PluginManager` in `src/plugin_system/plugin_manager.py`).
|
||||
|
||||
### Plugin Integration (Developer Guide)
|
||||
|
||||
**1. Implement Content Method:**
|
||||
@@ -451,7 +464,7 @@ time when something is active.
|
||||
|
||||
### REST API Reference
|
||||
|
||||
The API is mounted at `/api/v3` (`web_interface/app.py:144`).
|
||||
The API is mounted at `/api/v3` (`web_interface/app.py:199`).
|
||||
|
||||
#### Start On-Demand Display
|
||||
|
||||
@@ -518,13 +531,15 @@ curl http://localhost:5000/api/v3/display/on-demand/status
|
||||
|
||||
> There is no public Python on-demand API. The display controller's
|
||||
> on-demand machinery is internal — drive it through the REST endpoints
|
||||
> above (or the web UI buttons), which write a request into the cache
|
||||
> manager under the `display_on_demand_request` key
|
||||
> (`web_interface/blueprints/api_v3.py:1622,1687`) that the controller
|
||||
> polls at `src/display_controller.py:921`. A separate
|
||||
> above (or the web UI buttons). The API handlers
|
||||
> (`start_on_demand_display()` / `stop_on_demand_display()` in
|
||||
> `web_interface/blueprints/api_v3.py`) write a request into the cache
|
||||
> manager under the `display_on_demand_request` key, which
|
||||
> `DisplayController._poll_on_demand_requests()`
|
||||
> (`src/display_controller.py`) picks up. A separate
|
||||
> `display_on_demand_config` key is used by the controller itself
|
||||
> during activation to track what's currently running (written at
|
||||
> `display_controller.py:1195`, cleared at `:1221`).
|
||||
> during activation (`_activate_on_demand()`) to track what's
|
||||
> currently running, and is cleared by `_clear_on_demand()`.
|
||||
|
||||
### Duration Modes
|
||||
|
||||
@@ -646,13 +661,13 @@ keys helps troubleshoot stuck states.
|
||||
**When Set:** Every display loop iteration
|
||||
**Auto-Cleared:** Never (continuously updated)
|
||||
|
||||
**4. display_on_demand_processed_id** (TTL: 5 minutes)
|
||||
```
|
||||
**4. display_on_demand_processed_id** (TTL: 1 hour)
|
||||
```text
|
||||
"uuid-string-of-last-processed-request"
|
||||
```
|
||||
**Purpose:** Prevents duplicate request processing
|
||||
**When Set:** After processing request
|
||||
**Auto-Cleared:** After 5 minutes TTL
|
||||
**Auto-Cleared:** After 1 hour TTL
|
||||
|
||||
### When Manual Clearing is Needed
|
||||
|
||||
@@ -685,9 +700,9 @@ keys helps troubleshoot stuck states.
|
||||
The cache is stored as JSON files under one of:
|
||||
|
||||
- `/var/cache/ledmatrix/` (preferred when the service has permission)
|
||||
- `~/.cache/ledmatrix/`
|
||||
- `~/.ledmatrix_cache/`
|
||||
- `/opt/ledmatrix/cache/`
|
||||
- `/tmp/ledmatrix-cache/` (fallback)
|
||||
- `$TMPDIR/ledmatrix_cache/` (fallback)
|
||||
|
||||
```bash
|
||||
# Find the cache dir actually in use
|
||||
@@ -711,8 +726,9 @@ cache.clear_cache('display_on_demand_request')
|
||||
cache.clear_cache('display_on_demand_processed_id')
|
||||
```
|
||||
|
||||
> The actual public method is `clear_cache(key=None)` — there is no
|
||||
> `delete()` method on `CacheManager`.
|
||||
> `CacheManager` also has a `delete(key)` method — a thin wrapper over
|
||||
> `clear_cache(key)` — so `cache.delete('display_on_demand_config')`
|
||||
> works equally well.
|
||||
|
||||
### Cache Impact on Running Service
|
||||
|
||||
@@ -730,7 +746,7 @@ The display controller automatically handles cleanup:
|
||||
- **Config key**: Cleared when on-demand stops
|
||||
- **State key**: Updated every display loop iteration
|
||||
- **Request key**: Expires after 1 hour TTL (or after processing)
|
||||
- **Processed ID**: Expires after 5 minutes TTL
|
||||
- **Processed ID**: Expires after 1 hour TTL
|
||||
|
||||
---
|
||||
|
||||
@@ -821,9 +837,6 @@ same shape as the example above.
|
||||
### Testing
|
||||
|
||||
```bash
|
||||
# Run background service test
|
||||
python test_background_service.py
|
||||
|
||||
# Check logs for background operations
|
||||
sudo journalctl -u ledmatrix -f | grep "background"
|
||||
```
|
||||
@@ -832,9 +845,10 @@ sudo journalctl -u ledmatrix -f | grep "background"
|
||||
|
||||
**View Statistics:**
|
||||
```python
|
||||
from src.background_data_service import BackgroundDataService
|
||||
from src.background_data_service import get_background_service
|
||||
from src.cache_manager import CacheManager
|
||||
|
||||
service = BackgroundDataService()
|
||||
service = get_background_service(CacheManager())
|
||||
stats = service.get_statistics()
|
||||
print(f"Active tasks: {stats['active_tasks']}")
|
||||
print(f"Completed: {stats['completed']}")
|
||||
@@ -875,6 +889,7 @@ from src.common.permission_utils import (
|
||||
ensure_file_permissions,
|
||||
get_config_file_mode,
|
||||
get_assets_file_mode,
|
||||
get_assets_dir_mode,
|
||||
get_plugin_file_mode,
|
||||
get_cache_dir_mode
|
||||
)
|
||||
@@ -883,7 +898,10 @@ from src.common.permission_utils import (
|
||||
ensure_directory_permissions(Path("assets/sports"), get_assets_dir_mode())
|
||||
|
||||
# Set file permissions after writing
|
||||
ensure_file_permissions(Path("config/config.json"), get_config_file_mode())
|
||||
# (get_config_file_mode requires the file path — secrets files get a
|
||||
# stricter mode than the main config)
|
||||
config_path = Path("config/config.json")
|
||||
ensure_file_permissions(config_path, get_config_file_mode(config_path))
|
||||
```
|
||||
|
||||
### When to Use Utilities
|
||||
@@ -938,7 +956,7 @@ from src.common.permission_utils import ensure_file_permissions, get_config_file
|
||||
config_path = Path("config/config.json")
|
||||
with open(config_path, 'w') as f:
|
||||
json.dump(data, f)
|
||||
ensure_file_permissions(config_path, get_config_file_mode())
|
||||
ensure_file_permissions(config_path, get_config_file_mode(config_path))
|
||||
```
|
||||
|
||||
**Pattern 3: Downloading Logo**
|
||||
@@ -984,8 +1002,11 @@ These core utilities **already handle permissions** - you don't need to call per
|
||||
If you encounter permission issues:
|
||||
|
||||
```bash
|
||||
# Fix all permissions at once
|
||||
sudo ./scripts/fix_permissions.sh
|
||||
# Targeted permission fixes (see scripts/fix_perms/README.md)
|
||||
sudo ./scripts/fix_perms/fix_assets_permissions.sh # assets/ tree (logos, fonts)
|
||||
sudo ./scripts/fix_perms/fix_cache_permissions.sh # all cache directories
|
||||
sudo ./scripts/fix_perms/fix_plugin_permissions.sh # plugin directories
|
||||
sudo ./scripts/fix_perms/fix_web_permissions.sh # web interface files
|
||||
|
||||
# Fix specific directory
|
||||
sudo chown -R ledpi:ledpi /home/ledpi/LEDMatrix/config
|
||||
@@ -1017,7 +1038,7 @@ stat -c "%a %n" config/config.json
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [PLUGIN_DEVELOPMENT.md](PLUGIN_DEVELOPMENT.md) - Creating plugins with Vegas/on-demand support
|
||||
- [PLUGIN_DEVELOPMENT_GUIDE.md](PLUGIN_DEVELOPMENT_GUIDE.md) - Creating plugins with Vegas/on-demand support
|
||||
- [WEB_INTERFACE_GUIDE.md](WEB_INTERFACE_GUIDE.md) - Using on-demand controls in web UI
|
||||
- [PLUGIN_API_REFERENCE.md](PLUGIN_API_REFERENCE.md) - Complete API documentation
|
||||
- [DEVELOPMENT.md](DEVELOPMENT.md) - Development environment and testing
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
# Configuration Reference
|
||||
|
||||
Every key in `config/config.json`, what it does, its default, and where the
|
||||
code reads it. The file is created from `config/config.template.json` on
|
||||
first run, and `ConfigManager._migrate_config()` merges any template keys
|
||||
added by later releases into your existing config (your values are never
|
||||
overwritten). Secrets live in `config/config_secrets.json` and are merged
|
||||
into the config at load time.
|
||||
|
||||
Most settings are editable from the web interface; this page documents the
|
||||
underlying keys for people editing `config.json` directly or writing
|
||||
tooling against it.
|
||||
|
||||
## Top level
|
||||
|
||||
| Key | Type / default | Meaning | Read by |
|
||||
|---|---|---|---|
|
||||
| `web_display_autostart` | bool, `true` | Whether the web interface service starts with the system | `scripts/utils/start_web_conditionally.py` |
|
||||
| `timezone` | string, `"America/New_York"` | IANA timezone for schedules and displays | `ConfigManager.get_timezone()` |
|
||||
| `target_fps` | int, `100` | Frame-rate ceiling for plugin rendering | `src/plugin_system/base_plugin.py`, `src/common/sports_scroll.py` |
|
||||
| `location` | object | `city` / `state` / `country`, offered to plugins that need a location (weather, etc.) | plugins via merged config |
|
||||
|
||||
## `schedule` — display on/off hours
|
||||
|
||||
| Key | Type / default | Meaning |
|
||||
|---|---|---|
|
||||
| `enabled` | bool, `false` | Master switch for scheduled display on/off |
|
||||
| `mode` | `"global"` or `"per-day"`, template uses `"per-day"` | Whether one time range applies to all days or each day has its own |
|
||||
| `start_time` / `end_time` | `"HH:MM"`, `07:00`–`23:00` | Global-mode on/off times |
|
||||
| `days.<weekday>.{enabled,start_time,end_time}` | per-day objects | Per-day-mode overrides |
|
||||
|
||||
Read by `DisplayController` (`src/display_controller.py`, `_check_schedule`
|
||||
around line 603). Managed in the web UI under Schedule.
|
||||
|
||||
## `dim_schedule` — scheduled brightness dimming
|
||||
|
||||
Same shape as `schedule`, plus:
|
||||
|
||||
| Key | Type / default | Meaning |
|
||||
|---|---|---|
|
||||
| `dim_brightness` | int, `30` | Brightness percentage applied while the dim window is active |
|
||||
|
||||
Read by `DisplayController` (`src/display_controller.py` around line 770;
|
||||
saved via `POST /api/v3/config/dim-schedule`). The display returns to
|
||||
`display.hardware.brightness` outside the window.
|
||||
|
||||
## `display.hardware` — matrix panel hardware
|
||||
|
||||
All keys map to the corresponding `rpi-rgb-led-matrix` options and are read
|
||||
in `DisplayManager` (`src/display_manager.py`, ~lines 270–295).
|
||||
|
||||
| Key | Type / default |
|
||||
|---|---|
|
||||
| `rows` / `cols` | int, `32` / `64` |
|
||||
| `chain_length` | int, `2` |
|
||||
| `parallel` | int, `1` |
|
||||
| `brightness` | int, `90` |
|
||||
| `hardware_mapping` | string, `"adafruit-hat"` (code default `"adafruit-hat-pwm"`) |
|
||||
| `scan_mode` | int, `0` |
|
||||
| `pwm_bits` | int, `9` (code default 10) |
|
||||
| `pwm_dither_bits` | int, `1` |
|
||||
| `pwm_lsb_nanoseconds` | int, `130` (code default 150) |
|
||||
| `disable_hardware_pulsing` | bool, `false` |
|
||||
| `inverse_colors` | bool, `false` |
|
||||
| `show_refresh_rate` | bool, `false` |
|
||||
| `led_rgb_sequence` | string, `"RGB"` |
|
||||
| `limit_refresh_rate_hz` | int, `100` (code default 90) |
|
||||
| `pixel_mapper_config` | string, `""` — e.g. `"U-mapper"` / `"Rotate:90"` |
|
||||
| `row_address_type` | int, `0` — non-standard panel row addressing |
|
||||
| `multiplexing` | int, `0` — panel multiplexing scheme |
|
||||
| `panel_type` | string, `""` — set to `"FM6126A"` or `"FM6127"` for panels needing init |
|
||||
|
||||
Where "code default" differs from the template value, the code default only
|
||||
applies if the key is missing entirely from your config.
|
||||
|
||||
## `display.runtime`
|
||||
|
||||
| Key | Type / default | Meaning |
|
||||
|---|---|---|
|
||||
| `gpio_slowdown` | int, `3` | GPIO timing slowdown for faster Pis |
|
||||
| `rp1_rio` | int, `0` | RP1 RIO mode on Pi 5 (applied only if the installed matrix library supports it) |
|
||||
|
||||
## `display.double_sided`
|
||||
|
||||
Drives `_LogicalMatrix` in `src/display_manager.py` — renders the same
|
||||
logical image to multiple chained physical panels.
|
||||
|
||||
| Key | Type / default | Meaning |
|
||||
|---|---|---|
|
||||
| `enabled` | bool, `false` | Mirror output across panel copies |
|
||||
| `copies` | int, `2` | Number of physical copies in the chain |
|
||||
| `axis` | `"horizontal"`, default | Axis along which panels are chained |
|
||||
|
||||
## `display` — other keys
|
||||
|
||||
| Key | Type / default | Meaning | Read by |
|
||||
|---|---|---|---|
|
||||
| `display_durations` | object, `{}` | Per-plugin display duration in seconds, keyed by plugin id (e.g. `"clock": 15`) | `src/display_controller.py:1030` |
|
||||
| `plugin_rotation_order` | array, `[]` | Explicit rotation order of plugin ids; empty = all enabled plugins in discovery order | `src/display_controller.py:2894` |
|
||||
| `use_short_date_format` | bool, `true` | Compact date rendering in sports scoreboards | `src/base_classes/sports/core.py` |
|
||||
| `dynamic_duration.max_duration_seconds` | int, optional | Cap for plugins that request dynamic display time | `src/display_controller.py:405` |
|
||||
|
||||
## `display.vegas_scroll` — continuous scroll mode
|
||||
|
||||
Read by `src/vegas_mode/config.py` (`VegasScrollConfig.from_config`). See
|
||||
[ADVANCED_FEATURES.md](ADVANCED_FEATURES.md) for behavior details.
|
||||
|
||||
| Key | Type / default |
|
||||
|---|---|
|
||||
| `enabled` | bool, `false` |
|
||||
| `scroll_speed` | int, `50` (px/s) |
|
||||
| `separator_width` | int, `32` |
|
||||
| `plugin_order` | array, `[]` |
|
||||
| `excluded_plugins` | array, `[]` |
|
||||
| `target_fps` | int, `125` |
|
||||
| `buffer_ahead` | int, `2` |
|
||||
| `intra_plugin_gap` | int, `8` |
|
||||
| `render_width_pct` | int, `100` |
|
||||
| `min_content_separation` | int, `24` |
|
||||
| `min_cut_gap` | int, `6` |
|
||||
| `continuous_scroll` | bool, `true` |
|
||||
| `smooth_scroll` | bool, `true` |
|
||||
| `extend_threshold_screens` | float, `2.0` |
|
||||
| `auto_trim` | bool, `true` |
|
||||
| `trim_threshold` | int, `10` |
|
||||
| `content_padding` | int, `8` |
|
||||
| `min_plugin_width` | int, `8` |
|
||||
| `lead_in_width` | int, `0` |
|
||||
| `plugins_per_cycle` | int, `6` |
|
||||
| `max_plugin_width_ratio` | float, `3.0` |
|
||||
| `overflow_mode` | string, `"rotate"` |
|
||||
| `dynamic_duration_enabled` | bool, `true` |
|
||||
| `min_cycle_duration` | int, `60` |
|
||||
| `max_cycle_duration` | int, `240` |
|
||||
| `frame_based_scrolling` | bool, `true` — frame-count-based scroll stepping |
|
||||
| `scroll_delay` | float, `0.02` — seconds between scroll updates (~50 FPS) |
|
||||
|
||||
## `sync` — multi-display synchronization
|
||||
|
||||
Read by `src/common/sync_manager.py` and `src/display_controller.py`.
|
||||
|
||||
| Key | Type / default | Meaning |
|
||||
|---|---|---|
|
||||
| `role` | `"standalone"` (default), `"leader"`, or `"follower"` | This device's role in a synced pair |
|
||||
| `port` | int, `5765` | TCP port used for sync traffic |
|
||||
| `follower_position` | `"left"` (default) or `"right"` | Which half of the combined image this follower renders (`src/display_controller.py:522`) |
|
||||
|
||||
## `plugin_system`
|
||||
|
||||
Read by the plugin loader/manager (`src/plugin_system/`).
|
||||
|
||||
| Key | Type / default | Meaning |
|
||||
|---|---|---|
|
||||
| `plugins_directory` | string, `"plugin-repos"` | Where the Plugin Store installs plugins |
|
||||
| `auto_discover` | bool, `true` | Scan the plugins directory at startup |
|
||||
| `auto_load_enabled` | bool, `true` | Load discovered plugins automatically |
|
||||
| `development_mode` | bool, `false` | Development conveniences in the web UI (editable under General settings) |
|
||||
|
||||
## Plugin config blocks
|
||||
|
||||
Every installed plugin stores its settings under a top-level key equal to
|
||||
its plugin id (the template ships one for the bundled `web-ui-info`
|
||||
plugin). The shape of each block is defined by that plugin's
|
||||
`config_schema.json`; common keys are `enabled` and `display_duration`.
|
||||
See [PLUGIN_CONFIG_CORE_PROPERTIES.md](PLUGIN_CONFIG_CORE_PROPERTIES.md).
|
||||
|
||||
## `config/config_secrets.json`
|
||||
|
||||
| Key | Meaning |
|
||||
|---|---|
|
||||
| `github.api_token` | Optional GitHub token the Plugin Store uses to avoid API rate limits (`src/plugin_system/store_manager.py:348`) |
|
||||
| `<plugin-id>.*` | Secrets a plugin declares with `"x-secret": true` in its config schema; merged into that plugin's config at load time |
|
||||
@@ -31,7 +31,7 @@ POST /api/v3/system/action
|
||||
|
||||
**Base URL**: `http://your-pi-ip:5000/api/v3`
|
||||
|
||||
See [API_REFERENCE.md](API_REFERENCE.md) for complete documentation.
|
||||
See [REST_API_REFERENCE.md](REST_API_REFERENCE.md) for complete documentation.
|
||||
|
||||
## Display Manager Quick Methods
|
||||
|
||||
@@ -190,12 +190,13 @@ def display(self, force_clear=False):
|
||||
|
||||
```
|
||||
LEDMatrix/
|
||||
├── plugins/ # Installed plugins
|
||||
├── plugin-repos/ # Installed plugins (default; plugins/ is only
|
||||
│ # for dev symlinks via scripts/dev/dev_plugin_setup.sh)
|
||||
├── config/
|
||||
│ ├── config.json # Main configuration
|
||||
│ └── config_secrets.json # API keys and secrets
|
||||
├── docs/ # Documentation
|
||||
│ ├── API_REFERENCE.md
|
||||
│ ├── REST_API_REFERENCE.md
|
||||
│ ├── PLUGIN_API_REFERENCE.md
|
||||
│ └── ...
|
||||
└── src/
|
||||
@@ -207,7 +208,7 @@ LEDMatrix/
|
||||
|
||||
## Quick Links
|
||||
|
||||
- [Complete API Reference](API_REFERENCE.md)
|
||||
- [Complete REST API Reference](REST_API_REFERENCE.md)
|
||||
- [Plugin API Reference](PLUGIN_API_REFERENCE.md)
|
||||
- [Plugin Development Guide](PLUGIN_DEVELOPMENT_GUIDE.md)
|
||||
- [Advanced Patterns](ADVANCED_PLUGIN_DEVELOPMENT.md)
|
||||
|
||||
@@ -69,23 +69,24 @@ default configuration as it ships in the repo:
|
||||
```json
|
||||
{
|
||||
"pixel_outline": 0,
|
||||
"pixel_size": 5,
|
||||
"pixel_size": 16,
|
||||
"pixel_style": "square",
|
||||
"pixel_glow": 6,
|
||||
"display_adapter": "pygame",
|
||||
"display_adapter": "browser",
|
||||
"allow_adapter_fallback": true,
|
||||
"icon_path": null,
|
||||
"emulator_title": null,
|
||||
"suppress_font_warnings": false,
|
||||
"suppress_adapter_load_errors": false,
|
||||
"browser": {
|
||||
"_comment": "For use with the browser adapter only.",
|
||||
"port": 8888,
|
||||
"target_fps": 24,
|
||||
"target_fps": 60,
|
||||
"fps_display": false,
|
||||
"quality": 70,
|
||||
"image_border": true,
|
||||
"debug_text": false,
|
||||
"image_format": "JPEG"
|
||||
"image_format": "JPEG",
|
||||
"open_immediately": false
|
||||
},
|
||||
"log_level": "info"
|
||||
}
|
||||
@@ -96,13 +97,13 @@ default configuration as it ships in the repo:
|
||||
| Option | Description | Default | Values |
|
||||
|--------|-------------|---------|--------|
|
||||
| `pixel_outline` | Pixel border thickness | 0 | 0-5 |
|
||||
| `pixel_size` | Size of each pixel | 5 | 1-64 (8–16 is typical for testing) |
|
||||
| `pixel_size` | Size of each pixel | 16 | 1-64 (8–16 is typical for testing) |
|
||||
| `pixel_style` | Pixel shape | "square" | "square", "circle" |
|
||||
| `pixel_glow` | Glow effect intensity | 6 | 0-20 |
|
||||
| `display_adapter` | Display backend | "pygame" | "pygame", "browser" |
|
||||
| `display_adapter` | Display backend | "browser" | "browser", "pygame" |
|
||||
| `allow_adapter_fallback` | Fall back to another adapter if the configured one fails to load | true | true/false |
|
||||
| `emulator_title` | Window title | null | Any string |
|
||||
| `suppress_font_warnings` | Hide font warnings | false | true/false |
|
||||
| `suppress_adapter_load_errors` | Hide adapter errors | false | true/false |
|
||||
|
||||
### 3. Browser Adapter Configuration
|
||||
|
||||
@@ -111,18 +112,32 @@ When using the browser adapter, additional options are available:
|
||||
| Option | Description | Default |
|
||||
|--------|-------------|---------|
|
||||
| `port` | Web server port | 8888 |
|
||||
| `target_fps` | Target frames per second | 24 |
|
||||
| `target_fps` | Target frames per second | 60 |
|
||||
| `fps_display` | Show FPS counter | false |
|
||||
| `quality` | Image compression quality | 70 |
|
||||
| `image_border` | Show image border | true |
|
||||
| `debug_text` | Show debug information | false |
|
||||
| `image_format` | Image format | "JPEG" |
|
||||
| `open_immediately` | Open the browser page automatically on start | false |
|
||||
|
||||
## Running the Emulator
|
||||
|
||||
### 1. Set Environment Variable
|
||||
### 1. Use the `-e` Flag (Recommended)
|
||||
|
||||
Enable emulator mode by setting the `EMULATOR` environment variable:
|
||||
`run.py` accepts exactly two flags: `-e`/`--emulator` and
|
||||
`-d`/`--debug`.
|
||||
|
||||
```bash
|
||||
python3 run.py -e
|
||||
|
||||
# With verbose logging
|
||||
python3 run.py -e -d
|
||||
```
|
||||
|
||||
### 2. Alternative: Set the Environment Variable
|
||||
|
||||
You can also enable emulator mode via the `EMULATOR` environment
|
||||
variable:
|
||||
|
||||
**Windows (Command Prompt):**
|
||||
```cmd
|
||||
@@ -137,15 +152,6 @@ python run.py
|
||||
```
|
||||
|
||||
**Linux/macOS:**
|
||||
```bash
|
||||
export EMULATOR=true
|
||||
python3 run.py
|
||||
```
|
||||
|
||||
### 2. Alternative: Direct Python Execution
|
||||
|
||||
You can also run the emulator directly:
|
||||
|
||||
```bash
|
||||
EMULATOR=true python3 run.py
|
||||
```
|
||||
@@ -153,7 +159,8 @@ EMULATOR=true python3 run.py
|
||||
### 3. Verify Emulator Mode
|
||||
|
||||
When running in emulator mode, you should see:
|
||||
- A window displaying the LED matrix simulation
|
||||
- The emulated matrix — a web page at `http://localhost:8888` with the
|
||||
default browser adapter, or a desktop window with the pygame adapter
|
||||
- Console output indicating emulator mode
|
||||
- No hardware initialization errors
|
||||
|
||||
@@ -161,7 +168,36 @@ When running in emulator mode, you should see:
|
||||
|
||||
LEDMatrix supports two display adapters for the emulator:
|
||||
|
||||
### 1. Pygame Adapter (Default)
|
||||
### 1. Browser Adapter (Default)
|
||||
|
||||
The browser adapter runs a web server and displays the matrix as a web
|
||||
page at `http://localhost:8888`. This is the adapter the shipped
|
||||
`emulator_config.json` uses.
|
||||
|
||||
**Features:**
|
||||
- Web-based interface
|
||||
- Remote access capability
|
||||
- Mobile-friendly
|
||||
- Screenshot capture
|
||||
|
||||
**Configuration:**
|
||||
```json
|
||||
{
|
||||
"display_adapter": "browser",
|
||||
"browser": {
|
||||
"port": 8888,
|
||||
"target_fps": 60,
|
||||
"quality": 70
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Usage:**
|
||||
1. Start the emulator (`python3 run.py -e`)
|
||||
2. Open browser to `http://localhost:8888`
|
||||
3. View the LED matrix display
|
||||
|
||||
### 2. Pygame Adapter (Alternative)
|
||||
|
||||
The pygame adapter provides a native desktop window with real-time display.
|
||||
|
||||
@@ -186,33 +222,6 @@ The pygame adapter provides a native desktop window with real-time display.
|
||||
- `+/-` - Zoom in/out
|
||||
- `R` - Reset zoom
|
||||
|
||||
### 2. Browser Adapter
|
||||
|
||||
The browser adapter runs a web server and displays the matrix in a web browser.
|
||||
|
||||
**Features:**
|
||||
- Web-based interface
|
||||
- Remote access capability
|
||||
- Mobile-friendly
|
||||
- Screenshot capture
|
||||
|
||||
**Configuration:**
|
||||
```json
|
||||
{
|
||||
"display_adapter": "browser",
|
||||
"browser": {
|
||||
"port": 8888,
|
||||
"target_fps": 24,
|
||||
"quality": 70
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Usage:**
|
||||
1. Start the emulator with browser adapter
|
||||
2. Open browser to `http://localhost:8888`
|
||||
3. View the LED matrix display
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
@@ -274,8 +283,7 @@ Enable debug logging:
|
||||
```json
|
||||
{
|
||||
"log_level": "debug",
|
||||
"suppress_font_warnings": false,
|
||||
"suppress_adapter_load_errors": false
|
||||
"suppress_font_warnings": false
|
||||
}
|
||||
```
|
||||
|
||||
@@ -299,17 +307,18 @@ Modify the display dimensions in your main config:
|
||||
|
||||
### 2. Plugin Development
|
||||
|
||||
For plugin development with the emulator:
|
||||
`run.py` always runs the full rotation — it has no single-plugin flag.
|
||||
To preview or check one plugin in isolation, use the dev tools:
|
||||
|
||||
```bash
|
||||
# Enable emulator mode
|
||||
export EMULATOR=true
|
||||
# Run the full display in emulator mode (optionally with debug logging)
|
||||
python3 run.py -e -d
|
||||
|
||||
# Run with specific plugin
|
||||
python run.py --plugin my-plugin
|
||||
# Live single-plugin preview in the browser (port 5001)
|
||||
python3 scripts/dev_server.py
|
||||
|
||||
# Debug mode
|
||||
python run.py --debug
|
||||
# Headless render/validation of one plugin
|
||||
python3 scripts/check_plugin.py --plugin my-plugin
|
||||
```
|
||||
|
||||
### 3. Performance Tuning
|
||||
@@ -344,11 +353,10 @@ The emulator can work alongside the web interface:
|
||||
|
||||
```bash
|
||||
# Terminal 1: Start emulator
|
||||
export EMULATOR=true
|
||||
python run.py
|
||||
python3 run.py -e
|
||||
|
||||
# Terminal 2: Start web interface
|
||||
python web_interface/app.py
|
||||
# Terminal 2: Start web interface (supported entry point)
|
||||
python3 web_interface/start.py
|
||||
```
|
||||
|
||||
Access the web interface at `http://localhost:5000` while the emulator runs.
|
||||
@@ -365,13 +373,14 @@ Access the web interface at `http://localhost:5000` while the emulator runs.
|
||||
### 2. Plugin Testing
|
||||
|
||||
```bash
|
||||
# Test specific plugin
|
||||
export EMULATOR=true
|
||||
python run.py --plugin clock-simple
|
||||
# Test a specific plugin (headless check)
|
||||
python3 scripts/check_plugin.py --plugin clock-simple
|
||||
|
||||
# Test all plugins
|
||||
export EMULATOR=true
|
||||
python run.py --test-plugins
|
||||
# Preview a single plugin live in the browser (port 5001)
|
||||
python3 scripts/dev_server.py
|
||||
|
||||
# Test the full rotation in the emulator
|
||||
python3 run.py -e
|
||||
```
|
||||
|
||||
### 3. Configuration Management
|
||||
@@ -385,9 +394,8 @@ python run.py --test-plugins
|
||||
### Basic Clock Display
|
||||
|
||||
```bash
|
||||
# Start emulator with clock
|
||||
export EMULATOR=true
|
||||
python run.py
|
||||
# Start emulator with clock enabled in config.json
|
||||
python3 run.py -e
|
||||
```
|
||||
|
||||
### Sports Scores
|
||||
@@ -395,16 +403,16 @@ python run.py
|
||||
```bash
|
||||
# Configure for sports display
|
||||
# Edit config/config.json to enable sports plugins
|
||||
export EMULATOR=true
|
||||
python run.py
|
||||
python3 run.py -e
|
||||
```
|
||||
|
||||
### Custom Text Display
|
||||
|
||||
```bash
|
||||
# Use text display plugin
|
||||
export EMULATOR=true
|
||||
python run.py --plugin text-display --text "Hello World"
|
||||
# Preview the text display plugin on its own
|
||||
python3 scripts/check_plugin.py --plugin text-display
|
||||
# or use the live dev preview server
|
||||
python3 scripts/dev_server.py
|
||||
```
|
||||
|
||||
## Support
|
||||
|
||||
+41
-15
@@ -21,18 +21,30 @@ This guide will help you set up your LEDMatrix display for the first time and ge
|
||||
|
||||
---
|
||||
|
||||
## Quick Start (5 Minutes)
|
||||
## Quick Start
|
||||
|
||||
### 1. First Boot
|
||||
### 1. Install LEDMatrix
|
||||
|
||||
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)
|
||||
There is no prebuilt SD card image — you install LEDMatrix onto stock
|
||||
Raspberry Pi OS Lite yourself:
|
||||
|
||||
**Expected Behavior:**
|
||||
1. Flash Raspberry Pi OS Lite to the MicroSD card (Raspberry Pi Imager)
|
||||
2. Connect the LED matrix to your Raspberry Pi, insert the card, and
|
||||
power on
|
||||
3. SSH into the Pi and run the one-shot installer:
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/ChuckBuilds/LEDMatrix/main/scripts/install/one-shot-install.sh | bash
|
||||
```
|
||||
or clone the repo and run `sudo ./first_time_install.sh` — see the
|
||||
[README Installation Steps / Quick Install](../README.md#installation-steps)
|
||||
for full details
|
||||
|
||||
**Expected Behavior after install:**
|
||||
- LED matrix will light up
|
||||
- Display will show default plugins (clock, weather, etc.)
|
||||
- A fresh install ships only the bundled `starlark-apps` and
|
||||
`web-ui-info` plugins — clock, weather, sports, etc. must be
|
||||
installed from the Plugin Store (web UI → Plugin Manager) before
|
||||
anything else displays
|
||||
- Pi creates WiFi network "LEDMatrix-Setup" if not connected
|
||||
|
||||
### 2. Connect to WiFi
|
||||
@@ -73,7 +85,7 @@ You should see:
|
||||
2. Set your matrix configuration:
|
||||
- **Rows**: 32 or 64 (match your hardware)
|
||||
- **Columns**: commonly 64 or 96; the web UI accepts any integer
|
||||
in the 16–128 range, but 64 and 96 are the values the bundled
|
||||
in the 1–128 range, but 64 and 96 are the values the bundled
|
||||
panel hardware ships with
|
||||
- **Chain Length**: Number of panels chained horizontally
|
||||
- **Hardware Mapping**: usually `adafruit-hat-pwm` (with the PWM jumper
|
||||
@@ -115,11 +127,16 @@ You can also install community plugins straight from a GitHub URL using the
|
||||
|
||||
1. Each installed plugin gets its own tab in the second navigation row
|
||||
2. Open that plugin's tab to edit its settings (favorite teams, API keys,
|
||||
update intervals, display duration, etc.)
|
||||
update intervals, etc.)
|
||||
3. Click **Save**
|
||||
4. Restart the display service from **Overview** so the new settings take
|
||||
effect
|
||||
|
||||
**Note:** how long each plugin stays on screen is not set in the
|
||||
plugin's own tab — use the **Rotation** tab's **Screen Durations**
|
||||
section instead (saved to `display.display_durations` in
|
||||
`config.json`).
|
||||
|
||||
**Example: Weather Plugin**
|
||||
- Set your location (city, state, country)
|
||||
- Add an API key from OpenWeatherMap (free signup) to
|
||||
@@ -208,12 +225,14 @@ The fastest way to verify a plugin works without waiting for the rotation:
|
||||
### Customize Your Display
|
||||
|
||||
**Adjust display durations:**
|
||||
- Each plugin's tab has a **Display Duration (seconds)** field — set how
|
||||
long that plugin stays on screen each rotation.
|
||||
- Open the **Rotation** tab and use the **Screen Durations** section to
|
||||
set how long each plugin stays on screen per rotation (saved to
|
||||
`display.display_durations`).
|
||||
|
||||
**Organize plugin order:**
|
||||
- Use the **Plugin Manager** tab to enable/disable plugins. The display
|
||||
cycles through enabled plugins in the order they appear.
|
||||
- The **Rotation** tab also has a drag-and-drop **Rotation Order** list
|
||||
(saved to `display.plugin_rotation_order`). Enable/disable plugins
|
||||
from the **Plugin Manager** tab.
|
||||
|
||||
**Add more plugins:**
|
||||
- Check the **Plugin Store** section of **Plugin Manager** for new plugins.
|
||||
@@ -280,10 +299,14 @@ sudo journalctl -u ledmatrix-web -f
|
||||
│ ├── config_secrets.json # API keys and secrets
|
||||
│ └── wifi_config.json # WiFi settings
|
||||
├── plugin-repos/ # Installed plugins (default location)
|
||||
├── cache/ # Cached data
|
||||
└── web_interface/ # Web interface files
|
||||
```
|
||||
|
||||
> Cached data does not live in the project directory — the cache manager
|
||||
> uses the first writable location among `/var/cache/ledmatrix`,
|
||||
> `~/.ledmatrix_cache`, `/opt/ledmatrix/cache`, and
|
||||
> `$TMPDIR/ledmatrix_cache`.
|
||||
>
|
||||
> The plugin install location is configurable via
|
||||
> `plugin_system.plugins_directory` in `config.json`. The default is
|
||||
> `plugin-repos/`. Plugin discovery (`PluginManager.discover_plugins()`)
|
||||
@@ -303,11 +326,14 @@ System tabs:
|
||||
- WiFi Network selection and AP-mode setup
|
||||
- Schedule Power and dim schedules
|
||||
- Display Matrix hardware configuration
|
||||
- Rotation Rotation order (drag-and-drop) and screen durations
|
||||
- Config Editor Raw config.json editor
|
||||
- Backup & Restore Config backup and restore
|
||||
- Fonts Upload and manage fonts
|
||||
- Logs Real-time log viewing
|
||||
- Cache Cached data inspection and cleanup
|
||||
- Operation History Recent service operations
|
||||
- Tools System diagnostics, updates, dependencies, maintenance
|
||||
|
||||
Plugin tabs (second row):
|
||||
- Plugin Manager Browse the Plugin Store, install/enable plugins
|
||||
|
||||
+11
-15
@@ -10,10 +10,7 @@ Make sure you have the testing packages installed:
|
||||
|
||||
```bash
|
||||
# Install all dependencies including test packages
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Or install just the test dependencies
|
||||
pip install pytest pytest-cov pytest-mock
|
||||
pip install -r requirements.txt -r requirements-test.txt
|
||||
```
|
||||
|
||||
### 2. Set Environment Variables
|
||||
@@ -253,7 +250,6 @@ test/
|
||||
├── test_error_aggregator.py # Error aggregation tests
|
||||
├── test_schema_manager.py # Schema manager tests
|
||||
├── test_web_api.py # Web API tests
|
||||
├── test_nba_*.py # NBA-specific test suites
|
||||
├── plugins/ # Per-plugin test suites
|
||||
│ ├── test_clock_simple.py
|
||||
│ ├── test_calendar.py
|
||||
@@ -303,7 +299,7 @@ If tests fail due to missing packages:
|
||||
|
||||
```bash
|
||||
# Install all dependencies
|
||||
pip install -r requirements.txt
|
||||
pip install -r requirements.txt -r requirements-test.txt
|
||||
|
||||
# Or install specific missing package
|
||||
pip install <package-name>
|
||||
@@ -335,15 +331,15 @@ pytest --cov=src --cov-report=html
|
||||
|
||||
## Continuous Integration
|
||||
|
||||
The repo runs
|
||||
[`.github/workflows/security-audit.yml`](../.github/workflows/security-audit.yml)
|
||||
(bandit + semgrep) on every push. A pytest CI workflow at
|
||||
`.github/workflows/tests.yml` is queued to land alongside this
|
||||
PR ([ChuckBuilds/LEDMatrix#307](https://github.com/ChuckBuilds/LEDMatrix/pull/307));
|
||||
the workflow file itself was held back from that PR because the
|
||||
push token lacked the GitHub `workflow` scope, so it needs to be
|
||||
committed separately by a maintainer. Once it's in, this section
|
||||
will be updated to describe what the job runs.
|
||||
The repo runs the pytest suite via
|
||||
[`.github/workflows/test.yml`](../.github/workflows/test.yml) on every
|
||||
push and pull request: a plugin-safety job (harness, visual rendering
|
||||
and plugin-matrix tests) plus a unit-test job that runs an explicit
|
||||
allowlist of suites — new test files must be added to that list to run
|
||||
in CI. Release version consistency is checked by
|
||||
[`.github/workflows/release-version-check.yml`](../.github/workflows/release-version-check.yml).
|
||||
Bandit, flake8, mypy and gitleaks run as pre-commit hooks (see
|
||||
`.pre-commit-config.yaml`), not in CI.
|
||||
|
||||
## Best Practices
|
||||
|
||||
|
||||
@@ -86,7 +86,7 @@ The plugin system has been enhanced but remains backward compatible with existin
|
||||
|
||||
If you encounter issues during migration:
|
||||
|
||||
1. Check the [README.md](README.md) for current installation and usage instructions
|
||||
1. Check the [project root README](../README.md) for current installation and usage instructions
|
||||
2. Review script README files:
|
||||
- [`scripts/install/README.md`](../scripts/install/README.md) - Installation scripts documentation
|
||||
- [`scripts/fix_perms/README.md`](../scripts/fix_perms/README.md) - Permission scripts documentation
|
||||
|
||||
@@ -201,8 +201,9 @@ the mode selector for this plugin.
|
||||
|
||||
#### `get_vegas_segment_width() -> Optional[int]`
|
||||
|
||||
For `FIXED_SEGMENT` plugins, the width in pixels of the segment they
|
||||
occupy in the scroll. `None` lets the controller pick a default.
|
||||
For `FIXED_SEGMENT` plugins, the number of *panels* the segment
|
||||
occupies in the scroll (pixel width = panels × `single_panel_width`,
|
||||
from `display.hardware.cols`). `None` uses the default of 1 panel.
|
||||
|
||||
> The full source for `BasePlugin` lives in
|
||||
> `src/plugin_system/base_plugin.py`. If a method here disagrees with the
|
||||
|
||||
@@ -8,9 +8,12 @@
|
||||
> - Code paths reference `web_interface_v2.py`; the current web UI is
|
||||
> `web_interface/app.py` with v3 Blueprint-based templates.
|
||||
> - The example Flask routes use `/api/plugins/*`; the real API
|
||||
> blueprint is mounted at `/api/v3` (`web_interface/app.py:144`).
|
||||
> blueprint is mounted at `/api/v3` (`web_interface/app.py:199`).
|
||||
> - The default plugin location is `plugin-repos/` (configurable via
|
||||
> `plugin_system.plugins_directory`), not `./plugins/`.
|
||||
> - Example imports use `src/plugin_system/base_classes/*_plugin.py`;
|
||||
> the shipped base classes live in `src/base_classes/` (e.g.
|
||||
> `src.base_classes.sports.SportsCore`, `src.base_classes.hockey.Hockey`).
|
||||
> - The "Migration Strategy" and "Implementation Roadmap" sections
|
||||
> describe work that has now shipped.
|
||||
>
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# Plugin Configuration Tabs - Architecture
|
||||
|
||||
> This page covers internals (how the config system works under the
|
||||
> hood). For designing a plugin's config schema, the canonical guide is
|
||||
> [PLUGIN_CONFIGURATION_GUIDE.md](PLUGIN_CONFIGURATION_GUIDE.md); for
|
||||
> the user-facing tabs feature, see
|
||||
> [PLUGIN_CONFIGURATION_TABS.md](PLUGIN_CONFIGURATION_TABS.md).
|
||||
|
||||
## System Architecture
|
||||
|
||||
### Component Overview
|
||||
|
||||
@@ -296,7 +296,7 @@ Want to change icons programmatically? While not officially supported, you could
|
||||
## Related Documentation
|
||||
|
||||
- [Plugin Configuration Tabs](PLUGIN_CONFIGURATION_TABS.md) - Main plugin tabs documentation
|
||||
- [Plugin Development Guide](plugin_docs/) - How to create plugins
|
||||
- [Plugin Development Guide](PLUGIN_DEVELOPMENT_GUIDE.md) - How to create plugins
|
||||
- [Font Awesome Icons](https://fontawesome.com/icons) - Browse all available icons
|
||||
- [Emoji Reference](https://unicode.org/emoji/charts/full-emoji-list.html) - All emoji options
|
||||
|
||||
|
||||
@@ -169,6 +169,6 @@ If you continue to experience issues:
|
||||
## Related Documentation
|
||||
|
||||
- [Plugin Dependency Guide](PLUGIN_DEPENDENCY_GUIDE.md)
|
||||
- [Plugin Development Guide](docs/plugin_development.md)
|
||||
- [Troubleshooting Quick Start](TROUBLESHOOTING_QUICK_START.md)
|
||||
- [Plugin Development Guide](PLUGIN_DEVELOPMENT_GUIDE.md)
|
||||
- [Troubleshooting](TROUBLESHOOTING.md)
|
||||
|
||||
|
||||
@@ -589,11 +589,24 @@ Your plugin must:
|
||||
### Versioning Best Practices
|
||||
|
||||
- **Use semantic versioning**: `MAJOR.MINOR.PATCH` (e.g., `1.2.3`)
|
||||
- **Automatic version bumping**: Use the pre-push git hook for automatic patch version bumps
|
||||
- **Manual versioning**: Only needed for major/minor bumps or special cases
|
||||
- **GitHub as source of truth**: Plugin store fetches versions from GitHub releases/tags/manifest
|
||||
- **GitHub as source of truth**: the plugin store resolves versions in this
|
||||
order: GitHub Releases → GitHub Tags → manifest from branch → git commit hash
|
||||
- **Automatic version bumping**: install the self-contained pre-push hook in
|
||||
your plugin repo and patch versions bump themselves on push (a git tag
|
||||
`v{version}` is created and `manifest.json` staged automatically):
|
||||
|
||||
See the [Git Workflow rules](../.cursorrules) for version management details.
|
||||
```bash
|
||||
# From your plugin repository directory
|
||||
cp /path/to/LEDMatrix/scripts/git-hooks/pre-push-plugin-version .git/hooks/pre-push
|
||||
chmod +x .git/hooks/pre-push
|
||||
```
|
||||
|
||||
Set `SKIP_TAG=1` in the environment to skip auto-tagging for one push.
|
||||
- **Manual versioning**: only needed for major/minor bumps, CI pipelines that
|
||||
bypass hooks, or forks without the hook — use
|
||||
`scripts/bump_plugin_version.py`.
|
||||
- **Registry stores no versions**: `plugins.json` holds only metadata (name,
|
||||
description, repo URL).
|
||||
|
||||
### Submitting to Official Registry
|
||||
|
||||
@@ -667,5 +680,5 @@ For your plugin to work well in the plugin store:
|
||||
- [Advanced Plugin Development](ADVANCED_PLUGIN_DEVELOPMENT.md) - Advanced patterns and examples
|
||||
- [Plugin Quick Reference](PLUGIN_QUICK_REFERENCE.md) - Quick development reference
|
||||
- [Plugin Configuration Guide](PLUGIN_CONFIGURATION_GUIDE.md) - Configuration setup
|
||||
- [Plugin Store User Guide](PLUGIN_STORE_USER_GUIDE.md) - Using the plugin store
|
||||
- [Plugin Store Guide](PLUGIN_STORE_GUIDE.md) - Using the plugin store
|
||||
|
||||
|
||||
@@ -14,8 +14,10 @@ and [PLUGIN_DEVELOPMENT_GUIDE.md](PLUGIN_DEVELOPMENT_GUIDE.md).
|
||||
✅ **GitHub Store**: Discovery from `ledmatrix-plugins` registry plus
|
||||
any GitHub URL
|
||||
✅ **Plugin Location**: configured by `plugin_system.plugins_directory`
|
||||
in `config.json` (default `plugin-repos/`; the loader also searches
|
||||
`plugins/` as a fallback)
|
||||
in `config.json` (default `plugin-repos/`). Plugin discovery scans
|
||||
only this directory — there is no loader fallback to `plugins/`
|
||||
(only Plugin Store operations and schema lookup additionally probe
|
||||
`plugins/`)
|
||||
|
||||
## File Structure
|
||||
|
||||
@@ -109,7 +111,7 @@ git push -u origin main
|
||||
git tag v1.0.0
|
||||
git push origin v1.0.0
|
||||
|
||||
# Submit to registry (PR to ChuckBuilds/ledmatrix-plugin-registry)
|
||||
# Submit to registry (PR to ChuckBuilds/ledmatrix-plugins)
|
||||
```
|
||||
|
||||
## Using Plugins
|
||||
@@ -120,12 +122,12 @@ git push origin v1.0.0
|
||||
2. **Install**: Click **Install** in the plugin's row
|
||||
3. **Configure**: open the plugin's tab in the second nav row
|
||||
4. **Enable/Disable**: toggle switch in the **Installed Plugins** list
|
||||
5. **Reorder**: order is set by the position in `display_modes` /
|
||||
plugin order; rearranging via drag-and-drop is not yet supported
|
||||
5. **Reorder**: use the drag-and-drop **Rotation Order** list in the
|
||||
**Rotation** tab (saved to `display.plugin_rotation_order`)
|
||||
|
||||
### REST API
|
||||
|
||||
The API is mounted at `/api/v3` (`web_interface/app.py:144`).
|
||||
The API is mounted at `/api/v3` (`web_interface/app.py:199`).
|
||||
|
||||
```bash
|
||||
# Install plugin from the registry
|
||||
|
||||
@@ -323,16 +323,22 @@ curl -X POST http://pi:5000/api/v3/plugins/install-from-url \
|
||||
### Regular Updates
|
||||
|
||||
```bash
|
||||
# Update stars/downloads counts
|
||||
python3 scripts/update_stats.py
|
||||
# Refresh local clones of all plugin repos
|
||||
python3 scripts/update_plugin_repos.py
|
||||
|
||||
# Validate all plugin entries
|
||||
python3 scripts/validate_registry.py
|
||||
# (Re-)create local plugin repo checkouts from the registry
|
||||
python3 scripts/setup_plugin_repos.py
|
||||
|
||||
# Check for plugin updates
|
||||
python3 scripts/check_updates.py
|
||||
# Audit installed plugins for manifest/schema problems
|
||||
python3 scripts/audit_plugins.py
|
||||
|
||||
# Validate a single plugin
|
||||
python3 scripts/check_plugin.py --plugin <plugin-id>
|
||||
```
|
||||
|
||||
Registry regeneration (`update_registry.py`) lives in the
|
||||
`ledmatrix-plugins` monorepo, not in this repo.
|
||||
|
||||
## Converting Existing Plugins
|
||||
|
||||
To convert your existing plugins (hello-world, clock-simple) to this system:
|
||||
@@ -400,7 +406,7 @@ print(f'Found {len(registry[\"plugins\"])} plugins')
|
||||
|
||||
## References
|
||||
|
||||
- Plugin Store Implementation: See `PLUGIN_STORE_IMPLEMENTATION_SUMMARY.md`
|
||||
- User Guide: See `PLUGIN_STORE_USER_GUIDE.md`
|
||||
- Plugin Store Implementation: See `PLUGIN_IMPLEMENTATION_SUMMARY.md`
|
||||
- User Guide: See `PLUGIN_STORE_GUIDE.md`
|
||||
- Architecture: See `PLUGIN_ARCHITECTURE_SPEC.md`
|
||||
|
||||
|
||||
@@ -481,13 +481,13 @@ A: Yes, if a plugin needs API keys, it can access them like core managers do.
|
||||
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.
|
||||
A: Yes! See [PLUGIN_DEVELOPMENT_GUIDE.md](PLUGIN_DEVELOPMENT_GUIDE.md) for instructions.
|
||||
|
||||
---
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [PLUGIN_DEVELOPMENT.md](PLUGIN_DEVELOPMENT.md) - Create your own plugins
|
||||
- [PLUGIN_DEVELOPMENT_GUIDE.md](PLUGIN_DEVELOPMENT_GUIDE.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
|
||||
- [PLUGIN_ARCHITECTURE_SPEC.md](PLUGIN_ARCHITECTURE_SPEC.md) - Plugin system architecture (historical)
|
||||
- [REST_API_REFERENCE.md](REST_API_REFERENCE.md) - Complete REST API reference
|
||||
|
||||
+8
-3
@@ -29,15 +29,16 @@ Start here:
|
||||
Going deeper:
|
||||
|
||||
- [ADVANCED_PLUGIN_DEVELOPMENT.md](ADVANCED_PLUGIN_DEVELOPMENT.md) — advanced patterns
|
||||
- [PLUGIN_ARCHITECTURE_SPEC.md](PLUGIN_ARCHITECTURE_SPEC.md) — full plugin-system spec
|
||||
- [PLUGIN_ARCHITECTURE_SPEC.md](PLUGIN_ARCHITECTURE_SPEC.md) — original plugin-system design spec (historical; see its banner for what has drifted)
|
||||
- [PLUGIN_DEPENDENCY_GUIDE.md](PLUGIN_DEPENDENCY_GUIDE.md) /
|
||||
[PLUGIN_DEPENDENCY_TROUBLESHOOTING.md](PLUGIN_DEPENDENCY_TROUBLESHOOTING.md)
|
||||
- [PLUGIN_WEB_UI_ACTIONS.md](PLUGIN_WEB_UI_ACTIONS.md) (+ [example JSON](PLUGIN_WEB_UI_ACTIONS_EXAMPLE.json))
|
||||
- [PLUGIN_CUSTOM_ICONS.md](PLUGIN_CUSTOM_ICONS.md) /
|
||||
[PLUGIN_CUSTOM_ICONS_FEATURE.md](PLUGIN_CUSTOM_ICONS_FEATURE.md)
|
||||
- [PLUGIN_CUSTOM_ICONS.md](PLUGIN_CUSTOM_ICONS.md)
|
||||
- [PLUGIN_REGISTRY_SETUP_GUIDE.md](PLUGIN_REGISTRY_SETUP_GUIDE.md) (+ [registry template](plugin_registry_template.json))
|
||||
- [STARLARK_APPS_GUIDE.md](STARLARK_APPS_GUIDE.md) — Starlark-based mini-apps
|
||||
- [widget-guide.md](widget-guide.md) — widget development
|
||||
- [ADAPTIVE_LAYOUT.md](ADAPTIVE_LAYOUT.md) — render legibly on any panel size (opt-in font/layout scaling)
|
||||
- [plugin-safety-harness.md](plugin-safety-harness.md) — test a plugin across every screen and matrix size
|
||||
|
||||
## Configuring plugins
|
||||
|
||||
@@ -52,9 +53,12 @@ Going deeper:
|
||||
- [ADVANCED_FEATURES.md](ADVANCED_FEATURES.md) — Vegas scroll, on-demand display,
|
||||
cache management, background services, permissions
|
||||
- [FONT_MANAGER.md](FONT_MANAGER.md) — font system
|
||||
- [SKIN_SYSTEM.md](SKIN_SYSTEM.md) — skin architecture for sports scoreboards
|
||||
- [CREATING_SKINS.md](CREATING_SKINS.md) — writing and validating a skin
|
||||
|
||||
## Reference
|
||||
|
||||
- [CONFIG_REFERENCE.md](CONFIG_REFERENCE.md) — every key in config.json and config_secrets.json
|
||||
- [REST_API_REFERENCE.md](REST_API_REFERENCE.md) — all web-interface HTTP endpoints
|
||||
- [PLUGIN_API_REFERENCE.md](PLUGIN_API_REFERENCE.md) — Python APIs available to plugins
|
||||
- [DEVELOPER_QUICK_REFERENCE.md](DEVELOPER_QUICK_REFERENCE.md) — common dev tasks
|
||||
@@ -66,6 +70,7 @@ Going deeper:
|
||||
- [HOW_TO_RUN_TESTS.md](HOW_TO_RUN_TESTS.md) — running the test suite
|
||||
- [MULTI_ROOT_WORKSPACE_SETUP.md](MULTI_ROOT_WORKSPACE_SETUP.md) — multi-repo workspace
|
||||
- [MIGRATION_GUIDE.md](MIGRATION_GUIDE.md) — breaking changes between releases
|
||||
- [SPORTS_UNIFICATION.md](SPORTS_UNIFICATION.md) — how the sports scoreboard base classes are organized
|
||||
|
||||
## Archive
|
||||
|
||||
|
||||
@@ -31,9 +31,9 @@ All endpoints return JSON responses with a standard format:
|
||||
- [Plugin-specific endpoints](#plugin-specific-endpoints)
|
||||
- [Starlark Apps](#starlark-apps)
|
||||
|
||||
> The API blueprint is mounted at `/api/v3` (`web_interface/app.py:144`).
|
||||
> The API blueprint is mounted at `/api/v3` (`web_interface/app.py:199`).
|
||||
> SSE stream endpoints (`/api/v3/stream/*`) are defined directly on the
|
||||
> Flask app at `app.py:607-615`. There are about 92 routes total — see
|
||||
> Flask app at `app.py:799-809`. There are 94 routes total — see
|
||||
> `web_interface/blueprints/api_v3.py` for the canonical list.
|
||||
|
||||
---
|
||||
|
||||
+1
-1
@@ -33,7 +33,7 @@ crashing) simply restores the built-in look.
|
||||
## The render funnel
|
||||
|
||||
Every sports scoreboard (baseball, football, basketball, hockey — anything
|
||||
built on `src/base_classes/sports.py`) renders through exactly one seam:
|
||||
built on the `src/base_classes/sports/` package, `core.py`) renders through exactly one seam:
|
||||
`SportsCore._render_game(game, force_clear)`.
|
||||
|
||||
1. The mode class's `display()` (live, `SportsUpcoming`, `SportsRecent`)
|
||||
|
||||
+46
-52
@@ -330,8 +330,8 @@ sudo systemctl cat ledmatrix-web | grep User
|
||||
|
||||
6. **Manually enable AP mode:**
|
||||
```bash
|
||||
# Via API
|
||||
curl -X POST http://localhost:5000/api/wifi/ap/enable
|
||||
# Via API (the WiFi blueprint is mounted under /api/v3)
|
||||
curl -X POST http://localhost:5000/api/v3/wifi/ap/enable
|
||||
|
||||
# Via Python
|
||||
python3 -c "
|
||||
@@ -482,19 +482,19 @@ sudo systemctl cat ledmatrix-web | grep User
|
||||
|
||||
1. **Check plugin directory exists:**
|
||||
```bash
|
||||
ls -ld plugins/plugin-id/
|
||||
ls -ld plugin-repos/plugin-id/
|
||||
```
|
||||
|
||||
2. **Verify manifest.json:**
|
||||
```bash
|
||||
cat plugins/plugin-id/manifest.json
|
||||
cat plugin-repos/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
|
||||
if [ -f plugin-repos/plugin-id/requirements.txt ]; then
|
||||
pip3 install --break-system-packages -r plugin-repos/plugin-id/requirements.txt
|
||||
fi
|
||||
```
|
||||
|
||||
@@ -507,7 +507,7 @@ sudo systemctl cat ledmatrix-web | grep User
|
||||
```bash
|
||||
python3 -c "
|
||||
import sys
|
||||
sys.path.insert(0, 'plugins/plugin-id')
|
||||
sys.path.insert(0, 'plugin-repos/plugin-id')
|
||||
from manager import PluginClass
|
||||
print('Plugin imports successfully')
|
||||
"
|
||||
@@ -523,12 +523,18 @@ sudo systemctl cat ledmatrix-web | grep User
|
||||
**Solutions:**
|
||||
|
||||
1. **Manual cache clearing:**
|
||||
```bash
|
||||
# Remove plugin-specific cache
|
||||
rm -rf cache/plugin-id*
|
||||
|
||||
# Or remove all cache
|
||||
rm -rf cache/*
|
||||
The cache does not live in the project directory. The cache manager
|
||||
uses the first writable location among `/var/cache/ledmatrix`,
|
||||
`~/.ledmatrix_cache`, `/opt/ledmatrix/cache`, and
|
||||
`$TMPDIR/ledmatrix_cache`. The easiest option is the helper script:
|
||||
|
||||
```bash
|
||||
# Clear the cache with the helper script
|
||||
sudo python3 scripts/utils/clear_cache.py
|
||||
|
||||
# Or remove files manually from the cache dir in use, e.g.:
|
||||
sudo rm -rf /var/cache/ledmatrix/*
|
||||
|
||||
# Restart display
|
||||
sudo systemctl restart ledmatrix
|
||||
@@ -536,8 +542,8 @@ sudo systemctl cat ledmatrix-web | grep User
|
||||
|
||||
2. **Check cache permissions:**
|
||||
```bash
|
||||
ls -ld cache/
|
||||
sudo chown -R ledpi:ledpi cache/
|
||||
ls -ld /var/cache/ledmatrix
|
||||
sudo ./scripts/fix_perms/fix_cache_permissions.sh
|
||||
```
|
||||
|
||||
---
|
||||
@@ -772,11 +778,11 @@ nmcli device status
|
||||
```bash
|
||||
# Check file exists
|
||||
ls -l config/config.json
|
||||
ls -l plugins/plugin-id/manifest.json
|
||||
ls -l plugin-repos/plugin-id/manifest.json
|
||||
|
||||
# Check directory structure
|
||||
ls -la web_interface/
|
||||
ls -la plugins/
|
||||
ls -la plugin-repos/
|
||||
|
||||
# Check file permissions
|
||||
ls -l config/config_secrets.json
|
||||
@@ -804,7 +810,7 @@ python3 -c "from src.wifi_manager import WiFiManager; print('OK')"
|
||||
# Test plugin import
|
||||
python3 -c "
|
||||
import sys
|
||||
sys.path.insert(0, 'plugins/plugin-id')
|
||||
sys.path.insert(0, 'plugin-repos/plugin-id')
|
||||
from manager import PluginClass
|
||||
print('Plugin imports OK')
|
||||
"
|
||||
@@ -812,40 +818,29 @@ print('Plugin imports OK')
|
||||
|
||||
---
|
||||
|
||||
## Service File Template
|
||||
## Reinstalling Service Files
|
||||
|
||||
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:
|
||||
If a systemd service file is corrupted or missing, do NOT hand-write
|
||||
one. The real unit files live in the repo's `systemd/` directory
|
||||
(`ledmatrix.service`, `ledmatrix-web.service`,
|
||||
`ledmatrix-wifi-monitor.service`) and contain a
|
||||
`__PROJECT_ROOT_DIR__` placeholder that the install scripts substitute
|
||||
with your actual checkout path:
|
||||
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable ledmatrix-web
|
||||
sudo systemctl start ledmatrix-web
|
||||
# Reinstall the display service unit
|
||||
sudo ./scripts/install/install_service.sh
|
||||
|
||||
# Reinstall the web interface service unit
|
||||
sudo ./scripts/install/install_web_service.sh
|
||||
```
|
||||
|
||||
Note that `ledmatrix-web.service` runs as root via
|
||||
`scripts/utils/start_web_conditionally.py` — root is needed for
|
||||
system operations (service control, WiFi management), and the wrapper
|
||||
honors the `web_display_autostart` config flag before actually
|
||||
starting the web server.
|
||||
|
||||
---
|
||||
|
||||
## Complete Diagnostic Script
|
||||
@@ -878,7 +873,7 @@ echo ""
|
||||
|
||||
echo "5. File Structure:"
|
||||
ls -la web_interface/ | head -10
|
||||
ls -la plugins/ | head -10
|
||||
ls -la plugin-repos/ | head -10
|
||||
echo ""
|
||||
|
||||
echo "6. Python Imports:"
|
||||
@@ -954,12 +949,11 @@ sudo systemctl restart ledmatrix-web
|
||||
# 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/
|
||||
# Recreate service files (substitutes __PROJECT_ROOT_DIR__ in systemd/ units)
|
||||
sudo ./scripts/install/install_service.sh
|
||||
sudo ./scripts/install/install_web_service.sh
|
||||
|
||||
# Reload and restart
|
||||
sudo systemctl daemon-reload
|
||||
# Restart
|
||||
sudo systemctl restart ledmatrix ledmatrix-web
|
||||
```
|
||||
|
||||
|
||||
+23
-18
@@ -39,12 +39,18 @@ present:
|
||||
- **WiFi** — Network selection and AP-mode setup
|
||||
- **Schedule** — Power and dim schedules
|
||||
- **Display** — Matrix hardware configuration (rows, cols, hardware
|
||||
mapping, GPIO slowdown, brightness, PWM)
|
||||
mapping, GPIO slowdown, brightness, PWM) and Vegas Scroll Mode
|
||||
settings
|
||||
- **Rotation** — drag-and-drop **Rotation Order** list and per-plugin
|
||||
**Screen Durations**
|
||||
- **Config Editor** — Raw `config.json` editor with validation
|
||||
- **Backup & Restore** — back up and restore your configuration
|
||||
- **Fonts** — Upload and manage fonts
|
||||
- **Logs** — Real-time log streaming
|
||||
- **Cache** — Cached data inspection and cleanup
|
||||
- **Operation History** — Recent service operations
|
||||
- **Tools** — system diagnostics, git & updates, Python dependencies,
|
||||
maintenance, power supply, network radio, services, and plugin health
|
||||
|
||||
A second nav row holds plugin tabs:
|
||||
|
||||
@@ -111,6 +117,12 @@ Configure your LED matrix hardware:
|
||||
- Dynamic Duration — global cap for plugins that extend their display
|
||||
time based on content
|
||||
|
||||
**Vegas Scroll Mode:** the Display tab also has a full Vegas Scroll
|
||||
Mode section — enable toggle, scroll speed, separator width, dynamic
|
||||
duration, and related settings — so you can configure Vegas mode
|
||||
entirely from the web UI without hand-editing JSON. See
|
||||
[ADVANCED_FEATURES.md](ADVANCED_FEATURES.md) for what the options do.
|
||||
|
||||
Changes require **Restart Display Service** from the Overview tab.
|
||||
|
||||
### Plugin Manager Tab
|
||||
@@ -159,9 +171,10 @@ Manage fonts for your display:
|
||||
- See font previews
|
||||
- Check font sizes and styles
|
||||
|
||||
**Plugin Font Overrides:**
|
||||
- Set custom fonts for specific plugins
|
||||
- Override default font choices
|
||||
**Font Overrides:**
|
||||
- Overrides are set per display *element* (e.g. a specific score or
|
||||
clock text element), not per plugin
|
||||
- Override default font choices for individual elements
|
||||
- Preview font changes
|
||||
|
||||
**Delete Fonts:**
|
||||
@@ -183,9 +196,11 @@ View real-time system logs:
|
||||
- Filter by plugin or component
|
||||
|
||||
**Actions:**
|
||||
- **Refresh**: Reload the log view
|
||||
- **Clear**: Clear the current view
|
||||
- **Download**: Download logs for offline analysis
|
||||
- **Pause**: Pause auto-scrolling
|
||||
- **Auto-scroll** checkbox: toggle automatic scrolling to the latest
|
||||
entries
|
||||
|
||||
---
|
||||
|
||||
@@ -248,7 +263,8 @@ The web interface uses Server-Sent Events (SSE) for real-time updates:
|
||||
**Performance:**
|
||||
- Minimal bandwidth usage
|
||||
- Server-side rendering for fast load times
|
||||
- Progressive enhancement - works without JavaScript
|
||||
- The UI is built on Alpine.js and HTMX, so JavaScript must be enabled
|
||||
in the browser
|
||||
|
||||
---
|
||||
|
||||
@@ -267,17 +283,6 @@ The interface is fully responsive and works on mobile devices:
|
||||
|
||||
---
|
||||
|
||||
## 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:
|
||||
@@ -288,7 +293,7 @@ http://your-pi-ip:5000/api/v3
|
||||
```
|
||||
|
||||
The API blueprint mounts at `/api/v3` (see
|
||||
`web_interface/app.py:144`). All endpoints below are relative to that
|
||||
`web_interface/app.py:199`). All endpoints below are relative to that
|
||||
base.
|
||||
|
||||
**Common Endpoints:**
|
||||
|
||||
+13
-16
@@ -821,10 +821,6 @@ if [ ! -f "$PROJECT_ROOT_DIR/config/config_secrets.json" ]; then
|
||||
echo "⚠ Template config/config_secrets.template.json not found; creating a minimal secrets file"
|
||||
cat > "$PROJECT_ROOT_DIR/config/config_secrets.json" <<'EOF'
|
||||
{
|
||||
"youtube": {
|
||||
"api_key": "YOUR_YOUTUBE_API_KEY",
|
||||
"channel_id": "YOUR_YOUTUBE_CHANNEL_ID"
|
||||
},
|
||||
"github": {
|
||||
"api_token": "YOUR_GITHUB_PERSONAL_ACCESS_TOKEN"
|
||||
}
|
||||
@@ -1187,25 +1183,26 @@ else
|
||||
cd "$PROJECT_ROOT_DIR"
|
||||
|
||||
# Try to install dependencies using the smart installer if available
|
||||
WEB_DEPS_OK=true
|
||||
if [ -f "$PROJECT_ROOT_DIR/scripts/install_dependencies_apt.py" ]; then
|
||||
echo "Using smart dependency installer..."
|
||||
# -u: unbuffered stdout/stderr so output is captured in $LOG_FILE in
|
||||
# real time and in order relative to this script's own echo statements
|
||||
python3 -u "$PROJECT_ROOT_DIR/scripts/install_dependencies_apt.py"
|
||||
else
|
||||
echo "Using pip to install dependencies..."
|
||||
if [ -f "$PROJECT_ROOT_DIR/requirements_web_v2.txt" ]; then
|
||||
# --ignore-installed: see the Step 5 web_interface/requirements.txt
|
||||
# install above — same apt/pip RECORD-file conflict applies here.
|
||||
python3 -m pip install --break-system-packages --prefer-binary --ignore-installed -r requirements_web_v2.txt
|
||||
else
|
||||
echo "⚠ requirements_web_v2.txt not found; skipping web dependency install"
|
||||
if ! python3 -u "$PROJECT_ROOT_DIR/scripts/install_dependencies_apt.py"; then
|
||||
WEB_DEPS_OK=false
|
||||
fi
|
||||
else
|
||||
echo "Web dependencies already installed from web_interface/requirements.txt in Step 5"
|
||||
fi
|
||||
|
||||
# Create marker file to indicate dependencies are installed
|
||||
touch "$PROJECT_ROOT_DIR/.web_deps_installed"
|
||||
echo "✓ Web interface dependencies installed"
|
||||
# Create the marker only when installation actually succeeded, so a
|
||||
# re-run retries instead of silently skipping missing dependencies.
|
||||
if [ "$WEB_DEPS_OK" = true ]; then
|
||||
touch "$PROJECT_ROOT_DIR/.web_deps_installed"
|
||||
echo "✓ Web interface dependencies installed"
|
||||
else
|
||||
echo "⚠ Web interface dependency install reported errors; not creating .web_deps_installed (will retry on next run)"
|
||||
fi
|
||||
fi
|
||||
echo ""
|
||||
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
# Test-only dependencies for the plugin safety harness and pytest suite.
|
||||
# Test/dev-only dependencies (not needed on a running display).
|
||||
# Install alongside requirements.txt: pip install -r requirements.txt -r requirements-test.txt
|
||||
#
|
||||
# pytest, pytest-cov, pytest-mock, and jsonschema are already pinned (with
|
||||
# major-version caps) in requirements.txt, so they are intentionally NOT
|
||||
# repeated here — re-pinning pytest to <9 collided with requirements.txt's
|
||||
# pytest>=9.0.3,<10 and made the two files impossible to install together.
|
||||
# Only declare what requirements.txt doesn't already provide.
|
||||
pytest>=9.0.3,<10.0.0
|
||||
pytest-cov>=4.1.0,<5.0.0
|
||||
pytest-mock>=3.11.0,<4.0.0
|
||||
freezegun>=1.2,<2 # deterministic time for golden-image tests
|
||||
mypy>=1.5.0,<2.0.0 # static type checking (also pinned in .pre-commit-config.yaml)
|
||||
|
||||
+10
-16
@@ -11,27 +11,22 @@ pytz>=2024.2,<2025.0 # Updated for latest timezone data
|
||||
|
||||
# HTTP requests
|
||||
requests>=2.33.0,<3.0.0
|
||||
urllib3>=2.7.0,<3.0.0 # requests transitive, but imported directly (urllib3.util.retry.Retry); floor is a security floor, not the API floor — 1.26.x carries ~10 CVEs
|
||||
|
||||
# Google API integration
|
||||
|
||||
# Font rendering
|
||||
freetype-py>=2.5.1,<3.0.0
|
||||
|
||||
# Spotify integration
|
||||
# Spotify integration (used by web_interface/blueprints/api_v3.py OAuth endpoints)
|
||||
spotipy>=2.25.2,<3.0.0
|
||||
|
||||
# Flask web framework
|
||||
Flask>=3.1.3,<4.0.0
|
||||
|
||||
# Text processing
|
||||
|
||||
# Calendar integration
|
||||
|
||||
# WebSocket support
|
||||
python-socketio>=5.14.0,<6.0.0
|
||||
python-engineio>=4.9.0,<5.0.0
|
||||
websockets>=12.0,<14.0
|
||||
websocket-client>=1.8.0,<2.0.0
|
||||
# WebSocket support: intentionally NOT declared here. Plugins that need
|
||||
# it (e.g. ledmatrix-music's Socket.IO client) declare it in their own
|
||||
# requirements.txt, which the plugin store installs.
|
||||
|
||||
# JSON Schema validation
|
||||
jsonschema>=4.20.0,<5.0.0
|
||||
@@ -39,11 +34,8 @@ jsonschema>=4.20.0,<5.0.0
|
||||
# Requirement specifier parsing (plugin dependency satisfaction checks)
|
||||
packaging>=23.0,<27.0
|
||||
|
||||
# Testing dependencies
|
||||
pytest>=9.0.3,<10.0.0
|
||||
pytest-cov>=4.1.0,<5.0.0
|
||||
pytest-mock>=3.11.0,<4.0.0
|
||||
mypy>=1.5.0,<2.0.0
|
||||
# Testing dependencies live in requirements-test.txt:
|
||||
# pip install -r requirements.txt -r requirements-test.txt
|
||||
|
||||
# ───────────────────────────────────────────────────────────────────────
|
||||
# Optional dependencies — the code imports these inside try/except
|
||||
@@ -59,7 +51,9 @@ mypy>=1.5.0,<2.0.0
|
||||
# psutil — per-plugin resource monitoring in
|
||||
# src/plugin_system/resource_monitor.py. The monitor
|
||||
# silently no-ops when missing (PSUTIL_AVAILABLE = False).
|
||||
# pip install 'psutil>=5.9.0,<6.0.0'
|
||||
# Note: web_interface/requirements.txt requires this
|
||||
# range as a hard dependency — keep the two in sync.
|
||||
# pip install 'psutil>=6.0.0,<7.0.0'
|
||||
#
|
||||
# Flask-Limiter — request rate limiting in web_interface/app.py
|
||||
# (accidental-abuse protection, not security). The
|
||||
|
||||
@@ -201,7 +201,7 @@ def process_schema_file(schema_path: Path) -> bool:
|
||||
def main():
|
||||
"""Main entry point."""
|
||||
project_root = Path(__file__).parent.parent
|
||||
plugins_dir = project_root / 'plugins'
|
||||
plugins_dir = project_root / 'plugin-repos'
|
||||
|
||||
if not plugins_dir.exists():
|
||||
print(f"Error: Plugins directory not found: {plugins_dir}")
|
||||
|
||||
@@ -193,7 +193,7 @@ def analyze_schema(schema_path: Path) -> Dict[str, Any]:
|
||||
def main():
|
||||
"""Main analysis function."""
|
||||
project_root = Path(__file__).parent.parent
|
||||
plugins_dir = project_root / "plugins"
|
||||
plugins_dir = project_root / "plugin-repos"
|
||||
|
||||
if not plugins_dir.exists():
|
||||
print(f"Plugins directory not found: {plugins_dir}")
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Check what imports are actually in the app.py file on the Pi
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
# Read the app.py file and check the import lines
|
||||
app_py_path = Path.home() / 'LEDMatrix' / 'web_interface' / 'app.py'
|
||||
|
||||
print(f"🔍 Checking imports in: {app_py_path}")
|
||||
print(f"📁 File exists: {app_py_path.exists()}")
|
||||
|
||||
if app_py_path.exists():
|
||||
with open(app_py_path, 'r') as f:
|
||||
lines = f.readlines()
|
||||
|
||||
print("\n🔍 Import lines in app.py:")
|
||||
for i, line in enumerate(lines, 1):
|
||||
if 'from' in line and 'blueprints' in line and 'import' in line:
|
||||
print(f" Line {i}: {line.strip()}")
|
||||
|
||||
print("\n🔍 Blueprint registration lines:")
|
||||
for i, line in enumerate(lines, 1):
|
||||
if 'register_blueprint' in line:
|
||||
print(f" Line {i}: {line.strip()}")
|
||||
else:
|
||||
print("❌ app.py file not found!")
|
||||
@@ -13,8 +13,8 @@ def main():
|
||||
print("🔍 LED Matrix Web Interface Debug Tool")
|
||||
print("=" * 50)
|
||||
|
||||
# Change to project root (where this script is located)
|
||||
project_root = Path(__file__).parent.resolve()
|
||||
# Change to project root (two levels up from scripts/debug/)
|
||||
project_root = Path(__file__).parent.parent.parent.resolve()
|
||||
os.chdir(project_root)
|
||||
print(f"📁 Working directory: {os.getcwd()}")
|
||||
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Direct fix for import issues - manually edit the app.py file
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
def fix_imports():
|
||||
app_py_path = Path.home() / 'LEDMatrix' / 'web_interface' / 'app.py'
|
||||
|
||||
print(f"🔧 Directly fixing imports in: {app_py_path}")
|
||||
|
||||
# Read the file
|
||||
with open(app_py_path, 'r') as f:
|
||||
lines = f.readlines()
|
||||
|
||||
# Find and fix the import lines
|
||||
fixed = False
|
||||
for i, line in enumerate(lines, 1):
|
||||
if 'from blueprints.pages_v3 import' in line:
|
||||
lines[i-1] = "from web_interface.blueprints.pages_v3 import pages_v3\n"
|
||||
print(f"✅ Fixed line {i}: from blueprints.pages_v3 import → from web_interface.blueprints.pages_v3 import")
|
||||
fixed = True
|
||||
elif 'from blueprints.api_v3 import' in line:
|
||||
lines[i-1] = "from web_interface.blueprints.api_v3 import api_v3\n"
|
||||
print(f"✅ Fixed line {i}: from blueprints.api_v3 import → from web_interface.blueprints.api_v3 import")
|
||||
fixed = True
|
||||
|
||||
if not fixed:
|
||||
print("❌ No import lines found to fix")
|
||||
return False
|
||||
|
||||
# Write the fixed file back
|
||||
with open(app_py_path, 'w') as f:
|
||||
f.writelines(lines)
|
||||
|
||||
print("✅ File updated successfully")
|
||||
return True
|
||||
|
||||
def verify_fix():
|
||||
print("\n🔍 Verifying the fix...")
|
||||
os.system("python3 check_imports.py")
|
||||
|
||||
if __name__ == "__main__":
|
||||
if fix_imports():
|
||||
print("\n🧹 Clearing Python cache...")
|
||||
os.system("find ~/LEDMatrix -type d -name '__pycache__' -exec rm -rf {} + 2>/dev/null || true")
|
||||
os.system("find ~/LEDMatrix -name '*.pyc' -delete 2>/dev/null || true")
|
||||
|
||||
print("\n✅ Imports fixed and cache cleared!")
|
||||
verify_fix()
|
||||
|
||||
print("\n🚀 Now try running the web interface:")
|
||||
print("cd ~/LEDMatrix")
|
||||
print("python3 web_interface/start.py")
|
||||
else:
|
||||
print("\n❌ Fix failed")
|
||||
@@ -7,8 +7,8 @@ import os
|
||||
import logging
|
||||
from typing import Tuple
|
||||
|
||||
# Add the src directory to Python path so we can import the logo downloader
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src'))
|
||||
# Add the project root to Python path so we can import the logo downloader
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
# Set up logging
|
||||
logging.basicConfig(
|
||||
@@ -28,7 +28,7 @@ def download_nba_logos(force_download: bool = False) -> Tuple[int, int]:
|
||||
Tuple of (downloaded_count, failed_count)
|
||||
"""
|
||||
try:
|
||||
from logo_downloader import download_all_logos_for_league
|
||||
from src.logo_downloader import download_all_logos_for_league
|
||||
|
||||
logger.info("🏀 Starting NBA logo download...")
|
||||
logger.info(f"Target directory: assets/sports/nba_logos/")
|
||||
|
||||
@@ -31,9 +31,6 @@ owned by the `ledmatrix` service user or by `root`.
|
||||
systemd journal access, and the sudoers entries the web interface
|
||||
needs to control the display service.
|
||||
|
||||
- **`fix_nhl_cache.sh`** — Targeted fix for NHL plugin cache issues
|
||||
(clears the NHL cache and restarts the display service).
|
||||
|
||||
- **`safe_plugin_rm.sh`** — Validates that a plugin removal path is
|
||||
inside an allowed base directory before deleting it. Used by the web
|
||||
interface (via sudo) when a user clicks **Uninstall** on a plugin —
|
||||
|
||||
Regular → Executable
Regular → Executable
@@ -1,21 +0,0 @@
|
||||
#!/bin/bash
|
||||
"""
|
||||
Script to fix NHL cache issues on Raspberry Pi.
|
||||
This will clear the NHL cache and restart the display service.
|
||||
"""
|
||||
|
||||
echo "=========================================="
|
||||
echo "Fixing NHL Cache Issues"
|
||||
echo "=========================================="
|
||||
|
||||
# Clear NHL cache
|
||||
echo "Clearing NHL cache..."
|
||||
python3 clear_nhl_cache.py
|
||||
|
||||
# Restart the display service to force fresh data fetch
|
||||
echo "Restarting display service..."
|
||||
sudo systemctl restart ledmatrix.service
|
||||
|
||||
echo "NHL cache cleared and service restarted!"
|
||||
echo "NHL managers should now fetch fresh data from ESPN API."
|
||||
echo "Check the logs to see if NHL games are now being displayed."
|
||||
Regular → Executable
Regular → Executable
@@ -1,356 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Security Report Generator
|
||||
|
||||
Aggregates JSON output from all CI security audit jobs into a single
|
||||
Markdown report suitable for PR comments and artifact storage.
|
||||
|
||||
Expected artifact layout (from actions/download-artifact@v4):
|
||||
<artifact-dir>/
|
||||
sast-results/
|
||||
bandit-results.json
|
||||
semgrep-results.json
|
||||
dependency-audit-results/
|
||||
pip-audit-results.json
|
||||
safety-results.json
|
||||
secrets-scan-results/
|
||||
gitleaks-results.json
|
||||
security-proofs-results/
|
||||
security-proofs-results.json
|
||||
plugin-audit-results/
|
||||
plugin-audit-results.json
|
||||
|
||||
Usage:
|
||||
python scripts/generate_report.py --artifact-dir audit-artifacts/ --output report.md
|
||||
python scripts/generate_report.py --artifact-dir audit-artifacts/ --output report.md --verbose
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timezone
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
# Gitleaks matches exactly equal to one of these (not a substring match -- a
|
||||
# real secret that merely contains one of these words as part of its actual
|
||||
# value must still be reported) are known template placeholders.
|
||||
_GITLEAKS_SUPPRESS_EXACT_VALUES = {
|
||||
"YOUR_YOUTUBE_API_KEY",
|
||||
"YOUR_YOUTUBE_CHANNEL_ID",
|
||||
"YOUR_GITHUB_PERSONAL_ACCESS_TOKEN",
|
||||
}
|
||||
|
||||
# Findings in these files are suppressed regardless of value -- they are
|
||||
# template/example files that are expected to only ever contain placeholders.
|
||||
_GITLEAKS_SUPPRESS_PATHS = [
|
||||
"config_secrets.template.json",
|
||||
"config.template.json",
|
||||
]
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Helpers
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _load(path: Path) -> tuple[dict | list | None, str | None]:
|
||||
"""Load a JSON artifact file.
|
||||
|
||||
Returns (data, error): error is None on success (data is whatever was
|
||||
parsed, which may legitimately be an empty list/dict for a clean scan);
|
||||
otherwise error is a human-readable reason the artifact is unavailable,
|
||||
distinguishing "missing/malformed artifact" from "valid empty result" so
|
||||
callers don't silently treat a broken CI job as a clean pass.
|
||||
"""
|
||||
if not path.exists():
|
||||
return None, f"artifact not found: {path}"
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8")), None
|
||||
except (json.JSONDecodeError, OSError) as exc:
|
||||
return None, f"could not read/parse {path}: {exc}"
|
||||
|
||||
|
||||
def _md_sanitize_cell(value: object) -> str:
|
||||
"""Escape/normalize a value so scanner-controlled content (a matched
|
||||
secret, a bandit issue_text, a file path) can't alter the Markdown
|
||||
table's structure: pipes would add bogus columns, newlines would break
|
||||
out of the row (or forge a fake header/separator line)."""
|
||||
text = str(value)
|
||||
text = text.replace("\\", "\\\\").replace("|", "\\|")
|
||||
text = text.replace("\r\n", " ").replace("\n", " ").replace("\r", " ")
|
||||
return text
|
||||
|
||||
|
||||
def _md_table_row(*cells: str) -> str:
|
||||
return "| " + " | ".join(_md_sanitize_cell(c) for c in cells) + " |"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Per-tool summarizers
|
||||
# Returns: (markdown_lines: list[str], critical_count: int, available: bool)
|
||||
# `available=False` means the artifact was missing or malformed -- distinct
|
||||
# from a valid scan that simply found nothing -- so the caller can report
|
||||
# INCOMPLETE instead of silently counting it as a clean pass.
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _summarize_bandit(artifact_dir: Path) -> tuple[list[str], int, bool]:
|
||||
data, error = _load(artifact_dir / "sast-results" / "bandit-results.json")
|
||||
if error:
|
||||
return [f"_bandit results unavailable: {error}_"], 0, False
|
||||
|
||||
results = data.get("results", [])
|
||||
high = [r for r in results if r.get("issue_severity") == "HIGH"]
|
||||
medium = [r for r in results if r.get("issue_severity") == "MEDIUM"]
|
||||
low = [r for r in results if r.get("issue_severity") == "LOW"]
|
||||
|
||||
lines = [
|
||||
f"**Bandit**: {len(high)} HIGH · {len(medium)} MEDIUM · {len(low)} LOW"
|
||||
]
|
||||
|
||||
if high:
|
||||
lines += [
|
||||
"",
|
||||
"| Severity | File | Line | Issue |",
|
||||
"| --- | --- | --- | --- |",
|
||||
]
|
||||
for r in high[:10]:
|
||||
fname = Path(r.get("filename", "")).name
|
||||
lines.append(_md_table_row(
|
||||
"HIGH", f"`{fname}`",
|
||||
str(r.get("line_number", "?")),
|
||||
r.get("issue_text", "")
|
||||
))
|
||||
if len(high) > 10:
|
||||
lines.append(f"_… and {len(high) - 10} more HIGH findings_")
|
||||
|
||||
return lines, len(high), True
|
||||
|
||||
|
||||
def _summarize_pip_audit(artifact_dir: Path) -> tuple[list[str], int, bool]:
|
||||
data, error = _load(artifact_dir / "dependency-audit-results" / "pip-audit-results.json")
|
||||
if error:
|
||||
return [f"_pip-audit results unavailable: {error}_"], 0, False
|
||||
|
||||
# pip-audit JSON format: {"dependencies": [{"name": ..., "vulns": [...]}]}
|
||||
vulns: list[dict] = []
|
||||
for dep in data.get("dependencies", []):
|
||||
for v in dep.get("vulns", []):
|
||||
vulns.append({"package": dep.get("name", "?"), **v})
|
||||
|
||||
lines = [f"**pip-audit**: {len(vulns)} vulnerabilities found"]
|
||||
|
||||
if vulns:
|
||||
lines += ["", "| Package | ID | Fix |", "| --- | --- | --- |"]
|
||||
for v in vulns[:10]:
|
||||
fix = v.get("fix_versions", ["none"])
|
||||
fix_str = ", ".join(fix) if fix else "none"
|
||||
lines.append(_md_table_row(
|
||||
v.get("package", "?"),
|
||||
v.get("id", "?"),
|
||||
fix_str,
|
||||
))
|
||||
|
||||
# Treat known vulnerabilities as warnings, not critical (they may be unavoidable)
|
||||
return lines, 0, True
|
||||
|
||||
|
||||
def _summarize_gitleaks(artifact_dir: Path) -> tuple[list[str], int, bool]:
|
||||
data, error = _load(artifact_dir / "secrets-scan-results" / "gitleaks-results.json")
|
||||
if error:
|
||||
return [f"_gitleaks results unavailable: {error}_"], 0, False
|
||||
|
||||
if not isinstance(data, list):
|
||||
data = []
|
||||
|
||||
real_findings = []
|
||||
suppressed = 0
|
||||
for finding in data:
|
||||
secret_val = str(finding.get("Secret", "") or finding.get("Match", ""))
|
||||
file_name = Path(finding.get("File", "")).name
|
||||
if (secret_val in _GITLEAKS_SUPPRESS_EXACT_VALUES
|
||||
or file_name in _GITLEAKS_SUPPRESS_PATHS):
|
||||
suppressed += 1
|
||||
else:
|
||||
real_findings.append(finding)
|
||||
|
||||
lines = [
|
||||
f"**Gitleaks**: {len(real_findings)} finding(s) "
|
||||
f"({suppressed} suppressed as template placeholders)"
|
||||
]
|
||||
|
||||
if real_findings:
|
||||
lines += ["", "| Rule | File | Line | Description |", "| --- | --- | --- | --- |"]
|
||||
for f in real_findings[:10]:
|
||||
fname = Path(f.get("File", "")).name
|
||||
lines.append(_md_table_row(
|
||||
f.get("RuleID", "?"),
|
||||
f"`{fname}`",
|
||||
str(f.get("StartLine", "?")),
|
||||
f.get("Description", ""),
|
||||
))
|
||||
|
||||
critical = len(real_findings) # any real secret is critical
|
||||
return lines, critical, True
|
||||
|
||||
|
||||
def _summarize_security_proofs(artifact_dir: Path) -> tuple[list[str], int, bool]:
|
||||
data, error = _load(artifact_dir / "security-proofs-results" / "security-proofs-results.json")
|
||||
if error:
|
||||
return [f"_security proofs results unavailable: {error}_"], 0, False
|
||||
|
||||
if not isinstance(data, list):
|
||||
data = []
|
||||
|
||||
critical = [r for r in data if r.get("severity") == "CRITICAL"]
|
||||
warnings = [r for r in data if r.get("severity") == "WARNING"]
|
||||
passed = [r for r in data if r.get("severity") == "PASS"]
|
||||
skipped = [r for r in data if r.get("severity") == "SKIP"]
|
||||
|
||||
lines = [
|
||||
f"**Security Proofs**: "
|
||||
f"{len(passed)} PASS · {len(warnings)} WARN · "
|
||||
f"{len(critical)} CRITICAL · {len(skipped)} SKIP",
|
||||
"",
|
||||
]
|
||||
|
||||
_icon = {"PASS": "✅", "INFO": "ℹ️", "WARNING": "⚠️", # nosec B105 - severity labels, not credentials
|
||||
"CRITICAL": "🚨", "SKIP": "⏭️"}
|
||||
for r in data:
|
||||
icon = _icon.get(r.get("severity", ""), "❓")
|
||||
lines.append(
|
||||
f"- {icon} **{r.get('test_id', '?')}**: {r.get('message', '')}"
|
||||
)
|
||||
if r.get("details") and r.get("severity") in ("CRITICAL", "WARNING"):
|
||||
lines.append(f" - _{r['details']}_")
|
||||
|
||||
return lines, len(critical), True
|
||||
|
||||
|
||||
def _summarize_plugin_audit(artifact_dir: Path) -> tuple[list[str], int, bool]:
|
||||
data, error = _load(artifact_dir / "plugin-audit-results" / "plugin-audit-results.json")
|
||||
if error:
|
||||
return [f"_plugin audit results unavailable: {error}_"], 0, False
|
||||
|
||||
summary = data.get("summary", {})
|
||||
findings = data.get("findings", [])
|
||||
critical_findings = [f for f in findings if f.get("severity") == "CRITICAL"]
|
||||
warning_findings = [f for f in findings if f.get("severity") == "WARNING"]
|
||||
|
||||
lines = [
|
||||
f"**Plugin Audit**: {data.get('plugins_scanned', '?')} plugins scanned — "
|
||||
f"{summary.get('critical', 0)} CRITICAL · {summary.get('warnings', 0)} WARNINGS"
|
||||
]
|
||||
|
||||
if critical_findings:
|
||||
lines += ["", "| Plugin | File | Line | Rule | Message |",
|
||||
"| --- | --- | --- | --- | --- |"]
|
||||
for f in critical_findings[:10]:
|
||||
fname = Path(f.get("file", "")).name
|
||||
lines.append(_md_table_row(
|
||||
f.get("plugin_id", "?"),
|
||||
f"`{fname}`",
|
||||
str(f.get("line", "?")),
|
||||
f.get("rule", "?"),
|
||||
f.get("message", ""),
|
||||
))
|
||||
|
||||
if warning_findings and not critical_findings:
|
||||
lines.append(f"\n_{len(warning_findings)} warning(s) found — see artifact for details_")
|
||||
|
||||
return lines, summary.get("critical", 0), True
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Main
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Generate consolidated security audit report",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
parser.add_argument("--artifact-dir", required=True,
|
||||
help="Directory containing downloaded CI artifacts")
|
||||
parser.add_argument("--output", "-o", required=True,
|
||||
help="Output Markdown file path")
|
||||
parser.add_argument("--verbose", "-v", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
artifact_dir = Path(args.artifact_dir)
|
||||
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
||||
|
||||
bandit_lines, bandit_crit, bandit_ok = _summarize_bandit(artifact_dir)
|
||||
pip_audit_lines, pip_audit_crit, pip_audit_ok = _summarize_pip_audit(artifact_dir)
|
||||
gitleaks_lines, gitleaks_crit, gitleaks_ok = _summarize_gitleaks(artifact_dir)
|
||||
proofs_lines, proofs_crit, proofs_ok = _summarize_security_proofs(artifact_dir)
|
||||
plugins_lines, plugins_crit, plugins_ok = _summarize_plugin_audit(artifact_dir)
|
||||
|
||||
unavailable_tools = [
|
||||
name for name, ok in [
|
||||
("bandit", bandit_ok), ("pip-audit", pip_audit_ok),
|
||||
("gitleaks", gitleaks_ok), ("security-proofs", proofs_ok),
|
||||
("plugin-audit", plugins_ok),
|
||||
] if not ok
|
||||
]
|
||||
|
||||
total_critical = bandit_crit + pip_audit_crit + gitleaks_crit + proofs_crit + plugins_crit
|
||||
if unavailable_tools:
|
||||
# A missing/malformed artifact means that tool's checks never
|
||||
# actually ran -- this must not be reported as a clean PASS just
|
||||
# because the *artifacts that did load* found nothing.
|
||||
overall = "INCOMPLETE ⚠️"
|
||||
elif total_critical > 0:
|
||||
overall = "ACTION REQUIRED 🚨"
|
||||
else:
|
||||
overall = "PASSED ✅"
|
||||
|
||||
def section(title: str, lines: list[str]) -> str:
|
||||
return f"### {title}\n\n" + "\n".join(lines) + "\n"
|
||||
|
||||
incomplete_note = (
|
||||
f"\n_⚠️ Incomplete: results unavailable for {', '.join(unavailable_tools)} "
|
||||
f"— see the corresponding section(s) below for details_\n"
|
||||
if unavailable_tools else ""
|
||||
)
|
||||
|
||||
report = f"""## 🔒 Security Audit — {overall}
|
||||
|
||||
_Generated: {timestamp}_
|
||||
{incomplete_note}
|
||||
| Critical | High/Warn | Overall |
|
||||
| :---: | :---: | :---: |
|
||||
| {'🚨 ' + str(total_critical) if total_critical else '✅ 0'} | ⚠️ see below | {overall} |
|
||||
|
||||
---
|
||||
|
||||
{section('SAST — Bandit', bandit_lines)}
|
||||
{section('Dependencies — pip-audit', pip_audit_lines)}
|
||||
{section('Secrets — Gitleaks', gitleaks_lines)}
|
||||
{section('LEDMatrix Security Proofs', proofs_lines)}
|
||||
{section('Plugin Security Audit', plugins_lines)}
|
||||
---
|
||||
|
||||
_Total critical findings: **{total_critical}**_
|
||||
"""
|
||||
|
||||
output_path = Path(args.output)
|
||||
output_path.write_text(report, encoding="utf-8")
|
||||
|
||||
if args.verbose:
|
||||
print(f" Report written to: {output_path}")
|
||||
print(f" Status: {overall}")
|
||||
print(f" Critical findings: {total_critical}")
|
||||
print(f" bandit={bandit_crit} pip-audit={pip_audit_crit} "
|
||||
f"gitleaks={gitleaks_crit} proofs={proofs_crit} plugins={plugins_crit}")
|
||||
if unavailable_tools:
|
||||
print(f" Unavailable: {', '.join(unavailable_tools)}")
|
||||
|
||||
if unavailable_tools:
|
||||
return 1
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -44,17 +44,12 @@ def install_via_apt(package_name: str) -> Tuple[bool, str]:
|
||||
apt_package_map = {
|
||||
'flask': 'python3-flask',
|
||||
'PIL': 'python3-pil',
|
||||
'freetype': 'python3-freetype',
|
||||
'freetype-py': 'python3-freetype',
|
||||
'psutil': 'python3-psutil',
|
||||
'werkzeug': 'python3-werkzeug',
|
||||
'numpy': 'python3-numpy',
|
||||
'requests': 'python3-requests',
|
||||
'python-dateutil': 'python3-dateutil',
|
||||
'pytz': 'python3-tz',
|
||||
'geopy': 'python3-geopy',
|
||||
'unidecode': 'python3-unidecode',
|
||||
'websockets': 'python3-websockets',
|
||||
'websocket-client': 'python3-websocket-client'
|
||||
'pytz': 'python3-tz'
|
||||
}
|
||||
|
||||
apt_package = apt_package_map.get(package_name, f'python3-{package_name}')
|
||||
@@ -81,8 +76,8 @@ def install_via_pip(package_name: str) -> Tuple[bool, str]:
|
||||
pip RECORD file, so an uninstall attempt fails with "uninstall-no-record-file"
|
||||
and aborts the whole install. With --ignore-installed, pip lays the new
|
||||
version down in /usr/local where it shadows the apt copy instead of removing
|
||||
it. This matters when a pip dependency (google-api-python-client pulls a
|
||||
newer requests) needs to upgrade an apt-managed package.
|
||||
it. This matters when a pip dependency needs to upgrade an apt-managed
|
||||
package (e.g. a package that pulls a newer requests).
|
||||
|
||||
Returns (success, output).
|
||||
"""
|
||||
@@ -101,13 +96,35 @@ def install_via_pip(package_name: str) -> Tuple[bool, str]:
|
||||
|
||||
# Distribution (pip/apt) names whose importable module name differs.
|
||||
IMPORT_NAME_MAP = {
|
||||
'python-dateutil': 'dateutil',
|
||||
'websocket-client': 'websocket',
|
||||
'freetype-py': 'freetype',
|
||||
}
|
||||
|
||||
# Minimum versions that must be met for an already-installed package to count
|
||||
# as satisfied. Debian Bookworm's python3-freetype is 2.3.0, below the
|
||||
# freetype-py>=2.5.1 pin in requirements.txt, so an import-only check would
|
||||
# wrongly skip the pip upgrade.
|
||||
MIN_VERSIONS = {
|
||||
'freetype-py': (2, 5, 1),
|
||||
}
|
||||
|
||||
|
||||
def _installed_version_tuple(dist_name: str) -> tuple:
|
||||
"""Return the installed distribution version as an int tuple, or () if unknown."""
|
||||
try:
|
||||
from importlib.metadata import version
|
||||
parts = []
|
||||
for part in version(dist_name).split('.'):
|
||||
digits = ''.join(ch for ch in part if ch.isdigit())
|
||||
if not digits:
|
||||
break
|
||||
parts.append(int(digits))
|
||||
return tuple(parts)
|
||||
except Exception:
|
||||
return ()
|
||||
|
||||
|
||||
def check_package_installed(package_name: str) -> bool:
|
||||
"""Check if a package is already installed."""
|
||||
"""Check if a package is already installed (and meets any minimum version)."""
|
||||
import_name = IMPORT_NAME_MAP.get(package_name, package_name)
|
||||
# Suppress deprecation warnings when checking if packages are installed
|
||||
# (we're just checking, not using them)
|
||||
@@ -115,9 +132,16 @@ def check_package_installed(package_name: str) -> bool:
|
||||
warnings.filterwarnings('ignore', category=DeprecationWarning)
|
||||
try:
|
||||
__import__(import_name)
|
||||
return True
|
||||
except ImportError:
|
||||
return False
|
||||
minimum = MIN_VERSIONS.get(package_name)
|
||||
if minimum:
|
||||
installed = _installed_version_tuple(package_name)
|
||||
if not installed or installed < minimum:
|
||||
print(f"{package_name} is installed but below the required "
|
||||
f"{'.'.join(map(str, minimum))}; will upgrade via pip")
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def print_failure_summary(failed_packages: List[str], failure_details: dict) -> None:
|
||||
@@ -147,17 +171,12 @@ def main():
|
||||
required_packages = [
|
||||
'flask',
|
||||
'PIL',
|
||||
'freetype',
|
||||
'freetype-py',
|
||||
'psutil',
|
||||
'werkzeug',
|
||||
'numpy',
|
||||
'requests',
|
||||
'python-dateutil',
|
||||
'pytz',
|
||||
'geopy',
|
||||
'unidecode',
|
||||
'websockets',
|
||||
'websocket-client'
|
||||
'pytz'
|
||||
]
|
||||
|
||||
failed_packages = []
|
||||
@@ -168,8 +187,13 @@ def main():
|
||||
print(f"{package} is already installed")
|
||||
continue
|
||||
|
||||
# Try apt first, then pip
|
||||
# Try apt first, then pip. An apt install only counts if it also
|
||||
# satisfies any minimum version (Debian's python3-freetype can be
|
||||
# older than the freetype-py pin), otherwise fall through to pip.
|
||||
ok, apt_output = install_via_apt(package)
|
||||
if ok and package in MIN_VERSIONS and not check_package_installed(package):
|
||||
ok = False
|
||||
apt_output = f"apt version of {package} is below the required minimum"
|
||||
if not ok:
|
||||
ok, pip_output = install_via_pip(package)
|
||||
if not ok:
|
||||
@@ -177,15 +201,12 @@ def main():
|
||||
failure_details[package] = pip_output or apt_output
|
||||
|
||||
# Install packages that don't have apt equivalents
|
||||
# Packages without apt equivalents. Plugin-specific dependencies
|
||||
# (timezonefinder, google-api stack, icalevents, socketio, ...) are
|
||||
# no longer installed here — store plugins declare their own
|
||||
# requirements.txt, which the plugin store installs.
|
||||
special_packages = [
|
||||
'timezonefinder>=6.5.0,<7.0.0',
|
||||
'google-auth-oauthlib>=1.2.0,<2.0.0',
|
||||
'google-auth-httplib2>=0.2.0,<1.0.0',
|
||||
'google-api-python-client>=2.147.0,<3.0.0',
|
||||
'spotipy',
|
||||
'icalevents',
|
||||
'python-socketio>=5.11.0,<6.0.0',
|
||||
'python-engineio>=4.9.0,<5.0.0'
|
||||
]
|
||||
|
||||
for package in special_packages:
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Script to safely remove plugin backup directories
|
||||
# These were created during the plugin-to-submodule conversion
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
PLUGINS_DIR="$PROJECT_ROOT/plugins"
|
||||
|
||||
# Colors
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
RED='\033[0;31m'
|
||||
NC='\033[0m'
|
||||
|
||||
log_info() {
|
||||
echo -e "${GREEN}[INFO]${NC} $1"
|
||||
}
|
||||
|
||||
log_warn() {
|
||||
echo -e "${YELLOW}[WARN]${NC} $1"
|
||||
}
|
||||
|
||||
log_error() {
|
||||
echo -e "${RED}[ERROR]${NC} $1"
|
||||
}
|
||||
|
||||
# Verify submodules are working
|
||||
verify_submodules() {
|
||||
log_info "Verifying submodules are working..."
|
||||
local issues=0
|
||||
|
||||
for submod in football-scoreboard hockey-scoreboard ledmatrix-flights \
|
||||
ledmatrix-leaderboard ledmatrix-stocks ledmatrix-weather \
|
||||
mqtt-notifications; do
|
||||
if [ ! -d "$PLUGINS_DIR/$submod" ]; then
|
||||
log_error "Submodule directory missing: $submod"
|
||||
issues=$((issues + 1))
|
||||
elif [ ! -f "$PLUGINS_DIR/$submod/.git" ]; then
|
||||
log_error "Submodule .git file missing: $submod"
|
||||
issues=$((issues + 1))
|
||||
elif [ ! -f "$PLUGINS_DIR/$submod/manifest.json" ]; then
|
||||
log_warn "Submodule manifest missing: $submod (may be OK)"
|
||||
fi
|
||||
done
|
||||
|
||||
if [ $issues -eq 0 ]; then
|
||||
log_info "All submodules verified ✓"
|
||||
return 0
|
||||
else
|
||||
log_error "Found $issues issues with submodules"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Remove backup directories
|
||||
remove_backups() {
|
||||
log_info "Removing backup directories..."
|
||||
|
||||
local removed=0
|
||||
local total_size=0
|
||||
|
||||
for backup in "$PLUGINS_DIR"/*.backup*; do
|
||||
if [ -d "$backup" ]; then
|
||||
local name=$(basename "$backup")
|
||||
local size=$(du -sb "$backup" 2>/dev/null | awk '{print $1}')
|
||||
total_size=$((total_size + size))
|
||||
|
||||
log_info "Removing: $name"
|
||||
rm -rf "$backup"
|
||||
removed=$((removed + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
if [ $removed -gt 0 ]; then
|
||||
log_info "Removed $removed backup directory(ies)"
|
||||
log_info "Freed approximately $(numfmt --to=iec-i --suffix=B $total_size 2>/dev/null || echo "$total_size bytes")"
|
||||
else
|
||||
log_info "No backup directories found"
|
||||
fi
|
||||
}
|
||||
|
||||
# Main
|
||||
main() {
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
echo "=== Plugin Backup Removal Script ==="
|
||||
echo
|
||||
|
||||
# Verify submodules first
|
||||
if ! verify_submodules; then
|
||||
log_error "Submodule verification failed. Not removing backups."
|
||||
log_warn "Please fix submodule issues before removing backups."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo
|
||||
log_warn "This will permanently delete backup directories:"
|
||||
ls -1d "$PLUGINS_DIR"/*.backup* 2>/dev/null | sed 's|.*/| - |' || echo " (none found)"
|
||||
echo
|
||||
|
||||
read -p "Continue? (y/N): " -n 1 -r
|
||||
echo
|
||||
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||
log_info "Aborted"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
remove_backups
|
||||
|
||||
log_info "Done!"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
|
||||
@@ -8,10 +8,10 @@ import os
|
||||
import sys
|
||||
import argparse
|
||||
|
||||
# Add the src directory to the path so we can import our modules
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src'))
|
||||
# Add the project root to the path so we can import our modules
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
|
||||
|
||||
from cache_manager import CacheManager
|
||||
from src.cache_manager import CacheManager
|
||||
|
||||
def list_cache_keys(cache_manager):
|
||||
"""List all available cache keys."""
|
||||
|
||||
@@ -39,14 +39,10 @@ from src.cache.cache_strategy import CacheStrategy
|
||||
from src.cache.cache_metrics import CacheMetrics
|
||||
from src.logging_config import get_logger
|
||||
|
||||
class DateTimeEncoder(json.JSONEncoder):
|
||||
"""JSON encoder that serialises ``datetime`` objects as ISO-8601 strings."""
|
||||
|
||||
def default(self, obj):
|
||||
"""Return ISO-8601 string for datetime; delegate all other types to the base encoder."""
|
||||
if isinstance(obj, datetime):
|
||||
return obj.isoformat()
|
||||
return super().default(obj)
|
||||
# Canonical implementation lives in src.cache.disk_cache; re-exported here
|
||||
# because this module's docstring documents it and external code may import
|
||||
# it from either path.
|
||||
from src.cache.disk_cache import DateTimeEncoder # noqa: F401 - deliberate re-export
|
||||
|
||||
class CacheManager:
|
||||
"""Manages caching of API responses to reduce API calls."""
|
||||
|
||||
@@ -99,11 +99,6 @@ Helpers for ensuring directory permissions and ownership are correct
|
||||
when running as a service (used by `CacheManager` to set up its
|
||||
persistent cache directory).
|
||||
|
||||
## CLI Helpers (`cli.py`)
|
||||
|
||||
Shared CLI argument parsing helpers used by `scripts/dev/*` and other
|
||||
command-line entry points.
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Use centralized logging**: Import from `src.logging_config` instead of creating loggers directly
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
"""
|
||||
LEDMatrix Common CLI
|
||||
|
||||
Command-line interface for LEDMatrix Common utilities.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def main():
|
||||
"""Main CLI entry point."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="LEDMatrix Common Utilities",
|
||||
prog="ledmatrix-common"
|
||||
)
|
||||
|
||||
subparsers = parser.add_subparsers(dest='command', help='Available commands')
|
||||
|
||||
# Test command
|
||||
test_parser = subparsers.add_parser('test', help='Test common utilities')
|
||||
test_parser.add_argument('--display-width', type=int, default=128, help='Display width')
|
||||
test_parser.add_argument('--display-height', type=int, default=64, help='Display height')
|
||||
|
||||
# Validate command
|
||||
validate_parser = subparsers.add_parser('validate', help='Validate configuration')
|
||||
validate_parser.add_argument('config_file', help='Configuration file to validate')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.command == 'test':
|
||||
test_utilities(args.display_width, args.display_height)
|
||||
elif args.command == 'validate':
|
||||
validate_config(args.config_file)
|
||||
else:
|
||||
parser.print_help()
|
||||
|
||||
|
||||
def test_utilities(display_width: int, display_height: int):
|
||||
"""Test common utilities."""
|
||||
print(f"Testing LEDMatrix Common utilities with {display_width}x{display_height} display")
|
||||
|
||||
try:
|
||||
from ledmatrix_common import LogoHelper, TextHelper, DisplayHelper, GameHelper, ConfigHelper
|
||||
|
||||
# Test LogoHelper
|
||||
print("Testing LogoHelper...")
|
||||
logo_helper = LogoHelper(display_width, display_height)
|
||||
print(f"Logo cache stats: {logo_helper.get_cache_stats()}")
|
||||
|
||||
# Test TextHelper
|
||||
print("Testing TextHelper...")
|
||||
text_helper = TextHelper()
|
||||
fonts = text_helper.load_fonts()
|
||||
print(f"Loaded {len(fonts)} fonts")
|
||||
|
||||
# Test DisplayHelper
|
||||
print("Testing DisplayHelper...")
|
||||
display_helper = DisplayHelper(display_width, display_height)
|
||||
img = display_helper.create_base_image()
|
||||
print(f"Created {img.size} base image")
|
||||
|
||||
# Test GameHelper
|
||||
print("Testing GameHelper...")
|
||||
GameHelper()
|
||||
print("GameHelper initialized")
|
||||
|
||||
# Test ConfigHelper
|
||||
print("Testing ConfigHelper...")
|
||||
ConfigHelper()
|
||||
print("ConfigHelper initialized")
|
||||
|
||||
print("All tests passed!")
|
||||
|
||||
except ImportError as e:
|
||||
print(f"Import error: {e}")
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
print(f"Test error: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def validate_config(config_file: str):
|
||||
"""Validate configuration file."""
|
||||
config_path = Path(config_file)
|
||||
|
||||
if not config_path.exists():
|
||||
print(f"Configuration file not found: {config_file}")
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
from ledmatrix_common import ConfigHelper
|
||||
|
||||
config_helper = ConfigHelper()
|
||||
config = config_helper.load_config(config_path)
|
||||
|
||||
if config:
|
||||
print(f"Configuration loaded successfully from {config_file}")
|
||||
print(f"Found {len(config)} top-level keys")
|
||||
else:
|
||||
print(f"Failed to load configuration from {config_file}")
|
||||
sys.exit(1)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Validation error: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -187,10 +187,17 @@ class LogoHelper:
|
||||
def normalize_abbreviation(self, team_abbr: str) -> str:
|
||||
"""
|
||||
Normalize team abbreviation for consistent filename usage.
|
||||
|
||||
|
||||
NOTE: this deliberately differs from
|
||||
LogoDownloader.normalize_abbreviation (src/logo_downloader.py),
|
||||
which replaces filesystem-unsafe characters (/ \\ : * ? " < > |)
|
||||
but does not strip spaces. Plugins call the LogoDownloader
|
||||
version; changing either implementation changes which logo
|
||||
filenames resolve on existing installs.
|
||||
|
||||
Args:
|
||||
team_abbr: Raw team abbreviation
|
||||
|
||||
|
||||
Returns:
|
||||
Normalized abbreviation
|
||||
"""
|
||||
|
||||
@@ -449,10 +449,6 @@ class ConfigManager:
|
||||
"""Get display configuration."""
|
||||
return self.config.get('display', {})
|
||||
|
||||
def get_clock_config(self) -> Dict[str, Any]:
|
||||
"""Get clock configuration."""
|
||||
return self.config.get('clock', {})
|
||||
|
||||
def get_config(self) -> Dict[str, Any]:
|
||||
"""Get the full configuration dictionary.
|
||||
|
||||
|
||||
@@ -118,7 +118,14 @@ class LogoDownloader:
|
||||
|
||||
@staticmethod
|
||||
def normalize_abbreviation(abbreviation: str) -> str:
|
||||
"""Normalize team abbreviation for consistent filename usage."""
|
||||
"""Normalize team abbreviation for consistent filename usage.
|
||||
|
||||
Public API: sports scoreboard plugins call this directly.
|
||||
NOTE: LogoHelper.normalize_abbreviation (src/common/logo_helper.py)
|
||||
is a deliberately different variant (strips spaces, fewer character
|
||||
replacements) — keep both behaviors stable; logo filenames on
|
||||
existing installs depend on them.
|
||||
"""
|
||||
# Handle special characters that can cause filesystem issues
|
||||
normalized = abbreviation.upper()
|
||||
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
Base test class for LEDMatrix plugins.
|
||||
|
||||
Provides common fixtures and helper methods for plugin testing.
|
||||
|
||||
Note: this is the plugin-author-facing base class shipped with the
|
||||
core (importable as src.plugin_system.testing.plugin_test_base). The
|
||||
repo's own plugin tests use a separate, richer harness in
|
||||
test/plugins/test_plugin_base.py — the two are intentionally distinct.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
|
||||
@@ -10,6 +10,17 @@ without requiring hardware or the RGBMatrixEmulator. Used for:
|
||||
Unlike MockDisplayManager (which logs calls but doesn't render) or
|
||||
MagicMock (which tracks nothing visual), this class creates a real
|
||||
PIL Image canvas and draws text using the actual project fonts.
|
||||
|
||||
MAINTENANCE WARNING: this class is a deliberate fork of
|
||||
src/display_manager.py so it can run without hardware. It mirrors
|
||||
these DisplayManager methods by name and behavior: _load_fonts,
|
||||
_draw_bdf_text, get_font_height, get_text_width, draw_text,
|
||||
draw_text_with_icons, draw_weather_icon (and the _draw_sun/_draw_cloud/
|
||||
_draw_rain/_draw_snow/_draw_storm family), format_date_with_ordinal,
|
||||
capture_mode, set_scrolling_state, is_currently_scrolling,
|
||||
process_deferred_updates, update_display, render_size. A behavior
|
||||
change to any of those in DisplayManager must be mirrored here, or
|
||||
plugin visual tests will pass against stale behavior.
|
||||
"""
|
||||
|
||||
import math
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
"""
|
||||
Centralized error handling for web interface.
|
||||
|
||||
Provides decorators and helpers for consistent error handling across API endpoints.
|
||||
Provides helpers for consistent error responses across API endpoints.
|
||||
"""
|
||||
|
||||
import functools
|
||||
from typing import Callable, Any, Optional
|
||||
from typing import Any, Optional
|
||||
from flask import jsonify
|
||||
|
||||
from src.web_interface.errors import (
|
||||
@@ -17,70 +16,6 @@ from src.logging_config import get_logger
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def handle_errors(
|
||||
default_error_code: Optional[ErrorCode] = None,
|
||||
default_category: Optional[ErrorCategory] = None,
|
||||
log_error: bool = True
|
||||
):
|
||||
"""
|
||||
Decorator to handle errors in API endpoints.
|
||||
|
||||
Catches exceptions and converts them to structured error responses.
|
||||
|
||||
Args:
|
||||
default_error_code: Default error code if exception doesn't match known types
|
||||
default_category: Default error category
|
||||
log_error: Whether to log the error
|
||||
"""
|
||||
def decorator(func: Callable) -> Callable:
|
||||
@functools.wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
try:
|
||||
return func(*args, **kwargs)
|
||||
except WebInterfaceError as e:
|
||||
# Already a structured error
|
||||
if log_error:
|
||||
logger.error(
|
||||
f"Error in {func.__name__}: {e.message}",
|
||||
extra={
|
||||
'error_code': e.error_code.value,
|
||||
'category': e.category.value,
|
||||
'context': e.context
|
||||
}
|
||||
)
|
||||
return jsonify(e.to_dict()), 500
|
||||
|
||||
except Exception as e:
|
||||
# Convert to structured error
|
||||
web_error = WebInterfaceError.from_exception(
|
||||
e,
|
||||
error_code=default_error_code,
|
||||
context={
|
||||
'function': func.__name__,
|
||||
'endpoint': getattr(func, '__name__', 'unknown')
|
||||
}
|
||||
)
|
||||
|
||||
if default_category:
|
||||
web_error.category = default_category
|
||||
|
||||
if log_error:
|
||||
logger.error(
|
||||
f"Unhandled error in {func.__name__}: {e}",
|
||||
exc_info=True,
|
||||
extra={
|
||||
'error_code': web_error.error_code.value,
|
||||
'category': web_error.category.value,
|
||||
'context': web_error.context
|
||||
}
|
||||
)
|
||||
|
||||
return jsonify(web_error.to_dict()), 500
|
||||
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
|
||||
def create_error_response(
|
||||
error_code: ErrorCode,
|
||||
message: str,
|
||||
|
||||
@@ -1,160 +0,0 @@
|
||||
"""
|
||||
Structured logging configuration for web interface.
|
||||
|
||||
Provides JSON-formatted structured logging for better debugging and monitoring.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any, Optional
|
||||
|
||||
|
||||
class StructuredFormatter(logging.Formatter):
|
||||
"""
|
||||
JSON formatter for structured logging.
|
||||
|
||||
Formats log records as JSON for easy parsing and analysis.
|
||||
"""
|
||||
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
"""Format log record as JSON."""
|
||||
log_data = {
|
||||
'timestamp': datetime.utcnow().isoformat(),
|
||||
'level': record.levelname,
|
||||
'logger': record.name,
|
||||
'message': record.getMessage(),
|
||||
'module': record.module,
|
||||
'function': record.funcName,
|
||||
'line': record.lineno
|
||||
}
|
||||
|
||||
# Add exception info if present
|
||||
if record.exc_info:
|
||||
log_data['exception'] = self.formatException(record.exc_info)
|
||||
|
||||
# Add extra fields from record
|
||||
if hasattr(record, 'extra'):
|
||||
log_data.update(record.extra)
|
||||
|
||||
# Add context from record
|
||||
if hasattr(record, 'context'):
|
||||
log_data['context'] = record.context
|
||||
|
||||
return json.dumps(log_data)
|
||||
|
||||
def formatException(self, exc_info) -> Dict[str, Any]:
|
||||
"""Format exception as structured data."""
|
||||
import traceback
|
||||
return {
|
||||
'type': exc_info[0].__name__ if exc_info[0] else None,
|
||||
'message': str(exc_info[1]) if exc_info[1] else None,
|
||||
'traceback': traceback.format_exception(*exc_info)
|
||||
}
|
||||
|
||||
|
||||
def setup_structured_logging(
|
||||
level: int = logging.INFO,
|
||||
use_json: bool = False,
|
||||
output_stream = sys.stdout
|
||||
) -> None:
|
||||
"""
|
||||
Set up structured logging for web interface.
|
||||
|
||||
Args:
|
||||
level: Logging level
|
||||
use_json: Whether to use JSON formatting
|
||||
output_stream: Output stream for logs
|
||||
"""
|
||||
root_logger = logging.getLogger()
|
||||
root_logger.setLevel(level)
|
||||
|
||||
# Remove existing handlers
|
||||
for handler in root_logger.handlers[:]:
|
||||
root_logger.removeHandler(handler)
|
||||
|
||||
# Create handler
|
||||
handler = logging.StreamHandler(output_stream)
|
||||
handler.setLevel(level)
|
||||
|
||||
# Set formatter
|
||||
if use_json:
|
||||
formatter = StructuredFormatter()
|
||||
else:
|
||||
formatter = logging.Formatter(
|
||||
'%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
|
||||
handler.setFormatter(formatter)
|
||||
root_logger.addHandler(handler)
|
||||
|
||||
|
||||
def log_plugin_operation(
|
||||
logger: logging.Logger,
|
||||
operation: str,
|
||||
plugin_id: str,
|
||||
status: str,
|
||||
context: Optional[Dict[str, Any]] = None
|
||||
) -> None:
|
||||
"""
|
||||
Log a plugin operation with structured data.
|
||||
|
||||
Args:
|
||||
logger: Logger instance
|
||||
operation: Operation name (install, update, uninstall, etc.)
|
||||
plugin_id: Plugin identifier
|
||||
status: Operation status (success, failed, etc.)
|
||||
context: Optional additional context
|
||||
"""
|
||||
extra = {
|
||||
'operation': operation,
|
||||
'plugin_id': plugin_id,
|
||||
'status': status
|
||||
}
|
||||
|
||||
if context:
|
||||
extra['context'] = context
|
||||
|
||||
logger.info(
|
||||
f"Plugin operation: {operation} for {plugin_id} - {status}",
|
||||
extra=extra
|
||||
)
|
||||
|
||||
|
||||
def log_config_change(
|
||||
logger: logging.Logger,
|
||||
config_key: str,
|
||||
action: str,
|
||||
before: Optional[Dict[str, Any]] = None,
|
||||
after: Optional[Dict[str, Any]] = None,
|
||||
context: Optional[Dict[str, Any]] = None
|
||||
) -> None:
|
||||
"""
|
||||
Log a configuration change with before/after values.
|
||||
|
||||
Args:
|
||||
logger: Logger instance
|
||||
config_key: Configuration key that changed
|
||||
action: Action performed (save, update, delete, etc.)
|
||||
before: Configuration before change
|
||||
after: Configuration after change
|
||||
context: Optional additional context
|
||||
"""
|
||||
extra = {
|
||||
'config_key': config_key,
|
||||
'action': action
|
||||
}
|
||||
|
||||
if before:
|
||||
extra['before'] = before
|
||||
if after:
|
||||
extra['after'] = after
|
||||
if context:
|
||||
extra['context'] = context
|
||||
|
||||
logger.info(
|
||||
f"Config change: {action} on {config_key}",
|
||||
extra=extra
|
||||
)
|
||||
|
||||
@@ -1,166 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Diagnostic script to examine NBA API data structure and identify the missing 'id' field issue.
|
||||
"""
|
||||
import requests
|
||||
import logging
|
||||
from typing import Dict, Any
|
||||
|
||||
# Set up logging
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def fetch_nba_teams_data() -> Dict[str, Any]:
|
||||
"""Fetch NBA teams data from ESPN API."""
|
||||
teams_url = "https://site.api.espn.com/apis/site/v2/sports/basketball/nba/teams"
|
||||
|
||||
try:
|
||||
logger.info(f"Fetching NBA teams data from: {teams_url}")
|
||||
response = requests.get(teams_url, timeout=30)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
logger.info(f"Successfully fetched NBA teams data")
|
||||
logger.info(f"Response structure keys: {list(data.keys())}")
|
||||
|
||||
# Examine the structure
|
||||
sports = data.get('sports', [])
|
||||
if sports:
|
||||
logger.info(f"Number of sports: {len(sports)}")
|
||||
sport = sports[0]
|
||||
logger.info(f"Sport keys: {list(sport.keys())}")
|
||||
|
||||
leagues = sport.get('leagues', [])
|
||||
if leagues:
|
||||
league = leagues[0]
|
||||
logger.info(f"League keys: {list(league.keys())}")
|
||||
|
||||
teams = league.get('teams', [])
|
||||
logger.info(f"Number of teams: {len(teams)}")
|
||||
|
||||
if teams:
|
||||
# Examine first team structure
|
||||
first_team = teams[0]
|
||||
logger.info(f"First team keys: {list(first_team.keys())}")
|
||||
|
||||
team_data = first_team.get('team', {})
|
||||
logger.info(f"Team data keys: {list(team_data.keys())}")
|
||||
|
||||
# Check for id field
|
||||
team_id = team_data.get('id')
|
||||
team_abbr = team_data.get('abbreviation')
|
||||
team_name = team_data.get('name')
|
||||
|
||||
logger.info(f"Sample team: ID={team_id}, ABBR={team_abbr}, NAME={team_name}")
|
||||
|
||||
if team_id:
|
||||
logger.info(f"Team ID field exists: {team_id}")
|
||||
else:
|
||||
logger.error("Team ID field is missing!")
|
||||
|
||||
# Check a few more teams to confirm structure
|
||||
for i in range(min(5, len(teams))):
|
||||
team = teams[i].get('team', {})
|
||||
logger.info(f"Team {i+1}: ID={team.get('id')}, ABBR={team.get('abbreviation')}")
|
||||
|
||||
return data
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching NBA teams data: {e}")
|
||||
return {}
|
||||
|
||||
def fetch_nba_standings_data() -> Dict[str, Any]:
|
||||
"""Fetch NBA standings data from ESPN API."""
|
||||
standings_url = "https://site.api.espn.com/apis/v2/sports/basketball/nba/standings"
|
||||
|
||||
try:
|
||||
logger.info(f"Fetching NBA standings data from: {standings_url}")
|
||||
response = requests.get(standings_url, timeout=30)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
logger.info(f"Successfully fetched NBA standings data")
|
||||
logger.info(f"Response structure keys: {list(data.keys())}")
|
||||
|
||||
# Check if standings has entries (direct structure)
|
||||
if 'standings' in data and 'entries' in data['standings']:
|
||||
entries = data['standings']['entries']
|
||||
logger.info(f"Number of standings entries (direct): {len(entries)}")
|
||||
|
||||
if entries:
|
||||
# Examine first entry structure
|
||||
first_entry = entries[0]
|
||||
logger.info(f"First entry keys: {list(first_entry.keys())}")
|
||||
|
||||
team_data = first_entry.get('team', {})
|
||||
logger.info(f"Team data keys: {list(team_data.keys())}")
|
||||
|
||||
# Check for id field
|
||||
team_id = team_data.get('id')
|
||||
team_abbr = team_data.get('abbreviation')
|
||||
team_name = team_data.get('displayName')
|
||||
|
||||
logger.info(f"Sample standings team: ID={team_id}, ABBR={team_abbr}, NAME={team_name}")
|
||||
|
||||
if team_id:
|
||||
logger.info(f"Standings team ID field exists: {team_id}")
|
||||
else:
|
||||
logger.error("Standings team ID field is missing!")
|
||||
|
||||
# Check children structure (divisions/conferences)
|
||||
if 'children' in data:
|
||||
children = data.get('children', [])
|
||||
logger.info(f"Number of children (divisions/conferences): {len(children)}")
|
||||
|
||||
for i, child in enumerate(children):
|
||||
logger.info(f"Child {i+1} keys: {list(child.keys())}")
|
||||
child_name = child.get('displayName', 'Unknown')
|
||||
logger.info(f"Child {i+1} name: {child_name}")
|
||||
|
||||
if 'standings' in child and 'entries' in child['standings']:
|
||||
entries = child['standings']['entries']
|
||||
logger.info(f"Child {i+1} has {len(entries)} entries")
|
||||
|
||||
if entries:
|
||||
# Examine first entry in this child
|
||||
first_entry = entries[0]
|
||||
logger.info(f"Child {i+1} first entry keys: {list(first_entry.keys())}")
|
||||
|
||||
team_data = first_entry.get('team', {})
|
||||
logger.info(f"Child {i+1} team data keys: {list(team_data.keys())}")
|
||||
|
||||
# Check for id field
|
||||
team_id = team_data.get('id')
|
||||
team_abbr = team_data.get('abbreviation')
|
||||
team_name = team_data.get('displayName')
|
||||
|
||||
logger.info(f"Child {i+1} sample team: ID={team_id}, ABBR={team_abbr}, NAME={team_name}")
|
||||
|
||||
if team_id:
|
||||
logger.info(f"Child {i+1} team ID field exists: {team_id}")
|
||||
else:
|
||||
logger.error(f"Child {i+1} team ID field is missing!")
|
||||
|
||||
return data
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching NBA standings data: {e}")
|
||||
return {}
|
||||
|
||||
def main():
|
||||
"""Main diagnostic function."""
|
||||
logger.info("Starting NBA API data structure diagnosis")
|
||||
|
||||
# Fetch teams data
|
||||
teams_data = fetch_nba_teams_data()
|
||||
|
||||
# Fetch standings data
|
||||
standings_data = fetch_nba_standings_data()
|
||||
|
||||
# Summary
|
||||
logger.info("Diagnosis complete")
|
||||
logger.info("Check the logs above to see if team 'id' fields are present")
|
||||
logger.info("The leaderboard manager needs team 'id' fields for logo fetching")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -383,19 +383,6 @@ class TestConfigHelpers:
|
||||
display_config = manager.get_display_config()
|
||||
assert display_config["hardware"]["rows"] == 32
|
||||
|
||||
def test_get_clock_config(self, tmp_path):
|
||||
"""Test getting clock config."""
|
||||
config_file = tmp_path / "config.json"
|
||||
config_data = {"clock": {"format": "12h"}}
|
||||
|
||||
with open(config_file, 'w') as f:
|
||||
json.dump(config_data, f)
|
||||
|
||||
manager = ConfigManager(config_path=str(config_file))
|
||||
manager.load_config()
|
||||
|
||||
clock_config = manager.get_clock_config()
|
||||
assert clock_config["format"] == "12h"
|
||||
|
||||
|
||||
class TestPluginConfigManagement:
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Guard: relative markdown links in active docs must resolve.
|
||||
|
||||
Scans repo-root *.md and docs/ (excluding docs/archive/, which is allowed
|
||||
to rot). External URLs, mailto links, and pure anchors are skipped, as are
|
||||
links inside fenced code blocks.
|
||||
"""
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Iterator
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
LINK_RE = re.compile(r'\[[^\]]*\]\(([^)\s]+)\)')
|
||||
FENCE_RE = re.compile(r'^(```|~~~)')
|
||||
|
||||
|
||||
def _md_files() -> Iterator[Path]:
|
||||
"""Yield active markdown files (repo root + docs/, excluding docs/archive/)."""
|
||||
yield from PROJECT_ROOT.glob('*.md')
|
||||
for path in PROJECT_ROOT.glob('docs/**/*.md'):
|
||||
if 'archive' not in path.parts:
|
||||
yield path
|
||||
|
||||
|
||||
def test_relative_markdown_links_resolve() -> None:
|
||||
"""Every relative markdown link outside code fences must resolve on disk."""
|
||||
broken = []
|
||||
for md in _md_files():
|
||||
in_fence = False
|
||||
for lineno, line in enumerate(md.read_text(encoding='utf-8').splitlines(), 1):
|
||||
if FENCE_RE.match(line.strip()):
|
||||
in_fence = not in_fence
|
||||
continue
|
||||
if in_fence:
|
||||
continue
|
||||
for target in LINK_RE.findall(line):
|
||||
if target.startswith(('http://', 'https://', 'mailto:', '#')):
|
||||
continue
|
||||
resolved = (md.parent / target.split('#')[0]).resolve()
|
||||
if not resolved.exists():
|
||||
broken.append(
|
||||
f'{md.relative_to(PROJECT_ROOT)}:{lineno} -> {target}'
|
||||
)
|
||||
assert not broken, 'Broken relative markdown links:\n' + '\n'.join(broken)
|
||||
@@ -1,262 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Core functionality test for NBA components without hardware dependencies.
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
import logging
|
||||
import json
|
||||
|
||||
# Set up logging
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def test_nba_data_structure():
|
||||
"""Test NBA data structure and team ID field presence."""
|
||||
try:
|
||||
import requests
|
||||
|
||||
# Test teams endpoint for data structure
|
||||
teams_url = "https://site.api.espn.com/apis/site/v2/sports/basketball/nba/teams"
|
||||
response = requests.get(teams_url, timeout=10)
|
||||
response.raise_for_status()
|
||||
teams_data = response.json()
|
||||
|
||||
# Extract first team to check structure
|
||||
sports = teams_data.get('sports', [])
|
||||
if not sports:
|
||||
logger.error("No sports data found")
|
||||
return False
|
||||
|
||||
leagues = sports[0].get('leagues', [])
|
||||
if not leagues:
|
||||
logger.error("No leagues data found")
|
||||
return False
|
||||
|
||||
teams = leagues[0].get('teams', [])
|
||||
if not teams:
|
||||
logger.error("No teams data found")
|
||||
return False
|
||||
|
||||
first_team = teams[0].get('team', {})
|
||||
team_id = first_team.get('id')
|
||||
team_abbr = first_team.get('abbreviation')
|
||||
|
||||
logger.info(f"Sample team: ID={team_id}, ABBR={team_abbr}")
|
||||
|
||||
if team_id is None:
|
||||
logger.error("❌ Team ID field missing!")
|
||||
return False
|
||||
|
||||
logger.info("✅ NBA data structure test PASSED")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ NBA data structure test FAILED: {e}")
|
||||
return False
|
||||
|
||||
def test_odds_data_structure():
|
||||
"""Test odds data structure."""
|
||||
try:
|
||||
import requests
|
||||
|
||||
# Test odds endpoint for data structure
|
||||
odds_url = "https://sports.core.api.espn.com/v2/sports/basketball/leagues/nba/events/401585515/competitions/401585515/odds"
|
||||
response = requests.get(odds_url, timeout=10)
|
||||
response.raise_for_status()
|
||||
odds_data = response.json()
|
||||
|
||||
logger.info(f"Odds data structure keys: {list(odds_data.keys())}")
|
||||
|
||||
# Check if odds data has expected structure
|
||||
if 'items' in odds_data:
|
||||
logger.info("✅ Odds data has expected structure")
|
||||
return True
|
||||
else:
|
||||
logger.warning("⚠️ Odds data structure different than expected")
|
||||
return True # Still pass since API is working
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Odds data structure test FAILED: {e}")
|
||||
return False
|
||||
|
||||
def test_nba_standings_structure():
|
||||
"""Test NBA standings data structure for team IDs."""
|
||||
try:
|
||||
import requests
|
||||
|
||||
# Test standings endpoint
|
||||
standings_url = "https://site.api.espn.com/apis/v2/sports/basketball/nba/standings"
|
||||
response = requests.get(standings_url, timeout=10)
|
||||
response.raise_for_status()
|
||||
standings_data = response.json()
|
||||
|
||||
# Check children structure (Eastern/Western conferences)
|
||||
children = standings_data.get('children', [])
|
||||
if not children:
|
||||
logger.error("No children (conferences) found in standings")
|
||||
return False
|
||||
|
||||
# Check first conference for team data
|
||||
first_conference = children[0]
|
||||
standings = first_conference.get('standings', {})
|
||||
entries = standings.get('entries', [])
|
||||
|
||||
if not entries:
|
||||
logger.error("No standings entries found")
|
||||
return False
|
||||
|
||||
# Check first team for ID field
|
||||
first_team = entries[0].get('team', {})
|
||||
team_id = first_team.get('id')
|
||||
team_abbr = first_team.get('abbreviation')
|
||||
|
||||
logger.info(f"Standings team: ID={team_id}, ABBR={team_abbr}")
|
||||
|
||||
if team_id is None:
|
||||
logger.error("❌ Standings team ID field missing!")
|
||||
return False
|
||||
|
||||
logger.info("✅ NBA standings structure test PASSED")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ NBA standings structure test FAILED: {e}")
|
||||
return False
|
||||
|
||||
def test_configuration_analysis():
|
||||
"""Analyze current NBA configuration."""
|
||||
try:
|
||||
with open('config/config.json', 'r') as f:
|
||||
config = json.load(f)
|
||||
|
||||
# Analyze NBA scoreboard config
|
||||
nba_scoreboard = config.get('nba_scoreboard', {})
|
||||
logger.info("NBA Scoreboard Configuration:")
|
||||
logger.info(f" Enabled: {nba_scoreboard.get('enabled', False)}")
|
||||
logger.info(f" Show Odds: {nba_scoreboard.get('show_odds', False)}")
|
||||
logger.info(f" Favorite Teams: {nba_scoreboard.get('favorite_teams', [])}")
|
||||
logger.info(f" Logo Directory: {nba_scoreboard.get('logo_dir', 'N/A')}")
|
||||
|
||||
# Analyze leaderboard config
|
||||
leaderboard = config.get('leaderboard', {})
|
||||
nba_leaderboard = leaderboard.get('enabled_sports', {}).get('nba', {})
|
||||
|
||||
logger.info("\nLeaderboard NBA Configuration:")
|
||||
logger.info(f" Leaderboard Enabled: {leaderboard.get('enabled', False)}")
|
||||
logger.info(f" NBA Enabled: {nba_leaderboard.get('enabled', False)}")
|
||||
logger.info(f" NBA Top Teams: {nba_leaderboard.get('top_teams', 'N/A')}")
|
||||
|
||||
# Check for potential issues
|
||||
issues = []
|
||||
|
||||
if not nba_scoreboard.get('enabled', False) and nba_scoreboard.get('show_odds', False):
|
||||
issues.append("⚠️ NBA scoreboard disabled but odds enabled")
|
||||
|
||||
if leaderboard.get('enabled', False) and not nba_leaderboard.get('enabled', False):
|
||||
issues.append("ℹ️ Leaderboard enabled but NBA disabled")
|
||||
|
||||
if issues:
|
||||
logger.warning("Configuration Issues Found:")
|
||||
for issue in issues:
|
||||
logger.warning(f" {issue}")
|
||||
else:
|
||||
logger.info("✅ No configuration issues found")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Configuration analysis FAILED: {e}")
|
||||
return False
|
||||
|
||||
def test_nba_logo_path_construction():
|
||||
"""Test NBA logo path construction logic."""
|
||||
try:
|
||||
# Simulate the logo path construction from leaderboard manager
|
||||
team_abbr = "LAL"
|
||||
logo_dir = "assets/sports/nba_logos"
|
||||
expected_path = f"{logo_dir}/{team_abbr}.png"
|
||||
|
||||
logger.info(f"Expected logo path: {expected_path}")
|
||||
|
||||
# Check if directory exists
|
||||
if os.path.exists(logo_dir):
|
||||
logger.info(f"✅ Logo directory exists: {logo_dir}")
|
||||
else:
|
||||
logger.warning(f"⚠️ Logo directory does not exist: {logo_dir}")
|
||||
|
||||
# Test team ID mapping (simulate what we fixed)
|
||||
sample_teams = [
|
||||
("LAL", "13"), # Lakers
|
||||
("BOS", "2"), # Celtics
|
||||
("MIA", "14"), # Heat
|
||||
]
|
||||
|
||||
for abbr, team_id in sample_teams:
|
||||
logger.info(f"Team {abbr}: ID={team_id} (for logo fetching)")
|
||||
|
||||
logger.info("✅ NBA logo path construction test PASSED")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ NBA logo path construction test FAILED: {e}")
|
||||
return False
|
||||
|
||||
def main():
|
||||
"""Run core functionality tests."""
|
||||
logger.info("🧪 Starting NBA Core Functionality Tests")
|
||||
logger.info("=" * 60)
|
||||
|
||||
tests = [
|
||||
("NBA Data Structure", test_nba_data_structure),
|
||||
("Odds Data Structure", test_odds_data_structure),
|
||||
("NBA Standings Structure", test_nba_standings_structure),
|
||||
("Configuration Analysis", test_configuration_analysis),
|
||||
("NBA Logo Path Construction", test_nba_logo_path_construction),
|
||||
]
|
||||
|
||||
results = []
|
||||
for test_name, test_func in tests:
|
||||
logger.info(f"\n🔍 Running: {test_name}")
|
||||
try:
|
||||
result = test_func()
|
||||
results.append((test_name, result))
|
||||
except Exception as e:
|
||||
logger.error(f"❌ {test_name} crashed: {e}")
|
||||
results.append((test_name, False))
|
||||
|
||||
# Summary
|
||||
logger.info("\n" + "=" * 60)
|
||||
logger.info("📊 TEST SUMMARY")
|
||||
logger.info("=" * 60)
|
||||
|
||||
passed = 0
|
||||
failed = 0
|
||||
|
||||
for test_name, result in results:
|
||||
status = "✅ PASSED" if result else "❌ FAILED"
|
||||
logger.info(f"{test_name:<30} {status}")
|
||||
if result:
|
||||
passed += 1
|
||||
else:
|
||||
failed += 1
|
||||
|
||||
logger.info("-" * 60)
|
||||
logger.info(f"Total: {len(results)} | Passed: {passed} | Failed: {failed}")
|
||||
|
||||
if failed == 0:
|
||||
logger.info("🎉 ALL CORE TESTS PASSED!")
|
||||
logger.info("\n📋 SUMMARY:")
|
||||
logger.info("✅ NBA API provides team ID fields correctly")
|
||||
logger.info("✅ Odds API integration is working")
|
||||
logger.info("✅ NBA standings structure includes team IDs")
|
||||
logger.info("✅ Logo fetching will work with team IDs")
|
||||
logger.info("✅ Configuration is properly set up")
|
||||
return True
|
||||
else:
|
||||
logger.error(f"❌ {failed} test(s) failed. Please check the issues above.")
|
||||
return False
|
||||
|
||||
if __name__ == "__main__":
|
||||
success = main()
|
||||
sys.exit(0 if success else 1)
|
||||
@@ -1,147 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Simple test script to verify NBA data structure includes team ID fields.
|
||||
"""
|
||||
import sys
|
||||
import requests
|
||||
import logging
|
||||
|
||||
# Set up logging
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def test_nba_data_structure():
|
||||
"""Test that NBA data includes team ID fields."""
|
||||
try:
|
||||
# Test fetching NBA teams data directly
|
||||
logger.info("Testing NBA teams API...")
|
||||
teams_url = "https://site.api.espn.com/apis/site/v2/sports/basketball/nba/teams"
|
||||
response = requests.get(teams_url, timeout=30)
|
||||
response.raise_for_status()
|
||||
teams_data = response.json()
|
||||
|
||||
# Extract team information
|
||||
sports = teams_data.get('sports', [])
|
||||
if not sports:
|
||||
logger.error("No sports data found!")
|
||||
return False
|
||||
|
||||
leagues = sports[0].get('leagues', [])
|
||||
if not leagues:
|
||||
logger.error("No leagues data found!")
|
||||
return False
|
||||
|
||||
teams = leagues[0].get('teams', [])
|
||||
if not teams:
|
||||
logger.error("No teams data found!")
|
||||
return False
|
||||
|
||||
logger.info(f"Found {len(teams)} NBA teams")
|
||||
|
||||
# Check first few teams for ID fields
|
||||
teams_with_ids = 0
|
||||
for i, team_data in enumerate(teams[:5]):
|
||||
team = team_data.get('team', {})
|
||||
team_id = team.get('id')
|
||||
team_abbr = team.get('abbreviation', 'Unknown')
|
||||
team_name = team.get('name', 'Unknown')
|
||||
|
||||
logger.info(f"Team {i+1}: ID={team_id}, ABBR={team_abbr}, NAME={team_name}")
|
||||
|
||||
if team_id is not None:
|
||||
teams_with_ids += 1
|
||||
|
||||
if teams_with_ids == 0:
|
||||
logger.error("No teams have ID fields!")
|
||||
return False
|
||||
|
||||
logger.info(f"{teams_with_ids} out of 5 tested teams have ID fields")
|
||||
|
||||
# Test fetching NBA standings data directly
|
||||
logger.info("Testing NBA standings API...")
|
||||
standings_url = "https://site.api.espn.com/apis/v2/sports/basketball/nba/standings"
|
||||
response = requests.get(standings_url, timeout=30)
|
||||
response.raise_for_status()
|
||||
standings_data = response.json()
|
||||
|
||||
# Check standings structure
|
||||
children = standings_data.get('children', [])
|
||||
logger.info(f"Found {len(children)} conference/division groups")
|
||||
|
||||
standings_teams_with_ids = 0
|
||||
total_standings_teams = 0
|
||||
|
||||
for child in children:
|
||||
if 'standings' in child and 'entries' in child['standings']:
|
||||
entries = child['standings']['entries']
|
||||
total_standings_teams += len(entries)
|
||||
|
||||
for entry in entries[:3]: # Check first 3 teams per conference
|
||||
team = entry.get('team', {})
|
||||
team_id = team.get('id')
|
||||
team_abbr = team.get('abbreviation', 'Unknown')
|
||||
team_name = team.get('displayName', 'Unknown')
|
||||
|
||||
logger.info(f"Standings team: ID={team_id}, ABBR={team_abbr}, NAME={team_name}")
|
||||
|
||||
if team_id is not None:
|
||||
standings_teams_with_ids += 1
|
||||
|
||||
if standings_teams_with_ids == 0:
|
||||
logger.error("No standings teams have ID fields!")
|
||||
return False
|
||||
|
||||
logger.info(f"{standings_teams_with_ids} standings teams have ID fields out of {total_standings_teams} total teams")
|
||||
|
||||
# Simulate the fixed leaderboard manager logic
|
||||
logger.info("Simulating fixed leaderboard manager logic...")
|
||||
|
||||
# Simulate the team data structure that would be created by the fixed code
|
||||
simulated_teams = []
|
||||
for team_data in teams[:3]: # Test with first 3 teams
|
||||
team = team_data.get('team', {})
|
||||
simulated_teams.append({
|
||||
'name': team.get('name', 'Unknown'),
|
||||
'id': team.get('id'), # This is the fix - including the ID field
|
||||
'abbreviation': team.get('abbreviation', 'Unknown'),
|
||||
'wins': 10, # Mock data
|
||||
'losses': 5, # Mock data
|
||||
'ties': 0, # Mock data
|
||||
'win_percentage': 0.667 # Mock data
|
||||
})
|
||||
|
||||
# Verify that our simulated teams have ID fields
|
||||
teams_with_ids_in_simulation = 0
|
||||
for team in simulated_teams:
|
||||
if team.get('id') is not None:
|
||||
teams_with_ids_in_simulation += 1
|
||||
logger.info(f"Simulated team: {team['abbreviation']} (ID: {team['id']})")
|
||||
|
||||
if teams_with_ids_in_simulation == len(simulated_teams):
|
||||
logger.info("✅ All simulated teams have ID fields - fix is working!")
|
||||
return True
|
||||
else:
|
||||
logger.error(f"❌ {len(simulated_teams) - teams_with_ids_in_simulation} simulated teams missing ID fields!")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error testing NBA data structure: {e}")
|
||||
return False
|
||||
|
||||
def main():
|
||||
"""Main test function."""
|
||||
logger.info("Testing NBA data structure and fix...")
|
||||
|
||||
success = test_nba_data_structure()
|
||||
|
||||
if success:
|
||||
logger.info("✅ NBA data structure test PASSED!")
|
||||
logger.info("The NBA leaderboard fix should work correctly")
|
||||
else:
|
||||
logger.error("❌ NBA data structure test FAILED!")
|
||||
|
||||
return success
|
||||
|
||||
if __name__ == "__main__":
|
||||
success = main()
|
||||
sys.exit(0 if success else 1)
|
||||
@@ -1,280 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Comprehensive test script to verify NBA Manager, Leaderboard, and Odds Manager integration.
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
import logging
|
||||
import json
|
||||
|
||||
# Set up logging
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def test_nba_api_connectivity():
|
||||
"""Test basic NBA API connectivity."""
|
||||
try:
|
||||
import requests
|
||||
|
||||
# Test teams endpoint
|
||||
teams_url = "https://site.api.espn.com/apis/site/v2/sports/basketball/nba/teams"
|
||||
response = requests.get(teams_url, timeout=10)
|
||||
response.raise_for_status()
|
||||
teams_data = response.json()
|
||||
|
||||
# Test standings endpoint
|
||||
standings_url = "https://site.api.espn.com/apis/v2/sports/basketball/nba/standings"
|
||||
response = requests.get(standings_url, timeout=10)
|
||||
response.raise_for_status()
|
||||
standings_data = response.json()
|
||||
|
||||
# Test live games endpoint
|
||||
live_url = "https://site.api.espn.com/apis/site/v2/sports/basketball/nba/scoreboard"
|
||||
response = requests.get(live_url, timeout=10)
|
||||
response.raise_for_status()
|
||||
live_data = response.json()
|
||||
|
||||
logger.info("✅ NBA API connectivity test PASSED")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ NBA API connectivity test FAILED: {e}")
|
||||
return False
|
||||
|
||||
def test_odds_api_connectivity():
|
||||
"""Test odds API connectivity."""
|
||||
try:
|
||||
import requests
|
||||
|
||||
# Test ESPN odds API
|
||||
odds_url = "https://sports.core.api.espn.com/v2/sports/basketball/leagues/nba/events/401585515/competitions/401585515/odds"
|
||||
response = requests.get(odds_url, timeout=10)
|
||||
response.raise_for_status()
|
||||
odds_data = response.json()
|
||||
|
||||
logger.info("✅ Odds API connectivity test PASSED")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Odds API connectivity test FAILED: {e}")
|
||||
return False
|
||||
|
||||
def test_nba_manager_initialization():
|
||||
"""Test NBA manager initialization and configuration."""
|
||||
try:
|
||||
# Mock the required dependencies since we're not on Raspberry Pi
|
||||
class MockDisplayManager:
|
||||
def __init__(self):
|
||||
self.matrix = type('obj', (object,), {'width': 64, 'height': 32})()
|
||||
|
||||
class MockCacheManager:
|
||||
def __init__(self):
|
||||
self.config_manager = None
|
||||
|
||||
def get(self, key):
|
||||
return None
|
||||
|
||||
def save_cache(self, key, data):
|
||||
pass
|
||||
|
||||
# Load config
|
||||
with open('config/config.json', 'r') as f:
|
||||
config = json.load(f)
|
||||
|
||||
# Test manager imports
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src'))
|
||||
|
||||
from nba_managers import BaseNBAManager, NBALiveManager, NBARecentManager, NBAUpcomingManager
|
||||
|
||||
# Test initialization
|
||||
display_manager = MockDisplayManager()
|
||||
cache_manager = MockCacheManager()
|
||||
|
||||
# Test base manager
|
||||
base_manager = BaseNBAManager(config, display_manager, cache_manager)
|
||||
logger.info(f"✅ Base NBA Manager initialized: {base_manager.league}")
|
||||
|
||||
# Test live manager
|
||||
live_manager = NBALiveManager(config, display_manager, cache_manager)
|
||||
logger.info(f"✅ NBA Live Manager initialized")
|
||||
|
||||
# Test recent manager
|
||||
recent_manager = NBARecentManager(config, display_manager, cache_manager)
|
||||
logger.info(f"✅ NBA Recent Manager initialized")
|
||||
|
||||
# Test upcoming manager
|
||||
upcoming_manager = NBAUpcomingManager(config, display_manager, cache_manager)
|
||||
logger.info(f"✅ NBA Upcoming Manager initialized")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ NBA Manager initialization test FAILED: {e}")
|
||||
return False
|
||||
|
||||
def test_leaderboard_nba_integration():
|
||||
"""Test leaderboard NBA integration."""
|
||||
try:
|
||||
# Mock dependencies
|
||||
class MockDisplayManager:
|
||||
def __init__(self):
|
||||
self.matrix = type('obj', (object,), {'width': 64, 'height': 32})()
|
||||
|
||||
class MockCacheManager:
|
||||
def __init__(self):
|
||||
self.config_manager = None
|
||||
|
||||
def get_cached_data_with_strategy(self, key, strategy):
|
||||
return None
|
||||
|
||||
def save_cache(self, key, data):
|
||||
pass
|
||||
|
||||
def clear_cache(self, key):
|
||||
pass
|
||||
|
||||
# Load config
|
||||
with open('config/config.json', 'r') as f:
|
||||
config = json.load(f)
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src'))
|
||||
|
||||
from leaderboard_manager import LeaderboardManager
|
||||
|
||||
# Test initialization
|
||||
display_manager = MockDisplayManager()
|
||||
cache_manager = MockCacheManager()
|
||||
|
||||
leaderboard = LeaderboardManager(config, display_manager)
|
||||
|
||||
# Check if NBA is configured in leaderboard
|
||||
nba_config = leaderboard.league_configs.get('nba', {})
|
||||
logger.info(f"NBA leaderboard config: {nba_config}")
|
||||
|
||||
# Test NBA standings fetching (without actual API call)
|
||||
logger.info("✅ Leaderboard NBA integration test PASSED")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Leaderboard NBA integration test FAILED: {e}")
|
||||
return False
|
||||
|
||||
def test_odds_manager_integration():
|
||||
"""Test odds manager integration."""
|
||||
try:
|
||||
# Mock cache manager
|
||||
class MockCacheManager:
|
||||
def __init__(self):
|
||||
self.config_manager = None
|
||||
|
||||
def get_with_auto_strategy(self, key):
|
||||
return None
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src'))
|
||||
|
||||
from odds_manager import OddsManager
|
||||
|
||||
# Test initialization
|
||||
cache_manager = MockCacheManager()
|
||||
odds_manager = OddsManager(cache_manager)
|
||||
|
||||
logger.info(f"✅ Odds Manager initialized")
|
||||
|
||||
# Test NBA odds URL construction (without actual API call)
|
||||
test_event_id = "401585515" # Sample NBA game ID
|
||||
expected_url = f"https://sports.core.api.espn.com/v2/sports/basketball/leagues/nba/events/{test_event_id}/competitions/{test_event_id}/odds"
|
||||
logger.info(f"Expected odds URL: {expected_url}")
|
||||
|
||||
logger.info("✅ Odds Manager integration test PASSED")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Odds Manager integration test FAILED: {e}")
|
||||
return False
|
||||
|
||||
def test_configuration_consistency():
|
||||
"""Test that configurations are consistent across components."""
|
||||
try:
|
||||
with open('config/config.json', 'r') as f:
|
||||
config = json.load(f)
|
||||
|
||||
# Check NBA scoreboard config
|
||||
nba_scoreboard = config.get('nba_scoreboard', {})
|
||||
nba_enabled = nba_scoreboard.get('enabled', False)
|
||||
nba_show_odds = nba_scoreboard.get('show_odds', False)
|
||||
|
||||
# Check leaderboard config
|
||||
leaderboard = config.get('leaderboard', {})
|
||||
leaderboard_enabled = leaderboard.get('enabled', False)
|
||||
nba_leaderboard_enabled = leaderboard.get('enabled_sports', {}).get('nba', {}).get('enabled', False)
|
||||
|
||||
logger.info(f"NBA Scoreboard - Enabled: {nba_enabled}, Show Odds: {nba_show_odds}")
|
||||
logger.info(f"Leaderboard - Enabled: {leaderboard_enabled}, NBA Enabled: {nba_leaderboard_enabled}")
|
||||
|
||||
# Check for consistency
|
||||
if not nba_enabled and nba_show_odds:
|
||||
logger.warning("⚠️ NBA scoreboard disabled but odds enabled - odds won't be used")
|
||||
|
||||
if leaderboard_enabled and not nba_leaderboard_enabled:
|
||||
logger.info("ℹ️ Leaderboard enabled but NBA disabled - NBA won't appear in leaderboard")
|
||||
|
||||
logger.info("✅ Configuration consistency test PASSED")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Configuration consistency test FAILED: {e}")
|
||||
return False
|
||||
|
||||
def main():
|
||||
"""Run all integration tests."""
|
||||
logger.info("🧪 Starting NBA Manager, Leaderboard, and Odds Manager Integration Tests")
|
||||
logger.info("=" * 70)
|
||||
|
||||
tests = [
|
||||
("NBA API Connectivity", test_nba_api_connectivity),
|
||||
("Odds API Connectivity", test_odds_api_connectivity),
|
||||
("NBA Manager Initialization", test_nba_manager_initialization),
|
||||
("Leaderboard NBA Integration", test_leaderboard_nba_integration),
|
||||
("Odds Manager Integration", test_odds_manager_integration),
|
||||
("Configuration Consistency", test_configuration_consistency),
|
||||
]
|
||||
|
||||
results = []
|
||||
for test_name, test_func in tests:
|
||||
logger.info(f"\n🔍 Running: {test_name}")
|
||||
try:
|
||||
result = test_func()
|
||||
results.append((test_name, result))
|
||||
except Exception as e:
|
||||
logger.error(f"❌ {test_name} crashed: {e}")
|
||||
results.append((test_name, False))
|
||||
|
||||
# Summary
|
||||
logger.info("\n" + "=" * 70)
|
||||
logger.info("📊 TEST SUMMARY")
|
||||
logger.info("=" * 70)
|
||||
|
||||
passed = 0
|
||||
failed = 0
|
||||
|
||||
for test_name, result in results:
|
||||
status = "✅ PASSED" if result else "❌ FAILED"
|
||||
logger.info(f"{test_name:<25} {status}")
|
||||
if result:
|
||||
passed += 1
|
||||
else:
|
||||
failed += 1
|
||||
|
||||
logger.info("-" * 70)
|
||||
logger.info(f"Total: {len(results)} | Passed: {passed} | Failed: {failed}")
|
||||
|
||||
if failed == 0:
|
||||
logger.info("🎉 ALL TESTS PASSED! NBA integration is working correctly.")
|
||||
return True
|
||||
else:
|
||||
logger.error(f"❌ {failed} test(s) failed. Please check the issues above.")
|
||||
return False
|
||||
|
||||
if __name__ == "__main__":
|
||||
success = main()
|
||||
sys.exit(0 if success else 1)
|
||||
@@ -1,113 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test script to verify that the NBA leaderboard fix works correctly.
|
||||
This script simulates the leaderboard manager's data fetching process.
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
import logging
|
||||
|
||||
# Add the src directory to Python path so we can import the leaderboard manager
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src'))
|
||||
|
||||
# Set up logging
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def test_nba_standings_data():
|
||||
"""Test that NBA standings data includes team ID fields."""
|
||||
try:
|
||||
from leaderboard_manager import LeaderboardManager
|
||||
from display_manager import DisplayManager
|
||||
from cache_manager import CacheManager
|
||||
import json
|
||||
|
||||
# Load config
|
||||
with open('config/config.json', 'r') as f:
|
||||
config = json.load(f)
|
||||
|
||||
# Create mock display and cache managers
|
||||
display_manager = DisplayManager(config)
|
||||
cache_manager = CacheManager()
|
||||
|
||||
# Create leaderboard manager
|
||||
leaderboard_manager = LeaderboardManager(config, display_manager)
|
||||
|
||||
# Test NBA standings fetching
|
||||
logger.info("Testing NBA standings data fetching...")
|
||||
nba_config = leaderboard_manager.league_configs['nba']
|
||||
nba_config['enabled'] = True # Enable NBA for testing
|
||||
|
||||
standings = leaderboard_manager._fetch_standings(nba_config)
|
||||
|
||||
if not standings:
|
||||
logger.error("No NBA standings data returned!")
|
||||
return False
|
||||
|
||||
logger.info(f"Successfully fetched {len(standings)} NBA teams")
|
||||
|
||||
# Check if team ID fields are present
|
||||
missing_id_count = 0
|
||||
for i, team in enumerate(standings[:5]): # Check first 5 teams
|
||||
team_id = team.get('id')
|
||||
team_abbr = team.get('abbreviation', 'Unknown')
|
||||
team_name = team.get('name', 'Unknown')
|
||||
|
||||
logger.info(f"Team {i+1}: ID={team_id}, ABBR={team_abbr}, NAME={team_name}")
|
||||
|
||||
if team_id is None:
|
||||
logger.error(f"Team {team_abbr} is missing ID field!")
|
||||
missing_id_count += 1
|
||||
|
||||
if missing_id_count > 0:
|
||||
logger.error(f"{missing_id_count} teams are missing ID fields!")
|
||||
return False
|
||||
else:
|
||||
logger.info("All tested teams have ID fields!")
|
||||
|
||||
# Test that we can create a leaderboard image (without actually displaying)
|
||||
logger.info("Testing leaderboard image creation...")
|
||||
leaderboard_manager.leaderboard_data = [{
|
||||
'league': 'nba',
|
||||
'league_config': nba_config,
|
||||
'teams': standings[:3] # Test with first 3 teams
|
||||
}]
|
||||
|
||||
try:
|
||||
leaderboard_manager._create_leaderboard_image()
|
||||
if leaderboard_manager.leaderboard_image:
|
||||
logger.info(f"Successfully created leaderboard image: {leaderboard_manager.leaderboard_image.width}x{leaderboard_manager.leaderboard_image.height}")
|
||||
return True
|
||||
else:
|
||||
logger.error("Failed to create leaderboard image!")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating leaderboard image: {e}")
|
||||
return False
|
||||
|
||||
except ImportError as e:
|
||||
logger.error(f"Import error: {e}")
|
||||
logger.info("This script needs to be run from the LEDMatrix project directory")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error: {e}")
|
||||
return False
|
||||
|
||||
def main():
|
||||
"""Main test function."""
|
||||
logger.info("Testing NBA leaderboard fix...")
|
||||
|
||||
success = test_nba_standings_data()
|
||||
|
||||
if success:
|
||||
logger.info("✅ NBA leaderboard fix test PASSED!")
|
||||
logger.info("The NBA leaderboard should now work correctly with team logos")
|
||||
else:
|
||||
logger.error("❌ NBA leaderboard fix test FAILED!")
|
||||
logger.info("The issue may not be fully resolved")
|
||||
|
||||
return success
|
||||
|
||||
if __name__ == "__main__":
|
||||
success = main()
|
||||
sys.exit(0 if success else 1)
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Guard: every literal render_template() target must exist on disk.
|
||||
|
||||
Catches routes that reference templates deleted in a refactor (a real bug
|
||||
class: the weather/stocks partials 500'd for months because their
|
||||
templates were removed when those displays became plugins).
|
||||
"""
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Iterator
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
TEMPLATE_ROOT = PROJECT_ROOT / 'web_interface' / 'templates'
|
||||
RENDER_RE = re.compile(r"""render_template\(\s*['"]([^'"]+)['"]""")
|
||||
|
||||
|
||||
def _python_sources() -> Iterator[Path]:
|
||||
"""Yield every Python source that can call render_template()."""
|
||||
yield PROJECT_ROOT / 'web_interface' / 'app.py'
|
||||
yield from (PROJECT_ROOT / 'web_interface' / 'blueprints').glob('*.py')
|
||||
|
||||
|
||||
def test_all_literal_render_template_targets_exist() -> None:
|
||||
"""Every string-literal render_template() target must exist on disk."""
|
||||
missing = []
|
||||
for source in _python_sources():
|
||||
text = source.read_text(encoding='utf-8')
|
||||
for match in RENDER_RE.finditer(text):
|
||||
target = match.group(1)
|
||||
if not (TEMPLATE_ROOT / target).is_file():
|
||||
lineno = text.count('\n', 0, match.start()) + 1
|
||||
missing.append(
|
||||
f'{source.relative_to(PROJECT_ROOT)}:{lineno} -> {target}'
|
||||
)
|
||||
assert not missing, (
|
||||
'render_template() references templates that do not exist under '
|
||||
'web_interface/templates/:\n' + '\n'.join(missing)
|
||||
)
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Guard: every widget JS file must be loaded by base.html or explicitly allowlisted.
|
||||
|
||||
Widget files register themselves with LEDMatrixWidgets at load time; a file
|
||||
that exists but is never <script>-included silently breaks any plugin whose
|
||||
config schema declares that widget (the field renders as an empty container
|
||||
that polls the registry forever). base.html's widget list is maintained by
|
||||
hand, so this test keeps it honest.
|
||||
"""
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Set
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
WIDGETS_DIR = PROJECT_ROOT / 'web_interface' / 'static' / 'v3' / 'js' / 'widgets'
|
||||
BASE_HTML = PROJECT_ROOT / 'web_interface' / 'templates' / 'v3' / 'base.html'
|
||||
|
||||
# Matches url_for('static', filename='...') inside actual <script> tags.
|
||||
SCRIPT_SRC_RE = re.compile(
|
||||
r"""<script\s[^>]*src="\{\{\s*url_for\(\s*'static'\s*,\s*filename='([^']+)'\s*\)\s*\}\}[^"]*"""
|
||||
)
|
||||
|
||||
# Files that must NOT be script-included, with the reason.
|
||||
ALLOWLIST = {
|
||||
# Documentation example (docs/widget-guide.md); registers the name
|
||||
# 'color-picker' and would shadow the real color-picker.js if loaded.
|
||||
'example-color-picker.js',
|
||||
}
|
||||
|
||||
|
||||
def _included_widget_scripts() -> Set[str]:
|
||||
"""Return widget JS basenames referenced by real <script> tags in base.html."""
|
||||
base_html = BASE_HTML.read_text(encoding='utf-8')
|
||||
return {
|
||||
Path(filename).name
|
||||
for filename in SCRIPT_SRC_RE.findall(base_html)
|
||||
if filename.startswith('v3/js/widgets/')
|
||||
}
|
||||
|
||||
|
||||
def test_every_widget_script_is_included_in_base_html() -> None:
|
||||
"""Every non-allowlisted widget file must be loaded by a <script> tag."""
|
||||
assert WIDGETS_DIR.is_dir(), f'Widget directory missing: {WIDGETS_DIR}'
|
||||
included = _included_widget_scripts()
|
||||
assert included, 'No widget <script> tags found in base.html — regex or template drift?'
|
||||
missing = [
|
||||
js_file.name
|
||||
for js_file in sorted(WIDGETS_DIR.glob('*.js'))
|
||||
if js_file.name not in ALLOWLIST and js_file.name not in included
|
||||
]
|
||||
assert not missing, (
|
||||
'Widget files exist but are never <script>-included in base.html '
|
||||
'(plugins declaring these widgets get blank config fields): '
|
||||
+ ', '.join(missing)
|
||||
+ '. Add a script tag to base.html or add the file to ALLOWLIST '
|
||||
'with a reason.'
|
||||
)
|
||||
|
||||
|
||||
def test_allowlisted_widgets_are_not_included() -> None:
|
||||
"""Allowlisted (must-not-load) widget files must stay out of base.html."""
|
||||
included = _included_widget_scripts()
|
||||
wrongly_included = [name for name in ALLOWLIST if name in included]
|
||||
assert not wrongly_included, (
|
||||
'Allowlisted (must-not-load) widget files are script-included in '
|
||||
'base.html: ' + ', '.join(wrongly_included)
|
||||
)
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Tests for the web interface's in-memory cache helpers."""
|
||||
from typing import Iterator
|
||||
|
||||
import pytest
|
||||
|
||||
from web_interface.cache import delete_cached, get_cached, invalidate_cache, set_cached
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clean_cache() -> Iterator[None]:
|
||||
"""Start and finish every test with an empty cache."""
|
||||
invalidate_cache()
|
||||
yield
|
||||
invalidate_cache()
|
||||
|
||||
|
||||
def test_set_and_get() -> None:
|
||||
"""A cached value is returned before its TTL expires."""
|
||||
set_cached('key', 'value')
|
||||
assert get_cached('key') == 'value'
|
||||
|
||||
|
||||
def test_get_missing_returns_none() -> None:
|
||||
"""Reading an unknown key returns None."""
|
||||
assert get_cached('missing') is None
|
||||
|
||||
|
||||
def test_delete_cached_removes_key() -> None:
|
||||
"""delete_cached removes exactly the named key."""
|
||||
set_cached('fonts_catalog', ['a-font'])
|
||||
delete_cached('fonts_catalog')
|
||||
assert get_cached('fonts_catalog') is None
|
||||
|
||||
|
||||
def test_delete_cached_missing_key_is_noop() -> None:
|
||||
"""Deleting a key that was never set must not raise."""
|
||||
delete_cached('never-set')
|
||||
|
||||
|
||||
def test_invalidate_cache_pattern() -> None:
|
||||
"""Pattern invalidation removes matching keys and keeps the rest."""
|
||||
set_cached('fonts_catalog', 1)
|
||||
set_cached('plugins_list', 2)
|
||||
invalidate_cache('fonts')
|
||||
assert get_cached('fonts_catalog') is None
|
||||
assert get_cached('plugins_list') == 2
|
||||
@@ -30,7 +30,12 @@ web_interface/
|
||||
└── static/ # CSS/JS assets
|
||||
└── v3/
|
||||
├── app.css
|
||||
└── app.js
|
||||
├── app.js
|
||||
├── manifest.json # PWA manifest
|
||||
├── plugins_manager.js
|
||||
├── icons/ # PWA / touch icons
|
||||
├── js/ # Alpine, htmx, app shell, widgets, utils
|
||||
└── vendor/ # codemirror, fontawesome
|
||||
```
|
||||
|
||||
## Running the Web Interface
|
||||
|
||||
@@ -807,7 +807,7 @@ def save_main_config():
|
||||
'gpio_slowdown', 'rp1_rio', 'scan_mode', 'disable_hardware_pulsing', 'inverse_colors', 'show_refresh_rate',
|
||||
'pwm_bits', 'pwm_dither_bits', 'pwm_lsb_nanoseconds', 'limit_refresh_rate_hz', 'use_short_date_format',
|
||||
'max_dynamic_duration_seconds', 'led_rgb_sequence', 'multiplexing', 'panel_type',
|
||||
'row_address_type']
|
||||
'row_address_type', 'pixel_mapper_config']
|
||||
|
||||
if any(k in data for k in display_fields):
|
||||
if 'display' not in current_config:
|
||||
@@ -838,6 +838,10 @@ def save_main_config():
|
||||
except (ValueError, TypeError):
|
||||
return jsonify({'status': 'error', 'message': f"Invalid multiplexing value '{data['multiplexing']}'. Must be an integer from 0 to 22."}), 400
|
||||
|
||||
# Validate pixel_mapper_config (free-form mapper string, e.g. "U-mapper;Rotate:90")
|
||||
if 'pixel_mapper_config' in data and not isinstance(data['pixel_mapper_config'], str):
|
||||
return jsonify({'status': 'error', 'message': 'pixel_mapper_config must be a string (e.g. "U-mapper;Rotate:90" or empty)'}), 400
|
||||
|
||||
# Validate row_address_type
|
||||
if 'row_address_type' in data:
|
||||
try:
|
||||
@@ -850,7 +854,8 @@ def save_main_config():
|
||||
# Handle hardware settings
|
||||
for field in ['rows', 'cols', 'chain_length', 'parallel', 'brightness', 'hardware_mapping', 'scan_mode',
|
||||
'pwm_bits', 'pwm_dither_bits', 'pwm_lsb_nanoseconds', 'limit_refresh_rate_hz',
|
||||
'led_rgb_sequence', 'multiplexing', 'panel_type', 'row_address_type']:
|
||||
'led_rgb_sequence', 'multiplexing', 'panel_type', 'row_address_type',
|
||||
'pixel_mapper_config']:
|
||||
if field in data:
|
||||
if field in ['rows', 'cols', 'chain_length', 'parallel', 'brightness', 'scan_mode',
|
||||
'pwm_bits', 'pwm_dither_bits', 'pwm_lsb_nanoseconds', 'limit_refresh_rate_hz',
|
||||
|
||||
@@ -180,10 +180,6 @@ def load_partial(partial_name):
|
||||
return _load_durations_partial()
|
||||
elif partial_name == 'schedule':
|
||||
return _load_schedule_partial()
|
||||
elif partial_name == 'weather':
|
||||
return _load_weather_partial()
|
||||
elif partial_name == 'stocks':
|
||||
return _load_stocks_partial()
|
||||
elif partial_name == 'plugins':
|
||||
return _load_plugins_partial()
|
||||
elif partial_name == 'fonts':
|
||||
@@ -464,28 +460,6 @@ def _load_schedule_partial():
|
||||
return "Error loading partial", 500
|
||||
|
||||
|
||||
def _load_weather_partial():
|
||||
"""Load weather configuration partial"""
|
||||
try:
|
||||
if pages_v3.config_manager:
|
||||
main_config = pages_v3.config_manager.load_config()
|
||||
return render_template('v3/partials/weather.html',
|
||||
main_config=main_config)
|
||||
except Exception as e:
|
||||
logger.error("Error loading partial", exc_info=True)
|
||||
return "Error loading partial", 500
|
||||
|
||||
def _load_stocks_partial():
|
||||
"""Load stocks configuration partial"""
|
||||
try:
|
||||
if pages_v3.config_manager:
|
||||
main_config = pages_v3.config_manager.load_config()
|
||||
return render_template('v3/partials/stocks.html',
|
||||
main_config=main_config)
|
||||
except Exception as e:
|
||||
logger.error("Error loading partial", exc_info=True)
|
||||
return "Error loading partial", 500
|
||||
|
||||
def _load_plugins_partial():
|
||||
"""Load plugins management partial"""
|
||||
try:
|
||||
|
||||
@@ -29,6 +29,12 @@ def set_cached(key: str, value: Any, ttl_seconds: int = 60) -> None:
|
||||
_cache_timestamps[key] = time.time()
|
||||
|
||||
|
||||
def delete_cached(key: str) -> None:
|
||||
"""Remove a single key from the cache if present."""
|
||||
_cache.pop(key, None)
|
||||
_cache_timestamps.pop(key, None)
|
||||
|
||||
|
||||
def invalidate_cache(pattern: Optional[str] = None) -> None:
|
||||
"""Invalidate cache entries matching pattern, or all if pattern is None."""
|
||||
if pattern is None:
|
||||
|
||||
@@ -4,20 +4,16 @@
|
||||
|
||||
# Web framework
|
||||
flask>=3.1.3,<4.0.0
|
||||
werkzeug>=3.1.6,<4.0.0
|
||||
flask-wtf>=1.2.0 # CSRF protection (optional for local-only, but recommended)
|
||||
flask-limiter>=3.5.0 # Rate limiting (prevent accidental abuse)
|
||||
werkzeug>=3.1.6,<4.0.0 # Flask transitive; pinned to keep a security floor above Flask's own >=3.1.0
|
||||
flask-limiter>=3.5.0,<4.0.0 # Rate limiting (prevent accidental abuse)
|
||||
flask-compress>=1.14 # gzip/brotli response compression (big win for the large JS/HTML over WiFi)
|
||||
jinja2>=3.1.6,<4.0.0 # Flask transitive, but imported directly (TemplateNotFound); 3.1.6 is the security floor — earlier 3.1.x has sandbox breakouts
|
||||
markupsafe>=2.1.0,<4.0.0 # Flask transitive, but imported directly (escape)
|
||||
|
||||
# WebSocket support for plugins
|
||||
# Note: Web interface uses Server-Sent Events (SSE) for real-time updates, not WebSockets
|
||||
# However, plugins may need websocket support to connect to external services
|
||||
# (e.g., music plugin connecting to YTM Companion server via Socket.IO)
|
||||
# These packages are required for plugin compatibility
|
||||
python-socketio>=5.14.0,<6.0.0
|
||||
python-engineio>=4.9.0,<5.0.0
|
||||
websockets>=12.0,<14.0
|
||||
websocket-client>=1.8.0,<2.0.0
|
||||
# WebSocket support: intentionally NOT declared here. The web interface
|
||||
# uses Server-Sent Events, and plugins that need Socket.IO (e.g.
|
||||
# ledmatrix-music) declare it in their own requirements.txt, which the
|
||||
# plugin store installs.
|
||||
|
||||
# Image processing
|
||||
Pillow>=12.2.0,<13.0.0
|
||||
@@ -26,7 +22,7 @@ Pillow>=12.2.0,<13.0.0
|
||||
psutil>=6.0.0,<7.0.0
|
||||
|
||||
# Font rendering
|
||||
freetype-py>=2.5.0,<3.0.0
|
||||
freetype-py>=2.5.1,<3.0.0
|
||||
|
||||
# Numerical operations
|
||||
# NumPy 1.24+ required for Python 3.12+ compatibility (compatible with 2.x)
|
||||
@@ -35,24 +31,13 @@ numpy>=1.24.0
|
||||
# HTTP requests
|
||||
requests>=2.33.0,<3.0.0
|
||||
|
||||
# Date/time utilities
|
||||
python-dateutil>=2.9.0,<3.0.0
|
||||
|
||||
# Timezone handling (must match main requirements)
|
||||
pytz>=2024.2,<2025.0
|
||||
timezonefinder>=6.5.0,<7.0.0
|
||||
geopy>=2.4.1,<3.0.0
|
||||
|
||||
# Google API integration (must match main requirements)
|
||||
google-auth-oauthlib>=1.2.0,<2.0.0
|
||||
google-auth-httplib2>=0.2.0,<1.0.0
|
||||
google-api-python-client>=2.147.0,<3.0.0
|
||||
|
||||
# Spotify integration (must match main requirements)
|
||||
spotipy>=2.25.2,<3.0.0
|
||||
|
||||
# Text processing (must match main requirements)
|
||||
unidecode>=1.3.8,<2.0.0
|
||||
|
||||
# Calendar integration (must match main requirements)
|
||||
icalevents>=0.1.27,<1.0.0
|
||||
# Plugin-era note: timezonefinder, geopy, the google-api client stack,
|
||||
# unidecode, icalevents and python-dateutil used to live here for the
|
||||
# built-in weather/calendar/music displays. Those are store plugins now
|
||||
# and declare their own dependencies, which the plugin store installs.
|
||||
|
||||
@@ -8,6 +8,10 @@
|
||||
* 1. Copy this file to your plugin's widgets directory
|
||||
* 2. Reference it in your config_schema.json with "x-widget": "color-picker"
|
||||
* 3. The widget will be automatically loaded when the plugin config form is rendered
|
||||
*
|
||||
* Do NOT add this file to base.html's widget script list: it registers
|
||||
* under the name 'color-picker' and would shadow the built-in
|
||||
* color-picker.js widget.
|
||||
*
|
||||
* @module ColorPickerWidget
|
||||
*/
|
||||
|
||||
@@ -57,6 +57,11 @@
|
||||
target.appendChild(frag);
|
||||
}
|
||||
|
||||
if (typeof window.LEDMatrixWidgets === 'undefined') {
|
||||
console.error('[TimePicker] LEDMatrixWidgets registry not loaded');
|
||||
return;
|
||||
}
|
||||
|
||||
window.LEDMatrixWidgets.register('time-picker', {
|
||||
name: 'Time Picker Widget',
|
||||
version: '1.0.0',
|
||||
|
||||
@@ -2299,56 +2299,6 @@ function renderArrayObjectItem(fieldId, fullKey, itemProperties, itemValue, inde
|
||||
|
||||
|
||||
// Functions to handle patternProperties key-value pairs
|
||||
window.addKeyValuePair = function(fieldId, fullKey, maxProperties) {
|
||||
const pairsContainer = document.getElementById(fieldId + '_pairs');
|
||||
if (!pairsContainer) return;
|
||||
|
||||
const currentPairs = pairsContainer.querySelectorAll('.key-value-pair');
|
||||
if (currentPairs.length >= maxProperties) {
|
||||
alert(`Maximum ${maxProperties} entries allowed`);
|
||||
return;
|
||||
}
|
||||
|
||||
const newIndex = currentPairs.length;
|
||||
const valueType = 'string'; // Default to string, could be determined from schema
|
||||
|
||||
const pairHtml = `
|
||||
<div class="flex items-center gap-2 key-value-pair" data-index="${newIndex}">
|
||||
<input type="text"
|
||||
name="${fullKey}[key_${newIndex}]"
|
||||
value=""
|
||||
placeholder="Key"
|
||||
class="flex-1 px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
|
||||
data-key-index="${newIndex}"
|
||||
onchange="updateKeyValuePairData('${fieldId}', '${fullKey}')">
|
||||
<input type="text"
|
||||
name="${fullKey}[value_${newIndex}]"
|
||||
value=""
|
||||
placeholder="Value"
|
||||
class="flex-1 px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
|
||||
data-value-index="${newIndex}"
|
||||
onchange="updateKeyValuePairData('${fieldId}', '${fullKey}')">
|
||||
<button type="button"
|
||||
onclick="removeKeyValuePair('${fieldId}', ${newIndex})"
|
||||
class="px-3 py-2 text-red-600 hover:text-red-800 hover:bg-red-50 rounded-md transition-colors"
|
||||
title="Remove">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
pairsContainer.insertAdjacentHTML('beforeend', pairHtml);
|
||||
updateKeyValuePairData(fieldId, fullKey);
|
||||
|
||||
// Update add button state
|
||||
const addButton = pairsContainer.nextElementSibling;
|
||||
if (addButton && currentPairs.length + 1 >= maxProperties) {
|
||||
addButton.disabled = true;
|
||||
addButton.style.opacity = '0.5';
|
||||
addButton.style.cursor = 'not-allowed';
|
||||
}
|
||||
};
|
||||
|
||||
window.removeKeyValuePair = function(fieldId, index) {
|
||||
const pairsContainer = document.getElementById(fieldId + '_pairs');
|
||||
if (!pairsContainer) return;
|
||||
@@ -4559,23 +4509,6 @@ function formatDate(dateString) {
|
||||
}
|
||||
}
|
||||
|
||||
function formatCommit(commit, branch) {
|
||||
const shortCommit = commit ? String(commit).substring(0, 7) : '';
|
||||
const branchText = branch ? String(branch) : '';
|
||||
|
||||
if (branchText && shortCommit) {
|
||||
return `${branchText} · ${shortCommit}`;
|
||||
}
|
||||
if (branchText) {
|
||||
return branchText;
|
||||
}
|
||||
if (shortCommit) {
|
||||
return shortCommit;
|
||||
}
|
||||
return 'Latest';
|
||||
}
|
||||
|
||||
// Check if plugin is new (updated within last 7 days)
|
||||
function isNewPlugin(lastUpdated) {
|
||||
if (!lastUpdated) return false;
|
||||
|
||||
@@ -4605,26 +4538,6 @@ function debounce(func, wait) {
|
||||
}
|
||||
|
||||
// Toggle password visibility for secret fields
|
||||
function togglePasswordVisibility(fieldId) {
|
||||
const input = document.getElementById(fieldId);
|
||||
const icon = document.getElementById(fieldId + '-icon');
|
||||
|
||||
if (input && icon) {
|
||||
if (input.type === 'password') {
|
||||
input.type = 'text';
|
||||
icon.classList.remove('fa-eye');
|
||||
icon.classList.add('fa-eye-slash');
|
||||
} else {
|
||||
input.type = 'password';
|
||||
icon.classList.remove('fa-eye-slash');
|
||||
icon.classList.add('fa-eye');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GitHub Token Configuration Functions
|
||||
// Open GitHub Token Settings panel (only opens, doesn't close)
|
||||
// Used when user clicks "Configure Token" link
|
||||
window.openGithubTokenSettings = function() {
|
||||
const settings = document.getElementById('github-token-settings');
|
||||
const warning = document.getElementById('github-auth-warning');
|
||||
|
||||
@@ -989,6 +989,9 @@
|
||||
<script src="{{ url_for('static', filename='v3/js/widgets/google-calendar-picker.js') }}" defer></script>
|
||||
<script src="{{ url_for('static', filename='v3/js/widgets/day-selector.js') }}" defer></script>
|
||||
<script src="{{ url_for('static', filename='v3/js/widgets/time-range.js') }}" defer></script>
|
||||
<script src="{{ url_for('static', filename='v3/js/widgets/time-picker.js') }}" defer></script>
|
||||
<script src="{{ url_for('static', filename='v3/js/widgets/file-upload-single.js') }}" defer></script>
|
||||
<script src="{{ url_for('static', filename='v3/js/widgets/plugin-file-manager.js') }}" defer></script>
|
||||
<script src="{{ url_for('static', filename='v3/js/widgets/schedule-picker.js') }}" defer></script>
|
||||
<!-- Basic input widgets -->
|
||||
<script src="{{ url_for('static', filename='v3/js/widgets/text-input.js') }}" defer></script>
|
||||
@@ -1010,7 +1013,11 @@
|
||||
<!-- Reusable JSON file manager widget (used by of-the-day and others via x-widget: json-file-manager) -->
|
||||
<script src="{{ url_for('static', filename='v3/js/widgets/json-file-manager.js') }}" defer></script>
|
||||
|
||||
<!-- Legacy plugins_manager.js (for backward compatibility during migration) -->
|
||||
<!-- plugins_manager.js: loaded LAST on purpose — it defines the live
|
||||
window.* implementations for several plugin actions (installPlugin,
|
||||
uninstallPlugin, executePluginAction, ...) and intentionally wins
|
||||
over same-named definitions in js/app.js and js/app-shell.js. Do not
|
||||
remove or reorder without resolving that overlap first. -->
|
||||
<script src="{{ url_for('static', filename='v3/plugins_manager.js') }}?v=20260307" defer></script>
|
||||
|
||||
<!-- Custom feeds table helpers live in js/widgets/custom-feeds.js (the
|
||||
|
||||
Reference in New Issue
Block a user