fix(display): Vegas teardown and 240s default; remove dead Vegas buffer code (#637)

* fix(display): tear down Vegas mode on controller cleanup

DisplayController.cleanup() never called VegasModeCoordinator.cleanup(),
so the Vegas teardown (stop, pipeline/stream reset, adapter cache drop)
was unreachable. Call it before the display manager is cleaned up, and
skip it when Vegas was never created.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(vegas): default max_cycle_duration to the documented 240s

The template, the web UI help, CONFIG_REFERENCE and the controller all
say 240, but the code defaulted to 600 in two places, so a config
without the key ran Vegas iterations 2.5x longer than documented.

from_config now falls back to the dataclass field defaults instead of
repeating each one, so the two copies can no longer drift, and the
controller's follower scroll-speed default reads VegasModeConfig's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(display): let run.py -d show display_manager's DEBUG output

display_manager pinned its logger to INFO at import, overriding the root
level, so debug mode never showed its DEBUG lines. Use get_logger() from
src.logging_config like the rest of the core and leave the level to the
logging setup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(display): run each startup validation check once

StartupValidator.validate_all() ran twice at boot, before and after the
plugin manager was created, so every config, cache, display and
systemd-unit warning was logged twice. The second pass now runs only the
plugin checks. Drop the commented-out raise_on_errors line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(vegas): one INFO line per plugin-list refresh

StreamManager logged "=" * 60 banners and a line per plugin (INCLUDED,
SKIPPED, FETCHING CONTENT, SEGMENT CREATED) at INFO on every refresh and
fetch, i.e. at each cycle start and every 30s. Log one INFO summary of
the rotation per refresh and move the per-plugin detail, the weighting
breakdown and "no content this cycle" to DEBUG (the adapter still warns
when every content path fails).

Also drop the check/cross marks from the controller's log messages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(vegas): drop the per-iteration static-mode plugin scan

run_iteration() rebuilt _static_mode_plugins on every iteration, asking
every plugin for its display mode and logging the set at INFO, but
nothing ever read it: static pauses are triggered by
_check_static_plugin_trigger() from the next segment. Delete it, the
coordinator's get_ordered_plugins() that only it used, and the
write-only _static_pause_plugin / _static_pause_start.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(vegas): remove the staging buffer that was never filled

StreamManager and RenderPipeline carried a double-buffer design that
nothing used: _staging_buffer was only ever cleared or swapped, so
swap_buffers() never did anything and should_recompose()'s
staging_count > 0 branch was dead, and _active_scroll_image,
_staging_scroll_image, _is_rendering, _last_frame_time and
_frame_interval were written but never read. Delete the machinery and
rewrite the docstrings around what actually carries updates:
_pending_updates, consumed by process_updates() in swap mode and
invalidate_pending_updates() in continuous mode.

should_recompose() no longer builds a buffer-status dict every frame.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(display): tidy the display controller without changing behaviour

- Import VegasModeCoordinator locally instead of through module globals
  (there is no circular import to avoid).
- Drop hasattr() checks on attributes PluginManager.__init__ always sets
  (plugin_executor, plugin_last_update, get_plugin_lock,
  run_scheduled_updates*, stop_update_worker) and the dead "older
  manager" fallbacks; keep the health_tracker None checks, now via
  _health_tracker().
- Extract _display_once() for the per-frame display call both render
  loops copied, _advance_on_demand() for the two on-demand rotations,
  _reset_on_demand_fields() for the error and clear paths, and
  _timezone() / _in_window() for the two schedule checks.
- Remove always-true conditions and the unreachable non-plugin else
  branch in run(), and read _was_display_active / _last_published_mode /
  vegas_coordinator directly now that __init__ declares them.
- Declare the follower render state in __init__, name its tuning
  constants, add _follower_sign(), and share the 90/s sync send
  interval with the render pipeline (SYNC_SEND_INTERVAL).
- Delete history narration and the "Opt #N" labels, fix the comment
  that called _scroll_speed constant (hot reload updates it), and drop
  a startup timing log that measured nothing.
- render_pipeline / plugin_adapter: read display_manager.width/height
  as the properties they are, drop an empty TYPE_CHECKING block, an
  aliased threading import and a duplicated `if result and
  self.sync_manager:`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(display): trim dead code from display_manager

- Add _new_canvas() for the image/draw/fontmode="1" setup that was
  copied six times.
- Call resolve_double_sided() and compose_pixel_mapper_config() directly
  instead of through a module alias and a passthrough method, and replace
  the comment that said the passthrough read class attributes.
- Delete the unused _initialized flag and _ORIENTATION_ROTATE_DEGREES
  alias (no core or monorepo reader; tests stop resetting the flag), the
  test pattern's unreachable no-matrix branch (it only runs once the
  matrix exists), `del old_image  # help GC` (a no-op on a local), a
  duplicated early return in process_deferred_updates, and stale
  comments.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(vegas): remove unread fields and test-only helpers, fix docstrings

- ContentSegment: drop total_width, fetched_at, is_stale, image_count and
  is_static, none of which is read.
- StreamManager: drop _current_index (never advanced) and the test-only
  get_all_content_for_composition() and has_pending_updates();
  VegasModeConfig: drop the test-only is_plugin_included().
- geometry.find_blank_cut() has had no production caller since the crop
  moved to item boundaries; delete it and its tests.
- PluginAdapter: the _finalize docstring described separator_width
  between every image, and _crop_to_budget's said cuts snap to the
  nearest blank column; both now describe what the code does.
- Coordinator: the static-pause interrupt log no longer blames follower
  mode for every interrupt, and set_update_callback names the callback
  the controller actually wires.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(scroll): correct ScrollHelper comments and drop dead branches

- Four comments said the strip always starts with display_width of
  blank; it does only when lead_gap is None (Vegas passes its own).
- Delete the "Width calculation mismatch" warning: the image is created
  at the calculated width, so the two can never differ.
- Remove the two scroll_delay <= 0 fallbacks (which disagreed with each
  other): set_scroll_delay clamps it to at least 0.001 and nothing in
  core or the plugin monorepo assigns it directly.
- Trim the scipy history from the blend docstring.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(run): drop a redundant comment

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(display): log set_scrolling_state only when it changes

