-
-
Notifications
You must be signed in to change notification settings - Fork 29
Expand file tree
/
Copy pathbase_plugin.py
More file actions
1421 lines (1187 loc) · 59.3 KB
/
Copy pathbase_plugin.py
File metadata and controls
1421 lines (1187 loc) · 59.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""
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 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)
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)