Repository navigation
Expand file tree
/
Copy pathdisplay_controller.py
More file actions
5052 lines (4483 loc) · 249 KB
/
Copy pathdisplay_controller.py
File metadata and controls
5052 lines (4483 loc) · 249 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
"""
Display Controller — top-level orchestration for the LEDMatrix application.
This module owns the main run loop that drives the LED display. It ties
together every major subsystem:
- ConfigManager / ConfigService — loads config.json, hot-reloads on change
- DisplayManager — hardware (or emulator) output interface
- FontManager — TTF/BDF font loading and caching
- CacheManager — multi-tier API response cache
- PluginManager — plugin lifecycle (load, update, display)
- DisplaySyncManager — optional leader/follower multi-Pi sync
- VegasModeCoordinator — optional continuous Vegas scroll mode
The main loop inside :meth:`DisplayController.run` rotates through enabled
plugin display modes, respecting schedule windows, brightness dim schedules,
on-demand overrides, and live-priority interrupts.
Entry point: :func:`main` — instantiates :class:`DisplayController` and calls
:meth:`~DisplayController.run`.
"""
import time
import os
import inspect
import signal
import json
import threading
import types
from collections import deque
from contextlib import contextmanager
from dataclasses import replace
from typing import Dict, Any, FrozenSet, List, Optional, Callable, Set, Tuple
from datetime import datetime
from concurrent.futures import ThreadPoolExecutor, as_completed # pylint: disable=no-name-in-module
import pytz
from src import display_watchdog
from src.malloc_tuning import MallocTrimmer
from src.display_arbiter import (
Arbiter, ArbiterInputs, ArbiterState, FramePolicy,
ScreenPlan, Source, WifiNotice, live_pick, live_takeover, on_demand_bound, rotation_plan,
wifi_notice_preempts,
)
from src.screen_runner import (
FRAME, Checkpoint, ExitReason, FirstFrame, NoticeRead, Outcome, Screen, ScreenRunner,
)
from src.display_manager import DisplayManager
from src.config_manager import ConfigManager
from src.config_service import ConfigService
from src.cache_manager import CacheManager, MailboxWatch
from src.font_manager import FontManager
from src.logging_config import get_logger
from src.exceptions import PluginError
from src.common.frame_timing import HANDOVER_OP
from src.common.sync_manager import DisplaySyncManager, SyncRole
from src.ipc.contract import (
BrightnessResult,
BrightnessSetArgs,
Command as ControlCommand,
ErrorCode as ControlErrorCode,
PluginReloadArgs,
PluginReloadResult,
)
from src.ipc.server import ControlServer, QueuedCommand, StateHub, start_control_server
from src.plugin_system.base_plugin import finite_seconds
from src.vegas_mode.render_pipeline import SYNC_SEND_INTERVAL
# Get logger with consistent configuration
logger = get_logger(__name__)
# The on-demand file mailbox: the fallback for a web interface that cannot
# reach the control socket, and how some plugins still ask for the screen.
ON_DEMAND_MAILBOX_KEY = 'display_on_demand_request'
# How often the unchanged current mode is republished for the web UI, which
# treats display_current_state older than 120 s as unknown.
CURRENT_STATE_REFRESH_SECONDS = 30
# While the control socket serves the web interface's state readers
# (StateHub.readers_active), display_current_state is only their fallback:
# it is then rewritten at this interval and on a change of the flags, not on
# every mode change. Below the readers' 120 s max_age, so the fallback copy
# never reads as unknown.
CURRENT_STATE_RELAXED_REFRESH_SECONDS = 60
# How long startup will wait for plugins to fetch their first data before
# showing anything. Each plugin's update blocks for up to the executor's 30s
# timeout and they run one after another, so the uncapped total is the sum of
# every slow plugin: 82 seconds on the worst boot measured, with a blank panel
# throughout. Whatever does not finish in time is picked up by the scheduled
# update tick moments later, with the display already running.
_INITIAL_UPDATE_BUDGET_SECONDS = 20.0
# The least budget worth starting a plugin with. Below this the plugin is
# deferred instead: granting it a floor would let the pass run past its
# deadline, and granting it the true remainder would record a timeout for a
# slot it never had a chance to use.
_MIN_INITIAL_UPDATE_TIMEOUT_SECONDS = 2.0
DEFAULT_DYNAMIC_DURATION_CAP = 180.0
class _PluginReloadJob:
"""A ``plugin.reload`` whose slow half runs off the render thread.
The render thread takes the plugin out of the rotation and out of the
plugin manager (DisplayController._start_plugin_reload); then this job,
on its own thread, tears the old instance down and loads the new one
(run()). Tearing down waits for the plugin's lock, which a Vegas content
render of the old instance can hold for seconds. Done on the render
thread, that wait froze the panel (3.0 s on ledpi). The render thread
puts the new instance in the rotation once ``done`` is set
(DisplayController._finish_plugin_reloads).
"""
def __init__(self, command: QueuedCommand, plugin_id: str,
previous_order: Dict[str, int], old_instance: Any) -> None:
self.command = command
self.plugin_id = plugin_id
#: Each mode's place in available_modes before the reload.
self.previous_order = previous_order
self.old_instance = old_instance
self.done = threading.Event()
self.loaded = False
#: The old instance's teardown failed, so its modules may still be in
#: sys.modules and a load now could quietly reuse the old code.
self.unload_failed = False
self.error: Optional[Exception] = None
#: Reloads of the same plugin asked for while this one ran. They
#: start once it is done, so they load the files as they are then.
self.followers: List[QueuedCommand] = []
def run(self, plugin_manager: Any) -> None:
"""Tear down the old instance, then load the plugin again. Never raises."""
try:
if self.old_instance is not None:
if not plugin_manager.unload_detached_plugin(self.plugin_id, self.old_instance):
# Loading now could reuse the old plugin_<id> module and
# report a reload that never happened (the synchronous
# path refused this too: reload_plugin stops when
# unload_plugin fails).
self.old_instance = None
self.unload_failed = True
return
self.old_instance = None
self.loaded = bool(plugin_manager.reload_plugin(self.plugin_id))
except Exception as exc: # pylint: disable=broad-except
self.error = exc
finally:
self.done.set()
# Follower dead reckoning (the follower branch of DisplayController.run()).
# A leader position further off than this fraction of the strip is a cycle
# reset, and is snapped to rather than corrected toward.
_FOLLOWER_SNAP_FRACTION = 0.5
# Strip width assumed, in screens, before the follower has the leader's image.
_FOLLOWER_FALLBACK_STRIP_SCREENS = 4
# Drift beyond this many pixels is corrected by _FOLLOWER_DRIFT_GAIN of the
# error per tick; smaller drift by _FOLLOWER_NEAR_GAIN, so UDP jitter does not
# show as the scroll twitching.
_FOLLOWER_DRIFT_PX = 10
_FOLLOWER_DRIFT_GAIN = 0.20
_FOLLOWER_NEAR_GAIN = 0.05
# Follower render period, and how late a frame may run before the pacing grid
# is restarted rather than caught up.
_FOLLOWER_FRAME_INTERVAL = 1.0 / 60
_FOLLOWER_DEADLINE_SLIP = 0.1
class _ModuleClock:
"""The FrameClock a ScreenRunner paces with: this module's ``time``.
Looked up on every call, so a test that patches
``src.display_controller.time`` (the golden traces' fake clock) drives
the runner too.
"""
@staticmethod
def time() -> float:
return time.time()
@staticmethod
def perf_counter() -> float:
return time.perf_counter()
@staticmethod
def sleep(seconds: float) -> None:
time.sleep(seconds)
_MODULE_CLOCK = _ModuleClock()
class _ScreenHost:
"""The DisplayController as a ScreenRunner's ScreenHost.
One-line forwards to the controller's own methods, which keeps generic
names (draw, tick, check) off the controller, and means a test that
replaces one of those methods on the instance is still the one called.
"""
def __init__(self, controller: "DisplayController"):
self._c = controller
def first_frame(self, plan: ScreenPlan, plugin: Any) -> FirstFrame:
return FirstFrame(*self._c._dispatch_first_frame(plugin, plan.mode))
def complete_plan(self, plan: ScreenPlan, plugin: Any) -> Optional[ScreenPlan]:
return self._c._complete_plan(plan, plugin)
def draw(self, screen: Screen) -> Any:
# Only the high-FPS loop times a run of frames held by the plugin's
# update() (see _display_once's report_hold).
return self._c._display_once(
screen.plugin, screen.mode, screen.accepts_display_mode,
report_hold=screen.plan.frame_policy is FramePolicy.HIGH_FPS)
def after_frame(self, screen: Screen) -> None:
self._c._send_follower_frame(screen.plugin)
def tick(self) -> None:
self._c._tick_plugin_updates_if_due()
def service(self, screen: Screen) -> Optional[Tuple[str, ...]]:
return self._c._screen_service(screen)
def wait_frame(self, interval: float, screen: Screen) -> Optional[ScreenPlan]:
return self._c._wait_frame_interval(interval, screen)
def check(self, screen: Screen, checkpoint: Checkpoint,
live_scan: Optional[Tuple[str, ...]] = None) -> Optional[ScreenPlan]:
return self._c._screen_check(screen, checkpoint, live_scan)
def dwell(self, seconds: float) -> None:
self._c._sleep_with_plugin_updates(seconds)
def cycle_complete(self, screen: Screen) -> bool:
return self._c._plugin_cycle_complete(screen.plugin)
class DisplayController:
"""
Top-level controller that owns the LED display run loop.
Responsibilities
----------------
* Initialise and wire together all subsystems at startup.
* Rotate through plugin display modes in :meth:`run`.
* Honour schedule windows (active/inactive hours) and dim schedules.
* Handle on-demand override requests (external callers can pin a
specific plugin/mode for a fixed duration via the cache bus).
* Coordinate with a follower Pi when multi-display sync is configured.
* Delegate all actual content to the plugin system — this class contains
no display logic of its own.
There is exactly one instance per process; call :func:`main` to create
it and start the run loop.
"""
#: How long the run loop pauses per pass once a whole rotation has had
#: nothing to show. See _note_empty_pass.
EMPTY_ROTATION_PAUSE = 1.0
#: Consecutive passes whose mode had nothing to show, and the rotation
#: (on-demand or not, and its modes) they were counted in. Class-level so
#: controllers built without __init__ (tests) have them too.
_empty_pass_streak = 0
_empty_pass_rotation: Optional[Tuple[bool, Tuple[str, ...]]] = None
def __init__(self):
start_time = time.time()
logger.info("Starting DisplayController initialization")
# Initialize ConfigManager and wrap with ConfigService for hot-reload
config_manager = ConfigManager()
enable_hot_reload = os.environ.get('LEDMATRIX_HOT_RELOAD', 'true').lower() == 'true'
self.config_service = ConfigService(
config_manager=config_manager,
enable_hot_reload=enable_hot_reload
)
self.config_manager = config_manager
self.config = self.config_service.get_config()
self.cache_manager = CacheManager()
# The web interface's /api/v3/errors/* read what this publishes.
from src.error_aggregator import start_error_snapshot_publisher
start_error_snapshot_publisher(self.cache_manager)
# Host budgets and the other fetch_service settings, before any plugin
# fetches; the web UI's fetch statistics read what the publisher
# writes (src/common/fetch_service.py).
from src.common.fetch_service import (
configure_fetch_service, start_fetch_stats_publisher)
configure_fetch_service(self.config.get('fetch_service'))
self._fetch_stats_publisher = start_fetch_stats_publisher(self.cache_manager)
logger.info("Config loaded in %.3f seconds (hot-reload: %s)", time.time() - start_time, enable_hot_reload)
# Validate startup configuration. Errors are logged, not fatal. The
# plugin checks need the plugin manager and run once it exists.
try:
from src.startup_validator import StartupValidator
validator = StartupValidator(self.config_manager,
cache_manager=self.cache_manager)
is_valid, errors, warnings = validator.validate_all()
for warning in warnings:
logger.warning("Startup validation warning: %s", warning)
if not is_valid:
logger.error("Startup validation failed:\n%s",
"\n".join(f" - {e}" for e in errors))
except Exception as e:
logger.warning("Startup validation could not be completed: %s", e)
# Automatic updates need their health-check units, and this is the
# one root process running project code, so it installs them while
# automatic updates are on. See src/auto_update_setup.py.
try:
from src.auto_update_setup import ensure_update_helper
ensure_update_helper(self.config)
except Exception as e:
logger.warning("Automatic update setup could not be completed: %s", e)
config_time = time.time()
self.display_manager = DisplayManager(self.config)
logger.info("DisplayManager initialized in %.3f seconds", time.time() - config_time)
# Initialize multi-display sync (standalone by default — no-op unless configured)
sync_cfg = self.config.get("sync", {})
hw_cfg = self.config.get("display", {}).get("hardware", {})
self.sync_manager = DisplaySyncManager(
role_str=sync_cfg.get("role", "standalone"),
cfg=sync_cfg,
hw_config=hw_cfg,
logger=logger,
)
# Tell the leader its own physical display width so it can include it in hello_ack
if self.sync_manager.role == SyncRole.LEADER:
self.sync_manager.set_leader_width(self.display_manager.width)
# Follower mode setup
if self.sync_manager.role == SyncRole.FOLLOWER:
# Gate update_display() so background plugin threads cannot write to
# hardware — only our render loop is permitted.
_real_update = self.display_manager.update_display
_dm = self.display_manager
def _follower_gated_update():
# Allow through when the sync render loop has the token, or when
# the leader has gone offline and we've fallen back to standalone.
if getattr(_dm, '_sync_render_allowed', False) or not self.sync_manager.is_follower_active():
_real_update()
self.display_manager.update_display = _follower_gated_update
# No new_cycle handler is registered: the leader sends its scroll
# image over TCP at each new cycle and the follower adopts it (see
# set_on_scroll_image in _initialize_vegas_mode). A local rebuild
# would overwrite that image with a different, locally built one.
# Follower render-loop state (see the follower branch of run()).
self._follower_dr_last_t: Optional[float] = None # perf_counter of last tick
self._follower_local_x: Optional[float] = None # dead-reckoned scroll_x
# Set on a cycle reset; holds the last frame until the leader's new
# scroll image arrives.
self._follower_pending_new_image = False
self._follower_last_frame = None
# (image, array) from the leader, handed from the sync TCP thread to
# the render thread, which adopts it at the start of a follower frame
# (_adopt_follower_scroll_image). One append / one popleft, each
# atomic, so the render thread never draws from a half-swapped
# cached_image / cached_array / total_scroll_width.
self._follower_incoming_image: deque = deque(maxlen=1)
self._follower_deadline: Optional[float] = None
# Leader: time.time() of the last follower frame sent.
self._last_follower_send = 0.0
# Initialize Font Manager
font_time = time.time()
self.font_manager = FontManager(self.config)
logger.info("FontManager initialized in %.3f seconds", time.time() - font_time)
self.force_change = False
self.available_modes = []
# Initialize Plugin System
plugin_time = time.time()
self.plugin_manager = None
self._plugin_runtime_publisher = None
# On-demand requests plugins make in this process (BasePlugin.
# request_on_demand / end_on_demand), from any thread; the render
# thread drains them with the socket's commands. Created before the
# plugins load, because a plugin may ask from its first thread.
self._plugin_on_demand: deque = deque()
self._plugin_on_demand_lock = threading.Lock()
self.plugin_modes = {} # mode -> plugin_instance mapping for plugin-first dispatch
self.mode_to_plugin_id: Dict[str, str] = {}
self.plugin_display_modes: Dict[str, List[str]] = {}
# plugin_display_modes is mutated only by _register_loaded_plugin /
# _unregister_plugin on the render thread, but the config-watcher
# thread reads it in _enabled_plugin_not_running. Both mutation sites
# run during reconcile (rare), so this lock never touches the per-frame
# path -- the hot-path reads are same-thread as the writes.
self._plugin_modes_lock = threading.Lock()
# Guards the consume-and-clear of _pending_plugin_reconcile. Only taken
# when a reconcile is actually pending or a config change arrives, both
# rare -- the per-frame path just reads the bool.
self._reconcile_flag_lock = threading.Lock()
# Per-plugin config-change callbacks, kept so we can unsubscribe a
# plugin when it is disabled live.
self._plugin_config_callbacks: Dict[str, Callable] = {}
# Set by the config-watcher thread when the enabled-plugin set changes;
# the main run loop reconciles (loads/unloads) on its own thread so
# mutating available_modes never races with rendering.
self._pending_plugin_reconcile = False
# Set by the config-watcher thread when Vegas is switched on but no
# coordinator exists (Vegas was off at startup). The render thread
# creates it in _is_vegas_mode_active(), never the watcher thread.
self._pending_vegas_init = False
# Monotonic stamp of the last mailbox disk read; see
# _poll_on_demand_requests. None means "never polled", so the first
# call always goes through.
self._last_on_demand_poll: Optional[float] = None
# Monotonic stamp of the last _service_pending_changes pass; same
# "None means never" convention as _last_on_demand_poll.
self._last_pending_service: Optional[float] = None
# Monotonic stamp of the last scheduled-update pass; see
# _tick_plugin_updates_if_due. Same "None means never" convention.
self._last_plugin_update_tick: Optional[float] = None
# The control socket (src/ipc), started by run(). None when it is not
# served (Windows, LEDMATRIX_CONTROL_SOCKET=off, a bind failure);
# the file mailbox works either way.
self._control_server = None
# A brightness set_brightness() refused, so the periodic service pass
# doesn't retry (and log) the same failure several times a second.
self._failed_brightness_target: Optional[int] = None
self.on_demand_active = False
self.on_demand_mode: Optional[str] = None
self.on_demand_modes: List[str] = [] # All modes for the on-demand plugin
self.on_demand_mode_index: int = 0 # Current index in on-demand modes rotation
self.on_demand_plugin_id: Optional[str] = None
self.on_demand_duration: Optional[float] = None
self.on_demand_requested_at: Optional[float] = None
self.on_demand_expires_at: Optional[float] = None
self.on_demand_pinned = False
self.on_demand_request_id: Optional[str] = None
self.on_demand_status: str = 'idle'
self.on_demand_last_error: Optional[str] = None
self.on_demand_last_event: Optional[str] = None
self.on_demand_schedule_override = False
# The mode the request named, when it named one (not a mode resolved
# from a bare plugin id). Shown even when the plugin's live checks
# would leave it out of the session (_on_demand_modes_for_plugin).
self._on_demand_named_mode: Optional[str] = None
# Plugins that are disabled in config and loaded only because an
# on-demand request named them. The main loop unloads each one once
# on-demand has moved off it (_release_on_demand_plugins).
self._on_demand_loaded_plugins: Set[str] = set()
self.rotation_resume_index: Optional[int] = None
# Saved rotation position when a live-priority plugin preempts the
# rotation, so it resumes where it left off (not after the live plugin)
# once live priority ends.
self._live_resume_index: Optional[int] = None
# WiFi status message tracking. The path comes from wifi_manager so
# reader and writer can't drift: this used to resolve three levels up
# from src/, one above the repo, and never saw a message.
from src.wifi_manager import get_wifi_status_path
self.wifi_status_file = get_wifi_status_path()
self.wifi_status_active = False
self.wifi_status_expires_at: Optional[float] = None
# _check_wifi_status_message throttle state (checked at frame rate,
# stat'd at most once per second)
self._wifi_status_check_ts = 0.0
self._wifi_status_last_result: Optional[Dict[str, Any]] = None
# Plugin display() signature cache — must be initialised before the plugin
# loading loop below so the .pop() invalidation at load time is always safe.
self._plugin_accepts_display_mode: Dict[str, bool] = {}
try:
logger.info("Attempting to import plugin system...")
from src.plugin_system import PluginManager
logger.info("Plugin system imported successfully")
# Get plugin directory from config, default to plugin-repos for production
plugin_system_config = self.config.get('plugin_system', {})
plugins_dir_name = plugin_system_config.get('plugins_directory', 'plugin-repos')
# Resolve plugin directory - handle both absolute and relative paths
if os.path.isabs(plugins_dir_name):
plugins_dir = plugins_dir_name
else:
# If relative, resolve against the current working directory.
# That is the project root only because ledmatrix.service
# sets WorkingDirectory to it; run from anywhere else, a
# relative path resolves against wherever that is.
project_root = os.getcwd()
plugins_dir = os.path.join(project_root, plugins_dir_name)
logger.info("Plugin Manager initialized with plugins directory: %s", plugins_dir)
self.plugin_manager = PluginManager(
plugins_dir=plugins_dir,
config_manager=self.config_manager,
display_manager=self.display_manager,
cache_manager=self.cache_manager,
font_manager=self.font_manager
)
# BasePlugin.request_on_demand() / end_on_demand() land here.
# Before any plugin loads: a plugin may ask from its first thread.
# getattr: tests and golden traces stand in simpler managers.
set_handler = getattr(self.plugin_manager, 'set_on_demand_handler', None)
if callable(set_handler):
set_handler(self.submit_plugin_on_demand)
# The web UI's loaded / state / error_info for each plugin read
# what this publishes. Started before loading, so the loads that
# follow are published as they land.
from src.plugin_system.plugin_runtime import start_plugin_runtime_publisher
self._plugin_runtime_publisher = start_plugin_runtime_publisher(
self.cache_manager, self.plugin_manager.state_manager)
# Activate the plugin health/metrics subsystem. PluginManager leaves
# health_tracker/resource_monitor as None by default; wiring real
# instances here turns on the circuit breaker (a repeatedly-failing
# plugin's update() is skipped after consecutive failures, then
# retried after a cooldown) and per-plugin execution-time metrics.
# Both persist to the shared cache so the web UI can surface them.
# Done before discovery/loading so load-time schema warnings have a
# tracker to record against.
try:
from src.plugin_system.plugin_health import PluginHealthTracker
from src.plugin_system.resource_monitor import PluginResourceMonitor
self.plugin_manager.health_tracker = PluginHealthTracker(self.cache_manager)
self.plugin_manager.resource_monitor = PluginResourceMonitor(self.cache_manager)
logger.info("Plugin health tracking and resource monitoring enabled")
except Exception as e:
logger.warning("Could not enable plugin health/resource monitoring: %s", e)
# Discover plugins. Before the plugin checks so they can reuse the
# list: each discover_plugins() call rescans the plugins directory
# and logs every plugin again.
discovered_plugins = self.plugin_manager.discover_plugins()
logger.info("Discovered %d plugin(s)", len(discovered_plugins))
# Only the plugin checks: validate_all() above has run the rest,
# and running it again logged every config warning twice.
try:
from src.startup_validator import StartupValidator
validator = StartupValidator(self.config_manager, self.plugin_manager,
cache_manager=self.cache_manager)
validator._validate_plugins(discovered_plugins=discovered_plugins)
for warning in validator.warnings:
logger.warning("Plugin validation warning: %s", warning)
if validator.errors:
logger.error("Plugin validation failed:\n%s",
"\n".join(f" - {e}" for e in validator.errors))
except Exception as e:
logger.warning("Plugin validation could not be completed: %s", e)
# Check for on-demand plugin filter from cache
on_demand_config = self.cache_manager.get('display_on_demand_config', max_age=3600)
enabled_plugins = self._select_startup_plugins(discovered_plugins, on_demand_config)
# Count enabled plugins for progress tracking
enabled_count = len(enabled_plugins)
logger.info("Loading %d enabled plugin(s) in parallel (max 4 concurrent)...", enabled_count)
# Helper function for parallel loading
def load_single_plugin(plugin_id):
"""Load a single plugin and return result."""
plugin_load_start = time.time()
try:
if plugin_id in self._on_demand_loaded_plugins:
loaded = self.plugin_manager.load_plugin(plugin_id, force_enabled=True)
else:
loaded = self.plugin_manager.load_plugin(plugin_id)
if loaded:
plugin_load_time = time.time() - plugin_load_start
return {
'success': True,
'plugin_id': plugin_id,
'load_time': plugin_load_time,
'error': None
}
else:
return {
'success': False,
'plugin_id': plugin_id,
'load_time': time.time() - plugin_load_start,
'error': 'Load returned False'
}
except Exception as e:
return {
'success': False,
'plugin_id': plugin_id,
'load_time': time.time() - plugin_load_start,
'error': str(e)
}
# Load enabled plugins in parallel with up to 4 concurrent workers
loaded_count = 0
with ThreadPoolExecutor(max_workers=4) as executor:
# Submit all enabled plugins for loading
future_to_plugin = {
executor.submit(load_single_plugin, plugin_id): plugin_id
for plugin_id in enabled_plugins
}
# Process results as they complete
for future in as_completed(future_to_plugin):
result = future.result()
loaded_count += 1
if result['success']:
plugin_id = result['plugin_id']
logger.info("Loaded plugin %s in %.3f seconds (%d/%d)",
plugin_id, result['load_time'], loaded_count, enabled_count)
# Register the loaded plugin's modes, config subscription
# and dispatch maps (shared with live enable hot-reload).
self._register_loaded_plugin(plugin_id)
# Show progress
progress_pct = int((loaded_count / enabled_count) * 100)
elapsed = time.time() - plugin_time
logger.info("Progress: %d%% (%d/%d plugins, %.1fs elapsed)",
progress_pct, loaded_count, enabled_count, elapsed)
else:
logger.warning("Failed to load plugin %s: %s",
result['plugin_id'], result['error'])
# Log disabled plugins
disabled_count = len(discovered_plugins) - enabled_count
if disabled_count > 0:
logger.debug("%d plugin(s) disabled in config", disabled_count)
logger.info("Plugin system initialized in %.3f seconds", time.time() - plugin_time)
# Parallel loading appends modes in load-completion order, which
# varies between restarts; apply the user's configured rotation
# order (no-op when not configured).
self._apply_plugin_rotation_order()
logger.info("Total available modes: %d", len(self.available_modes))
logger.info("Available modes: %s", self.available_modes)
# If on-demand mode was restored from cache, populate on_demand_modes now that plugins are loaded
if self.on_demand_active and self.on_demand_plugin_id:
self._populate_on_demand_modes_from_plugin()
except Exception: # pylint: disable=broad-except
logger.exception("Plugin system initialization failed")
self.plugin_manager = None
# A restored session has no plugin to resume on. It may have been
# read already (on_demand_active) or not yet, if initialization
# failed before the restore ran; either way, end it visibly.
try:
cached_session = self.cache_manager.get('display_on_demand_config',
max_age=3600)
except Exception: # pylint: disable=broad-except
cached_session = None
if self.on_demand_active or cached_session:
self.cache_manager.clear_cache('display_on_demand_config')
self._set_on_demand_error('restore-failed')
# Its state machine no longer describes what runs; let the last
# snapshot go stale (readers then say unknown) rather than keep
# refreshing it.
if self._plugin_runtime_publisher is not None:
self._plugin_runtime_publisher.stop(publish_stopped=False)
self._plugin_runtime_publisher = None
# The web UI's Fonts tab ("Used by") reads what this publishes.
from src.font_usage import start_font_usage_publisher
self._font_usage_publisher = start_font_usage_publisher(
self.cache_manager, self.font_manager, self.plugin_manager)
# Display rotation state
self.current_mode_index = 0
self.current_display_mode = None
# Last mode written to the display_current_state cache key, and when.
self._last_published_mode: Optional[str] = None
self._last_published_at = 0.0
# (is_display_active, on_demand_active) as last published: a change
# to either is republished at once, like a mode change.
self._last_published_flags: Optional[Tuple[bool, bool]] = None
self.global_dynamic_config = (
self.config.get("display", {}).get("dynamic_duration", {}) or {}
)
self._active_dynamic_mode: Optional[str] = None
# Memory monitoring
self._memory_log_interval = 3600.0 # Log memory stats every hour
self._last_memory_log = time.time()
self._enable_memory_logging = self.config.get("display", {}).get("memory_logging", False)
# Schedule management
self.is_display_active = True
# The previous schedule result, so only transitions log at INFO.
self._was_display_active = True
# Config values read on hot paths, cached so they are not re-read
# from the config dict every frame. _refresh_config_cache() updates
# them on every hot reload.
self._normal_brightness: int = (
self.config.get('display', {}).get('hardware', {}).get('brightness', 90)
)
self._scroll_speed: float = self._vegas_scroll_speed(self.config)
# Brightness state tracking for dim schedule
self.current_brightness = self._normal_brightness
self.is_dimmed = False
self._was_dimmed = False
# _check_schedule and _check_dim_schedule re-evaluate at most once per
# clock minute: each stores the (hour, minute) it last evaluated and
# skips the strptime and comparison work until the minute changes.
# Reset to None on config change so the next call re-evaluates.
self._tz = None # pytz timezone, built lazily by _timezone()
self._schedule_checked_minute: Optional[tuple] = None
self._dim_checked_minute: Optional[tuple] = None
self._cached_target_brightness: int = self._normal_brightness
# Register controller-level hot-reload callback so cached config values
# (_normal_brightness, _scroll_speed, _tz, minute-gates) stay in sync
# when the user saves settings via the web UI.
self.config_service.subscribe(self._controller_config_change)
# Publish initial on-demand state
try:
self._publish_on_demand_state()
except (OSError, ValueError, RuntimeError) as err:
logger.debug("Initial on-demand state publish failed: %s", err, exc_info=True)
# Initial data update for plugins (ensures data available on first display)
logger.info("Performing initial plugin data update...")
update_start = time.time()
self._update_modules(deadline=update_start + _INITIAL_UPDATE_BUDGET_SECONDS)
logger.info("Initial plugin update completed in %.3f seconds", time.time() - update_start)
# Initialize Vegas mode coordinator
self.vegas_coordinator = None
self._initialize_vegas_mode()
logger.info("DisplayController initialization completed in %.3f seconds", time.time() - start_time)
def _initialize_vegas_mode(self):
"""Initialize Vegas mode coordinator if enabled."""
vegas_config = self.config.get('display', {}).get('vegas_scroll', {})
if not vegas_config.get('enabled', False):
logger.debug("Vegas mode disabled in config")
return
if self.plugin_manager is None:
logger.warning("Vegas mode skipped: plugin_manager is None")
return
try:
from src.vegas_mode import VegasModeCoordinator
self.vegas_coordinator = VegasModeCoordinator(
config=self.config,
display_manager=self.display_manager,
plugin_manager=self.plugin_manager
)
# Set up live priority checker
self.vegas_coordinator.set_live_priority_checker(self._check_live_priority)
# Set up interrupt checker for on-demand/wifi status and follower mode
def _vegas_interrupt():
return self._check_vegas_interrupt() or self.sync_manager.is_follower_active()
# Every 10 frames (~80ms at 125 FPS, ~0.4 s at the 24 a Pi 4
# often manages), or at the next frame when a control socket
# command is queued: that check is one Event read per frame.
self.vegas_coordinator.set_interrupt_checker(
_vegas_interrupt,
check_interval=10,
urgent=self._control_command_pending,
)
# Run plugin updates inside the Vegas loop so the inter-iteration
# gap is <1 ms (nothing left for _tick_plugin_updates() to do).
# Use the Vegas-aware variant so plugins that got fresh data are
# hot-swapped into the scroll promptly instead of waiting for the
# next full cycle.
self.vegas_coordinator.set_update_callback(self._tick_plugin_updates_for_vegas)
# Wire multi-display sync into Vegas render pipeline
follower_pos = self.config.get("sync", {}).get("follower_position", "left")
self.vegas_coordinator.set_sync_manager(self.sync_manager, follower_pos)
logger.info("Vegas mode coordinator initialized")
# Follower does NOT build its own initial scroll image — the leader
# pushes its image via TCP as soon as set_on_follower_connected fires.
# A local build would create a different (wrong) image that could
# temporarily replace the leader's correct one.
# When the leader sends its scroll image (TCP), update our
# cached_array so both Pis have pixel-identical images. This runs
# on the sync TCP thread, so it only converts and queues the
# image; the render thread swaps it in between frames.
import numpy as _np
def _on_leader_scroll_image(image):
vc = self.vegas_coordinator
if vc and vc.render_pipeline:
arr = _np.asarray(image.convert("RGB"), dtype=_np.uint8)
self._follower_incoming_image.append((image, arr))
self.sync_manager.set_on_scroll_image(_on_leader_scroll_image)
if self.sync_manager.role == SyncRole.LEADER:
# When a follower first connects, push the current scroll image so
# the follower doesn't have to wait for the next new_cycle event.
# Polls until the image is ready (Vegas may still be composing on startup).
def _on_follower_connected():
for _ in range(300): # up to 30s
vc = self.vegas_coordinator
if vc and vc.render_pipeline:
img = vc.render_pipeline.scroll_helper.cached_image
if img is not None:
self.sync_manager.send_scroll_image(img)
return
time.sleep(0.1)
logger.warning("Sync: no scroll image available to push to new follower")
self.sync_manager.set_on_follower_connected(_on_follower_connected)
except Exception as e:
logger.error("Failed to initialize Vegas mode: %s", e, exc_info=True)
self.vegas_coordinator = None
def _adopt_follower_scroll_image(self, rp) -> None:
"""Swap in the leader's latest scroll image, on the render thread.
The sync TCP thread used to set cached_image, cached_array and
total_scroll_width one after another while this thread read them, so
a frame could slice the new array with the old width. It now queues
the image and this applies it between frames.
"""
try:
image, arr = self._follower_incoming_image.popleft()
except IndexError:
return
if rp is None:
return
rp.scroll_helper.cached_image = image
rp.scroll_helper.cached_array = arr
rp.scroll_helper.total_scroll_width = image.width
self._follower_pending_new_image = False
logger.info(
"Sync: follower adopted leader scroll image %dx%d",
image.width, image.height,
)
def _is_vegas_mode_active(self) -> bool:
"""Check if Vegas mode should be running."""
self._apply_pending_vegas_init()
if not self.vegas_coordinator:
return False
# A stopped coordinator never reaches run_frame(), where queued config
# is applied, so re-enabling Vegas from the web UI would never land.
self.vegas_coordinator.apply_pending_config_if_idle()
if not self.vegas_coordinator.is_enabled:
return False
if self.on_demand_active:
return False # On-demand takes priority
return True
def _apply_pending_vegas_init(self) -> None:
"""Create the Vegas coordinator if Vegas was switched on after startup.
Render thread only: the config watcher just sets _pending_vegas_init.
Called from _is_vegas_mode_active() and from the main loop before the
sync-follower branch, which skips _is_vegas_mode_active() while a
follower is connected but still needs the coordinator to show the
leader's scroll image.
"""
if not self.vegas_coordinator and self._pending_vegas_init:
self._pending_vegas_init = False
self._initialize_vegas_mode()
def _check_vegas_interrupt(self) -> bool:
"""
Check if Vegas should yield control for higher priority events.
Called periodically by Vegas coordinator to allow responsive
handling of on-demand requests, wifi status, etc.
Returns:
True if Vegas should yield control, False to continue
"""
# A Vegas iteration runs for up to max_cycle_duration (240s by
# default) without returning to the main loop, and this is the only
# code of ours it calls while it does. Without servicing here, an
# on-demand request was never even read until the iteration ended --
# on_demand_active below is only set by that read -- and a saved
# brightness or a schedule boundary waited just as long.
self._service_pending_changes()
# Check for pending on-demand request
if self.on_demand_active:
return True
# Scheduled off mid-iteration: hand back so the main loop blanks it.
if not self.is_display_active:
return True
# Check for wifi status that needs display
if self._check_wifi_status_message():
return True
# A plugin reload starts at the top of the loop, outside the
# iteration; the iteration then resumes while it loads.
if self._plugin_reload_pending:
return True
return False
def _timezone(self):
"""The configured timezone, built once and cached until config changes."""
if self._tz is None:
timezone_str = self.config.get('timezone', 'UTC')
try:
self._tz = pytz.timezone(timezone_str)
except pytz.UnknownTimeZoneError:
logger.warning("Unknown timezone '%s', using UTC", timezone_str)
self._tz = pytz.UTC
return self._tz
@staticmethod
def _in_window(start, end, now) -> bool:
"""Whether ``now`` is within [start, end), a window that may span midnight.
Half-open: on from the start minute, off at exactly the end minute.
A closed end made the end minute count as inside, and because ``now``
carries seconds, only a check at hh:mm:00.000 saw it that way -- so
whether the panel went off at the start or the end of that minute
depended on when the minute's one check ran. ``start == end`` is an
empty window, as it effectively was before.
"""
if start <= end:
return start <= now < end
return now >= start or now < end
def _check_schedule(self):
"""Check if display should be active based on schedule."""
schedule_config = self.config.get('schedule', {})
# No schedule configured: always active.
if not schedule_config:
self.is_display_active = True
self._was_display_active = True
return
# A schedule without an 'enabled' key counts as enabled.
if 'enabled' in schedule_config and not schedule_config.get('enabled', True):
self.is_display_active = True
self._was_display_active = True
logger.debug("Schedule is disabled - display always active")
return
current_time = datetime.now(self._timezone())
# Gate: schedule state can only change on a minute boundary, so skip
# all the strptime / comparison work if we already evaluated this minute.
current_minute_key = (current_time.hour, current_time.minute)
if current_minute_key == self._schedule_checked_minute:
return
self._schedule_checked_minute = current_minute_key
current_day = current_time.strftime('%A').lower() # e.g. 'monday'
current_time_only = current_time.time()
# Check if per-day schedule is configured
days_config = schedule_config.get('days')
# Determine which schedule to use. Respect an explicit 'mode' field
# (like the dim schedule does) so a stray/legacy 'days' dict left over
# from config migration or a prior per-day setup can't silently
# override a user's Global schedule selection.
mode = schedule_config.get('mode')
mode_normalized = mode.replace('_', '-') if mode else None
use_per_day = False
if mode_normalized == 'global':
use_per_day = False
elif mode_normalized == 'per-day':
use_per_day = bool(days_config and current_day in days_config)
elif days_config:
# No explicit mode recorded (legacy config) - fall back to
# inferring from presence of a 'days' dict for the current day.
if current_day in days_config:
use_per_day = True
else:
logger.debug("Per-day schedule exists but %s not configured, using global schedule", current_day)
if use_per_day:
day_config = days_config[current_day]
if not day_config.get('enabled', True):
was_active = self._was_display_active
self.is_display_active = False
if was_active:
logger.info("Schedule activated: Display is now INACTIVE (%s is disabled in schedule). Display will be blanked.", current_day)
else:
logger.debug("Display inactive - %s is disabled in schedule", current_day)
self._was_display_active = self.is_display_active