mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-06-22 12:28:38 +00:00
Compare commits
8 Commits
fix/post-i
...
1c4d5c5271
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c4d5c5271 | ||
|
|
dbb53da31d | ||
|
|
452afacd12 | ||
|
|
3b45a75f75 | ||
|
|
1a0f1c8015 | ||
|
|
b361866679 | ||
|
|
ceb4c4105f | ||
|
|
e9af18cdf1 |
@@ -1,4 +1,5 @@
|
|||||||
# LEDMatrix
|
# LEDMatrix
|
||||||
|
[](https://app.codacy.com/gh/ChuckBuilds/LEDMatrix/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade)
|
||||||
## Welcome to LEDMatrix!
|
## Welcome to LEDMatrix!
|
||||||
Welcome to the LEDMatrix Project! This open-source project enables you to run an information-rich display on a Raspberry Pi connected to an LED RGB Matrix panel. Whether you want to see your calendar, weather forecasts, sports scores, stock prices, or any other information at a glance, LEDMatrix brings it all together.
|
Welcome to the LEDMatrix Project! This open-source project enables you to run an information-rich display on a Raspberry Pi connected to an LED RGB Matrix panel. Whether you want to see your calendar, weather forecasts, sports scores, stock prices, or any other information at a glance, LEDMatrix brings it all together.
|
||||||
|
|
||||||
|
|||||||
@@ -126,6 +126,11 @@
|
|||||||
"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,
|
||||||
|
|||||||
@@ -1086,6 +1086,7 @@ SYSTEMCTL_PATH=$(which systemctl)
|
|||||||
REBOOT_PATH=$(which reboot)
|
REBOOT_PATH=$(which reboot)
|
||||||
POWEROFF_PATH=$(which poweroff)
|
POWEROFF_PATH=$(which poweroff)
|
||||||
BASH_PATH=$(which bash)
|
BASH_PATH=$(which bash)
|
||||||
|
JOURNALCTL_PATH=$(which journalctl 2>/dev/null || true)
|
||||||
|
|
||||||
# Create sudoers content
|
# Create sudoers content
|
||||||
cat > /tmp/ledmatrix_web_sudoers << EOF
|
cat > /tmp/ledmatrix_web_sudoers << EOF
|
||||||
@@ -1101,10 +1102,23 @@ $ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH restart ledmatrix.service
|
|||||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH enable ledmatrix.service
|
$ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH enable ledmatrix.service
|
||||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH disable ledmatrix.service
|
$ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH disable ledmatrix.service
|
||||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH status ledmatrix.service
|
$ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH status ledmatrix.service
|
||||||
|
$ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH is-active ledmatrix
|
||||||
|
$ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH is-active ledmatrix.service
|
||||||
|
$ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH start ledmatrix-web.service
|
||||||
|
$ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH stop ledmatrix-web.service
|
||||||
|
$ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH restart ledmatrix-web.service
|
||||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: $PYTHON_PATH $PROJECT_ROOT_DIR/display_controller.py
|
$ACTUAL_USER ALL=(ALL) NOPASSWD: $PYTHON_PATH $PROJECT_ROOT_DIR/display_controller.py
|
||||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_ROOT_DIR/start_display.sh
|
$ACTUAL_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_ROOT_DIR/start_display.sh
|
||||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_ROOT_DIR/stop_display.sh
|
$ACTUAL_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_ROOT_DIR/stop_display.sh
|
||||||
|
$ACTUAL_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_ROOT_DIR/scripts/fix_perms/safe_plugin_rm.sh *
|
||||||
EOF
|
EOF
|
||||||
|
if [ -n "$JOURNALCTL_PATH" ]; then
|
||||||
|
cat >> /tmp/ledmatrix_web_sudoers << EOF
|
||||||
|
$ACTUAL_USER ALL=(ALL) NOPASSWD: $JOURNALCTL_PATH -u ledmatrix.service *
|
||||||
|
$ACTUAL_USER ALL=(ALL) NOPASSWD: $JOURNALCTL_PATH -u ledmatrix *
|
||||||
|
$ACTUAL_USER ALL=(ALL) NOPASSWD: $JOURNALCTL_PATH -t ledmatrix *
|
||||||
|
EOF
|
||||||
|
fi
|
||||||
|
|
||||||
if [ -f "$SUDOERS_FILE" ] && cmp -s /tmp/ledmatrix_web_sudoers "$SUDOERS_FILE"; then
|
if [ -f "$SUDOERS_FILE" ] && cmp -s /tmp/ledmatrix_web_sudoers "$SUDOERS_FILE"; then
|
||||||
echo "Sudoers configuration already up to date"
|
echo "Sudoers configuration already up to date"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
requests>=2.28.0
|
requests>=2.28.0
|
||||||
Pillow>=9.1.0
|
Pillow>=12.2.0
|
||||||
pytz>=2022.1
|
pytz>=2022.1
|
||||||
numpy>=1.24.0
|
numpy>=1.24.0
|
||||||
|
|||||||
@@ -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=$((success_count + 1))
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
|
|||||||
@@ -89,9 +89,9 @@ TEMP_SUDOERS="/tmp/ledmatrix_web_sudoers_$$"
|
|||||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH status ledmatrix.service"
|
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH status ledmatrix.service"
|
||||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH is-active ledmatrix"
|
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH is-active ledmatrix"
|
||||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH is-active ledmatrix.service"
|
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH is-active ledmatrix.service"
|
||||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH start ledmatrix-web"
|
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH start ledmatrix-web.service"
|
||||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH stop ledmatrix-web"
|
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH stop ledmatrix-web.service"
|
||||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH restart ledmatrix-web"
|
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH restart ledmatrix-web.service"
|
||||||
|
|
||||||
# Optional: journalctl (non-critical — skip if not found)
|
# Optional: journalctl (non-critical — skip if not found)
|
||||||
if [ -n "$JOURNALCTL_PATH" ]; then
|
if [ -n "$JOURNALCTL_PATH" ]; then
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import sys
|
|||||||
import time
|
import time
|
||||||
import logging
|
import logging
|
||||||
import signal
|
import signal
|
||||||
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
# Add project root to path (parent of scripts/utils/)
|
# Add project root to path (parent of scripts/utils/)
|
||||||
@@ -43,6 +44,10 @@ class WiFiMonitorDaemon:
|
|||||||
self.wifi_manager = WiFiManager()
|
self.wifi_manager = WiFiManager()
|
||||||
self.running = True
|
self.running = True
|
||||||
self.last_state = None
|
self.last_state = None
|
||||||
|
# Counts consecutive checks where nmcli says "connected" but internet is unreachable.
|
||||||
|
# After _nm_restart_threshold failures, NetworkManager is restarted as a recovery step.
|
||||||
|
self._consecutive_internet_failures = 0
|
||||||
|
self._nm_restart_threshold = 5 # ~2.5 min at 30s interval
|
||||||
|
|
||||||
# Register signal handlers for graceful shutdown
|
# Register signal handlers for graceful shutdown
|
||||||
signal.signal(signal.SIGINT, self._signal_handler)
|
signal.signal(signal.SIGINT, self._signal_handler)
|
||||||
@@ -122,6 +127,43 @@ class WiFiMonitorDaemon:
|
|||||||
else:
|
else:
|
||||||
logger.debug(f"Status check: WiFi=disconnected, Ethernet={updated_ethernet}, AP={updated_status.ap_mode_active}")
|
logger.debug(f"Status check: WiFi=disconnected, Ethernet={updated_ethernet}, AP={updated_status.ap_mode_active}")
|
||||||
|
|
||||||
|
# Escalating recovery: if nmcli reports connected but actual internet
|
||||||
|
# is unreachable for several consecutive checks, restart NetworkManager.
|
||||||
|
# This is done HERE (not inside check_and_manage_ap_mode) to keep the
|
||||||
|
# AP-enable trigger clean and avoid false-positive AP enables from
|
||||||
|
# transient packet loss on otherwise working WiFi.
|
||||||
|
if updated_status.connected and not updated_status.ap_mode_active:
|
||||||
|
if not self.wifi_manager.check_internet_connectivity():
|
||||||
|
self._consecutive_internet_failures += 1
|
||||||
|
logger.warning(
|
||||||
|
f"Internet unreachable despite nmcli connection "
|
||||||
|
f"({self._consecutive_internet_failures}/{self._nm_restart_threshold})"
|
||||||
|
)
|
||||||
|
if self._consecutive_internet_failures >= self._nm_restart_threshold:
|
||||||
|
logger.warning("Restarting NetworkManager to recover internet connectivity")
|
||||||
|
try:
|
||||||
|
subprocess.run(
|
||||||
|
["/usr/bin/systemctl", "restart", "NetworkManager"],
|
||||||
|
capture_output=True, timeout=20, check=True
|
||||||
|
)
|
||||||
|
self._consecutive_internet_failures = 0
|
||||||
|
# NM restart causes a brief WiFi drop; reset the AP-mode grace
|
||||||
|
# counter so that transient disconnect doesn't count toward
|
||||||
|
# triggering AP mode.
|
||||||
|
self.wifi_manager._disconnected_checks = 0
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
logger.error(f"NetworkManager restart failed (rc={e.returncode}); "
|
||||||
|
"resetting failure counter to avoid tight retry loop")
|
||||||
|
self._consecutive_internet_failures = 0
|
||||||
|
except (subprocess.SubprocessError, OSError) as e:
|
||||||
|
logger.error(f"NetworkManager restart error: {e}; "
|
||||||
|
"resetting failure counter to avoid tight retry loop")
|
||||||
|
self._consecutive_internet_failures = 0
|
||||||
|
else:
|
||||||
|
self._consecutive_internet_failures = 0
|
||||||
|
else:
|
||||||
|
self._consecutive_internet_failures = 0
|
||||||
|
|
||||||
# Sleep until next check
|
# Sleep until next check
|
||||||
time.sleep(self.check_interval)
|
time.sleep(self.check_interval)
|
||||||
|
|
||||||
|
|||||||
651
src/common/sync_manager.py
Normal file
651
src/common/sync_manager.py
Normal file
@@ -0,0 +1,651 @@
|
|||||||
|
"""
|
||||||
|
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 ~20–50KB,
|
||||||
|
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)
|
||||||
@@ -16,6 +16,7 @@ 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,9 +34,6 @@ class DisplayController:
|
|||||||
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'
|
||||||
@@ -70,6 +68,38 @@ class DisplayController:
|
|||||||
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)
|
||||||
@@ -82,7 +112,6 @@ 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")
|
||||||
@@ -396,20 +425,64 @@ 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
|
# Set up interrupt checker for on-demand/wifi status and follower mode
|
||||||
|
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(
|
||||||
self._check_vegas_interrupt,
|
_vegas_interrupt,
|
||||||
check_interval=10 # Check every 10 frames (~80ms at 125 FPS)
|
check_interval=10 # Check every 10 frames (~80ms at 125 FPS)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Set up plugin update tick to keep data fresh during Vegas mode
|
# Run plugin updates inside the Vegas loop so the inter-iteration
|
||||||
self.vegas_coordinator.set_update_tick(
|
# gap is <1 ms (nothing left for _tick_plugin_updates() to do).
|
||||||
self._tick_plugin_updates_for_vegas,
|
self.vegas_coordinator.set_update_callback(self._tick_plugin_updates)
|
||||||
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
|
||||||
@@ -444,44 +517,9 @@ 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."""
|
||||||
# Get fresh config from config_service to support hot-reload
|
schedule_config = self.config.get('schedule', {})
|
||||||
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:
|
||||||
@@ -498,7 +536,7 @@ class DisplayController:
|
|||||||
return
|
return
|
||||||
|
|
||||||
# Get configured timezone, default to UTC
|
# Get configured timezone, default to UTC
|
||||||
timezone_str = current_config.get('timezone', 'UTC')
|
timezone_str = self.config.get('timezone', 'UTC')
|
||||||
try:
|
try:
|
||||||
tz = pytz.timezone(timezone_str)
|
tz = pytz.timezone(timezone_str)
|
||||||
except pytz.UnknownTimeZoneError:
|
except pytz.UnknownTimeZoneError:
|
||||||
@@ -596,18 +634,15 @@ 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 = current_config.get('display', {}).get('hardware', {}).get('brightness', 90)
|
normal_brightness = self.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 = current_config.get('dim_schedule', {})
|
dim_config = self.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):
|
||||||
@@ -615,7 +650,7 @@ class DisplayController:
|
|||||||
return normal_brightness
|
return normal_brightness
|
||||||
|
|
||||||
# Get configured timezone
|
# Get configured timezone
|
||||||
timezone_str = current_config.get('timezone', 'UTC')
|
timezone_str = self.config.get('timezone', 'UTC')
|
||||||
try:
|
try:
|
||||||
tz = pytz.timezone(timezone_str)
|
tz = pytz.timezone(timezone_str)
|
||||||
except pytz.UnknownTimeZoneError:
|
except pytz.UnknownTimeZoneError:
|
||||||
@@ -722,21 +757,83 @@ 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")
|
||||||
|
|
||||||
def _tick_plugin_updates_throttled(self, min_interval: float = 0.0):
|
_FOLLOWER_SEND_INTERVAL = 1.0 / 90 # raw bytes are cheap; 90fps > follower render rate
|
||||||
"""Throttled version of _tick_plugin_updates for high-FPS loops.
|
|
||||||
|
|
||||||
Args:
|
def _follower_rebuild_scroll_image(self) -> None:
|
||||||
min_interval: Minimum seconds between calls. When <= 0 the
|
"""Follower: rebuild the local Vegas scroll image so both Pis render from
|
||||||
call passes straight through to _tick_plugin_updates so
|
the same fresh plugin data. Called at startup (after Vegas initializes)
|
||||||
plugin-configured update_interval values are never capped.
|
and each time the leader broadcasts a new-cycle signal. Runs in a daemon
|
||||||
|
thread so it never blocks the 60fps render loop.
|
||||||
"""
|
"""
|
||||||
if min_interval <= 0:
|
try:
|
||||||
self._tick_plugin_updates()
|
vc = getattr(self, 'vegas_coordinator', None)
|
||||||
|
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 - self._last_plugin_tick_time >= min_interval:
|
if now - getattr(self, '_last_follower_send', 0) < self._FOLLOWER_SEND_INTERVAL:
|
||||||
self._last_plugin_tick_time = now
|
return
|
||||||
self._tick_plugin_updates()
|
self._last_follower_send = now
|
||||||
|
|
||||||
|
follower_frame = None
|
||||||
|
width = self.display_manager.width
|
||||||
|
sync_cfg = self.config.get("sync", {})
|
||||||
|
sign = -1 if sync_cfg.get("follower_position", "left") == "left" else 1
|
||||||
|
offset = sign * width
|
||||||
|
|
||||||
|
# 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."""
|
||||||
@@ -1373,6 +1470,88 @@ 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()
|
||||||
@@ -1746,7 +1925,7 @@ class DisplayController:
|
|||||||
)
|
)
|
||||||
|
|
||||||
target_duration = max_duration
|
target_duration = max_duration
|
||||||
start_time = time.monotonic()
|
start_time = time.time()
|
||||||
|
|
||||||
def _should_exit_dynamic(elapsed_time: float) -> bool:
|
def _should_exit_dynamic(elapsed_time: float) -> bool:
|
||||||
if not dynamic_enabled:
|
if not dynamic_enabled:
|
||||||
@@ -1805,34 +1984,19 @@ 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_throttled(min_interval=1.0)
|
self._tick_plugin_updates()
|
||||||
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",
|
||||||
@@ -1862,7 +2026,7 @@ class DisplayController:
|
|||||||
time.sleep(display_interval)
|
time.sleep(display_interval)
|
||||||
self._tick_plugin_updates()
|
self._tick_plugin_updates()
|
||||||
|
|
||||||
elapsed = time.monotonic() - start_time
|
elapsed = time.time() - 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",
|
||||||
@@ -1889,25 +2053,11 @@ 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
|
||||||
@@ -1921,26 +2071,19 @@ 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.monotonic() - start_time
|
elapsed = time.time() - 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.monotonic() - start_time
|
elapsed_total = time.time() - 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
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ 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
|
||||||
@@ -28,6 +29,8 @@ 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
|
||||||
@@ -255,6 +258,22 @@ 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:
|
||||||
@@ -265,6 +284,9 @@ class DisplayManager:
|
|||||||
self._write_snapshot_if_due()
|
self._write_snapshot_if_due()
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if self._capture_mode_active:
|
||||||
|
return # Skip hardware write — content is being captured off-screen
|
||||||
|
|
||||||
# Copy the current image to the offscreen canvas
|
# Copy the current image to the offscreen canvas
|
||||||
self.offscreen_canvas.SetImage(self.image)
|
self.offscreen_canvas.SetImage(self.image)
|
||||||
|
|
||||||
@@ -305,20 +327,22 @@ class DisplayManager:
|
|||||||
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)
|
||||||
|
|
||||||
# Clear both canvases and the underlying matrix to ensure no artifacts
|
if not self._capture_mode_active:
|
||||||
try:
|
# Clear both canvases and the underlying matrix to ensure no artifacts.
|
||||||
self.offscreen_canvas.Clear()
|
# Failures are non-fatal — the image buffer is already black above, so
|
||||||
except Exception:
|
# the next update_display() call will push clean content regardless.
|
||||||
pass
|
try:
|
||||||
try:
|
self.offscreen_canvas.Clear()
|
||||||
self.current_canvas.Clear()
|
except (RuntimeError, OSError) as e:
|
||||||
except Exception:
|
logger.error("Failed to clear offscreen canvas: %s", e)
|
||||||
pass
|
try:
|
||||||
try:
|
self.current_canvas.Clear()
|
||||||
# Extra safety: clear the matrix front buffer as well
|
except (RuntimeError, OSError) as e:
|
||||||
self.matrix.Clear()
|
logger.error("Failed to clear current canvas: %s", e)
|
||||||
except Exception:
|
try:
|
||||||
pass
|
self.matrix.Clear()
|
||||||
|
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.
|
||||||
|
|||||||
@@ -90,13 +90,11 @@ 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 tick for keeping data fresh during Vegas mode
|
# Plugin update callback — fired from a background thread inside the loop
|
||||||
self._update_tick: Optional[Callable[[], Optional[List[str]]]] = None
|
# so the main loop's _tick_plugin_updates() finds nothing due when Vegas
|
||||||
self._update_tick_interval: float = 1.0 # Tick every 1 second
|
# returns, eliminating the inter-iteration frozen-frame gap.
|
||||||
self._update_thread: Optional[threading.Thread] = None
|
self._update_callback: Optional[Callable[[], None]] = None
|
||||||
self._update_results: Optional[List[str]] = None
|
self._update_tick_running: bool = False
|
||||||
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
|
||||||
@@ -139,6 +137,25 @@ 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.
|
||||||
@@ -166,24 +183,19 @@ 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_tick(
|
def set_update_callback(self, callback: Callable[[], None]) -> None:
|
||||||
self,
|
|
||||||
callback: Callable[[], Optional[List[str]]],
|
|
||||||
interval: float = 1.0
|
|
||||||
) -> None:
|
|
||||||
"""
|
"""
|
||||||
Set the callback for periodic plugin update ticking during Vegas mode.
|
Set a callback for running plugin updates from inside the Vegas loop.
|
||||||
|
|
||||||
This keeps plugin data fresh while the Vegas render loop is running.
|
Fired in a daemon background thread every ~4 s so plugin data stays
|
||||||
The callback should run scheduled plugin updates and return a list of
|
fresh without blocking the render loop. The main loop's
|
||||||
plugin IDs that were actually updated, or None/empty if no updates occurred.
|
_tick_plugin_updates() then finds all intervals already satisfied and
|
||||||
|
returns immediately, collapsing the inter-iteration gap to <1 ms.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
callback: Callable that returns list of updated plugin IDs or None
|
callback: Callable with no arguments (typically _tick_plugin_updates)
|
||||||
interval: Seconds between update tick calls (default 1.0)
|
|
||||||
"""
|
"""
|
||||||
self._update_tick = callback
|
self._update_callback = callback
|
||||||
self._update_tick_interval = max(0.5, interval)
|
|
||||||
|
|
||||||
def start(self) -> bool:
|
def start(self) -> bool:
|
||||||
"""
|
"""
|
||||||
@@ -237,9 +249,6 @@ 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()
|
||||||
@@ -335,83 +344,101 @@ 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)
|
||||||
|
|
||||||
try:
|
while True:
|
||||||
while True:
|
# Check for STATIC mode plugin that should pause scroll
|
||||||
# Check for STATIC mode plugin that should pause scroll
|
static_plugin = self._check_static_plugin_trigger()
|
||||||
static_plugin = self._check_static_plugin_trigger()
|
if static_plugin:
|
||||||
if static_plugin:
|
if not self._handle_static_pause(static_plugin):
|
||||||
if not self._handle_static_pause(static_plugin):
|
# Static pause was interrupted
|
||||||
# Static pause was interrupted
|
return False
|
||||||
|
# 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
|
|
||||||
|
|
||||||
# Run frame
|
# Sleep for frame interval
|
||||||
if not self.run_frame():
|
time.sleep(frame_interval)
|
||||||
# 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
|
|
||||||
|
|
||||||
# Sleep for frame interval
|
# Increment frame count and check for interrupt periodically
|
||||||
time.sleep(frame_interval)
|
frame_count += 1
|
||||||
|
fps_frame_count += 1
|
||||||
|
|
||||||
# Increment frame count and check for interrupt periodically
|
# Periodic FPS logging
|
||||||
frame_count += 1
|
current_time = time.time()
|
||||||
fps_frame_count += 1
|
if current_time - last_fps_log_time >= fps_log_interval:
|
||||||
|
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
|
||||||
|
|
||||||
# Periodic FPS logging
|
if (self._interrupt_check and
|
||||||
current_time = time.time()
|
frame_count % self._interrupt_check_interval == 0):
|
||||||
if current_time - last_fps_log_time >= fps_log_interval:
|
try:
|
||||||
fps = fps_frame_count / (current_time - last_fps_log_time)
|
if self._interrupt_check():
|
||||||
logger.info(
|
logger.debug(
|
||||||
"Vegas FPS: %.1f (target: %d, frames: %d)",
|
"Vegas interrupted by callback after %d frames",
|
||||||
fps, self.vegas_config.target_fps, fps_frame_count
|
frame_count
|
||||||
)
|
)
|
||||||
last_fps_log_time = current_time
|
return False
|
||||||
fps_frame_count = 0
|
except Exception:
|
||||||
|
# Log but don't let interrupt check errors stop Vegas
|
||||||
|
logger.exception("Interrupt check failed")
|
||||||
|
|
||||||
# Periodic plugin update tick to keep data fresh (non-blocking)
|
# Fire plugin update tick in a background thread every ~4 s.
|
||||||
self._drive_background_updates()
|
# Running it here (rather than only between iterations) means the
|
||||||
|
# main loop's _tick_plugin_updates() finds all intervals already
|
||||||
if (self._interrupt_check and
|
# satisfied on return, so the inter-iteration gap is <1 ms and the
|
||||||
frame_count % self._interrupt_check_interval == 0):
|
# display never shows a frozen frame between iterations.
|
||||||
|
_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:
|
||||||
if self._interrupt_check():
|
cb()
|
||||||
logger.debug(
|
finally:
|
||||||
"Vegas interrupted by callback after %d frames",
|
self._update_tick_running = False
|
||||||
frame_count
|
threading.Thread(
|
||||||
)
|
target=_run_tick, daemon=True, name="vegas-plugin-tick"
|
||||||
return False
|
).start()
|
||||||
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
|
||||||
|
|
||||||
# Check for cycle completion
|
# NOTE: do NOT break on is_cycle_complete() here.
|
||||||
if self.render_pipeline.is_cycle_complete():
|
# When multi-display sync is active, breaking exits run_iteration()
|
||||||
break
|
# which causes a 2-3s delay before start_new_cycle() is called on
|
||||||
|
# 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:
|
||||||
"""
|
"""
|
||||||
@@ -500,71 +527,6 @@ 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.
|
||||||
@@ -683,8 +645,10 @@ class VegasModeCoordinator:
|
|||||||
logger.info("Static pause interrupted by live priority")
|
logger.info("Static pause interrupted by live priority")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Keep plugin data fresh during static pause
|
# Yield immediately if multi-display follower mode becomes active
|
||||||
self._drive_background_updates()
|
if self._interrupt_check and self._interrupt_check():
|
||||||
|
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)
|
||||||
|
|||||||
@@ -329,50 +329,51 @@ 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()
|
||||||
|
|
||||||
# Method 1: Try _create_scrolling_display (stocks pattern)
|
with self.display_manager.capture_mode():
|
||||||
if hasattr(plugin, '_create_scrolling_display'):
|
# Method 1: Try _create_scrolling_display (stocks pattern)
|
||||||
logger.info(
|
if hasattr(plugin, '_create_scrolling_display'):
|
||||||
"[%s] Triggering via _create_scrolling_display()",
|
|
||||||
plugin_id
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
plugin._create_scrolling_display()
|
|
||||||
cached_image = getattr(scroll_helper, 'cached_image', None)
|
|
||||||
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] Triggering via _create_scrolling_display()",
|
||||||
plugin_id
|
plugin_id
|
||||||
)
|
)
|
||||||
except (AttributeError, TypeError, ValueError, OSError):
|
try:
|
||||||
logger.exception(
|
plugin._create_scrolling_display()
|
||||||
"[%s] display(force_clear=True) failed", plugin_id
|
cached_image = getattr(scroll_helper, 'cached_image', None)
|
||||||
|
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(
|
||||||
|
"[%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",
|
||||||
@@ -408,10 +409,7 @@ 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)
|
||||||
|
|
||||||
# Lightweight in-memory data refresh before capturing.
|
# Ensure plugin has fresh data 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:
|
||||||
@@ -421,21 +419,24 @@ 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
|
# Clear and call plugin display — use capture_mode to suppress hardware writes
|
||||||
self.display_manager.clear()
|
# that plugins may trigger internally via update_display().
|
||||||
logger.info("[%s] Fallback: display cleared, calling display()", plugin_id)
|
with self.display_manager.capture_mode():
|
||||||
|
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
|
||||||
@@ -454,9 +455,10 @@ class PluginAdapter:
|
|||||||
plugin_id
|
plugin_id
|
||||||
)
|
)
|
||||||
# Try once more with force_clear=True
|
# Try once more with force_clear=True
|
||||||
self.display_manager.clear()
|
with self.display_manager.capture_mode():
|
||||||
plugin.display(force_clear=True)
|
self.display_manager.clear()
|
||||||
captured = self.display_manager.image.copy()
|
plugin.display(force_clear=True)
|
||||||
|
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(
|
||||||
@@ -585,28 +587,6 @@ 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.
|
||||||
|
|||||||
@@ -52,6 +52,10 @@ 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 = (
|
||||||
@@ -202,8 +206,26 @@ class RenderPipeline:
|
|||||||
# Update scroll position
|
# Update scroll position
|
||||||
self.scroll_helper.update_scroll_position()
|
self.scroll_helper.update_scroll_position()
|
||||||
|
|
||||||
# Check if cycle is complete
|
# Determine if the cycle is done.
|
||||||
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
|
||||||
@@ -211,6 +233,17 @@ 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()
|
||||||
@@ -221,6 +254,15 @@ 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)
|
||||||
|
|
||||||
@@ -260,33 +302,38 @@ 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 or pending updates exist.
|
Called when staging buffer has updated content.
|
||||||
Preserves scroll position for mid-cycle updates to prevent visual jumps.
|
Swaps atomically to prevent visual glitches.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if swap occurred
|
True if swap occurred
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Save scroll position for mid-cycle updates
|
# Snapshot position before swap so we can reposition after.
|
||||||
saved_position = self.scroll_helper.scroll_position
|
# The new image has completely different content — if scroll_position
|
||||||
saved_total_distance = self.scroll_helper.total_distance_scrolled
|
# is left unchanged it lands at an arbitrary mid-content point in the
|
||||||
saved_total_width = max(1, self.scroll_helper.total_scroll_width)
|
# new image, causing a visible jump on both displays.
|
||||||
was_mid_cycle = not self._cycle_complete
|
old_width = self.scroll_helper.total_scroll_width
|
||||||
|
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()
|
||||||
@@ -294,20 +341,24 @@ 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
|
||||||
# Restore scroll position for mid-cycle updates so the
|
logger.debug(
|
||||||
# scroll continues from where it was instead of jumping to 0
|
"Hot-swap completed: scroll repositioned %.0f→%.0f (%.1f%% of new %dpx image)",
|
||||||
if was_mid_cycle:
|
old_pos, self.scroll_helper.scroll_position,
|
||||||
new_total_width = max(1, self.scroll_helper.total_scroll_width)
|
(self.scroll_helper.scroll_position / new_width * 100) if new_width else 0,
|
||||||
progress_ratio = min(saved_total_distance / saved_total_width, 0.999)
|
new_width,
|
||||||
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
|
||||||
@@ -342,7 +393,29 @@ class RenderPipeline:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
# Compose new scroll content
|
# Compose new scroll content
|
||||||
return self.compose_scroll_content()
|
result = 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."""
|
||||||
|
|||||||
@@ -60,12 +60,16 @@ def get_wifi_config_path():
|
|||||||
|
|
||||||
HOSTAPD_CONFIG_PATH = Path("/etc/hostapd/hostapd.conf")
|
HOSTAPD_CONFIG_PATH = Path("/etc/hostapd/hostapd.conf")
|
||||||
DNSMASQ_CONFIG_PATH = Path("/etc/dnsmasq.d/ledmatrix-captive.conf")
|
DNSMASQ_CONFIG_PATH = Path("/etc/dnsmasq.d/ledmatrix-captive.conf")
|
||||||
|
# Drop-in config for NetworkManager's built-in dnsmasq (ipv4.method=shared).
|
||||||
|
# Writing address=/#/<ap_ip> here causes NM to resolve every hostname to the AP,
|
||||||
|
# triggering the OS captive-portal popup automatically on iOS/Android/Windows/macOS.
|
||||||
|
NM_DNSMASQ_SHARED_DIR = Path("/etc/NetworkManager/dnsmasq-shared.d")
|
||||||
|
NM_DNSMASQ_SHARED_CONF = NM_DNSMASQ_SHARED_DIR / "ledmatrix-captive.conf"
|
||||||
HOSTAPD_SERVICE = "hostapd"
|
HOSTAPD_SERVICE = "hostapd"
|
||||||
DNSMASQ_SERVICE = "dnsmasq"
|
DNSMASQ_SERVICE = "dnsmasq"
|
||||||
|
|
||||||
# Default AP settings
|
# Default AP settings
|
||||||
DEFAULT_AP_SSID = "LEDMatrix-Setup"
|
DEFAULT_AP_SSID = "LEDMatrix-Setup"
|
||||||
DEFAULT_AP_PASSWORD = "ledmatrix123"
|
|
||||||
DEFAULT_AP_CHANNEL = 7
|
DEFAULT_AP_CHANNEL = 7
|
||||||
|
|
||||||
# LED status message file (for display_controller integration)
|
# LED status message file (for display_controller integration)
|
||||||
@@ -138,6 +142,11 @@ class WiFiManager:
|
|||||||
self._disconnected_checks = 0
|
self._disconnected_checks = 0
|
||||||
self._disconnected_checks_required = 3 # Require 3 consecutive disconnected checks (90 seconds at 30s interval)
|
self._disconnected_checks_required = 3 # Require 3 consecutive disconnected checks (90 seconds at 30s interval)
|
||||||
|
|
||||||
|
# Timestamp set when AP mode is enabled; used for the idle-timeout check
|
||||||
|
self._ap_enabled_at: Optional[float] = None
|
||||||
|
# Which redirect backend was used (iptables/nftables/None); set per-instance
|
||||||
|
self._redirect_backend: Optional[str] = None
|
||||||
|
|
||||||
logger.info(f"WiFi Manager initialized - nmcli: {self.has_nmcli}, iwlist: {self.has_iwlist}, "
|
logger.info(f"WiFi Manager initialized - nmcli: {self.has_nmcli}, iwlist: {self.has_iwlist}, "
|
||||||
f"hostapd: {self.has_hostapd}, dnsmasq: {self.has_dnsmasq}, "
|
f"hostapd: {self.has_hostapd}, dnsmasq: {self.has_dnsmasq}, "
|
||||||
f"interface: {self._wifi_interface}, trixie: {self._is_trixie}")
|
f"interface: {self._wifi_interface}, trixie: {self._is_trixie}")
|
||||||
@@ -201,6 +210,24 @@ class WiFiManager:
|
|||||||
except (subprocess.TimeoutExpired, subprocess.SubprocessError, OSError):
|
except (subprocess.TimeoutExpired, subprocess.SubprocessError, OSError):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def _find_command_path(self, command: str) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Return the absolute path of a command, checking sbin locations that may not
|
||||||
|
be on PATH in restricted service environments. Returns None if not found.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(["which", command], capture_output=True,
|
||||||
|
text=True, timeout=2)
|
||||||
|
if result.returncode == 0 and result.stdout.strip():
|
||||||
|
return result.stdout.strip()
|
||||||
|
except (subprocess.TimeoutExpired, subprocess.SubprocessError, OSError):
|
||||||
|
pass
|
||||||
|
for path in [f"/usr/sbin/{command}", f"/sbin/{command}",
|
||||||
|
f"/usr/local/sbin/{command}"]:
|
||||||
|
if os.path.isfile(path) and os.access(path, os.X_OK):
|
||||||
|
return path
|
||||||
|
return None
|
||||||
|
|
||||||
def _discover_wifi_interface(self) -> str:
|
def _discover_wifi_interface(self) -> str:
|
||||||
"""
|
"""
|
||||||
Discover the primary WiFi interface name dynamically.
|
Discover the primary WiFi interface name dynamically.
|
||||||
@@ -303,7 +330,6 @@ class WiFiManager:
|
|||||||
else:
|
else:
|
||||||
self.config = {
|
self.config = {
|
||||||
"ap_ssid": DEFAULT_AP_SSID,
|
"ap_ssid": DEFAULT_AP_SSID,
|
||||||
"ap_password": DEFAULT_AP_PASSWORD,
|
|
||||||
"ap_channel": DEFAULT_AP_CHANNEL,
|
"ap_channel": DEFAULT_AP_CHANNEL,
|
||||||
"auto_enable_ap_mode": True, # Default: auto-enable when no network (safe due to grace period)
|
"auto_enable_ap_mode": True, # Default: auto-enable when no network (safe due to grace period)
|
||||||
"saved_networks": []
|
"saved_networks": []
|
||||||
@@ -659,6 +685,285 @@ class WiFiManager:
|
|||||||
except (subprocess.TimeoutExpired, subprocess.SubprocessError, OSError):
|
except (subprocess.TimeoutExpired, subprocess.SubprocessError, OSError):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_IP_FORWARD_SAVE_PATH = Path("/tmp/ledmatrix_ip_forward_saved")
|
||||||
|
|
||||||
|
def _validate_ap_config(self) -> Tuple[str, int]:
|
||||||
|
"""Return a sanitized (ssid, channel) pair from config, falling back to defaults."""
|
||||||
|
ssid = str(self.config.get("ap_ssid", DEFAULT_AP_SSID))
|
||||||
|
if not ssid or len(ssid) > 32 or not re.match(r'^[\x20-\x7E]+$', ssid):
|
||||||
|
logger.warning(f"AP SSID '{ssid}' is invalid, falling back to default")
|
||||||
|
ssid = DEFAULT_AP_SSID
|
||||||
|
try:
|
||||||
|
channel = int(self.config.get("ap_channel", DEFAULT_AP_CHANNEL))
|
||||||
|
if channel < 1 or channel > 14:
|
||||||
|
raise ValueError
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
logger.warning("AP channel out of range, falling back to default")
|
||||||
|
channel = DEFAULT_AP_CHANNEL
|
||||||
|
return ssid, channel
|
||||||
|
|
||||||
|
def _setup_iptables_redirect(self) -> bool:
|
||||||
|
"""
|
||||||
|
Add port 80 → 5000 redirect rules for the captive portal.
|
||||||
|
|
||||||
|
Tries iptables first, falls back to nftables (used by Debian Trixie).
|
||||||
|
When neither tool is available, logs a warning and returns True — the AP
|
||||||
|
still works and DNS spoofing still triggers the OS popup; users just land
|
||||||
|
on port 5000 directly rather than being redirected from port 80.
|
||||||
|
|
||||||
|
Only returns False when a tool was found but the rule addition itself failed.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
iptables = self._find_command_path("iptables")
|
||||||
|
nft = self._find_command_path("nft")
|
||||||
|
|
||||||
|
if not iptables and not nft:
|
||||||
|
logger.warning(
|
||||||
|
"Neither iptables nor nft found; captive portal port-80 redirect unavailable. "
|
||||||
|
"DNS spoofing will still trigger the OS popup but HTTP on port 80 won't reach Flask."
|
||||||
|
)
|
||||||
|
self._redirect_backend = None
|
||||||
|
return True # AP works; redirect is best-effort
|
||||||
|
|
||||||
|
if iptables:
|
||||||
|
return self._setup_iptables_redirect_iptables(iptables)
|
||||||
|
else:
|
||||||
|
return self._setup_iptables_redirect_nftables(nft)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Could not set up port redirect: {e}")
|
||||||
|
try:
|
||||||
|
self._teardown_iptables_redirect()
|
||||||
|
except Exception as cleanup_e:
|
||||||
|
logger.warning(f"Cleanup after redirect exception also failed: {cleanup_e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _setup_iptables_redirect_iptables(self, iptables: str) -> bool:
|
||||||
|
"""Set up port 80→5000 redirect using iptables."""
|
||||||
|
# Save ip_forward state before enabling
|
||||||
|
try:
|
||||||
|
current_fwd = Path("/proc/sys/net/ipv4/ip_forward").read_text().strip()
|
||||||
|
except OSError:
|
||||||
|
current_fwd = None
|
||||||
|
if current_fwd is not None:
|
||||||
|
try:
|
||||||
|
self._IP_FORWARD_SAVE_PATH.write_text(current_fwd)
|
||||||
|
except OSError:
|
||||||
|
current_fwd = None
|
||||||
|
logger.warning("Could not write ip_forward save file; state will not be restored")
|
||||||
|
|
||||||
|
if current_fwd != "1":
|
||||||
|
sysctl = self._find_command_path("sysctl")
|
||||||
|
sysctl_bin = sysctl if sysctl else "sysctl"
|
||||||
|
r = subprocess.run(["sudo", sysctl_bin, "-w", "net.ipv4.ip_forward=1"],
|
||||||
|
capture_output=True, text=True, timeout=5)
|
||||||
|
if r.returncode != 0:
|
||||||
|
logger.error(f"Failed to enable ip_forward: {r.stderr.strip()}")
|
||||||
|
self._teardown_iptables_redirect()
|
||||||
|
return False
|
||||||
|
|
||||||
|
if subprocess.run(
|
||||||
|
["sudo", iptables, "-t", "nat", "-C", "PREROUTING",
|
||||||
|
"-i", self._wifi_interface, "-p", "tcp", "--dport", "80",
|
||||||
|
"-j", "REDIRECT", "--to-port", "5000"],
|
||||||
|
capture_output=True, timeout=5
|
||||||
|
).returncode != 0:
|
||||||
|
r = subprocess.run(
|
||||||
|
["sudo", iptables, "-t", "nat", "-A", "PREROUTING",
|
||||||
|
"-i", self._wifi_interface, "-p", "tcp", "--dport", "80",
|
||||||
|
"-j", "REDIRECT", "--to-port", "5000"],
|
||||||
|
capture_output=True, text=True, timeout=5
|
||||||
|
)
|
||||||
|
if r.returncode != 0:
|
||||||
|
logger.error(f"Failed to add PREROUTING rule: {r.stderr.strip()}")
|
||||||
|
self._teardown_iptables_redirect()
|
||||||
|
return False
|
||||||
|
|
||||||
|
if subprocess.run(
|
||||||
|
["sudo", iptables, "-C", "INPUT",
|
||||||
|
"-i", self._wifi_interface, "-p", "tcp", "--dport", "5000", "-j", "ACCEPT"],
|
||||||
|
capture_output=True, timeout=5
|
||||||
|
).returncode != 0:
|
||||||
|
r = subprocess.run(
|
||||||
|
["sudo", iptables, "-A", "INPUT",
|
||||||
|
"-i", self._wifi_interface, "-p", "tcp", "--dport", "5000", "-j", "ACCEPT"],
|
||||||
|
capture_output=True, text=True, timeout=5
|
||||||
|
)
|
||||||
|
if r.returncode != 0:
|
||||||
|
logger.error(f"Failed to add INPUT rule: {r.stderr.strip()}")
|
||||||
|
self._teardown_iptables_redirect()
|
||||||
|
return False
|
||||||
|
|
||||||
|
self._redirect_backend = "iptables"
|
||||||
|
logger.info("iptables: port 80→5000 redirect rules added")
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _setup_iptables_redirect_nftables(self, nft: str) -> bool:
|
||||||
|
"""Set up port 80→5000 redirect using nftables (Debian Trixie / modern systems)."""
|
||||||
|
# NM's ipv4.method=shared already enables ip_forward; no sysctl needed.
|
||||||
|
cmds = [
|
||||||
|
["sudo", nft, "add", "table", "ip", "ledmatrix"],
|
||||||
|
["sudo", nft, "add", "chain", "ip", "ledmatrix", "prerouting",
|
||||||
|
"{", "type", "nat", "hook", "prerouting", "priority", "-100", ";", "}"],
|
||||||
|
["sudo", nft, "add", "rule", "ip", "ledmatrix", "prerouting",
|
||||||
|
"iif", self._wifi_interface, "tcp", "dport", "80", "redirect", "to", ":5000"],
|
||||||
|
]
|
||||||
|
for cmd in cmds:
|
||||||
|
r = subprocess.run(cmd, capture_output=True, text=True, timeout=5)
|
||||||
|
if r.returncode != 0:
|
||||||
|
# Table/chain may already exist — only fail on rule add
|
||||||
|
if "add rule" in " ".join(cmd):
|
||||||
|
logger.error(f"Failed to add nftables redirect rule: {r.stderr.strip()}")
|
||||||
|
self._teardown_iptables_redirect()
|
||||||
|
return False
|
||||||
|
logger.debug(f"nft cmd non-zero (may already exist): {r.stderr.strip()}")
|
||||||
|
|
||||||
|
self._redirect_backend = "nftables"
|
||||||
|
logger.info("nftables: port 80→5000 redirect rule added")
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _teardown_iptables_redirect(self) -> None:
|
||||||
|
"""Remove the port 80→5000 redirect rules and restore ip_forward if saved."""
|
||||||
|
try:
|
||||||
|
backend = self._redirect_backend
|
||||||
|
self._redirect_backend = None
|
||||||
|
|
||||||
|
if backend == "iptables":
|
||||||
|
iptables = self._find_command_path("iptables")
|
||||||
|
if iptables:
|
||||||
|
subprocess.run(
|
||||||
|
["sudo", iptables, "-t", "nat", "-D", "PREROUTING",
|
||||||
|
"-i", self._wifi_interface, "-p", "tcp", "--dport", "80",
|
||||||
|
"-j", "REDIRECT", "--to-port", "5000"],
|
||||||
|
capture_output=True, timeout=5
|
||||||
|
)
|
||||||
|
subprocess.run(
|
||||||
|
["sudo", iptables, "-D", "INPUT",
|
||||||
|
"-i", self._wifi_interface, "-p", "tcp", "--dport", "5000",
|
||||||
|
"-j", "ACCEPT"],
|
||||||
|
capture_output=True, timeout=5
|
||||||
|
)
|
||||||
|
# Restore ip_forward only when we saved it
|
||||||
|
if self._IP_FORWARD_SAVE_PATH.exists():
|
||||||
|
try:
|
||||||
|
saved = self._IP_FORWARD_SAVE_PATH.read_text().strip()
|
||||||
|
self._IP_FORWARD_SAVE_PATH.unlink(missing_ok=True)
|
||||||
|
sysctl = self._find_command_path("sysctl")
|
||||||
|
sysctl_bin = sysctl if sysctl else "sysctl"
|
||||||
|
subprocess.run(["sudo", sysctl_bin, "-w", f"net.ipv4.ip_forward={saved}"],
|
||||||
|
capture_output=True, timeout=5)
|
||||||
|
logger.info(f"ip_forward restored to {saved}")
|
||||||
|
except OSError as e:
|
||||||
|
logger.warning(f"Could not restore ip_forward: {e}")
|
||||||
|
else:
|
||||||
|
logger.debug("ip_forward not modified by setup; leaving unchanged")
|
||||||
|
|
||||||
|
elif backend == "nftables":
|
||||||
|
nft = self._find_command_path("nft")
|
||||||
|
if nft:
|
||||||
|
subprocess.run(
|
||||||
|
["sudo", nft, "delete", "table", "ip", "ledmatrix"],
|
||||||
|
capture_output=True, timeout=5
|
||||||
|
)
|
||||||
|
logger.info("nftables ledmatrix table removed")
|
||||||
|
|
||||||
|
else:
|
||||||
|
# No redirect was set up (neither tool available); nothing to tear down
|
||||||
|
self._IP_FORWARD_SAVE_PATH.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Could not tear down port redirect: {e}")
|
||||||
|
|
||||||
|
def _write_nm_dnsmasq_captive_conf(self, ap_ip: str = "192.168.4.1") -> None:
|
||||||
|
"""
|
||||||
|
Write the NM dnsmasq-shared.d drop-in that makes NM's built-in dnsmasq
|
||||||
|
resolve every hostname to the AP IP. This triggers the OS captive-portal
|
||||||
|
popup automatically on iOS / Android / Windows / macOS as soon as the
|
||||||
|
device connects — no manual navigation required.
|
||||||
|
|
||||||
|
NetworkManager reads /etc/NetworkManager/dnsmasq-shared.d/*.conf when it
|
||||||
|
starts the dnsmasq instance for ipv4.method=shared connections.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
content = f"# LEDMatrix captive portal: resolve all hostnames to AP\naddress=/#/{ap_ip}\n"
|
||||||
|
with open("/tmp/ledmatrix-nm-dnsmasq.conf", "w") as f:
|
||||||
|
f.write(content)
|
||||||
|
subprocess.run(
|
||||||
|
["sudo", "mkdir", "-p", str(NM_DNSMASQ_SHARED_DIR)],
|
||||||
|
capture_output=True, timeout=5
|
||||||
|
)
|
||||||
|
subprocess.run(
|
||||||
|
["sudo", "cp", "/tmp/ledmatrix-nm-dnsmasq.conf", str(NM_DNSMASQ_SHARED_CONF)],
|
||||||
|
capture_output=True, timeout=5
|
||||||
|
)
|
||||||
|
logger.info(f"Wrote NM dnsmasq captive-portal config: {NM_DNSMASQ_SHARED_CONF}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Could not write NM dnsmasq captive config: {e}")
|
||||||
|
|
||||||
|
def _remove_nm_dnsmasq_captive_conf(self) -> None:
|
||||||
|
"""Remove the NM dnsmasq-shared.d drop-in written by _write_nm_dnsmasq_captive_conf."""
|
||||||
|
try:
|
||||||
|
subprocess.run(
|
||||||
|
["sudo", "rm", "-f", str(NM_DNSMASQ_SHARED_CONF)],
|
||||||
|
capture_output=True, timeout=5
|
||||||
|
)
|
||||||
|
logger.info("Removed NM dnsmasq captive-portal config")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Could not remove NM dnsmasq captive config: {e}")
|
||||||
|
|
||||||
|
def _check_internet_connectivity(self, timeout: int = 5) -> bool:
|
||||||
|
"""
|
||||||
|
Test actual internet reachability — not just nmcli association state.
|
||||||
|
|
||||||
|
A device can be 'connected' in nmcli (associated with an AP) while the
|
||||||
|
router has no WAN link. This check catches that case so the daemon can
|
||||||
|
auto-enable AP mode even when nmcli reports a connection.
|
||||||
|
|
||||||
|
Returns True if at least one reachability method succeeds.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
r = subprocess.run(
|
||||||
|
["ping", "-c", "1", "-W", str(timeout), "8.8.8.8"],
|
||||||
|
capture_output=True, timeout=timeout + 1
|
||||||
|
)
|
||||||
|
if r.returncode == 0:
|
||||||
|
logger.debug("Internet connectivity confirmed via ping 8.8.8.8")
|
||||||
|
return True
|
||||||
|
except (subprocess.SubprocessError, OSError):
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
import urllib.request as _ureq
|
||||||
|
_ureq.urlopen("http://connectivity-check.ubuntu.com/", timeout=timeout)
|
||||||
|
logger.debug("Internet connectivity confirmed via HTTP check")
|
||||||
|
return True
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
logger.debug("Internet connectivity check failed (both ping and HTTP)")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def check_internet_connectivity(self, timeout: int = 5) -> bool:
|
||||||
|
"""Public wrapper around _check_internet_connectivity for use by the daemon."""
|
||||||
|
return self._check_internet_connectivity(timeout=timeout)
|
||||||
|
|
||||||
|
def _has_ap_clients(self) -> bool:
|
||||||
|
"""
|
||||||
|
Return True if at least one client is associated with the AP.
|
||||||
|
Uses 'iw dev <iface> station dump' which works for both hostapd and
|
||||||
|
nmcli AP modes.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["iw", "dev", self._wifi_interface, "station", "dump"],
|
||||||
|
capture_output=True, text=True, timeout=5
|
||||||
|
)
|
||||||
|
return bool(result.stdout.strip())
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
def scan_networks(self, allow_cached: bool = True) -> Tuple[List[WiFiNetwork], bool]:
|
def scan_networks(self, allow_cached: bool = True) -> Tuple[List[WiFiNetwork], bool]:
|
||||||
"""
|
"""
|
||||||
Scan for available WiFi networks.
|
Scan for available WiFi networks.
|
||||||
@@ -1293,12 +1598,27 @@ class WiFiManager:
|
|||||||
error_msg = result.stderr.strip() or result.stdout.strip()
|
error_msg = result.stderr.strip() or result.stdout.strip()
|
||||||
logger.error(f"Failed to connect to {ssid}: {error_msg}")
|
logger.error(f"Failed to connect to {ssid}: {error_msg}")
|
||||||
self._show_led_message("Connection failed", duration=5)
|
self._show_led_message("Connection failed", duration=5)
|
||||||
|
if self._is_wrong_password_error(error_msg):
|
||||||
|
return False, f"wrong_password: {error_msg}"
|
||||||
return False, error_msg
|
return False, error_msg
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error connecting with nmcli: {e}")
|
logger.error(f"Error connecting with nmcli: {e}")
|
||||||
self._show_led_message("Connection error", duration=5)
|
self._show_led_message("Connection error", duration=5)
|
||||||
return False, str(e)
|
return False, str(e)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_wrong_password_error(error_msg: str) -> bool:
|
||||||
|
"""Return True when nmcli's error output indicates an authentication failure."""
|
||||||
|
indicators = [
|
||||||
|
"secrets were required",
|
||||||
|
"no secret agent",
|
||||||
|
"802-11-wireless-security.psk",
|
||||||
|
"authentication rejected",
|
||||||
|
"association rejected",
|
||||||
|
]
|
||||||
|
lower = error_msg.lower()
|
||||||
|
return any(ind in lower for ind in indicators)
|
||||||
|
|
||||||
def _connect_wpa_supplicant(self, ssid: str, password: str) -> Tuple[bool, str]:
|
def _connect_wpa_supplicant(self, ssid: str, password: str) -> Tuple[bool, str]:
|
||||||
"""Connect using wpa_supplicant (fallback)"""
|
"""Connect using wpa_supplicant (fallback)"""
|
||||||
try:
|
try:
|
||||||
@@ -1570,13 +1890,17 @@ class WiFiManager:
|
|||||||
if self.has_hostapd and self.has_dnsmasq:
|
if self.has_hostapd and self.has_dnsmasq:
|
||||||
result = self._enable_ap_mode_hostapd()
|
result = self._enable_ap_mode_hostapd()
|
||||||
if result[0]:
|
if result[0]:
|
||||||
|
self._ap_enabled_at = time.time()
|
||||||
return result
|
return result
|
||||||
|
|
||||||
# Fallback to nmcli hotspot (simpler, no captive portal)
|
# Fallback to nmcli hotspot (simpler, no captive portal)
|
||||||
if self.has_nmcli:
|
if self.has_nmcli:
|
||||||
logger.info("hostapd/dnsmasq failed or unavailable, trying nmcli hotspot fallback...")
|
logger.info("hostapd/dnsmasq failed or unavailable, trying nmcli hotspot fallback...")
|
||||||
self._show_led_message("Setup Mode", duration=5)
|
self._show_led_message("Setup Mode", duration=5)
|
||||||
return self._enable_ap_mode_nmcli_hotspot()
|
result = self._enable_ap_mode_nmcli_hotspot()
|
||||||
|
if result[0]:
|
||||||
|
self._ap_enabled_at = time.time()
|
||||||
|
return result
|
||||||
|
|
||||||
return False, "No WiFi tools available (nmcli, hostapd, or dnsmasq required)"
|
return False, "No WiFi tools available (nmcli, hostapd, or dnsmasq required)"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -1649,63 +1973,21 @@ class WiFiManager:
|
|||||||
subprocess.run(["sudo", "systemctl", "stop", HOSTAPD_SERVICE], timeout=5)
|
subprocess.run(["sudo", "systemctl", "stop", HOSTAPD_SERVICE], timeout=5)
|
||||||
return False, f"Failed to start dnsmasq: {result.stderr}"
|
return False, f"Failed to start dnsmasq: {result.stderr}"
|
||||||
|
|
||||||
# Set up iptables port forwarding: redirect port 80 to 5000
|
# Set up iptables port forwarding (port 80 → 5000) and save ip_forward state
|
||||||
# This makes the captive portal work on standard HTTP port
|
if not self._setup_iptables_redirect():
|
||||||
try:
|
logger.error("Captive-portal redirect setup failed; stopping AP services")
|
||||||
# Check if iptables is available
|
subprocess.run(["sudo", "systemctl", "stop", HOSTAPD_SERVICE],
|
||||||
iptables_check = subprocess.run(
|
capture_output=True, timeout=10)
|
||||||
["which", "iptables"],
|
subprocess.run(["sudo", "systemctl", "stop", DNSMASQ_SERVICE],
|
||||||
capture_output=True,
|
capture_output=True, timeout=10)
|
||||||
timeout=2
|
return False, "AP started but captive-portal redirect setup failed"
|
||||||
)
|
|
||||||
|
|
||||||
if iptables_check.returncode == 0:
|
|
||||||
# Enable IP forwarding (needed for NAT)
|
|
||||||
subprocess.run(
|
|
||||||
["sudo", "sysctl", "-w", "net.ipv4.ip_forward=1"],
|
|
||||||
capture_output=True,
|
|
||||||
timeout=5
|
|
||||||
)
|
|
||||||
|
|
||||||
# Add NAT rule to redirect port 80 to 5000 on WiFi interface
|
|
||||||
# First check if rule already exists
|
|
||||||
check_result = subprocess.run(
|
|
||||||
["sudo", "iptables", "-t", "nat", "-C", "PREROUTING", "-i", self._wifi_interface, "-p", "tcp", "--dport", "80", "-j", "REDIRECT", "--to-port", "5000"],
|
|
||||||
capture_output=True,
|
|
||||||
timeout=5
|
|
||||||
)
|
|
||||||
|
|
||||||
if check_result.returncode != 0:
|
|
||||||
# Rule doesn't exist, add it
|
|
||||||
subprocess.run(
|
|
||||||
["sudo", "iptables", "-t", "nat", "-A", "PREROUTING", "-i", self._wifi_interface, "-p", "tcp", "--dport", "80", "-j", "REDIRECT", "--to-port", "5000"],
|
|
||||||
capture_output=True,
|
|
||||||
timeout=5
|
|
||||||
)
|
|
||||||
logger.info("Added iptables rule to redirect port 80 to 5000")
|
|
||||||
|
|
||||||
# Also allow incoming connections on port 80
|
|
||||||
check_input = subprocess.run(
|
|
||||||
["sudo", "iptables", "-C", "INPUT", "-i", self._wifi_interface, "-p", "tcp", "--dport", "80", "-j", "ACCEPT"],
|
|
||||||
capture_output=True,
|
|
||||||
timeout=5
|
|
||||||
)
|
|
||||||
|
|
||||||
if check_input.returncode != 0:
|
|
||||||
subprocess.run(
|
|
||||||
["sudo", "iptables", "-A", "INPUT", "-i", self._wifi_interface, "-p", "tcp", "--dport", "80", "-j", "ACCEPT"],
|
|
||||||
capture_output=True,
|
|
||||||
timeout=5
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
logger.debug("iptables not available, port forwarding not set up")
|
|
||||||
logger.info("Note: Port 80 forwarding requires iptables. Users will need to access port 5000 directly.")
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Could not set up iptables port forwarding: {e}")
|
|
||||||
# Continue anyway - port 5000 will still work
|
|
||||||
|
|
||||||
logger.info("AP mode enabled successfully")
|
logger.info("AP mode enabled successfully")
|
||||||
self._show_led_message("Setup Mode Active", duration=5)
|
# Use the validated SSID so the displayed name matches what hostapd broadcast
|
||||||
|
ap_ssid, _ = self._validate_ap_config()
|
||||||
|
self._show_led_message(
|
||||||
|
f"WiFi Setup\n{ap_ssid}\nNo password\n192.168.4.1:5000", duration=10
|
||||||
|
)
|
||||||
return True, "AP mode enabled"
|
return True, "AP mode enabled"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error starting AP services: {e}")
|
logger.error(f"Error starting AP services: {e}")
|
||||||
@@ -1716,245 +1998,120 @@ class WiFiManager:
|
|||||||
|
|
||||||
def _enable_ap_mode_nmcli_hotspot(self) -> Tuple[bool, str]:
|
def _enable_ap_mode_nmcli_hotspot(self) -> Tuple[bool, str]:
|
||||||
"""
|
"""
|
||||||
Enable AP mode using nmcli hotspot.
|
Enable AP mode using nmcli as an open (passwordless) access point.
|
||||||
|
|
||||||
This method is optimized for both Bookworm and Trixie:
|
Uses 'nmcli connection add type wifi 802-11-wireless.mode ap' instead of
|
||||||
- Trixie: Uses Netplan, connections stored in /run/NetworkManager/system-connections
|
'nmcli device wifi hotspot' because the hotspot subcommand always creates a
|
||||||
- Bookworm: Traditional NetworkManager, connections in /etc/NetworkManager/system-connections
|
WPA2-protected network on Bookworm/Trixie and silently ignores attempts to
|
||||||
|
strip security after creation.
|
||||||
|
|
||||||
On Trixie, we also disable PMF (Protected Management Frames) which can cause
|
Tested for both Bookworm and Trixie (Netplan-based NetworkManager).
|
||||||
connection issues with certain WiFi adapters and clients.
|
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Stop any existing connection
|
# Stop any existing connection
|
||||||
self.disconnect_from_network()
|
self.disconnect_from_network()
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
|
|
||||||
# Delete any existing hotspot connections (more thorough cleanup)
|
ap_ssid, ap_channel = self._validate_ap_config()
|
||||||
# First, list all connections to find any with the same SSID or hotspot-related ones
|
|
||||||
ap_ssid = self.config.get("ap_ssid", DEFAULT_AP_SSID)
|
|
||||||
result = subprocess.run(
|
|
||||||
["nmcli", "-t", "-f", "NAME,TYPE,802-11-wireless.ssid", "connection", "show"],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=10
|
|
||||||
)
|
|
||||||
if result.returncode == 0:
|
|
||||||
for line in result.stdout.strip().split('\n'):
|
|
||||||
if ':' in line:
|
|
||||||
parts = line.split(':')
|
|
||||||
if len(parts) >= 2:
|
|
||||||
conn_name = parts[0].strip()
|
|
||||||
conn_type = parts[1].strip().lower() if len(parts) > 1 else ""
|
|
||||||
conn_ssid = parts[2].strip() if len(parts) > 2 else ""
|
|
||||||
|
|
||||||
# Delete if:
|
# Delete only the specific application-managed AP profiles by name.
|
||||||
# 1. It's a hotspot type
|
# Never delete by SSID — that would destroy a user's saved home network.
|
||||||
# 2. It has the same SSID as our AP
|
|
||||||
# 3. It matches our known connection names
|
|
||||||
should_delete = (
|
|
||||||
'hotspot' in conn_type or
|
|
||||||
conn_ssid == ap_ssid or
|
|
||||||
'hotspot' in conn_name.lower() or
|
|
||||||
conn_name in ["Hotspot", "LEDMatrix-Setup-AP", "TickerSetup-AP"]
|
|
||||||
)
|
|
||||||
|
|
||||||
if should_delete:
|
|
||||||
logger.info(f"Deleting existing connection: {conn_name} (type: {conn_type}, SSID: {conn_ssid})")
|
|
||||||
# First disconnect it if active
|
|
||||||
subprocess.run(
|
|
||||||
["nmcli", "connection", "down", conn_name],
|
|
||||||
capture_output=True,
|
|
||||||
timeout=5
|
|
||||||
)
|
|
||||||
# Then delete it
|
|
||||||
subprocess.run(
|
|
||||||
["nmcli", "connection", "delete", conn_name],
|
|
||||||
capture_output=True,
|
|
||||||
timeout=10
|
|
||||||
)
|
|
||||||
|
|
||||||
# Also explicitly delete known connection names (in case they weren't caught above)
|
|
||||||
for conn_name in ["Hotspot", "LEDMatrix-Setup-AP", "TickerSetup-AP"]:
|
for conn_name in ["Hotspot", "LEDMatrix-Setup-AP", "TickerSetup-AP"]:
|
||||||
subprocess.run(
|
subprocess.run(["nmcli", "connection", "down", conn_name],
|
||||||
["nmcli", "connection", "down", conn_name],
|
capture_output=True, timeout=5)
|
||||||
capture_output=True,
|
subprocess.run(["nmcli", "connection", "delete", conn_name],
|
||||||
timeout=5
|
capture_output=True, timeout=10)
|
||||||
)
|
|
||||||
subprocess.run(
|
|
||||||
["nmcli", "connection", "delete", conn_name],
|
|
||||||
capture_output=True,
|
|
||||||
timeout=10
|
|
||||||
)
|
|
||||||
|
|
||||||
# Wait a moment for deletions to complete
|
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
|
|
||||||
# Get AP settings from config
|
# Create an open AP connection profile from scratch.
|
||||||
ap_ssid = self.config.get("ap_ssid", DEFAULT_AP_SSID)
|
# Using 'connection add' instead of 'device wifi hotspot' because the
|
||||||
ap_channel = self.config.get("ap_channel", DEFAULT_AP_CHANNEL)
|
# hotspot subcommand always attaches a WPA2 PSK on Bookworm/Trixie and
|
||||||
|
# ignores post-creation security modifications.
|
||||||
# Use nmcli hotspot command (simpler, works with Broadcom chips)
|
logger.info(f"Creating open AP with nmcli connection add: {ap_ssid} on "
|
||||||
# Open network (no password) for easy setup access
|
f"{self._wifi_interface} (no password)")
|
||||||
logger.info(f"Creating open hotspot with nmcli: {ap_ssid} on {self._wifi_interface} (no password)")
|
|
||||||
|
|
||||||
# Note: Some NetworkManager versions add a default password to hotspots
|
|
||||||
# We'll create it and then immediately remove all security settings
|
|
||||||
cmd = [
|
cmd = [
|
||||||
"nmcli", "device", "wifi", "hotspot",
|
"nmcli", "connection", "add",
|
||||||
"ifname", self._wifi_interface,
|
"type", "wifi",
|
||||||
"con-name", "LEDMatrix-Setup-AP",
|
"con-name", "LEDMatrix-Setup-AP",
|
||||||
|
"ifname", self._wifi_interface,
|
||||||
"ssid", ap_ssid,
|
"ssid", ap_ssid,
|
||||||
"band", "bg", # 2.4GHz for maximum compatibility
|
"802-11-wireless.mode", "ap",
|
||||||
"channel", str(ap_channel),
|
"802-11-wireless.band", "bg", # 2.4 GHz for maximum compatibility
|
||||||
# Don't pass password parameter - we'll remove security after creation
|
"802-11-wireless.channel", str(ap_channel),
|
||||||
|
"ipv4.method", "shared",
|
||||||
|
"ipv4.addresses", "192.168.4.1/24",
|
||||||
|
# No 802-11-wireless-security section → open network
|
||||||
]
|
]
|
||||||
|
|
||||||
result = subprocess.run(
|
# PMF (Protected Management Frames) is only meaningful for WPA2/WPA3.
|
||||||
cmd,
|
# An open AP has no security section, so adding 802-11-wireless-security.pmf
|
||||||
capture_output=True,
|
# would cause NM to require key-mgmt too, breaking the connection add on
|
||||||
text=True,
|
# Trixie NM 1.52+. Leave PMF untouched — open APs have no frame protection.
|
||||||
timeout=30
|
|
||||||
)
|
|
||||||
|
|
||||||
if result.returncode == 0:
|
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
||||||
# Always explicitly remove all security settings to ensure open network
|
|
||||||
# NetworkManager sometimes adds default security even when not specified
|
|
||||||
logger.info("Ensuring hotspot is open (no password)...")
|
|
||||||
time.sleep(2) # Give it a moment to create
|
|
||||||
|
|
||||||
# Remove all possible security settings
|
if result.returncode != 0:
|
||||||
security_settings = [
|
|
||||||
("802-11-wireless-security.key-mgmt", "none"),
|
|
||||||
("802-11-wireless-security.psk", ""),
|
|
||||||
("802-11-wireless-security.wep-key", ""),
|
|
||||||
("802-11-wireless-security.wep-key-type", ""),
|
|
||||||
("802-11-wireless-security.auth-alg", "open"),
|
|
||||||
]
|
|
||||||
|
|
||||||
# On Trixie, also disable PMF (Protected Management Frames)
|
|
||||||
# This can cause connection issues with certain WiFi adapters and clients
|
|
||||||
if self._is_trixie:
|
|
||||||
security_settings.append(("802-11-wireless-security.pmf", "disable"))
|
|
||||||
logger.info("Trixie detected: disabling PMF for better client compatibility")
|
|
||||||
|
|
||||||
for setting, value in security_settings:
|
|
||||||
result_modify = subprocess.run(
|
|
||||||
["nmcli", "connection", "modify", "LEDMatrix-Setup-AP", setting, str(value)],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=5
|
|
||||||
)
|
|
||||||
if result_modify.returncode != 0:
|
|
||||||
logger.debug(f"Could not set {setting} to {value}: {result_modify.stderr}")
|
|
||||||
|
|
||||||
# On Trixie, set static IP address for the hotspot (default is 10.42.0.1)
|
|
||||||
# We want 192.168.4.1 for consistency
|
|
||||||
subprocess.run(
|
|
||||||
["nmcli", "connection", "modify", "LEDMatrix-Setup-AP",
|
|
||||||
"ipv4.addresses", "192.168.4.1/24",
|
|
||||||
"ipv4.method", "shared"],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=5
|
|
||||||
)
|
|
||||||
|
|
||||||
# Verify it's open
|
|
||||||
verify_result = subprocess.run(
|
|
||||||
["nmcli", "-t", "-f", "802-11-wireless-security.key-mgmt,802-11-wireless-security.psk", "connection", "show", "LEDMatrix-Setup-AP"],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=5
|
|
||||||
)
|
|
||||||
|
|
||||||
if verify_result.returncode == 0:
|
|
||||||
output = verify_result.stdout.strip()
|
|
||||||
key_mgmt = ""
|
|
||||||
psk = ""
|
|
||||||
for line in output.split('\n'):
|
|
||||||
if 'key-mgmt:' in line:
|
|
||||||
key_mgmt = line.split(':', 1)[1].strip() if ':' in line else ""
|
|
||||||
elif 'psk:' in line:
|
|
||||||
psk = line.split(':', 1)[1].strip() if ':' in line else ""
|
|
||||||
|
|
||||||
if key_mgmt != "none" or (psk and psk != ""):
|
|
||||||
logger.warning(f"Hotspot still has security (key-mgmt={key_mgmt}, psk={'set' if psk else 'empty'}), deleting and recreating...")
|
|
||||||
# Delete and recreate as last resort
|
|
||||||
subprocess.run(
|
|
||||||
["nmcli", "connection", "down", "LEDMatrix-Setup-AP"],
|
|
||||||
capture_output=True,
|
|
||||||
timeout=5
|
|
||||||
)
|
|
||||||
subprocess.run(
|
|
||||||
["nmcli", "connection", "delete", "LEDMatrix-Setup-AP"],
|
|
||||||
capture_output=True,
|
|
||||||
timeout=5
|
|
||||||
)
|
|
||||||
time.sleep(1)
|
|
||||||
# Recreate without any password parameters
|
|
||||||
cmd_recreate = [
|
|
||||||
"nmcli", "device", "wifi", "hotspot",
|
|
||||||
"ifname", self._wifi_interface,
|
|
||||||
"con-name", "LEDMatrix-Setup-AP",
|
|
||||||
"ssid", ap_ssid,
|
|
||||||
"band", "bg",
|
|
||||||
"channel", str(ap_channel),
|
|
||||||
]
|
|
||||||
subprocess.run(cmd_recreate, capture_output=True, timeout=30)
|
|
||||||
# Set IP address for consistency
|
|
||||||
subprocess.run(
|
|
||||||
["nmcli", "connection", "modify", "LEDMatrix-Setup-AP",
|
|
||||||
"ipv4.addresses", "192.168.4.1/24",
|
|
||||||
"ipv4.method", "shared"],
|
|
||||||
capture_output=True,
|
|
||||||
timeout=5
|
|
||||||
)
|
|
||||||
# Disable PMF on Trixie
|
|
||||||
if self._is_trixie:
|
|
||||||
subprocess.run(
|
|
||||||
["nmcli", "connection", "modify", "LEDMatrix-Setup-AP",
|
|
||||||
"802-11-wireless-security.pmf", "disable"],
|
|
||||||
capture_output=True,
|
|
||||||
timeout=5
|
|
||||||
)
|
|
||||||
logger.info("Recreated hotspot as open network")
|
|
||||||
else:
|
|
||||||
logger.info("Hotspot verified as open (no password)")
|
|
||||||
|
|
||||||
# Restart the connection to apply all changes
|
|
||||||
subprocess.run(
|
|
||||||
["nmcli", "connection", "down", "LEDMatrix-Setup-AP"],
|
|
||||||
capture_output=True,
|
|
||||||
timeout=5
|
|
||||||
)
|
|
||||||
time.sleep(1)
|
|
||||||
subprocess.run(
|
|
||||||
["nmcli", "connection", "up", "LEDMatrix-Setup-AP"],
|
|
||||||
capture_output=True,
|
|
||||||
timeout=10
|
|
||||||
)
|
|
||||||
logger.info("Hotspot restarted with open network settings")
|
|
||||||
logger.info(f"AP mode started via nmcli hotspot: {ap_ssid}")
|
|
||||||
time.sleep(2)
|
|
||||||
|
|
||||||
# Verify hotspot is running
|
|
||||||
status = self._get_ap_status_nmcli()
|
|
||||||
if status.get('active'):
|
|
||||||
ip = status.get('ip', '192.168.4.1')
|
|
||||||
logger.info(f"AP mode confirmed active at {ip}")
|
|
||||||
self._show_led_message(f"Setup: {ip}", duration=5)
|
|
||||||
return True, f"AP mode enabled (hotspot mode) - Access at {ip}:5000"
|
|
||||||
else:
|
|
||||||
logger.error("AP mode started but not verified")
|
|
||||||
return False, "AP mode started but verification failed"
|
|
||||||
else:
|
|
||||||
error_msg = result.stderr.strip() or result.stdout.strip()
|
error_msg = result.stderr.strip() or result.stdout.strip()
|
||||||
logger.error(f"Failed to start AP mode via nmcli: {error_msg}")
|
logger.error(f"Failed to create AP connection profile: {error_msg}")
|
||||||
self._show_led_message("AP mode failed", duration=5)
|
self._show_led_message("AP mode failed", duration=5)
|
||||||
return False, f"Failed to start AP mode: {error_msg}"
|
return False, f"Failed to create AP profile: {error_msg}"
|
||||||
|
|
||||||
|
# Write the NM dnsmasq-shared.d captive-portal config BEFORE bringing up
|
||||||
|
# the connection so NM's dnsmasq picks it up at start time.
|
||||||
|
# This causes every hostname DNS query from a connected device to resolve
|
||||||
|
# to 192.168.4.1, automatically triggering the OS captive-portal popup.
|
||||||
|
self._write_nm_dnsmasq_captive_conf()
|
||||||
|
|
||||||
|
logger.info("AP connection profile created, bringing it up...")
|
||||||
|
up_result = subprocess.run(
|
||||||
|
["nmcli", "connection", "up", "LEDMatrix-Setup-AP"],
|
||||||
|
capture_output=True, text=True, timeout=20
|
||||||
|
)
|
||||||
|
if up_result.returncode != 0:
|
||||||
|
error_msg = up_result.stderr.strip() or up_result.stdout.strip()
|
||||||
|
logger.error(f"Failed to bring up AP connection: {error_msg}")
|
||||||
|
self._remove_nm_dnsmasq_captive_conf()
|
||||||
|
subprocess.run(["nmcli", "connection", "delete", "LEDMatrix-Setup-AP"],
|
||||||
|
capture_output=True, timeout=10)
|
||||||
|
self._show_led_message("AP mode failed", duration=5)
|
||||||
|
return False, f"Failed to start AP: {error_msg}"
|
||||||
|
|
||||||
|
time.sleep(2)
|
||||||
|
|
||||||
|
# NM's ipv4.method=shared manages ip_forward automatically, so we only
|
||||||
|
# need to add the iptables port-redirect rules for the captive portal.
|
||||||
|
if not self._setup_iptables_redirect():
|
||||||
|
logger.error("Captive-portal redirect setup failed; rolling back AP profile")
|
||||||
|
self._remove_nm_dnsmasq_captive_conf()
|
||||||
|
subprocess.run(["nmcli", "connection", "down", "LEDMatrix-Setup-AP"],
|
||||||
|
capture_output=True, timeout=10)
|
||||||
|
subprocess.run(["nmcli", "connection", "delete", "LEDMatrix-Setup-AP"],
|
||||||
|
capture_output=True, timeout=10)
|
||||||
|
self._clear_led_message()
|
||||||
|
return False, "AP started but captive-portal redirect setup failed"
|
||||||
|
|
||||||
|
# Verify the AP is actually running
|
||||||
|
status = self._get_ap_status_nmcli()
|
||||||
|
if status.get('active'):
|
||||||
|
ip = status.get('ip', '192.168.4.1')
|
||||||
|
logger.info(f"AP mode confirmed active at {ip} (open network, no password)")
|
||||||
|
self._show_led_message(f"WiFi Setup\n{ap_ssid}\nNo password\n{ip}:5000", duration=10)
|
||||||
|
return True, f"AP mode enabled (open network) - Access at {ip}:5000"
|
||||||
|
else:
|
||||||
|
logger.error("AP mode started but not verified by status check — rolling back")
|
||||||
|
self._teardown_iptables_redirect()
|
||||||
|
self._remove_nm_dnsmasq_captive_conf()
|
||||||
|
subprocess.run(["nmcli", "connection", "down", "LEDMatrix-Setup-AP"],
|
||||||
|
capture_output=True, timeout=10)
|
||||||
|
subprocess.run(["nmcli", "connection", "delete", "LEDMatrix-Setup-AP"],
|
||||||
|
capture_output=True, timeout=10)
|
||||||
|
self._clear_led_message()
|
||||||
|
return False, "AP mode started but verification failed"
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error starting AP mode with nmcli hotspot: {e}")
|
logger.error(f"Error starting AP mode with nmcli: {e}")
|
||||||
|
self._remove_nm_dnsmasq_captive_conf()
|
||||||
self._show_led_message("Setup mode error", duration=5)
|
self._show_led_message("Setup mode error", duration=5)
|
||||||
return False, str(e)
|
return False, str(e)
|
||||||
|
|
||||||
@@ -1976,7 +2133,12 @@ class WiFiManager:
|
|||||||
|
|
||||||
for line in result.stdout.strip().split('\n'):
|
for line in result.stdout.strip().split('\n'):
|
||||||
parts = line.split(':')
|
parts = line.split(':')
|
||||||
if len(parts) >= 2 and 'hotspot' in parts[1].lower():
|
if len(parts) < 2:
|
||||||
|
continue
|
||||||
|
conn_name = parts[0].strip()
|
||||||
|
conn_type = parts[1].strip().lower()
|
||||||
|
# Match our known AP profile name OR the legacy nmcli hotspot type
|
||||||
|
if conn_name == "LEDMatrix-Setup-AP" or 'hotspot' in conn_type:
|
||||||
# Get actual IP address (may be 192.168.4.1 or 10.42.0.1 depending on config)
|
# Get actual IP address (may be 192.168.4.1 or 10.42.0.1 depending on config)
|
||||||
ip = '192.168.4.1'
|
ip = '192.168.4.1'
|
||||||
interface = parts[2] if len(parts) > 2 else self._wifi_interface
|
interface = parts[2] if len(parts) > 2 else self._wifi_interface
|
||||||
@@ -2072,45 +2234,9 @@ class WiFiManager:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Could not remove dnsmasq drop-in config: {e}")
|
logger.warning(f"Could not remove dnsmasq drop-in config: {e}")
|
||||||
|
|
||||||
# Remove iptables port forwarding rules and disable IP forwarding (only for hostapd mode)
|
# Remove iptables redirect rules and restore ip_forward state (hostapd mode only)
|
||||||
if hostapd_active:
|
if hostapd_active:
|
||||||
try:
|
self._teardown_iptables_redirect()
|
||||||
# Check if iptables is available
|
|
||||||
iptables_check = subprocess.run(
|
|
||||||
["which", "iptables"],
|
|
||||||
capture_output=True,
|
|
||||||
timeout=2
|
|
||||||
)
|
|
||||||
|
|
||||||
if iptables_check.returncode == 0:
|
|
||||||
# Remove NAT redirect rule
|
|
||||||
subprocess.run(
|
|
||||||
["sudo", "iptables", "-t", "nat", "-D", "PREROUTING", "-i", self._wifi_interface, "-p", "tcp", "--dport", "80", "-j", "REDIRECT", "--to-port", "5000"],
|
|
||||||
capture_output=True,
|
|
||||||
timeout=5
|
|
||||||
)
|
|
||||||
|
|
||||||
# Remove INPUT rule
|
|
||||||
subprocess.run(
|
|
||||||
["sudo", "iptables", "-D", "INPUT", "-i", self._wifi_interface, "-p", "tcp", "--dport", "80", "-j", "ACCEPT"],
|
|
||||||
capture_output=True,
|
|
||||||
timeout=5
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info("Removed iptables port forwarding rules")
|
|
||||||
else:
|
|
||||||
logger.debug("iptables not available, skipping rule removal")
|
|
||||||
|
|
||||||
# Disable IP forwarding (restore to default client mode)
|
|
||||||
subprocess.run(
|
|
||||||
["sudo", "sysctl", "-w", "net.ipv4.ip_forward=0"],
|
|
||||||
capture_output=True,
|
|
||||||
timeout=5
|
|
||||||
)
|
|
||||||
logger.info("Disabled IP forwarding")
|
|
||||||
except (subprocess.TimeoutExpired, subprocess.SubprocessError, OSError) as e:
|
|
||||||
logger.warning(f"Could not remove iptables rules or disable forwarding: {e}")
|
|
||||||
# Continue anyway
|
|
||||||
|
|
||||||
# Clean up WiFi interface IP configuration
|
# Clean up WiFi interface IP configuration
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
@@ -2153,14 +2279,17 @@ class WiFiManager:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Final WiFi radio unblock attempt failed: {e}")
|
logger.error(f"Final WiFi radio unblock attempt failed: {e}")
|
||||||
else:
|
else:
|
||||||
# nmcli hotspot mode - restart not needed, just ensure WiFi radio is enabled
|
# nmcli AP mode — NM's ipv4.method=shared manages ip_forward automatically,
|
||||||
logger.info("Skipping NetworkManager restart (nmcli hotspot mode, restart not needed)")
|
# so we only need to remove the iptables redirect rules we added.
|
||||||
# Still ensure WiFi radio is enabled (may have been disabled by nmcli operations)
|
logger.info("Skipping NetworkManager restart (nmcli AP mode, restart not needed)")
|
||||||
# Use retries for safety
|
self._teardown_iptables_redirect()
|
||||||
|
self._remove_nm_dnsmasq_captive_conf()
|
||||||
|
# Ensure WiFi radio is enabled after nmcli operations
|
||||||
wifi_enabled = self._ensure_wifi_radio_enabled(max_retries=3)
|
wifi_enabled = self._ensure_wifi_radio_enabled(max_retries=3)
|
||||||
if not wifi_enabled:
|
if not wifi_enabled:
|
||||||
logger.warning("WiFi radio may be disabled after nmcli hotspot cleanup")
|
logger.warning("WiFi radio may be disabled after nmcli AP cleanup")
|
||||||
|
|
||||||
|
self._ap_enabled_at = None
|
||||||
logger.info("AP mode disabled successfully")
|
logger.info("AP mode disabled successfully")
|
||||||
return True, "AP mode disabled"
|
return True, "AP mode disabled"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -2176,8 +2305,10 @@ class WiFiManager:
|
|||||||
config_dir = HOSTAPD_CONFIG_PATH.parent
|
config_dir = HOSTAPD_CONFIG_PATH.parent
|
||||||
config_dir.mkdir(parents=True, exist_ok=True)
|
config_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
ap_ssid = self.config.get("ap_ssid", DEFAULT_AP_SSID)
|
# Use validated values — strips invalid chars and ensures channel is an int.
|
||||||
ap_channel = self.config.get("ap_channel", DEFAULT_AP_CHANNEL)
|
# Also strip newlines from SSID to prevent config-file injection.
|
||||||
|
ap_ssid, ap_channel = self._validate_ap_config()
|
||||||
|
ap_ssid = ap_ssid.replace('\n', '').replace('\r', '')
|
||||||
|
|
||||||
# Open network configuration (no password) for easy setup access
|
# Open network configuration (no password) for easy setup access
|
||||||
config_content = f"""interface={self._wifi_interface}
|
config_content = f"""interface={self._wifi_interface}
|
||||||
@@ -2305,12 +2436,11 @@ address=/detectportal.firefox.com/192.168.4.1
|
|||||||
f"Ethernet={ethernet_connected}, AP_active={ap_active}, "
|
f"Ethernet={ethernet_connected}, AP_active={ap_active}, "
|
||||||
f"auto_enable={auto_enable}, disconnected_checks={self._disconnected_checks}")
|
f"auto_enable={auto_enable}, disconnected_checks={self._disconnected_checks}")
|
||||||
|
|
||||||
# Determine if we should have AP mode active
|
# Determine if we should have AP mode active.
|
||||||
# AP mode should only be auto-enabled if:
|
# AP-enable uses only the nmcli association state (fast, no network calls).
|
||||||
# - auto_enable_ap_mode is True AND
|
# This keeps the same reliable behaviour as before: momentary packet loss
|
||||||
# - WiFi is NOT connected AND
|
# while on working WiFi does NOT trigger AP mode. The internet-reachability
|
||||||
# - Ethernet is NOT connected AND
|
# check is performed separately in the daemon watchdog for NM recovery.
|
||||||
# - We've had multiple consecutive disconnected checks (grace period)
|
|
||||||
is_disconnected = not status.connected and not ethernet_connected
|
is_disconnected = not status.connected and not ethernet_connected
|
||||||
|
|
||||||
if is_disconnected:
|
if is_disconnected:
|
||||||
@@ -2318,9 +2448,9 @@ address=/detectportal.firefox.com/192.168.4.1
|
|||||||
self._disconnected_checks += 1
|
self._disconnected_checks += 1
|
||||||
logger.debug(f"Network disconnected (check {self._disconnected_checks}/{self._disconnected_checks_required})")
|
logger.debug(f"Network disconnected (check {self._disconnected_checks}/{self._disconnected_checks_required})")
|
||||||
else:
|
else:
|
||||||
# Reset counter if we're connected
|
# Reset counter if we're associated
|
||||||
if self._disconnected_checks > 0:
|
if self._disconnected_checks > 0:
|
||||||
logger.debug(f"Network connected, resetting disconnected check counter")
|
logger.debug("Network connected, resetting disconnected check counter")
|
||||||
self._disconnected_checks = 0
|
self._disconnected_checks = 0
|
||||||
|
|
||||||
# Only enable AP if we've had enough consecutive disconnected checks
|
# Only enable AP if we've had enough consecutive disconnected checks
|
||||||
@@ -2366,6 +2496,24 @@ address=/detectportal.firefox.com/192.168.4.1
|
|||||||
# Don't disable it automatically, let it stay active
|
# Don't disable it automatically, let it stay active
|
||||||
logger.debug("AP mode is active (manually enabled), keeping active")
|
logger.debug("AP mode is active (manually enabled), keeping active")
|
||||||
|
|
||||||
|
# Idle-timeout check: disable AP if no client has connected within the window.
|
||||||
|
# Only applies when AP is active and we haven't just decided to enable/disable it.
|
||||||
|
if ap_active and self._ap_enabled_at is not None:
|
||||||
|
try:
|
||||||
|
idle_timeout_min = max(1, min(1440, int(self.config.get("ap_idle_timeout_minutes", 15))))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
idle_timeout_min = 15
|
||||||
|
elapsed = time.time() - self._ap_enabled_at
|
||||||
|
if elapsed > idle_timeout_min * 60 and not self._has_ap_clients():
|
||||||
|
logger.info(
|
||||||
|
f"AP idle timeout ({idle_timeout_min} min, no clients) — disabling AP"
|
||||||
|
)
|
||||||
|
success, message = self.disable_ap_mode()
|
||||||
|
if success:
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
logger.warning(f"Failed to disable AP on idle timeout: {message}")
|
||||||
|
|
||||||
return False
|
return False
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error checking AP mode: {e}", exc_info=True)
|
logger.error(f"Error checking AP mode: {e}", exc_info=True)
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ Wants=network.target
|
|||||||
Type=simple
|
Type=simple
|
||||||
User=root
|
User=root
|
||||||
WorkingDirectory=__PROJECT_ROOT_DIR__
|
WorkingDirectory=__PROJECT_ROOT_DIR__
|
||||||
ExecStart=/usr/bin/python3 /home/ledpi/LEDMatrix/scripts/utils/wifi_monitor_daemon.py --interval 30
|
ExecStart=/usr/bin/python3 __PROJECT_ROOT_DIR__/scripts/utils/wifi_monitor_daemon.py --interval 30
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=10
|
RestartSec=10
|
||||||
StandardOutput=syslog
|
StandardOutput=syslog
|
||||||
|
|||||||
334
test/test_wifi_manager_ap.py
Normal file
334
test/test_wifi_manager_ap.py
Normal file
@@ -0,0 +1,334 @@
|
|||||||
|
"""
|
||||||
|
Unit tests for WiFi AP mode — src.wifi_manager.
|
||||||
|
|
||||||
|
Each test exercises logic that can be verified through the subprocess calls the
|
||||||
|
manager emits, without requiring root access, hardware, or a running Pi.
|
||||||
|
|
||||||
|
Scenarios covered:
|
||||||
|
1. nmcli AP profile is created with no security parameters (open/passwordless).
|
||||||
|
2. iptables PREROUTING and INPUT rules are added when the nmcli AP starts.
|
||||||
|
3. iptables rules and ip_forward are reverted when the AP is torn down.
|
||||||
|
4. LED matrix message includes the SSID, 'No password', and the setup URL.
|
||||||
|
5. Known AP profile names are deleted before the new profile is created.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.wifi_manager import WiFiManager
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _ok(stdout: str = "", stderr: str = "") -> MagicMock:
|
||||||
|
r = MagicMock()
|
||||||
|
r.returncode = 0
|
||||||
|
r.stdout = stdout
|
||||||
|
r.stderr = stderr
|
||||||
|
return r
|
||||||
|
|
||||||
|
|
||||||
|
def _fail(stdout: str = "", stderr: str = "error") -> MagicMock:
|
||||||
|
r = MagicMock()
|
||||||
|
r.returncode = 1
|
||||||
|
r.stdout = stdout
|
||||||
|
r.stderr = stderr
|
||||||
|
return r
|
||||||
|
|
||||||
|
|
||||||
|
def _find_path_side_effect(name: str) -> str:
|
||||||
|
"""Deterministic fake for _find_command_path."""
|
||||||
|
return {"iptables": "/usr/sbin/iptables", "sysctl": "/usr/sbin/sysctl"}.get(
|
||||||
|
name, f"/usr/bin/{name}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fixtures
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def wifi_config(tmp_path: Path) -> Path:
|
||||||
|
"""Minimal wifi_config.json in a temporary directory."""
|
||||||
|
cfg_dir = tmp_path / "config"
|
||||||
|
cfg_dir.mkdir()
|
||||||
|
cfg = {
|
||||||
|
"ap_ssid": "LEDMatrix-Setup",
|
||||||
|
"ap_channel": 7,
|
||||||
|
"auto_enable_ap_mode": True,
|
||||||
|
"saved_networks": [],
|
||||||
|
}
|
||||||
|
p = cfg_dir / "wifi_config.json"
|
||||||
|
p.write_text(json.dumps(cfg))
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def manager(wifi_config: Path, tmp_path: Path) -> WiFiManager:
|
||||||
|
"""
|
||||||
|
WiFiManager with all system calls stubbed out during construction and the
|
||||||
|
ip_forward save file redirected to a per-test temporary path.
|
||||||
|
"""
|
||||||
|
with patch("src.wifi_manager.subprocess.run", return_value=_ok(stdout="wlan0\n")), \
|
||||||
|
patch.object(WiFiManager, "_detect_trixie", return_value=False):
|
||||||
|
mgr = WiFiManager(config_path=wifi_config)
|
||||||
|
|
||||||
|
# Force clean, deterministic state regardless of what __init__ inferred
|
||||||
|
mgr._wifi_interface = "wlan0"
|
||||||
|
mgr.has_nmcli = True
|
||||||
|
mgr.has_hostapd = False
|
||||||
|
mgr.has_dnsmasq = False
|
||||||
|
mgr.has_iwlist = False
|
||||||
|
mgr._is_trixie = False
|
||||||
|
# Redirect the ip_forward save file to tmp so tests never share state
|
||||||
|
mgr._IP_FORWARD_SAVE_PATH = tmp_path / "ip_fwd_saved"
|
||||||
|
return mgr
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 1. AP profile is open (no password)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_nmcli_ap_profile_has_no_security_params(manager: WiFiManager) -> None:
|
||||||
|
"""
|
||||||
|
The 'nmcli connection add' command must not include key-mgmt, psk, or any
|
||||||
|
WPA-related parameter. On Bookworm/Trixie, NM creates a WPA2-protected
|
||||||
|
hotspot even when those values are set to 'none'/empty via a later
|
||||||
|
'connection modify', so the profile must be created without a security
|
||||||
|
section from the start.
|
||||||
|
"""
|
||||||
|
captured: list[list[str]] = []
|
||||||
|
|
||||||
|
def _run(cmd, **kw):
|
||||||
|
captured.append(list(cmd))
|
||||||
|
return _ok()
|
||||||
|
|
||||||
|
with patch("src.wifi_manager.subprocess.run", side_effect=_run), \
|
||||||
|
patch.object(manager, "disconnect_from_network", return_value=(True, "ok")), \
|
||||||
|
patch.object(manager, "_setup_iptables_redirect", return_value=True), \
|
||||||
|
patch.object(manager, "_get_ap_status_nmcli",
|
||||||
|
return_value={"active": True, "ip": "192.168.4.1"}), \
|
||||||
|
patch.object(manager, "_show_led_message"):
|
||||||
|
|
||||||
|
success, _ = manager._enable_ap_mode_nmcli_hotspot()
|
||||||
|
|
||||||
|
assert success, "AP enable should report success"
|
||||||
|
|
||||||
|
add_calls = [c for c in captured if "nmcli" in c and "connection" in c and "add" in c]
|
||||||
|
assert add_calls, "Expected at least one 'nmcli connection add' invocation"
|
||||||
|
|
||||||
|
add_str = " ".join(add_calls[0])
|
||||||
|
assert "key-mgmt" not in add_str, "AP profile must not set key-mgmt"
|
||||||
|
assert "psk" not in add_str, "AP profile must not include a PSK/password"
|
||||||
|
assert "wpa" not in add_str.lower(), "AP profile must not reference WPA"
|
||||||
|
assert "802-11-wireless.mode" in add_str, "AP profile must declare wireless mode"
|
||||||
|
# Verify the value for 802-11-wireless.mode is exactly "ap" — check the element
|
||||||
|
# that immediately follows the key in the command list, not a loose substring match.
|
||||||
|
cmd = add_calls[0]
|
||||||
|
try:
|
||||||
|
mode_idx = cmd.index("802-11-wireless.mode")
|
||||||
|
assert cmd[mode_idx + 1] == "ap", \
|
||||||
|
f"802-11-wireless.mode value must be exactly 'ap', got {cmd[mode_idx + 1]!r}"
|
||||||
|
except ValueError:
|
||||||
|
pytest.fail("802-11-wireless.mode not found as a list element in nmcli command")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 2. iptables NAT rules are added when the AP starts
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_iptables_nat_rules_added_on_ap_start(manager: WiFiManager) -> None:
|
||||||
|
"""
|
||||||
|
_setup_iptables_redirect must add:
|
||||||
|
- a PREROUTING REDIRECT rule that maps incoming TCP port 80 to port 5000, and
|
||||||
|
- an INPUT ACCEPT rule for port 5000 (the post-redirect destination port,
|
||||||
|
NOT port 80 which never hits the INPUT chain after PREROUTING rewrites it).
|
||||||
|
"""
|
||||||
|
captured: list[list[str]] = []
|
||||||
|
|
||||||
|
def _run(cmd, **kw):
|
||||||
|
captured.append(list(cmd))
|
||||||
|
# iptables -C (check) → rc=1 so the -A (add) branch executes
|
||||||
|
if "iptables" in " ".join(str(x) for x in cmd) and "-C" in cmd:
|
||||||
|
return _fail()
|
||||||
|
return _ok()
|
||||||
|
|
||||||
|
# Patch Path.read_text so /proc/sys/net/ipv4/ip_forward is readable on any OS
|
||||||
|
with patch("src.wifi_manager.subprocess.run", side_effect=_run), \
|
||||||
|
patch.object(manager, "_find_command_path", side_effect=_find_path_side_effect), \
|
||||||
|
patch("pathlib.Path.read_text", return_value="0\n"):
|
||||||
|
|
||||||
|
result = manager._setup_iptables_redirect()
|
||||||
|
|
||||||
|
assert result, "_setup_iptables_redirect must return True on success"
|
||||||
|
|
||||||
|
prerouting_adds = [c for c in captured if "iptables" in " ".join(c) and "-A" in c and "PREROUTING" in c]
|
||||||
|
assert prerouting_adds, "Expected 'iptables -A PREROUTING' invocation"
|
||||||
|
pr_str = " ".join(prerouting_adds[0])
|
||||||
|
assert "--dport" in pr_str and "80" in pr_str, "PREROUTING rule must match dport 80"
|
||||||
|
assert "5000" in pr_str, "PREROUTING rule must redirect to port 5000"
|
||||||
|
assert "REDIRECT" in pr_str, "PREROUTING rule must use REDIRECT target"
|
||||||
|
|
||||||
|
input_adds = [c for c in captured if "iptables" in " ".join(c) and "-A" in c and "INPUT" in c]
|
||||||
|
assert input_adds, "Expected 'iptables -A INPUT' invocation"
|
||||||
|
in_str = " ".join(input_adds[0])
|
||||||
|
assert "5000" in in_str, "INPUT rule must accept port 5000 (post-PREROUTING destination)"
|
||||||
|
assert "ACCEPT" in in_str, "INPUT rule must use ACCEPT target"
|
||||||
|
|
||||||
|
# Port 80 must NOT be used in the INPUT rule (it is already redirected by PREROUTING)
|
||||||
|
input_80 = [c for c in captured if "iptables" in " ".join(c) and "INPUT" in c and "--dport" in c and "80" in c]
|
||||||
|
assert not input_80, "INPUT rule must target port 5000, not port 80"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 3a. iptables rules and ip_forward reverted on teardown
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_iptables_rules_and_ip_forward_reverted_on_teardown(manager: WiFiManager) -> None:
|
||||||
|
"""
|
||||||
|
_teardown_iptables_redirect must:
|
||||||
|
- remove the PREROUTING and INPUT iptables rules, and
|
||||||
|
- restore ip_forward to the exact value recorded in the save file.
|
||||||
|
"""
|
||||||
|
original_fwd = "0"
|
||||||
|
manager._IP_FORWARD_SAVE_PATH.write_text(original_fwd)
|
||||||
|
# Teardown dispatches on the backend recorded during setup
|
||||||
|
manager._redirect_backend = "iptables"
|
||||||
|
|
||||||
|
captured: list[list[str]] = []
|
||||||
|
|
||||||
|
with patch("src.wifi_manager.subprocess.run",
|
||||||
|
side_effect=lambda cmd, **kw: (captured.append(list(cmd)) or _ok())), \
|
||||||
|
patch.object(manager, "_find_command_path", side_effect=_find_path_side_effect):
|
||||||
|
|
||||||
|
manager._teardown_iptables_redirect()
|
||||||
|
|
||||||
|
assert [c for c in captured if "iptables" in " ".join(c) and "-D" in c and "PREROUTING" in c], \
|
||||||
|
"Expected 'iptables -D PREROUTING' invocation"
|
||||||
|
|
||||||
|
assert [c for c in captured if "iptables" in " ".join(c) and "-D" in c and "INPUT" in c], \
|
||||||
|
"Expected 'iptables -D INPUT' invocation"
|
||||||
|
|
||||||
|
restore_calls = [
|
||||||
|
c for c in captured
|
||||||
|
if "sysctl" in " ".join(c) and f"ip_forward={original_fwd}" in " ".join(c)
|
||||||
|
]
|
||||||
|
assert restore_calls, f"Expected sysctl to restore ip_forward to {original_fwd!r}"
|
||||||
|
|
||||||
|
assert not manager._IP_FORWARD_SAVE_PATH.exists(), \
|
||||||
|
"Save file must be removed after successful teardown"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 3b. ip_forward untouched when no save file exists
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_ip_forward_not_restored_when_save_file_absent(manager: WiFiManager) -> None:
|
||||||
|
"""
|
||||||
|
When the save file is missing (setup never wrote it, e.g. because /proc was
|
||||||
|
unreadable or the write failed), teardown must NOT call sysctl so it does not
|
||||||
|
accidentally clobber ip_forward state owned by a VPN or NetworkManager.
|
||||||
|
"""
|
||||||
|
assert not manager._IP_FORWARD_SAVE_PATH.exists()
|
||||||
|
|
||||||
|
captured: list[list[str]] = []
|
||||||
|
|
||||||
|
with patch("src.wifi_manager.subprocess.run",
|
||||||
|
side_effect=lambda cmd, **kw: (captured.append(list(cmd)) or _ok())), \
|
||||||
|
patch.object(manager, "_find_command_path", side_effect=_find_path_side_effect):
|
||||||
|
|
||||||
|
manager._teardown_iptables_redirect()
|
||||||
|
|
||||||
|
sysctl_calls = [
|
||||||
|
c for c in captured
|
||||||
|
if "sysctl" in " ".join(c) and "ip_forward" in " ".join(c)
|
||||||
|
]
|
||||||
|
assert not sysctl_calls, \
|
||||||
|
"sysctl must not be called when no ip_forward save file exists"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 4. LED message content
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_led_message_shows_ssid_no_password_and_url(manager: WiFiManager) -> None:
|
||||||
|
"""
|
||||||
|
When the nmcli AP activates, the LED message must include:
|
||||||
|
- the AP SSID ('LEDMatrix-Setup')
|
||||||
|
- the string 'No password'
|
||||||
|
- the AP IP address (192.168.4.1) and Flask port (5000)
|
||||||
|
"""
|
||||||
|
led_messages: list[str] = []
|
||||||
|
|
||||||
|
with patch("src.wifi_manager.subprocess.run", return_value=_ok()), \
|
||||||
|
patch.object(manager, "disconnect_from_network", return_value=(True, "ok")), \
|
||||||
|
patch.object(manager, "_setup_iptables_redirect", return_value=True), \
|
||||||
|
patch.object(manager, "_get_ap_status_nmcli",
|
||||||
|
return_value={"active": True, "ip": "192.168.4.1"}), \
|
||||||
|
patch.object(manager, "_show_led_message",
|
||||||
|
side_effect=lambda msg, **kw: led_messages.append(msg)):
|
||||||
|
|
||||||
|
success, _ = manager._enable_ap_mode_nmcli_hotspot()
|
||||||
|
|
||||||
|
assert success, "AP enable should report success"
|
||||||
|
assert led_messages, "Expected at least one _show_led_message call"
|
||||||
|
|
||||||
|
combined = "\n".join(led_messages)
|
||||||
|
assert "No password" in combined, "LED message must say 'No password'"
|
||||||
|
assert "LEDMatrix-Setup" in combined, "LED message must include the AP SSID"
|
||||||
|
assert "192.168.4.1" in combined, "LED message must include the AP IP address"
|
||||||
|
assert "5000" in combined, "LED message must include the Flask port"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 5. Stale AP profiles deleted before the new one is created
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_existing_ap_profiles_deleted_before_new_profile_created(manager: WiFiManager) -> None:
|
||||||
|
"""
|
||||||
|
Before 'nmcli connection add', the manager must issue
|
||||||
|
'nmcli connection down/delete' for every known AP profile name so stale
|
||||||
|
profiles (from a previous crash or partial setup) cannot block the new one.
|
||||||
|
"""
|
||||||
|
captured: list[list[str]] = []
|
||||||
|
|
||||||
|
def _run(cmd, **kw):
|
||||||
|
captured.append(list(cmd))
|
||||||
|
return _ok()
|
||||||
|
|
||||||
|
with patch("src.wifi_manager.subprocess.run", side_effect=_run), \
|
||||||
|
patch.object(manager, "disconnect_from_network", return_value=(True, "ok")), \
|
||||||
|
patch.object(manager, "_setup_iptables_redirect", return_value=True), \
|
||||||
|
patch.object(manager, "_get_ap_status_nmcli",
|
||||||
|
return_value={"active": True, "ip": "192.168.4.1"}), \
|
||||||
|
patch.object(manager, "_show_led_message"):
|
||||||
|
|
||||||
|
success, _ = manager._enable_ap_mode_nmcli_hotspot()
|
||||||
|
|
||||||
|
assert success
|
||||||
|
|
||||||
|
cmd_strs = [" ".join(c) for c in captured]
|
||||||
|
|
||||||
|
for profile in ("LEDMatrix-Setup-AP", "Hotspot", "TickerSetup-AP"):
|
||||||
|
assert any("connection delete" in s and profile in s for s in cmd_strs), \
|
||||||
|
f"Expected 'nmcli connection delete {profile}' before creating the new profile"
|
||||||
|
|
||||||
|
add_indices = [i for i, s in enumerate(cmd_strs) if "connection add" in s]
|
||||||
|
del_indices = [i for i, s in enumerate(cmd_strs) if "connection delete" in s]
|
||||||
|
|
||||||
|
assert add_indices, "Expected 'nmcli connection add' call"
|
||||||
|
assert del_indices, "Expected 'nmcli connection delete' calls"
|
||||||
|
assert max(del_indices) < min(add_indices), \
|
||||||
|
"All connection deletions must complete before the new profile is created"
|
||||||
@@ -226,13 +226,24 @@ 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 = 5 # seconds
|
_AP_MODE_CACHE_TTL = 30 # seconds — AP mode is user-initiated; 30s is fine
|
||||||
|
|
||||||
|
# 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, 5s TTL).
|
Check if access point mode is currently active (cached, 30s 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()
|
||||||
@@ -444,7 +455,8 @@ def system_status_generator():
|
|||||||
# Try to import psutil for system stats
|
# Try to import psutil for system stats
|
||||||
try:
|
try:
|
||||||
import psutil
|
import psutil
|
||||||
cpu_percent = round(psutil.cpu_percent(interval=1), 1)
|
# interval=None is non-blocking; primed at module startup above
|
||||||
|
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)
|
||||||
|
|
||||||
@@ -461,14 +473,17 @@ def system_status_generator():
|
|||||||
memory_used_percent = 0
|
memory_used_percent = 0
|
||||||
cpu_temp = 0
|
cpu_temp = 0
|
||||||
|
|
||||||
# Check if display service is running
|
# Check if display service is running (cached to avoid per-client subprocess forks)
|
||||||
service_active = False
|
now = time.time()
|
||||||
try:
|
if (now - _ledmatrix_service_cache['timestamp']) >= _LEDMATRIX_SERVICE_CACHE_TTL:
|
||||||
result = subprocess.run(['systemctl', 'is-active', 'ledmatrix'],
|
try:
|
||||||
capture_output=True, text=True, timeout=2)
|
result = subprocess.run(['systemctl', 'is-active', 'ledmatrix'],
|
||||||
service_active = result.stdout.strip() == 'active'
|
capture_output=True, text=True, timeout=2)
|
||||||
except (subprocess.SubprocessError, OSError):
|
_ledmatrix_service_cache['active'] = result.stdout.strip() == 'active'
|
||||||
pass
|
except (subprocess.SubprocessError, OSError):
|
||||||
|
pass
|
||||||
|
_ledmatrix_service_cache['timestamp'] = now
|
||||||
|
service_active = _ledmatrix_service_cache['active']
|
||||||
|
|
||||||
status = {
|
status = {
|
||||||
'timestamp': time.time(),
|
'timestamp': time.time(),
|
||||||
@@ -546,7 +561,7 @@ def display_preview_generator():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
yield {'error': str(e)}
|
yield {'error': str(e)}
|
||||||
|
|
||||||
time.sleep(0.5) # Check 2 times per second (reduced frequency for better performance)
|
time.sleep(1.0) # Check once per second — halves PIL encode overhead vs 0.5s
|
||||||
|
|
||||||
# Logs generator for SSE
|
# Logs generator for SSE
|
||||||
def logs_generator():
|
def logs_generator():
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -898,6 +898,10 @@ window.currentPluginConfig = null;
|
|||||||
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes in milliseconds
|
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes in milliseconds
|
||||||
let storeFilteredList = [];
|
let storeFilteredList = [];
|
||||||
|
|
||||||
|
function storeCacheExpired() {
|
||||||
|
return !cacheTimestamp || (Date.now() - cacheTimestamp >= CACHE_DURATION);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Plugin Store Filter State ───────────────────────────────────────────
|
// ── Plugin Store Filter State ───────────────────────────────────────────
|
||||||
const storeFilterState = {
|
const storeFilterState = {
|
||||||
sort: safeLocalStorage.getItem('storeSort') || 'a-z',
|
sort: safeLocalStorage.getItem('storeSort') || 'a-z',
|
||||||
@@ -1214,12 +1218,18 @@ function initializePlugins() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Load both installed plugins and plugin store.
|
// Load both installed plugins and plugin store.
|
||||||
// On HTMX re-swaps use cached store data (fetchCommitInfo=false) to avoid
|
// On HTMX re-swaps with a still-warm cache, skip GitHub metadata to avoid
|
||||||
// re-hitting GitHub on every tab switch; only fetch fresh on first load.
|
// re-hitting the API on every tab switch. If the cache TTL has expired even
|
||||||
const isReswap = !!window.pluginManager._reswap;
|
// during a re-swap, fetch fresh data including GitHub commit/version info.
|
||||||
|
const isReswapWarm = !!window.pluginManager._reswap && !storeCacheExpired();
|
||||||
window.pluginManager._reswap = false;
|
window.pluginManager._reswap = false;
|
||||||
loadInstalledPlugins();
|
// Await the installed-plugins fetch so window.installedPlugins is populated before
|
||||||
searchPluginStore(!isReswap);
|
// searchPluginStore renders Installed/Reinstall badges against it.
|
||||||
|
loadInstalledPlugins().catch(err => {
|
||||||
|
console.error('[PluginStore] loadInstalledPlugins failed:', err);
|
||||||
|
}).finally(() => {
|
||||||
|
searchPluginStore(!isReswapWarm);
|
||||||
|
});
|
||||||
|
|
||||||
// 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');
|
||||||
@@ -4843,9 +4853,9 @@ window.executePluginAction = function(actionId, actionIndex, pluginIdParam = nul
|
|||||||
showNotification(data.message || 'Action completed successfully!', 'success');
|
showNotification(data.message || 'Action completed successfully!', 'success');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
statusDiv.innerHTML = `<div class="text-red-600"><i class="fas fa-exclamation-circle mr-2"></i>${data.message}</div>`;
|
statusDiv.innerHTML = `<div class="text-red-600"><i class="fas fa-exclamation-circle mr-2"></i>${escapeHtml(data.message || 'Error')}</div>`;
|
||||||
if (data.output) {
|
if (data.output) {
|
||||||
statusDiv.innerHTML += `<pre class="mt-2 text-xs bg-red-50 p-2 rounded overflow-auto max-h-32">${data.output}</pre>`;
|
statusDiv.innerHTML += `<pre class="mt-2 text-xs bg-red-50 p-2 rounded overflow-auto max-h-32">${escapeHtml(data.output)}</pre>`;
|
||||||
}
|
}
|
||||||
btn.innerHTML = originalText;
|
btn.innerHTML = originalText;
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
@@ -4889,8 +4899,8 @@ window.executePluginAction = function(actionId, actionIndex, pluginIdParam = nul
|
|||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<p class="text-sm text-blue-700 mb-2">1. Click the link below to authorize:</p>
|
<p class="text-sm text-blue-700 mb-2">1. Click the link below to authorize:</p>
|
||||||
<a href="${data.auth_url}" target="_blank" class="text-blue-600 hover:text-blue-800 underline break-all">
|
<a href="${data.auth_url && data.auth_url.startsWith('http') ? escapeHtml(data.auth_url) : '#'}" target="_blank" class="text-blue-600 hover:text-blue-800 underline break-all">
|
||||||
${data.auth_url}
|
${escapeHtml(data.auth_url || '')}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-2">
|
<div class="mb-2">
|
||||||
@@ -4912,7 +4922,7 @@ window.executePluginAction = function(actionId, actionIndex, pluginIdParam = nul
|
|||||||
<div class="text-green-900 font-medium mb-2">
|
<div class="text-green-900 font-medium mb-2">
|
||||||
<i class="fas fa-check-circle mr-2"></i>${data.message || 'Action completed successfully'}
|
<i class="fas fa-check-circle mr-2"></i>${data.message || 'Action completed successfully'}
|
||||||
</div>
|
</div>
|
||||||
${data.output ? `<pre class="mt-2 text-xs bg-green-50 p-2 rounded overflow-auto max-h-32">${data.output}</pre>` : ''}
|
${data.output ? `<pre class="mt-2 text-xs bg-green-50 p-2 rounded overflow-auto max-h-32">${escapeHtml(data.output)}</pre>` : ''}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
btn.innerHTML = originalText;
|
btn.innerHTML = originalText;
|
||||||
@@ -4925,9 +4935,9 @@ window.executePluginAction = function(actionId, actionIndex, pluginIdParam = nul
|
|||||||
statusDiv.innerHTML = `
|
statusDiv.innerHTML = `
|
||||||
<div class="bg-red-50 border border-red-200 rounded p-3">
|
<div class="bg-red-50 border border-red-200 rounded p-3">
|
||||||
<div class="text-red-900 font-medium mb-2">
|
<div class="text-red-900 font-medium mb-2">
|
||||||
<i class="fas fa-exclamation-circle mr-2"></i>${data.message || 'Action failed'}
|
<i class="fas fa-exclamation-circle mr-2"></i>${escapeHtml(data.message || 'Action failed')}
|
||||||
</div>
|
</div>
|
||||||
${data.output ? `<pre class="mt-2 text-xs bg-red-50 p-2 rounded overflow-auto max-h-32">${data.output}</pre>` : ''}
|
${data.output ? `<pre class="mt-2 text-xs bg-red-50 p-2 rounded overflow-auto max-h-32">${escapeHtml(data.output)}</pre>` : ''}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
btn.innerHTML = originalText;
|
btn.innerHTML = originalText;
|
||||||
@@ -5133,10 +5143,13 @@ function refreshPlugins() {
|
|||||||
pluginStoreCache = null;
|
pluginStoreCache = null;
|
||||||
cacheTimestamp = null;
|
cacheTimestamp = null;
|
||||||
|
|
||||||
refreshInstalledPlugins(); // invalidates cache before fetching
|
// refreshInstalledPlugins() is async (returns a Promise via loadInstalledPlugins).
|
||||||
// Fetch latest metadata from GitHub when refreshing
|
// Only search the store and notify after window.installedPlugins is updated so
|
||||||
searchPluginStore(true);
|
// that Installed/Reinstall badges reflect the freshly fetched state.
|
||||||
showNotification('Plugins refreshed with latest metadata from GitHub', 'success');
|
refreshInstalledPlugins().then(() => {
|
||||||
|
searchPluginStore(true);
|
||||||
|
showNotification('Plugins refreshed with latest metadata from GitHub', 'success');
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function restartDisplay() {
|
function restartDisplay() {
|
||||||
@@ -8054,4 +8067,3 @@ setTimeout(function() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
|||||||
@@ -191,7 +191,10 @@ function doConnect() {
|
|||||||
// Poll for the new IP
|
// Poll for the new IP
|
||||||
setTimeout(function() { checkNewIP(ssid); }, 3000);
|
setTimeout(function() { checkNewIP(ssid); }, 3000);
|
||||||
} else {
|
} else {
|
||||||
showMsg(data.message || 'Connection failed', 'err');
|
var msg = data.error_type === 'wrong_password'
|
||||||
|
? 'Incorrect password — please try again'
|
||||||
|
: (data.message || 'Connection failed');
|
||||||
|
showMsg(msg, 'err');
|
||||||
connecting = false;
|
connecting = false;
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
btn.innerHTML = 'Connect';
|
btn.innerHTML = 'Connect';
|
||||||
|
|||||||
@@ -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="32"
|
max="8"
|
||||||
class="form-control">
|
class="form-control">
|
||||||
<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>
|
<p class="mt-1 text-sm text-gray-600">Number of LED panels chained together</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -386,6 +386,68 @@
|
|||||||
</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"
|
||||||
@@ -643,4 +705,111 @@ 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>
|
||||||
|
|||||||
Reference in New Issue
Block a user