-
-
Notifications
You must be signed in to change notification settings - Fork 29
Expand file tree
/
Copy pathdisplay_manager.py
More file actions
2011 lines (1767 loc) · 98 KB
/
Copy pathdisplay_manager.py
File metadata and controls
2011 lines (1767 loc) · 98 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 Manager — hardware abstraction layer for the RGB LED matrix.
This module provides :class:`DisplayManager`, the single interface between
application code and the physical (or emulated) LED panel.
Key responsibilities
--------------------
* Initialise the ``RGBMatrix`` (hardware) or ``RGBMatrixEmulator`` depending
on the ``EMULATOR`` environment variable.
* Expose a PIL ``Image``/``ImageDraw`` canvas that plugins draw into, then
flush it to the matrix via double-buffering (:meth:`DisplayManager.update_display`).
* Load and cache TTF/BDF fonts; expose ``draw_text`` for consistent text rendering.
* Provide ``width`` / ``height`` properties — always use these instead of
hard-coding display dimensions.
* Write periodic PNG snapshots to ``/tmp/led_matrix_preview.png`` for the
web-interface live preview.
* Track scrolling state and gate deferred updates so plugins don't race with
an in-progress scroll.
Singleton: only one ``DisplayManager`` instance exists per process. The
first call to ``DisplayManager(config)`` creates it; subsequent calls return
the same object, but ``__init__`` runs again on it each time, so it is
re-initialised (matrix included) with the new arguments rather than handed
back as it was. Construct it once and pass that instance around;
:meth:`DisplayManager.cleanup` clears the singleton.
"""
import json
import os
import socket
import tempfile
if os.getenv("EMULATOR", "false") == "true":
from RGBMatrixEmulator import RGBMatrix, RGBMatrixOptions
else:
from rgbmatrix import RGBMatrix, RGBMatrixOptions
from contextlib import contextmanager
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
from src.common.bdf_font import draw_bdf_text, load_bdf_face
from src.common.font_layout import crisp_size, load_truetype, resolve_asset_path
from src import scan_order
from src.display_geometry import (
DEFAULT_CHAIN_LENGTH, DEFAULT_COLS, DEFAULT_PARALLEL, DEFAULT_ROWS,
compose_pixel_mapper_config, physical_size, resolve_double_sided,
)
from src.matrix_support import (
DEFAULT_REFRESH_LIMIT_HZ, MatrixSettingsRefused, library_refusals, refusal_message,
)
from src.pi5_matrix_support import is_raspberry_pi_5
import threading
import time
from collections import OrderedDict, deque
from typing import Dict, Any, List, Optional, Tuple, TYPE_CHECKING
import zlib
import freetype
from src.common import snapshot_policy
from src import display_watchdog
from src.common.frame_timing import (
FrameTimingRecorder, install_gc_monitor, uninstall_gc_monitor)
if TYPE_CHECKING:
from src.common.render_gate import RenderGate
from src.logging_config import get_logger
from src.common.permission_utils import (
ensure_directory_permissions,
ensure_file_permissions,
get_assets_dir_mode,
get_assets_file_mode,
)
logger = get_logger(__name__)
#: The strike 5x7.bdf is drawn at. FreeType renders a BDF at its own fixed
#: size regardless, but a Face needs an active size before its metrics --
#: and therefore get_font_height() -- report anything but 0.
_CALENDAR_FONT_PX = 7
#: Seconds between repeats of update_display()'s error log. It runs every
#: frame, so a fault that persists would otherwise log ~100 lines a second.
_UPDATE_ERROR_LOG_INTERVAL = 60.0
#: zlib level for the preview snapshot PNG. The fastest level: each file is
#: read by the web UI and soon replaced by the next, so encode time (paid on
#: the render thread for a static screen) matters more than its size.
#: Lossless at any level. Against Pillow's default (6), on a desktop with
#: Pillow 12.3, a text-dense 512x64 frame encoded in about half the time,
#: into 12 KB instead of 7 KB; sparser frames saved less time (10-20%) and
#: stayed under 2 KB.
_SNAPSHOT_PNG_COMPRESS_LEVEL = 1
def _bdf_native_size(face) -> int:
"""The pixel height a BDF Face declares, or 0 if it does not say.
Used only to rescue a Face that was built without ``set_char_size``, so a
zero line height never reaches layout code.
"""
try:
sizes = getattr(face, "available_sizes", None) or []
if sizes:
return int(getattr(sizes[0], "height", 0) or 0)
except (AttributeError, IndexError, TypeError, ValueError) as exc:
# This runs on the measurement path for a face the caller already
# holds, so a malformed strike table must degrade to "unknown" rather
# than take the display down. Say which face, so a font that is
# actually broken is diagnosable rather than silently 8px.
logger.debug("Could not read BDF strike size from %r: %s", face, exc)
return 0
class _LogicalMatrix:
"""Proxy that reports a logical (per-screen) size for a physical matrix.
In double-sided mode the physical panel chain shows N identical copies of a
smaller logical screen. Plugins size themselves from
``display_manager.width`` / ``height`` (the documented convention), which
defer to ``matrix.width`` / ``matrix.height`` -- and many older plugins read
``matrix.width`` directly -- so this proxy reports the logical dimensions
while delegating every real
operation — ``CreateFrameCanvas``, ``SwapOnVSync``, ``brightness``,
``Clear`` and so on — to the underlying physical matrix. The duplication
itself happens once per frame in :meth:`DisplayManager.update_display`.
"""
__slots__ = ("_logical_height", "_logical_width", "_matrix")
def __init__(self, matrix: RGBMatrix, logical_width: int, logical_height: int) -> None:
object.__setattr__(self, "_matrix", matrix)
object.__setattr__(self, "_logical_width", logical_width)
object.__setattr__(self, "_logical_height", logical_height)
@property
def width(self) -> int:
"""Logical (per-screen) width reported to plugins."""
return self._logical_width
@property
def height(self) -> int:
"""Logical (per-screen) height reported to plugins."""
return self._logical_height
def __getattr__(self, name: str) -> Any:
"""Forward any non-overridden attribute access to the physical matrix.
Reached only when normal lookup fails (i.e. not width/height/_*).
"""
return getattr(object.__getattribute__(self, "_matrix"), name)
def __setattr__(self, name: str, value: Any) -> None:
"""Forward attribute writes (e.g. ``matrix.brightness = 80``) to it."""
setattr(object.__getattribute__(self, "_matrix"), name, value)
class _OffscreenMatrix(_LogicalMatrix):
"""``display_manager.matrix`` as a thread drawing off-screen sees it.
Reports the surface's size, so plugins that lay out from ``matrix.width``
follow it, and swallows every write that would reach the hardware. Nothing
drawn off-screen may touch the panel the render loop is driving. Method
names mirror the rgbmatrix API they stand in for.
"""
# pylint: disable=invalid-name
__slots__ = ()
def SetImage(self, *_args: Any, **_kwargs: Any) -> None:
"""Inert: off-screen drawing never reaches the panel."""
def SetPixel(self, *_args: Any, **_kwargs: Any) -> None:
"""Inert: off-screen drawing never reaches the panel."""
def Clear(self) -> None:
"""Inert: off-screen drawing never reaches the panel."""
def Fill(self, *_args: Any, **_kwargs: Any) -> None:
"""Inert: off-screen drawing never reaches the panel."""
def SwapOnVSync(self, canvas: Any, *_args: Any, **_kwargs: Any) -> Any:
"""Inert: hands the canvas straight back without waiting on the panel."""
return canvas
def __setattr__(self, name: str, value: Any) -> None:
"""Inert: brightness and other writes stay off the real matrix."""
class _OffscreenSurface:
"""One thread's private canvas while it renders off-screen.
See :meth:`DisplayManager.offscreen`.
"""
__slots__ = ("draw", "image", "matrix")
def __init__(self, width: int, height: int, real_matrix: Any) -> None:
self.image = Image.new('RGB', (width, height))
self.draw = ImageDraw.Draw(self.image)
# 1-bit text: the panel has no partial brightness, so AA only smears glyphs.
self.draw.fontmode = "1"
self.matrix = (_OffscreenMatrix(real_matrix, width, height)
if real_matrix is not None else None)
def _per_thread_canvas_attr(name: str) -> property:
"""A DisplayManager attribute that resolves per thread.
A thread inside :meth:`DisplayManager.offscreen` reads and writes its own
surface's ``name``; every other thread reads and writes the shared value,
exactly as when this was a plain attribute. Existing ``self.image = ...``
assignments therefore keep working and become thread-correct as they are.
"""
shared = "_shared_" + name
def fget(self: "DisplayManager") -> Any:
surface = self._current_surface() # pylint: disable=protected-access
if surface is not None:
return getattr(surface, name)
try:
return self.__dict__[shared]
except KeyError:
raise AttributeError(name) from None
def fset(self: "DisplayManager", value: Any) -> None:
surface = self._current_surface() # pylint: disable=protected-access
if surface is not None:
setattr(surface, name, value)
else:
self.__dict__[shared] = value
return property(fget, fset, doc=f"The plugin-facing ``{name}``, per thread.")
#: A held frame is only split when its blit takes less than this share of a
#: refresh: the second blit has to land before the next vsync.
_SPLIT_BLIT_FRACTION = 0.5
class DisplayManager:
"""
Singleton hardware abstraction layer for the RGB LED matrix.
Plugins should never interact with ``RGBMatrix`` directly; they use this
class to draw content and call :meth:`update_display` to push frames to
the panel.
Typical plugin usage::
canvas = Image.new('RGB', (self.display_manager.width,
self.display_manager.height), (0, 0, 0))
draw = ImageDraw.Draw(canvas)
# ... draw content ...
self.display_manager.image = canvas
self.display_manager.draw = ImageDraw.Draw(self.display_manager.image)
self.display_manager.update_display()
"""
_instance = None
# update_display()'s error-log throttle. Class defaults so instances built
# without __init__ (tests, doubles) have them too.
_update_error_logged_at: Optional[float] = None
_update_errors_suppressed = 0
def __new__(cls, *args, **kwargs):
if cls._instance is None:
cls._instance = super(DisplayManager, cls).__new__(cls)
return cls._instance
# The plugin-facing canvas. Per thread: see offscreen().
image = _per_thread_canvas_attr("image")
draw = _per_thread_canvas_attr("draw")
matrix = _per_thread_canvas_attr("matrix")
def __init__(self, config: Dict[str, Any] = None, force_fallback: bool = False, suppress_test_pattern: bool = False):
start_time = time.time()
self.config = config or {}
self._force_fallback = force_fallback
self._suppress_test_pattern = suppress_test_pattern
# Per-thread capture state. update_display() and clear() skip hardware
# writes while the *calling* thread is capturing content off-screen.
#
# Thread-local rather than a plain flag because Vegas mode prepares
# upcoming content on a background thread: a shared flag set there would
# suppress the render loop's own frame pushes for the duration, freezing
# the panel exactly when the point was to avoid a freeze.
self._capture_state = threading.local()
# Per-thread off-screen surface. While a thread is inside offscreen(),
# image, draw and matrix resolve to its own canvas; see offscreen().
self._surface_state = threading.local()
# Double-sided mode state (resolved in _setup_matrix). When disabled,
# the logical image is blitted to the matrix unchanged.
self._double_sided = None # dict {copies, axis, logical_width, logical_height} or None
self._physical_image = None # full-chain buffer reused each frame when tiling
# Text-width measurement cache: (text, id(font)) -> (width, font_ref)
# Avoids re-measuring the same string+font on every display() call.
# LRU-bounded: keys embed the TEXT, so changing strings (a clock, a
# live score) would otherwise grow it forever on a 24/7 service.
# Entries hold a strong reference to the font so its id() can't be
# recycled by a different font object — an id-keyed cache without
# the reference can return the WRONG width after garbage collection.
# Cleared on _load_fonts() so stale entries don't survive a font reload.
self._text_width_cache: "OrderedDict[tuple, Tuple[int, Any]]" = OrderedDict()
self._TEXT_WIDTH_CACHE_MAX = 1024
# Snapshot mirror for web preview + health check (service writes, web
# reads). Cadence/skip decisions live in src/common/snapshot_policy.py:
# the viewer rate only while the web SSE broadcaster keeps the viewer
# marker fresh; unchanged frames are never re-encoded, only mtime-touched.
self._snapshot_path = "/tmp/led_matrix_preview.png" # nosec B108 - fixed path intentional; web UI reads same path
self._viewer_marker_path = "/tmp/led_matrix_preview_viewer" # nosec B108 - touched by web SSE broadcaster
self._last_snapshot_ts = 0.0
self._last_snapshot_touch_ts = 0.0
self._last_snapshot_digest: Optional[int] = None
# The frame actually on disk. _last_snapshot_digest moves when a frame
# is handed to the writer; this only once it has been saved, so an
# mtime touch never vouches for a frame still waiting to be written.
self._saved_snapshot_digest: Optional[int] = None
# A changed frame reached _write_snapshot_if_due() inside the write
# interval and was skipped. Nothing writes it unless update_display()
# runs again, and a screen that draws once and holds never calls it
# again -- see write_owed_snapshot().
self._snapshot_owed = False
self._snapshot_dir_prepared = False
# Background writer used mid-scroll; see _write_snapshot_if_due.
self._snapshot_cond = threading.Condition()
self._snapshot_pending: Optional[Tuple[Image.Image, Optional[int]]] = None
self._snapshot_thread: Optional[threading.Thread] = None
self._snapshot_stop = False
# Held for the whole of each PNG write, by the writer thread and by
# the inline static path, so the two land on disk in order.
self._snapshot_write_lock = threading.Lock()
self._viewer_check_ts = 0.0
self._viewer_fresh = False
self._viewer_was_fresh = False
# Snapshot failures are logged as warnings, rate-limited so a
# persistent failure (e.g. an unwritable file) can't spam the log —
# but is never silent: the snapshot's mtime doubles as the web UI's
# hardware-liveness signal, so a quiet failure makes health checks lie.
self._snapshot_fail_log_ts = 0.0
# Dirty tracking: (image digest, brightness) of the last frame pushed
# to the panel; update_display() skips identical pushes. Kill switch:
# display.dirty_tracking: false.
self._dirty_tracking_enabled = bool(
self.config.get('display', {}).get('dirty_tracking', True))
self._last_pushed_digest = None
# Serializes update_display(): plugins can call it directly from
# background threads (see docstring on update_display), not just the
# render loop. RLock in case a caller within the critical section
# ever re-enters (e.g. via a nested draw callback).
self._update_lock = threading.RLock()
# Scrolling state tracking for graceful updates
# How many panel refreshes each pushed frame is held for. 1 means a new
# frame every refresh. Higher values are how a scroll runs slower than
# one pixel per refresh WITHOUT fractional pixel positions: the panel
# keeps refreshing at full rate (so flicker is unchanged) but motion
# advances a whole pixel every Nth refresh instead of every one.
# See src/common/scroll_config.py and scripts/scroll_speeds.py.
self._frame_hold = 1
# True while a static screen draws its first frame after a scroll,
# whose state is left set until then: those frames go out without
# scan-order compensation. See end_scroll_for_static_screen().
self._static_handover = False
# A src.common.render_gate.RenderGate while Vegas runs with
# vegas_scroll.prefetch_gate on: opened around each swap so the
# prefetch thread only runs Python while this thread waits on vsync.
self.render_gate: Optional['RenderGate'] = None
# Timing of every presented frame, whoever drew it, for
# scripts/frame_soak.py. See src/common/frame_timing.py.
self.frame_timing = FrameTimingRecorder(
info=self._frame_timing_info(), gc_monitor=install_gc_monitor())
self.frame_timing.scrolling_now = self._scrolling_now
self._scrolling_state = {
'is_scrolling': False,
'last_scroll_activity': 0,
'scroll_inactivity_threshold': 2.0, # seconds of inactivity before considering "not scrolling"
'deferred_updates': [],
'max_deferred_updates': 50, # Limit queue size to prevent memory issues
'deferred_update_ttl': 300.0 # 5 minutes TTL for deferred updates
}
# Guards _scrolling_state['deferred_updates']. defer_update() is called
# from plugin update() on the update worker thread while
# process_deferred_updates() runs on the render thread, and both
# rebuild the list (TTL filter, [n:] slice) and assign it back -- an
# append landing between one side's read and its assignment was lost.
# Never held while a queued callable runs: those may defer again.
self._deferred_lock = threading.Lock()
self._setup_matrix()
logger.info("Matrix setup completed in %.3f seconds", time.time() - start_time)
self._setup_scan_order_compensation()
font_time = time.time()
self._load_fonts()
logger.info("Font loading completed in %.3f seconds", time.time() - font_time)
def _new_canvas(self, width: int, height: int) -> None:
"""Replace ``image``/``draw`` with a black canvas of the given size.
Text is drawn 1-bit (``fontmode = "1"``): the panel has no partial
brightness, so anti-aliasing only smears glyphs.
"""
self.image = Image.new('RGB', (width, height))
self.draw = ImageDraw.Draw(self.image)
self.draw.fontmode = "1"
@staticmethod
def _fallback_advice(cause: str, error: Exception) -> str:
"""What to do about a failed matrix init, for the log.
Only a library failure gets the rebuild hint: advice about the build
or GPIO timing sends someone whose settings were refused the wrong way.
"""
if cause == "settings":
return (f"{error} Change these in the web interface's Display tab "
"(or display.hardware / display.runtime in config.json) "
"and restart the display service.")
if cause == "forced":
return f"Error: {error}."
advice = (f"Error: {error}. If the rgbmatrix library printed a message "
"just before this, it names the problem.")
if is_raspberry_pi_5():
advice += (" On a Raspberry Pi 5, an mmap error means the library was "
"built without Pi 5 support: sudo RPI_RGB_FORCE_REBUILD=1 "
"./first_time_install.sh")
return advice
def _setup_matrix(self):
"""Initialize the RGB matrix with configuration settings."""
_init_error_str = None
_init_cause = None
try:
# Allow callers (e.g., web UI) to force non-hardware fallback mode
if getattr(self, '_force_fallback', False):
raise RuntimeError('Forced fallback mode requested')
options = RGBMatrixOptions()
# Hardware configuration
hardware_config = self.config.get('display', {}).get('hardware', {})
runtime_config = self.config.get('display', {}).get('runtime', {})
# The library has no error path for many settings it can't use:
# it returns no matrix (which the binding doesn't check, so the
# process crashes on its next call) or calls abort(), and systemd
# restarts the service into the same crash. Refuse those first so
# they become a logged, reported fallback (src/matrix_support.py).
refused = refusal_message(library_refusals(
hardware_config, runtime_config, pi5=is_raspberry_pi_5()))
if refused:
if os.getenv("EMULATOR", "false") != "true":
raise MatrixSettingsRefused(refused)
logger.warning("Emulator mode: continuing, but on a real panel the display would not start. %s", refused)
# Every option comes from display.hardware / display.runtime, in
# one place that scripts/scroll_speeds.py shares.
self.apply_matrix_options(options, self.config)
logger.info(f"Initializing RGB Matrix with settings: rows={options.rows}, cols={options.cols}, chain_length={options.chain_length}, parallel={options.parallel}, hardware_mapping={options.hardware_mapping}")
# Initialize the matrix
self.matrix = RGBMatrix(options=options)
logger.info("RGB Matrix initialized successfully")
# Create double buffer for smooth updates. The canvases are always
# full physical size — they back the real chain regardless of mode.
self.offscreen_canvas = self.matrix.CreateFrameCanvas()
self.current_canvas = self.matrix.CreateFrameCanvas()
logger.info("Frame canvases created successfully")
# Double-sided mode: wrap the physical matrix so plugins see the
# logical (per-screen) size, and keep a full-chain buffer to tile
# the rendered screen into once per frame.
ds_config = self.config.get('display', {}).get('double_sided', {})
ds = resolve_double_sided(self.matrix.width, self.matrix.height, ds_config)
self._double_sided = ds
if ds is not None:
self._physical_image = Image.new(
'RGB', (self.matrix.width, self.matrix.height))
self.matrix = _LogicalMatrix(
self.matrix, ds['logical_width'], ds['logical_height'])
# Create image with the (logical) display dimensions
self._new_canvas(self.matrix.width, self.matrix.height)
logger.info(f"Image canvas created with dimensions: {self.matrix.width}x{self.matrix.height}")
# Initialize font with Press Start 2P
try:
self.font = load_truetype(
self._font_asset(self._PRESS_START),
crisp_size(self._PRESS_START, 8))
logger.info("Initial Press Start 2P font loaded successfully")
except Exception as e:
logger.error(f"Failed to load initial font: {e}")
self.font = ImageFont.load_default()
# Draw a test pattern unless caller suppressed it (e.g., web on-demand)
if not getattr(self, '_suppress_test_pattern', False):
self._draw_test_pattern()
except Exception as e:
_init_error_str = str(e)
if isinstance(e, MatrixSettingsRefused):
_init_cause = "settings"
logger.error("Failed to initialize RGB Matrix: %s", e)
else:
_init_cause = "forced" if getattr(self, '_force_fallback', False) else "library"
logger.error(f"Failed to initialize RGB Matrix: {e}", exc_info=True)
# Create a fallback image for web preview using configured dimensions when available
self.matrix = None
try:
fallback_width, fallback_height = physical_size(self.config)
# Mirror double-sided in fallback so the preview shows one screen.
ds_config = self.config.get('display', {}).get('double_sided', {}) if self.config else {}
ds = resolve_double_sided(fallback_width, fallback_height, ds_config)
self._double_sided = ds
if ds is not None:
fallback_width = ds['logical_width']
fallback_height = ds['logical_height']
except Exception:
fallback_width, fallback_height = 128, 32
self._new_canvas(fallback_width, fallback_height)
# Simple fallback visualization so web UI shows a realistic canvas
try:
self.draw.rectangle([0, 0, fallback_width - 1, fallback_height - 1], outline=(255, 0, 0))
self.draw.line([0, 0, fallback_width - 1, fallback_height - 1], fill=(0, 255, 0))
self.draw.text((2, max(0, (fallback_height // 2) - 4)), "Simulation", fill=(0, 128, 255))
except Exception: # nosec B110 - best-effort fallback visualization; drawing errors must not crash startup
# Best-effort; ignore drawing errors in fallback
pass
logger.error(
"Matrix initialization failed — running in fallback/simulation mode "
"(size %dx%d). %s",
fallback_width, fallback_height, self._fallback_advice(_init_cause, e))
# Do not raise here; allow fallback mode so web preview and non-hardware environments work
# Write hardware status file so the web UI can surface init failures
# cause: None when ok; "settings" when LEDMatrix refused the config
# (fix the named settings), "library" when the library itself failed,
# "forced" for a caller-requested fallback. The Display tab keys its
# advice on it.
_hw_status = {"ok": self.matrix is not None, "error": _init_error_str,
"cause": None if self.matrix is not None else _init_cause}
_status_path = "/tmp/led_matrix_hw_status.json" # nosec B108
try:
if os.path.islink(_status_path):
logger.warning("Skipping hardware status write: %s is a symlink", _status_path)
else:
_fd, _tmp_path = tempfile.mkstemp(dir="/tmp", prefix=".led_hw_") # nosec B108
try:
with os.fdopen(_fd, "w") as _f:
json.dump(_hw_status, _f)
_f.flush()
os.fsync(_f.fileno())
os.chmod(_tmp_path, 0o644)
os.replace(_tmp_path, _status_path)
except Exception:
try:
os.unlink(_tmp_path)
except OSError:
pass
raise
except Exception:
logger.error("Failed to write hardware status file", exc_info=True)
@property
def width(self):
"""Get the display width."""
if hasattr(self, 'matrix') and self.matrix is not None:
return self.matrix.width
elif hasattr(self, 'image'):
return self.image.width
else:
return 128 # Default fallback width
@property
def height(self):
"""Get the display height."""
if hasattr(self, 'matrix') and self.matrix is not None:
return self.matrix.height
elif hasattr(self, 'image'):
return self.image.height
else:
return 32 # Default fallback height
def set_brightness(self, brightness: int) -> bool:
"""
Set display brightness at runtime.
Args:
brightness: Brightness level (0-100)
Returns:
True if brightness was set successfully, False otherwise
"""
# Fail fast: validate input type
if not isinstance(brightness, (int, float)):
logger.error(f"[BRIGHTNESS] Invalid brightness type: {type(brightness).__name__}, expected int")
return False
if self.matrix is None:
logger.warning("[BRIGHTNESS] Cannot set brightness in fallback mode")
return False
# Clamp to valid range
brightness = max(0, min(100, int(brightness)))
try:
# RGBMatrix accepts brightness as a property
self.matrix.brightness = brightness
# Brightness applies on the next swap — force a re-push even if
# the image itself is unchanged (belt-and-braces: brightness is
# also part of the dirty-tracking digest when readable).
self._last_pushed_digest = None
logger.info(f"[BRIGHTNESS] Display brightness set to {brightness}%")
return True
except AttributeError as e:
logger.error(f"[BRIGHTNESS] Matrix does not support brightness property: {e}", exc_info=True)
return False
except (TypeError, ValueError) as e:
logger.error(f"[BRIGHTNESS] Invalid brightness value rejected by hardware: {e}", exc_info=True)
return False
def get_brightness(self) -> int:
"""
Get current display brightness.
Returns:
Current brightness level (0-100), or -1 if unavailable
"""
if self.matrix is None:
logger.debug("[BRIGHTNESS] Cannot get brightness in fallback mode")
return -1
try:
return self.matrix.brightness
except AttributeError as e:
logger.warning(f"[BRIGHTNESS] Matrix does not support brightness property: {e}", exc_info=True)
return -1
@staticmethod
def _local_ip() -> Optional[str]:
"""This device's address on the network it routes through, or None.
Deliberately not `hostname -I` or a systemctl probe for AP mode, which
is how the web launcher does it: both spawn processes with multi-second
timeouts, and this runs on the startup path the rest of this change
exists to shorten. Connecting a UDP socket sends no packets -- it only
asks the kernel which source address it would use -- so it costs
microseconds and works with the network down, as long as a route
exists.
"""
sock = None
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.settimeout(0.2)
sock.connect(("8.8.8.8", 80)) # nosec B104 - no traffic; selects a route
ip = sock.getsockname()[0]
return ip if ip and not ip.startswith("127.") else None
except OSError:
return None
finally:
if sock is not None:
try:
sock.close()
except OSError:
pass
def _fitting_font(self, lines, width):
"""The largest font from the usual ladder that fits every line.
The ladder ends at 4x6 at 5px because a full dotted-quad address --
"255.255.255.255", the widest this screen ever shows -- is 66px at
6px and a 64px panel has 62 to give it. That used to squeak in only
because the measurement depended on which text layout engine the host
Pillow had; with the engine pinned it does not, so the rung the
worst case actually needs is here rather than implied.
"""
# The middle rung is on the 7px grid; the bottom one is deliberately
# not. 4x6 advances the same whether it is asked for 6 or 7 -- the
# dotted quad is 66px at both -- so the middle rung costs no width and
# gains the fourth column in every glyph, which is the difference
# between reading an address off a wall and guessing at it. The 5 rung
# is the exception this screen needs: it drops the advance to 4px and
# the quad to 51px, the only rung that fits a 64px panel, and no
# on-grid size does that. It is the one place in the core that draws
# 4x6 off-grid on purpose.
candidates = [self.font,
(self._font_asset(self._FOUR_BY_SIX),
crisp_size(self._FOUR_BY_SIX, 6)),
(self._font_asset(self._FOUR_BY_SIX), 5)]
narrowest = None
for candidate in candidates:
try:
font = candidate
if isinstance(candidate, tuple):
font = load_truetype(candidate[0], candidate[1])
narrowest = font
if all(self.draw.textlength(t, font=font) <= width for t in lines):
return font
except (OSError, ValueError, AttributeError):
continue
# Nothing fit. Return the smallest face that loaded, not self.font --
# falling back to the widest option is how "Initializing" ran off the
# side of a 64px panel in the first place.
return narrowest or self.font
def _draw_startup_banner(self, lines, width: int, height: int) -> None:
"""Centre `lines` over whatever the test pattern already drew.
This screen stays on the panel for the whole initial plugin update, and
on a headless Pi it is the only place the device's address appears
without going looking for it -- so it has to be readable off a wall,
not merely present.
The font is chosen to fit rather than fixed at 8px: "Initializing" is
96px in PressStart2P, which ran off the side of a 64px panel even
before an address was added. And the pattern is punched out behind the
text, because the diagonal runs through the middle of the panel, which
is exactly where this sits.
The text stays blue. It is not decoration: the pattern draws one pure
channel per element -- red border, green diagonal, blue text -- so that
a glance at the panel says whether led_rgb_sequence is right. Swap the
wiring to BGR and the border comes up blue and this text red. Drawing
it white would light all three channels and destroy the only blue
reference on the screen, which is why it is worth a comment rather
than a quiet preference.
"""
if not lines:
return
font = self._fitting_font(lines, width - 2)
line_height = self.draw.textbbox((0, 0), "Ag", font=font)[3] + 1
block_height = line_height * len(lines)
block_top = max(1, (height - block_height) // 2)
block_width = max(self.draw.textlength(t, font=font) for t in lines)
block_left = max(0, (width - block_width) // 2)
self.draw.rectangle(
[block_left - 2, block_top - 1,
block_left + block_width + 1, block_top + block_height],
fill=(0, 0, 0))
for row, line in enumerate(lines):
line_width = self.draw.textlength(line, font=font)
self.draw.text(
(max(0, (width - line_width) // 2), block_top + row * line_height),
line, font=font, fill=(0, 0, 255))
def _draw_test_pattern(self):
"""Draw a test pattern to verify the display is working.
Only called from _setup_matrix once the matrix exists; fallback mode
draws its own "Simulation" canvas there.
"""
try:
self.clear()
# Draw a red rectangle border
self.draw.rectangle([0, 0, self.matrix.width-1, self.matrix.height-1], outline=(255, 0, 0))
# Draw a diagonal line
self.draw.line([0, 0, self.matrix.width-1, self.matrix.height-1], fill=(0, 255, 0))
lines = ["Initializing"]
ip = self._local_ip()
if ip:
lines.append(ip)
self._draw_startup_banner(lines, self.matrix.width, self.matrix.height)
# Update the display once after everything is drawn
self.update_display()
time.sleep(0.5)
except Exception as e:
logger.error(f"Error drawing test pattern: {e}", exc_info=True)
@property
def _capture_mode_active(self) -> bool:
"""True while the calling thread is capturing content off-screen."""
# Read like _current_surface(): a DisplayManager built without
# __init__ (tests do) has no per-thread state, and captures nothing.
state = self.__dict__.get('_capture_state')
return getattr(state, 'active', False) if state is not None else False
@_capture_mode_active.setter
def _capture_mode_active(self, value: bool) -> None:
self._capture_state.active = bool(value)
@contextmanager
def capture_mode(self):
"""Suppress hardware output during off-screen content capture.
Plugins call update_display() as part of their normal display() flow.
When fetching content for Vegas mode the render loop is still running,
so any incidental hardware write causes a visible flash on the matrix.
Entering this context prevents those writes without affecting the PIL
image buffer, which the adapter reads to extract content.
"""
# Restore rather than clear: capture_mode() inside offscreen() must not
# switch suppression off for the rest of the off-screen block.
was_active = self._capture_mode_active
self._capture_mode_active = True
try:
yield
finally:
self._capture_mode_active = was_active
def _current_surface(self) -> Optional[_OffscreenSurface]:
"""The calling thread's off-screen surface, or None."""
state = self.__dict__.get('_surface_state')
return getattr(state, 'surface', None) if state is not None else None
def _writes_suppressed(self) -> bool:
"""True when the calling thread must not touch the panel or its pacing."""
return self._capture_mode_active or self._current_surface() is not None
@contextmanager
def offscreen(self, width: Optional[int] = None, height: Optional[int] = None):
"""Give the calling thread its own canvas to draw on.
Inside the block, for the calling thread only, ``image``, ``draw`` and
``matrix`` (and so ``width``/``height``) are a fresh black canvas of the
requested size, and nothing reaches the hardware: ``update_display()``
and the hardware half of ``clear()`` are skipped, and
``set_scrolling_state()``/``set_frame_hold()`` cannot re-pace the live
scroll. Every other thread, the render loop above all, keeps seeing the
real canvas.
That is what lets Vegas mode render a plugin on its background prefetch
thread. The shared canvas used to be the only one, so any plugin that
drew on it (display capture, scroll-content generation, narrowed
rendering) had to be fetched on the render thread, stalling the scroll
for 40-600ms each. See docs/OFFSCREEN_RENDERING.md.
Blocks nest; each restores the one outside it, also on an exception.
Args:
width: Width of the surface, clamped to the size this thread sees
now. Defaults to that size.
height: Height, likewise.
Yields:
The surface. ``surface.image`` is what the plugin drew.
"""
state = self.__dict__.get('_surface_state')
if state is None:
state = self._surface_state = threading.local()
current_w, current_h = self.width, self.height
target_w = max(1, min(int(width), current_w)) if width else current_w
target_h = max(1, min(int(height), current_h)) if height else current_h
surface = _OffscreenSurface(target_w, target_h, self.matrix)
previous = getattr(state, 'surface', None)
state.surface = surface
try:
yield surface
finally:
state.surface = previous
@contextmanager
def render_size(self, width: int, height: Optional[int] = None):
"""Temporarily present a smaller logical canvas to plugins.
Plugins lay out against the ``display_manager.width``/``height``
properties (which defer to ``matrix.width`` when hardware is present,
and to the canvas when it is not; some older plugins read
``matrix.width`` directly), so the only way to
get a *narrower layout* rather than a cropped one is to tell the plugin
the screen is narrower while it renders. Trimming after the fact cannot
fix a forecast spread across five columns or a progress bar drawn at
100% width — those need the plugin to make different layout decisions.
Vegas mode uses this so a plugin can occupy a fraction of a wide panel
and still look deliberately composed. Reuses the same _LogicalMatrix
indirection that double-sided mode relies on, so plugins see a
consistent size from every accessor.
Built on :meth:`offscreen`, so the narrower canvas belongs to the
calling thread alone; the render loop keeps drawing on the real one.
Args:
width: Logical width to report, clamped to at least 1 and to the
real panel width (a larger canvas would overflow the hardware).
height: Logical height, defaulting to the current height.
"""
current_w = self.width
current_h = self.height
target_w = max(1, min(int(width), current_w))
target_h = max(1, min(int(height) if height else current_h, current_h))
if target_w == current_w and target_h == current_h:
# Nothing to do; avoid pointless wrapping and buffer churn.
yield
return
with self.offscreen(target_w, target_h):
yield
def _composite_double_sided(self):
"""Tile the logical screen across the full physical chain.
Renders once into ``self._physical_image`` by pasting the rendered
logical image ``copies`` times along the configured axis. The paste is
a single memcpy per copy, so the per-frame cost is negligible and the
plugin render path is untouched.
"""
ds = self._double_sided
phys = self._physical_image
lw = ds['logical_width']
lh = ds['logical_height']
for i in range(ds['copies']):
if ds['axis'] == 'vertical':
phys.paste(self.image, (0, i * lh))
else:
phys.paste(self.image, (i * lw, 0))
return phys
def update_display(self):
"""Update the display using double buffering with proper sync.
Skips the panel push entirely when the frame is byte-identical to
the last pushed one (same image digest AND same brightness) — static
content re-rendered every second, and 125 fps loops between actual
scroll steps, otherwise re-walk the full framebuffer for nothing.
The panel keeps refreshing the current frame from its own thread,
so skipping a swap never blanks or freezes the hardware.
Correctness hinges on invalidation: clear() resets the digest (it
writes to the matrix directly), and brightness is PART of the digest
so a dim-schedule change is never skipped. Disable via config
``display.dirty_tracking: false`` if a redraw issue is ever suspected.
Serialized via ``_update_lock``: plugins can call this directly from
background threads of their own, so without a lock two callers
could both pass the digest check before either writes it back,
double-pushing a frame, or interleave the offscreen/current canvas
swap below. The lock is scoped to this method, so callers never
need to know about it.
"""
try:
if self._writes_suppressed():
# This thread is drawing off-screen. Checked before the lock,
# so it never contends with the render loop's swap, and before
# the fallback branch, so captured content never reaches the
# web preview either.
return
# The render loop's watchdog arms on the first frame to reach
# the panel (or the emulator/fallback path standing in for it).
display_watchdog.note_frame()
with self._update_lock:
if self.matrix is None:
# Fallback mode - no actual hardware to update
logger.debug("Update display called in fallback mode (no hardware)")
# Still write a snapshot so the web UI can preview
self._write_snapshot_if_due()
return
# Asked once per frame and the answer reused below: the call
# has side effects (it expires a stale scroll and drops its
# frame hold), so asking again further down could disagree
# with what this frame was already treated as. Asked first,
# so the dirty check, the scan-order segments, the pacing gate,
# the swaps and frame timing all see one answer and the frame
# hold it leaves.
scrolling = self.is_currently_scrolling()
digest = None
frame_checksum = None
# No digest mid-scroll. The skip it feeds is never taken while
# scrolling (see below), so all it bought there was the
# snapshot's changed-frame check -- a tobytes() plus adler32
# over the whole framebuffer every frame (~0.17ms at 256x64
# on a Pi 4, twice that at 512x64) for a decision acted on at
# most once a second. _write_snapshot_if_due hashes for
# itself when a write or touch is actually due. The cost: the
# first static frame after a scroll is always pushed, once.
if self._dirty_tracking_enabled and not scrolling:
try:
brightness = getattr(self.matrix, 'brightness', None)
except AttributeError:
brightness = None
frame_checksum = zlib.adler32(self.image.tobytes())
digest = (frame_checksum, brightness)
if digest == self._last_pushed_digest:
# Nothing changed since the last push — the panel is
# already showing exactly this frame.
#
# Never taken mid-scroll, and that exception is the
# point. SwapOnVSync is what paces the render loop, so
# skipping it also skips the wait: a duplicate frame
# returns in ~8ms instead of ~10ms on a 100Hz panel,
# advances only 0.8px instead of 1.0px, and so makes
# the *next* frame more likely to repeat as well. That
# is self-sustaining -- measured at ~20% duplicate