chore: remove dead code, deprecate unused plugin APIs (over-engineering audit)

Whole-tree audit. Every symbol was checked against core, the plugin
monorepo and all eight third-party plugins in plugins.json first.

- Deprecate (removal 3.10.0) plugin-facing methods nothing calls:
  LogoDownloader bulk download, ConfigManager backup/secret wrappers,
  APIHelper extras, BackgroundDataService poll API, PluginManager /
  PluginStateManager info readers, and a few CacheManager, FontManager,
  BaseOddsManager, DynamicTeamResolver methods and PluginTestCase.
  plugin_api_usage.py learns their receiver names; DEPRECATIONS doc
  regenerated.
- Remove core-internal dead code: CacheMetrics, Vegas status/stats
  plumbing, sync "new cycle" message (followers ignore unknown types),
  unused operation types, test-only PluginCatalog readers, IPC to_dict
  and ping, _parse_form_value, CacheStrategyProtocol, ErrorAggregator
  callbacks, duplicate web response helpers.
- Web UI: drop never-mounted json-file-manager.js, the example widget,
  utils/error_handler.js, four uncalled PluginAPI methods, and 29
  escapeHtml shims (call window.LEDEscape directly). Public globals,
  BaseWidget and widget names unchanged.
- Remove six one-off scripts (owner decision) and the unused markupsafe
  and pytest-mock pins.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-10-06 18:27:40 -04:00
