""" 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 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 # frames mid-scroll on the odds ticker, against # essentially zero on a lighter plugin with identical # scroll settings. Swapping an identical frame costs # one canvas copy and keeps the loop locked to the # panel; falling out of that lock costs smooth motion. # Static content is unaffected: is_currently_scrolling() # expires on its own inactivity threshold. self._write_snapshot_if_due(frame_checksum) return # Copy the current image to the offscreen canvas. In double-sided # mode the logical screen is first tiled across the full chain. blit_started = time.perf_counter() if self._double_sided is not None: segments = [(self._composite_double_sided(), self._frame_hold)] else: segments = self._scan_segments(self.image, scrolling) gate = self.render_gate blit_time = swap_time = 0.0 # Usually one segment: the frame, held for _frame_hold # refreshes. SwapOnVSync blocks for all of them, which is what # paces the render loop to the chosen frame rate. Scan-order # compensation on a held frame splits it, so the lagging rows # change one refresh after the rest. if gate is not None: gate.before_swap(self._frame_hold) for index, (shown, hold) in enumerate(segments): if index: blit_started = time.perf_counter() self.offscreen_canvas.SetImage(shown) blit_done = time.perf_counter() blit_time += blit_done - blit_started self.matrix.SwapOnVSync(self.offscreen_canvas, hold) swap_time += time.perf_counter() - blit_done # Swap our canvas references self.offscreen_canvas, self.current_canvas = self.current_canvas, self.offscreen_canvas if gate is not None: gate.after_swap(self._frame_hold) presented_at = time.perf_counter() self._last_blit_seconds = blit_time / len(segments) self.frame_timing.record( blit_time, swap_time, self._frame_hold, scrolling, presented_at) self._last_pushed_digest = digest # Write a snapshot for the web preview (throttled) self._write_snapshot_if_due(frame_checksum) except Exception as e: # Once with the traceback, then at most every # _UPDATE_ERROR_LOG_INTERVAL with a count of what was skipped. now = time.monotonic() last = self._update_error_logged_at if last is None: self._update_error_logged_at = now logger.error("Error updating display: %s", e, exc_info=True) elif now - last >= _UPDATE_ERROR_LOG_INTERVAL: skipped = self._update_errors_suppressed self._update_error_logged_at = now self._update_errors_suppressed = 0 logger.error("Error updating display: %s (%d more since the " "last report)", e, skipped) else: self._update_errors_suppressed += 1 def _setup_scan_order_compensation(self) -> None: """Work out which rows to show a refresh behind while scrolling. See src/scan_order.py. Only on real hardware: the emulator has no scan order, so there the lag would add the very step it removes elsewhere. """ self._scan_lag_bands = None self._scan_history = deque(maxlen=1) if (self.matrix is None or self._double_sided is not None or os.environ.get('EMULATOR', 'false') == 'true'): return display = self.config.get('display') or {} bands = scan_order.scan_lag_bands( display.get('hardware') or {}, self.height, display.get('scan_order_compensation', 'auto')) if not bands: return self._scan_lag_bands = bands self._scan_history = deque(maxlen=max(lag for _, _, lag in bands)) logger.info( "Scan-order compensation on: while scrolling, %s", ", ".join(f"rows {top}-{bottom - 1} show {lag} refresh(es) behind" for top, bottom, lag in bands)) def _scan_segments(self, image: Image.Image, scrolling: Optional[bool] = None ) -> List[Tuple[Image.Image, int]]: """What to present for this frame: ``[(image, refreshes), ...]``. Mid-scroll with compensation on, lagging rows are taken from earlier refreshes (see src/scan_order.py). At one refresh per frame that is one image. A frame held longer is split at the refresh where the lagging rows catch up, so those rows step a refresh after the rest. The split needs a second blit inside the refresh that follows the first swap, so it is skipped when a blit is too slow to fit. A static screen goes out as it is, and drops the history. So does a static screen's first frame after a scroll, while the scroll state is still set (see end_scroll_for_static_screen): one segment, held for the scroll's hold, with no rows from the scroller's frames. ``scrolling`` is the caller's is_currently_scrolling() answer for this frame. update_display() asks once, before calling this, so the frame hold read here is the one that answer left (an expired scroll's hold is already dropped). None asks here. """ hold = self._frame_hold bands = getattr(self, '_scan_lag_bands', None) if (not bands or not (scrolling if scrolling is not None else self.is_currently_scrolling()) or self._static_handover): if bands: self._scan_history.clear() return [(image, hold)] if hold > 1: blit = getattr(self, '_last_blit_seconds', 0.0) if blit > _SPLIT_BLIT_FRACTION / max(1.0, self.refresh_hz): self._scan_history.clear() return [(image, hold)] segments = [ (scan_order.compose(image, self._scan_history, bands, backs), count) for backs, count in scan_order.refresh_plan(bands, hold) ] # A copy: plugins draw into the same image object frame after frame. self._scan_history.appendleft(image.copy()) return segments def clear(self): """Clear the display completely.""" try: if self.matrix is None: # Fallback mode - just clear the image old_image = getattr(self, 'image', None) width = old_image.width if old_image else 64 height = old_image.height if old_image else 64 self._new_canvas(width, height) logger.debug("Cleared display in fallback mode") return self._new_canvas(self.matrix.width, self.matrix.height) if not self._writes_suppressed(): # Clear both canvases and the underlying matrix to ensure no artifacts. # Failures are non-fatal — the image buffer is already black above, so # the next update_display() call will push clean content regardless. # The matrix content no longer matches the last pushed digest, # so dirty tracking must not skip the next push. self._last_pushed_digest = None try: self.offscreen_canvas.Clear() except (RuntimeError, OSError) as e: logger.error("Failed to clear offscreen canvas: %s", e) try: self.current_canvas.Clear() except (RuntimeError, OSError) as e: logger.error("Failed to clear current canvas: %s", e) try: self.matrix.Clear() except (RuntimeError, OSError) as e: logger.error("Failed to clear matrix front buffer: %s", e) # Note: We do NOT call update_display() here to avoid black flashes. # The caller should call update_display() after drawing new content. # If an immediate clear is needed, the caller can explicitly call # clear() followed by update_display(). except Exception as e: logger.error(f"Error clearing display: {e}") def _draw_bdf_text(self, text, x, y, color=(255, 255, 255), font=None): """Draw text in a BDF ``freetype.Face`` with (x, y) as its top-left. Delegates to :func:`src.common.bdf_font.draw_bdf_text`, which the plugin test harness uses too, so previews and golden images show the pixels the panel does. Clipped to the logical display size. """ try: face = font if font else self.calendar_font draw_bdf_text(self.draw, text, x, y, face, color, clip=(self.width, self.height)) except Exception as e: logger.error(f"Error drawing BDF text: {e}", exc_info=True) #: The bundled faces, and the size each is *asked* for. Every size here is #: run through `crisp_size`, so a number that drifts off the face's pixel #: grid is snapped rather than rendered anti-aliased -- see the note on #: `extra_small_font` below. _FONT_DIR = "assets/fonts" _PRESS_START = "PressStart2P-Regular.ttf" _FOUR_BY_SIX = "4x6-font.ttf" @classmethod def _font_asset(cls, filename: str) -> str: """Install-root-relative path to a bundled face. `_load_fonts` named these relative to the process cwd, which holds under the packaged systemd unit (WorkingDirectory is the install root) and nowhere else: the plugin safety harness, `python run.py` from $HOME, or a unit file written without WorkingDirectory all loaded nothing and fell through to `ImageFont.load_default()`. That failure is silent -- the panel just renders in PIL's default face at whatever size the layout was computed for. """ return resolve_asset_path(f"{cls._FONT_DIR}/{filename}") def _load_fonts(self): """Load fonts with proper error handling.""" # Font objects get new id()s after reload, so the text-width cache would # return stale measurements keyed on the old ids. Clear it here. self._text_width_cache.clear() try: # Load Press Start 2P font press_start = self._font_asset(self._PRESS_START) self.regular_font = load_truetype(press_start, crisp_size(self._PRESS_START, 8)) logger.info("Press Start 2P font loaded successfully") # Use the same font for small text (currently same size; adjust size here if needed) self.small_font = load_truetype(press_start, crisp_size(self._PRESS_START, 8)) logger.info("Press Start 2P small font loaded successfully") # Load 5x7 BDF font for calendar events try: self.calendar_font_path = self._font_asset("5x7.bdf") logger.info(f"Attempting to load 5x7 font from: {self.calendar_font_path}") if not os.path.exists(self.calendar_font_path): raise FileNotFoundError(f"Font file not found at {self.calendar_font_path}") # load_bdf_face sets the size: a Face built without # set_char_size reports face.size.height 0, and every caller # measuring the 5x7 face with get_font_height() got 0 and # stacked rows on top of one another. 5x7.bdf is a fixed # strike, so FreeType renders 7px whatever is asked for -- # the size sets the metrics, not the raster. face, _ = load_bdf_face(self.calendar_font_path, _CALENDAR_FONT_PX) logger.info(f"5x7 calendar font loaded successfully from {self.calendar_font_path}") logger.info(f"Calendar font size: {face.size.height >> 6} pixels") # Store the face for later use self.calendar_font = face except Exception as font_err: logger.error(f"Failed to load 5x7 font: {str(font_err)}", exc_info=True) logger.error("Falling back to small font") self.calendar_font = self.small_font # Assign the loaded calendar_font (which should be 5x7 BDF or its fallback) # to a new attribute for specific use, e.g., in MusicManager. self.bdf_5x7_font = self.calendar_font logger.info(f"Assigned calendar_font (type: {type(self.bdf_5x7_font).__name__}) to bdf_5x7_font.") # Load 4x6 font as extra_small_font. # # Asked for 6 -- the size the face's name suggests -- for years, # and 6 is off its 7px pixel grid. Plugins draw this face with # `draw.fontmode = "1"`, and the mono rasteriser thresholds each # glyph at 50% coverage, so off-grid every glyph came out 3px wide # instead of 4. The lost column deforms the letterforms rather than # merely thinning them: christmas-countdown rendered "UNTIL" as # "VM1JL" and "CHRISTMAS" as "CHAJS1MAS", and zero loses the left # half of its bowl. Those renders were committed as golden images. # # `crisp_size` snaps it to 7. The advance is unchanged -- 5px per # glyph at either size -- so nothing reflows and no layout gets # tighter; a string is at most a pixel or two wider because the # last glyph finally occupies the width it was always given. try: font_path = self._font_asset(self._FOUR_BY_SIX) size = crisp_size(self._FOUR_BY_SIX, 6) logger.info(f"Attempting to load 4x6 TTF font from: {font_path} at size {size}") self.extra_small_font = load_truetype(font_path, size) logger.info(f"4x6 TTF extra small font loaded successfully from {font_path}") except Exception as font_err: logger.error(f"Failed to load 4x6 TTF font: {font_err}. Falling back.") self.extra_small_font = self.small_font except Exception as e: logger.error(f"Error in font loading: {e}", exc_info=True) # Fallback to default font self.regular_font = ImageFont.load_default() self.small_font = self.regular_font self.calendar_font = self.regular_font if not hasattr(self, 'extra_small_font'): self.extra_small_font = self.regular_font if not hasattr(self, 'bdf_5x7_font'): # Ensure bdf_5x7_font also gets a fallback self.bdf_5x7_font = self.regular_font def get_text_width(self, text, font): """Get the width of text when rendered with the given font. Results are cached by (text, font identity) so plugins that measure the same string every frame (e.g. to centre a score) pay only one measurement per unique (text, font) pair. The entry keeps the font alive so its id() can't be recycled, and the cache is LRU-bounded so ever-changing text (clocks, tickers) can't grow it without limit. """ cache_key = (text, id(font)) cached = self._text_width_cache.get(cache_key) if cached is not None: self._text_width_cache.move_to_end(cache_key) return cached[0] try: if isinstance(font, freetype.Face): width = 0 for char in text: font.load_char(char) width += font.glyph.advance.x >> 6 else: bbox = self.draw.textbbox((0, 0), text, font=font) width = bbox[2] - bbox[0] except (AttributeError, TypeError, ValueError, OSError) as e: logger.error("Error getting text width: %s", e) return 0 self._text_width_cache[cache_key] = (width, font) while len(self._text_width_cache) > self._TEXT_WIDTH_CACHE_MAX: self._text_width_cache.popitem(last=False) return width def get_font_height(self, font): """Get the height of the given font for line spacing purposes.""" try: if isinstance(font, freetype.Face): # For FreeType faces (BDF), the 'height' metric gives the recommended line spacing. height = font.size.height >> 6 if height: return height # A Face constructed without set_char_size reports 0, and a # zero line height collapses every stacked row onto one line. # Fall back to the strike the file declares. return _bdf_native_size(font) or 8 else: # For PIL TTF fonts, getmetrics() provides ascent and descent. # The line height is the sum of ascent and descent. ascent, descent = font.getmetrics() return ascent + descent except Exception as e: logger.error(f"Error getting font height for font type {type(font).__name__}: {e}") # Fallback for TTF font if getmetrics() fails, or for other font types. if hasattr(font, 'size'): return font.size return 8 # A reasonable default for an 8px font. def draw_text(self, text: str, x: int = None, y: int = None, color: tuple = (255, 255, 255), small_font: bool = False, font: ImageFont = None, centered: bool = False): """Draw text on the canvas with optional font selection. Args: text: Text to display x: X position (None to auto-center, or used as center point if centered=True) y: Y position (None defaults to 0) color: RGB color tuple small_font: Use small font if True font: Custom font object (overrides small_font) centered: If True, x is treated as center point; if False, x is left edge """ try: # Select font based on parameters if font: current_font = font else: current_font = self.small_font if small_font else self.regular_font # Calculate x position if x is None: # No x provided - center text text_width = self.get_text_width(text, current_font) x = (self.width - text_width) // 2 elif centered: # x is provided as center point - adjust to left edge text_width = self.get_text_width(text, current_font) x = x - (text_width // 2) # Set default y position if not provided if y is None: y = 0 # Default to top of display # Draw the text if isinstance(current_font, freetype.Face): # For BDF fonts, _draw_bdf_text will compute the baseline from the # provided top-left y using the font ascender. Do not adjust here. self._draw_bdf_text(text, x, y, color, current_font) else: # For TTF fonts, use PIL's text drawing which expects top-left. self.draw.text((x, y), text, font=current_font, fill=color) except Exception as e: logger.error(f"Error drawing text: {e}", exc_info=True) def cleanup(self): """Clean up resources.""" if hasattr(self, '_snapshot_cond'): self._stop_snapshot_writer() if hasattr(self, 'matrix') and self.matrix is not None: try: self.matrix.Clear() except Exception as e: logger.warning(f"Error clearing matrix during cleanup: {e}") # Ensure image/draw are reset to a blank state if hasattr(self, 'image') and hasattr(self, 'draw'): try: self._new_canvas(self.width, self.height) except (OSError, RuntimeError, ValueError, MemoryError): logger.debug("Canvas reset during cleanup failed", exc_info=True) # The stall watchdog would otherwise outlive this manager. if getattr(self, 'frame_timing', None) is not None: self.frame_timing.close() # Installed with the recorder; stop timing collections with it. uninstall_gc_monitor() # Reset the singleton state when cleaning up DisplayManager._instance = None def format_date_with_ordinal(self, dt): """Formats a datetime object into 'Mon Aug 30th' style.""" day = dt.day if 11 <= day <= 13: suffix = 'th' else: suffix = {1: 'st', 2: 'nd', 3: 'rd'}.get(day % 10, 'th') return dt.strftime(f"%b %-d{suffix}") @classmethod def apply_matrix_options(cls, options, config: Dict[str, Any]): """Fill ``options`` (an ``RGBMatrixOptions``) from the LEDMatrix config. This is exactly what the display service drives the panel with, so a tool that opens the matrix itself (``scripts/scroll_speeds.py``) gets the same panel -- same runtime ``gpio_slowdown``, ``rp1_rio``, ``panel_type``, orientation and defaults -- rather than a private copy that drifts. Does not open the matrix. Returns ``options``. """ display = config.get('display', {}) if isinstance(config, dict) else {} hardware_config = display.get('hardware', {}) runtime_config = display.get('runtime', {}) # Basic hardware settings options.rows = hardware_config.get('rows', DEFAULT_ROWS) options.cols = hardware_config.get('cols', DEFAULT_COLS) options.chain_length = hardware_config.get('chain_length', DEFAULT_CHAIN_LENGTH) options.parallel = hardware_config.get('parallel', DEFAULT_PARALLEL) options.hardware_mapping = hardware_config.get('hardware_mapping', 'adafruit-hat-pwm') # Performance and stability settings options.brightness = hardware_config.get('brightness', 90) options.pwm_bits = hardware_config.get('pwm_bits', 10) options.pwm_lsb_nanoseconds = hardware_config.get('pwm_lsb_nanoseconds', 150) options.led_rgb_sequence = hardware_config.get('led_rgb_sequence', 'RGB') # Orientation becomes a "Rotate:" pixel mapper; the web preview # composes it the same way so it sizes the canvas identically. options.pixel_mapper_config = compose_pixel_mapper_config(hardware_config) options.row_address_type = hardware_config.get('row_address_type', 0) options.multiplexing = hardware_config.get('multiplexing', 0) options.panel_type = hardware_config.get('panel_type', '') options.disable_hardware_pulsing = hardware_config.get('disable_hardware_pulsing', False) options.show_refresh_rate = hardware_config.get('show_refresh_rate', False) options.limit_refresh_rate_hz = hardware_config.get('limit_refresh_rate_hz', DEFAULT_REFRESH_LIMIT_HZ) options.gpio_slowdown = runtime_config.get('gpio_slowdown', 3) # Disable internal privilege dropping - we manage this via systemd or remain root # This prevents the library from dropping to 'daemon' user which breaks file permissions options.drop_privileges = False # Additional settings from config if 'scan_mode' in hardware_config: options.scan_mode = hardware_config.get('scan_mode') if 'pwm_dither_bits' in hardware_config: options.pwm_dither_bits = hardware_config.get('pwm_dither_bits') if 'inverse_colors' in hardware_config: options.inverse_colors = hardware_config.get('inverse_colors') # Pi 5 only: 0=PIO/RP1 coprocessor (default, less CPU), # 1=RIO/Registered IO (faster; gpio_slowdown effect is inverted in this mode) if 'rp1_rio' in runtime_config: if hasattr(options, 'rp1_rio'): options.rp1_rio = runtime_config.get('rp1_rio') else: logger.warning( "rp1_rio is set in config but the installed rgbmatrix library does " "not support it — the library was likely built without Pi 5 RP1 " "support (mmap to 0x3f000000 instead of RP1 chip). " "Fix: sudo RPI_RGB_FORCE_REBUILD=1 ./first_time_install.sh" ) return options @property def refresh_hz(self) -> float: """The panel's refresh rate in Hz, from the hardware config. The authoritative place to ask, because a plugin only receives its own config section and cannot see display.hardware. Scroll pacing needs this: the speeds a panel can show in whole pixels are refresh_hz divided by the frame hold, so getting it wrong silently produces fractional-pixel motion. See src/common/scroll_config.py. Note this is the configured *cap*, not necessarily what the panel achieves -- scripts/scroll_speeds.py --measure reports the real rate. """ hardware = (self.config.get('display') or {}).get('hardware') or {} try: value = float(hardware.get('limit_refresh_rate_hz') or 0) except (TypeError, ValueError): value = 0.0 return value if value > 0 else float(DEFAULT_REFRESH_LIMIT_HZ) def _scrolling_now(self) -> bool: """Whether a scroll is running, without is_currently_scrolling()'s side effect of expiring the state -- safe from the stall watchdog's thread.""" state = self._scrolling_state return bool(state['is_scrolling']) and ( time.time() - state['last_scroll_activity'] <= state['scroll_inactivity_threshold']) def _frame_timing_info(self) -> Dict[str, Any]: """What the frame-timing stats were measured on, for the soak report.""" display = self.config.get('display') or {} hardware = display.get('hardware') or {} runtime = display.get('runtime') or {} info = {key: hardware.get(key) for key in ( 'rows', 'cols', 'chain_length', 'parallel', 'pwm_bits', 'hardware_mapping', 'limit_refresh_rate_hz', 'pixel_mapper_config')} info['gpio_slowdown'] = runtime.get('gpio_slowdown') info['emulator'] = os.environ.get('EMULATOR', 'false') == 'true' return info def set_frame_hold(self, refreshes: int) -> None: """Hold each pushed frame for this many panel refreshes (>=1). Set by the scroll configuration so a plugin can run at, say, 50px/s on a 100Hz panel as one whole pixel every second refresh, rather than half a pixel every refresh (which has to be blended or repeated unevenly). Reset to 1 whenever scrolling stops, so one plugin's pacing cannot leak into the next thing on screen. """ if self._writes_suppressed(): return # a plugin drawing off-screen cannot re-pace the live scroll try: value = int(refreshes) except (TypeError, ValueError): logger.warning("Ignoring unusable frame hold: %r", refreshes) return self._frame_hold = max(1, min(255, value)) def set_scrolling_state(self, is_scrolling: bool, frame_hold: int = 1): """Set the current scrolling state, and this scroll's frame pacing. Call this when a display starts or stops scrolling. ``frame_hold`` is how many panel refreshes each frame is held for -- 2 gives one whole pixel every second refresh, which is how a scroll runs at half the refresh rate without fractional pixel positions. The hold is part of the scroll's speed. A ScrollHelper configured by ``scroll_config.configure()`` advances a fixed whole-pixel step per presented frame and reads no clock, so pass the returned ``settings.frame_hold`` here: a scroll that leaves it at 1 is presented every refresh and runs ``frame_hold`` times too fast. The hold is set here rather than once at plugin construction because it must not outlive the scroll that asked for it: plugins share one display manager, so a hold left set by whoever scrolled last would silently re-pace the next plugin. Passing it alongside the state makes the lifetime exactly the scroll, and the default of 1 means any caller that does not care gets a new frame every refresh. """ if self._writes_suppressed(): # A plugin captured for Vegas calls this from its own display(); # it must not change the live scroll's state or frame hold. return # A scroll starting or ending also ends a static screen's handover; # see end_scroll_for_static_screen. self._static_handover = False current_time = time.time() # Scrolling callers set this every frame; log transitions only. changed = self._scrolling_state['is_scrolling'] != is_scrolling self._scrolling_state['is_scrolling'] = is_scrolling if is_scrolling: self._scrolling_state['last_scroll_activity'] = current_time self.set_frame_hold(frame_hold) else: self._frame_hold = 1 if changed: logger.debug("Scrolling state set to: %s", is_scrolling) def end_scroll_for_static_screen(self) -> None: """Ready the panel for a static screen's first frame after a scroll. The display controller calls this just before it dispatches the first frame of a screen that runs its 1 Hz loop, and ``set_scrolling_state(False)`` once that dispatch returns. Nothing else ends a scroll at a handover: the state belongs to the screen before, and would only expire 2 s after its last frame. Until then, the frames that dispatch presents go out as drawn, not scan-order composed: each as one segment, held for the scroll's hold. With the state still "scrolling", ``_scan_segments`` would take their lagging rows from the frame before: for the first, the scroller's last frame -- the bottom half of the old ticker under the new screen on a 96x48 panel. At hold 1 that frame stays up for a whole second; at a longer hold its first refresh flashes the old rows. For a second frame in the same call, the rows would come from the first, shown for as long as the first's would be. Dirty tracking does not keep such a frame up past the screen's next redraw: a frame pushed while the scroll state is set leaves it no digest to match, so that redraw is pushed. The rest of that scroll is left on purpose, until the controller ends it: * the scroll state, so the gap from the scroller's last frame to this screen's first is still timed by the frame-timing recorder and watched by the stall watchdog, which is where a slow first ``display()`` shows up; * its frame hold. On a frame that stays up for a second it only moves the swap to the scroll's next hold boundary, and it is the pacing that gap is due at: judged at hold 1, a handover that kept the scroller's own schedule would count as frames late. The next ``set_scrolling_state()`` call, whoever makes it, ends this. One attribute store, so no lock: ``update_display`` reads it once per frame, under its own, and the history is dropped there. """ if self._writes_suppressed(): return # a thread drawing off-screen cannot end the live scroll self._static_handover = True def is_currently_scrolling(self) -> bool: """Check if the display is currently in a scrolling state.""" current_time = time.time() # If explicitly not scrolling, return False if not self._scrolling_state['is_scrolling']: return False # If we've been inactive for the threshold period, consider it not scrolling if current_time - self._scrolling_state['last_scroll_activity'] > self._scrolling_state['scroll_inactivity_threshold']: self._scrolling_state['is_scrolling'] = False # Drop the hold with the state, exactly as set_scrolling_state(False) # does. This path is the one a scroll takes when it ends without # saying so -- the rotation moves on mid-scroll, or the plugin is # torn down -- and leaving the hold set there means every later # plugin, scrolling or static, is presented at refresh/N until # somebody calls set_scrolling_state(False). The hold must not # outlive the scroll that asked for it, however that scroll ends. self._frame_hold = 1 return False return True def defer_update(self, update_func, priority: int = 0): """Defer an update function to be called when not scrolling. Args: update_func: Function to call when not scrolling priority: Priority level (lower numbers = higher priority) """ current_time = time.time() with self._deferred_lock: # Clean up expired updates before adding new ones self._cleanup_expired_deferred_updates(current_time) # Limit queue size to prevent memory issues if len(self._scrolling_state['deferred_updates']) >= self._scrolling_state['max_deferred_updates']: # Remove oldest update to make room self._scrolling_state['deferred_updates'].pop(0) logger.debug("Removed oldest deferred update due to queue size limit") self._scrolling_state['deferred_updates'].append({ 'func': update_func, 'priority': priority, 'timestamp': current_time }) # Only sort if we have a reasonable number of updates to avoid excessive sorting if len(self._scrolling_state['deferred_updates']) <= 20: self._scrolling_state['deferred_updates'].sort(key=lambda x: x['priority']) queued = len(self._scrolling_state['deferred_updates']) logger.debug(f"Deferred update added. Total deferred: {queued}") def process_deferred_updates(self): """Process any deferred updates if not currently scrolling.""" current_time = time.time() # Always clean up expired updates, even if scrolling # This prevents memory leaks from accumulated expired updates with self._deferred_lock: self._cleanup_expired_deferred_updates(current_time) if self.is_currently_scrolling(): return with self._deferred_lock: if not self._scrolling_state['deferred_updates']: return # Process only a limited number of updates per call to avoid blocking max_updates_per_call = min(5, len(self._scrolling_state['deferred_updates'])) updates_to_process = self._scrolling_state['deferred_updates'][:max_updates_per_call] self._scrolling_state['deferred_updates'] = self._scrolling_state['deferred_updates'][max_updates_per_call:] queued = len(self._scrolling_state['deferred_updates']) logger.debug(f"Processing {len(updates_to_process)} deferred updates (queue size: {queued})") # The callables run outside the lock: they are plugin code of any # length, and one that defers again would deadlock on it. failed_updates = [] for update_info in updates_to_process: try: # Check if update is still valid (not too old) if current_time - update_info['timestamp'] > self._scrolling_state['deferred_update_ttl']: logger.debug("Skipping expired deferred update") continue update_info['func']() logger.debug("Deferred update executed successfully") except Exception as e: logger.error(f"Error executing deferred update: {e}") # Only retry recent failures, and limit retries if current_time - update_info['timestamp'] < 60.0: # Only retry for 1 minute failed_updates.append(update_info) # Re-add failed updates to the end of the queue (not the beginning) if failed_updates: with self._deferred_lock: self._scrolling_state['deferred_updates'].extend(failed_updates) def _cleanup_expired_deferred_updates(self, current_time: float): """Remove expired deferred updates to prevent memory leaks. Callers hold ``_deferred_lock``: this reads the list and assigns a filtered copy back. """ ttl = self._scrolling_state['deferred_update_ttl'] initial_count = len(self._scrolling_state['deferred_updates']) # Filter out expired updates self._scrolling_state['deferred_updates'] = [ update for update in self._scrolling_state['deferred_updates'] if current_time - update['timestamp'] <= ttl ] removed_count = initial_count - len(self._scrolling_state['deferred_updates']) if removed_count > 0: logger.debug(f"Cleaned up {removed_count} expired deferred updates") def _viewer_is_fresh(self, now: float) -> bool: """True when a browser preview is watching (marker file touched by the web SSE broadcaster). The marker is stat'd at most once per second — at 125 fps loops a per-call stat would be pure overhead.""" if (now - self._viewer_check_ts) >= 1.0: self._viewer_check_ts = now try: marker_age = now - os.stat(self._viewer_marker_path).st_mtime self._viewer_fresh = marker_age < snapshot_policy.VIEWER_MARKER_FRESH_SEC except OSError: self._viewer_fresh = False return self._viewer_fresh def _write_snapshot_if_due(self, frame_checksum: Optional[int] = None) -> None: """Mirror the current frame to the preview snapshot when the policy says it's worth it — see src/common/snapshot_policy.py. Unchanged frames are never re-encoded; without viewers the cadence drops to the idle keepalive. Args: frame_checksum: adler32 of the current frame, when the caller has already computed one. Dirty tracking checksums every static frame a few lines above the call site, and re-deriving it here meant a second tobytes() plus a second pass over the whole framebuffer on every single frame — ~0.17ms per frame of the two combined at 256x64, paid 100 times a second to reach the same number. None (mid-scroll, dirty tracking off, no hardware): the frame is hashed here, and only when the policy could act on it. """ try: now = time.time() viewer_fresh = self._viewer_is_fresh(now) if viewer_fresh and not self._viewer_was_fresh: # A preview just opened: let the next changed frame through # immediately instead of waiting out the idle interval. self._last_snapshot_ts = 0.0 self._viewer_was_fresh = viewer_fresh if frame_checksum is not None: digest = frame_checksum action = snapshot_policy.decide( now, self._last_snapshot_ts, self._last_snapshot_touch_ts, viewer_fresh, digest != self._last_snapshot_digest) else: # Ask as if the frame had changed before paying to find out. # decide() is monotone in frame_changed -- a SKIP for a # changed frame is a SKIP for an unchanged one too (its touch # branch ignores frame_changed) -- so returning here gives the # same answer the hash would have, and on most frames the hash # is never taken. test_snapshot_policy.py holds decide() to it. action = snapshot_policy.decide( now, self._last_snapshot_ts, self._last_snapshot_touch_ts, viewer_fresh, True) if action is snapshot_policy.SnapshotAction.SKIP: return digest = zlib.adler32(self.image.tobytes()) if digest == self._last_snapshot_digest: # Unchanged after all: the decision an unchanged frame gets. action = snapshot_policy.decide( now, self._last_snapshot_ts, self._last_snapshot_touch_ts, viewer_fresh, False) if action is snapshot_policy.SnapshotAction.SKIP: return if (action is snapshot_policy.SnapshotAction.TOUCH and self._saved_snapshot_digest == digest): # mtime bump only: keeps the health check (snapshot age) # green without paying for a PNG encode of an unchanged frame os.utime(self._snapshot_path, None) self._last_snapshot_touch_ts = now return # (A TOUCH for a frame that isn't on disk yet -- still queued, or # its write failed -- is written instead: touching would make the # older file on disk look current.) # WRITE. Mid-scroll the PNG encode goes to a background thread: at # 512x64 it takes 12-14ms on a Pi 4, longer than a 95Hz refresh, # so on the render thread every preview write made the next swap # miss its vsync -- five visible hitches a second, but only while # someone had the web preview open. Pillow releases the GIL while # it compresses, so the encode no longer holds the loop up. Static # frames still write inline: nothing is moving to disturb. if self.is_currently_scrolling(): self._queue_snapshot(self.image.copy(), digest) else: # A scroll that just ended can leave its last frame queued or # mid-write; it must not land on top of this newer one. with self._snapshot_write_lock: with self._snapshot_cond: self._snapshot_pending = None self._save_snapshot(self.image) self._saved_snapshot_digest = digest self._last_snapshot_ts = now self._last_snapshot_touch_ts = now self._last_snapshot_digest = digest except Exception as e: self._log_snapshot_failure(e) def _log_snapshot_failure(self, error: Exception) -> None: # Snapshot failures must never break display — but they must not # be silent either: the snapshot's mtime is the web UI's display # mirror AND its hardware-liveness proxy, so a quietly failing # write freezes the mirror and makes health checks lie (seen in # the field: a stale root-owned /tmp file froze it for a day). # Warn at most once per 5 minutes to avoid log spam. now = time.time() if (now - self._snapshot_fail_log_ts) > 300: self._snapshot_fail_log_ts = now logger.warning("Snapshot write failing (web preview/health " "mirror is stale): %s", error) else: logger.debug(f"Snapshot write skipped: {error}") def _save_snapshot(self, image: Image.Image) -> None: """Encode ``image`` to the snapshot path atomically. Raises on failure.""" # Ensure directory permissions once, not per frame snapshot_path_obj = Path(self._snapshot_path) if not self._snapshot_dir_prepared: # Never modify /tmp permissions - it has special system # permissions (1777) that must not be changed or it breaks # apt and other system tools parent_dir = snapshot_path_obj.parent if parent_dir and str(parent_dir) != '/tmp': # nosec B108 - guard to skip /tmp for permission ops ensure_directory_permissions(parent_dir, get_assets_dir_mode()) self._snapshot_dir_prepared = True # Write atomically: temp then replace. The temp name must be # unique, not ".tmp": /tmp is world-writable and sticky, # and this file is written by whichever user the display service # runs as while tests and tooling run as someone else. A leftover # fixed-name temp owned by another user is then unopenable even by # root (fs.protected_regular refuses O_CREAT on a foreign file in a # sticky dir), which froze the preview and the health check's # liveness proxy until somebody deleted it by hand. Same pattern as # the hardware-status write above. _fd, tmp_path = tempfile.mkstemp( dir=str(snapshot_path_obj.parent), prefix=f".{snapshot_path_obj.name}.", suffix=".tmp") try: with os.fdopen(_fd, "wb") as _f: image.save(_f, format='PNG', compress_level=_SNAPSHOT_PNG_COMPRESS_LEVEL) os.chmod(tmp_path, 0o644) os.replace(tmp_path, self._snapshot_path) except Exception: # Never leave the temp behind -- that is what made the failure # permanent rather than transient. try: os.unlink(tmp_path) except OSError: pass # Fallback to direct save if replace not supported image.save(self._snapshot_path, format='PNG', compress_level=_SNAPSHOT_PNG_COMPRESS_LEVEL) # Set proper file permissions after saving try: ensure_file_permissions(snapshot_path_obj, get_assets_file_mode()) except Exception: pass def _queue_snapshot(self, image: Image.Image, digest: Optional[int] = None) -> None: """Hand a frame to the snapshot writer thread; the newest frame wins. One slot, not a queue: if the writer is still encoding when the next frame is due, the waiting frame is simply replaced. The preview wants the latest frame, and a backlog would only cost memory and CPU. """ with self._snapshot_cond: self._snapshot_pending = (image, digest) if self._snapshot_thread is None or not self._snapshot_thread.is_alive(): self._snapshot_thread = threading.Thread( target=self._snapshot_writer, daemon=True, name="snapshot-writer") self._snapshot_thread.start() self._snapshot_cond.notify() def _snapshot_writer(self) -> None: while True: with self._snapshot_cond: while self._snapshot_pending is None and not self._snapshot_stop: self._snapshot_cond.wait() if self._snapshot_stop: return # shutting down: a pending frame is dropped # The write lock before the frame: whichever of this and an inline # static save gets it first also writes first, and a static save # clears the slot, so an older frame never lands on a newer one. with self._snapshot_write_lock: with self._snapshot_cond: pending, self._snapshot_pending = self._snapshot_pending, None if pending is None: continue image, digest = pending try: self._save_snapshot(image) self._saved_snapshot_digest = digest except Exception as e: # The frame was recorded as written when it was queued. # Forget that, so an unchanged frame is written again # rather than only mtime-touching a stale file into # looking healthy. self._last_snapshot_digest = None self._log_snapshot_failure(e) def _stop_snapshot_writer(self, timeout: float = 1.0) -> None: """Stop the writer thread, dropping any frame it has not started.""" with self._snapshot_cond: self._snapshot_stop = True self._snapshot_pending = None self._snapshot_cond.notify_all() thread = self._snapshot_thread if thread is not None and thread is not threading.current_thread(): thread.join(timeout) self._snapshot_thread = None