mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 22:35:08 +00:00
* fix(plugins): let a plugin ask to be polled faster while it has live content
Reported: "the football plugin with live games only updates the live game in
progress if I restart the display."
The data path was never the problem. NFLLiveManager fetches ESPN with no cache,
SportsLive.update() refreshes current_game in place when the game IDs are
unchanged, and the scorebug redraws from the game dict every frame -- which is
why the reporter's logs look healthy.
The problem is cadence. _get_plugin_update_interval() read only the manifest's
static update_interval, football's manifest pins that to 60, and the plugin's
own live_update_interval (15s) was invisible to the scheduler. Measured on a rig
during the fourth quarter of the game in the report:
23:21:49 23:22:50 23:23:50 23:24:50 23:25:50 <- exactly 60s apart
A clock and score up to a minute stale during a two-minute drill reads as a
frozen panel, and a restart is the one moment it is ever current.
A single static number cannot say "every 15 seconds while a game is on, every 15
minutes in July", and only the plugin knows which is true. get_update_interval()
lets it say so per tick; returning None means "no opinion" and the existing
manifest/config resolution applies, so every plugin that predates this is
unaffected.
Requests are clamped to MIN_DYNAMIC_UPDATE_INTERVAL (5s): a plugin returning 0
would otherwise be re-entered on every tick of the render loop, busy-waiting
against its own API. A hook that raises or returns a non-number is ignored
rather than propagated -- a scheduler that fails on one plugin's bug stops
updating all the others.
Deliberately NOT changed: the manifest still beats config in the static path.
That looked like the obvious fix -- user config being silently ignored -- until
checking a real rig, where football and baseball both carry update_interval 3600
in config against a manifest 60, and weather 1800 against 60. Those values are
stale precisely because nothing has been honouring them; making config win would
have slowed three plugins by 60x, turning a one-minute lag into an hour. The
dynamic hook makes the flip unnecessary. There is a test pinning the current
precedence with that reasoning attached.
Full suite: 4,283 passed, 68 skipped.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9
* test(plugins): drive the real scheduler, not just the interval resolver
test_plugin_dynamic_update_interval.py asserts that
_get_plugin_update_interval() returns the number the plugin asked for. That is
not the same claim as "the plugin gets updated more often", and the gap between
those two is exactly where the original bug lived: the plugin knew it wanted
15s, said so in live_update_interval, and nothing downstream acted on it.
So this ticks the real run_scheduled_updates() through a simulated hour and
counts dispatches. Against pre-fix core it reports "10 updates in 10 minutes of
a live game" -- the 60s manifest cadence, matching what was measured on a rig
during the reported game. Against the fix it reports ~40.
Also pins the regression that would be worse than the bug: an idle hour must
still be ~60 updates, not 240. Asking for the live interval year-round would
poll ESPN four times a minute all summer.
Scope note, since it is easy to over-read this fix: the *switch* display path
already refreshed the manager immediately before drawing, via
_try_manager_display() -> _ensure_manager_updated(), which honours the manager's
own 15s interval. So a switch-mode card was already <=15s stale at draw time
before this change. What this fixes is the background cadence, which is what
live-priority detection, Vegas content and scroll preparation all read.
Full suite: 4,288 passed, 68 skipped.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9
* fix(plugins): reject bool and -inf hook results in dynamic interval
get_update_interval() ran bool through float() (bool is an int subclass,
so True/False became 1.0/0.0) and only checked for +inf, not -inf. Both
cases landed on the MIN_DYNAMIC_UPDATE_INTERVAL floor by coincidence
instead of falling back to the static/manifest interval as invalid
input should.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Tst9cied2ri9bH4QRWa6H
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
964 lines
39 KiB
Python
964 lines
39 KiB
Python
"""
|
|
Base Plugin Interface
|
|
|
|
All LEDMatrix plugins must inherit from BasePlugin and implement
|
|
the required abstract methods: update() and display().
|
|
|
|
API Version: 1.0.0
|
|
Stability: Stable - maintains backward compatibility
|
|
"""
|
|
|
|
from abc import ABC, abstractmethod
|
|
from enum import Enum
|
|
from typing import Dict, Any, Optional, List
|
|
import logging
|
|
from src.logging_config import get_logger
|
|
|
|
|
|
_shared_fallback_font_manager: Optional[Any] = None
|
|
|
|
|
|
def _fallback_font_manager() -> Any:
|
|
"""Shared FontManager for environments (unit tests, mocks) where the
|
|
plugin manager doesn't carry one. Scans assets/fonts like the real one."""
|
|
global _shared_fallback_font_manager
|
|
if _shared_fallback_font_manager is None:
|
|
from src.font_manager import FontManager
|
|
_shared_fallback_font_manager = FontManager({})
|
|
return _shared_fallback_font_manager
|
|
|
|
|
|
class VegasDisplayMode(Enum):
|
|
"""
|
|
Display mode for Vegas scroll integration.
|
|
|
|
Determines how a plugin's content behaves within the continuous scroll:
|
|
|
|
- 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.
|
|
"""
|
|
SCROLL = "scroll"
|
|
FIXED_SEGMENT = "fixed"
|
|
STATIC = "static"
|
|
|
|
|
|
class BasePlugin(ABC):
|
|
"""
|
|
Base class that all plugins must inherit from.
|
|
Provides standard interface and helper methods.
|
|
|
|
This is the core plugin interface that all plugins must implement.
|
|
Provides common functionality for logging, configuration, and
|
|
integration with the LEDMatrix core system.
|
|
"""
|
|
|
|
API_VERSION = "1.0.0"
|
|
|
|
def __init__(
|
|
self,
|
|
plugin_id: str,
|
|
config: Dict[str, Any],
|
|
display_manager: Any,
|
|
cache_manager: Any,
|
|
plugin_manager: Any,
|
|
) -> None:
|
|
"""
|
|
Standard initialization for all plugins.
|
|
|
|
Args:
|
|
plugin_id: Unique identifier for this plugin instance
|
|
config: Plugin-specific configuration dictionary
|
|
display_manager: Shared display manager instance for rendering
|
|
cache_manager: Shared cache manager instance for data persistence
|
|
plugin_manager: Reference to plugin manager for inter-plugin communication
|
|
"""
|
|
self.plugin_id: str = plugin_id
|
|
self.config: Dict[str, Any] = config
|
|
self.display_manager: Any = display_manager
|
|
self.cache_manager: Any = cache_manager
|
|
self.plugin_manager: Any = plugin_manager
|
|
# get_logger returns a PluginLoggerAdapter here (plugin_id given), which
|
|
# stamps every record with plugin_id so it survives into formatted output.
|
|
self.logger = get_logger(f"plugin.{plugin_id}", plugin_id=plugin_id)
|
|
self.enabled: bool = config.get("enabled", True)
|
|
|
|
self.logger.info("Initialized plugin: %s", plugin_id)
|
|
|
|
@abstractmethod
|
|
def update(self) -> None:
|
|
"""
|
|
Fetch/update data for this plugin.
|
|
|
|
This method is called based on update_interval specified in the
|
|
plugin's manifest. It should fetch any necessary data from APIs,
|
|
databases, or other sources and prepare it for display.
|
|
|
|
Use the cache_manager for caching API responses to avoid
|
|
excessive requests.
|
|
|
|
Example:
|
|
def update(self):
|
|
cache_key = f"{self.plugin_id}_data"
|
|
cached = self.cache_manager.get(cache_key, max_age=3600)
|
|
if cached:
|
|
self.data = cached
|
|
return
|
|
|
|
self.data = self._fetch_from_api()
|
|
self.cache_manager.set(cache_key, self.data)
|
|
"""
|
|
raise NotImplementedError("Plugins must implement update()")
|
|
|
|
@abstractmethod
|
|
def display(self, force_clear: bool = False) -> None:
|
|
"""
|
|
Render this plugin's display.
|
|
|
|
This method is called during the display rotation or when the plugin
|
|
is explicitly requested to render. It should use the display_manager
|
|
to draw content on the LED matrix.
|
|
|
|
Args:
|
|
force_clear: If True, clear display before rendering
|
|
|
|
Example:
|
|
def display(self, force_clear=False):
|
|
if force_clear:
|
|
self.display_manager.clear()
|
|
|
|
self.display_manager.draw_text(
|
|
"Hello, World!",
|
|
x=5, y=15,
|
|
color=(255, 255, 255)
|
|
)
|
|
|
|
self.display_manager.update_display()
|
|
"""
|
|
raise NotImplementedError("Plugins must implement display()")
|
|
|
|
# -------------------------------------------------------------------------
|
|
# Global (whole-device) configuration
|
|
# -------------------------------------------------------------------------
|
|
@property
|
|
def global_config(self) -> Dict[str, Any]:
|
|
"""
|
|
The full LEDMatrix configuration, for reading device-wide settings.
|
|
|
|
``self.config`` is only this plugin's own slice, so cross-cutting
|
|
settings — ``target_fps``, ``timezone``, ``location`` — were previously
|
|
unreachable from a plugin without reaching into a manager by hand.
|
|
|
|
Resolution order mirrors the timezone helpers the sports plugins
|
|
already ship: ``plugin_manager.config_manager`` first (the cores that
|
|
hang it there), then ``cache_manager.config_manager``. Returns ``{}``
|
|
when neither is available, so callers can use plain ``.get()`` without
|
|
guarding, and a plugin on a core that predates this property still
|
|
loads — ``getattr(self, 'global_config', {})`` simply yields the
|
|
default.
|
|
|
|
Treat as read-only: the returned dict is the live config the core is
|
|
using, so mutating it edits every other consumer's view and can be
|
|
persisted back to disk.
|
|
|
|
Assignment is still allowed and wins over the resolved value. Several
|
|
shipped plugins (news, stock-news, ledmatrix-stocks, ledmatrix-
|
|
elections, ledmatrix-leaderboard, nfl-draft) set
|
|
``self.global_config`` to their own ``config['global']`` sub-dict; a
|
|
property without a setter would raise AttributeError and stop those
|
|
plugins loading.
|
|
|
|
Example:
|
|
fps = self.global_config.get('target_fps')
|
|
"""
|
|
override = getattr(self, '_global_config_override', None)
|
|
if override is not None:
|
|
return override
|
|
for owner in (self.plugin_manager, self.cache_manager):
|
|
config_manager = getattr(owner, 'config_manager', None)
|
|
if config_manager is None:
|
|
continue
|
|
try:
|
|
config = config_manager.get_config()
|
|
except Exception:
|
|
# A broken or unreadable config must never stop a plugin from
|
|
# loading; fall through to the next source, then to {}.
|
|
self.logger.debug(
|
|
"Could not read global config from %s",
|
|
type(owner).__name__, exc_info=True,
|
|
)
|
|
continue
|
|
# Only a real mapping is usable: callers do .get() on this and feed
|
|
# the result to numeric code, so handing back whatever a stub or a
|
|
# half-built manager returned would fail later and further away.
|
|
#
|
|
# An empty dict is treated as "nothing here yet" rather than a
|
|
# valid answer, so resolution continues to the next source. Both
|
|
# managers default to the same config/config.json, so falling
|
|
# through cannot pick up a different file's settings -- but it does
|
|
# rescue the case where the first manager simply hasn't loaded yet,
|
|
# which would otherwise return {} and silently disable every
|
|
# setting read through this property.
|
|
if isinstance(config, dict) and config:
|
|
return config
|
|
return {}
|
|
|
|
@global_config.setter
|
|
def global_config(self, value: Dict[str, Any]) -> None:
|
|
"""Let a plugin substitute its own view (see the getter's docstring)."""
|
|
self._global_config_override = value
|
|
|
|
# -------------------------------------------------------------------------
|
|
# Adaptive layout support (opt-in)
|
|
# -------------------------------------------------------------------------
|
|
@property
|
|
def layout(self) -> Any:
|
|
"""
|
|
LayoutContext for the current logical display size.
|
|
|
|
Lazily built and rebuilt automatically when the display size changes
|
|
(e.g. Vegas segment widths, double-sided logical screens). Provides
|
|
Region carving (self.layout.bounds), breakpoint tiers, a geometry
|
|
scale factor vs. the manifest's display.design_size, and fit-text
|
|
queries against font ladders. See src/adaptive_layout.py.
|
|
|
|
Example:
|
|
rows = self.layout.bounds.inset(1).split_v(3, 1, gap=1)
|
|
self.draw_fit(big_text, rows[0], ladder=LADDER_ARCADE)
|
|
self.draw_fit(small_text, rows[1])
|
|
"""
|
|
from src.adaptive_layout import LayoutContext
|
|
|
|
width = getattr(self.display_manager, "width", None)
|
|
height = getattr(self.display_manager, "height", None)
|
|
if not width or not height:
|
|
matrix = getattr(self.display_manager, "matrix", None)
|
|
width = getattr(matrix, "width", 128)
|
|
height = getattr(matrix, "height", 32)
|
|
|
|
font_manager = self._get_font_manager()
|
|
generation = getattr(font_manager, "cache_generation", 0)
|
|
cached = getattr(self, "_layout_context", None)
|
|
if (cached is not None
|
|
and (cached.width, cached.height) == (width, height)
|
|
and getattr(self, "_layout_font_generation", None) == generation):
|
|
return cached
|
|
|
|
context = LayoutContext(
|
|
width, height, font_manager,
|
|
design_size=self._get_design_size(),
|
|
)
|
|
self._layout_context = context
|
|
self._layout_font_generation = generation
|
|
return context
|
|
|
|
def draw_fit(self, text: str, box: Any,
|
|
color: tuple = (255, 255, 255),
|
|
ladder: Optional[Any] = None,
|
|
align: str = "center", valign: str = "center") -> Any:
|
|
"""
|
|
Fit text to a Region with the largest crisp font that fits, then draw
|
|
it aligned within that region via the display manager.
|
|
|
|
Args:
|
|
text: Text to display (ellipsized if even the smallest rung is too wide)
|
|
box: Region (or (w, h) tuple anchored at 0,0) to fit and align within
|
|
color: RGB color tuple
|
|
ladder: FontLadder to walk (default LADDER_GRID; use LADDER_ARCADE
|
|
for headline text like clocks and scores)
|
|
align/valign: alignment of the text ink within the box
|
|
|
|
Returns:
|
|
FitResult (font, family, size_px, text, ink metrics, fits flag)
|
|
"""
|
|
from src.adaptive_layout import LADDER_DEFAULT, draw_fitted_text
|
|
|
|
fit = self.layout.fit_text(text, box, ladder=ladder or LADDER_DEFAULT)
|
|
draw_fitted_text(self.display_manager, fit, box,
|
|
color=color, align=align, valign=valign)
|
|
return fit
|
|
|
|
def draw_image(self, img: Any, box: Any, *,
|
|
mode: str = "contain", align: str = "center",
|
|
valign: str = "center", crop_to_ink: bool = False,
|
|
anchor: str = "center", resample: Optional[Any] = None,
|
|
cache_key: Optional[Any] = None,
|
|
offset: tuple = (0, 0)) -> Any:
|
|
"""
|
|
Fit an image into a Region and paste it aligned within that region
|
|
onto the display canvas — the image counterpart to draw_fit().
|
|
|
|
Args:
|
|
img: Source PIL image (logos, art, icons)
|
|
box: Region (or (w, h) tuple) to fit and align within
|
|
mode: "contain" (letterbox), "cover" (crop-to-fill),
|
|
"fill_height" (logo-style), "stretch"
|
|
crop_to_ink: Trim transparent padding before fitting
|
|
anchor: "center" or "top" for cover crops
|
|
resample: PIL filter; default LANCZOS. Use RESAMPLE_NEAREST
|
|
(from src.adaptive_images) for pixel art/flags
|
|
cache_key: Stable identity (e.g. "logo:KC") for cross-reload
|
|
caching; defaults to the image object's identity
|
|
offset: Final (dx, dy) translation — the hook for user
|
|
x/y-offset customization
|
|
|
|
Returns:
|
|
ImageFitResult (processed image + dimensions + scale)
|
|
"""
|
|
from src.adaptive_images import draw_fitted_image
|
|
|
|
ifit = self.layout.fit_image(img, box, mode=mode,
|
|
crop_to_ink=crop_to_ink, anchor=anchor,
|
|
resample=resample, cache_key=cache_key)
|
|
draw_fitted_image(self.display_manager, ifit, box,
|
|
align=align, valign=valign, offset=offset)
|
|
return ifit
|
|
|
|
def _get_font_manager(self) -> Any:
|
|
"""The shared FontManager, or a module-level fallback when running
|
|
under mocks/harnesses that don't provide one."""
|
|
font_manager = getattr(self.plugin_manager, "font_manager", None)
|
|
if font_manager is not None and hasattr(font_manager, "get_font"):
|
|
return font_manager
|
|
return _fallback_font_manager()
|
|
|
|
def _get_design_size(self) -> tuple:
|
|
"""Panel size this plugin's layout was authored against, from the
|
|
manifest's optional display.design_size (defaults to 128x32)."""
|
|
from src.adaptive_layout import DEFAULT_DESIGN_SIZE
|
|
|
|
if self.plugin_manager and hasattr(self.plugin_manager, "plugin_manifests"):
|
|
manifest = self.plugin_manager.plugin_manifests.get(self.plugin_id, {})
|
|
declared = manifest.get("display", {}).get("design_size", {})
|
|
width, height = declared.get("width"), declared.get("height")
|
|
if width and height:
|
|
return (int(width), int(height))
|
|
return DEFAULT_DESIGN_SIZE
|
|
|
|
def get_display_duration(self) -> float:
|
|
"""
|
|
Get the display duration for this plugin instance.
|
|
|
|
Automatically detects duration from:
|
|
1. self.display_duration instance variable (if exists)
|
|
2. self.config.get("display_duration", 15.0) (fallback)
|
|
|
|
Can be overridden by plugins to provide dynamic durations based
|
|
on content (e.g., longer duration for more complex displays).
|
|
|
|
Returns:
|
|
Duration in seconds to display this plugin's content
|
|
"""
|
|
# Check for instance variable first (common pattern in scoreboard plugins)
|
|
if hasattr(self, 'display_duration'):
|
|
try:
|
|
duration = getattr(self, 'display_duration')
|
|
# Handle None case
|
|
if duration is None:
|
|
pass # Fall through to config
|
|
# Try to convert to float if it's a number or numeric string.
|
|
# bool is excluded: it's an int subclass, and True would
|
|
# otherwise read as a 1-second duration.
|
|
elif isinstance(duration, (int, float)) and not isinstance(duration, bool):
|
|
if duration > 0:
|
|
return float(duration)
|
|
else:
|
|
self.logger.debug(
|
|
"display_duration instance variable is non-positive (%s), using config fallback",
|
|
duration
|
|
)
|
|
# Try converting string representations of numbers
|
|
elif isinstance(duration, str):
|
|
try:
|
|
duration_float = float(duration)
|
|
if duration_float > 0:
|
|
return duration_float
|
|
else:
|
|
self.logger.debug(
|
|
"display_duration string value is non-positive (%s), using config fallback",
|
|
duration
|
|
)
|
|
except (ValueError, TypeError):
|
|
self.logger.warning(
|
|
"display_duration instance variable has invalid string value '%s', using config fallback",
|
|
duration
|
|
)
|
|
else:
|
|
self.logger.warning(
|
|
"display_duration instance variable has unexpected type %s (value: %s), using config fallback",
|
|
type(duration).__name__, duration
|
|
)
|
|
except (TypeError, ValueError, AttributeError) as e:
|
|
self.logger.warning(
|
|
"Error reading display_duration instance variable: %s, using config fallback",
|
|
e
|
|
)
|
|
|
|
# Fall back to config
|
|
config_duration = self.config.get("display_duration", 15.0)
|
|
try:
|
|
# Ensure config value is also a valid float (bool excluded — an
|
|
# int subclass that would otherwise read True as 1 second)
|
|
if isinstance(config_duration, (int, float)) and not isinstance(config_duration, bool):
|
|
if config_duration > 0:
|
|
return float(config_duration)
|
|
else:
|
|
self.logger.debug(
|
|
"Config display_duration is non-positive (%s), using default 15.0",
|
|
config_duration
|
|
)
|
|
return 15.0
|
|
elif isinstance(config_duration, str):
|
|
try:
|
|
duration_float = float(config_duration)
|
|
if duration_float > 0:
|
|
return duration_float
|
|
else:
|
|
self.logger.debug(
|
|
"Config display_duration string is non-positive (%s), using default 15.0",
|
|
config_duration
|
|
)
|
|
return 15.0
|
|
except ValueError:
|
|
self.logger.warning(
|
|
"Config display_duration has invalid string value '%s', using default 15.0",
|
|
config_duration
|
|
)
|
|
return 15.0
|
|
else:
|
|
self.logger.warning(
|
|
"Config display_duration has unexpected type %s (value: %s), using default 15.0",
|
|
type(config_duration).__name__, config_duration
|
|
)
|
|
except (ValueError, TypeError) as e:
|
|
self.logger.warning(
|
|
"Error processing config display_duration: %s, using default 15.0",
|
|
e
|
|
)
|
|
|
|
return 15.0
|
|
|
|
# ---------------------------------------------------------------------
|
|
# Dynamic duration support hooks
|
|
# ---------------------------------------------------------------------
|
|
def _get_dynamic_duration_config(self) -> Dict[str, Any]:
|
|
"""
|
|
Retrieve dynamic duration configuration block from plugin config.
|
|
|
|
Returns:
|
|
Dict with configuration values or empty dict if not configured.
|
|
"""
|
|
value = self.config.get("dynamic_duration", {})
|
|
if isinstance(value, dict):
|
|
return value
|
|
return {}
|
|
|
|
def supports_dynamic_duration(self) -> bool:
|
|
"""
|
|
Determine whether this plugin should use dynamic display durations.
|
|
|
|
Plugins can override to implement custom logic. By default this reads the
|
|
`dynamic_duration.enabled` flag from plugin configuration.
|
|
"""
|
|
config = self._get_dynamic_duration_config()
|
|
return bool(config.get("enabled", False))
|
|
|
|
def get_dynamic_duration_cap(self) -> Optional[float]:
|
|
"""
|
|
Return the maximum duration (in seconds) the controller should wait for
|
|
this plugin to complete its display cycle when using dynamic duration.
|
|
|
|
Returns:
|
|
Positive float value for explicit cap, or None to indicate no
|
|
additional cap beyond global defaults.
|
|
"""
|
|
config = self._get_dynamic_duration_config()
|
|
cap_value = config.get("max_duration_seconds")
|
|
if cap_value is None:
|
|
return None
|
|
try:
|
|
cap = float(cap_value)
|
|
if cap <= 0:
|
|
return None
|
|
return cap
|
|
except (TypeError, ValueError):
|
|
self.logger.warning(
|
|
"Invalid dynamic_duration.max_duration_seconds for %s: %s",
|
|
self.plugin_id,
|
|
cap_value,
|
|
)
|
|
return None
|
|
|
|
def is_cycle_complete(self) -> bool:
|
|
"""
|
|
Indicate whether the plugin has completed a full display cycle.
|
|
|
|
The display controller calls this after each display iteration when
|
|
dynamic duration is enabled. Plugins that render multi-step content
|
|
should override this method and return True only after all content has
|
|
been shown once.
|
|
|
|
Returns:
|
|
True if the plugin cycle is complete (default behaviour).
|
|
"""
|
|
return True
|
|
|
|
def reset_cycle_state(self) -> None:
|
|
"""
|
|
Reset any internal counters/state related to cycle tracking.
|
|
|
|
Called by the display controller before beginning a new dynamic-duration
|
|
session. Override in plugins that maintain custom tracking data.
|
|
"""
|
|
return
|
|
|
|
def get_update_interval(self) -> Optional[float]:
|
|
"""
|
|
How often this plugin wants update() called, right now, in seconds.
|
|
|
|
The manifest's ``update_interval`` is a single static number, which
|
|
cannot say "poll me every 15 seconds while a game is in progress and
|
|
every 15 minutes when nothing is on". Only the plugin knows which is
|
|
true at any moment, so override this to say so.
|
|
|
|
Return None (the default) to accept the manifest/config value.
|
|
|
|
Two constraints, both because the scheduler calls this on every tick of
|
|
the render loop:
|
|
|
|
- It must be cheap. Attribute reads only -- no config lookups, no I/O,
|
|
no locks that a fetch might be holding.
|
|
- It must not raise. A raising hook is ignored and the static interval
|
|
used, but a hook that raises every tick also logs every tick.
|
|
|
|
Values below PluginManager.MIN_DYNAMIC_UPDATE_INTERVAL are clamped up:
|
|
a plugin asking for 0 would otherwise busy-wait against its own API.
|
|
|
|
Example::
|
|
|
|
def get_update_interval(self):
|
|
# Fast while something is actually live, manifest default otherwise.
|
|
if any(m.live_games for m in self._live_managers):
|
|
return self.config.get("live_update_interval", 15)
|
|
return None
|
|
"""
|
|
return None
|
|
|
|
def has_live_priority(self) -> bool:
|
|
"""
|
|
Check if this plugin has live priority enabled.
|
|
|
|
Live priority allows a plugin to take over the display when it has
|
|
live/urgent content (e.g., live sports games, breaking news).
|
|
|
|
Returns:
|
|
True if live priority is enabled in config, False otherwise
|
|
"""
|
|
return self.config.get("live_priority", False)
|
|
|
|
def has_live_content(self) -> bool:
|
|
"""
|
|
Check if this plugin currently has live content to display.
|
|
|
|
Override this method in your plugin to implement live content detection.
|
|
This is called by the display controller to determine if a live priority
|
|
plugin should take over the display.
|
|
|
|
Returns:
|
|
True if plugin has live content, False otherwise
|
|
|
|
Example (sports plugin):
|
|
def has_live_content(self):
|
|
# Check if there are any live games
|
|
return hasattr(self, 'live_games') and len(self.live_games) > 0
|
|
|
|
Example (news plugin):
|
|
def has_live_content(self):
|
|
# Check if there's breaking news
|
|
return hasattr(self, 'breaking_news') and self.breaking_news
|
|
"""
|
|
return False
|
|
|
|
def get_vegas_priority_weight(self) -> Optional[int]:
|
|
"""How many slots per Vegas cycle this plugin should get, or None.
|
|
|
|
The Vegas ticker is otherwise a strict round robin: every plugin
|
|
appears exactly once per cycle. With a dozen plugins enabled that puts
|
|
minutes between a live score and its next appearance. A weight of N
|
|
gives the plugin N slots per cycle, spread evenly through it rather
|
|
than clumped together.
|
|
|
|
Return ``None`` (the default) to let the core decide. It gives a
|
|
plugin ``vegas_scroll.live_weight`` when ``has_live_priority()`` and
|
|
``has_live_content()`` are both true, and 1 otherwise -- so live sports
|
|
already get extra turns without implementing this at all.
|
|
|
|
Implement it only when the plugin knows something the core cannot. The
|
|
motivating case is favorite teams: the core can see *that* a game is
|
|
live but not *whose*, so a scoreboard that wants its favorite's game
|
|
shown more often than other live games has to say so::
|
|
|
|
def get_vegas_priority_weight(self):
|
|
if not (self.has_live_priority() and self.has_live_content()):
|
|
return None # let the core decide
|
|
cfg = self.global_config.get('display', {}).get('vegas_scroll', {})
|
|
if self._favorite_is_live():
|
|
return cfg.get('favorite_live_weight', 5)
|
|
return cfg.get('live_weight', 3)
|
|
|
|
The weight is per *plugin*, not per game. A scoreboard showing four
|
|
live games still occupies one slot at a time and rotates its own games
|
|
within that slot; this controls how often the plugin itself comes
|
|
round.
|
|
|
|
Raising is safe: the core logs it and falls back to its own
|
|
live-content check, so a broken weight calculation costs the plugin
|
|
the favorite distinction but not the live boost.
|
|
|
|
Returns:
|
|
Slots per cycle (clamped to 1..10 by the caller), or None to
|
|
defer to the core's own live-content weighting.
|
|
"""
|
|
return None
|
|
|
|
def get_live_modes(self) -> List[str]:
|
|
"""
|
|
Get list of display modes that should be used during live priority takeover.
|
|
|
|
Override this method to specify which modes should be shown when this
|
|
plugin has live content. By default, returns all display modes from manifest.
|
|
|
|
Returns:
|
|
List of mode names to display during live priority
|
|
|
|
Example:
|
|
def get_live_modes(self):
|
|
# Only show live game mode, not upcoming/recent
|
|
return ['nhl_live', 'nba_live']
|
|
"""
|
|
# Get display modes from manifest via plugin manager
|
|
if self.plugin_manager and hasattr(self.plugin_manager, "plugin_manifests"):
|
|
manifest = self.plugin_manager.plugin_manifests.get(self.plugin_id, {})
|
|
return manifest.get("display_modes", [self.plugin_id])
|
|
return [self.plugin_id]
|
|
|
|
# -------------------------------------------------------------------------
|
|
# Vegas scroll mode support
|
|
# -------------------------------------------------------------------------
|
|
def get_vegas_render_width(self) -> int:
|
|
"""
|
|
Width the Vegas ticker wants this plugin's content to occupy.
|
|
|
|
On a wide panel a layout built to fill the screen reads as sparse in a
|
|
ticker — a forecast spread over five columns, a progress bar drawn at
|
|
100% width, a stat block with the panel's whole width between its
|
|
elements. Vegas asks for a narrower render so the plugin can choose a
|
|
tighter arrangement instead of being cropped afterwards.
|
|
|
|
Vegas also narrows ``display_manager`` for the duration of the call, so
|
|
a plugin that already sizes itself from ``matrix.width`` needs no
|
|
changes. Read this only when you size content some other way.
|
|
|
|
Controlled by the plugin's own ``vegas_width_pct`` config value, else
|
|
the global ``display.vegas_scroll.render_width_pct``.
|
|
|
|
Returns:
|
|
Target width in pixels. Outside a Vegas content request, the full
|
|
display width.
|
|
"""
|
|
requested = getattr(self, '_vegas_render_width', None)
|
|
if isinstance(requested, int) and requested > 0:
|
|
return requested
|
|
|
|
display_manager = getattr(self, 'display_manager', None)
|
|
matrix = getattr(display_manager, 'matrix', None)
|
|
if matrix is not None and getattr(matrix, 'width', None):
|
|
return int(matrix.width)
|
|
width = getattr(display_manager, 'width', None)
|
|
if callable(width):
|
|
width = width()
|
|
return int(width) if width else 128
|
|
|
|
def get_vegas_content(self) -> Optional[Any]:
|
|
"""
|
|
Get content for Vegas-style continuous scroll mode.
|
|
|
|
Override this method to provide optimized content for continuous scrolling.
|
|
Plugins can return:
|
|
- A single PIL Image: Displayed as a static block in the scroll
|
|
- A list of PIL Images: Each image becomes a separate item in the scroll
|
|
- None: Vegas mode will fall back to capturing display() output
|
|
|
|
Multi-item plugins (sports scores, odds) should return individual game/item
|
|
images so they scroll smoothly with other plugins.
|
|
|
|
Returns:
|
|
PIL Image, list of PIL Images, or None
|
|
|
|
Example (sports plugin):
|
|
def get_vegas_content(self):
|
|
# Return individual game cards for smooth scrolling
|
|
return [self._render_game(game) for game in self.games]
|
|
|
|
Example (static plugin):
|
|
def get_vegas_content(self):
|
|
# Return current display as single block
|
|
return self._render_current_view()
|
|
"""
|
|
return None
|
|
|
|
def get_vegas_content_type(self) -> str:
|
|
"""
|
|
Indicate the type of content this plugin provides for Vegas scroll.
|
|
|
|
Override this to specify how Vegas mode should treat this plugin's content.
|
|
|
|
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.
|
|
|
|
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)
|
|
|
|
Override to change default behavior. By default, reads from config
|
|
or maps legacy get_vegas_content_type() for backward compatibility.
|
|
|
|
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")
|
|
if config_mode:
|
|
try:
|
|
return VegasDisplayMode(config_mode)
|
|
except ValueError:
|
|
self.logger.warning(
|
|
"Invalid vegas_mode '%s' for %s, using default",
|
|
config_mode, self.plugin_id
|
|
)
|
|
|
|
# Fall back to mapping legacy content_type
|
|
content_type = self.get_vegas_content_type()
|
|
if content_type == 'multi':
|
|
return VegasDisplayMode.SCROLL
|
|
elif content_type == 'static':
|
|
return VegasDisplayMode.FIXED_SEGMENT
|
|
elif content_type == 'none':
|
|
# 'none' means excluded - return FIXED_SEGMENT as default
|
|
# The exclusion is handled by checking get_vegas_content_type() separately
|
|
return VegasDisplayMode.FIXED_SEGMENT
|
|
|
|
return VegasDisplayMode.FIXED_SEGMENT
|
|
|
|
def get_supported_vegas_modes(self) -> List[VegasDisplayMode]:
|
|
"""
|
|
Return list of Vegas display modes this plugin supports.
|
|
|
|
Used by the web UI to show available mode options for user configuration.
|
|
Override to customize which modes are available for this plugin.
|
|
|
|
By default:
|
|
- 'multi' content type plugins support SCROLL and FIXED_SEGMENT
|
|
- 'static' content type plugins support FIXED_SEGMENT and STATIC
|
|
- 'none' content type plugins return empty list (excluded from Vegas)
|
|
|
|
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()
|
|
|
|
if content_type == 'none':
|
|
return []
|
|
elif content_type == 'multi':
|
|
return [VegasDisplayMode.SCROLL, VegasDisplayMode.FIXED_SEGMENT]
|
|
else: # 'static'
|
|
return [VegasDisplayMode.FIXED_SEGMENT, VegasDisplayMode.STATIC]
|
|
|
|
def get_vegas_segment_width(self) -> Optional[int]:
|
|
"""
|
|
Get the preferred width for this plugin in Vegas FIXED_SEGMENT mode.
|
|
|
|
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:
|
|
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
|
|
"""
|
|
raw_value = self.config.get("vegas_panel_count", None)
|
|
if raw_value is None:
|
|
return None
|
|
|
|
try:
|
|
panel_count = int(raw_value)
|
|
if panel_count > 0:
|
|
return panel_count
|
|
else:
|
|
self.logger.warning(
|
|
"vegas_panel_count must be positive, got %s; using default",
|
|
raw_value
|
|
)
|
|
return None
|
|
except (ValueError, TypeError):
|
|
self.logger.warning(
|
|
"Invalid vegas_panel_count value '%s'; using default",
|
|
raw_value
|
|
)
|
|
return None
|
|
|
|
def validate_config(self) -> bool:
|
|
"""
|
|
Validate plugin configuration against schema.
|
|
|
|
Called during plugin loading to ensure configuration is valid.
|
|
Override this method to implement custom validation logic.
|
|
|
|
Returns:
|
|
True if config is valid, False otherwise
|
|
|
|
Example:
|
|
def validate_config(self):
|
|
required_fields = ['api_key', 'city']
|
|
for field in required_fields:
|
|
if field not in self.config:
|
|
self.logger.error("Missing required field: %s", field)
|
|
return False
|
|
return True
|
|
"""
|
|
# Basic validation - check that enabled is a boolean if present
|
|
if "enabled" in self.config:
|
|
if not isinstance(self.config["enabled"], bool):
|
|
self.logger.error("'enabled' must be a boolean")
|
|
return False
|
|
|
|
# Check display_duration if present. bool is excluded explicitly:
|
|
# it's an int subclass, and get_display_duration rejects it too.
|
|
if "display_duration" in self.config:
|
|
duration = self.config["display_duration"]
|
|
if (not isinstance(duration, (int, float))
|
|
or isinstance(duration, bool) or duration <= 0):
|
|
self.logger.error("'display_duration' must be a positive number")
|
|
return False
|
|
|
|
return True
|
|
|
|
def cleanup(self) -> None:
|
|
"""
|
|
Cleanup resources when plugin is unloaded.
|
|
|
|
Override this method to clean up any resources (e.g., close
|
|
file handles, terminate threads, close network connections).
|
|
|
|
This method is called when the plugin is unloaded or when the
|
|
system is shutting down.
|
|
|
|
Example:
|
|
def cleanup(self):
|
|
if hasattr(self, 'api_client'):
|
|
self.api_client.close()
|
|
if hasattr(self, 'worker_thread'):
|
|
self.worker_thread.stop()
|
|
"""
|
|
self.logger.info("Cleaning up plugin: %s", self.plugin_id)
|
|
|
|
def on_config_change(self, new_config: Dict[str, Any]) -> None:
|
|
"""
|
|
Called after the plugin configuration has been updated via the web API.
|
|
|
|
Plugins may override this to apply changes immediately without a restart.
|
|
The default implementation updates the in-memory config.
|
|
|
|
Args:
|
|
new_config: The full, merged configuration for this plugin (including
|
|
any secret-derived values that are merged at runtime).
|
|
"""
|
|
# Update config reference
|
|
self.config = new_config or {}
|
|
|
|
# Update simple flags
|
|
self.enabled = self.config.get("enabled", self.enabled)
|
|
|
|
def get_info(self) -> Dict[str, Any]:
|
|
"""
|
|
Return plugin info for display in web UI.
|
|
|
|
Override this method to provide additional information about
|
|
the plugin's current state.
|
|
|
|
Returns:
|
|
Dict with plugin information including id, enabled status, and config
|
|
|
|
Example:
|
|
def get_info(self):
|
|
info = super().get_info()
|
|
info['games_count'] = len(self.games)
|
|
info['last_update'] = self.last_update_time
|
|
return info
|
|
"""
|
|
return {
|
|
"id": self.plugin_id,
|
|
"enabled": self.enabled,
|
|
"config": self.config,
|
|
"api_version": self.API_VERSION,
|
|
}
|
|
|
|
def on_enable(self) -> None:
|
|
"""
|
|
Called when plugin is enabled.
|
|
|
|
Override this method to perform any actions needed when the
|
|
plugin is enabled (e.g., start background tasks, open connections).
|
|
"""
|
|
self.enabled = True
|
|
self.logger.info("Plugin enabled: %s", self.plugin_id)
|
|
|
|
def on_disable(self) -> None:
|
|
"""
|
|
Called when plugin is disabled.
|
|
|
|
Override this method to perform any actions needed when the
|
|
plugin is disabled (e.g., stop background tasks, close connections).
|
|
"""
|
|
self.enabled = False
|
|
self.logger.info("Plugin disabled: %s", self.plugin_id)
|