mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-06 23:35:08 +00:00
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:
@@ -19,6 +19,70 @@ accepts both, but the store flags the old spelling as deprecated
|
|||||||
|
|
||||||
## Unreleased
|
## 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
|
## 3.8.2
|
||||||
|
|
||||||
The display hands freed memory back to the OS (#774), and sports consolidation
|
The display hands freed memory back to the OS (#774), and sports consolidation
|
||||||
|
|||||||
@@ -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
|
Only the display process imports plugin code, instantiates plugins and calls
|
||||||
their lifecycle hooks (`update`, `display`, `on_config_change`, `on_enable`,
|
their lifecycle hooks (`update`, `display`, `on_config_change`, `on_enable`,
|
||||||
`on_disable`). The web process is metadata-only: it reads plugins as files
|
`on_disable`). The web process is metadata-only: it reads plugins as files
|
||||||
through `PluginCatalog`
|
-- manifests and directories through `PluginCatalog`
|
||||||
([`src/plugin_system/plugin_catalog.py`](../src/plugin_system/plugin_catalog.py))
|
([`src/plugin_system/plugin_catalog.py`](../src/plugin_system/plugin_catalog.py)),
|
||||||
-- manifests, config schemas (through `SchemaManager`), each plugin's
|
config schemas through `SchemaManager`, and each plugin's section of
|
||||||
section of `config.json`, and installed versions. The catalog keeps the
|
`config.json` through `ConfigManager`. The catalog keeps the
|
||||||
read-only method names of `PluginManager` and has nothing that can run a
|
read-only method names of `PluginManager` and has nothing that can run a
|
||||||
plugin (no `load_plugin`, `get_plugin` or `plugins`).
|
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`) |
|
| 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>` |
|
| 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`) |
|
| 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) |
|
| 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) |
|
| 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) |
|
| Circuit breaker | [`plugin_health.py`](../src/plugin_system/plugin_health.py) (`PluginHealthTracker`: 3 consecutive failures open the circuit for 300 s) |
|
||||||
|
|||||||
+273
-101
@@ -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)).
|
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
|
- Scanned: 2026-10-05, core 3.8.2
|
||||||
- Monorepo: [ChuckBuilds/ledmatrix-plugins](https://github.com/ChuckBuilds/ledmatrix-plugins) (main @ 4de1d134), 46 plugins
|
- 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)
|
- 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.
|
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 |
|
| 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 |
|
| `BackgroundDataService.get_result` | 3.10.0 | core tests (15 test reviews) | — | — | unused — safe to remove in 3.10.0 |
|
||||||
| `CacheManager.update_cache` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
| `BackgroundDataService.is_request_complete` | 3.10.0 | core tests (11 test reviews) | — | — | unused — safe to remove in 3.10.0 |
|
||||||
| `CacheManager.setup_persistent_cache` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
| `BackgroundDataService.get_request_status` | 3.10.0 | core tests (2 test reviews) | — | — | unused — safe to remove in 3.10.0 |
|
||||||
| `CacheManager.get_sport_live_interval` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
| `BaseOddsManager.get_odds_for_games` | 3.10.0 | core tests (3 test reviews) | — | — | unused — safe to remove in 3.10.0 |
|
||||||
| `CacheManager.get_sport_key_from_cache_key` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
| `BaseOddsManager.format_odds_summary` | 3.10.0 | core tests (5 test reviews) | — | — | unused — safe to remove in 3.10.0 |
|
||||||
| `CacheManager.get_background_cached_data` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
| `CacheManager.load_cache` | 3.10.0 | core tests (1 test review) | — | — | unused — safe to remove in 3.10.0 |
|
||||||
| `CacheManager.is_background_data_available` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.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 |
|
||||||
| `CacheManager.record_cache_hit` | 3.8.0 | core (1 internal) | — | — | unused — safe to remove in 3.8.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 |
|
||||||
| `CacheManager.record_cache_miss` | 3.8.0 | core (1 internal) | — | — | unused — safe to remove in 3.8.0 |
|
| `APIHelper.fetch_espn_standings` | 3.10.0 | core tests (1 test review) | — | — | unused — safe to remove in 3.10.0 |
|
||||||
| `CacheManager.record_fetch_time` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
| `APIHelper.fetch_espn_rankings` | 3.10.0 | core tests (1 test review) | — | — | unused — safe to remove in 3.10.0 |
|
||||||
| `CacheManager.get_cache_metrics` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
| `APIHelper.set_cache` | 3.10.0 | core tests (1 test call, 1 test review) | — | — | unused — safe to remove in 3.10.0 |
|
||||||
| `CacheManager.log_cache_metrics` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
| `APIHelper.get_cache` | 3.10.0 | core tests (1 test call) | — | — | unused — safe to remove in 3.10.0 |
|
||||||
| `CacheManager.get_memory_cache_stats` | 3.8.0 | core tests (3 test calls) | — | — | unused — safe to remove in 3.8.0 |
|
| `APIHelper.set_rate_limit` | 3.10.0 | core tests (10 test calls, 3 test reviews) | — | — | unused — safe to remove in 3.10.0 |
|
||||||
| `DisplayManager.draw_sun` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
| `APIHelper.get_request_stats` | 3.10.0 | — | — | — | unused — safe to remove in 3.10.0 |
|
||||||
| `DisplayManager.draw_cloud` | 3.8.0 | core (2 internals) | — | ledmatrix-weather (1 unrelated) | unused — safe to remove in 3.8.0 |
|
| `ConfigManager.rollback_config` | 3.10.0 | core (1 internal); core tests (1 test review) | — | — | unused — safe to remove in 3.10.0 |
|
||||||
| `DisplayManager.draw_rain` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
| `ConfigManager.list_backups` | 3.10.0 | core (1 internal); core tests (1 test review) | — | — | unused — safe to remove in 3.10.0 |
|
||||||
| `DisplayManager.draw_snow` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
| `ConfigManager.validate_config_file` | 3.10.0 | core (1 internal) | — | — | unused — safe to remove in 3.10.0 |
|
||||||
| `DisplayManager.draw_weather_icon` | 3.8.0 | core (1 internal) | — | ledmatrix-weather (5 unrelateds) | unused — safe to remove in 3.8.0 |
|
| `ConfigManager.get_secret` | 3.10.0 | core tests (5 test calls, 1 test review) | — | — | unused — safe to remove in 3.10.0 |
|
||||||
| `DisplayManager.draw_text_with_icons` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.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 |
|
||||||
| `DisplayManager.get_scrolling_stats` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.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 |
|
||||||
| `FontManager.get_manager_fonts` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
| `DynamicTeamResolver.get_available_dynamic_teams` | 3.10.0 | — | — | — | unused — safe to remove in 3.10.0 |
|
||||||
| `FontManager.get_detected_fonts` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
| `DynamicTeamResolver.is_dynamic_team` | 3.10.0 | — | — | — | unused — safe to remove in 3.10.0 |
|
||||||
| `FontManager.unregister_plugin_fonts` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
| `FontManager.get_native_bdf_size` | 3.10.0 | core tests (3 test calls) | — | — | unused — safe to remove in 3.10.0 |
|
||||||
| `FontManager.get_plugin_fonts` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
| `FontManager.measure_text` | 3.10.0 | core tests (5 test calls) | — | — | unused — safe to remove in 3.10.0 |
|
||||||
| `FontManager.set_override` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
| `LogoDownloader.fetch_teams_data` | 3.10.0 | core (2 internals) | — | — | still used by core — keep or migrate first |
|
||||||
| `FontManager.remove_override` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
| `LogoDownloader.extract_teams_from_data` | 3.10.0 | core (2 internals) | — | — | still used by core — keep or migrate first |
|
||||||
| `FontManager.get_overrides` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
| `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 |
|
||||||
| `FontManager.get_available_fonts` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
| `LogoDownloader.download_all_ncaa_football_logos` | 3.10.0 | core tests (2 test calls) | — | — | unused — safe to remove in 3.10.0 |
|
||||||
| `FontManager.get_size_tokens` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
| `LogoDownloader.download_all_missing_logos` | 3.10.0 | — | — | — | unused — safe to remove in 3.10.0 |
|
||||||
| `FontManager.get_performance_stats` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
| `LogoDownloader.convert_image_to_rgba` | 3.10.0 | core (1 internal) | — | — | unused — safe to remove in 3.10.0 |
|
||||||
| `FontManager.get_font_catalog` | 3.8.0 | core tests (1 test call) | — | — | unused — safe to remove in 3.8.0 |
|
| `LogoDownloader.convert_all_logos_to_rgba` | 3.10.0 | — | — | — | unused — safe to remove in 3.10.0 |
|
||||||
| `FontManager.add_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) | — | — | unused — safe to remove in 3.9.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 |
|
|
||||||
| `BasePlugin.get_vegas_segment_width` | 3.9.0 | core tests (1 test review) | — | — | 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
|
## 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 |
|
| 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:` |
|
| `BackgroundDataService.get_result` | core tests | test/test_background_data_service.py:85 | test review | `result = service.get_result(req_id)` |
|
||||||
| `CacheManager.get_sport_live_interval` | core | src/cache/cache_strategy.py:60 | unrelated | `live_interval = self.get_sport_live_interval(sport_key)` |
|
| `BackgroundDataService.get_result` | core tests | test/test_background_data_service.py:107 | test review | `seen["filed"] = service.get_result(result.request_id) is result` |
|
||||||
| `CacheManager.get_sport_live_interval` | core | src/cache_manager.py:785 | unrelated | `return self._strategy_component.get_sport_live_interval(sport_key)` |
|
| `BackgroundDataService.get_result` | core tests | test/test_background_data_service.py:148 | test review | `result = service.get_result(req_id)` |
|
||||||
| `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]:` |
|
| `BackgroundDataService.get_result` | core tests | test/test_background_data_service.py:165 | test review | `result = service.get_result(req_id)` |
|
||||||
| `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)` |
|
| `BackgroundDataService.get_result` | core tests | test/test_background_data_service.py:254 | test review | `assert service.get_result("unknown") is None` |
|
||||||
| `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)` |
|
| `BackgroundDataService.get_result` | core tests | test/test_background_data_service.py:391 | test review | `assert service.get_result(rid).cached is True` |
|
||||||
| `CacheManager.record_cache_hit` | core | src/cache_manager.py:869 | internal (in `CacheManager.get_background_cached_data`) | `self.record_cache_hit('background')` |
|
| `BackgroundDataService.get_result` | core tests | test/test_background_fetch_dedupe.py:198 | test review | `assert service.get_result(req).success is True` |
|
||||||
| `CacheManager.record_cache_miss` | core | src/cache_manager.py:876 | internal (in `CacheManager.get_background_cached_data`) | `self.record_cache_miss('background')` |
|
| `BackgroundDataService.get_result` | core tests | test/test_background_payload_release.py:85 | test review | `result = service.get_result(req_id)` |
|
||||||
| `CacheManager.record_fetch_time` | core | src/cache/cache_metrics.py:67 | unrelated | `def record_fetch_time(self, duration: float) -> None:` |
|
| `BackgroundDataService.get_result` | core tests | test/test_background_payload_release.py:117 | test review | `stored = service.get_result(req_id)` |
|
||||||
| `CacheManager.record_fetch_time` | core | src/cache_manager.py:922 | unrelated | `self._metrics_component.record_fetch_time(duration)` |
|
| `BackgroundDataService.get_result` | core tests | test/test_background_payload_release.py:144 | test review | `assert service.get_result(req_id).data == PAYLOAD` |
|
||||||
| `CacheManager.get_memory_cache_stats` | core tests | test/test_cache_manager_memory_tier.py:43 | test call | `stats = cm.get_memory_cache_stats()` |
|
| `BackgroundDataService.get_result` | core tests | test/test_background_payload_release.py:154 | test review | `stored = service.get_result(req_id)` |
|
||||||
| `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` |
|
| `BackgroundDataService.get_result` | core tests | test/test_background_payload_release.py:171 | test review | `assert service.get_result(req_id).data is None` |
|
||||||
| `CacheManager.get_memory_cache_stats` | core tests | test/test_cache_manager_memory_tier.py:68 | test call | `stats = cm.get_memory_cache_stats()` |
|
| `BackgroundDataService.get_result` | core tests | test/test_background_payload_release.py:193 | test review | `assert service.get_result(req_id).data == PAYLOAD` |
|
||||||
| `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):` |
|
| `BackgroundDataService.get_result` | core tests | test/test_background_payload_release.py:278 | test review | `stored = service.get_result(first)` |
|
||||||
| `DisplayManager.draw_cloud` | core | src/display_manager.py:1359 | internal (in `DisplayManager.draw_rain`) | `self.draw_cloud(x, y, size)` |
|
| `BackgroundDataService.get_result` | core tests | test/test_fetch_service.py:703 | test review | `assert bds.get_result(request_id).success` |
|
||||||
| `DisplayManager.draw_cloud` | core | src/display_manager.py:1374 | internal (in `DisplayManager.draw_snow`) | `self.draw_cloud(x, y, size)` |
|
| `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:` |
|
||||||
| `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)):` |
|
| `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:` |
|
||||||
| `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)):` |
|
| `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:` |
|
||||||
| `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):` |
|
| `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:` |
|
||||||
| `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):` |
|
| `BackgroundDataService.is_request_complete` | core tests | test/test_background_data_service.py:246 | test review | `assert service.is_request_complete("r2") is False` |
|
||||||
| `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)` |
|
| `BackgroundDataService.is_request_complete` | core tests | test/test_background_data_service.py:251 | test review | `assert service.is_request_complete("r3") is True` |
|
||||||
| `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:` |
|
| `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:` |
|
||||||
| `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)` |
|
| `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:` |
|
||||||
| `DisplayManager.draw_weather_icon` | ledmatrix-weather | manager.py:84 | unrelated | `def draw_weather_icon(image, icon_code, x, y, size):` |
|
| `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:` |
|
||||||
| `DisplayManager.draw_weather_icon` | ledmatrix-weather | manager.py:1280 | unrelated | `WeatherIcons.draw_weather_icon(img, icon_code, icon_x, icon_y,` |
|
| `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:` |
|
||||||
| `DisplayManager.draw_weather_icon` | ledmatrix-weather | manager.py:1559 | unrelated | `WeatherIcons.draw_weather_icon(img, forecast['icon'], icon_x, icon_y, icon_size)` |
|
| `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:` |
|
||||||
| `DisplayManager.draw_weather_icon` | ledmatrix-weather | manager.py:1650 | unrelated | `WeatherIcons.draw_weather_icon(img, forecast['icon'], icon_x, icon_y, icon_size)` |
|
| `BackgroundDataService.get_request_status` | core tests | test/test_background_data_service.py:223 | test review | `assert service.get_request_status("nonexistent") is None` |
|
||||||
| `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):` |
|
| `BackgroundDataService.get_request_status` | core tests | test/test_background_fetch_dedupe.py:448 | test review | `assert service.get_request_status(rid) is FetchStatus.CANCELLED, (` |
|
||||||
| `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,` |
|
| `BaseOddsManager.get_odds_for_games` | core tests | test/test_base_odds_manager.py:356 | test review | `result = manager.get_odds_for_games(games)` |
|
||||||
| `FontManager.get_font_catalog` | core tests | test/test_deprecation.py:229 | test call | `assert fm.get_font_catalog() == fm.font_catalog` |
|
| `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: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` | 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` |
|
| `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
|
## Sources scanned
|
||||||
|
|
||||||
| Source | Group | Python files | Hits |
|
| Source | Group | Python files | Hits |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| core | core | 172 | 20 |
|
| core | core | 188 | 50 |
|
||||||
| core tests | core-tests | 347 | 17 |
|
| core tests | core-tests | 416 | 176 |
|
||||||
| 7-segment-clock | monorepo | 3 | 0 |
|
| 7-segment-clock | monorepo | 3 | 0 |
|
||||||
| afl-scoreboard | monorepo | 35 | 0 |
|
| afl-scoreboard | monorepo | 36 | 0 |
|
||||||
| baseball-scoreboard | monorepo | 61 | 0 |
|
| baseball-scoreboard | monorepo | 71 | 0 |
|
||||||
| basketball-scoreboard | monorepo | 49 | 0 |
|
| basketball-scoreboard | monorepo | 51 | 2 |
|
||||||
| birdnet-go | monorepo | 2 | 0 |
|
| birdnet-go | monorepo | 3 | 0 |
|
||||||
| blackjack | monorepo | 7 | 2 |
|
| blackjack | monorepo | 7 | 0 |
|
||||||
| calendar | monorepo | 5 | 1 |
|
| calendar | monorepo | 5 | 0 |
|
||||||
| christmas-countdown | monorepo | 3 | 0 |
|
| christmas-countdown | monorepo | 3 | 0 |
|
||||||
| clock-simple | monorepo | 2 | 0 |
|
| clock-simple | monorepo | 2 | 0 |
|
||||||
| countdown | monorepo | 5 | 0 |
|
| countdown | monorepo | 5 | 0 |
|
||||||
| cricket-scoreboard | monorepo | 8 | 0 |
|
| cricket-scoreboard | monorepo | 8 | 1 |
|
||||||
| f1-scoreboard | monorepo | 15 | 0 |
|
| f1-scoreboard | monorepo | 15 | 0 |
|
||||||
| fantasy-blitz | monorepo | 13 | 0 |
|
| fantasy-blitz | monorepo | 13 | 0 |
|
||||||
| football-scoreboard | monorepo | 74 | 0 |
|
| football-scoreboard | monorepo | 78 | 4 |
|
||||||
| geochron | monorepo | 10 | 0 |
|
| geochron | monorepo | 10 | 0 |
|
||||||
| hello-world | monorepo | 2 | 0 |
|
| hello-world | monorepo | 2 | 0 |
|
||||||
| hockey-scoreboard | monorepo | 52 | 0 |
|
| hockey-scoreboard | monorepo | 57 | 3 |
|
||||||
| incoming-packages | monorepo | 8 | 0 |
|
| incoming-packages | monorepo | 8 | 0 |
|
||||||
| jellyfin-now-playing | monorepo | 4 | 0 |
|
| jellyfin-now-playing | monorepo | 4 | 0 |
|
||||||
| lacrosse-scoreboard | monorepo | 40 | 0 |
|
| lacrosse-scoreboard | monorepo | 41 | 0 |
|
||||||
| ledmatrix-elections | monorepo | 12 | 0 |
|
| ledmatrix-elections | monorepo | 12 | 0 |
|
||||||
| ledmatrix-flights | monorepo | 48 | 0 |
|
| ledmatrix-flights | monorepo | 48 | 0 |
|
||||||
| ledmatrix-leaderboard | monorepo | 9 | 0 |
|
| ledmatrix-leaderboard | monorepo | 10 | 0 |
|
||||||
| ledmatrix-music | monorepo | 11 | 0 |
|
| ledmatrix-music | monorepo | 12 | 0 |
|
||||||
| ledmatrix-stocks | monorepo | 7 | 0 |
|
| ledmatrix-stocks | monorepo | 7 | 0 |
|
||||||
| ledmatrix-weather | monorepo | 15 | 6 |
|
| ledmatrix-weather | monorepo | 15 | 0 |
|
||||||
| march-madness | monorepo | 4 | 0 |
|
| march-madness | monorepo | 4 | 0 |
|
||||||
| masters-tournament | monorepo | 10 | 0 |
|
| masters-tournament | monorepo | 10 | 0 |
|
||||||
| mqtt-notifications | monorepo | 4 | 0 |
|
| mqtt-notifications | monorepo | 4 | 0 |
|
||||||
| news | monorepo | 6 | 0 |
|
| news | monorepo | 7 | 0 |
|
||||||
| nfl-draft | monorepo | 3 | 0 |
|
| nfl-draft | monorepo | 3 | 0 |
|
||||||
| nfl-stat-leaders | monorepo | 8 | 0 |
|
| nfl-stat-leaders | monorepo | 8 | 0 |
|
||||||
| nrl-scoreboard | monorepo | 30 | 0 |
|
| nrl-scoreboard | monorepo | 31 | 1 |
|
||||||
| odds-ticker | monorepo | 9 | 0 |
|
| odds-ticker | monorepo | 10 | 0 |
|
||||||
| of-the-day | monorepo | 14 | 0 |
|
| of-the-day | monorepo | 14 | 0 |
|
||||||
| olympics | monorepo | 16 | 1 |
|
| olympics | monorepo | 16 | 0 |
|
||||||
| on-air | monorepo | 2 | 0 |
|
| on-air | monorepo | 3 | 0 |
|
||||||
| pomodoro-timer | monorepo | 3 | 0 |
|
| pomodoro-timer | monorepo | 3 | 0 |
|
||||||
| soccer-scoreboard | monorepo | 47 | 0 |
|
| soccer-scoreboard | monorepo | 50 | 0 |
|
||||||
| static-image | monorepo | 3 | 0 |
|
| static-image | monorepo | 4 | 0 |
|
||||||
| stock-news | monorepo | 3 | 0 |
|
| stock-news | monorepo | 4 | 0 |
|
||||||
| text-display | monorepo | 4 | 0 |
|
| text-display | monorepo | 4 | 0 |
|
||||||
| tide-display | monorepo | 3 | 0 |
|
| tide-display | monorepo | 3 | 0 |
|
||||||
| ufc-scoreboard | monorepo | 38 | 0 |
|
| ufc-scoreboard | monorepo | 40 | 3 |
|
||||||
| web-ui-info | monorepo | 2 | 0 |
|
| web-ui-info | monorepo | 2 | 0 |
|
||||||
| youtube-stats | monorepo | 5 | 0 |
|
| youtube-stats | monorepo | 5 | 0 |
|
||||||
| f1-live | third-party | 10 | 0 |
|
| f1-live | third-party | 10 | 0 |
|
||||||
|
|||||||
@@ -44,8 +44,8 @@ and symlink the plugin directories you are working on into LEDMatrix's
|
|||||||
### 1. The plugin monorepo
|
### 1. The plugin monorepo
|
||||||
|
|
||||||
Clone ledmatrix-plugins into the same parent directory as LEDMatrix (the
|
Clone ledmatrix-plugins into the same parent directory as LEDMatrix (the
|
||||||
workspace file and `scripts/update_plugin_repos.py` look for
|
workspace file looks for `../ledmatrix-plugins` relative to the LEDMatrix
|
||||||
`../ledmatrix-plugins` relative to the LEDMatrix root):
|
root):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd ~/Github
|
cd ~/Github
|
||||||
@@ -86,7 +86,7 @@ the plugin from there. See the
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd ~/Github/LEDMatrix
|
cd ~/Github/LEDMatrix
|
||||||
python3 scripts/update_plugin_repos.py # git pull in ../ledmatrix-plugins
|
git -C ../ledmatrix-plugins pull # the sibling monorepo checkout
|
||||||
# or
|
# or
|
||||||
./scripts/dev/dev_plugin_setup.sh update # git pull in every linked checkout
|
./scripts/dev/dev_plugin_setup.sh update # git pull in every linked checkout
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ src/auto_update_setup.py
|
|||||||
src/backup_manager.py
|
src/backup_manager.py
|
||||||
src/base_odds_manager.py
|
src/base_odds_manager.py
|
||||||
src/cache/__init__.py
|
src/cache/__init__.py
|
||||||
src/cache/cache_metrics.py
|
|
||||||
src/cache/cache_strategy.py
|
src/cache/cache_strategy.py
|
||||||
src/cache/memory_cache.py
|
src/cache/memory_cache.py
|
||||||
src/common/__init__.py
|
src/common/__init__.py
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
# Install alongside requirements.txt: pip install -r requirements.txt -r requirements-test.txt
|
# Install alongside requirements.txt: pip install -r requirements.txt -r requirements-test.txt
|
||||||
pytest>=9.0.3,<10.0.0
|
pytest>=9.0.3,<10.0.0
|
||||||
pytest-cov>=4.1.0,<8.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
|
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
|
psutil>=6.0.0,<7.0.0 # optional at runtime; installed for tests so the
|
||||||
# /system/status endpoint's real path is exercised
|
# /system/status endpoint's real path is exercised
|
||||||
|
|||||||
+4
-8
@@ -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 |
|
| [`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`) |
|
| [`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) |
|
| [`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 |
|
| `templates/` | dev-only | `dev_preview.html`, the page `dev_server.py` serves |
|
||||||
|
|
||||||
## Top-level scripts
|
## 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 |
|
| `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) |
|
| `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 |
|
| `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_installation.sh` | diagnostic | Checks that an installation completed correctly |
|
||||||
| `verify_wifi_setup.sh` | diagnostic | Health check of the WiFi management setup |
|
| `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.
|
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 |
|
| 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_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()` |
|
| `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 |
|
| `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 |
|
| `dev/test_pillow_compat.py` | Pillow API smoke test to run after upgrading Pillow |
|
||||||
|
|||||||
@@ -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()
|
|
||||||
|
|
||||||
@@ -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()
|
|
||||||
|
|
||||||
@@ -5,7 +5,6 @@ This directory contains scripts and utilities for development and testing.
|
|||||||
## Scripts
|
## Scripts
|
||||||
|
|
||||||
- **`dev_plugin_setup.sh`** - Sets up plugin development environment by linking plugin repositories
|
- **`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)
|
- **`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`)
|
- **`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
|
### Running Emulator
|
||||||
```bash
|
```bash
|
||||||
./scripts/dev/run_emulator.sh
|
python3 run.py -e
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -75,6 +75,14 @@ OWNER_RECEIVERS: Dict[str, Tuple[str, ...]] = {
|
|||||||
"DisplayManager": ("display_manager", "display_mgr", "displaymanager", "display", "dm"),
|
"DisplayManager": ("display_manager", "display_mgr", "displaymanager", "display", "dm"),
|
||||||
"FontManager": ("font_manager", "font_mgr", "fontmanager", "fonts", "fm"),
|
"FontManager": ("font_manager", "font_mgr", "fontmanager", "fonts", "fm"),
|
||||||
"PluginManager": ("plugin_manager", "plugin_mgr", "pluginmanager", "pm"),
|
"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).
|
#: Directories never scanned (vendored environments, VCS metadata, caches).
|
||||||
@@ -330,8 +338,7 @@ class _Scanner(ast.NodeVisitor):
|
|||||||
return "call"
|
return "call"
|
||||||
definers = self.local_definers.get(m.method, ())
|
definers = self.local_definers.get(m.method, ())
|
||||||
if name in definers or self.built.get(name or "") in definers:
|
if name in definers or self.built.get(name or "") in definers:
|
||||||
# e.g. the weather plugin's WeatherIcons.draw_sun, or
|
# e.g. the weather plugin's WeatherIcons.draw_sun
|
||||||
# self._strategy_component = CacheStrategy(); ...get_sport_live_interval()
|
|
||||||
return "unrelated"
|
return "unrelated"
|
||||||
return "review"
|
return "review"
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|
||||||
@@ -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())
|
|
||||||
@@ -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
|
|
||||||
|
|
||||||
@@ -42,6 +42,7 @@ from src.common.espn_dates import (
|
|||||||
fetch_espn_date_chunks,
|
fetch_espn_date_chunks,
|
||||||
parse_espn_date_range,
|
parse_espn_date_range,
|
||||||
)
|
)
|
||||||
|
from src.deprecation import deprecated
|
||||||
# Configure logging
|
# Configure logging
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -679,6 +680,7 @@ class BackgroundDataService:
|
|||||||
|
|
||||||
raise last_exception
|
raise last_exception
|
||||||
|
|
||||||
|
@deprecated("3.10.0", "pass callback= to submit_fetch_request()")
|
||||||
def get_result(self, request_id: str) -> Optional[FetchResult]:
|
def get_result(self, request_id: str) -> Optional[FetchResult]:
|
||||||
"""
|
"""
|
||||||
Get the result of a fetch request.
|
Get the result of a fetch request.
|
||||||
@@ -695,6 +697,7 @@ class BackgroundDataService:
|
|||||||
with self._lock:
|
with self._lock:
|
||||||
return self.completed_requests.get(request_id)
|
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:
|
def is_request_complete(self, request_id: str) -> bool:
|
||||||
"""
|
"""
|
||||||
Check if a request has completed.
|
Check if a request has completed.
|
||||||
@@ -711,6 +714,7 @@ class BackgroundDataService:
|
|||||||
with self._lock:
|
with self._lock:
|
||||||
return request_id in self.completed_requests
|
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]:
|
def get_request_status(self, request_id: str) -> Optional[FetchStatus]:
|
||||||
"""
|
"""
|
||||||
Get the status of a fetch request.
|
Get the status of a fetch request.
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ from typing import Dict, Any, Optional, List, cast
|
|||||||
from src.common.api_helper import DEFAULT_HTTP_HEADERS
|
from src.common.api_helper import DEFAULT_HTTP_HEADERS
|
||||||
from src.common.fetch_service import fetch_get, share_connection_pool
|
from src.common.fetch_service import fetch_get, share_connection_pool
|
||||||
from src.common.json_body import response_json
|
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)}")
|
self.logger.warning(f"Unexpected response structure: {json.dumps(data, indent=2)}")
|
||||||
return None
|
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]]:
|
def get_odds_for_games(self, games: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Fetch odds for multiple games efficiently.
|
Fetch odds for multiple games efficiently.
|
||||||
@@ -335,6 +337,7 @@ class BaseOddsManager:
|
|||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
@deprecated("3.10.0")
|
||||||
def format_odds_summary(self, odds_data: Optional[Dict[str, Any]]) -> str:
|
def format_odds_summary(self, odds_data: Optional[Dict[str, Any]]) -> str:
|
||||||
"""
|
"""
|
||||||
Format odds data into a human-readable summary.
|
Format odds data into a human-readable summary.
|
||||||
|
|||||||
Vendored
-1
@@ -5,6 +5,5 @@ Provides specialized cache components:
|
|||||||
- MemoryCache: In-memory caching
|
- MemoryCache: In-memory caching
|
||||||
- DiskCache: Persistent disk caching
|
- DiskCache: Persistent disk caching
|
||||||
- CacheStrategy: Cache strategy management
|
- CacheStrategy: Cache strategy management
|
||||||
- CacheMetrics: Performance metrics tracking
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|||||||
Vendored
-134
@@ -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'])
|
|
||||||
|
|
||||||
Vendored
+7
-34
@@ -4,7 +4,6 @@ Cache Strategy
|
|||||||
Manages cache strategies (TTLs) for different data types.
|
Manages cache strategies (TTLs) for different data types.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
|
||||||
from typing import Dict, Any, Optional
|
from typing import Dict, Any, Optional
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import pytz
|
import pytz
|
||||||
@@ -13,51 +12,25 @@ import pytz
|
|||||||
class CacheStrategy:
|
class CacheStrategy:
|
||||||
"""Manages cache strategies for different data types."""
|
"""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]:
|
def get_cache_strategy(self, data_type: str, sport_key: Optional[str] = None) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Get cache strategy for different data types.
|
Get cache strategy for different data types.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
data_type: Type of data (e.g., 'live_scores', 'stocks', 'weather_current')
|
data_type: Type of data (e.g., 'live_scores', 'stocks', 'weather_current')
|
||||||
sport_key: Optional sport key; for live data it selects the
|
sport_key: Optional sport key; for live data any sport key
|
||||||
per-sport interval from :meth:`get_sport_live_interval`
|
selects a 60s interval instead of the generic live default.
|
||||||
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:
|
Returns:
|
||||||
Dictionary with cache strategy (max_age, memory_ttl, etc.)
|
Dictionary with cache strategy (max_age, memory_ttl, etc.)
|
||||||
"""
|
"""
|
||||||
live_interval = None
|
live_interval = None
|
||||||
if sport_key and data_type in ['sports_live', 'live_scores']:
|
if sport_key and data_type in ['sports_live', 'live_scores']:
|
||||||
live_interval = self.get_sport_live_interval(sport_key)
|
live_interval = 60
|
||||||
|
|
||||||
strategies = {
|
strategies = {
|
||||||
# Ultra time-sensitive data (live scores, current weather)
|
# Ultra time-sensitive data (live scores, current weather)
|
||||||
|
|||||||
Vendored
+6
-20
@@ -15,11 +15,14 @@ import tempfile
|
|||||||
import logging
|
import logging
|
||||||
import threading
|
import threading
|
||||||
import zlib
|
import zlib
|
||||||
from typing import Dict, Any, Optional, Protocol, Tuple
|
from typing import TYPE_CHECKING, Dict, Any, Optional, Tuple
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from src.common.path_safety import safe_path_component
|
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
|
try: # optional: large speedup on the cache write path, see _dumps below
|
||||||
import orjson
|
import orjson
|
||||||
except ImportError: # pragma: no cover - exercised on hosts without the wheel
|
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}"
|
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):
|
class DateTimeEncoder(json.JSONEncoder):
|
||||||
"""JSON encoder that handles datetime objects.
|
"""JSON encoder that handles datetime objects.
|
||||||
|
|
||||||
@@ -816,12 +802,12 @@ class DiskCache:
|
|||||||
# mkstemp's random component.
|
# mkstemp's random component.
|
||||||
return bool(sep) and len(head) > 1 and bool(suffix)
|
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.
|
Clean up expired cache files based on retention policies.
|
||||||
|
|
||||||
Args:
|
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
|
retention_policies: Dict mapping data types to retention days
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
|
|||||||
+4
-12
@@ -35,13 +35,13 @@ import tempfile
|
|||||||
from src.cache.memory_cache import MemoryCache, default_max_size
|
from src.cache.memory_cache import MemoryCache, default_max_size
|
||||||
from src.cache.disk_cache import DiskCache
|
from src.cache.disk_cache import DiskCache
|
||||||
from src.cache.cache_strategy import CacheStrategy
|
from src.cache.cache_strategy import CacheStrategy
|
||||||
from src.cache.cache_metrics import CacheMetrics
|
|
||||||
from src.logging_config import get_logger
|
from src.logging_config import get_logger
|
||||||
|
|
||||||
# Canonical implementation lives in src.cache.disk_cache; re-exported here
|
# Canonical implementation lives in src.cache.disk_cache; re-exported here
|
||||||
# because this module's docstring documents it and external code may import
|
# because this module's docstring documents it and external code may import
|
||||||
# it from either path.
|
# it from either path.
|
||||||
from src.cache.disk_cache import DateTimeEncoder # noqa: F401 - deliberate re-export
|
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").
|
# CacheManager.config_manager not built yet (None means "not available").
|
||||||
_UNSET: Any = object()
|
_UNSET: Any = object()
|
||||||
@@ -149,10 +149,7 @@ class CacheManager:
|
|||||||
max_size=default_max_size(), cleanup_interval=300.0
|
max_size=default_max_size(), cleanup_interval=300.0
|
||||||
)
|
)
|
||||||
self._disk_cache_component = DiskCache(cache_dir=self.cache_dir, logger=self.logger)
|
self._disk_cache_component = DiskCache(cache_dir=self.cache_dir, logger=self.logger)
|
||||||
# No config manager: CacheStrategy keeps the parameter for callers but
|
self._strategy_component = CacheStrategy()
|
||||||
# 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)
|
|
||||||
|
|
||||||
# Disk cleanup configuration
|
# Disk cleanup configuration
|
||||||
self._disk_cleanup_interval_hours = 24 # Run cleanup every 24 hours
|
self._disk_cleanup_interval_hours = 24 # Run cleanup every 24 hours
|
||||||
@@ -398,6 +395,7 @@ class CacheManager:
|
|||||||
# caller gets as is.
|
# caller gets as is.
|
||||||
self._disk_cache_component.set(key, data)
|
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]]:
|
def load_cache(self, key: str) -> Optional[Dict[str, Any]]:
|
||||||
"""Load data from cache with memory caching."""
|
"""Load data from cache with memory caching."""
|
||||||
# Check memory cache first (1 minute TTL)
|
# Check memory cache first (1 minute TTL)
|
||||||
@@ -607,13 +605,6 @@ class CacheManager:
|
|||||||
duration = time.time() - start_time
|
duration = time.time() - start_time
|
||||||
space_freed_mb = stats['space_freed_bytes'] / (1024 * 1024)
|
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
|
# Log summary
|
||||||
if stats['files_deleted'] > 0:
|
if stats['files_deleted'] > 0:
|
||||||
self.logger.info(
|
self.logger.info(
|
||||||
@@ -796,6 +787,7 @@ class CacheManager:
|
|||||||
data_type = self.get_data_type_from_key(key)
|
data_type = self.get_data_type_from_key(key)
|
||||||
return self.get_cached_data_with_strategy(key, data_type)
|
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:
|
def generate_sport_cache_key(self, sport: str, date_str: Optional[str] = None) -> str:
|
||||||
"""
|
"""
|
||||||
Centralized cache key generation for sports data.
|
Centralized cache key generation for sports data.
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ from typing import TYPE_CHECKING, Any, Dict, Mapping, Optional, cast
|
|||||||
|
|
||||||
import requests
|
import requests
|
||||||
from urllib3.util.retry import Retry
|
from urllib3.util.retry import Retry
|
||||||
|
from src.deprecation import deprecated
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
# What Session() puts in .headers; the stubs only promise a MutableMapping.
|
# 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}")
|
self.logger.error(f"Request failed for {url}: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
@deprecated("3.10.0", "use src.common.espn_dates.fetch_espn_scoreboard()")
|
||||||
def fetch_espn_scoreboard(self, sport: str, league: str,
|
def fetch_espn_scoreboard(self, sport: str, league: str,
|
||||||
date: Optional[str] = None,
|
date: Optional[str] = None,
|
||||||
cache_key: Optional[str] = None,
|
cache_key: Optional[str] = None,
|
||||||
@@ -227,6 +229,7 @@ class APIHelper:
|
|||||||
store_espn_scoreboard_cache(self.cache_manager, shared_key, data)
|
store_espn_scoreboard_cache(self.cache_manager, shared_key, data)
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
@deprecated("3.10.0", "call get() with the ESPN URL")
|
||||||
def fetch_espn_standings(self, sport: str, league: str,
|
def fetch_espn_standings(self, sport: str, league: str,
|
||||||
cache_key: Optional[str] = None,
|
cache_key: Optional[str] = None,
|
||||||
cache_ttl: int = 3600) -> Optional[Dict]:
|
cache_ttl: int = 3600) -> Optional[Dict]:
|
||||||
@@ -249,6 +252,7 @@ class APIHelper:
|
|||||||
|
|
||||||
return self.get(url, cache_key=cache_key, cache_ttl=cache_ttl)
|
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,
|
def fetch_espn_rankings(self, sport: str, league: str,
|
||||||
cache_key: Optional[str] = None,
|
cache_key: Optional[str] = None,
|
||||||
cache_ttl: int = 3600) -> Optional[Dict]:
|
cache_ttl: int = 3600) -> Optional[Dict]:
|
||||||
@@ -311,6 +315,7 @@ class APIHelper:
|
|||||||
self.logger.error(f"POST request failed for {url}: {e}")
|
self.logger.error(f"POST request failed for {url}: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
@deprecated("3.10.0", "use the plugin's cache_manager")
|
||||||
def set_cache(self, key: str, data: Any, ttl: int = 3600) -> None:
|
def set_cache(self, key: str, data: Any, ttl: int = 3600) -> None:
|
||||||
"""
|
"""
|
||||||
Set cache data.
|
Set cache data.
|
||||||
@@ -323,6 +328,7 @@ class APIHelper:
|
|||||||
"""
|
"""
|
||||||
self._set_cache(key, data, ttl)
|
self._set_cache(key, data, ttl)
|
||||||
|
|
||||||
|
@deprecated("3.10.0", "use the plugin's cache_manager")
|
||||||
def get_cache(self, key: str) -> Optional[Any]:
|
def get_cache(self, key: str) -> Optional[Any]:
|
||||||
"""
|
"""
|
||||||
Get cached data.
|
Get cached data.
|
||||||
@@ -392,6 +398,7 @@ class APIHelper:
|
|||||||
self._last_request_monotonic = time.monotonic()
|
self._last_request_monotonic = time.monotonic()
|
||||||
self._last_request_time = time.time()
|
self._last_request_time = time.time()
|
||||||
|
|
||||||
|
@deprecated("3.10.0")
|
||||||
def set_rate_limit(self, min_interval: float) -> None:
|
def set_rate_limit(self, min_interval: float) -> None:
|
||||||
"""
|
"""
|
||||||
Set minimum interval between requests.
|
Set minimum interval between requests.
|
||||||
@@ -402,6 +409,7 @@ class APIHelper:
|
|||||||
self._min_request_interval = min_interval
|
self._min_request_interval = min_interval
|
||||||
self.logger.debug(f"Rate limit set to {min_interval} seconds")
|
self.logger.debug(f"Rate limit set to {min_interval} seconds")
|
||||||
|
|
||||||
|
@deprecated("3.10.0")
|
||||||
def get_request_stats(self) -> Dict[str, Any]:
|
def get_request_stats(self) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Get request statistics.
|
Get request statistics.
|
||||||
|
|||||||
@@ -141,7 +141,6 @@ class DisplaySyncManager:
|
|||||||
self._last_leader_frame_time: float = 0.0
|
self._last_leader_frame_time: float = 0.0
|
||||||
self._frame_lock = threading.Lock()
|
self._frame_lock = threading.Lock()
|
||||||
self._leader_ip: Optional[str] = None
|
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._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._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
|
self._scroll_image_lock = threading.Lock() # guards _on_scroll_image / _pending_scroll_image
|
||||||
@@ -412,17 +411,6 @@ class DisplaySyncManager:
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self.logger.debug("Sync: scroll_x send error: %s", 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:
|
def send_frame(self, image: Image.Image) -> None:
|
||||||
"""Leader: send a rendered frame to the follower as raw RGB bytes.
|
"""Leader: send a rendered frame to the follower as raw RGB bytes.
|
||||||
Raw format is orders of magnitude faster than PNG on Pi hardware —
|
Raw format is orders of magnitude faster than PNG on Pi hardware —
|
||||||
@@ -506,21 +494,19 @@ class DisplaySyncManager:
|
|||||||
self._latest_frame = img
|
self._latest_frame = img
|
||||||
self._enter_follower_mode(sender_ip)
|
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
|
"""Note that the leader at ``sender_ip`` just sent something, and
|
||||||
switch from standalone to follower mode if not already following.
|
switch from standalone to follower mode if not already following."""
|
||||||
Returns True if this call made the switch."""
|
|
||||||
self._last_leader_frame_time = time.monotonic()
|
self._last_leader_frame_time = time.monotonic()
|
||||||
self._leader_ip = sender_ip
|
self._leader_ip = sender_ip
|
||||||
if self._follower_state != FollowerState.STANDALONE:
|
if self._follower_state != FollowerState.STANDALONE:
|
||||||
return False
|
return
|
||||||
self._follower_state = FollowerState.FOLLOWER
|
self._follower_state = FollowerState.FOLLOWER
|
||||||
self.logger.info(
|
self.logger.info(
|
||||||
"Sync: leader active at %s — switching to follower mode",
|
"Sync: leader active at %s — switching to follower mode",
|
||||||
sender_ip,
|
sender_ip,
|
||||||
)
|
)
|
||||||
self.write_status_file()
|
self.write_status_file()
|
||||||
return True
|
|
||||||
|
|
||||||
def _follower_recv_loop(self) -> None:
|
def _follower_recv_loop(self) -> None:
|
||||||
while self._running:
|
while self._running:
|
||||||
@@ -570,12 +556,9 @@ class DisplaySyncManager:
|
|||||||
# frame. Read and validate its fields under a guard —
|
# frame. Read and validate its fields under a guard —
|
||||||
# a UDP payload is attacker-shaped, so a non-object
|
# a UDP payload is attacker-shaped, so a non-object
|
||||||
# body makes .get() raise AttributeError and an "sx"
|
# body makes .get() raise AttributeError and an "sx"
|
||||||
# carrying a non-numeric x raises ValueError/TypeError
|
# carrying a non-numeric x raises ValueError/TypeError.
|
||||||
# — but dispatch the callback *outside* it. Running
|
# Any other "t" is ignored, including the "nc" (new
|
||||||
# the callback in here would let a fault in someone
|
# cycle) that older leaders send and no follower used.
|
||||||
# else's code read as a malformed packet and be
|
|
||||||
# logged as one.
|
|
||||||
fire_new_cycle = False
|
|
||||||
try:
|
try:
|
||||||
t = msg.get("t")
|
t = msg.get("t")
|
||||||
if t == "hello_ack":
|
if t == "hello_ack":
|
||||||
@@ -601,18 +584,11 @@ class DisplaySyncManager:
|
|||||||
# back from. Treat it as malformed.
|
# back from. Treat it as malformed.
|
||||||
raise ValueError(f"non-finite scroll x: {msg['x']!r}")
|
raise ValueError(f"non-finite scroll x: {msg['x']!r}")
|
||||||
self._latest_scroll_x = scroll_x
|
self._latest_scroll_x = scroll_x
|
||||||
if self._enter_follower_mode(sender_ip):
|
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
|
|
||||||
except (KeyError, AttributeError, TypeError, ValueError) as exc:
|
except (KeyError, AttributeError, TypeError, ValueError) as exc:
|
||||||
self.logger.debug("Sync: malformed control message: %s", exc)
|
self.logger.debug("Sync: malformed control message: %s", exc)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if fire_new_cycle and self._on_new_cycle:
|
|
||||||
self._on_new_cycle()
|
|
||||||
|
|
||||||
except socket.timeout:
|
except socket.timeout:
|
||||||
continue
|
continue
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -679,15 +655,6 @@ class DisplaySyncManager:
|
|||||||
"""Follower: return the most recently received Vegas scroll position, or None."""
|
"""Follower: return the most recently received Vegas scroll position, or None."""
|
||||||
return self._latest_scroll_x
|
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]:
|
def get_latest_frame(self) -> Optional[Image.Image]:
|
||||||
"""Follower: return the most recently received pixel frame (non-Vegas fallback)."""
|
"""Follower: return the most recently received pixel frame (non-Vegas fallback)."""
|
||||||
with self._frame_lock:
|
with self._frame_lock:
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ from src.common.permission_utils import (
|
|||||||
ensure_shared_group_ownership,
|
ensure_shared_group_ownership,
|
||||||
get_config_dir_mode
|
get_config_dir_mode
|
||||||
)
|
)
|
||||||
|
from src.deprecation import deprecated
|
||||||
|
|
||||||
|
|
||||||
def _private_copy(config: Dict[str, Any]) -> Dict[str, Any]:
|
def _private_copy(config: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
@@ -174,6 +175,7 @@ class ConfigManager:
|
|||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
@deprecated("3.10.0", "backups are handled by src.backup_manager")
|
||||||
def rollback_config(self, backup_version: Optional[str] = None) -> bool:
|
def rollback_config(self, backup_version: Optional[str] = None) -> bool:
|
||||||
"""
|
"""
|
||||||
Rollback configuration to a previous backup.
|
Rollback configuration to a previous backup.
|
||||||
@@ -198,6 +200,7 @@ class ConfigManager:
|
|||||||
|
|
||||||
return success
|
return success
|
||||||
|
|
||||||
|
@deprecated("3.10.0", "backups are handled by src.backup_manager")
|
||||||
def list_backups(self) -> List[BackupInfo]:
|
def list_backups(self) -> List[BackupInfo]:
|
||||||
"""
|
"""
|
||||||
List all available configuration backups.
|
List all available configuration backups.
|
||||||
@@ -208,6 +211,7 @@ class ConfigManager:
|
|||||||
atomic_mgr = self._get_atomic_manager()
|
atomic_mgr = self._get_atomic_manager()
|
||||||
return atomic_mgr.list_backups()
|
return atomic_mgr.list_backups()
|
||||||
|
|
||||||
|
@deprecated("3.10.0")
|
||||||
def validate_config_file(self, config_path: Optional[str] = None) -> ValidationResult:
|
def validate_config_file(self, config_path: Optional[str] = None) -> ValidationResult:
|
||||||
"""
|
"""
|
||||||
Validate a configuration file.
|
Validate a configuration file.
|
||||||
@@ -404,6 +408,7 @@ class ConfigManager:
|
|||||||
self.logger.error(error_msg, exc_info=True)
|
self.logger.error(error_msg, exc_info=True)
|
||||||
raise ConfigError(error_msg, config_path=self.config_path) from e
|
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]:
|
def get_secret(self, key: str) -> Optional[Any]:
|
||||||
"""Get a secret value by key."""
|
"""Get a secret value by key."""
|
||||||
try:
|
try:
|
||||||
@@ -757,6 +762,7 @@ class ConfigManager:
|
|||||||
self.logger.error(error_msg, exc_info=True)
|
self.logger.error(error_msg, exc_info=True)
|
||||||
raise ConfigError(error_msg, config_path=self.config_path, field=plugin_id) from e
|
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]:
|
def cleanup_orphaned_plugin_configs(self, valid_plugin_ids: List[str]) -> List[str]:
|
||||||
"""
|
"""
|
||||||
Remove configuration sections for plugins that are no longer installed.
|
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}")
|
self.logger.error(f"Error cleaning up orphaned plugin configs: {e}")
|
||||||
return removed
|
return removed
|
||||||
|
|
||||||
|
@deprecated("3.10.0")
|
||||||
def validate_all_plugin_configs(self, plugin_schema_manager=None) -> Dict[str, Dict[str, Any]]:
|
def validate_all_plugin_configs(self, plugin_schema_manager=None) -> Dict[str, Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Validate all plugin configurations against their schemas.
|
Validate all plugin configurations against their schemas.
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ from typing import Any, Dict, List
|
|||||||
|
|
||||||
from src.common.api_helper import DEFAULT_HTTP_HEADERS
|
from src.common.api_helper import DEFAULT_HTTP_HEADERS
|
||||||
from src.common.json_body import response_json
|
from src.common.json_body import response_json
|
||||||
|
from src.deprecation import deprecated
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -201,6 +202,7 @@ class DynamicTeamResolver:
|
|||||||
DynamicTeamResolver._failure_timestamp = current_time
|
DynamicTeamResolver._failure_timestamp = current_time
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
@deprecated("3.10.0", "use resolve_teams()")
|
||||||
def get_available_dynamic_teams(self) -> List[str]:
|
def get_available_dynamic_teams(self) -> List[str]:
|
||||||
"""
|
"""
|
||||||
Get list of available dynamic team names.
|
Get list of available dynamic team names.
|
||||||
@@ -210,6 +212,7 @@ class DynamicTeamResolver:
|
|||||||
"""
|
"""
|
||||||
return list(self.DYNAMIC_PATTERNS.keys())
|
return list(self.DYNAMIC_PATTERNS.keys())
|
||||||
|
|
||||||
|
@deprecated("3.10.0", "use resolve_teams()")
|
||||||
def is_dynamic_team(self, team_name: str) -> bool:
|
def is_dynamic_team(self, team_name: str) -> bool:
|
||||||
"""
|
"""
|
||||||
Check if a team name is a dynamic team.
|
Check if a team name is a dynamic team.
|
||||||
|
|||||||
+2
-40
@@ -123,8 +123,7 @@ class ErrorAggregator:
|
|||||||
self._error_counts: Dict[str, int] = defaultdict(int)
|
self._error_counts: Dict[str, int] = defaultdict(int)
|
||||||
self._plugin_error_counts: Dict[str, Dict[str, int]] = defaultdict(lambda: defaultdict(int))
|
self._plugin_error_counts: Dict[str, Dict[str, int]] = defaultdict(lambda: defaultdict(int))
|
||||||
self._patterns: Dict[str, ErrorPattern] = {}
|
self._patterns: Dict[str, ErrorPattern] = {}
|
||||||
self._pattern_callbacks: List[Callable[[ErrorPattern], None]] = []
|
self._lock = threading.RLock() # RLock: build_snapshot re-enters
|
||||||
self._lock = threading.RLock() # RLock: build_snapshot and pattern callbacks re-enter
|
|
||||||
|
|
||||||
# Track session start for relative timing
|
# Track session start for relative timing
|
||||||
self._session_start = datetime.now()
|
self._session_start = datetime.now()
|
||||||
@@ -238,13 +237,6 @@ class ErrorAggregator:
|
|||||||
f"{count} times in last {self.pattern_window}. "
|
f"{count} times in last {self.pattern_window}. "
|
||||||
f"Affected plugins: {set(affected_plugins) or 'unknown'}"
|
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:
|
else:
|
||||||
# Update existing pattern
|
# Update existing pattern
|
||||||
self._patterns[pattern_key].count = count
|
self._patterns[pattern_key].count = count
|
||||||
@@ -253,15 +245,6 @@ class ErrorAggregator:
|
|||||||
known = self._patterns[pattern_key].affected_plugins
|
known = self._patterns[pattern_key].affected_plugins
|
||||||
known.extend(p for p in affected_plugins if p not in known)
|
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]:
|
def get_error_summary(self) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Get summary of all errors for reporting.
|
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
|
"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
|
@property
|
||||||
def version(self) -> int:
|
def version(self) -> int:
|
||||||
"""Changes whenever the recorded errors do (see ErrorSnapshotPublisher)."""
|
"""Changes whenever the recorded errors do (see ErrorSnapshotPublisher)."""
|
||||||
@@ -354,7 +316,7 @@ class ErrorAggregator:
|
|||||||
def clear_before(self, cutoff: datetime) -> int:
|
def clear_before(self, cutoff: datetime) -> int:
|
||||||
"""Forget every error recorded at or before ``cutoff``.
|
"""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
|
the per-type and per-plugin counts are rebuilt from the records that
|
||||||
remain, and detected patterns that began before the cutoff are dropped
|
remain, and detected patterns that began before the cutoff are dropped
|
||||||
(one that is still happening is detected again on its next
|
(one that is still happening is detected again on its next
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ from PIL import ImageFont
|
|||||||
from src.common.bdf_font import load_bdf_face, read_bdf_native_size
|
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 src.common.font_layout import load_truetype, resolve_asset_path
|
||||||
from typing import Dict, Tuple, Optional, Union, Any
|
from typing import Dict, Tuple, Optional, Union, Any
|
||||||
|
from src.deprecation import deprecated
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -533,6 +534,7 @@ class FontManager:
|
|||||||
"""
|
"""
|
||||||
return load_bdf_face(font_path, size_px)[0]
|
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]:
|
def get_native_bdf_size(self, family: str) -> Optional[int]:
|
||||||
"""The one true pixel size of a BDF family in the catalog, or None
|
"""The one true pixel size of a BDF family in the catalog, or None
|
||||||
for scalable (TTF) families / unknown families."""
|
for scalable (TTF) families / unknown families."""
|
||||||
@@ -553,6 +555,7 @@ class FontManager:
|
|||||||
|
|
||||||
# ==================== Font Measurement ====================
|
# ==================== 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]:
|
def measure_text(self, text: str, font: Union[ImageFont.FreeTypeFont, freetype.Face]) -> Tuple[int, int, int]:
|
||||||
"""
|
"""
|
||||||
Measure text dimensions and baseline.
|
Measure text dimensions and baseline.
|
||||||
|
|||||||
@@ -288,11 +288,6 @@ def errors_clear(request_id: str, cutoff: float, *,
|
|||||||
timeout=timeout, paths=paths)
|
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,
|
def hello(client: str = 'web', *, timeout: float = DEFAULT_TIMEOUT_SECONDS,
|
||||||
paths: Optional[Sequence[str]] = None) -> Dict[str, Any]:
|
paths: Optional[Sequence[str]] = None) -> Dict[str, Any]:
|
||||||
"""Version negotiation: the result's ``version`` is the one both sides speak."""
|
"""Version negotiation: the result's ``version`` is the one both sides speak."""
|
||||||
|
|||||||
@@ -399,9 +399,6 @@ class HelloArgs:
|
|||||||
versions: Tuple[int, ...] = (PROTOCOL_VERSION,)
|
versions: Tuple[int, ...] = (PROTOCOL_VERSION,)
|
||||||
client: str = ''
|
client: str = ''
|
||||||
|
|
||||||
def to_dict(self) -> Dict[str, Any]:
|
|
||||||
return {'versions': list(self.versions), 'client': self.client}
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(cls, args: Mapping[str, Any]) -> 'HelloArgs':
|
def from_dict(cls, args: Mapping[str, Any]) -> 'HelloArgs':
|
||||||
versions = args.get('versions', [PROTOCOL_VERSION])
|
versions = args.get('versions', [PROTOCOL_VERSION])
|
||||||
@@ -426,10 +423,6 @@ class OnDemandStartArgs:
|
|||||||
duration: Optional[float] = None
|
duration: Optional[float] = None
|
||||||
pinned: bool = False
|
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
|
@classmethod
|
||||||
def from_dict(cls, args: Mapping[str, Any]) -> 'OnDemandStartArgs':
|
def from_dict(cls, args: Mapping[str, Any]) -> 'OnDemandStartArgs':
|
||||||
plugin_id = _optional_name(args, 'plugin_id')
|
plugin_id = _optional_name(args, 'plugin_id')
|
||||||
@@ -449,9 +442,6 @@ class OnDemandStartArgs:
|
|||||||
class OnDemandStopArgs:
|
class OnDemandStopArgs:
|
||||||
"""``on_demand.stop``: end the on-demand session and resume rotation."""
|
"""``on_demand.stop``: end the on-demand session and resume rotation."""
|
||||||
|
|
||||||
def to_dict(self) -> Dict[str, Any]:
|
|
||||||
return {}
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(cls, args: Mapping[str, Any]) -> 'OnDemandStopArgs':
|
def from_dict(cls, args: Mapping[str, Any]) -> 'OnDemandStopArgs':
|
||||||
return cls()
|
return cls()
|
||||||
@@ -461,9 +451,6 @@ class OnDemandStopArgs:
|
|||||||
class NoArgs:
|
class NoArgs:
|
||||||
"""``ping`` and ``on_demand.status`` take no arguments (extra ones are ignored)."""
|
"""``ping`` and ``on_demand.status`` take no arguments (extra ones are ignored)."""
|
||||||
|
|
||||||
def to_dict(self) -> Dict[str, Any]:
|
|
||||||
return {}
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(cls, args: Mapping[str, Any]) -> 'NoArgs':
|
def from_dict(cls, args: Mapping[str, Any]) -> 'NoArgs':
|
||||||
return cls()
|
return cls()
|
||||||
@@ -480,9 +467,6 @@ class BrightnessSetArgs:
|
|||||||
"""
|
"""
|
||||||
brightness: int
|
brightness: int
|
||||||
|
|
||||||
def to_dict(self) -> Dict[str, Any]:
|
|
||||||
return {'brightness': self.brightness}
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(cls, args: Mapping[str, Any]) -> 'BrightnessSetArgs':
|
def from_dict(cls, args: Mapping[str, Any]) -> 'BrightnessSetArgs':
|
||||||
value = args.get('brightness')
|
value = args.get('brightness')
|
||||||
@@ -503,9 +487,6 @@ class PluginReloadArgs:
|
|||||||
"""
|
"""
|
||||||
plugin_id: str
|
plugin_id: str
|
||||||
|
|
||||||
def to_dict(self) -> Dict[str, Any]:
|
|
||||||
return {'plugin_id': self.plugin_id}
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(cls, args: Mapping[str, Any]) -> 'PluginReloadArgs':
|
def from_dict(cls, args: Mapping[str, Any]) -> 'PluginReloadArgs':
|
||||||
plugin_id = _optional_name(args, 'plugin_id')
|
plugin_id = _optional_name(args, 'plugin_id')
|
||||||
@@ -546,9 +527,6 @@ class StateGetArgs:
|
|||||||
since: Optional[int] = None
|
since: Optional[int] = None
|
||||||
epoch: Optional[str] = None
|
epoch: Optional[str] = None
|
||||||
|
|
||||||
def to_dict(self) -> Dict[str, Any]:
|
|
||||||
return {'since': self.since, 'epoch': self.epoch}
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(cls, args: Mapping[str, Any]) -> 'StateGetArgs':
|
def from_dict(cls, args: Mapping[str, Any]) -> 'StateGetArgs':
|
||||||
return cls(since=_optional_version(args, 'since'), epoch=_optional_epoch(args))
|
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`.
|
and a ``tick`` at least every :data:`SUBSCRIBE_KEEPALIVE_SECONDS`.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def to_dict(self) -> Dict[str, Any]:
|
|
||||||
return {}
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(cls, args: Mapping[str, Any]) -> 'StateSubscribeArgs':
|
def from_dict(cls, args: Mapping[str, Any]) -> 'StateSubscribeArgs':
|
||||||
return cls()
|
return cls()
|
||||||
@@ -582,9 +557,6 @@ class ErrorsClearArgs:
|
|||||||
"""
|
"""
|
||||||
cutoff: float
|
cutoff: float
|
||||||
|
|
||||||
def to_dict(self) -> Dict[str, Any]:
|
|
||||||
return {'cutoff': self.cutoff}
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(cls, args: Mapping[str, Any]) -> 'ErrorsClearArgs':
|
def from_dict(cls, args: Mapping[str, Any]) -> 'ErrorsClearArgs':
|
||||||
value = args.get('cutoff')
|
value = args.get('cutoff')
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ from src.common.permission_utils import (
|
|||||||
get_assets_dir_mode,
|
get_assets_dir_mode,
|
||||||
get_assets_file_mode
|
get_assets_file_mode
|
||||||
)
|
)
|
||||||
|
from src.deprecation import deprecated
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -471,6 +472,7 @@ class LogoDownloader:
|
|||||||
logger.info(f"Using dynamic ESPN endpoint for custom soccer league: {league}")
|
logger.info(f"Using dynamic ESPN endpoint for custom soccer league: {league}")
|
||||||
return api_url
|
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]:
|
def fetch_teams_data(self, league: str) -> Optional[Dict]:
|
||||||
"""Fetch team data from ESPN API for a specific league."""
|
"""Fetch team data from ESPN API for a specific league."""
|
||||||
api_url = self._resolve_api_url(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}")
|
logger.error(f"Error parsing JSON response for {team_id} in {league}: {e}")
|
||||||
return None
|
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]]:
|
def extract_teams_from_data(self, data: Dict, league: str) -> List[Dict[str, str]]:
|
||||||
"""Extract team information from ESPN API response."""
|
"""Extract team information from ESPN API response."""
|
||||||
teams = []
|
teams = []
|
||||||
@@ -621,6 +624,7 @@ class LogoDownloader:
|
|||||||
# Default to FBS for unknown conferences
|
# Default to FBS for unknown conferences
|
||||||
return 'FBS'
|
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]:
|
def download_missing_logos_for_league(self, league: str, force_download: bool = False) -> Tuple[int, int]:
|
||||||
"""Download missing logos for a specific league."""
|
"""Download missing logos for a specific league."""
|
||||||
logger.info(f"Starting logo download for league: {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")
|
logger.info(f"Logo download complete for {league}: {downloaded_count} downloaded, {failed_count} failed")
|
||||||
return downloaded_count, failed_count
|
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]:
|
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."""
|
"""Download all NCAA football team logos including FCS teams."""
|
||||||
logger.info(f"Starting comprehensive NCAA football logo download (FCS: {include_fcs})")
|
logger.info(f"Starting comprehensive NCAA football logo download (FCS: {include_fcs})")
|
||||||
@@ -763,6 +768,7 @@ class LogoDownloader:
|
|||||||
time.sleep(0.1) # Small delay
|
time.sleep(0.1) # Small delay
|
||||||
return success
|
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]]:
|
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."""
|
"""Download missing logos for all specified leagues."""
|
||||||
if leagues is None:
|
if leagues is None:
|
||||||
@@ -858,6 +864,7 @@ class LogoDownloader:
|
|||||||
logger.error(f"Failed to create placeholder logo for {team_abbreviation}: {e}")
|
logger.error(f"Failed to create placeholder logo for {team_abbreviation}: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
@deprecated("3.10.0")
|
||||||
def convert_image_to_rgba(self, filepath: Path) -> bool:
|
def convert_image_to_rgba(self, filepath: Path) -> bool:
|
||||||
"""Convert an image file to RGBA format to avoid PIL warnings."""
|
"""Convert an image file to RGBA format to avoid PIL warnings."""
|
||||||
try:
|
try:
|
||||||
@@ -875,6 +882,7 @@ class LogoDownloader:
|
|||||||
logger.error(f"Failed to convert {filepath.name} to RGBA: {e}")
|
logger.error(f"Failed to convert {filepath.name} to RGBA: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
@deprecated("3.10.0")
|
||||||
def convert_all_logos_to_rgba(self, league: str) -> Tuple[int, int]:
|
def convert_all_logos_to_rgba(self, league: str) -> Tuple[int, int]:
|
||||||
"""Convert all logos in a league directory to RGBA format."""
|
"""Convert all logos in a league directory to RGBA format."""
|
||||||
logo_dir = Path(self.get_logo_directory(league))
|
logo_dir = Path(self.get_logo_directory(league))
|
||||||
|
|||||||
@@ -51,44 +51,36 @@ class OperationHistory:
|
|||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
history_file: Optional[str] = None,
|
history_file: Optional[str] = None,
|
||||||
max_records: int = 1000,
|
max_records: int = 1000
|
||||||
lazy_load: bool = False
|
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
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:
|
Args:
|
||||||
history_file: Path to file for persisting history
|
history_file: Path to file for persisting history
|
||||||
max_records: Maximum number of records to keep
|
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.logger = get_logger(__name__)
|
||||||
self.history_file = Path(history_file) if history_file else None
|
self.history_file = Path(history_file) if history_file else None
|
||||||
self.max_records = max_records
|
self.max_records = max_records
|
||||||
self._lazy_load = lazy_load
|
|
||||||
self._history_loaded = False
|
self._history_loaded = False
|
||||||
|
|
||||||
# In-memory history
|
# In-memory history
|
||||||
self._history: List[OperationRecord] = []
|
self._history: List[OperationRecord] = []
|
||||||
self._lock = threading.RLock()
|
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:
|
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():
|
if not self._history_loaded and self.history_file and self.history_file.exists():
|
||||||
self._load_history()
|
self._load_history()
|
||||||
self._history_loaded = True
|
self._history_loaded = True
|
||||||
|
|
||||||
def record_operation(
|
def record_operation(
|
||||||
self,
|
self,
|
||||||
operation_type: str,
|
operation_type: str,
|
||||||
plugin_id: Optional[str] = None,
|
plugin_id: Optional[str] = None,
|
||||||
status: str = "completed",
|
status: str = "completed",
|
||||||
user: Optional[str] = None,
|
|
||||||
details: Optional[Dict[str, Any]] = None,
|
details: Optional[Dict[str, Any]] = None,
|
||||||
error: Optional[str] = None,
|
error: Optional[str] = None,
|
||||||
operation_id: Optional[str] = None
|
operation_id: Optional[str] = None
|
||||||
@@ -100,7 +92,6 @@ class OperationHistory:
|
|||||||
operation_type: Type of operation (install, update, uninstall, etc.)
|
operation_type: Type of operation (install, update, uninstall, etc.)
|
||||||
plugin_id: Plugin identifier
|
plugin_id: Plugin identifier
|
||||||
status: Operation status
|
status: Operation status
|
||||||
user: User who performed operation
|
|
||||||
details: Optional operation details
|
details: Optional operation details
|
||||||
error: Optional error message
|
error: Optional error message
|
||||||
operation_id: Optional operation ID
|
operation_id: Optional operation ID
|
||||||
@@ -118,7 +109,6 @@ class OperationHistory:
|
|||||||
plugin_id=plugin_id,
|
plugin_id=plugin_id,
|
||||||
timestamp=datetime.now(),
|
timestamp=datetime.now(),
|
||||||
status=status,
|
status=status,
|
||||||
user=user,
|
|
||||||
details=details,
|
details=details,
|
||||||
error=error
|
error=error
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
Plugin operation queue manager.
|
Plugin operation queue manager.
|
||||||
|
|
||||||
Serializes plugin operations to prevent conflicts and provides
|
Serializes plugin operations to prevent conflicts and provides
|
||||||
status tracking and cancellation support.
|
status tracking.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import threading
|
import threading
|
||||||
@@ -25,8 +25,8 @@ class PluginOperationQueue:
|
|||||||
- Serialized execution (one operation at a time)
|
- Serialized execution (one operation at a time)
|
||||||
- Prevents concurrent operations on same plugin
|
- Prevents concurrent operations on same plugin
|
||||||
- Operation status tracking
|
- Operation status tracking
|
||||||
- Operation cancellation
|
- A bounded in-memory history of finished operations, which also caps
|
||||||
- In-memory history of finished operations
|
how many finished operations get_operation_status() remembers
|
||||||
|
|
||||||
The history is not persisted. The web UI's operation history comes from
|
The history is not persisted. The web UI's operation history comes from
|
||||||
OperationHistory (operation_history.py), which has its own file; a copy
|
OperationHistory (operation_history.py), which has its own file; a copy
|
||||||
@@ -133,56 +133,6 @@ class PluginOperationQueue:
|
|||||||
with self._lock:
|
with self._lock:
|
||||||
return self._operations.get(operation_id)
|
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:
|
def _start_worker(self) -> None:
|
||||||
"""Start the worker thread that processes operations."""
|
"""Start the worker thread that processes operations."""
|
||||||
if self._worker_thread and self._worker_thread.is_alive():
|
if self._worker_thread and self._worker_thread.is_alive():
|
||||||
@@ -207,11 +157,6 @@ class PluginOperationQueue:
|
|||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Check if operation was cancelled
|
|
||||||
if operation.status == OperationStatus.CANCELLED:
|
|
||||||
self._operation_queue.task_done()
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Execute operation
|
# Execute operation
|
||||||
self._execute_operation(operation)
|
self._execute_operation(operation)
|
||||||
|
|
||||||
|
|||||||
@@ -15,11 +15,7 @@ import uuid
|
|||||||
class OperationType(Enum):
|
class OperationType(Enum):
|
||||||
"""Types of plugin operations."""
|
"""Types of plugin operations."""
|
||||||
INSTALL = "install"
|
INSTALL = "install"
|
||||||
UPDATE = "update"
|
|
||||||
UNINSTALL = "uninstall"
|
UNINSTALL = "uninstall"
|
||||||
ENABLE = "enable"
|
|
||||||
DISABLE = "disable"
|
|
||||||
CONFIGURE = "configure"
|
|
||||||
|
|
||||||
|
|
||||||
class OperationStatus(Enum):
|
class OperationStatus(Enum):
|
||||||
@@ -28,7 +24,6 @@ class OperationStatus(Enum):
|
|||||||
RUNNING = "running"
|
RUNNING = "running"
|
||||||
COMPLETED = "completed"
|
COMPLETED = "completed"
|
||||||
FAILED = "failed"
|
FAILED = "failed"
|
||||||
CANCELLED = "cancelled"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -70,29 +65,3 @@ class PluginOperation:
|
|||||||
'started_at': self.started_at.isoformat() if self.started_at else None,
|
'started_at': self.started_at.isoformat() if self.started_at else None,
|
||||||
'completed_at': self.completed_at.isoformat() if self.completed_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
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
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 --
|
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
|
manifest, config schema, the plugin's section of config.json -- and never
|
||||||
version -- and never imports a plugin module, instantiates a plugin class or
|
imports a plugin module, instantiates a plugin class or calls a plugin
|
||||||
calls a plugin lifecycle hook. This class is that read side.
|
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`
|
It keeps the method names of the read-only part of :class:`PluginManager`
|
||||||
(``discover_plugins``, ``plugin_manifests``, ``get_plugin_info``,
|
(``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").
|
See docs/ARCHITECTURE.md ("Web and display processes").
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
|
||||||
import threading
|
import threading
|
||||||
from pathlib import Path
|
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 (
|
from src.common.permission_utils import (
|
||||||
ensure_directory_permissions, get_plugin_dir_mode,
|
ensure_directory_permissions, get_plugin_dir_mode,
|
||||||
@@ -41,18 +41,15 @@ PathLike = Union[str, Path]
|
|||||||
|
|
||||||
|
|
||||||
class PluginCatalog:
|
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`
|
Discovery is explicit and cheap to repeat: :meth:`discover_plugins`
|
||||||
rescans the plugins directory and replaces the manifest map, so an
|
rescans the plugins directory and replaces the manifest map, so an
|
||||||
uninstalled plugin disappears and a new one appears.
|
uninstalled plugin disappears and a new one appears.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, plugins_dir: PathLike, config_manager: Optional[Any] = None,
|
def __init__(self, plugins_dir: PathLike) -> None:
|
||||||
schema_manager: Optional[Any] = None) -> None:
|
|
||||||
self.plugins_dir: Path = Path(plugins_dir)
|
self.plugins_dir: Path = Path(plugins_dir)
|
||||||
self.config_manager = config_manager
|
|
||||||
self.schema_manager = schema_manager
|
|
||||||
self.logger = get_logger(__name__)
|
self.logger = get_logger(__name__)
|
||||||
|
|
||||||
# Guards plugin_manifests/plugin_directories: request threads read
|
# Guards plugin_manifests/plugin_directories: request threads read
|
||||||
@@ -132,30 +129,6 @@ class PluginCatalog:
|
|||||||
ids = list(self.plugin_manifests)
|
ids = list(self.plugin_manifests)
|
||||||
return [info for info in (self.get_plugin_info(pid) for pid in ids) if info]
|
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]:
|
def get_plugin_directory(self, plugin_id: str) -> Optional[str]:
|
||||||
"""Where ``plugin_id`` is installed, or None.
|
"""Where ``plugin_id`` is installed, or None.
|
||||||
|
|
||||||
@@ -195,31 +168,6 @@ class PluginCatalog:
|
|||||||
return plugin_id
|
return plugin_id
|
||||||
return None
|
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, *,
|
def display_restart_required(action: str, plugin_enabled: bool, *,
|
||||||
changed: bool = True,
|
changed: bool = True,
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ from src.common.permission_utils import (
|
|||||||
ensure_directory_permissions,
|
ensure_directory_permissions,
|
||||||
get_plugin_dir_mode
|
get_plugin_dir_mode
|
||||||
)
|
)
|
||||||
|
from src.deprecation import deprecated
|
||||||
|
|
||||||
|
|
||||||
class _DeferredConfigChange(NamedTuple):
|
class _DeferredConfigChange(NamedTuple):
|
||||||
@@ -939,6 +940,7 @@ class PluginManager:
|
|||||||
"""
|
"""
|
||||||
return self.plugins.get(plugin_id)
|
return self.plugins.get(plugin_id)
|
||||||
|
|
||||||
|
@deprecated("3.10.0", "use get_plugin(plugin_id)")
|
||||||
def get_all_plugins(self) -> Dict[str, Any]:
|
def get_all_plugins(self) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Get all loaded plugins.
|
Get all loaded plugins.
|
||||||
@@ -948,6 +950,7 @@ class PluginManager:
|
|||||||
"""
|
"""
|
||||||
return self.plugins.copy()
|
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]]:
|
def get_plugin_info(self, plugin_id: str) -> Optional[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Get information about a plugin (manifest + runtime info).
|
Get information about a plugin (manifest + runtime info).
|
||||||
@@ -985,6 +988,7 @@ class PluginManager:
|
|||||||
|
|
||||||
return info
|
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]]:
|
def get_all_plugin_info(self) -> List[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Get information about all plugins.
|
Get information about all plugins.
|
||||||
@@ -1025,6 +1029,7 @@ class PluginManager:
|
|||||||
by_manifest=False)
|
by_manifest=False)
|
||||||
return str(plugin_dir) if plugin_dir is not None else None
|
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]:
|
def get_plugin_display_modes(self, plugin_id: str) -> List[str]:
|
||||||
"""
|
"""
|
||||||
Get display modes provided by a plugin.
|
Get display modes provided by a plugin.
|
||||||
@@ -1045,6 +1050,7 @@ class PluginManager:
|
|||||||
return display_modes
|
return display_modes
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
@deprecated("3.10.0", "read manifests with src.plugin_system.plugin_catalog.PluginCatalog")
|
||||||
def find_plugin_for_mode(self, mode: str) -> Optional[str]:
|
def find_plugin_for_mode(self, mode: str) -> Optional[str]:
|
||||||
"""
|
"""
|
||||||
Find which plugin provides a given display mode.
|
Find which plugin provides a given display mode.
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from datetime import datetime
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
from src.logging_config import get_logger
|
from src.logging_config import get_logger
|
||||||
|
from src.deprecation import deprecated
|
||||||
|
|
||||||
|
|
||||||
class PluginState(Enum):
|
class PluginState(Enum):
|
||||||
@@ -138,6 +139,7 @@ class PluginStateManager:
|
|||||||
"""
|
"""
|
||||||
return self._states.get(plugin_id, PluginState.UNLOADED)
|
return self._states.get(plugin_id, PluginState.UNLOADED)
|
||||||
|
|
||||||
|
@deprecated("3.10.0", "use get_state()")
|
||||||
def is_loaded(self, plugin_id: str) -> bool:
|
def is_loaded(self, plugin_id: str) -> bool:
|
||||||
"""Check if plugin is loaded."""
|
"""Check if plugin is loaded."""
|
||||||
state = self.get_state(plugin_id)
|
state = self.get_state(plugin_id)
|
||||||
@@ -148,11 +150,13 @@ class PluginStateManager:
|
|||||||
state = self.get_state(plugin_id)
|
state = self.get_state(plugin_id)
|
||||||
return state == PluginState.ENABLED
|
return state == PluginState.ENABLED
|
||||||
|
|
||||||
|
@deprecated("3.10.0", "use get_state()")
|
||||||
def is_running(self, plugin_id: str) -> bool:
|
def is_running(self, plugin_id: str) -> bool:
|
||||||
"""Check if plugin is currently running."""
|
"""Check if plugin is currently running."""
|
||||||
state = self.get_state(plugin_id)
|
state = self.get_state(plugin_id)
|
||||||
return state == PluginState.RUNNING
|
return state == PluginState.RUNNING
|
||||||
|
|
||||||
|
@deprecated("3.10.0", "use get_state()")
|
||||||
def is_error(self, plugin_id: str) -> bool:
|
def is_error(self, plugin_id: str) -> bool:
|
||||||
"""Check if plugin is in error state."""
|
"""Check if plugin is in error state."""
|
||||||
state = self.get_state(plugin_id)
|
state = self.get_state(plugin_id)
|
||||||
@@ -197,6 +201,7 @@ class PluginStateManager:
|
|||||||
state.value,
|
state.value,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@deprecated("3.10.0")
|
||||||
def get_error_info(self, plugin_id: str) -> Optional[Dict[str, Any]]:
|
def get_error_info(self, plugin_id: str) -> Optional[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Get error information for a plugin.
|
Get error information for a plugin.
|
||||||
@@ -265,10 +270,12 @@ class PluginStateManager:
|
|||||||
"""Record that plugin update() was called."""
|
"""Record that plugin update() was called."""
|
||||||
self._last_update[plugin_id] = datetime.now()
|
self._last_update[plugin_id] = datetime.now()
|
||||||
|
|
||||||
|
@deprecated("3.10.0")
|
||||||
def get_last_update(self, plugin_id: str) -> Optional[datetime]:
|
def get_last_update(self, plugin_id: str) -> Optional[datetime]:
|
||||||
"""Get timestamp of last update() call."""
|
"""Get timestamp of last update() call."""
|
||||||
return self._last_update.get(plugin_id)
|
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]:
|
def get_state_info(self, plugin_id: str) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Get comprehensive state information for a plugin.
|
Get comprehensive state information for a plugin.
|
||||||
|
|||||||
@@ -38,7 +38,6 @@ class InconsistencyType(Enum):
|
|||||||
PLUGIN_MISSING_ON_DISK = "plugin_missing_on_disk"
|
PLUGIN_MISSING_ON_DISK = "plugin_missing_on_disk"
|
||||||
PLUGIN_ENABLED_MISMATCH = "plugin_enabled_mismatch"
|
PLUGIN_ENABLED_MISMATCH = "plugin_enabled_mismatch"
|
||||||
PLUGIN_VERSION_MISMATCH = "plugin_version_mismatch"
|
PLUGIN_VERSION_MISMATCH = "plugin_version_mismatch"
|
||||||
PLUGIN_STATE_CORRUPTED = "plugin_state_corrupted"
|
|
||||||
|
|
||||||
|
|
||||||
class FixAction(Enum):
|
class FixAction(Enum):
|
||||||
@@ -57,7 +56,6 @@ class Inconsistency:
|
|||||||
fix_action: FixAction
|
fix_action: FixAction
|
||||||
current_state: Dict[str, Any]
|
current_state: Dict[str, Any]
|
||||||
expected_state: Dict[str, Any]
|
expected_state: Dict[str, Any]
|
||||||
can_auto_fix: bool = False
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -270,7 +268,7 @@ class StateReconciliation:
|
|||||||
|
|
||||||
# Attempt to fix auto-fixable inconsistencies
|
# Attempt to fix auto-fixable inconsistencies
|
||||||
for inconsistency in 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):
|
if self._fix_inconsistency(inconsistency):
|
||||||
fixed.append(inconsistency)
|
fixed.append(inconsistency)
|
||||||
else:
|
else:
|
||||||
@@ -428,7 +426,6 @@ class StateReconciliation:
|
|||||||
fix_action=FixAction.AUTO_FIX,
|
fix_action=FixAction.AUTO_FIX,
|
||||||
current_state={'exists_in_config': False},
|
current_state={'exists_in_config': False},
|
||||||
expected_state={'exists_in_config': True, 'enabled': False},
|
expected_state={'exists_in_config': True, 'enabled': False},
|
||||||
can_auto_fix=True
|
|
||||||
))
|
))
|
||||||
|
|
||||||
# Check: Plugin in config but not on disk
|
# 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,
|
fix_action=FixAction.AUTO_FIX if can_repair else FixAction.MANUAL_FIX_REQUIRED,
|
||||||
current_state={'exists_on_disk': False},
|
current_state={'exists_on_disk': False},
|
||||||
expected_state={'exists_on_disk': True},
|
expected_state={'exists_on_disk': True},
|
||||||
can_auto_fix=can_repair
|
|
||||||
))
|
))
|
||||||
|
|
||||||
# Observed checks: only against a live snapshot, and only for a plugin
|
# Observed checks: only against a live snapshot, and only for a plugin
|
||||||
@@ -486,7 +482,6 @@ class StateReconciliation:
|
|||||||
fix_action=FixAction.NO_ACTION,
|
fix_action=FixAction.NO_ACTION,
|
||||||
current_state={'loaded': loaded, 'state': runtime.get('state')},
|
current_state={'loaded': loaded, 'state': runtime.get('state')},
|
||||||
expected_state={'loaded': config_enabled},
|
expected_state={'loaded': config_enabled},
|
||||||
can_auto_fix=False
|
|
||||||
))
|
))
|
||||||
loaded_version = runtime.get('loaded_version')
|
loaded_version = runtime.get('loaded_version')
|
||||||
disk_version = disk.get('version')
|
disk_version = disk.get('version')
|
||||||
@@ -500,7 +495,6 @@ class StateReconciliation:
|
|||||||
fix_action=FixAction.NO_ACTION,
|
fix_action=FixAction.NO_ACTION,
|
||||||
current_state={'version': loaded_version},
|
current_state={'version': loaded_version},
|
||||||
expected_state={'version': disk_version},
|
expected_state={'version': disk_version},
|
||||||
can_auto_fix=False
|
|
||||||
))
|
))
|
||||||
|
|
||||||
return inconsistencies
|
return inconsistencies
|
||||||
|
|||||||
@@ -183,16 +183,7 @@ class _RegistryMixin:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def _distinct_sequence(values: List[str]) -> List[str]:
|
def _distinct_sequence(values: List[str]) -> List[str]:
|
||||||
"""Return list preserving order while removing duplicates and falsey entries."""
|
"""Return list preserving order while removing duplicates and falsey entries."""
|
||||||
seen = set()
|
return list(dict.fromkeys(v for v in values if v))
|
||||||
ordered = []
|
|
||||||
for value in values:
|
|
||||||
if not value:
|
|
||||||
continue
|
|
||||||
if value in seen:
|
|
||||||
continue
|
|
||||||
seen.add(value)
|
|
||||||
ordered.append(value)
|
|
||||||
return ordered
|
|
||||||
|
|
||||||
def _validate_manifest_version_fields(self, manifest: Dict[str, Any]) -> List[str]:
|
def _validate_manifest_version_fields(self, manifest: Dict[str, Any]) -> List[str]:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ from src.plugin_system.testing.mocks import (
|
|||||||
MockConfigManager,
|
MockConfigManager,
|
||||||
MockPluginManager
|
MockPluginManager
|
||||||
)
|
)
|
||||||
|
from src.deprecation import deprecated
|
||||||
|
|
||||||
|
|
||||||
class PluginTestCase(unittest.TestCase):
|
class PluginTestCase(unittest.TestCase):
|
||||||
@@ -34,6 +35,7 @@ class PluginTestCase(unittest.TestCase):
|
|||||||
Provides common fixtures and helper methods.
|
Provides common fixtures and helper methods.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
@deprecated("3.10.0", "use src.plugin_system.testing.harness and the mocks directly")
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
"""Set up test fixtures."""
|
"""Set up test fixtures."""
|
||||||
# Create mock managers
|
# Create mock managers
|
||||||
|
|||||||
@@ -291,49 +291,6 @@ class VegasModeConfig:
|
|||||||
max_cycle_duration=int(get('max_cycle_duration', d.max_cycle_duration)),
|
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:
|
def get_frame_interval(self) -> float:
|
||||||
"""Get the frame interval in seconds for target FPS."""
|
"""Get the frame interval in seconds for target FPS."""
|
||||||
return 1.0 / max(1, self.target_fps)
|
return 1.0 / max(1, self.target_fps)
|
||||||
|
|||||||
@@ -170,16 +170,6 @@ class VegasModeCoordinator:
|
|||||||
self._static_pause_active = False
|
self._static_pause_active = False
|
||||||
self._saved_scroll_position: Optional[int] = None
|
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(
|
logger.info(
|
||||||
"VegasModeCoordinator initialized: enabled=%s, fps=%d, buffer_ahead=%d",
|
"VegasModeCoordinator initialized: enabled=%s, fps=%d, buffer_ahead=%d",
|
||||||
self.vegas_config.enabled,
|
self.vegas_config.enabled,
|
||||||
@@ -314,7 +304,6 @@ class VegasModeCoordinator:
|
|||||||
# new run would have run_frame() refuse every frame.
|
# new run would have run_frame() refuse every frame.
|
||||||
self._is_paused = False
|
self._is_paused = False
|
||||||
self._live_priority_active = False
|
self._live_priority_active = False
|
||||||
self._start_time = time.time()
|
|
||||||
# A fresh run starts with a clean health slate: no stale
|
# A fresh run starts with a clean health slate: no stale
|
||||||
# "was degraded" from the previous run, and a heartbeat that is
|
# "was degraded" from the previous run, and a heartbeat that is
|
||||||
# due immediately so the first sample confirms the marquee is up.
|
# due immediately so the first sample confirms the marquee is up.
|
||||||
@@ -345,10 +334,6 @@ class VegasModeCoordinator:
|
|||||||
self._is_paused = False
|
self._is_paused = False
|
||||||
self._live_priority_active = 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._restore_switch_interval()
|
||||||
self._remove_render_gate()
|
self._remove_render_gate()
|
||||||
self._set_live(False, None)
|
self._set_live(False, None)
|
||||||
@@ -473,7 +458,6 @@ class VegasModeCoordinator:
|
|||||||
if not self._is_active:
|
if not self._is_active:
|
||||||
return
|
return
|
||||||
self._is_paused = True
|
self._is_paused = True
|
||||||
self.stats['interruptions'] += 1
|
|
||||||
|
|
||||||
self.display_manager.set_scrolling_state(False)
|
self.display_manager.set_scrolling_state(False)
|
||||||
logger.info("Vegas mode paused")
|
logger.info("Vegas mode paused")
|
||||||
@@ -543,9 +527,8 @@ class VegasModeCoordinator:
|
|||||||
if self.render_pipeline.has_deferred():
|
if self.render_pipeline.has_deferred():
|
||||||
self.render_pipeline.drain_deferred()
|
self.render_pipeline.drain_deferred()
|
||||||
elif self.render_pipeline.needs_extension():
|
elif self.render_pipeline.needs_extension():
|
||||||
if self.render_pipeline.extend_scroll_content():
|
if (not self.render_pipeline.extend_scroll_content()
|
||||||
self.stats['cycles_completed'] += 1
|
and self.render_pipeline.is_cycle_complete()):
|
||||||
elif self.render_pipeline.is_cycle_complete():
|
|
||||||
# Extension failed and the strip has run out: fall back to
|
# Extension failed and the strip has run out: fall back to
|
||||||
# the swap rather than sitting on a dead frame.
|
# the swap rather than sitting on a dead frame.
|
||||||
self.render_pipeline.start_new_cycle()
|
self.render_pipeline.start_new_cycle()
|
||||||
@@ -555,7 +538,6 @@ class VegasModeCoordinator:
|
|||||||
if not self.render_pipeline.start_new_cycle():
|
if not self.render_pipeline.start_new_cycle():
|
||||||
logger.warning("Failed to start new Vegas cycle")
|
logger.warning("Failed to start new Vegas cycle")
|
||||||
return False
|
return False
|
||||||
self.stats['cycles_completed'] += 1
|
|
||||||
|
|
||||||
# Check for hot-swap opportunities
|
# Check for hot-swap opportunities
|
||||||
if self.render_pipeline.should_recompose():
|
if self.render_pipeline.should_recompose():
|
||||||
@@ -835,7 +817,6 @@ class VegasModeCoordinator:
|
|||||||
self._pending_config_update = True
|
self._pending_config_update = True
|
||||||
self._pending_config = new_config
|
self._pending_config = new_config
|
||||||
self._config_version += 1
|
self._config_version += 1
|
||||||
self.stats['config_updates'] += 1
|
|
||||||
|
|
||||||
logger.debug("Config update queued (version %d)", self._config_version)
|
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.stream_manager.mark_plugin_updated(plugin_id)
|
||||||
self.plugin_adapter.invalidate_cache(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)
|
# Static pause handling (for STATIC display mode)
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
@@ -980,7 +944,6 @@ class VegasModeCoordinator:
|
|||||||
# Save current scroll position for smooth resume
|
# Save current scroll position for smooth resume
|
||||||
self._saved_scroll_position = self.render_pipeline.get_scroll_position()
|
self._saved_scroll_position = self.render_pipeline.get_scroll_position()
|
||||||
self._static_pause_active = True
|
self._static_pause_active = True
|
||||||
self.stats['static_pauses'] += 1
|
|
||||||
|
|
||||||
logger.info("Static pause started for plugin: %s", plugin_id)
|
logger.info("Static pause started for plugin: %s", plugin_id)
|
||||||
|
|
||||||
|
|||||||
@@ -218,7 +218,6 @@ class RenderPipeline:
|
|||||||
|
|
||||||
# Render state
|
# Render state
|
||||||
self._cycle_complete = False
|
self._cycle_complete = False
|
||||||
self._segments_in_scroll: List[str] = [] # Plugin IDs in current scroll
|
|
||||||
self._record_by_seq: Dict[int, ElementRecord] = {}
|
self._record_by_seq: Dict[int, ElementRecord] = {}
|
||||||
# Live updates. _applied: per record, the (epoch, digest) of the
|
# Live updates. _applied: per record, the (epoch, digest) of the
|
||||||
# pixels the strip holds. _live_slots / _live_ready: the worker's
|
# 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._frame_interval = config.get_frame_interval()
|
||||||
self._cycle_start_time = 0.0
|
self._cycle_start_time = 0.0
|
||||||
|
|
||||||
# Statistics
|
# Read by _measure_refresh (warm-up) and the live integration test.
|
||||||
self.stats = {
|
self.frames_rendered = 0
|
||||||
'frames_rendered': 0,
|
self.extensions = 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
|
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"RenderPipeline initialized: %dx%d @ %d FPS",
|
"RenderPipeline initialized: %dx%d @ %d FPS",
|
||||||
@@ -342,7 +335,7 @@ class RenderPipeline:
|
|||||||
return
|
return
|
||||||
if getattr(self.display_manager, 'matrix', None) is None:
|
if getattr(self.display_manager, 'matrix', None) is None:
|
||||||
return # No hardware: nothing blocks, so there is nothing to time.
|
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
|
return
|
||||||
self._swap_times.append(time.monotonic())
|
self._swap_times.append(time.monotonic())
|
||||||
if len(self._swap_times) <= self.REFRESH_SAMPLES:
|
if len(self._swap_times) <= self.REFRESH_SAMPLES:
|
||||||
@@ -452,10 +445,6 @@ class RenderPipeline:
|
|||||||
layouts)
|
layouts)
|
||||||
self._note_op('compose', self._strip_nbytes())
|
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_start_time = time.time()
|
||||||
self._cycle_complete = False
|
self._cycle_complete = False
|
||||||
|
|
||||||
@@ -865,9 +854,7 @@ class RenderPipeline:
|
|||||||
# trim's copy, if it made one.
|
# trim's copy, if it made one.
|
||||||
self._note_op('extend', moved + (self._copied_bytes() if cut else 0))
|
self._note_op('extend', moved + (self._copied_bytes() if cut else 0))
|
||||||
|
|
||||||
self._segments_in_scroll = [pid for pid, _ in grouped]
|
self.extensions += 1
|
||||||
self.stats['composition_count'] += 1
|
|
||||||
self.stats['extensions'] = self.stats.get('extensions', 0) + 1
|
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"Extended scroll strip with %d plugin block(s), %d rows: "
|
"Extended scroll strip with %d plugin block(s), %d rows: "
|
||||||
@@ -1150,8 +1137,6 @@ class RenderPipeline:
|
|||||||
Returns:
|
Returns:
|
||||||
True if frame was rendered, False if no content
|
True if frame was rendered, False if no content
|
||||||
"""
|
"""
|
||||||
frame_start = time.time()
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if not self.scroll_helper.has_strip():
|
if not self.scroll_helper.has_strip():
|
||||||
return False
|
return False
|
||||||
@@ -1200,7 +1185,6 @@ class RenderPipeline:
|
|||||||
if at_wrap_point or self.scroll_helper.is_scroll_complete():
|
if at_wrap_point or self.scroll_helper.is_scroll_complete():
|
||||||
if not self._cycle_complete:
|
if not self._cycle_complete:
|
||||||
self._cycle_complete = True
|
self._cycle_complete = True
|
||||||
self.stats['scroll_cycles'] += 1
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"Scroll cycle complete after %.1fs",
|
"Scroll cycle complete after %.1fs",
|
||||||
time.time() - self._cycle_start_time
|
time.time() - self._cycle_start_time
|
||||||
@@ -1239,11 +1223,8 @@ class RenderPipeline:
|
|||||||
# Update scrolling state
|
# Update scrolling state
|
||||||
self.display_manager.set_scrolling_state(True, self._frame_hold)
|
self.display_manager.set_scrolling_state(True, self._frame_hold)
|
||||||
|
|
||||||
# Track statistics
|
self.frames_rendered += 1
|
||||||
self.stats['frames_rendered'] += 1
|
|
||||||
self._measure_refresh()
|
self._measure_refresh()
|
||||||
frame_time = time.time() - frame_start
|
|
||||||
self._track_frame_time(frame_time)
|
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -1252,15 +1233,6 @@ class RenderPipeline:
|
|||||||
logger.exception("Error rendering frame")
|
logger.exception("Error rendering frame")
|
||||||
return False
|
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:
|
def is_cycle_complete(self) -> bool:
|
||||||
"""Check if current scroll cycle is complete."""
|
"""Check if current scroll cycle is complete."""
|
||||||
return self._cycle_complete
|
return self._cycle_complete
|
||||||
@@ -1347,7 +1319,6 @@ class RenderPipeline:
|
|||||||
else:
|
else:
|
||||||
self.scroll_helper.scroll_position = 0.0
|
self.scroll_helper.scroll_position = 0.0
|
||||||
|
|
||||||
self.stats['hot_swaps'] += 1
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"Hot-swap completed: scroll repositioned %.0f→%.0f (%.1f%% of new %dpx image)",
|
"Hot-swap completed: scroll repositioned %.0f→%.0f (%.1f%% of new %dpx image)",
|
||||||
old_pos, self.scroll_helper.scroll_position,
|
old_pos, self.scroll_helper.scroll_position,
|
||||||
@@ -1396,8 +1367,6 @@ class RenderPipeline:
|
|||||||
# transition rather than near-end content wrapping around.
|
# transition rather than near-end content wrapping around.
|
||||||
self.scroll_helper.scroll_position = float(self.config.lead_in_width)
|
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.
|
# 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).
|
# Done in a background thread to not block the render loop (~15ms transfer).
|
||||||
image = self.scroll_helper.cached_image
|
image = self.scroll_helper.cached_image
|
||||||
@@ -1410,16 +1379,6 @@ class RenderPipeline:
|
|||||||
|
|
||||||
return result
|
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:
|
def get_scroll_position(self) -> int:
|
||||||
"""
|
"""
|
||||||
Get current scroll position.
|
Get current scroll position.
|
||||||
@@ -1465,8 +1424,6 @@ class RenderPipeline:
|
|||||||
self.scroll_helper.clear_cache()
|
self.scroll_helper.clear_cache()
|
||||||
|
|
||||||
self._cycle_complete = False
|
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
|
# 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
|
# first extension after Vegas is switched back on appended that stale
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ BasePlugin.get_vegas_participation):
|
|||||||
import logging
|
import logging
|
||||||
import threading
|
import threading
|
||||||
import time
|
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 collections import deque
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
@@ -94,13 +94,6 @@ class StreamManager:
|
|||||||
self._last_refresh: float = 0.0
|
self._last_refresh: float = 0.0
|
||||||
self._refresh_interval: float = 30.0 # Refresh plugin list every 30s
|
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)
|
logger.info("StreamManager initialized with buffer_ahead=%d", config.buffer_ahead)
|
||||||
|
|
||||||
def initialize(self) -> bool:
|
def initialize(self) -> bool:
|
||||||
@@ -143,47 +136,12 @@ class StreamManager:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
segment = self._active_buffer.popleft()
|
segment = self._active_buffer.popleft()
|
||||||
self.stats['segments_served'] += 1
|
|
||||||
|
|
||||||
# Trigger prefetch to maintain buffer
|
# Trigger prefetch to maintain buffer
|
||||||
self._ensure_buffer_filled()
|
self._ensure_buffer_filled()
|
||||||
|
|
||||||
return segment
|
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:
|
def mark_plugin_updated(self, plugin_id: str) -> None:
|
||||||
"""
|
"""
|
||||||
Mark a plugin as having updated data.
|
Mark a plugin as having updated data.
|
||||||
@@ -587,7 +545,6 @@ class StreamManager:
|
|||||||
images=[], # No images needed for static pause
|
images=[], # No images needed for static pause
|
||||||
display_mode=VegasDisplayMode.STATIC
|
display_mode=VegasDisplayMode.STATIC
|
||||||
)
|
)
|
||||||
self.stats['segments_fetched'] += 1
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"[%s] Created STATIC placeholder (pause trigger)",
|
"[%s] Created STATIC placeholder (pause trigger)",
|
||||||
plugin_id
|
plugin_id
|
||||||
@@ -610,7 +567,6 @@ class StreamManager:
|
|||||||
display_mode=VegasDisplayMode.SCROLL
|
display_mode=VegasDisplayMode.SCROLL
|
||||||
)
|
)
|
||||||
|
|
||||||
self.stats['segments_fetched'] += 1
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"[%s] Segment: %d image(s), %dpx",
|
"[%s] Segment: %d image(s), %dpx",
|
||||||
plugin_id, len(images), total_width
|
plugin_id, len(images), total_width
|
||||||
@@ -619,7 +575,6 @@ class StreamManager:
|
|||||||
|
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("[%s] ERROR fetching content", plugin_id)
|
logger.exception("[%s] ERROR fetching content", plugin_id)
|
||||||
self.stats['fetch_errors'] += 1
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _ensure_buffer_filled(self) -> None:
|
def _ensure_buffer_filled(self) -> None:
|
||||||
@@ -770,10 +725,8 @@ class StreamManager:
|
|||||||
plugin, plugin_id, offscreen_only=offscreen_only)
|
plugin, plugin_id, offscreen_only=offscreen_only)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("[%s] ERROR fetching content", plugin_id)
|
logger.exception("[%s] ERROR fetching content", plugin_id)
|
||||||
self.stats['fetch_errors'] += 1
|
|
||||||
return None
|
return None
|
||||||
if images:
|
if images:
|
||||||
self.stats['segments_fetched'] += 1
|
|
||||||
return (plugin_id, images)
|
return (plugin_id, images)
|
||||||
# Only the old contract hands anything back to the render thread.
|
# Only the old contract hands anything back to the render thread.
|
||||||
defer_empty = offscreen_only and not getattr(
|
defer_empty = offscreen_only and not getattr(
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import time
|
|||||||
from typing import Any, Optional, Dict, Tuple
|
from typing import Any, Optional, Dict, Tuple
|
||||||
from flask import jsonify, request
|
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
|
from src.web_interface.errors import ErrorCode, WebInterfaceError
|
||||||
|
|
||||||
|
|
||||||
@@ -31,7 +30,15 @@ def success_response(
|
|||||||
Returns:
|
Returns:
|
||||||
Flask jsonify response
|
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():
|
for key, value in (extra or {}).items():
|
||||||
response_data.setdefault(key, value)
|
response_data.setdefault(key, value)
|
||||||
|
|
||||||
@@ -70,14 +77,14 @@ def error_response(
|
|||||||
Returns:
|
Returns:
|
||||||
Flask jsonify response with status code
|
Flask jsonify response with status code
|
||||||
"""
|
"""
|
||||||
return create_error_response(
|
error = WebInterfaceError(
|
||||||
error_code=error_code,
|
error_code=error_code,
|
||||||
message=message,
|
message=message,
|
||||||
details=details,
|
details=details,
|
||||||
context=context,
|
context=context or {},
|
||||||
suggested_fixes=suggested_fixes,
|
suggested_fixes=suggested_fixes
|
||||||
status_code=status_code
|
|
||||||
)
|
)
|
||||||
|
return jsonify(error.to_dict()), status_code
|
||||||
|
|
||||||
|
|
||||||
def exception_error_response(
|
def exception_error_response(
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ from typing import Any, Dict
|
|||||||
def _schema_type_is(prop: Any, wanted: str) -> bool:
|
def _schema_type_is(prop: Any, wanted: str) -> bool:
|
||||||
"""Whether a schema property is of ``wanted`` type, unions included.
|
"""Whether a schema property is of ``wanted`` type, unions included.
|
||||||
|
|
||||||
Mirrors ``_schema_type_is`` in ``web_interface/blueprints/api_v3`` (kept
|
The one copy: ``web_interface/blueprints/api_v3`` imports it from here
|
||||||
here so src/ doesn't import the Flask blueprint). A union such as
|
(src/ must not import the Flask blueprint). A union such as
|
||||||
``["array", "null"]`` -- the per-element style overrides, where null means
|
``["array", "null"]`` -- the per-element style overrides, where null means
|
||||||
"inherit" -- is still an array for recombining position-keyed inputs.
|
"inherit" -- is still an array for recombining position-keyed inputs.
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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
|
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
|
# Long enough for an errno string with a path, short enough not to dump a
|
||||||
# parser's worth of context into a JSON field.
|
# parser's worth of context into a JSON field.
|
||||||
_MAX_DETAIL_LENGTH = 400
|
_MAX_DETAIL_LENGTH = 400
|
||||||
@@ -101,72 +94,3 @@ def http_exception_payload(error) -> dict:
|
|||||||
'error_code': (error.name or 'HTTP_ERROR').upper().replace(' ', '_'),
|
'error_code': (error.name or 'HTTP_ERROR').upper().replace(' ', '_'),
|
||||||
'message': error.description,
|
'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
|
|
||||||
|
|
||||||
|
|||||||
@@ -61,27 +61,14 @@ class WebInterfaceError:
|
|||||||
context: Optional[Dict[str, Any]] = None
|
context: Optional[Dict[str, Any]] = None
|
||||||
suggested_fixes: Optional[List[str]] = None
|
suggested_fixes: Optional[List[str]] = None
|
||||||
original_error: Optional[Exception] = None
|
original_error: Optional[Exception] = None
|
||||||
|
|
||||||
def __init__(
|
def __post_init__(self) -> None:
|
||||||
self,
|
self.context = self.context or {}
|
||||||
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 {}
|
|
||||||
# `is None`, not truthiness: an explicit [] means "this caller has
|
# `is None`, not truthiness: an explicit [] means "this caller has
|
||||||
# no suggestions to offer", which the default list would override.
|
# no suggestions to offer", which the default list would override.
|
||||||
self.suggested_fixes = (
|
if self.suggested_fixes is None:
|
||||||
suggested_fixes if suggested_fixes is not None
|
self.suggested_fixes = self._get_default_suggestions(self.error_code)
|
||||||
else self._get_default_suggestions(error_code))
|
|
||||||
self.original_error = original_error
|
|
||||||
|
|
||||||
def _get_default_suggestions(self, error_code: ErrorCode) -> List[str]:
|
def _get_default_suggestions(self, error_code: ErrorCode) -> List[str]:
|
||||||
"""Get default suggested fixes for error code."""
|
"""Get default suggested fixes for error code."""
|
||||||
suggestions_map = {
|
suggestions_map = {
|
||||||
|
|||||||
@@ -41,8 +41,7 @@ def mock_plugin_catalog():
|
|||||||
"""
|
"""
|
||||||
from src.plugin_system.plugin_catalog import PluginCatalog
|
from src.plugin_system.plugin_catalog import PluginCatalog
|
||||||
catalog = MagicMock(spec=PluginCatalog)
|
catalog = MagicMock(spec=PluginCatalog)
|
||||||
for name in ('plugins_dir', 'config_manager', 'schema_manager',
|
for name in ('plugins_dir', 'plugin_manifests', 'plugin_directories'):
|
||||||
'plugin_manifests', 'plugin_directories'):
|
|
||||||
setattr(catalog, name, MagicMock())
|
setattr(catalog, name, MagicMock())
|
||||||
return catalog
|
return catalog
|
||||||
|
|
||||||
|
|||||||
@@ -12,9 +12,10 @@
|
|||||||
//
|
//
|
||||||
// CodeQL reported 83 js/incomplete-html-attribute-sanitization alerts for
|
// CodeQL reported 83 js/incomplete-html-attribute-sanitization alerts for
|
||||||
// exactly this. The web UI now has one implementation, window.LEDEscape in
|
// 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
|
// app-early.js, which every page and widget calls directly (BaseWidget keeps
|
||||||
// suite runs LEDEscape and every one of those names as shipped, and fails if a
|
// an escapeHtml method for plugin widgets). This suite runs LEDEscape and that
|
||||||
// hand-rolled escaper appears anywhere else in web_interface/.
|
// method as shipped, and fails if a hand-rolled escaper appears anywhere else
|
||||||
|
// in web_interface/.
|
||||||
|
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
@@ -85,30 +86,6 @@ const ESCAPERS = [
|
|||||||
['app-early.js (LEDEscape.attr)', null, null, 'attr'],
|
['app-early.js (LEDEscape.attr)', null, null, 'attr'],
|
||||||
['base-widget.js (BaseWidget.escapeHtml)',
|
['base-widget.js (BaseWidget.escapeHtml)',
|
||||||
'static/v3/js/widgets/base-widget.js', 'escapeHtml(text) {', 'escapeHtml', true],
|
'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
|
// cache.html, backup_restore.html, operation_history.html and display.html
|
||||||
// have no script any more (display.html's two escapers were never called):
|
// have no script any more (display.html's two escapers were never called):
|
||||||
// their js/pages/ modules draw server data with textContent, and each
|
// 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');
|
console.log('\n4c. no hand-rolled escaper outside app-early.js');
|
||||||
{
|
{
|
||||||
const skip = new Set(['static/v3/js/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 found = [];
|
const found = [];
|
||||||
const walk = dir => fs.readdirSync(dir, { withFileTypes: true }).forEach(e => {
|
const walk = dir => fs.readdirSync(dir, { withFileTypes: true }).forEach(e => {
|
||||||
const p = path.join(dir, e.name);
|
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 ──
|
// ── 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,
|
// 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
|
// 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
|
// 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
|
// 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);
|
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 renderEntryTableSrc = extractFn('function renderEntryTable(fieldId, container, content) {');
|
||||||
|
|
||||||
const calls = [];
|
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 {
|
class FakeContainer {
|
||||||
constructor() { this._html = ''; this._listeners = {}; }
|
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
|
// eslint-disable-next-line no-eval
|
||||||
const renderEntryTable = eval(`(function(getState, escHtml, safeSetHTML, window){
|
const renderEntryTable = eval(`(function(getState, safeSetHTML, window){
|
||||||
${renderEntryTableSrc}
|
${renderEntryTableSrc}
|
||||||
return renderEntryTable;
|
return renderEntryTable;
|
||||||
})`)(
|
})`)(
|
||||||
() => ({ entriesPerPage: 20, _tablePage: 1 }),
|
() => ({ entriesPerPage: 20, _tablePage: 1 }),
|
||||||
escHtmlFn,
|
|
||||||
(target, html) => { target.innerHTML = html; },
|
(target, html) => { target.innerHTML = html; },
|
||||||
fakeWindow
|
fakeWindow
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -62,12 +62,12 @@ global.setGridHtmlIfChanged = (container, html) => { container.innerHTML = html;
|
|||||||
|
|
||||||
// eslint-disable-next-line no-eval
|
// eslint-disable-next-line no-eval
|
||||||
eval([
|
eval([
|
||||||
'function escapeHtml(text) {', 'function escapeAttribute(text) {', 'function jsStringAttr(value) {',
|
'function jsStringAttr(value) {',
|
||||||
'function renderPluginStore(plugins) {', 'function renderSavedRepositories(repositories) {',
|
'function renderPluginStore(plugins) {', 'function renderSavedRepositories(repositories) {',
|
||||||
'function renderCustomRegistryPlugins(plugins, registryUrl) {',
|
'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.renderPluginStore = renderPluginStore; global.renderSavedRepositories = renderSavedRepositories;'
|
||||||
+ '\nglobal.renderCustomRegistryPlugins = renderCustomRegistryPlugins; global.escapeAttribute = escapeAttribute;');
|
+ '\nglobal.renderCustomRegistryPlugins = renderCustomRegistryPlugins;');
|
||||||
|
|
||||||
// ── minimal HTML start-tag tokenizer ───────────────────────────────────────
|
// ── minimal HTML start-tag tokenizer ───────────────────────────────────────
|
||||||
function decodeEntities(s) {
|
function decodeEntities(s) {
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ const container = {
|
|||||||
innerHTML: '',
|
innerHTML: '',
|
||||||
querySelectorAll: () => [], // no skeletons in this harness
|
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.
|
// when you read innerHTML back off textContent: & < > are escaped, quotes are not.
|
||||||
class FakeEl {
|
class FakeEl {
|
||||||
set textContent(v) { this._t = String(v == null ? '' : v); }
|
set textContent(v) { this._t = String(v == null ? '' : v); }
|
||||||
@@ -36,7 +36,7 @@ global.PLUGIN_DEBUG = false;
|
|||||||
global.debugLog = () => {};
|
global.debugLog = () => {};
|
||||||
function setupInstalledEventDelegation() {} // stubbed; tested separately
|
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)',
|
eval(slice('function renderInstalledCards(plugins, total)',
|
||||||
'// Set up event delegation for plugin action buttons'));
|
'// Set up event delegation for plugin action buttons'));
|
||||||
|
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ global.installedPlugins = [];
|
|||||||
|
|
||||||
// eslint-disable-next-line no-eval
|
// eslint-disable-next-line no-eval
|
||||||
eval([
|
eval([
|
||||||
'function escapeHtml(text) {', 'function escapeAttribute(text) {', 'function jsStringAttr(value) {',
|
'function jsStringAttr(value) {',
|
||||||
'function isStorePluginInstalled(pluginIdOrPlugin) {',
|
'function isStorePluginInstalled(pluginIdOrPlugin) {',
|
||||||
'function findInstalledStorePlugin(pluginIdOrPlugin) {', 'function renderPluginStore(plugins) {',
|
'function findInstalledStorePlugin(pluginIdOrPlugin) {', 'function renderPluginStore(plugins) {',
|
||||||
].map(extract).join('\n') + '\nglobal.renderPluginStore = renderPluginStore;'
|
].map(extract).join('\n') + '\nglobal.renderPluginStore = renderPluginStore;'
|
||||||
|
|||||||
@@ -253,9 +253,10 @@ const noSleep = { sleep: async () => {} };
|
|||||||
for (const endpoint of bad) codes.push(await refusal(endpoint));
|
for (const endpoint of bad) codes.push(await refusal(endpoint));
|
||||||
ok('an endpoint that could leave the API path is refused before fetch()',
|
ok('an endpoint that could leave the API path is refused before fetch()',
|
||||||
codes.every(c => c === 'INVALID_ENDPOINT') && urls.length === 0, { codes, urls });
|
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',
|
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;
|
delete global.fetch;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
Tests for CacheManager and cache components.
|
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
|
import pytest
|
||||||
@@ -11,7 +11,6 @@ from src.cache_manager import CacheManager
|
|||||||
from src.cache.memory_cache import MemoryCache
|
from src.cache.memory_cache import MemoryCache
|
||||||
from src.cache.disk_cache import DiskCache
|
from src.cache.disk_cache import DiskCache
|
||||||
from src.cache.cache_strategy import CacheStrategy
|
from src.cache.cache_strategy import CacheStrategy
|
||||||
from src.cache.cache_metrics import CacheMetrics
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
@@ -26,7 +25,6 @@ class TestCacheManager:
|
|||||||
assert hasattr(cm, '_memory_cache_component')
|
assert hasattr(cm, '_memory_cache_component')
|
||||||
assert hasattr(cm, '_disk_cache_component')
|
assert hasattr(cm, '_disk_cache_component')
|
||||||
assert hasattr(cm, '_strategy_component')
|
assert hasattr(cm, '_strategy_component')
|
||||||
assert hasattr(cm, '_metrics_component')
|
|
||||||
|
|
||||||
def test_set_and_get(self, tmp_path):
|
def test_set_and_get(self, tmp_path):
|
||||||
"""Test basic set and get operations."""
|
"""Test basic set and get operations."""
|
||||||
@@ -196,50 +194,6 @@ class TestMemoryCache:
|
|||||||
assert stats["max_size"] == 1000 # default
|
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:
|
class TestDiskCache:
|
||||||
"""Test DiskCache functionality."""
|
"""Test DiskCache functionality."""
|
||||||
|
|
||||||
@@ -370,36 +324,6 @@ class TestDiskCache:
|
|||||||
|
|
||||||
# Should handle gracefully
|
# Should handle gracefully
|
||||||
assert result is None or isinstance(result, dict)
|
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:
|
class TestDiskCacheWriteEconomy:
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
"""CacheStrategy intervals, pinned across the whole input grid.
|
"""CacheStrategy intervals, pinned across the whole input grid.
|
||||||
|
|
||||||
The strategy table used to carry a per-sport defaults dict whose every value
|
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
|
was 60, a soccer branch identical to its else, and a config lookup of
|
||||||
returned strategy for every data type x sport key x config shape, so
|
`<sport>_scoreboard` sections that only the replaced built-in scoreboards
|
||||||
simplifying the lookup cannot change what any caller gets back. They were
|
had. These tests pin the returned strategy for every data type x sport key,
|
||||||
written against the pre-cleanup code and pass on it unchanged, except for
|
so simplifying the lookup cannot change what any caller gets back.
|
||||||
the legacy `<sport>_scoreboard` config shape (see below), which that code
|
|
||||||
still read.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -14,46 +12,6 @@ import pytest
|
|||||||
from src.cache.cache_strategy import CacheStrategy
|
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",
|
SPORT_KEYS = [None, "", "nfl", "nba", "mlb", "nhl", "soccer", "ncaa_fb",
|
||||||
"ncaa_baseball", "ncaam_basketball", "milb",
|
"ncaa_baseball", "ncaam_basketball", "milb",
|
||||||
"football-scoreboard", "curling"]
|
"football-scoreboard", "curling"]
|
||||||
@@ -93,16 +51,8 @@ def _expected(data_type, sport_key):
|
|||||||
return FIXED.get(data_type, DEFAULT)
|
return FIXED.get(data_type, DEFAULT)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("cm_name", sorted(CONFIG_MANAGERS))
|
def test_strategy_table_for_every_data_type_and_sport():
|
||||||
def test_live_interval_is_60_for_every_sport(cm_name):
|
strategy = CacheStrategy()
|
||||||
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])
|
|
||||||
data_types = ["live_scores", "sports_live", *FIXED, "unknown", ""]
|
data_types = ["live_scores", "sports_live", *FIXED, "unknown", ""]
|
||||||
for data_type in data_types:
|
for data_type in data_types:
|
||||||
for sport_key in SPORT_KEYS:
|
for sport_key in SPORT_KEYS:
|
||||||
|
|||||||
@@ -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.
|
#: Every pinned marker: (class path, method) -> the release that removes it.
|
||||||
PINNED = {(path, name): removal
|
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}
|
for path, names in table.items() for name in names}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -145,7 +145,6 @@ def vegas_coordinator(controller):
|
|||||||
coord.render_pipeline.target_fps = float(coord.vegas_config.target_fps)
|
coord.render_pipeline.target_fps = float(coord.vegas_config.target_fps)
|
||||||
coord.stream_manager = MagicMock()
|
coord.stream_manager = MagicMock()
|
||||||
coord.display_manager = controller.display_manager
|
coord.display_manager = controller.display_manager
|
||||||
coord.stats = {'cycles_completed': 0, 'interruptions': 0}
|
|
||||||
coord._state_lock = threading.Lock()
|
coord._state_lock = threading.Lock()
|
||||||
coord._is_active = True
|
coord._is_active = True
|
||||||
coord._is_paused = False
|
coord._is_paused = False
|
||||||
|
|||||||
@@ -218,24 +218,6 @@ class TestPatternDetection:
|
|||||||
assert pattern is not None
|
assert pattern is not None
|
||||||
assert pattern.severity in ["error", "critical"]
|
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:
|
class TestErrorSummary:
|
||||||
"""Test error summary generation."""
|
"""Test error summary generation."""
|
||||||
@@ -318,37 +300,6 @@ class TestPluginHealth:
|
|||||||
assert health["recent_error_count"] == 10
|
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:
|
class TestThreadSafety:
|
||||||
"""Test thread safety of error aggregator."""
|
"""Test thread safety of error aggregator."""
|
||||||
|
|
||||||
|
|||||||
@@ -330,9 +330,6 @@ class _Stream:
|
|||||||
def get_grouped_content_for_composition(self):
|
def get_grouped_content_for_composition(self):
|
||||||
return self.groups[0]
|
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):
|
def take_next_group(self, count=None, offscreen_only=False):
|
||||||
if self._i >= len(self.groups):
|
if self._i >= len(self.groups):
|
||||||
return []
|
return []
|
||||||
|
|||||||
@@ -60,7 +60,9 @@ class TestRoundTrip:
|
|||||||
def test_start_args_round_trip(self):
|
def test_start_args_round_trip(self):
|
||||||
args = OnDemandStartArgs(plugin_id='clock', mode='clock_main', duration=45.0,
|
args = OnDemandStartArgs(plugin_id='clock', mode='clock_main', duration=45.0,
|
||||||
pinned=True)
|
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):
|
def test_encoded_messages_are_ascii_single_lines(self):
|
||||||
data = c.encode_message({'v': 1, 'id': 'x', 'cmd': 'ping',
|
data = c.encode_message({'v': 1, 'id': 'x', 'cmd': 'ping',
|
||||||
|
|||||||
@@ -349,7 +349,6 @@ class TestVegasChecksEveryFrame:
|
|||||||
coord.render_pipeline.frame_interval = 0.0
|
coord.render_pipeline.frame_interval = 0.0
|
||||||
coord.render_pipeline.target_fps = 90
|
coord.render_pipeline.target_fps = 90
|
||||||
coord.display_manager = MagicMock()
|
coord.display_manager = MagicMock()
|
||||||
coord.stats = {'cycles_completed': 0, 'interruptions': 0}
|
|
||||||
coord._state_lock = threading.Lock()
|
coord._state_lock = threading.Lock()
|
||||||
coord._is_active = True
|
coord._is_active = True
|
||||||
coord._is_paused = False
|
coord._is_paused = False
|
||||||
|
|||||||
+14
-14
@@ -215,7 +215,7 @@ class TestWhereTheServerListens:
|
|||||||
assert srv.server_socket_path({}) is None
|
assert srv.server_socket_path({}) is None
|
||||||
assert ControlServer('x.sock').start() is False
|
assert ControlServer('x.sock').start() is False
|
||||||
with pytest.raises(client.ControlError) as e:
|
with pytest.raises(client.ControlError) as e:
|
||||||
client.ping(paths=['x.sock'])
|
client.request(Command.PING, paths=['x.sock'])
|
||||||
assert e.value.reason == 'unsupported'
|
assert e.value.reason == 'unsupported'
|
||||||
|
|
||||||
|
|
||||||
@@ -271,7 +271,7 @@ def _read_line(s):
|
|||||||
class TestLiveSocket:
|
class TestLiveSocket:
|
||||||
def test_client_round_trip(self, live, sock_path):
|
def test_client_round_trip(self, live, sock_path):
|
||||||
live()
|
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.hello(paths=[sock_path])['version'] == 1
|
||||||
assert client.on_demand_status(paths=[sock_path])['current_mode'] == 'clock'
|
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
|
assert s.recv(10) == b'' # and hung up
|
||||||
finally:
|
finally:
|
||||||
s.close()
|
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):
|
def test_a_client_that_hangs_up_mid_message(self, live, sock_path):
|
||||||
server = live()
|
server = live()
|
||||||
@@ -343,7 +343,7 @@ class TestLiveSocket:
|
|||||||
s.sendall(b'{"v":1,"id":"half","cmd":"on_demand.st')
|
s.sendall(b'{"v":1,"id":"half","cmd":"on_demand.st')
|
||||||
s.close()
|
s.close()
|
||||||
time.sleep(0.2)
|
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() == []
|
assert server.drain() == []
|
||||||
|
|
||||||
def test_a_slow_client_is_dropped_and_blocks_nobody(self, live, sock_path):
|
def test_a_slow_client_is_dropped_and_blocks_nobody(self, live, sock_path):
|
||||||
@@ -352,7 +352,7 @@ class TestLiveSocket:
|
|||||||
try:
|
try:
|
||||||
slow.sendall(b'{"v":1,') # ...and never finishes
|
slow.sendall(b'{"v":1,') # ...and never finishes
|
||||||
t0 = time.monotonic()
|
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 time.monotonic() - t0 < 0.5, 'a slow client held up another'
|
||||||
assert slow.recv(100) == b'' # hung up on, not answered
|
assert slow.recv(100) == b'' # hung up on, not answered
|
||||||
finally:
|
finally:
|
||||||
@@ -380,7 +380,7 @@ class TestLiveSocket:
|
|||||||
for s in held:
|
for s in held:
|
||||||
s.close()
|
s.close()
|
||||||
time.sleep(0.3)
|
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):
|
def test_many_concurrent_clients(self, live, sock_path):
|
||||||
server = live(queue_size=64)
|
server = live(queue_size=64)
|
||||||
@@ -408,7 +408,7 @@ class TestLiveSocket:
|
|||||||
s = ControlServer(sock_path, status_provider=lambda: status)
|
s = ControlServer(sock_path, status_provider=lambda: status)
|
||||||
try:
|
try:
|
||||||
assert s.start()
|
assert s.start()
|
||||||
assert client.ping(paths=[sock_path]) == {'pong': True}
|
assert client.request(Command.PING, paths=[sock_path]) == {'pong': True}
|
||||||
finally:
|
finally:
|
||||||
s.close()
|
s.close()
|
||||||
|
|
||||||
@@ -416,7 +416,7 @@ class TestLiveSocket:
|
|||||||
live()
|
live()
|
||||||
second = ControlServer(sock_path, status_provider=lambda: status)
|
second = ControlServer(sock_path, status_provider=lambda: status)
|
||||||
assert second.start() is False
|
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):
|
def test_a_regular_file_is_never_removed(self, sock_path):
|
||||||
with open(sock_path, 'w') as f:
|
with open(sock_path, 'w') as f:
|
||||||
@@ -434,24 +434,24 @@ class TestLiveSocket:
|
|||||||
assert second.start()
|
assert second.start()
|
||||||
try:
|
try:
|
||||||
first.close() # must not unlink second's file
|
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:
|
finally:
|
||||||
second.close()
|
second.close()
|
||||||
|
|
||||||
def test_client_reasons(self, sock_path, tmp_path):
|
def test_client_reasons(self, sock_path, tmp_path):
|
||||||
with pytest.raises(client.ControlError) as e:
|
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'
|
assert e.value.reason == 'no_socket'
|
||||||
dead = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
dead = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||||
dead.bind(sock_path)
|
dead.bind(sock_path)
|
||||||
try:
|
try:
|
||||||
with pytest.raises(client.ControlError) as e:
|
with pytest.raises(client.ControlError) as e:
|
||||||
client.ping(paths=[sock_path])
|
client.request(Command.PING, paths=[sock_path])
|
||||||
assert e.value.reason == 'refused'
|
assert e.value.reason == 'refused'
|
||||||
finally:
|
finally:
|
||||||
dead.close()
|
dead.close()
|
||||||
with pytest.raises(client.ControlError) as e:
|
with pytest.raises(client.ControlError) as e:
|
||||||
client.ping(paths=[])
|
client.request(Command.PING, paths=[])
|
||||||
assert e.value.reason == 'disabled'
|
assert e.value.reason == 'disabled'
|
||||||
with pytest.raises(client.ControlError) as e:
|
with pytest.raises(client.ControlError) as e:
|
||||||
client.on_demand_start('x', None, None, paths=[sock_path])
|
client.on_demand_start('x', None, None, paths=[sock_path])
|
||||||
@@ -464,7 +464,7 @@ class TestLiveSocket:
|
|||||||
try:
|
try:
|
||||||
t0 = time.monotonic()
|
t0 = time.monotonic()
|
||||||
with pytest.raises(client.ControlError) as e:
|
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 e.value.reason == 'timeout'
|
||||||
assert time.monotonic() - t0 < 1.0
|
assert time.monotonic() - t0 < 1.0
|
||||||
finally:
|
finally:
|
||||||
@@ -542,7 +542,7 @@ class TestPermissions:
|
|||||||
os.setgroups([])
|
os.setgroups([])
|
||||||
os.setgid(gid)
|
os.setgid(gid)
|
||||||
os.setuid(nobody.pw_uid)
|
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:
|
except client.ControlError as e:
|
||||||
result = 'error:' + e.reason
|
result = 'error:' + e.reason
|
||||||
except Exception as e: # report anything else to the parent
|
except Exception as e: # report anything else to the parent
|
||||||
|
|||||||
@@ -522,7 +522,7 @@ class TestLiveStream:
|
|||||||
# max_clients is 2 and three streams are open: subscribers gave
|
# max_clients is 2 and three streams are open: subscribers gave
|
||||||
# their request slots back.
|
# their request slots back.
|
||||||
for _ in range(4):
|
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',))
|
hub.publish('display', _display(mode='weather'), volatile=('last_updated',))
|
||||||
assert _wait_until(lambda: all(
|
assert _wait_until(lambda: all(
|
||||||
s.latest()['state']['display']['mode'] == 'weather' for s in subs))
|
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
|
assert _wait_for(lambda: op_queue.get_operation_status(first).status
|
||||||
== OperationStatus.COMPLETED)
|
== OperationStatus.COMPLETED)
|
||||||
# Once it has finished, the plugin accepts a new operation again.
|
# 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):
|
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(
|
assert _wait_for(lambda: all(
|
||||||
(op_queue.get_operation_status(i) is None
|
(op_queue.get_operation_status(i) is None
|
||||||
or op_queue.get_operation_status(i).status == OperationStatus.COMPLETED)
|
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
|
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
|
assert set(op_queue._operations) == kept
|
||||||
|
|||||||
@@ -404,7 +404,6 @@ class TestVegasWiring:
|
|||||||
off = VegasModeConfig.from_config(
|
off = VegasModeConfig.from_config(
|
||||||
{"display": {"vegas_scroll": {"prefetch_gate": False}}})
|
{"display": {"vegas_scroll": {"prefetch_gate": False}}})
|
||||||
assert off.prefetch_gate is False
|
assert off.prefetch_gate is False
|
||||||
assert off.to_dict()["prefetch_gate"] is False
|
|
||||||
assert VegasModeConfig.from_config(
|
assert VegasModeConfig.from_config(
|
||||||
{"display": {"vegas_scroll": {}}}).prefetch_gate is True
|
{"display": {"vegas_scroll": {}}}).prefetch_gate is True
|
||||||
|
|
||||||
|
|||||||
@@ -187,9 +187,6 @@ def test_vegas_extends_without_building_the_image(no_fromarray):
|
|||||||
def get_grouped_content_for_composition(self):
|
def get_grouped_content_for_composition(self):
|
||||||
return groups[0]
|
return groups[0]
|
||||||
|
|
||||||
def get_active_plugin_ids(self):
|
|
||||||
return ["a"]
|
|
||||||
|
|
||||||
def take_next_group(self, count=None, offscreen_only=False):
|
def take_next_group(self, count=None, offscreen_only=False):
|
||||||
self.i += 1
|
self.i += 1
|
||||||
return groups[self.i] if self.i < len(groups) else []
|
return groups[self.i] if self.i < len(groups) else []
|
||||||
|
|||||||
+17
-46
@@ -78,7 +78,6 @@ def make_manager(role=SyncRole.STANDALONE, hw_config=None):
|
|||||||
mgr._last_leader_frame_time = 0.0
|
mgr._last_leader_frame_time = 0.0
|
||||||
mgr._frame_lock = threading.Lock()
|
mgr._frame_lock = threading.Lock()
|
||||||
mgr._leader_ip = None
|
mgr._leader_ip = None
|
||||||
mgr._on_new_cycle = None
|
|
||||||
mgr._on_scroll_image = None
|
mgr._on_scroll_image = None
|
||||||
mgr._pending_scroll_image = None
|
mgr._pending_scroll_image = None
|
||||||
mgr._scroll_image_lock = threading.Lock()
|
mgr._scroll_image_lock = threading.Lock()
|
||||||
@@ -495,31 +494,35 @@ class TestFollowerRecvLoop:
|
|||||||
assert mgr._peer_compatible is True
|
assert mgr._peer_compatible is True
|
||||||
assert mgr.logger.error.called is False
|
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)
|
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())
|
self._drive(mgr, json.dumps({"t": "sx", "x": 12.34}).encode())
|
||||||
assert mgr._follower_state is FollowerState.FOLLOWER
|
assert mgr._follower_state is FollowerState.FOLLOWER
|
||||||
assert mgr.get_latest_scroll_x() == 12.34
|
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 = make_manager(role=SyncRole.FOLLOWER)
|
||||||
mgr._follower_state = FollowerState.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())
|
self._drive(mgr, json.dumps({"t": "sx", "x": 5.0}).encode())
|
||||||
assert mgr.get_latest_scroll_x() == 5.0
|
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 = make_manager(role=SyncRole.FOLLOWER)
|
||||||
mgr._follower_state = FollowerState.FOLLOWER
|
mgr._follower_state = FollowerState.FOLLOWER
|
||||||
calls = []
|
sleeps = MagicMock()
|
||||||
mgr._on_new_cycle = lambda: calls.append(1)
|
with patch.object(sync_manager, "time",
|
||||||
self._drive(mgr, json.dumps({"t": "nc"}).encode())
|
SimpleNamespace(time=time.time, monotonic=time.monotonic,
|
||||||
assert calls == [1]
|
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):
|
def test_non_object_json_does_not_reach_the_outer_handler(self):
|
||||||
# A bare JSON scalar parses, then msg.get() raises AttributeError.
|
# A bare JSON scalar parses, then msg.get() raises AttributeError.
|
||||||
@@ -547,28 +550,6 @@ class TestFollowerRecvLoop:
|
|||||||
assert mgr.get_latest_scroll_x() is None
|
assert mgr.get_latest_scroll_x() is None
|
||||||
sleeps.assert_not_called()
|
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):
|
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
|
# The UDP path is reachable by any host on the LAN, so it caps
|
||||||
# dimensions before load() just as the TCP image server does.
|
# 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'}',
|
for payload in (b'{"t": "sx", "x": ' + literal.encode() + b'}',
|
||||||
json.dumps({"t": "sx", "x": literal}).encode()):
|
json.dumps({"t": "sx", "x": literal}).encode()):
|
||||||
mgr = make_manager(role=SyncRole.FOLLOWER)
|
mgr = make_manager(role=SyncRole.FOLLOWER)
|
||||||
calls = []
|
|
||||||
mgr._on_new_cycle = lambda: calls.append(1)
|
|
||||||
self._drive(mgr, payload)
|
self._drive(mgr, payload)
|
||||||
assert mgr.get_latest_scroll_x() is None
|
assert mgr.get_latest_scroll_x() is None
|
||||||
assert mgr._follower_state is FollowerState.STANDALONE
|
assert mgr._follower_state is FollowerState.STANDALONE
|
||||||
assert calls == []
|
|
||||||
|
|
||||||
def test_non_finite_scroll_x_leaves_a_good_value_in_place(self):
|
def test_non_finite_scroll_x_leaves_a_good_value_in_place(self):
|
||||||
# The reject must not clear the last usable position either — a
|
# 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())
|
msg = json.loads(mgr._send_sock.sendto.call_args[0][0].decode())
|
||||||
assert msg == {"t": "sx", "x": 3.14}
|
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):
|
def test_control_messages_noop_when_disconnected(self):
|
||||||
mgr = self._connected_leader()
|
mgr = self._connected_leader()
|
||||||
mgr._leader_state = LeaderState.NO_PEER
|
mgr._leader_state = LeaderState.NO_PEER
|
||||||
mgr.send_scroll_x(1.0)
|
mgr.send_scroll_x(1.0)
|
||||||
mgr.send_new_cycle()
|
|
||||||
assert not mgr._send_sock.sendto.called
|
assert not mgr._send_sock.sendto.called
|
||||||
|
|
||||||
def test_set_leader_width(self):
|
def test_set_leader_width(self):
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
"""
|
"""
|
||||||
Tests for src/vegas_mode/config.py
|
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.
|
get_ordered_plugins, validate.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import dataclasses
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from src.vegas_mode.config import VegasModeConfig
|
from src.vegas_mode.config import VegasModeConfig
|
||||||
|
|
||||||
@@ -49,7 +51,8 @@ class TestVegasModeConfigDefaults:
|
|||||||
(Path(__file__).resolve().parent.parent / "config"
|
(Path(__file__).resolve().parent.parent / "config"
|
||||||
/ "config.template.json").read_text(encoding="utf-8"))
|
/ "config.template.json").read_text(encoding="utf-8"))
|
||||||
shipped = template["display"]["vegas_scroll"]
|
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()
|
mismatched = {k: (v, defaults[k]) for k, v in shipped.items()
|
||||||
if k in defaults and defaults[k] != v}
|
if k in defaults and defaults[k] != v}
|
||||||
assert not mismatched, f"template vs code default: {mismatched}"
|
assert not mismatched, f"template vs code default: {mismatched}"
|
||||||
@@ -57,9 +60,9 @@ class TestVegasModeConfigDefaults:
|
|||||||
def test_missing_keys_read_the_field_defaults(self):
|
def test_missing_keys_read_the_field_defaults(self):
|
||||||
# from_config used to repeat every default; with no keys set it must
|
# from_config used to repeat every default; with no keys set it must
|
||||||
# produce exactly the dataclass defaults.
|
# produce exactly the dataclass defaults.
|
||||||
assert VegasModeConfig.from_config({}).to_dict() == VegasModeConfig().to_dict()
|
assert VegasModeConfig.from_config({}) == VegasModeConfig()
|
||||||
assert (VegasModeConfig.from_config({"display": {"vegas_scroll": {}}}).to_dict()
|
assert (VegasModeConfig.from_config({"display": {"vegas_scroll": {}}})
|
||||||
== VegasModeConfig().to_dict())
|
== VegasModeConfig())
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -114,42 +117,6 @@ class TestFromConfig:
|
|||||||
assert cfg.frame_based_scrolling is False
|
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
|
# get_frame_interval
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -189,7 +189,6 @@ class TestCoordinatorWiring:
|
|||||||
coordinator.render_pipeline.is_cycle_complete.return_value = False
|
coordinator.render_pipeline.is_cycle_complete.return_value = False
|
||||||
coordinator.render_pipeline.should_recompose.return_value = False
|
coordinator.render_pipeline.should_recompose.return_value = False
|
||||||
coordinator.stream_manager = MagicMock()
|
coordinator.stream_manager = MagicMock()
|
||||||
coordinator.stats = {'cycles_completed': 0}
|
|
||||||
coordinator._state_lock = threading.Lock()
|
coordinator._state_lock = threading.Lock()
|
||||||
coordinator._is_active = True
|
coordinator._is_active = True
|
||||||
coordinator._is_paused = False
|
coordinator._is_paused = False
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ def _coordinator(active=True):
|
|||||||
c.render_pipeline.is_cycle_complete.return_value = False
|
c.render_pipeline.is_cycle_complete.return_value = False
|
||||||
c.stream_manager = MagicMock()
|
c.stream_manager = MagicMock()
|
||||||
c.plugin_adapter = MagicMock()
|
c.plugin_adapter = MagicMock()
|
||||||
c.stats = {'cycles_completed': 0, 'config_updates': 0}
|
|
||||||
c._state_lock = threading.Lock()
|
c._state_lock = threading.Lock()
|
||||||
c._is_active = active
|
c._is_active = active
|
||||||
c._is_paused = False
|
c._is_paused = False
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ def _coordinator(plugins):
|
|||||||
coord.stream_manager = MagicMock()
|
coord.stream_manager = MagicMock()
|
||||||
coord.display_manager = MagicMock()
|
coord.display_manager = MagicMock()
|
||||||
coord.plugin_manager = SimpleNamespace(plugins=plugins, get_plugin=plugins.get)
|
coord.plugin_manager = SimpleNamespace(plugins=plugins, get_plugin=plugins.get)
|
||||||
coord.stats = {'cycles_completed': 0, 'interruptions': 0}
|
|
||||||
coord._state_lock = threading.Lock()
|
coord._state_lock = threading.Lock()
|
||||||
coord._is_active = True
|
coord._is_active = True
|
||||||
coord._is_paused = False
|
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():
|
def test_stop_clears_a_live_priority_pause():
|
||||||
coord = _live_coordinator(['nfl_live'])
|
coord = _live_coordinator(['nfl_live'])
|
||||||
coord._start_time = None
|
|
||||||
coord._restore_switch_interval = lambda: None
|
coord._restore_switch_interval = lambda: None
|
||||||
coord._remove_render_gate = lambda: None
|
coord._remove_render_gate = lambda: None
|
||||||
coord.run_iteration()
|
coord.run_iteration()
|
||||||
|
|||||||
@@ -26,9 +26,6 @@ class FakeStream:
|
|||||||
def get_grouped_content_for_composition(self):
|
def get_grouped_content_for_composition(self):
|
||||||
return [('a', [Image.new('RGB', (4000, H), (255, 255, 255))])]
|
return [('a', [Image.new('RGB', (4000, H), (255, 255, 255))])]
|
||||||
|
|
||||||
def get_active_plugin_ids(self):
|
|
||||||
return ['a']
|
|
||||||
|
|
||||||
|
|
||||||
class FakeDM:
|
class FakeDM:
|
||||||
width = W
|
width = W
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ Tests for the Vegas mode density work: dead-space trimming in PluginAdapter
|
|||||||
and the configurable lead-in gap in ScrollHelper.
|
and the configurable lead-in gap in ScrollHelper.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import dataclasses
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -298,9 +299,6 @@ class TestPluginBoundaryGaps:
|
|||||||
def get_grouped_content_for_composition(self):
|
def get_grouped_content_for_composition(self):
|
||||||
return grouped
|
return grouped
|
||||||
|
|
||||||
def get_active_plugin_ids(self):
|
|
||||||
return [pid for pid, _ in grouped]
|
|
||||||
|
|
||||||
class DM:
|
class DM:
|
||||||
width = DISPLAY_W
|
width = DISPLAY_W
|
||||||
height = DISPLAY_H
|
height = DISPLAY_H
|
||||||
@@ -615,10 +613,10 @@ class TestConfigSurface:
|
|||||||
assert cfg.lead_in_width == 0
|
assert cfg.lead_in_width == 0
|
||||||
assert cfg.content_padding == 8
|
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)
|
cfg = VegasModeConfig(trim_threshold=20, lead_in_width=64)
|
||||||
restored = VegasModeConfig.from_config(
|
restored = VegasModeConfig.from_config(
|
||||||
{'display': {'vegas_scroll': cfg.to_dict()}})
|
{'display': {'vegas_scroll': dataclasses.asdict(cfg)}})
|
||||||
assert restored.trim_threshold == 20
|
assert restored.trim_threshold == 20
|
||||||
assert restored.lead_in_width == 64
|
assert restored.lead_in_width == 64
|
||||||
|
|
||||||
@@ -698,9 +696,6 @@ class TestMeasuredSeparation:
|
|||||||
def get_grouped_content_for_composition(self):
|
def get_grouped_content_for_composition(self):
|
||||||
return grouped
|
return grouped
|
||||||
|
|
||||||
def get_active_plugin_ids(self):
|
|
||||||
return [pid for pid, _ in grouped]
|
|
||||||
|
|
||||||
class DM:
|
class DM:
|
||||||
width = DISPLAY_W
|
width = DISPLAY_W
|
||||||
height = DISPLAY_H
|
height = DISPLAY_H
|
||||||
@@ -816,9 +811,6 @@ class TestCycleEndsBeforeWrap:
|
|||||||
def get_grouped_content_for_composition(self):
|
def get_grouped_content_for_composition(self):
|
||||||
return [('a', [Image.new('RGB', (strip_width, DISPLAY_H), (255, 255, 255))])]
|
return [('a', [Image.new('RGB', (strip_width, DISPLAY_H), (255, 255, 255))])]
|
||||||
|
|
||||||
def get_active_plugin_ids(self):
|
|
||||||
return ['a']
|
|
||||||
|
|
||||||
class DM:
|
class DM:
|
||||||
width = DISPLAY_W
|
width = DISPLAY_W
|
||||||
height = DISPLAY_H
|
height = DISPLAY_H
|
||||||
@@ -962,9 +954,6 @@ class TestBudgetUsesMeasuredGaps:
|
|||||||
def get_grouped_content_for_composition(self):
|
def get_grouped_content_for_composition(self):
|
||||||
return [('rows', selected)]
|
return [('rows', selected)]
|
||||||
|
|
||||||
def get_active_plugin_ids(self):
|
|
||||||
return ['rows']
|
|
||||||
|
|
||||||
class DM:
|
class DM:
|
||||||
width = DISPLAY_W
|
width = DISPLAY_W
|
||||||
height = DISPLAY_H
|
height = DISPLAY_H
|
||||||
@@ -1258,9 +1247,6 @@ class TestContinuousExtension:
|
|||||||
def get_grouped_content_for_composition(self):
|
def get_grouped_content_for_composition(self):
|
||||||
return groups[0] if groups else []
|
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):
|
def take_next_group(self, count=None, offscreen_only=False):
|
||||||
self.calls.append(offscreen_only)
|
self.calls.append(offscreen_only)
|
||||||
if self._i >= len(groups):
|
if self._i >= len(groups):
|
||||||
@@ -1450,9 +1436,6 @@ class TestDeferredDraining:
|
|||||||
def get_grouped_content_for_composition(self):
|
def get_grouped_content_for_composition(self):
|
||||||
return [('seed', [Image.new('RGB', (600, DISPLAY_H), (255, 255, 255))])]
|
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):
|
def take_next_group(self, count=None, offscreen_only=False):
|
||||||
if self._served:
|
if self._served:
|
||||||
return []
|
return []
|
||||||
|
|||||||
@@ -49,9 +49,6 @@ class _Stream:
|
|||||||
def get_grouped_content_for_composition(self):
|
def get_grouped_content_for_composition(self):
|
||||||
return self.groups[0]
|
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):
|
def take_next_group(self, count=None, offscreen_only=False):
|
||||||
self.i += 1
|
self.i += 1
|
||||||
return self.groups[self.i] if self.i < len(self.groups) else []
|
return self.groups[self.i] if self.i < len(self.groups) else []
|
||||||
|
|||||||
@@ -43,9 +43,6 @@ class _Stream:
|
|||||||
def get_grouped_content_for_composition(self):
|
def get_grouped_content_for_composition(self):
|
||||||
return self.groups[0]
|
return self.groups[0]
|
||||||
|
|
||||||
def get_active_plugin_ids(self):
|
|
||||||
return ["p"]
|
|
||||||
|
|
||||||
def take_next_group(self, count=None, offscreen_only=False):
|
def take_next_group(self, count=None, offscreen_only=False):
|
||||||
self.i += 1
|
self.i += 1
|
||||||
return self.groups[self.i] if self.i < len(self.groups) else []
|
return self.groups[self.i] if self.i < len(self.groups) else []
|
||||||
|
|||||||
@@ -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
|
start_width = pipeline.scroll_helper.total_scroll_width
|
||||||
assert _run_until(
|
assert _run_until(
|
||||||
coordinator,
|
coordinator,
|
||||||
lambda: pipeline.stats.get('extensions', 0) >= 1, seconds=10.0)
|
lambda: pipeline.extensions >= 1, seconds=10.0)
|
||||||
assert pipeline.live_records() == ()
|
assert pipeline.live_records() == ()
|
||||||
assert np.asarray(pipeline.scroll_helper.cached_array).shape[1] > 0
|
assert np.asarray(pipeline.scroll_helper.cached_array).shape[1] > 0
|
||||||
assert start_width > 0
|
assert start_width > 0
|
||||||
|
|||||||
@@ -46,9 +46,6 @@ class _Stream:
|
|||||||
def get_grouped_content_for_composition(self):
|
def get_grouped_content_for_composition(self):
|
||||||
return self.first
|
return self.first
|
||||||
|
|
||||||
def get_active_plugin_ids(self):
|
|
||||||
return ["p"]
|
|
||||||
|
|
||||||
def take_next_group(self, count=None, offscreen_only=False):
|
def take_next_group(self, count=None, offscreen_only=False):
|
||||||
self.taken += 1
|
self.taken += 1
|
||||||
return [("q", [_image(40, 99)])]
|
return [("q", [_image(40, 99)])]
|
||||||
|
|||||||
@@ -67,9 +67,6 @@ class _Stream:
|
|||||||
def get_grouped_content_for_composition(self):
|
def get_grouped_content_for_composition(self):
|
||||||
return self.groups[0]
|
return self.groups[0]
|
||||||
|
|
||||||
def get_active_plugin_ids(self):
|
|
||||||
return []
|
|
||||||
|
|
||||||
def take_next_group(self, count=None, offscreen_only=False):
|
def take_next_group(self, count=None, offscreen_only=False):
|
||||||
self.i += 1
|
self.i += 1
|
||||||
return self.groups[self.i] if self.i < len(self.groups) else []
|
return self.groups[self.i] if self.i < len(self.groups) else []
|
||||||
|
|||||||
@@ -65,9 +65,6 @@ class _Stream:
|
|||||||
def get_grouped_content_for_composition(self):
|
def get_grouped_content_for_composition(self):
|
||||||
return self.groups[0]
|
return self.groups[0]
|
||||||
|
|
||||||
def get_active_plugin_ids(self):
|
|
||||||
return []
|
|
||||||
|
|
||||||
def take_next_group(self, count=None, offscreen_only=False):
|
def take_next_group(self, count=None, offscreen_only=False):
|
||||||
self.i += 1
|
self.i += 1
|
||||||
return self.groups[self.i] if self.i < len(self.groups) else []
|
return self.groups[self.i] if self.i < len(self.groups) else []
|
||||||
|
|||||||
@@ -52,9 +52,6 @@ class FakeStream:
|
|||||||
def get_static_layout(self):
|
def get_static_layout(self):
|
||||||
return [(pid, w is None) for pid, w in self.layout]
|
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):
|
def is_static_plugin(self, plugin_id):
|
||||||
return plugin_id in self.statics
|
return plugin_id in self.statics
|
||||||
|
|
||||||
@@ -163,7 +160,6 @@ class TestStreamManagerSkipsStaticContent:
|
|||||||
sm.plugin_manager = SimpleNamespace(plugins=plugins)
|
sm.plugin_manager = SimpleNamespace(plugins=plugins)
|
||||||
sm.plugin_adapter = MagicMock()
|
sm.plugin_adapter = MagicMock()
|
||||||
sm.plugin_adapter.get_content.return_value = [block(10)]
|
sm.plugin_adapter.get_content.return_value = [block(10)]
|
||||||
sm.stats = {'segments_fetched': 0, 'fetch_errors': 0}
|
|
||||||
sm.refresh = lambda: None
|
sm.refresh = lambda: None
|
||||||
return sm
|
return sm
|
||||||
|
|
||||||
@@ -213,7 +209,6 @@ class TestCoordinatorStaticPause:
|
|||||||
coord._live_priority_active = False
|
coord._live_priority_active = False
|
||||||
coord._live_priority_check = None
|
coord._live_priority_check = None
|
||||||
coord._interrupt_check = None
|
coord._interrupt_check = None
|
||||||
coord.stats = {'static_pauses': 0}
|
|
||||||
return coord
|
return coord
|
||||||
|
|
||||||
def _plugin(self):
|
def _plugin(self):
|
||||||
|
|||||||
@@ -174,7 +174,7 @@ def test_widget_bundle_is_served_and_requested(client):
|
|||||||
assert resp.mimetype == "application/javascript"
|
assert resp.mimetype == "application/javascript"
|
||||||
body = resp.get_data(as_text=True)
|
body = resp.get_data(as_text=True)
|
||||||
assert body.index("/* registry.js */") < body.index("/* notification.js */")
|
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):
|
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)
|
history = self.operation_history.get_history(plugin_id=plugin_id)
|
||||||
self.assertEqual([r.operation_type for r in history], ["install"])
|
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):
|
def test_uninstall_operation_flow(self):
|
||||||
"""Test complete uninstall operation flow."""
|
"""Test complete uninstall operation flow."""
|
||||||
plugin_id = "test-plugin"
|
plugin_id = "test-plugin"
|
||||||
@@ -162,7 +137,7 @@ class TestPluginOperationsIntegration(unittest.TestCase):
|
|||||||
# The prevention only works for truly concurrent (pending/running) operations
|
# The prevention only works for truly concurrent (pending/running) operations
|
||||||
try:
|
try:
|
||||||
op2_id = self.operation_queue.enqueue_operation(
|
op2_id = self.operation_queue.enqueue_operation(
|
||||||
OperationType.UPDATE,
|
OperationType.UNINSTALL,
|
||||||
plugin_id
|
plugin_id
|
||||||
)
|
)
|
||||||
# If no exception, the first operation may have completed already
|
# If no exception, the first operation may have completed already
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ from web_interface.blueprints.api_v3 import ( # noqa: E402
|
|||||||
_is_plugin_update_available,
|
_is_plugin_update_available,
|
||||||
_coerce_to_bool,
|
_coerce_to_bool,
|
||||||
deep_merge,
|
deep_merge,
|
||||||
_parse_form_value,
|
|
||||||
_get_schema_property,
|
_get_schema_property,
|
||||||
_set_nested_value,
|
_set_nested_value,
|
||||||
_SKIP_FIELD,
|
_SKIP_FIELD,
|
||||||
@@ -116,44 +115,6 @@ class TestDeepMerge:
|
|||||||
assert result["keep"] is base["keep"] # untouched subtree is shared
|
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:
|
class TestGetSchemaProperty:
|
||||||
SCHEMA: ClassVar[Dict[str, Any]] = {
|
SCHEMA: ClassVar[Dict[str, Any]] = {
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
@@ -1,24 +1,23 @@
|
|||||||
"""
|
"""
|
||||||
Tests for the response builders in src/web_interface/error_handler.py and
|
Tests for the response builders in src/web_interface/api_helpers.py.
|
||||||
the success path 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.
|
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
|
truthiness for `message` and `metadata` while using `is not None` for
|
||||||
`data`, so an explicitly-passed "" or {} was silently dropped —
|
`data`, so an explicitly-passed "" or {} was silently dropped —
|
||||||
api_helpers.success_response() repeated the same gate, which is the path
|
success_response() repeated the same gate, which is the path every api_v3
|
||||||
every api_v3 endpoint actually calls.
|
endpoint actually calls.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from flask import Flask
|
from flask import Flask
|
||||||
|
|
||||||
from src.web_interface.api_helpers import exception_error_response, success_response
|
from src.web_interface.api_helpers import (
|
||||||
from src.web_interface.error_handler import (
|
error_response,
|
||||||
create_error_response,
|
exception_error_response,
|
||||||
create_success_response,
|
success_response,
|
||||||
)
|
)
|
||||||
from src.web_interface.errors import ErrorCode, WebInterfaceError
|
from src.web_interface.errors import ErrorCode, WebInterfaceError
|
||||||
|
|
||||||
@@ -28,23 +27,23 @@ def app():
|
|||||||
return Flask(__name__)
|
return Flask(__name__)
|
||||||
|
|
||||||
|
|
||||||
class TestCreateErrorResponse:
|
class TestErrorResponse:
|
||||||
def test_returns_response_and_status_tuple(self, app):
|
def test_returns_response_and_status_tuple(self, app):
|
||||||
with app.test_request_context():
|
with app.test_request_context():
|
||||||
response, status = create_error_response(
|
response, status = error_response(
|
||||||
ErrorCode.CONFIG_SAVE_FAILED, "could not save")
|
ErrorCode.CONFIG_SAVE_FAILED, "could not save")
|
||||||
assert status == 500
|
assert status == 500
|
||||||
assert response.get_json()["message"] == "could not save"
|
assert response.get_json()["message"] == "could not save"
|
||||||
|
|
||||||
def test_status_code_passthrough(self, app):
|
def test_status_code_passthrough(self, app):
|
||||||
with app.test_request_context():
|
with app.test_request_context():
|
||||||
_, status = create_error_response(
|
_, status = error_response(
|
||||||
ErrorCode.INVALID_INPUT, "bad", status_code=400)
|
ErrorCode.INVALID_INPUT, "bad", status_code=400)
|
||||||
assert status == 400
|
assert status == 400
|
||||||
|
|
||||||
def test_body_matches_the_error_dataclass(self, app):
|
def test_body_matches_the_error_dataclass(self, app):
|
||||||
with app.test_request_context():
|
with app.test_request_context():
|
||||||
response, _ = create_error_response(
|
response, _ = error_response(
|
||||||
ErrorCode.NETWORK_ERROR, "offline",
|
ErrorCode.NETWORK_ERROR, "offline",
|
||||||
details="connection refused", context={"url": "http://x"})
|
details="connection refused", context={"url": "http://x"})
|
||||||
expected = WebInterfaceError(
|
expected = WebInterfaceError(
|
||||||
@@ -54,12 +53,12 @@ class TestCreateErrorResponse:
|
|||||||
|
|
||||||
def test_none_context_produces_no_context_key(self, app):
|
def test_none_context_produces_no_context_key(self, app):
|
||||||
with app.test_request_context():
|
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()
|
assert "context" not in response.get_json()
|
||||||
|
|
||||||
def test_suggested_fixes_passed_through(self, app):
|
def test_suggested_fixes_passed_through(self, app):
|
||||||
with app.test_request_context():
|
with app.test_request_context():
|
||||||
response, _ = create_error_response(
|
response, _ = error_response(
|
||||||
ErrorCode.SYSTEM_ERROR, "boom", suggested_fixes=["Try again"])
|
ErrorCode.SYSTEM_ERROR, "boom", suggested_fixes=["Try again"])
|
||||||
assert response.get_json()["suggested_fixes"] == ["Try again"]
|
assert response.get_json()["suggested_fixes"] == ["Try again"]
|
||||||
|
|
||||||
@@ -74,7 +73,6 @@ class TestExceptionErrorResponse:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _by_hand(exc, code, with_context):
|
def _by_hand(exc, code, with_context):
|
||||||
from src.web_interface.api_helpers import error_response
|
|
||||||
error = WebInterfaceError.from_exception(exc, code)
|
error = WebInterfaceError.from_exception(exc, code)
|
||||||
if with_context:
|
if with_context:
|
||||||
return error_response(error.error_code, error.message,
|
return error_response(error.error_code, error.message,
|
||||||
@@ -117,39 +115,45 @@ class TestExceptionErrorResponse:
|
|||||||
assert "context" not in response.get_json()
|
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):
|
def test_bare_success(self):
|
||||||
assert create_success_response() == {"status": "success"}
|
assert _success_body() == {"status": "success"}
|
||||||
|
|
||||||
def test_data_included(self):
|
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, {}, []])
|
@pytest.mark.parametrize("falsy", [0, "", False, {}, []])
|
||||||
def test_falsy_data_is_still_included(self, falsy):
|
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):
|
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):
|
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):
|
def test_empty_message_is_still_included(self):
|
||||||
# Regression: `if message:` dropped an explicitly-passed "".
|
# Regression: `if message:` dropped an explicitly-passed "".
|
||||||
assert create_success_response(message="")["message"] == ""
|
assert _success_body(message="")["message"] == ""
|
||||||
|
|
||||||
def test_none_message_omitted(self):
|
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):
|
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):
|
def test_empty_metadata_is_still_included(self):
|
||||||
# Regression: `if metadata:` dropped an explicitly-passed {}.
|
# Regression: `if metadata:` dropped an explicitly-passed {}.
|
||||||
assert create_success_response(metadata={})["metadata"] == {}
|
assert _success_body(metadata={})["metadata"] == {}
|
||||||
|
|
||||||
def test_none_metadata_omitted(self):
|
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:
|
class TestSuccessResponseHelper:
|
||||||
@@ -162,8 +166,8 @@ class TestSuccessResponseHelper:
|
|||||||
|
|
||||||
def test_explicit_empty_metadata_survives_the_wrapper(self, app):
|
def test_explicit_empty_metadata_survives_the_wrapper(self, app):
|
||||||
# Regression: the wrapper re-gated metadata on truthiness after
|
# Regression: the wrapper re-gated metadata on truthiness after
|
||||||
# create_success_response had already included it, so {} was
|
# the body builder had already included it, so {} was dropped
|
||||||
# dropped again on the way out.
|
# again on the way out.
|
||||||
with app.test_request_context():
|
with app.test_request_context():
|
||||||
body = success_response(data=None, metadata={}).get_json()
|
body = success_response(data=None, metadata={}).get_json()
|
||||||
assert body["metadata"] == {}
|
assert body["metadata"] == {}
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ class TestPluginOperationQueue(unittest.TestCase):
|
|||||||
# behavior may differ. For this test, we'll verify the mechanism exists.
|
# behavior may differ. For this test, we'll verify the mechanism exists.
|
||||||
try:
|
try:
|
||||||
self.queue.enqueue_operation(
|
self.queue.enqueue_operation(
|
||||||
OperationType.UPDATE,
|
OperationType.UNINSTALL,
|
||||||
"test-plugin"
|
"test-plugin"
|
||||||
)
|
)
|
||||||
# If no exception, the first operation may have completed
|
# If no exception, the first operation may have completed
|
||||||
@@ -64,21 +64,6 @@ class TestPluginOperationQueue(unittest.TestCase):
|
|||||||
# Expected behavior - concurrent operation prevented
|
# Expected behavior - concurrent operation prevented
|
||||||
pass
|
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):
|
def test_operation_history(self):
|
||||||
"""Test operation history tracking."""
|
"""Test operation history tracking."""
|
||||||
# Enqueue and complete an operation
|
# Enqueue and complete an operation
|
||||||
@@ -92,7 +77,7 @@ class TestPluginOperationQueue(unittest.TestCase):
|
|||||||
time.sleep(0.5)
|
time.sleep(0.5)
|
||||||
|
|
||||||
# Check history
|
# Check history
|
||||||
history = self.queue.get_operation_history(limit=10)
|
history = self.queue._operation_history
|
||||||
self.assertGreater(len(history), 0)
|
self.assertGreater(len(history), 0)
|
||||||
|
|
||||||
# Find our operation in history
|
# Find our operation in history
|
||||||
|
|||||||
@@ -100,7 +100,6 @@ class TestStateReconciliation(unittest.TestCase):
|
|||||||
inconsistency = result.inconsistencies_found[0]
|
inconsistency = result.inconsistencies_found[0]
|
||||||
self.assertEqual(inconsistency.plugin_id, "plugin1")
|
self.assertEqual(inconsistency.plugin_id, "plugin1")
|
||||||
self.assertEqual(inconsistency.inconsistency_type, InconsistencyType.PLUGIN_MISSING_IN_CONFIG)
|
self.assertEqual(inconsistency.inconsistency_type, InconsistencyType.PLUGIN_MISSING_IN_CONFIG)
|
||||||
self.assertTrue(inconsistency.can_auto_fix)
|
|
||||||
self.assertEqual(inconsistency.fix_action, FixAction.AUTO_FIX)
|
self.assertEqual(inconsistency.fix_action, FixAction.AUTO_FIX)
|
||||||
|
|
||||||
def test_plugin_missing_on_disk(self):
|
def test_plugin_missing_on_disk(self):
|
||||||
@@ -115,7 +114,6 @@ class TestStateReconciliation(unittest.TestCase):
|
|||||||
inconsistency = result.inconsistencies_found[0]
|
inconsistency = result.inconsistencies_found[0]
|
||||||
self.assertEqual(inconsistency.plugin_id, "plugin1")
|
self.assertEqual(inconsistency.plugin_id, "plugin1")
|
||||||
self.assertEqual(inconsistency.inconsistency_type, InconsistencyType.PLUGIN_MISSING_ON_DISK)
|
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)
|
self.assertEqual(inconsistency.fix_action, FixAction.MANUAL_FIX_REQUIRED)
|
||||||
|
|
||||||
def test_enabled_but_not_loaded_is_reported_not_fixed(self):
|
def test_enabled_but_not_loaded_is_reported_not_fixed(self):
|
||||||
@@ -139,7 +137,6 @@ class TestStateReconciliation(unittest.TestCase):
|
|||||||
inconsistency = result.inconsistencies_found[0]
|
inconsistency = result.inconsistencies_found[0]
|
||||||
self.assertEqual(inconsistency.inconsistency_type, InconsistencyType.PLUGIN_ENABLED_MISMATCH)
|
self.assertEqual(inconsistency.inconsistency_type, InconsistencyType.PLUGIN_ENABLED_MISMATCH)
|
||||||
self.assertEqual(inconsistency.fix_action, FixAction.NO_ACTION)
|
self.assertEqual(inconsistency.fix_action, FixAction.NO_ACTION)
|
||||||
self.assertFalse(inconsistency.can_auto_fix)
|
|
||||||
self.assertIn("No module named", inconsistency.description)
|
self.assertIn("No module named", inconsistency.description)
|
||||||
self.assertEqual(result.inconsistencies_fixed, [])
|
self.assertEqual(result.inconsistencies_fixed, [])
|
||||||
self.assertEqual(result.inconsistencies_manual, [])
|
self.assertEqual(result.inconsistencies_manual, [])
|
||||||
@@ -410,7 +407,6 @@ class TestStateReconciliationUnrecoverable(unittest.TestCase):
|
|||||||
# Still one inconsistency, still no install attempt, no new registry fetch
|
# Still one inconsistency, still no install attempt, no new registry fetch
|
||||||
self.assertEqual(len(result.inconsistencies_found), 1)
|
self.assertEqual(len(result.inconsistencies_found), 1)
|
||||||
inc = result.inconsistencies_found[0]
|
inc = result.inconsistencies_found[0]
|
||||||
self.assertFalse(inc.can_auto_fix)
|
|
||||||
self.assertEqual(inc.fix_action, FixAction.MANUAL_FIX_REQUIRED)
|
self.assertEqual(inc.fix_action, FixAction.MANUAL_FIX_REQUIRED)
|
||||||
self.store_manager.install_plugin.assert_not_called()
|
self.store_manager.install_plugin.assert_not_called()
|
||||||
self.store_manager.fetch_registry.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)
|
self.assertEqual(len(result.inconsistencies_found), 1)
|
||||||
inc = result.inconsistencies_found[0]
|
inc = result.inconsistencies_found[0]
|
||||||
self.assertFalse(inc.can_auto_fix)
|
|
||||||
self.assertEqual(inc.fix_action, FixAction.MANUAL_FIX_REQUIRED)
|
self.assertEqual(inc.fix_action, FixAction.MANUAL_FIX_REQUIRED)
|
||||||
self.store_manager.install_plugin.assert_not_called()
|
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.config_manager.template_path = str(tmp_path / "no-template.json")
|
||||||
self.schema_manager = SchemaManager(plugins_dir=self.plugins_dir, project_root=tmp_path,
|
self.schema_manager = SchemaManager(plugins_dir=self.plugins_dir, project_root=tmp_path,
|
||||||
config_manager=self.config_manager)
|
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 = self.api = api_v3_module.api_v3
|
||||||
api.config_manager = self.config_manager
|
api.config_manager = self.config_manager
|
||||||
@@ -242,7 +242,8 @@ class TestTheWebProcessNeverRunsAPlugin:
|
|||||||
_bump_version(web, "1.1.0")
|
_bump_version(web, "1.1.0")
|
||||||
body = web.post("/api/v3/plugins/update", {"plugin_id": PLUGIN_ID})
|
body = web.post("/api/v3/plugins/update", {"plugin_id": PLUGIN_ID})
|
||||||
assert body["data"]["update_status"] == "updated"
|
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() == []
|
assert web.ran() == []
|
||||||
|
|
||||||
def test_installing_it(self, web):
|
def test_installing_it(self, web):
|
||||||
@@ -497,16 +498,15 @@ class TestCatalogReadsWhatIsInstalled:
|
|||||||
assert expected, f"no plugins under {root}"
|
assert expected, f"no plugins under {root}"
|
||||||
before = set(sys.modules)
|
before = set(sys.modules)
|
||||||
schema_manager = SchemaManager(plugins_dir=root, project_root=PROJECT_ROOT)
|
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)
|
assert set(catalog.discover_plugins()) == set(expected)
|
||||||
for plugin_id, (plugin_dir, manifest) in expected.items():
|
for plugin_id, (plugin_dir, manifest) in expected.items():
|
||||||
assert catalog.get_manifest(plugin_id) == manifest
|
assert catalog.get_manifest(plugin_id) == manifest
|
||||||
assert catalog.get_plugin_directory(plugin_id) == str(plugin_dir)
|
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", [])
|
assert catalog.get_plugin_display_modes(plugin_id) == manifest.get("display_modes", [])
|
||||||
if (plugin_dir / "config_schema.json").exists():
|
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
|
assert isinstance(schema, dict) and "properties" in schema, plugin_id
|
||||||
|
|
||||||
imported = [name for name in set(sys.modules) - before
|
imported = [name for name in set(sys.modules) - before
|
||||||
@@ -537,16 +537,16 @@ class TestCatalogReadsWhatIsInstalled:
|
|||||||
assert catalog.discover_plugins() == []
|
assert catalog.discover_plugins() == []
|
||||||
assert catalog.get_manifest("ci-fixture-plugin") is None
|
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 = MagicMock()
|
||||||
config.load_config.return_value = {"a": {"enabled": True}, "b": {}, "c": "junk"}
|
config.load_config.return_value = {"a": {"enabled": True}, "b": {}, "c": "junk"}
|
||||||
catalog = PluginCatalog(tmp_path, config_manager=config)
|
api_v3_module.api_v3.config_manager = config
|
||||||
assert catalog.is_enabled("a") is True
|
enabled = api_v3_module._plugin_enabled_in_config
|
||||||
|
assert enabled("a") is True
|
||||||
# The display runs a plugin only when its section says so.
|
# The display runs a plugin only when its section says so.
|
||||||
assert catalog.is_enabled("b") is False
|
assert enabled("b") is False
|
||||||
assert catalog.is_enabled("c") is False
|
assert enabled("c") is False
|
||||||
assert catalog.is_enabled("missing") is False
|
assert enabled("missing") is False
|
||||||
assert catalog.get_config("c") == {}
|
|
||||||
|
|
||||||
def test_it_has_nothing_that_runs_a_plugin(self, tmp_path):
|
def test_it_has_nothing_that_runs_a_plugin(self, tmp_path):
|
||||||
catalog = PluginCatalog(tmp_path)
|
catalog = PluginCatalog(tmp_path)
|
||||||
|
|||||||
@@ -159,11 +159,7 @@ schema_manager = SchemaManager(
|
|||||||
# saves reach the running plugins through the display's config watcher; what
|
# saves reach the running plugins through the display's config watcher; what
|
||||||
# the display knows at run time (health, metrics, errors, current mode) it
|
# the display knows at run time (health, metrics, errors, current mode) it
|
||||||
# publishes to the shared cache. See docs/ARCHITECTURE.md.
|
# publishes to the shared cache. See docs/ARCHITECTURE.md.
|
||||||
plugin_catalog = PluginCatalog(
|
plugin_catalog = PluginCatalog(plugins_dir=plugins_dir)
|
||||||
plugins_dir=plugins_dir,
|
|
||||||
config_manager=config_manager,
|
|
||||||
schema_manager=schema_manager,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Initialize operation queue for plugin operations
|
# Initialize operation queue for plugin operations
|
||||||
operation_queue = PluginOperationQueue(max_history=500)
|
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
|
# snapshot the display publishes (src/plugin_system/plugin_runtime.py). An
|
||||||
# existing file is left where it is, unread; see docs/ARCHITECTURE.md.
|
# existing file is left where it is, unread; see docs/ARCHITECTURE.md.
|
||||||
|
|
||||||
# Initialize operation history
|
# Initialize operation history (its file is read on first use, not at startup)
|
||||||
# Use lazy_load=True to defer file loading until first use (improves startup time)
|
|
||||||
operation_history = OperationHistory(
|
operation_history = OperationHistory(
|
||||||
history_file=str(project_root / "data" / "operation_history.json"),
|
history_file=str(project_root / "data" / "operation_history.json"),
|
||||||
max_records=1000,
|
max_records=1000
|
||||||
lazy_load=True
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Plugin discovery is deferred until first API request that needs it
|
# Plugin discovery is deferred until first API request that needs it
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ from src.web_interface.error_handler import (describe_exception, http_exception_
|
|||||||
redact_text, unhandled_exception_payload)
|
redact_text, unhandled_exception_payload)
|
||||||
from werkzeug.exceptions import HTTPException
|
from werkzeug.exceptions import HTTPException
|
||||||
from src.plugin_system.operation_types import OperationType
|
from src.plugin_system.operation_types import OperationType
|
||||||
|
from src.web_interface.config_arrays import _schema_type_is
|
||||||
from src.web_interface.validators import (
|
from src.web_interface.validators import (
|
||||||
validate_file_upload
|
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
|
# For non-dict values or new keys, use the update value
|
||||||
result[key] = value
|
result[key] = value
|
||||||
return result
|
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):
|
def _get_schema_property(schema, key_path):
|
||||||
"""
|
"""
|
||||||
Get the schema property for a given key path (supports dot notation).
|
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()
|
_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):
|
def _schema_allows_null(prop):
|
||||||
|
|||||||
@@ -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-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)
|
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
|
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
|
# WebSocket support: intentionally NOT declared here. The web interface
|
||||||
# uses Server-Sent Events, and plugins that need Socket.IO (e.g.
|
# uses Server-Sent Events, and plugins that need Socket.IO (e.g.
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
* is the one Alpine uses)
|
* is the one Alpine uses)
|
||||||
* end of <body>, defer, in this order: app.js, js/tooltips.js,
|
* end of <body>, defer, in this order: app.js, js/tooltips.js,
|
||||||
* js/settings-search.js, js/utils/dialog.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,
|
* state_manager.js, install_manager.js, list_filter.js,
|
||||||
* the widget bundle (web_interface/widget_bundle.py),
|
* the widget bundle (web_interface/widget_bundle.py),
|
||||||
* plugins_manager.js
|
* plugins_manager.js
|
||||||
@@ -252,8 +252,7 @@ document.addEventListener('keydown', function(e) {
|
|||||||
// alone because during the x-transition between tabs both are visible.
|
// alone because during the x-transition between tabs both are visible.
|
||||||
// requestSubmit() runs validation and onsubmit guards like a real submit;
|
// requestSubmit() runs validation and onsubmit guards like a real submit;
|
||||||
// with no visible form, do nothing. Inside a modal dialog the shortcut is
|
// 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
|
// the dialog's, so the tab's form is left alone.
|
||||||
// form is left alone.
|
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const active = document.activeElement;
|
const active = document.activeElement;
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
* is the one Alpine uses)
|
* is the one Alpine uses)
|
||||||
* end of <body>, defer, in this order: app.js, js/tooltips.js,
|
* end of <body>, defer, in this order: app.js, js/tooltips.js,
|
||||||
* js/settings-search.js, js/utils/dialog.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,
|
* state_manager.js, install_manager.js, list_filter.js,
|
||||||
* the widget bundle (web_interface/widget_bundle.py),
|
* the widget bundle (web_interface/widget_bundle.py),
|
||||||
* plugins_manager.js
|
* plugins_manager.js
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
* is the one Alpine uses)
|
* is the one Alpine uses)
|
||||||
* end of <body>, defer, in this order: app.js, js/tooltips.js,
|
* end of <body>, defer, in this order: app.js, js/tooltips.js,
|
||||||
* js/settings-search.js, js/utils/dialog.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,
|
* state_manager.js, install_manager.js, list_filter.js,
|
||||||
* the widget bundle (web_interface/widget_bundle.py),
|
* the widget bundle (web_interface/widget_bundle.py),
|
||||||
* plugins_manager.js
|
* plugins_manager.js
|
||||||
|
|||||||
@@ -219,55 +219,6 @@ const PluginAPI = {
|
|||||||
return [];
|
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.
|
* Update plugin.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -22,8 +22,9 @@ const PluginStateManager = {
|
|||||||
window.installedPlugins = plugins; // For backward compatibility
|
window.installedPlugins = plugins; // For backward compatibility
|
||||||
return plugins;
|
return plugins;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (window.errorHandler) {
|
if (typeof window.showNotification === 'function') {
|
||||||
window.errorHandler.displayError(error, 'Failed to load installed plugins');
|
window.showNotification('Failed to load installed plugins: '
|
||||||
|
+ ((error && error.message) || String(error)), 'error');
|
||||||
}
|
}
|
||||||
throw 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
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -28,7 +28,7 @@ set by `BUNDLE_ORDER` there.
|
|||||||
| `file-upload-single` | string | One image upload; stores the uploaded file's relative path |
|
| `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 |
|
| `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` |
|
| `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 |
|
| `toggle-switch` | boolean | On/off switch |
|
||||||
| `slider` | integer / number | Range slider using `minimum` / `maximum` |
|
| `slider` | integer / number | Range slider using `minimum` / `maximum` |
|
||||||
| `number-input` | integer / number | Number field with min/max check |
|
| `number-input` | integer / number | Number field with min/max check |
|
||||||
@@ -51,7 +51,6 @@ Other files here:
|
|||||||
| `notification.js` | Toast notifications; owns `window.showNotification` |
|
| `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-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 |
|
| `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
|
Each widget file's header comment gives its schema options. The sections
|
||||||
below cover the ones that need more than a line.
|
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
|
### 2. Reference it in the schema
|
||||||
|
|
||||||
```json
|
```json
|
||||||
|
|||||||
@@ -25,8 +25,6 @@
|
|||||||
|
|
||||||
const base = window.BaseWidget ? new window.BaseWidget('ColorPicker', '1.0.0') : null;
|
const base = window.BaseWidget ? new window.BaseWidget('ColorPicker', '1.0.0') : null;
|
||||||
|
|
||||||
function escapeHtml(text) { return window.LEDEscape.html(text); }
|
|
||||||
|
|
||||||
function sanitizeId(id) {
|
function sanitizeId(id) {
|
||||||
if (base) return base.sanitizeId(id);
|
if (base) return base.sanitizeId(id);
|
||||||
return String(id).replace(/[^a-zA-Z0-9_-]/g, '_');
|
return String(id).replace(/[^a-zA-Z0-9_-]/g, '_');
|
||||||
@@ -141,7 +139,7 @@
|
|||||||
html += '</div>';
|
html += '</div>';
|
||||||
|
|
||||||
// Hidden input for form submission
|
// 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
|
// Preset colors - only render valid hex colors
|
||||||
if (Array.isArray(presets) && presets.length > 0) {
|
if (Array.isArray(presets) && presets.length > 0) {
|
||||||
@@ -155,12 +153,12 @@
|
|||||||
html += `
|
html += `
|
||||||
<button type="button"
|
<button type="button"
|
||||||
${disabled ? 'disabled' : ''}
|
${disabled ? 'disabled' : ''}
|
||||||
data-color="${escapeHtml(normalized)}"
|
data-color="${window.LEDEscape.html(normalized)}"
|
||||||
onclick="window.LEDMatrixWidgets.getHandlers('color-picker').onPresetClick('${fieldId}', this.dataset.color)"
|
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'}"
|
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)};"
|
style="background-color: ${window.LEDEscape.html(normalized)};"
|
||||||
title="${escapeHtml(normalized)}"
|
title="${window.LEDEscape.html(normalized)}"
|
||||||
aria-label="Use color ${escapeHtml(normalized)}">
|
aria-label="Use color ${window.LEDEscape.html(normalized)}">
|
||||||
</button>
|
</button>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,8 +26,6 @@
|
|||||||
|
|
||||||
const base = window.BaseWidget ? new window.BaseWidget('DatePicker', '1.0.0') : null;
|
const base = window.BaseWidget ? new window.BaseWidget('DatePicker', '1.0.0') : null;
|
||||||
|
|
||||||
function escapeHtml(text) { return window.LEDEscape.html(text); }
|
|
||||||
|
|
||||||
function sanitizeId(id) {
|
function sanitizeId(id) {
|
||||||
if (base) return base.sanitizeId(id);
|
if (base) return base.sanitizeId(id);
|
||||||
return String(id).replace(/[^a-zA-Z0-9_-]/g, '_');
|
return String(id).replace(/[^a-zA-Z0-9_-]/g, '_');
|
||||||
@@ -70,11 +68,11 @@
|
|||||||
<div class="relative flex-1">
|
<div class="relative flex-1">
|
||||||
<input type="date"
|
<input type="date"
|
||||||
id="${fieldId}_input"
|
id="${fieldId}_input"
|
||||||
name="${escapeHtml(options.name || fieldId)}"
|
name="${window.LEDEscape.html(options.name || fieldId)}"
|
||||||
value="${escapeHtml(currentValue)}"
|
value="${window.LEDEscape.html(currentValue)}"
|
||||||
${min ? `min="${escapeHtml(min)}"` : ''}
|
${min ? `min="${window.LEDEscape.html(min)}"` : ''}
|
||||||
${max ? `max="${escapeHtml(max)}"` : ''}
|
${max ? `max="${window.LEDEscape.html(max)}"` : ''}
|
||||||
${placeholder ? `placeholder="${escapeHtml(placeholder)}"` : ''}
|
${placeholder ? `placeholder="${window.LEDEscape.html(placeholder)}"` : ''}
|
||||||
${disabled ? 'disabled' : ''}
|
${disabled ? 'disabled' : ''}
|
||||||
${required ? 'required' : ''}
|
${required ? 'required' : ''}
|
||||||
onchange="window.LEDMatrixWidgets.getHandlers('date-picker').onChange('${fieldId}')"
|
onchange="window.LEDMatrixWidgets.getHandlers('date-picker').onChange('${fieldId}')"
|
||||||
@@ -109,7 +107,7 @@
|
|||||||
} else {
|
} else {
|
||||||
constraintText = `Until ${max}`;
|
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
|
// Error message area
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user