6 Commits

Author SHA1 Message Date
Chuck
aae95a1015 refactor(api): resolve sudo/systemctl/reboot/poweroff paths at startup
Use shutil.which() with safe fallbacks for the four privileged binaries
instead of relying on bare names being resolved by the subprocess shell
search. Resolves paths once at module load rather than per-call.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 14:49:34 -04:00
Chuck
246ea54635 fix: address five review findings (Pillow CVEs, daemon exception narrowing, timeout handling, plugin store)
- march-madness/requirements.txt: Pillow>=12.2.0 (patches CVE-2026-42308
  and CVE-2026-42310; previous floor of 10.3.0 was insufficient)

- wifi_monitor_daemon: narrow final except Exception to
  (subprocess.SubprocessError, OSError) so programming errors in the NM
  restart block are no longer silently swallowed

- api_v3/execute_system_action: add explicit subprocess.TimeoutExpired
  handler before the generic Exception catch; returns action-specific
  message with 'status','message','returncode','stdout','stderr' fields
  so the UI receives a precise, actionable payload instead of the generic
  'Failed to execute system action' string

- plugins_manager.js: move searchPluginStore into .finally() so the
  plugin store renders regardless of whether loadInstalledPlugins succeeds
  or fails; .catch() still logs the error

- first_time_install.sh: add safe_plugin_rm.sh NOPASSWD rule to the
  /tmp/ledmatrix_web_sudoers block; configure_web_sudo.sh had this rule
  but the standalone installer never granted it, leaving plugin removal
  broken after first-time install

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 13:56:24 -04:00
Chuck
a0f957be9e fix: address five review findings (NM retry loop, start_display message, code quality)
- wifi_monitor_daemon: reset _consecutive_internet_failures = 0 in both
  NM-restart exception handlers; previously both left the counter at threshold,
  causing an immediate retry on the next iteration instead of waiting another
  full backoff period

- api_v3: fix start_display failure message — when mode is set and systemctl
  returns non-zero, message now includes the failure reason and a hint rather
  than always reporting success phrasing

- wifi_manager: move _redirect_backend from class variable to instance variable
  in __init__ alongside _ap_enabled_at; class-level default shadowed correctly
  in practice (single instance) but was misleading

- wifi_manager: narrow broad except Exception in _check_internet_connectivity
  to (subprocess.SubprocessError, OSError) for ping and OSError for HTTP
  (urllib.error.URLError is an OSError subclass in Python 3)

- wifi_manager: remove redundant local 'import re as _re' in _validate_ap_config;
  re is already imported at module level (line 37)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 13:26:51 -04:00
Chuck
76cd010aab fix: address five valid review findings; skip two
Fixed:
- march-madness/requirements.txt: Pillow>=10.3.0 (patches CVE-2024-28219;
  10.3.0 is the actual fix version — reviewer cited 12.2.0 but that risks
  breaking API changes without test coverage)
- wifi_monitor_daemon.py: add missing `import subprocess`; subprocess.run
  and CalledProcessError would NameError at runtime on the NM restart path
- wifi_manager.py: validate ap_idle_timeout_minutes before arithmetic —
  coerce to int, clamp 1–1440, fall back to 15 on bad config values
- wifi_manager.py: call _remove_nm_dnsmasq_captive_conf() on all three
  rollback paths in _enable_ap_mode_nmcli_hotspot() and in the top-level
  except block so stale dnsmasq drop-ins are never left behind
- api_v3.py: fix wrong_password prefix strip — removeprefix("wrong_password:")
  then lstrip() handles both "wrong_password: msg" and "wrong_password:msg"
- plugins_manager.js: add .catch() to loadInstalledPlugins().then() to
  surface failures instead of silently dropping unhandled rejections

Skipped:
- WiFiManager AP state persistence: architectural overhaul; _is_ap_mode_active()
  already derives from live system state, not in-memory variables
- Absolute subprocess paths in api_v3.py: paths vary by distro (/usr/bin vs
  /bin); web service has a normal PATH; sudoers already use resolved paths

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 13:26:51 -04:00
Chuck
587daa780e revert: restore AP-mode grace period to 90s (3 checks)
The counter reset after NM restart already fully prevents the SSH-lockout
cascade: _disconnected_checks can never accumulate across NM restarts
because it is reset to 0 before the next daemon iteration runs.

The 3→6 increase provided no additional fix for the described problem and
caused a UX regression: fresh Pi devices with no WiFi configured would
wait 3 minutes instead of 90 seconds for the LEDMatrix-Setup hotspot to
appear.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 13:26:31 -04:00
Chuck
c19df29a21 fix: service control buttons and AP-mode SSH lockout post-install
Two user-reported issues after fresh install:

1. All service buttons (Start/Stop/Restart Display, Restart Web Service)
   failed silently — only Reboot worked.

   Root cause: sudoers rules use `ledmatrix.service` (with suffix) but
   api_v3.py called `sudo systemctl start ledmatrix` (no suffix). sudo
   does exact string matching, so every service action was rejected with
   returncode=1. Also missing from sudoers: ledmatrix-web, journalctl,
   and is-active entries.

   Fix:
   - Add `.service` suffix to all 8 sudo systemctl call sites in
     api_v3.py (_ensure_display_service_running, _stop_display_service,
     and all execute_system_action branches).
   - Add timeout=15 to all subprocess.run calls in execute_system_action
     (previously could hang indefinitely).
   - Add missing sudoers rules to first_time_install.sh and
     configure_web_sudo.sh: ledmatrix-web.service start/stop/restart,
     is-active for both name forms, and journalctl -u/-t ledmatrix rules.

2. SSH and web UI became inaccessible after ~1 hour even though the
   display kept running.

   Root cause: wifi_monitor_daemon restarts NetworkManager after 5
   consecutive internet failures (~2.5 min). Each NM restart drops WiFi
   briefly. During that window check_and_manage_ap_mode() increments
   _disconnected_checks but the daemon never reset it after the restart.
   After 3 such NM-restart cycles, _disconnected_checks reached 3 and
   AP mode activated — changing the Pi from WiFi client to hotspot
   (192.168.4.1) and killing SSH on the old IP.

   Fix:
   - Reset wifi_manager._disconnected_checks = 0 in the daemon
     immediately after a successful NM restart so the brief drop it
     causes doesn't count toward AP-mode activation.
   - Increase _disconnected_checks_required from 3 to 6 (90s → 3min)
     as an additional buffer against transient network flaps.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 13:26:31 -04:00
15 changed files with 2774 additions and 2497 deletions

View File

