From 1c928b203325ff826a24c6824531f52f6dcfc4de Mon Sep 17 00:00:00 2001 From: Chuck <33324927+ChuckBuilds@users.noreply.github.com> Date: Wed, 30 Sep 2026 09:30:37 -0400 Subject: [PATCH] feat(vegas): one declared participation per plugin (scroll | pause | exclude) (#682) A plugin takes part in Vegas mode in one declared way: 'scroll', 'pause' or 'exclude', resolved from the user's vegas_participation setting, the manifest field, then the legacy hooks, so no plugin changes behaviour. The stream manager decides inclusion and pauses through it; the installed plugins API and the Vegas plugin-order list report it. Deprecates get_supported_vegas_modes, get_vegas_segment_width and vegas_panel_count for removal in 3.9.0, and regenerates docs/DEPRECATIONS_3.8.md to include them. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 40 ++ docs/ADVANCED_FEATURES.md | 138 +++--- docs/DEPRECATIONS_3.8.md | 33 +- docs/PLUGIN_API_REFERENCE.md | 106 +++- docs/PLUGIN_CONFIG_CORE_PROPERTIES.md | 14 + docs/REST_API_REFERENCE.md | 10 +- schema/manifest_schema.json | 5 + src/deprecation.py | 24 + src/plugin_system/base_plugin.py | 313 +++++++++--- src/plugin_system/plugin_manager.py | 3 +- src/plugin_system/schema_manager.py | 22 +- src/vegas_mode/coordinator.py | 10 +- src/vegas_mode/plugin_adapter.py | 23 - src/vegas_mode/stream_manager.py | 81 ++- test/test_deprecation.py | 24 +- test/test_vegas_participation.py | 461 ++++++++++++++++++ test/test_vegas_stream_log_volume.py | 1 - web_interface/blueprints/api_v3/plugins.py | 12 + .../static/v3/js/widgets/plugin-order-list.js | 18 +- 19 files changed, 1094 insertions(+), 244 deletions(-) create mode 100644 test/test_vegas_participation.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e912ddc..d25c432b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -78,6 +78,46 @@ accepts both, but the store flags the old spelling as deprecated `POST /api/v3/auth/password`, `POST /api/v3/auth/disable`, `GET|POST /api/v3/auth/tokens`, `DELETE /api/v3/auth/tokens/`. +### Vegas participation + +A plugin now takes part in Vegas mode in one declared way: `'scroll'` (its +content scrolls by), `'pause'` (the scroll stops for its turn and its +`display()` draws it full screen) or `'exclude'`. No plugin changes +behaviour: one that declares nothing gets exactly what the old hooks gave +it, checked against every official plugin. + +- `BasePlugin.get_vegas_participation()` resolves, in order: the user's + `vegas_participation` config value, the manifest's `vegas_participation`, + then the legacy hooks (`get_vegas_display_mode()` returning `STATIC` → + pause, else `get_vegas_content_type()` returning `'none'` → exclude, else + scroll). `resolve_vegas_participation()` in `src.plugin_system.base_plugin` + is what the core calls; the user's setting wins even over a plugin that + overrides the method. +- The Vegas stream manager decides inclusion and pauses through it, and + `PluginAdapter.get_content_type()` is removed (core-internal, now unused). + Swap mode no longer drops a plugin's segment for a cycle when its + `get_vegas_display_mode()` raises something other than + `AttributeError`/`TypeError`: like every other decision point it now + treats that as "not paused". +- `vegas_participation` is a core-owned per-plugin property (an enum with no + default) and a manifest field in `schema/manifest_schema.json`. +- `GET /api/v3/plugins/installed` reports each plugin's + `vegas_participation`, and the Vegas plugin-order list badges it (Scroll / + Pause / Excluded) instead of the old Scroll / Fixed / Static. +- `src.deprecation.warn_deprecated()` warns once per process for what + `@deprecated` cannot decorate, such as a config key. + +Deprecated, removed in 3.9.0 (each logs a warning on first use). Vegas never +read any of them: + +- `BasePlugin.get_supported_vegas_modes()` and + `BasePlugin.get_vegas_segment_width()`. +- The `vegas_panel_count` per-plugin setting (warns once per plugin that sets + it). +- The SCROLL / FIXED_SEGMENT distinction (`vegas_mode` `"scroll"` vs + `"fixed"`): both always scrolled. Documented only; no warning, because + official plugins' schemas still offer `"fixed"`. + ### Fixes - On-demand no longer restarts a running display. `POST diff --git a/docs/ADVANCED_FEATURES.md b/docs/ADVANCED_FEATURES.md index 6c79cc40..3aed7ba5 100644 --- a/docs/ADVANCED_FEATURES.md +++ b/docs/ADVANCED_FEATURES.md @@ -10,22 +10,27 @@ This guide covers advanced LEDMatrix features for users and developers, includin Vegas scroll mode displays content from multiple plugins in a continuous horizontal scroll, similar to news tickers seen in Las Vegas casinos. Plugins contribute content segments that flow across the display in a seamless ticker-style presentation. -### Display Modes +### How a Plugin Takes Part -**SCROLL (Continuous Scrolling):** -- Content scrolls continuously left -- Smooth, fluid motion -- Best for news-ticker style displays +Each plugin has a *Vegas participation*: -**FIXED_SEGMENT (Fixed-Width Block):** -- Plugin gets fixed-width block on display -- Content doesn't scroll out of its segment -- Multiple plugins can share the display simultaneously +**`scroll` (the default):** +- The plugin's content scrolls by with everyone else's +- Best for news-ticker style content: scores, headlines, prices, the time -**STATIC (Scroll Pauses):** -- Scrolling pauses when content is fully visible -- Displays for specified duration, then resumes scrolling -- Best for content that needs to be fully read +**`pause`:** +- The scroll stops when the plugin's turn comes round +- The plugin draws the whole panel for its display duration, then the + scroll resumes +- Best for content that needs to be read in full, or alerts + +**`exclude`:** +- The plugin is left out of Vegas mode + +A plugin declares its default; set `vegas_participation` in a plugin's +config to override it (see [Per-Plugin Configuration](#per-plugin-configuration)). +Older documentation also describes a *fixed segment* mode; Vegas never +implemented one, and it has always behaved exactly like `scroll`. ### Configuration @@ -164,8 +169,7 @@ Override Vegas behavior for specific plugins: { "my_plugin": { "enabled": true, - "vegas_mode": "scroll", - "vegas_panel_count": 2, + "vegas_participation": "pause", "display_duration": 10 } } @@ -175,19 +179,30 @@ Override Vegas behavior for specific plugins: | Setting | Values | Description | |---------|--------|-------------| -| `vegas_mode` | `scroll`, `fixed`, `static` | Display mode for this plugin | -| `vegas_panel_count` | any positive integer | Width in panels (1 panel = display width) | -| `display_duration` | seconds | Pause duration for STATIC mode | +| `vegas_participation` | `scroll`, `pause`, `exclude` | How this plugin takes part: its content scrolls by, the scroll pauses for its turn and shows it full screen, or it is left out. Unset uses the plugin's own default | +| `display_duration` | seconds | How long a `pause` plugin holds the screen | +| `vegas_width_pct` | 10–100 | Width of this plugin's card, as a percentage of the panel | +| `vegas_overflow` | `rotate`, `truncate` | What to do when its content is wider than its allowance | +| `vegas_max_width_screens` | number of screens | The widest its card may be | -Plugins may also set `vegas_overflow` and `vegas_max_width_screens` in -their config section to control how oversized content is handled (see -`PluginManager` in `src/plugin_system/plugin_manager.py`). +These are core-owned settings (see +[PLUGIN_CONFIG_CORE_PROPERTIES.md](PLUGIN_CONFIG_CORE_PROPERTIES.md)): every +plugin accepts them whether or not its own schema lists them. Set them in +the plugin's section of config.json, in the web UI's **Config Editor** +tab. + +Some plugins also offer a `vegas_mode` setting of their own (`scroll`, +`fixed` or `static`). It still works — `static` pauses, the other two scroll +— but `vegas_participation` takes precedence, and `fixed` has never done +anything different from `scroll`. The old `vegas_panel_count` setting never +had an effect and is deprecated (removed in 3.9.0). ### Plugin Integration (Developer Guide) All of these have defaults in [`BasePlugin`](../src/plugin_system/base_plugin.py); override only what you -need. +need. The reference is +[PLUGIN_API_REFERENCE.md](PLUGIN_API_REFERENCE.md#vegas-scroll-hooks). **1. Implement Content Method:** @@ -203,43 +218,41 @@ If it returns `None` (the default), Vegas falls back to the plugin's (`PluginAdapter.get_content()` in [`src/vegas_mode/plugin_adapter.py`](../src/vegas_mode/plugin_adapter.py)). -**2. Specify Content Type:** +**2. Declare how the plugin takes part:** -```python -def get_vegas_content_type(self): - # 'multi' | 'static' | 'none' -- default is 'static' - return 'multi' +Most plugins need nothing: the default is `scroll`. A plugin that should +pause the scroll, or stay out of Vegas, says so in `manifest.json`: + +```json +{ + "vegas_participation": "pause" +} ``` -`'none'` excludes the plugin from Vegas mode. - -**3. Optionally Specify Display Mode:** - -These return `VegasDisplayMode` members, not strings: +The user's own `vegas_participation` setting overrides the manifest. When +the answer depends on state, override the method instead: ```python -from src.plugin_system.base_plugin import VegasDisplayMode - -def get_vegas_display_mode(self): - return VegasDisplayMode.SCROLL - -def get_supported_vegas_modes(self): - return [VegasDisplayMode.SCROLL, VegasDisplayMode.STATIC] +def get_vegas_participation(self): + # 'scroll' | 'pause' | 'exclude' + return 'pause' if self._alert_is_live() else 'scroll' ``` -`VegasDisplayMode` has `SCROLL` (`"scroll"`), `FIXED_SEGMENT` (`"fixed"`) and -`STATIC` (`"static"`). The default `get_vegas_display_mode()` uses the -plugin's `vegas_mode` config value if set, otherwise maps the content type -(`multi` to `SCROLL`, anything else to `FIXED_SEGMENT`). +A plugin written for an older core that declares nothing keeps its +behaviour: `get_vegas_display_mode()` returning `VegasDisplayMode.STATIC` +pauses, `get_vegas_content_type()` returning `'none'` excludes, and +everything else scrolls. `get_supported_vegas_modes()`, +`get_vegas_segment_width()` and the SCROLL / FIXED_SEGMENT distinction are +deprecated (removed in 3.9.0): Vegas never read them. ### Content Rendering Guidelines **Image Dimensions:** - **Height:** Must match display height (typically 32 pixels) -- **Width:** Varies by mode: - - SCROLL: Any width (recommended 64-512 pixels) - - FIXED_SEGMENT: `panel_count * display_width` - - STATIC: Any width, optimized for readability +- **Width:** Any width for `scroll` (recommended 64-512 pixels); + `get_vegas_render_width()` is the width Vegas would like, and it narrows + `display_manager` to match while it asks. A `pause` plugin draws the + whole panel in `display()`. **Color Mode:** - Use RGB color mode @@ -289,17 +302,10 @@ class WeatherPlugin(BasePlugin): def get_vegas_content(self): """Return cached Vegas image""" return self.vegas_image - - def get_vegas_content_type(self): - return 'multi' - - def get_vegas_display_mode(self): - return 'scroll' - - def get_supported_vegas_modes(self): - return ['scroll', 'static'] ``` +It scrolls, the default participation, so it declares nothing else. + ### System Architecture Vegas mode consists of four core components working together to provide smooth 125 FPS continuous scrolling: @@ -382,7 +388,8 @@ Vegas mode consists of four core components working together to provide smooth 1 **Responsibilities:** - Convert plugin content to scrollable images -- Handle different Vegas display modes (SCROLL, FIXED, STATIC) +- Fetch the content of `scroll` plugins (a `pause` plugin is drawn by + its own `display()` when the scroll pauses; see StreamManager) - Manage fallback for plugins without Vegas support - Cache plugin content for performance @@ -391,21 +398,16 @@ Vegas mode consists of four core components working together to provide smooth 1 - Calls `get_vegas_content()` if available - Falls back to `display()` method if not -2. **Handle display mode:** - - SCROLL: Returns image as-is for continuous scrolling - - FIXED_SEGMENT: Creates fixed-width block (panel_count * display_width) - - STATIC: Marks content for pause-when-visible behavior - -3. **Content type handling:** - - `multi`: Multiple segments (list of images) - - `static`: Single static image - - `none`: Skip this plugin in current cycle +2. **Participation** is decided by the StreamManager, not here + (`resolve_vegas_participation()` in + [`base_plugin.py`](../src/plugin_system/base_plugin.py)): `exclude` + plugins never reach the adapter, and `pause` plugins are not fetched. **Fallback Behavior:** - If plugin doesn't implement Vegas methods: - Calls plugin's `display()` method - Captures rendered display as static image - - Treats as fixed segment + - Scrolls it by as one block - Ensures all plugins work in Vegas mode without explicit support #### 4. RenderPipeline @@ -508,7 +510,7 @@ All components use thread-safe patterns: If a plugin doesn't implement Vegas methods: - System calls the plugin's `display()` method - Captures the rendered display as a static image -- Treats it as a fixed segment +- Scrolls it by as one block This ensures all plugins work in Vegas mode, even without explicit support. diff --git a/docs/DEPRECATIONS_3.8.md b/docs/DEPRECATIONS_3.8.md index 09738fa1..828ef121 100644 --- a/docs/DEPRECATIONS_3.8.md +++ b/docs/DEPRECATIONS_3.8.md @@ -2,11 +2,11 @@ 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-09-29, core 3.7.0 +- Scanned: 2026-09-30, core 3.7.0 - Monorepo: [ChuckBuilds/ledmatrix-plugins](https://github.com/ChuckBuilds/ledmatrix-plugins) (main @ 4327c2e4), 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) -**35 deprecated methods: 35 unused, 0 still used, 0 need review.** +**37 deprecated methods: 36 unused, 1 still used, 0 need review.** Counted per plugin: a *call* is `.method` on an object named like the owner (`cache_manager`, `display_manager`, `font_manager`, `plugin_manager`), or on `self`/`super()` in a subclass; an *override* is `def method` in a subclass of the owner. *Review* hits are `.method` on a receiver whose type the scan cannot tell. *Internal* hits sit inside another deprecated core method and go with it. *Unrelated* hits are a different class's own method with the same name (a name collision), and never block removal; neither do hits in test files. @@ -46,11 +46,17 @@ Counted per plugin: a *call* is `.method` on an object named like the | `FontManager.add_font` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 | | `FontManager.remove_font` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 | | `FontManager.validate_font` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 | +| `BasePlugin.get_supported_vegas_modes` | 3.9.0 | core tests (2 test reviews) | blackjack (1 call, 1 override); calendar (1 override); olympics (1 override) | — | still used by blackjack, calendar, olympics — keep or migrate first | +| `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 | -## Unused — safe to remove (35) +## Unused — safe to remove (36) -`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`, `PluginManager.get_enabled_plugins` +`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` + +## Still used — keep or migrate first (1) + +`BasePlugin.get_supported_vegas_modes` ## Every hit @@ -87,21 +93,28 @@ File paths are relative to the plugin's directory (core: the repo root). | `DisplayManager.draw_weather_icon` | ledmatrix-weather | manager.py:1635 | unrelated | `WeatherIcons.draw_weather_icon(img, forecast['icon'], icon_x, icon_y, icon_size)` | | `DisplayManager.draw_weather_icon` | ledmatrix-weather | weather_icons.py:168 | unrelated | `def draw_weather_icon(image: Image.Image, icon_code: str, x: int, y: int, size: int = DEFAULT_SIZE):` | | `DisplayManager.draw_text_with_icons` | core | src/plugin_system/testing/visual_display_manager.py:526 | unrelated | `def draw_text_with_icons(self, text: str, icons: List[tuple] = None,` | -| `FontManager.get_font_catalog` | core tests | test/test_deprecation.py:217 | test call | `assert fm.get_font_catalog() == fm.font_catalog` | +| `FontManager.get_font_catalog` | core tests | test/test_deprecation.py:229 | test call | `assert fm.get_font_catalog() == fm.font_catalog` | +| `BasePlugin.get_supported_vegas_modes` | core tests | test/test_vegas_participation.py:356 | test review | `assert plugin.get_supported_vegas_modes() == [` | +| `BasePlugin.get_supported_vegas_modes` | core tests | test/test_vegas_participation.py:358 | test review | `assert plugin.get_supported_vegas_modes()` | +| `BasePlugin.get_supported_vegas_modes` | blackjack | manager.py:732 | override | `def get_supported_vegas_modes(self):` | +| `BasePlugin.get_supported_vegas_modes` | blackjack | manager.py:695 | call | `if mode in self.get_supported_vegas_modes():` | +| `BasePlugin.get_supported_vegas_modes` | calendar | manager.py:875 | override | `def get_supported_vegas_modes(self) -> List[VegasDisplayMode]:` | +| `BasePlugin.get_supported_vegas_modes` | olympics | manager.py:624 | override | `def get_supported_vegas_modes(self) -> List[VegasDisplayMode]:` | +| `BasePlugin.get_vegas_segment_width` | core tests | test/test_vegas_participation.py:359 | test review | `assert plugin.get_vegas_segment_width() == 2` | ## Sources scanned | Source | Group | Python files | Hits | |---|---|---|---| -| core | core | 158 | 20 | -| core tests | core-tests | 316 | 14 | +| core | core | 164 | 20 | +| core tests | core-tests | 323 | 17 | | 7-segment-clock | monorepo | 3 | 0 | | afl-scoreboard | monorepo | 34 | 0 | | baseball-scoreboard | monorepo | 60 | 0 | | basketball-scoreboard | monorepo | 48 | 0 | | birdnet-go | monorepo | 2 | 0 | -| blackjack | monorepo | 7 | 0 | -| calendar | monorepo | 5 | 0 | +| blackjack | monorepo | 7 | 2 | +| calendar | monorepo | 5 | 1 | | christmas-countdown | monorepo | 3 | 0 | | clock-simple | monorepo | 2 | 0 | | countdown | monorepo | 5 | 0 | @@ -130,7 +143,7 @@ File paths are relative to the plugin's directory (core: the repo root). | nrl-scoreboard | monorepo | 29 | 0 | | odds-ticker | monorepo | 9 | 0 | | of-the-day | monorepo | 14 | 0 | -| olympics | monorepo | 16 | 0 | +| olympics | monorepo | 16 | 1 | | on-air | monorepo | 2 | 0 | | pomodoro-timer | monorepo | 3 | 0 | | soccer-scoreboard | monorepo | 46 | 0 | diff --git a/docs/PLUGIN_API_REFERENCE.md b/docs/PLUGIN_API_REFERENCE.md index efe3663f..a663cdfb 100644 --- a/docs/PLUGIN_API_REFERENCE.md +++ b/docs/PLUGIN_API_REFERENCE.md @@ -314,6 +314,58 @@ rotating one at a time. Plugins control how their content appears via these hooks. See [ADVANCED_FEATURES.md](ADVANCED_FEATURES.md) for the user side of Vegas mode. +#### Vegas participation + +Each plugin takes part in Vegas mode in one of three ways: + +| Participation | What Vegas does | +|---|---| +| `'scroll'` | The plugin's content (`get_vegas_content()`) scrolls by with everything else | +| `'pause'` | The scroll stops when the plugin's turn comes round; its `display()` draws it full screen for `get_display_duration()` seconds, then the scroll resumes | +| `'exclude'` | The plugin is left out of Vegas mode | + +Declare the plugin's default in `manifest.json`: + +```json +{ + "id": "my-alerts", + "vegas_participation": "pause" +} +``` + +The user can override it per plugin with `vegas_participation` in that +plugin's config section (it is one of the core-owned properties, see +[PLUGIN_CONFIG_CORE_PROPERTIES.md](PLUGIN_CONFIG_CORE_PROPERTIES.md)). +Vegas resolves it in this order: + +1. the user's `vegas_participation` config value; +2. the plugin's `get_vegas_participation()` — the default implementation + reads the manifest's `vegas_participation`, then derives a value from + the legacy hooks below; +3. derived from the legacy hooks: `get_vegas_display_mode()` returning + `VegasDisplayMode.STATIC` → `'pause'`; otherwise + `get_vegas_content_type()` returning `'none'` → `'exclude'`; everything + else → `'scroll'`. + +Step 3 is exactly what Vegas did before participation existed, so a plugin +that declares nothing behaves as it always has. Manifest +`vegas_participation` is new in core 3.8.0; older cores ignore it and use +the legacy hooks. + +#### `get_vegas_participation() -> str` + +Returns `'scroll'`, `'pause'` or `'exclude'`. Override it only when the +answer depends on state — pause only while an alert is live, exclude while +there is nothing to show; for a fixed answer use the manifest. Vegas applies +the user's config value before calling an override, so an override does not +need to check it. A value that is not one of the three is ignored with a log +line and the legacy hooks decide. + +```python +def get_vegas_participation(self): + return 'pause' if self._alert_is_live() else 'scroll' +``` + #### `get_vegas_content() -> Optional[PIL.Image | List[PIL.Image] | None]` Return content to inject into the scroll. Multi-item plugins (sports, @@ -321,26 +373,40 @@ odds, news) should return a *list* of PIL Images so each item scrolls independently. Static plugins (clock, weather) can return a single image. Returning `None` falls back to capturing whatever `display()` produces. -#### `get_vegas_content_type() -> str` +#### `get_vegas_render_width() -> int` -`'multi'`, `'static'`, or `'none'`. Affects how Vegas mode treats the -plugin. Default `'static'`. +The width Vegas wants this plugin's content to occupy, from the plugin's +`vegas_width_pct` config value or the global +`display.vegas_scroll.render_width_pct`. Vegas also narrows +`display_manager` while it asks for content, so a plugin that sizes itself +from `display_manager.width` does not need to read this. -#### `get_vegas_display_mode() -> VegasDisplayMode` +#### Legacy: `get_vegas_content_type()` and `get_vegas_display_mode()` -Returns one of `VegasDisplayMode.SCROLL`, `FIXED_SEGMENT`, or `STATIC`. -Read from `config["vegas_mode"]` or override directly. +Superseded by participation, and still read to derive it when neither the +user nor the manifest declares one (step 3 above). Only two answers ever +mattered: `get_vegas_content_type()` returning `'none'`, and +`get_vegas_display_mode()` returning `VegasDisplayMode.STATIC`. -#### `get_supported_vegas_modes() -> List[VegasDisplayMode]` +- `get_vegas_content_type()` returns `'multi'`, `'static'` or `'none'` + (default `'static'`). +- `get_vegas_display_mode()` returns a `VegasDisplayMode` member (not a + string — the string `'static'` never paused anything). The default reads + the plugin's `vegas_mode` config value (`"scroll"`, `"fixed"` or + `"static"`), else maps content type `'multi'` to `SCROLL` and anything + else to `FIXED_SEGMENT`. -The set of Vegas modes this plugin can render. Used by the UI to populate -the mode selector for this plugin. +`SCROLL` and `FIXED_SEGMENT` (and `vegas_mode` `"scroll"` and `"fixed"`) +have always behaved identically: both scroll. The distinction is deprecated +and goes away in 3.9.0 — see [Deprecated APIs](#deprecated-apis). -#### `get_vegas_segment_width() -> Optional[int]` +#### Deprecated: `get_supported_vegas_modes()` and `get_vegas_segment_width()` -For `FIXED_SEGMENT` plugins, the number of *panels* the segment -occupies in the scroll (pixel width = panels × `single_panel_width`, -from `display.hardware.cols`). `None` uses the default of 1 panel. +Never read by core, and removed in 3.9.0: calling the `BasePlugin` +implementation logs a deprecation warning. A plugin's own override keeps +working for the plugin itself. `get_vegas_segment_width()` read the +`vegas_panel_count` config value, which has never affected Vegas — a card's +width comes from `get_vegas_content()` and `vegas_width_pct`. > The full source for `BasePlugin` lives in > `src/plugin_system/base_plugin.py`. If a method here disagrees with the @@ -1095,3 +1161,17 @@ are removed in 3.8.0; the rest stay until their callers migrate. | `font_manager` | `set_override`, `remove_override`, `get_overrides`, `add_font`, `remove_font`, `validate_font`, `get_size_tokens`, `get_performance_stats`, `get_manager_fonts`, `get_detected_fonts`, `get_plugin_fonts`, `unregister_plugin_fonts` | no replacement | | `plugin_manager` | `get_enabled_plugins` | check `enabled` on the entries in `plugin_manager.plugins` | +### Removed in 3.9.0 + +The Vegas APIs that described a fixed-width segment, which Vegas never +implemented. Vegas participation (`'scroll'`, `'pause'`, `'exclude'`, see +[Vegas scroll hooks](#vegas-scroll-hooks)) replaces them. Calling one of the +methods, or setting `vegas_panel_count`, logs a warning once per process. +No official plugin calls them; calendar, olympics and blackjack override +`get_supported_vegas_modes()`, which keeps working for the plugin itself. + +| What | Instead | +|---|---| +| `BasePlugin.get_supported_vegas_modes()` | declare `vegas_participation` in the manifest | +| `BasePlugin.get_vegas_segment_width()` and the `vegas_panel_count` config key | nothing: a card's width comes from `get_vegas_content()` and `vegas_width_pct` | +| `VegasDisplayMode.SCROLL` vs `FIXED_SEGMENT` (`vegas_mode` `"scroll"` vs `"fixed"`) | `'scroll'` participation; the two always behaved the same | diff --git a/docs/PLUGIN_CONFIG_CORE_PROPERTIES.md b/docs/PLUGIN_CONFIG_CORE_PROPERTIES.md index 2939dd99..3913150c 100644 --- a/docs/PLUGIN_CONFIG_CORE_PROPERTIES.md +++ b/docs/PLUGIN_CONFIG_CORE_PROPERTIES.md @@ -33,6 +33,20 @@ is `CORE_PLUGIN_PROPERTIES` in `src/plugin_system/schema_manager.py`): - Read by `src/vegas_mode/plugin_adapter.py` and `BasePlugin`, which validate the values themselves and ignore a bad one with a log line +5. **`vegas_participation`** (string enum: `"scroll"`, `"pause"`, + `"exclude"`; no default) + - Description: how this plugin takes part in Vegas mode — its content + scrolls by, the scroll pauses for its turn and shows it full screen, or + it is left out + - Overrides the plugin's own default (its manifest's + `vegas_participation`, else what its legacy Vegas hooks say); unset + means "use the plugin's default" + - Deliberately has no default: one would be written into every plugin's + config and override what each plugin declares + - Read by `resolve_vegas_participation()` in + `src/plugin_system/base_plugin.py`; see + [PLUGIN_API_REFERENCE.md](PLUGIN_API_REFERENCE.md#vegas-participation) + `skin` and `skin_options` were core properties until the skin system was removed. A plugin config saved with them still loads and saves; the keys are dropped on the next save (see `RETIRED_PLUGIN_KEYS` in `schema_manager.py`). diff --git a/docs/REST_API_REFERENCE.md b/docs/REST_API_REFERENCE.md index 0fbc2411..4737ef5d 100644 --- a/docs/REST_API_REFERENCE.md +++ b/docs/REST_API_REFERENCE.md @@ -511,13 +511,21 @@ List all installed plugins with their status and metadata. "branch": "main", "web_ui_actions": [], "vegas_mode": null, - "vegas_content_type": null + "vegas_content_type": null, + "vegas_participation": "scroll" } ] } } ``` +`vegas_participation` is what Vegas mode does with the plugin: `"scroll"`, +`"pause"` or `"exclude"` (see +[PLUGIN_API_REFERENCE.md](PLUGIN_API_REFERENCE.md#vegas-participation)). +For a plugin that is not loaded it is only the user's own +`vegas_participation` setting, or `null`. `vegas_mode` and +`vegas_content_type` are the legacy hooks' raw answers. + ### Get Plugin Configuration **GET** `/api/v3/plugins/config?plugin_id=` diff --git a/schema/manifest_schema.json b/schema/manifest_schema.json index 5dd360d8..887ac468 100644 --- a/schema/manifest_schema.json +++ b/schema/manifest_schema.json @@ -149,6 +149,11 @@ }, "description": "Array of display mode names this plugin provides" }, + "vegas_participation": { + "type": "string", + "enum": ["scroll", "pause", "exclude"], + "description": "How this plugin takes part in Vegas mode by default: 'scroll' (its content scrolls by), 'pause' (the scroll stops for its turn and display() draws it full screen) or 'exclude' (left out). A user's per-plugin vegas_participation setting overrides it. Omit it to derive the participation from get_vegas_display_mode() / get_vegas_content_type(). Cores before 3.8.0 ignore it." + }, "api_requirements": { "type": "array", "items": { diff --git a/src/deprecation.py b/src/deprecation.py index e49c8984..e1ed4ffb 100644 --- a/src/deprecation.py +++ b/src/deprecation.py @@ -53,3 +53,27 @@ def deprecated(removal: str, alternative: Optional[str] = None) -> Callable[[F], return wrapper # type: ignore[return-value] return decorate + + +def warn_deprecated(what: str, removal: str, alternative: Optional[str] = None, + once_key: Optional[str] = None) -> bool: + """Warn that ``what`` will be removed in ``removal``, once per process. + + For what ``@deprecated`` cannot decorate: a config key, a manifest field, + a value a hook returns. Same message, log line and DeprecationWarning as + the decorator. ``once_key`` (default: ``what``) is what "once" counts + against, so one deprecated key can warn once for each plugin that sets it. + + Returns whether this call warned. + """ + message = f"{what} is deprecated and will be removed in LEDMatrix {removal}" + if alternative: + message += f"; {alternative}" + key = once_key or what + with _warned_lock: + if key in _warned: + return False + _warned.add(key) + logger.warning(message) + warnings.warn(message, DeprecationWarning, stacklevel=2) + return True diff --git a/src/plugin_system/base_plugin.py b/src/plugin_system/base_plugin.py index 8ab9abdf..ffc385bf 100644 --- a/src/plugin_system/base_plugin.py +++ b/src/plugin_system/base_plugin.py @@ -13,6 +13,7 @@ from enum import Enum from typing import Dict, Any, Optional, List import os import sys +from src.deprecation import deprecated, warn_deprecated from src.logging_config import get_logger @@ -65,27 +66,178 @@ def _fallback_font_manager() -> Any: class VegasDisplayMode(Enum): """ - Display mode for Vegas scroll integration. + Legacy display mode for Vegas scroll integration. - Determines how a plugin's content behaves within the continuous scroll: + Superseded by :meth:`BasePlugin.get_vegas_participation`. Vegas still + reads a plugin's :meth:`BasePlugin.get_vegas_display_mode` to derive its + participation when nothing declares one, and only STATIC matters there: - - SCROLL: Content scrolls continuously within the stream. - Best for multi-item plugins like sports scores, odds tickers, news feeds. - Plugin provides multiple frames via get_vegas_content(). - - - FIXED_SEGMENT: Content is a fixed-width block that scrolls BY with - the rest of the content. Best for static info like clock, weather. - Plugin provides a single image sized to vegas_panel_count panels. - - - STATIC: Scroll pauses, plugin displays for its duration, then scroll - resumes. Best for important alerts or detailed views that need attention. - Plugin uses standard display() method during the pause. + - STATIC: the scroll pauses for the plugin's turn and its display() draws + it full screen -- participation ``'pause'``. + - SCROLL and FIXED_SEGMENT: the plugin's content joins the scroll -- + participation ``'scroll'``. Vegas has never told the two apart: a card's + width comes from get_vegas_content() and ``vegas_width_pct``, not from + the mode. The distinction is deprecated and goes away in LEDMatrix 3.9.0. """ SCROLL = "scroll" FIXED_SEGMENT = "fixed" STATIC = "static" +#: How a plugin takes part in Vegas mode (BasePlugin.get_vegas_participation): +#: +#: - ``'scroll'``: its content joins the scrolling strip. +#: - ``'pause'``: the scroll stops when the plugin's turn comes round, and its +#: display() draws it full screen for its display duration. +#: - ``'exclude'``: it is left out of Vegas mode. +VEGAS_PARTICIPATION_VALUES = ('scroll', 'pause', 'exclude') + +#: The release that removes get_supported_vegas_modes(), +#: get_vegas_segment_width(), the ``vegas_panel_count`` setting and the +#: SCROLL / FIXED_SEGMENT distinction. The two @deprecated markers below +#: spell it as a literal, because tools that read markers statically (the +#: deprecation tests, the plugin API usage scan) cannot follow a name. +VEGAS_LEGACY_REMOVAL = "3.9.0" + +_vegas_logger = get_logger(__name__) +_vegas_warned: set = set() + + +def _vegas_warn_once(key: Any, message: str, *args: Any) -> None: + """Log a warning about a plugin's Vegas settings once per process. + + Participation is resolved at every rotation refresh, so a bad value would + otherwise log on every one of them. + """ + if key in _vegas_warned: + return + _vegas_warned.add(key) + _vegas_logger.warning(message, *args) + + +def vegas_participation_value(value: Any) -> Optional[str]: + """``value`` as one of VEGAS_PARTICIPATION_VALUES, or None if it is not one. + + Case and surrounding whitespace are ignored; anything that is not a string + (None, a MagicMock standing in for a plugin in a test) is not a value. + """ + if isinstance(value, str): + value = value.strip().lower() + if value in VEGAS_PARTICIPATION_VALUES: + return value + return None + + +def configured_vegas_participation(plugin_id: str, config: Any) -> Optional[str]: + """The user's ``vegas_participation`` setting in a plugin's config, if valid. + + An unset or empty value is no setting. Anything else that is not a + participation is logged once and ignored, so the plugin keeps its own. + """ + if not isinstance(config, dict): + return None + raw = config.get('vegas_participation') + if raw is None or (isinstance(raw, str) and not raw.strip()): + return None + value = vegas_participation_value(raw) + if value is None: + _vegas_warn_once( + ('config', plugin_id, repr(raw)), + "[%s] Invalid vegas_participation %r, expected one of %s; ignoring it", + plugin_id, raw, ', '.join(VEGAS_PARTICIPATION_VALUES)) + return value + + +def legacy_vegas_participation(plugin: Any) -> str: + """The participation a plugin's pre-3.8 Vegas hooks describe. + + Exactly what Vegas decided from them before participation existed: + + 1. get_vegas_display_mode() returning ``VegasDisplayMode.STATIC`` pauses, + whatever the content type -- a STATIC plugin whose content type is + ``'none'`` was still kept in the rotation to pause it. + 2. Otherwise get_vegas_content_type() returning ``'none'`` excludes. + 3. Everything else scrolls. SCROLL and FIXED_SEGMENT were never told + apart, and neither were content types ``'multi'``, ``'static'`` or any + other string. + + Only the enum member counts as STATIC (a plugin returning the string + ``'static'`` never paused), and a hook that raises or is missing counts as + not STATIC and as content type ``'static'``. + """ + display_mode = None + get_mode = getattr(plugin, 'get_vegas_display_mode', None) + if get_mode is not None: + try: + display_mode = get_mode() + except Exception: + _vegas_logger.debug("get_vegas_display_mode() failed on %s; not pausing", + type(plugin).__name__, exc_info=True) + if display_mode == VegasDisplayMode.STATIC: + return 'pause' + + content_type = 'static' + get_type = getattr(plugin, 'get_vegas_content_type', None) + if get_type is not None: + try: + content_type = get_type() + except Exception: + _vegas_logger.debug("get_vegas_content_type() failed on %s; treating as 'static'", + type(plugin).__name__, exc_info=True) + if content_type == 'none': + return 'exclude' + return 'scroll' + + +def resolve_vegas_participation(plugin: Any, plugin_id: Optional[str] = None) -> str: + """How Vegas mode treats ``plugin``: ``'scroll'``, ``'pause'`` or ``'exclude'``. + + What the core calls, rather than the plugin's own + get_vegas_participation(), so the user's setting wins even over a plugin + that overrides that method, and so a plugin that is not a BasePlugin (or a + test double) still gets the legacy derivation: + + 1. the user's ``vegas_participation`` in the plugin's config; + 2. the plugin's get_vegas_participation(), when it returns a valid value + (BasePlugin's reads the manifest's ``vegas_participation``, then + derives one from the legacy hooks); + 3. legacy_vegas_participation(). + + Also where the deprecated ``vegas_panel_count`` setting is reported, once + per plugin. Never raises. + """ + pid = plugin_id or getattr(plugin, 'plugin_id', None) or type(plugin).__name__ + config = getattr(plugin, 'config', None) + if isinstance(config, dict) and 'vegas_panel_count' in config: + warn_deprecated( + f"The vegas_panel_count setting (plugin '{pid}')", VEGAS_LEGACY_REMOVAL, + "it has no effect -- use vegas_width_pct to size the plugin's card", + once_key=f"vegas_panel_count:{pid}") + + configured = configured_vegas_participation(pid, config) + if configured is not None: + return configured + + getter = getattr(plugin, 'get_vegas_participation', None) + if callable(getter): + try: + declared = getter() + except Exception: + _vegas_logger.exception("[%s] get_vegas_participation() failed; " + "using its legacy Vegas hooks", pid) + declared = None + value = vegas_participation_value(declared) + if value is not None: + return value + if isinstance(declared, str): + _vegas_warn_once( + ('declared', pid, declared), + "[%s] get_vegas_participation() returned %r, expected one of %s; " + "using its legacy Vegas hooks", + pid, declared, ', '.join(VEGAS_PARTICIPATION_VALUES)) + return legacy_vegas_participation(plugin) + + class BasePlugin(ABC): """ Base class that all plugins must inherit from. @@ -834,41 +986,97 @@ class BasePlugin(ABC): """ return None + def get_vegas_participation(self) -> str: + """ + How this plugin takes part in Vegas mode: ``'scroll'``, ``'pause'`` or + ``'exclude'``. + + - ``'scroll'``: the plugin's content (get_vegas_content()) joins the + scrolling strip. + - ``'pause'``: the scroll stops when the plugin's turn comes round, and + its display() draws it full screen for get_display_duration(). + - ``'exclude'``: the plugin is left out of Vegas mode. + + Resolved in this order: + + 1. the user's ``vegas_participation`` setting in this plugin's config + (the web UI's per-plugin override); + 2. ``vegas_participation`` in the plugin's manifest.json -- the way a + plugin declares its own default; + 3. derived from the legacy hooks, so a plugin written before this + method existed keeps the behaviour it had: get_vegas_display_mode() + returning ``VegasDisplayMode.STATIC`` pauses, otherwise + get_vegas_content_type() returning ``'none'`` excludes, and + everything else scrolls. + + Declare a fixed participation in the manifest rather than overriding + this. Override it only when the answer depends on state -- pause only + while an alert is live, exclude while there is nothing to show. Vegas + applies the user's setting before calling an override, so an override + need not check it. + + Returns: + One of VEGAS_PARTICIPATION_VALUES. + + Example: + def get_vegas_participation(self): + return 'pause' if self._alert_is_live() else 'scroll' + """ + configured = configured_vegas_participation(self.plugin_id, self.config) + if configured is not None: + return configured + manifest_default = self._manifest_vegas_participation() + if manifest_default is not None: + return manifest_default + return legacy_vegas_participation(self) + + def _manifest_vegas_participation(self) -> Optional[str]: + """``vegas_participation`` from this plugin's manifest, if valid.""" + manifests = getattr(self.plugin_manager, 'plugin_manifests', None) + manifest = manifests.get(self.plugin_id) if isinstance(manifests, dict) else None + if not isinstance(manifest, dict) or manifest.get('vegas_participation') is None: + return None + raw = manifest['vegas_participation'] + value = vegas_participation_value(raw) + if value is None: + _vegas_warn_once( + ('manifest', self.plugin_id, repr(raw)), + "[%s] manifest vegas_participation %r is not one of %s; ignoring it", + self.plugin_id, raw, ', '.join(VEGAS_PARTICIPATION_VALUES)) + return value + def get_vegas_content_type(self) -> str: """ - Indicate the type of content this plugin provides for Vegas scroll. + Legacy: the type of content this plugin provides for Vegas scroll. - Override this to specify how Vegas mode should treat this plugin's content. + Superseded by get_vegas_participation(). Vegas reads it only to derive + a participation when neither the user nor the manifest declares one, + and only ``'none'`` matters there: it excludes the plugin (unless + get_vegas_display_mode() says STATIC). Every other value scrolls. Returns: 'multi' - Plugin has multiple scrollable items (sports, odds, news) 'static' - Plugin is a static block (clock, weather, music) 'none' - Plugin should not appear in Vegas scroll mode - - Example: - def get_vegas_content_type(self): - return 'multi' # We have multiple games to scroll """ return 'static' def get_vegas_display_mode(self) -> VegasDisplayMode: """ - Get the display mode for Vegas scroll integration. + Legacy: the display mode for Vegas scroll integration. - This method determines how the plugin's content behaves within Vegas mode: - - SCROLL: Content scrolls continuously (multi-item plugins) - - FIXED_SEGMENT: Fixed block that scrolls by (clock, weather) - - STATIC: Pause scroll to display (alerts, detailed views) + Superseded by get_vegas_participation(). Vegas reads it only to derive + a participation when neither the user nor the manifest declares one, + and only STATIC matters there: it pauses the scroll for the plugin's + turn. SCROLL and FIXED_SEGMENT both scroll -- Vegas has never told them + apart, and the distinction is deprecated (removed in 3.9.0). - Override to change default behavior. By default, reads from config - or maps legacy get_vegas_content_type() for backward compatibility. + Reads the plugin's ``vegas_mode`` config value, else maps + get_vegas_content_type() ('multi' to SCROLL, anything else to + FIXED_SEGMENT). Returns: VegasDisplayMode enum value - - Example: - def get_vegas_display_mode(self): - return VegasDisplayMode.SCROLL """ # Check for explicit config setting first config_mode = self.config.get("vegas_mode") @@ -888,13 +1096,16 @@ class BasePlugin(ABC): return VegasDisplayMode.SCROLL return VegasDisplayMode.FIXED_SEGMENT + @deprecated("3.9.0", + "nothing reads it -- declare vegas_participation in the manifest instead") def get_supported_vegas_modes(self) -> List[VegasDisplayMode]: """ - Return list of Vegas display modes this plugin supports. + Deprecated: the Vegas display modes this plugin supports. - Not currently consulted by core: neither Vegas mode nor the web UI - calls it. It is kept, and plugins override it, as the declared set of - modes a future mode picker would offer. + Never consulted by core -- neither Vegas mode nor the web UI calls it + -- and removed in LEDMatrix 3.9.0. A plugin's own override keeps + working for the plugin itself; calling this base implementation logs a + deprecation warning. By default: - 'multi' content type plugins support SCROLL and FIXED_SEGMENT @@ -903,11 +1114,6 @@ class BasePlugin(ABC): Returns: List of VegasDisplayMode values this plugin can use - - Example: - def get_supported_vegas_modes(self): - # This plugin only makes sense as a scrolling ticker - return [VegasDisplayMode.SCROLL] """ content_type = self.get_vegas_content_type() @@ -918,30 +1124,21 @@ class BasePlugin(ABC): else: # 'static' return [VegasDisplayMode.FIXED_SEGMENT, VegasDisplayMode.STATIC] + @deprecated("3.9.0", + "nothing reads it -- Vegas sizes a card from vegas_width_pct " + "(see get_vegas_render_width())") def get_vegas_segment_width(self) -> Optional[int]: """ - Get the preferred width for this plugin in Vegas FIXED_SEGMENT mode. + Deprecated: the number of panels this plugin wanted as a FIXED_SEGMENT. - Not currently consulted by core: Vegas mode sizes a card from the - ``vegas_width_pct`` / ``vegas_scroll.render_width_pct`` settings - (see get_vegas_render_width()). Kept because plugins override it. - - Returns the number of panels this plugin should occupy when displayed - as a fixed segment. The actual pixel width is calculated as: - width = panels * single_panel_width - - Where single_panel_width comes from display.hardware.cols in config. - - Override to provide dynamic sizing based on content. - Returns None to use the default (1 panel). + Never consulted by core: Vegas sizes a card from the + ``vegas_width_pct`` / ``vegas_scroll.render_width_pct`` settings (see + get_vegas_render_width()). Removed, with the ``vegas_panel_count`` + setting it reads, in LEDMatrix 3.9.0. Returns: - Number of panels, or None for default (1 panel) - - Example: - def get_vegas_segment_width(self): - # Clock needs 2 panels to show time clearly - return 2 + ``vegas_panel_count`` from config when it is a positive integer, + else None """ raw_value = self.config.get("vegas_panel_count", None) if raw_value is None: diff --git a/src/plugin_system/plugin_manager.py b/src/plugin_system/plugin_manager.py index cd4372a2..3d73f3c3 100644 --- a/src/plugin_system/plugin_manager.py +++ b/src/plugin_system/plugin_manager.py @@ -569,7 +569,8 @@ class PluginManager: #: prefix rule would silently stop validating it. #: #: Read by: ``vegas_mode/plugin_adapter.py`` (``vegas_width_pct``, - #: ``vegas_overflow``) and ``base_plugin.py`` (``vegas_max_width_screens``). + #: ``vegas_overflow``) and ``base_plugin.py`` (``vegas_max_width_screens``, + #: ``vegas_participation``). #: #: The list itself lives with the other core-owned per-plugin properties in #: ``schema_manager.CORE_PLUGIN_PROPERTIES``, which the web save path also diff --git a/src/plugin_system/schema_manager.py b/src/plugin_system/schema_manager.py index b146d019..40e45d4b 100644 --- a/src/plugin_system/schema_manager.py +++ b/src/plugin_system/schema_manager.py @@ -127,8 +127,9 @@ CORE_PLUGIN_PROPERTIES: Dict[str, Dict[str, Any]] = { "description": "Enable live priority takeover when plugin has live content" }, # Vegas tuning read by vegas_mode/plugin_adapter.py and base_plugin.py. - # Left untyped: the adapter validates them itself and ignores a bad - # value with a log line, so a stored one must never block a save. + # These three are left untyped: the adapter validates them itself and + # ignores a bad value with a log line, so a stored one must never block a + # save. "vegas_width_pct": { "description": "Vegas mode: width of this plugin's card, as a percentage of the panel" }, @@ -138,6 +139,22 @@ CORE_PLUGIN_PROPERTIES: Dict[str, Dict[str, Any]] = { "vegas_max_width_screens": { "description": "Vegas mode: widest this plugin's card may be, in screens" }, + # Read by resolve_vegas_participation / BasePlugin.get_vegas_participation. + # An enum with no default: a default would be written into every plugin's + # config and override the participation the plugin itself declares. + "vegas_participation": { + "type": "string", + "enum": ["scroll", "pause", "exclude"], + "title": "Vegas participation", + "description": ( + "Vegas mode: how this plugin takes part in the scrolling ticker. " + "'scroll' = its content scrolls by with everything else; " + "'pause' = the ticker stops for this plugin's turn and shows it " + "full screen for its display duration; " + "'exclude' = leave it out of Vegas mode. " + "Leave unset to use the plugin's own default." + ), + }, } #: The keys of CORE_PLUGIN_PROPERTIES that are Vegas tuning rather than plugin @@ -145,6 +162,7 @@ CORE_PLUGIN_PROPERTIES: Dict[str, Dict[str, Any]] = { #: PluginManager.CORE_OWNED_CONFIG_KEYS). CORE_VEGAS_TUNING_KEYS = frozenset({ 'vegas_width_pct', 'vegas_overflow', 'vegas_max_width_screens', + 'vegas_participation', }) diff --git a/src/vegas_mode/coordinator.py b/src/vegas_mode/coordinator.py index 3789ec03..ccd70319 100644 --- a/src/vegas_mode/coordinator.py +++ b/src/vegas_mode/coordinator.py @@ -5,10 +5,12 @@ Main orchestrator for Vegas-style continuous scroll mode. Coordinates between StreamManager, RenderPipeline, and the display system to provide smooth continuous scrolling of all enabled plugin content. -Supports three display modes per plugin: -- SCROLL: Content scrolls continuously within the stream -- FIXED_SEGMENT: Fixed block that scrolls by with other content -- STATIC: Scroll pauses, plugin displays for its duration, then resumes +Each plugin takes part in one of three ways (its Vegas participation, see +BasePlugin.get_vegas_participation): +- 'scroll': its content scrolls by within the stream +- 'pause': the scroll pauses, the plugin displays for its duration, then + the scroll resumes +- 'exclude': left out """ import logging diff --git a/src/vegas_mode/plugin_adapter.py b/src/vegas_mode/plugin_adapter.py index fc531379..8dd62187 100644 --- a/src/vegas_mode/plugin_adapter.py +++ b/src/vegas_mode/plugin_adapter.py @@ -1369,29 +1369,6 @@ class PluginAdapter: logger.debug("[%s] Cleared plugin scroll cache", plugin_id) return cleared - def get_content_type(self, plugin: 'BasePlugin', plugin_id: str) -> str: - """ - Get the type of content a plugin provides. - - Args: - plugin: Plugin instance - plugin_id: Plugin identifier - - Returns: - 'multi' for multiple items, 'static' for single frame, 'none' for excluded - """ - if hasattr(plugin, 'get_vegas_content_type'): - try: - return plugin.get_vegas_content_type() - except (AttributeError, TypeError, ValueError): - logger.exception( - "Error calling get_vegas_content_type() on %s", - plugin_id - ) - - # Default to static for plugins without explicit type - return 'static' - def cleanup(self) -> None: """Clean up resources.""" with self._cache_lock: diff --git a/src/vegas_mode/stream_manager.py b/src/vegas_mode/stream_manager.py index 1c4b2d41..039aae80 100644 --- a/src/vegas_mode/stream_manager.py +++ b/src/vegas_mode/stream_manager.py @@ -5,23 +5,25 @@ Manages plugin content streaming with look-ahead buffering. Maintains a queue of plugin content that's ready to be rendered, prefetching 1-2 plugins ahead of the current scroll position. -Supports three display modes: -- SCROLL: Continuous scrolling content -- FIXED_SEGMENT: Fixed block that scrolls by -- STATIC: Pause scroll to display (marked for coordinator handling) +Each plugin takes part in one of three ways (its Vegas participation, see +BasePlugin.get_vegas_participation): +- 'scroll': its content joins the strip +- 'pause': the scroll pauses for its turn (a STATIC segment, marked for the + coordinator) +- 'exclude': left out of the rotation """ import logging import threading import time -from typing import Optional, List, Dict, Any, Deque, Tuple, TYPE_CHECKING, cast +from typing import Optional, List, Dict, Any, Deque, Tuple, TYPE_CHECKING from collections import deque from dataclasses import dataclass, field from PIL import Image from src.vegas_mode.config import VegasModeConfig from src.vegas_mode.plugin_adapter import PluginAdapter -from src.plugin_system.base_plugin import VegasDisplayMode +from src.plugin_system.base_plugin import VegasDisplayMode, resolve_vegas_participation if TYPE_CHECKING: from src.plugin_system.plugin_manager import PluginManager @@ -34,11 +36,12 @@ class ContentSegment: """One plugin's content for a cycle. A STATIC segment carries no images: it marks where the coordinator pauses - the scroll to show the plugin full-screen. + the scroll to show a plugin whose participation is ``'pause'``. Every + other segment is SCROLL. """ plugin_id: str images: List[Image.Image] - display_mode: VegasDisplayMode = field(default=VegasDisplayMode.FIXED_SEGMENT) + display_mode: VegasDisplayMode = field(default=VegasDisplayMode.SCROLL) class StreamManager: @@ -335,25 +338,14 @@ class StreamManager: logger.debug("[%s] Vegas: skipped (not enabled)", plugin_id) continue - # Content type 'none' is left out, except for STATIC plugins, - # which pause the scroll rather than contributing to it. - content_type = self.plugin_adapter.get_content_type(plugin, plugin_id) - display_mode = VegasDisplayMode.FIXED_SEGMENT - try: - display_mode = plugin.get_vegas_display_mode() - except Exception: - # Plugin error should not abort refresh; use default mode - logger.exception( - "[%s] (%s) get_vegas_display_mode() failed, using default", - plugin_id, plugin.__class__.__name__ - ) - - included = (content_type != 'none' - or display_mode == VegasDisplayMode.STATIC) + # 'pause' plugins stay in the rotation: they pause the scroll + # for their turn rather than contributing to it. + participation = resolve_vegas_participation(plugin, plugin_id) + included = participation != 'exclude' logger.debug( - "[%s] Vegas: %s (content_type=%s, display_mode=%s)", + "[%s] Vegas: %s (participation=%s)", plugin_id, "included" if included else "excluded", - content_type, display_mode.value + participation ) if included: available_plugins.append(plugin_id) @@ -580,23 +572,13 @@ class StreamManager: logger.warning("[%s] Plugin not found in plugin_manager.plugins", plugin_id) return None - display_mode = VegasDisplayMode.FIXED_SEGMENT - try: - display_mode = plugin.get_vegas_display_mode() - except (AttributeError, TypeError) as e: - logger.debug( - "[%s] get_vegas_display_mode() not available: %s (using FIXED_SEGMENT)", - plugin_id, e - ) - - # For STATIC mode, we create a placeholder segment - # The actual content will be displayed by coordinator during pause - if display_mode == VegasDisplayMode.STATIC: - # Create minimal placeholder - coordinator handles actual display + # A 'pause' plugin gets a placeholder segment; the coordinator + # draws it with display() when the scroll reaches its turn. + if resolve_vegas_participation(plugin, plugin_id) == 'pause': segment = ContentSegment( plugin_id=plugin_id, images=[], # No images needed for static pause - display_mode=display_mode + display_mode=VegasDisplayMode.STATIC ) self.stats['segments_fetched'] += 1 logger.debug( @@ -605,7 +587,6 @@ class StreamManager: ) return segment - # Get content via adapter for SCROLL/FIXED_SEGMENT modes images = self.plugin_adapter.get_content(plugin, plugin_id) if not images: # The adapter already warns when every content path failed; @@ -619,13 +600,13 @@ class StreamManager: segment = ContentSegment( plugin_id=plugin_id, images=images, - display_mode=display_mode + display_mode=VegasDisplayMode.SCROLL ) self.stats['segments_fetched'] += 1 logger.debug( - "[%s] Segment: %d image(s), %dpx, mode=%s", - plugin_id, len(images), total_width, display_mode.value + "[%s] Segment: %d image(s), %dpx", + plugin_id, len(images), total_width ) return segment @@ -693,16 +674,16 @@ class StreamManager: return layout def is_static_plugin(self, plugin_id: str) -> bool: - """Whether a loaded plugin asks Vegas to pause for it (STATIC mode).""" + """Whether a loaded plugin asks Vegas to pause for it (participation 'pause'). + + Only 'pause' is acted on here. A plugin whose participation has turned + to 'exclude' since the rotation was built is still fetched this cycle, + as it always was; the next refresh drops it. + """ plugin = getattr(self.plugin_manager, 'plugins', {}).get(plugin_id) if plugin is None: return False - try: - return cast(bool, plugin.get_vegas_display_mode() == VegasDisplayMode.STATIC) - except Exception: - logger.debug("[%s] get_vegas_display_mode() failed; treating as not STATIC", - plugin_id, exc_info=True) - return False + return resolve_vegas_participation(plugin, plugin_id) == 'pause' def take_next_group( self, count: Optional[int] = None, offscreen_only: bool = False diff --git a/test/test_deprecation.py b/test/test_deprecation.py index 25116ab3..55e5a0ef 100644 --- a/test/test_deprecation.py +++ b/test/test_deprecation.py @@ -45,6 +45,19 @@ DEPRECATED = { "src.plugin_system.plugin_manager.PluginManager": ["get_enabled_plugins"], } +#: Deprecated with Vegas participation, for removal in 3.9.0: core never read +#: them (src.plugin_system.base_plugin.VEGAS_LEGACY_REMOVAL). +DEPRECATED_3_9 = { + "src.plugin_system.base_plugin.BasePlugin": [ + "get_supported_vegas_modes", "get_vegas_segment_width", + ], +} + +#: Every pinned marker: (class path, method) -> the release that removes it. +PINNED = {(path, name): removal + for removal, table in (("3.8.0", DEPRECATED), ("3.9.0", DEPRECATED_3_9)) + for path, names in table.items() for name in names} + def _cls(path): import importlib @@ -52,14 +65,14 @@ def _cls(path): return getattr(importlib.import_module(module), name) -@pytest.mark.parametrize("path", sorted(DEPRECATED)) +@pytest.mark.parametrize("path", sorted({path for path, _ in PINNED})) def test_exactly_these_methods_are_deprecated(path): cls = _cls(path) marked = sorted(name for name, value in vars(cls).items() if hasattr(value, "__deprecated__")) - assert marked == sorted(DEPRECATED[path]) + assert marked == sorted(name for owner, name in PINNED if owner == path) for name in marked: - assert "3.8.0" in getattr(cls, name).__deprecated__ + assert f"LEDMatrix {PINNED[(path, name)]}" in getattr(cls, name).__deprecated__ def _markers(): @@ -82,7 +95,7 @@ def _markers(): def test_markers_are_found(): - assert len(_markers()) == sum(len(v) for v in DEPRECATED.values()) + assert len(_markers()) == len(PINNED) def test_no_marker_names_a_release_already_shipped(): @@ -116,8 +129,7 @@ def usage_script(): def test_usage_script_lists_exactly_the_pinned_markers(usage_script): found = {(f"{m.module}.{m.owner}", m.method, m.removal) for m in usage_script.find_markers(REPO)} - assert found == {(path, name, "3.8.0") - for path, names in DEPRECATED.items() for name in names} + assert found == {(path, name, removal) for (path, name), removal in PINNED.items()} def test_usage_script_tells_uses_from_name_collisions(usage_script, tmp_path): diff --git a/test/test_vegas_participation.py b/test/test_vegas_participation.py new file mode 100644 index 00000000..a7a5d375 --- /dev/null +++ b/test/test_vegas_participation.py @@ -0,0 +1,461 @@ +"""Vegas participation: one declared 'scroll' | 'pause' | 'exclude' per plugin. + +Before it existed Vegas read two hooks and branched on only part of each: +get_vegas_content_type() == 'none' excluded a plugin (unless it was STATIC), +get_vegas_display_mode() == STATIC paused the scroll for it, and nothing +distinguished SCROLL from FIXED_SEGMENT. These tests pin the derivation from +those hooks to exactly what the old StreamManager did, so every existing +plugin keeps its behaviour, and cover the new ways to declare it. +""" + +import logging +import os +import warnings +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest +from PIL import Image + +os.environ.setdefault("EMULATOR", "true") + +from src import deprecation +from src.plugin_system import base_plugin +from src.plugin_system.base_plugin import ( + VEGAS_LEGACY_REMOVAL, VEGAS_PARTICIPATION_VALUES, BasePlugin, VegasDisplayMode, + legacy_vegas_participation, resolve_vegas_participation, +) +from src.vegas_mode.config import VegasModeConfig +from src.vegas_mode.stream_manager import StreamManager +from test._api_v3_test_helpers import ( # noqa: F401 - fixtures + api_v3_client, api_v3_module, +) + +_MISSING = object() + + +class _Raises: + """A hook that raises ``exc`` when called.""" + + def __init__(self, exc): + self.exc = exc + + +def _duck(display_mode=_MISSING, content_type=_MISSING, **attrs): + """A plugin that is not a BasePlugin: only the hooks it is given.""" + ns = SimpleNamespace(enabled=True, **attrs) + for name, value in (('get_vegas_display_mode', display_mode), + ('get_vegas_content_type', content_type)): + if value is _MISSING: + continue + if isinstance(value, _Raises): + def hook(exc=value.exc): + raise exc + else: + def hook(value=value): + return value + setattr(ns, name, hook) + return ns + + +class _Plugin(BasePlugin): + """A real BasePlugin; class attributes override the legacy hooks.""" + + content_type = None + display_mode = None + + def update(self): + pass + + def display(self, force_clear=False): + pass + + def get_vegas_content_type(self): + if self.content_type is None: + return super().get_vegas_content_type() + return self.content_type + + def get_vegas_display_mode(self): + if self.display_mode is None: + return super().get_vegas_display_mode() + return self.display_mode + + +def _plugin(config=None, manifest=None, cls=_Plugin, plugin_id='demo', **overrides): + manifests = {plugin_id: manifest} if manifest is not None else {} + plugin_manager = SimpleNamespace(plugin_manifests=manifests, plugins={}) + if overrides: + cls = type('Custom', (cls,), overrides) + return cls(plugin_id, dict(config or {}), MagicMock(), MagicMock(), plugin_manager) + + +@pytest.fixture(autouse=True) +def _quiet_once_sets(monkeypatch): + """Each test sees first-time warnings afresh.""" + monkeypatch.setattr(deprecation, '_warned', set()) + monkeypatch.setattr(base_plugin, '_vegas_warned', set()) + + +# --------------------------------------------------------------------------- +# What the StreamManager on origin/main (6047eb5e) decided, copied verbatim in +# substance, so the derivation is checked against the old code rather than +# against a restatement of the new one. +# --------------------------------------------------------------------------- + +def _old_included(plugin): + content_type = 'static' # PluginAdapter.get_content_type + if hasattr(plugin, 'get_vegas_content_type'): + try: + content_type = plugin.get_vegas_content_type() + except (AttributeError, TypeError, ValueError): + pass + display_mode = VegasDisplayMode.FIXED_SEGMENT # _refresh_plugin_list + try: + display_mode = plugin.get_vegas_display_mode() + except Exception: + pass + return content_type != 'none' or display_mode == VegasDisplayMode.STATIC + + +def _old_paused(plugin): # is_static_plugin + try: + return plugin.get_vegas_display_mode() == VegasDisplayMode.STATIC + except Exception: + return False + + +DISPLAY_MODES = [ + _MISSING, VegasDisplayMode.SCROLL, VegasDisplayMode.FIXED_SEGMENT, + VegasDisplayMode.STATIC, None, 'static', 'scroll', _Raises(AttributeError('x')), +] +CONTENT_TYPES = [ + _MISSING, 'multi', 'static', 'single', 'none', None, _Raises(ValueError('x')), +] + + +def _label(value): + if value is _MISSING: + return 'missing' + if isinstance(value, _Raises): + return f'raises-{type(value.exc).__name__}' + return repr(value) + + +class TestLegacyDerivation: + @pytest.mark.parametrize('display_mode', DISPLAY_MODES, ids=_label) + @pytest.mark.parametrize('content_type', CONTENT_TYPES, ids=_label) + def test_matches_what_the_old_stream_manager_did(self, display_mode, content_type): + plugin = _duck(display_mode, content_type) + participation = legacy_vegas_participation(plugin) + assert (participation != 'exclude') == _old_included(plugin) + assert (participation == 'pause') == _old_paused(plugin) + + @pytest.mark.parametrize('display_mode, content_type, expected', [ + (VegasDisplayMode.STATIC, 'multi', 'pause'), + (VegasDisplayMode.STATIC, 'none', 'pause'), # STATIC beats 'none' + (VegasDisplayMode.SCROLL, 'none', 'exclude'), + (VegasDisplayMode.FIXED_SEGMENT, 'none', 'exclude'), + (VegasDisplayMode.SCROLL, 'multi', 'scroll'), + (VegasDisplayMode.FIXED_SEGMENT, 'static', 'scroll'), # never told apart + (VegasDisplayMode.FIXED_SEGMENT, 'single', 'scroll'), + ('static', 'multi', 'scroll'), # only the enum member ever paused + ]) + def test_the_table(self, display_mode, content_type, expected): + assert legacy_vegas_participation(_duck(display_mode, content_type)) == expected + + @pytest.mark.parametrize('vegas_mode, content_type, expected', [ + (None, None, 'scroll'), # BasePlugin defaults: 'static' / FIXED + (None, 'multi', 'scroll'), + (None, 'none', 'exclude'), + ('scroll', None, 'scroll'), + ('fixed', 'multi', 'scroll'), + ('static', None, 'pause'), + ('static', 'none', 'pause'), + ('bogus', 'none', 'exclude'), # an invalid vegas_mode is ignored + ]) + def test_base_plugin_defaults(self, vegas_mode, content_type, expected): + config = {} if vegas_mode is None else {'vegas_mode': vegas_mode} + plugin = _plugin(config, content_type=content_type) + assert plugin.get_vegas_participation() == expected + assert resolve_vegas_participation(plugin) == expected + + def test_a_magicmock_plugin_scrolls(self): + # Test doubles all over the suite are MagicMocks; they scrolled before. + assert resolve_vegas_participation(MagicMock(), 'mock') == 'scroll' + + +class TestDeclaredParticipation: + @pytest.mark.parametrize('value', VEGAS_PARTICIPATION_VALUES) + def test_user_config_wins_over_manifest_and_hooks(self, value): + plugin = _plugin({'vegas_participation': value, 'vegas_mode': 'static'}, + manifest={'vegas_participation': 'exclude' if value != 'exclude' + else 'scroll'}, + content_type='none') + assert plugin.get_vegas_participation() == value + assert resolve_vegas_participation(plugin) == value + + @pytest.mark.parametrize('value', VEGAS_PARTICIPATION_VALUES) + def test_manifest_wins_over_hooks(self, value): + hooks_say = 'pause' if value != 'pause' else 'scroll' + plugin = _plugin({'vegas_mode': 'static'} if hooks_say == 'pause' else {}, + manifest={'vegas_participation': value}) + assert legacy_vegas_participation(plugin) == hooks_say + assert plugin.get_vegas_participation() == value + + def test_case_and_whitespace_are_ignored(self): + assert _plugin({'vegas_participation': ' Pause '}).get_vegas_participation() == 'pause' + + @pytest.mark.parametrize('unset', [None, '', ' ']) + def test_empty_config_value_is_no_setting(self, unset): + plugin = _plugin({'vegas_participation': unset, 'vegas_mode': 'static'}) + assert plugin.get_vegas_participation() == 'pause' + + def test_invalid_config_value_is_ignored_and_logged_once(self, caplog): + plugin = _plugin({'vegas_participation': 'fixed', 'vegas_mode': 'static'}) + with caplog.at_level(logging.WARNING): + for _ in range(3): + assert resolve_vegas_participation(plugin) == 'pause' + warned = [r for r in caplog.records if 'Invalid vegas_participation' in r.getMessage()] + assert len(warned) == 1 + + def test_invalid_manifest_value_is_ignored(self, caplog): + plugin = _plugin({}, manifest={'vegas_participation': 'sometimes'}, content_type='none') + with caplog.at_level(logging.WARNING): + assert plugin.get_vegas_participation() == 'exclude' + assert plugin.get_vegas_participation() == 'exclude' + assert sum('manifest vegas_participation' in r.getMessage() + for r in caplog.records) == 1 + + def test_a_plugin_manager_without_manifests_is_fine(self): + plugin = _Plugin('demo', {}, MagicMock(), MagicMock(), None) + assert plugin.get_vegas_participation() == 'scroll' + + def test_an_override_is_used(self): + plugin = _plugin(get_vegas_participation=lambda self: 'pause') + assert resolve_vegas_participation(plugin) == 'pause' + + def test_the_user_setting_beats_an_override(self): + plugin = _plugin({'vegas_participation': 'exclude'}, + get_vegas_participation=lambda self: 'pause') + assert resolve_vegas_participation(plugin) == 'exclude' + + def test_an_override_returning_junk_falls_back_to_the_hooks(self, caplog): + plugin = _plugin({'vegas_mode': 'static'}, + get_vegas_participation=lambda self: 'fixed') + with caplog.at_level(logging.WARNING): + assert resolve_vegas_participation(plugin) == 'pause' + + def test_an_override_that_raises_falls_back_to_the_hooks(self): + def boom(self): + raise RuntimeError('broken') + plugin = _plugin(content_type='none', get_vegas_participation=boom) + assert resolve_vegas_participation(plugin) == 'exclude' + + +# --------------------------------------------------------------------------- +# StreamManager decides through participation +# --------------------------------------------------------------------------- + +def _stream(plugins): + adapter = MagicMock() + adapter.get_content.return_value = [Image.new('RGB', (20, 8))] + return StreamManager(VegasModeConfig(), SimpleNamespace(plugins=plugins), adapter), adapter + + +class TestStreamManager: + def _plugins(self): + return { + 'scroller': _plugin(plugin_id='scroller', content_type='multi'), + 'pauser': _plugin({'vegas_mode': 'static'}, plugin_id='pauser'), + 'hidden': _plugin(plugin_id='hidden', content_type='none'), + 'declared': _plugin({'vegas_participation': 'pause'}, plugin_id='declared'), + 'opted_out': _plugin({'vegas_participation': 'exclude'}, plugin_id='opted_out', + content_type='multi'), + } + + def test_refresh_keeps_scroll_and_pause_and_drops_exclude(self): + stream, _ = _stream(self._plugins()) + stream._refresh_plugin_list() + assert sorted(stream._ordered_plugins) == ['declared', 'pauser', 'scroller'] + + def test_only_pause_plugins_are_static(self): + stream, _ = _stream(self._plugins()) + assert {pid: stream.is_static_plugin(pid) for pid in self._plugins()} == { + 'scroller': False, 'pauser': True, 'hidden': False, + 'declared': True, 'opted_out': False} + assert stream.is_static_plugin('not-loaded') is False + + def test_swap_mode_fetch_makes_a_placeholder_for_pause(self): + stream, adapter = _stream(self._plugins()) + pause = stream._fetch_plugin_content('declared') + assert pause.display_mode == VegasDisplayMode.STATIC and pause.images == [] + adapter.get_content.assert_not_called() + scroll = stream._fetch_plugin_content('scroller') + assert scroll.display_mode == VegasDisplayMode.SCROLL and len(scroll.images) == 1 + + def test_continuous_mode_group_leaves_pause_plugins_unfetched(self): + plugins = self._plugins() + stream, adapter = _stream(plugins) + group = dict(stream.take_next_group(count=10)) + assert group['declared'] == [] and group['pauser'] == [] + assert len(group['scroller']) == 1 + assert 'hidden' not in group and 'opted_out' not in group + fetched = {call.args[1] for call in adapter.get_content.call_args_list} + assert fetched == {'scroller'} + + def test_a_user_can_make_a_static_plugin_scroll(self): + plugin = _plugin({'vegas_mode': 'static', 'vegas_participation': 'scroll'}, + plugin_id='p') + stream, _ = _stream({'p': plugin}) + assert stream.is_static_plugin('p') is False + assert stream._fetch_plugin_content('p').display_mode == VegasDisplayMode.SCROLL + + @pytest.mark.parametrize('display_mode', DISPLAY_MODES, ids=_label) + @pytest.mark.parametrize('content_type', CONTENT_TYPES, ids=_label) + def test_decisions_match_the_old_stream_manager(self, display_mode, content_type): + plugin = _duck(display_mode, content_type) + stream, _ = _stream({'p': plugin}) + stream._refresh_plugin_list() + assert ('p' in stream._ordered_plugins) == _old_included(plugin) + assert stream.is_static_plugin('p') == _old_paused(plugin) + segment = stream._fetch_plugin_content('p') + assert (segment.display_mode == VegasDisplayMode.STATIC) == _old_paused(plugin) + + def test_a_display_mode_hook_that_raises_no_longer_drops_the_swap_segment(self): + # The one intended difference: swap mode's fetch let anything but + # AttributeError/TypeError from get_vegas_display_mode() abort the + # fetch, while every other decision point treated it as "not STATIC". + # All three now agree: the plugin scrolls. + stream, _ = _stream({'p': _duck(_Raises(RuntimeError('x')), 'multi')}) + assert stream._fetch_plugin_content('p').display_mode == VegasDisplayMode.SCROLL + + +# --------------------------------------------------------------------------- +# Deprecations (removal in 3.9.0) +# --------------------------------------------------------------------------- + +class TestDeprecations: + @pytest.mark.parametrize('name', ['get_supported_vegas_modes', 'get_vegas_segment_width']) + def test_marked_for_3_9_0(self, name): + marker = getattr(getattr(BasePlugin, name), '__deprecated__', '') + assert 'LEDMatrix 3.9.0' in marker + # The marker spells the version out; it must match the constant. + assert f'LEDMatrix {VEGAS_LEGACY_REMOVAL}' in marker + + def test_the_kept_hooks_are_not_deprecated(self): + for name in ('get_vegas_content', 'get_vegas_render_width', + 'get_vegas_priority_weight', 'get_vegas_participation', + 'get_vegas_content_type', 'get_vegas_display_mode'): + assert not hasattr(getattr(BasePlugin, name), '__deprecated__'), name + + def test_calling_them_still_works_and_warns_once(self, caplog): + plugin = _plugin({'vegas_panel_count': 2}, content_type='multi') + with warnings.catch_warnings(record=True) as caught, \ + caplog.at_level(logging.WARNING): + warnings.simplefilter('always') + assert plugin.get_supported_vegas_modes() == [ + VegasDisplayMode.SCROLL, VegasDisplayMode.FIXED_SEGMENT] + assert plugin.get_supported_vegas_modes() + assert plugin.get_vegas_segment_width() == 2 + messages = [str(w.message) for w in caught] + assert len(messages) == 2 + assert all('3.9.0' in m for m in messages) + + def test_vegas_panel_count_warns_once_per_plugin(self, caplog): + first = _plugin({'vegas_panel_count': 2}, plugin_id='a') + second = _plugin({'vegas_panel_count': 3}, plugin_id='b') + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter('always') + for _ in range(3): + resolve_vegas_participation(first, 'a') + resolve_vegas_participation(second, 'b') + messages = [str(w.message) for w in caught] + assert len(messages) == 2 + assert "plugin 'a'" in messages[0] and "plugin 'b'" in messages[1] + assert all('removed in LEDMatrix 3.9.0' in m for m in messages) + + def test_no_warning_without_the_key(self): + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter('always') + resolve_vegas_participation(_plugin(), 'demo') + assert caught == [] + + def test_warn_deprecated_is_once_per_key(self, caplog): + with warnings.catch_warnings(record=True) as caught, \ + caplog.at_level(logging.WARNING): + warnings.simplefilter('always') + assert deprecation.warn_deprecated('Thing', '9.9.9', 'use other') is True + assert deprecation.warn_deprecated('Thing', '9.9.9') is False + assert deprecation.warn_deprecated('Thing', '9.9.9', once_key='k2') is True + assert [str(w.message) for w in caught] == [ + 'Thing is deprecated and will be removed in LEDMatrix 9.9.9; use other', + 'Thing is deprecated and will be removed in LEDMatrix 9.9.9', + ] + assert caught[0].category is DeprecationWarning + + +# --------------------------------------------------------------------------- +# Config schema and web API +# --------------------------------------------------------------------------- + +class TestSchema: + def test_core_property_is_an_enum_without_a_default(self): + from src.plugin_system.schema_manager import ( + CORE_PLUGIN_PROPERTIES, CORE_VEGAS_TUNING_KEYS, plugin_config_defaults) + prop = CORE_PLUGIN_PROPERTIES['vegas_participation'] + assert prop['enum'] == list(VEGAS_PARTICIPATION_VALUES) + # A default would be written into every plugin's config and override + # what the plugin declares. + assert 'default' not in prop + assert 'vegas_participation' in CORE_VEGAS_TUNING_KEYS + defaults = plugin_config_defaults({'type': 'object', 'properties': {}}) + assert 'vegas_participation' not in defaults + + def test_validation_accepts_the_values_and_rejects_others(self): + from src.plugin_system.schema_manager import SchemaManager + manager = SchemaManager(plugins_dir='.') + schema = {'type': 'object', 'additionalProperties': False, + 'properties': {'city': {'type': 'string'}}} + for value in VEGAS_PARTICIPATION_VALUES: + ok, errors = manager.validate_config_against_schema( + {'vegas_participation': value}, schema, 'demo') + assert ok, errors + ok, _ = manager.validate_config_against_schema( + {'vegas_participation': 'fixed'}, schema, 'demo') + assert not ok + + def test_manifest_schema_declares_it(self): + import json + from pathlib import Path + schema = json.loads((Path(__file__).resolve().parent.parent / 'schema' + / 'manifest_schema.json').read_text(encoding='utf-8')) + assert schema['properties']['vegas_participation']['enum'] == list( + VEGAS_PARTICIPATION_VALUES) + + +class TestInstalledPluginsApi: + @pytest.fixture + def installed(self, api_v3_module, api_v3_client, tmp_path): + def _get(instance, config): + api = api_v3_module.api_v3 + info = {'id': 'demo', 'name': 'Demo', 'version': '1.0.0', 'loaded': True} + api.plugin_manager.plugins_dir = str(tmp_path) + api.plugin_manager.get_all_plugin_info = MagicMock(return_value=[info]) + api.plugin_manager.get_plugin = MagicMock(return_value=instance) + api.plugin_store_manager.get_registry_info = MagicMock(return_value=None) + api.config_manager.load_config = MagicMock(return_value={'demo': config}) + response = api_v3_client.get('/api/v3/plugins/installed') + assert response.status_code == 200 + return [p for p in response.get_json()['data']['plugins'] + if p['id'] == 'demo'][0] + return _get + + def test_a_loaded_plugin_reports_its_participation(self, installed): + plugin = _plugin({'vegas_mode': 'static'}) + assert installed(plugin, plugin.config)['vegas_participation'] == 'pause' + + def test_an_unloaded_plugin_reports_only_the_user_setting(self, installed): + assert installed(None, {'vegas_participation': 'exclude'})[ + 'vegas_participation'] == 'exclude' + assert installed(None, {})['vegas_participation'] is None + diff --git a/test/test_vegas_stream_log_volume.py b/test/test_vegas_stream_log_volume.py index 2f2e17c6..3a975362 100644 --- a/test/test_vegas_stream_log_volume.py +++ b/test/test_vegas_stream_log_volume.py @@ -31,7 +31,6 @@ class _Plugin: def _stream(plugins): adapter = MagicMock() - adapter.get_content_type.return_value = 'multi' adapter.get_content.return_value = [Image.new('RGB', (20, 8))] return StreamManager(VegasModeConfig(), SimpleNamespace(plugins=plugins), adapter) diff --git a/web_interface/blueprints/api_v3/plugins.py b/web_interface/blueprints/api_v3/plugins.py index 9f098d6a..9ca4546e 100644 --- a/web_interface/blueprints/api_v3/plugins.py +++ b/web_interface/blueprints/api_v3/plugins.py @@ -10,6 +10,9 @@ from web_interface.blueprints.api_v3 import ( jsonify, logger, os, request, subprocess, success_response, ) from src.common.path_safety import safe_path_component +from src.plugin_system.base_plugin import ( + configured_vegas_participation, resolve_vegas_participation, +) import web_interface.blueprints.api_v3 as _pkg # Read through the module rather than bound by value: tests patch these # as module attributes, and a value binding would not see the patch. @@ -129,6 +132,14 @@ def get_installed_plugins(): if 'vegas_mode' in plugin_config: vegas_mode = plugin_config['vegas_mode'] + # What Vegas actually does with the plugin: 'scroll', 'pause' or + # 'exclude'. The same resolution the ticker uses; without a loaded + # instance only the user's own setting is known. + if plugin_instance is not None: + vegas_participation = resolve_vegas_participation(plugin_instance, plugin_id) + else: + vegas_participation = configured_vegas_participation(plugin_id, plugin_config) + return { 'id': plugin_id, 'name': plugin_info.get('name', plugin_id), @@ -154,6 +165,7 @@ def get_installed_plugins(): 'web_ui_actions': plugin_info.get('web_ui_actions', []), 'vegas_mode': vegas_mode, 'vegas_content_type': vegas_content_type, + 'vegas_participation': vegas_participation, } from concurrent.futures import ThreadPoolExecutor diff --git a/web_interface/static/v3/js/widgets/plugin-order-list.js b/web_interface/static/v3/js/widgets/plugin-order-list.js index f4610b90..72323816 100644 --- a/web_interface/static/v3/js/widgets/plugin-order-list.js +++ b/web_interface/static/v3/js/widgets/plugin-order-list.js @@ -12,7 +12,7 @@ * orderInputId: 'vegas_plugin_order_value', // hidden input, JSON array of ids * excludedInputId: 'vegas_excluded_plugins_value', // optional: adds an * // include-checkbox per row; unchecked ids collect here (JSON array) - * showVegasModeBadge: true // optional: Scroll/Fixed/Static badge + * showVegasModeBadge: true // optional: Scroll/Pause/Excluded badge * }); * * The container re-renders from /api/v3/plugins/installed each init; the @@ -21,10 +21,14 @@ (function() { 'use strict'; + // Keyed by Vegas participation ('scroll' | 'pause' | 'exclude'); 'fixed' + // and 'static' are the legacy vegas_mode values, for an older API. const MODE_LABELS = new Map([ - ['scroll', { label: 'Scroll', icon: 'fa-scroll', color: 'text-blue-600' }], - ['fixed', { label: 'Fixed', icon: 'fa-square', color: 'text-green-600' }], - ['static', { label: 'Static', icon: 'fa-pause', color: 'text-orange-600' }] + ['scroll', { label: 'Scroll', icon: 'fa-scroll', color: 'text-blue-600' }], + ['pause', { label: 'Pause', icon: 'fa-pause', color: 'text-orange-600' }], + ['exclude', { label: 'Excluded', icon: 'fa-ban', color: 'text-gray-500' }], + ['fixed', { label: 'Scroll', icon: 'fa-scroll', color: 'text-blue-600' }], + ['static', { label: 'Pause', icon: 'fa-pause', color: 'text-orange-600' }] ]); function init(options) { @@ -165,11 +169,11 @@ } if (options.showVegasModeBadge) { - const vegasMode = plugin.vegas_mode || plugin.vegas_content_type || 'fixed'; - const modeInfo = MODE_LABELS.get(vegasMode) || MODE_LABELS.get('fixed'); + const vegasMode = plugin.vegas_participation || plugin.vegas_mode || 'scroll'; + const modeInfo = MODE_LABELS.get(vegasMode) || MODE_LABELS.get('scroll'); const badge = document.createElement('span'); badge.className = `text-xs ${modeInfo.color} ml-2`; - badge.title = `Vegas display mode: ${modeInfo.label}`; + badge.title = `Vegas participation: ${modeInfo.label}`; const badgeIcon = document.createElement('i'); badgeIcon.className = `fas ${modeInfo.icon} mr-1`; badge.appendChild(badgeIcon);