feat(vegas): live elements -- a plugin API for content that changes while it scrolls

Vegas bakes each plugin's pictures into one strip, so a card already on its
way across the panel keeps what it showed when it was drawn. This adds the
API and bookkeeping for content that can be updated in place; the worker
that redraws and swaps it follows separately. No shipped plugin implements
the hook yet, so nothing changes for users.

Plugin API (core 3.8.0), all no-ops by default:
- BasePlugin.get_vegas_elements() -> [VegasElement(key, image, version,
  live, refresh_hz)]: named, fixed-width pieces of Vegas content.
- BasePlugin.redraw_vegas_element(key, width, height, at): a lock-free
  redraw for content that changes with time.
- BasePlugin.notify_vegas_data_changed(): data that lands outside update().
- src/plugin_system/vegas_elements.py (VegasElement, re-exported from
  base_plugin).

Core:
- PluginAdapter asks a plugin that implements the hook for elements on the
  background fetch only (under its lock, on its own canvas); every other
  path keeps get_vegas_content(). Live elements are pinned (padded with
  content_padding, never trimmed), tagged with their key, digest and data
  epoch in Image.info so the existing cache and group plumbing carry them
  unchanged, and untagged if a width budget crops them.
- RenderPipeline records where each live element lands (ElementRecord), in
  absolute strip columns a trim does not move; the block-start arithmetic
  is shared with the STATIC markers.
- PluginManager update listeners (add/remove_update_listener,
  notify_data_changed): told the moment update() completes, not at the
  next ~4s Vegas poll. The coordinator uses one to move each plugin's data
  epoch on.
- vegas_scroll.live_refresh (kill switch), live_max_hz, live_min_interval,
  live_lead_screens; per-plugin core-owned vegas_live. Live elements are
  off under multi-display sync, in swap mode and with offscreen_prefetch off.
- scripts/check_plugin.py checks the element contract
  (src/plugin_system/testing/vegas.py); test/fixtures/plugins/vegas-live-stub
  is a working example.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-09-30 18:36:39 -04:00
