From 2dae36a09453ddaf9e47cfb3fe1e2d5d660d6e48 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 23:52:15 +0000 Subject: [PATCH 01/29] 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 Claude-Session: https://claude.ai/code/session_01SXb4mKcAkVaxkeTb3YnAdr --- test/web_interface/test_cache.py | 38 ++++++++++++++++++++++++++++++++ web_interface/cache.py | 6 +++++ 2 files changed, 44 insertions(+) create mode 100644 test/web_interface/test_cache.py diff --git a/test/web_interface/test_cache.py b/test/web_interface/test_cache.py new file mode 100644 index 000000000..98986b0ca --- /dev/null +++ b/test/web_interface/test_cache.py @@ -0,0 +1,38 @@ +"""Tests for the web interface's in-memory cache helpers.""" +import pytest + +from web_interface.cache import delete_cached, get_cached, invalidate_cache, set_cached + + +@pytest.fixture(autouse=True) +def clean_cache(): + invalidate_cache() + yield + invalidate_cache() + + +def test_set_and_get(): + set_cached('key', 'value') + assert get_cached('key') == 'value' + + +def test_get_missing_returns_none(): + assert get_cached('missing') is None + + +def test_delete_cached_removes_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(): + delete_cached('never-set') # must not raise + + +def test_invalidate_cache_pattern(): + 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 diff --git a/web_interface/cache.py b/web_interface/cache.py index c1b7d3218..f7aad3ead 100644 --- a/web_interface/cache.py +++ b/web_interface/cache.py @@ -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: From 27ce5af7147750d2f1613419259d5bf92896db0b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 23:52:24 +0000 Subject: [PATCH 02/29] fix(web): remove dead weather/stocks partial routes that returned 500 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01SXb4mKcAkVaxkeTb3YnAdr --- web_interface/blueprints/pages_v3.py | 26 -------------------------- 1 file changed, 26 deletions(-) diff --git a/web_interface/blueprints/pages_v3.py b/web_interface/blueprints/pages_v3.py index f25752ce1..958683c8b 100644 --- a/web_interface/blueprints/pages_v3.py +++ b/web_interface/blueprints/pages_v3.py @@ -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: From 6087d5162fc5b70913fa87b260bde40595532268 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 23:52:54 +0000 Subject: [PATCH 03/29] fix(deps): align contradictory psutil/Flask-Limiter/freetype-py pins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01SXb4mKcAkVaxkeTb3YnAdr --- requirements.txt | 4 +++- web_interface/requirements.txt | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/requirements.txt b/requirements.txt index 9725d6c3a..4b78e2345 100644 --- a/requirements.txt +++ b/requirements.txt @@ -59,7 +59,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 diff --git a/web_interface/requirements.txt b/web_interface/requirements.txt index 6b1a98e08..3363b7793 100644 --- a/web_interface/requirements.txt +++ b/web_interface/requirements.txt @@ -6,7 +6,7 @@ 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) +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) # WebSocket support for plugins @@ -26,7 +26,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) From c0a2d2f3013de170ff3c4d67f46dd4875a7ac3c7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 23:53:35 +0000 Subject: [PATCH 04/29] fix(config): add template keys the code already reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01SXb4mKcAkVaxkeTb3YnAdr --- config/config.template.json | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/config/config.template.json b/config/config.template.json index f63305679..433039f7a 100644 --- a/config/config.template.json +++ b/config/config.template.json @@ -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, From ab087cf60097f1eb47612d4e9223789aecf32fc5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 23:55:17 +0000 Subject: [PATCH 05/29] fix(scripts): repair broken sys.path setup in utility scripts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01SXb4mKcAkVaxkeTb3YnAdr --- scripts/debug/debug_web_manual.py | 4 ++-- scripts/download_nba_logos.py | 6 +++--- scripts/fix_perms/README.md | 3 --- scripts/fix_perms/fix_nhl_cache.sh | 21 --------------------- scripts/utils/clear_cache.py | 6 +++--- 5 files changed, 8 insertions(+), 32 deletions(-) delete mode 100644 scripts/fix_perms/fix_nhl_cache.sh diff --git a/scripts/debug/debug_web_manual.py b/scripts/debug/debug_web_manual.py index 36f64e1a6..626209464 100644 --- a/scripts/debug/debug_web_manual.py +++ b/scripts/debug/debug_web_manual.py @@ -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()}") diff --git a/scripts/download_nba_logos.py b/scripts/download_nba_logos.py index c0bfdb89a..99bb5b4b0 100644 --- a/scripts/download_nba_logos.py +++ b/scripts/download_nba_logos.py @@ -7,8 +7,8 @@ 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/") diff --git a/scripts/fix_perms/README.md b/scripts/fix_perms/README.md index 96f293d2e..5616e51af 100644 --- a/scripts/fix_perms/README.md +++ b/scripts/fix_perms/README.md @@ -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 — diff --git a/scripts/fix_perms/fix_nhl_cache.sh b/scripts/fix_perms/fix_nhl_cache.sh deleted file mode 100644 index ce84514a8..000000000 --- a/scripts/fix_perms/fix_nhl_cache.sh +++ /dev/null @@ -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." diff --git a/scripts/utils/clear_cache.py b/scripts/utils/clear_cache.py index 6d4d7b0d3..c490435bf 100644 --- a/scripts/utils/clear_cache.py +++ b/scripts/utils/clear_cache.py @@ -8,10 +8,10 @@ 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.""" From 2408309d84c404cf9e06ee5995416eab74c35b4d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 23:56:15 +0000 Subject: [PATCH 06/29] docs: correct stale file:line references and the loader-fallback contradiction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01SXb4mKcAkVaxkeTb3YnAdr --- .cursorrules | 10 +++++----- CLAUDE.md | 12 ++++++++---- docs/PLUGIN_ARCHITECTURE_SPEC.md | 5 ++++- docs/REST_API_REFERENCE.md | 4 ++-- 4 files changed, 19 insertions(+), 12 deletions(-) diff --git a/.cursorrules b/.cursorrules index 93b923fbf..b89202dd7 100644 --- a/.cursorrules +++ b/.cursorrules @@ -3,21 +3,21 @@ ## Plugin System Overview The LEDMatrix project uses a plugin-based architecture. All display -functionality (except core calendar) is implemented as plugins that are +functionality 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`). +`plugin-repos/` (per `config/config.template.json:167`). > **Fallback note (scoped):** `PluginManager.discover_plugins()` -> (`src/plugin_system/plugin_manager.py:154`) only scans the +> (`src/plugin_system/plugin_manager.py:208`) 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/ +> - `store_manager.py:2342-2373` — 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 +> - `schema_manager.py:48-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. diff --git a/CLAUDE.md b/CLAUDE.md index 496c4cced..01f4aa120 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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` @@ -33,7 +37,7 @@ ## Skin System (visual overlays for sports scoreboards) - Skins live in `skins//` (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 `; docs: `docs/SKIN_SYSTEM.md`, `docs/CREATING_SKINS.md` diff --git a/docs/PLUGIN_ARCHITECTURE_SPEC.md b/docs/PLUGIN_ARCHITECTURE_SPEC.md index fbd45b349..00a2914df 100644 --- a/docs/PLUGIN_ARCHITECTURE_SPEC.md +++ b/docs/PLUGIN_ARCHITECTURE_SPEC.md @@ -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. > diff --git a/docs/REST_API_REFERENCE.md b/docs/REST_API_REFERENCE.md index 132547b03..f95cb84c0 100644 --- a/docs/REST_API_REFERENCE.md +++ b/docs/REST_API_REFERENCE.md @@ -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. --- From 63076254d48a7d33d221205c31eb69c90bc5f0ab Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 23:57:40 +0000 Subject: [PATCH 07/29] 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 Claude-Session: https://claude.ai/code/session_01SXb4mKcAkVaxkeTb3YnAdr --- docs/ADVANCED_FEATURES.md | 9 ++++++--- docs/DEVELOPER_QUICK_REFERENCE.md | 6 +++--- docs/HOW_TO_RUN_TESTS.md | 18 +++++++++--------- docs/MIGRATION_GUIDE.md | 2 +- docs/PLUGIN_CUSTOM_ICONS.md | 2 +- docs/PLUGIN_DEPENDENCY_TROUBLESHOOTING.md | 4 ++-- docs/PLUGIN_DEVELOPMENT_GUIDE.md | 2 +- docs/PLUGIN_REGISTRY_SETUP_GUIDE.md | 22 ++++++++++++++-------- docs/PLUGIN_STORE_GUIDE.md | 6 +++--- 9 files changed, 40 insertions(+), 31 deletions(-) diff --git a/docs/ADVANCED_FEATURES.md b/docs/ADVANCED_FEATURES.md index ec94df6cf..a97421558 100644 --- a/docs/ADVANCED_FEATURES.md +++ b/docs/ADVANCED_FEATURES.md @@ -984,8 +984,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 +1020,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 diff --git a/docs/DEVELOPER_QUICK_REFERENCE.md b/docs/DEVELOPER_QUICK_REFERENCE.md index 93ca8e16c..3b05dcf90 100644 --- a/docs/DEVELOPER_QUICK_REFERENCE.md +++ b/docs/DEVELOPER_QUICK_REFERENCE.md @@ -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 @@ -195,7 +195,7 @@ LEDMatrix/ │ ├── 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 +207,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) diff --git a/docs/HOW_TO_RUN_TESTS.md b/docs/HOW_TO_RUN_TESTS.md index 5dde96ec1..a13b9628c 100644 --- a/docs/HOW_TO_RUN_TESTS.md +++ b/docs/HOW_TO_RUN_TESTS.md @@ -335,15 +335,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 diff --git a/docs/MIGRATION_GUIDE.md b/docs/MIGRATION_GUIDE.md index 422cd0644..b64269b92 100644 --- a/docs/MIGRATION_GUIDE.md +++ b/docs/MIGRATION_GUIDE.md @@ -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 diff --git a/docs/PLUGIN_CUSTOM_ICONS.md b/docs/PLUGIN_CUSTOM_ICONS.md index da9db63c9..79cabc5bd 100644 --- a/docs/PLUGIN_CUSTOM_ICONS.md +++ b/docs/PLUGIN_CUSTOM_ICONS.md @@ -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 diff --git a/docs/PLUGIN_DEPENDENCY_TROUBLESHOOTING.md b/docs/PLUGIN_DEPENDENCY_TROUBLESHOOTING.md index 755760c9a..1f33cb887 100644 --- a/docs/PLUGIN_DEPENDENCY_TROUBLESHOOTING.md +++ b/docs/PLUGIN_DEPENDENCY_TROUBLESHOOTING.md @@ -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) diff --git a/docs/PLUGIN_DEVELOPMENT_GUIDE.md b/docs/PLUGIN_DEVELOPMENT_GUIDE.md index 31cafc5e0..f587629c9 100644 --- a/docs/PLUGIN_DEVELOPMENT_GUIDE.md +++ b/docs/PLUGIN_DEVELOPMENT_GUIDE.md @@ -667,5 +667,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 diff --git a/docs/PLUGIN_REGISTRY_SETUP_GUIDE.md b/docs/PLUGIN_REGISTRY_SETUP_GUIDE.md index 7c1569b8f..ffaeda18f 100644 --- a/docs/PLUGIN_REGISTRY_SETUP_GUIDE.md +++ b/docs/PLUGIN_REGISTRY_SETUP_GUIDE.md @@ -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 ``` +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` diff --git a/docs/PLUGIN_STORE_GUIDE.md b/docs/PLUGIN_STORE_GUIDE.md index 4482d5b9c..421c99de5 100644 --- a/docs/PLUGIN_STORE_GUIDE.md +++ b/docs/PLUGIN_STORE_GUIDE.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 From b22fe2e71fcd0d31e2132f8e5e0ded305018f90c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 23:58:09 +0000 Subject: [PATCH 08/29] docs: complete the docs index and refresh the web interface file tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01SXb4mKcAkVaxkeTb3YnAdr --- docs/README.md | 7 ++++++- web_interface/README.md | 7 ++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/docs/README.md b/docs/README.md index 24345c520..a2fee3a0b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -29,7 +29,7 @@ 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)) @@ -38,6 +38,8 @@ Going deeper: - [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,6 +54,8 @@ 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 @@ -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 diff --git a/web_interface/README.md b/web_interface/README.md index 9fade3508..b6235dede 100644 --- a/web_interface/README.md +++ b/web_interface/README.md @@ -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 From a41cad6bc539c047f821d52340512a66788a9e63 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 00:00:12 +0000 Subject: [PATCH 09/29] chore: remove dead modules confirmed unused in-repo and across all store plugins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 Claude-Session: https://claude.ai/code/session_01SXb4mKcAkVaxkeTb3YnAdr --- src/common/README.md | 5 - src/common/cli.py | 111 ------------------- src/config_manager.py | 4 - src/web_interface/error_handler.py | 69 +----------- src/web_interface/logging_config.py | 160 ---------------------------- test/test_config_manager.py | 13 --- 6 files changed, 2 insertions(+), 360 deletions(-) delete mode 100644 src/common/cli.py delete mode 100644 src/web_interface/logging_config.py diff --git a/src/common/README.md b/src/common/README.md index cccaa40bc..4246ccff0 100644 --- a/src/common/README.md +++ b/src/common/README.md @@ -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 diff --git a/src/common/cli.py b/src/common/cli.py deleted file mode 100644 index ec33caaaf..000000000 --- a/src/common/cli.py +++ /dev/null @@ -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() diff --git a/src/config_manager.py b/src/config_manager.py index 3eb888f79..8a7edbb1c 100644 --- a/src/config_manager.py +++ b/src/config_manager.py @@ -449,10 +449,6 @@ def get_display_config(self) -> Dict[str, Any]: """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. diff --git a/src/web_interface/error_handler.py b/src/web_interface/error_handler.py index c15d373ce..0af53732f 100644 --- a/src/web_interface/error_handler.py +++ b/src/web_interface/error_handler.py @@ -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 @@ 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, diff --git a/src/web_interface/logging_config.py b/src/web_interface/logging_config.py deleted file mode 100644 index 010130d16..000000000 --- a/src/web_interface/logging_config.py +++ /dev/null @@ -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 - ) - diff --git a/test/test_config_manager.py b/test/test_config_manager.py index 29705f36e..68c411e9b 100644 --- a/test/test_config_manager.py +++ b/test/test_config_manager.py @@ -383,19 +383,6 @@ def test_get_display_config(self, tmp_path): 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: From 832bf32a8c1ce2d60b718432d39105f82a3f4d2b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 00:01:11 +0000 Subject: [PATCH 10/29] chore: remove manager-era NBA test files and one-off debug scripts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01SXb4mKcAkVaxkeTb3YnAdr --- docs/HOW_TO_RUN_TESTS.md | 1 - scripts/debug/check_imports.py | 28 --- scripts/debug/direct_fix_imports.py | 58 ------ test/debug_nba_api.py | 166 ----------------- test/test_nba_core_functionality.py | 262 -------------------------- test/test_nba_data_structure.py | 147 --------------- test/test_nba_integration.py | 280 ---------------------------- test/test_nba_leaderboard_fix.py | 113 ----------- 8 files changed, 1055 deletions(-) delete mode 100644 scripts/debug/check_imports.py delete mode 100644 scripts/debug/direct_fix_imports.py delete mode 100644 test/debug_nba_api.py delete mode 100644 test/test_nba_core_functionality.py delete mode 100644 test/test_nba_data_structure.py delete mode 100644 test/test_nba_integration.py delete mode 100644 test/test_nba_leaderboard_fix.py diff --git a/docs/HOW_TO_RUN_TESTS.md b/docs/HOW_TO_RUN_TESTS.md index a13b9628c..c491e15d7 100644 --- a/docs/HOW_TO_RUN_TESTS.md +++ b/docs/HOW_TO_RUN_TESTS.md @@ -253,7 +253,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 diff --git a/scripts/debug/check_imports.py b/scripts/debug/check_imports.py deleted file mode 100644 index a1cefae0e..000000000 --- a/scripts/debug/check_imports.py +++ /dev/null @@ -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!") diff --git a/scripts/debug/direct_fix_imports.py b/scripts/debug/direct_fix_imports.py deleted file mode 100644 index 79d451a9f..000000000 --- a/scripts/debug/direct_fix_imports.py +++ /dev/null @@ -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") diff --git a/test/debug_nba_api.py b/test/debug_nba_api.py deleted file mode 100644 index 5840f5e13..000000000 --- a/test/debug_nba_api.py +++ /dev/null @@ -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() diff --git a/test/test_nba_core_functionality.py b/test/test_nba_core_functionality.py deleted file mode 100644 index 85112cb43..000000000 --- a/test/test_nba_core_functionality.py +++ /dev/null @@ -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) diff --git a/test/test_nba_data_structure.py b/test/test_nba_data_structure.py deleted file mode 100644 index c4f207688..000000000 --- a/test/test_nba_data_structure.py +++ /dev/null @@ -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) diff --git a/test/test_nba_integration.py b/test/test_nba_integration.py deleted file mode 100644 index fc3ed3cfc..000000000 --- a/test/test_nba_integration.py +++ /dev/null @@ -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) diff --git a/test/test_nba_leaderboard_fix.py b/test/test_nba_leaderboard_fix.py deleted file mode 100644 index 81e180783..000000000 --- a/test/test_nba_leaderboard_fix.py +++ /dev/null @@ -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) From 5ae1a2b7c4ee6272018767dde39b6a30ef39966c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 00:01:41 +0000 Subject: [PATCH 11/29] chore: remove generate_report.py, which aggregates artifacts of CI jobs that do not exist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01SXb4mKcAkVaxkeTb3YnAdr --- scripts/generate_report.py | 356 ------------------------------------- 1 file changed, 356 deletions(-) delete mode 100644 scripts/generate_report.py diff --git a/scripts/generate_report.py b/scripts/generate_report.py deleted file mode 100644 index 753ea35b7..000000000 --- a/scripts/generate_report.py +++ /dev/null @@ -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): - / - 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()) From e1efbaf6a24e83daa96f392e550ca34ad82d878c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 00:02:51 +0000 Subject: [PATCH 12/29] fix(web): load the three widget scripts store plugins already declare MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01SXb4mKcAkVaxkeTb3YnAdr --- web_interface/static/v3/js/widgets/example-color-picker.js | 4 ++++ web_interface/templates/v3/base.html | 3 +++ 2 files changed, 7 insertions(+) diff --git a/web_interface/static/v3/js/widgets/example-color-picker.js b/web_interface/static/v3/js/widgets/example-color-picker.js index ea0a1d8b2..4121bca5c 100644 --- a/web_interface/static/v3/js/widgets/example-color-picker.js +++ b/web_interface/static/v3/js/widgets/example-color-picker.js @@ -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 */ diff --git a/web_interface/templates/v3/base.html b/web_interface/templates/v3/base.html index 2223106cd..3f3edcc19 100644 --- a/web_interface/templates/v3/base.html +++ b/web_interface/templates/v3/base.html @@ -989,6 +989,9 @@

