mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-02 17:28:05 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa3087609d |
@@ -2072,10 +2072,21 @@ class DisplayController:
|
||||
|
||||
# For plugins, call display multiple times to allow game rotation
|
||||
if manager_to_display and hasattr(manager_to_display, 'display'):
|
||||
# Check if plugin needs high FPS (like stock ticker)
|
||||
# Always enable high-FPS for static-image plugin (for GIF animation support)
|
||||
# High-FPS decision, in precedence order:
|
||||
# 1. A plugin that declares needs_high_fps knows best
|
||||
# (e.g. static-image sets it False for still PNGs,
|
||||
# True for animated GIFs).
|
||||
# 2. Back-compat: older static-image versions without
|
||||
# the attribute keep the historical forced high-FPS
|
||||
# (GIF support).
|
||||
# 3. Otherwise scrolling plugins get high FPS.
|
||||
plugin_id = getattr(manager_to_display, 'plugin_id', None)
|
||||
if plugin_id == 'static-image':
|
||||
declared = getattr(manager_to_display, 'needs_high_fps', None)
|
||||
if declared is not None:
|
||||
needs_high_fps = bool(declared)
|
||||
logger.debug("FPS check for %s - plugin declares needs_high_fps=%s",
|
||||
active_mode, needs_high_fps)
|
||||
elif plugin_id == 'static-image':
|
||||
needs_high_fps = True
|
||||
logger.debug("FPS check - static-image plugin: forcing high-FPS mode for GIF support")
|
||||
else:
|
||||
|
||||
+52
-37
@@ -33,10 +33,10 @@ else:
|
||||
from contextlib import contextmanager
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
import time
|
||||
from collections import OrderedDict
|
||||
from typing import Dict, Any, List, Optional, Tuple
|
||||
from typing import Dict, Any, List, Optional
|
||||
import logging
|
||||
import math
|
||||
import zlib
|
||||
import freetype
|
||||
|
||||
# Get logger without configuring
|
||||
@@ -181,25 +181,20 @@ class DisplayManager:
|
||||
# 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)
|
||||
# Text-width measurement cache: (text, id(font)) -> pixel_width
|
||||
# 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
|
||||
self._text_width_cache: Dict[tuple, int] = {}
|
||||
# Snapshot settings for web preview integration (service writes, web reads)
|
||||
self._snapshot_path = "/tmp/led_matrix_preview.png" # nosec B108 - fixed path intentional; web UI reads same path
|
||||
self._snapshot_min_interval_sec = 0.2 # max ~5 fps
|
||||
self._last_snapshot_ts = 0.0
|
||||
# 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
|
||||
|
||||
# Scrolling state tracking for graceful updates
|
||||
self._scrolling_state = {
|
||||
@@ -430,6 +425,10 @@ class DisplayManager:
|
||||
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:
|
||||
@@ -521,7 +520,20 @@ class DisplayManager:
|
||||
return phys
|
||||
|
||||
def update_display(self):
|
||||
"""Update the display using double buffering with proper sync."""
|
||||
"""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.
|
||||
"""
|
||||
try:
|
||||
if self.matrix is None:
|
||||
# Fallback mode - no actual hardware to update
|
||||
@@ -533,6 +545,19 @@ class DisplayManager:
|
||||
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 Exception:
|
||||
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:
|
||||
@@ -542,10 +567,12 @@ class DisplayManager:
|
||||
|
||||
# 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:
|
||||
@@ -581,6 +608,9 @@ class DisplayManager:
|
||||
# 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:
|
||||
@@ -711,15 +741,12 @@ class DisplayManager:
|
||||
|
||||
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.
|
||||
measurement per unique (text, font) pair.
|
||||
"""
|
||||
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]
|
||||
return cached
|
||||
|
||||
try:
|
||||
if isinstance(font, freetype.Face):
|
||||
@@ -734,9 +761,7 @@ class DisplayManager:
|
||||
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)
|
||||
self._text_width_cache[cache_key] = width
|
||||
return width
|
||||
|
||||
def get_font_height(self, font):
|
||||
@@ -1181,15 +1206,5 @@ class DisplayManager:
|
||||
pass
|
||||
self._last_snapshot_ts = now
|
||||
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}")
|
||||
# Snapshot failures should never break display; log at debug to avoid noise
|
||||
logger.debug(f"Snapshot write skipped: {e}")
|
||||
+5
-18
@@ -35,7 +35,6 @@ import urllib.request
|
||||
import zipfile
|
||||
import tempfile
|
||||
import time
|
||||
from collections import OrderedDict
|
||||
from pathlib import Path
|
||||
from PIL import ImageFont
|
||||
from typing import Dict, Tuple, Optional, Union, Any, List
|
||||
@@ -59,13 +58,7 @@ class FontManager:
|
||||
# Font discovery and catalog
|
||||
self.font_catalog: Dict[str, str] = {} # family_name -> file_path
|
||||
self.font_cache: Dict[str, Union[ImageFont.FreeTypeFont, freetype.Face]] = {} # (family, size) -> font
|
||||
# (text, id(font)) -> ((width, height, baseline), font_ref).
|
||||
# LRU-bounded — keys embed the measured TEXT, so changing strings
|
||||
# (clocks, live scores) would otherwise grow it forever. Entries
|
||||
# keep the font alive so its id() can't be recycled by a different
|
||||
# font object (which would silently return wrong metrics).
|
||||
self.metrics_cache: "OrderedDict[Any, Tuple[Tuple[int, int, int], Any]]" = OrderedDict()
|
||||
self._METRICS_CACHE_MAX = 1024
|
||||
self.metrics_cache: Dict[str, Tuple[int, int, int]] = {} # (text, font_id) -> (width, height, baseline)
|
||||
|
||||
# Plugin font management
|
||||
self.plugin_fonts: Dict[str, Dict[str, Any]] = {} # plugin_id -> font_manifest
|
||||
@@ -514,14 +507,10 @@ class FontManager:
|
||||
Returns:
|
||||
Tuple of (width, height, baseline_offset)
|
||||
"""
|
||||
# Key on the text itself (hash(text) could collide) + font identity;
|
||||
# the entry below keeps the font referenced so the id stays valid.
|
||||
cache_key = (text, id(font))
|
||||
cache_key = f"{hash(text)}_{id(font)}"
|
||||
|
||||
cached = self.metrics_cache.get(cache_key)
|
||||
if cached is not None:
|
||||
self.metrics_cache.move_to_end(cache_key)
|
||||
return cached[0]
|
||||
if cache_key in self.metrics_cache:
|
||||
return self.metrics_cache[cache_key]
|
||||
|
||||
try:
|
||||
if isinstance(font, freetype.Face):
|
||||
@@ -558,9 +547,7 @@ class FontManager:
|
||||
baseline = 10
|
||||
|
||||
result = (width, height, baseline)
|
||||
self.metrics_cache[cache_key] = (result, font)
|
||||
while len(self.metrics_cache) > self._METRICS_CACHE_MAX:
|
||||
self.metrics_cache.popitem(last=False)
|
||||
self.metrics_cache[cache_key] = result
|
||||
return result
|
||||
|
||||
def get_font_height(self, font: Union[ImageFont.FreeTypeFont, freetype.Face]) -> int:
|
||||
|
||||
+1
-5
@@ -38,11 +38,7 @@ def mock_cache_manager():
|
||||
mock._memory_cache_timestamps = {}
|
||||
mock.cache_dir = "/tmp/test_cache"
|
||||
|
||||
def mock_get(key: str, max_age: Optional[int] = 300,
|
||||
memory_ttl: Optional[int] = None) -> Optional[Dict]:
|
||||
# Signature mirrors CacheManager.get — keep in sync or callers
|
||||
# passing keyword args (health tracker, resource monitor) break
|
||||
# only in tests, hiding real-API compatibility.
|
||||
def mock_get(key: str, max_age: int = 300) -> Optional[Dict]:
|
||||
return mock._memory_cache.get(key)
|
||||
|
||||
def mock_set(key: str, data: Dict, ttl: Optional[int] = None) -> None:
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
"""Tests for update_display dirty tracking (src/display_manager.py).
|
||||
|
||||
Runs against RGBMatrixEmulator (EMULATOR=true), exercising the REAL
|
||||
DisplayManager — not a mock — so the skip logic, its invalidation hooks,
|
||||
and the kill switch are verified off-Pi.
|
||||
|
||||
The invariants:
|
||||
- identical frames are pushed exactly once (SwapOnVSync not re-called)
|
||||
- ANY pixel change pushes
|
||||
- clear() and set_brightness() invalidate (the two paths that alter panel
|
||||
state outside the digest's view)
|
||||
- the kill switch (display.dirty_tracking: false) restores always-push
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
os.environ["EMULATOR"] = "true"
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def dm():
|
||||
"""One real DisplayManager on the emulator (it's a process singleton)."""
|
||||
from src.display_manager import DisplayManager
|
||||
DisplayManager._instance = None
|
||||
DisplayManager._initialized = False
|
||||
manager = DisplayManager({
|
||||
"display": {
|
||||
"hardware": {"rows": 32, "cols": 64, "chain_length": 2,
|
||||
"parallel": 1, "brightness": 90},
|
||||
"runtime": {"gpio_slowdown": 0},
|
||||
},
|
||||
}, suppress_test_pattern=True)
|
||||
yield manager
|
||||
|
||||
|
||||
class _SwapSpy:
|
||||
"""Counts SwapOnVSync calls through the real matrix object."""
|
||||
|
||||
def __init__(self, matrix):
|
||||
self.matrix = matrix
|
||||
self.count = 0
|
||||
self._orig = matrix.SwapOnVSync
|
||||
|
||||
def __enter__(self):
|
||||
def counting(canvas):
|
||||
self.count += 1
|
||||
return self._orig(canvas)
|
||||
self.matrix.SwapOnVSync = counting
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
self.matrix.SwapOnVSync = self._orig
|
||||
|
||||
|
||||
class TestDirtyTracking:
|
||||
def test_identical_frames_push_once(self, dm):
|
||||
dm.draw.rectangle([0, 0, 10, 10], fill=(255, 0, 0))
|
||||
with _SwapSpy(dm.matrix) as spy:
|
||||
dm.update_display()
|
||||
dm.update_display()
|
||||
dm.update_display()
|
||||
assert spy.count == 1
|
||||
|
||||
def test_pixel_change_pushes(self, dm):
|
||||
dm.update_display()
|
||||
with _SwapSpy(dm.matrix) as spy:
|
||||
dm.draw.point((5, 5), fill=(0, 255, 0))
|
||||
dm.update_display()
|
||||
dm.update_display() # unchanged again
|
||||
assert spy.count == 1
|
||||
|
||||
def test_clear_invalidates(self, dm):
|
||||
dm.draw.rectangle([0, 0, 20, 20], fill=(0, 0, 255))
|
||||
dm.update_display()
|
||||
dm.clear() # writes to the matrix directly; digest must reset
|
||||
with _SwapSpy(dm.matrix) as spy:
|
||||
dm.update_display() # black frame after clear must still push
|
||||
assert spy.count == 1
|
||||
|
||||
def test_brightness_change_forces_push(self, dm):
|
||||
dm.draw.rectangle([0, 0, 20, 20], fill=(200, 200, 200))
|
||||
dm.update_display()
|
||||
with _SwapSpy(dm.matrix) as spy:
|
||||
dm.update_display() # identical -> skipped
|
||||
assert spy.count == 0
|
||||
dm.set_brightness(40) # dim schedule scenario
|
||||
dm.update_display() # same image, new brightness -> push
|
||||
assert spy.count == 1
|
||||
dm.set_brightness(90)
|
||||
|
||||
def test_snapshot_still_written_on_skip(self, dm, tmp_path):
|
||||
"""The web preview path must keep working through skipped pushes."""
|
||||
dm._snapshot_path = str(tmp_path / "snap.png")
|
||||
dm._last_snapshot_ts = 0.0
|
||||
dm.draw.rectangle([0, 0, 30, 8], fill=(255, 255, 0))
|
||||
dm.update_display() # push + snapshot
|
||||
assert os.path.exists(dm._snapshot_path)
|
||||
|
||||
|
||||
class TestKillSwitch:
|
||||
def test_dirty_tracking_can_be_disabled(self, dm):
|
||||
dm._dirty_tracking_enabled = False
|
||||
try:
|
||||
dm.draw.rectangle([0, 0, 10, 10], fill=(1, 2, 3))
|
||||
with _SwapSpy(dm.matrix) as spy:
|
||||
dm.update_display()
|
||||
dm.update_display()
|
||||
dm.update_display()
|
||||
assert spy.count == 3 # always-push, exactly the old behavior
|
||||
finally:
|
||||
dm._dirty_tracking_enabled = True
|
||||
dm._last_pushed_digest = None
|
||||
|
||||
def test_config_flag_wires_through(self):
|
||||
from src.display_manager import DisplayManager
|
||||
DisplayManager._instance = None
|
||||
DisplayManager._initialized = False
|
||||
manager = DisplayManager({
|
||||
"display": {
|
||||
"hardware": {"rows": 32, "cols": 64, "chain_length": 1,
|
||||
"parallel": 1},
|
||||
"runtime": {"gpio_slowdown": 0},
|
||||
"dirty_tracking": False,
|
||||
},
|
||||
}, suppress_test_pattern=True)
|
||||
assert manager._dirty_tracking_enabled is False
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(pytest.main([__file__, "-v"]))
|
||||
Reference in New Issue
Block a user