mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-01 08:48:05 +00:00
* Vegas mode: reclaim dead space and pace the rotation On a wide panel Vegas mode spent much of its time showing black. At 50px/s on a 512px display, one display width of blank is 10.2 seconds, which makes several long-standing behaviours expensive: - ScrollHelper prepended a full display width of black as an "initial gap", charged once per cycle — 10.2s of black at the start of every rotation. - Plugins without get_vegas_content() are captured off a full-display canvas, so their blank margins entered the ticker too. Measured: of-the-day drew 35px of "No Data" on a 512px canvas (92% blank), youtube-stats 142px of content with 185px of black either side. Only the scroll_helper path had any trimming. - Cycle transitions deliberately pushed a blank frame and then recomposed synchronously: 84ms at best, 4.8s at worst, every millisecond of it black. - buffer_ahead doubled as the cycle size, so a 21-plugin install showed 3 plugins per cycle and took ~7 cycles to come around. - separator_width was applied between every image rather than at plugin boundaries, so a per-row ticker like the F1 scoreboard (116 images, which it renders 4px apart internally) got a 32px chasm between each row — and the width budget didn't count those gaps, so the plugin quietly occupied far more of the panel than intended. Changes: - src/vegas_mode/geometry.py: numpy column-ink primitives shared by the trimmer and the audit tool, so the number reported is the number acted on. A Python per-column loop over a 17,000px strip is far too slow for the render path. - PluginAdapter trims every content path, not just scroll_helper. Only outer edges are cropped: interior blank columns are the plugin's own layout (logo left, score right) and closing them would corrupt the design. A plugin on a non-black background is inherently unaffected. - ScrollHelper.create_scrolling_image takes an explicit lead_gap, still defaulting to display_width so the many standalone-ticker callers are unchanged. Vegas passes lead_in_width (default 0). - Cycle end holds the last rendered frame instead of blanking, turning the recompose into a brief freeze rather than the panel switching off. - plugins_per_cycle (default 6) is split from buffer_ahead, which goes back to being only a prefetch low-water mark. - max_plugin_width_ratio (default 3x display width) caps one plugin's share of a cycle. Overflow is deferred, not discarded: a rotation offset advances each fetch so later rows appear on subsequent cycles. Single oversized images are cropped at a blank column so the cut misses glyphs. - Composition groups images by plugin: rows are joined by intra_plugin_gap (default 8) and separator_width applies only between plugins. The width budget now counts those gaps. - Plugin data updates no longer run on the Vegas render path. All new settings are user-configurable in Display -> Vegas Scroll, including min/max cycle duration and dynamic duration, which previously existed in code but were reachable only by hand-editing config.json. Measured with scripts/dev/vegas_audit.py on a 512x64 panel: mean ink coverage 42.7% -> 69.4% fully blank 5.9% -> 0% reads as empty 13.6% -> 0% worst blank stretch 4.8s -> 0s full rotation 414s -> 123s plugins per cycle 3 -> 6 Note the metric choice: a "fully blank" scan (>=95% black viewport) reported only 0.4% and badly understated the problem, because two full-width segments with mid-canvas content never fully blank the viewport — they hold it at ~28%. window_coverage_stats grades every viewport position by how much ink it carries, which is what tracks perceived dead time. Known remaining: cycle transitions still freeze ~3.5s while the next cycle is fetched. Fixing that needs background prefetch, which is deferred because the fallback-capture path mutates the shared display_manager.image and racing it against the render loop risks torn frames. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ * Drop unused Optional import from the vegas audit script Flagged by Codacy (F401). Any, Dict and List are all still used. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ * Align Vegas API bounds with validate(), fix audit config plumbing Both from review feedback on #423. The web API's accepted ranges disagreed with VegasModeConfig.validate(), which is what actually gates Vegas starting: scroll_speed 1-100 -> 1-200 (a slider value of 150 returned 400) separator_width 0-500 -> 0-128 target_fps 1-200 -> 30-200 buffer_ahead 1-20 -> 1-5 The three loose ones were the dangerous direction: the value saved with a 200, then VegasModeCoordinator.start() failed validation with only a log line, so the ticker silently never ran. The UI already matched validate() in all four cases, so the API was the odd one out. test_vegas_api_bounds_match_validate parses the numeric_fields map out of api_v3 and asserts every bound against validate(), plus that validate() accepts both endpoints and rejects just outside them, so these cannot drift apart again. That test immediately caught a missing upper bound on min_plugin_width, now added — unbounded it would drop every segment and leave a blank ticker. Separately, vegas_audit.py constructed PluginAdapter without the config, so it fell back to VegasModeConfig() defaults and would report trimming and width-budget behaviour that differed from the user's config.json. It now passes the loaded config exactly as the coordinator does. This is the same class of drift the explicit lead_gap and grouping arguments already guard against. Output is unchanged on a rig whose config matches the defaults. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ * Vegas mode: render plugins narrower, space rows by measured separation Trimming reclaims blank margins but cannot compact a layout that genuinely spans the display — a five-column forecast, a progress bar drawn at 100% width, a stat block with the panel's whole width between its elements. Those need the plugin to make different layout decisions, which means telling it the screen is narrower while it renders. DisplayManager.render_size() presents a smaller logical canvas for the duration of a Vegas content fetch, reusing the same _LogicalMatrix indirection double-sided mode already relies on so plugins see a consistent size from every accessor. Plugins that size themselves from matrix.width need no changes at all; one that wants to be explicit can read the new BasePlugin.get_vegas_render_width(). Width is a percentage so a single setting travels across panel sizes: vegas_scroll.render_width_pct globally, or vegas_width_pct in an individual plugin's config. Measured on a 512x64 panel with real data: ledmatrix-weather 1536px -> 576px (forecast becomes narrow cards) youtube-stats 353px -> 199px (2% blank left, so genuinely compact) geochron 453px -> 153px (ink density rises to 100%) ledmatrix-flights 950px -> 740px The youtube-stats figure is the clearest evidence the layout itself changed rather than being cropped: at full width the content had to be trimmed from 512px to 353px, whereas at 40% it arrives with almost no blank to reclaim. Row spacing is now measured rather than added. A flat gap gets it wrong in both directions at once — content drawn flush to its own edges ends up nearly touching (reported for recent sports scores, which sat 8px apart), while content already carrying wide margins gets pushed even further out. separation_gap() measures the blank each pair already has and adds only the shortfall, up to min_content_separation (default 24). intra_plugin_gap stays as a floor applied regardless. Two tests shipped in the previous commit encoded the old flat-gap arithmetic and are updated to the measured semantics, including one renamed to reflect that zero intra_plugin_gap alone no longer butts rows together. Also fixes a real bug found while testing: the harness display manager had no render_size(), and because the adapter catches broadly that surfaced as "no content" rather than an error, silently dropping five plugins. Added the context to VisualTestDisplayManager for parity, and _render_at() now degrades to a no-op on any display manager lacking it, so a third-party or older harness loses the narrowing rather than the content. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ * Vegas mode: end cycles before the wrap, keep the width budget honest Three fixes, the first a regression from lead_in_width defaulting to 0. get_visible_portion wraps: once scroll_position + display_width passes the end of the strip it fills the right of the frame from the *head* of the same strip. So the final display_width of travel showed the cycle's first plugin re-entering on the right while its last plugin exited on the left, and the recompose that followed replaced both at once. On a 512px panel at 50px/s that was 10.2s of two plugins on screen at once, ending in a hard cut — reported as the ticker "switching mid-scroll" from F1 to news. That used to be invisible because the strip began with a full display_width of blank, so the wrapped-in region was black. Removing that blank (it was 10s of dead panel per cycle) exposed the wrap. Cycles now end one display width earlier, before any wrapped content appears, clamped for strips no wider than the display so they don't complete instantly and spin the recompose loop. Verified on hardware: a 3936px strip now completes at 68.5s, exactly (3936 - 512) / 50. Second, auto_trim=False also skipped the width budget, which is an unrelated concern — turning off margin cropping should not let one plugin hold the panel for minutes. Seen in the field: the F1 scoreboard contributed 116 images and 14,848px untouched, giving a 33,821px cycle (11 minutes of content). The budget now applies regardless of trimming; with it restored that cycle is 6,362px. Third, the budget accounted for row gaps using the flat intra_plugin_gap while the compositor had moved to measured separation, so it under-counted by up to (min_content_separation - intra_plugin_gap) per row and a many-row plugin overran its cap. Both now use the same separation_gap() rule, and a test asserts the composed block fits the budget end to end rather than trusting the two paths to agree. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ * Fix IndexError in find_blank_cut when the cut lands on the image edge A cut position after the last column is legitimate — _crop_to_budget asks for min(start + budget, img.width), which equals the width whenever the remaining strip is shorter than the budget. find_blank_cut clamped target to width but then walked leftwards starting at target itself, so ink[width] raised IndexError. Caught on hardware: it killed the ledmatrix-stocks fetch, and because _fetch_plugin_content catches broadly that surfaced as the plugin silently contributing nothing for the cycle. Only reachable on the second or later pass of the rotating window over a single oversized image, which is why the existing tests missed it — they all exercised the first pass, where start is 0 and start + budget is comfortably inside the image. Added TestRotationAcrossMultipleCycles, which walks the window round several times and asserts content is never lost, plus direct coverage of find_blank_cut at and beyond the image edge. Both bounds now stop at width - 1 so neither direction can index past the end. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ * Only cut oversized segments at real gaps between items The width-budget crop snapped to the nearest blank column, and in rendered text the gap between two characters is a single column. So a cut routinely landed inside a word: the cycle showed "Wednesda" and the orphaned "y" turned up as a lone floating letter in the next cycle, positioned after whatever plugin happened to precede it. Measured on the clock-simple segment to confirm: its blank runs are [1, 1, 1, 1, 1, 8, 8] — five single-column letter gaps, every one of which find_blank_cut would happily have chosen. Cuts now only land in a run of at least min_cut_gap blank columns (default 6), which excludes letter spacing while still finding the gaps plugins put between items (the stocks ticker uses 32px, baseball 48px). Where no boundary falls inside the budget the cut waits for the next one and overruns, because splitting an item is worse than a slightly long segment. Continuous content is treated differently on purpose: an image with no internal gaps is a map or a chart, where any column is as good as another, so it is still cut to the budget exactly. The gap rule protects discrete items; letting a solid image escape the cap in its name would be wrong. blank_runs() is vectorised — 48ms for a 17,000px strip, against seconds for a per-column Python loop. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ * Hold capture_mode for every plugin render, not just narrowed ones The native content path only entered capture_mode when it was also narrowing the canvas, so at full width — which is every plugin without a vegas_width_pct override, i.e. most of them — a plugin calling update_display() while building its Vegas content wrote straight to the hardware. That is a visible flash mid-scroll, and it lines up with the flash reported at cycle transitions, when several plugins are fetched back to back. Suppression is now unconditional; the narrowing context stays separate because it is already a no-op at full width. Both contexts are reached through helpers that degrade to nullcontext when the display manager lacks them. That matters more than it looks: the adapter's handlers are deliberately broad, so an AttributeError from a missing context does not surface as an error — it surfaces as the plugin contributing nothing. Making the call unconditional without this turned 44 tests red for exactly that reason, all of them reporting lost content rather than the real cause. The test double now provides capture_mode and render_size too, so tests exercise the real contexts instead of silently taking the degraded path. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ * Vegas mode: one continuous strip instead of swapping cycles A cycle used to be a discrete strip that got replaced: motion stopped, every pixel was substituted at once, and the next group started with the viewport already full. That is the freeze, the flash and the jump. The strip is now extended rather than replaced. ScrollHelper gains append_content(), which adds items on the right without touching scroll_position or total_distance_scrolled, so motion continues and the next group simply arrives from the right. Because completion is measured against total_scroll_width, extending also defers completion — there is no longer a cycle boundary to see. drop_scrolled_prefix() reclaims what has gone past, keeping the strip bounded however long Vegas runs (observed 5,000-11,000px against an unbounded strip otherwise). It shifts total_distance_scrolled and total_scroll_width together so the completion arithmetic is unchanged, and refuses to run while the viewport is wrapping: wrapping reads the head of the strip into the right of the frame, so trimming the head there would visibly change the picture. A test caught that. Groups are prepared off the render thread. The constraint is that the canvas and the matrix proxy are process-wide mutable state, so narrowing or capturing through them from another thread would corrupt the frame the render loop is pushing. get_content() therefore takes offscreen_only: the background thread uses only paths that avoid the canvas, and anything needing it is marked and picked up on the render thread. That puts the expensive work (native renders of leaderboard and baseball cards, seconds each) in the background and leaves the cheap work (display capture, 40-600ms) in the foreground. DisplayManager's capture flag is now thread-local. As a shared flag, a background capture would have suppressed the render loop's own frame pushes for its duration, freezing the panel precisely when the point was to avoid a freeze. Canvas-bound plugins are drained one at a time rather than as a batch: six at once held the render thread for 1.75s. Drains are also spaced by two seconds while the lookahead is healthy, since taking them back to back turns one long stall into a run of short ones. When the strip is genuinely running short the throttle is ignored, because content matters more than smoothness there. Measured on hardware: zero cycle-complete swaps, drains landing 2-4s apart, lookahead holding at 1,200-3,500px, no errors. Set continuous_scroll false to restore the swap behaviour; the old path is intact. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ * Pace the Vegas frame loop adaptively: 31.5 -> 78.7 fps The loop slept a fixed frame_interval on top of however long the frame took, so at a measured 31.6ms per frame a flat 8ms of that was pure idle — a quarter of the budget spent not rendering. It now sleeps only the remainder of the budget. Measured on hardware: 31.5 fps to 78.7 fps sustained, with CPU going *down* from 150% to 127%. Scroll speed is unchanged at 49.9px/s against a configured 50, because motion is derived from elapsed time rather than frame count — this buys smoothness, not speed. Worth recording what the bottleneck was not: the per-frame render path measures 0.34ms in total (0.18ms for the numpy slice, 0.17ms for the dirty-tracking digest), which is a theoretical 2900 fps. Optimising any of that would have been wasted effort. The frame was idle, not busy. Also nices the prefetch thread. Its work is PIL and numpy that releases the GIL, so the scheduler can act on the priority, and without it the prefetch competes for the same cores as the render loop. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ * Sub-pixel scrolling: motion at the frame rate, not the pixel rate With integer positioning the number of distinct frames per second equals the scroll speed in px/s, however fast the loop renders. Measured at 50px/s and 78.7fps, 36% of frames were byte-identical: the extra frames cost work and bought no motion, and what was left was 50 discrete 1px steps a second. Two things were wrong with the pre-existing sub-pixel support. get_visible_portion never consulted sub_pixel_scrolling — it always took the integer path, so the flag and _get_visible_portion_subpixel were dead code. And that implementation needed scipy.ndimage.shift, which is not installed on the target devices (HAS_SCIPY is False there), so it would not have interpolated even if reached. Verified both: positions 1000.0 and 1000.5 produced identical frames either way. Blending is now wired up and implemented with numpy. Two details make it affordable: slice cached_array directly instead of building two PIL images only to convert them straight back (the naive version measured 15x the integer path), and use fixed-point uint16 multiply-add rather than float32, which suits the Pi's cores and gives finer weighting than the panel can resolve. Result 0.939ms against 0.237ms — 0.70ms added per frame, a 1065fps ceiling. Measured on hardware: 81.2 fps with blending on, against 78.7 with it off, so no cost within noise — and every frame is now a distinct position rather than one in three being a repeat. The trade is a slight horizontal softening of text, since each frame blends two positions. Set smooth_scroll false for maximum crispness. Also benchmarked and cleared as non-issues: extending the strip costs 9.4ms on an 11,000px strip and trimming 2.5ms, both under one frame at this rate. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ * Add overflow handling: keep ordered content whole instead of rotating a window The width budget split any oversized plugin by advancing a window each cycle. That is right for interchangeable items — news headlines, odds, stock prices — but wrong for ordered content: a league table showed ranks 1-6, then resumed at 7 two rotations later, which reads as out of order and out of context. Nobody needs rank 23 in a ticker; they need the top of the table, every time. overflow_mode chooses between them: rotate — advance a window each cycle so everything is seen eventually (unchanged default) truncate — always show the start and drop the rest, keeping ordered content coherent. Records no window state, so every pass starts at the top. Per-plugin vegas_overflow overrides the global setting, since one install has both kinds of plugin. Also adds per-plugin vegas_max_width_screens, so content that must stay whole can be given more room — or uncapped with 0 — without lifting the cap on every ticker. Applied on the test rig: f1-scoreboard and ledmatrix-leaderboard set to truncate, and baseball given 4.5 screens because it was showing 8 of 9 games when the whole slate needed only a little more room. Verified: F1 now reports "the first 10 of 116 ... the rest are not shown", baseball has dropped out of the budget log entirely, and stocks, odds-ticker and stock-news still rotate. Also corrects the crop log, which claimed "window advances next cycle" unconditionally and so misreported truncated crops. A test now pins the behaviour behind the message: truncate must leave no offset recorded. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ * Stop Vegas mode showing last night's games as if they were live A game that was live in the evening was still being drawn as live the next morning. Two faults combined to freeze plugin visuals indefinitely. PR #291 added a call to plugin_adapter.invalidate_plugin_scroll_cache() so a plugin's own cached scroll image would be rebuilt from fresh data. That method was never implemented. hot_swap_content() wraps the call in a broad except, so every hot swap has raised AttributeError and been swallowed silently ever since — which is why the visuals it was meant to keep fresh never were. Continuous scrolling then removed the only path that reached it at all: should_recompose() and hot_swap_content() are called from the non-continuous branch of run_frame(), and continuous_scroll defaults to True. So on a default install the pending-update flags were set by the update tick, never consumed, and grew without bound. Together these froze content completely, because refetching is not enough on its own: the sports plugins' get_vegas_content() regenerates only "if the cache is empty", so take_next_group() kept receiving the same picture however often it asked. Fixed by: - Implementing invalidate_plugin_scroll_cache(). It covers both layouts — a helper directly on the plugin (stocks, news, odds-ticker) and one owned by a scroll-display manager (the sports scoreboards, which is the shape that produced this bug) — and clears cached_image and cached_array together, since the array is the image's numpy mirror. - Adding StreamManager.invalidate_pending_updates() and calling it from the continuous branch. It only drops the caches; the plugin recomposes when it next comes round in the rotation. process_updates() is wrong here: it refetches synchronously and merges into the active buffer that continuous mode bypasses, and hot_swap_content() rebuilds and repositions the whole strip, which is the freeze-and-jump this mode exists to avoid. Tests assert the fix rather than the implementation: 14 of the 17 new tests fail without it. Includes the wiring itself, since the regression was a call that was simply absent, and a check that the scroll position is untouched so this cannot regress into the swap's visible jump. All Vegas suites pass (355 tests). test_display_controller_vegas_tick.py still cannot be collected off-device for want of rgbmatrix, identically with and without this change. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ * Fix two CodeRabbit-flagged test assertions in vegas density tests test_prepared_group_is_used_without_refetching had a tautological final assertion; now checks stream.calls directly. test_no_partial_letter_at_either_edge required both crop edges to be blank, but the left edge here is always the crop's start position with no lead-in gap in word_strip, so it legitimately carries ink — only the right edge is an actual cut and needs the check. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ --------- Co-authored-by: Claude <noreply@anthropic.com>
1369 lines
63 KiB
Python
1369 lines
63 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
|
||
# 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()
|
||
# 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)
|
||
|
||
@property
|
||
def _capture_mode_active(self) -> bool:
|
||
"""True while the calling thread is capturing content off-screen."""
|
||
return getattr(self._capture_state, 'active', 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.
|
||
"""
|
||
self._capture_mode_active = True
|
||
try:
|
||
yield
|
||
finally:
|
||
self._capture_mode_active = False
|
||
|
||
@contextmanager
|
||
def render_size(self, width: int, height: Optional[int] = None):
|
||
"""Temporarily present a smaller logical canvas to plugins.
|
||
|
||
Plugins lay out against ``display_manager.matrix.width`` (and the
|
||
``width``/``height`` properties, which defer to it), 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.
|
||
|
||
Only meaningful inside :meth:`capture_mode` — this swaps the shared
|
||
image buffer, so the render loop must not be writing to it concurrently.
|
||
|
||
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.
|
||
"""
|
||
real_matrix = self.matrix
|
||
prev_image = getattr(self, 'image', None)
|
||
prev_draw = getattr(self, 'draw', None)
|
||
|
||
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
|
||
|
||
try:
|
||
if real_matrix is not None:
|
||
self.matrix = _LogicalMatrix(real_matrix, target_w, target_h)
|
||
# With no hardware, the width/height properties fall through to
|
||
# self.image, so swapping the buffer below is enough on its own.
|
||
self.image = Image.new('RGB', (target_w, target_h))
|
||
self.draw = ImageDraw.Draw(self.image)
|
||
yield
|
||
finally:
|
||
self.matrix = real_matrix
|
||
if prev_image is not None:
|
||
self.image = prev_image
|
||
if prev_draw is not None:
|
||
self.draw = prev_draw
|
||
|
||
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}") |