Files
LEDMatrix/docs/VEGAS_SCROLL_MODE.md
Chuck 7524747e44 Feature/vegas scroll mode (#215)
* feat(display): add Vegas-style continuous scroll mode

Implement an opt-in Vegas ticker mode that composes all enabled plugin
content into a single continuous horizontal scroll. Includes a modular
package (src/vegas_mode/) with double-buffered streaming, 125 FPS
render pipeline using the existing ScrollHelper, live priority
interruption support, and a web UI for configuration with drag-drop
plugin ordering.

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

* feat(vegas): add three-mode display system (SCROLL, FIXED_SEGMENT, STATIC)

Adds a flexible display mode system for Vegas scroll mode that allows
plugins to control how their content appears in the continuous scroll:

- SCROLL: Content scrolls continuously (multi-item plugins like sports)
- FIXED_SEGMENT: Fixed block that scrolls by (clock, weather)
- STATIC: Scroll pauses, plugin displays, then resumes (alerts)

Changes:
- Add VegasDisplayMode enum to base_plugin.py with backward-compatible
  mapping from legacy get_vegas_content_type()
- Add static pause handling to coordinator with scroll position save/restore
- Add mode-aware content composition to stream_manager
- Add vegas_mode info to /api/v3/plugins/installed endpoint
- Add mode indicators to Vegas settings UI
- Add comprehensive plugin developer documentation

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

* fix(vegas,widgets): address validation, thread safety, and XSS issues

Vegas mode fixes:
- config.py: align validation limits with UI (scroll_speed max 200, separator_width max 128)
- coordinator.py: fix race condition by properly initializing _pending_config
- plugin_adapter.py: remove unused import
- render_pipeline.py: preserve deque type in reset() method
- stream_manager.py: fix lock handling and swap_buffers to truly swap

API fixes:
- api_v3.py: normalize boolean checkbox values, validate numeric fields, ensure JSON arrays

Widget fixes:
- day-selector.js: remove escapeHtml from JSON.stringify to prevent corruption
- password-input.js: use deterministic color class mapping for Tailwind JIT
- radio-group.js: replace inline onchange with addEventListener to prevent XSS
- select-dropdown.js: guard global registry access
- slider.js: add escapeAttr for attributes, fix null dereference in setValue

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

* fix(vegas): improve exception handling and static pause state management

coordinator.py:
- _check_live_priority: use logger.exception for full traceback
- _end_static_pause: guard scroll resume on interruption (stop/live priority)
- _update_static_mode_plugins: log errors instead of silently swallowing

render_pipeline.py:
- compose_scroll_content: use specific exceptions and logger.exception
- render_frame: use specific exceptions and logger.exception
- hot_swap_content: use specific exceptions and logger.exception

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

* fix(vegas): add interrupt mechanism and improve config/exception handling

- Add interrupt checker callback to Vegas coordinator for responsive
  handling of on-demand requests and wifi status during Vegas mode
- Fix config.py update() to include dynamic duration fields
- Fix is_plugin_included() consistency with get_ordered_plugins()
- Update _apply_pending_config to propagate config to StreamManager
- Change _fetch_plugin_content to use logger.exception for traceback
- Replace bare except in _refresh_plugin_list with specific exceptions
- Add aria-label accessibility to Vegas toggle checkbox
- Fix XSS vulnerability in plugin metadata rendering with escapeHtml

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

* fix(vegas): improve logging, validation, lock handling, and config updates

- display_controller.py: use logger.exception for Vegas errors with traceback
- base_plugin.py: validate vegas_panel_count as positive integer with warning
- coordinator.py: fix _apply_pending_config to avoid losing concurrent updates
  by clearing _pending_config while holding lock
- plugin_adapter.py: remove broad catch-all, use narrower exception types
  (AttributeError, TypeError, ValueError, OSError, RuntimeError) and
  logger.exception for traceback preservation
- api_v3.py: only update vegas_config['enabled'] when key is present in data
  to prevent incorrect disabling when checkbox is omitted

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

* fix(vegas): improve cycle advancement, logging, and accessibility

- Add advance_cycle() method to StreamManager for clearing buffer between cycles
- Call advance_cycle() in RenderPipeline.start_new_cycle() for fresh content
- Use logger.exception() for interrupt check and static pause errors (full tracebacks)
- Add id="vegas_scroll_label" to h3 for aria-labelledby reference
- Call updatePluginConfig() after rendering plugin list for proper initialization

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

* fix(vegas): add thread-safety, preserve updates, and improve logging

- display_controller.py: Use logger.exception() for Vegas import errors
- plugin_adapter.py: Add thread-safe cache lock, remove unused exception binding
- stream_manager.py: In-place merge in process_updates() preserves non-updated plugins
- api_v3.py: Change vegas_scroll_enabled default from False to True

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

* fix(vegas): add debug logging and narrow exception types

- stream_manager.py: Log when get_vegas_display_mode() is unavailable
- stream_manager.py: Narrow exception type from Exception to (AttributeError, TypeError)
- api_v3.py: Log exceptions when reading Vegas display metadata with plugin context

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

* fix(vegas): fix method call and improve exception logging

- Fix _check_vegas_interrupt() calling nonexistent _check_wifi_status(),
  now correctly calls _check_wifi_status_message()
- Update _refresh_plugin_list() exception handler to use logger.exception()
  with plugin_id and class name for remote debugging

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

* fix(web): replace complex toggle with standard checkbox for Vegas mode

The Tailwind pseudo-element toggle (after:content-[''], etc.) wasn't
rendering because these classes weren't in the CSS bundle. Replaced
with a simple checkbox that matches other form controls in the template.

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

* debug(vegas): add detailed logging to _refresh_plugin_list

Track why plugins aren't being found for Vegas scroll:
- Log count of loaded plugins
- Log enabled status for each plugin
- Log content_type and display_mode checks
- Log when plugin_manager lacks loaded_plugins

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

* fix(vegas): use correct attribute name for plugin manager

StreamManager and VegasModeCoordinator were checking for
plugin_manager.loaded_plugins but PluginManager stores active
plugins in plugin_manager.plugins. This caused Vegas scroll
to find zero plugins despite plugins being available.

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

* fix(vegas): convert scroll_speed from px/sec to px/frame correctly

The config scroll_speed is in pixels per second, but ScrollHelper
in frame_based_scrolling mode interprets it as pixels per frame.
Previously this caused the speed to be clamped to max 5.0 regardless
of the configured value.

Now properly converts: pixels_per_frame = scroll_speed * scroll_delay

With defaults (50 px/s, 0.02s delay), this gives 1 px/frame = 50 px/s.

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

* feat(vegas): add FPS logging every 5 seconds

Logs actual FPS vs target FPS to help diagnose performance issues.
Shows frame count in each 5-second interval.

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

* fix(vegas): improve plugin content capture reliability

- Call update_data() before capture to ensure fresh plugin data
- Try display() without force_clear first, fallback if TypeError
- Retry capture with force_clear=True if first attempt is blank
- Use histogram-based blank detection instead of point sampling
  (more reliable for content positioned anywhere in frame)

This should help capture content from plugins that don't implement
get_vegas_content() natively.

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

* fix(vegas): handle callable width/height on display_manager

DisplayManager.width and .height may be methods or properties depending
on the implementation. Use callable() check to call them if needed,
ensuring display_width and display_height are always integers.

Fixes potential TypeError when width/height are methods.

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

* fix(vegas): use logger.exception for display mode errors

Replace logger.error with logger.exception to capture full stack trace
when get_vegas_display_mode() fails on a plugin.

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

* fix(vegas): protect plugin list updates with buffer lock

Move assignment of _ordered_plugins and index resets under _buffer_lock
to prevent race conditions with _prefetch_content() which reads these
variables under the same lock.

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

* fix(vegas): catch all exceptions in get_vegas_display_mode

Broaden exception handling from AttributeError/TypeError to Exception
so any plugin error in get_vegas_display_mode() doesn't abort the
entire plugin list refresh. The loop continues with the default
FIXED_SEGMENT mode.

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

* fix(vegas): refresh stream manager when config updates

After updating stream_manager.config, force a refresh to pick up changes
to plugin_order, excluded_plugins, and buffer_ahead settings. Also use
logger.exception to capture full stack traces on config update errors.

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

* debug(vegas): add detailed logging for blank image detection

* feat(vegas): extract full scroll content from plugins using ScrollHelper

Plugins like ledmatrix-stocks and odds-ticker use ScrollHelper with a
cached_image that contains their full scrolling content. Instead of
falling back to single-frame capture, now check for scroll_helper.cached_image
first to get the complete scrolling content for Vegas mode.

* debug(vegas): add comprehensive INFO-level logging for plugin content flow

- Log each plugin being processed with class name
- Log which content methods are tried (native, scroll_helper, fallback)
- Log success/failure of each method with image dimensions
- Log brightness check results for blank image detection
- Add visual separators in logs for easier debugging
- Log plugin list refresh with enabled/excluded status

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

* feat(vegas): trigger scroll content generation when cache is empty

When a plugin has a scroll_helper but its cached_image is not yet
populated, try to trigger content generation by:
1. Calling _create_scrolling_display() if available (stocks pattern)
2. Calling display(force_clear=True) as a fallback

This allows plugins like stocks to provide their full scroll content
even when Vegas mode starts before the plugin has run its normal
display cycle.

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

* fix: improve exception handling in plugin_adapter scroll content retrieval

Replace broad except Exception handlers with narrow exception types
(AttributeError, TypeError, ValueError, OSError) and use logger.exception
instead of logger.warning/info to capture full stack traces for better
diagnosability.

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

* fix: narrow exception handling in coordinator and plugin_adapter

- coordinator.py: Replace broad Exception catch around get_vegas_display_mode()
  with (AttributeError, TypeError) and use logger.exception for stack traces
- plugin_adapter.py: Narrow update_data() exception handler to
  (AttributeError, RuntimeError, OSError) and use logger.exception

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

* fix: improve Vegas mode robustness and API validation

- display_controller: Guard against None plugin_manager in Vegas init
- coordinator: Restore scrolling state in resume() to match pause()
- api_v3: Validate Vegas numeric fields with range checks and 400 errors

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

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 10:23:56 -05:00

11 KiB
Raw Blame History

Vegas Scroll Mode - Plugin Developer Guide

Vegas scroll mode displays content from multiple plugins in a continuous horizontal scroll, similar to the news tickers seen in Las Vegas casinos. This guide explains how to integrate your plugin with Vegas mode.

Overview

When Vegas mode is enabled, the display controller composes content from all enabled plugins into a single continuous scroll. Each plugin can control how its content appears in the scroll using one of three display modes:

Mode Behavior Best For
SCROLL Content scrolls continuously within the stream Multi-item plugins (sports scores, odds, news)
FIXED_SEGMENT Fixed-width block that scrolls by Static info (clock, weather, current temp)
STATIC Scroll pauses, plugin displays for duration, then resumes Important alerts, detailed views

Quick Start

Minimal Integration (Zero Code Changes)

If you do nothing, your plugin will work with Vegas mode using these defaults:

  • Plugins with get_vegas_content_type() == 'multi' use SCROLL mode
  • Plugins with get_vegas_content_type() == 'static' use FIXED_SEGMENT mode
  • Content is captured by calling your plugin's display() method

Basic Integration

To provide optimized Vegas content, implement get_vegas_content():

from PIL import Image

class MyPlugin(BasePlugin):
    def get_vegas_content(self):
        """Return content for Vegas scroll mode."""
        # Return a single image for fixed content
        return self._render_current_view()

        # OR return multiple images for multi-item content
        # return [self._render_item(item) for item in self.items]

Full Integration

For complete control over Vegas behavior, implement these methods:

from src.plugin_system.base_plugin import BasePlugin, VegasDisplayMode

class MyPlugin(BasePlugin):
    def get_vegas_content_type(self) -> str:
        """Legacy method - determines default mode mapping."""
        return 'multi'  # or 'static' or 'none'

    def get_vegas_display_mode(self) -> VegasDisplayMode:
        """Specify how this plugin behaves in Vegas scroll."""
        return VegasDisplayMode.SCROLL

    def get_supported_vegas_modes(self) -> list:
        """Return list of modes users can configure."""
        return [VegasDisplayMode.SCROLL, VegasDisplayMode.FIXED_SEGMENT]

    def get_vegas_content(self):
        """Return PIL Image(s) for the scroll."""
        return [self._render_game(g) for g in self.games]

    def get_vegas_segment_width(self) -> int:
        """For FIXED_SEGMENT: width in panels (optional)."""
        return 2  # Use 2 panels width

Display Modes Explained

SCROLL Mode

Content scrolls continuously within the Vegas stream. Best for plugins with multiple items.

def get_vegas_display_mode(self):
    return VegasDisplayMode.SCROLL

def get_vegas_content(self):
    # Return list of images - each scrolls individually
    images = []
    for game in self.games:
        img = Image.new('RGB', (200, 32))
        # ... render game info ...
        images.append(img)
    return images

When to use:

  • Sports scores with multiple games
  • Stock/odds tickers with multiple items
  • News feeds with multiple headlines

FIXED_SEGMENT Mode

Content is rendered as a fixed-width block that scrolls by with other content.

def get_vegas_display_mode(self):
    return VegasDisplayMode.FIXED_SEGMENT

def get_vegas_content(self):
    # Return single image at your preferred width
    img = Image.new('RGB', (128, 32))  # 2 panels wide
    # ... render clock/weather/etc ...
    return img

def get_vegas_segment_width(self):
    # Optional: specify width in panels
    return 2

When to use:

  • Clock display
  • Current weather/temperature
  • System status indicators
  • Any "at a glance" information

STATIC Mode

Scroll pauses completely, your plugin displays using its normal display() method for its configured duration, then scroll resumes.

def get_vegas_display_mode(self):
    return VegasDisplayMode.STATIC

def get_display_duration(self):
    # How long to pause and show this plugin
    return 10.0  # 10 seconds

When to use:

  • Important alerts that need attention
  • Detailed information that's hard to read while scrolling
  • Interactive or animated content
  • Content that requires the full display

User Configuration

Users can override the default display mode per-plugin in their config:

{
  "my_plugin": {
    "enabled": true,
    "vegas_mode": "static",       // Override: "scroll", "fixed", or "static"
    "vegas_panel_count": 2,       // Width in panels for fixed mode
    "display_duration": 10        // Duration for static mode
  }
}

The get_vegas_display_mode() method checks config first, then falls back to your implementation.

Content Rendering Guidelines

Image Dimensions

  • Height: Must match display height (typically 32 pixels)
  • Width:
    • SCROLL: Any width, content will scroll
    • FIXED_SEGMENT: panels × single_panel_width (e.g., 2 × 64 = 128px)

Color Mode

Always use RGB mode for images:

img = Image.new('RGB', (width, 32), color=(0, 0, 0))

Performance Tips

  1. Cache rendered images - Don't re-render on every call
  2. Pre-render on update() - Render images when data changes, not when Vegas requests them
  3. Keep images small - Memory adds up with multiple plugins
class MyPlugin(BasePlugin):
    def __init__(self, ...):
        super().__init__(...)
        self._cached_vegas_images = None
        self._cache_valid = False

    def update(self):
        # Fetch new data
        self.data = self._fetch_data()
        # Invalidate cache so next Vegas request re-renders
        self._cache_valid = False

    def get_vegas_content(self):
        if not self._cache_valid:
            self._cached_vegas_images = self._render_all_items()
            self._cache_valid = True
        return self._cached_vegas_images

Fallback Behavior

If your plugin doesn't implement get_vegas_content(), Vegas mode will:

  1. Create a temporary canvas matching display dimensions
  2. Call your display() method
  3. Capture the resulting image
  4. Use that image in the scroll

This works but is less efficient than providing native Vegas content.

Excluding from Vegas Mode

To exclude your plugin from Vegas scroll entirely:

def get_vegas_content_type(self):
    return 'none'

Or users can exclude via config:

{
  "display": {
    "vegas_scroll": {
      "excluded_plugins": ["my_plugin"]
    }
  }
}

Complete Example

Here's a complete example of a weather plugin with full Vegas integration:

from PIL import Image, ImageDraw
from src.plugin_system.base_plugin import BasePlugin, VegasDisplayMode

class WeatherPlugin(BasePlugin):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.temperature = None
        self.conditions = None
        self._vegas_image = None

    def update(self):
        """Fetch weather data."""
        data = self._fetch_weather_api()
        self.temperature = data['temp']
        self.conditions = data['conditions']
        self._vegas_image = None  # Invalidate cache

    def display(self, force_clear=False):
        """Standard display for normal rotation."""
        if force_clear:
            self.display_manager.clear()

        # Full weather display with details
        self.display_manager.draw_text(
            f"{self.temperature}°F",
            x=10, y=8, color=(255, 255, 255)
        )
        self.display_manager.draw_text(
            self.conditions,
            x=10, y=20, color=(200, 200, 200)
        )
        self.display_manager.update_display()

    # --- Vegas Mode Integration ---

    def get_vegas_content_type(self):
        """Legacy compatibility."""
        return 'static'

    def get_vegas_display_mode(self):
        """Use FIXED_SEGMENT for compact weather display."""
        # Allow user override via config
        return super().get_vegas_display_mode()

    def get_supported_vegas_modes(self):
        """Weather can work as fixed or static."""
        return [VegasDisplayMode.FIXED_SEGMENT, VegasDisplayMode.STATIC]

    def get_vegas_segment_width(self):
        """Weather needs 2 panels to show clearly."""
        return self.config.get('vegas_panel_count', 2)

    def get_vegas_content(self):
        """Render compact weather for Vegas scroll."""
        if self._vegas_image is not None:
            return self._vegas_image

        # Create compact display (2 panels = 128px typical)
        panel_width = 64  # From display.hardware.cols
        panels = self.get_vegas_segment_width() or 2
        width = panel_width * panels
        height = 32

        img = Image.new('RGB', (width, height), color=(0, 0, 40))
        draw = ImageDraw.Draw(img)

        # Draw compact weather
        temp_text = f"{self.temperature}°"
        draw.text((10, 8), temp_text, fill=(255, 255, 255))
        draw.text((60, 8), self.conditions[:10], fill=(200, 200, 200))

        self._vegas_image = img
        return img

API Reference

VegasDisplayMode Enum

from src.plugin_system.base_plugin import VegasDisplayMode

VegasDisplayMode.SCROLL        # "scroll" - continuous scrolling
VegasDisplayMode.FIXED_SEGMENT # "fixed" - fixed block in scroll
VegasDisplayMode.STATIC        # "static" - pause scroll to display

BasePlugin Vegas Methods

Method Returns Description
get_vegas_content() Image or List[Image] or None Content for Vegas scroll
get_vegas_content_type() str Legacy: 'multi', 'static', or 'none'
get_vegas_display_mode() VegasDisplayMode How plugin behaves in Vegas
get_supported_vegas_modes() List[VegasDisplayMode] Modes available for user config
get_vegas_segment_width() int or None Width in panels for FIXED_SEGMENT

Configuration Options

Per-plugin config:

{
  "plugin_id": {
    "vegas_mode": "scroll|fixed|static",
    "vegas_panel_count": 2,
    "display_duration": 15
  }
}

Global Vegas config:

{
  "display": {
    "vegas_scroll": {
      "enabled": true,
      "scroll_speed": 50,
      "separator_width": 32,
      "plugin_order": ["clock", "weather", "sports"],
      "excluded_plugins": ["debug_plugin"],
      "target_fps": 125,
      "buffer_ahead": 2
    }
  }
}

Troubleshooting

Plugin not appearing in Vegas scroll

  1. Check get_vegas_content_type() doesn't return 'none'
  2. Verify plugin is not in excluded_plugins list
  3. Ensure plugin is enabled

Content looks wrong in scroll

  1. Verify image height matches display height (32px typical)
  2. Check image mode is 'RGB'
  3. Test with get_vegas_content() returning a simple test image

STATIC mode not pausing

  1. Verify get_vegas_display_mode() returns VegasDisplayMode.STATIC
  2. Check user hasn't overridden with vegas_mode in config
  3. Ensure display() method works correctly

Performance issues

  1. Implement image caching in get_vegas_content()
  2. Pre-render images in update() instead of on-demand
  3. Reduce image dimensions if possible