Vegas and scrolling plugins set the scrolling state every frame, so once
display_manager's DEBUG output became visible in debug mode it printed
"Scrolling state set to: True" about 120 times a second. Log only when
the value differs from the previous one; the state, activity timestamp
and frame hold still update on every call.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(changelog): display-vegas

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-09-24 17:37:15 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent f6afbdbb15
commit b9416ef803
26 changed files with 1182 additions and 1277 deletions
+8
View File
@@ -74,6 +74,14 @@ accepts both, but the store flags the old spelling as deprecated
- Stats the server can't read show `--`. - Stats the server can't read show `--`.
- New `window.LEDEscape` (`html`, `attr`, `jsStringAttr`) replaces about 30 copied escapers. `window.escapeHtml` and `window.escapeAttribute` remain as aliases for plugin pages. - New `window.LEDEscape` (`html`, `attr`, `jsStringAttr`) replaces about 30 copied escapers. `window.escapeHtml` and `window.escapeAttribute` remain as aliases for plugin pages.
- Display and Vegas:
- Vegas `max_cycle_duration` defaults to 240 s when unset, as documented (it was 600 s). The Vegas defaults are now defined once.
- The display controller stops Vegas mode on shutdown.
- Startup validation warnings are logged once, not twice.
- Vegas logs one INFO line per plugin-list refresh.
- `run.py -d` shows `display_manager` debug output.
- Removed: the Vegas staging buffer that was never filled (`swap_buffers()`, and `staging_count` / `current_index` in `get_buffer_status()`), unread `ContentSegment` fields, and `geometry.find_blank_cut()`.
- The web service (`ledmatrix-web`) logs through `src.logging_config` like the - The web service (`ledmatrix-web`) logs through `src.logging_config` like the
display service, so `journalctl -p err -u ledmatrix-web` works. Successful display service, so `journalctl -p err -u ledmatrix-web` works. Successful
GET/HEAD/OPTIONS requests (the UI's polling) are logged at DEBUG instead of GET/HEAD/OPTIONS requests (the UI's polling) are logged at DEBUG instead of
-2
View File
@@ -31,8 +31,6 @@ if args.emulator:
print("Using pygame/RGBMatrixEmulator for display") print("Using pygame/RGBMatrixEmulator for display")
print("Press ESC to exit\n") print("Press ESC to exit\n")
# Project directory already added above
# Debug output (only in debug mode or emulator mode) # Debug output (only in debug mode or emulator mode)
debug_mode = args.debug or args.emulator or os.environ.get('LEDMATRIX_DEBUG', '').lower() == 'true' debug_mode = args.debug or args.emulator or os.environ.get('LEDMATRIX_DEBUG', '').lower() == 'true'
if debug_mode: if debug_mode:
+13 -36
View File
@@ -238,20 +238,9 @@ class ScrollHelper:
self.cached_image = full_image self.cached_image = full_image
# Convert to numpy array for fast operations # Convert to numpy array for fast operations
self.cached_array = np.array(full_image) self.cached_array = np.array(full_image)
# Use actual image width instead of calculated width to ensure accuracy
# This fixes cases where width calculation doesn't match actual positioning
actual_image_width = full_image.width actual_image_width = full_image.width
self.total_scroll_width = actual_image_width self.total_scroll_width = actual_image_width
# Log if there's a mismatch (indicating a bug in width calculation)
if actual_image_width != total_width:
self.logger.warning(
"Width calculation mismatch: calculated=%dpx, actual=%dpx (diff=%dpx). "
"Using actual width for scroll calculations.",
total_width, actual_image_width, abs(actual_image_width - total_width)
)
self.scroll_position = 0.0 self.scroll_position = 0.0
self.total_distance_scrolled = 0.0 self.total_distance_scrolled = 0.0
self.scroll_complete = False self.scroll_complete = False
@@ -339,10 +328,8 @@ class ScrollHelper:
# gained. This is what the one visibly smooth scroller on the # gained. This is what the one visibly smooth scroller on the
# hardware (the stock ticker) was already doing by virtue of never # hardware (the stock ticker) was already doing by virtue of never
# enabling frame-based mode. # enabling frame-based mode.
if self.scroll_delay > 0: # set_scroll_delay clamps scroll_delay to at least 0.001.
pixels_per_second = self.scroll_speed / self.scroll_delay pixels_per_second = self.scroll_speed / self.scroll_delay
else:
pixels_per_second = self.scroll_speed * 100.0
pixels_to_move = pixels_per_second * delta_time pixels_to_move = pixels_per_second * delta_time
self.last_step_time = current_time self.last_step_time = current_time
else: else:
@@ -353,11 +340,11 @@ class ScrollHelper:
self.scroll_position += pixels_to_move self.scroll_position += pixels_to_move
self.total_distance_scrolled += pixels_to_move self.total_distance_scrolled += pixels_to_move
# Calculate required total distance: total_scroll_width only. # One pass is total_scroll_width. With the default lead_gap the strip
# The image already includes display_width pixels of blank padding at the start # starts with display_width of blank, so by then the last item has
# (added by create_scrolling_image), so once scroll_position reaches # fully left the panel; a caller passing a smaller lead_gap (Vegas)
# total_scroll_width the last card has fully scrolled off the left edge. # decides for itself where its cycle ends. Adding display_width here
# Adding display_width here would cause 1-2 extra wrap-arounds on wide chains. # caused 1-2 extra wrap-arounds on wide chains.
required_total_distance = self.total_scroll_width required_total_distance = self.total_scroll_width
# Guard: zero-width content has nothing to scroll — keep position at 0 and skip # Guard: zero-width content has nothing to scroll — keep position at 0 and skip
@@ -414,7 +401,6 @@ class ScrollHelper:
and current_time - self.last_progress_log_time >= self.progress_log_interval and current_time - self.last_progress_log_time >= self.progress_log_interval
): ):
elapsed_time = current_time - (self.scroll_start_time or current_time) elapsed_time = current_time - (self.scroll_start_time or current_time)
# The image already includes display_width padding, so we only need total_scroll_width
required_total_distance = self.total_scroll_width required_total_distance = self.total_scroll_width
# Progress telemetry, emitted every few seconds for the whole of # Progress telemetry, emitted every few seconds for the whole of
# every scroll. It says how far along a marquee is, which is what # every scroll. It says how far along a marquee is, which is what
@@ -461,10 +447,8 @@ class ScrollHelper:
""" """
Linear blend between the frames at ``start_x`` and ``start_x + 1``. Linear blend between the frames at ``start_x`` and ``start_x + 1``.
Implemented with numpy rather than scipy.ndimage.shift: scipy is not Implemented with numpy rather than scipy.ndimage.shift, which is not
installed on the target devices, and the old scipy-based sub-pixel path installed on the target devices.
was dead code -- get_visible_portion never consulted the flag. The scipy
import was removed with it; installing scipy has no effect.
Args: Args:
start_x: Left column of the earlier of the two frames start_x: Left column of the earlier of the two frames
@@ -571,22 +555,16 @@ class ScrollHelper:
return self.min_duration return self.min_duration
try: try:
# Calculate total scroll distance needed # The strip's width plus one more screen, so the duration covers
# The image already includes display_width padding at the start, so we need # the last item leaving the panel even when the strip has less
# to scroll total_scroll_width pixels to show all content, plus display_width # than display_width of lead-in blank (lead_gap).
# more pixels to ensure the last content scrolls completely off the screen
total_scroll_distance = self.total_scroll_width + self.display_width total_scroll_distance = self.total_scroll_width + self.display_width
# Calculate effective pixels per second based on scrolling mode # Calculate effective pixels per second based on scrolling mode
if self.frame_based_scrolling: if self.frame_based_scrolling:
# Frame-based mode: scroll_speed is pixels per frame, scroll_delay is seconds per frame # Frame-based mode: scroll_speed is pixels per scroll_delay
# Effective pixels per second = pixels per frame / seconds per frame # seconds, and set_scroll_delay keeps scroll_delay >= 0.001.
if self.scroll_delay > 0:
pixels_per_second = self.scroll_speed / self.scroll_delay pixels_per_second = self.scroll_speed / self.scroll_delay
else:
# Fallback if scroll_delay is invalid
pixels_per_second = self.scroll_speed * 50 # Assume 50 FPS default
self.logger.warning("Invalid scroll_delay (%s), using fallback calculation", self.scroll_delay)
scroll_mode_str = "frame-based" scroll_mode_str = "frame-based"
else: else:
# Time-based mode: scroll_speed is already pixels per second # Time-based mode: scroll_speed is already pixels per second
@@ -1072,7 +1050,6 @@ class ScrollHelper:
Returns: Returns:
Dictionary with scroll state information Dictionary with scroll state information
""" """
# The image already includes display_width padding, so we only need total_scroll_width
required_total_distance = self.total_scroll_width if self.total_scroll_width > 0 else 0 required_total_distance = self.total_scroll_width if self.total_scroll_width > 0 else 0
return { return {
'scroll_position': self.scroll_position, 'scroll_position': self.scroll_position,
+299 -370
View File
File diff suppressed because it is too large Load Diff
+31 -71
View File
@@ -38,8 +38,7 @@ from src.common.bdf_font import draw_bdf_text, load_bdf_face
from src.common.font_layout import crisp_size, load_truetype, resolve_asset_path from src.common.font_layout import crisp_size, load_truetype, resolve_asset_path
from src.display_geometry import ( from src.display_geometry import (
DEFAULT_CHAIN_LENGTH, DEFAULT_COLS, DEFAULT_PARALLEL, DEFAULT_ROWS, DEFAULT_CHAIN_LENGTH, DEFAULT_COLS, DEFAULT_PARALLEL, DEFAULT_ROWS,
ORIENTATION_ROTATE_DEGREES, compose_pixel_mapper_config, physical_size, compose_pixel_mapper_config, physical_size, resolve_double_sided,
resolve_double_sided,
) )
from src.matrix_support import MatrixSettingsRefused, library_refusals, refusal_message from src.matrix_support import MatrixSettingsRefused, library_refusals, refusal_message
from src.pi5_matrix_support import is_raspberry_pi_5 from src.pi5_matrix_support import is_raspberry_pi_5
@@ -47,13 +46,13 @@ import threading
import time import time
from collections import OrderedDict from collections import OrderedDict
from typing import Dict, Any, List, Optional, Tuple from typing import Dict, Any, List, Optional, Tuple
import logging
import math import math
import zlib import zlib
import freetype import freetype
from src.common import snapshot_policy from src.common import snapshot_policy
from src.deprecation import deprecated from src.deprecation import deprecated
from src.logging_config import get_logger
from src.common.permission_utils import ( from src.common.permission_utils import (
ensure_directory_permissions, ensure_directory_permissions,
ensure_file_permissions, ensure_file_permissions,
@@ -61,9 +60,7 @@ from src.common.permission_utils import (
get_assets_file_mode, get_assets_file_mode,
) )
# Get logger without configuring logger = get_logger(__name__)
logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO) # Set to INFO level
#: The strike 5x7.bdf is drawn at. FreeType renders a BDF at its own fixed #: The strike 5x7.bdf is drawn at. FreeType renders a BDF at its own fixed
#: size regardless, but a Face needs an active size before its metrics -- #: size regardless, but a Face needs an active size before its metrics --
@@ -134,11 +131,6 @@ class _LogicalMatrix:
setattr(object.__getattribute__(self, "_matrix"), name, value) setattr(object.__getattribute__(self, "_matrix"), name, value)
# Moved to src/display_geometry.py so the web preview, Starlark magnify and
# sync handshake compute the display size exactly as DisplayManager does
# without importing rgbmatrix. Aliased here for existing callers.
_resolve_double_sided = resolve_double_sided
class DisplayManager: class DisplayManager:
""" """
@@ -160,7 +152,6 @@ class DisplayManager:
""" """
_instance = None _instance = None
_initialized = False
def __new__(cls, *args, **kwargs): def __new__(cls, *args, **kwargs):
if cls._instance is None: if cls._instance is None:
@@ -249,19 +240,15 @@ class DisplayManager:
self._load_fonts() self._load_fonts()
logger.info("Font loading completed in %.3f seconds", time.time() - font_time) logger.info("Font loading completed in %.3f seconds", time.time() - font_time)
# Initialize managers def _new_canvas(self, width: int, height: int) -> None:
# Calendar manager is now initialized by DisplayController """Replace ``image``/``draw`` with a black canvas of the given size.
# Orientation setting -> rpi-rgb-led-matrix "Rotate:<deg>" pixel-mapper suffix. Text is drawn 1-bit (``fontmode = "1"``): the panel has no partial
_ORIENTATION_ROTATE_DEGREES = ORIENTATION_ROTATE_DEGREES brightness, so anti-aliasing only smears glyphs.
def _build_pixel_mapper_config(self, hardware_config: dict) -> str:
"""Compose pixel_mapper_config with the orientation setting.
See :func:`src.display_geometry.compose_pixel_mapper_config`, which the
web preview shares so it sizes the canvas the same way.
""" """
return compose_pixel_mapper_config(hardware_config) self.image = Image.new('RGB', (width, height))
self.draw = ImageDraw.Draw(self.image)
self.draw.fontmode = "1"
@staticmethod @staticmethod
def _fallback_advice(cause: str, error: Exception) -> str: def _fallback_advice(cause: str, error: Exception) -> str:
@@ -330,7 +317,7 @@ class DisplayManager:
# logical (per-screen) size, and keep a full-chain buffer to tile # logical (per-screen) size, and keep a full-chain buffer to tile
# the rendered screen into once per frame. # the rendered screen into once per frame.
ds_config = self.config.get('display', {}).get('double_sided', {}) ds_config = self.config.get('display', {}).get('double_sided', {})
ds = _resolve_double_sided(self.matrix.width, self.matrix.height, ds_config) ds = resolve_double_sided(self.matrix.width, self.matrix.height, ds_config)
self._double_sided = ds self._double_sided = ds
if ds is not None: if ds is not None:
self._physical_image = Image.new( self._physical_image = Image.new(
@@ -339,9 +326,7 @@ class DisplayManager:
self.matrix, ds['logical_width'], ds['logical_height']) self.matrix, ds['logical_width'], ds['logical_height'])
# Create image with the (logical) display dimensions # Create image with the (logical) display dimensions
self.image = Image.new('RGB', (self.matrix.width, self.matrix.height)) self._new_canvas(self.matrix.width, self.matrix.height)
self.draw = ImageDraw.Draw(self.image)
self.draw.fontmode = "1" # 1-bit text: the panel has no partial brightness, so AA only smears glyphs.
logger.info(f"Image canvas created with dimensions: {self.matrix.width}x{self.matrix.height}") logger.info(f"Image canvas created with dimensions: {self.matrix.width}x{self.matrix.height}")
# Initialize font with Press Start 2P # Initialize font with Press Start 2P
@@ -372,7 +357,7 @@ class DisplayManager:
fallback_width, fallback_height = physical_size(self.config) fallback_width, fallback_height = physical_size(self.config)
# Mirror double-sided in fallback so the preview shows one screen. # 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_config = self.config.get('display', {}).get('double_sided', {}) if self.config else {}
ds = _resolve_double_sided(fallback_width, fallback_height, ds_config) ds = resolve_double_sided(fallback_width, fallback_height, ds_config)
self._double_sided = ds self._double_sided = ds
if ds is not None: if ds is not None:
fallback_width = ds['logical_width'] fallback_width = ds['logical_width']
@@ -380,9 +365,7 @@ class DisplayManager:
except Exception: except Exception:
fallback_width, fallback_height = 128, 32 fallback_width, fallback_height = 128, 32
self.image = Image.new('RGB', (fallback_width, fallback_height)) self._new_canvas(fallback_width, fallback_height)
self.draw = ImageDraw.Draw(self.image)
self.draw.fontmode = "1" # 1-bit text: the panel has no partial brightness, so AA only smears glyphs.
# Simple fallback visualization so web UI shows a realistic canvas # Simple fallback visualization so web UI shows a realistic canvas
try: try:
self.draw.rectangle([0, 0, fallback_width - 1, fallback_height - 1], outline=(255, 0, 0)) self.draw.rectangle([0, 0, fallback_width - 1, fallback_height - 1], outline=(255, 0, 0))
@@ -611,18 +594,14 @@ class DisplayManager:
line, font=font, fill=(0, 0, 255)) line, font=font, fill=(0, 0, 255))
def _draw_test_pattern(self): def _draw_test_pattern(self):
"""Draw a test pattern to verify the display is working.""" """Draw a test pattern to verify the display is working.
Only called from _setup_matrix once the matrix exists; fallback mode
draws its own "Simulation" canvas there.
"""
try: try:
self.clear() 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 # Draw a red rectangle border
self.draw.rectangle([0, 0, self.matrix.width-1, self.matrix.height-1], outline=(255, 0, 0)) self.draw.rectangle([0, 0, self.matrix.width-1, self.matrix.height-1], outline=(255, 0, 0))
@@ -637,7 +616,7 @@ class DisplayManager:
# Update the display once after everything is drawn # Update the display once after everything is drawn
self.update_display() self.update_display()
time.sleep(0.5) # Reduced from 1 second to 0.5 seconds for faster animation time.sleep(0.5)
except Exception as e: except Exception as e:
logger.error(f"Error drawing test pattern: {e}", exc_info=True) logger.error(f"Error drawing test pattern: {e}", exc_info=True)
@@ -712,9 +691,7 @@ class DisplayManager:
self.matrix = _LogicalMatrix(real_matrix, target_w, target_h) self.matrix = _LogicalMatrix(real_matrix, target_w, target_h)
# With no hardware, the width/height properties fall through to # With no hardware, the width/height properties fall through to
# self.image, so swapping the buffer below is enough on its own. # self.image, so swapping the buffer below is enough on its own.
self.image = Image.new('RGB', (target_w, target_h)) self._new_canvas(target_w, target_h)
self.draw = ImageDraw.Draw(self.image)
self.draw.fontmode = "1" # 1-bit text: the panel has no partial brightness, so AA only smears glyphs.
yield yield
finally: finally:
self.matrix = real_matrix self.matrix = real_matrix
@@ -834,28 +811,14 @@ class DisplayManager:
try: try:
if self.matrix is None: if self.matrix is None:
# Fallback mode - just clear the image # Fallback mode - just clear the image
# Explicitly clear old image reference to help garbage collection
old_image = getattr(self, 'image', None) old_image = getattr(self, 'image', None)
width = old_image.width if old_image else 64 width = old_image.width if old_image else 64
height = old_image.height if old_image else 64 height = old_image.height if old_image else 64
if old_image is not None: self._new_canvas(width, height)
del old_image
self.image = Image.new('RGB', (width, height))
self.draw = ImageDraw.Draw(self.image)
self.draw.fontmode = "1" # 1-bit text: the panel has no partial brightness, so AA only smears glyphs.
logger.debug("Cleared display in fallback mode") logger.debug("Cleared display in fallback mode")
return return
# Explicitly clear old image reference to help garbage collection self._new_canvas(self.matrix.width, self.matrix.height)
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)
self.draw.fontmode = "1" # 1-bit text: the panel has no partial brightness, so AA only smears glyphs.
if not self._capture_mode_active: if not self._capture_mode_active:
# Clear both canvases and the underlying matrix to ensure no artifacts. # Clear both canvases and the underlying matrix to ensure no artifacts.
@@ -1315,14 +1278,11 @@ class DisplayManager:
# Ensure image/draw are reset to a blank state # Ensure image/draw are reset to a blank state
if hasattr(self, 'image') and hasattr(self, 'draw'): if hasattr(self, 'image') and hasattr(self, 'draw'):
try: try:
self.image = Image.new('RGB', (self.width, self.height)) self._new_canvas(self.width, self.height)
self.draw = ImageDraw.Draw(self.image)
self.draw.fontmode = "1" # 1-bit text: the panel has no partial brightness, so AA only smears glyphs.
except (OSError, RuntimeError, ValueError, MemoryError): except (OSError, RuntimeError, ValueError, MemoryError):
logger.debug("Canvas reset during cleanup failed", exc_info=True) logger.debug("Canvas reset during cleanup failed", exc_info=True)
# Reset the singleton state when cleaning up # Reset the singleton state when cleaning up
DisplayManager._instance = None DisplayManager._instance = None
DisplayManager._initialized = False
def format_date_with_ordinal(self, dt): def format_date_with_ordinal(self, dt):
"""Formats a datetime object into 'Mon Aug 30th' style.""" """Formats a datetime object into 'Mon Aug 30th' style."""
@@ -1360,9 +1320,9 @@ class DisplayManager:
options.pwm_bits = hardware_config.get('pwm_bits', 10) options.pwm_bits = hardware_config.get('pwm_bits', 10)
options.pwm_lsb_nanoseconds = hardware_config.get('pwm_lsb_nanoseconds', 150) options.pwm_lsb_nanoseconds = hardware_config.get('pwm_lsb_nanoseconds', 150)
options.led_rgb_sequence = hardware_config.get('led_rgb_sequence', 'RGB') options.led_rgb_sequence = hardware_config.get('led_rgb_sequence', 'RGB')
# _build_pixel_mapper_config reads only class attributes, so the class # Orientation becomes a "Rotate:<deg>" pixel mapper; the web preview
# stands in for an instance here. # composes it the same way so it sizes the canvas identically.
options.pixel_mapper_config = cls._build_pixel_mapper_config(cls, hardware_config) options.pixel_mapper_config = compose_pixel_mapper_config(hardware_config)
options.row_address_type = hardware_config.get('row_address_type', 0) options.row_address_type = hardware_config.get('row_address_type', 0)
options.multiplexing = hardware_config.get('multiplexing', 0) options.multiplexing = hardware_config.get('multiplexing', 0)
options.panel_type = hardware_config.get('panel_type', '') options.panel_type = hardware_config.get('panel_type', '')
@@ -1455,13 +1415,16 @@ class DisplayManager:
that does not care gets a new frame every refresh. that does not care gets a new frame every refresh.
""" """
current_time = time.time() current_time = time.time()
# Scrolling callers set this every frame; log transitions only.
changed = self._scrolling_state['is_scrolling'] != is_scrolling
self._scrolling_state['is_scrolling'] = is_scrolling self._scrolling_state['is_scrolling'] = is_scrolling
if is_scrolling: if is_scrolling:
self._scrolling_state['last_scroll_activity'] = current_time self._scrolling_state['last_scroll_activity'] = current_time
self.set_frame_hold(frame_hold) self.set_frame_hold(frame_hold)
else: else:
self._frame_hold = 1 self._frame_hold = 1
logger.debug(f"Scrolling state set to: {is_scrolling}") if changed:
logger.debug("Scrolling state set to: %s", is_scrolling)
def is_currently_scrolling(self) -> bool: def is_currently_scrolling(self) -> bool:
"""Check if the display is currently in a scrolling state.""" """Check if the display is currently in a scrolling state."""
@@ -1530,9 +1493,6 @@ class DisplayManager:
if not self._scrolling_state['deferred_updates']: if not self._scrolling_state['deferred_updates']:
return return
if not self._scrolling_state['deferred_updates']:
return
# Process only a limited number of updates per call to avoid blocking # Process only a limited number of updates per call to avoid blocking
max_updates_per_call = min(5, len(self._scrolling_state['deferred_updates'])) max_updates_per_call = min(5, len(self._scrolling_state['deferred_updates']))
updates_to_process = self._scrolling_state['deferred_updates'][:max_updates_per_call] updates_to_process = self._scrolling_state['deferred_updates'][:max_updates_per_call]
+2 -2
View File
@@ -6,8 +6,8 @@ plugins' content is composed into a single horizontally scrolling display.
Components: Components:
- VegasModeCoordinator: Main orchestrator for Vegas mode - VegasModeCoordinator: Main orchestrator for Vegas mode
- StreamManager: Manages plugin content streaming with 1-2 ahead buffering - StreamManager: Plugin rotation, content fetching and pending-update tracking
- RenderPipeline: Handles 125 FPS rendering with double-buffering - RenderPipeline: Strip composition and per-frame rendering
- PluginAdapter: Converts plugin content to scrollable images - PluginAdapter: Converts plugin content to scrollable images
- VegasModeConfig: Configuration management - VegasModeConfig: Configuration management
""" """
+38 -48
View File
@@ -167,7 +167,7 @@ class VegasModeConfig:
# Dynamic duration # Dynamic duration
dynamic_duration_enabled: bool = True dynamic_duration_enabled: bool = True
min_cycle_duration: int = 60 # Minimum seconds per full cycle min_cycle_duration: int = 60 # Minimum seconds per full cycle
max_cycle_duration: int = 600 # Maximum seconds per full cycle max_cycle_duration: int = 240 # Maximum seconds per full cycle
@classmethod @classmethod
def from_config(cls, config: Dict[str, Any]) -> 'VegasModeConfig': def from_config(cls, config: Dict[str, Any]) -> 'VegasModeConfig':
@@ -181,44 +181,50 @@ class VegasModeConfig:
VegasModeConfig instance VegasModeConfig instance
""" """
vegas_config = config.get('display', {}).get('vegas_scroll', {}) vegas_config = config.get('display', {}).get('vegas_scroll', {})
# Missing keys fall back to the field defaults above, so each default
# is written once and the two cannot drift apart.
d = cls()
get = vegas_config.get
return cls( return cls(
enabled=vegas_config.get('enabled', False), enabled=get('enabled', d.enabled),
scroll_speed=float(vegas_config.get('scroll_speed', 50.0)), scroll_speed=float(get('scroll_speed', d.scroll_speed)),
separator_width=int(vegas_config.get('separator_width', 32)), separator_width=int(get('separator_width', d.separator_width)),
intra_plugin_gap=int(vegas_config.get('intra_plugin_gap', 8)), intra_plugin_gap=int(get('intra_plugin_gap', d.intra_plugin_gap)),
render_width_pct=int(vegas_config.get('render_width_pct', 100)), render_width_pct=int(get('render_width_pct', d.render_width_pct)),
min_content_separation=int( min_content_separation=int(
vegas_config.get('min_content_separation', 24)), get('min_content_separation', d.min_content_separation)),
min_cut_gap=int(vegas_config.get('min_cut_gap', 6)), min_cut_gap=int(get('min_cut_gap', d.min_cut_gap)),
smooth_scroll=vegas_config.get('smooth_scroll', True), smooth_scroll=get('smooth_scroll', d.smooth_scroll),
continuous_scroll=vegas_config.get('continuous_scroll', True), continuous_scroll=get('continuous_scroll', d.continuous_scroll),
extend_threshold_screens=float( extend_threshold_screens=float(
vegas_config.get('extend_threshold_screens', 2.0)), get('extend_threshold_screens', d.extend_threshold_screens)),
auto_trim=vegas_config.get('auto_trim', True), auto_trim=get('auto_trim', d.auto_trim),
trim_threshold=int(vegas_config.get('trim_threshold', 10)), trim_threshold=int(get('trim_threshold', d.trim_threshold)),
content_padding=int(vegas_config.get('content_padding', 8)), content_padding=int(get('content_padding', d.content_padding)),
min_plugin_width=int(vegas_config.get('min_plugin_width', 8)), min_plugin_width=int(get('min_plugin_width', d.min_plugin_width)),
lead_in_width=int(vegas_config.get('lead_in_width', 0)), lead_in_width=int(get('lead_in_width', d.lead_in_width)),
plugins_per_cycle=int(vegas_config.get('plugins_per_cycle', 6)), plugins_per_cycle=int(get('plugins_per_cycle', d.plugins_per_cycle)),
max_plugin_width_ratio=float( max_plugin_width_ratio=float(
vegas_config.get('max_plugin_width_ratio', 0.0)), get('max_plugin_width_ratio', d.max_plugin_width_ratio)),
overflow_mode=str(vegas_config.get('overflow_mode', 'rotate')), overflow_mode=str(get('overflow_mode', d.overflow_mode)),
plugin_order=list(vegas_config.get('plugin_order', [])), plugin_order=list(get('plugin_order', d.plugin_order)),
excluded_plugins=set(vegas_config.get('excluded_plugins', [])), excluded_plugins=set(get('excluded_plugins', d.excluded_plugins)),
live_in_ticker=bool(vegas_config.get('live_in_ticker', False)), live_in_ticker=bool(get('live_in_ticker', d.live_in_ticker)),
# Clamped: a weight below 1 would drop the plugin from the rotation # Clamped: a weight below 1 would drop the plugin from the rotation
# entirely, and a very large one starves everything else. # entirely, and a very large one starves everything else.
live_weight=max(1, min(10, int(vegas_config.get('live_weight', 3)))), live_weight=max(1, min(10, int(get('live_weight', d.live_weight)))),
favorite_live_weight=max( favorite_live_weight=max(1, min(10, int(
1, min(10, int(vegas_config.get('favorite_live_weight', 5)))), get('favorite_live_weight', d.favorite_live_weight)))),
target_fps=int(vegas_config.get('target_fps', 125)), target_fps=int(get('target_fps', d.target_fps)),
buffer_ahead=int(vegas_config.get('buffer_ahead', 2)), buffer_ahead=int(get('buffer_ahead', d.buffer_ahead)),
frame_based_scrolling=vegas_config.get('frame_based_scrolling', True), frame_based_scrolling=get(
scroll_delay=float(vegas_config.get('scroll_delay', 0.02)), 'frame_based_scrolling', d.frame_based_scrolling),
dynamic_duration_enabled=vegas_config.get('dynamic_duration_enabled', True), scroll_delay=float(get('scroll_delay', d.scroll_delay)),
min_cycle_duration=int(vegas_config.get('min_cycle_duration', 60)), dynamic_duration_enabled=get(
max_cycle_duration=int(vegas_config.get('max_cycle_duration', 600)), 'dynamic_duration_enabled', d.dynamic_duration_enabled),
min_cycle_duration=int(get('min_cycle_duration', d.min_cycle_duration)),
max_cycle_duration=int(get('max_cycle_duration', d.max_cycle_duration)),
) )
def to_dict(self) -> Dict[str, Any]: def to_dict(self) -> Dict[str, Any]:
@@ -260,22 +266,6 @@ class VegasModeConfig:
"""Get the frame interval in seconds for target FPS.""" """Get the frame interval in seconds for target FPS."""
return 1.0 / max(1, self.target_fps) return 1.0 / max(1, self.target_fps)
def is_plugin_included(self, plugin_id: str) -> bool:
"""
Check if a plugin should be included in Vegas scroll.
This is consistent with get_ordered_plugins - plugins not explicitly
in plugin_order are still included (appended at the end) unless excluded.
Args:
plugin_id: Plugin identifier to check
Returns:
True if plugin should be included
"""
# Plugins are included unless explicitly excluded
return plugin_id not in self.excluded_plugins
def get_ordered_plugins(self, available_plugins: List[str]) -> List[str]: def get_ordered_plugins(self, available_plugins: List[str]) -> List[str]:
""" """
Get plugins in configured order, filtering excluded ones. Get plugins in configured order, filtering excluded ones.
+5 -45
View File
@@ -145,13 +145,8 @@ class VegasModeCoordinator:
# Static pause handling # Static pause handling
self._static_pause_active = False self._static_pause_active = False
self._static_pause_plugin: Optional['BasePlugin'] = None
self._static_pause_start: Optional[float] = None
self._saved_scroll_position: Optional[int] = None self._saved_scroll_position: Optional[int] = None
# Track which plugins should use STATIC mode (pause scroll)
self._static_mode_plugins: set = set()
# Statistics # Statistics
self.stats = { self.stats = {
'total_runtime_seconds': 0.0, 'total_runtime_seconds': 0.0,
@@ -235,7 +230,9 @@ class VegasModeCoordinator:
returns immediately, collapsing the inter-iteration gap to <1 ms. returns immediately, collapsing the inter-iteration gap to <1 ms.
Args: Args:
callback: Callable with no arguments (typically _tick_plugin_updates) callback: Callable with no arguments. The display controller
passes _tick_plugin_updates_for_vegas, which also reports the
plugins that got fresh data through mark_plugin_updated().
""" """
self._update_callback = callback self._update_callback = callback
@@ -417,9 +414,6 @@ class VegasModeCoordinator:
if not self.start(): if not self.start():
return False return False
# Update static mode plugin list on iteration start
self._update_static_mode_plugins()
frame_interval = self.vegas_config.get_frame_interval() frame_interval = self.vegas_config.get_frame_interval()
if self.vegas_config.continuous_scroll: if self.vegas_config.continuous_scroll:
# The strip is continuously extended and trimmed, so its width says # The strip is continuously extended and trimmed, so its width says
@@ -733,13 +727,6 @@ class VegasModeCoordinator:
return status return status
def get_ordered_plugins(self) -> List[str]:
"""Get the current ordered list of plugins in Vegas scroll."""
if hasattr(self.plugin_manager, 'plugins'):
available = list(self.plugin_manager.plugins.keys())
return self.vegas_config.get_ordered_plugins(available)
return []
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
# Static pause handling (for STATIC display mode) # Static pause handling (for STATIC display mode)
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
@@ -795,8 +782,6 @@ class VegasModeCoordinator:
# Save current scroll position for smooth resume # Save current scroll position for smooth resume
self._saved_scroll_position = self.render_pipeline.get_scroll_position() self._saved_scroll_position = self.render_pipeline.get_scroll_position()
self._static_pause_active = True self._static_pause_active = True
self._static_pause_plugin = plugin
self._static_pause_start = time.time()
self.stats['static_pauses'] += 1 self.stats['static_pauses'] += 1
logger.info("Static pause started for plugin: %s", plugin_id) logger.info("Static pause started for plugin: %s", plugin_id)
@@ -823,9 +808,9 @@ class VegasModeCoordinator:
logger.info("Static pause interrupted by live priority") logger.info("Static pause interrupted by live priority")
return False return False
# Yield immediately if multi-display follower mode becomes active # On-demand, a WiFi message, the schedule, follower mode...
if self._interrupt_check and self._interrupt_check(): if self._interrupt_check and self._interrupt_check():
logger.info("Static pause interrupted by sync follower mode") logger.info("Static pause interrupted by the display controller")
return False return False
# Sleep in small increments to remain responsive # Sleep in small increments to remain responsive
@@ -860,8 +845,6 @@ class VegasModeCoordinator:
# Clear pause state # Clear pause state
self._static_pause_active = False self._static_pause_active = False
self._static_pause_plugin = None
self._static_pause_start = None
# Restore scroll position if we're resuming # Restore scroll position if we're resuming
if should_resume_scrolling and self._saved_scroll_position is not None: if should_resume_scrolling and self._saved_scroll_position is not None:
@@ -875,29 +858,6 @@ class VegasModeCoordinator:
else: else:
logger.debug("Static pause ended (interrupted, not resuming scroll)") logger.debug("Static pause ended (interrupted, not resuming scroll)")
def _update_static_mode_plugins(self) -> None:
"""Update the set of plugins using STATIC display mode."""
self._static_mode_plugins.clear()
for plugin_id in self.get_ordered_plugins():
plugin = self.plugin_manager.get_plugin(plugin_id)
if plugin:
try:
mode = plugin.get_vegas_display_mode()
if mode == VegasDisplayMode.STATIC:
self._static_mode_plugins.add(plugin_id)
except Exception:
logger.exception(
"Error getting vegas display mode for plugin %s",
plugin_id
)
if self._static_mode_plugins:
logger.info(
"Static mode plugins: %s",
', '.join(self._static_mode_plugins)
)
def cleanup(self) -> None: def cleanup(self) -> None:
"""Clean up all resources.""" """Clean up all resources."""
self.stop() self.stop()
-48
View File
@@ -230,54 +230,6 @@ def blank_runs(
return list(zip(starts[long_enough].tolist(), ends[long_enough].tolist())) return list(zip(starts[long_enough].tolist(), ends[long_enough].tolist()))
def find_blank_cut(
img: Image.Image,
target: int,
search_radius: int,
threshold: int = DEFAULT_INK_THRESHOLD,
) -> int:
"""
Find a column near ``target`` that carries no ink, so an image can be cut
there without slicing through a glyph or logo.
Used when a single oversized segment has to be narrowed to fit a width
budget. Cutting at an arbitrary column would leave half a character
hanging at the panel edge; snapping to the nearest gap hides the cut.
Args:
img: Image to cut
target: Preferred cut column
search_radius: How far either side of ``target`` to look
threshold: Ink threshold
Returns:
A blank column within the search window, or ``target`` clamped to the
image bounds when the window contains no blank column at all.
"""
width = img.width
target = max(0, min(target, width))
if search_radius <= 0 or width == 0:
return target
ink = column_has_ink(img, threshold)
# target may legitimately equal width (a cut after the last column), but
# there is no column to inspect there, so both bounds stop at width - 1.
lo = max(0, min(target - search_radius, width - 1))
hi = max(0, min(target + search_radius, width - 1))
# Walk outwards from target so the nearest gap wins.
for offset in range(0, search_radius + 1):
right = target + offset
if lo <= right <= hi and not ink[right]:
return right
left = target - offset
if lo <= left <= hi and not ink[left]:
return left
return target
class DeadWindowStats(NamedTuple): class DeadWindowStats(NamedTuple):
"""How much of a composed ticker reads as blank to a viewer.""" """How much of a composed ticker reads as blank to a viewer."""
+14 -17
View File
@@ -48,15 +48,8 @@ class PluginAdapter:
from src.vegas_mode.config import VegasModeConfig from src.vegas_mode.config import VegasModeConfig
config = VegasModeConfig() config = VegasModeConfig()
self.config = config self.config = config
# Handle both property and method access patterns self.display_width = display_manager.width
self.display_width = ( self.display_height = display_manager.height
display_manager.width() if callable(display_manager.width)
else display_manager.width
)
self.display_height = (
display_manager.height() if callable(display_manager.height)
else display_manager.height
)
# Cache for recently fetched content (prevents redundant fetch) # Cache for recently fetched content (prevents redundant fetch)
self._content_cache: dict = {} self._content_cache: dict = {}
@@ -185,13 +178,14 @@ class PluginAdapter:
Trim dead space off a segment, then cache it. Trim dead space off a segment, then cache it.
Every content path funnels through here so trimming is applied Every content path funnels through here so trimming is applied
uniformly. Previously only the scroll_helper path had its margins uniformly; a plugin that renders onto a full-display canvas would
stripped, which left plugins that render onto a full-display canvas otherwise contribute its whole blank canvas to the ticker.
contributing their entire blank canvas to the ticker.
Each image is trimmed independently because compose_scroll_content() Each image is trimmed independently. The render pipeline joins one
treats every image as its own item and inserts separator_width between plugin's images with a gap measured from their ink
them — so a per-image trim is what makes that separator the real gap. (RenderPipeline._join_plugin_rows) and puts separator_width only
between plugins, so the margins a row keeps are content_padding, not
whatever blank canvas the plugin happened to draw it on.
Args: Args:
images: Raw content from one of the fetch paths images: Raw content from one of the fetch paths
@@ -602,8 +596,11 @@ class PluginAdapter:
Narrow a single oversized image to the budget, advancing a window Narrow a single oversized image to the budget, advancing a window
through it across cycles. through it across cycles.
The cut is snapped to the nearest blank column so it does not slice Cuts land only at item boundaries: the middle of a blank run at least
through a glyph or logo and leave half a character at the panel edge. ``min_cut_gap`` columns wide. The window ends at the last boundary
inside the budget, or overruns to the next one when there is none, so
an item is never sliced. An image with no such runs (a map, a chart)
is continuous content and is cropped to the budget exactly.
Rotation is tracked as an index into the strip's item boundaries rather Rotation is tracked as an index into the strip's item boundaries rather
than as a pixel column, because a ticker re-renders between fetches. A than as a pixel column, because a ticker re-renders between fetches. A
+25 -63
View File
@@ -1,8 +1,8 @@
""" """
Render Pipeline for Vegas Mode Render Pipeline for Vegas Mode
Handles high-FPS (125 FPS) rendering with double-buffering for smooth scrolling. Composes plugin content into one wide strip and renders the visible window of
Uses the existing ScrollHelper for numpy-optimized scroll operations. it each frame, using ScrollHelper for the numpy-backed scroll.
""" """
import logging import logging
@@ -10,7 +10,7 @@ import os
import time import time
import threading import threading
from collections import deque from collections import deque
from typing import Optional, List, Any, Dict, Deque, TYPE_CHECKING from typing import Optional, List, Any, Dict, Deque
from PIL import Image from PIL import Image
from src.common.scroll_helper import ScrollHelper from src.common.scroll_helper import ScrollHelper
@@ -18,11 +18,13 @@ from src.vegas_mode.config import VegasModeConfig
from src.vegas_mode.geometry import separation_gap from src.vegas_mode.geometry import separation_gap
from src.vegas_mode.stream_manager import StreamManager from src.vegas_mode.stream_manager import StreamManager
if TYPE_CHECKING:
pass
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
#: Shortest gap between multi-display sync sends from the leader, for both the
#: Vegas scroll position and the controller's per-frame follower images. The
#: payloads are raw and cheap, and 90/s is above the follower's render rate.
SYNC_SEND_INTERVAL = 1.0 / 90
class RenderPipeline: class RenderPipeline:
""" """
@@ -31,8 +33,9 @@ class RenderPipeline:
Key responsibilities: Key responsibilities:
- Compose content segments into scrollable image - Compose content segments into scrollable image
- Manage scroll position and velocity - Manage scroll position and velocity
- Handle 125 FPS rendering loop - Render one frame per call at the target FPS
- Double-buffer for hot-swap during updates - Extend the strip (continuous mode) or recompose and hot-swap it (swap
mode) as content changes
- Track scroll cycle completion - Track scroll cycle completion
""" """
@@ -59,18 +62,10 @@ class RenderPipeline:
self.stream_manager = stream_manager self.stream_manager = stream_manager
self.sync_manager = None # Optional DisplaySyncManager — set by coordinator self.sync_manager = None # Optional DisplaySyncManager — set by coordinator
self.sync_follower_left = True # True = follower is LEFT of leader (default) self.sync_follower_left = True # True = follower is LEFT of leader (default)
self._sync_send_interval = 1.0 / 90 # raw bytes are cheap; 90fps > follower render rate
self._last_sync_send = 0.0 self._last_sync_send = 0.0
# Display dimensions (handle both property and method access patterns) self.display_width = display_manager.width
self.display_width = ( self.display_height = display_manager.height
display_manager.width() if callable(display_manager.width)
else display_manager.width
)
self.display_height = (
display_manager.height() if callable(display_manager.height)
else display_manager.height
)
# ScrollHelper for optimized scrolling # ScrollHelper for optimized scrolling
self.scroll_helper = ScrollHelper( self.scroll_helper = ScrollHelper(
@@ -82,11 +77,6 @@ class RenderPipeline:
# Configure scroll helper # Configure scroll helper
self._configure_scroll_helper() self._configure_scroll_helper()
# Double-buffer for composed images
self._active_scroll_image: Optional[Image.Image] = None
self._staging_scroll_image: Optional[Image.Image] = None
self._buffer_lock = threading.Lock()
# Group prepared off the render thread, waiting to be appended. # Group prepared off the render thread, waiting to be appended.
self._prepared_group = None self._prepared_group = None
# Plugins that need the shared canvas, appended one at a time. # Plugins that need the shared canvas, appended one at a time.
@@ -96,13 +86,9 @@ class RenderPipeline:
self._prefetch_lock = threading.Lock() self._prefetch_lock = threading.Lock()
# Render state # Render state
self._is_rendering = False
self._cycle_complete = False self._cycle_complete = False
self._segments_in_scroll: List[str] = [] # Plugin IDs in current scroll self._segments_in_scroll: List[str] = [] # Plugin IDs in current scroll
# Timing
self._last_frame_time = 0.0
self._frame_interval = config.get_frame_interval()
self._cycle_start_time = 0.0 self._cycle_start_time = 0.0
# Statistics # Statistics
@@ -192,10 +178,6 @@ class RenderPipeline:
logger.error("ScrollHelper failed to create cached image") logger.error("ScrollHelper failed to create cached image")
return False return False
# Store reference to composed image
with self._buffer_lock:
self._active_scroll_image = self.scroll_helper.cached_image
# Track which plugins are in this scroll (get safely via buffer status) # Track which plugins are in this scroll (get safely via buffer status)
self._segments_in_scroll = self.stream_manager.get_active_plugin_ids() self._segments_in_scroll = self.stream_manager.get_active_plugin_ids()
@@ -335,8 +317,6 @@ class RenderPipeline:
element_gap=0, element_gap=0,
) )
if appended: if appended:
with self._buffer_lock:
self._active_scroll_image = self.scroll_helper.cached_image
logger.info( logger.info(
"[%s] Appended deferred content: strip now %dpx, %dpx ahead", "[%s] Appended deferred content: strip now %dpx, %dpx ahead",
plugin_id, self.scroll_helper.total_scroll_width, plugin_id, self.scroll_helper.total_scroll_width,
@@ -420,9 +400,6 @@ class RenderPipeline:
# Keep a screen's worth behind the viewport as a safety margin. # Keep a screen's worth behind the viewport as a safety margin.
self.scroll_helper.drop_scrolled_prefix(keep_before=self.display_width) self.scroll_helper.drop_scrolled_prefix(keep_before=self.display_width)
with self._buffer_lock:
self._active_scroll_image = self.scroll_helper.cached_image
self._segments_in_scroll = [pid for pid, _ in grouped] self._segments_in_scroll = [pid for pid, _ in grouped]
self.stats['composition_count'] += 1 self.stats['composition_count'] += 1
self.stats['extensions'] = self.stats.get('extensions', 0) + 1 self.stats['extensions'] = self.stats.get('extensions', 0) + 1
@@ -565,7 +542,7 @@ class RenderPipeline:
# leader's via TCP image transfer at each new_cycle) at scroll_x ± display_width. # leader's via TCP image transfer at each new_cycle) at scroll_x ± display_width.
if self.sync_manager: if self.sync_manager:
now = time.time() now = time.time()
if now - self._last_sync_send >= self._sync_send_interval: if now - self._last_sync_send >= SYNC_SEND_INTERVAL:
self._last_sync_send = now self._last_sync_send = now
self.sync_manager.send_scroll_x(self.scroll_helper.scroll_position) self.sync_manager.send_scroll_x(self.scroll_helper.scroll_position)
@@ -603,7 +580,6 @@ class RenderPipeline:
Returns True when: Returns True when:
- Cycle is complete and we should start fresh - Cycle is complete and we should start fresh
- Staging buffer has new content
- A plugin currently visible in the scroll has pending updated data - A plugin currently visible in the scroll has pending updated data
(e.g. a live score changed) — standalone (non-sync) mode only (e.g. a live score changed) — standalone (non-sync) mode only
""" """
@@ -613,16 +589,11 @@ class RenderPipeline:
# When multi-display sync is active, defer mid-cycle hot swaps until the # When multi-display sync is active, defer mid-cycle hot swaps until the
# cycle ends naturally. Hot swaps block the render loop for 15-30ms while # cycle ends naturally. Hot swaps block the render loop for 15-30ms while
# the image is rebuilt, causing a freeze+jump that the follower perceives # the image is rebuilt, causing a freeze+jump that the follower perceives
# as a speed-up. Deferring to cycle boundaries keeps transitions clean. # as a speed-up. Deferring to cycle boundaries keeps transitions clean;
# Staging buffer content is still pre-loaded; it just applies at cycle end. # the pending updates are still applied, by the recompose at cycle end.
if self.sync_manager is not None: if self.sync_manager is not None:
return False return False
# Check if we need more content in the buffer
buffer_status = self.stream_manager.get_buffer_status()
if buffer_status['staging_count'] > 0:
return True
# Trigger recompose when pending updates affect visible segments, so # Trigger recompose when pending updates affect visible segments, so
# live score/status changes reach the display within a few seconds # live score/status changes reach the display within a few seconds
# instead of waiting for the next full cycle. # instead of waiting for the next full cycle.
@@ -652,10 +623,12 @@ class RenderPipeline:
def hot_swap_content(self) -> bool: def hot_swap_content(self) -> bool:
""" """
Hot-swap to new composed content. Refetch the plugins with pending updates and recompose the strip.
Called when staging buffer has updated content. Swap mode only (continuous mode uses :meth:`refresh_updated_plugins`).
Swaps atomically to prevent visual glitches. Called when :meth:`should_recompose` finds a visible plugin with
pending updates. The scroll resumes at the same relative position in
the rebuilt strip.
Returns: Returns:
True if swap occurred True if swap occurred
@@ -668,9 +641,7 @@ class RenderPipeline:
old_width = self.scroll_helper.total_scroll_width old_width = self.scroll_helper.total_scroll_width
old_pos = self.scroll_helper.scroll_position old_pos = self.scroll_helper.scroll_position
# Process any pending updates
self.stream_manager.process_updates() self.stream_manager.process_updates()
self.stream_manager.swap_buffers()
# Recompose with updated content # Recompose with updated content
if self.compose_scroll_content(): if self.compose_scroll_content():
@@ -729,21 +700,17 @@ class RenderPipeline:
result = self.compose_scroll_content() result = self.compose_scroll_content()
if result and self.sync_manager: if result and self.sync_manager:
# When sync is active, start the leader past the lead-in gap so it # Start the leader past the lead-in gap so it immediately shows
# immediately shows content, leaving the follower on the blank gap # content, leaving the follower on the blank gap for a clean
# for a clean transition rather than near-end content wrapping # transition rather than near-end content wrapping around.
# around. This tracks lead_in_width rather than assuming a full
# display width of gap, which is no longer the default.
self.scroll_helper.scroll_position = float(self.config.lead_in_width) self.scroll_helper.scroll_position = float(self.config.lead_in_width)
if result and self.sync_manager:
# Signal follower that a new cycle started (triggers its own rebuild) # Signal follower that a new cycle started (triggers its own rebuild)
self.sync_manager.send_new_cycle() self.sync_manager.send_new_cycle()
# Push the actual scroll image over TCP so follower has identical pixels. # Push the actual scroll image over TCP so follower has identical pixels.
# Done in a background thread to not block the render loop (~15ms transfer). # Done in a background thread to not block the render loop (~15ms transfer).
if self.scroll_helper.cached_image is not None: if self.scroll_helper.cached_image is not None:
import threading as _t threading.Thread(
_t.Thread(
target=self.sync_manager.send_scroll_image, target=self.sync_manager.send_scroll_image,
args=(self.scroll_helper.cached_image,), args=(self.scroll_helper.cached_image,),
daemon=True, name="sync-image-push" daemon=True, name="sync-image-push"
@@ -792,7 +759,6 @@ class RenderPipeline:
""" """
old_fps = self.config.target_fps old_fps = self.config.target_fps
self.config = new_config self.config = new_config
self._frame_interval = new_config.get_frame_interval()
# Reconfigure scroll helper # Reconfigure scroll helper
self._configure_scroll_helper() self._configure_scroll_helper()
@@ -805,10 +771,6 @@ class RenderPipeline:
self.scroll_helper.reset_scroll() self.scroll_helper.reset_scroll()
self.scroll_helper.clear_cache() self.scroll_helper.clear_cache()
with self._buffer_lock:
self._active_scroll_image = None
self._staging_scroll_image = None
self._cycle_complete = False self._cycle_complete = False
self._segments_in_scroll = [] self._segments_in_scroll = []
self._frame_times = deque(maxlen=100) self._frame_times = deque(maxlen=100)
+53 -130
View File
@@ -31,22 +31,14 @@ logger = logging.getLogger(__name__)
@dataclass @dataclass
class ContentSegment: class ContentSegment:
"""Represents a segment of scrollable content from a plugin.""" """One plugin's content for a cycle.
A STATIC segment carries no images: it marks where the coordinator pauses
the scroll to show the plugin full-screen.
"""
plugin_id: str plugin_id: str
images: List[Image.Image] images: List[Image.Image]
total_width: int
display_mode: VegasDisplayMode = field(default=VegasDisplayMode.FIXED_SEGMENT) display_mode: VegasDisplayMode = field(default=VegasDisplayMode.FIXED_SEGMENT)
fetched_at: float = field(default_factory=time.time)
is_stale: bool = False
@property
def image_count(self) -> int:
return len(self.images)
@property
def is_static(self) -> bool:
"""Check if this segment should trigger a static pause."""
return self.display_mode == VegasDisplayMode.STATIC
class StreamManager: class StreamManager:
@@ -54,10 +46,12 @@ class StreamManager:
Manages streaming of plugin content for Vegas scroll mode. Manages streaming of plugin content for Vegas scroll mode.
Key responsibilities: Key responsibilities:
- Maintain ordered list of plugins to stream - Maintain the ordered (and priority-weighted) rotation of plugins
- Prefetch content 1-2 plugins ahead of current position - Fill the active buffer with a cycle's worth of segments (swap mode), or
- Handle plugin data updates via double-buffer swap hand out the next group of plugins directly (continuous mode)
- Manage content lifecycle and staleness - Track plugins whose data changed in ``_pending_updates``, which
:meth:`process_updates` (swap mode) or
:meth:`invalidate_pending_updates` (continuous mode) consumes
""" """
def __init__( def __init__(
@@ -78,14 +72,14 @@ class StreamManager:
self.plugin_manager = plugin_manager self.plugin_manager = plugin_manager
self.plugin_adapter = plugin_adapter self.plugin_adapter = plugin_adapter
# Content queue (double-buffered) # Segments composed into the current cycle (swap mode only).
self._active_buffer: Deque[ContentSegment] = deque() self._active_buffer: Deque[ContentSegment] = deque()
self._staging_buffer: Deque[ContentSegment] = deque() # Reentrant: _prefetch_content releases and re-acquires it around the
self._buffer_lock = threading.RLock() # RLock for reentrant access # slow fetch while a caller may already hold it.
self._buffer_lock = threading.RLock()
# Plugin rotation state # Plugin rotation, and the position of the next plugin to fetch in it.
self._ordered_plugins: List[str] = [] self._ordered_plugins: List[str] = []
self._current_index: int = 0
self._prefetch_index: int = 0 self._prefetch_index: int = 0
# Update tracking # Update tracking
@@ -97,7 +91,6 @@ class StreamManager:
self.stats = { self.stats = {
'segments_fetched': 0, 'segments_fetched': 0,
'segments_served': 0, 'segments_served': 0,
'buffer_swaps': 0,
'fetch_errors': 0, 'fetch_errors': 0,
} }
@@ -167,9 +160,7 @@ class StreamManager:
with self._buffer_lock: with self._buffer_lock:
return { return {
'active_count': len(self._active_buffer), 'active_count': len(self._active_buffer),
'staging_count': len(self._staging_buffer),
'total_plugins': len(self._ordered_plugins), 'total_plugins': len(self._ordered_plugins),
'current_index': self._current_index,
'prefetch_index': self._prefetch_index, 'prefetch_index': self._prefetch_index,
'stats': self.stats.copy(), 'stats': self.stats.copy(),
} }
@@ -190,8 +181,9 @@ class StreamManager:
""" """
Mark a plugin as having updated data. Mark a plugin as having updated data.
Called when a plugin's data changes. Triggers content refresh Only records it in ``_pending_updates``. The refetch happens later,
for that plugin in the staging buffer. in :meth:`process_updates` (swap mode) or by dropping the plugin's
caches in :meth:`invalidate_pending_updates` (continuous mode).
Args: Args:
plugin_id: Plugin that was updated plugin_id: Plugin that was updated
@@ -242,11 +234,6 @@ class StreamManager:
) )
return updated return updated
def has_pending_updates(self) -> bool:
"""Check if any plugins have pending updates awaiting processing."""
with self._buffer_lock:
return len(self._pending_updates) > 0
def has_pending_updates_for_visible_segments(self) -> bool: def has_pending_updates_for_visible_segments(self) -> bool:
"""Check if pending updates affect plugins currently in the active buffer.""" """Check if pending updates affect plugins currently in the active buffer."""
with self._buffer_lock: with self._buffer_lock:
@@ -309,19 +296,6 @@ class StreamManager:
logger.debug("Processed in-place updates for %d plugins", len(updated_plugins)) logger.debug("Processed in-place updates for %d plugins", len(updated_plugins))
def swap_buffers(self) -> None:
"""
Swap active and staging buffers.
Called when staging buffer has updated content ready.
"""
with self._buffer_lock:
if self._staging_buffer:
# True swap: staging becomes active, old active is discarded
self._active_buffer, self._staging_buffer = self._staging_buffer, deque()
self.stats['buffer_swaps'] += 1
logger.debug("Swapped buffers, active now has %d segments", len(self._active_buffer))
def refresh(self) -> None: def refresh(self) -> None:
""" """
Refresh the plugin list and content. Refresh the plugin list and content.
@@ -337,38 +311,30 @@ class StreamManager:
self._refresh_plugin_list() self._refresh_plugin_list()
if len(self._ordered_plugins) != old_count: if len(self._ordered_plugins) != old_count:
logger.info( logger.debug(
"Plugin list refreshed: %d -> %d plugins", "Plugin list refreshed: %d -> %d plugins",
old_count, len(self._ordered_plugins) old_count, len(self._ordered_plugins)
) )
def _refresh_plugin_list(self) -> None: def _refresh_plugin_list(self) -> None:
"""Refresh the ordered list of plugins from plugin manager.""" """Refresh the ordered list of plugins from plugin manager.
logger.info("=" * 60)
logger.info("REFRESHING PLUGIN LIST FOR VEGAS SCROLL")
logger.info("=" * 60)
# Get all enabled plugins Runs at every cycle start and every ``_refresh_interval`` seconds, so
it logs one INFO summary; the per-plugin decisions are at DEBUG.
"""
available_plugins = [] available_plugins = []
loaded = 0
if hasattr(self.plugin_manager, 'plugins'): if hasattr(self.plugin_manager, 'plugins'):
logger.info( loaded = len(self.plugin_manager.plugins)
"Checking %d loaded plugins for Vegas scroll",
len(self.plugin_manager.plugins)
)
for plugin_id, plugin in self.plugin_manager.plugins.items(): for plugin_id, plugin in self.plugin_manager.plugins.items():
has_enabled = hasattr(plugin, 'enabled') if not getattr(plugin, 'enabled', False):
is_enabled = getattr(plugin, 'enabled', False) logger.debug("[%s] Vegas: skipped (not enabled)", plugin_id)
logger.info( continue
"[%s] class=%s, has_enabled=%s, enabled=%s",
plugin_id, plugin.__class__.__name__, has_enabled, is_enabled
)
if has_enabled and is_enabled:
# Check vegas content type - skip 'none' unless in STATIC mode
content_type = self.plugin_adapter.get_content_type(plugin, plugin_id)
# Also check display mode - STATIC plugins should be included # Content type 'none' is left out, except for STATIC plugins,
# even if their content_type is 'none' # which pause the scroll rather than contributing to it.
content_type = self.plugin_adapter.get_content_type(plugin, plugin_id)
display_mode = VegasDisplayMode.FIXED_SEGMENT display_mode = VegasDisplayMode.FIXED_SEGMENT
try: try:
display_mode = plugin.get_vegas_display_mode() display_mode = plugin.get_vegas_display_mode()
@@ -379,19 +345,15 @@ class StreamManager:
plugin_id, plugin.__class__.__name__ plugin_id, plugin.__class__.__name__
) )
logger.info( included = (content_type != 'none'
"[%s] content_type=%s, display_mode=%s", or display_mode == VegasDisplayMode.STATIC)
plugin_id, content_type, display_mode.value logger.debug(
"[%s] Vegas: %s (content_type=%s, display_mode=%s)",
plugin_id, "included" if included else "excluded",
content_type, display_mode.value
) )
if included:
if content_type != 'none' or display_mode == VegasDisplayMode.STATIC:
available_plugins.append(plugin_id) available_plugins.append(plugin_id)
logger.info("[%s] --> INCLUDED in Vegas scroll", plugin_id)
else:
logger.info("[%s] --> EXCLUDED from Vegas scroll", plugin_id)
else:
logger.info("[%s] --> SKIPPED (not enabled)", plugin_id)
else: else:
logger.warning( logger.warning(
"plugin_manager does not have plugins attribute: %s", "plugin_manager does not have plugins attribute: %s",
@@ -400,24 +362,20 @@ class StreamManager:
# Apply ordering from config (outside lock for potentially slow operation) # Apply ordering from config (outside lock for potentially slow operation)
ordered_plugins = self.config.get_ordered_plugins(available_plugins) ordered_plugins = self.config.get_ordered_plugins(available_plugins)
logger.info(
"Vegas scroll plugin list: %d available -> %d ordered",
len(available_plugins), len(ordered_plugins)
)
logger.info("Ordered plugins: %s", ordered_plugins)
ordered_plugins = self._apply_priority_weights(ordered_plugins) ordered_plugins = self._apply_priority_weights(ordered_plugins)
# Atomically update shared state under lock to avoid races with prefetchers # Atomically update shared state under lock to avoid races with prefetchers
with self._buffer_lock: with self._buffer_lock:
self._ordered_plugins = ordered_plugins self._ordered_plugins = ordered_plugins
# Reset indices if needed
if self._current_index >= len(self._ordered_plugins):
self._current_index = 0
if self._prefetch_index >= len(self._ordered_plugins): if self._prefetch_index >= len(self._ordered_plugins):
self._prefetch_index = 0 self._prefetch_index = 0
logger.info("=" * 60) slots = (f", {len(ordered_plugins)} slots"
if len(ordered_plugins) != len(set(ordered_plugins)) else "")
logger.info(
"Vegas rotation: %d of %d loaded plugin(s)%s: %s",
len(set(ordered_plugins)), loaded, slots, ', '.join(ordered_plugins)
)
def _plugin_weight(self, plugin_id: str) -> int: def _plugin_weight(self, plugin_id: str) -> int:
"""Slots per cycle for one plugin. """Slots per cycle for one plugin.
@@ -490,7 +448,7 @@ class StreamManager:
schedule = self._unclump_seam(schedule) schedule = self._unclump_seam(schedule)
boosted = {p: w for p, w in weights.items() if w > 1} boosted = {p: w for p, w in weights.items() if w > 1}
logger.info( logger.debug(
"Vegas rotation weighted: %d slots for %d plugins (boosted: %s)", "Vegas rotation weighted: %d slots for %d plugins (boosted: %s)",
len(schedule), len(ordered), boosted) len(schedule), len(ordered), boosted)
return schedule return schedule
@@ -607,11 +565,6 @@ class StreamManager:
ContentSegment or None if fetch failed ContentSegment or None if fetch failed
""" """
try: try:
logger.info("=" * 60)
logger.info("[%s] FETCHING CONTENT", plugin_id)
logger.info("=" * 60)
# Get plugin instance
if not hasattr(self.plugin_manager, 'plugins'): if not hasattr(self.plugin_manager, 'plugins'):
logger.warning("[%s] plugin_manager has no plugins attribute", plugin_id) logger.warning("[%s] plugin_manager has no plugins attribute", plugin_id)
return None return None
@@ -621,18 +574,11 @@ class StreamManager:
logger.warning("[%s] Plugin not found in plugin_manager.plugins", plugin_id) logger.warning("[%s] Plugin not found in plugin_manager.plugins", plugin_id)
return None return None
logger.info(
"[%s] Plugin found: class=%s, enabled=%s",
plugin_id, plugin.__class__.__name__, getattr(plugin, 'enabled', 'N/A')
)
# Get display mode from plugin
display_mode = VegasDisplayMode.FIXED_SEGMENT display_mode = VegasDisplayMode.FIXED_SEGMENT
try: try:
display_mode = plugin.get_vegas_display_mode() display_mode = plugin.get_vegas_display_mode()
logger.info("[%s] Display mode: %s", plugin_id, display_mode.value)
except (AttributeError, TypeError) as e: except (AttributeError, TypeError) as e:
logger.info( logger.debug(
"[%s] get_vegas_display_mode() not available: %s (using FIXED_SEGMENT)", "[%s] get_vegas_display_mode() not available: %s (using FIXED_SEGMENT)",
plugin_id, e plugin_id, e
) )
@@ -644,21 +590,21 @@ class StreamManager:
segment = ContentSegment( segment = ContentSegment(
plugin_id=plugin_id, plugin_id=plugin_id,
images=[], # No images needed for static pause images=[], # No images needed for static pause
total_width=0,
display_mode=display_mode display_mode=display_mode
) )
self.stats['segments_fetched'] += 1 self.stats['segments_fetched'] += 1
logger.info( logger.debug(
"[%s] Created STATIC placeholder (pause trigger)", "[%s] Created STATIC placeholder (pause trigger)",
plugin_id plugin_id
) )
return segment return segment
# Get content via adapter for SCROLL/FIXED_SEGMENT modes # Get content via adapter for SCROLL/FIXED_SEGMENT modes
logger.info("[%s] Calling plugin_adapter.get_content()...", plugin_id)
images = self.plugin_adapter.get_content(plugin, plugin_id) images = self.plugin_adapter.get_content(plugin, plugin_id)
if not images: if not images:
logger.warning("[%s] NO CONTENT RETURNED from plugin_adapter", plugin_id) # The adapter already warns when every content path failed;
# an empty result is otherwise routine (nothing scheduled).
logger.debug("[%s] No Vegas content this cycle", plugin_id)
return None return None
# Calculate total width # Calculate total width
@@ -667,17 +613,14 @@ class StreamManager:
segment = ContentSegment( segment = ContentSegment(
plugin_id=plugin_id, plugin_id=plugin_id,
images=images, images=images,
total_width=total_width,
display_mode=display_mode display_mode=display_mode
) )
self.stats['segments_fetched'] += 1 self.stats['segments_fetched'] += 1
logger.info( logger.debug(
"[%s] SEGMENT CREATED: %d images, %dpx total, mode=%s", "[%s] Segment: %d image(s), %dpx, mode=%s",
plugin_id, len(images), total_width, display_mode.value plugin_id, len(images), total_width, display_mode.value
) )
logger.info("=" * 60)
return segment return segment
except Exception: except Exception:
@@ -696,24 +639,6 @@ class StreamManager:
if len(self._active_buffer) < low_water: if len(self._active_buffer) < low_water:
self._prefetch_content(count=low_water - len(self._active_buffer)) self._prefetch_content(count=low_water - len(self._active_buffer))
def get_all_content_for_composition(self) -> List[Image.Image]:
"""
Get all buffered content as a flat list of images.
Skips STATIC segments as they don't have images to compose.
Prefer get_grouped_content_for_composition(): flattening loses the
plugin boundaries, which is what tells the compositor where a
separator belongs and where it does not.
Returns:
List of all images in buffer order
"""
all_images = []
for _plugin_id, images in self.get_grouped_content_for_composition():
all_images.extend(images)
return all_images
def get_grouped_content_for_composition(self) -> List[Tuple[str, List[Image.Image]]]: def get_grouped_content_for_composition(self) -> List[Tuple[str, List[Image.Image]]]:
""" """
Get buffered content grouped by the plugin that produced it. Get buffered content grouped by the plugin that produced it.
@@ -816,8 +741,6 @@ class StreamManager:
"""Reset the stream manager state.""" """Reset the stream manager state."""
with self._buffer_lock: with self._buffer_lock:
self._active_buffer.clear() self._active_buffer.clear()
self._staging_buffer.clear()
self._current_index = 0
self._prefetch_index = 0 self._prefetch_index = 0
self._pending_updates.clear() self._pending_updates.clear()
-1
View File
@@ -105,7 +105,6 @@ def _reset_display_manager_globals():
# An instance left here is what the next module's DisplayManager() call # An instance left here is what the next module's DisplayManager() call
# gets back -- potentially one built against a MagicMock matrix. # gets back -- potentially one built against a MagicMock matrix.
dm_mod.DisplayManager._instance = None dm_mod.DisplayManager._instance = None
dm_mod.DisplayManager._initialized = False
# Put a binding back if a patch outlived the module that started it. # Put a binding back if a patch outlived the module that started it.
# Restoring rather than failing: a leak reported against an innocent module # Restoring rather than failing: a leak reported against an innocent module
+59
View File
@@ -0,0 +1,59 @@
"""DisplayController.cleanup() must tear down Vegas mode.
VegasModeCoordinator.cleanup() stops the scroll, resets the render pipeline and
stream manager and drops the adapter's content cache, but the controller never
called it, so none of that teardown ran at shutdown.
"""
import os
from unittest.mock import MagicMock
os.environ.setdefault("EMULATOR", "true")
from src.display_controller import DisplayController
def _controller(vegas_coordinator):
dc = object.__new__(DisplayController)
dc.plugin_manager = MagicMock()
dc.config_service = MagicMock()
dc._font_usage_publisher = None
dc.display_manager = MagicMock()
dc.vegas_coordinator = vegas_coordinator
return dc
def test_cleanup_tears_down_vegas():
vegas = MagicMock()
_controller(vegas).cleanup()
vegas.cleanup.assert_called_once_with()
def test_vegas_is_torn_down_before_the_display_manager():
# Stopping Vegas resets the display's scrolling state, so the display
# manager has to still be there when it runs.
order = []
vegas = MagicMock()
vegas.cleanup.side_effect = lambda: order.append('vegas')
dc = _controller(vegas)
dc.display_manager.cleanup.side_effect = lambda: order.append('display')
dc.cleanup()
assert order == ['vegas', 'display']
def test_cleanup_without_vegas_does_not_raise():
dc = _controller(None)
dc.cleanup()
dc.display_manager.cleanup.assert_called_once_with()
def test_a_failing_vegas_teardown_does_not_stop_the_rest():
vegas = MagicMock()
vegas.cleanup.side_effect = RuntimeError('boom')
dc = _controller(vegas)
dc.cleanup()
dc.display_manager.cleanup.assert_called_once_with()
@@ -0,0 +1,77 @@
"""Startup validation runs each check once.
DisplayController ran StartupValidator.validate_all() twice -- once before the
plugin manager existed and again after -- so every config, cache, display and
systemd-unit warning was logged twice at every boot. The second pass only
needs the plugin checks.
"""
import logging
from unittest.mock import MagicMock, patch
class RecordingValidator:
"""Stands in for StartupValidator and records which checks ran."""
calls = []
def __init__(self, config_manager, plugin_manager=None, cache_manager=None):
self.plugin_manager = plugin_manager
self.errors = []
self.warnings = []
def validate_all(self):
RecordingValidator.calls.append(('validate_all', self.plugin_manager is not None))
self.warnings = ['config warning']
return True, [], list(self.warnings)
def _validate_plugins(self):
RecordingValidator.calls.append(('plugins',))
self.warnings = ['plugin warning']
def _build_controller(mock_config_manager, mock_display_manager,
mock_cache_manager, config):
from src.display_controller import DisplayController
mock_config_manager.get_config.return_value = config
mock_config_manager.load_config.return_value = config
mock_pm = MagicMock()
mock_pm.discover_plugins.return_value = []
mock_pm.plugins = {}
mock_pm.plugin_manifests = {}
mock_pm.plugin_last_update = {}
mock_pm.health_tracker = None
with patch('src.display_controller.ConfigManager', return_value=mock_config_manager), \
patch('src.display_controller.DisplayManager', return_value=mock_display_manager), \
patch('src.display_controller.CacheManager', return_value=mock_cache_manager), \
patch('src.display_controller.FontManager'), \
patch('src.plugin_system.PluginManager', return_value=mock_pm), \
patch('src.startup_validator.StartupValidator', RecordingValidator):
controller = DisplayController()
return controller
def test_each_check_runs_once(mock_config_manager, mock_display_manager,
mock_cache_manager, test_config_with_plugins,
emulator_mode, caplog):
RecordingValidator.calls = []
with caplog.at_level(logging.WARNING, logger='src.display_controller'):
controller = _build_controller(mock_config_manager, mock_display_manager,
mock_cache_manager, test_config_with_plugins)
try:
full_passes = [c for c in RecordingValidator.calls if c[0] == 'validate_all']
assert len(full_passes) == 1, RecordingValidator.calls
plugin_checks = (
sum(1 for c in RecordingValidator.calls if c[0] == 'plugins')
+ sum(1 for c in full_passes if c[1]))
assert plugin_checks == 1, RecordingValidator.calls
config_warnings = [r for r in caplog.records
if 'config warning' in r.getMessage()]
assert len(config_warnings) == 1
assert any('plugin warning' in r.getMessage() for r in caplog.records)
finally:
controller.cleanup()
+3 -9
View File
@@ -83,15 +83,9 @@ class TestVegasCoordinatorCallbackWiring:
fake_coordinator = MagicMock() fake_coordinator = MagicMock()
import src.display_controller as dc_module from unittest.mock import patch
original_imported = dc_module._vegas_mode_imported with patch('src.vegas_mode.VegasModeCoordinator',
original_class = dc_module.VegasModeCoordinator MagicMock(return_value=fake_coordinator)):
try:
dc_module._vegas_mode_imported = True
dc_module.VegasModeCoordinator = MagicMock(return_value=fake_coordinator)
dc._initialize_vegas_mode() dc._initialize_vegas_mode()
finally:
dc_module._vegas_mode_imported = original_imported
dc_module.VegasModeCoordinator = original_class
fake_coordinator.set_update_callback.assert_called_once_with(dc._tick_plugin_updates_for_vegas) fake_coordinator.set_update_callback.assert_called_once_with(dc._tick_plugin_updates_for_vegas)
-4
View File
@@ -28,7 +28,6 @@ def dm(tmp_path_factory):
"""One real DisplayManager on the emulator (it's a process singleton).""" """One real DisplayManager on the emulator (it's a process singleton)."""
from src.display_manager import DisplayManager from src.display_manager import DisplayManager
DisplayManager._instance = None DisplayManager._instance = None
DisplayManager._initialized = False
manager = DisplayManager({ manager = DisplayManager({
"display": { "display": {
"hardware": {"rows": 32, "cols": 64, "chain_length": 2, "hardware": {"rows": 32, "cols": 64, "chain_length": 2,
@@ -60,7 +59,6 @@ def dm(tmp_path_factory):
"test module.") "test module.")
yield manager yield manager
DisplayManager._instance = None DisplayManager._instance = None
DisplayManager._initialized = False
class _SwapSpy: class _SwapSpy:
@@ -230,7 +228,6 @@ class TestKillSwitch:
def test_config_flag_wires_through(self): def test_config_flag_wires_through(self):
from src.display_manager import DisplayManager from src.display_manager import DisplayManager
DisplayManager._instance = None DisplayManager._instance = None
DisplayManager._initialized = False
try: try:
manager = DisplayManager({ manager = DisplayManager({
"display": { "display": {
@@ -243,7 +240,6 @@ class TestKillSwitch:
assert manager._dirty_tracking_enabled is False assert manager._dirty_tracking_enabled is False
finally: finally:
DisplayManager._instance = None DisplayManager._instance = None
DisplayManager._initialized = False
if __name__ == "__main__": if __name__ == "__main__":
+49
View File
@@ -0,0 +1,49 @@
"""display_manager's logger must follow the level run.py configures.
The module pinned its own logger to INFO at import, which overrides the root
level, so ``run.py -d`` never showed a single DEBUG line from the display
manager -- the dirty-tracking, fallback-mode and scrolling-state traces were
unreachable without editing the source.
"""
import logging
import os
os.environ.setdefault("EMULATOR", "true")
import src.display_manager as dm
def test_the_module_sets_no_level_of_its_own():
assert dm.logger.level == logging.NOTSET
def test_scrolling_state_logs_only_when_it_changes(caplog):
# Vegas and every scrolling plugin set the state on each frame, so a line
# per call was ~120 DEBUG lines a second once debug output was visible.
dm_obj = object.__new__(dm.DisplayManager)
dm_obj._frame_hold = 1
dm_obj._scrolling_state = {'is_scrolling': False, 'last_scroll_activity': 0}
with caplog.at_level(logging.DEBUG, logger='src.display_manager'):
for _ in range(5):
dm_obj.set_scrolling_state(True, frame_hold=2)
dm_obj.set_scrolling_state(False)
dm_obj.set_scrolling_state(False)
lines = [r.getMessage() for r in caplog.records
if r.getMessage().startswith('Scrolling state set to')]
assert lines == ['Scrolling state set to: True', 'Scrolling state set to: False']
# The state itself still updates on every call.
assert dm_obj._scrolling_state['is_scrolling'] is False
assert dm_obj._frame_hold == 1
def test_debug_output_appears_when_the_root_is_at_debug():
root = logging.getLogger()
previous = root.level
root.setLevel(logging.DEBUG)
try:
assert dm.logger.isEnabledFor(logging.DEBUG)
finally:
root.setLevel(previous)
-1
View File
@@ -154,7 +154,6 @@ def vegas_coordinator(controller):
coord._update_callback = None coord._update_callback = None
coord._update_tick_running = False coord._update_tick_running = False
coord.sync_manager = None coord.sync_manager = None
coord._update_static_mode_plugins = lambda: None
coord._check_static_plugin_trigger = lambda: None coord._check_static_plugin_trigger = lambda: None
coord.frames = 0 coord.frames = 0
+24 -19
View File
@@ -2,7 +2,7 @@
Tests for src/vegas_mode/config.py Tests for src/vegas_mode/config.py
Covers VegasModeConfig: from_config, to_dict, get_frame_interval, Covers VegasModeConfig: from_config, to_dict, get_frame_interval,
is_plugin_included, get_ordered_plugins, validate. get_ordered_plugins, validate.
""" """
import pytest import pytest
@@ -38,6 +38,29 @@ class TestVegasModeConfigDefaults:
cfg = VegasModeConfig() cfg = VegasModeConfig()
assert len(cfg.excluded_plugins) == 0 assert len(cfg.excluded_plugins) == 0
def test_defaults_match_the_shipped_template(self):
# The template, the web UI help and CONFIG_REFERENCE all document
# these values. max_cycle_duration defaulted to 600 in code while all
# three said 240, so an install without the key ran iterations 2.5x
# longer than documented.
import json
from pathlib import Path
template = json.loads(
(Path(__file__).resolve().parent.parent / "config"
/ "config.template.json").read_text(encoding="utf-8"))
shipped = template["display"]["vegas_scroll"]
defaults = VegasModeConfig().to_dict()
mismatched = {k: (v, defaults[k]) for k, v in shipped.items()
if k in defaults and defaults[k] != v}
assert not mismatched, f"template vs code default: {mismatched}"
def test_missing_keys_read_the_field_defaults(self):
# from_config used to repeat every default; with no keys set it must
# produce exactly the dataclass defaults.
assert VegasModeConfig.from_config({}).to_dict() == VegasModeConfig().to_dict()
assert (VegasModeConfig.from_config({"display": {"vegas_scroll": {}}}).to_dict()
== VegasModeConfig().to_dict())
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# from_config # from_config
@@ -147,24 +170,6 @@ class TestGetFrameInterval:
assert result == 1.0 assert result == 1.0
# ---------------------------------------------------------------------------
# is_plugin_included
# ---------------------------------------------------------------------------
class TestIsPluginIncluded:
def test_not_excluded_is_included(self):
cfg = VegasModeConfig(excluded_plugins={"bad_plugin"})
assert cfg.is_plugin_included("good_plugin") is True
def test_excluded_plugin_not_included(self):
cfg = VegasModeConfig(excluded_plugins={"bad_plugin"})
assert cfg.is_plugin_included("bad_plugin") is False
def test_empty_exclusions_all_included(self):
cfg = VegasModeConfig()
assert cfg.is_plugin_included("anything") is True
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# get_ordered_plugins # get_ordered_plugins
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+2 -4
View File
@@ -108,10 +108,10 @@ class TestInvalidatePendingUpdates:
# Left unconsumed they accumulate forever and nothing ever refreshes. # Left unconsumed they accumulate forever and nothing ever refreshes.
stream = self._manager({'baseball': SimpleNamespace()}) stream = self._manager({'baseball': SimpleNamespace()})
stream.mark_plugin_updated('baseball') stream.mark_plugin_updated('baseball')
assert stream.has_pending_updates() is True assert stream._pending_updates
stream.invalidate_pending_updates() stream.invalidate_pending_updates()
assert stream.has_pending_updates() is False assert not stream._pending_updates
assert stream.invalidate_pending_updates() == [] assert stream.invalidate_pending_updates() == []
def test_no_pending_updates_does_no_work(self): def test_no_pending_updates_does_no_work(self):
@@ -133,7 +133,6 @@ class TestInvalidatePendingUpdates:
class TestContinuousModeReachesTheRefresh: class TestContinuousModeReachesTheRefresh:
def _pipeline(self): def _pipeline(self):
stream = MagicMock() stream = MagicMock()
stream.get_buffer_status.return_value = {'staging_count': 0}
return RenderPipeline(VegasModeConfig(), FakeDisplayManager(), stream), stream return RenderPipeline(VegasModeConfig(), FakeDisplayManager(), stream), stream
def test_refresh_delegates_to_the_stream_manager(self): def test_refresh_delegates_to_the_stream_manager(self):
@@ -161,7 +160,6 @@ class TestContinuousModeReachesTheRefresh:
pipeline.refresh_updated_plugins() pipeline.refresh_updated_plugins()
assert pipeline.scroll_helper.scroll_position == 1234 assert pipeline.scroll_helper.scroll_position == 1234
stream.swap_buffers.assert_not_called()
stream.process_updates.assert_not_called() stream.process_updates.assert_not_called()
+54
View File
@@ -0,0 +1,54 @@
"""run_iteration() does no per-iteration static-mode bookkeeping.
Every iteration used to rebuild a set of STATIC-mode plugins -- asking each
plugin for its display mode and logging the result at INFO -- that nothing
ever read. The static pause is driven by _check_static_plugin_trigger(), which
looks at the next segment, not at that set.
"""
import logging
import threading
from types import SimpleNamespace
from unittest.mock import MagicMock
from src.plugin_system.base_plugin import VegasDisplayMode
from src.vegas_mode.config import VegasModeConfig
from src.vegas_mode.coordinator import VegasModeCoordinator
def _coordinator(plugins):
coord = VegasModeCoordinator.__new__(VegasModeCoordinator)
coord.vegas_config = VegasModeConfig.from_config({'display': {'vegas_scroll': {
'enabled': True, 'max_cycle_duration': 0}}})
coord.render_pipeline = MagicMock()
coord.stream_manager = MagicMock()
coord.display_manager = MagicMock()
coord.plugin_manager = SimpleNamespace(plugins=plugins, get_plugin=plugins.get)
coord.stats = {'cycles_completed': 0, 'interruptions': 0}
coord._state_lock = threading.Lock()
coord._is_active = True
coord._is_paused = False
coord._should_stop = False
coord._fps_last_health_log = 0.0
coord._fps_was_degraded = False
coord._live_priority_check = None
coord._live_priority_active = False
coord._interrupt_check = None
coord._interrupt_check_interval = 10
coord._update_callback = None
coord._update_tick_running = False
coord._check_static_plugin_trigger = lambda: None
coord.run_frame = lambda: True
return coord
def test_an_iteration_does_not_poll_every_plugin_for_its_mode(caplog):
static = MagicMock()
static.get_vegas_display_mode.return_value = VegasDisplayMode.STATIC
coord = _coordinator({'static-plugin': static})
with caplog.at_level(logging.INFO, logger='src.vegas_mode.coordinator'):
assert coord.run_iteration() is True
static.get_vegas_display_mode.assert_not_called()
assert not any('Static mode plugins' in r.getMessage() for r in caplog.records)
+3 -7
View File
@@ -416,7 +416,7 @@ class TestStreamGrouping:
from src.plugin_system.base_plugin import VegasDisplayMode from src.plugin_system.base_plugin import VegasDisplayMode
imgs = [Image.new('RGB', (10, 8)) for _ in range(count)] imgs = [Image.new('RGB', (10, 8)) for _ in range(count)]
return ContentSegment( return ContentSegment(
plugin_id=plugin_id, images=imgs, total_width=10 * count, plugin_id=plugin_id, images=imgs,
display_mode=mode or VegasDisplayMode.SCROLL) display_mode=mode or VegasDisplayMode.SCROLL)
def test_grouping_preserves_plugin_boundaries(self): def test_grouping_preserves_plugin_boundaries(self):
@@ -437,10 +437,6 @@ class TestStreamGrouping:
sm = self._stream([self._seg('a', 0), self._seg('b', 2)]) sm = self._stream([self._seg('a', 0), self._seg('b', 2)])
assert [pid for pid, _ in sm.get_grouped_content_for_composition()] == ['b'] assert [pid for pid, _ in sm.get_grouped_content_for_composition()] == ['b']
def test_flat_accessor_still_matches_grouped_total(self):
sm = self._stream([self._seg('a', 3), self._seg('b', 2)])
assert len(sm.get_all_content_for_composition()) == 5
class TestApiBoundsMatchValidate: class TestApiBoundsMatchValidate:
""" """
@@ -980,8 +976,8 @@ class TestRotationAcrossMultipleCycles:
""" """
The single-image crop advances a window across cycles. The second and later The single-image crop advances a window across cycles. The second and later
passes are where start + budget can land exactly on the image width, which passes are where start + budget can land exactly on the image width, which
crashed find_blank_cut in the field and lost that plugin's content for the once crashed the cut and lost that plugin's content for the cycle.
cycle. First-pass-only tests never reach it. First-pass-only tests never reach it.
""" """
def test_window_advances_over_many_cycles_without_error(self): def test_window_advances_over_many_cycles_without_error(self):
-49
View File
@@ -10,7 +10,6 @@ from src.vegas_mode.geometry import (
content_bounds, content_bounds,
dead_window_stats, dead_window_stats,
edge_blank, edge_blank,
find_blank_cut,
separation_gap, separation_gap,
trim_to_content, trim_to_content,
window_coverage_stats, window_coverage_stats,
@@ -320,51 +319,3 @@ class TestSeparationGap:
# flat gap; measured separation lifts them to the 24px target. # flat gap; measured separation lifts them to the 24px target.
card = paint(make_img(150), 0, 150) card = paint(make_img(150), 0, 150)
assert separation_gap(card, card, target=24, minimum=8) == 24 assert separation_gap(card, card, target=24, minimum=8) == 24
class TestFindBlankCut:
def test_snaps_to_the_nearest_gap(self):
img = paint(make_img(200), 0, 90)
paint(img, 110, 200)
# 100 is inside the 90..110 gap already.
assert find_blank_cut(img, 100, 20) == 100
def test_walks_outwards_to_find_a_gap(self):
img = paint(make_img(200), 0, 95)
paint(img, 105, 200)
cut = find_blank_cut(img, 90, 20)
assert 95 <= cut < 105
def test_solid_ink_returns_the_target(self):
assert find_blank_cut(paint(make_img(200), 0, 200), 100, 20) == 100
def test_target_at_image_width_does_not_index_past_the_end(self):
# A cut after the last column is legal. Indexing ink[width] raised
# IndexError in the field, losing that plugin's content for the cycle.
# Reached once the rotation offset advances so start + budget lands
# exactly on the image width.
img = paint(make_img(1840), 0, 1840)
assert find_blank_cut(img, 1840, 32) == 1840
def test_target_past_image_width_is_clamped(self):
img = paint(make_img(100), 0, 100)
assert find_blank_cut(img, 500, 32) == 100
def test_target_at_width_with_a_trailing_gap_snaps_back(self):
# Content 0..179, blank 180..199. The nearest blank column to 200 is
# 199, not the start of the gap — nearest is what keeps the cut as
# close as possible to the requested budget.
img = paint(make_img(200), 0, 180)
assert find_blank_cut(img, 200, 32) == 199
def test_zero_radius_returns_the_target(self):
assert find_blank_cut(paint(make_img(100), 0, 100), 50, 0) == 50
def test_negative_target_is_clamped_to_zero(self):
assert find_blank_cut(paint(make_img(100), 0, 100), -20, 8) == 0
@pytest.mark.parametrize("target", [0, 1, 50, 99, 100])
def test_never_raises_across_the_range(self, target):
img = paint(make_img(100), 0, 100)
cut = find_blank_cut(img, target, 16)
assert 0 <= cut <= 100
+1 -8
View File
@@ -24,7 +24,6 @@ class FakeDisplayManager:
def _make_pipeline(sync_manager=None): def _make_pipeline(sync_manager=None):
stream_manager = MagicMock() stream_manager = MagicMock()
stream_manager.get_buffer_status.return_value = {'staging_count': 0}
pipeline = RenderPipeline(VegasModeConfig(), FakeDisplayManager(), stream_manager) pipeline = RenderPipeline(VegasModeConfig(), FakeDisplayManager(), stream_manager)
pipeline.sync_manager = sync_manager pipeline.sync_manager = sync_manager
return pipeline, stream_manager return pipeline, stream_manager
@@ -37,7 +36,7 @@ class TestShouldRecompose:
stream_manager.has_pending_updates_for_visible_segments.return_value = False stream_manager.has_pending_updates_for_visible_segments.return_value = False
assert pipeline.should_recompose() is True assert pipeline.should_recompose() is True
def test_no_pending_updates_no_staging_does_not_recompose(self): def test_no_pending_updates_does_not_recompose(self):
pipeline, stream_manager = _make_pipeline() pipeline, stream_manager = _make_pipeline()
stream_manager.has_pending_updates_for_visible_segments.return_value = False stream_manager.has_pending_updates_for_visible_segments.return_value = False
assert pipeline.should_recompose() is False assert pipeline.should_recompose() is False
@@ -49,12 +48,6 @@ class TestShouldRecompose:
stream_manager.has_pending_updates_for_visible_segments.return_value = True stream_manager.has_pending_updates_for_visible_segments.return_value = True
assert pipeline.should_recompose() is True assert pipeline.should_recompose() is True
def test_staging_buffer_content_triggers_recompose(self):
pipeline, stream_manager = _make_pipeline()
stream_manager.get_buffer_status.return_value = {'staging_count': 1}
stream_manager.has_pending_updates_for_visible_segments.return_value = False
assert pipeline.should_recompose() is True
def test_sync_active_defers_pending_updates_to_cycle_boundary(self): def test_sync_active_defers_pending_updates_to_cycle_boundary(self):
"""Sync-mode deferral (PR #330's actual intent) must still hold: """Sync-mode deferral (PR #330's actual intent) must still hold:
pending updates alone must NOT trigger a mid-cycle hot-swap when a pending updates alone must NOT trigger a mid-cycle hot-swap when a
+79
View File
@@ -0,0 +1,79 @@
"""StreamManager logs one INFO line per plugin-list refresh.
The refresh runs at each cycle start and every 30 seconds. It used to write
decorative "=" * 60 banners plus a line per plugin ("INCLUDED", "SKIPPED"),
and each fetch added its own banners and "FETCHING CONTENT" / "SEGMENT
CREATED" lines, all at INFO: dozens of journal lines a minute on a Pi, burying
anything that mattered. Per-plugin detail belongs at DEBUG.
"""
import logging
from types import SimpleNamespace
from unittest.mock import MagicMock
from PIL import Image
from src.plugin_system.base_plugin import VegasDisplayMode
from src.vegas_mode.config import VegasModeConfig
from src.vegas_mode.stream_manager import StreamManager
LOGGER = "src.vegas_mode.stream_manager"
class _Plugin:
def __init__(self, enabled=True, mode=VegasDisplayMode.SCROLL):
self.enabled = enabled
self._mode = mode
def get_vegas_display_mode(self):
return self._mode
def _stream(plugins):
adapter = MagicMock()
adapter.get_content_type.return_value = 'multi'
adapter.get_content.return_value = [Image.new('RGB', (20, 8))]
return StreamManager(VegasModeConfig(), SimpleNamespace(plugins=plugins), adapter)
def _info(caplog):
return [r for r in caplog.records
if r.name == LOGGER and r.levelno == logging.INFO]
def test_a_refresh_logs_a_single_info_summary(caplog):
stream = _stream({
'clock': _Plugin(),
'weather': _Plugin(),
'off': _Plugin(enabled=False),
})
with caplog.at_level(logging.DEBUG, logger=LOGGER):
stream._refresh_plugin_list()
info = _info(caplog)
assert len(info) == 1, [r.getMessage() for r in info]
summary = info[0].getMessage()
assert 'clock' in summary and 'weather' in summary
assert 'off' not in summary.split(':', 1)[1]
# The per-plugin decisions are still there for anyone at DEBUG.
debug = [r.getMessage() for r in caplog.records if r.levelno == logging.DEBUG]
assert any('off' in m and 'not enabled' in m for m in debug)
def test_fetching_content_logs_nothing_at_info(caplog):
stream = _stream({'clock': _Plugin()})
with caplog.at_level(logging.DEBUG, logger=LOGGER):
segment = stream._fetch_plugin_content('clock')
assert segment is not None
assert _info(caplog) == []
def test_no_decorative_banners(caplog):
stream = _stream({'clock': _Plugin(), 'static': _Plugin(mode=VegasDisplayMode.STATIC)})
with caplog.at_level(logging.DEBUG, logger=LOGGER):
stream._refresh_plugin_list()
stream._fetch_plugin_content('clock')
stream._fetch_plugin_content('static')
assert not any(set(r.getMessage()) == {'='} for r in caplog.records)