co-authored by Claude Opus 5.5
parent e40bc47d28
commit 7e066174d9
128 changed files with 914 additions and 4168 deletions
+64
View File
@@ -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
+5 -5
View File
@@ -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 `<id>` or `ledmatrix-<id>` |
| 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) |
+273 -101
View File
@@ -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 `<receiver>.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 |
+3 -3
View File
@@ -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
```
-1
View File
@@ -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
-1
View File
@@ -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
+4 -8
View File
@@ -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 |
-231
View File
@@ -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()
-279
View File
@@ -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()
+1 -2
View File
@@ -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
```
-13
View File
@@ -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
+9 -2
View File
@@ -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"
-149
View File
@@ -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
-43
View File
@@ -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())
-225
View File
@@ -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 <name>"
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
+4
View File
@@ -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.
+3
View File
@@ -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.
-1
View File
@@ -5,6 +5,5 @@ Provides specialized cache components:
- MemoryCache: In-memory caching
- DiskCache: Persistent disk caching
- CacheStrategy: Cache strategy management
- CacheMetrics: Performance metrics tracking
"""
-134
View File
@@ -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'])
+7 -34
View File
@@ -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 ``<sport>_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
``<sport>_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)
+6 -20
View File
@@ -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:
+4 -12
View File
@@ -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.
+8
View File
@@ -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.
+7 -40
View File
@@ -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:
+7
View File
@@ -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.
+3
View File
@@ -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.
+2 -40
View File
@@ -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
+3
View File
@@ -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.
-5
View File
@@ -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."""
-28
View File
@@ -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')
+8
View File
@@ -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))
+8 -18
View File
@@ -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
)
+3 -58
View File
@@ -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)
-31
View File
@@ -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
+7 -59
View File
@@ -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,
+6
View File
@@ -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.
+7
View File
@@ -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.
+1 -7
View File
@@ -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
+1 -10
View File
@@ -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]:
"""
@@ -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
-43
View File
@@ -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)
+2 -39
View File
@@ -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)
+6 -49
View File
@@ -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
+1 -48
View File
@@ -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(
+13 -6
View File
@@ -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(
+2 -2
View File
@@ -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.
"""
+3 -79
View File
@@ -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
+6 -19
View File
@@ -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 = {
+1 -2
View File
@@ -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
+8 -35
View File
@@ -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
);
+3 -3
View File
@@ -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) {
+2 -2
View File
@@ -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'));
+1 -1
View File
@@ -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;'
+3 -2
View File
@@ -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;
}
+1 -77
View File
@@ -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:
+6 -56
View File
@@ -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 `<sport>_scoreboard` config shape (see below), which that code
still read.
was 60, a soccer branch identical to its else, and a config lookup of
`<sport>_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")
}
# `<sport>_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:
+38 -1
View File
@@ -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}
-1
View File
@@ -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
-49
View File
@@ -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."""
-3
View File
@@ -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 []
+3 -1
View File
@@ -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',
-1
View File
@@ -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
+14 -14
View File
@@ -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
+1 -1
View File
@@ -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))
@@ -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
-1
View File
@@ -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
-3
View File
@@ -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 []
+17 -46
View File
@@ -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):
+8 -41
View File
@@ -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
# ---------------------------------------------------------------------------
-1
View File
@@ -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
-1
View File
@@ -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
-2
View File
@@ -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()
-3
View File
@@ -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
+3 -20
View File
@@ -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 []
-3
View File
@@ -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 []
-3
View File
@@ -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 []
+1 -1
View File
@@ -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
-3
View File
@@ -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)])]
-3
View File
@@ -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 []
-3
View File
@@ -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 []
-5
View File
@@ -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):
+1 -1
View File
@@ -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):
@@ -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
-39
View File
@@ -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": {
+34 -30
View File
@@ -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"] == {}
@@ -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
@@ -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()
@@ -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)
+3 -9
View File
@@ -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
+1 -56
View File
@@ -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):
-1
View File
@@ -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.
+2 -3
View File
@@ -15,7 +15,7 @@
* is the one Alpine uses)
* end of <body>, 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;
+1 -1
View File
@@ -15,7 +15,7 @@
* is the one Alpine uses)
* end of <body>, 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
+1 -1
View File
@@ -17,7 +17,7 @@
* is the one Alpine uses)
* end of <body>, 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
@@ -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<Object>} 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<Object>} 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<Object>} 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<Object>} Response data
*/
async resetPluginConfig(pluginId) {
return await this.request(`/plugins/config/reset?plugin_id=${encodeURIComponent(pluginId)}`, 'POST');
},
/**
* Update plugin.
*
@@ -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;
}
@@ -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<string>} 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<string>} 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 ? `<div class="text-sm text-gray-600 mb-2">${window.LEDEscape.html(context)}</div>` : '';
const suggestionsHtml = suggestions.length > 0 ? `
<div class="mt-4">
<h4 class="text-sm font-semibold text-gray-900 mb-2">Suggested fixes:</h4>
<ul class="list-disc list-inside space-y-1 text-sm text-gray-700">
${suggestions.map(s => `<li>${window.LEDEscape.html(s)}</li>`).join('')}
</ul>
</div>
` : '';
const docLinkHtml = docLink ? `
<div class="mt-4">
<a href="${window.LEDEscape.html(docLink)}" target="_blank" rel="noopener noreferrer" class="text-blue-600 hover:text-blue-800 text-sm underline">
<i class="fas fa-book mr-1" aria-hidden="true"></i>View troubleshooting guide<span style="position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;"> (opens in a new tab)</span>
</a>
</div>
` : '';
const detailsHtml = error.details ? `
<div class="mt-4">
<details class="cursor-pointer">
<summary class="text-sm font-medium text-gray-700 hover:text-gray-900">Technical details</summary>
<pre class="mt-2 text-xs bg-gray-100 p-3 rounded overflow-auto max-h-48 text-gray-800">${window.LEDEscape.html(error.details)}</pre>
</details>
</div>
` : '';
const errorCodeHtml = error.error_code ? `
<div class="mt-2 text-xs text-gray-500">
Error code: <code class="bg-gray-100 px-1 py-0.5 rounded">${window.LEDEscape.html(error.error_code)}</code>
</div>
` : '';
modalContainer.innerHTML = `
<div class="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" aria-hidden="true" onclick="window.errorHandler.closeErrorModal()"></div>
<div id="error-modal-panel" class="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full" style="position:relative;">
<div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div class="sm:flex sm:items-start">
<div class="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-red-100 sm:mx-0 sm:h-10 sm:w-10" aria-hidden="true">
<i class="fas fa-exclamation-triangle text-red-600"></i>
</div>
<div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left flex-1">
<h3 id="error-modal-title" class="text-lg leading-6 font-medium text-gray-900">Something went wrong</h3>
<div class="mt-2" id="error-modal-description">
${contextText}
<p class="text-sm text-gray-500">${window.LEDEscape.html(message)}</p>
${errorCodeHtml}
${suggestionsHtml}
${docLinkHtml}
${detailsHtml}
</div>
</div>
</div>
</div>
<div class="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
<button type="button" id="error-modal-copy-btn"
class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-blue-600 text-base font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:ml-3 sm:w-auto sm:text-sm">
<i class="fas fa-copy mr-2" aria-hidden="true"></i>Copy Error Details
</button>
<button type="button" id="error-modal-close-btn" onclick="window.errorHandler.closeErrorModal()"
class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm">
Close
</button>
</div>
</div>
</div>
`;
// 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
};
}
+1 -4
View File
@@ -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
@@ -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 += '</div>';
// Hidden input for form submission
html += `<input type="hidden" id="${fieldId}_input" name="${escapeHtml(options.name || fieldId)}" value="${currentValue}">`;
html += `<input type="hidden" id="${fieldId}_input" name="${window.LEDEscape.html(options.name || fieldId)}" value="${currentValue}">`;
// Preset colors - only render valid hex colors
if (Array.isArray(presets) && presets.length > 0) {
@@ -155,12 +153,12 @@
html += `
<button type="button"
${disabled ? 'disabled' : ''}
data-color="${escapeHtml(normalized)}"
data-color="${window.LEDEscape.html(normalized)}"
onclick="window.LEDMatrixWidgets.getHandlers('color-picker').onPresetClick('${fieldId}', this.dataset.color)"
class="w-6 h-6 rounded border border-gray-300 hover:scale-110 transition-transform ${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}"
style="background-color: ${escapeHtml(normalized)};"
title="${escapeHtml(normalized)}"
aria-label="Use color ${escapeHtml(normalized)}">
style="background-color: ${window.LEDEscape.html(normalized)};"
title="${window.LEDEscape.html(normalized)}"
aria-label="Use color ${window.LEDEscape.html(normalized)}">
</button>
`;
}
@@ -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 @@
<div class="relative flex-1">
<input type="date"
id="${fieldId}_input"
name="${escapeHtml(options.name || fieldId)}"
value="${escapeHtml(currentValue)}"
${min ? `min="${escapeHtml(min)}"` : ''}
${max ? `max="${escapeHtml(max)}"` : ''}
${placeholder ? `placeholder="${escapeHtml(placeholder)}"` : ''}
name="${window.LEDEscape.html(options.name || fieldId)}"
value="${window.LEDEscape.html(currentValue)}"
${min ? `min="${window.LEDEscape.html(min)}"` : ''}
${max ? `max="${window.LEDEscape.html(max)}"` : ''}
${placeholder ? `placeholder="${window.LEDEscape.html(placeholder)}"` : ''}
${disabled ? 'disabled' : ''}
${required ? 'required' : ''}
onchange="window.LEDMatrixWidgets.getHandlers('date-picker').onChange('${fieldId}')"
@@ -109,7 +107,7 @@
} else {
constraintText = `Until ${max}`;
}
html += `<div class="text-xs text-gray-400 mt-1">${escapeHtml(constraintText)}</div>`;
html += `<div class="text-xs text-gray-400 mt-1">${window.LEDEscape.html(constraintText)}</div>`;
}
// Error message area

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