From b9416ef803028ec37e0f2f756c051e52d52622d7 Mon Sep 17 00:00:00 2001 From: Chuck <33324927+ChuckBuilds@users.noreply.github.com> Date: Thu, 24 Sep 2026 17:37:15 -0400 Subject: [PATCH] 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * refactor(run): drop a redundant comment Co-Authored-By: Claude Opus 5.5 * 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 * docs(changelog): display-vegas Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- CHANGELOG.md | 8 + run.py | 2 - src/common/scroll_helper.py | 53 +- src/display_controller.py | 1327 ++++++++--------- src/display_manager.py | 106 +- src/vegas_mode/__init__.py | 4 +- src/vegas_mode/config.py | 86 +- src/vegas_mode/coordinator.py | 50 +- src/vegas_mode/geometry.py | 48 - src/vegas_mode/plugin_adapter.py | 31 +- src/vegas_mode/render_pipeline.py | 88 +- src/vegas_mode/stream_manager.py | 203 +-- test/conftest.py | 1 - test/test_display_controller_cleanup.py | 59 + ...t_display_controller_startup_validation.py | 77 + test/test_display_controller_vegas_tick.py | 12 +- test/test_display_dirty_tracking.py | 4 - test/test_display_manager_logging.py | 49 + test/test_display_pending_changes.py | 1 - test/test_vegas_config.py | 43 +- test/test_vegas_continuous_refresh.py | 6 +- test/test_vegas_coordinator_iteration.py | 54 + test/test_vegas_density.py | 10 +- test/test_vegas_geometry.py | 49 - test/test_vegas_render_pipeline_recompose.py | 9 +- test/test_vegas_stream_log_volume.py | 79 + 26 files changed, 1182 insertions(+), 1277 deletions(-) create mode 100644 test/test_display_controller_cleanup.py create mode 100644 test/test_display_controller_startup_validation.py create mode 100644 test/test_display_manager_logging.py create mode 100644 test/test_vegas_coordinator_iteration.py create mode 100644 test/test_vegas_stream_log_volume.py diff --git a/CHANGELOG.md b/CHANGELOG.md index d581dd30..761676fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -74,6 +74,14 @@ accepts both, but the store flags the old spelling as deprecated - 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. +- 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 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 diff --git a/run.py b/run.py index a1400c68..b7b1f52a 100755 --- a/run.py +++ b/run.py @@ -31,8 +31,6 @@ if args.emulator: print("Using pygame/RGBMatrixEmulator for display") print("Press ESC to exit\n") -# Project directory already added above - # Debug output (only in debug mode or emulator mode) debug_mode = args.debug or args.emulator or os.environ.get('LEDMATRIX_DEBUG', '').lower() == 'true' if debug_mode: diff --git a/src/common/scroll_helper.py b/src/common/scroll_helper.py index 73c6e1e8..fe76942a 100644 --- a/src/common/scroll_helper.py +++ b/src/common/scroll_helper.py @@ -238,20 +238,9 @@ class ScrollHelper: self.cached_image = full_image # Convert to numpy array for fast operations 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 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.total_distance_scrolled = 0.0 self.scroll_complete = False @@ -339,10 +328,8 @@ class ScrollHelper: # gained. This is what the one visibly smooth scroller on the # hardware (the stock ticker) was already doing by virtue of never # enabling frame-based mode. - if self.scroll_delay > 0: - pixels_per_second = self.scroll_speed / self.scroll_delay - else: - pixels_per_second = self.scroll_speed * 100.0 + # set_scroll_delay clamps scroll_delay to at least 0.001. + pixels_per_second = self.scroll_speed / self.scroll_delay pixels_to_move = pixels_per_second * delta_time self.last_step_time = current_time else: @@ -353,11 +340,11 @@ class ScrollHelper: self.scroll_position += pixels_to_move self.total_distance_scrolled += pixels_to_move - # Calculate required total distance: total_scroll_width only. - # The image already includes display_width pixels of blank padding at the start - # (added by create_scrolling_image), so once scroll_position reaches - # total_scroll_width the last card has fully scrolled off the left edge. - # Adding display_width here would cause 1-2 extra wrap-arounds on wide chains. + # One pass is total_scroll_width. With the default lead_gap the strip + # starts with display_width of blank, so by then the last item has + # fully left the panel; a caller passing a smaller lead_gap (Vegas) + # decides for itself where its cycle ends. Adding display_width here + # caused 1-2 extra wrap-arounds on wide chains. required_total_distance = self.total_scroll_width # 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 ): 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 # Progress telemetry, emitted every few seconds for the whole of # 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``. - Implemented with numpy rather than scipy.ndimage.shift: scipy is not - installed on the target devices, and the old scipy-based sub-pixel path - was dead code -- get_visible_portion never consulted the flag. The scipy - import was removed with it; installing scipy has no effect. + Implemented with numpy rather than scipy.ndimage.shift, which is not + installed on the target devices. Args: start_x: Left column of the earlier of the two frames @@ -571,22 +555,16 @@ class ScrollHelper: return self.min_duration try: - # Calculate total scroll distance needed - # The image already includes display_width padding at the start, so we need - # to scroll total_scroll_width pixels to show all content, plus display_width - # more pixels to ensure the last content scrolls completely off the screen + # The strip's width plus one more screen, so the duration covers + # the last item leaving the panel even when the strip has less + # than display_width of lead-in blank (lead_gap). total_scroll_distance = self.total_scroll_width + self.display_width # Calculate effective pixels per second based on scrolling mode if self.frame_based_scrolling: - # Frame-based mode: scroll_speed is pixels per frame, scroll_delay is seconds per frame - # Effective pixels per second = pixels per frame / seconds per frame - if self.scroll_delay > 0: - 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) + # Frame-based mode: scroll_speed is pixels per scroll_delay + # seconds, and set_scroll_delay keeps scroll_delay >= 0.001. + pixels_per_second = self.scroll_speed / self.scroll_delay scroll_mode_str = "frame-based" else: # Time-based mode: scroll_speed is already pixels per second @@ -1072,7 +1050,6 @@ class ScrollHelper: Returns: 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 return { 'scroll_position': self.scroll_position, diff --git a/src/display_controller.py b/src/display_controller.py index 42201c73..3f6222bf 100644 --- a/src/display_controller.py +++ b/src/display_controller.py @@ -22,6 +22,7 @@ Entry point: :func:`main` — instantiates :class:`DisplayController` and calls import time import os +import inspect import json import threading import types @@ -31,7 +32,6 @@ from datetime import datetime from concurrent.futures import ThreadPoolExecutor, as_completed # pylint: disable=no-name-in-module import pytz -# Core system imports only - all functionality now handled via plugins from src.display_manager import DisplayManager from src.config_manager import ConfigManager from src.config_service import ConfigService @@ -39,6 +39,7 @@ from src.cache_manager import CacheManager from src.font_manager import FontManager from src.logging_config import get_logger from src.common.sync_manager import DisplaySyncManager, SyncRole +from src.vegas_mode.render_pipeline import SYNC_SEND_INTERVAL # Get logger with consistent configuration logger = get_logger(__name__) @@ -57,11 +58,25 @@ _INITIAL_UPDATE_BUDGET_SECONDS = 20.0 # slot it never had a chance to use. _MIN_INITIAL_UPDATE_TIMEOUT_SECONDS = 2.0 -# Vegas mode import (lazy loaded to avoid circular imports) -_vegas_mode_imported = False -VegasModeCoordinator = None DEFAULT_DYNAMIC_DURATION_CAP = 180.0 +# Follower dead reckoning (the follower branch of DisplayController.run()). +# A leader position further off than this fraction of the strip is a cycle +# reset, and is snapped to rather than corrected toward. +_FOLLOWER_SNAP_FRACTION = 0.5 +# Strip width assumed, in screens, before the follower has the leader's image. +_FOLLOWER_FALLBACK_STRIP_SCREENS = 4 +# Drift beyond this many pixels is corrected by _FOLLOWER_DRIFT_GAIN of the +# error per tick; smaller drift by _FOLLOWER_NEAR_GAIN, so UDP jitter does not +# show as the scroll twitching. +_FOLLOWER_DRIFT_PX = 10 +_FOLLOWER_DRIFT_GAIN = 0.20 +_FOLLOWER_NEAR_GAIN = 0.05 +# Follower render period, and how late a frame may run before the pacing grid +# is restarted rather than caught up. +_FOLLOWER_FRAME_INTERVAL = 1.0 / 60 +_FOLLOWER_DEADLINE_SLIP = 0.1 + class DisplayController: """ Top-level controller that owns the LED display run loop. @@ -92,7 +107,7 @@ class DisplayController: config_manager=config_manager, enable_hot_reload=enable_hot_reload ) - self.config_manager = config_manager # Keep for backward compatibility + self.config_manager = config_manager self.config = self.config_service.get_config() self.cache_manager = CacheManager() # The web interface's /api/v3/errors/* read what this publishes. @@ -100,24 +115,20 @@ class DisplayController: start_error_snapshot_publisher(self.cache_manager) logger.info("Config loaded in %.3f seconds (hot-reload: %s)", time.time() - start_time, enable_hot_reload) - # Validate startup configuration + # Validate startup configuration. Errors are logged, not fatal. The + # plugin checks need the plugin manager and run once it exists. try: from src.startup_validator import StartupValidator validator = StartupValidator(self.config_manager, cache_manager=self.cache_manager) is_valid, errors, warnings = validator.validate_all() - - if warnings: - for warning in warnings: - logger.warning(f"Startup validation warning: {warning}") - + for warning in warnings: + logger.warning("Startup validation warning: %s", warning) if not is_valid: - error_msg = "Startup validation failed:\n" + "\n".join(f" - {e}" for e in errors) - logger.error(error_msg) - # For now, log errors but continue - can be made stricter later - # validator.raise_on_errors() # Uncomment to fail fast on errors + logger.error("Startup validation failed:\n%s", + "\n".join(f" - {e}" for e in errors)) except Exception as e: - logger.warning(f"Startup validation could not be completed: {e}") + logger.warning("Startup validation could not be completed: %s", e) # Automatic updates need their health-check units, and this is the # one root process running project code, so it installs them while @@ -158,29 +169,28 @@ class DisplayController: _real_update() self.display_manager.update_display = _follower_gated_update - # Note: _on_new_cycle is NOT registered here. The leader now sends - # its actual scroll image via TCP at each new_cycle, so the follower - # adopts that image directly via set_on_scroll_image(). Registering - # _on_new_cycle would trigger a local rebuild that overwrites the - # leader's just-received image with a different locally-built one. + # No new_cycle handler is registered: the leader sends its scroll + # image over TCP at each new cycle and the follower adopts it (see + # set_on_scroll_image in _initialize_vegas_mode). A local rebuild + # would overwrite that image with a different, locally built one. + + # Follower render-loop state (see the follower branch of run()). + self._follower_dr_last_t: Optional[float] = None # perf_counter of last tick + self._follower_local_x: Optional[float] = None # dead-reckoned scroll_x + # Set on a cycle reset; holds the last frame until the leader's new + # scroll image arrives. + self._follower_pending_new_image = False + self._follower_last_frame = None + self._follower_deadline: Optional[float] = None + # Leader: time.time() of the last follower frame sent. + self._last_follower_send = 0.0 # Initialize Font Manager font_time = time.time() self.font_manager = FontManager(self.config) logger.info("FontManager initialized in %.3f seconds", time.time() - font_time) - - # Initialize display modes - all functionality now handled via plugins - init_time = time.time() - - # All other functionality handled via plugins - logger.info("Display modes initialized in %.3f seconds", time.time() - init_time) - + self.force_change = False - - # All sports and content managers now handled via plugins - logger.info("All sports and content managers now handled via plugin system") - - # List of available display modes - now handled entirely by plugins self.available_modes = [] # Initialize Plugin System @@ -296,22 +306,20 @@ class DisplayController: except Exception as e: logger.warning("Could not enable plugin health/resource monitoring: %s", e) - # Validate plugins after plugin manager is created + # Only the plugin checks: validate_all() above has run the rest, + # and running it again logged every config warning twice. try: from src.startup_validator import StartupValidator validator = StartupValidator(self.config_manager, self.plugin_manager, cache_manager=self.cache_manager) - is_valid, errors, warnings = validator.validate_all() - - if warnings: - for warning in warnings: - logger.warning(f"Plugin validation warning: {warning}") - - if not is_valid: - error_msg = "Plugin validation failed:\n" + "\n".join(f" - {e}" for e in errors) - logger.error(error_msg) + validator._validate_plugins() + for warning in validator.warnings: + logger.warning("Plugin validation warning: %s", warning) + if validator.errors: + logger.error("Plugin validation failed:\n%s", + "\n".join(f" - {e}" for e in validator.errors)) except Exception as e: - logger.warning(f"Plugin validation could not be completed: {e}") + logger.warning("Plugin validation could not be completed: %s", e) # Discover plugins discovered_plugins = self.plugin_manager.discover_plugins() @@ -369,7 +377,7 @@ class DisplayController: if result['success']: plugin_id = result['plugin_id'] - logger.info("✓ Loaded plugin %s in %.3f seconds (%d/%d)", + logger.info("Loaded plugin %s in %.3f seconds (%d/%d)", plugin_id, result['load_time'], loaded_count, enabled_count) # Register the loaded plugin's modes, config subscription @@ -382,7 +390,7 @@ class DisplayController: logger.info("Progress: %d%% (%d/%d plugins, %.1fs elapsed)", progress_pct, loaded_count, enabled_count, elapsed) else: - logger.warning("✗ Failed to load plugin %s: %s", + logger.warning("Failed to load plugin %s: %s", result['plugin_id'], result['error']) # Log disabled plugins @@ -414,6 +422,8 @@ class DisplayController: # Display rotation state self.current_mode_index = 0 self.current_display_mode = None + # Last mode written to the display_current_state cache key. + self._last_published_mode: Optional[str] = None self.global_dynamic_config = ( self.config.get("display", {}).get("dynamic_duration", {}) or {} ) @@ -426,11 +436,12 @@ class DisplayController: # Schedule management self.is_display_active = True - self._was_display_active = True # Track previous state for schedule change detection + # The previous schedule result, so only transitions log at INFO. + self._was_display_active = True - # --- Opt #2: cached config values --- - # Avoids chained dict.get() with temporary {} defaults on every hot path call. - # Refreshed via _refresh_config_cache() on every hot-reload. + # Config values read on hot paths, cached so they are not re-read + # from the config dict every frame. _refresh_config_cache() updates + # them on every hot reload. self._normal_brightness: int = ( self.config.get('display', {}).get('hardware', {}).get('brightness', 90) ) @@ -441,12 +452,11 @@ class DisplayController: self.is_dimmed = False self._was_dimmed = False - # --- Opt #3: schedule minute-gate --- - # Both _check_schedule and _check_dim_schedule re-evaluated at most once per - # clock minute. Storing the (hour, minute) tuple that was last evaluated lets - # the methods skip all timezone / strptime work within the same minute. - # Reset to None on config change so the next call re-evaluates immediately. - self._tz = None # pytz timezone, lazily built from config + # _check_schedule and _check_dim_schedule re-evaluate at most once per + # clock minute: each stores the (hour, minute) it last evaluated and + # skips the strptime and comparison work until the minute changes. + # Reset to None on config change so the next call re-evaluates. + self._tz = None # pytz timezone, built lazily by _timezone() self._schedule_checked_minute: Optional[tuple] = None self._dim_checked_minute: Optional[tuple] = None self._cached_target_brightness: int = self._normal_brightness @@ -476,8 +486,6 @@ class DisplayController: def _initialize_vegas_mode(self): """Initialize Vegas mode coordinator if enabled.""" - global _vegas_mode_imported, VegasModeCoordinator - vegas_config = self.config.get('display', {}).get('vegas_scroll', {}) if not vegas_config.get('enabled', False): logger.debug("Vegas mode disabled in config") @@ -488,15 +496,7 @@ class DisplayController: return try: - # Lazy import to avoid circular imports - if not _vegas_mode_imported: - try: - from src.vegas_mode import VegasModeCoordinator as VMC - VegasModeCoordinator = VMC - _vegas_mode_imported = True - except ImportError: - logger.exception("Failed to import Vegas mode module") - return + from src.vegas_mode import VegasModeCoordinator self.vegas_coordinator = VegasModeCoordinator( config=self.config, @@ -537,7 +537,7 @@ class DisplayController: # cached_array so both Pis have pixel-identical images. import numpy as _np def _on_leader_scroll_image(image): - vc = getattr(self, 'vegas_coordinator', None) + vc = self.vegas_coordinator if vc and vc.render_pipeline: rp = vc.render_pipeline arr = _np.asarray(image.convert("RGB"), dtype=_np.uint8) @@ -556,15 +556,14 @@ class DisplayController: # the follower doesn't have to wait for the next new_cycle event. # Polls until the image is ready (Vegas may still be composing on startup). def _on_follower_connected(): - import time as _t for _ in range(300): # up to 30s - vc = getattr(self, 'vegas_coordinator', None) + vc = self.vegas_coordinator if vc and vc.render_pipeline: img = vc.render_pipeline.scroll_helper.cached_image if img is not None: self.sync_manager.send_scroll_image(img) return - _t.sleep(0.1) + time.sleep(0.1) logger.warning("Sync: no scroll image available to push to new follower") self.sync_manager.set_on_follower_connected(_on_follower_connected) @@ -617,25 +616,8 @@ class DisplayController: return False - def _check_schedule(self): - """Check if display should be active based on schedule.""" - schedule_config = self.config.get('schedule', {}) - - # If schedule config doesn't exist or is empty, default to always active - if not schedule_config: - self.is_display_active = True - self._was_display_active = True # Track previous state for schedule change detection - return - - # Check if schedule is explicitly disabled - # Default to True (schedule enabled) if 'enabled' key is missing for backward compatibility - if 'enabled' in schedule_config and not schedule_config.get('enabled', True): - self.is_display_active = True - self._was_display_active = True # Track previous state for schedule change detection - logger.debug("Schedule is disabled - display always active") - return - - # Lazily build the timezone object once; reuse on every subsequent call. + def _timezone(self): + """The configured timezone, built once and cached until config changes.""" if self._tz is None: timezone_str = self.config.get('timezone', 'UTC') try: @@ -643,8 +625,33 @@ class DisplayController: except pytz.UnknownTimeZoneError: logger.warning("Unknown timezone '%s', using UTC", timezone_str) self._tz = pytz.UTC + return self._tz - current_time = datetime.now(self._tz) + @staticmethod + def _in_window(start, end, now) -> bool: + """Whether ``now`` is within [start, end], a window that may span midnight.""" + if start <= end: + return start <= now <= end + return now >= start or now <= end + + def _check_schedule(self): + """Check if display should be active based on schedule.""" + schedule_config = self.config.get('schedule', {}) + + # No schedule configured: always active. + if not schedule_config: + self.is_display_active = True + self._was_display_active = True + return + + # A schedule without an 'enabled' key counts as enabled. + if 'enabled' in schedule_config and not schedule_config.get('enabled', True): + self.is_display_active = True + self._was_display_active = True + logger.debug("Schedule is disabled - display always active") + return + + current_time = datetime.now(self._timezone()) # Gate: schedule state can only change on a minute boundary, so skip # all the strptime / comparison work if we already evaluated this minute. current_minute_key = (current_time.hour, current_time.minute) @@ -677,14 +684,12 @@ class DisplayController: use_per_day = True else: logger.debug("Per-day schedule exists but %s not configured, using global schedule", current_day) - + if use_per_day: - # Use per-day schedule day_config = days_config[current_day] - - # Check if this day is enabled + if not day_config.get('enabled', True): - was_active = getattr(self, '_was_display_active', True) + was_active = self._was_display_active self.is_display_active = False if was_active: logger.info("Schedule activated: Display is now INACTIVE (%s is disabled in schedule). Display will be blanked.", current_day) @@ -692,56 +697,43 @@ class DisplayController: logger.debug("Display inactive - %s is disabled in schedule", current_day) self._was_display_active = self.is_display_active return - + start_time_str = day_config.get('start_time', '07:00') end_time_str = day_config.get('end_time', '23:00') schedule_type = f"per-day ({current_day})" else: - # Use global schedule start_time_str = schedule_config.get('start_time', '07:00') end_time_str = schedule_config.get('end_time', '23:00') schedule_type = "global" - + try: start_time = datetime.strptime(start_time_str, '%H:%M').time() end_time = datetime.strptime(end_time_str, '%H:%M').time() - - if start_time <= end_time: - # Normal case: start and end on same day - self.is_display_active = start_time <= current_time_only <= end_time - else: - # Overnight case: start and end on different days - self.is_display_active = current_time_only >= start_time or current_time_only <= end_time - - # Track previous state to detect changes - was_active = getattr(self, '_was_display_active', True) - - # Log schedule state changes + self.is_display_active = self._in_window(start_time, end_time, current_time_only) + + was_active = self._was_display_active if not self.is_display_active: if was_active: - # State changed from active to inactive - schedule kicked in - logger.info("Schedule activated: Display is now INACTIVE (outside %s schedule window %s - %s). Display will be blanked.", + logger.info("Schedule activated: Display is now INACTIVE (outside %s schedule window %s - %s). Display will be blanked.", schedule_type, start_time_str, end_time_str) else: - logger.debug("Display inactive - outside %s schedule window (%s - %s)", + logger.debug("Display inactive - outside %s schedule window (%s - %s)", schedule_type, start_time_str, end_time_str) else: if not was_active: - # State changed from inactive to active - logger.info("Schedule activated: Display is now ACTIVE (within %s schedule window %s - %s)", + logger.info("Schedule activated: Display is now ACTIVE (within %s schedule window %s - %s)", schedule_type, start_time_str, end_time_str) else: - logger.debug("Display active - within %s schedule window (%s - %s)", + logger.debug("Display active - within %s schedule window (%s - %s)", schedule_type, start_time_str, end_time_str) - - # Store current state for next check + self._was_display_active = self.is_display_active - + except ValueError as e: logger.warning("Invalid schedule format for %s schedule: %s (start: %s, end: %s). Defaulting to active.", schedule_type, e, start_time_str, end_time_str) self.is_display_active = True - self._was_display_active = True # Track previous state for schedule change detection + self._was_display_active = True def _check_dim_schedule(self) -> int: """ @@ -751,7 +743,6 @@ class DisplayController: Target brightness level (dim_brightness if in dim period, normal brightness otherwise) """ - # Opt #2: use cached brightness rather than re-traversing config dict normal_brightness = self._normal_brightness # If display is OFF via schedule, don't process dim schedule @@ -766,16 +757,8 @@ class DisplayController: self.is_dimmed = False return normal_brightness - # Opt #3: lazily build timezone; gate full re-parse to once per clock minute - if self._tz is None: - timezone_str = self.config.get('timezone', 'UTC') - try: - self._tz = pytz.timezone(timezone_str) - except pytz.UnknownTimeZoneError: - logger.warning("Unknown timezone '%s' in dim schedule, using UTC", timezone_str) - self._tz = pytz.UTC - - current_time = datetime.now(self._tz) + # Re-evaluated at most once per clock minute, like _check_schedule. + current_time = datetime.now(self._timezone()) current_minute_key = (current_time.hour, current_time.minute) if current_minute_key == self._dim_checked_minute: return self._cached_target_brightness @@ -806,15 +789,7 @@ class DisplayController: start_time = datetime.strptime(start_time_str, '%H:%M').time() end_time = datetime.strptime(end_time_str, '%H:%M').time() - # Determine if currently in dim period - if start_time <= end_time: - # Same-day schedule (e.g., 10:00 to 18:00) - in_dim_period = start_time <= current_time_only <= end_time - else: - # Overnight schedule (e.g., 20:00 to 07:00) - in_dim_period = current_time_only >= start_time or current_time_only <= end_time - - if in_dim_period: + if self._in_window(start_time, end_time, current_time_only): self.is_dimmed = True target_brightness = dim_config.get('dim_brightness', 30) else: @@ -872,38 +847,19 @@ class DisplayController: # running. deferred.append(plugin_id) continue - # Check circuit breaker before attempting update - if hasattr(self.plugin_manager, 'health_tracker') and self.plugin_manager.health_tracker: - if self.plugin_manager.health_tracker.should_skip_plugin(plugin_id): - logger.debug(f"Skipping update for plugin {plugin_id} due to circuit breaker") - continue - - # Use PluginExecutor if available for safe execution - if hasattr(self.plugin_manager, 'plugin_executor'): - # The remaining budget is the timeout, so the pass cannot - # run past its deadline. Bounding the loop alone did not do - # it: the last plugin to start could still block for the - # executor's full 30s, which turned a 20s budget into a 31.8s - # pass on the rig. - success = self.plugin_manager.plugin_executor.execute_update( - plugin_instance, plugin_id, timeout=update_timeout) - if success and hasattr(self.plugin_manager, 'plugin_last_update'): - self.plugin_manager.plugin_last_update[plugin_id] = time.time() - else: - # Fallback to direct call - try: - if hasattr(plugin_instance, 'update'): - plugin_instance.update() - if hasattr(self.plugin_manager, 'plugin_last_update'): - self.plugin_manager.plugin_last_update[plugin_id] = time.time() - # Record success - if hasattr(self.plugin_manager, 'health_tracker') and self.plugin_manager.health_tracker: - self.plugin_manager.health_tracker.record_success(plugin_id) - except Exception as exc: # pylint: disable=broad-except - logger.exception("Error updating plugin %s", plugin_id) - # Record failure - if hasattr(self.plugin_manager, 'health_tracker') and self.plugin_manager.health_tracker: - self.plugin_manager.health_tracker.record_failure(plugin_id, exc) + health_tracker = self.plugin_manager.health_tracker + if health_tracker is not None and health_tracker.should_skip_plugin(plugin_id): + logger.debug("Skipping update for plugin %s due to circuit breaker", plugin_id) + continue + + # The remaining budget is the timeout, so the pass cannot run past + # its deadline. Bounding the loop alone did not do it: the last + # plugin to start could still block for the executor's full 30s, + # which turned a 20s budget into a 31.8s pass on the rig. + success = self.plugin_manager.plugin_executor.execute_update( + plugin_instance, plugin_id, timeout=update_timeout) + if success: + self.plugin_manager.plugin_last_update[plugin_id] = time.time() if deferred: logger.info( @@ -913,29 +869,25 @@ class DisplayController: def _tick_plugin_updates_for_vegas(self) -> None: """Run scheduled plugin updates and tell Vegas mode which plugins - actually got fresh data, so it can hot-swap them into the scroll + actually got fresh data, so it can refresh them in the scroll without waiting for a full cycle to complete. - Used as the Vegas coordinator's update callback instead of the plain - _tick_plugin_updates() so that a live score change is reflected in - the ticker within a few seconds rather than at the next cycle - boundary (which, depending on min/max_cycle_duration, can be - minutes away). Restores wiring that PR #299 added and PR #330's - sync-mode refactor inadvertently dropped: coordinator.mark_plugin_updated() - has been unreachable dead code since. + This is the Vegas coordinator's update callback, used instead of the + plain _tick_plugin_updates() so that a live score change reaches the + ticker within seconds rather than at the next cycle boundary (which, + depending on min/max_cycle_duration, can be minutes away). - Delegates the before/after plugin_last_update snapshot to - PluginManager.run_scheduled_updates_with_changes() so the snapshot, - update pass, and diff are lock-protected against this callback's own - background update-tick thread racing the main render loop. + PluginManager.run_scheduled_updates_with_changes() takes the + before/after plugin_last_update snapshot, so the snapshot, update + pass and diff are locked against the main render loop's own update + tick running concurrently on the other thread. """ - if not self.plugin_manager or not hasattr(self.plugin_manager, "run_scheduled_updates_with_changes"): - self._tick_plugin_updates() + if not self.plugin_manager: return updated = self.plugin_manager.run_scheduled_updates_with_changes() - vc = getattr(self, "vegas_coordinator", None) + vc = self.vegas_coordinator if vc is None: return @@ -948,29 +900,27 @@ class DisplayController: logger.exception("Error marking plugin %s updated for Vegas", plugin_id) def _tick_plugin_updates(self): - """Run scheduled plugin updates if the plugin manager supports them.""" + """Run any plugin updates that are due.""" if not self.plugin_manager: return - - if hasattr(self.plugin_manager, "run_scheduled_updates"): - try: - self.plugin_manager.run_scheduled_updates() - except Exception: # pylint: disable=broad-except - logger.exception("Error running scheduled plugin updates") + try: + self.plugin_manager.run_scheduled_updates() + except Exception: # pylint: disable=broad-except + logger.exception("Error running scheduled plugin updates") @contextmanager def _display_lock_or_skip(self, plugin_id): """Try-lock guard keeping a plugin's display() off its in-flight update(). Yields True when display may run (lock held, released on exit) or - when no lock support exists (older plugin manager). Yields False when - the plugin's update() is currently executing on the background + when there is no plugin manager or plugin id to lock on. Yields False + when the plugin's update() is currently executing on the background worker — the caller should treat the frame as displayed (the panel holds the last pushed frame) rather than as a plugin failure, so a mid-update skip never advances the rotation. """ pm = self.plugin_manager - if not pm or not hasattr(pm, 'get_plugin_lock') or not plugin_id: + if not pm or not plugin_id: yield True return lock = pm.get_plugin_lock(plugin_id) @@ -982,7 +932,51 @@ class DisplayController: finally: lock.release() - _FOLLOWER_SEND_INTERVAL = 1.0 / 90 # raw bytes are cheap; 90fps > follower render rate + def _display_once(self, plugin, mode: str, accepts_display_mode: bool, + force_clear: bool = False): + """Call ``plugin.display()`` directly for one frame of a render loop. + + Frames after a screen's first dispatch come through here rather than + PluginExecutor: the executor spawns a thread per call, which at the + high-FPS loop's frame rate would cost more than the advisory timeout + it buys -- and that timeout cannot cancel a hung plugin anyway (see + execute_with_timeout). The first dispatch in run() still goes through + the executor, so failures there are caught and recorded. + + Args: + plugin: The plugin to draw. + mode: The display mode, passed on when the plugin accepts + ``display_mode`` so plugins with several modes stay on it. + accepts_display_mode: Whether display() takes ``display_mode``. + force_clear: Passed through to display(). + + Returns: + display()'s result, or True when the frame was skipped because + the plugin's update() holds its lock (the panel keeps the last + frame; that is not a failure). + """ + with self._display_lock_or_skip(getattr(plugin, 'plugin_id', None)) as can_display: + if not can_display: + return True + if accepts_display_mode: + return plugin.display(display_mode=mode, force_clear=force_clear) + return plugin.display(force_clear=force_clear) + + def _health_tracker(self): + """The plugin circuit breaker, or None when it is not enabled.""" + if self.plugin_manager is None: + return None + return self.plugin_manager.health_tracker + + def _follower_sign(self) -> int: + """-1 when the follower panel sits left of the leader, +1 when right. + + The follower shows the strip at the leader's position plus + ``sign * width``: content that has already scrolled off the leader's + left edge when it is on the left (the default). + """ + position = self.config.get("sync", {}).get("follower_position", "left") + return -1 if position == "left" else 1 def _send_follower_frame(self, plugin_instance) -> None: """Leader: generate and send the follower's portion of the current frame. @@ -994,17 +988,13 @@ class DisplayController: """ if not (self.sync_manager and self.sync_manager.role == SyncRole.LEADER): return - # Throttle to ~90fps via _FOLLOWER_SEND_INTERVAL — raw RGB bytes, no encode/decode now = time.time() - if now - getattr(self, '_last_follower_send', 0) < self._FOLLOWER_SEND_INTERVAL: + if now - self._last_follower_send < SYNC_SEND_INTERVAL: return self._last_follower_send = now follower_frame = None - width = self.display_manager.width - sync_cfg = self.config.get("sync", {}) - sign = -1 if sync_cfg.get("follower_position", "left") == "left" else 1 - offset = sign * width + offset = self._follower_sign() * self.display_manager.width # 1. Explicit hook — plugin opted in with get_offset_frame() try: @@ -1206,7 +1196,7 @@ class DisplayController: def _publish_current_mode_state_if_changed(self) -> None: """Publish current mode state only when it actually changed, to avoid writing to the shared cache on every render tick.""" - if self.current_display_mode != getattr(self, '_last_published_mode', None): + if self.current_display_mode != self._last_published_mode: self._publish_current_mode_state() def _publish_on_demand_state(self) -> None: @@ -1232,9 +1222,20 @@ class DisplayController: def _set_on_demand_error(self, message: str) -> None: """Set on-demand state to error and publish.""" + self._reset_on_demand_fields() self.on_demand_status = 'error' self.on_demand_last_error = message self.on_demand_last_event = None + self.rotation_resume_index = None + self._publish_on_demand_state() + + def _reset_on_demand_fields(self) -> None: + """Clear the on-demand session: no plugin, no modes, no expiry. + + Leaves status, last error/event and rotation_resume_index to the + caller, which is what differs between ending a session and failing + to start one. + """ self.on_demand_active = False self.on_demand_mode = None self.on_demand_modes = [] @@ -1244,8 +1245,19 @@ class DisplayController: self.on_demand_requested_at = None self.on_demand_expires_at = None self.on_demand_pinned = False - self.rotation_resume_index = None self.on_demand_schedule_override = False + + def _advance_on_demand(self) -> None: + """Move an active on-demand session to its next mode and publish it. + + The caller checks that on_demand_modes is non-empty. + """ + self.on_demand_mode_index = (self.on_demand_mode_index + 1) % len(self.on_demand_modes) + next_mode = self.on_demand_modes[self.on_demand_mode_index] + logger.info("Rotating to next on-demand mode: %s (index %d/%d)", + next_mode, self.on_demand_mode_index, len(self.on_demand_modes)) + self.current_display_mode = next_mode + self.force_change = True self._publish_on_demand_state() #: Shortest gap between mailbox disk reads. This is called after every @@ -1679,7 +1691,7 @@ class DisplayController: ordered_modes = self._apply_on_demand_pin(ordered_modes, resolved_mode, pinned) self.on_demand_active = True - self.on_demand_mode = resolved_mode # Keep for backward compatibility + self.on_demand_mode = resolved_mode self.on_demand_modes = ordered_modes self.on_demand_mode_index = 0 self.on_demand_plugin_id = resolved_plugin_id @@ -1731,20 +1743,11 @@ class DisplayController: self._publish_on_demand_state() return - self.on_demand_active = False - self.on_demand_mode = None - self.on_demand_modes = [] - self.on_demand_mode_index = 0 - self.on_demand_plugin_id = None - self.on_demand_duration = None - self.on_demand_requested_at = None - self.on_demand_expires_at = None - self.on_demand_pinned = False + self._reset_on_demand_fields() self.on_demand_status = 'idle' self.on_demand_last_error = None self.on_demand_last_event = reason or 'cleared' - self.on_demand_schedule_override = False - + # Clear on-demand configuration from cache self.cache_manager.clear_cache('display_on_demand_config') @@ -1764,7 +1767,7 @@ class DisplayController: self.rotation_resume_index = None self.force_change = True - logger.info("✓ ON-DEMAND MODE CLEARED (reason=%s), resuming normal rotation to mode: %s", + logger.info("On-demand mode cleared (reason=%s), resuming normal rotation to mode: %s", reason, self.current_display_mode) self._publish_on_demand_state() @@ -1882,7 +1885,7 @@ class DisplayController: def _vegas_keeps_live_in_ticker(self) -> bool: """Whether live content should stay in the ticker instead of preempting it.""" - coordinator = getattr(self, 'vegas_coordinator', None) + coordinator = self.vegas_coordinator config = getattr(coordinator, 'vegas_config', None) return bool(getattr(config, 'live_in_ticker', False)) @@ -1993,78 +1996,77 @@ class DisplayController: # Plugin update() threads still run (via _tick_plugin_updates above) so # data is fresh when we return to standalone if the leader goes offline. if self.sync_manager.is_follower_active(): - # Dead-reckoning follower render: - # Advance local position at configured speed each tick; snap or - # gently correct toward received scroll_x to absorb UDP jitter. - _now_dr = time.perf_counter() - _dt = _now_dr - getattr(self, '_follower_dr_last_t', _now_dr) - self._follower_dr_last_t = _now_dr + # Dead-reckoning follower render: advance the local + # position at the configured speed each tick, then snap or + # nudge it toward the leader's scroll_x to absorb UDP + # jitter. + now_dr = time.perf_counter() + last_t = self._follower_dr_last_t + dt = now_dr - last_t if last_t is not None else 0.0 + self._follower_dr_last_t = now_dr - vc = getattr(self, 'vegas_coordinator', None) + vc = self.vegas_coordinator rp = vc.render_pipeline if (vc and vc.render_pipeline) else None width = self.display_manager.width - # Opt #2: use pre-cached scroll speed (constant for the run) - vegas_speed = self._scroll_speed - local_x = getattr(self, '_follower_local_x', None) + local_x = self._follower_local_x if local_x is None: local_x = float(width) # safe start (past pre-roll guard) - local_x += vegas_speed * _dt + local_x += self._scroll_speed * dt - # Pull latest position from leader (may be None if no packet yet) + # Latest position from the leader (None until a packet arrives) scroll_x = self.sync_manager.get_latest_scroll_x() if scroll_x is not None: diff = scroll_x - local_x total_w = ( rp.scroll_helper.total_scroll_width if rp and rp.scroll_helper.total_scroll_width - else width * 4 + else width * _FOLLOWER_FALLBACK_STRIP_SCREENS ) - if abs(diff) > total_w * 0.5: - # Large jump → cycle reset, snap immediately + if abs(diff) > total_w * _FOLLOWER_SNAP_FRACTION: + # A jump that large is a cycle reset: snap. local_x = float(scroll_x) self._follower_pending_new_image = True - elif abs(diff) > 10: - # Moderate drift → 20% correction per tick - local_x += diff * 0.20 + elif abs(diff) > _FOLLOWER_DRIFT_PX: + local_x += diff * _FOLLOWER_DRIFT_GAIN else: - # Near → gentle 5% correction - local_x += diff * 0.05 + local_x += diff * _FOLLOWER_NEAR_GAIN self._follower_local_x = local_x if rp and rp.scroll_helper.cached_image is not None: - sync_cfg = self.config.get("sync", {}) - sign = -1 if sync_cfg.get("follower_position", "left") == "left" else 1 # Hold last frame until TCP image arrives after cycle reset - if not getattr(self, "_follower_pending_new_image", False): - if local_x >= width: - rp.scroll_helper.scroll_position = local_x + sign * width - frame = rp.scroll_helper.get_visible_portion() - if frame is not None: - self._follower_last_frame = frame + if not self._follower_pending_new_image and local_x >= width: + rp.scroll_helper.scroll_position = ( + local_x + self._follower_sign() * width) + frame = rp.scroll_helper.get_visible_portion() + if frame is not None: + self._follower_last_frame = frame elif scroll_x is None: # Fallback: pixel frame before first scroll_x arrives frame = self.sync_manager.get_latest_frame() if frame is not None: self._follower_last_frame = frame - display_frame = getattr(self, '_follower_last_frame', None) - if display_frame is not None: - self.display_manager.image = display_frame + if self._follower_last_frame is not None: + self.display_manager.image = self._follower_last_frame self.display_manager._sync_render_allowed = True self.display_manager.update_display() self.display_manager._sync_render_allowed = False - # Precision deadline timer — keeps render at exactly 60fps - _deadline = getattr(self, '_follower_deadline', None) - _now = time.perf_counter() - if _deadline is None or _now > _deadline + 0.1: - _deadline = _now - _deadline += 1.0 / 60 - self._follower_deadline = _deadline - _sleep = _deadline - time.perf_counter() - if _sleep > 0: - time.sleep(_sleep) + + # Pace to a fixed deadline grid rather than sleeping a + # flat interval, so render time doesn't lower the rate. + # After a stall longer than _FOLLOWER_DEADLINE_SLIP the + # grid restarts instead of rendering a burst to catch up. + now = time.perf_counter() + deadline = self._follower_deadline + if deadline is None or now > deadline + _FOLLOWER_DEADLINE_SLIP: + deadline = now + deadline += _FOLLOWER_FRAME_INTERVAL + self._follower_deadline = deadline + remaining = deadline - time.perf_counter() + if remaining > 0: + time.sleep(remaining) continue # Process any deferred updates that may have accumulated @@ -2163,13 +2165,12 @@ class DisplayController: if hasattr(plugin_instance, 'display'): # Check plugin health before attempting to display plugin_id = getattr(plugin_instance, 'plugin_id', active_mode) - should_skip = False - if self.plugin_manager and hasattr(self.plugin_manager, 'health_tracker') and self.plugin_manager.health_tracker: - should_skip = self.plugin_manager.health_tracker.should_skip_plugin(plugin_id) - if should_skip: - logger.info("Skipping plugin %s due to circuit breaker (mode: %s)", plugin_id, active_mode) - - if not should_skip: + health_tracker = self._health_tracker() + should_skip = (health_tracker is not None + and health_tracker.should_skip_plugin(plugin_id)) + if should_skip: + logger.info("Skipping plugin %s due to circuit breaker (mode: %s)", plugin_id, active_mode) + else: manager_to_display = plugin_instance logger.debug(f"Found plugin manager for mode {active_mode}: {type(plugin_instance).__name__}") else: @@ -2177,168 +2178,138 @@ class DisplayController: else: logger.warning(f"Mode {active_mode} not found in plugin_modes (available: {list(self.plugin_modes.keys())})") - # Display the current mode - display_result = True # Default to True for backward compatibility - display_failed_due_to_exception = False # Track if False was due to exception vs no content + # Display the current mode. A plugin that returns nothing + # (None) counts as having displayed. + display_result = True + # Whether a False result came from an exception rather than + # the plugin having no content. + display_failed_due_to_exception = False if not manager_to_display: logger.warning(f"No plugin manager found for mode {active_mode} - skipping display and rotating to next mode") display_result = False - elif manager_to_display: + else: plugin_id = getattr(manager_to_display, 'plugin_id', active_mode) try: logger.debug(f"Calling display() for {active_mode} with force_clear={self.force_change}") - can_display = False - if hasattr(manager_to_display, 'display'): - # Opt #1: look up (or compute once) whether display() accepts display_mode - _cache_key = plugin_id - if _cache_key not in self._plugin_accepts_display_mode: - import inspect as _inspect - self._plugin_accepts_display_mode[_cache_key] = ( - 'display_mode' in _inspect.signature(manager_to_display.display).parameters - ) - _accepts_display_mode = self._plugin_accepts_display_mode[_cache_key] + if plugin_id not in self._plugin_accepts_display_mode: + self._plugin_accepts_display_mode[plugin_id] = ( + 'display_mode' in inspect.signature(manager_to_display.display).parameters + ) + _accepts_display_mode = self._plugin_accepts_display_mode[plugin_id] - pm = self.plugin_manager - display_lock = None - can_display = True - if pm and hasattr(pm, 'get_plugin_lock'): - display_lock = pm.get_plugin_lock(plugin_id) - can_display = display_lock.acquire(blocking=False) + pm = self.plugin_manager + display_lock = pm.get_plugin_lock(plugin_id) if pm else None + can_display = display_lock is None or display_lock.acquire(blocking=False) - if not can_display: - # update() in flight on the worker — hold - # the last frame; not a plugin failure - result = True - elif pm and hasattr(pm, 'plugin_executor'): - # PluginExecutor's own thread.join(timeout) can - # return before the real display() call - # finishes (a lingering daemon thread keeps - # running it) -- so the lock is released from - # inside the wrapped call itself, whichever - # thread actually finishes it, rather than - # here when this dispatch merely returns. - release_guard = threading.Lock() - released = {'done': False} + if display_lock is None: + # Only when plugin loading failed part-way. + result = self._display_once( + manager_to_display, active_mode, _accepts_display_mode, + force_clear=self.force_change) + elif not can_display: + # update() in flight on the worker — hold + # the last frame; not a plugin failure + result = True + else: + # PluginExecutor's own thread.join(timeout) can + # return before the real display() call + # finishes (a lingering daemon thread keeps + # running it) -- so the lock is released from + # inside the wrapped call itself, whichever + # thread actually finishes it, rather than + # here when this dispatch merely returns. + release_guard = threading.Lock() + released = {'done': False} - def _release_display_lock(): - with release_guard: - if released['done']: - return - released['done'] = True - if display_lock is not None: - display_lock.release() + def _release_display_lock(): + with release_guard: + if released['done']: + return + released['done'] = True + display_lock.release() - if _accepts_display_mode: - def _display_target(display_mode=None, force_clear=False): - try: - return manager_to_display.display( - display_mode=display_mode, force_clear=force_clear) - finally: - _release_display_lock() - else: - def _display_target(force_clear=False): - try: - return manager_to_display.display(force_clear=force_clear) - finally: - _release_display_lock() - - try: - result = self.plugin_manager.plugin_executor.execute_display( - types.SimpleNamespace(display=_display_target), - plugin_id, - force_clear=self.force_change, - display_mode=active_mode if _accepts_display_mode else None, - # Already resolved and cached above. - # Without this the executor re-derives - # it with inspect.signature() against - # the SimpleNamespace built two lines - # up -- a fresh callable every call, so - # nothing there can ever cache. - accepts_display_mode=_accepts_display_mode - ) - except Exception: # pragma: no cover - defensive; - # execute_display catches everything - # internally, but guarantee the lock is - # never leaked if something unexpected - # slips through. - _release_display_lock() - raise + if _accepts_display_mode: + def _display_target(display_mode=None, force_clear=False): + try: + return manager_to_display.display( + display_mode=display_mode, force_clear=force_clear) + finally: + _release_display_lock() else: - # Fallback to direct call if executor not available - try: - if _accepts_display_mode: - result = manager_to_display.display(display_mode=active_mode, force_clear=self.force_change) - else: - result = manager_to_display.display(force_clear=self.force_change) - finally: - if display_lock is not None: - display_lock.release() - - logger.debug(f"display() returned: {result} (type: {type(result)})") - # Check if display() returned a boolean (new behavior) - if isinstance(result, bool): - display_result = result - if not display_result: - logger.info("Plugin %s display() returned False for mode %s", plugin_id, active_mode) - + def _display_target(force_clear=False): + try: + return manager_to_display.display(force_clear=force_clear) + finally: + _release_display_lock() + + try: + result = pm.plugin_executor.execute_display( + types.SimpleNamespace(display=_display_target), + plugin_id, + force_clear=self.force_change, + display_mode=active_mode if _accepts_display_mode else None, + # Already resolved and cached above. + # Without this the executor re-derives + # it with inspect.signature() against + # the SimpleNamespace built above -- a + # fresh callable every call, so nothing + # there can ever cache. + accepts_display_mode=_accepts_display_mode + ) + except Exception: # pragma: no cover - defensive; + # execute_display catches everything + # internally, but guarantee the lock is + # never leaked if something unexpected + # slips through. + _release_display_lock() + raise + + logger.debug(f"display() returned: {result} (type: {type(result)})") + if isinstance(result, bool): + display_result = result + if not display_result: + logger.info("Plugin %s display() returned False for mode %s", plugin_id, active_mode) + # Record success only when display() actually ran this # frame -- a skipped frame (lock busy) held the last # frame, not a real success, and must not clear # force_change or the pending mode-switch clear will # be lost when display() finally does run. if can_display: - if self.plugin_manager and hasattr(self.plugin_manager, 'health_tracker') and self.plugin_manager.health_tracker: - self.plugin_manager.health_tracker.record_success(plugin_id) + health_tracker = self._health_tracker() + if health_tracker is not None: + health_tracker.record_success(plugin_id) self.force_change = False except Exception as exc: # pylint: disable=broad-except logger.exception("Error displaying %s", self.current_display_mode) - # Record failure - if self.plugin_manager and hasattr(self.plugin_manager, 'health_tracker') and self.plugin_manager.health_tracker: - self.plugin_manager.health_tracker.record_failure(plugin_id, exc) + health_tracker = self._health_tracker() + if health_tracker is not None: + health_tracker.record_failure(plugin_id, exc) self.force_change = True display_result = False - display_failed_due_to_exception = True # Mark that this was an exception, not just no content - + display_failed_due_to_exception = True + # If display() returned False, skip to next mode immediately if not display_result: if self.on_demand_active: - # Skip to next on-demand mode if no content logger.info("No content for on-demand mode %s, skipping to next mode", active_mode) - - # Guard against empty on_demand_modes to prevent ZeroDivisionError - if not self.on_demand_modes or len(self.on_demand_modes) == 0: + if not self.on_demand_modes: logger.warning("On-demand active but no modes configured, skipping rotation") - logger.debug("on_demand_modes is empty, cannot rotate to next mode") - # Skip rotation and continue to next iteration - continue - - # Move to next mode in rotation (only if on_demand_modes is non-empty) - self.on_demand_mode_index = (self.on_demand_mode_index + 1) % len(self.on_demand_modes) - next_mode = self.on_demand_modes[self.on_demand_mode_index] - - # Only log when next_mode is valid - if next_mode: - logger.info("Rotating to next on-demand mode: %s (index %d/%d)", - next_mode, self.on_demand_mode_index, len(self.on_demand_modes)) - self.current_display_mode = next_mode - self.force_change = True - self._publish_on_demand_state() - continue - else: - logger.warning("Next on-demand mode is invalid, skipping rotation") continue + self._advance_on_demand() + continue else: logger.info("No content to display for %s, skipping to next mode", active_mode) # Don't clear display when immediately moving to next mode - this causes black flashes # The next mode will render immediately with force_clear=True, which is sufficient - + # Only skip all modes for this plugin if there was an exception (broken plugin) # If it's just "no content", we should still try other modes (recent, upcoming) if display_failed_due_to_exception: current_plugin_id = self.mode_to_plugin_id.get(active_mode) if current_plugin_id and current_plugin_id in self.plugin_display_modes: plugin_modes = self.plugin_display_modes[current_plugin_id] - logger.warning("Skipping all %d mode(s) for plugin %s due to exception: %s", + logger.warning("Skipping all %d mode(s) for plugin %s due to exception: %s", len(plugin_modes), current_plugin_id, plugin_modes) # Find the next mode that's not from this plugin next_index = self.current_mode_index @@ -2353,7 +2324,7 @@ class DisplayController: self.current_mode_index = next_index self.current_display_mode = next_mode self.force_change = True - logger.info("Switching to mode: %s (skipped plugin %s due to exception)", + logger.info("Switching to mode: %s (skipped plugin %s due to exception)", self.current_display_mode, current_plugin_id) found_next = True break @@ -2370,9 +2341,7 @@ class DisplayController: else: # Get base duration for current mode base_duration = self._get_display_duration(active_mode) - dynamic_enabled = ( - manager_to_display and self._plugin_supports_dynamic(manager_to_display) - ) + dynamic_enabled = self._plugin_supports_dynamic(manager_to_display) # Log dynamic duration status if dynamic_enabled: @@ -2473,296 +2442,252 @@ class DisplayController: self._check_on_demand_expiration() continue - # For plugins, call display multiple times to allow game rotation - if manager_to_display and hasattr(manager_to_display, 'display'): - # High-FPS decision, in precedence order: - # 1. A plugin that declares needs_high_fps knows best - # (e.g. static-image sets it False for still PNGs, - # True for animated GIFs). - # 2. Back-compat: older static-image versions without - # the attribute keep the historical forced high-FPS - # (GIF support). - # 3. Otherwise scrolling plugins get high FPS. - plugin_id = getattr(manager_to_display, 'plugin_id', None) - declared = getattr(manager_to_display, 'needs_high_fps', None) - if declared is not None: - needs_high_fps = bool(declared) - logger.debug( - "[DisplayController] FPS check for %s (plugin=%s) - " - "plugin declares needs_high_fps=%s", - active_mode, plugin_id, needs_high_fps) - elif plugin_id == 'static-image': - needs_high_fps = True - logger.debug("FPS check - static-image plugin: forcing high-FPS mode for GIF support") - else: - has_enable_scrolling = hasattr(manager_to_display, 'enable_scrolling') - enable_scrolling_value = getattr(manager_to_display, 'enable_scrolling', False) - needs_high_fps = has_enable_scrolling and enable_scrolling_value - logger.info( - "FPS check for %s - has_enable_scrolling: %s, enable_scrolling_value: %s, needs_high_fps: %s", - active_mode, - has_enable_scrolling, - enable_scrolling_value, - needs_high_fps, - ) - - target_duration = max_duration - start_time = time.time() - - def _should_exit_dynamic(elapsed_time: float) -> bool: - if not dynamic_enabled: - return False - # Add small grace period (0.5s) after min_duration to prevent - # premature exits due to timing issues - grace_period = 0.5 - if elapsed_time < min_duration + grace_period: - logger.debug( - "_should_exit_dynamic: elapsed %.2fs < min_duration %.2fs + grace %.2fs, returning False", - elapsed_time, - min_duration, - grace_period, - ) - return False - cycle_complete = self._plugin_cycle_complete(manager_to_display) - logger.debug( - "_should_exit_dynamic: elapsed %.2fs >= min %.2fs, cycle_complete=%s, returning %s", - elapsed_time, - min_duration + grace_period, - cycle_complete, - cycle_complete, - ) - if cycle_complete: - logger.debug( - "Cycle complete detected for %s after %.2fs (min: %.2fs, grace: %.2fs)", - active_mode, - elapsed_time, - min_duration, - grace_period, - ) - return cycle_complete - - loop_completed = False - - if needs_high_fps: - # Ultra-smooth FPS for scrolling plugins (8ms = 125 FPS) - display_interval = 0.008 - logger.debug( - "Entering high-FPS loop for %s with display_interval=%.3fs (%.1f FPS)", - active_mode, - display_interval, - 1.0 / display_interval - ) - - # Deliberate: frames after the first call - # display() directly rather than through - # PluginExecutor. The executor spawns a thread per - # call, which at this loop's frame rate would cost - # more than the advisory timeout it buys -- and - # that timeout cannot cancel a hung plugin anyway - # (see execute_with_timeout). The first dispatch - # above still goes through it, so load-time - # failures are still caught and recorded. - while True: - _frame_start = time.perf_counter() - try: - with self._display_lock_or_skip(plugin_id) as can_display: - if can_display: - # Pass display_mode to maintain sticky manager state - if _accepts_display_mode: - result = manager_to_display.display(display_mode=active_mode, force_clear=False) - else: - result = manager_to_display.display(force_clear=False) - else: - # update() in flight — hold the last frame - result = True - if isinstance(result, bool) and not result: - logger.debug("Display returned False, breaking early") - break - except Exception: # pylint: disable=broad-except - logger.exception("Error during display update") - - # Multi-display sync: send follower frame after each render - self._send_follower_frame(manager_to_display) - - self._tick_plugin_updates() - # Throttled: one clock compare between passes. - self._service_pending_changes() - - # Pace to the frame deadline rather than sleeping a flat - # interval on top of the work. display() has already - # blocked on the panel's vsync by this point, so an - # unconditional sleep is added to a wait that already - # happened. Measured on a 2x128x64 chain at - # limit_refresh_rate_hz=100: ~4ms of render plus a flat - # 8ms put each iteration at ~12ms against a 10ms refresh - # grid, so every swap missed a refresh and the loop - # settled at 50fps where display_interval asks for 125 -- - # and with zero headroom, ~14% of frames slipped a - # further refresh, which is what reads as scroll stutter. - _remaining = display_interval - (time.perf_counter() - _frame_start) - # Yield even when the frame overran its budget, so plugin - # update threads and the web UI are not starved of the GIL. - time.sleep(_remaining if _remaining > 0 else 0.001) - - if (self.current_display_mode != active_mode - or not self.is_display_active): - logger.debug("Mode changed during high-FPS loop, breaking early") - break - - elapsed = time.time() - start_time - if elapsed >= target_duration: - logger.debug( - "Reached high-FPS target duration %.2fs for mode %s", - target_duration, - active_mode, - ) - loop_completed = True - break - if _should_exit_dynamic(elapsed): - logger.debug( - "Dynamic duration cycle complete for %s after %.2fs", - active_mode, - elapsed, - ) - loop_completed = True - break - else: - # Normal FPS for other plugins (1 second) - display_interval = 1.0 - logger.debug( - "Entering normal FPS loop for %s with display_interval=%.3fs", - active_mode, - display_interval - ) - - # Deliberate: frames after the first call - # display() directly rather than through - # PluginExecutor. The executor spawns a thread per - # call, which at this loop's frame rate would cost - # more than the advisory timeout it buys -- and - # that timeout cannot cancel a hung plugin anyway - # (see execute_with_timeout). The first dispatch - # above still goes through it, so load-time - # failures are still caught and recorded. - while True: - time.sleep(display_interval) - self._tick_plugin_updates() - - elapsed = time.time() - start_time - if elapsed >= target_duration: - logger.debug( - "Reached standard target duration %.2fs for mode %s", - target_duration, - active_mode, - ) - loop_completed = True - break - - try: - with self._display_lock_or_skip(plugin_id) as can_display: - if can_display: - # Pass display_mode to maintain sticky manager state - if _accepts_display_mode: - result = manager_to_display.display(display_mode=active_mode, force_clear=False) - else: - result = manager_to_display.display(force_clear=False) - else: - # update() in flight — hold the last frame - result = True - if isinstance(result, bool) and not result: - # For dynamic duration plugins, don't exit on False - keep looping - # until cycle is complete or max duration is reached - if not dynamic_enabled: - logger.info("Display returned False for %s (no dynamic duration), breaking early", active_mode) - break - else: - logger.debug("Display returned False for %s (dynamic duration enabled), continuing loop", active_mode) - except Exception: # pylint: disable=broad-except - logger.exception("Error during display update") - - # Multi-display sync: send follower frame after each render - self._send_follower_frame(manager_to_display) - - self._service_pending_changes() - if (self.current_display_mode != active_mode - or not self.is_display_active): - logger.info("Mode changed during display loop from %s to %s, breaking early", active_mode, self.current_display_mode) - break - - if _should_exit_dynamic(elapsed): - logger.info( - "Dynamic duration cycle complete for %s after %.2fs", - active_mode, - elapsed, - ) - loop_completed = True - break - - # LOAD-BEARING: if current_display_mode changed mid-loop (on-demand - # activation, live priority, etc.), restart the main loop now instead - # of falling into the "honour minimum duration" sleep below. That sleep - # can run for up to the *previous* mode's full display_duration (default - # 30s) and doesn't poll on-demand requests or re-check the mode, so a - # freshly-requested mode switch would sit invisible for up to 30s — or - # get clobbered by a queued stop request — before ever rendering. - # - # This guard was added in #298 (live priority interrupting long display - # durations) and was accidentally dropped in #330 as collateral damage of - # an unrelated time.monotonic() -> time.time() cleanup in the same hunk. - # Removing it again will silently reintroduce both issues. _activate_on_demand - # already sets force_change=True and clears the display, so the next loop - # iteration renders the new mode immediately. - # Likewise if the schedule turned the display off - # mid-screen: the next iteration blanks it. - if (self.current_display_mode != active_mode - or not self.is_display_active): - continue - - # Ensure we honour minimum duration when not dynamic and loop ended early - if ( - not dynamic_enabled - and not loop_completed - and not needs_high_fps - ): - elapsed = time.time() - start_time - remaining_sleep = max(0.0, max_duration - elapsed) - if remaining_sleep > 0: - self._sleep_with_plugin_updates(remaining_sleep) - - if dynamic_enabled: - elapsed_total = time.time() - start_time - cycle_done = self._plugin_cycle_complete(manager_to_display) - - # Log cycle completion status and metrics - if cycle_done: - logger.info( - "Dynamic duration cycle completed for %s after %.2fs (target: %.2fs, min: %.2fs, max: %.2fs)", - active_mode, - elapsed_total, - target_duration, - min_duration, - max_duration, - ) - elif elapsed_total >= max_duration: - logger.info( - "Dynamic duration cap reached before cycle completion for %s (%.2fs/%ds, min: %.2fs)", - active_mode, - elapsed_total, - int(max_duration), - min_duration, - ) - else: - logger.debug( - "Dynamic duration cycle in progress for %s: %.2fs elapsed (target: %.2fs, min: %.2fs, max: %.2fs)", - active_mode, - elapsed_total, - target_duration, - min_duration, - max_duration, - ) + # High-FPS decision, in precedence order: + # 1. A plugin that declares needs_high_fps knows best + # (e.g. static-image sets it False for still PNGs, + # True for animated GIFs). + # 2. Back-compat: older static-image versions without + # the attribute keep the historical forced high-FPS + # (GIF support). + # 3. Otherwise scrolling plugins get high FPS. + plugin_id = getattr(manager_to_display, 'plugin_id', None) + declared = getattr(manager_to_display, 'needs_high_fps', None) + if declared is not None: + needs_high_fps = bool(declared) + logger.debug( + "[DisplayController] FPS check for %s (plugin=%s) - " + "plugin declares needs_high_fps=%s", + active_mode, plugin_id, needs_high_fps) + elif plugin_id == 'static-image': + needs_high_fps = True + logger.debug("FPS check - static-image plugin: forcing high-FPS mode for GIF support") else: - # For non-plugin modes, use the original behavior - self._sleep_with_plugin_updates(max_duration) + has_enable_scrolling = hasattr(manager_to_display, 'enable_scrolling') + enable_scrolling_value = getattr(manager_to_display, 'enable_scrolling', False) + needs_high_fps = has_enable_scrolling and enable_scrolling_value + logger.info( + "FPS check for %s - has_enable_scrolling: %s, enable_scrolling_value: %s, needs_high_fps: %s", + active_mode, + has_enable_scrolling, + enable_scrolling_value, + needs_high_fps, + ) + + target_duration = max_duration + start_time = time.time() + + def _should_exit_dynamic(elapsed_time: float) -> bool: + if not dynamic_enabled: + return False + # Add small grace period (0.5s) after min_duration to prevent + # premature exits due to timing issues + grace_period = 0.5 + if elapsed_time < min_duration + grace_period: + logger.debug( + "_should_exit_dynamic: elapsed %.2fs < min_duration %.2fs + grace %.2fs, returning False", + elapsed_time, + min_duration, + grace_period, + ) + return False + cycle_complete = self._plugin_cycle_complete(manager_to_display) + logger.debug( + "_should_exit_dynamic: elapsed %.2fs >= min %.2fs, cycle_complete=%s, returning %s", + elapsed_time, + min_duration + grace_period, + cycle_complete, + cycle_complete, + ) + if cycle_complete: + logger.debug( + "Cycle complete detected for %s after %.2fs (min: %.2fs, grace: %.2fs)", + active_mode, + elapsed_time, + min_duration, + grace_period, + ) + return cycle_complete + + loop_completed = False + + if needs_high_fps: + # Ultra-smooth FPS for scrolling plugins (8ms = 125 FPS) + display_interval = 0.008 + logger.debug( + "Entering high-FPS loop for %s with display_interval=%.3fs (%.1f FPS)", + active_mode, + display_interval, + 1.0 / display_interval + ) + + while True: + _frame_start = time.perf_counter() + try: + result = self._display_once( + manager_to_display, active_mode, _accepts_display_mode) + if isinstance(result, bool) and not result: + logger.debug("Display returned False, breaking early") + break + except Exception: # pylint: disable=broad-except + logger.exception("Error during display update") + + # Multi-display sync: send follower frame after each render + self._send_follower_frame(manager_to_display) + + self._tick_plugin_updates() + # Throttled: one clock compare between passes. + self._service_pending_changes() + + # Pace to the frame deadline rather than sleeping a flat + # interval on top of the work. display() has already + # blocked on the panel's vsync by this point, so an + # unconditional sleep is added to a wait that already + # happened. Measured on a 2x128x64 chain at + # limit_refresh_rate_hz=100: ~4ms of render plus a flat + # 8ms put each iteration at ~12ms against a 10ms refresh + # grid, so every swap missed a refresh and the loop + # settled at 50fps where display_interval asks for 125 -- + # and with zero headroom, ~14% of frames slipped a + # further refresh, which is what reads as scroll stutter. + _remaining = display_interval - (time.perf_counter() - _frame_start) + # Yield even when the frame overran its budget, so plugin + # update threads and the web UI are not starved of the GIL. + time.sleep(_remaining if _remaining > 0 else 0.001) + + if (self.current_display_mode != active_mode + or not self.is_display_active): + logger.debug("Mode changed during high-FPS loop, breaking early") + break + + elapsed = time.time() - start_time + if elapsed >= target_duration: + logger.debug( + "Reached high-FPS target duration %.2fs for mode %s", + target_duration, + active_mode, + ) + loop_completed = True + break + if _should_exit_dynamic(elapsed): + logger.debug( + "Dynamic duration cycle complete for %s after %.2fs", + active_mode, + elapsed, + ) + loop_completed = True + break + else: + # Normal FPS for other plugins (1 second) + display_interval = 1.0 + logger.debug( + "Entering normal FPS loop for %s with display_interval=%.3fs", + active_mode, + display_interval + ) + + while True: + time.sleep(display_interval) + self._tick_plugin_updates() + + elapsed = time.time() - start_time + if elapsed >= target_duration: + logger.debug( + "Reached standard target duration %.2fs for mode %s", + target_duration, + active_mode, + ) + loop_completed = True + break + + try: + result = self._display_once( + manager_to_display, active_mode, _accepts_display_mode) + if isinstance(result, bool) and not result: + # For dynamic duration plugins, don't exit on False - keep looping + # until cycle is complete or max duration is reached + if not dynamic_enabled: + logger.info("Display returned False for %s (no dynamic duration), breaking early", active_mode) + break + else: + logger.debug("Display returned False for %s (dynamic duration enabled), continuing loop", active_mode) + except Exception: # pylint: disable=broad-except + logger.exception("Error during display update") + + # Multi-display sync: send follower frame after each render + self._send_follower_frame(manager_to_display) + + self._service_pending_changes() + if (self.current_display_mode != active_mode + or not self.is_display_active): + logger.info("Mode changed during display loop from %s to %s, breaking early", active_mode, self.current_display_mode) + break + + if _should_exit_dynamic(elapsed): + logger.info( + "Dynamic duration cycle complete for %s after %.2fs", + active_mode, + elapsed, + ) + loop_completed = True + break + + # LOAD-BEARING: if current_display_mode changed mid-loop (on-demand + # activation, live priority, etc.), restart the main loop now instead + # of falling into the "honour minimum duration" sleep below. That sleep + # can run for up to the *previous* mode's full display_duration (default + # 30s) and doesn't poll on-demand requests or re-check the mode, so a + # freshly-requested mode switch would sit invisible for up to 30s — or + # get clobbered by a queued stop request — before ever rendering. + # _activate_on_demand already sets force_change=True and clears the + # display, so the next loop iteration renders the new mode immediately. + # Likewise if the schedule turned the display off + # mid-screen: the next iteration blanks it. + if (self.current_display_mode != active_mode + or not self.is_display_active): + continue + + # Ensure we honour minimum duration when not dynamic and loop ended early + if ( + not dynamic_enabled + and not loop_completed + and not needs_high_fps + ): + elapsed = time.time() - start_time + remaining_sleep = max(0.0, max_duration - elapsed) + if remaining_sleep > 0: + self._sleep_with_plugin_updates(remaining_sleep) + + if dynamic_enabled: + elapsed_total = time.time() - start_time + cycle_done = self._plugin_cycle_complete(manager_to_display) + + # Log cycle completion status and metrics + if cycle_done: + logger.info( + "Dynamic duration cycle completed for %s after %.2fs (target: %.2fs, min: %.2fs, max: %.2fs)", + active_mode, + elapsed_total, + target_duration, + min_duration, + max_duration, + ) + elif elapsed_total >= max_duration: + logger.info( + "Dynamic duration cap reached before cycle completion for %s (%.2fs/%ds, min: %.2fs)", + active_mode, + elapsed_total, + int(max_duration), + min_duration, + ) + else: + logger.debug( + "Dynamic duration cycle in progress for %s: %.2fs elapsed (target: %.2fs, min: %.2fs, max: %.2fs)", + active_mode, + elapsed_total, + target_duration, + min_duration, + max_duration, + ) # The dwell sleeps above return early when a pending change # (on-demand started or stopped, display scheduled off) has @@ -2780,14 +2705,7 @@ class DisplayController: self._clear_on_demand(reason='no-modes-available') # Fall through to normal rotation else: - # Rotate to next on-demand mode - self.on_demand_mode_index = (self.on_demand_mode_index + 1) % len(self.on_demand_modes) - next_mode = self.on_demand_modes[self.on_demand_mode_index] - logger.info("Rotating to next on-demand mode: %s (index %d/%d)", - next_mode, self.on_demand_mode_index, len(self.on_demand_modes)) - self.current_display_mode = next_mode - self.force_change = True - self._publish_on_demand_state() + self._advance_on_demand() continue # Check for live priority - don't rotate if current plugin has live content @@ -3324,22 +3242,33 @@ class DisplayController: @staticmethod def _vegas_scroll_speed(config: Dict[str, Any]) -> float: - """Vegas scroll speed in px/s. The default must match VegasModeConfig's - (50): a follower dead-reckons with this value between the leader's - position packets, so a different default made it run 50% fast.""" + """Vegas scroll speed in px/s, with VegasModeConfig's default. + + The default has to be the one Vegas itself uses: a follower + dead-reckons with this value between the leader's position packets, + so a different default makes it run at the wrong speed. + """ + from src.vegas_mode.config import VegasModeConfig vegas_cfg = (config.get('display', {}) or {}).get('vegas_scroll', {}) or {} - return float(vegas_cfg.get('scroll_speed', 50.0)) + return float(vegas_cfg.get('scroll_speed', VegasModeConfig.scroll_speed)) def cleanup(self): """Clean up resources.""" # Stop the async update worker first so no in-flight update() call # is still touching display/cache-backed resources while they're # torn down below. - if self.plugin_manager and hasattr(self.plugin_manager, 'stop_update_worker'): + if self.plugin_manager: try: self.plugin_manager.stop_update_worker() except Exception as e: logger.warning("Error stopping plugin update worker: %s", e) + # Vegas is torn down before the display manager: stopping it resets + # the display's scrolling state. + if self.vegas_coordinator is not None: + try: + self.vegas_coordinator.cleanup() + except Exception as e: + logger.warning("Error cleaning up Vegas mode: %s", e) # Shutdown config service if it exists if hasattr(self, 'config_service'): try: diff --git a/src/display_manager.py b/src/display_manager.py index 603ee042..0123e40e 100644 --- a/src/display_manager.py +++ b/src/display_manager.py @@ -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.display_geometry import ( DEFAULT_CHAIN_LENGTH, DEFAULT_COLS, DEFAULT_PARALLEL, DEFAULT_ROWS, - ORIENTATION_ROTATE_DEGREES, compose_pixel_mapper_config, physical_size, - resolve_double_sided, + compose_pixel_mapper_config, physical_size, resolve_double_sided, ) from src.matrix_support import MatrixSettingsRefused, library_refusals, refusal_message from src.pi5_matrix_support import is_raspberry_pi_5 @@ -47,13 +46,13 @@ import threading import time from collections import OrderedDict from typing import Dict, Any, List, Optional, Tuple -import logging import math import zlib import freetype from src.common import snapshot_policy from src.deprecation import deprecated +from src.logging_config import get_logger from src.common.permission_utils import ( ensure_directory_permissions, ensure_file_permissions, @@ -61,9 +60,7 @@ from src.common.permission_utils import ( get_assets_file_mode, ) -# Get logger without configuring -logger = logging.getLogger(__name__) -logger.setLevel(logging.INFO) # Set to INFO level +logger = get_logger(__name__) #: The strike 5x7.bdf is drawn at. FreeType renders a BDF at its own fixed #: size regardless, but a Face needs an active size before its metrics -- @@ -134,11 +131,6 @@ class _LogicalMatrix: 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: """ @@ -160,7 +152,6 @@ class DisplayManager: """ _instance = None - _initialized = False def __new__(cls, *args, **kwargs): if cls._instance is None: @@ -248,20 +239,16 @@ class DisplayManager: font_time = time.time() self._load_fonts() logger.info("Font loading completed in %.3f seconds", time.time() - font_time) - - # Initialize managers - # Calendar manager is now initialized by DisplayController - - # Orientation setting -> rpi-rgb-led-matrix "Rotate:" pixel-mapper suffix. - _ORIENTATION_ROTATE_DEGREES = ORIENTATION_ROTATE_DEGREES - def _build_pixel_mapper_config(self, hardware_config: dict) -> str: - """Compose pixel_mapper_config with the orientation setting. + def _new_canvas(self, width: int, height: int) -> None: + """Replace ``image``/``draw`` with a black canvas of the given size. - See :func:`src.display_geometry.compose_pixel_mapper_config`, which the - web preview shares so it sizes the canvas the same way. + Text is drawn 1-bit (``fontmode = "1"``): the panel has no partial + brightness, so anti-aliasing only smears glyphs. """ - 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 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 # the rendered screen into once per frame. ds_config = self.config.get('display', {}).get('double_sided', {}) - ds = _resolve_double_sided(self.matrix.width, self.matrix.height, ds_config) + ds = resolve_double_sided(self.matrix.width, self.matrix.height, ds_config) self._double_sided = ds if ds is not None: self._physical_image = Image.new( @@ -339,9 +326,7 @@ class DisplayManager: self.matrix, ds['logical_width'], ds['logical_height']) # Create image with the (logical) display dimensions - self.image = Image.new('RGB', (self.matrix.width, self.matrix.height)) - self.draw = ImageDraw.Draw(self.image) - self.draw.fontmode = "1" # 1-bit text: the panel has no partial brightness, so AA only smears glyphs. + self._new_canvas(self.matrix.width, self.matrix.height) logger.info(f"Image canvas created with dimensions: {self.matrix.width}x{self.matrix.height}") # Initialize font with Press Start 2P @@ -372,7 +357,7 @@ class DisplayManager: fallback_width, fallback_height = physical_size(self.config) # Mirror double-sided in fallback so the preview shows one screen. ds_config = self.config.get('display', {}).get('double_sided', {}) if self.config else {} - ds = _resolve_double_sided(fallback_width, fallback_height, ds_config) + ds = resolve_double_sided(fallback_width, fallback_height, ds_config) self._double_sided = ds if ds is not None: fallback_width = ds['logical_width'] @@ -380,9 +365,7 @@ class DisplayManager: except Exception: fallback_width, fallback_height = 128, 32 - self.image = Image.new('RGB', (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. + self._new_canvas(fallback_width, fallback_height) # Simple fallback visualization so web UI shows a realistic canvas try: self.draw.rectangle([0, 0, fallback_width - 1, fallback_height - 1], outline=(255, 0, 0)) @@ -611,18 +594,14 @@ class DisplayManager: line, font=font, fill=(0, 0, 255)) 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: self.clear() - - if self.matrix is None: - # Fallback mode - just draw on the image - self.draw.rectangle([0, 0, self.image.width-1, self.image.height-1], outline=(255, 0, 0)) - self.draw.line([0, 0, self.image.width-1, self.image.height-1], fill=(0, 255, 0)) - self.draw.text((10, 10), "Simulation", font=self.font, fill=(0, 0, 255)) - logger.info("Drew test pattern in fallback mode") - return - + # Draw a red rectangle border self.draw.rectangle([0, 0, self.matrix.width-1, self.matrix.height-1], outline=(255, 0, 0)) @@ -637,7 +616,7 @@ class DisplayManager: # Update the display once after everything is drawn self.update_display() - time.sleep(0.5) # Reduced from 1 second to 0.5 seconds for faster animation + time.sleep(0.5) except Exception as e: 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) # With no hardware, the width/height properties fall through to # self.image, so swapping the buffer below is enough on its own. - self.image = Image.new('RGB', (target_w, target_h)) - self.draw = ImageDraw.Draw(self.image) - self.draw.fontmode = "1" # 1-bit text: the panel has no partial brightness, so AA only smears glyphs. + self._new_canvas(target_w, target_h) yield finally: self.matrix = real_matrix @@ -834,28 +811,14 @@ class DisplayManager: try: if self.matrix is None: # Fallback mode - just clear the image - # Explicitly clear old image reference to help garbage collection old_image = getattr(self, 'image', None) width = old_image.width if old_image else 64 height = old_image.height if old_image else 64 - if old_image is not None: - del old_image - - self.image = Image.new('RGB', (width, height)) - self.draw = ImageDraw.Draw(self.image) - self.draw.fontmode = "1" # 1-bit text: the panel has no partial brightness, so AA only smears glyphs. + self._new_canvas(width, height) logger.debug("Cleared display in fallback mode") return - - # Explicitly clear old image reference to help garbage collection - old_image = getattr(self, 'image', None) - if old_image is not None: - del old_image - - # Create a new black image - self.image = Image.new('RGB', (self.matrix.width, self.matrix.height)) - self.draw = ImageDraw.Draw(self.image) - self.draw.fontmode = "1" # 1-bit text: the panel has no partial brightness, so AA only smears glyphs. + + self._new_canvas(self.matrix.width, self.matrix.height) if not self._capture_mode_active: # 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 if hasattr(self, 'image') and hasattr(self, 'draw'): try: - self.image = Image.new('RGB', (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. + self._new_canvas(self.width, self.height) except (OSError, RuntimeError, ValueError, MemoryError): logger.debug("Canvas reset during cleanup failed", exc_info=True) # Reset the singleton state when cleaning up DisplayManager._instance = None - DisplayManager._initialized = False def format_date_with_ordinal(self, dt): """Formats a datetime object into 'Mon Aug 30th' style.""" @@ -1360,9 +1320,9 @@ class DisplayManager: options.pwm_bits = hardware_config.get('pwm_bits', 10) options.pwm_lsb_nanoseconds = hardware_config.get('pwm_lsb_nanoseconds', 150) options.led_rgb_sequence = hardware_config.get('led_rgb_sequence', 'RGB') - # _build_pixel_mapper_config reads only class attributes, so the class - # stands in for an instance here. - options.pixel_mapper_config = cls._build_pixel_mapper_config(cls, hardware_config) + # Orientation becomes a "Rotate:" pixel mapper; the web preview + # composes it the same way so it sizes the canvas identically. + options.pixel_mapper_config = compose_pixel_mapper_config(hardware_config) options.row_address_type = hardware_config.get('row_address_type', 0) options.multiplexing = hardware_config.get('multiplexing', 0) options.panel_type = hardware_config.get('panel_type', '') @@ -1455,13 +1415,16 @@ class DisplayManager: that does not care gets a new frame every refresh. """ current_time = time.time() + # Scrolling callers set this every frame; log transitions only. + changed = self._scrolling_state['is_scrolling'] != is_scrolling self._scrolling_state['is_scrolling'] = is_scrolling if is_scrolling: self._scrolling_state['last_scroll_activity'] = current_time self.set_frame_hold(frame_hold) else: self._frame_hold = 1 - 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: """Check if the display is currently in a scrolling state.""" @@ -1530,9 +1493,6 @@ class DisplayManager: if not self._scrolling_state['deferred_updates']: return - if not self._scrolling_state['deferred_updates']: - return - # Process only a limited number of updates per call to avoid blocking max_updates_per_call = min(5, len(self._scrolling_state['deferred_updates'])) updates_to_process = self._scrolling_state['deferred_updates'][:max_updates_per_call] diff --git a/src/vegas_mode/__init__.py b/src/vegas_mode/__init__.py index 6cea87a5..eb1c1fa4 100644 --- a/src/vegas_mode/__init__.py +++ b/src/vegas_mode/__init__.py @@ -6,8 +6,8 @@ plugins' content is composed into a single horizontally scrolling display. Components: - VegasModeCoordinator: Main orchestrator for Vegas mode -- StreamManager: Manages plugin content streaming with 1-2 ahead buffering -- RenderPipeline: Handles 125 FPS rendering with double-buffering +- StreamManager: Plugin rotation, content fetching and pending-update tracking +- RenderPipeline: Strip composition and per-frame rendering - PluginAdapter: Converts plugin content to scrollable images - VegasModeConfig: Configuration management """ diff --git a/src/vegas_mode/config.py b/src/vegas_mode/config.py index 6ba61526..8f33aab6 100644 --- a/src/vegas_mode/config.py +++ b/src/vegas_mode/config.py @@ -167,7 +167,7 @@ class VegasModeConfig: # Dynamic duration dynamic_duration_enabled: bool = True 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 def from_config(cls, config: Dict[str, Any]) -> 'VegasModeConfig': @@ -181,44 +181,50 @@ class VegasModeConfig: VegasModeConfig instance """ 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( - enabled=vegas_config.get('enabled', False), - scroll_speed=float(vegas_config.get('scroll_speed', 50.0)), - separator_width=int(vegas_config.get('separator_width', 32)), - intra_plugin_gap=int(vegas_config.get('intra_plugin_gap', 8)), - render_width_pct=int(vegas_config.get('render_width_pct', 100)), + enabled=get('enabled', d.enabled), + scroll_speed=float(get('scroll_speed', d.scroll_speed)), + separator_width=int(get('separator_width', d.separator_width)), + intra_plugin_gap=int(get('intra_plugin_gap', d.intra_plugin_gap)), + render_width_pct=int(get('render_width_pct', d.render_width_pct)), min_content_separation=int( - vegas_config.get('min_content_separation', 24)), - min_cut_gap=int(vegas_config.get('min_cut_gap', 6)), - smooth_scroll=vegas_config.get('smooth_scroll', True), - continuous_scroll=vegas_config.get('continuous_scroll', True), + get('min_content_separation', d.min_content_separation)), + min_cut_gap=int(get('min_cut_gap', d.min_cut_gap)), + smooth_scroll=get('smooth_scroll', d.smooth_scroll), + continuous_scroll=get('continuous_scroll', d.continuous_scroll), extend_threshold_screens=float( - vegas_config.get('extend_threshold_screens', 2.0)), - auto_trim=vegas_config.get('auto_trim', True), - trim_threshold=int(vegas_config.get('trim_threshold', 10)), - content_padding=int(vegas_config.get('content_padding', 8)), - min_plugin_width=int(vegas_config.get('min_plugin_width', 8)), - lead_in_width=int(vegas_config.get('lead_in_width', 0)), - plugins_per_cycle=int(vegas_config.get('plugins_per_cycle', 6)), + get('extend_threshold_screens', d.extend_threshold_screens)), + auto_trim=get('auto_trim', d.auto_trim), + trim_threshold=int(get('trim_threshold', d.trim_threshold)), + content_padding=int(get('content_padding', d.content_padding)), + min_plugin_width=int(get('min_plugin_width', d.min_plugin_width)), + lead_in_width=int(get('lead_in_width', d.lead_in_width)), + plugins_per_cycle=int(get('plugins_per_cycle', d.plugins_per_cycle)), max_plugin_width_ratio=float( - vegas_config.get('max_plugin_width_ratio', 0.0)), - overflow_mode=str(vegas_config.get('overflow_mode', 'rotate')), - plugin_order=list(vegas_config.get('plugin_order', [])), - excluded_plugins=set(vegas_config.get('excluded_plugins', [])), - live_in_ticker=bool(vegas_config.get('live_in_ticker', False)), + get('max_plugin_width_ratio', d.max_plugin_width_ratio)), + overflow_mode=str(get('overflow_mode', d.overflow_mode)), + plugin_order=list(get('plugin_order', d.plugin_order)), + excluded_plugins=set(get('excluded_plugins', d.excluded_plugins)), + live_in_ticker=bool(get('live_in_ticker', d.live_in_ticker)), # Clamped: a weight below 1 would drop the plugin from the rotation # entirely, and a very large one starves everything else. - live_weight=max(1, min(10, int(vegas_config.get('live_weight', 3)))), - favorite_live_weight=max( - 1, min(10, int(vegas_config.get('favorite_live_weight', 5)))), - target_fps=int(vegas_config.get('target_fps', 125)), - buffer_ahead=int(vegas_config.get('buffer_ahead', 2)), - frame_based_scrolling=vegas_config.get('frame_based_scrolling', True), - scroll_delay=float(vegas_config.get('scroll_delay', 0.02)), - dynamic_duration_enabled=vegas_config.get('dynamic_duration_enabled', True), - min_cycle_duration=int(vegas_config.get('min_cycle_duration', 60)), - max_cycle_duration=int(vegas_config.get('max_cycle_duration', 600)), + live_weight=max(1, min(10, int(get('live_weight', d.live_weight)))), + favorite_live_weight=max(1, min(10, int( + get('favorite_live_weight', d.favorite_live_weight)))), + target_fps=int(get('target_fps', d.target_fps)), + buffer_ahead=int(get('buffer_ahead', d.buffer_ahead)), + frame_based_scrolling=get( + 'frame_based_scrolling', d.frame_based_scrolling), + scroll_delay=float(get('scroll_delay', d.scroll_delay)), + dynamic_duration_enabled=get( + '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]: @@ -260,22 +266,6 @@ class VegasModeConfig: """Get the frame interval in seconds for 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]: """ Get plugins in configured order, filtering excluded ones. diff --git a/src/vegas_mode/coordinator.py b/src/vegas_mode/coordinator.py index f755326c..c053c06b 100644 --- a/src/vegas_mode/coordinator.py +++ b/src/vegas_mode/coordinator.py @@ -145,13 +145,8 @@ class VegasModeCoordinator: # Static pause handling 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 - # Track which plugins should use STATIC mode (pause scroll) - self._static_mode_plugins: set = set() - # Statistics self.stats = { 'total_runtime_seconds': 0.0, @@ -235,7 +230,9 @@ class VegasModeCoordinator: returns immediately, collapsing the inter-iteration gap to <1 ms. 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 @@ -417,9 +414,6 @@ class VegasModeCoordinator: if not self.start(): return False - # Update static mode plugin list on iteration start - self._update_static_mode_plugins() - frame_interval = self.vegas_config.get_frame_interval() if self.vegas_config.continuous_scroll: # The strip is continuously extended and trimmed, so its width says @@ -733,13 +727,6 @@ class VegasModeCoordinator: 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) # ------------------------------------------------------------------------- @@ -795,8 +782,6 @@ class VegasModeCoordinator: # Save current scroll position for smooth resume self._saved_scroll_position = self.render_pipeline.get_scroll_position() self._static_pause_active = True - self._static_pause_plugin = plugin - self._static_pause_start = time.time() self.stats['static_pauses'] += 1 logger.info("Static pause started for plugin: %s", plugin_id) @@ -823,9 +808,9 @@ class VegasModeCoordinator: logger.info("Static pause interrupted by live priority") 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(): - logger.info("Static pause interrupted by sync follower mode") + logger.info("Static pause interrupted by the display controller") return False # Sleep in small increments to remain responsive @@ -860,8 +845,6 @@ class VegasModeCoordinator: # Clear pause state self._static_pause_active = False - self._static_pause_plugin = None - self._static_pause_start = None # Restore scroll position if we're resuming if should_resume_scrolling and self._saved_scroll_position is not None: @@ -875,29 +858,6 @@ class VegasModeCoordinator: else: 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: """Clean up all resources.""" self.stop() diff --git a/src/vegas_mode/geometry.py b/src/vegas_mode/geometry.py index 50cff306..d0e396a0 100644 --- a/src/vegas_mode/geometry.py +++ b/src/vegas_mode/geometry.py @@ -230,54 +230,6 @@ def blank_runs( 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): """How much of a composed ticker reads as blank to a viewer.""" diff --git a/src/vegas_mode/plugin_adapter.py b/src/vegas_mode/plugin_adapter.py index 01ef8cca..e7a579b4 100644 --- a/src/vegas_mode/plugin_adapter.py +++ b/src/vegas_mode/plugin_adapter.py @@ -48,15 +48,8 @@ class PluginAdapter: from src.vegas_mode.config import VegasModeConfig config = VegasModeConfig() self.config = config - # Handle both property and method access patterns - self.display_width = ( - 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 - ) + self.display_width = display_manager.width + self.display_height = display_manager.height # Cache for recently fetched content (prevents redundant fetch) self._content_cache: dict = {} @@ -185,13 +178,14 @@ class PluginAdapter: Trim dead space off a segment, then cache it. Every content path funnels through here so trimming is applied - uniformly. Previously only the scroll_helper path had its margins - stripped, which left plugins that render onto a full-display canvas - contributing their entire blank canvas to the ticker. + uniformly; a plugin that renders onto a full-display canvas would + otherwise contribute its whole blank canvas to the ticker. - Each image is trimmed independently because compose_scroll_content() - treats every image as its own item and inserts separator_width between - them — so a per-image trim is what makes that separator the real gap. + Each image is trimmed independently. The render pipeline joins one + plugin's images with a gap measured from their ink + (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: 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 through it across cycles. - The cut is snapped to the nearest blank column so it does not slice - through a glyph or logo and leave half a character at the panel edge. + Cuts land only at item boundaries: the middle of a blank run at least + ``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 than as a pixel column, because a ticker re-renders between fetches. A diff --git a/src/vegas_mode/render_pipeline.py b/src/vegas_mode/render_pipeline.py index d9b713f5..b9e5914b 100644 --- a/src/vegas_mode/render_pipeline.py +++ b/src/vegas_mode/render_pipeline.py @@ -1,8 +1,8 @@ """ Render Pipeline for Vegas Mode -Handles high-FPS (125 FPS) rendering with double-buffering for smooth scrolling. -Uses the existing ScrollHelper for numpy-optimized scroll operations. +Composes plugin content into one wide strip and renders the visible window of +it each frame, using ScrollHelper for the numpy-backed scroll. """ import logging @@ -10,7 +10,7 @@ import os import time import threading 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 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.stream_manager import StreamManager -if TYPE_CHECKING: - pass - 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: """ @@ -31,8 +33,9 @@ class RenderPipeline: Key responsibilities: - Compose content segments into scrollable image - Manage scroll position and velocity - - Handle 125 FPS rendering loop - - Double-buffer for hot-swap during updates + - Render one frame per call at the target FPS + - Extend the strip (continuous mode) or recompose and hot-swap it (swap + mode) as content changes - Track scroll cycle completion """ @@ -59,18 +62,10 @@ class RenderPipeline: self.stream_manager = stream_manager self.sync_manager = None # Optional DisplaySyncManager — set by coordinator 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 - # Display dimensions (handle both property and method access patterns) - self.display_width = ( - 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 - ) + self.display_width = display_manager.width + self.display_height = display_manager.height # ScrollHelper for optimized scrolling self.scroll_helper = ScrollHelper( @@ -82,11 +77,6 @@ class RenderPipeline: # 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. self._prepared_group = None # Plugins that need the shared canvas, appended one at a time. @@ -96,13 +86,9 @@ class RenderPipeline: self._prefetch_lock = threading.Lock() # Render state - self._is_rendering = False self._cycle_complete = False 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 # Statistics @@ -192,10 +178,6 @@ class RenderPipeline: logger.error("ScrollHelper failed to create cached image") 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) self._segments_in_scroll = self.stream_manager.get_active_plugin_ids() @@ -335,8 +317,6 @@ class RenderPipeline: element_gap=0, ) if appended: - with self._buffer_lock: - self._active_scroll_image = self.scroll_helper.cached_image logger.info( "[%s] Appended deferred content: strip now %dpx, %dpx ahead", 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. 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.stats['composition_count'] += 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. if self.sync_manager: 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.sync_manager.send_scroll_x(self.scroll_helper.scroll_position) @@ -603,7 +580,6 @@ class RenderPipeline: Returns True when: - Cycle is complete and we should start fresh - - Staging buffer has new content - A plugin currently visible in the scroll has pending updated data (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 # 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 - # as a speed-up. Deferring to cycle boundaries keeps transitions clean. - # Staging buffer content is still pre-loaded; it just applies at cycle end. + # as a speed-up. Deferring to cycle boundaries keeps transitions clean; + # the pending updates are still applied, by the recompose at cycle end. if self.sync_manager is not None: 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 # live score/status changes reach the display within a few seconds # instead of waiting for the next full cycle. @@ -652,10 +623,12 @@ class RenderPipeline: 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. - Swaps atomically to prevent visual glitches. + Swap mode only (continuous mode uses :meth:`refresh_updated_plugins`). + 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: True if swap occurred @@ -668,9 +641,7 @@ class RenderPipeline: old_width = self.scroll_helper.total_scroll_width old_pos = self.scroll_helper.scroll_position - # Process any pending updates self.stream_manager.process_updates() - self.stream_manager.swap_buffers() # Recompose with updated content if self.compose_scroll_content(): @@ -729,21 +700,17 @@ class RenderPipeline: result = self.compose_scroll_content() if result and self.sync_manager: - # When sync is active, start the leader past the lead-in gap so it - # immediately shows content, leaving the follower on the blank gap - # for a clean transition rather than near-end content wrapping - # around. This tracks lead_in_width rather than assuming a full - # display width of gap, which is no longer the default. + # Start the leader past the lead-in gap so it immediately shows + # content, leaving the follower on the blank gap for a clean + # transition rather than near-end content wrapping around. 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) self.sync_manager.send_new_cycle() # 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). if self.scroll_helper.cached_image is not None: - import threading as _t - _t.Thread( + threading.Thread( target=self.sync_manager.send_scroll_image, args=(self.scroll_helper.cached_image,), daemon=True, name="sync-image-push" @@ -792,7 +759,6 @@ class RenderPipeline: """ old_fps = self.config.target_fps self.config = new_config - self._frame_interval = new_config.get_frame_interval() # Reconfigure scroll helper self._configure_scroll_helper() @@ -805,10 +771,6 @@ class RenderPipeline: self.scroll_helper.reset_scroll() self.scroll_helper.clear_cache() - with self._buffer_lock: - self._active_scroll_image = None - self._staging_scroll_image = None - self._cycle_complete = False self._segments_in_scroll = [] self._frame_times = deque(maxlen=100) diff --git a/src/vegas_mode/stream_manager.py b/src/vegas_mode/stream_manager.py index cb7cc2bc..8faaaead 100644 --- a/src/vegas_mode/stream_manager.py +++ b/src/vegas_mode/stream_manager.py @@ -31,22 +31,14 @@ logger = logging.getLogger(__name__) @dataclass 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 images: List[Image.Image] - total_width: int 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: @@ -54,10 +46,12 @@ class StreamManager: Manages streaming of plugin content for Vegas scroll mode. Key responsibilities: - - Maintain ordered list of plugins to stream - - Prefetch content 1-2 plugins ahead of current position - - Handle plugin data updates via double-buffer swap - - Manage content lifecycle and staleness + - Maintain the ordered (and priority-weighted) rotation of plugins + - Fill the active buffer with a cycle's worth of segments (swap mode), or + hand out the next group of plugins directly (continuous mode) + - Track plugins whose data changed in ``_pending_updates``, which + :meth:`process_updates` (swap mode) or + :meth:`invalidate_pending_updates` (continuous mode) consumes """ def __init__( @@ -78,14 +72,14 @@ class StreamManager: self.plugin_manager = plugin_manager 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._staging_buffer: Deque[ContentSegment] = deque() - self._buffer_lock = threading.RLock() # RLock for reentrant access + # Reentrant: _prefetch_content releases and re-acquires it around the + # 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._current_index: int = 0 self._prefetch_index: int = 0 # Update tracking @@ -97,7 +91,6 @@ class StreamManager: self.stats = { 'segments_fetched': 0, 'segments_served': 0, - 'buffer_swaps': 0, 'fetch_errors': 0, } @@ -167,9 +160,7 @@ class StreamManager: with self._buffer_lock: return { 'active_count': len(self._active_buffer), - 'staging_count': len(self._staging_buffer), 'total_plugins': len(self._ordered_plugins), - 'current_index': self._current_index, 'prefetch_index': self._prefetch_index, 'stats': self.stats.copy(), } @@ -190,8 +181,9 @@ class StreamManager: """ Mark a plugin as having updated data. - Called when a plugin's data changes. Triggers content refresh - for that plugin in the staging buffer. + Only records it in ``_pending_updates``. The refetch happens later, + in :meth:`process_updates` (swap mode) or by dropping the plugin's + caches in :meth:`invalidate_pending_updates` (continuous mode). Args: plugin_id: Plugin that was updated @@ -242,11 +234,6 @@ class StreamManager: ) 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: """Check if pending updates affect plugins currently in the active buffer.""" with self._buffer_lock: @@ -309,19 +296,6 @@ class StreamManager: 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: """ Refresh the plugin list and content. @@ -337,61 +311,49 @@ class StreamManager: self._refresh_plugin_list() if len(self._ordered_plugins) != old_count: - logger.info( + logger.debug( "Plugin list refreshed: %d -> %d plugins", old_count, len(self._ordered_plugins) ) def _refresh_plugin_list(self) -> None: - """Refresh the ordered list of plugins from plugin manager.""" - logger.info("=" * 60) - logger.info("REFRESHING PLUGIN LIST FOR VEGAS SCROLL") - logger.info("=" * 60) + """Refresh the ordered list of plugins from plugin manager. - # 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 = [] + loaded = 0 if hasattr(self.plugin_manager, 'plugins'): - logger.info( - "Checking %d loaded plugins for Vegas scroll", - len(self.plugin_manager.plugins) - ) + loaded = len(self.plugin_manager.plugins) for plugin_id, plugin in self.plugin_manager.plugins.items(): - has_enabled = hasattr(plugin, 'enabled') - is_enabled = getattr(plugin, 'enabled', False) - logger.info( - "[%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) + if not getattr(plugin, 'enabled', False): + logger.debug("[%s] Vegas: skipped (not enabled)", plugin_id) + continue - # Also check display mode - STATIC plugins should be included - # even if their content_type is 'none' - display_mode = VegasDisplayMode.FIXED_SEGMENT - try: - display_mode = plugin.get_vegas_display_mode() - except Exception: - # Plugin error should not abort refresh; use default mode - logger.exception( - "[%s] (%s) get_vegas_display_mode() failed, using default", - plugin_id, plugin.__class__.__name__ - ) - - logger.info( - "[%s] content_type=%s, display_mode=%s", - plugin_id, content_type, display_mode.value + # Content type 'none' is left out, except for STATIC plugins, + # 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 + try: + display_mode = plugin.get_vegas_display_mode() + except Exception: + # Plugin error should not abort refresh; use default mode + logger.exception( + "[%s] (%s) get_vegas_display_mode() failed, using default", + plugin_id, plugin.__class__.__name__ ) - if content_type != 'none' or display_mode == VegasDisplayMode.STATIC: - 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) - + included = (content_type != 'none' + or display_mode == VegasDisplayMode.STATIC) + 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: + available_plugins.append(plugin_id) else: logger.warning( "plugin_manager does not have plugins attribute: %s", @@ -400,24 +362,20 @@ class StreamManager: # Apply ordering from config (outside lock for potentially slow operation) 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) # Atomically update shared state under lock to avoid races with prefetchers with self._buffer_lock: 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): 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: """Slots per cycle for one plugin. @@ -490,7 +448,7 @@ class StreamManager: schedule = self._unclump_seam(schedule) 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)", len(schedule), len(ordered), boosted) return schedule @@ -607,11 +565,6 @@ class StreamManager: ContentSegment or None if fetch failed """ try: - logger.info("=" * 60) - logger.info("[%s] FETCHING CONTENT", plugin_id) - logger.info("=" * 60) - - # Get plugin instance if not hasattr(self.plugin_manager, 'plugins'): logger.warning("[%s] plugin_manager has no plugins attribute", plugin_id) return None @@ -621,18 +574,11 @@ class StreamManager: logger.warning("[%s] Plugin not found in plugin_manager.plugins", plugin_id) 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 try: display_mode = plugin.get_vegas_display_mode() - logger.info("[%s] Display mode: %s", plugin_id, display_mode.value) except (AttributeError, TypeError) as e: - logger.info( + logger.debug( "[%s] get_vegas_display_mode() not available: %s (using FIXED_SEGMENT)", plugin_id, e ) @@ -644,21 +590,21 @@ class StreamManager: segment = ContentSegment( plugin_id=plugin_id, images=[], # No images needed for static pause - total_width=0, display_mode=display_mode ) self.stats['segments_fetched'] += 1 - logger.info( + logger.debug( "[%s] Created STATIC placeholder (pause trigger)", plugin_id ) return segment # 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) 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 # Calculate total width @@ -667,17 +613,14 @@ class StreamManager: segment = ContentSegment( plugin_id=plugin_id, images=images, - total_width=total_width, display_mode=display_mode ) self.stats['segments_fetched'] += 1 - logger.info( - "[%s] SEGMENT CREATED: %d images, %dpx total, mode=%s", + logger.debug( + "[%s] Segment: %d image(s), %dpx, mode=%s", plugin_id, len(images), total_width, display_mode.value ) - logger.info("=" * 60) - return segment except Exception: @@ -696,24 +639,6 @@ class StreamManager: if len(self._active_buffer) < low_water: 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]]]: """ Get buffered content grouped by the plugin that produced it. @@ -816,8 +741,6 @@ class StreamManager: """Reset the stream manager state.""" with self._buffer_lock: self._active_buffer.clear() - self._staging_buffer.clear() - self._current_index = 0 self._prefetch_index = 0 self._pending_updates.clear() diff --git a/test/conftest.py b/test/conftest.py index 6bfba2e2..c04502e4 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -105,7 +105,6 @@ def _reset_display_manager_globals(): # An instance left here is what the next module's DisplayManager() call # gets back -- potentially one built against a MagicMock matrix. dm_mod.DisplayManager._instance = None - dm_mod.DisplayManager._initialized = False # Put a binding back if a patch outlived the module that started it. # Restoring rather than failing: a leak reported against an innocent module diff --git a/test/test_display_controller_cleanup.py b/test/test_display_controller_cleanup.py new file mode 100644 index 00000000..5511e0d8 --- /dev/null +++ b/test/test_display_controller_cleanup.py @@ -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() diff --git a/test/test_display_controller_startup_validation.py b/test/test_display_controller_startup_validation.py new file mode 100644 index 00000000..d7941d7e --- /dev/null +++ b/test/test_display_controller_startup_validation.py @@ -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() diff --git a/test/test_display_controller_vegas_tick.py b/test/test_display_controller_vegas_tick.py index 538e9272..42d7f3ca 100644 --- a/test/test_display_controller_vegas_tick.py +++ b/test/test_display_controller_vegas_tick.py @@ -83,15 +83,9 @@ class TestVegasCoordinatorCallbackWiring: fake_coordinator = MagicMock() - import src.display_controller as dc_module - original_imported = dc_module._vegas_mode_imported - original_class = dc_module.VegasModeCoordinator - try: - dc_module._vegas_mode_imported = True - dc_module.VegasModeCoordinator = MagicMock(return_value=fake_coordinator) + from unittest.mock import patch + with patch('src.vegas_mode.VegasModeCoordinator', + MagicMock(return_value=fake_coordinator)): 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) diff --git a/test/test_display_dirty_tracking.py b/test/test_display_dirty_tracking.py index cd607c0b..01af2884 100644 --- a/test/test_display_dirty_tracking.py +++ b/test/test_display_dirty_tracking.py @@ -28,7 +28,6 @@ def dm(tmp_path_factory): """One real DisplayManager on the emulator (it's a process singleton).""" from src.display_manager import DisplayManager DisplayManager._instance = None - DisplayManager._initialized = False manager = DisplayManager({ "display": { "hardware": {"rows": 32, "cols": 64, "chain_length": 2, @@ -60,7 +59,6 @@ def dm(tmp_path_factory): "test module.") yield manager DisplayManager._instance = None - DisplayManager._initialized = False class _SwapSpy: @@ -230,7 +228,6 @@ class TestKillSwitch: def test_config_flag_wires_through(self): from src.display_manager import DisplayManager DisplayManager._instance = None - DisplayManager._initialized = False try: manager = DisplayManager({ "display": { @@ -243,7 +240,6 @@ class TestKillSwitch: assert manager._dirty_tracking_enabled is False finally: DisplayManager._instance = None - DisplayManager._initialized = False if __name__ == "__main__": diff --git a/test/test_display_manager_logging.py b/test/test_display_manager_logging.py new file mode 100644 index 00000000..f543e5fe --- /dev/null +++ b/test/test_display_manager_logging.py @@ -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) diff --git a/test/test_display_pending_changes.py b/test/test_display_pending_changes.py index 8a4e4684..50aa4574 100644 --- a/test/test_display_pending_changes.py +++ b/test/test_display_pending_changes.py @@ -154,7 +154,6 @@ def vegas_coordinator(controller): coord._update_callback = None coord._update_tick_running = False coord.sync_manager = None - coord._update_static_mode_plugins = lambda: None coord._check_static_plugin_trigger = lambda: None coord.frames = 0 diff --git a/test/test_vegas_config.py b/test/test_vegas_config.py index a639896e..3b51903c 100644 --- a/test/test_vegas_config.py +++ b/test/test_vegas_config.py @@ -2,7 +2,7 @@ Tests for src/vegas_mode/config.py Covers VegasModeConfig: from_config, to_dict, get_frame_interval, -is_plugin_included, get_ordered_plugins, validate. +get_ordered_plugins, validate. """ import pytest @@ -38,6 +38,29 @@ class TestVegasModeConfigDefaults: cfg = VegasModeConfig() 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 @@ -147,24 +170,6 @@ class TestGetFrameInterval: 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 # --------------------------------------------------------------------------- diff --git a/test/test_vegas_continuous_refresh.py b/test/test_vegas_continuous_refresh.py index 422a4921..3b885f1b 100644 --- a/test/test_vegas_continuous_refresh.py +++ b/test/test_vegas_continuous_refresh.py @@ -108,10 +108,10 @@ class TestInvalidatePendingUpdates: # Left unconsumed they accumulate forever and nothing ever refreshes. stream = self._manager({'baseball': SimpleNamespace()}) stream.mark_plugin_updated('baseball') - assert stream.has_pending_updates() is True + assert stream._pending_updates stream.invalidate_pending_updates() - assert stream.has_pending_updates() is False + assert not stream._pending_updates assert stream.invalidate_pending_updates() == [] def test_no_pending_updates_does_no_work(self): @@ -133,7 +133,6 @@ class TestInvalidatePendingUpdates: class TestContinuousModeReachesTheRefresh: def _pipeline(self): stream = MagicMock() - stream.get_buffer_status.return_value = {'staging_count': 0} return RenderPipeline(VegasModeConfig(), FakeDisplayManager(), stream), stream def test_refresh_delegates_to_the_stream_manager(self): @@ -161,7 +160,6 @@ class TestContinuousModeReachesTheRefresh: pipeline.refresh_updated_plugins() assert pipeline.scroll_helper.scroll_position == 1234 - stream.swap_buffers.assert_not_called() stream.process_updates.assert_not_called() diff --git a/test/test_vegas_coordinator_iteration.py b/test/test_vegas_coordinator_iteration.py new file mode 100644 index 00000000..329b36fa --- /dev/null +++ b/test/test_vegas_coordinator_iteration.py @@ -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) diff --git a/test/test_vegas_density.py b/test/test_vegas_density.py index 4c410314..28e80a9c 100644 --- a/test/test_vegas_density.py +++ b/test/test_vegas_density.py @@ -416,7 +416,7 @@ class TestStreamGrouping: from src.plugin_system.base_plugin import VegasDisplayMode imgs = [Image.new('RGB', (10, 8)) for _ in range(count)] return ContentSegment( - plugin_id=plugin_id, images=imgs, total_width=10 * count, + plugin_id=plugin_id, images=imgs, display_mode=mode or VegasDisplayMode.SCROLL) def test_grouping_preserves_plugin_boundaries(self): @@ -437,10 +437,6 @@ class TestStreamGrouping: sm = self._stream([self._seg('a', 0), self._seg('b', 2)]) 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: """ @@ -980,8 +976,8 @@ class TestRotationAcrossMultipleCycles: """ 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 - crashed find_blank_cut in the field and lost that plugin's content for the - cycle. First-pass-only tests never reach it. + once crashed the cut and lost that plugin's content for the cycle. + First-pass-only tests never reach it. """ def test_window_advances_over_many_cycles_without_error(self): diff --git a/test/test_vegas_geometry.py b/test/test_vegas_geometry.py index 5a336fc0..2573fc16 100644 --- a/test/test_vegas_geometry.py +++ b/test/test_vegas_geometry.py @@ -10,7 +10,6 @@ from src.vegas_mode.geometry import ( content_bounds, dead_window_stats, edge_blank, - find_blank_cut, separation_gap, trim_to_content, window_coverage_stats, @@ -320,51 +319,3 @@ class TestSeparationGap: # flat gap; measured separation lifts them to the 24px target. card = paint(make_img(150), 0, 150) 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 diff --git a/test/test_vegas_render_pipeline_recompose.py b/test/test_vegas_render_pipeline_recompose.py index 2e453337..092ea768 100644 --- a/test/test_vegas_render_pipeline_recompose.py +++ b/test/test_vegas_render_pipeline_recompose.py @@ -24,7 +24,6 @@ class FakeDisplayManager: def _make_pipeline(sync_manager=None): stream_manager = MagicMock() - stream_manager.get_buffer_status.return_value = {'staging_count': 0} pipeline = RenderPipeline(VegasModeConfig(), FakeDisplayManager(), stream_manager) pipeline.sync_manager = sync_manager return pipeline, stream_manager @@ -37,7 +36,7 @@ class TestShouldRecompose: stream_manager.has_pending_updates_for_visible_segments.return_value = False 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() stream_manager.has_pending_updates_for_visible_segments.return_value = False assert pipeline.should_recompose() is False @@ -49,12 +48,6 @@ class TestShouldRecompose: stream_manager.has_pending_updates_for_visible_segments.return_value = 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): """Sync-mode deferral (PR #330's actual intent) must still hold: pending updates alone must NOT trigger a mid-cycle hot-swap when a diff --git a/test/test_vegas_stream_log_volume.py b/test/test_vegas_stream_log_volume.py new file mode 100644 index 00000000..2f2e17c6 --- /dev/null +++ b/test/test_vegas_stream_log_volume.py @@ -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)