@@ -126,11 +126,6 @@
"buffer_ahead": 2 "buffer_ahead": 2
} }
}, },
"sync": {
"role": "standalone",
"port": 5765,
"follower_position": "left"
},
"plugin_system": { "plugin_system": {
"plugins_directory": "plugin-repos", "plugins_directory": "plugin-repos",
"auto_discover": true, "auto_discover": true,

View File

@@ -118,7 +118,7 @@ total_count=${#ARCHITECTURES[@]}
for arch in "${!ARCHITECTURES[@]}"; do for arch in "${!ARCHITECTURES[@]}"; do
if download_binary "$arch" "${ARCHITECTURES[$arch]}"; then if download_binary "$arch" "${ARCHITECTURES[$arch]}"; then
success_count=$((success_count + 1)) ((success_count++))
fi fi
done done

View File

@@ -193,21 +193,19 @@ class CacheStrategy:
Data type string for strategy lookup Data type string for strategy lookup
""" """
key_lower = key.lower() key_lower = key.lower()
# Odds data — checked before the generic 'live' block below because # Odds data — checked FIRST because odds keys may also contain 'live'/'current'
# live-odds cache keys (e.g. odds_espn_basketball_nba_<id>_live) contain # (e.g. odds_espn_nba_game_123_live). The odds TTL (120s for live, 1800s for
# both 'odds' AND 'live'. Without this ordering the 'live' check below # upcoming) must win over the generic sports_live TTL (30s) to avoid hitting
# would match first and return 'sports_live' (30 s TTL) instead of the # the ESPN odds API every 30 seconds per game.
# correct 'odds_live' (120 s TTL).
if 'odds' in key_lower: if 'odds' in key_lower:
# For live games, use shorter cache; for upcoming games, use longer cache
if any(x in key_lower for x in ['live', 'current']): if any(x in key_lower for x in ['live', 'current']):
return 'odds_live' # Live odds change more frequently return 'odds_live' # Live odds change more frequently (120s TTL)
return 'odds' # Regular odds for upcoming games return 'odds' # Regular odds for upcoming games (1800s TTL)
# Live sports data # Live sports data (only reached if key does NOT contain 'odds')
if any(x in key_lower for x in ['live', 'current', 'scoreboard']): if any(x in key_lower for x in ['live', 'current', 'scoreboard']):
if 'soccer' in key_lower:
return 'sports_live' # Soccer live data is very time-sensitive
return 'sports_live' return 'sports_live'
# Weather data # Weather data

View File

@@ -1,651 +0,0 @@
"""
Multi-Display Sync Manager
Synchronizes scrolling content across two LED matrix display units over UDP.
Runs at the core framework level — works with any plugin automatically.
Roles:
standalone No sync (default behavior)
leader Drives scroll, sends rendered follower frames via UDP
follower Receives frames from leader; falls back to own plugins when
the leader goes offline
Compatibility rule: rows and cols must match between leader and follower.
chain_length may differ — each display can have a different number of panels.
Port default: 5765 (UDP). Open this port on both Pis if ufw is active:
sudo ufw allow 5765/udp
"""
import io
import json
import os
import socket
import struct
import tempfile
import threading
import time
import logging
from enum import Enum
from typing import Callable, Optional
import numpy as np
from PIL import Image
# Raw-frame wire format: 8-byte magic + 4-byte header + raw RGB pixels
# Much faster than PNG: no encode/decode, negligible CPU, same UDP packet size
_RAW_MAGIC = b'SYNC_RAW'
_RAW_HEADER = struct.Struct('<HH') # width, height (uint16 LE)
SYNC_PORT = 5765
HELLO_INTERVAL = 5.0 # follower broadcasts hello every 5 s
HEARTBEAT_INTERVAL = 2.0 # follower sends heartbeat every 2 s
PEER_TIMEOUT = 6.0 # leader: no heartbeat → follower gone
LEADER_TIMEOUT = 6.0 # follower: no frame → leader gone
STATUS_FILE = os.path.join(tempfile.gettempdir(), "led_matrix_sync_status.json")
class SyncRole(Enum):
STANDALONE = "standalone"
LEADER = "leader"
FOLLOWER = "follower"
class LeaderState(Enum):
NO_PEER = "no_peer"
CONNECTED = "connected"
INCOMPATIBLE = "incompatible"
class FollowerState(Enum):
STANDALONE = "standalone"
FOLLOWER = "follower"
class DisplaySyncManager:
"""
Core sync manager. Instantiated by DisplayController based on config['sync'].
Leader sends compressed PNG frames to the follower after each render cycle.
Follower renders received frames; returns to own plugin stack when leader
goes offline.
"""
def __init__(
self,
role_str: str,
cfg: dict,
hw_config: dict,
logger: logging.Logger,
) -> None:
"""
Args:
role_str: "standalone" | "leader" | "follower"
cfg: config['sync'] dict
hw_config: config['display']['hardware'] dict (this Pi's own config)
logger: framework logger
"""
try:
self.role = SyncRole(role_str)
except ValueError:
logger.warning("Invalid sync role '%s', defaulting to standalone", role_str)
self.role = SyncRole.STANDALONE
self.logger = logger
self.port = int(cfg.get("port", SYNC_PORT))
self._hw_config = hw_config
# Leader state
self._leader_state = LeaderState.NO_PEER
self._peer_ip: Optional[str] = None
self._peer_compatible: bool = False
self._peer_chain: int = 0
self._last_heartbeat_time: float = 0.0
self._leader_width: int = 0 # set by display_controller after init
# Follower state
self._follower_state = FollowerState.STANDALONE
self._latest_frame: Optional[Image.Image] = None # pixel-frame fallback
self._latest_scroll_x: Optional[float] = None # Vegas scroll position
self._last_leader_frame_time: float = 0.0
self._frame_lock = threading.Lock()
self._leader_ip: Optional[str] = None
self._on_new_cycle: Optional[Callable[[], None]] = None # called when leader starts new cycle
self._on_scroll_image: Optional[Callable[[Image.Image], None]] = None # called with Image when received
self._pending_scroll_image: Optional[Image.Image] = None # image received before callback set
self._scroll_image_lock = threading.Lock() # guards _on_scroll_image / _pending_scroll_image
self._img_server_sock = None # TCP server for scroll image transfer
# Leader state additions
self._on_follower_connected: Optional[Callable[[], None]] = None # called when follower connects
self._error_message: Optional[str] = None
self._running = False
self._recv_sock: Optional[socket.socket] = None
self._send_sock: Optional[socket.socket] = None
if self.role == SyncRole.STANDALONE:
return
if self.role == SyncRole.LEADER:
self._start_leader()
elif self.role == SyncRole.FOLLOWER:
self._start_follower()
# ------------------------------------------------------------------ #
# Leader setup #
# ------------------------------------------------------------------ #
def _start_leader(self) -> None:
# Receive socket: listens for hello + heartbeat from follower
self._recv_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) # nosec B104
self._recv_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
self._recv_sock.bind(("", self.port)) # nosec B104 — intentional: must receive UDP broadcast on all interfaces
self._recv_sock.settimeout(1.0)
# Send socket: unicast frames + hello_ack to follower
self._send_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
self._running = True
threading.Thread(
target=self._leader_recv_loop, daemon=True, name="sync-leader-recv"
).start()
threading.Thread(
target=self._leader_watchdog, daemon=True, name="sync-leader-watchdog"
).start()
self.logger.info("Sync: leader started on UDP port %d", self.port)
self.write_status_file()
def _leader_recv_loop(self) -> None:
while self._running:
try:
data, addr = self._recv_sock.recvfrom(1024)
sender_ip = addr[0]
try:
msg = json.loads(data.decode("utf-8"))
except (json.JSONDecodeError, UnicodeDecodeError):
continue
t = msg.get("t")
if t == "hello":
self._handle_hello(msg, sender_ip)
elif t == "hb":
if self._peer_ip == sender_ip:
self._last_heartbeat_time = time.time()
except socket.timeout:
continue
except Exception as exc:
self.logger.debug("Sync leader recv error: %s", exc)
def _handle_hello(self, msg: dict, sender_ip: str) -> None:
hw = self._hw_config
local_rows = hw.get("rows", 32)
local_cols = hw.get("cols", 64)
peer_rows = int(msg.get("rows", 0))
peer_cols = int(msg.get("cols", 0))
peer_chain = int(msg.get("chain", 1))
compatible = peer_rows == local_rows and peer_cols == local_cols
self._peer_ip = sender_ip
self._peer_compatible = compatible
self._peer_chain = peer_chain
self._last_heartbeat_time = time.time()
prev_state = self._leader_state
if compatible:
if prev_state != LeaderState.CONNECTED:
self.logger.info(
"Sync: follower connected at %s (chain=%d)", sender_ip, peer_chain
)
self._leader_state = LeaderState.CONNECTED
self._error_message = None
# Send scroll image immediately on new connection so follower has identical content
if prev_state != LeaderState.CONNECTED and self._on_follower_connected:
threading.Thread(
target=self._on_follower_connected,
daemon=True, name="sync-leader-img-push"
).start()
else:
self._leader_state = LeaderState.INCOMPATIBLE
self._error_message = (
f"Incompatible panels: follower is {peer_cols}x{peer_rows}, "
f"leader is {local_cols}x{local_rows}. "
f"rows and cols must match between displays."
)
if prev_state != LeaderState.INCOMPATIBLE:
self.logger.error("Sync: %s", self._error_message)
if self._leader_state != prev_state:
self.write_status_file()
ack = json.dumps({
"t": "hello_ack",
"compatible": compatible,
"leader_width": self._leader_width,
"error": self._error_message,
}).encode("utf-8")
try:
self._send_sock.sendto(ack, (sender_ip, self.port))
except Exception as exc:
self.logger.debug("Sync: hello_ack send failed: %s", exc)
def _leader_watchdog(self) -> None:
while self._running:
time.sleep(1.0)
if self._leader_state == LeaderState.CONNECTED:
if time.time() - self._last_heartbeat_time > PEER_TIMEOUT:
self.logger.info(
"Sync: follower heartbeat timeout — peer disconnected"
)
self._leader_state = LeaderState.NO_PEER
self._peer_ip = None
self._peer_compatible = False
self.write_status_file()
def _image_server_loop(self) -> None:
"""Follower: TCP server that receives the leader's scroll image at each new cycle."""
while self._running:
try:
conn, addr = self._img_server_sock.accept()
conn.settimeout(10.0)
try:
# 4-byte big-endian length prefix
hdr = b""
while len(hdr) < 4:
chunk = conn.recv(4 - len(hdr))
if not chunk:
break
hdr += chunk
if len(hdr) < 4:
continue
length = int.from_bytes(hdr, "big")
_MAX_IMAGE_BYTES = 10 * 1024 * 1024 # 10 MB — well above any real scroll image
if length <= 0 or length > _MAX_IMAGE_BYTES:
self.logger.warning(
"Sync: rejected TCP image with invalid length %d (max %d) from %s",
length, _MAX_IMAGE_BYTES, addr,
)
conn.close()
continue
data = bytearray()
while len(data) < length:
chunk = conn.recv(min(65536, length - len(data)))
if not chunk:
break
data.extend(chunk)
img = Image.open(io.BytesIO(data))
_MAX_W, _MAX_H = 100_000, 256 # generous for any real scroll image
if img.width > _MAX_W or img.height > _MAX_H:
self.logger.warning(
"Sync: rejected oversized scroll image %dx%d (max %dx%d) from %s",
img.width, img.height, _MAX_W, _MAX_H, addr,
)
continue
try:
img.load()
except (Image.DecompressionBombError, ValueError) as exc:
self.logger.warning("Sync: rejected decompression bomb from %s: %s", addr, exc)
continue
self.logger.info(
"Sync: received scroll image %dx%d (%d bytes compressed)",
img.width, img.height, length,
)
with self._scroll_image_lock:
if self._on_scroll_image:
cb = self._on_scroll_image
else:
# Callback not registered yet (startup race) — cache it
self._pending_scroll_image = img
cb = None
if cb:
cb(img)
finally:
conn.close()
except socket.timeout:
continue
except Exception as exc:
self.logger.debug("Sync: image server error: %s", exc)
def send_scroll_image(self, image: Image.Image) -> None:
"""Leader: send the full scroll image to the follower via TCP.
PNG compression typically reduces a 5000×32 image to ~2050KB,
transferring in <20ms on local WiFi. Called at new_cycle and on
first connection so both Pis always have identical cached_arrays.
"""
if self.role != SyncRole.LEADER:
return
if self._leader_state != LeaderState.CONNECTED or not self._peer_ip:
return
try:
buf = io.BytesIO()
image.save(buf, format="PNG", optimize=True)
data = buf.getvalue()
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.settimeout(5.0)
sock.connect((self._peer_ip, self.port + 1))
sock.sendall(len(data).to_bytes(4, "big") + data)
self.logger.info(
"Sync: sent scroll image %dx%d (%d bytes compressed)",
image.width, image.height, len(data),
)
except Exception as exc:
self.logger.debug("Sync: image send error: %s", exc)
def set_on_follower_connected(self, callback: Callable[[], None]) -> None:
"""Leader: callback fired (in a thread) when a compatible follower first connects.
Use this to push the current scroll image immediately.
If a follower is already connected when this is called, fires right away
(handles the race where follower connects during leader startup).
"""
self._on_follower_connected = callback
if self._leader_state == LeaderState.CONNECTED:
threading.Thread(
target=callback, daemon=True, name="sync-leader-img-push-late"
).start()
def set_on_scroll_image(self, callback: Callable[[Image.Image], None]) -> None:
"""Follower: callback fired with the received Image when leader sends scroll image.
If an image was received before this callback was registered (startup race),
fires immediately with that cached image.
"""
with self._scroll_image_lock:
self._on_scroll_image = callback
pending = self._pending_scroll_image
self._pending_scroll_image = None
if pending is not None:
callback(pending)
def send_scroll_x(self, scroll_x: float) -> None:
"""Leader (Vegas mode): broadcast scroll position instead of a pixel frame.
The follower renders from its own local pipeline at scroll_x - display_width.
~20 bytes vs ~18KB for raw frames — eliminates all content-change artifacts.
"""
if self.role != SyncRole.LEADER:
return
if self._leader_state != LeaderState.CONNECTED or not self._peer_ip:
return
try:
msg = json.dumps({"t": "sx", "x": round(scroll_x, 2)}).encode("utf-8")
self._send_sock.sendto(msg, (self._peer_ip, self.port))
except Exception as exc:
self.logger.debug("Sync: scroll_x send error: %s", exc)
def send_new_cycle(self) -> None:
"""Leader: signal that a new scroll cycle has started so follower rebuilds its image."""
if self.role != SyncRole.LEADER:
return
if self._leader_state != LeaderState.CONNECTED or not self._peer_ip:
return
try:
self._send_sock.sendto(b'{"t":"nc"}', (self._peer_ip, self.port))
except Exception as exc:
self.logger.debug("Sync: new_cycle send error: %s", exc)
def send_frame(self, image: Image.Image) -> None:
"""Leader: send a rendered frame to the follower as raw RGB bytes.
Raw format is orders of magnitude faster than PNG on Pi hardware —
no encode on sender, no decode on receiver.
Packet: 8-byte magic + 4-byte (width, height) header + raw RGB bytes.
"""
if self.role != SyncRole.LEADER:
return
if self._leader_state != LeaderState.CONNECTED or not self._peer_ip:
return
try:
arr = np.asarray(image.convert("RGB"), dtype=np.uint8)
header = _RAW_MAGIC + _RAW_HEADER.pack(image.width, image.height)
data = header + arr.tobytes()
if len(data) <= 65000:
self._send_sock.sendto(data, (self._peer_ip, self.port))
elif not getattr(self, '_oversized_frame_warned', False):
self._oversized_frame_warned = True
self.logger.warning(
"Sync: frame too large for UDP (%d bytes, max 65000) — "
"image %dx%d will not be sent; use TCP image sync instead",
len(data), image.width, image.height,
)
except Exception as exc:
self.logger.debug("Sync: frame send error: %s", exc)
def set_leader_width(self, width: int) -> None:
"""Called by DisplayController once display_manager.width is known."""
self._leader_width = width
# ------------------------------------------------------------------ #
# Follower setup #
# ------------------------------------------------------------------ #
def _start_follower(self) -> None:
# Receive socket: listens for frames + hello_ack from leader
self._recv_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
self._recv_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
self._recv_sock.bind(("", self.port)) # nosec B104 — intentional: must receive UDP broadcast on all interfaces
self._recv_sock.settimeout(0.1)
# Send socket: broadcasts hello + heartbeat
self._send_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
self._send_sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
self._running = True
threading.Thread(
target=self._follower_recv_loop, daemon=True, name="sync-follower-recv"
).start()
threading.Thread(
target=self._follower_announce_loop, daemon=True, name="sync-follower-announce"
).start()
threading.Thread(
target=self._follower_watchdog, daemon=True, name="sync-follower-watchdog"
).start()
# TCP server: receives scroll images from leader (port + 1)
self._img_server_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) # nosec B104
self._img_server_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
self._img_server_sock.bind(("", self.port + 1)) # nosec B104 — intentional: TCP server must accept connections on all interfaces
self._img_server_sock.listen(1)
self._img_server_sock.settimeout(1.0)
threading.Thread(
target=self._image_server_loop, daemon=True, name="sync-image-server"
).start()
self.logger.info(
"Sync: follower started on UDP port %d, image server on TCP %d",
self.port, self.port + 1,
)
self.write_status_file()
def _follower_recv_loop(self) -> None:
while self._running:
try:
data, addr = self._recv_sock.recvfrom(65535)
sender_ip = addr[0]
if data[:8] == _RAW_MAGIC or len(data) > 512:
# Frame data: prefer magic-tagged raw RGB; fall back to legacy PNG
try:
if data[:8] == _RAW_MAGIC:
w, h = _RAW_HEADER.unpack(data[8:12])
raw = data[12:]
img = Image.frombuffer(
"RGB", (w, h), raw, "raw", "RGB", 0, 1
)
else:
# Fallback: try legacy PNG
img = Image.open(io.BytesIO(data))
img.load()
with self._frame_lock:
self._latest_frame = img
self._last_leader_frame_time = time.time()
self._leader_ip = sender_ip
if self._follower_state == FollowerState.STANDALONE:
self._follower_state = FollowerState.FOLLOWER
self.logger.info(
"Sync: leader active at %s — switching to follower mode",
sender_ip,
)
self.write_status_file()
except Exception as exc:
self.logger.debug("Sync: frame decode error: %s", exc)
else:
# Control message
try:
msg = json.loads(data.decode("utf-8"))
t = msg.get("t")
if t == "hello_ack":
self._leader_ip = sender_ip
self._peer_compatible = msg.get("compatible", False)
self._error_message = msg.get("error")
if not self._peer_compatible and self._error_message:
self.logger.error(
"Sync: leader rejected handshake — %s",
self._error_message,
)
self.write_status_file()
elif t == "sx":
# Vegas scroll-position sync — tiny message, renders locally
self._latest_scroll_x = float(msg["x"])
self._last_leader_frame_time = time.time()
self._leader_ip = sender_ip
if self._follower_state == FollowerState.STANDALONE:
self._follower_state = FollowerState.FOLLOWER
self.logger.info(
"Sync: leader active at %s — switching to follower mode",
sender_ip,
)
self.write_status_file()
if self._on_new_cycle:
self._on_new_cycle() # build initial scroll image
elif t == "nc":
# Leader started a new scroll cycle — rebuild local image
if self._on_new_cycle:
self._on_new_cycle()
except (json.JSONDecodeError, UnicodeDecodeError, KeyError):
pass
except socket.timeout:
continue
except Exception as exc:
self.logger.debug("Sync follower recv error: %s", exc)
def _follower_announce_loop(self) -> None:
hw = self._hw_config
hello = json.dumps({
"t": "hello",
"rows": hw.get("rows", 32),
"cols": hw.get("cols", 64),
"chain": hw.get("chain_length", 1),
}).encode("utf-8")
heartbeat = json.dumps({"t": "hb"}).encode("utf-8")
dest = ("<broadcast>", self.port)
last_hello = 0.0
last_hb = 0.0
while self._running:
now = time.time()
if now - last_hello >= HELLO_INTERVAL:
try:
self._send_sock.sendto(hello, dest)
last_hello = now
except Exception as exc:
self.logger.debug("Sync: hello broadcast error: %s", exc)
if now - last_hb >= HEARTBEAT_INTERVAL:
try:
self._send_sock.sendto(heartbeat, dest)
last_hb = now
except Exception as exc:
self.logger.debug("Sync: heartbeat error: %s", exc)
time.sleep(0.5)
def _follower_watchdog(self) -> None:
while self._running:
time.sleep(1.0)
if self._follower_state == FollowerState.FOLLOWER:
if time.time() - self._last_leader_frame_time > LEADER_TIMEOUT:
self.logger.info(
"Sync: leader frame timeout — returning to standalone mode"
)
self._follower_state = FollowerState.STANDALONE
with self._frame_lock:
self._latest_frame = None
self.write_status_file()
# ------------------------------------------------------------------ #
# Public API #
# ------------------------------------------------------------------ #
def is_follower_active(self) -> bool:
"""True when this Pi is in active follower mode (receiving frames)."""
return (
self.role == SyncRole.FOLLOWER
and self._follower_state == FollowerState.FOLLOWER
)
def get_latest_scroll_x(self) -> Optional[float]:
"""Follower: return the most recently received Vegas scroll position, or None."""
return self._latest_scroll_x
def set_on_new_cycle(self, callback: Callable[[], None]) -> None:
"""Follower: register a callback fired when the leader starts a new scroll cycle.
Used to trigger a local start_new_cycle() so both Pis rebuild from same fresh data.
"""
self._on_new_cycle = callback
def get_latest_frame(self) -> Optional[Image.Image]:
"""Follower: return the most recently received pixel frame (non-Vegas fallback)."""
with self._frame_lock:
return self._latest_frame
def get_status(self) -> dict:
"""Return sync state dict for the web API status endpoint."""
hw = self._hw_config
base = {
"role": self.role.value,
"port": self.port,
"local_rows": hw.get("rows", 32),
"local_cols": hw.get("cols", 64),
"local_chain": hw.get("chain_length", 1),
}
if self.role == SyncRole.STANDALONE:
return {**base, "state": "standalone"}
if self.role == SyncRole.LEADER:
return {
**base,
"state": self._leader_state.value,
"peer_ip": self._peer_ip,
"peer_compatible": self._peer_compatible,
"peer_chain": self._peer_chain,
"leader_width": self._leader_width,
"error": self._error_message,
}
# Follower
return {
**base,
"state": self._follower_state.value,
"leader_ip": self._leader_ip,
"peer_compatible": self._peer_compatible,
"error": self._error_message,
}
def write_status_file(self) -> None:
"""Write current sync status to STATUS_FILE for the web UI to read."""
try:
status = self.get_status()
status["ts"] = time.time()
tmp = STATUS_FILE + ".tmp"
with open(tmp, "w") as f:
json.dump(status, f)
os.replace(tmp, STATUS_FILE)
except Exception as exc:
self.logger.debug("Sync: status file write error: %s", exc)
def stop(self) -> None:
"""Shut down threads and close sockets."""
self._running = False
for sock in (self._recv_sock, self._send_sock, self._img_server_sock):
if sock:
try:
sock.close()
except Exception as exc:
self.logger.debug("Sync: error closing socket: %s", exc)

View File

@@ -16,7 +16,6 @@ from src.config_service import ConfigService
from src.cache_manager import CacheManager from src.cache_manager import CacheManager
from src.font_manager import FontManager from src.font_manager import FontManager
from src.logging_config import get_logger from src.logging_config import get_logger
from src.common.sync_manager import DisplaySyncManager, SyncRole
# Get logger with consistent configuration # Get logger with consistent configuration
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -33,7 +32,10 @@ class DisplayController:
def __init__(self): def __init__(self):
start_time = time.time() start_time = time.time()
logger.info("Starting DisplayController initialization") logger.info("Starting DisplayController initialization")
# Throttle tracking for _tick_plugin_updates in high-FPS loops
self._last_plugin_tick_time = 0.0
# Initialize ConfigManager and wrap with ConfigService for hot-reload # Initialize ConfigManager and wrap with ConfigService for hot-reload
config_manager = ConfigManager() config_manager = ConfigManager()
enable_hot_reload = os.environ.get('LEDMATRIX_HOT_RELOAD', 'true').lower() == 'true' enable_hot_reload = os.environ.get('LEDMATRIX_HOT_RELOAD', 'true').lower() == 'true'
@@ -67,39 +69,7 @@ class DisplayController:
config_time = time.time() config_time = time.time()
self.display_manager = DisplayManager(self.config) self.display_manager = DisplayManager(self.config)
logger.info("DisplayManager initialized in %.3f seconds", time.time() - config_time) logger.info("DisplayManager initialized in %.3f seconds", time.time() - config_time)
# Initialize multi-display sync (standalone by default — no-op unless configured)
sync_cfg = self.config.get("sync", {})
hw_cfg = self.config.get("display", {}).get("hardware", {})
self.sync_manager = DisplaySyncManager(
role_str=sync_cfg.get("role", "standalone"),
cfg=sync_cfg,
hw_config=hw_cfg,
logger=logger,
)
# Tell the leader its own physical display width so it can include it in hello_ack
if self.sync_manager.role == SyncRole.LEADER:
self.sync_manager.set_leader_width(self.display_manager.width)
# Follower mode setup
if self.sync_manager.role == SyncRole.FOLLOWER:
# Gate update_display() so background plugin threads cannot write to
# hardware — only our render loop is permitted.
_real_update = self.display_manager.update_display
_dm = self.display_manager
def _follower_gated_update():
# Allow through when the sync render loop has the token, or when
# the leader has gone offline and we've fallen back to standalone.
if getattr(_dm, '_sync_render_allowed', False) or not self.sync_manager.is_follower_active():
_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.
# Initialize Font Manager # Initialize Font Manager
font_time = time.time() font_time = time.time()
self.font_manager = FontManager(self.config) self.font_manager = FontManager(self.config)
@@ -112,7 +82,8 @@ class DisplayController:
logger.info("Display modes initialized in %.3f seconds", time.time() - init_time) logger.info("Display modes initialized in %.3f seconds", time.time() - init_time)
self.force_change = False self.force_change = False
self._next_live_priority_check = 0.0 # monotonic timestamp for throttled live priority checks
# All sports and content managers now handled via plugins # All sports and content managers now handled via plugins
logger.info("All sports and content managers now handled via plugin system") logger.info("All sports and content managers now handled via plugin system")
@@ -425,64 +396,20 @@ class DisplayController:
# Set up live priority checker # Set up live priority checker
self.vegas_coordinator.set_live_priority_checker(self._check_live_priority) self.vegas_coordinator.set_live_priority_checker(self._check_live_priority)
# Set up interrupt checker for on-demand/wifi status and follower mode # Set up interrupt checker for on-demand/wifi status
def _vegas_interrupt():
return self._check_vegas_interrupt() or self.sync_manager.is_follower_active()
self.vegas_coordinator.set_interrupt_checker( self.vegas_coordinator.set_interrupt_checker(
_vegas_interrupt, self._check_vegas_interrupt,
check_interval=10 # Check every 10 frames (~80ms at 125 FPS) check_interval=10 # Check every 10 frames (~80ms at 125 FPS)
) )
# Run plugin updates inside the Vegas loop so the inter-iteration # Set up plugin update tick to keep data fresh during Vegas mode
# gap is <1 ms (nothing left for _tick_plugin_updates() to do). self.vegas_coordinator.set_update_tick(
self.vegas_coordinator.set_update_callback(self._tick_plugin_updates) self._tick_plugin_updates_for_vegas,
interval=1.0
# Wire multi-display sync into Vegas render pipeline )
follower_pos = self.config.get("sync", {}).get("follower_position", "left")
self.vegas_coordinator.set_sync_manager(self.sync_manager, follower_pos)
logger.info("Vegas mode coordinator initialized") logger.info("Vegas mode coordinator initialized")
# Follower does NOT build its own initial scroll image — the leader
# pushes its image via TCP as soon as set_on_follower_connected fires.
# A local build would create a different (wrong) image that could
# temporarily replace the leader's correct one.
# When the leader sends its scroll image (TCP), update our
# 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)
if vc and vc.render_pipeline:
rp = vc.render_pipeline
arr = _np.asarray(image.convert("RGB"), dtype=_np.uint8)
rp.scroll_helper.cached_image = image
rp.scroll_helper.cached_array = arr
rp.scroll_helper.total_scroll_width = image.width
self._follower_pending_new_image = False
logger.info(
"Sync: follower adopted leader scroll image %dx%d",
image.width, image.height,
)
self.sync_manager.set_on_scroll_image(_on_leader_scroll_image)
if self.sync_manager.role == SyncRole.LEADER:
# When a follower first connects, push the current scroll image so
# 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)
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)
logger.warning("Sync: no scroll image available to push to new follower")
self.sync_manager.set_on_follower_connected(_on_follower_connected)
except Exception as e: except Exception as e:
logger.error("Failed to initialize Vegas mode: %s", e, exc_info=True) logger.error("Failed to initialize Vegas mode: %s", e, exc_info=True)
self.vegas_coordinator = None self.vegas_coordinator = None
@@ -517,16 +444,51 @@ class DisplayController:
return False return False
def _tick_plugin_updates_for_vegas(self):
"""
Run scheduled plugin updates and return IDs of plugins that were updated.
Called periodically by the Vegas coordinator to keep plugin data fresh
during Vegas mode. Returns a list of plugin IDs whose data changed so
Vegas can refresh their content in the scroll.
Returns:
List of updated plugin IDs, or None if no updates occurred
"""
if not self.plugin_manager or not hasattr(self.plugin_manager, 'plugin_last_update'):
self._tick_plugin_updates()
return None
# Snapshot update timestamps before ticking
old_times = dict(self.plugin_manager.plugin_last_update)
# Run the scheduled updates
self._tick_plugin_updates()
# Detect which plugins were actually updated
updated = []
for plugin_id, new_time in self.plugin_manager.plugin_last_update.items():
if new_time > old_times.get(plugin_id, 0.0):
updated.append(plugin_id)
if updated:
logger.info("Vegas update tick: %d plugin(s) updated: %s", len(updated), updated)
return updated or None
def _check_schedule(self): def _check_schedule(self):
"""Check if display should be active based on schedule.""" """Check if display should be active based on schedule."""
schedule_config = self.config.get('schedule', {}) # Get fresh config from config_service to support hot-reload
current_config = self.config_service.get_config()
schedule_config = current_config.get('schedule', {})
# If schedule config doesn't exist or is empty, default to always active # If schedule config doesn't exist or is empty, default to always active
if not schedule_config: if not schedule_config:
self.is_display_active = True self.is_display_active = True
self._was_display_active = True # Track previous state for schedule change detection self._was_display_active = True # Track previous state for schedule change detection
return return
# Check if schedule is explicitly disabled # Check if schedule is explicitly disabled
# Default to True (schedule enabled) if 'enabled' key is missing for backward compatibility # 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): if 'enabled' in schedule_config and not schedule_config.get('enabled', True):
@@ -536,7 +498,7 @@ class DisplayController:
return return
# Get configured timezone, default to UTC # Get configured timezone, default to UTC
timezone_str = self.config.get('timezone', 'UTC') timezone_str = current_config.get('timezone', 'UTC')
try: try:
tz = pytz.timezone(timezone_str) tz = pytz.timezone(timezone_str)
except pytz.UnknownTimeZoneError: except pytz.UnknownTimeZoneError:
@@ -634,15 +596,18 @@ class DisplayController:
Target brightness level (dim_brightness if in dim period, Target brightness level (dim_brightness if in dim period,
normal brightness otherwise) normal brightness otherwise)
""" """
# Get fresh config from config_service to support hot-reload
current_config = self.config_service.get_config()
# Get normal brightness from config # Get normal brightness from config
normal_brightness = self.config.get('display', {}).get('hardware', {}).get('brightness', 90) normal_brightness = current_config.get('display', {}).get('hardware', {}).get('brightness', 90)
# If display is OFF via schedule, don't process dim schedule # If display is OFF via schedule, don't process dim schedule
if not self.is_display_active: if not self.is_display_active:
self.is_dimmed = False self.is_dimmed = False
return normal_brightness return normal_brightness
dim_config = self.config.get('dim_schedule', {}) dim_config = current_config.get('dim_schedule', {})
# If dim schedule doesn't exist or is disabled, use normal brightness # If dim schedule doesn't exist or is disabled, use normal brightness
if not dim_config or not dim_config.get('enabled', False): if not dim_config or not dim_config.get('enabled', False):
@@ -650,7 +615,7 @@ class DisplayController:
return normal_brightness return normal_brightness
# Get configured timezone # Get configured timezone
timezone_str = self.config.get('timezone', 'UTC') timezone_str = current_config.get('timezone', 'UTC')
try: try:
tz = pytz.timezone(timezone_str) tz = pytz.timezone(timezone_str)
except pytz.UnknownTimeZoneError: except pytz.UnknownTimeZoneError:
@@ -757,83 +722,21 @@ class DisplayController:
except Exception: # pylint: disable=broad-except except Exception: # pylint: disable=broad-except
logger.exception("Error running scheduled plugin updates") logger.exception("Error running scheduled plugin updates")
_FOLLOWER_SEND_INTERVAL = 1.0 / 90 # raw bytes are cheap; 90fps > follower render rate def _tick_plugin_updates_throttled(self, min_interval: float = 0.0):
"""Throttled version of _tick_plugin_updates for high-FPS loops.
def _follower_rebuild_scroll_image(self) -> None: Args:
"""Follower: rebuild the local Vegas scroll image so both Pis render from min_interval: Minimum seconds between calls. When <= 0 the
the same fresh plugin data. Called at startup (after Vegas initializes) call passes straight through to _tick_plugin_updates so
and each time the leader broadcasts a new-cycle signal. Runs in a daemon plugin-configured update_interval values are never capped.
thread so it never blocks the 60fps render loop.
""" """
try: if min_interval <= 0:
vc = getattr(self, 'vegas_coordinator', None) self._tick_plugin_updates()
if not vc:
logger.warning("Sync: follower has no vegas_coordinator — cannot build scroll image")
return
rp = vc.render_pipeline
if not rp:
logger.warning("Sync: follower vegas_coordinator has no render_pipeline")
return
logger.info("Sync: follower starting scroll image rebuild")
ok = rp.start_new_cycle()
if ok and rp.scroll_helper.cached_image is not None:
logger.info(
"Sync: follower scroll image ready — %dx%d",
rp.scroll_helper.cached_image.width,
rp.scroll_helper.cached_image.height,
)
else:
logger.warning(
"Sync: follower scroll image rebuild FAILED (ok=%s, cached=%s)",
ok, rp.scroll_helper.cached_image is not None,
)
except Exception as exc:
logger.warning("Sync: follower scroll image rebuild error: %s", exc, exc_info=True)
def _send_follower_frame(self, plugin_instance) -> None:
"""Leader: generate and send the follower's portion of the current frame.
The follower is physically to the LEFT of the leader in a right-to-left
scrolling ticker, so it shows content at scroll_position - display_width
(content that already scrolled off the leader's left edge).
Set sync.follower_position = "right" in config to invert this.
"""
if not (self.sync_manager and self.sync_manager.role == SyncRole.LEADER):
return return
# Throttle to ~90fps via _FOLLOWER_SEND_INTERVAL — raw RGB bytes, no encode/decode
now = time.time() now = time.time()
if now - getattr(self, '_last_follower_send', 0) < self._FOLLOWER_SEND_INTERVAL: if now - self._last_plugin_tick_time >= min_interval:
return self._last_plugin_tick_time = now
self._last_follower_send = now self._tick_plugin_updates()
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
# 1. Explicit hook — plugin opted in with get_offset_frame()
try:
follower_frame = plugin_instance.get_offset_frame(offset)
except Exception:
pass
# 2. Auto-detect — plugin has a scroll_helper (standard pattern for all
# scroll plugins). Works with zero plugin code changes.
if follower_frame is None:
try:
scroll_h = getattr(plugin_instance, 'scroll_helper', None)
if scroll_h is not None:
follower_frame = scroll_h.get_portion_at(scroll_h.scroll_position + offset)
except Exception:
pass
# 3. Mirror fallback — static plugins (clock, weather) show same frame
if follower_frame is None:
follower_frame = self.display_manager.image
if follower_frame is not None:
self.sync_manager.send_frame(follower_frame)
def _sleep_with_plugin_updates(self, duration: float, tick_interval: float = 1.0): def _sleep_with_plugin_updates(self, duration: float, tick_interval: float = 1.0):
"""Sleep while continuing to service plugin update schedules.""" """Sleep while continuing to service plugin update schedules."""
@@ -1470,88 +1373,6 @@ class DisplayController:
# Plugins update on their own schedules - no forced sync updates needed # Plugins update on their own schedules - no forced sync updates needed
# Each plugin has its own update_interval and background services # Each plugin has its own update_interval and background services
# Multi-display sync: follower mode — render frames received from leader.
# 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
vc = getattr(self, 'vegas_coordinator', None)
rp = vc.render_pipeline if (vc and vc.render_pipeline) else None
width = self.display_manager.width
# Advance local position at Vegas scroll speed (px/s → px/tick)
vegas_speed = (
self.config.get('display', {})
.get('vegas_scroll', {})
.get('scroll_speed', 75)
)
local_x = getattr(self, '_follower_local_x', None)
if local_x is None:
local_x = float(width) # safe start (past pre-roll guard)
local_x += vegas_speed * _dt
# Pull latest position from leader (may be None if no packet yet)
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
)
if abs(diff) > total_w * 0.5:
# Large jump → cycle reset, snap immediately
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
else:
# Near → gentle 5% correction
local_x += diff * 0.05
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
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
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)
continue
# Process any deferred updates that may have accumulated # Process any deferred updates that may have accumulated
# This also cleans up expired updates to prevent memory leaks # This also cleans up expired updates to prevent memory leaks
self.display_manager.process_deferred_updates() self.display_manager.process_deferred_updates()
@@ -1925,7 +1746,7 @@ class DisplayController:
) )
target_duration = max_duration target_duration = max_duration
start_time = time.time() start_time = time.monotonic()
def _should_exit_dynamic(elapsed_time: float) -> bool: def _should_exit_dynamic(elapsed_time: float) -> bool:
if not dynamic_enabled: if not dynamic_enabled:
@@ -1984,19 +1805,34 @@ class DisplayController:
except Exception: # pylint: disable=broad-except except Exception: # pylint: disable=broad-except
logger.exception("Error during display update") logger.exception("Error during display update")
# Multi-display sync: send follower frame after each render
self._send_follower_frame(manager_to_display)
time.sleep(display_interval) time.sleep(display_interval)
self._tick_plugin_updates() self._tick_plugin_updates_throttled(min_interval=1.0)
self._poll_on_demand_requests() self._poll_on_demand_requests()
self._check_on_demand_expiration() self._check_on_demand_expiration()
# Check for live priority every ~30s so live
# games can interrupt long display durations
elapsed = time.monotonic() - start_time
now = time.monotonic()
if not self.on_demand_active and now >= self._next_live_priority_check:
self._next_live_priority_check = now + 30.0
live_mode = self._check_live_priority()
if live_mode and live_mode != active_mode:
logger.info("Live priority detected during high-FPS loop: %s", live_mode)
self.current_display_mode = live_mode
self.force_change = True
try:
self.current_mode_index = self.available_modes.index(live_mode)
except ValueError:
pass
# continue the main while loop to skip
# post-loop rotation/sleep logic
break
if self.current_display_mode != active_mode: if self.current_display_mode != active_mode:
logger.debug("Mode changed during high-FPS loop, breaking early") logger.debug("Mode changed during high-FPS loop, breaking early")
break break
elapsed = time.time() - start_time
if elapsed >= target_duration: if elapsed >= target_duration:
logger.debug( logger.debug(
"Reached high-FPS target duration %.2fs for mode %s", "Reached high-FPS target duration %.2fs for mode %s",
@@ -2026,7 +1862,7 @@ class DisplayController:
time.sleep(display_interval) time.sleep(display_interval)
self._tick_plugin_updates() self._tick_plugin_updates()
elapsed = time.time() - start_time elapsed = time.monotonic() - start_time
if elapsed >= target_duration: if elapsed >= target_duration:
logger.debug( logger.debug(
"Reached standard target duration %.2fs for mode %s", "Reached standard target duration %.2fs for mode %s",
@@ -2053,11 +1889,25 @@ class DisplayController:
except Exception: # pylint: disable=broad-except except Exception: # pylint: disable=broad-except
logger.exception("Error during display update") logger.exception("Error during display update")
# Multi-display sync: send follower frame after each render
self._send_follower_frame(manager_to_display)
self._poll_on_demand_requests() self._poll_on_demand_requests()
self._check_on_demand_expiration() self._check_on_demand_expiration()
# Check for live priority every ~30s so live
# games can interrupt long display durations
now = time.monotonic()
if not self.on_demand_active and now >= self._next_live_priority_check:
self._next_live_priority_check = now + 30.0
live_mode = self._check_live_priority()
if live_mode and live_mode != active_mode:
logger.info("Live priority detected during display loop: %s", live_mode)
self.current_display_mode = live_mode
self.force_change = True
try:
self.current_mode_index = self.available_modes.index(live_mode)
except ValueError:
pass
break
if self.current_display_mode != active_mode: if self.current_display_mode != active_mode:
logger.info("Mode changed during display loop from %s to %s, breaking early", active_mode, self.current_display_mode) logger.info("Mode changed during display loop from %s to %s, breaking early", active_mode, self.current_display_mode)
break break
@@ -2071,19 +1921,26 @@ class DisplayController:
loop_completed = True loop_completed = True
break break
# If live priority preempted the display loop, skip
# all post-loop logic (remaining sleep, rotation) and
# restart the main loop so the live mode displays
# immediately.
if self.current_display_mode != active_mode:
continue
# Ensure we honour minimum duration when not dynamic and loop ended early # Ensure we honour minimum duration when not dynamic and loop ended early
if ( if (
not dynamic_enabled not dynamic_enabled
and not loop_completed and not loop_completed
and not needs_high_fps and not needs_high_fps
): ):
elapsed = time.time() - start_time elapsed = time.monotonic() - start_time
remaining_sleep = max(0.0, max_duration - elapsed) remaining_sleep = max(0.0, max_duration - elapsed)
if remaining_sleep > 0: if remaining_sleep > 0:
self._sleep_with_plugin_updates(remaining_sleep) self._sleep_with_plugin_updates(remaining_sleep)
if dynamic_enabled: if dynamic_enabled:
elapsed_total = time.time() - start_time elapsed_total = time.monotonic() - start_time
cycle_done = self._plugin_cycle_complete(manager_to_display) cycle_done = self._plugin_cycle_complete(manager_to_display)
# Log cycle completion status and metrics # Log cycle completion status and metrics

View File

@@ -3,7 +3,6 @@ if os.getenv("EMULATOR", "false") == "true":
from RGBMatrixEmulator import RGBMatrix, RGBMatrixOptions from RGBMatrixEmulator import RGBMatrix, RGBMatrixOptions
else: else:
from rgbmatrix import RGBMatrix, RGBMatrixOptions from rgbmatrix import RGBMatrix, RGBMatrixOptions
from contextlib import contextmanager
from PIL import Image, ImageDraw, ImageFont from PIL import Image, ImageDraw, ImageFont
import time import time
from typing import Dict, Any, List, Tuple from typing import Dict, Any, List, Tuple
@@ -29,8 +28,6 @@ class DisplayManager:
self.config = config or {} self.config = config or {}
self._force_fallback = force_fallback self._force_fallback = force_fallback
self._suppress_test_pattern = suppress_test_pattern self._suppress_test_pattern = suppress_test_pattern
# When True, update_display() and clear() skip hardware writes (used during off-screen content capture)
self._capture_mode_active = False
# Snapshot settings for web preview integration (service writes, web reads) # Snapshot settings for web preview integration (service writes, web reads)
self._snapshot_path = "/tmp/led_matrix_preview.png" self._snapshot_path = "/tmp/led_matrix_preview.png"
self._snapshot_min_interval_sec = 0.2 # max ~5 fps self._snapshot_min_interval_sec = 0.2 # max ~5 fps
@@ -258,22 +255,6 @@ class DisplayManager:
except Exception as e: except Exception as e:
logger.error(f"Error drawing test pattern: {e}", exc_info=True) logger.error(f"Error drawing test pattern: {e}", exc_info=True)
@contextmanager
def capture_mode(self):
"""Suppress hardware output during off-screen content capture.
Plugins call update_display() as part of their normal display() flow.
When fetching content for Vegas mode the render loop is still running,
so any incidental hardware write causes a visible flash on the matrix.
Entering this context prevents those writes without affecting the PIL
image buffer, which the adapter reads to extract content.
"""
self._capture_mode_active = True
try:
yield
finally:
self._capture_mode_active = False
def update_display(self): def update_display(self):
"""Update the display using double buffering with proper sync.""" """Update the display using double buffering with proper sync."""
try: try:
@@ -283,13 +264,10 @@ class DisplayManager:
# Still write a snapshot so the web UI can preview # Still write a snapshot so the web UI can preview
self._write_snapshot_if_due() self._write_snapshot_if_due()
return return
if self._capture_mode_active: # Copy the current image to the offscreen canvas
return # Skip hardware write — content is being captured off-screen
# Copy the current image to the offscreen canvas
self.offscreen_canvas.SetImage(self.image) self.offscreen_canvas.SetImage(self.image)
# Swap buffers immediately # Swap buffers immediately
self.matrix.SwapOnVSync(self.offscreen_canvas) self.matrix.SwapOnVSync(self.offscreen_canvas)
@@ -326,23 +304,21 @@ class DisplayManager:
# Create a new black image # Create a new black image
self.image = Image.new('RGB', (self.matrix.width, self.matrix.height)) self.image = Image.new('RGB', (self.matrix.width, self.matrix.height))
self.draw = ImageDraw.Draw(self.image) self.draw = ImageDraw.Draw(self.image)
if not self._capture_mode_active: # Clear both canvases and the underlying matrix to ensure no artifacts
# Clear both canvases and the underlying matrix to ensure no artifacts. try:
# Failures are non-fatal — the image buffer is already black above, so self.offscreen_canvas.Clear()
# the next update_display() call will push clean content regardless. except Exception:
try: pass
self.offscreen_canvas.Clear() try:
except (RuntimeError, OSError) as e: self.current_canvas.Clear()
logger.error("Failed to clear offscreen canvas: %s", e) except Exception:
try: pass
self.current_canvas.Clear() try:
except (RuntimeError, OSError) as e: # Extra safety: clear the matrix front buffer as well
logger.error("Failed to clear current canvas: %s", e) self.matrix.Clear()
try: except Exception:
self.matrix.Clear() pass
except (RuntimeError, OSError) as e:
logger.error("Failed to clear matrix front buffer: %s", e)
# Note: We do NOT call update_display() here to avoid black flashes. # Note: We do NOT call update_display() here to avoid black flashes.
# The caller should call update_display() after drawing new content. # The caller should call update_display() after drawing new content.

View File

@@ -1850,72 +1850,58 @@ class PluginStoreManager:
return cached[1] return cached[1]
try: try:
# .git may be a file (worktree / submodule) containing "gitdir: <path>". sha_result = subprocess.run(
# Resolve it to the actual git directory before reading any files. ['git', '-C', str(plugin_path), 'rev-parse', 'HEAD'],
try:
if git_dir.is_file():
pointer = git_dir.read_text(encoding='utf-8', errors='replace').strip()
if pointer.startswith('gitdir:'):
resolved = (plugin_path / pointer[len('gitdir:'):].strip()).resolve()
if resolved.is_dir():
git_dir = resolved
else:
return None
else:
return None
except (OSError, NotADirectoryError):
return None
# Read branch directly from .git/HEAD (no subprocess).
branch = ''
try:
head_text = (git_dir / 'HEAD').read_text(encoding='utf-8', errors='replace').strip()
if head_text.startswith('ref: refs/heads/'):
branch = head_text[len('ref: refs/heads/'):]
elif head_text.startswith('ref: '):
branch = head_text[len('ref: '):]
# else: detached HEAD — branch stays ''
except (OSError, NotADirectoryError):
pass
# Remote URL from .git/config — parse [remote "origin"] url line.
remote_url = None
try:
config_text = (git_dir / 'config').read_text(encoding='utf-8', errors='replace')
in_origin = False
for line in config_text.splitlines():
stripped = line.strip()
if stripped == '[remote "origin"]':
in_origin = True
elif stripped.startswith('['):
in_origin = False
elif in_origin and stripped.startswith('url') and '=' in stripped:
remote_url = stripped.split('=', 1)[1].strip()
break
except (OSError, NotADirectoryError):
pass
# Single subprocess: SHA + commit date in one call.
log_result = subprocess.run(
['git', '-C', str(plugin_path), 'log', '-1', '--format=%H%n%cI', 'HEAD'],
capture_output=True, capture_output=True,
text=True, text=True,
timeout=10, timeout=10,
check=True check=True
) )
lines = log_result.stdout.strip().splitlines() sha = sha_result.stdout.strip()
sha = lines[0] if lines else ''
commit_date_iso = lines[1] if len(lines) > 1 else '' branch_result = subprocess.run(
['git', '-C', str(plugin_path), 'rev-parse', '--abbrev-ref', 'HEAD'],
capture_output=True,
text=True,
timeout=10,
check=True
)
branch = branch_result.stdout.strip()
if branch == 'HEAD':
branch = ''
# Get remote URL
remote_url_result = subprocess.run(
['git', '-C', str(plugin_path), 'config', '--get', 'remote.origin.url'],
capture_output=True,
text=True,
timeout=10,
check=False
)
remote_url = remote_url_result.stdout.strip() if remote_url_result.returncode == 0 else None
# Get commit date in ISO format
date_result = subprocess.run(
['git', '-C', str(plugin_path), 'log', '-1', '--format=%cI', 'HEAD'],
capture_output=True,
text=True,
timeout=10,
check=True
)
commit_date_iso = date_result.stdout.strip()
result = { result = {
'sha': sha, 'sha': sha,
'short_sha': sha[:7] if sha else '', 'short_sha': sha[:7] if sha else '',
'branch': branch, 'branch': branch
} }
# Add remote URL if available
if remote_url: if remote_url:
result['remote_url'] = remote_url result['remote_url'] = remote_url
# Add commit date if available
if commit_date_iso: if commit_date_iso:
result['date_iso'] = commit_date_iso result['date_iso'] = commit_date_iso
result['date'] = self._iso_to_date(commit_date_iso) result['date'] = self._iso_to_date(commit_date_iso)

View File

@@ -90,11 +90,13 @@ class VegasModeCoordinator:
self._interrupt_check: Optional[Callable[[], bool]] = None self._interrupt_check: Optional[Callable[[], bool]] = None
self._interrupt_check_interval: int = 10 # Check every N frames self._interrupt_check_interval: int = 10 # Check every N frames
# Plugin update callback — fired from a background thread inside the loop # Plugin update tick for keeping data fresh during Vegas mode
# so the main loop's _tick_plugin_updates() finds nothing due when Vegas self._update_tick: Optional[Callable[[], Optional[List[str]]]] = None
# returns, eliminating the inter-iteration frozen-frame gap. self._update_tick_interval: float = 1.0 # Tick every 1 second
self._update_callback: Optional[Callable[[], None]] = None self._update_thread: Optional[threading.Thread] = None
self._update_tick_running: bool = False self._update_results: Optional[List[str]] = None
self._update_results_lock = threading.Lock()
self._last_update_tick_time: float = 0.0
# Config update tracking # Config update tracking
self._config_version = 0 self._config_version = 0
@@ -137,25 +139,6 @@ class VegasModeCoordinator:
"""Check if Vegas mode is currently running.""" """Check if Vegas mode is currently running."""
return self._is_active return self._is_active
def set_sync_manager(self, sync_manager, follower_position: str = "left") -> None:
"""
Attach a DisplaySyncManager so Vegas mode sends the follower's portion
of the ticker to the second display on every rendered frame.
Args:
sync_manager: DisplaySyncManager instance, or None to disable sync
follower_position: "left" (default) or "right" — physical position of
the follower display relative to the leader
"""
if self.render_pipeline:
# Don't expose a standalone (no-op) manager to the pipeline — treat it as None
if sync_manager is not None and hasattr(sync_manager, 'role'):
from src.common.sync_manager import SyncRole
if sync_manager.role == SyncRole.STANDALONE:
sync_manager = None
self.render_pipeline.sync_manager = sync_manager
self.render_pipeline.sync_follower_left = (follower_position == "left")
def set_live_priority_checker(self, checker: Callable[[], Optional[str]]) -> None: def set_live_priority_checker(self, checker: Callable[[], Optional[str]]) -> None:
""" """
Set the callback for checking live priority content. Set the callback for checking live priority content.
@@ -183,19 +166,24 @@ class VegasModeCoordinator:
self._interrupt_check = checker self._interrupt_check = checker
self._interrupt_check_interval = max(1, check_interval) self._interrupt_check_interval = max(1, check_interval)
def set_update_callback(self, callback: Callable[[], None]) -> None: def set_update_tick(
self,
callback: Callable[[], Optional[List[str]]],
interval: float = 1.0
) -> None:
""" """
Set a callback for running plugin updates from inside the Vegas loop. Set the callback for periodic plugin update ticking during Vegas mode.
Fired in a daemon background thread every ~4 s so plugin data stays This keeps plugin data fresh while the Vegas render loop is running.
fresh without blocking the render loop. The main loop's The callback should run scheduled plugin updates and return a list of
_tick_plugin_updates() then finds all intervals already satisfied and plugin IDs that were actually updated, or None/empty if no updates occurred.
returns immediately, collapsing the inter-iteration gap to <1 ms.
Args: Args:
callback: Callable with no arguments (typically _tick_plugin_updates) callback: Callable that returns list of updated plugin IDs or None
interval: Seconds between update tick calls (default 1.0)
""" """
self._update_callback = callback self._update_tick = callback
self._update_tick_interval = max(0.5, interval)
def start(self) -> bool: def start(self) -> bool:
""" """
@@ -249,6 +237,9 @@ class VegasModeCoordinator:
self.stats['total_runtime_seconds'] += time.time() - self._start_time self.stats['total_runtime_seconds'] += time.time() - self._start_time
self._start_time = None self._start_time = None
# Wait for in-flight background update before tearing down state
self._drain_update_thread()
# Cleanup components # Cleanup components
self.render_pipeline.reset() self.render_pipeline.reset()
self.stream_manager.reset() self.stream_manager.reset()
@@ -344,101 +335,83 @@ class VegasModeCoordinator:
last_fps_log_time = start_time last_fps_log_time = start_time
fps_frame_count = 0 fps_frame_count = 0
self._last_update_tick_time = start_time
logger.info("Starting Vegas iteration for %.1fs", duration) logger.info("Starting Vegas iteration for %.1fs", duration)
while True: try:
# Check for STATIC mode plugin that should pause scroll while True:
static_plugin = self._check_static_plugin_trigger() # Check for STATIC mode plugin that should pause scroll
if static_plugin: static_plugin = self._check_static_plugin_trigger()
if not self._handle_static_pause(static_plugin): if static_plugin:
# Static pause was interrupted if not self._handle_static_pause(static_plugin):
return False # Static pause was interrupted
# After static pause, skip this segment and continue
self.stream_manager.get_next_segment() # Consume the segment
continue
# Run frame
if not self.run_frame():
# Check why we stopped
with self._state_lock:
if self._should_stop:
return False
if self._is_paused:
# Paused for live priority - let caller handle
return False return False
# After static pause, skip this segment and continue
self.stream_manager.get_next_segment() # Consume the segment
continue
# Sleep for frame interval # Run frame
time.sleep(frame_interval) if not self.run_frame():
# Check why we stopped
with self._state_lock:
if self._should_stop:
return False
if self._is_paused:
# Paused for live priority - let caller handle
return False
# Increment frame count and check for interrupt periodically # Sleep for frame interval
frame_count += 1 time.sleep(frame_interval)
fps_frame_count += 1
# Periodic FPS logging # Increment frame count and check for interrupt periodically
current_time = time.time() frame_count += 1
if current_time - last_fps_log_time >= fps_log_interval: fps_frame_count += 1
fps = fps_frame_count / (current_time - last_fps_log_time)
logger.info(
"Vegas FPS: %.1f (target: %d, frames: %d)",
fps, self.vegas_config.target_fps, fps_frame_count
)
last_fps_log_time = current_time
fps_frame_count = 0
if (self._interrupt_check and # Periodic FPS logging
frame_count % self._interrupt_check_interval == 0): current_time = time.time()
try: if current_time - last_fps_log_time >= fps_log_interval:
if self._interrupt_check(): fps = fps_frame_count / (current_time - last_fps_log_time)
logger.debug( logger.info(
"Vegas interrupted by callback after %d frames", "Vegas FPS: %.1f (target: %d, frames: %d)",
frame_count fps, self.vegas_config.target_fps, fps_frame_count
) )
return False last_fps_log_time = current_time
except Exception: fps_frame_count = 0
# Log but don't let interrupt check errors stop Vegas
logger.exception("Interrupt check failed")
# Fire plugin update tick in a background thread every ~4 s. # Periodic plugin update tick to keep data fresh (non-blocking)
# Running it here (rather than only between iterations) means the self._drive_background_updates()
# main loop's _tick_plugin_updates() finds all intervals already
# satisfied on return, so the inter-iteration gap is <1 ms and the if (self._interrupt_check and
# display never shows a frozen frame between iterations. frame_count % self._interrupt_check_interval == 0):
_UPDATE_TICK_FRAMES = max(1, int(self.vegas_config.target_fps * 4)) # every 4 s regardless of FPS
if (self._update_callback and
frame_count % _UPDATE_TICK_FRAMES == 0 and
not self._update_tick_running):
self._update_tick_running = True
def _run_tick(cb=self._update_callback):
try: try:
cb() if self._interrupt_check():
finally: logger.debug(
self._update_tick_running = False "Vegas interrupted by callback after %d frames",
threading.Thread( frame_count
target=_run_tick, daemon=True, name="vegas-plugin-tick" )
).start() return False
except Exception:
# Log but don't let interrupt check errors stop Vegas
logger.exception("Interrupt check failed")
# Check elapsed time # Check elapsed time
elapsed = time.time() - start_time elapsed = time.time() - start_time
if elapsed >= duration: if elapsed >= duration:
break break
# NOTE: do NOT break on is_cycle_complete() here. # Check for cycle completion
# When multi-display sync is active, breaking exits run_iteration() if self.render_pipeline.is_cycle_complete():
# which causes a 2-3s delay before start_new_cycle() is called on break
# the next run_iteration(). During that gap the scroll advances into
# the pre-roll zone, then start_new_cycle() resets it — producing a
# second visible jump on the follower display ~2.5s after the first.
#
# Instead, run_frame() handles cycle completion directly (it calls
# start_new_cycle() in the very next frame, 8ms later), collapsing
# the two events into a single clean transition.
#
# Without sync, the iteration now runs to its full duration and may
# cycle content multiple times within one iteration — acceptable for
# a continuous ticker.
logger.info("Vegas iteration completed after %.1fs", time.time() - start_time) logger.info("Vegas iteration completed after %.1fs", time.time() - start_time)
return True return True
finally:
# Ensure background update thread finishes before the main loop
# resumes its own _tick_plugin_updates() calls, preventing concurrent
# run_scheduled_updates() execution.
self._drain_update_thread()
def _check_live_priority(self) -> bool: def _check_live_priority(self) -> bool:
""" """
@@ -527,6 +500,71 @@ class VegasModeCoordinator:
if self._pending_config is None: if self._pending_config is None:
self._pending_config_update = False self._pending_config_update = False
def _run_update_tick_background(self) -> None:
"""Run the plugin update tick in a background thread.
Stores results for the render loop to pick up on its next iteration,
so the scroll never blocks on API calls.
"""
try:
updated_plugins = self._update_tick()
if updated_plugins:
with self._update_results_lock:
# Accumulate rather than replace to avoid losing notifications
# if a previous result hasn't been picked up yet
if self._update_results is None:
self._update_results = updated_plugins
else:
self._update_results.extend(updated_plugins)
except Exception:
logger.exception("Background plugin update tick failed")
def _drain_update_thread(self, timeout: float = 2.0) -> None:
"""Wait for any in-flight background update thread to finish.
Called when transitioning out of Vegas mode so the main-loop
``_tick_plugin_updates`` call doesn't race with a still-running
background thread.
"""
if self._update_thread is not None and self._update_thread.is_alive():
self._update_thread.join(timeout=timeout)
if self._update_thread.is_alive():
logger.warning(
"Background update thread did not finish within %.1fs", timeout
)
def _drive_background_updates(self) -> None:
"""Collect finished background update results and launch new ticks.
Safe to call from both the main render loop and the static-pause
wait loop so that plugin data stays fresh regardless of which
code path is active.
"""
# 1. Collect results from a previously completed background update
with self._update_results_lock:
ready_results = self._update_results
self._update_results = None
if ready_results:
for pid in ready_results:
self.mark_plugin_updated(pid)
# 2. Kick off a new background update if interval elapsed and none running
current_time = time.time()
if (self._update_tick and
current_time - self._last_update_tick_time >= self._update_tick_interval):
thread_alive = (
self._update_thread is not None
and self._update_thread.is_alive()
)
if not thread_alive:
self._last_update_tick_time = current_time
self._update_thread = threading.Thread(
target=self._run_update_tick_background,
daemon=True,
name="vegas-update-tick",
)
self._update_thread.start()
def mark_plugin_updated(self, plugin_id: str) -> None: def mark_plugin_updated(self, plugin_id: str) -> None:
""" """
Notify that a plugin's data has been updated. Notify that a plugin's data has been updated.
@@ -645,10 +683,8 @@ class VegasModeCoordinator:
logger.info("Static pause interrupted by live priority") logger.info("Static pause interrupted by live priority")
return False return False
# Yield immediately if multi-display follower mode becomes active # Keep plugin data fresh during static pause
if self._interrupt_check and self._interrupt_check(): self._drive_background_updates()
logger.info("Static pause interrupted by sync follower mode")
return False
# Sleep in small increments to remain responsive # Sleep in small increments to remain responsive
time.sleep(0.1) time.sleep(0.1)

View File

@@ -329,51 +329,50 @@ class PluginAdapter:
# Save display state to restore after # Save display state to restore after
original_image = self.display_manager.image.copy() original_image = self.display_manager.image.copy()
with self.display_manager.capture_mode(): # Method 1: Try _create_scrolling_display (stocks pattern)
# Method 1: Try _create_scrolling_display (stocks pattern) if hasattr(plugin, '_create_scrolling_display'):
if hasattr(plugin, '_create_scrolling_display'): logger.info(
logger.info( "[%s] Triggering via _create_scrolling_display()",
"[%s] Triggering via _create_scrolling_display()", plugin_id
plugin_id )
) try:
try: plugin._create_scrolling_display()
plugin._create_scrolling_display() cached_image = getattr(scroll_helper, 'cached_image', None)
cached_image = getattr(scroll_helper, 'cached_image', None) if cached_image is not None and isinstance(cached_image, Image.Image):
if cached_image is not None and isinstance(cached_image, Image.Image):
logger.info(
"[%s] _create_scrolling_display() SUCCESS: %dx%d",
plugin_id, cached_image.width, cached_image.height
)
return cached_image
except (AttributeError, TypeError, ValueError, OSError):
logger.exception(
"[%s] _create_scrolling_display() failed", plugin_id
)
# Method 2: Try display(force_clear=True) which typically builds scroll content
if hasattr(plugin, 'display'):
logger.info(
"[%s] Triggering via display(force_clear=True)",
plugin_id
)
try:
self.display_manager.clear()
plugin.display(force_clear=True)
cached_image = getattr(scroll_helper, 'cached_image', None)
if cached_image is not None and isinstance(cached_image, Image.Image):
logger.info(
"[%s] display(force_clear=True) SUCCESS: %dx%d",
plugin_id, cached_image.width, cached_image.height
)
return cached_image
logger.info( logger.info(
"[%s] display(force_clear=True) did not populate cached_image", "[%s] _create_scrolling_display() SUCCESS: %dx%d",
plugin_id plugin_id, cached_image.width, cached_image.height
) )
except (AttributeError, TypeError, ValueError, OSError): return cached_image
logger.exception( except (AttributeError, TypeError, ValueError, OSError):
"[%s] display(force_clear=True) failed", plugin_id logger.exception(
"[%s] _create_scrolling_display() failed", plugin_id
)
# Method 2: Try display(force_clear=True) which typically builds scroll content
if hasattr(plugin, 'display'):
logger.info(
"[%s] Triggering via display(force_clear=True)",
plugin_id
)
try:
self.display_manager.clear()
plugin.display(force_clear=True)
cached_image = getattr(scroll_helper, 'cached_image', None)
if cached_image is not None and isinstance(cached_image, Image.Image):
logger.info(
"[%s] display(force_clear=True) SUCCESS: %dx%d",
plugin_id, cached_image.width, cached_image.height
) )
return cached_image
logger.info(
"[%s] display(force_clear=True) did not populate cached_image",
plugin_id
)
except (AttributeError, TypeError, ValueError, OSError):
logger.exception(
"[%s] display(force_clear=True) failed", plugin_id
)
logger.info( logger.info(
"[%s] Could not trigger scroll content generation", "[%s] Could not trigger scroll content generation",
@@ -409,7 +408,10 @@ class PluginAdapter:
original_image = self.display_manager.image.copy() original_image = self.display_manager.image.copy()
logger.info("[%s] Fallback: saved original display state", plugin_id) logger.info("[%s] Fallback: saved original display state", plugin_id)
# Ensure plugin has fresh data before capturing # Lightweight in-memory data refresh before capturing.
# Full update() is intentionally skipped here — the background
# update tick in the Vegas coordinator handles periodic API
# refreshes so we don't block the content-fetch thread.
has_update_data = hasattr(plugin, 'update_data') has_update_data = hasattr(plugin, 'update_data')
logger.info("[%s] Fallback: has update_data=%s", plugin_id, has_update_data) logger.info("[%s] Fallback: has update_data=%s", plugin_id, has_update_data)
if has_update_data: if has_update_data:
@@ -419,24 +421,21 @@ class PluginAdapter:
except (AttributeError, RuntimeError, OSError): except (AttributeError, RuntimeError, OSError):
logger.exception("[%s] Fallback: update_data() failed", plugin_id) logger.exception("[%s] Fallback: update_data() failed", plugin_id)
# Clear and call plugin display — use capture_mode to suppress hardware writes # Clear and call plugin display
# that plugins may trigger internally via update_display(). self.display_manager.clear()
with self.display_manager.capture_mode(): logger.info("[%s] Fallback: display cleared, calling display()", plugin_id)
self.display_manager.clear()
logger.info("[%s] Fallback: display cleared, calling display()", plugin_id)
# First try without force_clear (some plugins behave better this way) # First try without force_clear (some plugins behave better this way)
try: try:
plugin.display() plugin.display()
logger.info("[%s] Fallback: display() called successfully", plugin_id) logger.info("[%s] Fallback: display() called successfully", plugin_id)
except TypeError: except TypeError:
# Plugin may require force_clear argument # Plugin may require force_clear argument
logger.info("[%s] Fallback: display() failed, trying with force_clear=True", plugin_id) logger.info("[%s] Fallback: display() failed, trying with force_clear=True", plugin_id)
plugin.display(force_clear=True) plugin.display(force_clear=True)
# Capture the result
captured = self.display_manager.image.copy()
# Capture the result
captured = self.display_manager.image.copy()
logger.info( logger.info(
"[%s] Fallback: captured frame %dx%d, mode=%s", "[%s] Fallback: captured frame %dx%d, mode=%s",
plugin_id, captured.width, captured.height, captured.mode plugin_id, captured.width, captured.height, captured.mode
@@ -455,10 +454,9 @@ class PluginAdapter:
plugin_id plugin_id
) )
# Try once more with force_clear=True # Try once more with force_clear=True
with self.display_manager.capture_mode(): self.display_manager.clear()
self.display_manager.clear() plugin.display(force_clear=True)
plugin.display(force_clear=True) captured = self.display_manager.image.copy()
captured = self.display_manager.image.copy()
is_blank, bright_ratio = self._is_blank_image(captured, return_ratio=True) is_blank, bright_ratio = self._is_blank_image(captured, return_ratio=True)
logger.info( logger.info(
@@ -587,6 +585,28 @@ class PluginAdapter:
else: else:
self._content_cache.clear() self._content_cache.clear()
def invalidate_plugin_scroll_cache(self, plugin: 'BasePlugin', plugin_id: str) -> None:
"""
Clear a plugin's scroll_helper cache so Vegas re-fetches fresh visuals.
Uses scroll_helper.clear_cache() to reset all cached state (cached_image,
cached_array, total_scroll_width, scroll_position, etc.) — not just the
image. Without this, plugins that use scroll_helper (stocks, news,
odds-ticker, etc.) would keep serving stale scroll images even after
their data refreshes.
Args:
plugin: Plugin instance
plugin_id: Plugin identifier
"""
scroll_helper = getattr(plugin, 'scroll_helper', None)
if scroll_helper is None:
return
if getattr(scroll_helper, 'cached_image', None) is not None:
scroll_helper.clear_cache()
logger.debug("[%s] Cleared scroll_helper cache", plugin_id)
def get_content_type(self, plugin: 'BasePlugin', plugin_id: str) -> str: def get_content_type(self, plugin: 'BasePlugin', plugin_id: str) -> str:
""" """
Get the type of content a plugin provides. Get the type of content a plugin provides.

View File

@@ -52,10 +52,6 @@ class RenderPipeline:
self.config = config self.config = config
self.display_manager = display_manager self.display_manager = display_manager
self.stream_manager = stream_manager 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) # Display dimensions (handle both property and method access patterns)
self.display_width = ( self.display_width = (
@@ -206,26 +202,8 @@ class RenderPipeline:
# Update scroll position # Update scroll position
self.scroll_helper.update_scroll_position() self.scroll_helper.update_scroll_position()
# Determine if the cycle is done. # Check if cycle is complete
# if self.scroll_helper.is_scroll_complete():
# scroll_helper considers a cycle complete only after
# total_distance_scrolled >= total_scroll_width + display_width.
# That extra display_width of travel causes a "wrap-around" phase
# where scroll_position resets to ~0 and the first plugin's content
# re-enters from the right — the user sees this 2-3 s of re-entry
# as "a plugin partially displaying before the next one starts."
#
# We end the cycle as soon as total_distance_scrolled reaches
# total_scroll_width (the wrap-around point), before any second-pass
# content becomes visible. The scroll_helper's own is_scroll_complete()
# check is kept as a fallback for any edge-cases where that threshold
# is never hit.
at_wrap_point = (
not self._cycle_complete and
self.scroll_helper.total_distance_scrolled >= self.scroll_helper.total_scroll_width
)
if at_wrap_point or self.scroll_helper.is_scroll_complete():
if not self._cycle_complete: if not self._cycle_complete:
self._cycle_complete = True self._cycle_complete = True
self.stats['scroll_cycles'] += 1 self.stats['scroll_cycles'] += 1
@@ -233,17 +211,6 @@ class RenderPipeline:
"Scroll cycle complete after %.1fs", "Scroll cycle complete after %.1fs",
time.time() - self._cycle_start_time time.time() - self._cycle_start_time
) )
# Push blank immediately so the hardware never shows any
# post-wrap content while the coordinator recomposes the
# next cycle (~100 ms).
try:
from PIL import Image as _Image
blank = _Image.new('RGB', (self.display_width, self.display_height))
self.display_manager.image = blank
self.display_manager.update_display()
except Exception:
logger.exception("Failed to write blank frame to display at cycle end")
return True # Cycle done; coordinator starts new cycle next frame
# Get visible portion # Get visible portion
visible_frame = self.scroll_helper.get_visible_portion() visible_frame = self.scroll_helper.get_visible_portion()
@@ -254,15 +221,6 @@ class RenderPipeline:
self.display_manager.image = visible_frame self.display_manager.image = visible_frame
self.display_manager.update_display() self.display_manager.update_display()
# Multi-display sync: send scroll position to follower.
# The follower renders from its own cached_array (kept identical to the
# 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:
self._last_sync_send = now
self.sync_manager.send_scroll_x(self.scroll_helper.scroll_position)
# Update scrolling state # Update scrolling state
self.display_manager.set_scrolling_state(True) self.display_manager.set_scrolling_state(True)
@@ -302,38 +260,33 @@ class RenderPipeline:
if self._cycle_complete: if self._cycle_complete:
return True return True
# 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.
if self.sync_manager is not None:
return False
# Check if we need more content in the buffer # Check if we need more content in the buffer
buffer_status = self.stream_manager.get_buffer_status() buffer_status = self.stream_manager.get_buffer_status()
if buffer_status['staging_count'] > 0: if buffer_status['staging_count'] > 0:
return True return True
# Trigger recompose when pending updates affect visible segments
if self.stream_manager.has_pending_updates_for_visible_segments():
return True
return False return False
def hot_swap_content(self) -> bool: def hot_swap_content(self) -> bool:
""" """
Hot-swap to new composed content. Hot-swap to new composed content.
Called when staging buffer has updated content. Called when staging buffer has updated content or pending updates exist.
Swaps atomically to prevent visual glitches. Preserves scroll position for mid-cycle updates to prevent visual jumps.
Returns: Returns:
True if swap occurred True if swap occurred
""" """
try: try:
# Snapshot position before swap so we can reposition after. # Save scroll position for mid-cycle updates
# The new image has completely different content — if scroll_position saved_position = self.scroll_helper.scroll_position
# is left unchanged it lands at an arbitrary mid-content point in the saved_total_distance = self.scroll_helper.total_distance_scrolled
# new image, causing a visible jump on both displays. saved_total_width = max(1, self.scroll_helper.total_scroll_width)
old_width = self.scroll_helper.total_scroll_width was_mid_cycle = not self._cycle_complete
old_pos = self.scroll_helper.scroll_position
# Process any pending updates # Process any pending updates
self.stream_manager.process_updates() self.stream_manager.process_updates()
@@ -341,24 +294,20 @@ class RenderPipeline:
# Recompose with updated content # Recompose with updated content
if self.compose_scroll_content(): if self.compose_scroll_content():
# Map scroll position proportionally into the new image width so
# we resume at the same relative progress through the content.
# This keeps the visual tempo consistent and avoids the jump that
# occurred when old scroll_position landed arbitrarily in new image.
new_width = self.scroll_helper.total_scroll_width
if old_width > 0 and new_width > 0:
ratio = (old_pos % old_width) / old_width
self.scroll_helper.scroll_position = ratio * new_width
else:
self.scroll_helper.scroll_position = 0.0
self.stats['hot_swaps'] += 1 self.stats['hot_swaps'] += 1
logger.debug( # Restore scroll position for mid-cycle updates so the
"Hot-swap completed: scroll repositioned %.0f%.0f (%.1f%% of new %dpx image)", # scroll continues from where it was instead of jumping to 0
old_pos, self.scroll_helper.scroll_position, if was_mid_cycle:
(self.scroll_helper.scroll_position / new_width * 100) if new_width else 0, new_total_width = max(1, self.scroll_helper.total_scroll_width)
new_width, progress_ratio = min(saved_total_distance / saved_total_width, 0.999)
) self.scroll_helper.total_distance_scrolled = progress_ratio * new_total_width
self.scroll_helper.scroll_position = min(
saved_position,
float(new_total_width - 1)
)
self.scroll_helper.scroll_complete = False
self._cycle_complete = False
logger.debug("Hot-swap completed (mid_cycle_restore=%s)", was_mid_cycle)
return True return True
return False return False
@@ -393,29 +342,7 @@ class RenderPipeline:
return False return False
# Compose new scroll content # Compose new scroll content
result = self.compose_scroll_content() return self.compose_scroll_content()
if result and self.sync_manager:
# When sync is active, start the leader at display_width instead of 0.
# This skips the initial black gap so the leader immediately shows content.
# The follower starts at position 0 (the gap) which looks like a clean
# blank transition rather than near-end content wrapping around.
self.scroll_helper.scroll_position = float(self.display_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(
target=self.sync_manager.send_scroll_image,
args=(self.scroll_helper.cached_image,),
daemon=True, name="sync-image-push"
).start()
return result
def get_current_scroll_info(self) -> Dict[str, Any]: def get_current_scroll_info(self) -> Dict[str, Any]:
"""Get current scroll state information.""" """Get current scroll state information."""

View File

@@ -226,24 +226,13 @@ def serve_plugin_asset(plugin_id, filename):
'message': 'Internal server error' 'message': 'Internal server error'
}), 500 }), 500
# Prime psutil CPU measurement once at startup so interval=None returns a real value
try:
import psutil as _psutil_prime
_psutil_prime.cpu_percent(interval=None)
except ImportError:
pass
# Cached AP mode check — avoids creating a WiFiManager per request # Cached AP mode check — avoids creating a WiFiManager per request
_ap_mode_cache = {'value': False, 'timestamp': 0} _ap_mode_cache = {'value': False, 'timestamp': 0}
_AP_MODE_CACHE_TTL = 30 # seconds — AP mode is user-initiated; 30s is fine _AP_MODE_CACHE_TTL = 5 # seconds
# Cached ledmatrix service status for SSE stats stream
_ledmatrix_service_cache = {'active': False, 'timestamp': 0}
_LEDMATRIX_SERVICE_CACHE_TTL = 15 # seconds
def is_ap_mode_active(): def is_ap_mode_active():
""" """
Check if access point mode is currently active (cached, 30s TTL). Check if access point mode is currently active (cached, 5s TTL).
Uses a direct systemctl check instead of instantiating WiFiManager. Uses a direct systemctl check instead of instantiating WiFiManager.
""" """
now = time.time() now = time.time()
@@ -455,11 +444,10 @@ def system_status_generator():
# Try to import psutil for system stats # Try to import psutil for system stats
try: try:
import psutil import psutil
# interval=None is non-blocking; primed at module startup above cpu_percent = round(psutil.cpu_percent(interval=1), 1)
cpu_percent = round(psutil.cpu_percent(interval=None), 1)
memory = psutil.virtual_memory() memory = psutil.virtual_memory()
memory_used_percent = round(memory.percent, 1) memory_used_percent = round(memory.percent, 1)
# Try to get CPU temperature (Raspberry Pi specific) # Try to get CPU temperature (Raspberry Pi specific)
cpu_temp = 0 cpu_temp = 0
try: try:
@@ -467,23 +455,20 @@ def system_status_generator():
cpu_temp = round(float(f.read()) / 1000.0, 1) cpu_temp = round(float(f.read()) / 1000.0, 1)
except (OSError, ValueError): except (OSError, ValueError):
pass pass
except ImportError: except ImportError:
cpu_percent = 0 cpu_percent = 0
memory_used_percent = 0 memory_used_percent = 0
cpu_temp = 0 cpu_temp = 0
# Check if display service is running (cached to avoid per-client subprocess forks) # Check if display service is running
now = time.time() service_active = False
if (now - _ledmatrix_service_cache['timestamp']) >= _LEDMATRIX_SERVICE_CACHE_TTL: try:
try: result = subprocess.run(['systemctl', 'is-active', 'ledmatrix'],
result = subprocess.run(['systemctl', 'is-active', 'ledmatrix'], capture_output=True, text=True, timeout=2)
capture_output=True, text=True, timeout=2) service_active = result.stdout.strip() == 'active'
_ledmatrix_service_cache['active'] = result.stdout.strip() == 'active' except (subprocess.SubprocessError, OSError):
except (subprocess.SubprocessError, OSError): pass
pass
_ledmatrix_service_cache['timestamp'] = now
service_active = _ledmatrix_service_cache['active']
status = { status = {
'timestamp': time.time(), 'timestamp': time.time(),
@@ -561,7 +546,7 @@ def display_preview_generator():
except Exception as e: except Exception as e:
yield {'error': str(e)} yield {'error': str(e)}
time.sleep(1.0) # Check once per second — halves PIL encode overhead vs 0.5s time.sleep(0.5) # Check 2 times per second (reduced frequency for better performance)
# Logs generator for SSE # Logs generator for SSE
def logs_generator(): def logs_generator():

File diff suppressed because it is too large Load Diff

View File

@@ -1223,21 +1223,13 @@ function initializePlugins() {
// during a re-swap, fetch fresh data including GitHub commit/version info. // during a re-swap, fetch fresh data including GitHub commit/version info.
const isReswapWarm = !!window.pluginManager._reswap && !storeCacheExpired(); const isReswapWarm = !!window.pluginManager._reswap && !storeCacheExpired();
window.pluginManager._reswap = false; window.pluginManager._reswap = false;
// Await the installed-plugins fetch so window.installedPlugins is populated before
// Fire both requests in parallel so the store doesn't wait for installed plugins. // searchPluginStore renders Installed/Reinstall badges against it.
// The store renders install/update badges using window.installedPlugins || [] so loadInstalledPlugins().catch(err => {
// it works with an empty list. When installed plugins finish loading we do a console.error('[PluginStore] loadInstalledPlugins failed:', err);
// lightweight re-render from the already-cached store data to refresh the badges. }).finally(() => {
searchPluginStore(!isReswapWarm); searchPluginStore(!isReswapWarm);
loadInstalledPlugins() });
.catch(err => console.error('[PluginStore] loadInstalledPlugins failed:', err))
.then(() => {
// Re-render store from cache to update install/update/reinstall badges now
// that window.installedPlugins is populated. No network call — instant.
if (typeof applyStoreFiltersAndSort === 'function') {
applyStoreFiltersAndSort(true);
}
});
// Setup search functionality (with guard against duplicate listeners) // Setup search functionality (with guard against duplicate listeners)
const searchInput = document.getElementById('plugin-search'); const searchInput = document.getElementById('plugin-search');
@@ -1426,9 +1418,6 @@ function renderInstalledPlugins(plugins) {
} }
} }
// Remove skeleton cards before rendering real content
container.querySelectorAll('.installed-skeleton').forEach(el => el.remove());
if (plugins.length === 0) { if (plugins.length === 0) {
container.innerHTML = ` container.innerHTML = `
<div class="col-span-full empty-state"> <div class="col-span-full empty-state">

View File

@@ -49,9 +49,9 @@
name="chain_length" name="chain_length"
value="{{ main_config.display.hardware.chain_length or 2 }}" value="{{ main_config.display.hardware.chain_length or 2 }}"
min="1" min="1"
max="8" max="32"
class="form-control"> class="form-control">
<p class="mt-1 text-sm text-gray-600">Number of LED panels chained together</p> <p class="mt-1 text-sm text-gray-600">Number of LED panels chained together (e.g. 2 for 128×32, 5 for 320×32)</p>
</div> </div>
<div class="form-group"> <div class="form-group">
@@ -380,74 +380,12 @@
<!-- Plugin order list will be populated by JavaScript --> <!-- Plugin order list will be populated by JavaScript -->
<p class="text-sm text-gray-500 italic">Loading plugins...</p> <p class="text-sm text-gray-500 italic">Loading plugins...</p>
</div> </div>
<input type="hidden" id="vegas_plugin_order_value" name="vegas_plugin_order" value='{{ main_config.display.get("vegas_scroll", {}).get("plugin_order", [])|tojson }}'> <input type="hidden" id="vegas_plugin_order_value" name="vegas_plugin_order" value="{{ main_config.display.get('vegas_scroll', {}).get('plugin_order', [])|tojson }}">
<input type="hidden" id="vegas_excluded_plugins_value" name="vegas_excluded_plugins" value='{{ main_config.display.get("vegas_scroll", {}).get("excluded_plugins", [])|tojson }}'> <input type="hidden" id="vegas_excluded_plugins_value" name="vegas_excluded_plugins" value="{{ main_config.display.get('vegas_scroll', {}).get('excluded_plugins', [])|tojson }}">
</div> </div>
</div> </div>
</div> </div>
<!-- Multi-Display Sync Settings -->
<div class="bg-gray-50 rounded-lg p-4 mt-6">
<div class="flex items-center justify-between mb-4">
<div>
<h3 class="text-md font-medium text-gray-900">
<i class="fas fa-clone mr-2"></i>Multi-Display Sync
</h3>
<p class="mt-1 text-sm text-gray-600">
Extend scrolling content across two LED matrix display units over WiFi.
Both displays must have identical rows and cols. Chain length may differ.
</p>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-group">
<label for="sync_role" class="block text-sm font-medium text-gray-700">Role</label>
<select id="sync_role" name="sync_role" class="form-control" onchange="updateSyncUI()">
<option value="standalone" {% if main_config.get('sync', {}).get('role', 'standalone') == 'standalone' %}selected{% endif %}>Standalone (disabled)</option>
<option value="leader" {% if main_config.get('sync', {}).get('role', 'standalone') == 'leader' %}selected{% endif %}>Leader (drives scroll)</option>
<option value="follower" {% if main_config.get('sync', {}).get('role', 'standalone') == 'follower' %}selected{% endif %}>Follower (receives frames)</option>
</select>
<p class="mt-1 text-sm text-gray-600">Set Leader on one Pi, Follower on the other. Restart required after changing.</p>
</div>
<div class="form-group">
<label for="sync_port" class="block text-sm font-medium text-gray-700">UDP Port</label>
<input type="number"
id="sync_port"
name="sync_port"
value="{{ main_config.get('sync', {}).get('port', 5765) }}"
min="1024"
max="65535"
class="form-control">
<p class="mt-1 text-sm text-gray-600">
Must match on both Pis. If ufw is active:
<code class="text-xs bg-gray-200 px-1 rounded">sudo ufw allow {{ main_config.get('sync', {}).get('port', 5765) }}/udp</code>
</p>
</div>
<div class="form-group" id="sync_position_group" style="display:none">
<label for="sync_follower_position" class="block text-sm font-medium text-gray-700">Position</label>
<select id="sync_follower_position" name="sync_follower_position" class="form-control">
<option value="left" {% if main_config.get('sync', {}).get('follower_position', 'left') == 'left' %}selected{% endif %}>Left of leader</option>
<option value="right" {% if main_config.get('sync', {}).get('follower_position', 'left') == 'right' %}selected{% endif %}>Right of leader</option>
</select>
<p class="mt-1 text-sm text-gray-600">Which side of the leader display this unit sits on.</p>
</div>
</div>
<!-- Live status indicator (populated by JS) -->
<div id="sync_status_bar" class="mt-4 hidden">
<div id="sync_status_content" class="flex items-start space-x-2 p-3 rounded-lg border text-sm"></div>
</div>
<!-- Incompatibility detail (shown when error) -->
<div id="sync_error_detail" class="mt-2 hidden">
<p class="text-xs text-yellow-700 bg-yellow-50 border border-yellow-200 rounded p-2" id="sync_error_text"></p>
<p class="text-xs text-gray-500 mt-1">rows and cols must match between displays. chain_length may differ.</p>
</div>
</div>
<!-- Submit Button --> <!-- Submit Button -->
<div class="flex justify-end"> <div class="flex justify-end">
<button type="submit" <button type="submit"
@@ -705,111 +643,4 @@ if (typeof window.fixInvalidNumberInputs !== 'function') {
initPluginOrderList(); initPluginOrderList();
} }
})(); })();
// Multi-Display Sync UI
(function() {
function updateSyncUI() {
const role = document.getElementById('sync_role').value;
const bar = document.getElementById('sync_status_bar');
const posGroup = document.getElementById('sync_position_group');
if (role === 'standalone') {
bar.classList.add('hidden');
document.getElementById('sync_error_detail').classList.add('hidden');
posGroup.style.display = 'none';
} else {
bar.classList.remove('hidden');
posGroup.style.display = role === 'follower' ? '' : 'none';
pollSyncStatus();
}
}
window.updateSyncUI = updateSyncUI;
function pollSyncStatus() {
const role = document.getElementById('sync_role') && document.getElementById('sync_role').value;
if (!role || role === 'standalone') return;
fetch('/api/v3/sync/status')
.then(r => r.json())
.then(resp => {
const d = resp.data || {};
renderSyncStatus(d);
})
.catch(() => {
renderSyncStatus({state: 'unknown'});
});
}
function renderSyncStatus(d) {
const content = document.getElementById('sync_status_content');
const errorDetail = document.getElementById('sync_error_detail');
const errorText = document.getElementById('sync_error_text');
if (!content) return;
const state = d.state || 'unknown';
const role = d.role || 'unknown';
let icon, colorClass, text;
if (state === 'connected' || state === 'follower') {
icon = '●';
colorClass = 'bg-green-50 border-green-200 text-green-800';
const peer = d.peer_ip || d.leader_ip || 'peer';
text = role === 'leader'
? `Follower connected — ${peer} (chain ${d.peer_chain || '?'})`
: `Receiving from leader — ${peer}`;
errorDetail.classList.add('hidden');
} else if (state === 'incompatible') {
icon = '⚠';
colorClass = 'bg-yellow-50 border-yellow-200 text-yellow-800';
text = `Follower connected but incompatible panels`;
if (d.error) {
errorText.textContent = d.error;
errorDetail.classList.remove('hidden');
}
} else if (state === 'no_peer' || state === 'standalone') {
icon = '○';
colorClass = 'bg-gray-50 border-gray-200 text-gray-600';
text = role === 'leader' ? 'No follower detected' : 'Searching for leader…';
errorDetail.classList.add('hidden');
} else if (state === 'starting') {
icon = '○';
colorClass = 'bg-gray-50 border-gray-200 text-gray-500';
text = 'Display process starting…';
errorDetail.classList.add('hidden');
} else {
icon = '✕';
colorClass = 'bg-red-50 border-red-200 text-red-700';
text = 'Sync status unavailable';
errorDetail.classList.add('hidden');
}
content.className = `flex items-start space-x-2 p-3 rounded-lg border text-sm ${colorClass}`;
content.textContent = '';
const iconSpan = document.createElement('span');
iconSpan.className = 'font-bold text-lg leading-none';
iconSpan.textContent = icon;
const textSpan = document.createElement('span');
textSpan.textContent = text;
content.appendChild(iconSpan);
content.appendChild(textSpan);
}
// Initial UI state and polling — guard against duplicate intervals on re-run
function startSyncPolling() {
updateSyncUI();
if (!window.syncStatusInterval) {
window.syncStatusInterval = setInterval(pollSyncStatus, 5000);
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', startSyncPolling);
} else {
startSyncPolling();
}
})();
</script> </script>

View File

@@ -31,52 +31,7 @@
</div> </div>
<div id="installed-plugins-content" class="block"> <div id="installed-plugins-content" class="block">
<div id="installed-plugins-grid" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-6"> <div id="installed-plugins-grid" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-6">
<!-- Skeleton cards shown while installed plugins load --> <!-- Plugins will be loaded here -->
<div class="installed-skeleton plugin-card animate-pulse">
<div class="flex items-start justify-between mb-4">
<div class="flex-1 min-w-0 space-y-2">
<div class="h-4 bg-gray-200 rounded w-3/4"></div>
<div class="h-3 bg-gray-200 rounded w-1/2"></div>
<div class="h-3 bg-gray-200 rounded w-2/3"></div>
</div>
<div class="h-6 w-10 bg-gray-200 rounded-full ml-4 flex-shrink-0"></div>
</div>
<div class="space-y-2 mb-4">
<div class="h-3 bg-gray-200 rounded w-full"></div>
<div class="h-3 bg-gray-200 rounded w-5/6"></div>
</div>
<div class="h-8 bg-gray-200 rounded w-full mt-auto"></div>
</div>
<div class="installed-skeleton plugin-card animate-pulse hidden md:block">
<div class="flex items-start justify-between mb-4">
<div class="flex-1 min-w-0 space-y-2">
<div class="h-4 bg-gray-200 rounded w-2/3"></div>
<div class="h-3 bg-gray-200 rounded w-1/3"></div>
<div class="h-3 bg-gray-200 rounded w-1/2"></div>
</div>
<div class="h-6 w-10 bg-gray-200 rounded-full ml-4 flex-shrink-0"></div>
</div>
<div class="space-y-2 mb-4">
<div class="h-3 bg-gray-200 rounded w-full"></div>
<div class="h-3 bg-gray-200 rounded w-4/5"></div>
</div>
<div class="h-8 bg-gray-200 rounded w-full mt-auto"></div>
</div>
<div class="installed-skeleton plugin-card animate-pulse hidden lg:block">
<div class="flex items-start justify-between mb-4">
<div class="flex-1 min-w-0 space-y-2">
<div class="h-4 bg-gray-200 rounded w-4/5"></div>
<div class="h-3 bg-gray-200 rounded w-2/5"></div>
<div class="h-3 bg-gray-200 rounded w-3/5"></div>
</div>
<div class="h-6 w-10 bg-gray-200 rounded-full ml-4 flex-shrink-0"></div>
</div>
<div class="space-y-2 mb-4">
<div class="h-3 bg-gray-200 rounded w-full"></div>
<div class="h-3 bg-gray-200 rounded w-3/4"></div>
</div>
<div class="h-8 bg-gray-200 rounded w-full mt-auto"></div>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -248,30 +203,14 @@
</div> </div>
<div id="plugin-store-grid" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-6"> <div id="plugin-store-grid" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-6">
<!-- Loading skeleton — hidden by showStoreLoading(false) when data arrives --> <!-- Loading skeleton -->
<div class="store-loading col-span-full"> <div class="store-loading col-span-full">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-6"> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
{% for _ in range(10) %} <div class="bg-gray-200 rounded-lg p-4 h-48 animate-pulse"></div>
<div class="plugin-card animate-pulse"> <div class="bg-gray-200 rounded-lg p-4 h-48 animate-pulse"></div>
<div class="flex items-start justify-between mb-4"> <div class="bg-gray-200 rounded-lg p-4 h-48 animate-pulse"></div>
<div class="flex-1 min-w-0 space-y-2"> <div class="bg-gray-200 rounded-lg p-4 h-48 animate-pulse"></div>
<div class="h-4 bg-gray-200 rounded w-3/4"></div> <div class="bg-gray-200 rounded-lg p-4 h-48 animate-pulse"></div>
<div class="h-3 bg-gray-200 rounded w-1/2"></div>
</div>
<div class="h-5 w-14 bg-gray-200 rounded-full ml-3 flex-shrink-0"></div>
</div>
<div class="space-y-2 mb-4">
<div class="h-3 bg-gray-200 rounded w-full"></div>
<div class="h-3 bg-gray-200 rounded w-5/6"></div>
<div class="h-3 bg-gray-200 rounded w-4/6"></div>
</div>
<div class="flex gap-2 mt-auto">
<div class="h-3 bg-gray-200 rounded w-12"></div>
<div class="h-3 bg-gray-200 rounded w-16"></div>
</div>
<div class="h-8 bg-gray-200 rounded w-full mt-3"></div>
</div>
{% endfor %}
</div> </div>
</div> </div>
</div> </div>