+ + + From 45032f96e1e686a60743c929352f550b420ec21f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 00:03:24 +0000 Subject: [PATCH 13/29] chore: drop the legacy youtube block from the secrets template MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01SXb4mKcAkVaxkeTb3YnAdr --- config/config_secrets.template.json | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/config/config_secrets.template.json b/config/config_secrets.template.json index 619098766..cbf603364 100644 --- a/config/config_secrets.template.json +++ b/config/config_secrets.template.json @@ -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" } -} \ No newline at end of file +} From 1ec22db2d1ca7cc980d5d55628925eab8e0c1cf3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 00:06:36 +0000 Subject: [PATCH 14/29] chore(deps): remove packages nothing imports, declare direct imports, move mypy to test deps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01SXb4mKcAkVaxkeTb3YnAdr --- requirements-test.txt | 1 + requirements.txt | 16 ++++--------- scripts/install_dependencies_apt.py | 25 +++++--------------- web_interface/requirements.txt | 36 ++++++++--------------------- 4 files changed, 22 insertions(+), 56 deletions(-) diff --git a/requirements-test.txt b/requirements-test.txt index a11b33a42..efbbf5220 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -7,3 +7,4 @@ # pytest>=9.0.3,<10 and made the two files impossible to install together. # Only declare what requirements.txt doesn't already provide. 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) diff --git a/requirements.txt b/requirements.txt index 4b78e2345..b673f04ef 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,27 +11,22 @@ pytz>=2024.2,<2025.0 # Updated for latest timezone data # HTTP requests requests>=2.33.0,<3.0.0 +urllib3>=1.26.0,<3.0.0 # requests transitive, but imported directly (urllib3.util.retry.Retry) # 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 @@ -43,7 +38,6 @@ packaging>=23.0,<27.0 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 # ─────────────────────────────────────────────────────────────────────── # Optional dependencies — the code imports these inside try/except diff --git a/scripts/install_dependencies_apt.py b/scripts/install_dependencies_apt.py index a03d0243c..c877ab675 100644 --- a/scripts/install_dependencies_apt.py +++ b/scripts/install_dependencies_apt.py @@ -49,12 +49,7 @@ def install_via_apt(package_name: str) -> Tuple[bool, str]: '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}') @@ -152,12 +147,7 @@ def main(): 'werkzeug', 'numpy', 'requests', - 'python-dateutil', - 'pytz', - 'geopy', - 'unidecode', - 'websockets', - 'websocket-client' + 'pytz' ] failed_packages = [] @@ -177,15 +167,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: diff --git a/web_interface/requirements.txt b/web_interface/requirements.txt index 3363b7793..f04386e78 100644 --- a/web_interface/requirements.txt +++ b/web_interface/requirements.txt @@ -4,20 +4,15 @@ # 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,<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.0,<4.0.0 # Flask transitive, but imported directly (TemplateNotFound) +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 @@ -35,24 +30,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. From b68c1fa8fe0243d9111f51fa37cd58068a6895e5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 00:06:37 +0000 Subject: [PATCH 15/29] refactor: single canonical DateTimeEncoder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01SXb4mKcAkVaxkeTb3YnAdr --- src/cache_manager.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/src/cache_manager.py b/src/cache_manager.py index 5065dcdb3..20a3cfd75 100644 --- a/src/cache_manager.py +++ b/src/cache_manager.py @@ -39,14 +39,10 @@ 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 class CacheManager: """Manages caching of API responses to reduce API calls.""" From 0ec343181503e64c6f2330b07f1bdb2c1f9f4117 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 00:08:01 +0000 Subject: [PATCH 16/29] docs(code): document deliberate duplicates instead of merging them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01SXb4mKcAkVaxkeTb3YnAdr --- src/common/logo_helper.py | 11 +++++++++-- src/logo_downloader.py | 9 ++++++++- src/plugin_system/testing/plugin_test_base.py | 5 +++++ src/plugin_system/testing/visual_display_manager.py | 11 +++++++++++ 4 files changed, 33 insertions(+), 3 deletions(-) diff --git a/src/common/logo_helper.py b/src/common/logo_helper.py index 7d0dc4df5..13f9f73ef 100644 --- a/src/common/logo_helper.py +++ b/src/common/logo_helper.py @@ -187,10 +187,17 @@ def get_logo_variations(self, team_abbr: str) -> List[str]: 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 """ diff --git a/src/logo_downloader.py b/src/logo_downloader.py index e4dad3355..b799b7c12 100644 --- a/src/logo_downloader.py +++ b/src/logo_downloader.py @@ -118,7 +118,14 @@ def __init__(self, request_timeout: int = 30, retry_attempts: int = 3): @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() diff --git a/src/plugin_system/testing/plugin_test_base.py b/src/plugin_system/testing/plugin_test_base.py index f873ea9e1..806f3d3ff 100644 --- a/src/plugin_system/testing/plugin_test_base.py +++ b/src/plugin_system/testing/plugin_test_base.py @@ -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 diff --git a/src/plugin_system/testing/visual_display_manager.py b/src/plugin_system/testing/visual_display_manager.py index e3dc2ec60..5211a2263 100644 --- a/src/plugin_system/testing/visual_display_manager.py +++ b/src/plugin_system/testing/visual_display_manager.py @@ -10,6 +10,17 @@ 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 From 1994a81617d8fb1217e483d60977d6567071160a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 00:09:33 +0000 Subject: [PATCH 17/29] docs: add a unified configuration reference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01SXb4mKcAkVaxkeTb3YnAdr --- docs/CONFIG_REFERENCE.md | 172 +++++++++++++++++++++++++++++++++++++++ docs/README.md | 1 + 2 files changed, 173 insertions(+) create mode 100644 docs/CONFIG_REFERENCE.md diff --git a/docs/CONFIG_REFERENCE.md b/docs/CONFIG_REFERENCE.md new file mode 100644 index 000000000..dbe15681b --- /dev/null +++ b/docs/CONFIG_REFERENCE.md @@ -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..{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"` | +| `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`) | +| `.*` | Secrets a plugin declares with `"x-secret": true` in its config schema; merged into that plugin's config at load time | diff --git a/docs/README.md b/docs/README.md index a2fee3a0b..0378deb09 100644 --- a/docs/README.md +++ b/docs/README.md @@ -59,6 +59,7 @@ Going deeper: ## 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 From 1073e7098a5497bac85f4bd7bff2d4a28d6634cc Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 00:09:56 +0000 Subject: [PATCH 18/29] docs: bring the README's feature tour into the plugin era MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01SXb4mKcAkVaxkeTb3YnAdr --- README.md | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 0d50c38eb..c0791461d 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,14 @@ I'm trying to be open to constructive criticism and support, as long as it's a r
Core Features -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`); everything else lives +in the [ledmatrix-plugins](https://github.com/ChuckBuilds/ledmatrix-plugins) +monorepo and installs with one click. Displays available in the store +include: ### Time and Weather - Real-time clock display (2x 64x32 Displays 4mm Pixel Pitch) @@ -372,6 +379,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 . @@ -610,12 +621,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 From e1fa597b29bfabc1d1d98234a3c78c1e837f7968 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 00:10:53 +0000 Subject: [PATCH 19/29] docs: archive the custom-icons status report, cross-link config docs, document assets/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01SXb4mKcAkVaxkeTb3YnAdr --- assets/README.md | 22 +++++++++++++++++++ docs/PLUGIN_CONFIG_ARCHITECTURE.md | 6 +++++ docs/README.md | 3 +-- .../PLUGIN_CUSTOM_ICONS_FEATURE.md | 0 4 files changed, 29 insertions(+), 2 deletions(-) create mode 100644 assets/README.md rename docs/{ => archive}/PLUGIN_CUSTOM_ICONS_FEATURE.md (100%) diff --git a/assets/README.md b/assets/README.md new file mode 100644 index 000000000..e88d7f0de --- /dev/null +++ b/assets/README.md @@ -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//uploads/` | +| `plugins/` | Per-plugin uploaded files (`assets/plugins//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. diff --git a/docs/PLUGIN_CONFIG_ARCHITECTURE.md b/docs/PLUGIN_CONFIG_ARCHITECTURE.md index 28fa1ba3d..823bb4db1 100644 --- a/docs/PLUGIN_CONFIG_ARCHITECTURE.md +++ b/docs/PLUGIN_CONFIG_ARCHITECTURE.md @@ -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 diff --git a/docs/README.md b/docs/README.md index 0378deb09..acb2d9449 100644 --- a/docs/README.md +++ b/docs/README.md @@ -33,8 +33,7 @@ Going deeper: - [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 diff --git a/docs/PLUGIN_CUSTOM_ICONS_FEATURE.md b/docs/archive/PLUGIN_CUSTOM_ICONS_FEATURE.md similarity index 100% rename from docs/PLUGIN_CUSTOM_ICONS_FEATURE.md rename to docs/archive/PLUGIN_CUSTOM_ICONS_FEATURE.md From 6edd6f9beb9fcb60930072b348d87a0d1d2c1934 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 00:11:48 +0000 Subject: [PATCH 20/29] 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 Claude-Session: https://claude.ai/code/session_01SXb4mKcAkVaxkeTb3YnAdr --- .github/workflows/test.yml | 6 ++++- test/test_doc_links.py | 40 +++++++++++++++++++++++++++++ test/test_template_targets.py | 33 ++++++++++++++++++++++++ test/test_widget_scripts.py | 48 +++++++++++++++++++++++++++++++++++ 4 files changed, 126 insertions(+), 1 deletion(-) create mode 100644 test/test_doc_links.py create mode 100644 test/test_template_targets.py create mode 100644 test/test_widget_scripts.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0000c47f2..0583f0b2e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -80,4 +80,8 @@ jobs: test/test_version_consistency.py \ test/test_plugin_compatibility_gate.py \ test/test_install_preserves_existing.py \ - test/test_core_owned_config_keys.py + test/test_core_owned_config_keys.py \ + test/test_template_targets.py \ + test/test_widget_scripts.py \ + test/test_doc_links.py \ + test/web_interface/test_cache.py diff --git a/test/test_doc_links.py b/test/test_doc_links.py new file mode 100644 index 000000000..ac95ea375 --- /dev/null +++ b/test/test_doc_links.py @@ -0,0 +1,40 @@ +"""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 + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +LINK_RE = re.compile(r'\[[^\]]*\]\(([^)\s]+)\)') +FENCE_RE = re.compile(r'^(```|~~~)') + + +def _md_files(): + 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(): + 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) diff --git a/test/test_template_targets.py b/test/test_template_targets.py new file mode 100644 index 000000000..4a26d9ffb --- /dev/null +++ b/test/test_template_targets.py @@ -0,0 +1,33 @@ +"""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 + +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(): + yield PROJECT_ROOT / 'web_interface' / 'app.py' + yield from (PROJECT_ROOT / 'web_interface' / 'blueprints').glob('*.py') + + +def test_all_literal_render_template_targets_exist(): + missing = [] + for source in _python_sources(): + text = source.read_text(encoding='utf-8') + for lineno, line in enumerate(text.splitlines(), 1): + for target in RENDER_RE.findall(line): + if not (TEMPLATE_ROOT / target).is_file(): + missing.append( + f'{source.relative_to(PROJECT_ROOT)}:{lineno} -> {target}' + ) + assert not missing, ( + 'render_template() references templates that do not exist under ' + f'web_interface/templates/:\n' + '\n'.join(missing) + ) diff --git a/test/test_widget_scripts.py b/test/test_widget_scripts.py new file mode 100644 index 000000000..8c07a8b0e --- /dev/null +++ b/test/test_widget_scripts.py @@ -0,0 +1,48 @@ +"""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 - +