mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-01 16:58:06 +00:00
* perf(display): dirty tracking in update_display + plugin FPS declaration update_display now skips SetImage+SwapOnVSync when the frame is byte-identical to the last pushed one (adler32 digest) AND brightness is unchanged — brightness is part of the digest, and set_brightness additionally resets it, so a dim-schedule change can never be skipped. clear() resets the digest (it writes to the matrix directly). Skipping a swap is hardware-safe: the panel refreshes the current frame from the driver's own thread; swaps only change content. Kill switch: display.dirty_tracking: false restores always-push. display_controller's high-FPS decision gains a precedence step: a plugin exposing needs_high_fps is honored first (so static-image can declare False for still PNGs and stop burning a 125fps loop on them); static-image without the attribute keeps its historical forced high-FPS (GIF back-compat); scrolling logic is otherwise unchanged. Verified with 7 tests against the real DisplayManager on RGBMatrixEmulator (identical-frame skip, pixel-change push, clear and brightness invalidation, snapshot-through-skip, kill switch) plus the 202-test display/controller/vegas suites, and a clean devpi deploy. Audit: every SetImage/SwapOnVSync/Clear/brightness call site is inside display_manager — no external writer can bypass the digest. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FqzC1nzTWL4kaqgMaQZFam * fix(display): serialize update_display, narrow brightness exception, log fixes CodeRabbit review on #406, verified against current code: - update_display() can genuinely be called from background threads (some sports base classes call it directly from inside update() for an immediate "live" refresh), not just the render loop — confirmed via the existing follower-mode gating wrapper in display_controller.py, which exists specifically because "background plugin threads" can reach it. Without a lock, two callers could both pass the digest check before either writes _last_pushed_digest back, causing a redundant push, or interleave the offscreen/current canvas swap. Added self._update_lock (RLock, in case of re-entrant callers) around the full method body so every call site is automatically covered — no caller changes needed. (No prior lock existed to reuse on DisplayManager; this adds one.) - Narrowed the brightness-read exception handler to AttributeError, matching the established pattern in get_brightness()/set_brightness() — a getattr() with a default already swallows AttributeError, so the only case this guards is the property getter itself raising, and the established pattern treats that as an expected, specific failure mode rather than something to blanket-catch. - FPS-check debug log now includes the plugin_id already in scope (previously only active_mode) and a "[DisplayController]" prefix for grep-ability, matching the sibling log two lines below it. - test_display_dirty_tracking.py: dm fixture and test_config_flag_wires_through now reset the DisplayManager singleton on teardown, matching the pattern test_display_manager.py already uses elsewhere in the same file family. - test_snapshot_still_written_on_skip previously only exercised the non-skip (push) path despite its name; now performs a second update that meets the skip conditions (identical frame) and asserts the snapshot is still written even though the panel push itself is skipped. All 7 dirty-tracking tests pass, plus the full display_manager/ display_controller/vegas suite (140 passed). Full repo suite has only the 5 known pre-existing failures (double-sided config x2, state_reconciliation x2, and test_circuit_breaker's conftest.py mock signature drift — the latter fixed in #400, which this branch's base predates). --------- Co-authored-by: Chuck <chuck@example.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1301 lines
60 KiB
Python
1301 lines
60 KiB
Python
"""
|
||
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.
|
||
"""
|
||
|
||
import json
|
||
import os
|
||
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
|
||
import threading
|
||
import time
|
||
from collections import OrderedDict
|
||
from typing import Dict, Any, List, Optional, Tuple
|
||
import logging
|
||
import math
|
||
import zlib
|
||
import freetype
|
||
|
||
from src.common import snapshot_policy
|
||
from src.common.permission_utils import (
|
||
ensure_directory_permissions,
|
||
ensure_file_permissions,
|
||
get_assets_dir_mode,
|
||
get_assets_file_mode,
|
||
)
|
||
|
||
# Get logger without configuring
|
||
logger = logging.getLogger(__name__)
|
||
logger.setLevel(logging.INFO) # Set to INFO level
|
||
|
||
|
||
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 ``matrix.width`` /
|
||
``matrix.height`` (the documented convention, used at 30+ call sites), 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)
|
||
|
||
|
||
def _resolve_double_sided(physical_width: int, physical_height: int,
|
||
ds_config: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||
"""Validate the ``display.double_sided`` config against the physical size.
|
||
|
||
Returns a dict ``{copies, axis, logical_width, logical_height}`` when the
|
||
feature is enabled and the physical panel divides evenly into ``copies``
|
||
along the chosen axis, otherwise ``None`` (single-screen behaviour). Bad
|
||
config is logged and disabled rather than raised — a misconfigured panel
|
||
should still light up.
|
||
"""
|
||
if not isinstance(ds_config, dict) or not ds_config.get('enabled', False):
|
||
return None
|
||
|
||
copies = ds_config.get('copies', 2)
|
||
if not isinstance(copies, int) or copies < 2:
|
||
logger.warning(
|
||
"double_sided: 'copies' must be an integer >= 2 (got %r); "
|
||
"disabling double-sided mode", copies)
|
||
return None
|
||
|
||
axis = ds_config.get('axis', 'horizontal')
|
||
if axis not in ('horizontal', 'vertical'):
|
||
logger.warning(
|
||
"double_sided: 'axis' must be 'horizontal' or 'vertical' "
|
||
"(got %r); defaulting to 'horizontal'", axis)
|
||
axis = 'horizontal'
|
||
|
||
# Horizontal splits the chain (panels side by side); vertical splits the
|
||
# parallel outputs (panels stacked). The split axis must divide evenly.
|
||
if axis == 'horizontal':
|
||
if physical_width % copies != 0:
|
||
logger.warning(
|
||
"double_sided: physical width %d is not divisible by copies "
|
||
"%d; disabling double-sided mode", physical_width, copies)
|
||
return None
|
||
logical_width = physical_width // copies
|
||
logical_height = physical_height
|
||
else:
|
||
if physical_height % copies != 0:
|
||
logger.warning(
|
||
"double_sided: physical height %d is not divisible by copies "
|
||
"%d; disabling double-sided mode", physical_height, copies)
|
||
return None
|
||
logical_width = physical_width
|
||
logical_height = physical_height // copies
|
||
|
||
logger.info(
|
||
"double_sided enabled: %d copies on %s axis — logical screen %dx%d "
|
||
"tiled across physical %dx%d", copies, axis, logical_width,
|
||
logical_height, physical_width, physical_height)
|
||
return {
|
||
'copies': copies,
|
||
'axis': axis,
|
||
'logical_width': logical_width,
|
||
'logical_height': logical_height,
|
||
}
|
||
|
||
|
||
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
|
||
_initialized = False
|
||
|
||
def __new__(cls, *args, **kwargs):
|
||
if cls._instance is None:
|
||
cls._instance = super(DisplayManager, cls).__new__(cls)
|
||
return cls._instance
|
||
|
||
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
|
||
# When True, update_display() and clear() skip hardware writes (used during off-screen content capture)
|
||
self._capture_mode_active = False
|
||
# 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:
|
||
# full 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
|
||
self._snapshot_dir_prepared = False
|
||
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
|
||
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
|
||
}
|
||
|
||
self._setup_matrix()
|
||
logger.info("Matrix setup completed in %.3f seconds", time.time() - start_time)
|
||
|
||
font_time = time.time()
|
||
self._load_fonts()
|
||
logger.info("Font loading completed in %.3f seconds", time.time() - font_time)
|
||
|
||
# Initialize managers
|
||
# Calendar manager is now initialized by DisplayController
|
||
|
||
def _setup_matrix(self):
|
||
"""Initialize the RGB matrix with configuration settings."""
|
||
_init_error_str = 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', {})
|
||
|
||
# Basic hardware settings
|
||
options.rows = hardware_config.get('rows', 32)
|
||
options.cols = hardware_config.get('cols', 64)
|
||
options.chain_length = hardware_config.get('chain_length', 2)
|
||
options.parallel = hardware_config.get('parallel', 1)
|
||
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')
|
||
options.pixel_mapper_config = hardware_config.get('pixel_mapper_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', 90)
|
||
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"
|
||
)
|
||
|
||
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.image = Image.new('RGB', (self.matrix.width, self.matrix.height))
|
||
self.draw = ImageDraw.Draw(self.image)
|
||
logger.info(f"Image canvas created with dimensions: {self.matrix.width}x{self.matrix.height}")
|
||
|
||
# Initialize font with Press Start 2P
|
||
try:
|
||
self.font = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 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)
|
||
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:
|
||
hardware_config = self.config.get('display', {}).get('hardware', {}) if self.config else {}
|
||
rows = int(hardware_config.get('rows', 32))
|
||
cols = int(hardware_config.get('cols', 64))
|
||
chain_length = int(hardware_config.get('chain_length', 2))
|
||
parallel = int(hardware_config.get('parallel', 1))
|
||
fallback_width = max(1, cols * chain_length)
|
||
fallback_height = max(1, rows * parallel)
|
||
# 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.image = Image.new('RGB', (fallback_width, fallback_height))
|
||
self.draw = ImageDraw.Draw(self.image)
|
||
# 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(
|
||
f"Matrix initialization failed — running in fallback/simulation mode "
|
||
f"(size {fallback_width}x{fallback_height}). Error: {e}. "
|
||
"On Raspberry Pi 5: ensure rpi-rgb-led-matrix was built from the latest "
|
||
"submodule (re-run first_time_install.sh). gpio_slowdown of 2–3 is typical for Pi 5 PIO mode."
|
||
)
|
||
# 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
|
||
_hw_status = {"ok": self.matrix is not None, "error": _init_error_str}
|
||
_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
|
||
|
||
def _draw_test_pattern(self):
|
||
"""Draw a test pattern to verify the display is working."""
|
||
try:
|
||
self.clear()
|
||
|
||
if self.matrix is None:
|
||
# Fallback mode - just draw on the image
|
||
self.draw.rectangle([0, 0, self.image.width-1, self.image.height-1], outline=(255, 0, 0))
|
||
self.draw.line([0, 0, self.image.width-1, self.image.height-1], fill=(0, 255, 0))
|
||
self.draw.text((10, 10), "Simulation", font=self.font, fill=(0, 0, 255))
|
||
logger.info("Drew test pattern in fallback mode")
|
||
return
|
||
|
||
# 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))
|
||
|
||
# Draw some text - changed from "TEST" to "Initializing" with smaller font
|
||
self.draw.text((10, 10), "Initializing", font=self.font, fill=(0, 0, 255))
|
||
|
||
# Update the display once after everything is drawn
|
||
self.update_display()
|
||
time.sleep(0.5) # Reduced from 1 second to 0.5 seconds for faster animation
|
||
|
||
except Exception as e:
|
||
logger.error(f"Error drawing test pattern: {e}", exc_info=True)
|
||
|
||
@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.
|
||
"""
|
||
self._capture_mode_active = True
|
||
try:
|
||
yield
|
||
finally:
|
||
self._capture_mode_active = False
|
||
|
||
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 (e.g. sports base classes push an immediate
|
||
"live" refresh from inside update()), 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:
|
||
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
|
||
|
||
if self._capture_mode_active:
|
||
return # Skip hardware write — content is being captured off-screen
|
||
|
||
digest = None
|
||
if self._dirty_tracking_enabled:
|
||
try:
|
||
brightness = getattr(self.matrix, 'brightness', None)
|
||
except AttributeError:
|
||
brightness = None
|
||
digest = (zlib.adler32(self.image.tobytes()), brightness)
|
||
if digest == self._last_pushed_digest:
|
||
# Nothing changed since the last push — the panel is
|
||
# already showing exactly this frame.
|
||
self._write_snapshot_if_due()
|
||
return
|
||
|
||
# Copy the current image to the offscreen canvas. In double-sided
|
||
# mode the logical screen is first tiled across the full chain.
|
||
if self._double_sided is not None:
|
||
self.offscreen_canvas.SetImage(self._composite_double_sided())
|
||
else:
|
||
self.offscreen_canvas.SetImage(self.image)
|
||
|
||
# Swap buffers immediately
|
||
self.matrix.SwapOnVSync(self.offscreen_canvas)
|
||
|
||
# Swap our canvas references
|
||
self.offscreen_canvas, self.current_canvas = self.current_canvas, self.offscreen_canvas
|
||
|
||
self._last_pushed_digest = digest
|
||
|
||
# Write a snapshot for the web preview (throttled)
|
||
self._write_snapshot_if_due()
|
||
except Exception as e:
|
||
logger.error(f"Error updating display: {e}")
|
||
|
||
def clear(self):
|
||
"""Clear the display completely."""
|
||
try:
|
||
if self.matrix is None:
|
||
# Fallback mode - just clear the image
|
||
# Explicitly clear old image reference to help garbage collection
|
||
old_image = getattr(self, 'image', None)
|
||
width = old_image.width if old_image else 64
|
||
height = old_image.height if old_image else 64
|
||
if old_image is not None:
|
||
del old_image
|
||
|
||
self.image = Image.new('RGB', (width, height))
|
||
self.draw = ImageDraw.Draw(self.image)
|
||
logger.debug("Cleared display in fallback mode")
|
||
return
|
||
|
||
# Explicitly clear old image reference to help garbage collection
|
||
old_image = getattr(self, 'image', None)
|
||
if old_image is not None:
|
||
del old_image
|
||
|
||
# Create a new black image
|
||
self.image = Image.new('RGB', (self.matrix.width, self.matrix.height))
|
||
self.draw = ImageDraw.Draw(self.image)
|
||
|
||
if not self._capture_mode_active:
|
||
# 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 using BDF font with proper bitmap handling."""
|
||
try:
|
||
# Use the passed font or fall back to calendar_font
|
||
face = font if font else self.calendar_font
|
||
|
||
# Compute baseline from font ascender so caller can pass top-left y
|
||
try:
|
||
ascender_px = face.size.ascender >> 6
|
||
except Exception:
|
||
ascender_px = 0
|
||
baseline_y = y + ascender_px
|
||
|
||
for char in text:
|
||
face.load_char(char)
|
||
bitmap = face.glyph.bitmap
|
||
|
||
# Get glyph metrics
|
||
glyph_left = face.glyph.bitmap_left
|
||
glyph_top = face.glyph.bitmap_top
|
||
|
||
# Draw the character
|
||
for i in range(bitmap.rows):
|
||
for j in range(bitmap.width):
|
||
byte_index = i * bitmap.pitch + (j // 8)
|
||
if byte_index < len(bitmap.buffer):
|
||
byte = bitmap.buffer[byte_index]
|
||
if byte & (1 << (7 - (j % 8))):
|
||
# Calculate actual pixel position
|
||
pixel_x = x + glyph_left + j
|
||
pixel_y = baseline_y - glyph_top + i
|
||
# Only draw if within bounds
|
||
if (0 <= pixel_x < self.width and 0 <= pixel_y < self.height):
|
||
self.draw.point((pixel_x, pixel_y), fill=color)
|
||
|
||
# Move to next character
|
||
x += face.glyph.advance.x >> 6
|
||
|
||
except Exception as e:
|
||
logger.error(f"Error drawing BDF text: {e}", exc_info=True)
|
||
|
||
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
|
||
self.regular_font = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 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 = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 8)
|
||
logger.info("Press Start 2P small font loaded successfully")
|
||
|
||
# Load 5x7 BDF font for calendar events
|
||
try:
|
||
self.calendar_font_path = "assets/fonts/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 with freetype for proper BDF handling
|
||
face = freetype.Face(self.calendar_font_path)
|
||
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
|
||
try:
|
||
font_path = "assets/fonts/4x6-font.ttf"
|
||
logger.info(f"Attempting to load 4x6 TTF font from: {font_path} at size 6")
|
||
self.extra_small_font = ImageFont.truetype(font_path, 6)
|
||
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.
|
||
return font.size.height >> 6
|
||
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 draw_sun(self, x: int, y: int, size: int = 16):
|
||
"""Draw a sun icon using yellow circles and lines."""
|
||
center = (x + size//2, y + size//2)
|
||
radius = size//3
|
||
|
||
# Draw the center circle
|
||
self.draw.ellipse([center[0]-radius, center[1]-radius,
|
||
center[0]+radius, center[1]+radius],
|
||
fill=(255, 255, 0)) # Yellow
|
||
|
||
# Draw the rays
|
||
ray_length = size//4
|
||
for angle in range(0, 360, 45):
|
||
rad = math.radians(angle)
|
||
start_x = center[0] + (radius * math.cos(rad))
|
||
start_y = center[1] + (radius * math.sin(rad))
|
||
end_x = center[0] + ((radius + ray_length) * math.cos(rad))
|
||
end_y = center[1] + ((radius + ray_length) * math.sin(rad))
|
||
self.draw.line([start_x, start_y, end_x, end_y], fill=(255, 255, 0), width=2)
|
||
|
||
def draw_cloud(self, x: int, y: int, size: int = 16, color=(200, 200, 200)):
|
||
"""Draw a cloud icon."""
|
||
# Draw multiple circles to form a cloud shape
|
||
self.draw.ellipse([x+size//4, y+size//3, x+size//4+size//2, y+size//3+size//2], fill=color)
|
||
self.draw.ellipse([x+size//2, y+size//3, x+size//2+size//2, y+size//3+size//2], fill=color)
|
||
self.draw.ellipse([x+size//3, y+size//6, x+size//3+size//2, y+size//6+size//2], fill=color)
|
||
|
||
def draw_rain(self, x: int, y: int, size: int = 16):
|
||
"""Draw rain icon with cloud and droplets."""
|
||
# Draw cloud
|
||
self.draw_cloud(x, y, size)
|
||
|
||
# Draw rain drops
|
||
drop_color = (0, 0, 255) # Blue
|
||
drop_size = size//6
|
||
for i in range(3):
|
||
drop_x = x + size//4 + (i * size//3)
|
||
drop_y = y + size//2
|
||
self.draw.line([drop_x, drop_y, drop_x, drop_y+drop_size],
|
||
fill=drop_color, width=2)
|
||
|
||
def draw_snow(self, x: int, y: int, size: int = 16):
|
||
"""Draw snow icon with cloud and snowflakes."""
|
||
# Draw cloud
|
||
self.draw_cloud(x, y, size)
|
||
|
||
# Draw snowflakes
|
||
snow_color = (200, 200, 255) # Light blue
|
||
for i in range(3):
|
||
center_x = x + size//4 + (i * size//3)
|
||
center_y = y + size//2 + size//4
|
||
# Draw a small star shape
|
||
for angle in range(0, 360, 60):
|
||
rad = math.radians(angle)
|
||
end_x = center_x + (size//8 * math.cos(rad))
|
||
end_y = center_y + (size//8 * math.sin(rad))
|
||
self.draw.line([center_x, center_y, end_x, end_y],
|
||
fill=snow_color, width=1)
|
||
|
||
# Weather icon color constants
|
||
WEATHER_COLORS = {
|
||
'sun': (255, 200, 0), # Bright yellow
|
||
'cloud': (200, 200, 200), # Light gray
|
||
'rain': (0, 100, 255), # Light blue
|
||
'snow': (220, 220, 255), # Ice blue
|
||
'storm': (255, 255, 0) # Lightning yellow
|
||
}
|
||
|
||
def _draw_sun(self, x: int, y: int, size: int) -> None:
|
||
"""Draw a sun icon with rays."""
|
||
center_x, center_y = x + size//2, y + size//2
|
||
radius = size//4
|
||
ray_length = size//3
|
||
|
||
# Draw the main sun circle
|
||
self.draw.ellipse([center_x - radius, center_y - radius,
|
||
center_x + radius, center_y + radius],
|
||
fill=self.WEATHER_COLORS['sun'])
|
||
|
||
# Draw sun rays
|
||
for angle in range(0, 360, 45):
|
||
rad = math.radians(angle)
|
||
start_x = center_x + int((radius + 2) * math.cos(rad))
|
||
start_y = center_y + int((radius + 2) * math.sin(rad))
|
||
end_x = center_x + int((radius + ray_length) * math.cos(rad))
|
||
end_y = center_y + int((radius + ray_length) * math.sin(rad))
|
||
self.draw.line([start_x, start_y, end_x, end_y],
|
||
fill=self.WEATHER_COLORS['sun'], width=2)
|
||
|
||
def _draw_cloud(self, x: int, y: int, size: int) -> None:
|
||
"""Draw a cloud using multiple circles."""
|
||
cloud_color = self.WEATHER_COLORS['cloud']
|
||
base_y = y + size//2
|
||
|
||
# Draw main cloud body (3 overlapping circles)
|
||
circle_radius = size//4
|
||
positions = [
|
||
(x + size//3, base_y), # Left circle
|
||
(x + size//2, base_y - size//6), # Top circle
|
||
(x + 2*size//3, base_y) # Right circle
|
||
]
|
||
|
||
for cx, cy in positions:
|
||
self.draw.ellipse([cx - circle_radius, cy - circle_radius,
|
||
cx + circle_radius, cy + circle_radius],
|
||
fill=cloud_color)
|
||
|
||
def _draw_rain(self, x: int, y: int, size: int) -> None:
|
||
"""Draw rain drops falling from a cloud."""
|
||
self._draw_cloud(x, y, size)
|
||
rain_color = self.WEATHER_COLORS['rain']
|
||
|
||
# Draw rain drops at an angle
|
||
drop_size = size//8
|
||
drops = [
|
||
(x + size//4, y + 2*size//3),
|
||
(x + size//2, y + 3*size//4),
|
||
(x + 3*size//4, y + 2*size//3)
|
||
]
|
||
|
||
for dx, dy in drops:
|
||
# Draw angled rain drops
|
||
self.draw.line([dx, dy, dx - drop_size//2, dy + drop_size],
|
||
fill=rain_color, width=2)
|
||
|
||
def _draw_snow(self, x: int, y: int, size: int) -> None:
|
||
"""Draw snowflakes falling from a cloud."""
|
||
self._draw_cloud(x, y, size)
|
||
snow_color = self.WEATHER_COLORS['snow']
|
||
|
||
# Draw snowflakes
|
||
flake_size = size//6
|
||
flakes = [
|
||
(x + size//4, y + 2*size//3),
|
||
(x + size//2, y + 3*size//4),
|
||
(x + 3*size//4, y + 2*size//3)
|
||
]
|
||
|
||
for fx, fy in flakes:
|
||
# Draw a snowflake (six-pointed star)
|
||
for angle in range(0, 360, 60):
|
||
rad = math.radians(angle)
|
||
end_x = fx + int(flake_size * math.cos(rad))
|
||
end_y = fy + int(flake_size * math.sin(rad))
|
||
self.draw.line([fx, fy, end_x, end_y],
|
||
fill=snow_color, width=1)
|
||
|
||
def _draw_storm(self, x: int, y: int, size: int) -> None:
|
||
"""Draw a storm cloud with lightning bolt."""
|
||
self._draw_cloud(x, y, size)
|
||
|
||
# Draw lightning bolt
|
||
bolt_color = self.WEATHER_COLORS['storm']
|
||
bolt_points = [
|
||
(x + size//2, y + size//2), # Top
|
||
(x + 3*size//5, y + 2*size//3), # Middle right
|
||
(x + 2*size//5, y + 2*size//3), # Middle left
|
||
(x + size//2, y + 5*size//6) # Bottom
|
||
]
|
||
self.draw.polygon(bolt_points, fill=bolt_color)
|
||
|
||
def draw_weather_icon(self, condition: str, x: int, y: int, size: int = 16) -> None:
|
||
"""Draw a weather icon based on the condition."""
|
||
if condition.lower() in ['clear', 'sunny']:
|
||
self._draw_sun(x, y, size)
|
||
elif condition.lower() in ['clouds', 'cloudy', 'partly cloudy']:
|
||
self._draw_cloud(x, y, size)
|
||
elif condition.lower() in ['rain', 'drizzle', 'shower']:
|
||
self._draw_rain(x, y, size)
|
||
elif condition.lower() in ['snow', 'sleet', 'hail']:
|
||
self._draw_snow(x, y, size)
|
||
elif condition.lower() in ['thunderstorm', 'storm']:
|
||
self._draw_storm(x, y, size)
|
||
else:
|
||
self._draw_sun(x, y, size)
|
||
# Note: No update_display() here - let the caller handle the update
|
||
|
||
def draw_text_with_icons(self, text: str, icons: List[tuple] = None, x: int = None, y: int = None,
|
||
color: tuple = (255, 255, 255)):
|
||
"""Draw text with weather icons at specified positions."""
|
||
# Draw the text
|
||
self.draw_text(text, x, y, color)
|
||
|
||
# Draw any icons
|
||
if icons:
|
||
for icon_type, icon_x, icon_y in icons:
|
||
self.draw_weather_icon(icon_type, icon_x, icon_y)
|
||
|
||
# Update the display once after everything is drawn
|
||
self.update_display()
|
||
|
||
def cleanup(self):
|
||
"""Clean up resources."""
|
||
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.image = Image.new('RGB', (self.width, self.height))
|
||
self.draw = ImageDraw.Draw(self.image)
|
||
except (OSError, RuntimeError, ValueError, MemoryError):
|
||
logger.debug("Canvas reset during cleanup failed", exc_info=True)
|
||
# Reset the singleton state when cleaning up
|
||
DisplayManager._instance = None
|
||
DisplayManager._initialized = False
|
||
|
||
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}")
|
||
|
||
def set_scrolling_state(self, is_scrolling: bool):
|
||
"""Set the current scrolling state. Call this when a display starts/stops scrolling."""
|
||
current_time = time.time()
|
||
self._scrolling_state['is_scrolling'] = is_scrolling
|
||
if is_scrolling:
|
||
self._scrolling_state['last_scroll_activity'] = current_time
|
||
logger.debug(f"Scrolling state set to: {is_scrolling}")
|
||
|
||
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
|
||
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()
|
||
|
||
# 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'])
|
||
|
||
logger.debug(f"Deferred update added. Total deferred: {len(self._scrolling_state['deferred_updates'])}")
|
||
|
||
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
|
||
self._cleanup_expired_deferred_updates(current_time)
|
||
|
||
if self.is_currently_scrolling():
|
||
return
|
||
|
||
if not self._scrolling_state['deferred_updates']:
|
||
return
|
||
|
||
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:]
|
||
|
||
logger.debug(f"Processing {len(updates_to_process)} deferred updates (queue size: {len(self._scrolling_state['deferred_updates'])})")
|
||
|
||
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:
|
||
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."""
|
||
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 get_scrolling_stats(self) -> dict:
|
||
"""Get current scrolling statistics for debugging."""
|
||
return {
|
||
'is_scrolling': self._scrolling_state['is_scrolling'],
|
||
'last_activity': self._scrolling_state['last_scroll_activity'],
|
||
'deferred_count': len(self._scrolling_state['deferred_updates']),
|
||
'inactivity_threshold': self._scrolling_state['scroll_inactivity_threshold'],
|
||
'max_deferred_updates': self._scrolling_state['max_deferred_updates'],
|
||
'deferred_update_ttl': self._scrolling_state['deferred_update_ttl']
|
||
}
|
||
|
||
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) -> 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."""
|
||
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
|
||
|
||
digest = zlib.adler32(self.image.tobytes())
|
||
action = snapshot_policy.decide(
|
||
now, self._last_snapshot_ts, self._last_snapshot_touch_ts,
|
||
viewer_fresh, digest != self._last_snapshot_digest)
|
||
if action is snapshot_policy.SnapshotAction.SKIP:
|
||
return
|
||
if action is snapshot_policy.SnapshotAction.TOUCH:
|
||
# 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
|
||
|
||
# WRITE: 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
|
||
tmp_path = f"{self._snapshot_path}.tmp"
|
||
self.image.save(tmp_path, format='PNG')
|
||
try:
|
||
os.replace(tmp_path, self._snapshot_path)
|
||
except Exception:
|
||
# Fallback to direct save if replace not supported
|
||
self.image.save(self._snapshot_path, format='PNG')
|
||
# Set proper file permissions after saving
|
||
try:
|
||
ensure_file_permissions(snapshot_path_obj, get_assets_file_mode())
|
||
except Exception:
|
||
pass
|
||
self._last_snapshot_ts = now
|
||
self._last_snapshot_touch_ts = now
|
||
self._last_snapshot_digest = digest
|
||
except Exception as e:
|
||
# 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.
|
||
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", e)
|
||
else:
|
||
logger.debug(f"Snapshot write skipped: {e}") |