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 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-09-30 09:30:37 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent b2df0fda1b
commit 1c928b2033
19 changed files with 1094 additions and 244 deletions
+40
View File
@@ -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`, `POST /api/v3/auth/password`, `POST /api/v3/auth/disable`,
`GET|POST /api/v3/auth/tokens`, `DELETE /api/v3/auth/tokens/<id>`. `GET|POST /api/v3/auth/tokens`, `DELETE /api/v3/auth/tokens/<id>`.
### 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 ### Fixes
- On-demand no longer restarts a running display. `POST - On-demand no longer restarts a running display. `POST
+70 -68
View File
@@ -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. 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):** Each plugin has a *Vegas participation*:
- Content scrolls continuously left
- Smooth, fluid motion
- Best for news-ticker style displays
**FIXED_SEGMENT (Fixed-Width Block):** **`scroll` (the default):**
- Plugin gets fixed-width block on display - The plugin's content scrolls by with everyone else's
- Content doesn't scroll out of its segment - Best for news-ticker style content: scores, headlines, prices, the time
- Multiple plugins can share the display simultaneously
**STATIC (Scroll Pauses):** **`pause`:**
- Scrolling pauses when content is fully visible - The scroll stops when the plugin's turn comes round
- Displays for specified duration, then resumes scrolling - The plugin draws the whole panel for its display duration, then the
- Best for content that needs to be fully read 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 ### Configuration
@@ -164,8 +169,7 @@ Override Vegas behavior for specific plugins:
{ {
"my_plugin": { "my_plugin": {
"enabled": true, "enabled": true,
"vegas_mode": "scroll", "vegas_participation": "pause",
"vegas_panel_count": 2,
"display_duration": 10 "display_duration": 10
} }
} }
@@ -175,19 +179,30 @@ Override Vegas behavior for specific plugins:
| Setting | Values | Description | | Setting | Values | Description |
|---------|--------|-------------| |---------|--------|-------------|
| `vegas_mode` | `scroll`, `fixed`, `static` | Display mode for this plugin | | `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 |
| `vegas_panel_count` | any positive integer | Width in panels (1 panel = display width) | | `display_duration` | seconds | How long a `pause` plugin holds the screen |
| `display_duration` | seconds | Pause duration for STATIC mode | | `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 These are core-owned settings (see
their config section to control how oversized content is handled (see [PLUGIN_CONFIG_CORE_PROPERTIES.md](PLUGIN_CONFIG_CORE_PROPERTIES.md)): every
`PluginManager` in `src/plugin_system/plugin_manager.py`). 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) ### Plugin Integration (Developer Guide)
All of these have defaults in All of these have defaults in
[`BasePlugin`](../src/plugin_system/base_plugin.py); override only what you [`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:** **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 (`PluginAdapter.get_content()` in
[`src/vegas_mode/plugin_adapter.py`](../src/vegas_mode/plugin_adapter.py)). [`src/vegas_mode/plugin_adapter.py`](../src/vegas_mode/plugin_adapter.py)).
**2. Specify Content Type:** **2. Declare how the plugin takes part:**
```python Most plugins need nothing: the default is `scroll`. A plugin that should
def get_vegas_content_type(self): pause the scroll, or stay out of Vegas, says so in `manifest.json`:
# 'multi' | 'static' | 'none' -- default is 'static'
return 'multi' ```json
{
"vegas_participation": "pause"
}
``` ```
`'none'` excludes the plugin from Vegas mode. The user's own `vegas_participation` setting overrides the manifest. When
the answer depends on state, override the method instead:
**3. Optionally Specify Display Mode:**
These return `VegasDisplayMode` members, not strings:
```python ```python
from src.plugin_system.base_plugin import VegasDisplayMode def get_vegas_participation(self):
# 'scroll' | 'pause' | 'exclude'
def get_vegas_display_mode(self): return 'pause' if self._alert_is_live() else 'scroll'
return VegasDisplayMode.SCROLL
def get_supported_vegas_modes(self):
return [VegasDisplayMode.SCROLL, VegasDisplayMode.STATIC]
``` ```
`VegasDisplayMode` has `SCROLL` (`"scroll"`), `FIXED_SEGMENT` (`"fixed"`) and A plugin written for an older core that declares nothing keeps its
`STATIC` (`"static"`). The default `get_vegas_display_mode()` uses the behaviour: `get_vegas_display_mode()` returning `VegasDisplayMode.STATIC`
plugin's `vegas_mode` config value if set, otherwise maps the content type pauses, `get_vegas_content_type()` returning `'none'` excludes, and
(`multi` to `SCROLL`, anything else to `FIXED_SEGMENT`). 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 ### Content Rendering Guidelines
**Image Dimensions:** **Image Dimensions:**
- **Height:** Must match display height (typically 32 pixels) - **Height:** Must match display height (typically 32 pixels)
- **Width:** Varies by mode: - **Width:** Any width for `scroll` (recommended 64-512 pixels);
- SCROLL: Any width (recommended 64-512 pixels) `get_vegas_render_width()` is the width Vegas would like, and it narrows
- FIXED_SEGMENT: `panel_count * display_width` `display_manager` to match while it asks. A `pause` plugin draws the
- STATIC: Any width, optimized for readability whole panel in `display()`.
**Color Mode:** **Color Mode:**
- Use RGB color mode - Use RGB color mode
@@ -289,17 +302,10 @@ class WeatherPlugin(BasePlugin):
def get_vegas_content(self): def get_vegas_content(self):
"""Return cached Vegas image""" """Return cached Vegas image"""
return self.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 ### System Architecture
Vegas mode consists of four core components working together to provide smooth 125 FPS continuous scrolling: 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:** **Responsibilities:**
- Convert plugin content to scrollable images - 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 - Manage fallback for plugins without Vegas support
- Cache plugin content for performance - 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 - Calls `get_vegas_content()` if available
- Falls back to `display()` method if not - Falls back to `display()` method if not
2. **Handle display mode:** 2. **Participation** is decided by the StreamManager, not here
- SCROLL: Returns image as-is for continuous scrolling (`resolve_vegas_participation()` in
- FIXED_SEGMENT: Creates fixed-width block (panel_count * display_width) [`base_plugin.py`](../src/plugin_system/base_plugin.py)): `exclude`
- STATIC: Marks content for pause-when-visible behavior plugins never reach the adapter, and `pause` plugins are not fetched.
3. **Content type handling:**
- `multi`: Multiple segments (list of images)
- `static`: Single static image
- `none`: Skip this plugin in current cycle
**Fallback Behavior:** **Fallback Behavior:**
- If plugin doesn't implement Vegas methods: - If plugin doesn't implement Vegas methods:
- Calls plugin's `display()` method - Calls plugin's `display()` method
- Captures rendered display as static image - 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 - Ensures all plugins work in Vegas mode without explicit support
#### 4. RenderPipeline #### 4. RenderPipeline
@@ -508,7 +510,7 @@ All components use thread-safe patterns:
If a plugin doesn't implement Vegas methods: If a plugin doesn't implement Vegas methods:
- System calls the plugin's `display()` method - System calls the plugin's `display()` method
- Captures the rendered display as a static image - 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. This ensures all plugins work in Vegas mode, even without explicit support.
+23 -10
View File
@@ -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)). 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 - 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) - 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 `<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.
@@ -46,11 +46,17 @@ Counted per plugin: a *call* is `<receiver>.method` on an object named like the
| `FontManager.add_font` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 | | `FontManager.add_font` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
| `FontManager.remove_font` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 | | `FontManager.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 | | `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 | | `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 ## 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 | 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_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,` | | `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 ## Sources scanned
| Source | Group | Python files | Hits | | Source | Group | Python files | Hits |
|---|---|---|---| |---|---|---|---|
| core | core | 158 | 20 | | core | core | 164 | 20 |
| core tests | core-tests | 316 | 14 | | core tests | core-tests | 323 | 17 |
| 7-segment-clock | monorepo | 3 | 0 | | 7-segment-clock | monorepo | 3 | 0 |
| afl-scoreboard | monorepo | 34 | 0 | | afl-scoreboard | monorepo | 34 | 0 |
| baseball-scoreboard | monorepo | 60 | 0 | | baseball-scoreboard | monorepo | 60 | 0 |
| basketball-scoreboard | monorepo | 48 | 0 | | basketball-scoreboard | monorepo | 48 | 0 |
| birdnet-go | monorepo | 2 | 0 | | birdnet-go | monorepo | 2 | 0 |
| blackjack | monorepo | 7 | 0 | | blackjack | monorepo | 7 | 2 |
| calendar | monorepo | 5 | 0 | | calendar | monorepo | 5 | 1 |
| 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 |
@@ -130,7 +143,7 @@ File paths are relative to the plugin's directory (core: the repo root).
| nrl-scoreboard | monorepo | 29 | 0 | | nrl-scoreboard | monorepo | 29 | 0 |
| odds-ticker | monorepo | 9 | 0 | | odds-ticker | monorepo | 9 | 0 |
| of-the-day | monorepo | 14 | 0 | | of-the-day | monorepo | 14 | 0 |
| olympics | monorepo | 16 | 0 | | olympics | monorepo | 16 | 1 |
| on-air | monorepo | 2 | 0 | | on-air | monorepo | 2 | 0 |
| pomodoro-timer | monorepo | 3 | 0 | | pomodoro-timer | monorepo | 3 | 0 |
| soccer-scoreboard | monorepo | 46 | 0 | | soccer-scoreboard | monorepo | 46 | 0 |
+93 -13
View File
@@ -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 these hooks. See [ADVANCED_FEATURES.md](ADVANCED_FEATURES.md) for the user
side of Vegas mode. 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]` #### `get_vegas_content() -> Optional[PIL.Image | List[PIL.Image] | None]`
Return content to inject into the scroll. Multi-item plugins (sports, 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. independently. Static plugins (clock, weather) can return a single image.
Returning `None` falls back to capturing whatever `display()` produces. 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 The width Vegas wants this plugin's content to occupy, from the plugin's
plugin. Default `'static'`. `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`. Superseded by participation, and still read to derive it when neither the
Read from `config["vegas_mode"]` or override directly. 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 `SCROLL` and `FIXED_SEGMENT` (and `vegas_mode` `"scroll"` and `"fixed"`)
the mode selector for this plugin. 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 Never read by core, and removed in 3.9.0: calling the `BasePlugin`
occupies in the scroll (pixel width = panels × `single_panel_width`, implementation logs a deprecation warning. A plugin's own override keeps
from `display.hardware.cols`). `None` uses the default of 1 panel. 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 > The full source for `BasePlugin` lives in
> `src/plugin_system/base_plugin.py`. If a method here disagrees with the > `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 | | `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` | | `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 |
+14
View File
@@ -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 - Read by `src/vegas_mode/plugin_adapter.py` and `BasePlugin`, which
validate the values themselves and ignore a bad one with a log line 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 `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 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`). dropped on the next save (see `RETIRED_PLUGIN_KEYS` in `schema_manager.py`).
+9 -1
View File
@@ -511,13 +511,21 @@ List all installed plugins with their status and metadata.
"branch": "main", "branch": "main",
"web_ui_actions": [], "web_ui_actions": [],
"vegas_mode": null, "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 Plugin Configuration
**GET** `/api/v3/plugins/config?plugin_id=<plugin_id>` **GET** `/api/v3/plugins/config?plugin_id=<plugin_id>`
+5
View File
@@ -149,6 +149,11 @@
}, },
"description": "Array of display mode names this plugin provides" "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": { "api_requirements": {
"type": "array", "type": "array",
"items": { "items": {
+24
View File
@@ -53,3 +53,27 @@ def deprecated(removal: str, alternative: Optional[str] = None) -> Callable[[F],
return wrapper # type: ignore[return-value] return wrapper # type: ignore[return-value]
return decorate 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
+255 -58
View File
@@ -13,6 +13,7 @@ from enum import Enum
from typing import Dict, Any, Optional, List from typing import Dict, Any, Optional, List
import os import os
import sys import sys
from src.deprecation import deprecated, warn_deprecated
from src.logging_config import get_logger from src.logging_config import get_logger
@@ -65,27 +66,178 @@ def _fallback_font_manager() -> Any:
class VegasDisplayMode(Enum): 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. - STATIC: the scroll pauses for the plugin's turn and its display() draws
Best for multi-item plugins like sports scores, odds tickers, news feeds. it full screen -- participation ``'pause'``.
Plugin provides multiple frames via get_vegas_content(). - SCROLL and FIXED_SEGMENT: the plugin's content joins the scroll --
participation ``'scroll'``. Vegas has never told the two apart: a card's
- FIXED_SEGMENT: Content is a fixed-width block that scrolls BY with width comes from get_vegas_content() and ``vegas_width_pct``, not from
the rest of the content. Best for static info like clock, weather. the mode. The distinction is deprecated and goes away in LEDMatrix 3.9.0.
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.
""" """
SCROLL = "scroll" SCROLL = "scroll"
FIXED_SEGMENT = "fixed" FIXED_SEGMENT = "fixed"
STATIC = "static" 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): class BasePlugin(ABC):
""" """
Base class that all plugins must inherit from. Base class that all plugins must inherit from.
@@ -834,41 +986,97 @@ class BasePlugin(ABC):
""" """
return None 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: 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: Returns:
'multi' - Plugin has multiple scrollable items (sports, odds, news) 'multi' - Plugin has multiple scrollable items (sports, odds, news)
'static' - Plugin is a static block (clock, weather, music) 'static' - Plugin is a static block (clock, weather, music)
'none' - Plugin should not appear in Vegas scroll mode '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' return 'static'
def get_vegas_display_mode(self) -> VegasDisplayMode: 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: Superseded by get_vegas_participation(). Vegas reads it only to derive
- SCROLL: Content scrolls continuously (multi-item plugins) a participation when neither the user nor the manifest declares one,
- FIXED_SEGMENT: Fixed block that scrolls by (clock, weather) and only STATIC matters there: it pauses the scroll for the plugin's
- STATIC: Pause scroll to display (alerts, detailed views) 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 Reads the plugin's ``vegas_mode`` config value, else maps
or maps legacy get_vegas_content_type() for backward compatibility. get_vegas_content_type() ('multi' to SCROLL, anything else to
FIXED_SEGMENT).
Returns: Returns:
VegasDisplayMode enum value VegasDisplayMode enum value
Example:
def get_vegas_display_mode(self):
return VegasDisplayMode.SCROLL
""" """
# Check for explicit config setting first # Check for explicit config setting first
config_mode = self.config.get("vegas_mode") config_mode = self.config.get("vegas_mode")
@@ -888,13 +1096,16 @@ class BasePlugin(ABC):
return VegasDisplayMode.SCROLL return VegasDisplayMode.SCROLL
return VegasDisplayMode.FIXED_SEGMENT 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]: 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 Never consulted by core -- neither Vegas mode nor the web UI calls it
calls it. It is kept, and plugins override it, as the declared set of -- and removed in LEDMatrix 3.9.0. A plugin's own override keeps
modes a future mode picker would offer. working for the plugin itself; calling this base implementation logs a
deprecation warning.
By default: By default:
- 'multi' content type plugins support SCROLL and FIXED_SEGMENT - 'multi' content type plugins support SCROLL and FIXED_SEGMENT
@@ -903,11 +1114,6 @@ class BasePlugin(ABC):
Returns: Returns:
List of VegasDisplayMode values this plugin can use 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() content_type = self.get_vegas_content_type()
@@ -918,30 +1124,21 @@ class BasePlugin(ABC):
else: # 'static' else: # 'static'
return [VegasDisplayMode.FIXED_SEGMENT, VegasDisplayMode.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]: 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 Never consulted by core: Vegas sizes a card from the
``vegas_width_pct`` / ``vegas_scroll.render_width_pct`` settings ``vegas_width_pct`` / ``vegas_scroll.render_width_pct`` settings (see
(see get_vegas_render_width()). Kept because plugins override it. get_vegas_render_width()). Removed, with the ``vegas_panel_count``
setting it reads, in LEDMatrix 3.9.0.
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).
Returns: Returns:
Number of panels, or None for default (1 panel) ``vegas_panel_count`` from config when it is a positive integer,
else None
Example:
def get_vegas_segment_width(self):
# Clock needs 2 panels to show time clearly
return 2
""" """
raw_value = self.config.get("vegas_panel_count", None) raw_value = self.config.get("vegas_panel_count", None)
if raw_value is None: if raw_value is None:
+2 -1
View File
@@ -569,7 +569,8 @@ class PluginManager:
#: prefix rule would silently stop validating it. #: prefix rule would silently stop validating it.
#: #:
#: Read by: ``vegas_mode/plugin_adapter.py`` (``vegas_width_pct``, #: 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 #: The list itself lives with the other core-owned per-plugin properties in
#: ``schema_manager.CORE_PLUGIN_PROPERTIES``, which the web save path also #: ``schema_manager.CORE_PLUGIN_PROPERTIES``, which the web save path also
+20 -2
View File
@@ -127,8 +127,9 @@ CORE_PLUGIN_PROPERTIES: Dict[str, Dict[str, Any]] = {
"description": "Enable live priority takeover when plugin has live content" "description": "Enable live priority takeover when plugin has live content"
}, },
# Vegas tuning read by vegas_mode/plugin_adapter.py and base_plugin.py. # Vegas tuning read by vegas_mode/plugin_adapter.py and base_plugin.py.
# Left untyped: the adapter validates them itself and ignores a bad # These three are left untyped: the adapter validates them itself and
# value with a log line, so a stored one must never block a save. # ignores a bad value with a log line, so a stored one must never block a
# save.
"vegas_width_pct": { "vegas_width_pct": {
"description": "Vegas mode: width of this plugin's card, as a percentage of the panel" "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": { "vegas_max_width_screens": {
"description": "Vegas mode: widest this plugin's card may be, in 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 #: 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). #: PluginManager.CORE_OWNED_CONFIG_KEYS).
CORE_VEGAS_TUNING_KEYS = frozenset({ CORE_VEGAS_TUNING_KEYS = frozenset({
'vegas_width_pct', 'vegas_overflow', 'vegas_max_width_screens', 'vegas_width_pct', 'vegas_overflow', 'vegas_max_width_screens',
'vegas_participation',
}) })
+6 -4
View File
@@ -5,10 +5,12 @@ Main orchestrator for Vegas-style continuous scroll mode. Coordinates between
StreamManager, RenderPipeline, and the display system to provide smooth StreamManager, RenderPipeline, and the display system to provide smooth
continuous scrolling of all enabled plugin content. continuous scrolling of all enabled plugin content.
Supports three display modes per plugin: Each plugin takes part in one of three ways (its Vegas participation, see
- SCROLL: Content scrolls continuously within the stream BasePlugin.get_vegas_participation):
- FIXED_SEGMENT: Fixed block that scrolls by with other content - 'scroll': its content scrolls by within the stream
- STATIC: Scroll pauses, plugin displays for its duration, then resumes - 'pause': the scroll pauses, the plugin displays for its duration, then
the scroll resumes
- 'exclude': left out
""" """
import logging import logging
-23
View File
@@ -1369,29 +1369,6 @@ class PluginAdapter:
logger.debug("[%s] Cleared plugin scroll cache", plugin_id) logger.debug("[%s] Cleared plugin scroll cache", plugin_id)
return cleared 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: def cleanup(self) -> None:
"""Clean up resources.""" """Clean up resources."""
with self._cache_lock: with self._cache_lock:
+31 -50
View File
@@ -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 plugin content that's ready to be rendered, prefetching 1-2 plugins ahead
of the current scroll position. of the current scroll position.
Supports three display modes: Each plugin takes part in one of three ways (its Vegas participation, see
- SCROLL: Continuous scrolling content BasePlugin.get_vegas_participation):
- FIXED_SEGMENT: Fixed block that scrolls by - 'scroll': its content joins the strip
- STATIC: Pause scroll to display (marked for coordinator handling) - 'pause': the scroll pauses for its turn (a STATIC segment, marked for the
coordinator)
- 'exclude': left out of the rotation
""" """
import logging import logging
import threading import threading
import time 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 collections import deque
from dataclasses import dataclass, field from dataclasses import dataclass, field
from PIL import Image from PIL import Image
from src.vegas_mode.config import VegasModeConfig from src.vegas_mode.config import VegasModeConfig
from src.vegas_mode.plugin_adapter import PluginAdapter 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: if TYPE_CHECKING:
from src.plugin_system.plugin_manager import PluginManager from src.plugin_system.plugin_manager import PluginManager
@@ -34,11 +36,12 @@ class ContentSegment:
"""One plugin's content for a cycle. """One plugin's content for a cycle.
A STATIC segment carries no images: it marks where the coordinator pauses 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 plugin_id: str
images: List[Image.Image] images: List[Image.Image]
display_mode: VegasDisplayMode = field(default=VegasDisplayMode.FIXED_SEGMENT) display_mode: VegasDisplayMode = field(default=VegasDisplayMode.SCROLL)
class StreamManager: class StreamManager:
@@ -335,25 +338,14 @@ class StreamManager:
logger.debug("[%s] Vegas: skipped (not enabled)", plugin_id) logger.debug("[%s] Vegas: skipped (not enabled)", plugin_id)
continue continue
# Content type 'none' is left out, except for STATIC plugins, # 'pause' plugins stay in the rotation: they pause the scroll
# which pause the scroll rather than contributing to it. # for their turn rather than contributing to it.
content_type = self.plugin_adapter.get_content_type(plugin, plugin_id) participation = resolve_vegas_participation(plugin, plugin_id)
display_mode = VegasDisplayMode.FIXED_SEGMENT included = participation != 'exclude'
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)
logger.debug( logger.debug(
"[%s] Vegas: %s (content_type=%s, display_mode=%s)", "[%s] Vegas: %s (participation=%s)",
plugin_id, "included" if included else "excluded", plugin_id, "included" if included else "excluded",
content_type, display_mode.value participation
) )
if included: if included:
available_plugins.append(plugin_id) available_plugins.append(plugin_id)
@@ -580,23 +572,13 @@ class StreamManager:
logger.warning("[%s] Plugin not found in plugin_manager.plugins", plugin_id) logger.warning("[%s] Plugin not found in plugin_manager.plugins", plugin_id)
return None return None
display_mode = VegasDisplayMode.FIXED_SEGMENT # A 'pause' plugin gets a placeholder segment; the coordinator
try: # draws it with display() when the scroll reaches its turn.
display_mode = plugin.get_vegas_display_mode() if resolve_vegas_participation(plugin, plugin_id) == 'pause':
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
segment = ContentSegment( segment = ContentSegment(
plugin_id=plugin_id, plugin_id=plugin_id,
images=[], # No images needed for static pause images=[], # No images needed for static pause
display_mode=display_mode display_mode=VegasDisplayMode.STATIC
) )
self.stats['segments_fetched'] += 1 self.stats['segments_fetched'] += 1
logger.debug( logger.debug(
@@ -605,7 +587,6 @@ class StreamManager:
) )
return segment return segment
# Get content via adapter for SCROLL/FIXED_SEGMENT modes
images = self.plugin_adapter.get_content(plugin, plugin_id) images = self.plugin_adapter.get_content(plugin, plugin_id)
if not images: if not images:
# The adapter already warns when every content path failed; # The adapter already warns when every content path failed;
@@ -619,13 +600,13 @@ class StreamManager:
segment = ContentSegment( segment = ContentSegment(
plugin_id=plugin_id, plugin_id=plugin_id,
images=images, images=images,
display_mode=display_mode display_mode=VegasDisplayMode.SCROLL
) )
self.stats['segments_fetched'] += 1 self.stats['segments_fetched'] += 1
logger.debug( logger.debug(
"[%s] Segment: %d image(s), %dpx, mode=%s", "[%s] Segment: %d image(s), %dpx",
plugin_id, len(images), total_width, display_mode.value plugin_id, len(images), total_width
) )
return segment return segment
@@ -693,16 +674,16 @@ class StreamManager:
return layout return layout
def is_static_plugin(self, plugin_id: str) -> bool: 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) plugin = getattr(self.plugin_manager, 'plugins', {}).get(plugin_id)
if plugin is None: if plugin is None:
return False return False
try: return resolve_vegas_participation(plugin, plugin_id) == 'pause'
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
def take_next_group( def take_next_group(
self, count: Optional[int] = None, offscreen_only: bool = False self, count: Optional[int] = None, offscreen_only: bool = False
+18 -6
View File
@@ -45,6 +45,19 @@ DEPRECATED = {
"src.plugin_system.plugin_manager.PluginManager": ["get_enabled_plugins"], "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): def _cls(path):
import importlib import importlib
@@ -52,14 +65,14 @@ def _cls(path):
return getattr(importlib.import_module(module), name) 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): def test_exactly_these_methods_are_deprecated(path):
cls = _cls(path) cls = _cls(path)
marked = sorted(name for name, value in vars(cls).items() marked = sorted(name for name, value in vars(cls).items()
if hasattr(value, "__deprecated__")) 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: 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(): def _markers():
@@ -82,7 +95,7 @@ def _markers():
def test_markers_are_found(): 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(): 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): def test_usage_script_lists_exactly_the_pinned_markers(usage_script):
found = {(f"{m.module}.{m.owner}", m.method, m.removal) found = {(f"{m.module}.{m.owner}", m.method, m.removal)
for m in usage_script.find_markers(REPO)} for m in usage_script.find_markers(REPO)}
assert found == {(path, name, "3.8.0") assert found == {(path, name, removal) for (path, name), removal in PINNED.items()}
for path, names in DEPRECATED.items() for name in names}
def test_usage_script_tells_uses_from_name_collisions(usage_script, tmp_path): def test_usage_script_tells_uses_from_name_collisions(usage_script, tmp_path):
+461
View File
@@ -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
-1
View File
@@ -31,7 +31,6 @@ class _Plugin:
def _stream(plugins): def _stream(plugins):
adapter = MagicMock() adapter = MagicMock()
adapter.get_content_type.return_value = 'multi'
adapter.get_content.return_value = [Image.new('RGB', (20, 8))] adapter.get_content.return_value = [Image.new('RGB', (20, 8))]
return StreamManager(VegasModeConfig(), SimpleNamespace(plugins=plugins), adapter) return StreamManager(VegasModeConfig(), SimpleNamespace(plugins=plugins), adapter)
@@ -10,6 +10,9 @@ from web_interface.blueprints.api_v3 import (
jsonify, logger, os, request, subprocess, success_response, jsonify, logger, os, request, subprocess, success_response,
) )
from src.common.path_safety import safe_path_component 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 import web_interface.blueprints.api_v3 as _pkg
# Read through the module rather than bound by value: tests patch these # Read through the module rather than bound by value: tests patch these
# as module attributes, and a value binding would not see the patch. # 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: if 'vegas_mode' in plugin_config:
vegas_mode = plugin_config['vegas_mode'] 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 { return {
'id': plugin_id, 'id': plugin_id,
'name': plugin_info.get('name', 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', []), 'web_ui_actions': plugin_info.get('web_ui_actions', []),
'vegas_mode': vegas_mode, 'vegas_mode': vegas_mode,
'vegas_content_type': vegas_content_type, 'vegas_content_type': vegas_content_type,
'vegas_participation': vegas_participation,
} }
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
@@ -12,7 +12,7 @@
* orderInputId: 'vegas_plugin_order_value', // hidden input, JSON array of ids * orderInputId: 'vegas_plugin_order_value', // hidden input, JSON array of ids
* excludedInputId: 'vegas_excluded_plugins_value', // optional: adds an * excludedInputId: 'vegas_excluded_plugins_value', // optional: adds an
* // include-checkbox per row; unchecked ids collect here (JSON array) * // 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 * The container re-renders from /api/v3/plugins/installed each init; the
@@ -21,10 +21,14 @@
(function() { (function() {
'use strict'; '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([ const MODE_LABELS = new Map([
['scroll', { label: 'Scroll', icon: 'fa-scroll', color: 'text-blue-600' }], ['scroll', { label: 'Scroll', icon: 'fa-scroll', color: 'text-blue-600' }],
['fixed', { label: 'Fixed', icon: 'fa-square', color: 'text-green-600' }], ['pause', { label: 'Pause', icon: 'fa-pause', color: 'text-orange-600' }],
['static', { label: 'Static', 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) { function init(options) {
@@ -165,11 +169,11 @@
} }
if (options.showVegasModeBadge) { if (options.showVegasModeBadge) {
const vegasMode = plugin.vegas_mode || plugin.vegas_content_type || 'fixed'; const vegasMode = plugin.vegas_participation || plugin.vegas_mode || 'scroll';
const modeInfo = MODE_LABELS.get(vegasMode) || MODE_LABELS.get('fixed'); const modeInfo = MODE_LABELS.get(vegasMode) || MODE_LABELS.get('scroll');
const badge = document.createElement('span'); const badge = document.createElement('span');
badge.className = `text-xs ${modeInfo.color} ml-2`; 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'); const badgeIcon = document.createElement('i');
badgeIcon.className = `fas ${modeInfo.icon} mr-1`; badgeIcon.className = `fas ${modeInfo.icon} mr-1`;
badge.appendChild(badgeIcon); badge.appendChild(badgeIcon);