mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-16 11:59:13 +00:00
* feat(ipc)!: remove the cache-key mailboxes (control socket stage 5) The control socket is now the only way the web interface sends the display a command. The display stops reading display_on_demand_request and plugin_error_clear_request, and the web interface stops writing them. - Display: no mailbox poll (MailboxWatch, the 1 s / 0.25 s cadence, _consume_on_demand_request, the deprecation log) and no persisted display_on_demand_processed_id guard; the error publisher reads no clear request. CacheManager.file_signature and MailboxWatch are removed. - A write to either retired key is dropped by CacheManager.save_cache and logged once per writer, naming the plugin from the call stack (or the request's plugin_id), with the API to move to. - Web: on-demand start with no display listening starts the service (when start_service) and sends the request again once the socket answers (45 s, 10 s for a running service without a socket yet); every other failure is a 503 (400 for invalid_args). Stop answers 503 when no display listens, unless stop_service. errors/clear answers 503 with a reason-specific message instead of writing a request; clear_pending is always false. src.ipc.client.should_fall_back is replaced by display_not_listening. - Kept: display_current_state, display_on_demand_state, plugin_runtime_snapshot and the heartbeat (read whenever the socket cannot answer), and display_on_demand_config (the display's resume record). Tests: mailbox-only tests removed (test_on_demand_mailbox.py, the mailbox cadence, file_signature and MailboxWatch tests); tests that injected requests through the mailbox now use the socket queue or a plugin's in-process request. The run-loop harness sends on-demand requests over its fake control socket, so four golden traces change: on-demand starts and stops land at the request instant instead of the next 0.25 s mailbox look (one frame fewer on the screen they end), and in vegas.json within one frame instead of 263 ms, which shifts the later 1 s-throttled WiFi-notice check by under a second. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: the on-demand routes' other callers ack over the socket, not the mailbox test_api_v3_bool_coercion, test_api_v3_lazy_plugin_discovery and test_web_api's stop test checked the mailbox write; they now get an ack from a mocked control socket. The stop route touches no manager any more, so it leaves the removed-catch-all sample. A request that was sent is never 'not listening' (pins the sent check the mutation run let through). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(web): a start that waits for the display answers 202 and is delivered in the background The start route held a request open for up to 45 s while a cold-started display loaded its plugins; the MQTT bridge (15 s timeout) and browsers reported a failure for a request that was then delivered. Now, when no display is listening, the route starts the service if asked and answers 202 with status "starting" at once. A single worker in the web process (web_interface/on_demand_dispatch.py) sends the request until the display acknowledges it or the wait runs out (45 s cold start, 10 s for a running service without a socket yet). A newer start supersedes the pending one; a stop cancels it (and succeeds, with cancelled_request_id, even with no display listening). The outcome is reported by /display/on-demand/status (source "web": starting, or error with start-timeout or the socket's reason, until the display publishes something newer) and by /display/current-status as on_demand_pending. Callers: the web UI's on-demand modal and "Preview on display" treat "starting" as taken (an info toast); the MQTT bridge already treats any non-error 2xx as success (now pinned by a test). Tests: the dispatcher (ack, retry then ack, start-timeout, other failures, superseded, an in-flight ack for a superseded start, stop while pending, a per-start wait, outcome lifetime); the routes (202, status routes while pending and after a timeout, a later display state replacing the failure, stop while pending, a new start superseding); a JS suite for app.js. Mutation check: 20 mutants on the worker, the routes and app.js, 20 killed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web): a cancelled on-demand start never reaches the display after its replacement CI caught a race in test_a_new_start_supersedes_the_pending_one: the dispatcher's worker could read the old start, the route cancel it and send the new one, and the worker's send of the old one land after it. cancel() now waits out a send already in flight (the worker holds a send lock while it reads the pending start and sends it), so whatever the caller sends next lands after it. Pinned by test_a_cancel_waits_for_a_send_in_flight, which fails without the wait. The Linux-only TestRealSocket test for a display that went away now expects the 202 and the start-timeout that follows, as the route answers since the background dispatcher. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web): a delivered on-demand start reads as starting until the display acts on it On ledpi (three cold starts) the display acknowledged the start as its socket opened, then took ~5 s to act on it while Vegas built its first strip; the status routes meanwhile showed the display's own idle state, so a UI polling every 700 ms flashed idle. The display's on-demand state now names the request it answers (request_id). A delivered start keeps reading as status "starting" with delivered: true, in /display/on-demand/status and as on_demand_pending in /display/current-status, until the display publishes state for that request id (a display without the field: any state newer than the delivery), for at most DELIVERED_SHOWN_SECONDS (30 s). The display's startup state, which can be published after the acknowledgement, names no request and does not end it. Tests: stays starting against the startup idle state (no id, an older id); the matching active state and the matching error take over; an older display's newer state takes over; the 30 s cap; the display's state names its request. Mutation check: 11 mutants, 11 killed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(display): on_demand_request_id has a class default for controllers built without __init__ _on_demand_state now publishes it, and test_state_stream_readers builds controllers with __new__. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: the case-spelling on-demand test patches _send_on_demand now the mailbox is gone Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * docs: blank line before 3.8.7 heading --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1443 lines
60 KiB
Python
1443 lines
60 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 math
|
|
import os
|
|
import sys
|
|
from src.deprecation import deprecated, warn_deprecated
|
|
from src.logging_config import get_logger
|
|
# Re-exported: a plugin may import it from here beside VegasDisplayMode.
|
|
from src.plugin_system.vegas_elements import VegasElement # noqa: F401
|
|
|
|
|
|
_shared_fallback_font_manager: Optional[Any] = None
|
|
|
|
#: Distinguishes "not looked up yet" from "looked up and not found", so a
|
|
#: plugin with no schema does not re-scan the disk on every frame.
|
|
_UNSET_SCHEMA_PATH = object()
|
|
|
|
|
|
class _NullStyleResolver:
|
|
"""Stand-in for ElementStyleResolver when the module is unavailable.
|
|
|
|
Only reachable on a core that predates src.element_style, which
|
|
``styles`` degrades to rather than raising: every lookup returns the
|
|
caller's classic values, which is what the plugin drew before styling
|
|
existed.
|
|
"""
|
|
|
|
def __init__(self, config: Any) -> None:
|
|
self._config = config
|
|
|
|
def style(self, element_key: str, classic_font: str = None,
|
|
classic_size: int = 8, classic_color: Any = None,
|
|
mode: Optional[str] = None) -> Any:
|
|
from types import SimpleNamespace
|
|
return SimpleNamespace(
|
|
font=None, color=classic_color or (255, 255, 255), offset=(0, 0),
|
|
font_name=classic_font, font_size=classic_size,
|
|
user_forced=False, user_forced_color=False,
|
|
visible=True, align=None, scale=1.0)
|
|
|
|
def offset(self, element_key: str, mode: Optional[str] = None) -> tuple:
|
|
return (0, 0)
|
|
|
|
def offset_value(self, element_key: str, axis: str, default: int = 0,
|
|
mode: Optional[str] = None) -> int:
|
|
return default
|
|
|
|
|
|
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):
|
|
"""
|
|
Legacy display mode for Vegas scroll integration.
|
|
|
|
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:
|
|
|
|
- STATIC: the scroll pauses for the plugin's turn and its display() draws
|
|
it full screen -- participation ``'pause'``.
|
|
- SCROLL and FIXED_SEGMENT: the plugin's content joins the scroll --
|
|
participation ``'scroll'``. Vegas has never told the two apart: a card's
|
|
width comes from get_vegas_content() and ``vegas_width_pct``, not from
|
|
the mode. The distinction is deprecated and goes away in LEDMatrix 3.9.0.
|
|
"""
|
|
SCROLL = "scroll"
|
|
FIXED_SEGMENT = "fixed"
|
|
STATIC = "static"
|
|
|
|
|
|
#: How a plugin takes part in Vegas mode (BasePlugin.get_vegas_participation):
|
|
#:
|
|
#: - ``'scroll'``: its content joins the scrolling strip.
|
|
#: - ``'pause'``: the scroll stops when the plugin's turn comes round, and its
|
|
#: display() draws it full screen for its display duration.
|
|
#: - ``'exclude'``: it is left out of Vegas mode.
|
|
VEGAS_PARTICIPATION_VALUES = ('scroll', 'pause', 'exclude')
|
|
|
|
#: The release that removes get_supported_vegas_modes(),
|
|
#: get_vegas_segment_width(), the ``vegas_panel_count`` setting and the
|
|
#: SCROLL / FIXED_SEGMENT distinction. The two @deprecated markers below
|
|
#: spell it as a literal, because tools that read markers statically (the
|
|
#: deprecation tests, the plugin API usage scan) cannot follow a name.
|
|
VEGAS_LEGACY_REMOVAL = "3.9.0"
|
|
|
|
_vegas_logger = get_logger(__name__)
|
|
_vegas_warned: set = set()
|
|
|
|
|
|
def _vegas_warn_once(key: Any, message: str, *args: Any) -> None:
|
|
"""Log a warning about a plugin's Vegas settings once per process.
|
|
|
|
Participation is resolved at every rotation refresh, so a bad value would
|
|
otherwise log on every one of them.
|
|
"""
|
|
if key in _vegas_warned:
|
|
return
|
|
_vegas_warned.add(key)
|
|
_vegas_logger.warning(message, *args)
|
|
|
|
|
|
def vegas_participation_value(value: Any) -> Optional[str]:
|
|
"""``value`` as one of VEGAS_PARTICIPATION_VALUES, or None if it is not one.
|
|
|
|
Case and surrounding whitespace are ignored; anything that is not a string
|
|
(None, a MagicMock standing in for a plugin in a test) is not a value.
|
|
"""
|
|
if isinstance(value, str):
|
|
value = value.strip().lower()
|
|
if value in VEGAS_PARTICIPATION_VALUES:
|
|
return value
|
|
return None
|
|
|
|
|
|
def configured_vegas_participation(plugin_id: str, config: Any) -> Optional[str]:
|
|
"""The user's ``vegas_participation`` setting in a plugin's config, if valid.
|
|
|
|
An unset or empty value is no setting. Anything else that is not a
|
|
participation is logged once and ignored, so the plugin keeps its own.
|
|
"""
|
|
if not isinstance(config, dict):
|
|
return None
|
|
raw = config.get('vegas_participation')
|
|
if raw is None or (isinstance(raw, str) and not raw.strip()):
|
|
return None
|
|
value = vegas_participation_value(raw)
|
|
if value is None:
|
|
_vegas_warn_once(
|
|
('config', plugin_id, repr(raw)),
|
|
"[%s] Invalid vegas_participation %r, expected one of %s; ignoring it",
|
|
plugin_id, raw, ', '.join(VEGAS_PARTICIPATION_VALUES))
|
|
return value
|
|
|
|
|
|
def legacy_vegas_participation(plugin: Any) -> str:
|
|
"""The participation a plugin's pre-3.8 Vegas hooks describe.
|
|
|
|
Exactly what Vegas decided from them before participation existed:
|
|
|
|
1. get_vegas_display_mode() returning ``VegasDisplayMode.STATIC`` pauses,
|
|
whatever the content type -- a STATIC plugin whose content type is
|
|
``'none'`` was still kept in the rotation to pause it.
|
|
2. Otherwise get_vegas_content_type() returning ``'none'`` excludes.
|
|
3. Everything else scrolls. SCROLL and FIXED_SEGMENT were never told
|
|
apart, and neither were content types ``'multi'``, ``'static'`` or any
|
|
other string.
|
|
|
|
Only the enum member counts as STATIC (a plugin returning the string
|
|
``'static'`` never paused), and a hook that raises or is missing counts as
|
|
not STATIC and as content type ``'static'``.
|
|
"""
|
|
display_mode = None
|
|
get_mode = getattr(plugin, 'get_vegas_display_mode', None)
|
|
if get_mode is not None:
|
|
try:
|
|
display_mode = get_mode()
|
|
except Exception:
|
|
_vegas_logger.debug("get_vegas_display_mode() failed on %s; not pausing",
|
|
type(plugin).__name__, exc_info=True)
|
|
if display_mode == VegasDisplayMode.STATIC:
|
|
return 'pause'
|
|
|
|
content_type = 'static'
|
|
get_type = getattr(plugin, 'get_vegas_content_type', None)
|
|
if get_type is not None:
|
|
try:
|
|
content_type = get_type()
|
|
except Exception:
|
|
_vegas_logger.debug("get_vegas_content_type() failed on %s; treating as 'static'",
|
|
type(plugin).__name__, exc_info=True)
|
|
if content_type == 'none':
|
|
return 'exclude'
|
|
return 'scroll'
|
|
|
|
|
|
def resolve_vegas_participation(plugin: Any, plugin_id: Optional[str] = None) -> str:
|
|
"""How Vegas mode treats ``plugin``: ``'scroll'``, ``'pause'`` or ``'exclude'``.
|
|
|
|
What the core calls, rather than the plugin's own
|
|
get_vegas_participation(), so the user's setting wins even over a plugin
|
|
that overrides that method, and so a plugin that is not a BasePlugin (or a
|
|
test double) still gets the legacy derivation:
|
|
|
|
1. the user's ``vegas_participation`` in the plugin's config;
|
|
2. the plugin's get_vegas_participation(), when it returns a valid value
|
|
(BasePlugin's reads the manifest's ``vegas_participation``, then
|
|
derives one from the legacy hooks);
|
|
3. legacy_vegas_participation().
|
|
|
|
Also where the deprecated ``vegas_panel_count`` setting is reported, once
|
|
per plugin. Never raises.
|
|
"""
|
|
pid = plugin_id or getattr(plugin, 'plugin_id', None) or type(plugin).__name__
|
|
config = getattr(plugin, 'config', None)
|
|
if isinstance(config, dict) and 'vegas_panel_count' in config:
|
|
warn_deprecated(
|
|
f"The vegas_panel_count setting (plugin '{pid}')", VEGAS_LEGACY_REMOVAL,
|
|
"it has no effect -- use vegas_width_pct to size the plugin's card",
|
|
once_key=f"vegas_panel_count:{pid}")
|
|
|
|
configured = configured_vegas_participation(pid, config)
|
|
if configured is not None:
|
|
return configured
|
|
|
|
getter = getattr(plugin, 'get_vegas_participation', None)
|
|
if callable(getter):
|
|
try:
|
|
declared = getter()
|
|
except Exception:
|
|
_vegas_logger.exception("[%s] get_vegas_participation() failed; "
|
|
"using its legacy Vegas hooks", pid)
|
|
declared = None
|
|
value = vegas_participation_value(declared)
|
|
if value is not None:
|
|
return value
|
|
if isinstance(declared, str):
|
|
_vegas_warn_once(
|
|
('declared', pid, declared),
|
|
"[%s] get_vegas_participation() returned %r, expected one of %s; "
|
|
"using its legacy Vegas hooks",
|
|
pid, declared, ', '.join(VEGAS_PARTICIPATION_VALUES))
|
|
return legacy_vegas_participation(plugin)
|
|
|
|
|
|
def finite_seconds(value: Any) -> Optional[float]:
|
|
"""``value`` as seconds when it is a finite number or a numeric string,
|
|
else None. A bool is not a number here, though it is an int: True would
|
|
read as a one-second screen.
|
|
|
|
How the core reads a plugin's get_display_duration() -- the rotation
|
|
(DisplayController._get_display_duration) and the Vegas static pause --
|
|
which several plugins answer straight from config.json, so a value saved
|
|
as "20" or null arrives as a string or None. A number at or below zero is
|
|
returned as it is; each caller has its own rule for that.
|
|
"""
|
|
if isinstance(value, bool):
|
|
return None
|
|
try:
|
|
seconds = float(value)
|
|
except (TypeError, ValueError, OverflowError):
|
|
return None
|
|
return seconds if math.isfinite(seconds) else None
|
|
|
|
|
|
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"
|
|
|
|
#: Which ``customization.modes.<mode>`` overrides :attr:`styles` applies.
|
|
#: A plugin with one instance per display mode (the scoreboards' live /
|
|
#: upcoming / recent classes) sets this and every existing style lookup
|
|
#: becomes mode-aware without changing a call site.
|
|
STYLE_MODE: Optional[str] = None
|
|
|
|
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
|
|
|
|
@property
|
|
def styles(self) -> Any:
|
|
"""
|
|
The user's per-element styling: fonts, sizes, colours, offsets,
|
|
visibility, alignment and scale, resolved against this plugin's own
|
|
config_schema.json.
|
|
|
|
Every consumer of src.element_style used to repeat the same three
|
|
things -- a guarded import, finding its own schema file, and
|
|
rebuilding the resolver when on_config_change swapped the config
|
|
dict. This is those three things, once.
|
|
|
|
Ask for a style by element name, passing what the plugin drew before
|
|
the user could customise anything::
|
|
|
|
title = self.styles.style('title_text',
|
|
classic_font='PressStart2P-Regular.ttf',
|
|
classic_size=8,
|
|
classic_color=(255, 255, 255))
|
|
x, y = title.offset
|
|
self.display_manager.draw_text(text, x=x, y=y,
|
|
font=title.font, color=title.color)
|
|
|
|
The classic_* arguments matter: when the user has chosen nothing,
|
|
they come back verbatim, so a plugin that adopts this renders
|
|
identically until someone actually changes a setting.
|
|
|
|
A plugin whose display has modes (a scoreboard's live/upcoming/
|
|
recent, weather's current/hourly/daily) sets ``STYLE_MODE`` on the
|
|
class, and every lookup here honours the matching
|
|
``customization.modes.<mode>`` overrides without any call site
|
|
passing a mode. Use :meth:`styles_for` for a one-off mode.
|
|
|
|
Never raises: with no schema on disk, or with the element-style
|
|
module unavailable, lookups fall back to the classic values.
|
|
"""
|
|
resolver = getattr(self, "_style_resolver", None)
|
|
# The config dict is swapped wholesale by on_config_change, so
|
|
# identity is the invalidation signal -- the same check the sports
|
|
# base classes use.
|
|
if resolver is not None and resolver._config is self.config:
|
|
return resolver
|
|
resolver = self._build_style_resolver(getattr(self, "STYLE_MODE", None))
|
|
self._style_resolver = resolver
|
|
return resolver
|
|
|
|
def styles_for(self, mode: Optional[str]) -> Any:
|
|
""":attr:`styles`, bound to ``mode`` instead of ``STYLE_MODE``.
|
|
|
|
For a plugin that renders more than one mode from one instance. A
|
|
plugin with an instance per mode should set ``STYLE_MODE`` instead
|
|
and leave its call sites alone.
|
|
"""
|
|
cache = getattr(self, "_style_resolvers_by_mode", None)
|
|
if cache is None or getattr(self, "_style_resolver_config", None) is not self.config:
|
|
cache = {}
|
|
self._style_resolvers_by_mode = cache
|
|
self._style_resolver_config = self.config
|
|
if mode not in cache:
|
|
cache[mode] = self._build_style_resolver(mode)
|
|
return cache[mode]
|
|
|
|
def _build_style_resolver(self, mode: Optional[str]) -> Any:
|
|
"""Construct a resolver for this plugin's config and schema."""
|
|
try:
|
|
from src.element_style import (ElementStyleResolver,
|
|
defaults_from_schema_file)
|
|
except ImportError: # pragma: no cover - core always ships it
|
|
return _NullStyleResolver(self.config)
|
|
|
|
schema_path = self._config_schema_path()
|
|
defaults = (defaults_from_schema_file(schema_path) if schema_path
|
|
else {})
|
|
return ElementStyleResolver(self.config, defaults, mode=mode)
|
|
|
|
def _config_schema_path(self) -> Optional[str]:
|
|
"""This plugin's config_schema.json, or None.
|
|
|
|
Looked up from the concrete class's own module rather than from this
|
|
file: a plugin's subclass lives in its plugin directory, while this
|
|
module lives in src/plugin_system, where no plugin schema exists.
|
|
Falls back to the configured plugins directory, including the
|
|
ledmatrix- prefix form the loader accepts.
|
|
|
|
Returning None is safe, not fatal -- the resolver then has no
|
|
defaults to compare against, so every configured value counts as a
|
|
deliberate override, which is the conservative reading.
|
|
"""
|
|
cached = getattr(self, "_config_schema_path_cache", _UNSET_SCHEMA_PATH)
|
|
if cached is not _UNSET_SCHEMA_PATH:
|
|
return cached
|
|
|
|
path = None
|
|
try:
|
|
for candidate in self._schema_path_candidates():
|
|
if candidate and os.path.isfile(candidate):
|
|
path = candidate
|
|
break
|
|
except Exception as exc: # pragma: no cover - defensive
|
|
self.logger.debug("Could not locate config_schema.json: %s", exc)
|
|
self._config_schema_path_cache = path
|
|
return path
|
|
|
|
def _schema_path_candidates(self) -> list:
|
|
"""Where a plugin's schema might be, best guess first."""
|
|
candidates = []
|
|
|
|
module = sys.modules.get(type(self).__module__)
|
|
module_file = getattr(module, "__file__", None)
|
|
if module_file:
|
|
candidates.append(os.path.join(
|
|
os.path.dirname(os.path.abspath(module_file)),
|
|
"config_schema.json"))
|
|
|
|
plugins_dir = getattr(self.plugin_manager, "plugins_dir", None)
|
|
if plugins_dir:
|
|
for plugin_id in (self.plugin_id, f"ledmatrix-{self.plugin_id}"):
|
|
candidates.append(os.path.join(
|
|
str(plugins_dir), os.path.basename(plugin_id),
|
|
"config_schema.json"))
|
|
return candidates
|
|
|
|
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.
|
|
|
|
Uses, in order, the first positive number among:
|
|
1. ``self.display_duration`` (a common pattern in scoreboard plugins)
|
|
2. ``self.config["display_duration"]``
|
|
3. 15.0
|
|
|
|
Numeric strings count as numbers. 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
|
|
"""
|
|
try:
|
|
duration = getattr(self, 'display_duration', None)
|
|
except (TypeError, ValueError, AttributeError) as e:
|
|
# A plugin may define display_duration as a property that raises.
|
|
self.logger.warning(
|
|
"Error reading display_duration instance variable: %s, using config fallback", e)
|
|
duration = None
|
|
if duration is not None:
|
|
seconds = self._positive_seconds(duration, "display_duration instance variable")
|
|
if seconds is not None:
|
|
return seconds
|
|
|
|
seconds = self._positive_seconds(
|
|
self.config.get("display_duration", 15.0), "config display_duration")
|
|
return seconds if seconds is not None else 15.0
|
|
|
|
def _positive_seconds(self, value: Any, source: str) -> Optional[float]:
|
|
"""``value`` as a positive float, or None (with a log line) if it is not one.
|
|
|
|
bool is rejected although it is an int subclass: True would otherwise
|
|
read as a 1-second duration.
|
|
"""
|
|
if isinstance(value, bool) or not isinstance(value, (int, float, str)):
|
|
self.logger.warning("%s has unexpected type %s (value: %s), ignoring it",
|
|
source, type(value).__name__, value)
|
|
return None
|
|
try:
|
|
seconds = float(value)
|
|
except ValueError:
|
|
self.logger.warning("%s has invalid value %r, ignoring it", source, value)
|
|
return None
|
|
if seconds > 0:
|
|
return seconds
|
|
self.logger.debug("%s is non-positive (%s), ignoring it", source, value)
|
|
return None
|
|
|
|
# ---------------------------------------------------------------------
|
|
# 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 ``display_manager.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: ``display_manager.width``, which falls back to the
|
|
canvas size when ``matrix`` is None (hardware init failed).
|
|
"""
|
|
requested = getattr(self, '_vegas_render_width', None)
|
|
if isinstance(requested, int) and requested > 0:
|
|
return requested
|
|
|
|
# display_manager.width first, as CLAUDE.md asks of every plugin: it
|
|
# already reads matrix.width when there is a matrix. matrix.width is
|
|
# only the fallback for a display_manager without a width (a test
|
|
# double, an older wrapper).
|
|
display_manager = getattr(self, 'display_manager', None)
|
|
width = getattr(display_manager, 'width', None)
|
|
if callable(width):
|
|
width = width()
|
|
if width:
|
|
return int(width)
|
|
matrix = getattr(display_manager, 'matrix', None)
|
|
if matrix is not None and getattr(matrix, 'width', None):
|
|
return int(matrix.width)
|
|
return 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_elements(self) -> Optional[List[Any]]:
|
|
"""
|
|
Vegas content as live elements: named, fixed-width pieces the ticker
|
|
can swap in place while they are on screen.
|
|
|
|
get_vegas_content() hands the ticker pictures, and a picture already
|
|
in the scrolling strip keeps what it showed when it was drawn. Return
|
|
a list of ``VegasElement`` (src/plugin_system/vegas_elements.py)
|
|
instead and the ticker records where each one is; after your update()
|
|
it calls this again, compares each element's ``version`` (or pixels)
|
|
with what the strip holds, and swaps the changed ones in between two
|
|
frames -- a score changes on a card already crossing the panel, and
|
|
nothing next to it moves.
|
|
|
|
The contract:
|
|
|
|
- Called only on the ticker's background thread, under this plugin's
|
|
lock (never while update() runs), on a canvas of its own and told
|
|
its width (get_vegas_render_width()), like get_vegas_content().
|
|
- Called often -- after every update() while any of your elements is
|
|
on or ahead of the screen -- so it must be cheap when nothing
|
|
changed (cache images by version), idempotent, and must not fetch.
|
|
- A live element's width must not depend on its data: a redraw at a
|
|
different width is not swapped in (it shows the next time the
|
|
plugin comes round).
|
|
- Keys must be unique in the list and stable for the same logical
|
|
item.
|
|
|
|
Return None (the default) to use get_vegas_content(). A core older
|
|
than 3.8.0 never calls this, so keep get_vegas_content() working and
|
|
floor ``ledmatrix_min_version`` at 3.8.0 only if you rely on it.
|
|
|
|
Example (scoreboard)::
|
|
|
|
def get_vegas_elements(self):
|
|
return [VegasElement(key=f"game:{g['id']}",
|
|
image=self._card_for(g), # cached by fingerprint
|
|
version=self._fingerprint(g))
|
|
for g in self.games]
|
|
|
|
Returns:
|
|
A list of VegasElement, or None.
|
|
"""
|
|
return None
|
|
|
|
def redraw_vegas_element(self, key: str, width: int, height: int,
|
|
at: float) -> Optional[Any]:
|
|
"""
|
|
Redraw one live element for a moment in time, without the plugin lock.
|
|
|
|
Only for elements returned with ``refresh_hz > 0``: content that
|
|
changes with time rather than with data, such as an aircraft moving
|
|
between position reports. The ticker calls it up to that often while
|
|
the element is on or near the screen.
|
|
|
|
- Called WITHOUT this plugin's lock, possibly while update() runs, so
|
|
read only state that update() replaces in a single assignment (an
|
|
immutable snapshot), never state it mutates in place.
|
|
- ``at`` is the time.monotonic() at which the pixels are expected to
|
|
reach the panel; draw the element as it should look then.
|
|
- Return an image of exactly ``width`` x ``height``, or None to skip
|
|
this tick.
|
|
|
|
Returns:
|
|
PIL Image of exactly (width, height), or None.
|
|
"""
|
|
return None
|
|
|
|
def notify_vegas_data_changed(self) -> None:
|
|
"""
|
|
Tell the Vegas ticker this plugin's data changed outside update().
|
|
|
|
The ticker redraws a plugin's live elements when its update()
|
|
completes. Data that lands some other way -- a background thread, a
|
|
push callback -- calls this so the change reaches the screen without
|
|
waiting for the next update(). Cheap and safe from any thread.
|
|
"""
|
|
notify = getattr(getattr(self, 'plugin_manager', None),
|
|
'notify_data_changed', None)
|
|
if callable(notify):
|
|
notify(self.plugin_id)
|
|
|
|
def request_on_demand(self, mode: Optional[str] = None,
|
|
duration: Optional[float] = None,
|
|
pinned: bool = False) -> Optional[str]:
|
|
"""
|
|
Take the screen now: show this plugin on demand. Safe from any thread.
|
|
|
|
For a plugin that reacts to something outside the rotation -- an MQTT
|
|
message, a timer, a detection -- and wants the panel for it. The
|
|
request goes straight to the display in this process and is applied
|
|
on its render thread within a frame or so, exactly like an on-demand
|
|
start from the web interface.
|
|
|
|
Args:
|
|
mode: One of this plugin's display modes; None for its first.
|
|
duration: Seconds to show it before the rotation resumes; None
|
|
(or zero) for no limit, until end_on_demand() or the user
|
|
stops it.
|
|
pinned: Stay on ``mode`` instead of cycling through the
|
|
plugin's other modes.
|
|
|
|
Returns:
|
|
The request id once the display has queued it, or None when
|
|
there is no display in this process to ask (the web interface,
|
|
scripts/check_plugin.py) or its queue is full. Nothing else can
|
|
take the request then: the ``display_on_demand_request`` file
|
|
mailbox older cores read is gone, and a write to it is dropped
|
|
with a warning. See "On-demand display" in
|
|
docs/PLUGIN_API_REFERENCE.md.
|
|
|
|
Example::
|
|
|
|
if self.request_on_demand(mode='my_alert', duration=15) is None:
|
|
self.logger.info("No display to show the alert on")
|
|
"""
|
|
request = getattr(getattr(self, 'plugin_manager', None), 'request_on_demand', None)
|
|
if not callable(request):
|
|
return None
|
|
request_id = request(self.plugin_id, mode=mode, duration=duration, pinned=pinned)
|
|
# Only a real id counts: a test's MagicMock manager answers a mock,
|
|
# which must read as "not taken" so the plugin's fallback runs.
|
|
return request_id if isinstance(request_id, str) else None
|
|
|
|
def end_on_demand(self) -> Optional[str]:
|
|
"""
|
|
Give the screen back: end this plugin's on-demand session. Any thread.
|
|
|
|
Ends only a session this plugin owns. One the user started for
|
|
another plugin, or a session that already ended, is left alone. The
|
|
rotation resumes where it left off.
|
|
|
|
Returns:
|
|
The request id once queued, or None as request_on_demand() does.
|
|
"""
|
|
end = getattr(getattr(self, 'plugin_manager', None), 'end_on_demand', None)
|
|
if not callable(end):
|
|
return None
|
|
request_id = end(self.plugin_id)
|
|
return request_id if isinstance(request_id, str) else 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:
|
|
"""
|
|
Legacy: the type of content this plugin provides for Vegas scroll.
|
|
|
|
Superseded by get_vegas_participation(). Vegas reads it only to derive
|
|
a participation when neither the user nor the manifest declares one,
|
|
and only ``'none'`` matters there: it excludes the plugin (unless
|
|
get_vegas_display_mode() says STATIC). Every other value scrolls.
|
|
|
|
Returns:
|
|
'multi' - Plugin has multiple scrollable items (sports, odds, news)
|
|
'static' - Plugin is a static block (clock, weather, music)
|
|
'none' - Plugin should not appear in Vegas scroll mode
|
|
"""
|
|
return 'static'
|
|
|
|
def get_vegas_display_mode(self) -> VegasDisplayMode:
|
|
"""
|
|
Legacy: the display mode for Vegas scroll integration.
|
|
|
|
Superseded by get_vegas_participation(). Vegas reads it only to derive
|
|
a participation when neither the user nor the manifest declares one,
|
|
and only STATIC matters there: it pauses the scroll for the plugin's
|
|
turn. SCROLL and FIXED_SEGMENT both scroll -- Vegas has never told them
|
|
apart, and the distinction is deprecated (removed in 3.9.0).
|
|
|
|
Reads the plugin's ``vegas_mode`` config value, else maps
|
|
get_vegas_content_type() ('multi' to SCROLL, anything else to
|
|
FIXED_SEGMENT).
|
|
|
|
Returns:
|
|
VegasDisplayMode enum value
|
|
"""
|
|
# 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. 'none' (excluded from
|
|
# Vegas) also maps to FIXED_SEGMENT: exclusion is decided by checking
|
|
# get_vegas_content_type() separately.
|
|
if self.get_vegas_content_type() == 'multi':
|
|
return VegasDisplayMode.SCROLL
|
|
return VegasDisplayMode.FIXED_SEGMENT
|
|
|
|
@deprecated("3.9.0",
|
|
"nothing reads it -- declare vegas_participation in the manifest instead")
|
|
def get_supported_vegas_modes(self) -> List[VegasDisplayMode]:
|
|
"""
|
|
Deprecated: the Vegas display modes this plugin supports.
|
|
|
|
Never consulted by core -- neither Vegas mode nor the web UI calls it
|
|
-- and removed in LEDMatrix 3.9.0. A plugin's own override keeps
|
|
working for the plugin itself; calling this base implementation logs a
|
|
deprecation warning.
|
|
|
|
By default:
|
|
- 'multi' content type plugins support SCROLL and FIXED_SEGMENT
|
|
- '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
|
|
"""
|
|
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]
|
|
|
|
@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]:
|
|
"""
|
|
Deprecated: the number of panels this plugin wanted as a FIXED_SEGMENT.
|
|
|
|
Never consulted by core: Vegas sizes a card from the
|
|
``vegas_width_pct`` / ``vegas_scroll.render_width_pct`` settings (see
|
|
get_vegas_render_width()). Removed, with the ``vegas_panel_count``
|
|
setting it reads, in LEDMatrix 3.9.0.
|
|
|
|
Returns:
|
|
``vegas_panel_count`` from config when it is a positive integer,
|
|
else None
|
|
"""
|
|
raw_value = self.config.get("vegas_panel_count", None)
|
|
if raw_value is None:
|
|
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)
|