diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f729e9e..4757292f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,70 @@ accepts both, but the store flags the old spelling as deprecated ## Unreleased +### Dead code removed, unused plugin APIs deprecated + +An over-engineering audit of the whole tree. Every symbol below was checked +against core, the plugin monorepo and all eight third-party plugins in +`plugins.json` before it went. Nothing a plugin imports was removed; +plugin-facing methods only get `@deprecated` (see below). + +- **Deprecated for removal in 3.10.0** (warn once per process, in + `journalctl -u ledmatrix`). No plugin in core, the monorepo or the registry + calls them. `docs/DEPRECATIONS_3.8.md` is the regenerated scan, which + `scripts/plugin_api_usage.py` now runs for these owners too: + - `LogoDownloader`: the bulk-download and RGBA-conversion methods + (`fetch_teams_data`, `extract_teams_from_data`, + `download_missing_logos_for_league`, `download_all_ncaa_football_logos`, + `download_all_missing_logos`, `convert_image_to_rgba`, + `convert_all_logos_to_rgba`). `download_missing_logo()` stays. + - `ConfigManager`: `rollback_config`, `list_backups`, + `validate_config_file`, `get_secret`, `cleanup_orphaned_plugin_configs`, + `validate_all_plugin_configs`. + - `APIHelper`: `fetch_espn_scoreboard`/`_standings`/`_rankings`, + `set_cache`, `get_cache`, `set_rate_limit`, `get_request_stats`. `get()` + stays. + - `BackgroundDataService`: `get_result`, `is_request_complete`, + `get_request_status` (pass `callback=` to `submit_fetch_request()`). + - `PluginManager`: `get_all_plugins`, `get_plugin_info`, + `get_all_plugin_info`, `get_plugin_display_modes`, `find_plugin_for_mode`. + `PluginStateManager`: `is_loaded`, `is_running`, `is_error`, + `get_last_update`, `get_error_info`, `get_state_info`. + - `CacheManager.load_cache`, `CacheManager.generate_sport_cache_key`, + `FontManager.measure_text`, `FontManager.get_native_bdf_size`, + `BaseOddsManager.get_odds_for_games`, `BaseOddsManager.format_odds_summary`, + `DynamicTeamResolver.get_available_dynamic_teams`, + `DynamicTeamResolver.is_dynamic_team`, `PluginTestCase`. +- **Removed (core-internal, no caller):** + - `src/cache/cache_metrics.py` + - Vegas status/stats plumbing that nothing read (`get_status`, + `get_current_scroll_info`, `get_buffer_status`, `VegasModeConfig.to_dict`) + - the sync "new cycle" message, which no follower ever handled (followers + now ignore any message type they don't know) + - unused `OperationType` members, `PluginOperation.from_dict`, + `cancel_operation` + - the test-only `PluginCatalog` readers + - `IPC *Args.to_dict` and `client.ping()` + - `_parse_form_value` + - `CacheStrategyProtocol` + - `ErrorAggregator.on_pattern_detected` and `clear_old_records` + - the duplicate `create_error_response`/`create_success_response` +- **Web UI:** + - `json-file-manager.js` was never mounted: the schema widget renders the + plugin's own file manager in an iframe. + - `example-color-picker.js` was a docs example; `utils/error_handler.js` had + one fallback caller. + - The 29 one-line `escapeHtml` shims now call `window.LEDEscape` directly. + - Four uncalled `PluginAPI` methods are gone. + - `window.escapeHtml`, `BaseWidget` and every widget name are unchanged. +- **Scripts and dependencies:** + - One-off scripts removed: `add_defaults_to_schemas.py`, + `analyze_plugin_schemas.py`, `test_captive_portal.sh`, + `verify_wifi_before_testing.sh`, `dev/run_emulator.sh` (use + `python3 run.py -e`), `update_plugin_repos.py` (use + `git -C ../ledmatrix-plugins pull`). + - Unused pins dropped: `markupsafe` (Flask still installs it) and + `pytest-mock`. + ## 3.8.2 The display hands freed memory back to the OS (#774), and sports consolidation diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 7a095786..5e294d0b 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -76,10 +76,10 @@ for the protocol, the permission model and the plan to retire the mailboxes. Only the display process imports plugin code, instantiates plugins and calls their lifecycle hooks (`update`, `display`, `on_config_change`, `on_enable`, `on_disable`). The web process is metadata-only: it reads plugins as files -through `PluginCatalog` -([`src/plugin_system/plugin_catalog.py`](../src/plugin_system/plugin_catalog.py)) --- manifests, config schemas (through `SchemaManager`), each plugin's -section of `config.json`, and installed versions. The catalog keeps the +-- manifests and directories through `PluginCatalog` +([`src/plugin_system/plugin_catalog.py`](../src/plugin_system/plugin_catalog.py)), +config schemas through `SchemaManager`, and each plugin's section of +`config.json` through `ConfigManager`. The catalog keeps the read-only method names of `PluginManager` and has nothing that can run a plugin (no `load_plugin`, `get_plugin` or `plugins`). @@ -294,7 +294,7 @@ and must not vouch for it. | Base class plugins implement | [`base_plugin.py`](../src/plugin_system/base_plugin.py) (`BasePlugin`, `VegasDisplayMode`) | | Finding a plugin's directory | [`plugin_dirs.py`](../src/plugin_system/plugin_dirs.py): manifest `id` first, then directory `` or `ledmatrix-` | | Discovery, load, unload, scheduled updates (display process) | [`plugin_manager.py`](../src/plugin_system/plugin_manager.py) (`PluginManager`) | -| Manifest, schema, config and version reads (web process) | [`plugin_catalog.py`](../src/plugin_system/plugin_catalog.py) (`PluginCatalog`; see [who runs plugins](#web-and-display-processes-who-runs-plugins)) | +| Manifest reads (web process) | [`plugin_catalog.py`](../src/plugin_system/plugin_catalog.py) (`PluginCatalog`; see [who runs plugins](#web-and-display-processes-who-runs-plugins)) | | Import and instantiate | [`plugin_loader.py`](../src/plugin_system/plugin_loader.py) (`PluginLoader.load_plugin()`: dependencies, module, class) | | Timeouts | [`plugin_executor.py`](../src/plugin_system/plugin_executor.py) (`PluginExecutor`, 30 s default; a timed-out thread is abandoned, not killed) | | Circuit breaker | [`plugin_health.py`](../src/plugin_system/plugin_health.py) (`PluginHealthTracker`: 3 consecutive failures open the circuit for 300 s) | diff --git a/docs/DEPRECATIONS_3.8.md b/docs/DEPRECATIONS_3.8.md index c07841f6..fdf62fec 100644 --- a/docs/DEPRECATIONS_3.8.md +++ b/docs/DEPRECATIONS_3.8.md @@ -2,61 +2,73 @@ Generated by `scripts/plugin_api_usage.py` — do not edit by hand; re-run it (see [How to re-run](#how-to-re-run)). -- Scanned: 2026-10-01, core 3.7.0 -- Monorepo: [ChuckBuilds/ledmatrix-plugins](https://github.com/ChuckBuilds/ledmatrix-plugins) (main @ 4de1d134), 46 plugins +- Scanned: 2026-10-05, core 3.8.2 +- Monorepo: [ChuckBuilds/ledmatrix-plugins](https://github.com/ChuckBuilds/ledmatrix-plugins) (main @ 662fb86f), 46 plugins - Third-party plugins: 8 with their own repo in `plugins.json` (f1-live, gif-player, pga-tour-leaderboard, plex-marquee, ledmatrix-dresden-departures, tidbyt-baseball-scoreboard, sleeper-fantasy, ledmatrix-nascar) -**37 deprecated methods: 36 unused, 1 still used, 0 need review.** +**45 deprecated methods: 31 unused, 3 still used, 11 need review.** Counted per plugin: a *call* is `.method` on an object named like the owner (`cache_manager`, `display_manager`, `font_manager`, `plugin_manager`), or on `self`/`super()` in a subclass; an *override* is `def method` in a subclass of the owner. *Review* hits are `.method` on a receiver whose type the scan cannot tell. *Internal* hits sit inside another deprecated core method and go with it. *Unrelated* hits are a different class's own method with the same name (a name collision), and never block removal; neither do hits in test files. | Method | Removal | Core | Plugins (calls / overrides) | Name collisions & tests | Verdict | |---|---|---|---|---|---| -| `CacheManager.has_data_changed` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 | -| `CacheManager.update_cache` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 | -| `CacheManager.setup_persistent_cache` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 | -| `CacheManager.get_sport_live_interval` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 | -| `CacheManager.get_sport_key_from_cache_key` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 | -| `CacheManager.get_background_cached_data` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 | -| `CacheManager.is_background_data_available` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 | -| `CacheManager.record_cache_hit` | 3.8.0 | core (1 internal) | — | — | unused — safe to remove in 3.8.0 | -| `CacheManager.record_cache_miss` | 3.8.0 | core (1 internal) | — | — | unused — safe to remove in 3.8.0 | -| `CacheManager.record_fetch_time` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 | -| `CacheManager.get_cache_metrics` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 | -| `CacheManager.log_cache_metrics` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 | -| `CacheManager.get_memory_cache_stats` | 3.8.0 | core tests (3 test calls) | — | — | unused — safe to remove in 3.8.0 | -| `DisplayManager.draw_sun` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 | -| `DisplayManager.draw_cloud` | 3.8.0 | core (2 internals) | — | ledmatrix-weather (1 unrelated) | unused — safe to remove in 3.8.0 | -| `DisplayManager.draw_rain` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 | -| `DisplayManager.draw_snow` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 | -| `DisplayManager.draw_weather_icon` | 3.8.0 | core (1 internal) | — | ledmatrix-weather (5 unrelateds) | unused — safe to remove in 3.8.0 | -| `DisplayManager.draw_text_with_icons` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 | -| `DisplayManager.get_scrolling_stats` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 | -| `FontManager.get_manager_fonts` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 | -| `FontManager.get_detected_fonts` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 | -| `FontManager.unregister_plugin_fonts` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 | -| `FontManager.get_plugin_fonts` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 | -| `FontManager.set_override` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 | -| `FontManager.remove_override` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 | -| `FontManager.get_overrides` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 | -| `FontManager.get_available_fonts` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 | -| `FontManager.get_size_tokens` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 | -| `FontManager.get_performance_stats` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 | -| `FontManager.get_font_catalog` | 3.8.0 | core tests (1 test call) | — | — | unused — safe to remove in 3.8.0 | -| `FontManager.add_font` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 | -| `FontManager.remove_font` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 | -| `FontManager.validate_font` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 | -| `BasePlugin.get_supported_vegas_modes` | 3.9.0 | core tests (2 test reviews) | blackjack (1 call, 1 override); calendar (1 override); olympics (1 override) | — | still used by blackjack, calendar, olympics — keep or migrate first | +| `BackgroundDataService.get_result` | 3.10.0 | core tests (15 test reviews) | — | — | unused — safe to remove in 3.10.0 | +| `BackgroundDataService.is_request_complete` | 3.10.0 | core tests (11 test reviews) | — | — | unused — safe to remove in 3.10.0 | +| `BackgroundDataService.get_request_status` | 3.10.0 | core tests (2 test reviews) | — | — | unused — safe to remove in 3.10.0 | +| `BaseOddsManager.get_odds_for_games` | 3.10.0 | core tests (3 test reviews) | — | — | unused — safe to remove in 3.10.0 | +| `BaseOddsManager.format_odds_summary` | 3.10.0 | core tests (5 test reviews) | — | — | unused — safe to remove in 3.10.0 | +| `CacheManager.load_cache` | 3.10.0 | core tests (1 test review) | — | — | unused — safe to remove in 3.10.0 | +| `CacheManager.generate_sport_cache_key` | 3.10.0 | core tests (2 test calls, 1 test review) | — | — | unused — safe to remove in 3.10.0 | +| `APIHelper.fetch_espn_scoreboard` | 3.10.0 | core tests (1 test call, 4 test reviews) | — | football-scoreboard (1 test review); hockey-scoreboard (1 test review); ufc-scoreboard (3 test reviews) | unused — safe to remove in 3.10.0 | +| `APIHelper.fetch_espn_standings` | 3.10.0 | core tests (1 test review) | — | — | unused — safe to remove in 3.10.0 | +| `APIHelper.fetch_espn_rankings` | 3.10.0 | core tests (1 test review) | — | — | unused — safe to remove in 3.10.0 | +| `APIHelper.set_cache` | 3.10.0 | core tests (1 test call, 1 test review) | — | — | unused — safe to remove in 3.10.0 | +| `APIHelper.get_cache` | 3.10.0 | core tests (1 test call) | — | — | unused — safe to remove in 3.10.0 | +| `APIHelper.set_rate_limit` | 3.10.0 | core tests (10 test calls, 3 test reviews) | — | — | unused — safe to remove in 3.10.0 | +| `APIHelper.get_request_stats` | 3.10.0 | — | — | — | unused — safe to remove in 3.10.0 | +| `ConfigManager.rollback_config` | 3.10.0 | core (1 internal); core tests (1 test review) | — | — | unused — safe to remove in 3.10.0 | +| `ConfigManager.list_backups` | 3.10.0 | core (1 internal); core tests (1 test review) | — | — | unused — safe to remove in 3.10.0 | +| `ConfigManager.validate_config_file` | 3.10.0 | core (1 internal) | — | — | unused — safe to remove in 3.10.0 | +| `ConfigManager.get_secret` | 3.10.0 | core tests (5 test calls, 1 test review) | — | — | unused — safe to remove in 3.10.0 | +| `ConfigManager.cleanup_orphaned_plugin_configs` | 3.10.0 | core tests (2 test calls, 1 test review) | — | — | unused — safe to remove in 3.10.0 | +| `ConfigManager.validate_all_plugin_configs` | 3.10.0 | core tests (1 test call, 1 test review) | — | — | unused — safe to remove in 3.10.0 | +| `DynamicTeamResolver.get_available_dynamic_teams` | 3.10.0 | — | — | — | unused — safe to remove in 3.10.0 | +| `DynamicTeamResolver.is_dynamic_team` | 3.10.0 | — | — | — | unused — safe to remove in 3.10.0 | +| `FontManager.get_native_bdf_size` | 3.10.0 | core tests (3 test calls) | — | — | unused — safe to remove in 3.10.0 | +| `FontManager.measure_text` | 3.10.0 | core tests (5 test calls) | — | — | unused — safe to remove in 3.10.0 | +| `LogoDownloader.fetch_teams_data` | 3.10.0 | core (2 internals) | — | — | still used by core — keep or migrate first | +| `LogoDownloader.extract_teams_from_data` | 3.10.0 | core (2 internals) | — | — | still used by core — keep or migrate first | +| `LogoDownloader.download_missing_logos_for_league` | 3.10.0 | core (1 call, 1 internal); core tests (2 test calls) | — | — | still used by core — keep or migrate first | +| `LogoDownloader.download_all_ncaa_football_logos` | 3.10.0 | core tests (2 test calls) | — | — | unused — safe to remove in 3.10.0 | +| `LogoDownloader.download_all_missing_logos` | 3.10.0 | — | — | — | unused — safe to remove in 3.10.0 | +| `LogoDownloader.convert_image_to_rgba` | 3.10.0 | core (1 internal) | — | — | unused — safe to remove in 3.10.0 | +| `LogoDownloader.convert_all_logos_to_rgba` | 3.10.0 | — | — | — | unused — safe to remove in 3.10.0 | +| `BasePlugin.get_supported_vegas_modes` | 3.9.0 | core tests (2 test reviews) | — | — | unused — safe to remove in 3.9.0 | | `BasePlugin.get_vegas_segment_width` | 3.9.0 | core tests (1 test review) | — | — | unused — safe to remove in 3.9.0 | -| `PluginManager.get_enabled_plugins` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 | +| `PluginManager.get_all_plugins` | 3.10.0 | — | football-scoreboard (1 review) | football-scoreboard (2 test reviews); hockey-scoreboard (1 test review) | needs review: possible use in football-scoreboard | +| `PluginManager.get_plugin_info` | 3.10.0 | core (12 reviews, 1 internal); core tests (6 test calls, 9 test reviews) | — | — | needs review: possible use in core | +| `PluginManager.get_all_plugin_info` | 3.10.0 | core (3 reviews); core tests (2 test calls, 11 test reviews) | — | — | needs review: possible use in core | +| `PluginManager.get_plugin_display_modes` | 3.10.0 | core (4 reviews); core tests (3 test calls, 5 test reviews) | — | — | needs review: possible use in core | +| `PluginManager.find_plugin_for_mode` | 3.10.0 | core (2 reviews) | — | — | needs review: possible use in core | +| `PluginStateManager.is_loaded` | 3.10.0 | core (1 internal) | — | — | needs review: possible use in core | +| `PluginStateManager.is_running` | 3.10.0 | core (1 internal) | — | — | needs review: possible use in core | +| `PluginStateManager.is_error` | 3.10.0 | core (1 internal) | — | — | needs review: possible use in core | +| `PluginStateManager.get_error_info` | 3.10.0 | core (1 internal); core tests (4 test calls) | — | — | needs review: possible use in core | +| `PluginStateManager.get_last_update` | 3.10.0 | core (1 internal) | — | — | needs review: possible use in core | +| `PluginStateManager.get_state_info` | 3.10.0 | core (1 internal); core tests (7 test reviews) | — | — | needs review: possible use in core | +| `PluginTestCase.setUp` | 3.10.0 | — | — | basketball-scoreboard (2 test unrelateds); cricket-scoreboard (1 test unrelated); hockey-scoreboard (1 test unrelated); nrl-scoreboard (1 test unrelated) | unused — safe to remove in 3.10.0 | -## Unused — safe to remove (36) +## Unused — safe to remove (31) -`CacheManager.has_data_changed`, `CacheManager.update_cache`, `CacheManager.setup_persistent_cache`, `CacheManager.get_sport_live_interval`, `CacheManager.get_sport_key_from_cache_key`, `CacheManager.get_background_cached_data`, `CacheManager.is_background_data_available`, `CacheManager.record_cache_hit`, `CacheManager.record_cache_miss`, `CacheManager.record_fetch_time`, `CacheManager.get_cache_metrics`, `CacheManager.log_cache_metrics`, `CacheManager.get_memory_cache_stats`, `DisplayManager.draw_sun`, `DisplayManager.draw_cloud`, `DisplayManager.draw_rain`, `DisplayManager.draw_snow`, `DisplayManager.draw_weather_icon`, `DisplayManager.draw_text_with_icons`, `DisplayManager.get_scrolling_stats`, `FontManager.get_manager_fonts`, `FontManager.get_detected_fonts`, `FontManager.unregister_plugin_fonts`, `FontManager.get_plugin_fonts`, `FontManager.set_override`, `FontManager.remove_override`, `FontManager.get_overrides`, `FontManager.get_available_fonts`, `FontManager.get_size_tokens`, `FontManager.get_performance_stats`, `FontManager.get_font_catalog`, `FontManager.add_font`, `FontManager.remove_font`, `FontManager.validate_font`, `BasePlugin.get_vegas_segment_width`, `PluginManager.get_enabled_plugins` +`BackgroundDataService.get_result`, `BackgroundDataService.is_request_complete`, `BackgroundDataService.get_request_status`, `BaseOddsManager.get_odds_for_games`, `BaseOddsManager.format_odds_summary`, `CacheManager.load_cache`, `CacheManager.generate_sport_cache_key`, `APIHelper.fetch_espn_scoreboard`, `APIHelper.fetch_espn_standings`, `APIHelper.fetch_espn_rankings`, `APIHelper.set_cache`, `APIHelper.get_cache`, `APIHelper.set_rate_limit`, `APIHelper.get_request_stats`, `ConfigManager.rollback_config`, `ConfigManager.list_backups`, `ConfigManager.validate_config_file`, `ConfigManager.get_secret`, `ConfigManager.cleanup_orphaned_plugin_configs`, `ConfigManager.validate_all_plugin_configs`, `DynamicTeamResolver.get_available_dynamic_teams`, `DynamicTeamResolver.is_dynamic_team`, `FontManager.get_native_bdf_size`, `FontManager.measure_text`, `LogoDownloader.download_all_ncaa_football_logos`, `LogoDownloader.download_all_missing_logos`, `LogoDownloader.convert_image_to_rgba`, `LogoDownloader.convert_all_logos_to_rgba`, `BasePlugin.get_supported_vegas_modes`, `BasePlugin.get_vegas_segment_width`, `PluginTestCase.setUp` -## Still used — keep or migrate first (1) +## Still used — keep or migrate first (3) -`BasePlugin.get_supported_vegas_modes` +`LogoDownloader.fetch_teams_data`, `LogoDownloader.extract_teams_from_data`, `LogoDownloader.download_missing_logos_for_league` + +## Needs review (11) + +`PluginManager.get_all_plugins`, `PluginManager.get_plugin_info`, `PluginManager.get_all_plugin_info`, `PluginManager.get_plugin_display_modes`, `PluginManager.find_plugin_for_mode`, `PluginStateManager.is_loaded`, `PluginStateManager.is_running`, `PluginStateManager.is_error`, `PluginStateManager.get_error_info`, `PluginStateManager.get_last_update`, `PluginStateManager.get_state_info` ## Every hit @@ -64,94 +76,254 @@ File paths are relative to the plugin's directory (core: the repo root). | Method | Where | File:line | Kind | Code | |---|---|---|---|---| -| `CacheManager.get_sport_live_interval` | core | src/cache/cache_strategy.py:28 | unrelated | `def get_sport_live_interval(self, sport_key: str) -> int:` | -| `CacheManager.get_sport_live_interval` | core | src/cache/cache_strategy.py:60 | unrelated | `live_interval = self.get_sport_live_interval(sport_key)` | -| `CacheManager.get_sport_live_interval` | core | src/cache_manager.py:785 | unrelated | `return self._strategy_component.get_sport_live_interval(sport_key)` | -| `CacheManager.get_sport_key_from_cache_key` | core | src/cache/cache_strategy.py:214 | unrelated | `def get_sport_key_from_cache_key(self, key: str) -> Optional[str]:` | -| `CacheManager.get_sport_key_from_cache_key` | core | src/cache_manager.py:806 | unrelated | `return self._strategy_component.get_sport_key_from_cache_key(key)` | -| `CacheManager.get_sport_key_from_cache_key` | core | src/cache_manager.py:816 | unrelated | `sport_key = self._strategy_component.get_sport_key_from_cache_key(key)` | -| `CacheManager.record_cache_hit` | core | src/cache_manager.py:869 | internal (in `CacheManager.get_background_cached_data`) | `self.record_cache_hit('background')` | -| `CacheManager.record_cache_miss` | core | src/cache_manager.py:876 | internal (in `CacheManager.get_background_cached_data`) | `self.record_cache_miss('background')` | -| `CacheManager.record_fetch_time` | core | src/cache/cache_metrics.py:67 | unrelated | `def record_fetch_time(self, duration: float) -> None:` | -| `CacheManager.record_fetch_time` | core | src/cache_manager.py:922 | unrelated | `self._metrics_component.record_fetch_time(duration)` | -| `CacheManager.get_memory_cache_stats` | core tests | test/test_cache_manager_memory_tier.py:43 | test call | `stats = cm.get_memory_cache_stats()` | -| `CacheManager.get_memory_cache_stats` | core tests | test/test_cache_manager_memory_tier.py:63 | test call | `assert cm.get_memory_cache_stats()["last_cleanup"] >= before` | -| `CacheManager.get_memory_cache_stats` | core tests | test/test_cache_manager_memory_tier.py:68 | test call | `stats = cm.get_memory_cache_stats()` | -| `DisplayManager.draw_sun` | core | src/plugin_system/testing/visual_display_manager.py:417 | unrelated | `def draw_sun(self, x: int, y: int, size: int = 16):` | -| `DisplayManager.draw_cloud` | core | src/display_manager.py:1359 | internal (in `DisplayManager.draw_rain`) | `self.draw_cloud(x, y, size)` | -| `DisplayManager.draw_cloud` | core | src/display_manager.py:1374 | internal (in `DisplayManager.draw_snow`) | `self.draw_cloud(x, y, size)` | -| `DisplayManager.draw_cloud` | core | src/plugin_system/testing/visual_display_manager.py:421 | unrelated | `def draw_cloud(self, x: int, y: int, size: int = 16, color: Tuple[int, int, int] = (200, 200, 200)):` | -| `DisplayManager.draw_cloud` | ledmatrix-weather | weather_icons.py:184 | unrelated | `def draw_cloud(draw: ImageDraw, x: int, y: int, size: int = 16, color: tuple = (200, 200, 200)):` | -| `DisplayManager.draw_rain` | core | src/plugin_system/testing/visual_display_manager.py:425 | unrelated | `def draw_rain(self, x: int, y: int, size: int = 16):` | -| `DisplayManager.draw_snow` | core | src/plugin_system/testing/visual_display_manager.py:429 | unrelated | `def draw_snow(self, x: int, y: int, size: int = 16):` | -| `DisplayManager.draw_weather_icon` | core | src/display_manager.py:1518 | internal (in `DisplayManager.draw_text_with_icons`) | `self.draw_weather_icon(icon_type, icon_x, icon_y)` | -| `DisplayManager.draw_weather_icon` | core | src/plugin_system/testing/visual_display_manager.py:510 | unrelated | `def draw_weather_icon(self, condition: str, x: int, y: int, size: int = 16) -> None:` | -| `DisplayManager.draw_weather_icon` | core | src/plugin_system/testing/visual_display_manager.py:533 | unrelated | `self.draw_weather_icon(icon_type, icon_x, icon_y)` | -| `DisplayManager.draw_weather_icon` | ledmatrix-weather | manager.py:84 | unrelated | `def draw_weather_icon(image, icon_code, x, y, size):` | -| `DisplayManager.draw_weather_icon` | ledmatrix-weather | manager.py:1280 | unrelated | `WeatherIcons.draw_weather_icon(img, icon_code, icon_x, icon_y,` | -| `DisplayManager.draw_weather_icon` | ledmatrix-weather | manager.py:1559 | unrelated | `WeatherIcons.draw_weather_icon(img, forecast['icon'], icon_x, icon_y, icon_size)` | -| `DisplayManager.draw_weather_icon` | ledmatrix-weather | manager.py:1650 | unrelated | `WeatherIcons.draw_weather_icon(img, forecast['icon'], icon_x, icon_y, icon_size)` | -| `DisplayManager.draw_weather_icon` | ledmatrix-weather | weather_icons.py:168 | unrelated | `def draw_weather_icon(image: Image.Image, icon_code: str, x: int, y: int, size: int = DEFAULT_SIZE):` | -| `DisplayManager.draw_text_with_icons` | core | src/plugin_system/testing/visual_display_manager.py:526 | unrelated | `def draw_text_with_icons(self, text: str, icons: List[tuple] = None,` | -| `FontManager.get_font_catalog` | core tests | test/test_deprecation.py:229 | test call | `assert fm.get_font_catalog() == fm.font_catalog` | +| `BackgroundDataService.get_result` | core tests | test/test_background_data_service.py:85 | test review | `result = service.get_result(req_id)` | +| `BackgroundDataService.get_result` | core tests | test/test_background_data_service.py:107 | test review | `seen["filed"] = service.get_result(result.request_id) is result` | +| `BackgroundDataService.get_result` | core tests | test/test_background_data_service.py:148 | test review | `result = service.get_result(req_id)` | +| `BackgroundDataService.get_result` | core tests | test/test_background_data_service.py:165 | test review | `result = service.get_result(req_id)` | +| `BackgroundDataService.get_result` | core tests | test/test_background_data_service.py:254 | test review | `assert service.get_result("unknown") is None` | +| `BackgroundDataService.get_result` | core tests | test/test_background_data_service.py:391 | test review | `assert service.get_result(rid).cached is True` | +| `BackgroundDataService.get_result` | core tests | test/test_background_fetch_dedupe.py:198 | test review | `assert service.get_result(req).success is True` | +| `BackgroundDataService.get_result` | core tests | test/test_background_payload_release.py:85 | test review | `result = service.get_result(req_id)` | +| `BackgroundDataService.get_result` | core tests | test/test_background_payload_release.py:117 | test review | `stored = service.get_result(req_id)` | +| `BackgroundDataService.get_result` | core tests | test/test_background_payload_release.py:144 | test review | `assert service.get_result(req_id).data == PAYLOAD` | +| `BackgroundDataService.get_result` | core tests | test/test_background_payload_release.py:154 | test review | `stored = service.get_result(req_id)` | +| `BackgroundDataService.get_result` | core tests | test/test_background_payload_release.py:171 | test review | `assert service.get_result(req_id).data is None` | +| `BackgroundDataService.get_result` | core tests | test/test_background_payload_release.py:193 | test review | `assert service.get_result(req_id).data == PAYLOAD` | +| `BackgroundDataService.get_result` | core tests | test/test_background_payload_release.py:278 | test review | `stored = service.get_result(first)` | +| `BackgroundDataService.get_result` | core tests | test/test_fetch_service.py:703 | test review | `assert bds.get_result(request_id).success` | +| `BackgroundDataService.is_request_complete` | core tests | test/test_background_data_service.py:145 | test review | `while not service.is_request_complete(req_id) and time.time() < deadline:` | +| `BackgroundDataService.is_request_complete` | core tests | test/test_background_data_service.py:162 | test review | `while not service.is_request_complete(req_id) and time.time() < deadline:` | +| `BackgroundDataService.is_request_complete` | core tests | test/test_background_data_service.py:194 | test review | `while not service.is_request_complete(req_id) and time.time() < deadline:` | +| `BackgroundDataService.is_request_complete` | core tests | test/test_background_data_service.py:211 | test review | `while not service.is_request_complete(req_id) and time.time() < deadline:` | +| `BackgroundDataService.is_request_complete` | core tests | test/test_background_data_service.py:246 | test review | `assert service.is_request_complete("r2") is False` | +| `BackgroundDataService.is_request_complete` | core tests | test/test_background_data_service.py:251 | test review | `assert service.is_request_complete("r3") is True` | +| `BackgroundDataService.is_request_complete` | core tests | test/test_background_data_service_espn_ranges.py:91 | test review | `while not service.is_request_complete(request_id) and time.time() < deadline:` | +| `BackgroundDataService.is_request_complete` | core tests | test/test_background_data_service_espn_ranges.py:154 | test review | `while not service.is_request_complete(request_id) and time.time() < deadline:` | +| `BackgroundDataService.is_request_complete` | core tests | test/test_background_fetch_dedupe.py:55 | test review | `while not service.is_request_complete(req_id) and time.time() < deadline:` | +| `BackgroundDataService.is_request_complete` | core tests | test/test_background_payload_release.py:67 | test review | `while not service.is_request_complete(req_id) and time.time() < deadline:` | +| `BackgroundDataService.is_request_complete` | core tests | test/test_fetch_service.py:701 | test review | `while not bds.is_request_complete(request_id) and time.monotonic() < deadline:` | +| `BackgroundDataService.get_request_status` | core tests | test/test_background_data_service.py:223 | test review | `assert service.get_request_status("nonexistent") is None` | +| `BackgroundDataService.get_request_status` | core tests | test/test_background_fetch_dedupe.py:448 | test review | `assert service.get_request_status(rid) is FetchStatus.CANCELLED, (` | +| `BaseOddsManager.get_odds_for_games` | core tests | test/test_base_odds_manager.py:356 | test review | `result = manager.get_odds_for_games(games)` | +| `BaseOddsManager.get_odds_for_games` | core tests | test/test_base_odds_manager.py:374 | test review | `result = manager.get_odds_for_games(games)` | +| `BaseOddsManager.get_odds_for_games` | core tests | test/test_base_odds_manager.py:385 | test review | `result = manager.get_odds_for_games([game])` | +| `BaseOddsManager.format_odds_summary` | core tests | test/test_base_odds_manager.py:322 | test review | `result = manager.format_odds_summary({` | +| `BaseOddsManager.format_odds_summary` | core tests | test/test_base_odds_manager.py:329 | test review | `result = manager.format_odds_summary(FULL_EXTRACTED)` | +| `BaseOddsManager.format_odds_summary` | core tests | test/test_base_odds_manager.py:333 | test review | `assert manager.format_odds_summary(None) == 'No odds available'` | +| `BaseOddsManager.format_odds_summary` | core tests | test/test_base_odds_manager.py:336 | test review | `assert manager.format_odds_summary({}) == 'No odds available'` | +| `BaseOddsManager.format_odds_summary` | core tests | test/test_base_odds_manager.py:339 | test review | `assert manager.format_odds_summary(` | +| `CacheManager.load_cache` | core tests | test/conftest.py:219 | test review | `mock.load_cache = Mock(side_effect=mock_get)` | +| `CacheManager.generate_sport_cache_key` | core tests | test/test_background_data_service.py:42 | test review | `m.generate_sport_cache_key.return_value = "test_key"` | +| `CacheManager.generate_sport_cache_key` | core tests | test/test_background_data_service.py:356 | test call | `expected = CacheManager.generate_sport_cache_key(None, sport, date_str)` | +| `CacheManager.generate_sport_cache_key` | core tests | test/test_background_data_service.py:367 | test call | `theirs = cm_module.CacheManager.generate_sport_cache_key(None, "nba")` | +| `APIHelper.fetch_espn_scoreboard` | core tests | test/test_api_helper.py:214 | test review | `result = helper.fetch_espn_scoreboard('football', 'nfl')` | +| `APIHelper.fetch_espn_scoreboard` | core tests | test/test_api_helper.py:230 | test review | `helper.fetch_espn_scoreboard('basketball', 'nba', date='20250115')` | +| `APIHelper.fetch_espn_scoreboard` | core tests | test/test_api_helper.py:239 | test review | `helper.fetch_espn_scoreboard('basketball', 'nba', date='20250115', cache_key='mine')` | +| `APIHelper.fetch_espn_scoreboard` | core tests | test/test_api_helper.py:251 | test review | `assert helper.fetch_espn_scoreboard('basketball', 'nba', date='20250115') == {` | +| `APIHelper.fetch_espn_scoreboard` | core tests | test/test_espn_scoreboard_cache.py:199 | test call | `helper.fetch_espn_scoreboard("football", "nfl", date=self.DAY)` | +| `APIHelper.fetch_espn_scoreboard` | football-scoreboard | test_espn_date_ranges.py:119 | test review | `helper = sys.modules[sports.fetch_espn_scoreboard.__module__]` | +| `APIHelper.fetch_espn_scoreboard` | hockey-scoreboard | test_espn_date_ranges.py:106 | test review | `helper = sys.modules[sports.fetch_espn_scoreboard.__module__]` | +| `APIHelper.fetch_espn_scoreboard` | ufc-scoreboard | test_every_bout_is_its_own_fight.py:103 | test review | `_real_fetch = sports.fetch_espn_scoreboard` | +| `APIHelper.fetch_espn_scoreboard` | ufc-scoreboard | test_every_bout_is_its_own_fight.py:108 | test review | `sports.fetch_espn_scoreboard = lambda *a, **k: board(name)` | +| `APIHelper.fetch_espn_scoreboard` | ufc-scoreboard | test_every_bout_is_its_own_fight.py:113 | test review | `sports.fetch_espn_scoreboard = _real_fetch` | +| `APIHelper.fetch_espn_standings` | core tests | test/test_api_helper.py:258 | test review | `helper.fetch_espn_standings('football', 'nfl')` | +| `APIHelper.fetch_espn_rankings` | core tests | test/test_api_helper.py:269 | test review | `helper.fetch_espn_rankings('football', 'college-football')` | +| `APIHelper.set_cache` | core tests | test/test_api_helper.py:128 | test review | `helper.set_cache('k', {'a': 1}, ttl=42)` | +| `APIHelper.set_cache` | core tests | test/test_api_helper.py:350 | test call | `assert helper.set_cache('k', {'a': 1}) is None` | +| `APIHelper.get_cache` | core tests | test/test_api_helper.py:348 | test call | `assert helper.get_cache('k') is None` | +| `APIHelper.set_rate_limit` | core tests | test/test_api_helper.py:42 | test call | `helper.set_rate_limit(0)` | +| `APIHelper.set_rate_limit` | core tests | test/test_api_helper.py:57 | test review | `helper.set_rate_limit(5)` | +| `APIHelper.set_rate_limit` | core tests | test/test_api_helper.py:72 | test review | `helper.set_rate_limit(5)` | +| `APIHelper.set_rate_limit` | core tests | test/test_api_helper.py:91 | test review | `helper.set_rate_limit(5)` | +| `APIHelper.set_rate_limit` | core tests | test/test_api_helper.py:150 | test call | `helper.set_rate_limit(0)` | +| `APIHelper.set_rate_limit` | core tests | test/test_api_helper.py:305 | test call | `helper.set_rate_limit(0)` | +| `APIHelper.set_rate_limit` | core tests | test/test_api_helper.py:313 | test call | `helper.set_rate_limit(0)` | +| `APIHelper.set_rate_limit` | core tests | test/test_api_helper.py:326 | test call | `helper.set_rate_limit(0)` | +| `APIHelper.set_rate_limit` | core tests | test/test_api_helper.py:334 | test call | `helper.set_rate_limit(0)` | +| `APIHelper.set_rate_limit` | core tests | test/test_api_helper.py:346 | test call | `helper.set_rate_limit(0)` | +| `APIHelper.set_rate_limit` | core tests | test/test_espn_scoreboard_cache.py:197 | test call | `helper.set_rate_limit(0)` | +| `APIHelper.set_rate_limit` | core tests | test/test_fetch_service.py:726 | test call | `helper.set_rate_limit(0)` | +| `APIHelper.set_rate_limit` | core tests | test/test_fetch_service.py:1143 | test call | `helper.set_rate_limit(0)` | +| `ConfigManager.rollback_config` | core | src/config_manager.py:191 | internal (in `ConfigManager.rollback_config`) | `success = atomic_mgr.rollback_config(backup_version)` | +| `ConfigManager.rollback_config` | core | src/config_manager_atomic.py:297 | unrelated | `def rollback_config(self, backup_version: Optional[str] = None) -> bool:` | +| `ConfigManager.rollback_config` | core tests | test/test_config_durable_writes.py:345 | test review | `assert manager.rollback_config()` | +| `ConfigManager.list_backups` | core | src/config_manager.py:212 | internal (in `ConfigManager.list_backups`) | `return atomic_mgr.list_backups()` | +| `ConfigManager.list_backups` | core | src/config_manager_atomic.py:334 | unrelated | `def list_backups(self) -> List[BackupInfo]:` | +| `ConfigManager.list_backups` | core | src/config_manager_atomic.py:309 | unrelated | `backups = self.list_backups()` | +| `ConfigManager.list_backups` | core tests | test/test_config_durable_writes.py:323 | test review | `assert [b.path for b in manager.list_backups()] == [` | +| `ConfigManager.validate_config_file` | core | src/config_manager.py:226 | internal (in `ConfigManager.validate_config_file`) | `return atomic_mgr.validate_config_file(config_path)` | +| `ConfigManager.validate_config_file` | core | src/config_manager_atomic.py:426 | unrelated | `def validate_config_file(self, config_path: Optional[str] = None) -> ValidationResult:` | +| `ConfigManager.get_secret` | core tests | test/conftest.py:246 | test review | `mock.get_secret = Mock(side_effect=mock_get_secret)` | +| `ConfigManager.get_secret` | core tests | test/test_config_manager.py:317 | test call | `assert manager.get_secret("api_key") == "secret123"` | +| `ConfigManager.get_secret` | core tests | test/test_config_manager.py:318 | test call | `assert manager.get_secret("token") == "token456"` | +| `ConfigManager.get_secret` | core tests | test/test_config_manager.py:319 | test call | `assert manager.get_secret("nonexistent") is None` | +| `ConfigManager.get_secret` | core tests | test/test_config_manager.py:325 | test call | `assert manager.get_secret("api_key") is None` | +| `ConfigManager.get_secret` | core tests | test/test_config_manager.py:337 | test call | `assert manager.get_secret("api_key") is None` | +| `ConfigManager.cleanup_orphaned_plugin_configs` | core tests | test/test_config_manager.py:447 | test call | `removed = manager.cleanup_orphaned_plugin_configs(["plugin1", "plugin2"])` | +| `ConfigManager.cleanup_orphaned_plugin_configs` | core tests | test/test_core_config_key_adopters.py:80 | test review | `removed = manager.cleanup_orphaned_plugin_configs(['installed'])` | +| `ConfigManager.cleanup_orphaned_plugin_configs` | core tests | test/test_web_auth.py:616 | test call | `config_manager.cleanup_orphaned_plugin_configs([])` | +| `ConfigManager.validate_all_plugin_configs` | core tests | test/test_core_config_key_adopters.py:91 | test review | `results = manager.validate_all_plugin_configs(schema_manager)` | +| `ConfigManager.validate_all_plugin_configs` | core tests | test/test_retired_plugin_config_keys.py:112 | test call | `results = config_manager.validate_all_plugin_configs(schema_manager)` | +| `FontManager.get_native_bdf_size` | core tests | test/test_font_manager.py:104 | test call | `assert fm.get_native_bdf_size("five_by_seven") == 7` | +| `FontManager.get_native_bdf_size` | core tests | test/test_font_manager.py:107 | test call | `assert fm.get_native_bdf_size("press_start") is None` | +| `FontManager.get_native_bdf_size` | core tests | test/test_font_manager.py:110 | test call | `assert fm.get_native_bdf_size("no-such-family") is None` | +| `FontManager.measure_text` | core tests | test/test_font_manager.py:116 | test call | `width, height, baseline = fm.measure_text("SCORE", font)` | +| `FontManager.measure_text` | core tests | test/test_font_manager.py:119 | test call | `assert fm.measure_text("SCORE", font) == (width, height, baseline)` | +| `FontManager.measure_text` | core tests | test/test_font_manager.py:124 | test call | `short, _, _ = fm.measure_text("AB", font)` | +| `FontManager.measure_text` | core tests | test/test_font_manager.py:125 | test call | `long, _, _ = fm.measure_text("ABCD", font)` | +| `FontManager.measure_text` | core tests | test/test_font_manager.py:132 | test call | `fm.measure_text("X", font)` | +| `LogoDownloader.fetch_teams_data` | core | src/logo_downloader.py:639 | internal (in `LogoDownloader.download_missing_logos_for_league`) | `data = self.fetch_teams_data(league)` | +| `LogoDownloader.fetch_teams_data` | core | src/logo_downloader.py:695 | internal (in `LogoDownloader.download_all_ncaa_football_logos`) | `data = self.fetch_teams_data(league)` | +| `LogoDownloader.extract_teams_from_data` | core | src/logo_downloader.py:645 | internal (in `LogoDownloader.download_missing_logos_for_league`) | `teams = self.extract_teams_from_data(data, league)` | +| `LogoDownloader.extract_teams_from_data` | core | src/logo_downloader.py:701 | internal (in `LogoDownloader.download_all_ncaa_football_logos`) | `teams = self.extract_teams_from_data(data, league)` | +| `LogoDownloader.download_missing_logos_for_league` | core | src/logo_downloader.py:786 | internal (in `LogoDownloader.download_all_missing_logos`) | `downloaded, failed = self.download_missing_logos_for_league(league, force_download)` | +| `LogoDownloader.download_missing_logos_for_league` | core | src/logo_downloader.py:1034 | call | `return downloader.download_missing_logos_for_league(league, force_download)` | +| `LogoDownloader.download_missing_logos_for_league` | core tests | test/test_logo_downloader.py:292 | test call | `downloader.download_missing_logos_for_league("nfl")` | +| `LogoDownloader.download_missing_logos_for_league` | core tests | test/test_logo_downloader.py:304 | test call | `downloader.download_missing_logos_for_league("nfl")` | +| `LogoDownloader.download_all_ncaa_football_logos` | core tests | test/test_logo_downloader.py:319 | test call | `downloader.download_all_ncaa_football_logos()` | +| `LogoDownloader.download_all_ncaa_football_logos` | core tests | test/test_logo_downloader.py:332 | test call | `downloader.download_all_ncaa_football_logos()` | +| `LogoDownloader.convert_image_to_rgba` | core | src/logo_downloader.py:897 | internal (in `LogoDownloader.convert_all_logos_to_rgba`) | `if self.convert_image_to_rgba(logo_file):` | | `BasePlugin.get_supported_vegas_modes` | core tests | test/test_vegas_participation.py:356 | test review | `assert plugin.get_supported_vegas_modes() == [` | | `BasePlugin.get_supported_vegas_modes` | core tests | test/test_vegas_participation.py:358 | test review | `assert plugin.get_supported_vegas_modes()` | -| `BasePlugin.get_supported_vegas_modes` | blackjack | manager.py:732 | override | `def get_supported_vegas_modes(self):` | -| `BasePlugin.get_supported_vegas_modes` | blackjack | manager.py:695 | call | `if mode in self.get_supported_vegas_modes():` | -| `BasePlugin.get_supported_vegas_modes` | calendar | manager.py:875 | override | `def get_supported_vegas_modes(self) -> List[VegasDisplayMode]:` | -| `BasePlugin.get_supported_vegas_modes` | olympics | manager.py:624 | override | `def get_supported_vegas_modes(self) -> List[VegasDisplayMode]:` | | `BasePlugin.get_vegas_segment_width` | core tests | test/test_vegas_participation.py:359 | test review | `assert plugin.get_vegas_segment_width() == 2` | +| `PluginManager.get_all_plugins` | core | src/plugin_system/testing/mocks.py:218 | unrelated | `def get_all_plugins(self) -> Dict[str, Any]:` | +| `PluginManager.get_all_plugins` | football-scoreboard | emulator_demo.py:68 | review | `mock_plugin_manager.get_all_plugins = Mock(return_value=[])` | +| `PluginManager.get_all_plugins` | football-scoreboard | test_dynamic_duration.py:64 | test review | `mock_plugin_manager.get_all_plugins = Mock(return_value=[])` | +| `PluginManager.get_all_plugins` | football-scoreboard | test_football_plugin.py:74 | test review | `mock_plugin_manager.get_all_plugins = Mock(return_value=[])` | +| `PluginManager.get_all_plugins` | hockey-scoreboard | test_hockey_emulator.py:99 | test review | `mock_plugin_manager.get_all_plugins = Mock(return_value=[])` | +| `PluginManager.get_plugin_info` | core | src/plugin_system/plugin_catalog.py:120 | unrelated | `def get_plugin_info(self, plugin_id: str) -> Optional[Dict[str, Any]]:` | +| `PluginManager.get_plugin_info` | core | src/plugin_system/plugin_catalog.py:133 | unrelated | `return [info for info in (self.get_plugin_info(pid) for pid in ids) if info]` | +| `PluginManager.get_plugin_info` | core | src/plugin_system/plugin_manager.py:1001 | internal (in `PluginManager.get_all_plugin_info`) | `return [info for info in [self.get_plugin_info(pid) for pid in pids] if info]` | +| `PluginManager.get_plugin_info` | core | src/plugin_system/store_install.py:208 | review | `plugin_info = self.get_plugin_info(plugin_id, fetch_latest_from_github=True, force_refresh=True)` | +| `PluginManager.get_plugin_info` | core | src/plugin_system/store_registry.py:801 | unrelated | `def get_plugin_info(self, plugin_id: str, fetch_latest_from_github: bool = True, force_refresh: bool = False) -> Optional[Dict]:` | +| `PluginManager.get_plugin_info` | core | src/plugin_system/store_update.py:357 | review | `plugin_info_remote = self.get_plugin_info(plugin_id, fetch_latest_from_github=True, force_refresh=True)` | +| `PluginManager.get_plugin_info` | core | src/plugin_system/store_update.py:362 | review | `plugin_info_remote = self.get_plugin_info(alt_id, fetch_latest_from_github=True, force_refresh=True)` | +| `PluginManager.get_plugin_info` | core | src/plugin_system/store_update.py:685 | review | `plugin_info_remote = self.get_plugin_info(plugin_id, fetch_latest_from_github=True, force_refresh=True)` | +| `PluginManager.get_plugin_info` | core | src/plugin_system/store_update.py:691 | review | `plugin_info_remote = self.get_plugin_info(alt_id, fetch_latest_from_github=True, force_refresh=True)` | +| `PluginManager.get_plugin_info` | core | src/plugin_system/testing/mocks.py:223 | unrelated | `def get_plugin_info(self, plugin_id: str) -> Optional[Dict[str, Any]]:` | +| `PluginManager.get_plugin_info` | core | web_interface/blueprints/api_v3/plugin_store.py:216 | review | `remote_info = api_v3.plugin_store_manager.get_plugin_info(plugin_id, fetch_latest_from_github=True)` | +| `PluginManager.get_plugin_info` | core | web_interface/blueprints/api_v3/plugin_store.py:334 | review | `plugin_info = api_v3.plugin_store_manager.get_plugin_info(plugin_id)` | +| `PluginManager.get_plugin_info` | core | web_interface/blueprints/api_v3/plugin_store.py:545 | review | `elif not api_v3.plugin_store_manager.get_plugin_info(plugin_id):` | +| `PluginManager.get_plugin_info` | core | web_interface/blueprints/api_v3/plugin_store.py:602 | review | `elif not api_v3.plugin_store_manager.get_plugin_info(plugin_id):` | +| `PluginManager.get_plugin_info` | core | web_interface/blueprints/pages_v3.py:224 | review | `info = pages_v3.plugin_catalog.get_plugin_info(pid) or {}` | +| `PluginManager.get_plugin_info` | core | web_interface/blueprints/pages_v3.py:722 | review | `plugin_info = pages_v3.plugin_catalog.get_plugin_info(plugin_id)` | +| `PluginManager.get_plugin_info` | core | web_interface/blueprints/pages_v3.py:727 | review | `plugin_info = pages_v3.plugin_catalog.get_plugin_info(plugin_id)` | +| `PluginManager.get_plugin_info` | core tests | test/test_api_v3_plugin_install_endpoints.py:111 | test review | `manager.get_plugin_info.return_value = None` | +| `PluginManager.get_plugin_info` | core tests | test/test_api_v3_plugin_install_endpoints.py:119 | test review | `manager.get_plugin_info.return_value = {"id": "clock"}` | +| `PluginManager.get_plugin_info` | core tests | test/test_pages_v3_path_guards.py:47 | test call | `plugin_manager.get_plugin_info.return_value = {"name": "Weather", "version": "1.0.0"}` | +| `PluginManager.get_plugin_info` | core tests | test/test_registry_id_resolution.py:81 | test review | `_ids(store.get_plugin_info("ledmatrix-weather", fetch_latest_from_github=False))` | +| `PluginManager.get_plugin_info` | core tests | test/test_store_manager_caches.py:612 | test review | `info = self.sm.get_plugin_info("foo", fetch_latest_from_github=True, force_refresh=True)` | +| `PluginManager.get_plugin_info` | core tests | test/test_store_non_plugin_entries.py:37 | test review | `store.get_plugin_info = MagicMock(return_value=dict(SKIN))` | +| `PluginManager.get_plugin_info` | core tests | test/test_web_plugin_dir_resolution.py:86 | test review | `api.plugin_store_manager.get_plugin_info = MagicMock(return_value=None)` | +| `PluginManager.get_plugin_info` | core tests | test/test_web_plugin_dir_resolution.py:118 | test review | `api.plugin_store_manager.get_plugin_info = MagicMock(return_value=None)` | +| `PluginManager.get_plugin_info` | core tests | test/test_web_plugin_dir_resolution.py:178 | test call | `plugin_manager.get_plugin_info.return_value = {"id": "weather", "name": "Weather"}` | +| `PluginManager.get_plugin_info` | core tests | test/web_interface/test_plugin_config_form_defaults.py:119 | test call | `pm.get_plugin_info.return_value = {"name": "Demo", "version": "1.0.0"}` | +| `PluginManager.get_plugin_info` | core tests | test/web_interface/test_plugin_config_schema_expansion.py:88 | test call | `pm.get_plugin_info.return_value = {"id": plugin_id, "name": plugin_id}` | +| `PluginManager.get_plugin_info` | core tests | test/web_interface/test_plugin_widget_route.py:226 | test call | `pm.get_plugin_info.return_value = {"id": plugin_id, "name": plugin_id}` | +| `PluginManager.get_plugin_info` | core tests | test/web_interface/test_plugin_widget_route.py:228 | test call | `pm.get_plugin_info.return_value["version"] = version` | +| `PluginManager.get_plugin_info` | core tests | test/web_interface/test_update_all_plugins.py:41 | test review | `sm.get_plugin_info.return_value = None` | +| `PluginManager.get_plugin_info` | core tests | test/web_interface/test_web_process_runs_no_plugin_code.py:132 | test review | `store.get_plugin_info.return_value = None` | +| `PluginManager.get_all_plugin_info` | core | src/plugin_system/plugin_catalog.py:129 | unrelated | `def get_all_plugin_info(self) -> List[Dict[str, Any]]:` | +| `PluginManager.get_all_plugin_info` | core | web_interface/blueprints/api_v3/plugins.py:68 | review | `all_plugin_info = api_v3.plugin_catalog.get_all_plugin_info()` | +| `PluginManager.get_all_plugin_info` | core | web_interface/blueprints/pages_v3.py:209 | review | `pi.get('id') for pi in pages_v3.plugin_catalog.get_all_plugin_info()` | +| `PluginManager.get_all_plugin_info` | core | web_interface/blueprints/pages_v3.py:559 | review | `infos = sorted(pages_v3.plugin_catalog.get_all_plugin_info(),` | +| `PluginManager.get_all_plugin_info` | core tests | test/test_api_v3_installed_display_modes.py:30 | test review | `api.plugin_catalog.get_all_plugin_info = MagicMock(return_value=[info])` | +| `PluginManager.get_all_plugin_info` | core tests | test/test_api_v3_installed_plugin_icon.py:24 | test review | `api.plugin_catalog.get_all_plugin_info = MagicMock(return_value=[info])` | +| `PluginManager.get_all_plugin_info` | core tests | test/test_installed_list_registry_offline.py:71 | test review | `api.plugin_catalog.get_all_plugin_info = MagicMock(return_value=[` | +| `PluginManager.get_all_plugin_info` | core tests | test/test_onboarding_checklist.py:68 | test review | `mock_pm.get_all_plugin_info.return_value = []` | +| `PluginManager.get_all_plugin_info` | core tests | test/test_plugin_manager_load_failures.py:78 | test call | `infos = {i["id"]: i for i in pm.get_all_plugin_info()}` | +| `PluginManager.get_all_plugin_info` | core tests | test/test_plugin_runtime_snapshot.py:422 | test review | `api.plugin_catalog.get_all_plugin_info = MagicMock(return_value=[` | +| `PluginManager.get_all_plugin_info` | core tests | test/test_vegas_participation.py:447 | test review | `api.plugin_catalog.get_all_plugin_info = MagicMock(return_value=[info])` | +| `PluginManager.get_all_plugin_info` | core tests | test/test_web_api.py:551 | test review | `mock_plugin_catalog.get_all_plugin_info.return_value = [` | +| `PluginManager.get_all_plugin_info` | core tests | test/test_web_api.py:570 | test review | `mock_plugin_catalog.get_all_plugin_info.return_value = [` | +| `PluginManager.get_all_plugin_info` | core tests | test/test_web_api.py:592 | test review | `mock_plugin_catalog.get_all_plugin_info.return_value = [` | +| `PluginManager.get_all_plugin_info` | core tests | test/test_web_plugin_dir_resolution.py:61 | test review | `api.plugin_catalog.get_all_plugin_info = MagicMock(return_value=[info])` | +| `PluginManager.get_all_plugin_info` | core tests | test/test_web_plugin_dir_resolution.py:195 | test call | `plugin_manager.get_all_plugin_info.assert_not_called()` | +| `PluginManager.get_all_plugin_info` | core tests | test/test_web_smoke.py:95 | test review | `mock_pm.get_all_plugin_info.return_value = [` | +| `PluginManager.get_plugin_display_modes` | core | src/plugin_system/plugin_catalog.py:175 | unrelated | `def get_plugin_display_modes(self, plugin_id: str) -> List[str]:` | +| `PluginManager.get_plugin_display_modes` | core | web_interface/blueprints/api_v3/display.py:197 | review | `plugin_modes = api_v3.plugin_catalog.get_plugin_display_modes(plugin_id) or [plugin_id]` | +| `PluginManager.get_plugin_display_modes` | core | web_interface/blueprints/api_v3/display.py:267 | review | `modes = api_v3.plugin_catalog.get_plugin_display_modes(resolved_plugin)` | +| `PluginManager.get_plugin_display_modes` | core | web_interface/blueprints/api_v3/plugins.py:157 | review | `declared_modes = api_v3.plugin_catalog.get_plugin_display_modes(plugin_id)` | +| `PluginManager.get_plugin_display_modes` | core | web_interface/blueprints/pages_v3.py:565 | review | `modes = pages_v3.plugin_catalog.get_plugin_display_modes(pid) or [pid]` | +| `PluginManager.get_plugin_display_modes` | core tests | test/test_api_v3_display_modes.py:40 | test call | `pm.get_plugin_display_modes = MagicMock(` | +| `PluginManager.get_plugin_display_modes` | core tests | test/test_api_v3_display_modes.py:100 | test call | `pm.get_plugin_display_modes = MagicMock(return_value=[])` | +| `PluginManager.get_plugin_display_modes` | core tests | test/test_api_v3_display_modes.py:122 | test call | `pm.get_plugin_display_modes = MagicMock(` | +| `PluginManager.get_plugin_display_modes` | core tests | test/test_api_v3_installed_display_modes.py:31 | test review | `api.plugin_catalog.get_plugin_display_modes = MagicMock(return_value=declared_modes)` | +| `PluginManager.get_plugin_display_modes` | core tests | test/test_api_v3_installed_display_modes.py:39 | test review | `api.plugin_catalog.get_plugin_display_modes.assert_any_call('football-scoreboard')` | +| `PluginManager.get_plugin_display_modes` | core tests | test/test_installed_list_registry_offline.py:75 | test review | `api.plugin_catalog.get_plugin_display_modes = MagicMock(return_value=[])` | +| `PluginManager.get_plugin_display_modes` | core tests | test/test_onboarding_checklist.py:69 | test review | `mock_pm.get_plugin_display_modes.side_effect = lambda pid: []` | +| `PluginManager.get_plugin_display_modes` | core tests | test/test_web_smoke.py:99 | test review | `mock_pm.get_plugin_display_modes.side_effect = (` | +| `PluginManager.find_plugin_for_mode` | core | src/plugin_system/plugin_catalog.py:186 | unrelated | `def find_plugin_for_mode(self, mode: str) -> Optional[str]:` | +| `PluginManager.find_plugin_for_mode` | core | web_interface/blueprints/api_v3/display.py:271 | review | `resolved_plugin = api_v3.plugin_catalog.find_plugin_for_mode(resolved_mode)` | +| `PluginManager.find_plugin_for_mode` | core | web_interface/blueprints/api_v3/display.py:276 | review | `resolved_plugin = api_v3.plugin_catalog.find_plugin_for_mode(resolved_mode)` | +| `PluginStateManager.is_loaded` | core | src/plugin_system/plugin_state.py:299 | internal (in `PluginStateManager.get_state_info`) | `'is_loaded': self.is_loaded(plugin_id),` | +| `PluginStateManager.is_running` | core | src/plugin_system/plugin_state.py:301 | internal (in `PluginStateManager.get_state_info`) | `'is_running': self.is_running(plugin_id),` | +| `PluginStateManager.is_error` | core | src/plugin_system/plugin_state.py:302 | internal (in `PluginStateManager.get_state_info`) | `'is_error': self.is_error(plugin_id),` | +| `PluginStateManager.get_error_info` | core | src/plugin_system/plugin_state.py:305 | internal (in `PluginStateManager.get_state_info`) | `'error_info': self.get_error_info(plugin_id),` | +| `PluginStateManager.get_error_info` | core tests | test/test_async_plugin_updates.py:236 | test call | `error = pm.state_manager.get_error_info(plugin_id)` | +| `PluginStateManager.get_error_info` | core tests | test/test_plugin_hang_containment.py:191 | test call | `error_info = pm.state_manager.get_error_info('hung')` | +| `PluginStateManager.get_error_info` | core tests | test/test_sports_sunset_matrix.py:212 | test call | `f"{manager.state_manager.get_error_info(plugin_id)}"` | +| `PluginStateManager.get_error_info` | core tests | test/test_sports_sunset_matrix.py:285 | test call | `info = manager.state_manager.get_error_info(plugin_id)` | +| `PluginStateManager.get_last_update` | core | src/plugin_system/plugin_state.py:304 | internal (in `PluginStateManager.get_state_info`) | `'last_update': self.get_last_update(plugin_id),` | +| `PluginStateManager.get_state_info` | core | src/plugin_system/plugin_manager.py:987 | internal (in `PluginManager.get_plugin_info`) | `info['state'] = self.state_manager.get_state_info(plugin_id)` | +| `PluginStateManager.get_state_info` | core tests | test/test_plugin_state_transition_count.py:39 | test review | `info = manager.get_state_info("clock")` | +| `PluginStateManager.get_state_info` | core tests | test/test_plugin_state_transition_count.py:51 | test review | `info = manager.get_state_info("clock")` | +| `PluginStateManager.get_state_info` | core tests | test/test_plugin_state_transition_count.py:62 | test review | `assert manager.get_state_info("clock")["state_history_count"] == 101` | +| `PluginStateManager.get_state_info` | core tests | test/test_plugin_state_transition_count.py:63 | test review | `assert manager.get_state_info("weather")["state_history_count"] == 1` | +| `PluginStateManager.get_state_info` | core tests | test/test_plugin_state_transition_count.py:74 | test review | `info = manager.get_state_info("clock")` | +| `PluginStateManager.get_state_info` | core tests | test/test_plugin_state_transition_count.py:95 | test review | `info = m.get_state_info("clock")` | +| `PluginStateManager.get_state_info` | core tests | test/test_plugin_state_transition_count.py:130 | test review | `info = manager.get_state_info("clock")` | ## Sources scanned | Source | Group | Python files | Hits | |---|---|---|---| -| core | core | 172 | 20 | -| core tests | core-tests | 347 | 17 | +| core | core | 188 | 50 | +| core tests | core-tests | 416 | 176 | | 7-segment-clock | monorepo | 3 | 0 | -| afl-scoreboard | monorepo | 35 | 0 | -| baseball-scoreboard | monorepo | 61 | 0 | -| basketball-scoreboard | monorepo | 49 | 0 | -| birdnet-go | monorepo | 2 | 0 | -| blackjack | monorepo | 7 | 2 | -| calendar | monorepo | 5 | 1 | +| afl-scoreboard | monorepo | 36 | 0 | +| baseball-scoreboard | monorepo | 71 | 0 | +| basketball-scoreboard | monorepo | 51 | 2 | +| birdnet-go | monorepo | 3 | 0 | +| blackjack | monorepo | 7 | 0 | +| calendar | monorepo | 5 | 0 | | christmas-countdown | monorepo | 3 | 0 | | clock-simple | monorepo | 2 | 0 | | countdown | monorepo | 5 | 0 | -| cricket-scoreboard | monorepo | 8 | 0 | +| cricket-scoreboard | monorepo | 8 | 1 | | f1-scoreboard | monorepo | 15 | 0 | | fantasy-blitz | monorepo | 13 | 0 | -| football-scoreboard | monorepo | 74 | 0 | +| football-scoreboard | monorepo | 78 | 4 | | geochron | monorepo | 10 | 0 | | hello-world | monorepo | 2 | 0 | -| hockey-scoreboard | monorepo | 52 | 0 | +| hockey-scoreboard | monorepo | 57 | 3 | | incoming-packages | monorepo | 8 | 0 | | jellyfin-now-playing | monorepo | 4 | 0 | -| lacrosse-scoreboard | monorepo | 40 | 0 | +| lacrosse-scoreboard | monorepo | 41 | 0 | | ledmatrix-elections | monorepo | 12 | 0 | | ledmatrix-flights | monorepo | 48 | 0 | -| ledmatrix-leaderboard | monorepo | 9 | 0 | -| ledmatrix-music | monorepo | 11 | 0 | +| ledmatrix-leaderboard | monorepo | 10 | 0 | +| ledmatrix-music | monorepo | 12 | 0 | | ledmatrix-stocks | monorepo | 7 | 0 | -| ledmatrix-weather | monorepo | 15 | 6 | +| ledmatrix-weather | monorepo | 15 | 0 | | march-madness | monorepo | 4 | 0 | | masters-tournament | monorepo | 10 | 0 | | mqtt-notifications | monorepo | 4 | 0 | -| news | monorepo | 6 | 0 | +| news | monorepo | 7 | 0 | | nfl-draft | monorepo | 3 | 0 | | nfl-stat-leaders | monorepo | 8 | 0 | -| nrl-scoreboard | monorepo | 30 | 0 | -| odds-ticker | monorepo | 9 | 0 | +| nrl-scoreboard | monorepo | 31 | 1 | +| odds-ticker | monorepo | 10 | 0 | | of-the-day | monorepo | 14 | 0 | -| olympics | monorepo | 16 | 1 | -| on-air | monorepo | 2 | 0 | +| olympics | monorepo | 16 | 0 | +| on-air | monorepo | 3 | 0 | | pomodoro-timer | monorepo | 3 | 0 | -| soccer-scoreboard | monorepo | 47 | 0 | -| static-image | monorepo | 3 | 0 | -| stock-news | monorepo | 3 | 0 | +| soccer-scoreboard | monorepo | 50 | 0 | +| static-image | monorepo | 4 | 0 | +| stock-news | monorepo | 4 | 0 | | text-display | monorepo | 4 | 0 | | tide-display | monorepo | 3 | 0 | -| ufc-scoreboard | monorepo | 38 | 0 | +| ufc-scoreboard | monorepo | 40 | 3 | | web-ui-info | monorepo | 2 | 0 | | youtube-stats | monorepo | 5 | 0 | | f1-live | third-party | 10 | 0 | diff --git a/docs/MULTI_ROOT_WORKSPACE_SETUP.md b/docs/MULTI_ROOT_WORKSPACE_SETUP.md index 17e0fe09..34a37295 100644 --- a/docs/MULTI_ROOT_WORKSPACE_SETUP.md +++ b/docs/MULTI_ROOT_WORKSPACE_SETUP.md @@ -44,8 +44,8 @@ and symlink the plugin directories you are working on into LEDMatrix's ### 1. The plugin monorepo Clone ledmatrix-plugins into the same parent directory as LEDMatrix (the -workspace file and `scripts/update_plugin_repos.py` look for -`../ledmatrix-plugins` relative to the LEDMatrix root): +workspace file looks for `../ledmatrix-plugins` relative to the LEDMatrix +root): ```bash cd ~/Github @@ -86,7 +86,7 @@ the plugin from there. See the ```bash cd ~/Github/LEDMatrix -python3 scripts/update_plugin_repos.py # git pull in ../ledmatrix-plugins +git -C ../ledmatrix-plugins pull # the sibling monorepo checkout # or ./scripts/dev/dev_plugin_setup.sh update # git pull in every linked checkout ``` diff --git a/mypy-clean.txt b/mypy-clean.txt index fa11e729..a5b8fae0 100644 --- a/mypy-clean.txt +++ b/mypy-clean.txt @@ -14,7 +14,6 @@ src/auto_update_setup.py src/backup_manager.py src/base_odds_manager.py src/cache/__init__.py -src/cache/cache_metrics.py src/cache/cache_strategy.py src/cache/memory_cache.py src/common/__init__.py diff --git a/requirements-test.txt b/requirements-test.txt index e1f7dfc6..cf19ded6 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -2,7 +2,6 @@ # Install alongside requirements.txt: pip install -r requirements.txt -r requirements-test.txt pytest>=9.0.3,<10.0.0 pytest-cov>=4.1.0,<8.0.0 -pytest-mock>=3.11.0,<4.0.0 freezegun>=1.2,<2 # deterministic time for golden-image tests psutil>=6.0.0,<7.0.0 # optional at runtime; installed for tests so the # /system/status endpoint's real path is exercised diff --git a/scripts/README.md b/scripts/README.md index 3f1ad3e2..1dba9b67 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -15,7 +15,7 @@ display; **diagnostic** — run by hand on a Pi when something is wrong. | [`install/`](install/README.md) | keep | The installers: one-shot, services, sudoers/WiFi permissions, cache setup, and the shared `lib_*.sh` helpers `first_time_install.sh` sources | | [`fix_perms/`](fix_perms/README.md) | keep | Permission repair scripts, plus the two root helpers the web interface runs through sudo (`safe_plugin_rm.sh`, `safe_pip_install.sh`) | | [`utils/`](utils/README.md) | keep | Scripts run by systemd units or the web interface (conditional web start, WiFi monitor, update verify, DNS fix, Pixlet config editor, cache clearing) | -| [`dev/`](dev/README.md) | dev-only | Plugin linking, emulator runner, Vegas density audit, Pillow smoke test | +| [`dev/`](dev/README.md) | dev-only | Plugin linking, Vegas density audit, Pillow smoke test | | `templates/` | dev-only | `dev_preview.html`, the page `dev_server.py` serves | ## Top-level scripts @@ -43,22 +43,18 @@ display; **diagnostic** — run by hand on a Pi when something is wrong. | `scroll_speeds.py` | keep | Shows and tries the scroll speeds your panel can display cleanly | | `sports_drift_report.py` | keep | Counts the different bodies of each method across the nine scoreboards in a `ledmatrix-plugins` checkout (report-only CI job; docs/SPORTS_UNIFICATION.md) | | `troubleshoot_captive_portal.sh` | diagnostic | Troubleshoots captive-portal WiFi setup after you can SSH back in | -| `update_plugin_repos.py` | dev-only | Pulls the latest `ledmatrix-plugins` monorepo | | `verify_installation.sh` | diagnostic | Checks that an installation completed correctly | | `verify_wifi_setup.sh` | diagnostic | Health check of the WiFi management setup | -## Candidates for removal +## Hand-run tools nothing else references Nothing in the repo (docs, CI, tests, other scripts or code) refers to these. -They are kept for now; each one needs an owner decision before it goes. +They are run by hand and were kept by owner decision (October 2026); the +old one-off schema fixers and WiFi test scripts listed here were removed. | Script | What it does | |---|---| -| `add_defaults_to_schemas.py` | One-off: adds missing `default` values to plugin config schemas | -| `analyze_plugin_schemas.py` | One-off: reports duplicate/inconsistent fields across plugin schemas | | `audit_plugins.py` | AST security audit of plugin code; says it is "designed to run in CI" but no workflow runs it | | `audit_render_path.py` | Finds blocking calls reachable from a plugin's `display()` | | `sports_scroll_check.py` | Drives a sports scoreboard scroll on the panel and reports its pacing | -| `test_captive_portal.sh` | Tests the captive portal from a device connected to the AP | -| `verify_wifi_before_testing.sh` | Pre-flight check before unplugging Ethernet to test WiFi | | `dev/test_pillow_compat.py` | Pillow API smoke test to run after upgrading Pillow | diff --git a/scripts/add_defaults_to_schemas.py b/scripts/add_defaults_to_schemas.py deleted file mode 100755 index 6433269a..00000000 --- a/scripts/add_defaults_to_schemas.py +++ /dev/null @@ -1,231 +0,0 @@ -#!/usr/bin/env python3 -""" -Script to add default values to plugin config schemas where missing. - -This ensures that configs never start with None values, improving user experience -and preventing validation errors. -""" - -import json -import sys -from pathlib import Path -from typing import Any, Dict, List - - -def get_default_for_field(prop: Dict[str, Any]) -> Any: - """ - Determine a sensible default value for a field based on its type and constraints. - - Args: - prop: Field property schema - - Returns: - Default value or None if no default should be added - """ - prop_type = prop.get('type') - - # Handle union types (array with multiple types) - if isinstance(prop_type, list): - # Use the first non-null type - prop_type = next((t for t in prop_type if t != 'null'), prop_type[0] if prop_type else 'string') - - if prop_type == 'boolean': - return False - - elif prop_type == 'number': - # For numbers, use minimum if available, or a sensible default - minimum = prop.get('minimum') - maximum = prop.get('maximum') - - if minimum is not None: - return minimum - elif maximum is not None: - # Use a reasonable fraction of max (like 30% or minimum 1) - return max(1, int(maximum * 0.3)) - else: - # No constraints, use 0 - return 0 - - elif prop_type == 'integer': - # Similar to number - minimum = prop.get('minimum') - maximum = prop.get('maximum') - - if minimum is not None: - return minimum - elif maximum is not None: - return max(1, int(maximum * 0.3)) - else: - return 0 - - elif prop_type == 'string': - # Only add default for strings if it makes sense - # Check if there's an enum - use first value - enum_values = prop.get('enum') - if enum_values: - return enum_values[0] - - # For optional string fields, empty string might be okay, but be cautious - # We'll skip adding defaults for strings unless explicitly needed - return None - - elif prop_type == 'array': - # Empty array as default - return [] - - elif prop_type == 'object': - # Empty object - but we'll handle nested objects separately - return {} - - return None - - -def should_add_default(prop: Dict[str, Any], field_path: str) -> bool: - """ - Determine if we should add a default value to this field. - - Args: - prop: Field property schema - field_path: Dot-separated path to the field - - Returns: - True if default should be added - """ - # Skip if already has a default - if 'default' in prop: - return False - - # Skip secret fields (they should be user-provided) - if prop.get('x-secret', False): - return False - - # Skip API keys and similar sensitive fields - field_name = field_path.split('.')[-1].lower() - sensitive_keywords = ['key', 'password', 'secret', 'token', 'auth', 'credential'] - if any(keyword in field_name for keyword in sensitive_keywords): - return False - - prop_type = prop.get('type') - if isinstance(prop_type, list): - prop_type = next((t for t in prop_type if t != 'null'), prop_type[0] if prop_type else None) - - # Only add defaults for certain types - if prop_type in ('boolean', 'number', 'integer', 'array'): - return True - - # For strings, only if there's an enum - if prop_type == 'string' and 'enum' in prop: - return True - - return False - - -def add_defaults_recursive(schema: Dict[str, Any], path: str = "", modified: List[str] = None) -> bool: - """ - Recursively add default values to schema fields. - - Args: - schema: Schema dictionary to modify - path: Current path in the schema (for logging) - modified: List to track which fields were modified - - Returns: - True if any modifications were made - """ - if modified is None: - modified = [] - - if not isinstance(schema, dict) or 'properties' not in schema: - return False - - changes_made = False - - for key, prop in schema['properties'].items(): - if not isinstance(prop, dict): - continue - - current_path = f"{path}.{key}" if path else key - - # Check nested objects - if prop.get('type') == 'object' and 'properties' in prop: - if add_defaults_recursive(prop, current_path, modified): - changes_made = True - - # Add default if appropriate - if should_add_default(prop, current_path): - default_value = get_default_for_field(prop) - if default_value is not None: - prop['default'] = default_value - modified.append(current_path) - changes_made = True - print(f" Added default to {current_path}: {default_value} (type: {prop.get('type')})") - - return changes_made - - -def process_schema_file(schema_path: Path) -> bool: - """ - Process a single schema file to add defaults. - - Args: - schema_path: Path to the schema file - - Returns: - True if file was modified - """ - print(f"\nProcessing: {schema_path}") - - try: - with open(schema_path, 'r', encoding='utf-8') as f: - schema = json.load(f) - except Exception as e: - print(f" Error reading schema: {e}") - return False - - modified_fields = [] - changes_made = add_defaults_recursive(schema, modified=modified_fields) - - if changes_made: - # Write back with pretty formatting - with open(schema_path, 'w', encoding='utf-8') as f: - json.dump(schema, f, indent=2, ensure_ascii=False) - f.write('\n') # Add trailing newline - - print(f" ✓ Modified {len(modified_fields)} fields") - return True - else: - print(" ✓ No changes needed") - return False - - -def main(): - """Main entry point.""" - project_root = Path(__file__).parent.parent - plugins_dir = project_root / 'plugin-repos' - - if not plugins_dir.exists(): - print(f"Error: Plugins directory not found: {plugins_dir}") - sys.exit(1) - - # Find all config_schema.json files - schema_files = list(plugins_dir.rglob('config_schema.json')) - - if not schema_files: - print("No config_schema.json files found") - sys.exit(0) - - print(f"Found {len(schema_files)} schema files") - - modified_count = 0 - for schema_file in sorted(schema_files): - if process_schema_file(schema_file): - modified_count += 1 - - print(f"\n{'='*60}") - print(f"Summary: Modified {modified_count} out of {len(schema_files)} schema files") - print(f"{'='*60}") - - -if __name__ == '__main__': - main() - diff --git a/scripts/analyze_plugin_schemas.py b/scripts/analyze_plugin_schemas.py deleted file mode 100755 index 6720dd8c..00000000 --- a/scripts/analyze_plugin_schemas.py +++ /dev/null @@ -1,279 +0,0 @@ -#!/usr/bin/env python3 -""" -Analyze all plugin config schemas to identify issues: -- Duplicate fields -- Inconsistencies -- Missing common fields -- Naming variations -- Formatting issues -""" - -import json -from pathlib import Path -from typing import Dict, List, Any -import jsonschema -from jsonschema import Draft7Validator - -# Standard common fields that should be in all plugins -STANDARD_COMMON_FIELDS = { - "enabled": { - "type": "boolean", - "default": False, - "description": "Enable or disable this plugin", - "required": True, - "order": 1 - }, - "display_duration": { - "type": "number", - "default": 15, - "minimum": 1, - "maximum": 300, - "description": "How long to display this plugin in seconds", - "order": 2 - }, - "live_priority": { - "type": "boolean", - "default": False, - "description": "Enable live priority takeover when plugin has live content", - "order": 3 - }, - "high_performance_transitions": { - "type": "boolean", - "default": False, - "description": "Use high-performance transitions (120 FPS) instead of standard (30 FPS)", - "order": 4 - }, - "update_interval": { - "type": "integer", - "default": 60, - "minimum": 1, - "description": "How often to refresh data in seconds", - "order": 5 - }, - "transition": { - "type": "object", - "order": 6 - } -} - -def find_duplicate_fields(schema: Dict[str, Any], path: str = "") -> List[str]: - """Find duplicate field definitions within a schema.""" - duplicates = [] - seen_fields = {} - - def check_properties(props: Dict[str, Any], current_path: str): - if not isinstance(props, dict): - return - - for key, value in props.items(): - full_path = f"{current_path}.{key}" if current_path else key - if key in seen_fields: - duplicates.append(f"Duplicate field '{key}' at {full_path} (also at {seen_fields[key]})") - else: - seen_fields[key] = full_path - - # Recursively check nested objects - if isinstance(value, dict): - if "properties" in value: - check_properties(value["properties"], full_path) - elif "items" in value and isinstance(value["items"], dict): - if "properties" in value["items"]: - check_properties(value["items"]["properties"], f"{full_path}[items]") - - if "properties" in schema: - check_properties(schema["properties"], "") - - return duplicates - -def validate_schema_syntax(schema_path: Path) -> tuple[bool, List[str]]: - """Validate JSON Schema syntax.""" - try: - with open(schema_path, 'r', encoding='utf-8') as f: - schema = json.load(f) - - # Validate schema structure - Draft7Validator.check_schema(schema) - return True, [] - except json.JSONDecodeError as e: - return False, [f"JSON syntax error: {str(e)}"] - except jsonschema.SchemaError as e: - return False, [f"Schema validation error: {str(e)}"] - except Exception as e: - return False, [f"Error: {str(e)}"] - -def analyze_schema(schema_path: Path) -> Dict[str, Any]: - """Analyze a single schema file.""" - plugin_id = schema_path.parent.name - analysis = { - "plugin_id": plugin_id, - "path": str(schema_path), - "valid": False, - "errors": [], - "warnings": [], - "has_title": False, - "has_description": False, - "common_fields": {}, - "missing_common_fields": [], - "naming_issues": [], - "duplicates": [], - "property_order": [], - "update_interval_variant": None - } - - try: - with open(schema_path, 'r', encoding='utf-8') as f: - schema = json.load(f) - - # Check for title and description - analysis["has_title"] = "title" in schema - analysis["has_description"] = "description" in schema - - if not analysis["has_title"]: - analysis["warnings"].append("Missing 'title' field at root level") - if not analysis["has_description"]: - analysis["warnings"].append("Missing 'description' field at root level") - - # Validate schema syntax - is_valid, errors = validate_schema_syntax(schema_path) - analysis["valid"] = is_valid - analysis["errors"].extend(errors) - - if not is_valid: - return analysis - - # Check for duplicate fields - duplicates = find_duplicate_fields(schema) - analysis["duplicates"] = duplicates - - # Check properties - if "properties" not in schema: - analysis["errors"].append("Missing 'properties' field") - return analysis - - properties = schema["properties"] - - # Check common fields - for field_name, field_spec in STANDARD_COMMON_FIELDS.items(): - if field_name in properties: - analysis["common_fields"][field_name] = properties[field_name] - else: - # Check for variants - if field_name == "update_interval": - # Check for update_interval_seconds variant - if "update_interval_seconds" in properties: - analysis["update_interval_variant"] = "update_interval_seconds" - analysis["naming_issues"].append( - "Uses 'update_interval_seconds' instead of 'update_interval'" - ) - else: - analysis["missing_common_fields"].append(field_name) - else: - analysis["missing_common_fields"].append(field_name) - - # Check property order (enabled should be first) - prop_keys = list(properties.keys()) - analysis["property_order"] = prop_keys - - if prop_keys and prop_keys[0] != "enabled": - analysis["warnings"].append( - f"'enabled' is not first property. First property is '{prop_keys[0]}'" - ) - - # Check for required fields - required = schema.get("required", []) - if "enabled" not in required: - analysis["warnings"].append("'enabled' is not in required fields") - - except Exception as e: - analysis["errors"].append(f"Failed to analyze schema: {str(e)}") - - return analysis - -def main(): - """Main analysis function.""" - project_root = Path(__file__).parent.parent - plugins_dir = project_root / "plugin-repos" - - if not plugins_dir.exists(): - print(f"Plugins directory not found: {plugins_dir}") - return - - results = [] - - # Find all config_schema.json files - schema_files = list(plugins_dir.glob("*/config_schema.json")) - - print(f"Found {len(schema_files)} plugin schemas to analyze\n") - - for schema_path in sorted(schema_files): - print(f"Analyzing {schema_path.parent.name}...") - analysis = analyze_schema(schema_path) - results.append(analysis) - - # Print summary - print("\n" + "="*80) - print("ANALYSIS SUMMARY") - print("="*80) - - for result in results: - print(f"\n{result['plugin_id']}:") - print(f" Valid: {result['valid']}") - - if result['errors']: - print(f" Errors ({len(result['errors'])}):") - for error in result['errors']: - print(f" - {error}") - - if result['warnings']: - print(f" Warnings ({len(result['warnings'])}):") - for warning in result['warnings']: - print(f" - {warning}") - - if result['duplicates']: - print(f" Duplicates ({len(result['duplicates'])}):") - for dup in result['duplicates']: - print(f" - {dup}") - - if result['missing_common_fields']: - print(f" Missing common fields: {', '.join(result['missing_common_fields'])}") - - if result['naming_issues']: - print(" Naming issues:") - for issue in result['naming_issues']: - print(f" - {issue}") - - if result['property_order'] and result['property_order'][0] != 'enabled': - print(f" Property order: First is '{result['property_order'][0]}' (should be 'enabled')") - - # Overall statistics - print("\n" + "="*80) - print("OVERALL STATISTICS") - print("="*80) - - valid_count = sum(1 for r in results if r['valid']) - has_title_count = sum(1 for r in results if r['has_title']) - has_description_count = sum(1 for r in results if r['has_description']) - enabled_first_count = sum(1 for r in results if r['property_order'] and r['property_order'][0] == 'enabled') - total_errors = sum(len(r['errors']) for r in results) - total_warnings = sum(len(r['warnings']) for r in results) - total_duplicates = sum(len(r['duplicates']) for r in results) - - print(f"Total plugins: {len(results)}") - print(f"Valid schemas: {valid_count}/{len(results)}") - print(f"Has title: {has_title_count}/{len(results)}") - print(f"Has description: {has_description_count}/{len(results)}") - print(f"'enabled' first: {enabled_first_count}/{len(results)}") - print(f"Total errors: {total_errors}") - print(f"Total warnings: {total_warnings}") - print(f"Total duplicates: {total_duplicates}") - - # Save detailed report - report_path = project_root / "plugin_schema_analysis.json" - with open(report_path, 'w', encoding='utf-8') as f: - json.dump(results, f, indent=2) - - print(f"\nDetailed report saved to: {report_path}") - -if __name__ == "__main__": - main() - diff --git a/scripts/dev/README.md b/scripts/dev/README.md index 6dfdc676..c07e1b85 100644 --- a/scripts/dev/README.md +++ b/scripts/dev/README.md @@ -5,7 +5,6 @@ This directory contains scripts and utilities for development and testing. ## Scripts - **`dev_plugin_setup.sh`** - Sets up plugin development environment by linking plugin repositories -- **`run_emulator.sh`** - Runs the LED Matrix display in emulator mode (for development without hardware) - **`vegas_audit.py`** - Measures how much of the Vegas ticker strip actually shows content (dead-frame ratio) - **`test_pillow_compat.py`** - Pillow API smoke test to run after upgrading Pillow (`python3 scripts/dev/test_pillow_compat.py`) @@ -28,6 +27,6 @@ links. To use a fork or another clone location, copy ### Running Emulator ```bash -./scripts/dev/run_emulator.sh +python3 run.py -e ``` diff --git a/scripts/dev/run_emulator.sh b/scripts/dev/run_emulator.sh deleted file mode 100755 index 66d0fda8..00000000 --- a/scripts/dev/run_emulator.sh +++ /dev/null @@ -1,13 +0,0 @@ -#!/bin/bash -# LEDMatrix Emulator Runner -# This script runs the LEDMatrix system in emulator mode for development and testing - -echo "Starting LEDMatrix Emulator..." -echo "Press Ctrl+C to stop" -echo "" - -# Set emulator mode -export EMULATOR=true - -# Run the main application -python3 run.py diff --git a/scripts/plugin_api_usage.py b/scripts/plugin_api_usage.py index 0d7fd1eb..69455249 100644 --- a/scripts/plugin_api_usage.py +++ b/scripts/plugin_api_usage.py @@ -75,6 +75,14 @@ OWNER_RECEIVERS: Dict[str, Tuple[str, ...]] = { "DisplayManager": ("display_manager", "display_mgr", "displaymanager", "display", "dm"), "FontManager": ("font_manager", "font_mgr", "fontmanager", "fonts", "fm"), "PluginManager": ("plugin_manager", "plugin_mgr", "pluginmanager", "pm"), + "PluginStateManager": ("state_manager", "plugin_state", "state_mgr"), + "ConfigManager": ("config_manager", "config_mgr", "configmanager"), + "LogoDownloader": ("logo_downloader", "downloader", "logodownloader"), + "APIHelper": ("api_helper", "apihelper", "api"), + "BackgroundDataService": ("background_service", "background_data_service", "bg_service", + "data_service"), + "BaseOddsManager": ("odds_manager", "oddsmanager", "odds"), + "DynamicTeamResolver": ("dynamic_resolver", "team_resolver", "resolver"), } #: Directories never scanned (vendored environments, VCS metadata, caches). @@ -330,8 +338,7 @@ class _Scanner(ast.NodeVisitor): return "call" definers = self.local_definers.get(m.method, ()) if name in definers or self.built.get(name or "") in definers: - # e.g. the weather plugin's WeatherIcons.draw_sun, or - # self._strategy_component = CacheStrategy(); ...get_sport_live_interval() + # e.g. the weather plugin's WeatherIcons.draw_sun return "unrelated" return "review" diff --git a/scripts/test_captive_portal.sh b/scripts/test_captive_portal.sh deleted file mode 100755 index d30a1bb6..00000000 --- a/scripts/test_captive_portal.sh +++ /dev/null @@ -1,149 +0,0 @@ -#!/bin/bash - -# Test script for captive portal functionality -# This script tests the captive portal from a device connected to the AP network - -set -e - -PI_IP="192.168.4.1" -PI_PORT="5000" -BASE_URL="http://${PI_IP}:${PI_PORT}" - -echo "==========================================" -echo "Captive Portal Functionality Test" -echo "==========================================" -echo "" -echo "Make sure you're connected to 'LEDMatrix-Setup' network" -echo "Pi IP: ${PI_IP}" -echo "Web Interface Port: ${PI_PORT}" -echo "" - -# Colors for output -GREEN='\033[0;32m' -RED='\033[0;31m' -YELLOW='\033[1;33m' -NC='\033[0m' # No Color - -# Test counter -PASSED=0 -FAILED=0 - -test_result() { - if [ $1 -eq 0 ]; then - echo -e "${GREEN}✓${NC} $2" - ((PASSED++)) - else - echo -e "${RED}✗${NC} $2" - ((FAILED++)) - fi -} - -# Test 1: Check if Pi is reachable -echo "1. Testing Pi connectivity..." -if ping -c 1 -W 2 ${PI_IP} > /dev/null 2>&1; then - test_result 0 "Pi is reachable at ${PI_IP}" -else - test_result 1 "Pi is NOT reachable at ${PI_IP}" - echo " Make sure you're connected to LEDMatrix-Setup network" - exit 1 -fi - -# Test 2: DNS Redirection -echo "" -echo "2. Testing DNS redirection..." -DNS_RESULT=$(nslookup google.com 2>/dev/null | grep -i "address" | tail -1 | awk '{print $2}') -if [ "$DNS_RESULT" = "${PI_IP}" ]; then - test_result 0 "DNS redirection works (google.com resolves to ${PI_IP})" -else - test_result 1 "DNS redirection failed (got ${DNS_RESULT}, expected ${PI_IP})" -fi - -# Test 3: HTTP Redirect -echo "" -echo "3. Testing HTTP redirect..." -HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -L --max-time 5 "${BASE_URL}/google.com" 2>/dev/null || echo "000") -if [ "$HTTP_CODE" = "200" ]; then - test_result 0 "HTTP redirect works (got 200, redirected to setup page)" -else - test_result 1 "HTTP redirect failed (got ${HTTP_CODE})" -fi - -# Test 4: Captive Portal Detection Endpoints -echo "" -echo "4. Testing captive portal detection endpoints..." - -# iOS/macOS -IOS_RESPONSE=$(curl -s --max-time 5 "${BASE_URL}/hotspot-detect.html" 2>/dev/null || echo "") -if echo "$IOS_RESPONSE" | grep -qi "success"; then - test_result 0 "iOS/macOS endpoint works" -else - test_result 1 "iOS/macOS endpoint failed" -fi - -# Android -ANDROID_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 "${BASE_URL}/generate_204" 2>/dev/null || echo "000") -if [ "$ANDROID_CODE" = "204" ]; then - test_result 0 "Android endpoint works" -else - test_result 1 "Android endpoint failed (got ${ANDROID_CODE})" -fi - -# Windows -WIN_RESPONSE=$(curl -s --max-time 5 "${BASE_URL}/connecttest.txt" 2>/dev/null || echo "") -if echo "$WIN_RESPONSE" | grep -qi "microsoft"; then - test_result 0 "Windows endpoint works" -else - test_result 1 "Windows endpoint failed" -fi - -# Firefox -FF_RESPONSE=$(curl -s --max-time 5 "${BASE_URL}/success.txt" 2>/dev/null || echo "") -if echo "$FF_RESPONSE" | grep -qi "success"; then - test_result 0 "Firefox endpoint works" -else - test_result 1 "Firefox endpoint failed" -fi - -# Test 5: API Endpoints (should NOT redirect) -echo "" -echo "5. Testing API endpoints (should work normally)..." -API_RESPONSE=$(curl -s --max-time 5 "${BASE_URL}/api/v3/wifi/status" 2>/dev/null || echo "") -if echo "$API_RESPONSE" | grep -qi "status"; then - test_result 0 "API endpoints work (not redirected)" -else - test_result 1 "API endpoints failed or were redirected" -fi - -# Test 6: Main Interface (should be accessible) -echo "" -echo "6. Testing main interface accessibility..." -MAIN_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 "${BASE_URL}/v3" 2>/dev/null || echo "000") -if [ "$MAIN_CODE" = "200" ]; then - test_result 0 "Main interface is accessible" -else - test_result 1 "Main interface failed (got ${MAIN_CODE})" -fi - -# Summary -echo "" -echo "==========================================" -echo "Test Summary" -echo "==========================================" -echo -e "${GREEN}Passed: ${PASSED}${NC}" -echo -e "${RED}Failed: ${FAILED}${NC}" -echo "" - -if [ $FAILED -eq 0 ]; then - echo -e "${GREEN}All tests passed! Captive portal is working correctly.${NC}" - exit 0 -else - echo -e "${YELLOW}Some tests failed. Check the output above for details.${NC}" - echo "" - echo "Troubleshooting tips:" - echo "1. Verify AP mode is active: sudo systemctl status hostapd" - echo "2. Check dnsmasq config: sudo cat /etc/dnsmasq.conf" - echo "3. Check web interface logs: sudo journalctl -u ledmatrix-web -n 50" - echo "4. Verify you're connected to LEDMatrix-Setup network" - exit 1 -fi - diff --git a/scripts/update_plugin_repos.py b/scripts/update_plugin_repos.py deleted file mode 100755 index 653e4e2e..00000000 --- a/scripts/update_plugin_repos.py +++ /dev/null @@ -1,43 +0,0 @@ -#!/usr/bin/env python3 -""" -Update the ledmatrix-plugins monorepo by pulling latest changes. -""" - -import subprocess -import sys -from pathlib import Path - -MONOREPO_DIR = Path(__file__).parent.parent.parent / "ledmatrix-plugins" - - -def main(): - if not MONOREPO_DIR.exists(): - print(f"Error: Monorepo not found: {MONOREPO_DIR}") - return 1 - - if not (MONOREPO_DIR / ".git").exists(): - print(f"Error: {MONOREPO_DIR} is not a git repository") - return 1 - - print(f"Updating {MONOREPO_DIR}...") - try: - result = subprocess.run( - ["git", "-C", str(MONOREPO_DIR), "pull"], - capture_output=True, - text=True, - timeout=120, - ) - except subprocess.TimeoutExpired: - print(f"Error: git pull timed out after 120 seconds for {MONOREPO_DIR}") - return 1 - - if result.returncode == 0: - print(result.stdout.strip()) - return 0 - else: - print(f"Error: {result.stderr.strip()}") - return 1 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/scripts/verify_wifi_before_testing.sh b/scripts/verify_wifi_before_testing.sh deleted file mode 100755 index e29b734d..00000000 --- a/scripts/verify_wifi_before_testing.sh +++ /dev/null @@ -1,225 +0,0 @@ -#!/bin/bash -# Pre-Testing WiFi Verification Script -# Run this BEFORE disconnecting Ethernet to ensure WiFi is ready - -# Don't use set -e as it can cause premature exits with arithmetic operations -# Instead, we'll check return codes explicitly where needed -set -u # Fail on undefined variables - -echo "==========================================" -echo "WiFi Pre-Testing Verification" -echo "==========================================" -echo "" -echo "This script verifies WiFi is enabled and working" -echo "before you disconnect Ethernet for captive portal testing." -echo "" - -# Colors -GREEN='\033[0;32m' -RED='\033[0;31m' -YELLOW='\033[1;33m' -NC='\033[0m' - -# Check counter -PASSED=0 -FAILED=0 -WARNINGS=0 - -check_result() { - local result=$1 - local message=$2 - if [ $result -eq 0 ]; then - echo -e "${GREEN}✓${NC} $message" - PASSED=$((PASSED + 1)) - else - echo -e "${RED}✗${NC} $message" - FAILED=$((FAILED + 1)) - fi -} - -warn_result() { - local message=$2 - echo -e "${YELLOW}⚠${NC} $message" - WARNINGS=$((WARNINGS + 1)) -} - -# Check 1: WiFi interface exists -echo "1. Checking WiFi interface..." -if ip link show wlan0 > /dev/null 2>&1; then - check_result 0 "WiFi interface wlan0 exists" -else - check_result 1 "WiFi interface wlan0 NOT found" - echo " → Check if WiFi adapter is connected" - echo " → Run: lsusb (for USB WiFi) or check built-in WiFi" - exit 1 -fi - -# Check 2: WiFi radio is enabled -echo "" -echo "2. Checking WiFi radio status..." -WIFI_STATUS=$(nmcli radio wifi 2>/dev/null || echo "unknown") -if echo "$WIFI_STATUS" | grep -qi "enabled"; then - check_result 0 "WiFi radio is enabled" -elif echo "$WIFI_STATUS" | grep -qi "disabled"; then - check_result 1 "WiFi radio is DISABLED" - echo " → Enabling WiFi..." - sudo nmcli radio wifi on - sleep 2 - if nmcli radio wifi | grep -qi "enabled"; then - check_result 0 "WiFi radio enabled successfully" - else - check_result 1 "Failed to enable WiFi radio" - exit 1 - fi -else - warn_result 1 "Could not determine WiFi radio status" -fi - -# Check 3: WiFi can scan for networks -echo "" -echo "3. Testing WiFi scanning capability..." -SCAN_RESULT=$(timeout 10 nmcli device wifi list 2>&1 | head -5) -if [ $? -eq 0 ] && [ -n "$SCAN_RESULT" ]; then - NETWORK_COUNT=$(echo "$SCAN_RESULT" | wc -l) - if [ "$NETWORK_COUNT" -gt 1 ]; then - check_result 0 "WiFi scanning works (found networks)" - echo " Sample networks found:" - echo "$SCAN_RESULT" | head -3 | sed 's/^/ /' - else - warn_result 1 "WiFi scanning works but no networks found" - echo " → This might be okay if you're in a remote location" - echo " → Make sure you can see networks when you need to connect" - fi -else - check_result 1 "WiFi scanning FAILED" - echo " → WiFi adapter may not be working properly" - echo " → Check: dmesg | grep -i wifi" - exit 1 -fi - -# Check 4: Current network connections -echo "" -echo "4. Checking current network status..." -ETH_STATUS=$(nmcli device status | grep "ethernet" | grep -v "unavailable" | head -1 || echo "") -WIFI_STATUS=$(nmcli device status | grep "wifi" | head -1 || echo "") - -if echo "$ETH_STATUS" | grep -q "connected"; then - ETH_NAME=$(echo "$ETH_STATUS" | awk '{print $1}') - ETH_IP=$(ip addr show $ETH_NAME 2>/dev/null | grep "inet " | awk '{print $2}' | cut -d/ -f1 | head -1) - check_result 0 "Ethernet is connected ($ETH_NAME)" - if [ -n "$ETH_IP" ]; then - echo " Ethernet IP: $ETH_IP" - fi -else - warn_result 1 "Ethernet is NOT connected" - echo " → You may already be on WiFi only" -fi - -if echo "$WIFI_STATUS" | grep -q "connected"; then - WIFI_NAME=$(echo "$WIFI_STATUS" | awk '{print $1}') - WIFI_IP=$(ip addr show $WIFI_NAME 2>/dev/null | grep "inet " | awk '{print $2}' | cut -d/ -f1 | head -1) - WIFI_SSID=$(nmcli -t -f active,ssid dev wifi | grep "^yes:" | cut -d: -f2 | head -1) - check_result 0 "WiFi is connected ($WIFI_NAME)" - if [ -n "$WIFI_SSID" ]; then - echo " Connected to: $WIFI_SSID" - fi - if [ -n "$WIFI_IP" ]; then - echo " WiFi IP: $WIFI_IP" - fi - echo "" - echo " ⚠ You are already connected via WiFi!" - echo " → You may want to disconnect WiFi first to test captive portal" - echo " → Or test from a different device" -else - if echo "$WIFI_STATUS" | grep -q "disconnected"; then - check_result 0 "WiFi is disconnected (ready for AP mode)" - else - warn_result 1 "WiFi status unclear" - fi -fi - -# Check 5: Internet connectivity test -echo "" -echo "5. Testing internet connectivity..." -if ping -c 2 -W 3 8.8.8.8 > /dev/null 2>&1; then - check_result 0 "Internet connectivity working" - echo " → You have internet access via current connection" -else - warn_result 1 "No internet connectivity detected" - echo " → This might be okay if you're testing in isolation" - echo " → But you won't be able to download packages if needed" -fi - -# Check 6: Saved WiFi connections -echo "" -echo "6. Checking saved WiFi connections..." -SAVED_CONNECTIONS=$(nmcli connection show | grep -i wifi | wc -l) -if [ "$SAVED_CONNECTIONS" -gt 0 ]; then - check_result 0 "Found $SAVED_CONNECTIONS saved WiFi connection(s)" - echo " Saved connections:" - nmcli connection show | grep -i wifi | awk '{print " - " $1}' | head -5 - echo "" - echo " → You can reconnect using: sudo nmcli connection up " -else - warn_result 1 "No saved WiFi connections found" - echo " → Make sure you know your WiFi SSID and password" - echo " → You'll need them to reconnect after testing" -fi - -# Check 7: Required services -echo "" -echo "7. Checking required services..." -if systemctl is-active --quiet hostapd 2>/dev/null; then - warn_result 1 "hostapd is already running (AP mode may be active)" -else - check_result 0 "hostapd service is stopped (normal)" -fi - -if systemctl is-active --quiet dnsmasq 2>/dev/null; then - warn_result 1 "dnsmasq is already running (AP mode may be active)" -else - check_result 0 "dnsmasq service is stopped (normal)" -fi - -# Check 8: WiFi monitor service -echo "" -echo "8. Checking WiFi monitor service..." -if systemctl is-active --quiet ledmatrix-wifi-monitor 2>/dev/null; then - check_result 0 "WiFi monitor service is running" -else - warn_result 1 "WiFi monitor service is NOT running" - echo " → Start with: sudo systemctl start ledmatrix-wifi-monitor" -fi - -# Summary -echo "" -echo "==========================================" -echo "Verification Summary" -echo "==========================================" -echo -e "${GREEN}Passed: ${PASSED}${NC}" -echo -e "${YELLOW}Warnings: ${WARNINGS}${NC}" -echo -e "${RED}Failed: ${FAILED}${NC}" -echo "" - -if [ $FAILED -eq 0 ]; then - if [ $WARNINGS -eq 0 ]; then - echo -e "${GREEN}✓ All checks passed! WiFi is ready for testing.${NC}" - echo "" - echo "Next steps:" - echo "1. You can safely disconnect Ethernet" - echo "2. Enable AP mode to test captive portal" - echo "3. Use emergency_reconnect.sh if you need to reconnect" - else - echo -e "${YELLOW}⚠ Checks passed with warnings.${NC}" - echo "" - echo "WiFi appears ready, but review warnings above." - echo "You can proceed with testing, but be aware of the warnings." - fi - exit 0 -else - echo -e "${RED}✗ Some checks failed. Please fix issues before testing.${NC}" - echo "" - echo "Do NOT disconnect Ethernet until all issues are resolved!" - exit 1 -fi - diff --git a/src/background_data_service.py b/src/background_data_service.py index be860bb9..414a0300 100644 --- a/src/background_data_service.py +++ b/src/background_data_service.py @@ -42,6 +42,7 @@ from src.common.espn_dates import ( fetch_espn_date_chunks, parse_espn_date_range, ) +from src.deprecation import deprecated # Configure logging logger = logging.getLogger(__name__) @@ -679,6 +680,7 @@ class BackgroundDataService: raise last_exception + @deprecated("3.10.0", "pass callback= to submit_fetch_request()") def get_result(self, request_id: str) -> Optional[FetchResult]: """ Get the result of a fetch request. @@ -695,6 +697,7 @@ class BackgroundDataService: with self._lock: return self.completed_requests.get(request_id) + @deprecated("3.10.0", "pass callback= to submit_fetch_request()") def is_request_complete(self, request_id: str) -> bool: """ Check if a request has completed. @@ -711,6 +714,7 @@ class BackgroundDataService: with self._lock: return request_id in self.completed_requests + @deprecated("3.10.0", "pass callback= to submit_fetch_request()") def get_request_status(self, request_id: str) -> Optional[FetchStatus]: """ Get the status of a fetch request. diff --git a/src/base_odds_manager.py b/src/base_odds_manager.py index 69185f06..845f5c5e 100644 --- a/src/base_odds_manager.py +++ b/src/base_odds_manager.py @@ -21,6 +21,7 @@ from typing import Dict, Any, Optional, List, cast from src.common.api_helper import DEFAULT_HTTP_HEADERS from src.common.fetch_service import fetch_get, share_connection_pool from src.common.json_body import response_json +from src.deprecation import deprecated @@ -277,6 +278,7 @@ class BaseOddsManager: self.logger.warning(f"Unexpected response structure: {json.dumps(data, indent=2)}") return None + @deprecated("3.10.0", "call get_odds() for each game") def get_odds_for_games(self, games: List[Dict[str, Any]]) -> List[Dict[str, Any]]: """ Fetch odds for multiple games efficiently. @@ -335,6 +337,7 @@ class BaseOddsManager: return False + @deprecated("3.10.0") def format_odds_summary(self, odds_data: Optional[Dict[str, Any]]) -> str: """ Format odds data into a human-readable summary. diff --git a/src/cache/__init__.py b/src/cache/__init__.py index a4dc26a9..a86b1986 100644 --- a/src/cache/__init__.py +++ b/src/cache/__init__.py @@ -5,6 +5,5 @@ Provides specialized cache components: - MemoryCache: In-memory caching - DiskCache: Persistent disk caching - CacheStrategy: Cache strategy management -- CacheMetrics: Performance metrics tracking """ diff --git a/src/cache/cache_metrics.py b/src/cache/cache_metrics.py deleted file mode 100644 index 6abd7084..00000000 --- a/src/cache/cache_metrics.py +++ /dev/null @@ -1,134 +0,0 @@ -""" -Cache Metrics - -Tracks cache performance metrics including hit rates, miss rates, and fetch times. -""" - -import threading -import time -import logging -from typing import Dict, Any, Optional - - -class CacheMetrics: - """Tracks cache performance metrics.""" - - def __init__(self, logger: Optional[logging.Logger] = None) -> None: - """ - Initialize cache metrics tracker. - - Args: - logger: Optional logger instance - """ - self.logger = logger or logging.getLogger(__name__) - self._lock = threading.Lock() - self._metrics: Dict[str, Any] = { - 'hits': 0, - 'misses': 0, - 'api_calls_saved': 0, - 'background_hits': 0, - 'background_misses': 0, - 'total_fetch_time': 0.0, - 'fetch_count': 0, - # Disk cleanup metrics - 'last_disk_cleanup': 0.0, - 'total_files_cleaned': 0, - 'total_space_freed_mb': 0.0, - 'last_cleanup_duration_sec': 0.0 - } - - def record_hit(self, cache_type: str = 'regular') -> None: - """ - Record a cache hit. - - Args: - cache_type: Type of cache hit ('regular' or 'background') - """ - with self._lock: - if cache_type == 'background': - self._metrics['background_hits'] += 1 - else: - self._metrics['hits'] += 1 - - def record_miss(self, cache_type: str = 'regular') -> None: - """ - Record a cache miss. - - Args: - cache_type: Type of cache miss ('regular' or 'background') - """ - with self._lock: - if cache_type == 'background': - self._metrics['background_misses'] += 1 - else: - self._metrics['misses'] += 1 - self._metrics['api_calls_saved'] += 1 - - def record_fetch_time(self, duration: float) -> None: - """ - Record fetch operation duration. - - Args: - duration: Duration in seconds - """ - with self._lock: - self._metrics['total_fetch_time'] += duration - self._metrics['fetch_count'] += 1 - - def record_disk_cleanup(self, files_cleaned: int, space_freed_mb: float, duration_sec: float) -> None: - """ - Record disk cleanup operation results. - - Args: - files_cleaned: Number of files deleted - space_freed_mb: Space freed in megabytes - duration_sec: Duration of cleanup operation in seconds - """ - with self._lock: - self._metrics['last_disk_cleanup'] = time.time() - self._metrics['total_files_cleaned'] += files_cleaned - self._metrics['total_space_freed_mb'] += space_freed_mb - self._metrics['last_cleanup_duration_sec'] = duration_sec - - def get_metrics(self) -> Dict[str, Any]: - """ - Get current cache performance metrics. - - Returns: - Dictionary with cache metrics - """ - with self._lock: - total_hits = self._metrics['hits'] + self._metrics['background_hits'] - total_misses = self._metrics['misses'] + self._metrics['background_misses'] - total_requests = total_hits + total_misses - - avg_fetch_time = (self._metrics['total_fetch_time'] / - self._metrics['fetch_count']) if self._metrics['fetch_count'] > 0 else 0.0 - - return { - 'total_requests': total_requests, - 'cache_hit_rate': total_hits / total_requests if total_requests > 0 else 0.0, - 'background_hit_rate': (self._metrics['background_hits'] / - (self._metrics['background_hits'] + self._metrics['background_misses']) - if (self._metrics['background_hits'] + self._metrics['background_misses']) > 0 else 0.0), - 'api_calls_saved': self._metrics['api_calls_saved'], - 'average_fetch_time': avg_fetch_time, - 'total_fetch_time': self._metrics['total_fetch_time'], - 'fetch_count': self._metrics['fetch_count'], - # Disk cleanup metrics - 'last_disk_cleanup': self._metrics['last_disk_cleanup'], - 'total_files_cleaned': self._metrics['total_files_cleaned'], - 'total_space_freed_mb': self._metrics['total_space_freed_mb'], - 'last_cleanup_duration_sec': self._metrics['last_cleanup_duration_sec'] - } - - def log_metrics(self) -> None: - """Log current cache performance metrics.""" - metrics = self.get_metrics() - self.logger.info("Cache Performance - Hit Rate: %.2f%%, Background Hit Rate: %.2f%%, " - "API Calls Saved: %d, Avg Fetch Time: %.2fs", - metrics['cache_hit_rate'] * 100, - metrics['background_hit_rate'] * 100, - metrics['api_calls_saved'], - metrics['average_fetch_time']) - diff --git a/src/cache/cache_strategy.py b/src/cache/cache_strategy.py index 66962495..6e0556aa 100644 --- a/src/cache/cache_strategy.py +++ b/src/cache/cache_strategy.py @@ -4,7 +4,6 @@ Cache Strategy Manages cache strategies (TTLs) for different data types. """ -import logging from typing import Dict, Any, Optional from datetime import datetime import pytz @@ -13,51 +12,25 @@ import pytz class CacheStrategy: """Manages cache strategies for different data types.""" - def __init__(self, config_manager: Optional[Any] = None, logger: Optional[logging.Logger] = None) -> None: - """ - Initialize cache strategy manager. - - Args: - config_manager: Optional ConfigManager instance. Kept for callers - that pass one; no strategy currently reads it. - logger: Optional logger instance - """ - self.config_manager = config_manager - self.logger = logger or logging.getLogger(__name__) - - def get_sport_live_interval(self, sport_key: str) -> int: - """ - Live-data cache interval, in seconds, for a sport: 60 for every sport. - - This used to read ``live_update_interval`` from a ``_scoreboard`` - config section. Those sections belonged to the built-in scoreboards - that the plugin system replaced; plugin config is keyed by plugin id - (``football-scoreboard``), so the lookup always fell back to 60. - - Args: - sport_key: Sport identifier (e.g., 'nba', 'nfl') - - Returns: - Live update interval in seconds - """ - return 60 - def get_cache_strategy(self, data_type: str, sport_key: Optional[str] = None) -> Dict[str, Any]: """ Get cache strategy for different data types. Args: data_type: Type of data (e.g., 'live_scores', 'stocks', 'weather_current') - sport_key: Optional sport key; for live data it selects the - per-sport interval from :meth:`get_sport_live_interval` - instead of the generic live default. + sport_key: Optional sport key; for live data any sport key + selects a 60s interval instead of the generic live default. + (That used to be a per-sport ``live_update_interval`` from + ``_scoreboard`` config sections, which belonged to the + built-in scoreboards the plugin system replaced, so every + lookup fell back to 60.) Returns: Dictionary with cache strategy (max_age, memory_ttl, etc.) """ live_interval = None if sport_key and data_type in ['sports_live', 'live_scores']: - live_interval = self.get_sport_live_interval(sport_key) + live_interval = 60 strategies = { # Ultra time-sensitive data (live scores, current weather) diff --git a/src/cache/disk_cache.py b/src/cache/disk_cache.py index e66f4b10..eb4bc824 100644 --- a/src/cache/disk_cache.py +++ b/src/cache/disk_cache.py @@ -15,11 +15,14 @@ import tempfile import logging import threading import zlib -from typing import Dict, Any, Optional, Protocol, Tuple +from typing import TYPE_CHECKING, Dict, Any, Optional, Tuple from datetime import datetime from src.common.path_safety import safe_path_component +if TYPE_CHECKING: + from src.cache.cache_strategy import CacheStrategy + try: # optional: large speedup on the cache write path, see _dumps below import orjson except ImportError: # pragma: no cover - exercised on hosts without the wheel @@ -62,23 +65,6 @@ def _filename_stem(key: str) -> str: return f"{prefix}-{digest}" - -class CacheStrategyProtocol(Protocol): - """Protocol for cache strategy objects that categorize cache keys.""" - - def get_data_type_from_key(self, key: str) -> str: - """ - Determine the data type from a cache key. - - Args: - key: Cache key - - Returns: - Data type string for strategy lookup - """ - ... - - class DateTimeEncoder(json.JSONEncoder): """JSON encoder that handles datetime objects. @@ -816,12 +802,12 @@ class DiskCache: # mkstemp's random component. return bool(sep) and len(head) > 1 and bool(suffix) - def cleanup_expired_files(self, cache_strategy: CacheStrategyProtocol, retention_policies: Dict[str, int]) -> Dict[str, Any]: + def cleanup_expired_files(self, cache_strategy: 'CacheStrategy', retention_policies: Dict[str, int]) -> Dict[str, Any]: """ Clean up expired cache files based on retention policies. Args: - cache_strategy: Object implementing CacheStrategyProtocol for categorizing files + cache_strategy: Categorizes files by key (get_data_type_from_key) retention_policies: Dict mapping data types to retention days Returns: diff --git a/src/cache_manager.py b/src/cache_manager.py index b922c3e9..318c9a8b 100644 --- a/src/cache_manager.py +++ b/src/cache_manager.py @@ -35,13 +35,13 @@ import tempfile from src.cache.memory_cache import MemoryCache, default_max_size from src.cache.disk_cache import DiskCache from src.cache.cache_strategy import CacheStrategy -from src.cache.cache_metrics import CacheMetrics from src.logging_config import get_logger # Canonical implementation lives in src.cache.disk_cache; re-exported here # because this module's docstring documents it and external code may import # it from either path. from src.cache.disk_cache import DateTimeEncoder # noqa: F401 - deliberate re-export +from src.deprecation import deprecated # CacheManager.config_manager not built yet (None means "not available"). _UNSET: Any = object() @@ -149,10 +149,7 @@ class CacheManager: max_size=default_max_size(), cleanup_interval=300.0 ) self._disk_cache_component = DiskCache(cache_dir=self.cache_dir, logger=self.logger) - # No config manager: CacheStrategy keeps the parameter for callers but - # reads nothing from it, and passing ours would build it eagerly. - self._strategy_component = CacheStrategy(logger=self.logger) - self._metrics_component = CacheMetrics(logger=self.logger) + self._strategy_component = CacheStrategy() # Disk cleanup configuration self._disk_cleanup_interval_hours = 24 # Run cleanup every 24 hours @@ -398,6 +395,7 @@ class CacheManager: # caller gets as is. self._disk_cache_component.set(key, data) + @deprecated("3.10.0", "use get(key, max_age=3600)") def load_cache(self, key: str) -> Optional[Dict[str, Any]]: """Load data from cache with memory caching.""" # Check memory cache first (1 minute TTL) @@ -607,13 +605,6 @@ class CacheManager: duration = time.time() - start_time space_freed_mb = stats['space_freed_bytes'] / (1024 * 1024) - # Record metrics - self._metrics_component.record_disk_cleanup( - files_cleaned=stats['files_deleted'], - space_freed_mb=space_freed_mb, - duration_sec=duration - ) - # Log summary if stats['files_deleted'] > 0: self.logger.info( @@ -796,6 +787,7 @@ class CacheManager: data_type = self.get_data_type_from_key(key) return self.get_cached_data_with_strategy(key, data_type) + @deprecated("3.10.0") def generate_sport_cache_key(self, sport: str, date_str: Optional[str] = None) -> str: """ Centralized cache key generation for sports data. diff --git a/src/common/api_helper.py b/src/common/api_helper.py index ddb26c26..917b43ad 100644 --- a/src/common/api_helper.py +++ b/src/common/api_helper.py @@ -22,6 +22,7 @@ from typing import TYPE_CHECKING, Any, Dict, Mapping, Optional, cast import requests from urllib3.util.retry import Retry +from src.deprecation import deprecated if TYPE_CHECKING: # What Session() puts in .headers; the stubs only promise a MutableMapping. @@ -171,6 +172,7 @@ class APIHelper: self.logger.error(f"Request failed for {url}: {e}") return None + @deprecated("3.10.0", "use src.common.espn_dates.fetch_espn_scoreboard()") def fetch_espn_scoreboard(self, sport: str, league: str, date: Optional[str] = None, cache_key: Optional[str] = None, @@ -227,6 +229,7 @@ class APIHelper: store_espn_scoreboard_cache(self.cache_manager, shared_key, data) return data + @deprecated("3.10.0", "call get() with the ESPN URL") def fetch_espn_standings(self, sport: str, league: str, cache_key: Optional[str] = None, cache_ttl: int = 3600) -> Optional[Dict]: @@ -249,6 +252,7 @@ class APIHelper: return self.get(url, cache_key=cache_key, cache_ttl=cache_ttl) + @deprecated("3.10.0", "call get() with the ESPN URL") def fetch_espn_rankings(self, sport: str, league: str, cache_key: Optional[str] = None, cache_ttl: int = 3600) -> Optional[Dict]: @@ -311,6 +315,7 @@ class APIHelper: self.logger.error(f"POST request failed for {url}: {e}") return None + @deprecated("3.10.0", "use the plugin's cache_manager") def set_cache(self, key: str, data: Any, ttl: int = 3600) -> None: """ Set cache data. @@ -323,6 +328,7 @@ class APIHelper: """ self._set_cache(key, data, ttl) + @deprecated("3.10.0", "use the plugin's cache_manager") def get_cache(self, key: str) -> Optional[Any]: """ Get cached data. @@ -392,6 +398,7 @@ class APIHelper: self._last_request_monotonic = time.monotonic() self._last_request_time = time.time() + @deprecated("3.10.0") def set_rate_limit(self, min_interval: float) -> None: """ Set minimum interval between requests. @@ -402,6 +409,7 @@ class APIHelper: self._min_request_interval = min_interval self.logger.debug(f"Rate limit set to {min_interval} seconds") + @deprecated("3.10.0") def get_request_stats(self) -> Dict[str, Any]: """ Get request statistics. diff --git a/src/common/sync_manager.py b/src/common/sync_manager.py index ce43de34..876c6a1e 100644 --- a/src/common/sync_manager.py +++ b/src/common/sync_manager.py @@ -141,7 +141,6 @@ class DisplaySyncManager: self._last_leader_frame_time: float = 0.0 self._frame_lock = threading.Lock() self._leader_ip: Optional[str] = None - self._on_new_cycle: Optional[Callable[[], None]] = None # called when leader starts new cycle self._on_scroll_image: Optional[Callable[[Image.Image], None]] = None # called with Image when received self._pending_scroll_image: Optional[Image.Image] = None # image received before callback set self._scroll_image_lock = threading.Lock() # guards _on_scroll_image / _pending_scroll_image @@ -412,17 +411,6 @@ class DisplaySyncManager: except Exception as exc: self.logger.debug("Sync: scroll_x send error: %s", exc) - def send_new_cycle(self) -> None: - """Leader: signal that a new scroll cycle has started so follower rebuilds its image.""" - if self.role != SyncRole.LEADER: - return - if self._leader_state != LeaderState.CONNECTED or not self._peer_ip: - return - try: - self._send_sock.sendto(b'{"t":"nc"}', (self._peer_ip, self.port)) - except Exception as exc: - self.logger.debug("Sync: new_cycle send error: %s", exc) - def send_frame(self, image: Image.Image) -> None: """Leader: send a rendered frame to the follower as raw RGB bytes. Raw format is orders of magnitude faster than PNG on Pi hardware — @@ -506,21 +494,19 @@ class DisplaySyncManager: self._latest_frame = img self._enter_follower_mode(sender_ip) - def _enter_follower_mode(self, sender_ip: str) -> bool: + def _enter_follower_mode(self, sender_ip: str) -> None: """Note that the leader at ``sender_ip`` just sent something, and - switch from standalone to follower mode if not already following. - Returns True if this call made the switch.""" + switch from standalone to follower mode if not already following.""" self._last_leader_frame_time = time.monotonic() self._leader_ip = sender_ip if self._follower_state != FollowerState.STANDALONE: - return False + return self._follower_state = FollowerState.FOLLOWER self.logger.info( "Sync: leader active at %s — switching to follower mode", sender_ip, ) self.write_status_file() - return True def _follower_recv_loop(self) -> None: while self._running: @@ -570,12 +556,9 @@ class DisplaySyncManager: # frame. Read and validate its fields under a guard — # a UDP payload is attacker-shaped, so a non-object # body makes .get() raise AttributeError and an "sx" - # carrying a non-numeric x raises ValueError/TypeError - # — but dispatch the callback *outside* it. Running - # the callback in here would let a fault in someone - # else's code read as a malformed packet and be - # logged as one. - fire_new_cycle = False + # carrying a non-numeric x raises ValueError/TypeError. + # Any other "t" is ignored, including the "nc" (new + # cycle) that older leaders send and no follower used. try: t = msg.get("t") if t == "hello_ack": @@ -601,18 +584,11 @@ class DisplaySyncManager: # back from. Treat it as malformed. raise ValueError(f"non-finite scroll x: {msg['x']!r}") self._latest_scroll_x = scroll_x - if self._enter_follower_mode(sender_ip): - fire_new_cycle = True # build initial scroll image - elif t == "nc": - # Leader started a new scroll cycle — rebuild local image - fire_new_cycle = True + self._enter_follower_mode(sender_ip) except (KeyError, AttributeError, TypeError, ValueError) as exc: self.logger.debug("Sync: malformed control message: %s", exc) continue - if fire_new_cycle and self._on_new_cycle: - self._on_new_cycle() - except socket.timeout: continue except Exception as exc: @@ -679,15 +655,6 @@ class DisplaySyncManager: """Follower: return the most recently received Vegas scroll position, or None.""" return self._latest_scroll_x - def set_on_new_cycle(self, callback: Callable[[], None]) -> None: - """Follower: register a callback fired when the leader starts a new scroll cycle. - - Nothing in core registers one: display_controller follows the leader - through set_on_scroll_image() and the scroll position instead of - rebuilding locally. The hook stays for callers that want the signal. - """ - self._on_new_cycle = callback - def get_latest_frame(self) -> Optional[Image.Image]: """Follower: return the most recently received pixel frame (non-Vegas fallback).""" with self._frame_lock: diff --git a/src/config_manager.py b/src/config_manager.py index 25911d2a..f3da2761 100644 --- a/src/config_manager.py +++ b/src/config_manager.py @@ -45,6 +45,7 @@ from src.common.permission_utils import ( ensure_shared_group_ownership, get_config_dir_mode ) +from src.deprecation import deprecated def _private_copy(config: Dict[str, Any]) -> Dict[str, Any]: @@ -174,6 +175,7 @@ class ConfigManager: return result + @deprecated("3.10.0", "backups are handled by src.backup_manager") def rollback_config(self, backup_version: Optional[str] = None) -> bool: """ Rollback configuration to a previous backup. @@ -198,6 +200,7 @@ class ConfigManager: return success + @deprecated("3.10.0", "backups are handled by src.backup_manager") def list_backups(self) -> List[BackupInfo]: """ List all available configuration backups. @@ -208,6 +211,7 @@ class ConfigManager: atomic_mgr = self._get_atomic_manager() return atomic_mgr.list_backups() + @deprecated("3.10.0") def validate_config_file(self, config_path: Optional[str] = None) -> ValidationResult: """ Validate a configuration file. @@ -404,6 +408,7 @@ class ConfigManager: self.logger.error(error_msg, exc_info=True) raise ConfigError(error_msg, config_path=self.config_path) from e + @deprecated("3.10.0", "secrets are merged into each plugin's config; read them with config.get()") def get_secret(self, key: str) -> Optional[Any]: """Get a secret value by key.""" try: @@ -757,6 +762,7 @@ class ConfigManager: self.logger.error(error_msg, exc_info=True) raise ConfigError(error_msg, config_path=self.config_path, field=plugin_id) from e + @deprecated("3.10.0") def cleanup_orphaned_plugin_configs(self, valid_plugin_ids: List[str]) -> List[str]: """ Remove configuration sections for plugins that are no longer installed. @@ -810,6 +816,7 @@ class ConfigManager: self.logger.error(f"Error cleaning up orphaned plugin configs: {e}") return removed + @deprecated("3.10.0") def validate_all_plugin_configs(self, plugin_schema_manager=None) -> Dict[str, Dict[str, Any]]: """ Validate all plugin configurations against their schemas. diff --git a/src/dynamic_team_resolver.py b/src/dynamic_team_resolver.py index c90c9a4f..ed78ad1c 100644 --- a/src/dynamic_team_resolver.py +++ b/src/dynamic_team_resolver.py @@ -24,6 +24,7 @@ from typing import Any, Dict, List from src.common.api_helper import DEFAULT_HTTP_HEADERS from src.common.json_body import response_json +from src.deprecation import deprecated logger = logging.getLogger(__name__) @@ -201,6 +202,7 @@ class DynamicTeamResolver: DynamicTeamResolver._failure_timestamp = current_time return {} + @deprecated("3.10.0", "use resolve_teams()") def get_available_dynamic_teams(self) -> List[str]: """ Get list of available dynamic team names. @@ -210,6 +212,7 @@ class DynamicTeamResolver: """ return list(self.DYNAMIC_PATTERNS.keys()) + @deprecated("3.10.0", "use resolve_teams()") def is_dynamic_team(self, team_name: str) -> bool: """ Check if a team name is a dynamic team. diff --git a/src/error_aggregator.py b/src/error_aggregator.py index 4d100de9..816aadbb 100644 --- a/src/error_aggregator.py +++ b/src/error_aggregator.py @@ -123,8 +123,7 @@ class ErrorAggregator: self._error_counts: Dict[str, int] = defaultdict(int) self._plugin_error_counts: Dict[str, Dict[str, int]] = defaultdict(lambda: defaultdict(int)) self._patterns: Dict[str, ErrorPattern] = {} - self._pattern_callbacks: List[Callable[[ErrorPattern], None]] = [] - self._lock = threading.RLock() # RLock: build_snapshot and pattern callbacks re-enter + self._lock = threading.RLock() # RLock: build_snapshot re-enters # Track session start for relative timing self._session_start = datetime.now() @@ -238,13 +237,6 @@ class ErrorAggregator: f"{count} times in last {self.pattern_window}. " f"Affected plugins: {set(affected_plugins) or 'unknown'}" ) - - # Notify callbacks - for callback in self._pattern_callbacks: - try: - callback(pattern) - except Exception as e: - self.logger.error(f"Pattern callback failed: {e}") else: # Update existing pattern self._patterns[pattern_key].count = count @@ -253,15 +245,6 @@ class ErrorAggregator: known = self._patterns[pattern_key].affected_plugins known.extend(p for p in affected_plugins if p not in known) - def on_pattern_detected(self, callback: Callable[[ErrorPattern], None]) -> None: - """ - Register a callback to be called when a new error pattern is detected. - - Args: - callback: Function that takes an ErrorPattern as argument - """ - self._pattern_callbacks.append(callback) - def get_error_summary(self) -> Dict[str, Any]: """ Get summary of all errors for reporting. @@ -325,27 +308,6 @@ class ErrorAggregator: "last_error": recent_plugin_errors[-1].to_dict() if recent_plugin_errors else None } - def clear_old_records(self, max_age_hours: int = 24) -> int: - """ - Clear records older than specified age. - - Args: - max_age_hours: Maximum age in hours - - Returns: - Number of records cleared - """ - with self._lock: - cutoff = datetime.now() - timedelta(hours=max_age_hours) - original_count = len(self._records) - self._records = [r for r in self._records if r.timestamp > cutoff] - cleared = original_count - len(self._records) - - if cleared > 0: - self.logger.info(f"Cleared {cleared} old error records") - - return cleared - @property def version(self) -> int: """Changes whenever the recorded errors do (see ErrorSnapshotPublisher).""" @@ -354,7 +316,7 @@ class ErrorAggregator: def clear_before(self, cutoff: datetime) -> int: """Forget every error recorded at or before ``cutoff``. - Unlike clear_old_records, this also resets what the summary reports: + This also resets what the summary reports: the per-type and per-plugin counts are rebuilt from the records that remain, and detected patterns that began before the cutoff are dropped (one that is still happening is detected again on its next diff --git a/src/font_manager.py b/src/font_manager.py index 17990983..1b3ac19f 100644 --- a/src/font_manager.py +++ b/src/font_manager.py @@ -41,6 +41,7 @@ from PIL import ImageFont from src.common.bdf_font import load_bdf_face, read_bdf_native_size from src.common.font_layout import load_truetype, resolve_asset_path from typing import Dict, Tuple, Optional, Union, Any +from src.deprecation import deprecated logger = logging.getLogger(__name__) @@ -533,6 +534,7 @@ class FontManager: """ return load_bdf_face(font_path, size_px)[0] + @deprecated("3.10.0", "use src.common.bdf_font.read_bdf_native_size()") def get_native_bdf_size(self, family: str) -> Optional[int]: """The one true pixel size of a BDF family in the catalog, or None for scalable (TTF) families / unknown families.""" @@ -553,6 +555,7 @@ class FontManager: # ==================== Font Measurement ==================== + @deprecated("3.10.0", "use src.adaptive_layout.measure_ink()") def measure_text(self, text: str, font: Union[ImageFont.FreeTypeFont, freetype.Face]) -> Tuple[int, int, int]: """ Measure text dimensions and baseline. diff --git a/src/ipc/client.py b/src/ipc/client.py index 17ab5a19..9a3631e6 100644 --- a/src/ipc/client.py +++ b/src/ipc/client.py @@ -288,11 +288,6 @@ def errors_clear(request_id: str, cutoff: float, *, timeout=timeout, paths=paths) -def ping(*, timeout: float = DEFAULT_TIMEOUT_SECONDS, - paths: Optional[Sequence[str]] = None) -> Dict[str, Any]: - return request(Command.PING, {}, timeout=timeout, paths=paths) - - def hello(client: str = 'web', *, timeout: float = DEFAULT_TIMEOUT_SECONDS, paths: Optional[Sequence[str]] = None) -> Dict[str, Any]: """Version negotiation: the result's ``version`` is the one both sides speak.""" diff --git a/src/ipc/contract.py b/src/ipc/contract.py index 004dd8bb..20ba869c 100644 --- a/src/ipc/contract.py +++ b/src/ipc/contract.py @@ -399,9 +399,6 @@ class HelloArgs: versions: Tuple[int, ...] = (PROTOCOL_VERSION,) client: str = '' - def to_dict(self) -> Dict[str, Any]: - return {'versions': list(self.versions), 'client': self.client} - @classmethod def from_dict(cls, args: Mapping[str, Any]) -> 'HelloArgs': versions = args.get('versions', [PROTOCOL_VERSION]) @@ -426,10 +423,6 @@ class OnDemandStartArgs: duration: Optional[float] = None pinned: bool = False - def to_dict(self) -> Dict[str, Any]: - return {'plugin_id': self.plugin_id, 'mode': self.mode, - 'duration': self.duration, 'pinned': self.pinned} - @classmethod def from_dict(cls, args: Mapping[str, Any]) -> 'OnDemandStartArgs': plugin_id = _optional_name(args, 'plugin_id') @@ -449,9 +442,6 @@ class OnDemandStartArgs: class OnDemandStopArgs: """``on_demand.stop``: end the on-demand session and resume rotation.""" - def to_dict(self) -> Dict[str, Any]: - return {} - @classmethod def from_dict(cls, args: Mapping[str, Any]) -> 'OnDemandStopArgs': return cls() @@ -461,9 +451,6 @@ class OnDemandStopArgs: class NoArgs: """``ping`` and ``on_demand.status`` take no arguments (extra ones are ignored).""" - def to_dict(self) -> Dict[str, Any]: - return {} - @classmethod def from_dict(cls, args: Mapping[str, Any]) -> 'NoArgs': return cls() @@ -480,9 +467,6 @@ class BrightnessSetArgs: """ brightness: int - def to_dict(self) -> Dict[str, Any]: - return {'brightness': self.brightness} - @classmethod def from_dict(cls, args: Mapping[str, Any]) -> 'BrightnessSetArgs': value = args.get('brightness') @@ -503,9 +487,6 @@ class PluginReloadArgs: """ plugin_id: str - def to_dict(self) -> Dict[str, Any]: - return {'plugin_id': self.plugin_id} - @classmethod def from_dict(cls, args: Mapping[str, Any]) -> 'PluginReloadArgs': plugin_id = _optional_name(args, 'plugin_id') @@ -546,9 +527,6 @@ class StateGetArgs: since: Optional[int] = None epoch: Optional[str] = None - def to_dict(self) -> Dict[str, Any]: - return {'since': self.since, 'epoch': self.epoch} - @classmethod def from_dict(cls, args: Mapping[str, Any]) -> 'StateGetArgs': return cls(since=_optional_version(args, 'since'), epoch=_optional_epoch(args)) @@ -565,9 +543,6 @@ class StateSubscribeArgs: and a ``tick`` at least every :data:`SUBSCRIBE_KEEPALIVE_SECONDS`. """ - def to_dict(self) -> Dict[str, Any]: - return {} - @classmethod def from_dict(cls, args: Mapping[str, Any]) -> 'StateSubscribeArgs': return cls() @@ -582,9 +557,6 @@ class ErrorsClearArgs: """ cutoff: float - def to_dict(self) -> Dict[str, Any]: - return {'cutoff': self.cutoff} - @classmethod def from_dict(cls, args: Mapping[str, Any]) -> 'ErrorsClearArgs': value = args.get('cutoff') diff --git a/src/logo_downloader.py b/src/logo_downloader.py index 22ab83ab..5b372d08 100644 --- a/src/logo_downloader.py +++ b/src/logo_downloader.py @@ -29,6 +29,7 @@ from src.common.permission_utils import ( get_assets_dir_mode, get_assets_file_mode ) +from src.deprecation import deprecated logger = logging.getLogger(__name__) @@ -471,6 +472,7 @@ class LogoDownloader: logger.info(f"Using dynamic ESPN endpoint for custom soccer league: {league}") return api_url + @deprecated("3.10.0", "download logos one at a time with download_missing_logo()") def fetch_teams_data(self, league: str) -> Optional[Dict]: """Fetch team data from ESPN API for a specific league.""" api_url = self._resolve_api_url(league) @@ -518,6 +520,7 @@ class LogoDownloader: logger.error(f"Error parsing JSON response for {team_id} in {league}: {e}") return None + @deprecated("3.10.0", "download logos one at a time with download_missing_logo()") def extract_teams_from_data(self, data: Dict, league: str) -> List[Dict[str, str]]: """Extract team information from ESPN API response.""" teams = [] @@ -621,6 +624,7 @@ class LogoDownloader: # Default to FBS for unknown conferences return 'FBS' + @deprecated("3.10.0", "download logos one at a time with download_missing_logo()") def download_missing_logos_for_league(self, league: str, force_download: bool = False) -> Tuple[int, int]: """Download missing logos for a specific league.""" logger.info(f"Starting logo download for league: {league}") @@ -675,6 +679,7 @@ class LogoDownloader: logger.info(f"Logo download complete for {league}: {downloaded_count} downloaded, {failed_count} failed") return downloaded_count, failed_count + @deprecated("3.10.0", "download logos one at a time with download_missing_logo()") def download_all_ncaa_football_logos(self, include_fcs: bool = True, force_download: bool = False) -> Tuple[int, int]: """Download all NCAA football team logos including FCS teams.""" logger.info(f"Starting comprehensive NCAA football logo download (FCS: {include_fcs})") @@ -763,6 +768,7 @@ class LogoDownloader: time.sleep(0.1) # Small delay return success + @deprecated("3.10.0", "download logos one at a time with download_missing_logo()") def download_all_missing_logos(self, leagues: List[str] | None = None, force_download: bool = False) -> Dict[str, Tuple[int, int]]: """Download missing logos for all specified leagues.""" if leagues is None: @@ -858,6 +864,7 @@ class LogoDownloader: logger.error(f"Failed to create placeholder logo for {team_abbreviation}: {e}") return False + @deprecated("3.10.0") def convert_image_to_rgba(self, filepath: Path) -> bool: """Convert an image file to RGBA format to avoid PIL warnings.""" try: @@ -875,6 +882,7 @@ class LogoDownloader: logger.error(f"Failed to convert {filepath.name} to RGBA: {e}") return False + @deprecated("3.10.0") def convert_all_logos_to_rgba(self, league: str) -> Tuple[int, int]: """Convert all logos in a league directory to RGBA format.""" logo_dir = Path(self.get_logo_directory(league)) diff --git a/src/plugin_system/operation_history.py b/src/plugin_system/operation_history.py index f20fd2a4..35790895 100644 --- a/src/plugin_system/operation_history.py +++ b/src/plugin_system/operation_history.py @@ -51,44 +51,36 @@ class OperationHistory: def __init__( self, history_file: Optional[str] = None, - max_records: int = 1000, - lazy_load: bool = False + max_records: int = 1000 ): """ - Initialize operation history. - + Initialize operation history. The history file is read on first use, + not here, so constructing this costs the web app's startup nothing. + Args: history_file: Path to file for persisting history max_records: Maximum number of records to keep - lazy_load: If True, defer loading history file until first access """ self.logger = get_logger(__name__) self.history_file = Path(history_file) if history_file else None self.max_records = max_records - self._lazy_load = lazy_load self._history_loaded = False - + # In-memory history self._history: List[OperationRecord] = [] self._lock = threading.RLock() - - # Load history from file if it exists (unless lazy loading) - if not self._lazy_load and self.history_file and self.history_file.exists(): - self._load_history() - self._history_loaded = True - + def _ensure_loaded(self) -> None: - """Ensure history is loaded (for lazy loading).""" + """Load the history file on first use.""" if not self._history_loaded and self.history_file and self.history_file.exists(): self._load_history() self._history_loaded = True - + def record_operation( self, operation_type: str, plugin_id: Optional[str] = None, status: str = "completed", - user: Optional[str] = None, details: Optional[Dict[str, Any]] = None, error: Optional[str] = None, operation_id: Optional[str] = None @@ -100,7 +92,6 @@ class OperationHistory: operation_type: Type of operation (install, update, uninstall, etc.) plugin_id: Plugin identifier status: Operation status - user: User who performed operation details: Optional operation details error: Optional error message operation_id: Optional operation ID @@ -118,7 +109,6 @@ class OperationHistory: plugin_id=plugin_id, timestamp=datetime.now(), status=status, - user=user, details=details, error=error ) diff --git a/src/plugin_system/operation_queue.py b/src/plugin_system/operation_queue.py index 7bd24b78..137dcd87 100644 --- a/src/plugin_system/operation_queue.py +++ b/src/plugin_system/operation_queue.py @@ -2,7 +2,7 @@ Plugin operation queue manager. Serializes plugin operations to prevent conflicts and provides -status tracking and cancellation support. +status tracking. """ import threading @@ -25,8 +25,8 @@ class PluginOperationQueue: - Serialized execution (one operation at a time) - Prevents concurrent operations on same plugin - Operation status tracking - - Operation cancellation - - In-memory history of finished operations + - A bounded in-memory history of finished operations, which also caps + how many finished operations get_operation_status() remembers The history is not persisted. The web UI's operation history comes from OperationHistory (operation_history.py), which has its own file; a copy @@ -133,56 +133,6 @@ class PluginOperationQueue: with self._lock: return self._operations.get(operation_id) - def cancel_operation(self, operation_id: str) -> bool: - """ - Cancel a pending operation. - - Args: - operation_id: Operation identifier - - Returns: - True if operation was cancelled, False if not found or already running - """ - with self._lock: - operation = self._operations.get(operation_id) - if not operation: - return False - - if operation.status == OperationStatus.RUNNING: - self.logger.warning( - f"Cannot cancel running operation {operation_id}" - ) - return False - - if operation.status == OperationStatus.PENDING: - operation.status = OperationStatus.CANCELLED - operation.completed_at = datetime.now() - operation.message = "Operation cancelled by user" - self._add_to_history(operation) - self.logger.info(f"Cancelled operation {operation_id}") - return True - - return False - - def get_operation_history(self, limit: int = 50) -> List[PluginOperation]: - """ - Get operation history. - - Args: - limit: Maximum number of operations to return - - Returns: - List of operations, sorted by creation time (newest first) - """ - with self._lock: - # Sort by creation time (newest first) - history = sorted( - self._operation_history, - key=lambda op: op.created_at, - reverse=True - ) - return history[:limit] - def _start_worker(self) -> None: """Start the worker thread that processes operations.""" if self._worker_thread and self._worker_thread.is_alive(): @@ -207,11 +157,6 @@ class PluginOperationQueue: except queue.Empty: continue - # Check if operation was cancelled - if operation.status == OperationStatus.CANCELLED: - self._operation_queue.task_done() - continue - # Execute operation self._execute_operation(operation) diff --git a/src/plugin_system/operation_types.py b/src/plugin_system/operation_types.py index 950b0fd3..fa220fa7 100644 --- a/src/plugin_system/operation_types.py +++ b/src/plugin_system/operation_types.py @@ -15,11 +15,7 @@ import uuid class OperationType(Enum): """Types of plugin operations.""" INSTALL = "install" - UPDATE = "update" UNINSTALL = "uninstall" - ENABLE = "enable" - DISABLE = "disable" - CONFIGURE = "configure" class OperationStatus(Enum): @@ -28,7 +24,6 @@ class OperationStatus(Enum): RUNNING = "running" COMPLETED = "completed" FAILED = "failed" - CANCELLED = "cancelled" @dataclass @@ -70,29 +65,3 @@ class PluginOperation: 'started_at': self.started_at.isoformat() if self.started_at else None, 'completed_at': self.completed_at.isoformat() if self.completed_at else None, } - - @classmethod - def from_dict(cls, data: Dict[str, Any]) -> 'PluginOperation': - """Create operation from dictionary.""" - op = cls( - operation_type=OperationType(data['operation_type']), - plugin_id=data['plugin_id'], - operation_id=data.get('operation_id', str(uuid.uuid4())), - parameters=data.get('parameters', {}), - status=OperationStatus(data.get('status', 'pending')), - progress=data.get('progress', 0.0), - message=data.get('message', ''), - error=data.get('error'), - result=data.get('result'), - ) - - # Parse datetime fields - if data.get('created_at'): - op.created_at = datetime.fromisoformat(data['created_at']) - if data.get('started_at'): - op.started_at = datetime.fromisoformat(data['started_at']) - if data.get('completed_at'): - op.completed_at = datetime.fromisoformat(data['completed_at']) - - return op - diff --git a/src/plugin_system/plugin_catalog.py b/src/plugin_system/plugin_catalog.py index 8e0a25f0..a2fbabf6 100644 --- a/src/plugin_system/plugin_catalog.py +++ b/src/plugin_system/plugin_catalog.py @@ -3,9 +3,10 @@ Plugin catalog: what the web process knows about installed plugins. The web interface and the display run as two processes. Only the display imports plugin code and runs it; the web process reads plugins as files -- -manifest, config schema, the plugin's section of config.json, the installed -version -- and never imports a plugin module, instantiates a plugin class or -calls a plugin lifecycle hook. This class is that read side. +manifest, config schema, the plugin's section of config.json -- and never +imports a plugin module, instantiates a plugin class or calls a plugin +lifecycle hook. This class is the manifest side of that; schemas come from +SchemaManager and config from ConfigManager. It keeps the method names of the read-only part of :class:`PluginManager` (``discover_plugins``, ``plugin_manifests``, ``get_plugin_info``, @@ -24,10 +25,9 @@ machine) the web cannot know, and reports as unknown. See docs/ARCHITECTURE.md ("Web and display processes"). """ -import json import threading from pathlib import Path -from typing import Any, Dict, List, Optional, Union, cast +from typing import Any, Dict, List, Optional, Union from src.common.permission_utils import ( ensure_directory_permissions, get_plugin_dir_mode, @@ -41,18 +41,15 @@ PathLike = Union[str, Path] class PluginCatalog: - """Manifests, schemas, config and versions of the installed plugins. + """Manifests and directories of the installed plugins. Discovery is explicit and cheap to repeat: :meth:`discover_plugins` rescans the plugins directory and replaces the manifest map, so an uninstalled plugin disappears and a new one appears. """ - def __init__(self, plugins_dir: PathLike, config_manager: Optional[Any] = None, - schema_manager: Optional[Any] = None) -> None: + def __init__(self, plugins_dir: PathLike) -> None: self.plugins_dir: Path = Path(plugins_dir) - self.config_manager = config_manager - self.schema_manager = schema_manager self.logger = get_logger(__name__) # Guards plugin_manifests/plugin_directories: request threads read @@ -132,30 +129,6 @@ class PluginCatalog: ids = list(self.plugin_manifests) return [info for info in (self.get_plugin_info(pid) for pid in ids) if info] - def read_manifest(self, plugin_id: str) -> Optional[Dict[str, Any]]: - """The manifest as it is on disk now, not as discovery last saw it. - - For reads that must reflect a change made since the last scan -- the - version just after an update, say. None when the plugin has no - directory or its manifest is missing, unreadable or not an object. - """ - plugin_dir = self.get_plugin_directory(plugin_id) - if plugin_dir is None: - return None - try: - with open(Path(plugin_dir) / 'manifest.json', 'r', encoding='utf-8') as f: - manifest = json.load(f) - except (OSError, ValueError) as exc: - self.logger.debug("Could not read manifest for %s: %s", plugin_id, exc) - return None - return manifest if isinstance(manifest, dict) else None - - def get_installed_version(self, plugin_id: str) -> str: - """The installed version from the on-disk manifest, or ''.""" - manifest = self.read_manifest(plugin_id) or {} - version = manifest.get('version', '') - return version if isinstance(version, str) else str(version) - def get_plugin_directory(self, plugin_id: str) -> Optional[str]: """Where ``plugin_id`` is installed, or None. @@ -195,31 +168,6 @@ class PluginCatalog: return plugin_id return None - # -- schema and config ------------------------------------------------ - - def get_schema(self, plugin_id: str, use_cache: bool = True) -> Optional[Dict[str, Any]]: - """The plugin's config schema through SchemaManager, or None.""" - if self.schema_manager is None: - return None - schema = self.schema_manager.load_schema(plugin_id, use_cache=use_cache) - return cast(Optional[Dict[str, Any]], schema) - - def get_config(self, plugin_id: str) -> Dict[str, Any]: - """The plugin's section of config.json (secrets merged), or {}.""" - if self.config_manager is None: - return {} - section = (self.config_manager.load_config() or {}).get(plugin_id) - return section if isinstance(section, dict) else {} - - def is_enabled(self, plugin_id: str) -> bool: - """Whether config.json enables the plugin, by the display's rule. - - The display loads a plugin only when its section says - ``"enabled": true``; a missing flag or section means disabled - (``DisplayController._reconcile_enabled_plugins``). - """ - return bool(self.get_config(plugin_id).get('enabled', False)) - def display_restart_required(action: str, plugin_enabled: bool, *, changed: bool = True, diff --git a/src/plugin_system/plugin_manager.py b/src/plugin_system/plugin_manager.py index 2ff6fd1f..ad3d5af3 100644 --- a/src/plugin_system/plugin_manager.py +++ b/src/plugin_system/plugin_manager.py @@ -38,6 +38,7 @@ from src.common.permission_utils import ( ensure_directory_permissions, get_plugin_dir_mode ) +from src.deprecation import deprecated class _DeferredConfigChange(NamedTuple): @@ -939,6 +940,7 @@ class PluginManager: """ return self.plugins.get(plugin_id) + @deprecated("3.10.0", "use get_plugin(plugin_id)") def get_all_plugins(self) -> Dict[str, Any]: """ Get all loaded plugins. @@ -948,6 +950,7 @@ class PluginManager: """ return self.plugins.copy() + @deprecated("3.10.0", "read the manifest with src.plugin_system.plugin_catalog.PluginCatalog") def get_plugin_info(self, plugin_id: str) -> Optional[Dict[str, Any]]: """ Get information about a plugin (manifest + runtime info). @@ -985,6 +988,7 @@ class PluginManager: return info + @deprecated("3.10.0", "read manifests with src.plugin_system.plugin_catalog.PluginCatalog") def get_all_plugin_info(self) -> List[Dict[str, Any]]: """ Get information about all plugins. @@ -1025,6 +1029,7 @@ class PluginManager: by_manifest=False) return str(plugin_dir) if plugin_dir is not None else None + @deprecated("3.10.0", "read manifests with src.plugin_system.plugin_catalog.PluginCatalog") def get_plugin_display_modes(self, plugin_id: str) -> List[str]: """ Get display modes provided by a plugin. @@ -1045,6 +1050,7 @@ class PluginManager: return display_modes return [] + @deprecated("3.10.0", "read manifests with src.plugin_system.plugin_catalog.PluginCatalog") def find_plugin_for_mode(self, mode: str) -> Optional[str]: """ Find which plugin provides a given display mode. diff --git a/src/plugin_system/plugin_state.py b/src/plugin_system/plugin_state.py index 17755890..bb0dc771 100644 --- a/src/plugin_system/plugin_state.py +++ b/src/plugin_system/plugin_state.py @@ -15,6 +15,7 @@ from datetime import datetime import logging from src.logging_config import get_logger +from src.deprecation import deprecated class PluginState(Enum): @@ -138,6 +139,7 @@ class PluginStateManager: """ return self._states.get(plugin_id, PluginState.UNLOADED) + @deprecated("3.10.0", "use get_state()") def is_loaded(self, plugin_id: str) -> bool: """Check if plugin is loaded.""" state = self.get_state(plugin_id) @@ -148,11 +150,13 @@ class PluginStateManager: state = self.get_state(plugin_id) return state == PluginState.ENABLED + @deprecated("3.10.0", "use get_state()") def is_running(self, plugin_id: str) -> bool: """Check if plugin is currently running.""" state = self.get_state(plugin_id) return state == PluginState.RUNNING + @deprecated("3.10.0", "use get_state()") def is_error(self, plugin_id: str) -> bool: """Check if plugin is in error state.""" state = self.get_state(plugin_id) @@ -197,6 +201,7 @@ class PluginStateManager: state.value, ) + @deprecated("3.10.0") def get_error_info(self, plugin_id: str) -> Optional[Dict[str, Any]]: """ Get error information for a plugin. @@ -265,10 +270,12 @@ class PluginStateManager: """Record that plugin update() was called.""" self._last_update[plugin_id] = datetime.now() + @deprecated("3.10.0") def get_last_update(self, plugin_id: str) -> Optional[datetime]: """Get timestamp of last update() call.""" return self._last_update.get(plugin_id) + @deprecated("3.10.0", "use get_state()") def get_state_info(self, plugin_id: str) -> Dict[str, Any]: """ Get comprehensive state information for a plugin. diff --git a/src/plugin_system/state_reconciliation.py b/src/plugin_system/state_reconciliation.py index 1477984e..72268f49 100644 --- a/src/plugin_system/state_reconciliation.py +++ b/src/plugin_system/state_reconciliation.py @@ -38,7 +38,6 @@ class InconsistencyType(Enum): PLUGIN_MISSING_ON_DISK = "plugin_missing_on_disk" PLUGIN_ENABLED_MISMATCH = "plugin_enabled_mismatch" PLUGIN_VERSION_MISMATCH = "plugin_version_mismatch" - PLUGIN_STATE_CORRUPTED = "plugin_state_corrupted" class FixAction(Enum): @@ -57,7 +56,6 @@ class Inconsistency: fix_action: FixAction current_state: Dict[str, Any] expected_state: Dict[str, Any] - can_auto_fix: bool = False @dataclass @@ -270,7 +268,7 @@ class StateReconciliation: # Attempt to fix auto-fixable inconsistencies for inconsistency in inconsistencies: - if inconsistency.can_auto_fix and inconsistency.fix_action == FixAction.AUTO_FIX: + if inconsistency.fix_action == FixAction.AUTO_FIX: if self._fix_inconsistency(inconsistency): fixed.append(inconsistency) else: @@ -428,7 +426,6 @@ class StateReconciliation: fix_action=FixAction.AUTO_FIX, current_state={'exists_in_config': False}, expected_state={'exists_in_config': True, 'enabled': False}, - can_auto_fix=True )) # Check: Plugin in config but not on disk @@ -459,7 +456,6 @@ class StateReconciliation: fix_action=FixAction.AUTO_FIX if can_repair else FixAction.MANUAL_FIX_REQUIRED, current_state={'exists_on_disk': False}, expected_state={'exists_on_disk': True}, - can_auto_fix=can_repair )) # Observed checks: only against a live snapshot, and only for a plugin @@ -486,7 +482,6 @@ class StateReconciliation: fix_action=FixAction.NO_ACTION, current_state={'loaded': loaded, 'state': runtime.get('state')}, expected_state={'loaded': config_enabled}, - can_auto_fix=False )) loaded_version = runtime.get('loaded_version') disk_version = disk.get('version') @@ -500,7 +495,6 @@ class StateReconciliation: fix_action=FixAction.NO_ACTION, current_state={'version': loaded_version}, expected_state={'version': disk_version}, - can_auto_fix=False )) return inconsistencies diff --git a/src/plugin_system/store_registry.py b/src/plugin_system/store_registry.py index 2270843c..13228695 100644 --- a/src/plugin_system/store_registry.py +++ b/src/plugin_system/store_registry.py @@ -183,16 +183,7 @@ class _RegistryMixin: @staticmethod def _distinct_sequence(values: List[str]) -> List[str]: """Return list preserving order while removing duplicates and falsey entries.""" - seen = set() - ordered = [] - for value in values: - if not value: - continue - if value in seen: - continue - seen.add(value) - ordered.append(value) - return ordered + return list(dict.fromkeys(v for v in values if v)) def _validate_manifest_version_fields(self, manifest: Dict[str, Any]) -> List[str]: """ diff --git a/src/plugin_system/testing/plugin_test_base.py b/src/plugin_system/testing/plugin_test_base.py index 806f3d3f..fcedd01b 100644 --- a/src/plugin_system/testing/plugin_test_base.py +++ b/src/plugin_system/testing/plugin_test_base.py @@ -25,6 +25,7 @@ from src.plugin_system.testing.mocks import ( MockConfigManager, MockPluginManager ) +from src.deprecation import deprecated class PluginTestCase(unittest.TestCase): @@ -34,6 +35,7 @@ class PluginTestCase(unittest.TestCase): Provides common fixtures and helper methods. """ + @deprecated("3.10.0", "use src.plugin_system.testing.harness and the mocks directly") def setUp(self): """Set up test fixtures.""" # Create mock managers diff --git a/src/vegas_mode/config.py b/src/vegas_mode/config.py index affc7a15..9b9f2d29 100644 --- a/src/vegas_mode/config.py +++ b/src/vegas_mode/config.py @@ -291,49 +291,6 @@ class VegasModeConfig: max_cycle_duration=int(get('max_cycle_duration', d.max_cycle_duration)), ) - def to_dict(self) -> Dict[str, Any]: - """Convert config to dictionary for serialization.""" - return { - 'enabled': self.enabled, - 'scroll_speed': self.scroll_speed, - 'separator_width': self.separator_width, - 'intra_plugin_gap': self.intra_plugin_gap, - 'render_width_pct': self.render_width_pct, - 'min_content_separation': self.min_content_separation, - 'min_cut_gap': self.min_cut_gap, - 'smooth_scroll': self.smooth_scroll, - 'sub_pixel_blend': self.sub_pixel_blend, - 'continuous_scroll': self.continuous_scroll, - 'offscreen_prefetch': self.offscreen_prefetch, - 'switch_interval_ms': self.switch_interval_ms, - 'prefetch_gate': self.prefetch_gate, - 'live_refresh': self.live_refresh, - 'live_max_hz': self.live_max_hz, - 'live_min_interval': self.live_min_interval, - 'live_lead_screens': self.live_lead_screens, - 'extend_threshold_screens': self.extend_threshold_screens, - 'auto_trim': self.auto_trim, - 'trim_threshold': self.trim_threshold, - 'content_padding': self.content_padding, - 'min_plugin_width': self.min_plugin_width, - 'lead_in_width': self.lead_in_width, - 'plugins_per_cycle': self.plugins_per_cycle, - 'max_plugin_width_ratio': self.max_plugin_width_ratio, - 'live_in_ticker': self.live_in_ticker, - 'live_weight': self.live_weight, - 'favorite_live_weight': self.favorite_live_weight, - 'overflow_mode': self.overflow_mode, - 'plugin_order': self.plugin_order, - 'excluded_plugins': list(self.excluded_plugins), - 'target_fps': self.target_fps, - 'buffer_ahead': self.buffer_ahead, - 'frame_based_scrolling': self.frame_based_scrolling, - 'scroll_delay': self.scroll_delay, - 'dynamic_duration_enabled': self.dynamic_duration_enabled, - 'min_cycle_duration': self.min_cycle_duration, - 'max_cycle_duration': self.max_cycle_duration, - } - def get_frame_interval(self) -> float: """Get the frame interval in seconds for target FPS.""" return 1.0 / max(1, self.target_fps) diff --git a/src/vegas_mode/coordinator.py b/src/vegas_mode/coordinator.py index 8eb0f0ab..c76128a0 100644 --- a/src/vegas_mode/coordinator.py +++ b/src/vegas_mode/coordinator.py @@ -170,16 +170,6 @@ class VegasModeCoordinator: self._static_pause_active = False self._saved_scroll_position: Optional[int] = None - # Statistics - self.stats = { - 'total_runtime_seconds': 0.0, - 'cycles_completed': 0, - 'interruptions': 0, - 'config_updates': 0, - 'static_pauses': 0, - } - self._start_time: Optional[float] = None - logger.info( "VegasModeCoordinator initialized: enabled=%s, fps=%d, buffer_ahead=%d", self.vegas_config.enabled, @@ -314,7 +304,6 @@ class VegasModeCoordinator: # new run would have run_frame() refuse every frame. self._is_paused = False self._live_priority_active = False - self._start_time = time.time() # A fresh run starts with a clean health slate: no stale # "was degraded" from the previous run, and a heartbeat that is # due immediately so the first sample confirms the marquee is up. @@ -345,10 +334,6 @@ class VegasModeCoordinator: self._is_paused = False self._live_priority_active = False - if self._start_time: - self.stats['total_runtime_seconds'] += time.time() - self._start_time - self._start_time = None - self._restore_switch_interval() self._remove_render_gate() self._set_live(False, None) @@ -473,7 +458,6 @@ class VegasModeCoordinator: if not self._is_active: return self._is_paused = True - self.stats['interruptions'] += 1 self.display_manager.set_scrolling_state(False) logger.info("Vegas mode paused") @@ -543,9 +527,8 @@ class VegasModeCoordinator: if self.render_pipeline.has_deferred(): self.render_pipeline.drain_deferred() elif self.render_pipeline.needs_extension(): - if self.render_pipeline.extend_scroll_content(): - self.stats['cycles_completed'] += 1 - elif self.render_pipeline.is_cycle_complete(): + if (not self.render_pipeline.extend_scroll_content() + and self.render_pipeline.is_cycle_complete()): # Extension failed and the strip has run out: fall back to # the swap rather than sitting on a dead frame. self.render_pipeline.start_new_cycle() @@ -555,7 +538,6 @@ class VegasModeCoordinator: if not self.render_pipeline.start_new_cycle(): logger.warning("Failed to start new Vegas cycle") return False - self.stats['cycles_completed'] += 1 # Check for hot-swap opportunities if self.render_pipeline.should_recompose(): @@ -835,7 +817,6 @@ class VegasModeCoordinator: self._pending_config_update = True self._pending_config = new_config self._config_version += 1 - self.stats['config_updates'] += 1 logger.debug("Config update queued (version %d)", self._config_version) @@ -910,23 +891,6 @@ class VegasModeCoordinator: self.stream_manager.mark_plugin_updated(plugin_id) self.plugin_adapter.invalidate_cache(plugin_id) - def get_status(self) -> Dict[str, Any]: - """Get comprehensive Vegas mode status.""" - status = { - 'enabled': self.vegas_config.enabled, - 'active': self._is_active, - 'paused': self._is_paused, - 'live_priority_active': self._live_priority_active, - 'config': self.vegas_config.to_dict(), - 'stats': self.stats.copy(), - } - - if self._is_active: - status['render_info'] = self.render_pipeline.get_current_scroll_info() - status['stream_status'] = self.stream_manager.get_buffer_status() - - return status - # ------------------------------------------------------------------------- # Static pause handling (for STATIC display mode) # ------------------------------------------------------------------------- @@ -980,7 +944,6 @@ class VegasModeCoordinator: # Save current scroll position for smooth resume self._saved_scroll_position = self.render_pipeline.get_scroll_position() self._static_pause_active = True - self.stats['static_pauses'] += 1 logger.info("Static pause started for plugin: %s", plugin_id) diff --git a/src/vegas_mode/render_pipeline.py b/src/vegas_mode/render_pipeline.py index 44cd6e90..bb973b84 100644 --- a/src/vegas_mode/render_pipeline.py +++ b/src/vegas_mode/render_pipeline.py @@ -218,7 +218,6 @@ class RenderPipeline: # Render state self._cycle_complete = False - self._segments_in_scroll: List[str] = [] # Plugin IDs in current scroll self._record_by_seq: Dict[int, ElementRecord] = {} # Live updates. _applied: per record, the (epoch, digest) of the # pixels the strip holds. _live_slots / _live_ready: the worker's @@ -235,15 +234,9 @@ class RenderPipeline: self._frame_interval = config.get_frame_interval() self._cycle_start_time = 0.0 - # Statistics - self.stats = { - 'frames_rendered': 0, - 'scroll_cycles': 0, - 'composition_count': 0, - 'hot_swaps': 0, - 'avg_frame_time_ms': 0.0, - } - self._frame_times: Deque[float] = deque(maxlen=100) # Efficient fixed-size buffer + # Read by _measure_refresh (warm-up) and the live integration test. + self.frames_rendered = 0 + self.extensions = 0 logger.info( "RenderPipeline initialized: %dx%d @ %d FPS", @@ -342,7 +335,7 @@ class RenderPipeline: return if getattr(self.display_manager, 'matrix', None) is None: return # No hardware: nothing blocks, so there is nothing to time. - if self.stats['frames_rendered'] < self.REFRESH_WARMUP_FRAMES: + if self.frames_rendered < self.REFRESH_WARMUP_FRAMES: return self._swap_times.append(time.monotonic()) if len(self._swap_times) <= self.REFRESH_SAMPLES: @@ -452,10 +445,6 @@ class RenderPipeline: layouts) self._note_op('compose', self._strip_nbytes()) - # Track which plugins are in this scroll (get safely via buffer status) - self._segments_in_scroll = self.stream_manager.get_active_plugin_ids() - - self.stats['composition_count'] += 1 self._cycle_start_time = time.time() self._cycle_complete = False @@ -865,9 +854,7 @@ class RenderPipeline: # trim's copy, if it made one. self._note_op('extend', moved + (self._copied_bytes() if cut else 0)) - self._segments_in_scroll = [pid for pid, _ in grouped] - self.stats['composition_count'] += 1 - self.stats['extensions'] = self.stats.get('extensions', 0) + 1 + self.extensions += 1 logger.info( "Extended scroll strip with %d plugin block(s), %d rows: " @@ -1150,8 +1137,6 @@ class RenderPipeline: Returns: True if frame was rendered, False if no content """ - frame_start = time.time() - try: if not self.scroll_helper.has_strip(): return False @@ -1200,7 +1185,6 @@ class RenderPipeline: if at_wrap_point or self.scroll_helper.is_scroll_complete(): if not self._cycle_complete: self._cycle_complete = True - self.stats['scroll_cycles'] += 1 logger.info( "Scroll cycle complete after %.1fs", time.time() - self._cycle_start_time @@ -1239,11 +1223,8 @@ class RenderPipeline: # Update scrolling state self.display_manager.set_scrolling_state(True, self._frame_hold) - # Track statistics - self.stats['frames_rendered'] += 1 + self.frames_rendered += 1 self._measure_refresh() - frame_time = time.time() - frame_start - self._track_frame_time(frame_time) return True @@ -1252,15 +1233,6 @@ class RenderPipeline: logger.exception("Error rendering frame") return False - def _track_frame_time(self, frame_time: float) -> None: - """Track frame timing for statistics.""" - self._frame_times.append(frame_time) # deque with maxlen auto-removes old entries - - if self._frame_times: - self.stats['avg_frame_time_ms'] = ( - sum(self._frame_times) / len(self._frame_times) * 1000 - ) - def is_cycle_complete(self) -> bool: """Check if current scroll cycle is complete.""" return self._cycle_complete @@ -1347,7 +1319,6 @@ class RenderPipeline: else: self.scroll_helper.scroll_position = 0.0 - self.stats['hot_swaps'] += 1 logger.debug( "Hot-swap completed: scroll repositioned %.0f→%.0f (%.1f%% of new %dpx image)", old_pos, self.scroll_helper.scroll_position, @@ -1396,8 +1367,6 @@ class RenderPipeline: # transition rather than near-end content wrapping around. self.scroll_helper.scroll_position = float(self.config.lead_in_width) - # Signal follower that a new cycle started (triggers its own rebuild) - self.sync_manager.send_new_cycle() # Push the actual scroll image over TCP so follower has identical pixels. # Done in a background thread to not block the render loop (~15ms transfer). image = self.scroll_helper.cached_image @@ -1410,16 +1379,6 @@ class RenderPipeline: return result - def get_current_scroll_info(self) -> Dict[str, Any]: - """Get current scroll state information.""" - scroll_info = self.scroll_helper.get_scroll_info() - return { - **scroll_info, - 'cycle_complete': self._cycle_complete, - 'plugins_in_scroll': self._segments_in_scroll, - 'stats': self.stats.copy(), - } - def get_scroll_position(self) -> int: """ Get current scroll position. @@ -1465,8 +1424,6 @@ class RenderPipeline: self.scroll_helper.clear_cache() self._cycle_complete = False - self._segments_in_scroll = [] - self._frame_times = deque(maxlen=100) # Content lined up for the old run belongs to it. Left in place, the # first extension after Vegas is switched back on appended that stale diff --git a/src/vegas_mode/stream_manager.py b/src/vegas_mode/stream_manager.py index 46f9de16..ccbf7db2 100644 --- a/src/vegas_mode/stream_manager.py +++ b/src/vegas_mode/stream_manager.py @@ -16,7 +16,7 @@ BasePlugin.get_vegas_participation): import logging import threading import time -from typing import Optional, List, Dict, Any, Deque, Tuple, TYPE_CHECKING +from typing import Optional, List, Dict, Deque, Tuple, TYPE_CHECKING from collections import deque from dataclasses import dataclass, field from PIL import Image @@ -94,13 +94,6 @@ class StreamManager: self._last_refresh: float = 0.0 self._refresh_interval: float = 30.0 # Refresh plugin list every 30s - # Statistics - self.stats = { - 'segments_fetched': 0, - 'segments_served': 0, - 'fetch_errors': 0, - } - logger.info("StreamManager initialized with buffer_ahead=%d", config.buffer_ahead) def initialize(self) -> bool: @@ -143,47 +136,12 @@ class StreamManager: return None segment = self._active_buffer.popleft() - self.stats['segments_served'] += 1 # Trigger prefetch to maintain buffer self._ensure_buffer_filled() return segment - def peek_next_segment(self) -> Optional[ContentSegment]: - """ - Peek at the next segment without removing it. - - Returns: - ContentSegment or None if buffer is empty - """ - with self._buffer_lock: - if self._active_buffer: - return self._active_buffer[0] - return None - - def get_buffer_status(self) -> Dict[str, Any]: - """Get current buffer status for monitoring.""" - with self._buffer_lock: - return { - 'active_count': len(self._active_buffer), - 'total_plugins': len(self._ordered_plugins), - 'prefetch_index': self._prefetch_index, - 'stats': self.stats.copy(), - } - - def get_active_plugin_ids(self) -> List[str]: - """ - Get list of plugin IDs currently in the active buffer. - - Thread-safe accessor for render pipeline. - - Returns: - List of plugin IDs in buffer order - """ - with self._buffer_lock: - return [seg.plugin_id for seg in self._active_buffer] - def mark_plugin_updated(self, plugin_id: str) -> None: """ Mark a plugin as having updated data. @@ -587,7 +545,6 @@ class StreamManager: images=[], # No images needed for static pause display_mode=VegasDisplayMode.STATIC ) - self.stats['segments_fetched'] += 1 logger.debug( "[%s] Created STATIC placeholder (pause trigger)", plugin_id @@ -610,7 +567,6 @@ class StreamManager: display_mode=VegasDisplayMode.SCROLL ) - self.stats['segments_fetched'] += 1 logger.debug( "[%s] Segment: %d image(s), %dpx", plugin_id, len(images), total_width @@ -619,7 +575,6 @@ class StreamManager: except Exception: logger.exception("[%s] ERROR fetching content", plugin_id) - self.stats['fetch_errors'] += 1 return None def _ensure_buffer_filled(self) -> None: @@ -770,10 +725,8 @@ class StreamManager: plugin, plugin_id, offscreen_only=offscreen_only) except Exception: logger.exception("[%s] ERROR fetching content", plugin_id) - self.stats['fetch_errors'] += 1 return None if images: - self.stats['segments_fetched'] += 1 return (plugin_id, images) # Only the old contract hands anything back to the render thread. defer_empty = offscreen_only and not getattr( diff --git a/src/web_interface/api_helpers.py b/src/web_interface/api_helpers.py index b51d2b78..ee2d84cf 100644 --- a/src/web_interface/api_helpers.py +++ b/src/web_interface/api_helpers.py @@ -8,7 +8,6 @@ import time from typing import Any, Optional, Dict, Tuple from flask import jsonify, request -from src.web_interface.error_handler import create_error_response, create_success_response from src.web_interface.errors import ErrorCode, WebInterfaceError @@ -31,7 +30,15 @@ def success_response( Returns: Flask jsonify response """ - response_data = create_success_response(data, message, metadata) + response_data: Dict[str, Any] = {'status': 'success'} + # `is not None` rather than truthiness: "" and {} are values a caller + # chose to send, and dropping them would make the shape depend on the data. + if data is not None: + response_data['data'] = data + if message is not None: + response_data['message'] = message + if metadata is not None: + response_data['metadata'] = metadata for key, value in (extra or {}).items(): response_data.setdefault(key, value) @@ -70,14 +77,14 @@ def error_response( Returns: Flask jsonify response with status code """ - return create_error_response( + error = WebInterfaceError( error_code=error_code, message=message, details=details, - context=context, - suggested_fixes=suggested_fixes, - status_code=status_code + context=context or {}, + suggested_fixes=suggested_fixes ) + return jsonify(error.to_dict()), status_code def exception_error_response( diff --git a/src/web_interface/config_arrays.py b/src/web_interface/config_arrays.py index 0e644a05..bc0eac28 100644 --- a/src/web_interface/config_arrays.py +++ b/src/web_interface/config_arrays.py @@ -12,8 +12,8 @@ from typing import Any, Dict def _schema_type_is(prop: Any, wanted: str) -> bool: """Whether a schema property is of ``wanted`` type, unions included. - Mirrors ``_schema_type_is`` in ``web_interface/blueprints/api_v3`` (kept - here so src/ doesn't import the Flask blueprint). A union such as + The one copy: ``web_interface/blueprints/api_v3`` imports it from here + (src/ must not import the Flask blueprint). A union such as ``["array", "null"]`` -- the per-element style overrides, where null means "inherit" -- is still an array for recombining position-keyed inputs. """ diff --git a/src/web_interface/error_handler.py b/src/web_interface/error_handler.py index c3360def..6f31235f 100644 --- a/src/web_interface/error_handler.py +++ b/src/web_interface/error_handler.py @@ -1,20 +1,13 @@ """ -Centralized error handling for web interface. +Error text and payloads for web interface responses. -Provides helpers for consistent error responses across API endpoints. +Safe exception descriptions and the bodies for exceptions no route handled. +The standard success/error responses are in api_helpers. """ -from typing import Any, Optional -from flask import jsonify - -from src.web_interface.errors import WebInterfaceError, ErrorCode -from src.logging_config import get_logger from src.redaction import redact_credentials -logger = get_logger(__name__) - - # Long enough for an errno string with a path, short enough not to dump a # parser's worth of context into a JSON field. _MAX_DETAIL_LENGTH = 400 @@ -101,72 +94,3 @@ def http_exception_payload(error) -> dict: 'error_code': (error.name or 'HTTP_ERROR').upper().replace(' ', '_'), 'message': error.description, } - - -def create_error_response( - error_code: ErrorCode, - message: str, - details: Optional[str] = None, - context: Optional[dict] = None, - suggested_fixes: Optional[list] = None, - status_code: int = 500 -) -> tuple: - """ - Create a standardized error response. - - Args: - error_code: Error code - message: Error message - details: Optional detailed error information - context: Optional context dictionary - suggested_fixes: Optional list of suggested fixes - status_code: HTTP status code - - Returns: - Tuple of (jsonify response, status_code) - """ - error = WebInterfaceError( - error_code=error_code, - message=message, - details=details, - context=context or {}, - suggested_fixes=suggested_fixes - ) - - return jsonify(error.to_dict()), status_code - - -def create_success_response( - data: Any = None, - message: Optional[str] = None, - metadata: Optional[dict] = None -) -> dict: - """ - Create a standardized success response. - - Args: - data: Response data - message: Optional success message - metadata: Optional metadata (timing, version, etc.) - - Returns: - Dictionary for jsonify - """ - response: dict[str, Any] = { - "status": "success" - } - - # All three use `is not None` rather than truthiness: "" and {} are - # values a caller chose to send, and dropping them silently would make - # the response shape depend on the data. - if data is not None: - response["data"] = data - - if message is not None: - response["message"] = message - - if metadata is not None: - response["metadata"] = metadata - - return response - diff --git a/src/web_interface/errors.py b/src/web_interface/errors.py index 4d3f9fd6..2441ba22 100644 --- a/src/web_interface/errors.py +++ b/src/web_interface/errors.py @@ -61,27 +61,14 @@ class WebInterfaceError: context: Optional[Dict[str, Any]] = None suggested_fixes: Optional[List[str]] = None original_error: Optional[Exception] = None - - def __init__( - self, - error_code: ErrorCode, - message: str, - details: Optional[str] = None, - context: Optional[Dict[str, Any]] = None, - suggested_fixes: Optional[List[str]] = None, - original_error: Optional[Exception] = None - ): - self.error_code = error_code - self.message = message - self.details = details - self.context = context or {} + + def __post_init__(self) -> None: + self.context = self.context or {} # `is None`, not truthiness: an explicit [] means "this caller has # no suggestions to offer", which the default list would override. - self.suggested_fixes = ( - suggested_fixes if suggested_fixes is not None - else self._get_default_suggestions(error_code)) - self.original_error = original_error - + if self.suggested_fixes is None: + self.suggested_fixes = self._get_default_suggestions(self.error_code) + def _get_default_suggestions(self, error_code: ErrorCode) -> List[str]: """Get default suggested fixes for error code.""" suggestions_map = { diff --git a/test/_api_v3_test_helpers.py b/test/_api_v3_test_helpers.py index 4e1439d4..e9ab39ac 100644 --- a/test/_api_v3_test_helpers.py +++ b/test/_api_v3_test_helpers.py @@ -41,8 +41,7 @@ def mock_plugin_catalog(): """ from src.plugin_system.plugin_catalog import PluginCatalog catalog = MagicMock(spec=PluginCatalog) - for name in ('plugins_dir', 'config_manager', 'schema_manager', - 'plugin_manifests', 'plugin_directories'): + for name in ('plugins_dir', 'plugin_manifests', 'plugin_directories'): setattr(catalog, name, MagicMock()) return catalog diff --git a/test/js/unit/test_html_escaping.js b/test/js/unit/test_html_escaping.js index cedc73ea..1b6f8437 100644 --- a/test/js/unit/test_html_escaping.js +++ b/test/js/unit/test_html_escaping.js @@ -12,9 +12,10 @@ // // CodeQL reported 83 js/incomplete-html-attribute-sanitization alerts for // exactly this. The web UI now has one implementation, window.LEDEscape in -// app-early.js, and the old per-file escapers are one-line names for it. This -// suite runs LEDEscape and every one of those names as shipped, and fails if a -// hand-rolled escaper appears anywhere else in web_interface/. +// app-early.js, which every page and widget calls directly (BaseWidget keeps +// an escapeHtml method for plugin widgets). This suite runs LEDEscape and that +// method as shipped, and fails if a hand-rolled escaper appears anywhere else +// in web_interface/. const fs = require('fs'); const path = require('path'); @@ -85,30 +86,6 @@ const ESCAPERS = [ ['app-early.js (LEDEscape.attr)', null, null, 'attr'], ['base-widget.js (BaseWidget.escapeHtml)', 'static/v3/js/widgets/base-widget.js', 'escapeHtml(text) {', 'escapeHtml', true], - ['plugins_manager.js (top-level escapeHtml)', - 'static/v3/plugins_manager.js', 'function escapeHtml(text) {', 'escapeHtml', false], - ['plugins_manager.js (starlark escapeHtml)', - 'static/v3/plugins_manager.js', 'function escapeHtml(str) {', 'escapeHtml', false], - ['json-file-manager.js (_esc)', - 'static/v3/js/widgets/json-file-manager.js', '_esc(str) {', '_esc', true], - ['plugin-file-manager.js (escHtml)', - 'static/v3/js/widgets/plugin-file-manager.js', 'function escHtml(s) {', 'escHtml', false], - ['plugins_manager.js (escapeAttribute)', - 'static/v3/plugins_manager.js', 'function escapeAttribute(text) {', 'escapeAttribute', false], - ['notification.js (escapeHtml)', - 'static/v3/js/widgets/notification.js', 'function escapeHtml(text) {', 'escapeHtml', false], - ['google-calendar-picker.js (escapeHtml)', - 'static/v3/js/widgets/google-calendar-picker.js', 'function escapeHtml(str) {', 'escapeHtml', false], - ['text-input.js (escapeHtml)', - 'static/v3/js/widgets/text-input.js', 'function escapeHtml(text) {', 'escapeHtml', false], - ['slider.js (escapeAttr)', - 'static/v3/js/widgets/slider.js', 'function escapeAttr(text) {', 'escapeAttr', false], - ['tools.html (escHtml)', - 'templates/v3/partials/tools.html', 'function escHtml(s) {', 'escHtml', false], - ['tools.html (phEscape)', - 'templates/v3/partials/tools.html', 'function phEscape(s) {', 'phEscape', false], - ['logs.html (escapeHtml)', - 'templates/v3/partials/logs.html', 'function escapeHtml(text) {', 'escapeHtml', false], // cache.html, backup_restore.html, operation_history.html and display.html // have no script any more (display.html's two escapers were never called): // their js/pages/ modules draw server data with textContent, and each @@ -169,9 +146,7 @@ console.log('\n4b. LEDEscape.jsStringAttr: a JS string literal that survives an console.log('\n4c. no hand-rolled escaper outside app-early.js'); { - const skip = new Set(['static/v3/js/app-early.js', - // documentation example, kept self-contained on purpose - 'static/v3/js/widgets/example-color-picker.js']); + const skip = new Set(['static/v3/js/app-early.js']); const found = []; const walk = dir => fs.readdirSync(dir, { withFileTypes: true }).forEach(e => { const p = path.join(dir, e.name); @@ -308,7 +283,7 @@ console.log('\n6. url-input onInput: previewLink.href is guarded at the sink'); // ── plugin-file-manager: cell edits travel via data-*, not inline handlers ── // A JSON key/day from an uploaded file used to be spliced, HTML-escaped, -// into an oninput="...('${escHtml(col)}'...)" attribute. escHtml neutralises +// into an oninput="...('${escHtml(col)}'...)" attribute. Escaping neutralises // a quote for an ordinary attribute, but here the value also has to survive // as a *JS string literal* -- the browser HTML-decodes the attribute before // running it as script, which turns the escaped quote back into a real one @@ -332,11 +307,10 @@ console.log("\n7. plugin-file-manager: cell edits never go through an inline han process.exit(1); } - const escHtmlFn = loadFn('static/v3/js/widgets/plugin-file-manager.js', 'function escHtml(s) {', 'escHtml', false); const renderEntryTableSrc = extractFn('function renderEntryTable(fieldId, container, content) {'); const calls = []; - const fakeWindow = { _pfmCellEdit: (fieldId, day, col, value) => calls.push({ fieldId, day, col, value }) }; + const fakeWindow = { LEDEscape, _pfmCellEdit: (fieldId, day, col, value) => calls.push({ fieldId, day, col, value }) }; class FakeContainer { constructor() { this._html = ''; this._listeners = {}; } @@ -354,12 +328,11 @@ console.log("\n7. plugin-file-manager: cell edits never go through an inline han } // eslint-disable-next-line no-eval - const renderEntryTable = eval(`(function(getState, escHtml, safeSetHTML, window){ + const renderEntryTable = eval(`(function(getState, safeSetHTML, window){ ${renderEntryTableSrc} return renderEntryTable; })`)( () => ({ entriesPerPage: 20, _tablePage: 1 }), - escHtmlFn, (target, html) => { target.innerHTML = html; }, fakeWindow ); diff --git a/test/js/unit/test_inline_handler_escaping.js b/test/js/unit/test_inline_handler_escaping.js index e32d6d8c..27d992d6 100644 --- a/test/js/unit/test_inline_handler_escaping.js +++ b/test/js/unit/test_inline_handler_escaping.js @@ -62,12 +62,12 @@ global.setGridHtmlIfChanged = (container, html) => { container.innerHTML = html; // eslint-disable-next-line no-eval eval([ - 'function escapeHtml(text) {', 'function escapeAttribute(text) {', 'function jsStringAttr(value) {', + 'function jsStringAttr(value) {', 'function renderPluginStore(plugins) {', 'function renderSavedRepositories(repositories) {', 'function renderCustomRegistryPlugins(plugins, registryUrl) {', -].map(extract).join('\n') + '\nglobal.jsStringAttr = jsStringAttr; global.escapeHtml = escapeHtml;' +].map(extract).join('\n') + '\nglobal.jsStringAttr = jsStringAttr;' + '\nglobal.renderPluginStore = renderPluginStore; global.renderSavedRepositories = renderSavedRepositories;' - + '\nglobal.renderCustomRegistryPlugins = renderCustomRegistryPlugins; global.escapeAttribute = escapeAttribute;'); + + '\nglobal.renderCustomRegistryPlugins = renderCustomRegistryPlugins;'); // ── minimal HTML start-tag tokenizer ─────────────────────────────────────── function decodeEntities(s) { diff --git a/test/js/unit/test_render_cards.js b/test/js/unit/test_render_cards.js index 741a7824..7edecb66 100644 --- a/test/js/unit/test_render_cards.js +++ b/test/js/unit/test_render_cards.js @@ -16,7 +16,7 @@ const container = { innerHTML: '', querySelectorAll: () => [], // no skeletons in this harness }; -// escapeHtml() escapes via a detached element, so mirror what a browser does +// LEDEscape.html() escapes via a detached element, so mirror what a browser does // when you read innerHTML back off textContent: & < > are escaped, quotes are not. class FakeEl { set textContent(v) { this._t = String(v == null ? '' : v); } @@ -36,7 +36,7 @@ global.PLUGIN_DEBUG = false; global.debugLog = () => {}; function setupInstalledEventDelegation() {} // stubbed; tested separately -eval(slice('function escapeHtml(text)', '\nfunction isNewPlugin')); +eval(slice('function jsStringAttr(value)', '\nfunction isNewPlugin')); eval(slice('function renderInstalledCards(plugins, total)', '// Set up event delegation for plugin action buttons')); diff --git a/test/js/unit/test_store_registry_fields.js b/test/js/unit/test_store_registry_fields.js index 968cd35f..99e477cc 100644 --- a/test/js/unit/test_store_registry_fields.js +++ b/test/js/unit/test_store_registry_fields.js @@ -51,7 +51,7 @@ global.installedPlugins = []; // eslint-disable-next-line no-eval eval([ - 'function escapeHtml(text) {', 'function escapeAttribute(text) {', 'function jsStringAttr(value) {', + 'function jsStringAttr(value) {', 'function isStorePluginInstalled(pluginIdOrPlugin) {', 'function findInstalledStorePlugin(pluginIdOrPlugin) {', 'function renderPluginStore(plugins) {', ].map(extract).join('\n') + '\nglobal.renderPluginStore = renderPluginStore;' diff --git a/test/js/unit/test_update_all.js b/test/js/unit/test_update_all.js index b3fbaa79..bd257218 100644 --- a/test/js/unit/test_update_all.js +++ b/test/js/unit/test_update_all.js @@ -253,9 +253,10 @@ const noSleep = { sleep: async () => {} }; for (const endpoint of bad) codes.push(await refusal(endpoint)); ok('an endpoint that could leave the API path is refused before fetch()', codes.every(c => c === 'INVALID_ENDPOINT') && urls.length === 0, { codes, urls }); - await PluginAPI.resetPluginConfig('a/../b&x=1'); + global.debugLog = () => {}; // GETs go through the throttler, which logs + await PluginAPI.getPluginHealth('a/../b&x=1'); ok('a plugin id is encoded into the URL, not spliced into it', - urls[0] === '/api/v3/plugins/config/reset?plugin_id=a%2F..%2Fb%26x%3D1', urls); + urls[0] === '/api/v3/plugins/health/a%2F..%2Fb%26x%3D1', urls); delete global.fetch; } diff --git a/test/test_cache_manager.py b/test/test_cache_manager.py index 9d1195a8..19e4d3fb 100644 --- a/test/test_cache_manager.py +++ b/test/test_cache_manager.py @@ -1,7 +1,7 @@ """ Tests for CacheManager and cache components. -Tests cache functionality including memory cache, disk cache, strategy, and metrics. +Tests cache functionality including memory cache, disk cache, and strategy. """ import pytest @@ -11,7 +11,6 @@ from src.cache_manager import CacheManager from src.cache.memory_cache import MemoryCache from src.cache.disk_cache import DiskCache from src.cache.cache_strategy import CacheStrategy -from src.cache.cache_metrics import CacheMetrics from datetime import datetime @@ -26,7 +25,6 @@ class TestCacheManager: assert hasattr(cm, '_memory_cache_component') assert hasattr(cm, '_disk_cache_component') assert hasattr(cm, '_strategy_component') - assert hasattr(cm, '_metrics_component') def test_set_and_get(self, tmp_path): """Test basic set and get operations.""" @@ -196,50 +194,6 @@ class TestMemoryCache: assert stats["max_size"] == 1000 # default -class TestCacheMetrics: - """Test CacheMetrics functionality.""" - - def test_record_hit(self): - """Test recording cache hit.""" - metrics = CacheMetrics() - metrics.record_hit() - stats = metrics.get_metrics() - - # get_metrics() returns calculated values, not raw hits/misses - assert stats['total_requests'] == 1 - assert stats['cache_hit_rate'] == 1.0 # 1 hit out of 1 request - - def test_record_miss(self): - """Test recording cache miss.""" - metrics = CacheMetrics() - metrics.record_miss() - stats = metrics.get_metrics() - - # get_metrics() returns calculated values, not raw hits/misses - assert stats['total_requests'] == 1 - assert stats['cache_hit_rate'] == 0.0 # 0 hits out of 1 request - - def test_record_fetch_time(self): - """Test recording fetch time.""" - metrics = CacheMetrics() - metrics.record_fetch_time(0.5) - stats = metrics.get_metrics() - - assert stats['fetch_count'] == 1 - assert stats['total_fetch_time'] == 0.5 - assert stats['average_fetch_time'] == 0.5 - - def test_cache_hit_rate(self): - """Test cache hit rate calculation.""" - metrics = CacheMetrics() - metrics.record_hit() - metrics.record_hit() - metrics.record_miss() - - stats = metrics.get_metrics() - assert stats['cache_hit_rate'] == pytest.approx(0.666, abs=0.01) - - class TestDiskCache: """Test DiskCache functionality.""" @@ -370,36 +324,6 @@ class TestDiskCache: # Should handle gracefully assert result is None or isinstance(result, dict) - - def test_record_background_hit(self): - """Test recording background cache hit.""" - metrics = CacheMetrics() - metrics.record_hit(cache_type='background') - stats = metrics.get_metrics() - - assert stats['total_requests'] == 1 - assert stats['background_hit_rate'] == 1.0 - - def test_record_background_miss(self): - """Test recording background cache miss.""" - metrics = CacheMetrics() - metrics.record_miss(cache_type='background') - stats = metrics.get_metrics() - - assert stats['total_requests'] == 1 - assert stats['background_hit_rate'] == 0.0 - - def test_multiple_fetch_times(self): - """Test recording multiple fetch times.""" - metrics = CacheMetrics() - metrics.record_fetch_time(0.5) - metrics.record_fetch_time(1.0) - metrics.record_fetch_time(0.3) - - stats = metrics.get_metrics() - assert stats['fetch_count'] == 3 - assert stats['total_fetch_time'] == 1.8 - assert stats['average_fetch_time'] == pytest.approx(0.6, abs=0.01) class TestDiskCacheWriteEconomy: diff --git a/test/test_cache_strategy_intervals.py b/test/test_cache_strategy_intervals.py index ef489973..e9594ea3 100644 --- a/test/test_cache_strategy_intervals.py +++ b/test/test_cache_strategy_intervals.py @@ -1,12 +1,10 @@ """CacheStrategy intervals, pinned across the whole input grid. The strategy table used to carry a per-sport defaults dict whose every value -was 60, and a soccer branch identical to its else. These tests pin the -returned strategy for every data type x sport key x config shape, so -simplifying the lookup cannot change what any caller gets back. They were -written against the pre-cleanup code and pass on it unchanged, except for -the legacy `_scoreboard` config shape (see below), which that code -still read. +was 60, a soccer branch identical to its else, and a config lookup of +`_scoreboard` sections that only the replaced built-in scoreboards +had. These tests pin the returned strategy for every data type x sport key, +so simplifying the lookup cannot change what any caller gets back. """ import pytest @@ -14,46 +12,6 @@ import pytest from src.cache.cache_strategy import CacheStrategy -class _Cfg: - def __init__(self, config): - self.config = config - - -class _NoConfigAttr: - pass - - -# Plugin config sections are keyed by plugin id. Their intervals belong to the -# plugin, and the strategy table has never read them. -_PLUGIN_ID_CONFIG = { - pid: {"live_update_interval": 5, "recent_update_interval": 7, - "upcoming_update_interval": 9} - for pid in ("football-scoreboard", "basketball-scoreboard", - "baseball-scoreboard", "hockey-scoreboard", "soccer-scoreboard") -} - -# `_scoreboard` sections come from the built-in scoreboards the plugin -# system replaced. An install upgraded from that era can still carry them in -# config.json (nothing deletes them). No current caller passes a sport key to -# the strategy, but a stale section must not steer cache TTLs if one does. -_LEGACY_SCOREBOARD_CONFIG = { - f"{sport}_scoreboard": {"live_update_interval": 5, - "recent_update_interval": 7, - "upcoming_update_interval": 9} - for sport in ("nfl", "nba", "mlb", "nhl", "soccer", "ncaa_fb", - "ncaa_baseball", "ncaam_basketball", "milb") -} - -CONFIG_MANAGERS = { - "no_config_manager": None, - "empty_config": _Cfg({}), - "plugin_id_config": _Cfg(_PLUGIN_ID_CONFIG), - "legacy_scoreboard_config": _Cfg(_LEGACY_SCOREBOARD_CONFIG), - "config_is_none": _Cfg(None), - "config_is_not_a_dict": _Cfg("x"), - "config_manager_without_config": _NoConfigAttr(), -} - SPORT_KEYS = [None, "", "nfl", "nba", "mlb", "nhl", "soccer", "ncaa_fb", "ncaa_baseball", "ncaam_basketball", "milb", "football-scoreboard", "curling"] @@ -93,16 +51,8 @@ def _expected(data_type, sport_key): return FIXED.get(data_type, DEFAULT) -@pytest.mark.parametrize("cm_name", sorted(CONFIG_MANAGERS)) -def test_live_interval_is_60_for_every_sport(cm_name): - strategy = CacheStrategy(config_manager=CONFIG_MANAGERS[cm_name]) - for sport_key in SPORT_KEYS: - assert strategy.get_sport_live_interval(sport_key) == 60, sport_key - - -@pytest.mark.parametrize("cm_name", sorted(CONFIG_MANAGERS)) -def test_strategy_table_for_every_data_type_and_sport(cm_name): - strategy = CacheStrategy(config_manager=CONFIG_MANAGERS[cm_name]) +def test_strategy_table_for_every_data_type_and_sport(): + strategy = CacheStrategy() data_types = ["live_scores", "sports_live", *FIXED, "unknown", ""] for data_type in data_types: for sport_key in SPORT_KEYS: diff --git a/test/test_deprecation.py b/test/test_deprecation.py index 82469291..5bd521f4 100644 --- a/test/test_deprecation.py +++ b/test/test_deprecation.py @@ -32,9 +32,46 @@ DEPRECATED_3_9 = { ], } +#: Deprecated after the October 2026 over-engineering audit, for removal in +#: 3.10.0: nothing in core, the monorepo or the registry's third-party plugins +#: calls them. +DEPRECATED_3_10 = { + "src.logo_downloader.LogoDownloader": [ + "fetch_teams_data", "extract_teams_from_data", "download_missing_logos_for_league", + "download_all_ncaa_football_logos", "download_all_missing_logos", + "convert_image_to_rgba", "convert_all_logos_to_rgba", + ], + "src.config_manager.ConfigManager": [ + "rollback_config", "list_backups", "validate_config_file", "get_secret", + "cleanup_orphaned_plugin_configs", "validate_all_plugin_configs", + ], + "src.common.api_helper.APIHelper": [ + "fetch_espn_scoreboard", "fetch_espn_standings", "fetch_espn_rankings", + "set_cache", "get_cache", "set_rate_limit", "get_request_stats", + ], + "src.plugin_system.testing.plugin_test_base.PluginTestCase": ["setUp"], + "src.background_data_service.BackgroundDataService": [ + "get_result", "is_request_complete", "get_request_status", + ], + "src.plugin_system.plugin_manager.PluginManager": [ + "get_all_plugins", "get_plugin_info", "get_all_plugin_info", + "get_plugin_display_modes", "find_plugin_for_mode", + ], + "src.plugin_system.plugin_state.PluginStateManager": [ + "is_loaded", "is_running", "is_error", "get_last_update", "get_error_info", + "get_state_info", + ], + "src.cache_manager.CacheManager": ["load_cache", "generate_sport_cache_key"], + "src.font_manager.FontManager": ["measure_text", "get_native_bdf_size"], + "src.base_odds_manager.BaseOddsManager": ["get_odds_for_games", "format_odds_summary"], + "src.dynamic_team_resolver.DynamicTeamResolver": [ + "get_available_dynamic_teams", "is_dynamic_team", + ], +} + #: Every pinned marker: (class path, method) -> the release that removes it. PINNED = {(path, name): removal - for removal, table in (("3.9.0", DEPRECATED_3_9),) + for removal, table in (("3.9.0", DEPRECATED_3_9), ("3.10.0", DEPRECATED_3_10)) for path, names in table.items() for name in names} diff --git a/test/test_display_pending_changes.py b/test/test_display_pending_changes.py index 0a2443a7..794aef02 100644 --- a/test/test_display_pending_changes.py +++ b/test/test_display_pending_changes.py @@ -145,7 +145,6 @@ def vegas_coordinator(controller): coord.render_pipeline.target_fps = float(coord.vegas_config.target_fps) coord.stream_manager = MagicMock() coord.display_manager = controller.display_manager - coord.stats = {'cycles_completed': 0, 'interruptions': 0} coord._state_lock = threading.Lock() coord._is_active = True coord._is_paused = False diff --git a/test/test_error_aggregator.py b/test/test_error_aggregator.py index 2d8ca40e..aa20ccef 100644 --- a/test/test_error_aggregator.py +++ b/test/test_error_aggregator.py @@ -218,24 +218,6 @@ class TestPatternDetection: assert pattern is not None assert pattern.severity in ["error", "critical"] - def test_pattern_callback_called(self): - """Pattern detection callback should be called.""" - aggregator = ErrorAggregator(pattern_threshold=2) - - callback_called = [] - - def callback(pattern): - callback_called.append(pattern) - - aggregator.on_pattern_detected(callback) - - # Trigger pattern - for _ in range(3): - aggregator.record_error(error=ValueError("Pattern trigger")) - - assert len(callback_called) == 1 - assert callback_called[0].error_type == "ValueError" - class TestErrorSummary: """Test error summary generation.""" @@ -318,37 +300,6 @@ class TestPluginHealth: assert health["recent_error_count"] == 10 -class TestRecordClearing: - """Test clearing old records.""" - - def test_clear_old_records(self): - """Old records should be cleared.""" - aggregator = ErrorAggregator() - - # Add a record - aggregator.record_error(error=ValueError("Old error")) - - # Manually age the record - aggregator._records[0].timestamp = datetime.now() - timedelta(hours=48) - - # Clear records older than 24 hours - cleared = aggregator.clear_old_records(max_age_hours=24) - - assert cleared == 1 - assert len(aggregator._records) == 0 - - def test_recent_records_not_cleared(self): - """Recent records should not be cleared.""" - aggregator = ErrorAggregator() - - aggregator.record_error(error=ValueError("Recent error")) - - cleared = aggregator.clear_old_records(max_age_hours=24) - - assert cleared == 0 - assert len(aggregator._records) == 1 - - class TestThreadSafety: """Test thread safety of error aggregator.""" diff --git a/test/test_frame_ops.py b/test/test_frame_ops.py index 0fc3f622..dec1d772 100644 --- a/test/test_frame_ops.py +++ b/test/test_frame_ops.py @@ -330,9 +330,6 @@ class _Stream: def get_grouped_content_for_composition(self): return self.groups[0] - def get_active_plugin_ids(self): - return [pid for pid, _ in self.groups[0]] - def take_next_group(self, count=None, offscreen_only=False): if self._i >= len(self.groups): return [] diff --git a/test/test_ipc_contract.py b/test/test_ipc_contract.py index 1847f039..42fb2a24 100644 --- a/test/test_ipc_contract.py +++ b/test/test_ipc_contract.py @@ -60,7 +60,9 @@ class TestRoundTrip: def test_start_args_round_trip(self): args = OnDemandStartArgs(plugin_id='clock', mode='clock_main', duration=45.0, pinned=True) - assert OnDemandStartArgs.from_dict(_wire(args.to_dict())) == args + wire = _wire({'plugin_id': 'clock', 'mode': 'clock_main', 'duration': 45.0, + 'pinned': True}) + assert OnDemandStartArgs.from_dict(wire) == args def test_encoded_messages_are_ascii_single_lines(self): data = c.encode_message({'v': 1, 'id': 'x', 'cmd': 'ping', diff --git a/test/test_ipc_display_stage2.py b/test/test_ipc_display_stage2.py index 2d745665..93d0c085 100644 --- a/test/test_ipc_display_stage2.py +++ b/test/test_ipc_display_stage2.py @@ -349,7 +349,6 @@ class TestVegasChecksEveryFrame: coord.render_pipeline.frame_interval = 0.0 coord.render_pipeline.target_fps = 90 coord.display_manager = MagicMock() - coord.stats = {'cycles_completed': 0, 'interruptions': 0} coord._state_lock = threading.Lock() coord._is_active = True coord._is_paused = False diff --git a/test/test_ipc_server.py b/test/test_ipc_server.py index 4d15e47f..fad5dc4d 100644 --- a/test/test_ipc_server.py +++ b/test/test_ipc_server.py @@ -215,7 +215,7 @@ class TestWhereTheServerListens: assert srv.server_socket_path({}) is None assert ControlServer('x.sock').start() is False with pytest.raises(client.ControlError) as e: - client.ping(paths=['x.sock']) + client.request(Command.PING, paths=['x.sock']) assert e.value.reason == 'unsupported' @@ -271,7 +271,7 @@ def _read_line(s): class TestLiveSocket: def test_client_round_trip(self, live, sock_path): live() - assert client.ping(paths=[sock_path]) == {'pong': True} + assert client.request(Command.PING, paths=[sock_path]) == {'pong': True} assert client.hello(paths=[sock_path])['version'] == 1 assert client.on_demand_status(paths=[sock_path])['current_mode'] == 'clock' @@ -335,7 +335,7 @@ class TestLiveSocket: assert s.recv(10) == b'' # and hung up finally: s.close() - assert client.ping(paths=[sock_path]) == {'pong': True} + assert client.request(Command.PING, paths=[sock_path]) == {'pong': True} def test_a_client_that_hangs_up_mid_message(self, live, sock_path): server = live() @@ -343,7 +343,7 @@ class TestLiveSocket: s.sendall(b'{"v":1,"id":"half","cmd":"on_demand.st') s.close() time.sleep(0.2) - assert client.ping(paths=[sock_path]) == {'pong': True} + assert client.request(Command.PING, paths=[sock_path]) == {'pong': True} assert server.drain() == [] def test_a_slow_client_is_dropped_and_blocks_nobody(self, live, sock_path): @@ -352,7 +352,7 @@ class TestLiveSocket: try: slow.sendall(b'{"v":1,') # ...and never finishes t0 = time.monotonic() - assert client.ping(paths=[sock_path]) == {'pong': True} + assert client.request(Command.PING, paths=[sock_path]) == {'pong': True} assert time.monotonic() - t0 < 0.5, 'a slow client held up another' assert slow.recv(100) == b'' # hung up on, not answered finally: @@ -380,7 +380,7 @@ class TestLiveSocket: for s in held: s.close() time.sleep(0.3) - assert client.ping(paths=[sock_path]) == {'pong': True} + assert client.request(Command.PING, paths=[sock_path]) == {'pong': True} def test_many_concurrent_clients(self, live, sock_path): server = live(queue_size=64) @@ -408,7 +408,7 @@ class TestLiveSocket: s = ControlServer(sock_path, status_provider=lambda: status) try: assert s.start() - assert client.ping(paths=[sock_path]) == {'pong': True} + assert client.request(Command.PING, paths=[sock_path]) == {'pong': True} finally: s.close() @@ -416,7 +416,7 @@ class TestLiveSocket: live() second = ControlServer(sock_path, status_provider=lambda: status) assert second.start() is False - assert client.ping(paths=[sock_path]) == {'pong': True} + assert client.request(Command.PING, paths=[sock_path]) == {'pong': True} def test_a_regular_file_is_never_removed(self, sock_path): with open(sock_path, 'w') as f: @@ -434,24 +434,24 @@ class TestLiveSocket: assert second.start() try: first.close() # must not unlink second's file - assert client.ping(paths=[sock_path]) == {'pong': True} + assert client.request(Command.PING, paths=[sock_path]) == {'pong': True} finally: second.close() def test_client_reasons(self, sock_path, tmp_path): with pytest.raises(client.ControlError) as e: - client.ping(paths=[sock_path]) + client.request(Command.PING, paths=[sock_path]) assert e.value.reason == 'no_socket' dead = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) dead.bind(sock_path) try: with pytest.raises(client.ControlError) as e: - client.ping(paths=[sock_path]) + client.request(Command.PING, paths=[sock_path]) assert e.value.reason == 'refused' finally: dead.close() with pytest.raises(client.ControlError) as e: - client.ping(paths=[]) + client.request(Command.PING, paths=[]) assert e.value.reason == 'disabled' with pytest.raises(client.ControlError) as e: client.on_demand_start('x', None, None, paths=[sock_path]) @@ -464,7 +464,7 @@ class TestLiveSocket: try: t0 = time.monotonic() with pytest.raises(client.ControlError) as e: - client.ping(paths=[sock_path], timeout=0.3) + client.request(Command.PING, paths=[sock_path], timeout=0.3) assert e.value.reason == 'timeout' assert time.monotonic() - t0 < 1.0 finally: @@ -542,7 +542,7 @@ class TestPermissions: os.setgroups([]) os.setgid(gid) os.setuid(nobody.pw_uid) - result = json.dumps(client.ping(paths=[sock_path])) + result = json.dumps(client.request(Command.PING, paths=[sock_path])) except client.ControlError as e: result = 'error:' + e.reason except Exception as e: # report anything else to the parent diff --git a/test/test_ipc_state_stream.py b/test/test_ipc_state_stream.py index 5418a945..6985bbd0 100644 --- a/test/test_ipc_state_stream.py +++ b/test/test_ipc_state_stream.py @@ -522,7 +522,7 @@ class TestLiveStream: # max_clients is 2 and three streams are open: subscribers gave # their request slots back. for _ in range(4): - assert client.ping(paths=[path]) == {'pong': True} + assert client.request(Command.PING, paths=[path]) == {'pong': True} hub.publish('display', _display(mode='weather'), volatile=('last_updated',)) assert _wait_until(lambda: all( s.latest()['state']['display']['mode'] == 'weather' for s in subs)) diff --git a/test/test_operation_queue_pending_and_trim.py b/test/test_operation_queue_pending_and_trim.py index 15559732..e53750da 100644 --- a/test/test_operation_queue_pending_and_trim.py +++ b/test/test_operation_queue_pending_and_trim.py @@ -54,7 +54,7 @@ def test_second_pending_operation_for_a_plugin_is_refused(op_queue): assert _wait_for(lambda: op_queue.get_operation_status(first).status == OperationStatus.COMPLETED) # Once it has finished, the plugin accepts a new operation again. - op_queue.enqueue_operation(OperationType.UPDATE, "demo") + op_queue.enqueue_operation(OperationType.UNINSTALL, "demo") def test_operations_map_is_trimmed_with_history(op_queue): @@ -64,8 +64,8 @@ def test_operations_map_is_trimmed_with_history(op_queue): assert _wait_for(lambda: all( (op_queue.get_operation_status(i) is None or op_queue.get_operation_status(i).status == OperationStatus.COMPLETED) - for i in ids) and len(op_queue.get_operation_history()) == 3) + for i in ids) and len(op_queue._operation_history) == 3) assert len(op_queue._operations) == 3 - kept = {op.operation_id for op in op_queue.get_operation_history()} + kept = {op.operation_id for op in op_queue._operation_history} assert set(op_queue._operations) == kept diff --git a/test/test_render_gate.py b/test/test_render_gate.py index 58f0b5e1..b2f34ea0 100644 --- a/test/test_render_gate.py +++ b/test/test_render_gate.py @@ -404,7 +404,6 @@ class TestVegasWiring: off = VegasModeConfig.from_config( {"display": {"vegas_scroll": {"prefetch_gate": False}}}) assert off.prefetch_gate is False - assert off.to_dict()["prefetch_gate"] is False assert VegasModeConfig.from_config( {"display": {"vegas_scroll": {}}}).prefetch_gate is True diff --git a/test/test_scroll_helper_lazy_image.py b/test/test_scroll_helper_lazy_image.py index e49bc5f0..e785b81d 100644 --- a/test/test_scroll_helper_lazy_image.py +++ b/test/test_scroll_helper_lazy_image.py @@ -187,9 +187,6 @@ def test_vegas_extends_without_building_the_image(no_fromarray): def get_grouped_content_for_composition(self): return groups[0] - def get_active_plugin_ids(self): - return ["a"] - def take_next_group(self, count=None, offscreen_only=False): self.i += 1 return groups[self.i] if self.i < len(groups) else [] diff --git a/test/test_sync_manager.py b/test/test_sync_manager.py index 3b53a2eb..4cd2576e 100644 --- a/test/test_sync_manager.py +++ b/test/test_sync_manager.py @@ -78,7 +78,6 @@ def make_manager(role=SyncRole.STANDALONE, hw_config=None): mgr._last_leader_frame_time = 0.0 mgr._frame_lock = threading.Lock() mgr._leader_ip = None - mgr._on_new_cycle = None mgr._on_scroll_image = None mgr._pending_scroll_image = None mgr._scroll_image_lock = threading.Lock() @@ -495,31 +494,35 @@ class TestFollowerRecvLoop: assert mgr._peer_compatible is True assert mgr.logger.error.called is False - def test_scroll_x_switches_to_follower_and_builds_cycle(self): + def test_scroll_x_switches_to_follower(self): mgr = make_manager(role=SyncRole.FOLLOWER) - calls = [] - mgr._on_new_cycle = lambda: calls.append(1) self._drive(mgr, json.dumps({"t": "sx", "x": 12.34}).encode()) assert mgr._follower_state is FollowerState.FOLLOWER assert mgr.get_latest_scroll_x() == 12.34 - assert calls == [1] - def test_scroll_x_while_already_following_does_not_rebuild(self): + def test_scroll_x_while_already_following_updates_the_position(self): mgr = make_manager(role=SyncRole.FOLLOWER) mgr._follower_state = FollowerState.FOLLOWER - calls = [] - mgr._on_new_cycle = lambda: calls.append(1) self._drive(mgr, json.dumps({"t": "sx", "x": 5.0}).encode()) assert mgr.get_latest_scroll_x() == 5.0 - assert calls == [] - def test_new_cycle_message_triggers_callback(self): + @pytest.mark.parametrize("payload", [{"t": "nc"}, {"t": "some-future-type"}]) + def test_an_older_or_newer_leaders_message_is_ignored(self, payload): + # Older leaders send "nc" at each new cycle. Nothing uses it, and a + # follower must take it -- or any type it does not know -- quietly: + # not as a frame, not as a malformed packet, no error back-off. mgr = make_manager(role=SyncRole.FOLLOWER) mgr._follower_state = FollowerState.FOLLOWER - calls = [] - mgr._on_new_cycle = lambda: calls.append(1) - self._drive(mgr, json.dumps({"t": "nc"}).encode()) - assert calls == [1] + sleeps = MagicMock() + with patch.object(sync_manager, "time", + SimpleNamespace(time=time.time, monotonic=time.monotonic, + sleep=sleeps)): + self._drive(mgr, json.dumps(payload).encode()) + assert mgr._follower_state is FollowerState.FOLLOWER + assert mgr.get_latest_frame() is None + assert mgr.get_latest_scroll_x() is None + assert not mgr.logger.debug.called + sleeps.assert_not_called() def test_non_object_json_does_not_reach_the_outer_handler(self): # A bare JSON scalar parses, then msg.get() raises AttributeError. @@ -547,28 +550,6 @@ class TestFollowerRecvLoop: assert mgr.get_latest_scroll_x() is None sleeps.assert_not_called() - def test_callback_failure_is_not_mistaken_for_a_malformed_packet(self, monkeypatch): - # A payload that parses is a control message, full stop. If the - # callback it triggers raises one of the types the field guard - # catches, that fault belongs to the callback: it must not send - # the packet to the image decoder, which would report it as a - # decode error and bury the real cause. The loop still survives - # it — the outer handler catches it like any other fault. - mgr = make_manager(role=SyncRole.FOLLOWER) - mgr._follower_state = FollowerState.FOLLOWER - - def boom(): - raise ValueError("callback is broken") - - mgr._on_new_cycle = boom - fake_clock(monkeypatch, sleep_fn=MagicMock()) - self._drive(mgr, json.dumps({"t": "nc"}).encode()) - - logged = " | ".join(str(c) for c in mgr.logger.debug.call_args_list) - assert "callback is broken" in logged - assert "frame decode error" not in logged - assert "malformed control message" not in logged - def test_oversized_legacy_frame_is_rejected_before_decode(self, monkeypatch): # The UDP path is reachable by any host on the LAN, so it caps # dimensions before load() just as the TCP image server does. @@ -603,12 +584,9 @@ class TestFollowerRecvLoop: for payload in (b'{"t": "sx", "x": ' + literal.encode() + b'}', json.dumps({"t": "sx", "x": literal}).encode()): mgr = make_manager(role=SyncRole.FOLLOWER) - calls = [] - mgr._on_new_cycle = lambda: calls.append(1) self._drive(mgr, payload) assert mgr.get_latest_scroll_x() is None assert mgr._follower_state is FollowerState.STANDALONE - assert calls == [] def test_non_finite_scroll_x_leaves_a_good_value_in_place(self): # The reject must not clear the last usable position either — a @@ -697,17 +675,10 @@ class TestSendControlMessages: msg = json.loads(mgr._send_sock.sendto.call_args[0][0].decode()) assert msg == {"t": "sx", "x": 3.14} - def test_send_new_cycle(self): - mgr = self._connected_leader() - mgr.send_new_cycle() - msg = json.loads(mgr._send_sock.sendto.call_args[0][0].decode()) - assert msg == {"t": "nc"} - def test_control_messages_noop_when_disconnected(self): mgr = self._connected_leader() mgr._leader_state = LeaderState.NO_PEER mgr.send_scroll_x(1.0) - mgr.send_new_cycle() assert not mgr._send_sock.sendto.called def test_set_leader_width(self): diff --git a/test/test_vegas_config.py b/test/test_vegas_config.py index 3b51903c..b87fdfb8 100644 --- a/test/test_vegas_config.py +++ b/test/test_vegas_config.py @@ -1,10 +1,12 @@ """ Tests for src/vegas_mode/config.py -Covers VegasModeConfig: from_config, to_dict, get_frame_interval, +Covers VegasModeConfig: from_config, get_frame_interval, get_ordered_plugins, validate. """ +import dataclasses + import pytest from src.vegas_mode.config import VegasModeConfig @@ -49,7 +51,8 @@ class TestVegasModeConfigDefaults: (Path(__file__).resolve().parent.parent / "config" / "config.template.json").read_text(encoding="utf-8")) shipped = template["display"]["vegas_scroll"] - defaults = VegasModeConfig().to_dict() + defaults = dataclasses.asdict(VegasModeConfig()) + defaults["excluded_plugins"] = sorted(defaults["excluded_plugins"]) mismatched = {k: (v, defaults[k]) for k, v in shipped.items() if k in defaults and defaults[k] != v} assert not mismatched, f"template vs code default: {mismatched}" @@ -57,9 +60,9 @@ class TestVegasModeConfigDefaults: def test_missing_keys_read_the_field_defaults(self): # from_config used to repeat every default; with no keys set it must # produce exactly the dataclass defaults. - assert VegasModeConfig.from_config({}).to_dict() == VegasModeConfig().to_dict() - assert (VegasModeConfig.from_config({"display": {"vegas_scroll": {}}}).to_dict() - == VegasModeConfig().to_dict()) + assert VegasModeConfig.from_config({}) == VegasModeConfig() + assert (VegasModeConfig.from_config({"display": {"vegas_scroll": {}}}) + == VegasModeConfig()) # --------------------------------------------------------------------------- @@ -114,42 +117,6 @@ class TestFromConfig: assert cfg.frame_based_scrolling is False -# --------------------------------------------------------------------------- -# to_dict -# --------------------------------------------------------------------------- - -class TestToDict: - def test_roundtrip(self): - original = VegasModeConfig( - enabled=True, - scroll_speed=75.0, - separator_width=24, - plugin_order=["a", "b"], - excluded_plugins={"z"}, - target_fps=100, - ) - d = original.to_dict() - assert d["enabled"] is True - assert d["scroll_speed"] == 75.0 - assert d["separator_width"] == 24 - assert d["plugin_order"] == ["a", "b"] - assert "z" in d["excluded_plugins"] - assert d["target_fps"] == 100 - - def test_excluded_plugins_is_list(self): - cfg = VegasModeConfig(excluded_plugins={"x"}) - d = cfg.to_dict() - assert isinstance(d["excluded_plugins"], list) - - def test_all_keys_present(self): - d = VegasModeConfig().to_dict() - for key in ("enabled", "scroll_speed", "separator_width", "plugin_order", - "excluded_plugins", "target_fps", "buffer_ahead", - "frame_based_scrolling", "scroll_delay", - "dynamic_duration_enabled", "min_cycle_duration", "max_cycle_duration"): - assert key in d - - # --------------------------------------------------------------------------- # get_frame_interval # --------------------------------------------------------------------------- diff --git a/test/test_vegas_continuous_refresh.py b/test/test_vegas_continuous_refresh.py index 3b885f1b..7032588e 100644 --- a/test/test_vegas_continuous_refresh.py +++ b/test/test_vegas_continuous_refresh.py @@ -189,7 +189,6 @@ class TestCoordinatorWiring: coordinator.render_pipeline.is_cycle_complete.return_value = False coordinator.render_pipeline.should_recompose.return_value = False coordinator.stream_manager = MagicMock() - coordinator.stats = {'cycles_completed': 0} coordinator._state_lock = threading.Lock() coordinator._is_active = True coordinator._is_paused = False diff --git a/test/test_vegas_coordinator_config.py b/test/test_vegas_coordinator_config.py index c5b1263e..48ace3b0 100644 --- a/test/test_vegas_coordinator_config.py +++ b/test/test_vegas_coordinator_config.py @@ -25,7 +25,6 @@ def _coordinator(active=True): c.render_pipeline.is_cycle_complete.return_value = False c.stream_manager = MagicMock() c.plugin_adapter = MagicMock() - c.stats = {'cycles_completed': 0, 'config_updates': 0} c._state_lock = threading.Lock() c._is_active = active c._is_paused = False diff --git a/test/test_vegas_coordinator_iteration.py b/test/test_vegas_coordinator_iteration.py index 8e73909a..4ed319fb 100644 --- a/test/test_vegas_coordinator_iteration.py +++ b/test/test_vegas_coordinator_iteration.py @@ -27,7 +27,6 @@ def _coordinator(plugins): coord.stream_manager = MagicMock() coord.display_manager = MagicMock() coord.plugin_manager = SimpleNamespace(plugins=plugins, get_plugin=plugins.get) - coord.stats = {'cycles_completed': 0, 'interruptions': 0} coord._state_lock = threading.Lock() coord._is_active = True coord._is_paused = False @@ -99,7 +98,6 @@ def test_vegas_resumes_after_a_live_priority_pause(): def test_stop_clears_a_live_priority_pause(): coord = _live_coordinator(['nfl_live']) - coord._start_time = None coord._restore_switch_interval = lambda: None coord._remove_render_gate = lambda: None coord.run_iteration() diff --git a/test/test_vegas_crisp_pacing.py b/test/test_vegas_crisp_pacing.py index c5b87005..dcd984d6 100644 --- a/test/test_vegas_crisp_pacing.py +++ b/test/test_vegas_crisp_pacing.py @@ -26,9 +26,6 @@ class FakeStream: def get_grouped_content_for_composition(self): return [('a', [Image.new('RGB', (4000, H), (255, 255, 255))])] - def get_active_plugin_ids(self): - return ['a'] - class FakeDM: width = W diff --git a/test/test_vegas_density.py b/test/test_vegas_density.py index 2118cfd5..594492b7 100644 --- a/test/test_vegas_density.py +++ b/test/test_vegas_density.py @@ -3,6 +3,7 @@ Tests for the Vegas mode density work: dead-space trimming in PluginAdapter and the configurable lead-in gap in ScrollHelper. """ +import dataclasses from contextlib import contextmanager import pytest @@ -298,9 +299,6 @@ class TestPluginBoundaryGaps: def get_grouped_content_for_composition(self): return grouped - def get_active_plugin_ids(self): - return [pid for pid, _ in grouped] - class DM: width = DISPLAY_W height = DISPLAY_H @@ -615,10 +613,10 @@ class TestConfigSurface: assert cfg.lead_in_width == 0 assert cfg.content_padding == 8 - def test_round_trips_through_to_dict(self): + def test_round_trips_through_asdict(self): cfg = VegasModeConfig(trim_threshold=20, lead_in_width=64) restored = VegasModeConfig.from_config( - {'display': {'vegas_scroll': cfg.to_dict()}}) + {'display': {'vegas_scroll': dataclasses.asdict(cfg)}}) assert restored.trim_threshold == 20 assert restored.lead_in_width == 64 @@ -698,9 +696,6 @@ class TestMeasuredSeparation: def get_grouped_content_for_composition(self): return grouped - def get_active_plugin_ids(self): - return [pid for pid, _ in grouped] - class DM: width = DISPLAY_W height = DISPLAY_H @@ -816,9 +811,6 @@ class TestCycleEndsBeforeWrap: def get_grouped_content_for_composition(self): return [('a', [Image.new('RGB', (strip_width, DISPLAY_H), (255, 255, 255))])] - def get_active_plugin_ids(self): - return ['a'] - class DM: width = DISPLAY_W height = DISPLAY_H @@ -962,9 +954,6 @@ class TestBudgetUsesMeasuredGaps: def get_grouped_content_for_composition(self): return [('rows', selected)] - def get_active_plugin_ids(self): - return ['rows'] - class DM: width = DISPLAY_W height = DISPLAY_H @@ -1258,9 +1247,6 @@ class TestContinuousExtension: def get_grouped_content_for_composition(self): return groups[0] if groups else [] - def get_active_plugin_ids(self): - return [pid for pid, _ in (groups[0] if groups else [])] - def take_next_group(self, count=None, offscreen_only=False): self.calls.append(offscreen_only) if self._i >= len(groups): @@ -1450,9 +1436,6 @@ class TestDeferredDraining: def get_grouped_content_for_composition(self): return [('seed', [Image.new('RGB', (600, DISPLAY_H), (255, 255, 255))])] - def get_active_plugin_ids(self): - return ['seed'] - def take_next_group(self, count=None, offscreen_only=False): if self._served: return [] diff --git a/test/test_vegas_elements_layout.py b/test/test_vegas_elements_layout.py index 68961405..846e44ef 100644 --- a/test/test_vegas_elements_layout.py +++ b/test/test_vegas_elements_layout.py @@ -49,9 +49,6 @@ class _Stream: def get_grouped_content_for_composition(self): return self.groups[0] - def get_active_plugin_ids(self): - return [pid for pid, _ in self.groups[0]] - def take_next_group(self, count=None, offscreen_only=False): self.i += 1 return self.groups[self.i] if self.i < len(self.groups) else [] diff --git a/test/test_vegas_live_apply.py b/test/test_vegas_live_apply.py index 1be111d6..0b3aa4b6 100644 --- a/test/test_vegas_live_apply.py +++ b/test/test_vegas_live_apply.py @@ -43,9 +43,6 @@ class _Stream: def get_grouped_content_for_composition(self): return self.groups[0] - def get_active_plugin_ids(self): - return ["p"] - def take_next_group(self, count=None, offscreen_only=False): self.i += 1 return self.groups[self.i] if self.i < len(self.groups) else [] diff --git a/test/test_vegas_live_integration.py b/test/test_vegas_live_integration.py index 467f60bc..e186226e 100644 --- a/test/test_vegas_live_integration.py +++ b/test/test_vegas_live_integration.py @@ -189,7 +189,7 @@ def test_with_live_refresh_off_the_same_run_is_plain_content(dm, tmp_path, ticke start_width = pipeline.scroll_helper.total_scroll_width assert _run_until( coordinator, - lambda: pipeline.stats.get('extensions', 0) >= 1, seconds=10.0) + lambda: pipeline.extensions >= 1, seconds=10.0) assert pipeline.live_records() == () assert np.asarray(pipeline.scroll_helper.cached_array).shape[1] > 0 assert start_width > 0 diff --git a/test/test_vegas_live_lifecycle.py b/test/test_vegas_live_lifecycle.py index 8729c00b..f49a4b21 100644 --- a/test/test_vegas_live_lifecycle.py +++ b/test/test_vegas_live_lifecycle.py @@ -46,9 +46,6 @@ class _Stream: def get_grouped_content_for_composition(self): return self.first - def get_active_plugin_ids(self): - return ["p"] - def take_next_group(self, count=None, offscreen_only=False): self.taken += 1 return [("q", [_image(40, 99)])] diff --git a/test/test_vegas_live_property.py b/test/test_vegas_live_property.py index 75fdcfdf..3c035e3e 100644 --- a/test/test_vegas_live_property.py +++ b/test/test_vegas_live_property.py @@ -67,9 +67,6 @@ class _Stream: def get_grouped_content_for_composition(self): return self.groups[0] - def get_active_plugin_ids(self): - return [] - def take_next_group(self, count=None, offscreen_only=False): self.i += 1 return self.groups[self.i] if self.i < len(self.groups) else [] diff --git a/test/test_vegas_prepared_blocks.py b/test/test_vegas_prepared_blocks.py index f9df9289..75e73c63 100644 --- a/test/test_vegas_prepared_blocks.py +++ b/test/test_vegas_prepared_blocks.py @@ -65,9 +65,6 @@ class _Stream: def get_grouped_content_for_composition(self): return self.groups[0] - def get_active_plugin_ids(self): - return [] - def take_next_group(self, count=None, offscreen_only=False): self.i += 1 return self.groups[self.i] if self.i < len(self.groups) else [] diff --git a/test/test_vegas_static_mode.py b/test/test_vegas_static_mode.py index 2d3f6679..33089c7b 100644 --- a/test/test_vegas_static_mode.py +++ b/test/test_vegas_static_mode.py @@ -52,9 +52,6 @@ class FakeStream: def get_static_layout(self): return [(pid, w is None) for pid, w in self.layout] - def get_active_plugin_ids(self): - return [pid for pid, _ in self.layout] - def is_static_plugin(self, plugin_id): return plugin_id in self.statics @@ -163,7 +160,6 @@ class TestStreamManagerSkipsStaticContent: sm.plugin_manager = SimpleNamespace(plugins=plugins) sm.plugin_adapter = MagicMock() sm.plugin_adapter.get_content.return_value = [block(10)] - sm.stats = {'segments_fetched': 0, 'fetch_errors': 0} sm.refresh = lambda: None return sm @@ -213,7 +209,6 @@ class TestCoordinatorStaticPause: coord._live_priority_active = False coord._live_priority_check = None coord._interrupt_check = None - coord.stats = {'static_pauses': 0} return coord def _plugin(self): diff --git a/test/test_web_smoke.py b/test/test_web_smoke.py index 6ccac074..a25708d6 100644 --- a/test/test_web_smoke.py +++ b/test/test_web_smoke.py @@ -174,7 +174,7 @@ def test_widget_bundle_is_served_and_requested(client): assert resp.mimetype == "application/javascript" body = resp.get_data(as_text=True) assert body.index("/* registry.js */") < body.index("/* notification.js */") - assert "/* json-file-manager.js */" in body + assert "/* plugin-loader.js */" in body def test_durations_page_groups_by_plugin(client): diff --git a/test/web_interface/integration/test_plugin_operations.py b/test/web_interface/integration/test_plugin_operations.py index dc9d08e5..b3635020 100644 --- a/test/web_interface/integration/test_plugin_operations.py +++ b/test/web_interface/integration/test_plugin_operations.py @@ -66,31 +66,6 @@ class TestPluginOperationsIntegration(unittest.TestCase): history = self.operation_history.get_history(plugin_id=plugin_id) self.assertEqual([r.operation_type for r in history], ["install"]) - def test_update_operation_flow(self): - """Test complete update operation flow.""" - plugin_id = "test-plugin" - - # Enqueue update operation - operation_id = self.operation_queue.enqueue_operation( - OperationType.UPDATE, - plugin_id, - {"from_version": "1.0.0", "to_version": "2.0.0"} - ) - - self.assertIsNotNone(operation_id) - - # Record in history - self.operation_history.record_operation( - operation_type="update", - plugin_id=plugin_id, - status="in_progress", - operation_id=operation_id - ) - - # Verify history - history = self.operation_history.get_history(plugin_id=plugin_id) - self.assertEqual([r.operation_type for r in history], ["update"]) - def test_uninstall_operation_flow(self): """Test complete uninstall operation flow.""" plugin_id = "test-plugin" @@ -162,7 +137,7 @@ class TestPluginOperationsIntegration(unittest.TestCase): # The prevention only works for truly concurrent (pending/running) operations try: op2_id = self.operation_queue.enqueue_operation( - OperationType.UPDATE, + OperationType.UNINSTALL, plugin_id ) # If no exception, the first operation may have completed already diff --git a/test/web_interface/test_api_v3_helpers.py b/test/web_interface/test_api_v3_helpers.py index 068e97e3..9f5afd6b 100644 --- a/test/web_interface/test_api_v3_helpers.py +++ b/test/web_interface/test_api_v3_helpers.py @@ -22,7 +22,6 @@ from web_interface.blueprints.api_v3 import ( # noqa: E402 _is_plugin_update_available, _coerce_to_bool, deep_merge, - _parse_form_value, _get_schema_property, _set_nested_value, _SKIP_FIELD, @@ -116,44 +115,6 @@ class TestDeepMerge: assert result["keep"] is base["keep"] # untouched subtree is shared -class TestParseFormValue: - def test_boolean_strings(self): - assert _parse_form_value("true") is True - assert _parse_form_value("False") is False - - def test_null_like_strings(self): - assert _parse_form_value("null") is None - assert _parse_form_value("none") is None - assert _parse_form_value("") is None - - def test_none_passthrough(self): - assert _parse_form_value(None) is None - - def test_numbers(self): - assert _parse_form_value("42") == 42 - assert isinstance(_parse_form_value("42"), int) - assert _parse_form_value("3.5") == 3.5 - assert isinstance(_parse_form_value("3.5"), float) - - def test_json_array_parsed_before_numbers(self): - # RGB arrays like "[255, 0, 0]" must come back as lists. - assert _parse_form_value("[255, 0, 0]") == [255, 0, 0] - - def test_json_object(self): - assert _parse_form_value('{"a": 1}') == {"a": 1} - - def test_malformed_json_falls_back_to_string(self): - assert _parse_form_value("[not json") == "[not json" - - def test_plain_string_returned_unstripped(self): - # The original value (not the stripped copy) is returned. - assert _parse_form_value(" hello ") == " hello " - - def test_non_string_passthrough(self): - assert _parse_form_value(7) == 7 - assert _parse_form_value([1, 2]) == [1, 2] - - class TestGetSchemaProperty: SCHEMA: ClassVar[Dict[str, Any]] = { "properties": { diff --git a/test/web_interface/test_error_handler.py b/test/web_interface/test_error_handler.py index 74926c31..1c057c81 100644 --- a/test/web_interface/test_error_handler.py +++ b/test/web_interface/test_error_handler.py @@ -1,24 +1,23 @@ """ -Tests for the response builders in src/web_interface/error_handler.py and -the success path in src/web_interface/api_helpers.py. +Tests for the response builders in src/web_interface/api_helpers.py. -describe_exception() in the same module is already covered by +describe_exception() in error_handler.py is already covered by test/test_web_error_detail.py and is not duplicated here. -Regression coverage for one fixed bug: create_success_response used +Regression coverage for one fixed bug: the success builder used truthiness for `message` and `metadata` while using `is not None` for `data`, so an explicitly-passed "" or {} was silently dropped — -api_helpers.success_response() repeated the same gate, which is the path -every api_v3 endpoint actually calls. +success_response() repeated the same gate, which is the path every api_v3 +endpoint actually calls. """ import pytest from flask import Flask -from src.web_interface.api_helpers import exception_error_response, success_response -from src.web_interface.error_handler import ( - create_error_response, - create_success_response, +from src.web_interface.api_helpers import ( + error_response, + exception_error_response, + success_response, ) from src.web_interface.errors import ErrorCode, WebInterfaceError @@ -28,23 +27,23 @@ def app(): return Flask(__name__) -class TestCreateErrorResponse: +class TestErrorResponse: def test_returns_response_and_status_tuple(self, app): with app.test_request_context(): - response, status = create_error_response( + response, status = error_response( ErrorCode.CONFIG_SAVE_FAILED, "could not save") assert status == 500 assert response.get_json()["message"] == "could not save" def test_status_code_passthrough(self, app): with app.test_request_context(): - _, status = create_error_response( + _, status = error_response( ErrorCode.INVALID_INPUT, "bad", status_code=400) assert status == 400 def test_body_matches_the_error_dataclass(self, app): with app.test_request_context(): - response, _ = create_error_response( + response, _ = error_response( ErrorCode.NETWORK_ERROR, "offline", details="connection refused", context={"url": "http://x"}) expected = WebInterfaceError( @@ -54,12 +53,12 @@ class TestCreateErrorResponse: def test_none_context_produces_no_context_key(self, app): with app.test_request_context(): - response, _ = create_error_response(ErrorCode.SYSTEM_ERROR, "boom") + response, _ = error_response(ErrorCode.SYSTEM_ERROR, "boom") assert "context" not in response.get_json() def test_suggested_fixes_passed_through(self, app): with app.test_request_context(): - response, _ = create_error_response( + response, _ = error_response( ErrorCode.SYSTEM_ERROR, "boom", suggested_fixes=["Try again"]) assert response.get_json()["suggested_fixes"] == ["Try again"] @@ -74,7 +73,6 @@ class TestExceptionErrorResponse: @staticmethod def _by_hand(exc, code, with_context): - from src.web_interface.api_helpers import error_response error = WebInterfaceError.from_exception(exc, code) if with_context: return error_response(error.error_code, error.message, @@ -117,39 +115,45 @@ class TestExceptionErrorResponse: assert "context" not in response.get_json() -class TestCreateSuccessResponse: +def _success_body(**kwargs): + """success_response()'s body, outside any request timing.""" + with Flask(__name__).test_request_context(): + return success_response(**kwargs).get_json() + + +class TestSuccessResponseBody: def test_bare_success(self): - assert create_success_response() == {"status": "success"} + assert _success_body() == {"status": "success"} def test_data_included(self): - assert create_success_response(data={"a": 1})["data"] == {"a": 1} + assert _success_body(data={"a": 1})["data"] == {"a": 1} @pytest.mark.parametrize("falsy", [0, "", False, {}, []]) def test_falsy_data_is_still_included(self, falsy): - assert create_success_response(data=falsy)["data"] == falsy + assert _success_body(data=falsy)["data"] == falsy def test_none_data_omitted(self): - assert "data" not in create_success_response(data=None) + assert "data" not in _success_body(data=None) def test_message_included(self): - assert create_success_response(message="done")["message"] == "done" + assert _success_body(message="done")["message"] == "done" def test_empty_message_is_still_included(self): # Regression: `if message:` dropped an explicitly-passed "". - assert create_success_response(message="")["message"] == "" + assert _success_body(message="")["message"] == "" def test_none_message_omitted(self): - assert "message" not in create_success_response(message=None) + assert "message" not in _success_body(message=None) def test_metadata_included(self): - assert create_success_response(metadata={"v": 1})["metadata"] == {"v": 1} + assert _success_body(metadata={"v": 1})["metadata"] == {"v": 1} def test_empty_metadata_is_still_included(self): # Regression: `if metadata:` dropped an explicitly-passed {}. - assert create_success_response(metadata={})["metadata"] == {} + assert _success_body(metadata={})["metadata"] == {} def test_none_metadata_omitted(self): - assert "metadata" not in create_success_response(metadata=None) + assert "metadata" not in _success_body(metadata=None) class TestSuccessResponseHelper: @@ -162,8 +166,8 @@ class TestSuccessResponseHelper: def test_explicit_empty_metadata_survives_the_wrapper(self, app): # Regression: the wrapper re-gated metadata on truthiness after - # create_success_response had already included it, so {} was - # dropped again on the way out. + # the body builder had already included it, so {} was dropped + # again on the way out. with app.test_request_context(): body = success_response(data=None, metadata={}).get_json() assert body["metadata"] == {} diff --git a/test/web_interface/test_plugin_operation_queue.py b/test/web_interface/test_plugin_operation_queue.py index b68d8f34..e3394775 100644 --- a/test/web_interface/test_plugin_operation_queue.py +++ b/test/web_interface/test_plugin_operation_queue.py @@ -55,7 +55,7 @@ class TestPluginOperationQueue(unittest.TestCase): # behavior may differ. For this test, we'll verify the mechanism exists. try: self.queue.enqueue_operation( - OperationType.UPDATE, + OperationType.UNINSTALL, "test-plugin" ) # If no exception, the first operation may have completed @@ -64,21 +64,6 @@ class TestPluginOperationQueue(unittest.TestCase): # Expected behavior - concurrent operation prevented pass - def test_operation_cancellation(self): - """Test cancelling a pending operation.""" - operation_id = self.queue.enqueue_operation( - OperationType.INSTALL, - "test-plugin" - ) - - # Cancel operation - success = self.queue.cancel_operation(operation_id) - self.assertTrue(success) - - # Check status - operation = self.queue.get_operation_status(operation_id) - self.assertEqual(operation.status, OperationStatus.CANCELLED) - def test_operation_history(self): """Test operation history tracking.""" # Enqueue and complete an operation @@ -92,7 +77,7 @@ class TestPluginOperationQueue(unittest.TestCase): time.sleep(0.5) # Check history - history = self.queue.get_operation_history(limit=10) + history = self.queue._operation_history self.assertGreater(len(history), 0) # Find our operation in history diff --git a/test/web_interface/test_state_reconciliation.py b/test/web_interface/test_state_reconciliation.py index fcab4012..452cd0c0 100644 --- a/test/web_interface/test_state_reconciliation.py +++ b/test/web_interface/test_state_reconciliation.py @@ -100,7 +100,6 @@ class TestStateReconciliation(unittest.TestCase): inconsistency = result.inconsistencies_found[0] self.assertEqual(inconsistency.plugin_id, "plugin1") self.assertEqual(inconsistency.inconsistency_type, InconsistencyType.PLUGIN_MISSING_IN_CONFIG) - self.assertTrue(inconsistency.can_auto_fix) self.assertEqual(inconsistency.fix_action, FixAction.AUTO_FIX) def test_plugin_missing_on_disk(self): @@ -115,7 +114,6 @@ class TestStateReconciliation(unittest.TestCase): inconsistency = result.inconsistencies_found[0] self.assertEqual(inconsistency.plugin_id, "plugin1") self.assertEqual(inconsistency.inconsistency_type, InconsistencyType.PLUGIN_MISSING_ON_DISK) - self.assertFalse(inconsistency.can_auto_fix) self.assertEqual(inconsistency.fix_action, FixAction.MANUAL_FIX_REQUIRED) def test_enabled_but_not_loaded_is_reported_not_fixed(self): @@ -139,7 +137,6 @@ class TestStateReconciliation(unittest.TestCase): inconsistency = result.inconsistencies_found[0] self.assertEqual(inconsistency.inconsistency_type, InconsistencyType.PLUGIN_ENABLED_MISMATCH) self.assertEqual(inconsistency.fix_action, FixAction.NO_ACTION) - self.assertFalse(inconsistency.can_auto_fix) self.assertIn("No module named", inconsistency.description) self.assertEqual(result.inconsistencies_fixed, []) self.assertEqual(result.inconsistencies_manual, []) @@ -410,7 +407,6 @@ class TestStateReconciliationUnrecoverable(unittest.TestCase): # Still one inconsistency, still no install attempt, no new registry fetch self.assertEqual(len(result.inconsistencies_found), 1) inc = result.inconsistencies_found[0] - self.assertFalse(inc.can_auto_fix) self.assertEqual(inc.fix_action, FixAction.MANUAL_FIX_REQUIRED) self.store_manager.install_plugin.assert_not_called() self.store_manager.fetch_registry.assert_not_called() @@ -458,7 +454,6 @@ class TestStateReconciliationUnrecoverable(unittest.TestCase): self.assertEqual(len(result.inconsistencies_found), 1) inc = result.inconsistencies_found[0] - self.assertFalse(inc.can_auto_fix) self.assertEqual(inc.fix_action, FixAction.MANUAL_FIX_REQUIRED) self.store_manager.install_plugin.assert_not_called() diff --git a/test/web_interface/test_web_process_runs_no_plugin_code.py b/test/web_interface/test_web_process_runs_no_plugin_code.py index 32ac40a0..08962153 100644 --- a/test/web_interface/test_web_process_runs_no_plugin_code.py +++ b/test/web_interface/test_web_process_runs_no_plugin_code.py @@ -119,7 +119,7 @@ class Web: self.config_manager.template_path = str(tmp_path / "no-template.json") self.schema_manager = SchemaManager(plugins_dir=self.plugins_dir, project_root=tmp_path, config_manager=self.config_manager) - self.catalog = PluginCatalog(self.plugins_dir, self.config_manager, self.schema_manager) + self.catalog = PluginCatalog(self.plugins_dir) api = self.api = api_v3_module.api_v3 api.config_manager = self.config_manager @@ -242,7 +242,8 @@ class TestTheWebProcessNeverRunsAPlugin: _bump_version(web, "1.1.0") body = web.post("/api/v3/plugins/update", {"plugin_id": PLUGIN_ID}) assert body["data"]["update_status"] == "updated" - assert web.catalog.get_installed_version(PLUGIN_ID) == "1.1.0" + # The route rescans the catalog, which now has the new manifest. + assert web.catalog.get_manifest(PLUGIN_ID)["version"] == "1.1.0" assert web.ran() == [] def test_installing_it(self, web): @@ -497,16 +498,15 @@ class TestCatalogReadsWhatIsInstalled: assert expected, f"no plugins under {root}" before = set(sys.modules) schema_manager = SchemaManager(plugins_dir=root, project_root=PROJECT_ROOT) - catalog = PluginCatalog(root, schema_manager=schema_manager) + catalog = PluginCatalog(root) assert set(catalog.discover_plugins()) == set(expected) for plugin_id, (plugin_dir, manifest) in expected.items(): assert catalog.get_manifest(plugin_id) == manifest assert catalog.get_plugin_directory(plugin_id) == str(plugin_dir) - assert catalog.get_installed_version(plugin_id) == manifest.get("version", "") assert catalog.get_plugin_display_modes(plugin_id) == manifest.get("display_modes", []) if (plugin_dir / "config_schema.json").exists(): - schema = catalog.get_schema(plugin_id, use_cache=False) + schema = schema_manager.load_schema(plugin_id, use_cache=False) assert isinstance(schema, dict) and "properties" in schema, plugin_id imported = [name for name in set(sys.modules) - before @@ -537,16 +537,16 @@ class TestCatalogReadsWhatIsInstalled: assert catalog.discover_plugins() == [] assert catalog.get_manifest("ci-fixture-plugin") is None - def test_enabled_follows_the_display_rule(self, tmp_path): + def test_enabled_follows_the_display_rule(self, api_v3_module): config = MagicMock() config.load_config.return_value = {"a": {"enabled": True}, "b": {}, "c": "junk"} - catalog = PluginCatalog(tmp_path, config_manager=config) - assert catalog.is_enabled("a") is True + api_v3_module.api_v3.config_manager = config + enabled = api_v3_module._plugin_enabled_in_config + assert enabled("a") is True # The display runs a plugin only when its section says so. - assert catalog.is_enabled("b") is False - assert catalog.is_enabled("c") is False - assert catalog.is_enabled("missing") is False - assert catalog.get_config("c") == {} + assert enabled("b") is False + assert enabled("c") is False + assert enabled("missing") is False def test_it_has_nothing_that_runs_a_plugin(self, tmp_path): catalog = PluginCatalog(tmp_path) diff --git a/web_interface/app.py b/web_interface/app.py index 4f4ca235..dcaa417e 100644 --- a/web_interface/app.py +++ b/web_interface/app.py @@ -159,11 +159,7 @@ schema_manager = SchemaManager( # saves reach the running plugins through the display's config watcher; what # the display knows at run time (health, metrics, errors, current mode) it # publishes to the shared cache. See docs/ARCHITECTURE.md. -plugin_catalog = PluginCatalog( - plugins_dir=plugins_dir, - config_manager=config_manager, - schema_manager=schema_manager, -) +plugin_catalog = PluginCatalog(plugins_dir=plugins_dir) # Initialize operation queue for plugin operations operation_queue = PluginOperationQueue(max_history=500) @@ -173,12 +169,10 @@ operation_queue = PluginOperationQueue(max_history=500) # snapshot the display publishes (src/plugin_system/plugin_runtime.py). An # existing file is left where it is, unread; see docs/ARCHITECTURE.md. -# Initialize operation history -# Use lazy_load=True to defer file loading until first use (improves startup time) +# Initialize operation history (its file is read on first use, not at startup) operation_history = OperationHistory( history_file=str(project_root / "data" / "operation_history.json"), - max_records=1000, - lazy_load=True + max_records=1000 ) # Plugin discovery is deferred until first API request that needs it diff --git a/web_interface/blueprints/api_v3/__init__.py b/web_interface/blueprints/api_v3/__init__.py index b40105be..4410c67a 100644 --- a/web_interface/blueprints/api_v3/__init__.py +++ b/web_interface/blueprints/api_v3/__init__.py @@ -46,6 +46,7 @@ from src.web_interface.error_handler import (describe_exception, http_exception_ redact_text, unhandled_exception_payload) from werkzeug.exceptions import HTTPException from src.plugin_system.operation_types import OperationType +from src.web_interface.config_arrays import _schema_type_is from src.web_interface.validators import ( validate_file_upload ) @@ -881,46 +882,6 @@ def deep_merge(base_dict, update_dict): # For non-dict values or new keys, use the update value result[key] = value return result -def _parse_form_value(value): - """ - Parse a form value into the appropriate Python type. - Handles booleans, numbers, JSON arrays/objects, and strings. - """ - if value is None: - return None - - # Handle string values - if isinstance(value, str): - stripped = value.strip() - - # Check for boolean strings - if stripped.lower() == 'true': - return True - if stripped.lower() == 'false': - return False - if stripped.lower() in ('null', 'none') or stripped == '': - return None - - # Try parsing as JSON (for arrays and objects) - do this BEFORE number parsing - # This handles RGB arrays like "[255, 0, 0]" correctly - if stripped.startswith('[') or stripped.startswith('{'): - try: - return json.loads(stripped) - except json.JSONDecodeError: - pass - - # Try parsing as number - try: - if '.' in stripped: - return float(stripped) - return int(stripped) - except ValueError: - pass - - # Return as string (original value, not stripped) - return value - - return value def _get_schema_property(schema, key_path): """ Get the schema property for a given key path (supports dot notation). @@ -1012,22 +973,6 @@ def _is_field_required(key_path, schema): _SKIP_FIELD = object() -def _schema_type_is(prop, wanted): - """Whether a schema property is of ``wanted`` type. - - JSON Schema allows a union (``["array", "null"]``), which the per-element - style system uses for its per-mode override fields: null there means - "inherit the base", so the type genuinely is "an array or nothing". A - bare ``prop.get('type') == 'array'`` reads False for those, which meant - the indexed colour inputs a form posts as ``...text_color.0/.1/.2`` were - never recombined into a list. - """ - if not isinstance(prop, dict): - return False - declared = prop.get('type') - if isinstance(declared, list): - return wanted in declared - return declared == wanted def _schema_allows_null(prop): diff --git a/web_interface/requirements.txt b/web_interface/requirements.txt index ea140dc7..df4cdb2c 100644 --- a/web_interface/requirements.txt +++ b/web_interface/requirements.txt @@ -8,7 +8,6 @@ werkzeug>=3.1.6,<4.0.0 # Flask transitive; pinned to keep a security floor abov flask-limiter>=3.5.0,<4.0.0 # Rate limiting (prevent accidental abuse) flask-compress>=1.14 # gzip/brotli response compression (big win for the large JS/HTML over WiFi) jinja2>=3.1.6,<4.0.0 # Flask transitive, but imported directly (TemplateNotFound); 3.1.6 is the security floor — earlier 3.1.x has sandbox breakouts -markupsafe>=2.1.0,<4.0.0 # Flask transitive, but imported directly (escape) # WebSocket support: intentionally NOT declared here. The web interface # uses Server-Sent Events, and plugins that need Socket.IO (e.g. diff --git a/web_interface/static/v3/app.js b/web_interface/static/v3/app.js index 0c046c34..27207e87 100644 --- a/web_interface/static/v3/app.js +++ b/web_interface/static/v3/app.js @@ -15,7 +15,7 @@ * is the one Alpine uses) * end of , defer, in this order: app.js, js/tooltips.js, * js/settings-search.js, js/utils/dialog.js, - * js/utils/error_handler.js, js/plugins/api_client.js, + * js/plugins/api_client.js, * state_manager.js, install_manager.js, list_filter.js, * the widget bundle (web_interface/widget_bundle.py), * plugins_manager.js @@ -252,8 +252,7 @@ document.addEventListener('keydown', function(e) { // alone because during the x-transition between tabs both are visible. // requestSubmit() runs validation and onsubmit guards like a real submit; // with no visible form, do nothing. Inside a modal dialog the shortcut is - // the dialog's (json-file-manager saves its file on Ctrl+S), so the tab's - // form is left alone. + // the dialog's, so the tab's form is left alone. if ((e.ctrlKey || e.metaKey) && e.key === 's') { e.preventDefault(); const active = document.activeElement; diff --git a/web_interface/static/v3/js/app-early.js b/web_interface/static/v3/js/app-early.js index d41abf4e..6d5e5be9 100644 --- a/web_interface/static/v3/js/app-early.js +++ b/web_interface/static/v3/js/app-early.js @@ -15,7 +15,7 @@ * is the one Alpine uses) * end of , defer, in this order: app.js, js/tooltips.js, * js/settings-search.js, js/utils/dialog.js, - * js/utils/error_handler.js, js/plugins/api_client.js, + * js/plugins/api_client.js, * state_manager.js, install_manager.js, list_filter.js, * the widget bundle (web_interface/widget_bundle.py), * plugins_manager.js diff --git a/web_interface/static/v3/js/app-shell.js b/web_interface/static/v3/js/app-shell.js index 43e25ebf..db486d7c 100644 --- a/web_interface/static/v3/js/app-shell.js +++ b/web_interface/static/v3/js/app-shell.js @@ -17,7 +17,7 @@ * is the one Alpine uses) * end of , defer, in this order: app.js, js/tooltips.js, * js/settings-search.js, js/utils/dialog.js, - * js/utils/error_handler.js, js/plugins/api_client.js, + * js/plugins/api_client.js, * state_manager.js, install_manager.js, list_filter.js, * the widget bundle (web_interface/widget_bundle.py), * plugins_manager.js diff --git a/web_interface/static/v3/js/plugins/api_client.js b/web_interface/static/v3/js/plugins/api_client.js index 1f9cad65..562d4691 100644 --- a/web_interface/static/v3/js/plugins/api_client.js +++ b/web_interface/static/v3/js/plugins/api_client.js @@ -219,55 +219,6 @@ const PluginAPI = { return []; }, - /** - * Toggle plugin enabled/disabled. - * - * @param {string} pluginId - Plugin identifier - * @param {boolean} enabled - Whether plugin should be enabled - * @returns {Promise} Response data - */ - async togglePlugin(pluginId, enabled) { - return await this.request('/plugins/toggle', 'POST', { - plugin_id: pluginId, - enabled: enabled - }); - }, - - /** - * Get plugin configuration. - * - * @param {string} pluginId - Plugin identifier - * @returns {Promise} Plugin configuration - */ - async getPluginConfig(pluginId) { - const response = await this.request(`/plugins/config?plugin_id=${encodeURIComponent(pluginId)}`); - return response.data || {}; - }, - - /** - * Save plugin configuration. - * - * @param {string} pluginId - Plugin identifier - * @param {Object} config - Configuration data - * @returns {Promise} Response data - */ - async savePluginConfig(pluginId, config) { - return await this.request('/plugins/config', 'POST', { - plugin_id: pluginId, - config: config - }); - }, - - /** - * Reset plugin configuration to defaults. - * - * @param {string} pluginId - Plugin identifier - * @returns {Promise} Response data - */ - async resetPluginConfig(pluginId) { - return await this.request(`/plugins/config/reset?plugin_id=${encodeURIComponent(pluginId)}`, 'POST'); - }, - /** * Update plugin. * diff --git a/web_interface/static/v3/js/plugins/state_manager.js b/web_interface/static/v3/js/plugins/state_manager.js index 30b9282b..d7b3eef8 100644 --- a/web_interface/static/v3/js/plugins/state_manager.js +++ b/web_interface/static/v3/js/plugins/state_manager.js @@ -22,8 +22,9 @@ const PluginStateManager = { window.installedPlugins = plugins; // For backward compatibility return plugins; } catch (error) { - if (window.errorHandler) { - window.errorHandler.displayError(error, 'Failed to load installed plugins'); + if (typeof window.showNotification === 'function') { + window.showNotification('Failed to load installed plugins: ' + + ((error && error.message) || String(error)), 'error'); } throw error; } diff --git a/web_interface/static/v3/js/utils/error_handler.js b/web_interface/static/v3/js/utils/error_handler.js deleted file mode 100644 index f0fd0700..00000000 --- a/web_interface/static/v3/js/utils/error_handler.js +++ /dev/null @@ -1,350 +0,0 @@ -/* global showNotification */ -/** - * Frontend error handling utilities. - * - * Provides user-friendly error formatting and display with enhanced UI. - */ - -/** - * Comprehensive error code to user-friendly message mapping. - * Used only when the server response carries no message of its own. - */ -const ERROR_MESSAGES = { - // Configuration errors - 'CONFIG_SAVE_FAILED': "Couldn't save your settings. Check the values and try again", - 'CONFIG_LOAD_FAILED': "Couldn't load your settings. Reload the page to try again", - 'CONFIG_VALIDATION_FAILED': 'Some settings have invalid values. Fix the highlighted fields and save again', - 'CONFIG_ROLLBACK_FAILED': "Couldn't restore the previous settings. Try again, or restore from Backup & Restore", - - // Plugin errors - 'PLUGIN_NOT_FOUND': "That plugin isn't installed. Check the Plugin Manager", - 'PLUGIN_INSTALL_FAILED': "Couldn't install the plugin. Check the Pi's internet connection and try again", - 'PLUGIN_UPDATE_FAILED': "Couldn't update the plugin. Try again in a moment", - 'PLUGIN_UNINSTALL_FAILED': "Couldn't uninstall the plugin. Try again in a moment", - 'PLUGIN_LOAD_FAILED': "The plugin couldn't start. Check the Logs tab for details", - 'PLUGIN_OPERATION_CONFLICT': 'Another plugin operation is still running. Wait for it to finish, then try again', - - // Validation errors - 'VALIDATION_ERROR': 'Some values are invalid. Fix them and try again', - 'SCHEMA_VALIDATION_FAILED': "Some settings don't match what the plugin expects. Fix them and save again", - 'INVALID_INPUT': "That value isn't valid. Check it and try again", - - // Network errors - 'NETWORK_ERROR': "Couldn't reach the LEDMatrix device. Check that it's on and connected, then try again", - 'API_ERROR': 'The request failed. Try again in a moment', - 'TIMEOUT': 'This took too long to respond. Try again in a moment', - - // Permission errors - 'PERMISSION_DENIED': "LEDMatrix doesn't have permission to do that. See the troubleshooting guide", - 'FILE_PERMISSION_ERROR': "LEDMatrix can't write a file it needs. See the troubleshooting guide", - - // System errors - 'SYSTEM_ERROR': 'Something went wrong on the device. Check the Logs tab for details', - 'SERVICE_UNAVAILABLE': 'The LEDMatrix service is not running. Restart it from the Overview tab', - - // Unknown errors - 'UNKNOWN_ERROR': 'Something went wrong. Try again, and check the Logs tab if it keeps happening' -}; - -/** - * Error code to troubleshooting documentation links. - */ -const TROUBLESHOOTING_URL = 'https://github.com/ChuckBuilds/LEDMatrix/blob/main/docs/TROUBLESHOOTING.md'; -const ERROR_DOCS = { - 'CONFIG_SAVE_FAILED': TROUBLESHOOTING_URL + '#4-check-configuration', - 'CONFIG_VALIDATION_FAILED': TROUBLESHOOTING_URL + '#4-check-configuration', - 'PLUGIN_INSTALL_FAILED': TROUBLESHOOTING_URL + '#plugin-issues', - 'PLUGIN_OPERATION_CONFLICT': TROUBLESHOOTING_URL + '#plugin-issues', - 'PERMISSION_DENIED': TROUBLESHOOTING_URL + '#permission-issues', - 'FILE_PERMISSION_ERROR': TROUBLESHOOTING_URL + '#permission-issues' -}; - -/** - * Format error message for display to user. - * - * @param {Object} error - Error object from API response - * @returns {string} Formatted error message - */ -function formatError(error) { - if (!error) { - return ERROR_MESSAGES.UNKNOWN_ERROR; - } - - // If error is a string, return it - if (typeof error === 'string') { - return error; - } - - // If error has a message, use it - if (error.message) { - return error.message; - } - - // If error has error_code, format it - if (error.error_code) { - const message = ERROR_MESSAGES[error.error_code] || error.message || ERROR_MESSAGES.UNKNOWN_ERROR; - - // Add details if available - if (error.details) { - return `${message}: ${error.details}`; - } - - return message; - } - - return ERROR_MESSAGES.UNKNOWN_ERROR; -} - -/** - * Get suggested fixes for an error. - * - * @param {Object} error - Error object from API response - * @returns {Array} Array of suggested fixes - */ -function getSuggestedFixes(error) { - if (!error || !error.suggested_fixes) { - return []; - } - - return error.suggested_fixes; -} - -/** - * Display error with suggestions in a rich UI. - * - * @param {Object} error - Error object from API response - * @param {string} context - Optional context about what was being done - * @param {Object} options - Display options - * @param {boolean} options.showDetails - Whether to show detailed error modal - * @param {boolean} options.showCopyButton - Whether to show copy button - */ -function displayError(error, context = null, options = {}) { - const message = formatError(error); - const suggestions = getSuggestedFixes(error); - const errorCode = error?.error_code; - const docLink = errorCode ? ERROR_DOCS[errorCode] : null; - - // Build full message - let fullMessage = message; - if (context) { - fullMessage = `${context}: ${message}`; - } - - if (suggestions.length > 0) { - fullMessage += '\n\nSuggested fixes:\n' + suggestions.map(s => `• ${s}`).join('\n'); - } - - // If showDetails is true, show a rich error modal - if (options.showDetails !== false && (suggestions.length > 0 || docLink || error?.details)) { - showErrorModal(error, context, message, suggestions, docLink); - } else { - // Simple notification - showNotification(fullMessage, 'error'); - } -} - -// Release function for the open error modal's focus trap (see utils/dialog.js). -let errorModalRelease = null; - -/** - * Show a rich error modal with details, suggestions, and copy button. - * - * @param {Object} error - Error object - * @param {string} context - Context - * @param {string} message - Formatted message - * @param {Array} suggestions - Suggested fixes - * @param {string} docLink - Documentation link - */ -function showErrorModal(error, context, message, suggestions, docLink) { - error = error || {}; - suggestions = suggestions || []; - - // Create modal container if it doesn't exist - let modalContainer = document.getElementById('error-modal-container'); - if (!modalContainer) { - modalContainer = document.createElement('div'); - modalContainer.id = 'error-modal-container'; - modalContainer.className = 'fixed inset-0 z-50 overflow-y-auto'; - modalContainer.style.display = 'none'; - document.body.appendChild(modalContainer); - } - - // Re-opening while open: release the previous trap before replacing it. - if (errorModalRelease) { - errorModalRelease(); - errorModalRelease = null; - } - - // Build modal content - const contextText = context ? `
${window.LEDEscape.html(context)}
` : ''; - const suggestionsHtml = suggestions.length > 0 ? ` -
-

Suggested fixes:

-
    - ${suggestions.map(s => `
  • ${window.LEDEscape.html(s)}
  • `).join('')} -
-
- ` : ''; - - const docLinkHtml = docLink ? ` - - ` : ''; - - const detailsHtml = error.details ? ` -
-
- Technical details -
${window.LEDEscape.html(error.details)}
-
-
- ` : ''; - - const errorCodeHtml = error.error_code ? ` -
- Error code: ${window.LEDEscape.html(error.error_code)} -
- ` : ''; - - modalContainer.innerHTML = ` -
- - -
-
-
- -
-

Something went wrong

-
- ${contextText} -

${window.LEDEscape.html(message)}

- ${errorCodeHtml} - ${suggestionsHtml} - ${docLinkHtml} - ${detailsHtml} -
-
-
-
-
- - -
-
-
- `; - - // Attach event listener to copy button - const copyBtn = modalContainer.querySelector('#error-modal-copy-btn'); - if (copyBtn) { - copyBtn.addEventListener('click', () => { - copyErrorDetails(error); - }); - } - - modalContainer.style.display = 'block'; - - const panel = modalContainer.querySelector('#error-modal-panel'); - if (panel) { - panel.setAttribute('aria-describedby', 'error-modal-description'); - if (window.LEDDialog) { - errorModalRelease = window.LEDDialog.trap(panel, { - labelledBy: 'error-modal-title', - initialFocus: '#error-modal-close-btn', - onEscape: closeErrorModal - }); - } - } -} - -/** - * Close the error modal. - */ -function closeErrorModal() { - const modalContainer = document.getElementById('error-modal-container'); - if (modalContainer) { - modalContainer.style.display = 'none'; - } - if (errorModalRelease) { - const release = errorModalRelease; - errorModalRelease = null; - release(); - } -} - -/** - * Copy error details to clipboard. - * - * @param {Object} error - Error object from API response - */ -function copyErrorDetails(error) { - const errorText = JSON.stringify(error, null, 2); - function copyFailed(err) { - console.error('Failed to copy error details:', err); - showNotification("Couldn't copy to the clipboard. Open Technical details and copy the text by hand.", 'warning'); - } - - if (navigator.clipboard && navigator.clipboard.writeText) { - navigator.clipboard.writeText(errorText).then(() => { - showNotification('Error details copied to clipboard', 'success'); - }).catch(copyFailed); - } else { - // Fallback for older browsers - const textArea = document.createElement('textarea'); - textArea.value = errorText; - textArea.style.position = 'fixed'; - textArea.style.opacity = '0'; - // Keep focus inside the open dialog while copying. - const panel = document.getElementById('error-modal-panel'); - const host = panel && panel.offsetParent !== null ? panel : document.body; - const returnFocus = document.activeElement; - host.appendChild(textArea); - textArea.select(); - try { - document.execCommand('copy'); - showNotification('Error details copied to clipboard', 'success'); - } catch (err) { - copyFailed(err); - } - host.removeChild(textArea); - if (returnFocus && typeof returnFocus.focus === 'function') returnFocus.focus(); - } -} - -// Export functions -if (typeof module !== 'undefined' && module.exports) { - module.exports = { - formatError, - getSuggestedFixes, - displayError, - copyErrorDetails, - showErrorModal, - closeErrorModal, - ERROR_MESSAGES, - ERROR_DOCS - }; -} else { - // Make available globally - window.errorHandler = { - formatError, - getSuggestedFixes, - displayError, - copyErrorDetails, - showErrorModal, - closeErrorModal, - ERROR_MESSAGES, - ERROR_DOCS - }; -} diff --git a/web_interface/static/v3/js/widgets/README.md b/web_interface/static/v3/js/widgets/README.md index 649b3bca..cdf2e348 100644 --- a/web_interface/static/v3/js/widgets/README.md +++ b/web_interface/static/v3/js/widgets/README.md @@ -28,7 +28,7 @@ set by `BUNDLE_ORDER` there. | `file-upload-single` | string | One image upload; stores the uploaded file's relative path | | `google-oauth` | string | Step 2 of the calendar plugin's Google sign-in | | `plugin-file-manager` | null | Inline file manager driven by the plugin's `web_ui_actions` | -| `json-file-manager` | null | JSON data-file manager driven by `web_ui_actions` | +| `json-file-manager` | null | Embeds the plugin's own `web_ui/file_manager.html` in an iframe | | `toggle-switch` | boolean | On/off switch | | `slider` | integer / number | Range slider using `minimum` / `maximum` | | `number-input` | integer / number | Number field with min/max check | @@ -51,7 +51,6 @@ Other files here: | `notification.js` | Toast notifications; owns `window.showNotification` | | `plugin-order-list.js` | Drag-and-drop plugin order list used by the Display and Durations tabs (`window.PluginOrderList`) | | `plugin-loader.js` | Loads a plugin-supplied widget on demand | -| `example-color-picker.js` | Example custom widget. Not bundled: it registers `color-picker` and would replace the real one | Each widget file's header comment gives its schema options. The sections below cover the ones that need more than a line. @@ -270,8 +269,6 @@ directory is the only place the core serves plugin widgets from. })(); ``` -[`example-color-picker.js`](example-color-picker.js) is a longer example. - ### 2. Reference it in the schema ```json diff --git a/web_interface/static/v3/js/widgets/color-picker.js b/web_interface/static/v3/js/widgets/color-picker.js index 2ebf6b6c..74031f50 100644 --- a/web_interface/static/v3/js/widgets/color-picker.js +++ b/web_interface/static/v3/js/widgets/color-picker.js @@ -25,8 +25,6 @@ const base = window.BaseWidget ? new window.BaseWidget('ColorPicker', '1.0.0') : null; - function escapeHtml(text) { return window.LEDEscape.html(text); } - function sanitizeId(id) { if (base) return base.sanitizeId(id); return String(id).replace(/[^a-zA-Z0-9_-]/g, '_'); @@ -141,7 +139,7 @@ html += ''; // Hidden input for form submission - html += ``; + html += ``; // Preset colors - only render valid hex colors if (Array.isArray(presets) && presets.length > 0) { @@ -155,12 +153,12 @@ html += ` `; } diff --git a/web_interface/static/v3/js/widgets/date-picker.js b/web_interface/static/v3/js/widgets/date-picker.js index d9bf36bc..875e78ee 100644 --- a/web_interface/static/v3/js/widgets/date-picker.js +++ b/web_interface/static/v3/js/widgets/date-picker.js @@ -26,8 +26,6 @@ const base = window.BaseWidget ? new window.BaseWidget('DatePicker', '1.0.0') : null; - function escapeHtml(text) { return window.LEDEscape.html(text); } - function sanitizeId(id) { if (base) return base.sanitizeId(id); return String(id).replace(/[^a-zA-Z0-9_-]/g, '_'); @@ -70,11 +68,11 @@
${escapeHtml(constraintText)}
`; + html += `
${window.LEDEscape.html(constraintText)}
`; } // Error message area diff --git a/web_interface/static/v3/js/widgets/day-selector.js b/web_interface/static/v3/js/widgets/day-selector.js index 5d2cc59d..02c16ca4 100644 --- a/web_interface/static/v3/js/widgets/day-selector.js +++ b/web_interface/static/v3/js/widgets/day-selector.js @@ -50,8 +50,6 @@ // Use BaseWidget utilities if available const base = window.BaseWidget ? new window.BaseWidget('DaySelector', '1.0.0') : null; - function escapeHtml(text) { return window.LEDEscape.html(text); } - function sanitizeId(id) { if (base) return base.sanitizeId(id); return String(id).replace(/[^a-zA-Z0-9_-]/g, '_'); @@ -100,7 +98,7 @@ // Hidden input to store the value as JSON array // Note: Using single quotes for attribute, JSON uses double quotes, so no escaping needed - html += ``; + html += ``; // Select All toggle if (showSelectAll) { @@ -141,7 +139,7 @@ ${isChecked ? 'checked' : ''} onchange="window.LEDMatrixWidgets.getHandlers('day-selector').onChange('${fieldId}')" class="day-checkbox h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"> - ${escapeHtml(label)} + ${window.LEDEscape.html(label)} `; } diff --git a/web_interface/static/v3/js/widgets/email-input.js b/web_interface/static/v3/js/widgets/email-input.js index c53aaff1..8014cce1 100644 --- a/web_interface/static/v3/js/widgets/email-input.js +++ b/web_interface/static/v3/js/widgets/email-input.js @@ -24,8 +24,6 @@ const base = window.BaseWidget ? new window.BaseWidget('EmailInput', '1.0.0') : null; - function escapeHtml(text) { return window.LEDEscape.html(text); } - function sanitizeId(id) { if (base) return base.sanitizeId(id); return String(id).replace(/[^a-zA-Z0-9_-]/g, '_'); @@ -73,9 +71,9 @@ html += ` . - const escapeHtml = (text) => { - const div = document.createElement('div'); - div.textContent = String(text ?? ''); - return div.innerHTML - .replace(/"/g, '"') - .replace(/'/g, '''); - }; - - // Use validated/sanitized hex for style attribute and input values - const safeHex = currentValue; // Already validated above - - container.innerHTML = ` -
-
- - -
-
- - -
-
-
-
-
-
-

Select a color using the color picker or enter a hex code

- `; - - // Get references to elements - const colorInput = container.querySelector('input[type="color"]'); - const hexInput = container.querySelector('input[type="text"]'); - const preview = container.querySelector(`#${fieldId}_preview`); - - // Update hex when color picker changes - colorInput.addEventListener('input', (e) => { - const color = e.target.value; - hexInput.value = color; - if (preview) { - preview.style.backgroundColor = color; - } - this.handlers.onChange(fieldId, color); - }); - - // Update color picker and preview when hex input changes - hexInput.addEventListener('input', (e) => { - const hex = e.target.value; - // Validate hex format - if (/^#[0-9A-Fa-f]{6}$/.test(hex)) { - colorInput.value = hex; - if (preview) { - preview.style.backgroundColor = hex; - } - hexInput.classList.remove('border-red-500'); - hexInput.classList.add('border-gray-300'); - this.handlers.onChange(fieldId, hex); - } else if (hex.length > 0) { - // Show error state for invalid hex - hexInput.classList.remove('border-gray-300'); - hexInput.classList.add('border-red-500'); - } - }); - - // Validate on blur - hexInput.addEventListener('blur', (e) => { - const hex = e.target.value; - if (hex && !/^#[0-9A-Fa-f]{6}$/.test(hex)) { - // Reset to current color picker value - e.target.value = colorInput.value; - e.target.classList.remove('border-red-500'); - e.target.classList.add('border-gray-300'); - } - }); - }, - - /** - * Get current value from widget - * @param {string} fieldId - Field ID - * @returns {string} Current hex color value - */ - getValue: function(fieldId) { - const colorInput = document.querySelector(`#${fieldId}_color`); - return colorInput ? colorInput.value : null; - }, - - /** - * Set value programmatically - * @param {string} fieldId - Field ID - * @param {string} value - Hex color value to set - */ - setValue: function(fieldId, value) { - // Validate hex color format before using - const hexColorRegex = /^#[0-9A-Fa-f]{6}$/; - const safeValue = hexColorRegex.test(value) ? value : '#000000'; - - const colorInput = document.querySelector(`#${fieldId}_color`); - const hexInput = document.querySelector(`#${fieldId}_hex`); - const preview = document.querySelector(`#${fieldId}_preview`); - - if (colorInput && hexInput) { - colorInput.value = safeValue; - hexInput.value = safeValue; - if (preview) { - preview.style.backgroundColor = safeValue; - } - } - }, - - /** - * Event handlers - */ - handlers: { - /** - * Handle color change - * @param {string} fieldId - Field ID - * @param {string} value - New color value - */ - onChange: function(fieldId, value) { - // Trigger form change event for validation and saving - const event = new CustomEvent('widget-change', { - detail: { fieldId, value }, - bubbles: true, - cancelable: true - }); - document.dispatchEvent(event); - - // Also update any hidden input if it exists - const hiddenInput = document.querySelector(`input[name*="${fieldId}"][type="hidden"]`); - if (hiddenInput) { - hiddenInput.value = value; - } - } - } - }); -})(); diff --git a/web_interface/static/v3/js/widgets/file-upload-single.js b/web_interface/static/v3/js/widgets/file-upload-single.js index 1996ed1f..f9c0ee60 100644 --- a/web_interface/static/v3/js/widgets/file-upload-single.js +++ b/web_interface/static/v3/js/widgets/file-upload-single.js @@ -33,8 +33,6 @@ const base = window.BaseWidget ? new window.BaseWidget('FileUploadSingle', '1.0.0') : null; - function escapeHtml(text) { return window.LEDEscape.html(text); } - function sanitizeId(id) { if (base) return base.sanitizeId(id); return String(id).replace(/[^a-zA-Z0-9_-]/g, '_'); @@ -78,22 +76,22 @@ const currentValue = value || ''; const hasImage = isImagePath(currentValue); - let html = `
`; + let html = `
`; // Hidden input carries the actual string value - html += ``; + html += ``; // Preview area (shown when a value is set) html += `
`; - html += `Preview`; html += ``; html += `
-

${escapeHtml(currentValue.split('/').pop() || '')}

-

${escapeHtml(currentValue)}

+

${window.LEDEscape.html(currentValue.split('/').pop() || '')}

+

${window.LEDEscape.html(currentValue)}

`; html += `` : ''} - -
-
- -
-
Loading…
-
- - ${hasUpload ? ` -
- -
- 📁 -

Drop a JSON file here, or click to browse

- ${this.uploadHint ? `

${this._esc(this.uploadHint)}

` : ''} -
-
` : ''} - - - - - - ${hasDelete ? ` - ` : ''} - - - ${hasCreate ? ` - ` : ''} - -
`; // end #${u} - - // Cache frequently-used elements - this._root = document.getElementById(u); - this._listEl = document.getElementById(`${u}-list`); - this._editorEl = document.getElementById(`${u}-editor`); - this._editModal = document.getElementById(`${u}-edit-modal`); - this._delModal = document.getElementById(`${u}-del-modal`); - this._createModal = document.getElementById(`${u}-create-modal`); - this._dropzone = document.getElementById(`${u}-dropzone`); - this._fileInput = document.getElementById(`${u}-fileinput`); - } - - _css(u) { - // Colors come from the app theme tokens (app.css :root / [data-theme="dark"]) - // with the original light values as fallbacks for pages without app.css. - // Shades the tokens don't cover are local variables with a dark override. - return ``; - } - - // ── Event Binding ──────────────────────────────────────────────────── - - _bind() { - // Delegated clicks on the widget root - this._root.addEventListener('click', this._onClick.bind(this)); - this._root.addEventListener('change', this._onChange.bind(this)); - - // Drag-and-drop on the dropzone - if (this._dropzone) { - this._dropzone.addEventListener('dragover', e => { - e.preventDefault(); - this._dropzone.classList.add('jfm-over'); - }); - this._dropzone.addEventListener('dragleave', () => { - this._dropzone.classList.remove('jfm-over'); - }); - this._dropzone.addEventListener('drop', e => { - e.preventDefault(); - this._dropzone.classList.remove('jfm-over'); - const file = e.dataTransfer?.files[0]; - if (file) this._uploadFile(file); - }); - // Keyboard activation of drop zone - this._dropzone.addEventListener('keydown', e => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - this._fileInput?.click(); - } - }); - } - - // Modal backdrop clicks - [this._editModal, this._delModal, this._createModal].forEach(m => { - if (m) m.addEventListener('click', e => { if (e.target === m) this._closeAll(); }); - }); - - // Editor: char count + Tab indent - if (this._editorEl) { - this._editorEl.addEventListener('input', () => this._updateStat()); - this._editorEl.addEventListener('keydown', e => { - // Plain Tab indents; Shift+Tab is left alone so keyboard - // users can always move focus out of the editor. - if (e.key === 'Tab' && !e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey) { - e.preventDefault(); - const s = this._editorEl.selectionStart; - const end = this._editorEl.selectionEnd; - const v = this._editorEl.value; - this._editorEl.value = v.slice(0, s) + ' ' + v.slice(end); - this._editorEl.selectionStart = this._editorEl.selectionEnd = s + 2; - this._updateStat(); - } - }); - } - - // Global keyboard shortcuts - document.addEventListener('keydown', this._keyHandler); - } - - _onKey(e) { - // Escape is handled by the dialog focus trap (see _trap). - const editOpen = this._editModal && !this._editModal.hidden; - if ((e.ctrlKey || e.metaKey) && e.key === 's' && editOpen) { - e.preventDefault(); - this._doSave(); - } - } - - _onClick(e) { - const btn = e.target.closest('[data-jfm]'); - if (!btn) return; - const action = btn.dataset.jfm; - - switch (action) { - case 'refresh': this._loadList(); break; - case 'open-picker': this._fileInput?.click(); break; - case 'open-create': this._openCreate(); break; - case 'close-edit': this._closeEdit(); break; - case 'close-del': this._closeDel(); break; - case 'close-create': this._closeCreate(); break; - case 'fmt': this._formatJson(); break; - case 'validate': this._validateJson(); break; - case 'save': this._doSave(); break; - case 'confirm-del': this._doDelete(); break; - case 'do-create': this._doCreate(); break; - case 'edit-file': { - const card = btn.closest('[data-jfm-file]'); - if (card) this._openEdit(card.dataset.jfmFile); - break; - } - case 'del-file': { - const card = btn.closest('[data-jfm-file]'); - if (card) this._openDel(card.dataset.jfmFile); - break; - } - } - } - - _onChange(e) { - // Toggle checkbox - if (e.target.classList.contains('jfm-toggle-cb')) { - const catName = e.target.dataset.cat; - const enabled = e.target.checked; - this._doToggle(catName, enabled, e.target); - } - // File input - if (e.target === this._fileInput) { - const file = e.target.files?.[0]; - if (file) this._uploadFile(file); - e.target.value = ''; - } - } - - // ── API helper ─────────────────────────────────────────────────────── - - async _api(actionKey, params) { - const actionId = Object.prototype.hasOwnProperty.call(this.actions, actionKey) ? this.actions[actionKey] : undefined; - if (!actionId) throw new Error(`Action "${actionKey}" not configured`); - const body = { plugin_id: this.pluginId, action_id: actionId }; - if (params !== undefined) body.params = params; - const r = await fetch('/api/v3/plugins/action', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body) - }); - const ct = r.headers.get('content-type') || ''; - if (!r.ok) { - // A failing action script comes back as a 400 whose JSON body - // carries the script's own message; surface it, not the status. - const data = ct.includes('application/json') ? await r.json().catch(() => null) : null; - throw new Error(data?.message || 'Server error ' + r.status); - } - if (!ct.includes('application/json')) { - const txt = await r.text(); - throw new Error('Unexpected response: ' + txt.slice(0, 120)); - } - return r.json(); - } - - // ── File List ──────────────────────────────────────────────────────── - - async _loadList() { - this._listEl.innerHTML = `
Loading…
`; - try { - const data = await this._api('list'); - if (data.status !== 'success') throw new Error(data.message || 'Load failed'); - this._renderList(data.files || []); - } catch (err) { - this._listEl.innerHTML = ` -
-
⚠
-

Failed to load files

-

${this._esc(err.message)}

-
`; - } - } - - _renderList(files) { - if (!files.length) { - this._listEl.innerHTML = ` -
-
📁
-

No files yet

-

Upload or create a JSON file to get started

-
`; - return; - } - this._listEl.innerHTML = files.map(f => this._card(f)).join(''); - } - - _card(f) { - const enabled = f.enabled !== false; - const displayName = this._esc(f.display_name || f.filename); - const filename = this._esc(f.filename); - const catName = this.toggleKey ? this._esc(f[this.toggleKey] || '') : ''; - const showToggle = !!(this.actions.toggle && this.toggleKey && f[this.toggleKey]); - const hasEdit = !!this.actions.get && !!this.actions.save; - const hasDelete = !!this.actions.delete; - - return ` -
-
- ${displayName} - ${showToggle ? ` - ` : ''} -
-
- 📄 ${filename} - 📊 ${f.entry_count ?? 0} entries · ${this._fmtSize(f.size || 0)} - 🕑 ${this._fmtDate(f.modified)} -
-
- ${hasEdit ? `` : ''} - ${hasDelete ? `` : ''} -
-
`; - } - - // ── Edit flow ──────────────────────────────────────────────────────── - - async _openEdit(filename) { - this._editFile = filename; - document.getElementById(`${this._uid}-edit-title`).textContent = `Edit: ${filename}`; - this._clearErr(); - this._editorEl.value = 'Loading…'; - this._updateStat(); - this._editModal.hidden = false; - this._trap('edit', this._editModal, `${this._uid}-edit-title`, this._editorEl, () => this._closeEdit()); - - try { - const data = await this._api('get', { filename }); - if (data.status !== 'success') throw new Error(data.message || 'Load failed'); - this._editorEl.value = JSON.stringify(data.content, null, 2); - this._updateStat(); - this._editorEl.focus(); - this._editorEl.setSelectionRange(0, 0); - this._editorEl.scrollTop = 0; - } catch (err) { - this._showErr(`Couldn't load this file (${err.message}). Close the editor and try again.`); - this._editorEl.value = ''; - } - } - - _closeEdit() { - if (this._editModal) this._editModal.hidden = true; - this._editFile = null; - this._clearErr(); - this._release('edit'); - } - - _formatJson() { - try { - const parsed = JSON.parse(this._editorEl.value); - this._editorEl.value = JSON.stringify(parsed, null, 2); - this._updateStat(); - this._clearErr(); - } catch (err) { - this._showErr('Invalid JSON — ' + err.message); - } - } - - _validateJson() { - try { - const parsed = JSON.parse(this._editorEl.value); - const n = (typeof parsed === 'object' && parsed !== null) ? Object.keys(parsed).length : '?'; - this._clearErr(); - this._notify(`Valid JSON — ${n} top-level keys`, 'success'); - } catch (err) { - this._showErr('Invalid JSON — ' + err.message); - } - } - - async _doSave() { - if (!this._editFile) return; - let contentStr; - try { - const parsed = JSON.parse(this._editorEl.value); - contentStr = JSON.stringify(parsed, null, 2); - } catch (err) { - this._showErr('Cannot save — fix JSON first: ' + err.message); - return; - } - const btn = document.getElementById(`${this._uid}-save-btn`); - this._busy(btn, 'Saving…'); - try { - const data = await this._api('save', { filename: this._editFile, content: contentStr }); - if (data.status !== 'success') throw new Error(data.message || 'Save failed'); - this._notify('File saved', 'success'); - this._closeEdit(); - this._loadList(); - } catch (err) { - this._showErr('Save failed: ' + err.message); - } finally { - this._idle(btn, 'Save'); - } - } - - // ── Delete flow ────────────────────────────────────────────────────── - - _openDel(filename) { - this._deleteFile = filename; - const el = document.getElementById(`${this._uid}-del-name`); - if (el) el.textContent = filename; - if (this._delModal) { - this._delModal.hidden = false; - // Destructive dialog: start on Cancel. - this._trap('del', this._delModal, `${this._uid}-del-title`, - document.getElementById(`${this._uid}-del-cancel`), () => this._closeDel()); - } - } - - _closeDel() { - if (this._delModal) this._delModal.hidden = true; - this._deleteFile = null; - this._release('del'); - } - - async _doDelete() { - if (!this._deleteFile) return; - const btn = document.getElementById(`${this._uid}-del-btn`); - this._busy(btn, 'Deleting…'); - try { - const data = await this._api('delete', { filename: this._deleteFile }); - if (data.status !== 'success') throw new Error(data.message || 'Delete failed'); - this._notify('File deleted', 'success'); - this._closeDel(); - this._loadList(); - } catch (err) { - this._notify('Delete failed: ' + err.message, 'error'); - } finally { - this._idle(btn, 'Delete'); - } - } - - // ── Create flow ────────────────────────────────────────────────────── - - _openCreate() { - if (!this._createModal) return; - this.createFields.forEach(f => { - const el = document.getElementById(`${this._uid}-cf-${f.key}`); - if (el) el.value = ''; - }); - this._createModal.hidden = false; - const first = this.createFields[0]; - const firstInput = first ? document.getElementById(`${this._uid}-cf-${first.key}`) : null; - this._trap('create', this._createModal, `${this._uid}-create-title`, firstInput, () => this._closeCreate()); - } - - _closeCreate() { - if (this._createModal) this._createModal.hidden = true; - this._release('create'); - } - - async _doCreate() { - const params = {}; - for (const f of this.createFields) { - const el = document.getElementById(`${this._uid}-cf-${f.key}`); - const val = (el?.value || '').trim(); - // display_name may be blank — auto-derived from category_name below - if (!val && f.key !== 'display_name') { - this._notify(`"${f.label}" is required`, 'error'); - el?.focus(); - return; - } - if (f.pattern && val && el && el.validity.patternMismatch) { - this._notify(`"${f.label}" format is invalid`, 'error'); - el?.focus(); - return; - } - if (val) params[f.key] = val; - } - // Auto-derive display_name from category_name when left blank - if (!params.display_name && params.category_name) { - params.display_name = params.category_name.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); - } - const btn = document.getElementById(`${this._uid}-create-btn`); - this._busy(btn, 'Creating…'); - try { - const data = await this._api('create', params); - if (data.status !== 'success') throw new Error(data.message || 'Create failed'); - this._notify('File created', 'success'); - this._closeCreate(); - this._loadList(); - } catch (err) { - this._notify('Create failed: ' + err.message, 'error'); - } finally { - this._idle(btn, 'Create'); - } - } - - // ── Upload ─────────────────────────────────────────────────────────── - - async _uploadFile(file) { - if (!file.name.endsWith('.json')) { - this._notify('Please select a .json file', 'error'); - return; - } - let content; - try { - content = await file.text(); - JSON.parse(content); // client-side validation - } catch (err) { - this._notify('Invalid JSON: ' + err.message, 'error'); - return; - } - if (this._dropzone) this._dropzone.style.opacity = '.5'; - try { - const data = await this._api('upload', { filename: file.name, content }); - if (data.status !== 'success') throw new Error(data.message || 'Upload failed'); - this._notify(`"${file.name}" uploaded`, 'success'); - this._loadList(); - } catch (err) { - this._notify('Upload failed: ' + err.message, 'error'); - } finally { - if (this._dropzone) this._dropzone.style.opacity = ''; - } - } - - // ── Toggle ─────────────────────────────────────────────────────────── - - async _doToggle(catName, enabled, checkbox) { - checkbox.disabled = true; - try { - const params = { enabled }; - if (this.toggleKey) params[this.toggleKey] = catName; - const data = await this._api('toggle', params); - if (data.status !== 'success') throw new Error(data.message || 'Toggle failed'); - this._notify(enabled ? 'Category enabled' : 'Category disabled', 'success'); - this._loadList(); - } catch (err) { - this._notify('Toggle failed: ' + err.message, 'error'); - checkbox.checked = !enabled; // revert - checkbox.disabled = false; - } - } - - // ── Helpers ────────────────────────────────────────────────────────── - - _closeAll() { - this._closeEdit(); - this._closeDel(); - this._closeCreate(); - } - - _updateStat() { - const v = this._editorEl?.value || ''; - const lines = v ? v.split('\n').length : 0; - const el = document.getElementById(`${this._uid}-charcount`); - if (el) el.textContent = `${lines.toLocaleString()} lines · ${v.length.toLocaleString()} chars`; - } - - _showErr(msg) { - const el = document.getElementById(`${this._uid}-edit-err`); - if (el) { el.textContent = msg; el.hidden = false; } - } - - _clearErr() { - const el = document.getElementById(`${this._uid}-edit-err`); - if (el) { el.textContent = ''; el.hidden = true; } - } - - _notify(msg, type) { - window.showNotification(msg, type || 'info'); - } - - _busy(btn, label) { - if (!btn) return; - btn._jfmOrigText = btn.textContent; - btn.disabled = true; - btn.textContent = ''; - const spin = document.createElement('span'); - spin.className = 'jfm-spin'; - btn.appendChild(spin); - btn.appendChild(document.createTextNode(' ' + label)); - } - - _idle(btn, label) { - if (!btn) return; - btn.disabled = false; - btn.textContent = btn._jfmOrigText !== undefined ? btn._jfmOrigText : label; - delete btn._jfmOrigText; - } - - _esc(str) { - return window.LEDEscape.html(str); - } - - _fmtSize(bytes) { - if (!bytes) return '0 B'; - const i = Math.min(Math.floor(Math.log2(bytes + 1) / 10), 2); - const unit = ['B', 'KB', 'MB'][i]; - const val = bytes / Math.pow(1024, i); - return (i ? val.toFixed(1) : val) + ' ' + unit; - } - - _fmtDate(str) { - if (!str) return '—'; - try { - return new Date(str).toLocaleDateString(undefined, { - month: 'short', day: 'numeric', year: 'numeric' - }); - } catch { return str; } - } - } - - // ── Widget registry integration ────────────────────────────────────────── - - window.JsonFileManager = JsonFileManager; - - if (typeof window.LEDMatrixWidgets !== 'undefined') { - window.LEDMatrixWidgets.register('json-file-manager', { - name: 'JSON File Manager', - version: '1.0.0', - render(container, config, _value, options) { - new JsonFileManager(container, config || {}, options?.pluginId || ''); - }, - getValue() { return null; }, - setValue() {} - }); - } -})(); diff --git a/web_interface/static/v3/js/widgets/notification.js b/web_interface/static/v3/js/widgets/notification.js index a123eb1d..5f156233 100644 --- a/web_interface/static/v3/js/widgets/notification.js +++ b/web_interface/static/v3/js/widgets/notification.js @@ -161,8 +161,6 @@ setTimeout(() => { region.textContent = text; }, 50); } - function escapeHtml(text) { return window.LEDEscape.html(text); } - function clearTimer(notificationId) { const t = timers.get(notificationId); if (t && t.timer) clearTimeout(t.timer); @@ -294,7 +292,7 @@ html += ``; } - html += `${style.label}: ${escapeHtml(message)}`; + html += `${style.label}: ${window.LEDEscape.html(message)}`; // Optional inline action button (e.g. "Restart Now" on a restart nudge). // The callback is stored by id and invoked via triggerAction, which @@ -306,7 +304,7 @@ onclick="window.LEDMatrixWidgets.get('notification').triggerAction('${notificationId}')" class="flex-shrink-0 ml-2 px-3 py-1 text-xs font-semibold rounded-md bg-white bg-opacity-20 hover:bg-opacity-30 transition-colors duration-150" style="background:rgba(255,255,255,.2);color:inherit;border:0;"> - ${escapeHtml(options.actionLabel)} + ${window.LEDEscape.html(options.actionLabel)} `; } diff --git a/web_interface/static/v3/js/widgets/number-input.js b/web_interface/static/v3/js/widgets/number-input.js index 3fdd039d..437cc24b 100644 --- a/web_interface/static/v3/js/widgets/number-input.js +++ b/web_interface/static/v3/js/widgets/number-input.js @@ -28,8 +28,6 @@ const base = window.BaseWidget ? new window.BaseWidget('NumberInput', '1.0.0') : null; - function escapeHtml(text) { return window.LEDEscape.html(text); } - function sanitizeId(id) { if (base) return base.sanitizeId(id); return String(id).replace(/[^a-zA-Z0-9_-]/g, '_'); @@ -80,16 +78,16 @@ const currentValue = rawValue === '' ? '' : (isNaN(Number(rawValue)) ? '' : String(Number(rawValue))); // Escape values for safe HTML attribute interpolation - const safeMin = min !== null ? escapeHtml(String(min)) : ''; - const safeMax = max !== null ? escapeHtml(String(max)) : ''; - const safeStep = escapeHtml(String(step)); + const safeMin = min !== null ? window.LEDEscape.html(String(min)) : ''; + const safeMax = max !== null ? window.LEDEscape.html(String(max)) : ''; + const safeStep = window.LEDEscape.html(String(step)); let html = `
`; html += '
'; if (prefix) { - html += `${escapeHtml(prefix)}`; + html += `${window.LEDEscape.html(prefix)}`; } if (showButtons && !disabled) { @@ -108,9 +106,9 @@ html += ` ${escapeHtml(suffix)}`; + html += `${window.LEDEscape.html(suffix)}`; } html += '
'; @@ -142,7 +140,7 @@ const rangeText = min !== null && max !== null ? `${min} - ${max}` : (min !== null ? `Min: ${min}` : `Max: ${max}`); - html += `
${escapeHtml(rangeText)}
`; + html += `
${window.LEDEscape.html(rangeText)}
`; } // Error message area diff --git a/web_interface/static/v3/js/widgets/password-input.js b/web_interface/static/v3/js/widgets/password-input.js index 4b7d051d..ef9bfa9a 100644 --- a/web_interface/static/v3/js/widgets/password-input.js +++ b/web_interface/static/v3/js/widgets/password-input.js @@ -28,8 +28,6 @@ const base = window.BaseWidget ? new window.BaseWidget('PasswordInput', '1.0.0') : null; - function escapeHtml(text) { return window.LEDEscape.html(text); } - function sanitizeId(id) { if (base) return base.sanitizeId(id); return String(id).replace(/[^a-zA-Z0-9_-]/g, '_'); @@ -120,9 +118,9 @@ html += ` 0 ? `minlength="${sanitizedMinLength}"` : ''} ${disabled ? 'disabled' : ''} ${required ? 'required' : ''} diff --git a/web_interface/static/v3/js/widgets/plugin-file-manager.js b/web_interface/static/v3/js/widgets/plugin-file-manager.js index 8dca0ad5..b8b348c0 100644 --- a/web_interface/static/v3/js/widgets/plugin-file-manager.js +++ b/web_interface/static/v3/js/widgets/plugin-file-manager.js @@ -238,8 +238,6 @@ window.showNotification(msg, type); } - function escHtml(s) { return window.LEDEscape.html(s); } - function formatSize(bytes) { if (bytes >= 1048576) return (bytes / 1048576).toFixed(1) + ' MB'; return (bytes / 1024).toFixed(2) + ' KB'; @@ -399,17 +397,17 @@ modal.className = 'pfm-modal'; safeSetHTML(modal, `
- ${escHtml(filename)} -
-
+
Loading…
`); @@ -423,7 +421,7 @@ const data = await callAction(st.pluginId, st.actions.get, { filename }).catch(() => null); const body = document.getElementById(`${fieldId}_edit_body`); if (!data || data.status !== 'success' || !body) { - if (body) safeSetHTML(body, `
Couldn't load ${escHtml(filename)}. Close this window and try again.
`); + if (body) safeSetHTML(body, `
Couldn't load ${window.LEDEscape.html(filename)}. Close this window and try again.
`); return; } @@ -438,10 +436,10 @@ // Textarea path: _editData stays null; save() reads from the - `); + + `); } }; @@ -465,7 +463,7 @@ // Delegated listener: day/col reach _pfmCellEdit only through data-* // attributes, never through a JS string spliced into an inline // handler -- the browser HTML-decodes attribute values before - // running them as script, which undoes escHtml's quote escaping and + // running them as script, which undoes LEDEscape's quote escaping and // reopens the exact injection it exists to close. `container` is a // fresh element per modal open (see _pfmOpenEdit), so this attaches // exactly once per table, even though buildPage() re-renders below. @@ -498,21 +496,21 @@ Day - ${cols.map(c => `${escHtml(c.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()))}`).join('')} + ${cols.map(c => `${window.LEDEscape.html(c.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()))}`).join('')} ${pageEntries.map(([day, val]) => ` - - ${escHtml(day)} + + ${window.LEDEscape.html(day)} ${cols.map(col => { const v = val[col] ?? ''; const isLong = String(v).length > 60 || col === 'description' || col === 'definition' || col === 'content'; return isLong - ? `` - : ``; + ? `` + : ``; }).join('')} `).join('')} @@ -604,20 +602,20 @@ modal.style.maxWidth = '28rem'; safeSetHTML(modal, `
- Delete File -
- ${escHtml(filename)} will be permanently deleted and removed + ${window.LEDEscape.html(filename)} will be permanently deleted and removed from the plugin configuration. This cannot be undone.
`); @@ -654,25 +652,25 @@ modal.style.maxWidth = '32rem'; safeSetHTML(modal, `
- Create New File -
- + ${fields.map(f => `
- - - ${f.hint ? `
${escHtml(f.hint)}
` : ''} + + + ${f.hint ? `
${window.LEDEscape.html(f.hint)}
` : ''}
`).join('')}
`); @@ -818,7 +816,7 @@
File Explorer
- ${st.directoryLabel ? `
Manage files in ${escHtml(st.directoryLabel)}
` : ''} + ${st.directoryLabel ? `
Manage files in ${window.LEDEscape.html(st.directoryLabel)}
` : ''}
${actions.create ? ` @@ -841,7 +839,7 @@ onchange="if(this.files[0])window._pfmUpload('${fieldId}',this.files[0]);this.value=''">

Drag and drop or click to upload

- ${escHtml(st.uploadHint)} + ${window.LEDEscape.html(st.uploadHint)}
` : ''}
diff --git a/web_interface/static/v3/js/widgets/radio-group.js b/web_interface/static/v3/js/widgets/radio-group.js index 6971a859..ff9e44c8 100644 --- a/web_interface/static/v3/js/widgets/radio-group.js +++ b/web_interface/static/v3/js/widgets/radio-group.js @@ -33,8 +33,6 @@ const base = window.BaseWidget ? new window.BaseWidget('RadioGroup', '1.0.0') : null; - function escapeHtml(text) { return window.LEDEscape.html(text); } - function sanitizeId(id) { if (base) return base.sanitizeId(id); return String(id).replace(/[^a-zA-Z0-9_-]/g, '_'); @@ -83,15 +81,15 @@
- ${escapeHtml(label)} - ${description ? `

${escapeHtml(description)}

` : ''} + ${window.LEDEscape.html(label)} + ${description ? `

${window.LEDEscape.html(description)}

` : ''}
`; diff --git a/web_interface/static/v3/js/widgets/schedule-picker.js b/web_interface/static/v3/js/widgets/schedule-picker.js index 69c8cab8..d4cb13e9 100644 --- a/web_interface/static/v3/js/widgets/schedule-picker.js +++ b/web_interface/static/v3/js/widgets/schedule-picker.js @@ -54,8 +54,6 @@ // Use BaseWidget utilities if available const base = window.BaseWidget ? new window.BaseWidget('SchedulePicker', '1.0.0') : null; - function escapeHtml(text) { return window.LEDEscape.html(text); } - function sanitizeId(id) { if (base) return base.sanitizeId(id); return String(id).replace(/[^a-zA-Z0-9_-]/g, '_'); @@ -239,7 +237,7 @@

When to start displaying content (HH:MM)

@@ -248,7 +246,7 @@

When to stop displaying content (HH:MM)

@@ -279,7 +277,7 @@ // Render each day row DAYS.forEach(day => { const dayConfig = schedule.days[day]; - const dayLabel = escapeHtml(DAY_LABEL_MAP.get(day) || day); + const dayLabel = window.LEDEscape.html(DAY_LABEL_MAP.get(day) || day); const disabled = !dayConfig.enabled; const disabledClass = disabled ? 'bg-gray-100' : ''; @@ -300,7 +298,7 @@ @@ -309,7 +307,7 @@ @@ -345,15 +343,15 @@ html += ``; // Global times (used when mode is global) - html += ``; - html += ``; + html += ``; + html += ``; // Per-day values (used when mode is per_day) DAYS.forEach(day => { const dayConfig = schedule.days[day]; html += ``; - html += ``; - html += ``; + html += ``; + html += ``; }); return html; diff --git a/web_interface/static/v3/js/widgets/select-dropdown.js b/web_interface/static/v3/js/widgets/select-dropdown.js index 30aaa9c5..3e253c1b 100644 --- a/web_interface/static/v3/js/widgets/select-dropdown.js +++ b/web_interface/static/v3/js/widgets/select-dropdown.js @@ -28,8 +28,6 @@ const base = window.BaseWidget ? new window.BaseWidget('SelectDropdown', '1.0.0') : null; - function escapeHtml(text) { return window.LEDEscape.html(text); } - function sanitizeId(id) { if (base) return base.sanitizeId(id); return String(id).replace(/[^a-zA-Z0-9_-]/g, '_'); @@ -73,7 +71,7 @@ html += ` '; diff --git a/web_interface/static/v3/js/widgets/slider.js b/web_interface/static/v3/js/widgets/slider.js index 08d5a94f..3e20868c 100644 --- a/web_interface/static/v3/js/widgets/slider.js +++ b/web_interface/static/v3/js/widgets/slider.js @@ -28,10 +28,6 @@ const base = window.BaseWidget ? new window.BaseWidget('Slider', '1.0.0') : null; - function escapeHtml(text) { return window.LEDEscape.html(text); } - - function escapeAttr(text) { return window.LEDEscape.attr(text); } - function sanitizeId(id) { if (base) return base.sanitizeId(id); return String(id).replace(/[^a-zA-Z0-9_-]/g, '_'); @@ -78,14 +74,14 @@ const currentValue = value !== null && value !== undefined ? value : min; const colorClass = COLOR_CLASSES[color] || COLOR_CLASSES.blue; - let html = `
`; + let html = `
`; // Value display above slider if (showValue) { html += `
- ${escapeHtml(prefix)}${escapeHtml(currentValue)}${escapeHtml(suffix)} + ${window.LEDEscape.html(prefix)}${window.LEDEscape.html(currentValue)}${window.LEDEscape.html(suffix)}
`; @@ -95,11 +91,11 @@ html += ` - ${escapeHtml(prefix)}${escapeHtml(min)}${escapeHtml(suffix)} - ${escapeHtml(prefix)}${escapeHtml(max)}${escapeHtml(suffix)} + ${window.LEDEscape.html(prefix)}${window.LEDEscape.html(min)}${window.LEDEscape.html(suffix)} + ${window.LEDEscape.html(prefix)}${window.LEDEscape.html(max)}${window.LEDEscape.html(suffix)}
`; } diff --git a/web_interface/static/v3/js/widgets/text-input.js b/web_interface/static/v3/js/widgets/text-input.js index 034ce5a9..17c8b176 100644 --- a/web_interface/static/v3/js/widgets/text-input.js +++ b/web_interface/static/v3/js/widgets/text-input.js @@ -29,8 +29,6 @@ const base = window.BaseWidget ? new window.BaseWidget('TextInput', '1.0.0') : null; - function escapeHtml(text) { return window.LEDEscape.html(text); } - function sanitizeId(id) { if (base) return base.sanitizeId(id); return String(id).replace(/[^a-zA-Z0-9_-]/g, '_'); @@ -80,14 +78,14 @@ const currentValue = value !== null && value !== undefined ? String(value) : ''; - let html = `
`; + let html = `
`; // Container for prefix/input/suffix layout const hasAddons = prefix || suffix || clearable; if (hasAddons) { html += '
'; if (prefix) { - html += `${escapeHtml(prefix)}`; + html += `${window.LEDEscape.html(prefix)}`; } } @@ -98,10 +96,10 @@ html += ` ${escapeHtml(suffix)}`; + html += `${window.LEDEscape.html(suffix)}`; } if (hasAddons) { diff --git a/web_interface/static/v3/js/widgets/textarea.js b/web_interface/static/v3/js/widgets/textarea.js index e4c1108b..c7b49b0c 100644 --- a/web_interface/static/v3/js/widgets/textarea.js +++ b/web_interface/static/v3/js/widgets/textarea.js @@ -26,8 +26,6 @@ const base = window.BaseWidget ? new window.BaseWidget('Textarea', '1.0.0') : null; - function escapeHtml(text) { return window.LEDEscape.html(text); } - function sanitizeId(id) { if (base) return base.sanitizeId(id); return String(id).replace(/[^a-zA-Z0-9_-]/g, '_'); @@ -75,15 +73,15 @@ html += ` + class="form-textarea w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 ${resizeClass} ${disabled ? 'bg-gray-100 cursor-not-allowed' : 'bg-white'} text-black placeholder:text-gray-400">${window.LEDEscape.html(currentValue)} `; // Character count diff --git a/web_interface/static/v3/js/widgets/time-picker.js b/web_interface/static/v3/js/widgets/time-picker.js index 9ae0ead4..a1ae7c77 100644 --- a/web_interface/static/v3/js/widgets/time-picker.js +++ b/web_interface/static/v3/js/widgets/time-picker.js @@ -25,8 +25,6 @@ const base = window.BaseWidget ? new window.BaseWidget('TimePicker', '1.0.0') : null; - function escapeHtml(text) { return window.LEDEscape.html(text); } - function sanitizeId(id) { if (base) return base.sanitizeId(id); return String(id).replace(/[^a-zA-Z0-9_-]/g, '_'); @@ -77,9 +75,9 @@
`; // Hidden inputs for form submission - html += ``; - html += ``; + html += ``; + html += ``; html += `
`; // Start time input html += `
- + @@ -170,10 +168,10 @@ // End time input html += `
- + @@ -187,7 +185,7 @@ const duration = calculateDuration(startTime, endTime, allowOvernight); html += `
- Duration: ${escapeHtml(duration)} + Duration: ${window.LEDEscape.html(duration)}
`; } diff --git a/web_interface/static/v3/js/widgets/timezone-selector.js b/web_interface/static/v3/js/widgets/timezone-selector.js index 21cbab08..7675396c 100644 --- a/web_interface/static/v3/js/widgets/timezone-selector.js +++ b/web_interface/static/v3/js/widgets/timezone-selector.js @@ -23,8 +23,6 @@ const base = window.BaseWidget ? new window.BaseWidget('TimezoneSelector', '1.0.0') : null; - function escapeHtml(text) { return window.LEDEscape.html(text); } - function sanitizeId(id) { if (base) return base.sanitizeId(id); return String(id).replace(/[^a-zA-Z0-9_-]/g, '_'); @@ -230,7 +228,7 @@ let html = `
`; // Hidden input for form submission - html += ``; + html += ``; html += ` `; + html += ``; html += `
@@ -2779,7 +2779,7 @@ function attachInstallButtonHandler() { debugLog('[attachInstallButtonHandler] Response data:', data); if (data.status === 'success') { if (pluginStatusDiv) { - pluginStatusDiv.innerHTML = `Successfully installed: ${escapeHtml(data.plugin_id)}`; + pluginStatusDiv.innerHTML = `Successfully installed: ${window.LEDEscape.html(data.plugin_id)}`; } pluginUrlInput.value = ''; window.noteRestartRequired(data); @@ -2790,14 +2790,14 @@ function attachInstallButtonHandler() { }, 1000); } else { if (pluginStatusDiv) { - pluginStatusDiv.innerHTML = `${escapeHtml(data.message || 'Installation failed')}`; + pluginStatusDiv.innerHTML = `${window.LEDEscape.html(data.message || 'Installation failed')}`; } } }) .catch(error => { console.error('[attachInstallButtonHandler] Error:', error); if (pluginStatusDiv) { - pluginStatusDiv.innerHTML = `Error: ${escapeHtml(error.message)}`; + pluginStatusDiv.innerHTML = `Error: ${window.LEDEscape.html(error.message)}`; } }) .finally(() => { @@ -2946,7 +2946,7 @@ function setupGitHubInstallHandlers() { } }) .catch(error => { - registryStatusDiv.innerHTML = `Error: ${escapeHtml(error.message)}`; + registryStatusDiv.innerHTML = `Error: ${window.LEDEscape.html(error.message)}`; customRegistryPlugins.classList.add('hidden'); }) .finally(() => { @@ -3045,8 +3045,8 @@ function renderCustomRegistryPlugins(plugins, registryUrl) {
-
${escapeHtml(plugin.name || plugin.id)}
-

${escapeHtml(plugin.description || 'No description')}

+
${window.LEDEscape.html(plugin.name || plugin.id)}
+

${window.LEDEscape.html(plugin.description || 'No description')}

@@ -3080,7 +3080,7 @@ function showInstalledLoadError(message) { content.innerHTML = `
-

${escapeHtml(message)}

+

${window.LEDEscape.html(message)}

`; } @@ -3104,10 +3104,8 @@ function isGithubUrl(url) { } } -// Short local names for window.LEDEscape (app-early.js), which says what each -// one is for. Function declarations, so they are usable anywhere in this IIFE. -function escapeHtml(text) { return window.LEDEscape.html(text); } -function escapeAttribute(text) { return window.LEDEscape.attr(text); } +// Short local name for window.LEDEscape.jsStringAttr (app-early.js). A function +// declaration, so it is usable anywhere in this IIFE. function jsStringAttr(value) { return window.LEDEscape.jsStringAttr(value); } function isNewPlugin(lastUpdated) { @@ -3428,8 +3426,6 @@ document.addEventListener('htmx:afterSettle', function() { let starlarkDataLoaded = false; // ── Helpers ───────────────────────────────────────────────────────────── - function escapeHtml(str) { return window.LEDEscape.html(str); } - function isStarlarkInstalled(appId) { // Check window.installedPlugins (populated by loadInstalledPlugins) if (window.installedPlugins && Array.isArray(window.installedPlugins)) { @@ -3487,7 +3483,7 @@ document.addEventListener('htmx:afterSettle', function() { if (!banner) return; if (data.pixlet_available) { banner.innerHTML = `
- Pixlet available${data.pixlet_version ? ' (' + escapeHtml(data.pixlet_version) + ')' : ''} — ${data.installed_apps || 0} app(s) installed + Pixlet available${data.pixlet_version ? ' (' + window.LEDEscape.html(data.pixlet_version) + ')' : ''} — ${data.installed_apps || 0} app(s) installed
`; } else { banner.innerHTML = `
@@ -3514,7 +3510,7 @@ document.addEventListener('htmx:afterSettle', function() { .then(r => r.json()) .then(data => { if (data.status !== 'success') { - if (grid) grid.innerHTML = `
${escapeHtml(data.message || 'Failed to load')}
`; + if (grid) grid.innerHTML = `
${window.LEDEscape.html(data.message || 'Failed to load')}
`; return; } @@ -3693,22 +3689,22 @@ document.addEventListener('htmx:afterSettle', function() { setGridHtmlIfChanged(grid, apps.map(app => { const installed = isStarlarkInstalled(app.id); return ` -
+
-

${escapeHtml(app.name || app.id)}

+

${window.LEDEscape.html(app.name || app.id)}

Starlark ${installed ? 'Installed' : ''}
- ${app.author ? `

${escapeHtml(app.author)}

` : ''} - ${app.category ? `

${escapeHtml(app.category)}

` : ''} + ${app.author ? `

${window.LEDEscape.html(app.author)}

` : ''} + ${app.category ? `

${window.LEDEscape.html(app.category)}

` : ''}
-

${escapeHtml(app.summary || app.desc || 'No description')}

+

${window.LEDEscape.html(app.summary || app.desc || 'No description')}

diff --git a/web_interface/tailwind/tailwind.config.js b/web_interface/tailwind/tailwind.config.js index 30b5aa44..bfdf8575 100644 --- a/web_interface/tailwind/tailwind.config.js +++ b/web_interface/tailwind/tailwind.config.js @@ -39,6 +39,13 @@ module.exports = { 'sm:hidden', 'sm:inline', 'sm:max-w-4xl', 'md:flex', 'lg:grid-cols-5', 'lg:grid-cols-6', 'lg:gap-x-6', 'xl:grid-cols-5', 'xl:grid-cols-6', 'xl:grid-cols-7', 'xl:grid-cols-8', 'xl:gap-x-8', + // Used only by the removed js/utils/error_handler.js modal; kept for the + // same reason (soccer-scoreboard's custom-leagues widget uses max-h-48). + 'align-bottom', 'bg-opacity-75', 'leading-6', 'list-inside', 'max-h-48', + 'pb-20', 'pt-5', 'transition-opacity', 'focus:ring-indigo-500', + 'sm:align-middle', 'sm:flex', 'sm:flex-row-reverse', 'sm:h-10', 'sm:items-start', + 'sm:max-w-lg', 'sm:ml-3', 'sm:ml-4', 'sm:mt-0', 'sm:mx-0', 'sm:my-8', 'sm:p-0', + 'sm:p-6', 'sm:pb-4', 'sm:text-left', 'sm:w-10', 'sm:w-auto', 'sm:w-full', ], // Rules app.css defines on purpose and Tailwind must not override. Tailwind diff --git a/web_interface/templates/v3/base.html b/web_interface/templates/v3/base.html index 552fd500..ed1ace5d 100644 --- a/web_interface/templates/v3/base.html +++ b/web_interface/templates/v3/base.html @@ -930,7 +930,6 @@ - diff --git a/web_interface/templates/v3/partials/logs.html b/web_interface/templates/v3/partials/logs.html index b7471286..f04eef74 100644 --- a/web_interface/templates/v3/partials/logs.html +++ b/web_interface/templates/v3/partials/logs.html @@ -493,7 +493,7 @@ function updatePluginFilterOptions() { const plugins = Array.from(new Set(window._allLogs.map(log => log.plugin).filter(Boolean))).sort(); pluginFilterEl.innerHTML = '' + - plugins.map(p => ``).join(''); + plugins.map(p => ``).join(''); // Restore previous selection if it's still a valid option if (previousValue && plugins.includes(previousValue)) { @@ -517,14 +517,14 @@ function renderLogs() { const logElement = document.createElement('div'); logElement.className = `log-entry py-1 px-2 hover:bg-gray-800 rounded transition-colors duration-150 ${getLogLevelClass(log.level)}`; const pluginBadge = log.plugin - ? `${escapeHtml(log.plugin)}` + ? `${window.LEDEscape.html(log.plugin)}` : ''; logElement.innerHTML = `
- ${escapeHtml(log.timestamp)} + ${window.LEDEscape.html(log.timestamp)} ${log.level} ${pluginBadge} - ${escapeHtml(log.message)} + ${window.LEDEscape.html(log.message)}
`; if (window._logsContent) { @@ -761,13 +761,11 @@ function showEmptyState() { function showError(message) { if (window._logsContent) { - window._logsContent.innerHTML = `
${escapeHtml(message)}
`; + window._logsContent.innerHTML = `
${window.LEDEscape.html(message)}
`; window._logsContent.classList.remove('hidden'); } } -function escapeHtml(text) { return window.LEDEscape.html(text); } - function refreshCurrentPluginStatus() { fetch('/api/v3/display/current-status') .then(response => response.json()) @@ -843,11 +841,11 @@ function renderPluginErrors(summary) { 'Types'; plugins.forEach(p => { const types = Object.entries(p.types) - .map(([type, n]) => `${escapeHtml(type)} ×${escapeHtml(String(Number(n) || 0))}`) + .map(([type, n]) => `${window.LEDEscape.html(type)} ×${window.LEDEscape.html(String(Number(n) || 0))}`) .join(', '); html += '' + - `${escapeHtml(p.id)}` + - `${escapeHtml(String(p.total))}` + + `${window.LEDEscape.html(p.id)}` + + `${window.LEDEscape.html(String(p.total))}` + `${types}`; }); html += '
'; @@ -862,16 +860,16 @@ function renderPluginErrors(summary) { html += '

Repeating errors

    '; patterns.forEach(p => { const severity = String(p.severity || 'warning'); - const affected = (p.affected_plugins || []).map(id => escapeHtml(id)).join(', ') || 'unknown'; + const affected = (p.affected_plugins || []).map(id => window.LEDEscape.html(id)).join(', ') || 'unknown'; const sample = (p.sample_messages || [])[0]; html += '
  • ' + '
    ' + - `${escapeHtml(severity)}` + - `${escapeHtml(p.error_type || '')}` + - `×${escapeHtml(String(Number(p.count) || 0))}` + - `last seen ${escapeHtml(formatErrorTime(p.last_seen))}
    ` + + `${window.LEDEscape.html(severity)}` + + `${window.LEDEscape.html(p.error_type || '')}` + + `×${window.LEDEscape.html(String(Number(p.count) || 0))}` + + `last seen ${window.LEDEscape.html(formatErrorTime(p.last_seen))}
` + `
Plugins: ${affected}
` + - (sample ? `
${escapeHtml(sample)}
` : '') + + (sample ? `
${window.LEDEscape.html(sample)}
` : '') + ''; }); html += ''; diff --git a/web_interface/templates/v3/partials/tools.html b/web_interface/templates/v3/partials/tools.html index 0875ac40..4814f281 100644 --- a/web_interface/templates/v3/partials/tools.html +++ b/web_interface/templates/v3/partials/tools.html @@ -403,14 +403,14 @@
- ${escHtml(message)} + ${window.LEDEscape.html(message)}
`; if (output) { html += `
Show output -
${escHtml(output)}
+
${window.LEDEscape.html(output)}
`; } @@ -421,10 +421,10 @@ const di = d.ok ? 'fa-check' : 'fa-times'; html += `
  • - ${escHtml(d.plugin)}`; + ${window.LEDEscape.html(d.plugin)}`; if (d.output) { html += `
    output -
    ${escHtml(d.output)}
    `; +
    ${window.LEDEscape.html(d.output)}
    `; } html += `
  • `; } @@ -435,8 +435,6 @@ el.innerHTML = html; } - function escHtml(s) { return window.LEDEscape.html(s); } - // ── main action dispatcher ──────────────────────────────────────────────── window.toolsAction = function(action, btnId, resultId, showOutput, showPluginDetails) { @@ -507,7 +505,7 @@ }) .then(d => { if (d.status === 'error') { - panel.innerHTML = `${escHtml(d.message || 'Git info unavailable.')}`; + panel.innerHTML = `${window.LEDEscape.html(d.message || 'Git info unavailable.')}`; return; } @@ -519,45 +517,45 @@ let html = `
    - ${escHtml(d.detached ? (d.version || 'detached') : (d.branch || 'unknown'))} + ${window.LEDEscape.html(d.detached ? (d.version || 'detached') : (d.branch || 'unknown'))} ${dirtyBadge}
    `; if (d.dirty && d.status) { - html += `
    ${escHtml(d.status)}
    `; + html += `
    ${window.LEDEscape.html(d.status)}
    `; } if (d.recent_commits) { html += `

    Recent commits

    -
    ${escHtml(d.recent_commits)}
    +
    ${window.LEDEscape.html(d.recent_commits)}
    `; } if (d.detached && d.current_release) { // The stable update channel sits on release tags, with no branch. - html += `

    On release ${escHtml(d.current_release)}, not a branch (stable update channel). Pull Latest follows the update channel set on the General tab.

    `; + html += `

    On release ${window.LEDEscape.html(d.current_release)}, not a branch (stable update channel). Pull Latest follows the update channel set on the General tab.

    `; } else if (d.detached) { // Detached but not on a release: say what the channel does // with it, in the General tab's words. - html += `

    Not on a branch or a release. ${escHtml(d.channel_message || '')} Pull Latest follows the update channel set on the General tab.

    `; + html += `

    Not on a branch or a release. ${window.LEDEscape.html(d.channel_message || '')} Pull Latest follows the update channel set on the General tab.

    `; } else if (d.upstream) { - html += `

    tracking ${escHtml(d.upstream)}

    `; + html += `

    tracking ${window.LEDEscape.html(d.upstream)}

    `; } else if (d.can_pull) { - html += `

    No upstream set; Pull Latest will use origin/${escHtml(d.branch || '')} and set it.

    `; + html += `

    No upstream set; Pull Latest will use origin/${window.LEDEscape.html(d.branch || '')} and set it.

    `; } else { html += `

    No upstream and no matching branch on origin — Pull Latest cannot run. Switch to a branch that exists on the remote.

    `; } if (d.remote_url) { - html += `

    ${escHtml(d.remote_url)}

    `; + html += `

    ${window.LEDEscape.html(d.remote_url)}

    `; } html += `
    `; panel.innerHTML = html; }) .catch(err => { - panel.innerHTML = `Could not load git info: ${escHtml(String(err))}`; + panel.innerHTML = `Could not load git info: ${window.LEDEscape.html(String(err))}`; }); } @@ -574,7 +572,7 @@ .then(r => r.ok ? r.json() : r.json().then(d => Promise.reject(d.message || `HTTP ${r.status}`))) .then(d => { if (d.status === 'error') { - sel.innerHTML = ``; + sel.innerHTML = ``; sel.disabled = true; return; } @@ -592,7 +590,7 @@ if (!sel.options.length) add('', 'no branches found'); }) .catch(err => { - sel.innerHTML = ``; + sel.innerHTML = ``; sel.disabled = true; }); } @@ -680,7 +678,7 @@ let html = `
    - ${escHtml(summaryText)} + ${window.LEDEscape.html(summaryText)}
      `; @@ -688,7 +686,7 @@ const set = !!power[key]; html += `
    • - ${escHtml(label)} + ${window.LEDEscape.html(label)}
    • `; } html += `
    `; @@ -719,9 +717,9 @@
    -
    ${escHtml(label)}
    -
    ${escHtml(value)}
    - ${sub ? `
    ${escHtml(sub)}
    ` : ''} +
    ${window.LEDEscape.html(label)}
    +
    ${window.LEDEscape.html(value)}
    + ${sub ? `
    ${window.LEDEscape.html(sub)}
    ` : ''}
    `; @@ -781,7 +779,7 @@ 'Display Service', d.service_active ? 'Active' : 'Inactive', null); }) .catch(err => { - panel.innerHTML = `
    Diagnostics unavailable: ${escHtml(String(err))}
    `; + panel.innerHTML = `
    Diagnostics unavailable: ${window.LEDEscape.html(String(err))}
    `; }); }; @@ -925,7 +923,6 @@ }; // ── plugin health panel ────────────────────────────────────────────────── - function phEscape(s) { return window.LEDEscape.html(s); } function phFmtSecs(v) { if (typeof v !== 'number' || !isFinite(v)) return '—'; return v.toFixed(3) + 's'; @@ -968,10 +965,10 @@ const lastErr = h.degraded_reason || h.last_error || ''; const calls = (typeof m.call_count === 'number') ? m.call_count : '—'; const errCell = lastErr - ? '' + phEscape(lastErr) + '' + ? '' + window.LEDEscape.html(lastErr) + '' : '—'; rows += '' + - '' + phEscape(id) + '' + + '' + window.LEDEscape.html(id) + '' + '' + st.label + '' + '' + phFmtSecs(m.avg_execution_time) + '' + '' + phFmtSecs(m.max_execution_time) + '' + @@ -982,7 +979,7 @@ tbody.innerHTML = rows; } catch (e) { const emsg = (e && e.message) ? e.message : String(e); - tbody.innerHTML = 'Failed to load plugin health: ' + phEscape(emsg) + ''; + tbody.innerHTML = 'Failed to load plugin health: ' + window.LEDEscape.html(emsg) + ''; } } window.refreshPluginHealth = refreshPluginHealth; @@ -1042,8 +1039,8 @@ const c = data.config || {}; const field = (id, label, value, type, extra) => ` `; @@ -1102,9 +1099,9 @@

    - Saved to ${escHtml(data.config_path)}. + Saved to ${window.LEDEscape.html(data.config_path)}. Any field can also be set as - ${escHtml(data.env_override_prefix)}<KEY>, + ${window.LEDEscape.html(data.env_override_prefix)}<KEY>, which wins over the file.

    `).join('') : `

    No Starlark apps installed. Install one from the Plugins tab first - (looked in ${escHtml(appsDir)}). + (looked in ${window.LEDEscape.html(appsDir)}).

    `; body.innerHTML = active + `
    ${rows}
    `; - // Bound here rather than via an inline onclick: escHtml does not encode - // single quotes, and the id used to be interpolated into a single-quoted + // Bound here rather than via an inline onclick: the escaper used to leave + // single quotes alone, and the id used to be interpolated into a single-quoted // JS string, so an app directory containing an apostrophe could break // out of it and run script. Through dataset the value is only ever // parsed as an HTML attribute, never as JavaScript. @@ -1351,7 +1348,7 @@ }) .catch(err => { const body = document.getElementById('pixlet-editor-body'); - if (body) body.innerHTML = `

    Could not load: ${escHtml(err.message)}

    `; + if (body) body.innerHTML = `

    Could not load: ${window.LEDEscape.html(err.message)}

    `; }); }; diff --git a/web_interface/widget_bundle.py b/web_interface/widget_bundle.py index f37dbcc0..4c541aca 100644 --- a/web_interface/widget_bundle.py +++ b/web_interface/widget_bundle.py @@ -55,16 +55,10 @@ BUNDLE_ORDER = [ "password-input.js", "timezone-selector.js", "plugin-loader.js", - # Reusable JSON file manager (used via x-widget: json-file-manager) - "json-file-manager.js", ] # Widget files that must NOT be bundled, with the reason. -EXCLUDED = { - # Documentation example (docs/widget-guide.md); it registers the name - # 'color-picker' and would shadow the real color-picker.js. - "example-color-picker.js": "documentation example", -} +EXCLUDED = {} _lock = Lock() _cache = {"version": None, "body": None}