co-authored by Claude Opus 5.5
parent 1160eb5efe
commit 701c220ad0
25 changed files with 2578 additions and 31 deletions
@@ -0,0 +1,39 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"enabled": {
"type": "boolean",
"default": false,
"description": "Enable the stub (test fixture only)."
},
"cards": {
"type": "integer",
"minimum": 0,
"maximum": 32,
"default": 6,
"description": "How many keyed cards to return."
},
"card_width": {
"type": "integer",
"minimum": 0,
"maximum": 512,
"default": 0,
"description": "Card width in px; 0 sizes cards from the render width."
},
"map_hz": {
"type": "number",
"minimum": 0,
"maximum": 30,
"default": 4,
"description": "refresh_hz of the full-width animated element; 0 leaves it out."
},
"dot_speed": {
"type": "number",
"minimum": 0,
"maximum": 200,
"default": 20,
"description": "How fast the animated element's dot moves, in px per second."
}
}
}
+134
View File
@@ -0,0 +1,134 @@
"""
Vegas live-element stub.
A fixture, not a product: it exercises every part of the live-element contract
(src/plugin_system/vegas_elements.py) with content whose changes are easy to
see and to assert on, and nothing else -- no fonts, no network.
- ``card:<n>`` -- fixed-width cards. Each draws the bits of ``tick + n`` as
lit bars, so every update() changes every card's pixels but never its width.
- ``sep`` -- a separator, ``live=False``: placed and trimmed like plain content.
- ``map`` -- one full-render-width element with ``refresh_hz``: a dot that
moves across it with time, drawn by redraw_vegas_element() from state
published in a single attribute store, so it is safe to call without the
plugin's lock.
update() only advances the tick. display() draws the tick's bars full screen
so the plugin also passes the ordinary rendering harness.
"""
import time
from typing import List, Optional, Tuple
from PIL import Image, ImageDraw
from src.plugin_system.base_plugin import BasePlugin
try:
from src.plugin_system.vegas_elements import VegasElement
except ImportError: # core older than 3.8.0: the hooks are never called
VegasElement = None
_COLOURS = [(255, 64, 64), (64, 255, 64), (64, 128, 255), (255, 200, 0),
(255, 64, 255), (0, 220, 220)]
class VegasLiveStub(BasePlugin):
"""Keyed cards, a separator and an animated element for the Vegas ticker."""
def __init__(self, plugin_id, config, display_manager, cache_manager, plugin_manager):
super().__init__(plugin_id, config, display_manager, cache_manager, plugin_manager)
self.tick = 0
# Everything the lock-free redraw reads, published in one store.
self._snapshot: Tuple[int, float] = (0, time.monotonic())
# -- data -------------------------------------------------------------
def update(self) -> None:
self.tick += 1
self._snapshot = (self.tick, time.monotonic())
# -- drawing ----------------------------------------------------------
def _bars(self, image: Image.Image, value: int, colour, box) -> None:
x0, y0, x1, y1 = box
draw = ImageDraw.Draw(image)
draw.rectangle([x0, y0, x1, y1], outline=colour)
bits = 8
span = max(1, (x1 - x0 - 2) // bits)
for bit in range(bits):
if value >> bit & 1:
left = x0 + 1 + bit * span
draw.rectangle([left, y0 + 2, left + max(0, span - 2), y1 - 2],
fill=colour)
def _card_width(self) -> int:
configured = int(self.config.get('card_width', 0) or 0)
if configured > 0:
return configured
return max(24, min(64, self.get_vegas_render_width() // 4))
def _card(self, index: int, tick: int) -> Image.Image:
width, height = self._card_width(), self.display_manager.height
image = Image.new('RGB', (width, height), (0, 0, 0))
self._bars(image, tick + index, _COLOURS[index % len(_COLOURS)],
(0, 0, width - 1, height - 1))
return image
def _map(self, width: int, height: int, at: float) -> Image.Image:
tick, _published = self._snapshot
image = Image.new('RGB', (width, height), (0, 0, 16))
draw = ImageDraw.Draw(image)
draw.rectangle([0, 0, width - 1, height - 1], outline=(40, 40, 80))
speed = float(self.config.get('dot_speed', 20) or 0)
x = int(at * speed) % max(1, width - 4) + 2
y = 2 + tick % max(1, height - 4)
draw.rectangle([x - 1, y - 1, x + 1, y + 1], fill=(255, 255, 255))
return image
def _dot_column(self, width: int, at: float) -> int:
speed = float(self.config.get('dot_speed', 20) or 0)
return int(at * speed) % max(1, width - 4) + 2
def display(self, force_clear: bool = False) -> bool:
width, height = self.display_manager.width, self.display_manager.height
self.display_manager.clear()
self._bars(self.display_manager.image, self.tick, _COLOURS[0],
(0, 0, width - 1, height - 1))
self.display_manager.update_display()
return True
# -- Vegas ------------------------------------------------------------
def get_vegas_content(self) -> Optional[List[Image.Image]]:
cards = int(self.config.get('cards', 6))
return [self._card(i, self.tick) for i in range(cards)] or None
def get_vegas_elements(self):
if VegasElement is None:
return None
tick = self.tick
elements = []
for i in range(int(self.config.get('cards', 6))):
elements.append(VegasElement(
key=f"card:{i}", image=self._card(i, tick),
version=(tick, i, self._card_width())))
if i == 0:
separator = Image.new('RGB', (4, self.display_manager.height), (0, 0, 0))
ImageDraw.Draw(separator).rectangle(
[1, 0, 2, self.display_manager.height - 1], fill=(90, 90, 90))
elements.append(VegasElement(key="sep", image=separator, live=False))
hz = float(self.config.get('map_hz', 4) or 0)
if hz > 0:
width, height = self.get_vegas_render_width(), self.display_manager.height
now = time.monotonic()
elements.append(VegasElement(
key="map", image=self._map(width, height, now),
version=(tick, width, self._dot_column(width, now)),
refresh_hz=hz))
return elements
def redraw_vegas_element(self, key, width, height, at):
if key != "map":
return None
return self._map(width, height, at)
+13
View File
@@ -0,0 +1,13 @@
{
"id": "vegas-live-stub",
"name": "Vegas Live Stub",
"version": "1.0.0",
"description": "Test fixture for live Vegas elements: keyed cards whose content changes on every update, a separator, and a full-width element that animates with time. Drives the live-element tests and the hardware soaks. Not installable from the store and never shipped to devices.",
"author": "LEDMatrix",
"entry_point": "manager.py",
"class_name": "VegasLiveStub",
"display_modes": ["vegas-live-stub"],
"update_interval": 2,
"min_ledmatrix_version": "2.0.0",
"compatible_versions": [">=2.0.0"]
}
+180
View File
@@ -0,0 +1,180 @@
"""The offline live-element checks (src/plugin_system/testing/vegas.py).
Run against the stub fixture plugin, which honours the contract, and against
small broken plugins, each breaking one clause of it.
"""
import sys
from pathlib import Path
from PIL import Image
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from src.plugin_system.base_plugin import BasePlugin # noqa: E402
from src.plugin_system.testing.harness import _instantiate # noqa: E402
from src.plugin_system.testing.loading import build_full_config, load_harness_spec, load_manifest # noqa: E402
from src.plugin_system.testing.vegas import ( # noqa: E402
check_vegas_elements, implements_vegas_elements, render_vegas_elements,
)
from src.plugin_system.testing.visual_display_manager import VisualTestDisplayManager # noqa: E402
from src.plugin_system.vegas_elements import VegasElement # noqa: E402
STUB = Path(__file__).resolve().parent / "fixtures" / "plugins" / "vegas-live-stub"
W, H = 192, 48
def _stub(**config):
dm = VisualTestDisplayManager(width=W, height=H)
full = {**build_full_config(STUB, load_harness_spec(STUB), {}), **config}
plugin = _instantiate("vegas-live-stub", load_manifest(STUB), STUB, full, {}, dm)
return plugin, dm
class _Broken(BasePlugin):
"""A plugin whose get_vegas_elements returns whatever it is given."""
def __init__(self, dm, result, redraw=None):
self.plugin_id = "broken"
self.config = {}
self.display_manager = dm
self.plugin_manager = None
self._result = result
self._redraw = redraw
def update(self):
pass
def display(self, force_clear=False):
pass
def get_vegas_elements(self):
return self._result() if callable(self._result) else self._result
def redraw_vegas_element(self, key, width, height, at):
return self._redraw(width, height) if self._redraw else None
def _img(w, h=H):
return Image.new("RGB", (w, h), (255, 0, 0))
def test_the_stub_passes_every_check():
plugin, dm = _stub()
report = check_vegas_elements(plugin, dm)
assert report.implemented and report.ok, report.errors
assert report.elements == 8 and report.live == 7
assert not report.warnings, report.warnings
def test_the_stub_renders_at_the_width_it_is_given():
plugin, dm = _stub()
elements = render_vegas_elements(plugin, dm, width=96)
by_key = {e.key: e for e in elements}
assert by_key["map"].image.size == (96, H)
assert by_key["card:0"].image.width == 24
assert plugin.get_vegas_render_width() == W # restored afterwards
def test_the_stubs_cards_change_on_update_but_keep_their_width():
plugin, dm = _stub()
before = {e.key: e for e in render_vegas_elements(plugin, dm)}
plugin.update()
after = {e.key: e for e in render_vegas_elements(plugin, dm)}
for key in ("card:0", "card:3"):
assert after[key].version != before[key].version
assert after[key].image.size == before[key].image.size
assert after[key].image.tobytes() != before[key].image.tobytes()
def test_a_plugin_without_the_hook_is_not_checked():
class Plain(BasePlugin):
def update(self):
pass
def display(self, force_clear=False):
pass
plugin = Plain.__new__(Plain)
dm = VisualTestDisplayManager(width=W, height=H)
report = check_vegas_elements(plugin, dm)
assert not implements_vegas_elements(plugin)
assert not report.implemented and report.ok
def _errors(result, redraw=None):
dm = VisualTestDisplayManager(width=W, height=H)
return check_vegas_elements(_Broken(dm, result, redraw), dm)
def test_each_broken_clause_is_an_error():
assert "expected a list" in _errors("x").errors[0]
assert "not a VegasElement" in _errors([object()]).errors[0]
assert "twice" in _errors([VegasElement("k", _img(10)),
VegasElement("k", _img(10))]).errors[0]
assert "tall" in _errors([VegasElement("k", _img(10, H + 1))]).errors[0]
assert "no key" in _errors([VegasElement("", _img(10))]).errors[0]
def test_a_width_that_changes_with_nothing_new_is_an_error():
widths = iter([10, 12, 10, 10])
report = _errors(lambda: [VegasElement("k", _img(next(widths)))])
assert any("changed width" in e for e in report.errors)
def test_a_redraw_of_the_wrong_size_is_an_error():
report = _errors([VegasElement("m", _img(40), refresh_hz=2)],
redraw=lambda w, h: _img(w + 1, h))
assert any("asked for 40x48" in e for e in report.errors)
def test_animation_without_a_redraw_is_a_warning():
dm = VisualTestDisplayManager(width=W, height=H)
class NoRedraw(_Broken):
redraw_vegas_element = BasePlugin.redraw_vegas_element
report = check_vegas_elements(
NoRedraw(dm, [VegasElement("m", _img(40), refresh_hz=2)]), dm)
assert report.ok
assert any("not implemented" in w for w in report.warnings)
def test_none_means_legacy_content_and_is_only_a_warning():
report = _errors(None)
assert report.ok and "get_vegas_content" in report.warnings[0]
def test_a_refresh_rate_that_is_not_a_number_is_an_error_not_a_crash():
report = _errors([VegasElement("m", _img(40), refresh_hz="fast")])
assert any("not a number" in e for e in report.errors)
# None is what a plugin passing the dataclass default through gets.
assert _errors([VegasElement("m", _img(40), refresh_hz=None)]).ok
def test_an_empty_image_is_an_error():
assert any("empty" in e for e in _errors([VegasElement("k", _img(0))]).errors)
def test_a_second_call_that_breaks_the_contract_is_an_error_not_a_crash():
answers = iter([[VegasElement("k", _img(10))], "x", "x", "x"])
report = _errors(lambda: next(answers))
assert any("second call" in e for e in report.errors)
answers = iter([[VegasElement("k", _img(10))], [VegasElement("k", "not an image")],
[], []])
report = _errors(lambda: next(answers))
assert any("disappeared" in e for e in report.errors)
def test_check_plugin_reports_a_failing_element_check_and_carries_on(monkeypatch):
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
import check_plugin
def boom(*args, **kwargs):
raise RuntimeError("boom")
monkeypatch.setattr(check_plugin, "check_plugin_vegas_elements", boom)
results = check_plugin.check_one(
"vegas-live-stub", [str(STUB.parent)], [(W, H)], {}, {}, False, None,
False, None, None)
vegas = [r for r in results if r.mode == "vegas elements"]
assert len(vegas) == 1 and "boom" in vegas[0].error
+143
View File
@@ -0,0 +1,143 @@
"""PluginManager's update listeners: told the moment a plugin's data may have changed.
Vegas live elements redraw a plugin when its update() completes. Before these
listeners the only signal was a set drained by the Vegas tick every ~4s; the
listener hears it at once. It is called on the update worker with the
plugin's lock still held, which is why a listener may only hand off.
"""
import os
import sys
import threading
import time
import pytest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from src.plugin_system.base_plugin import BasePlugin # noqa: E402
from src.plugin_system.plugin_manager import PluginManager # noqa: E402
from src.plugin_system.plugin_state import PluginState # noqa: E402
@pytest.fixture
def pm(tmp_path):
manager = PluginManager(plugins_dir=str(tmp_path), config_manager=None,
display_manager=None, cache_manager=None)
yield manager
manager.stop_update_worker()
class _Plugin:
def __init__(self, fail=False):
self.enabled = True
self.fail = fail
self.updates = 0
def update(self):
self.updates += 1
if self.fail:
raise RuntimeError("no data")
def display(self, force_clear=False):
return True
def _install(pm, plugin, plugin_id="p"):
pm.plugins[plugin_id] = plugin
pm._update_interval_cache[plugin_id] = 0.01
pm.state_manager.set_state(plugin_id, PluginState.ENABLED)
return plugin_id
def _wait(predicate, timeout=5.0):
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if predicate():
return True
time.sleep(0.01)
return False
def test_a_completed_update_calls_the_listener_with_the_lock_held(pm):
plugin_id = _install(pm, _Plugin())
heard = []
def listener(pid):
heard.append((pid, pm.get_plugin_lock(pid).locked()))
pm.add_update_listener(listener)
pm.run_scheduled_updates()
assert _wait(lambda: heard)
assert heard[0] == (plugin_id, True)
# The poll still sees it too: the set is filled before listeners run.
assert plugin_id in pm.drain_completed_updates()
def test_a_failed_update_is_not_reported(pm):
_install(pm, _Plugin(fail=True))
heard = []
pm.add_update_listener(heard.append)
pm.run_scheduled_updates()
assert _wait(lambda: pm.plugins["p"].updates == 1)
time.sleep(0.1)
assert heard == []
def test_a_listener_that_raises_does_not_stop_the_others(pm):
heard = []
def broken(_pid):
raise ValueError("listener bug")
pm.add_update_listener(broken)
pm.add_update_listener(heard.append)
pm._note_update_completed("p") # must not raise
assert heard == ["p"]
assert "p" in pm.drain_completed_updates()
def test_adding_twice_calls_once_and_removing_stops_it(pm):
heard = []
pm.add_update_listener(heard.append)
pm.add_update_listener(heard.append)
pm.notify_data_changed("x")
assert heard == ["x"]
pm.remove_update_listener(heard.append)
pm.notify_data_changed("y")
assert heard == ["x"]
def test_notify_data_changed_reaches_listeners_but_not_the_poll(pm):
heard = []
pm.add_update_listener(heard.append)
pm.notify_data_changed("q")
assert heard == ["q"]
assert pm.drain_completed_updates() == []
def test_a_plugin_can_report_data_that_arrived_on_its_own_thread(pm):
class Pushed(BasePlugin):
def update(self):
pass
def display(self, force_clear=False):
pass
plugin = Pushed.__new__(Pushed)
plugin.plugin_id = "pushed"
plugin.plugin_manager = pm
heard = []
pm.add_update_listener(heard.append)
thread = threading.Thread(target=plugin.notify_vegas_data_changed)
thread.start()
thread.join()
assert heard == ["pushed"]
def test_a_bare_manager_has_no_listeners_to_call():
manager = PluginManager.__new__(PluginManager)
manager._completed_updates = set()
manager._completed_updates_lock = threading.Lock()
manager._note_update_completed("p")
manager.add_update_listener(lambda pid: None)
manager.remove_update_listener(lambda pid: None)
+331
View File
@@ -0,0 +1,331 @@
"""Live Vegas elements: the plugin API and how the adapter fetches them.
A plugin opts in by implementing get_vegas_elements(); the adapter then asks
for elements instead of pictures, but only where that is safe (the background
fetch, under the plugin's lock, with live elements switched on) and falls back
to get_vegas_content() everywhere else. What makes an element live rides in
its image's ``info`` so it survives the adapter's cache and the pipeline's
join unchanged. These tests pin all of that.
"""
import dataclasses
import sys
import threading
from contextlib import contextmanager
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock
import numpy as np
import pytest
from PIL import Image, ImageDraw
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from src.plugin_system.base_plugin import BasePlugin # noqa: E402
from src.plugin_system.vegas_elements import VegasElement # noqa: E402
from src.vegas_mode import elements # noqa: E402
from src.vegas_mode.config import VegasModeConfig # noqa: E402
from src.vegas_mode.elements import ElementMeta, LiveEpochs # noqa: E402
from src.vegas_mode.plugin_adapter import PluginAdapter # noqa: E402
W, H = 128, 32
def _card(width=40, colour=(255, 0, 0), height=H):
image = Image.new("RGB", (width, height), (0, 0, 0))
ImageDraw.Draw(image).rectangle([0, 0, width - 1, height - 1], outline=colour)
return image
class _DM:
"""A display manager with the per-thread canvas Vegas renders on."""
def __init__(self):
self.width, self.height = W, H
self.image = Image.new("RGB", (W, H))
self.offscreen_widths = []
@contextmanager
def offscreen(self, width=None, height=None):
self.offscreen_widths.append(width)
yield SimpleNamespace(image=Image.new("RGB", (width or W, H)))
class _Plugin(BasePlugin):
"""A BasePlugin with both the legacy and the element hooks."""
def __init__(self, elements_result=None, config=None):
self.plugin_id = "p"
self.config = config or {}
self.elements_result = elements_result
self.element_calls = 0
self.content_calls = 0
self.render_widths = []
self.plugin_manager = None
def update(self):
pass
def display(self, force_clear=False):
pass
def get_vegas_content(self):
self.content_calls += 1
return [_card(40, (0, 0, 255))]
def get_vegas_elements(self):
self.element_calls += 1
self.render_widths.append(self.get_vegas_render_width())
result = self.elements_result
if isinstance(result, Exception):
raise result
return result() if callable(result) else result
class _Legacy(_Plugin):
get_vegas_elements = BasePlugin.get_vegas_elements
def _adapter(**cfg):
lock = threading.Lock()
pm = SimpleNamespace(get_plugin_lock=lambda pid: lock)
adapter = PluginAdapter(_DM(), VegasModeConfig(**cfg), plugin_manager=pm)
adapter.live_elements_enabled = True
adapter.live_epochs = LiveEpochs()
return adapter
def _elements():
return [VegasElement("card:a", _card(40, (255, 0, 0)), version=1),
VegasElement("sep", _card(10, (90, 90, 90)), live=False),
VegasElement("card:b", _card(40, (0, 255, 0)), version=2)]
# -- the type and the tag -----------------------------------------------------
def test_element_defaults():
element = VegasElement("k", _card())
assert element.live and element.refresh_hz == 0 and element.version is None
with pytest.raises(dataclasses.FrozenInstanceError):
element.key = "other"
def test_a_tag_survives_what_the_plumbing_does_to_an_image():
meta = ElementMeta("p", "k", 3, ((H, 10, 3), 1), 0.0, 0.0)
image = elements.tag(_card(), meta)
for derived in (image.copy(), image.crop((0, 0, 10, H)), image.convert("RGB"),
image.resize((20, H))):
assert elements.meta_of(derived) == meta
assert elements.meta_of(elements.untag(image.copy())) is None
assert elements.meta_of(_card()) is None
assert elements.meta_of(object()) is None
def test_pinning_pads_with_black_and_freezes_the_pixels():
image, array = elements.pin_element(_card(20), 8)
assert image.size == (36, H)
assert array.shape == (H, 36, 3)
assert not array.flags.writeable
assert not array[:, :8].any() and not array[:, -8:].any()
assert np.array_equal(array[:, 8:28], np.asarray(_card(20)))
def test_the_digest_sees_a_one_pixel_change():
_, a = elements.pin_element(_card(20), 0)
b = np.array(a)
b[5, 5, 0] ^= 1
assert elements.pixel_digest(a) != elements.pixel_digest(b)
assert elements.pixel_digest(a) == elements.pixel_digest(np.array(a))
def test_epochs_move_on_per_plugin_and_never_repeat():
epochs = LiveEpochs()
assert epochs.get("a") == 0
first = epochs.bump("a")
second = epochs.bump("b")
assert second > first and epochs.get("a") == first
assert epochs.bump("a") > second
# -- BasePlugin ---------------------------------------------------------------
def test_the_base_hooks_do_nothing():
plugin = _Legacy()
assert plugin.get_vegas_elements() is None
assert plugin.redraw_vegas_element("k", 10, H, 0.0) is None
def test_notify_vegas_data_changed_reaches_the_plugin_manager():
plugin = _Legacy()
plugin.plugin_manager = MagicMock()
plugin.notify_vegas_data_changed()
plugin.plugin_manager.notify_data_changed.assert_called_once_with("p")
plugin.plugin_manager = None
plugin.notify_vegas_data_changed() # no manager: nothing to do
# -- the adapter's keyed path --------------------------------------------------
def test_the_background_fetch_asks_for_elements():
adapter = _adapter()
adapter.live_epochs.bump("p")
plugin = _Plugin(_elements)
images = adapter.get_content(plugin, "p", offscreen_only=True)
assert plugin.element_calls == 1 and plugin.content_calls == 0
metas = [elements.meta_of(img) for img in images]
assert [m.key if m else None for m in metas] == ["card:a", None, "card:b"]
assert metas[0].epoch == adapter.live_epochs.get("p")
assert metas[0].version == 1
def test_live_elements_are_pinned_not_trimmed():
adapter = _adapter(content_padding=8)
plugin = _Plugin(_elements)
images = adapter.get_content(plugin, "p", offscreen_only=True)
live = [img for img in images if elements.meta_of(img)]
assert all(img.width == 40 + 16 for img in live)
# The plain separator is trimmed as always: drawn to its edges, it keeps
# its width (trimming never widens an image).
separator = images[1]
assert elements.meta_of(separator) is None
assert separator.width == 10
def test_an_elements_digest_matches_its_pixels():
adapter = _adapter()
images = adapter.get_content(_Plugin(_elements), "p", offscreen_only=True)
meta = elements.meta_of(images[0])
assert meta.digest == elements.pixel_digest(np.asarray(images[0]))
def test_elements_render_on_a_canvas_of_their_own_at_the_render_width():
adapter = _adapter(render_width_pct=50)
plugin = _Plugin(_elements)
adapter.get_content(plugin, "p", offscreen_only=True)
assert plugin.render_widths == [W // 2]
assert adapter.display_manager.offscreen_widths == [W // 2]
assert plugin.get_vegas_render_width() == W # restored afterwards
@pytest.mark.parametrize("why", ["disabled", "render thread", "restricted",
"plugin opted out", "legacy plugin"])
def test_everywhere_else_the_legacy_content_is_used(why):
cfg = {"offscreen_prefetch": False} if why == "restricted" else {}
adapter = _adapter(**cfg)
plugin_cls = _Legacy if why == "legacy plugin" else _Plugin
plugin = plugin_cls(_elements, config={"vegas_live": False}
if why == "plugin opted out" else None)
if why == "disabled":
adapter.live_elements_enabled = False
images = adapter.get_content(plugin, "p", offscreen_only=(why != "render thread"))
assert plugin.element_calls == 0
if why != "restricted":
assert plugin.content_calls == 1
assert all(elements.meta_of(img) is None for img in images)
def test_a_mock_plugin_is_never_asked_for_elements():
adapter = _adapter()
plugin = MagicMock()
plugin.config = {}
plugin.get_vegas_content.return_value = [_card()]
adapter.get_content(plugin, "p", offscreen_only=True)
plugin.get_vegas_elements.assert_not_called()
@pytest.mark.parametrize("result", [None, RuntimeError("boom"), "nonsense",
[], [object()]])
def test_a_broken_or_empty_answer_falls_back_to_legacy_content(result):
adapter = _adapter()
plugin = _Plugin(result)
images = adapter.get_content(plugin, "p", offscreen_only=True)
assert plugin.content_calls == 1
assert images and all(elements.meta_of(img) is None for img in images)
def test_duplicate_keys_keep_the_first():
adapter = _adapter()
plugin = _Plugin(lambda: [VegasElement("k", _card(40)),
VegasElement("k", _card(30))])
images = adapter.get_content(plugin, "p", offscreen_only=True)
assert len(images) == 1 and images[0].width == 40 + 16
def test_elements_are_brought_to_the_display_height_and_rgb():
adapter = _adapter()
plugin = _Plugin(lambda: [VegasElement("k", _card(40, height=H * 2).convert("RGBA"))])
image = adapter.get_content(plugin, "p", offscreen_only=True)[0]
assert image.mode == "RGB" and image.height == H
assert elements.meta_of(image) is not None
def test_a_keyed_fetch_ignores_legacy_content_in_the_cache():
# The first compose runs on the render thread and caches legacy content;
# the first background fetch after it must still ask for elements.
adapter = _adapter()
plugin = _Plugin(_elements)
adapter.get_content(plugin, "p", offscreen_only=False)
assert plugin.content_calls == 1
images = adapter.get_content(plugin, "p", offscreen_only=True)
assert plugin.element_calls == 1
assert elements.meta_of(images[0]) is not None
def test_keyed_content_is_cached_with_its_tags():
adapter = _adapter()
plugin = _Plugin(_elements)
adapter.get_content(plugin, "p", offscreen_only=True)
again = adapter.get_content(plugin, "p", offscreen_only=True)
assert plugin.element_calls == 1
assert elements.meta_of(again[0]).key == "card:a"
def test_an_element_cropped_to_a_width_budget_is_no_longer_live():
adapter = _adapter(max_plugin_width_ratio=0.5)
wide = Image.new("RGB", (400, H), (255, 255, 255))
plugin = _Plugin(lambda: [VegasElement("map", wide)])
image = adapter.get_content(plugin, "p", offscreen_only=True)[0]
budget = adapter._width_budget(plugin, "p")
assert budget // 2 <= image.width < 400
# A real window of the element, not a cut in the middle of its padding.
assert np.asarray(image).any()
assert elements.meta_of(image) is None
def test_an_element_whose_drawing_fits_the_budget_stays_live():
adapter = _adapter(max_plugin_width_ratio=0.5)
budget = adapter._width_budget(None, "p")
# Over the budget only by the black padding pinned on either side.
plugin = _Plugin(lambda: [VegasElement("card", _card(budget - 4))])
image = adapter.get_content(plugin, "p", offscreen_only=True)[0]
assert image.width > budget
assert elements.meta_of(image).key == "card"
def test_a_padded_solid_image_over_the_budget_is_cropped_not_slivered():
# Legacy content has the same margins after trimming, and the same cut
# used to land mid-margin.
adapter = _adapter(max_plugin_width_ratio=0.5)
budget = adapter._width_budget(None, "p")
solid = Image.new("RGB", (budget * 3, H), (255, 255, 255))
cropped = adapter._apply_width_budget(
[_padded(solid, adapter.config.content_padding)], "p", None)
assert budget // 2 <= cropped[0].width
assert np.asarray(cropped[0]).any()
def _padded(image, pad):
out = Image.new("RGB", (image.width + 2 * pad, image.height))
out.paste(image, (pad, 0))
return out
def test_rows_under_a_width_budget_keep_their_tags():
adapter = _adapter(max_plugin_width_ratio=1.0)
plugin = _Plugin(lambda: [VegasElement(f"c{i}", _card(40)) for i in range(6)])
images = adapter.get_content(plugin, "p", offscreen_only=True)
assert 0 < len(images) < 6
assert all(elements.meta_of(img) is not None for img in images)
+199
View File
@@ -0,0 +1,199 @@
"""Where live elements land in the Vegas strip, and that the records stay true.
The pipeline records each live element's place (ElementRecord) as it builds
the strip, in absolute columns that a trim never moves: the strip column is
``abs_x - _strip_origin``. Everything a live update will do depends on these
being exact, so the check here is the strongest one available -- after any
sequence of compose, extend and trim, the strip's pixels at every record are
the element's own pixels.
"""
import sys
from pathlib import Path
import numpy as np
from PIL import Image, ImageDraw
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from src.vegas_mode import elements # noqa: E402
from src.vegas_mode.config import VegasModeConfig # noqa: E402
from src.vegas_mode.elements import ElementMeta # noqa: E402
from src.vegas_mode.render_pipeline import RenderPipeline # noqa: E402
W, H = 128, 32
def _live(pid, key, width, seed):
"""A tagged, pinned element as the adapter hands it over."""
rng = np.random.default_rng(seed)
pixels = rng.integers(20, 255, (H, width, 3), dtype=np.uint8)
image = Image.frombytes("RGB", (width, H), pixels.tobytes())
pinned, array = elements.pin_element(image, 8)
return elements.tag(pinned, ElementMeta(
pid, key, 1, elements.pixel_digest(array), 0.0, 0.0))
def _plain(width, colour=(200, 200, 200)):
image = Image.new("RGB", (width, H), (0, 0, 0))
ImageDraw.Draw(image).rectangle([0, 0, width - 1, H - 1], outline=colour)
return image
class _Stream:
def __init__(self, groups):
self.groups = groups
self.plugin_manager = type("PM", (), {"plugins": {}})()
self.plugin_adapter = None
self.i = 0
def get_grouped_content_for_composition(self):
return self.groups[0]
def get_active_plugin_ids(self):
return [pid for pid, _ in self.groups[0]]
def take_next_group(self, count=None, offscreen_only=False):
self.i += 1
return self.groups[self.i] if self.i < len(self.groups) else []
class _DM:
width, height = W, H
def __init__(self):
self.image = Image.new("RGB", (W, H))
def set_scrolling_state(self, *a):
pass
def update_display(self):
pass
def _pipeline(groups, **cfg):
cfg.setdefault("continuous_scroll", True)
return RenderPipeline(VegasModeConfig(**cfg), _DM(), _Stream(groups))
def _assert_records_match(p, images_by_key):
strip = p.scroll_helper.cached_array
for record in p.live_records():
x = record.abs_x - p._strip_origin
expected = np.asarray(images_by_key[record.key])
assert expected.shape[1] == record.width
lo = max(0, x)
got = strip[:, lo:x + record.width]
assert np.array_equal(got, expected[:, lo - x:]), record.key
def _group_images(groups):
found = {}
for group in groups:
for _pid, images in group:
for image in images:
meta = elements.meta_of(image)
if meta:
found[meta.key] = image
return found
def test_a_compose_records_every_live_element_exactly():
groups = [[("a", [_live("a", "a1", 40, 1), _plain(20), _live("a", "a2", 50, 2)]),
("b", [_plain(60)]),
("c", [_live("c", "c1", 70, 3)])]]
p = _pipeline(groups, lead_in_width=10)
assert p.compose_scroll_content()
keys = [r.key for r in p.live_records()]
assert keys == ["a1", "a2", "c1"]
assert all(r.width == 16 + {"a1": 40, "a2": 50, "c1": 70}[r.key]
for r in p.live_records())
_assert_records_match(p, _group_images(groups))
def test_extensions_and_trims_keep_every_record_true():
# A strip long enough that the viewport never runs off its end (trimming
# is refused while it wraps), advanced by less than each extension adds.
groups = [[("a", [_live("a", "a1", 40, 1), _plain(500)])]]
for n in range(12):
groups.append([(f"p{n}", [_live(f"p{n}", f"k{n}", 30 + n, 10 + n),
_plain(25)]),
(f"q{n}", [_plain(45)])])
p = _pipeline(groups, lead_in_width=0)
assert p.compose_scroll_content()
images = _group_images(groups)
cuts = 0
for _ in range(11):
width_before = p.scroll_helper.cached_array.shape[1]
p.scroll_helper.scroll_position += 150
assert p.scroll_helper.scroll_position + W <= width_before
position = p.scroll_helper.scroll_position
assert p.extend_scroll_content()
cut = int(position - p.scroll_helper.scroll_position)
assert 0 <= cut <= width_before
cuts += cut
assert p._strip_origin == cuts
_assert_records_match(p, images)
# Records wholly trimmed away are forgotten, and only those.
for record in p.live_records():
assert record.abs_x + record.width > p._strip_origin
assert set(p._record_by_seq) == {r.seq for r in p.live_records()}
assert cuts > 0
def test_the_first_extension_of_an_empty_strip_starts_at_zero():
groups = [[], [("a", [_live("a", "a1", 40, 1)]), ("b", [_live("b", "b1", 20, 2)])]]
p = _pipeline(groups)
assert p.extend_scroll_content()
first = p.live_records()[0]
assert first.abs_x == 0 and p._strip_origin == 0
_assert_records_match(p, _group_images(groups))
def test_plain_content_is_not_recorded():
p = _pipeline([[("a", [_plain(80)])], [("b", [_plain(90)])]])
assert p.compose_scroll_content()
assert p.extend_scroll_content()
assert p.live_records() == ()
def test_a_new_strip_starts_a_new_generation_with_no_records():
groups = [[("a", [_live("a", "a1", 40, 1)])]]
p = _pipeline(groups)
assert p.compose_scroll_content()
gen = p._strip_gen
assert p.live_records()
assert p.compose_scroll_content()
assert p._strip_gen == gen + 1
assert [r.key for r in p.live_records()] == ["a1"]
p.reset()
assert p._strip_gen == gen + 2
assert p.live_records() == () and p._strip_origin == 0
def test_record_numbers_are_never_reused():
groups = [[("a", [_live("a", "a1", 40, 1)])]]
p = _pipeline(groups)
seen = set()
for _ in range(3):
assert p.compose_scroll_content()
for record in p.live_records():
assert record.seq not in seen
seen.add(record.seq)
def test_static_markers_still_land_where_they_did():
# The marker arithmetic now shares _block_starts with the records.
class Stream(_Stream):
def is_static_plugin(self, pid):
return pid == "pause"
groups = [[("a", [_plain(100)])],
[("b", [_plain(60)]), ("pause", []), ("c", [_plain(70)])]]
p = RenderPipeline(VegasModeConfig(continuous_scroll=True, lead_in_width=0,
separator_width=32),
_DM(), Stream(groups))
assert p.compose_scroll_content()
strip_end = p.scroll_helper.total_scroll_width
assert p.extend_scroll_content()
# b starts after one separator and ends 60 later; the pause follows b.
assert p._static_markers == ((strip_end + 32 + 60, "pause"),)
+137
View File
@@ -0,0 +1,137 @@
"""When Vegas live elements are on, and that everywhere else nothing changes.
Live elements run only when the config allows them and nothing rules them
out: multi-display sync (the follower mirrors whole strips only), swap mode,
the offscreen kill switch, or a display manager with no off-screen canvas.
While they are on, the coordinator listens for plugin updates and moves each
plugin's data epoch on; while off, the adapter never asks for elements.
"""
import sys
from contextlib import contextmanager
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
from PIL import Image
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from src.vegas_mode.coordinator import VegasModeCoordinator # noqa: E402
class _DM:
width, height = 128, 32
def __init__(self, offscreen=True):
self.image = Image.new("RGB", (128, 32))
if offscreen:
self.offscreen = self._offscreen
@contextmanager
def _offscreen(self, width=None, height=None):
yield SimpleNamespace(image=Image.new("RGB", (width or 128, 32)))
def set_scrolling_state(self, *a, **k):
pass
def update_display(self):
pass
class _PM:
def __init__(self):
self.plugins = {}
self.listeners = []
def add_update_listener(self, fn):
if fn not in self.listeners:
self.listeners.append(fn)
def remove_update_listener(self, fn):
self.listeners = [f for f in self.listeners if f != fn]
def _coordinator(dm=None, **vegas):
vegas.setdefault("enabled", True)
config = {"display": {"vegas_scroll": vegas}}
return VegasModeCoordinator(config, dm or _DM(), _PM())
def test_on_by_default():
c = _coordinator()
c._apply_live_state()
assert c.live_active
assert c.plugin_adapter.live_elements_enabled
assert c._on_plugin_data_changed in c.plugin_manager.listeners
@pytest.mark.parametrize("why,vegas,dm", [
("switched off", {"live_refresh": False}, None),
("swap mode", {"continuous_scroll": False}, None),
("offscreen kill switch", {"offscreen_prefetch": False}, None),
("no offscreen canvas", {}, _DM(offscreen=False)),
])
def test_off_when_ruled_out(why, vegas, dm):
c = _coordinator(dm, **vegas)
c._apply_live_state()
assert not c.live_active
assert not c.plugin_adapter.live_elements_enabled
assert c.plugin_manager.listeners == []
@pytest.mark.parametrize("role", ["leader", "follower"])
def test_off_whenever_sync_is_configured(role):
c = _coordinator()
c.set_sync_manager(SimpleNamespace(role=role))
c._apply_live_state()
assert not c.live_active
assert not c.plugin_adapter.live_elements_enabled
def test_a_standalone_sync_manager_does_not_count():
from src.common.sync_manager import SyncRole
c = _coordinator()
c.set_sync_manager(SimpleNamespace(role=SyncRole.STANDALONE))
c._apply_live_state()
assert c.live_active
def test_stopping_switches_it_off_and_stops_listening():
c = _coordinator()
c._apply_live_state()
c._is_active = True
c.stop()
assert not c.live_active and not c.plugin_adapter.live_elements_enabled
assert c.plugin_manager.listeners == []
def test_a_config_change_can_switch_it_off_mid_run():
c = _coordinator()
c._apply_live_state()
c._is_active = True
c.stream_manager.refresh = MagicMock()
c.update_config({"display": {"vegas_scroll": {"enabled": True,
"live_refresh": False}}})
c._apply_pending_config()
assert not c.live_active
assert c.plugin_manager.listeners == []
def test_an_update_moves_the_plugins_epoch_on():
c = _coordinator()
c._apply_live_state()
before = c.live_epochs.get("p")
for listener in c.plugin_manager.listeners:
listener("p")
assert c.live_epochs.get("p") > before
assert c.plugin_adapter.live_epochs is c.live_epochs
def test_the_state_change_is_logged_once(caplog):
c = _coordinator(live_refresh=False)
with caplog.at_level("INFO"):
c._apply_live_state()
c._apply_live_state()
lines = [r.message for r in caplog.records if "live elements" in r.message]
assert lines == ["Vegas live elements off: switched off (vegas_scroll.live_refresh)"]
+137
View File
@@ -0,0 +1,137 @@
"""Live Vegas elements end to end: a real plugin through the real ticker.
The stub fixture plugin (test/fixtures/plugins/vegas-live-stub) is loaded by
the real plugin loader onto a real DisplayManager (RGBMatrixEmulator) and a
real PluginManager, and the real coordinator runs it: the first compose uses
its ordinary Vegas content, the background prefetch asks it for elements, and
the strip records where each one landed.
"""
import os
import sys
import time
from pathlib import Path
os.environ["EMULATOR"] = "true"
import numpy as np # noqa: E402
import pytest # noqa: E402
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from src.plugin_system.plugin_state import PluginState # noqa: E402
from src.plugin_system.testing.harness import _instantiate # noqa: E402
from src.plugin_system.testing.loading import build_full_config, load_harness_spec, load_manifest # noqa: E402
from src.vegas_mode import elements # noqa: E402
STUB = Path(__file__).resolve().parent / "fixtures" / "plugins" / "vegas-live-stub"
PID = "vegas-live-stub"
@pytest.fixture(scope="module")
def dm(tmp_path_factory):
from src.display_manager import DisplayManager
DisplayManager._instance = None
DisplayManager._initialized = False
manager = DisplayManager({
"display": {
"hardware": {"rows": 32, "cols": 64, "chain_length": 2,
"parallel": 1, "brightness": 90},
"runtime": {"gpio_slowdown": 0},
},
}, suppress_test_pattern=True)
manager._snapshot_path = str(
tmp_path_factory.mktemp("live") / "led_matrix_preview.png")
if manager.matrix is None:
pytest.fail("DisplayManager fell back to matrix=None")
yield manager
DisplayManager._instance = None
DisplayManager._initialized = False
@pytest.fixture
def ticker(dm, tmp_path):
from src.plugin_system.plugin_manager import PluginManager
from src.vegas_mode.coordinator import VegasModeCoordinator
pm = PluginManager(plugins_dir=str(tmp_path), config_manager=None,
display_manager=dm, cache_manager=None)
config = {**build_full_config(STUB, load_harness_spec(STUB), {}),
"enabled": True, "map_hz": 4}
plugin = _instantiate(PID, load_manifest(STUB), STUB, config, {}, dm)
plugin.plugin_manager = pm
pm.plugins[PID] = plugin
pm.state_manager.set_state(PID, PluginState.ENABLED)
coordinator = VegasModeCoordinator({"display": {"vegas_scroll": {
"enabled": True, "continuous_scroll": True, "plugins_per_cycle": 1,
"scroll_speed": 100, "lead_in_width": 0,
}}}, dm, pm)
yield coordinator, plugin, pm
coordinator.stop()
pm.stop_update_worker()
def _run_until(coordinator, predicate, seconds=10.0):
deadline = time.monotonic() + seconds
while time.monotonic() < deadline:
coordinator.run_frame()
if predicate():
return True
time.sleep(0.002)
return False
def test_the_prefetched_stub_is_placed_as_live_elements(ticker):
coordinator, plugin, _pm = ticker
assert coordinator.start()
assert coordinator.live_active
pipeline = coordinator.render_pipeline
# The first compose ran on this thread without the plugin's lock, so it
# is plain content.
assert pipeline.live_records() == ()
assert _run_until(coordinator, lambda: pipeline.live_records())
keys = [r.key for r in pipeline.live_records()]
assert {"card:0", "card:5", "map"} <= set(keys)
assert "sep" not in keys
assert all(r.plugin_id == PID for r in pipeline.live_records())
# Each record points at the element's pixels: a card is bordered in its
# colour, with content_padding black columns either side.
strip = pipeline.scroll_helper.cached_array
pad = coordinator.vegas_config.content_padding
for record in pipeline.live_records():
x = record.abs_x - pipeline._strip_origin
if x < 0:
continue
columns = strip[:, x:x + record.width]
assert not columns[:, :pad].any() and not columns[:, -pad:].any()
assert columns[:, pad:record.width - pad].any()
def test_an_update_moves_the_plugins_epoch_and_new_elements_carry_it(ticker):
coordinator, plugin, pm = ticker
assert coordinator.start()
before = coordinator.live_epochs.get(PID)
pm._note_update_completed(PID)
epoch = coordinator.live_epochs.get(PID)
assert epoch > before
coordinator.plugin_adapter.invalidate_cache(PID)
images = coordinator.plugin_adapter.get_content(plugin, PID, offscreen_only=True)
metas = [elements.meta_of(img) for img in images if elements.meta_of(img)]
assert metas and all(m.epoch == epoch for m in metas)
def test_with_live_refresh_off_the_same_run_is_plain_content(dm, tmp_path, ticker):
coordinator, _plugin, _pm = ticker
coordinator.vegas_config.live_refresh = False
assert coordinator.start()
assert not coordinator.live_active
pipeline = coordinator.render_pipeline
start_width = pipeline.scroll_helper.total_scroll_width
assert _run_until(
coordinator,
lambda: pipeline.stats.get('extensions', 0) >= 1, seconds=10.0)
assert pipeline.live_records() == ()
assert np.asarray(pipeline.scroll_helper.cached_array).shape[1] > 0
assert start_width > 0