mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-05 14:55:08 +00:00
fix(display): Vegas resumes after live priority, and six smaller runtime fixes (#644)
- Vegas: a live-priority pause was only lifted from inside run_frame(), which returns before that check while paused, so the ticker never came back until a restart. run_iteration() now resumes it (the controller only calls it when nothing preempts Vegas); start()/stop() clear the pause state. Iteration length is timed with the monotonic clock. - Dim schedule: a per-day disabled day now updates the minute-gate cache, so brightness no longer flips back to dim within each minute. - On-demand: a second request no longer overwrites the rotation resume index with the first request's mode. - Render pipeline: reset() drops the prepared group and deferred queue, and a prefetch in flight across a reset discards its result. - Sync: stop() removes the status file (and the controller's cleanup now calls it), standalone removes a stale one at startup, and writes use a unique mkstemp temp file. - render_gate.swap_releases_gil() delegates to frame_timing. - Stale docstrings/comments corrected. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -37,6 +37,15 @@ accepts both, but the store flags the old spelling as deprecated
|
|||||||
- `check_system_compatibility.sh` no longer reports installed packages as missing.
|
- `check_system_compatibility.sh` no longer reports installed packages as missing.
|
||||||
- A network failure fetching GitHub repo info logs a warning, not an error.
|
- A network failure fetching GitHub repo info logs a warning, not an error.
|
||||||
|
|
||||||
|
- Display runtime:
|
||||||
|
- Vegas comes back after live content interrupts it. It stayed paused, and the display fell back to normal rotation until a restart.
|
||||||
|
- A day with dimming turned off in a per-day dim schedule stays at normal brightness. Before, brightness went back to dim for most of each minute.
|
||||||
|
- Stopping on-demand after a second request resumes rotation where it was first interrupted, not at the first request's screen.
|
||||||
|
- Turning Vegas off and on no longer shows content prepared for the previous run, including plugins disabled in between.
|
||||||
|
- How long a Vegas iteration runs is timed with the monotonic clock, so an NTP clock step on a Pi without an RTC doesn't cut it short or stretch it.
|
||||||
|
- The sync status file is removed when the display service stops, and at startup in standalone mode, so the web UI no longer reports a peer from an earlier run. Concurrent writes each use their own temp file.
|
||||||
|
- `render_gate.swap_releases_gil()` delegates to `frame_timing.binding_releases_gil()` instead of duplicating it.
|
||||||
|
|
||||||
- Scripts and installer:
|
- Scripts and installer:
|
||||||
- `fix_web_permissions.sh` makes `safe_plugin_rm.sh` and `safe_pip_install.sh` root-owned again after resetting ownership. A web-user-owned copy of either is a root shell, since sudo lets the web user run them as root. It also restores `config_secrets.json` to mode 640.
|
- `fix_web_permissions.sh` makes `safe_plugin_rm.sh` and `safe_pip_install.sh` root-owned again after resetting ownership. A web-user-owned copy of either is a root shell, since sudo lets the web user run them as root. It also restores `config_secrets.json` to mode 640.
|
||||||
- `configure_wifi_permissions.sh` checks its rules with `visudo -c` before installing them, and grants the NetworkManager captive-portal `cp` and `rm` commands `wifi_manager` runs.
|
- `configure_wifi_permissions.sh` checks its rules with `visudo -c` before installing them, and grants the NetworkManager captive-portal `cp` and `rm` commands `wifi_manager` runs.
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ import time
|
|||||||
from collections import deque
|
from collections import deque
|
||||||
from typing import Any, Callable, Deque, List, Optional
|
from typing import Any, Callable, Deque, List, Optional
|
||||||
|
|
||||||
|
from src.common.frame_timing import binding_releases_gil
|
||||||
|
|
||||||
#: Park background threads this long before the refresh a swap will return on,
|
#: Park background threads this long before the refresh a swap will return on,
|
||||||
#: so a short C call already under way has finished by then.
|
#: so a short C call already under way has finished by then.
|
||||||
MARGIN_SECONDS = 0.002
|
MARGIN_SECONDS = 0.002
|
||||||
@@ -90,19 +92,11 @@ def _unsafe(frame: Any, base: Any) -> bool:
|
|||||||
def swap_releases_gil() -> Optional[bool]:
|
def swap_releases_gil() -> Optional[bool]:
|
||||||
"""Whether the loaded rgbmatrix binding releases the GIL, or None if none is loaded.
|
"""Whether the loaded rgbmatrix binding releases the GIL, or None if none is loaded.
|
||||||
|
|
||||||
The rebuilt binding links PyEval_SaveThread and the stock one never does.
|
A thin delegate to src.common.frame_timing.binding_releases_gil (#629),
|
||||||
The same test as src.common.frame_timing.binding_releases_gil (#629); one
|
which this used to duplicate line for line. The name stays because the
|
||||||
of the two goes once both have landed.
|
coordinator calls it here and tests replace it here.
|
||||||
"""
|
"""
|
||||||
module = sys.modules.get("rgbmatrix.core")
|
return binding_releases_gil()
|
||||||
path = getattr(module, "__file__", None)
|
|
||||||
if not path:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
with open(path, "rb") as handle:
|
|
||||||
return b"PyEval_SaveThread" in handle.read()
|
|
||||||
except OSError:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _held(lock: Any) -> bool:
|
def _held(lock: Any) -> bool:
|
||||||
|
|||||||
@@ -53,6 +53,17 @@ HEARTBEAT_INTERVAL = 2.0 # follower sends heartbeat every 2 s
|
|||||||
PEER_TIMEOUT = 6.0 # leader: no heartbeat → follower gone
|
PEER_TIMEOUT = 6.0 # leader: no heartbeat → follower gone
|
||||||
LEADER_TIMEOUT = 6.0 # follower: no frame → leader gone
|
LEADER_TIMEOUT = 6.0 # follower: no frame → leader gone
|
||||||
STATUS_FILE = os.path.join(tempfile.gettempdir(), "led_matrix_sync_status.json")
|
STATUS_FILE = os.path.join(tempfile.gettempdir(), "led_matrix_sync_status.json")
|
||||||
|
# Serialises writes to STATUS_FILE (several threads report status) against
|
||||||
|
# its removal in stop(), so a write already under way cannot put the file back
|
||||||
|
# after the display process has shut down.
|
||||||
|
_STATUS_LOCK = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def _remove_status_file() -> None:
|
||||||
|
try:
|
||||||
|
os.remove(STATUS_FILE)
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class SyncRole(Enum):
|
class SyncRole(Enum):
|
||||||
@@ -83,6 +94,10 @@ class DisplaySyncManager:
|
|||||||
back to its own plugins when the leader stops sending.
|
back to its own plugins when the leader stops sending.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# Set by stop(); status writes after that are dropped. Class-level so
|
||||||
|
# instances built without __init__ (tests) have it too.
|
||||||
|
_status_closed = False
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
role_str: str,
|
role_str: str,
|
||||||
@@ -138,6 +153,14 @@ class DisplaySyncManager:
|
|||||||
self._send_sock: Optional[socket.socket] = None
|
self._send_sock: Optional[socket.socket] = None
|
||||||
|
|
||||||
if self.role == SyncRole.STANDALONE:
|
if self.role == SyncRole.STANDALONE:
|
||||||
|
# Standalone never writes a status file, so one still here is
|
||||||
|
# from an earlier run as leader or follower. The web UI would
|
||||||
|
# keep reporting that run's peer as if it were live.
|
||||||
|
try:
|
||||||
|
with _STATUS_LOCK:
|
||||||
|
_remove_status_file()
|
||||||
|
except OSError as exc:
|
||||||
|
logger.debug("Sync: could not remove stale status file: %s", exc)
|
||||||
return
|
return
|
||||||
|
|
||||||
if self.role == SyncRole.LEADER:
|
if self.role == SyncRole.LEADER:
|
||||||
@@ -692,18 +715,38 @@ class DisplaySyncManager:
|
|||||||
|
|
||||||
def write_status_file(self) -> None:
|
def write_status_file(self) -> None:
|
||||||
"""Write current sync status to STATUS_FILE for the web UI to read."""
|
"""Write current sync status to STATUS_FILE for the web UI to read."""
|
||||||
|
tmp = None
|
||||||
try:
|
try:
|
||||||
status = self.get_status()
|
status = self.get_status()
|
||||||
status["ts"] = time.time()
|
status["ts"] = time.time()
|
||||||
tmp = STATUS_FILE + ".tmp"
|
with _STATUS_LOCK:
|
||||||
with open(tmp, "w") as f:
|
if self._status_closed:
|
||||||
|
return
|
||||||
|
# A unique temp name per write, like frame_timing's stats
|
||||||
|
# file: the receive loop, watchdog and hello handler all
|
||||||
|
# write, and with one fixed ".tmp" name one thread's
|
||||||
|
# os.replace() could move the other's half-written file.
|
||||||
|
fd, tmp = tempfile.mkstemp(
|
||||||
|
dir=os.path.dirname(STATUS_FILE) or ".",
|
||||||
|
prefix=".led_matrix_sync_status.", suffix=".tmp")
|
||||||
|
with os.fdopen(fd, "w") as f:
|
||||||
json.dump(status, f)
|
json.dump(status, f)
|
||||||
|
# mkstemp makes it owner-only; the web UI may run as a
|
||||||
|
# different user from the display service.
|
||||||
|
os.chmod(tmp, 0o644)
|
||||||
os.replace(tmp, STATUS_FILE)
|
os.replace(tmp, STATUS_FILE)
|
||||||
|
tmp = None
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self.logger.debug("Sync: status file write error: %s", exc)
|
self.logger.debug("Sync: status file write error: %s", exc)
|
||||||
|
finally:
|
||||||
|
if tmp is not None:
|
||||||
|
try:
|
||||||
|
os.unlink(tmp)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
def stop(self) -> None:
|
def stop(self) -> None:
|
||||||
"""Shut down threads and close sockets."""
|
"""Shut down threads, close sockets and withdraw the status file."""
|
||||||
self._running = False
|
self._running = False
|
||||||
for sock in (self._recv_sock, self._send_sock, self._img_server_sock):
|
for sock in (self._recv_sock, self._send_sock, self._img_server_sock):
|
||||||
if sock:
|
if sock:
|
||||||
@@ -711,3 +754,11 @@ class DisplaySyncManager:
|
|||||||
sock.close()
|
sock.close()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self.logger.debug("Sync: error closing socket: %s", exc)
|
self.logger.debug("Sync: error closing socket: %s", exc)
|
||||||
|
# The web UI reads this file as live status. Left behind, it went on
|
||||||
|
# reporting a connected peer after the display service had stopped.
|
||||||
|
try:
|
||||||
|
with _STATUS_LOCK:
|
||||||
|
self._status_closed = True
|
||||||
|
_remove_status_file()
|
||||||
|
except OSError as exc:
|
||||||
|
self.logger.debug("Sync: could not remove status file: %s", exc)
|
||||||
|
|||||||
@@ -284,7 +284,10 @@ class DisplayController:
|
|||||||
if os.path.isabs(plugins_dir_name):
|
if os.path.isabs(plugins_dir_name):
|
||||||
plugins_dir = plugins_dir_name
|
plugins_dir = plugins_dir_name
|
||||||
else:
|
else:
|
||||||
# If relative, resolve relative to the project root (LEDMatrix directory)
|
# If relative, resolve against the current working directory.
|
||||||
|
# That is the project root only because ledmatrix.service
|
||||||
|
# sets WorkingDirectory to it; run from anywhere else, a
|
||||||
|
# relative path resolves against wherever that is.
|
||||||
project_root = os.getcwd()
|
project_root = os.getcwd()
|
||||||
plugins_dir = os.path.join(project_root, plugins_dir_name)
|
plugins_dir = os.path.join(project_root, plugins_dir_name)
|
||||||
|
|
||||||
@@ -801,7 +804,15 @@ class DisplayController:
|
|||||||
if use_per_day:
|
if use_per_day:
|
||||||
day_config = days_config[current_day]
|
day_config = days_config[current_day]
|
||||||
if not day_config.get('enabled', True):
|
if not day_config.get('enabled', True):
|
||||||
|
# Past the minute gate, so the cache must say the same thing:
|
||||||
|
# returning here without it left the previous minute's dim
|
||||||
|
# value to be served for the rest of this one, and the
|
||||||
|
# brightness flipped between dim and normal every minute.
|
||||||
|
if self._was_dimmed:
|
||||||
|
logger.info(f"Dim schedule deactivated: brightness restored to {normal_brightness}%")
|
||||||
self.is_dimmed = False
|
self.is_dimmed = False
|
||||||
|
self._was_dimmed = False
|
||||||
|
self._cached_target_brightness = normal_brightness # persist for minute-gate
|
||||||
return normal_brightness
|
return normal_brightness
|
||||||
start_time_str = day_config.get('start_time', '20:00')
|
start_time_str = day_config.get('start_time', '20:00')
|
||||||
end_time_str = day_config.get('end_time', '07:00')
|
end_time_str = day_config.get('end_time', '07:00')
|
||||||
@@ -1710,6 +1721,12 @@ class DisplayController:
|
|||||||
pinned = bool(request.get('pinned', False))
|
pinned = bool(request.get('pinned', False))
|
||||||
now = time.time()
|
now = time.time()
|
||||||
|
|
||||||
|
# Only a request that starts a session records where rotation was.
|
||||||
|
# A request made while on-demand is already showing would otherwise
|
||||||
|
# save the previous request's mode (current_mode_index points at it
|
||||||
|
# by now), and clearing would resume there instead of where the
|
||||||
|
# normal rotation was interrupted.
|
||||||
|
if not self.on_demand_active:
|
||||||
if self.available_modes:
|
if self.available_modes:
|
||||||
self.rotation_resume_index = self.current_mode_index
|
self.rotation_resume_index = self.current_mode_index
|
||||||
else:
|
else:
|
||||||
@@ -3310,6 +3327,13 @@ class DisplayController:
|
|||||||
self.vegas_coordinator.cleanup()
|
self.vegas_coordinator.cleanup()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Error cleaning up Vegas mode: %s", e)
|
logger.warning("Error cleaning up Vegas mode: %s", e)
|
||||||
|
# After Vegas, which sends through it. Stopping also withdraws the
|
||||||
|
# sync status file, which the web UI otherwise kept showing as live.
|
||||||
|
if getattr(self, 'sync_manager', None) is not None:
|
||||||
|
try:
|
||||||
|
self.sync_manager.stop()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Error stopping display sync: %s", e)
|
||||||
# Shutdown config service if it exists
|
# Shutdown config service if it exists
|
||||||
if hasattr(self, 'config_service'):
|
if hasattr(self, 'config_service'):
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -20,7 +20,10 @@ Key responsibilities
|
|||||||
|
|
||||||
Singleton: only one ``DisplayManager`` instance exists per process. The
|
Singleton: only one ``DisplayManager`` instance exists per process. The
|
||||||
first call to ``DisplayManager(config)`` creates it; subsequent calls return
|
first call to ``DisplayManager(config)`` creates it; subsequent calls return
|
||||||
the same object.
|
the same object, but ``__init__`` runs again on it each time, so it is
|
||||||
|
re-initialised (matrix included) with the new arguments rather than handed
|
||||||
|
back as it was. Construct it once and pass that instance around;
|
||||||
|
:meth:`DisplayManager.cleanup` clears the singleton.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
|||||||
@@ -273,6 +273,10 @@ class VegasModeCoordinator:
|
|||||||
|
|
||||||
self._is_active = True
|
self._is_active = True
|
||||||
self._should_stop = False
|
self._should_stop = False
|
||||||
|
# A pause belongs to the run it happened in; carrying it into a
|
||||||
|
# new run would have run_frame() refuse every frame.
|
||||||
|
self._is_paused = False
|
||||||
|
self._live_priority_active = False
|
||||||
self._start_time = time.time()
|
self._start_time = time.time()
|
||||||
# A fresh run starts with a clean health slate: no stale
|
# A fresh run starts with a clean health slate: no stale
|
||||||
# "was degraded" from the previous run, and a heartbeat that is
|
# "was degraded" from the previous run, and a heartbeat that is
|
||||||
@@ -298,6 +302,8 @@ class VegasModeCoordinator:
|
|||||||
|
|
||||||
self._should_stop = True
|
self._should_stop = True
|
||||||
self._is_active = False
|
self._is_active = False
|
||||||
|
self._is_paused = False
|
||||||
|
self._live_priority_active = False
|
||||||
|
|
||||||
if self._start_time:
|
if self._start_time:
|
||||||
self.stats['total_runtime_seconds'] += time.time() - self._start_time
|
self.stats['total_runtime_seconds'] += time.time() - self._start_time
|
||||||
@@ -473,6 +479,20 @@ class VegasModeCoordinator:
|
|||||||
if not self.start():
|
if not self.start():
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
# A live-priority pause is only ever lifted by _check_live_priority(),
|
||||||
|
# and run_frame() returns before reaching it while paused -- so once
|
||||||
|
# paused, every later iteration returned False at its first frame and
|
||||||
|
# the ticker never came back until a restart. The display controller
|
||||||
|
# only calls run_iteration() when nothing preempts Vegas (no live mode,
|
||||||
|
# or live content is kept in the ticker), so being called at all means
|
||||||
|
# the live content that paused us has ended.
|
||||||
|
with self._state_lock:
|
||||||
|
paused_for_live = self._is_paused and self._live_priority_active
|
||||||
|
if paused_for_live:
|
||||||
|
self._live_priority_active = False
|
||||||
|
self.resume()
|
||||||
|
logger.info("Live priority ended - resuming Vegas")
|
||||||
|
|
||||||
if self.vegas_config.continuous_scroll:
|
if self.vegas_config.continuous_scroll:
|
||||||
# The strip is continuously extended and trimmed, so its width says
|
# The strip is continuously extended and trimmed, so its width says
|
||||||
# nothing about how long to run. This is only how often control
|
# nothing about how long to run. This is only how often control
|
||||||
@@ -481,7 +501,11 @@ class VegasModeCoordinator:
|
|||||||
duration = float(self.vegas_config.max_cycle_duration)
|
duration = float(self.vegas_config.max_cycle_duration)
|
||||||
else:
|
else:
|
||||||
duration = self.render_pipeline.get_dynamic_duration()
|
duration = self.render_pipeline.get_dynamic_duration()
|
||||||
start_time = time.time()
|
# Monotonic for the same reason as the per-frame clock below: this
|
||||||
|
# bounds how long the iteration runs, and an NTP step on an RTC-less
|
||||||
|
# Pi would otherwise end it at once (forward) or stretch it by the
|
||||||
|
# size of the correction (backward).
|
||||||
|
start_time = time.monotonic()
|
||||||
frame_count = 0
|
frame_count = 0
|
||||||
fps_log_interval = 5.0 # Sample FPS every 5 seconds
|
fps_log_interval = 5.0 # Sample FPS every 5 seconds
|
||||||
# Health state lives on the coordinator, not here: run_iteration() is
|
# Health state lives on the coordinator, not here: run_iteration() is
|
||||||
@@ -490,10 +514,8 @@ class VegasModeCoordinator:
|
|||||||
# of every iteration rather than once per interval, and a recovery
|
# of every iteration rather than once per interval, and a recovery
|
||||||
# that crossed an iteration boundary was never reported at all --
|
# that crossed an iteration boundary was never reported at all --
|
||||||
# was_degraded had already gone back to False.
|
# was_degraded had already gone back to False.
|
||||||
# Monotonic, and deliberately not start_time: start_time is wall
|
# Monotonic. Never mix it with a wall-clock value: every delta would
|
||||||
# clock and is used below to report the iteration's duration. Mixing
|
# be hugely negative and silence the frame-rate reporting altogether.
|
||||||
# the two here would make every delta hugely negative and silence the
|
|
||||||
# frame-rate reporting altogether.
|
|
||||||
last_fps_log_time = time.monotonic()
|
last_fps_log_time = time.monotonic()
|
||||||
fps_frame_count = 0
|
fps_frame_count = 0
|
||||||
# A mean hides stutter completely. At 120fps a five-second window is
|
# A mean hides stutter completely. At 120fps a five-second window is
|
||||||
@@ -633,7 +655,7 @@ class VegasModeCoordinator:
|
|||||||
).start()
|
).start()
|
||||||
|
|
||||||
# Check elapsed time
|
# Check elapsed time
|
||||||
elapsed = time.time() - start_time
|
elapsed = time.monotonic() - start_time
|
||||||
if elapsed >= duration:
|
if elapsed >= duration:
|
||||||
break
|
break
|
||||||
|
|
||||||
@@ -652,7 +674,7 @@ class VegasModeCoordinator:
|
|||||||
# cycle content multiple times within one iteration — acceptable for
|
# cycle content multiple times within one iteration — acceptable for
|
||||||
# a continuous ticker.
|
# a continuous ticker.
|
||||||
|
|
||||||
logger.info("Vegas iteration completed after %.1fs", time.time() - start_time)
|
logger.info("Vegas iteration completed after %.1fs", time.monotonic() - start_time)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def _check_live_priority(self) -> bool:
|
def _check_live_priority(self) -> bool:
|
||||||
|
|||||||
@@ -52,6 +52,12 @@ class RenderPipeline:
|
|||||||
# A panel measured within this fraction of its cap is keeping up with it.
|
# A panel measured within this fraction of its cap is keeping up with it.
|
||||||
REFRESH_TOLERANCE = 0.03
|
REFRESH_TOLERANCE = 0.03
|
||||||
|
|
||||||
|
# Bumped by reset(). A prefetch thread records the value it started under
|
||||||
|
# and drops its group if a reset happened meanwhile, so a fetch still in
|
||||||
|
# flight when Vegas stops cannot land in the next run. Class-level so
|
||||||
|
# pipelines built without __init__ (tests) still have it.
|
||||||
|
_prefetch_generation = 0
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
config: VegasModeConfig,
|
config: VegasModeConfig,
|
||||||
@@ -373,6 +379,7 @@ class RenderPipeline:
|
|||||||
return
|
return
|
||||||
if self._prepared_group is not None:
|
if self._prepared_group is not None:
|
||||||
return # already have one waiting
|
return # already have one waiting
|
||||||
|
generation = self._prefetch_generation
|
||||||
|
|
||||||
def _work():
|
def _work():
|
||||||
# Deprioritise against the render loop. Linux applies nice
|
# Deprioritise against the render loop. Linux applies nice
|
||||||
@@ -394,6 +401,8 @@ class RenderPipeline:
|
|||||||
logger.exception("Background prefetch failed")
|
logger.exception("Background prefetch failed")
|
||||||
group = []
|
group = []
|
||||||
with self._prefetch_lock:
|
with self._prefetch_lock:
|
||||||
|
if generation != self._prefetch_generation:
|
||||||
|
return # Vegas was reset while this was fetching
|
||||||
self._prepared_group = group
|
self._prepared_group = group
|
||||||
|
|
||||||
self._prefetch_thread = threading.Thread(
|
self._prefetch_thread = threading.Thread(
|
||||||
@@ -602,7 +611,8 @@ class RenderPipeline:
|
|||||||
"""
|
"""
|
||||||
Render a single frame to the display.
|
Render a single frame to the display.
|
||||||
|
|
||||||
Should be called at ~125 FPS (8ms intervals).
|
Called once per frame by the coordinator, which paces the calls by
|
||||||
|
frame_interval (see that property) rather than a fixed rate.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if frame was rendered, False if no content
|
True if frame was rendered, False if no content
|
||||||
@@ -917,6 +927,15 @@ class RenderPipeline:
|
|||||||
self._segments_in_scroll = []
|
self._segments_in_scroll = []
|
||||||
self._frame_times = deque(maxlen=100)
|
self._frame_times = deque(maxlen=100)
|
||||||
|
|
||||||
|
# Content lined up for the old run belongs to it. Left in place, the
|
||||||
|
# first extension after Vegas is switched back on appended that stale
|
||||||
|
# group -- including plugins disabled in the meantime -- and the
|
||||||
|
# deferred queue went on fetching the old run's plugins.
|
||||||
|
with self._prefetch_lock:
|
||||||
|
self._prefetch_generation += 1
|
||||||
|
self._prepared_group = None
|
||||||
|
self._deferred_queue = []
|
||||||
|
|
||||||
self.display_manager.set_scrolling_state(False)
|
self.display_manager.set_scrolling_state(False)
|
||||||
|
|
||||||
logger.info("RenderPipeline reset")
|
logger.info("RenderPipeline reset")
|
||||||
|
|||||||
@@ -49,6 +49,31 @@ def test_cleanup_without_vegas_does_not_raise():
|
|||||||
dc.display_manager.cleanup.assert_called_once_with()
|
dc.display_manager.cleanup.assert_called_once_with()
|
||||||
|
|
||||||
|
|
||||||
|
def test_cleanup_stops_display_sync_after_vegas():
|
||||||
|
# Stopping sync withdraws its status file; left behind, the web UI kept
|
||||||
|
# reporting a connected peer after the display service had stopped.
|
||||||
|
order = []
|
||||||
|
vegas = MagicMock()
|
||||||
|
vegas.cleanup.side_effect = lambda: order.append('vegas')
|
||||||
|
dc = _controller(vegas)
|
||||||
|
dc.sync_manager = MagicMock()
|
||||||
|
dc.sync_manager.stop.side_effect = lambda: order.append('sync')
|
||||||
|
|
||||||
|
dc.cleanup()
|
||||||
|
|
||||||
|
assert order == ['vegas', 'sync']
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_failing_sync_stop_does_not_stop_the_rest():
|
||||||
|
dc = _controller(None)
|
||||||
|
dc.sync_manager = MagicMock()
|
||||||
|
dc.sync_manager.stop.side_effect = OSError('boom')
|
||||||
|
|
||||||
|
dc.cleanup()
|
||||||
|
|
||||||
|
dc.display_manager.cleanup.assert_called_once_with()
|
||||||
|
|
||||||
|
|
||||||
def test_a_failing_vegas_teardown_does_not_stop_the_rest():
|
def test_a_failing_vegas_teardown_does_not_stop_the_rest():
|
||||||
vegas = MagicMock()
|
vegas = MagicMock()
|
||||||
vegas.cleanup.side_effect = RuntimeError('boom')
|
vegas.cleanup.side_effect = RuntimeError('boom')
|
||||||
|
|||||||
@@ -254,6 +254,27 @@ class TestDimSchedule:
|
|||||||
assert dim_at(dc, "23:00", day="tuesday") == 90 # day disabled
|
assert dim_at(dc, "23:00", day="tuesday") == 90 # day disabled
|
||||||
assert dc.is_dimmed is False
|
assert dc.is_dimmed is False
|
||||||
|
|
||||||
|
def test_disabled_day_stays_normal_within_the_minute(self):
|
||||||
|
# Dim late Monday, then Tuesday (dimming disabled) begins. The second
|
||||||
|
# call in the same minute is served from the minute-gate cache, which
|
||||||
|
# the disabled-day branch used to leave holding Monday's dim value --
|
||||||
|
# so brightness flipped back to dim for the rest of every minute.
|
||||||
|
dc = make_controller(self._config(mode="per-day", days={
|
||||||
|
"monday": {"enabled": True, "start_time": "22:00",
|
||||||
|
"end_time": "06:00"},
|
||||||
|
"tuesday": {"enabled": False},
|
||||||
|
}))
|
||||||
|
assert dim_at(dc, "23:59", day="monday") == 25
|
||||||
|
dc._dim_checked_minute = None
|
||||||
|
p = at("00:00", day="tuesday")
|
||||||
|
try:
|
||||||
|
assert dc._check_dim_schedule() == 90
|
||||||
|
assert dc._check_dim_schedule() == 90 # cached, same minute
|
||||||
|
finally:
|
||||||
|
p.stop()
|
||||||
|
assert dc.is_dimmed is False
|
||||||
|
assert dc._was_dimmed is False
|
||||||
|
|
||||||
def test_no_legacy_inference_for_dim(self):
|
def test_no_legacy_inference_for_dim(self):
|
||||||
# Unlike _check_schedule, dim mode defaults to GLOBAL even when a
|
# Unlike _check_schedule, dim mode defaults to GLOBAL even when a
|
||||||
# days config exists — no legacy inference.
|
# days config exists — no legacy inference.
|
||||||
|
|||||||
@@ -99,6 +99,31 @@ class TestPinSurvivesARestart:
|
|||||||
assert set(c.on_demand_modes) == {'app_a', 'app_b', 'app_c'}
|
assert set(c.on_demand_modes) == {'app_a', 'app_b', 'app_c'}
|
||||||
|
|
||||||
|
|
||||||
|
class TestASecondRequestKeepsTheResumePoint:
|
||||||
|
"""Clearing returns to where the normal rotation was, not the last request.
|
||||||
|
|
||||||
|
A second start while on-demand was showing overwrote the saved resume
|
||||||
|
index with the first request's mode, so stopping resumed rotation there.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_clear_resumes_the_original_rotation(self, test_display_controller):
|
||||||
|
c = test_display_controller
|
||||||
|
_plugin_with_modes(c, 'clock', ['clock'])
|
||||||
|
_plugin_with_modes(c, 'weather', ['weather'])
|
||||||
|
_plugin_with_modes(c, 'stocks', ['stocks'])
|
||||||
|
c.available_modes = ['clock', 'weather', 'stocks']
|
||||||
|
c.current_mode_index = 0
|
||||||
|
c.current_display_mode = 'clock'
|
||||||
|
|
||||||
|
c._activate_on_demand({'plugin_id': 'weather', 'mode': 'weather'})
|
||||||
|
c._activate_on_demand({'plugin_id': 'stocks', 'mode': 'stocks'})
|
||||||
|
assert c.current_display_mode == 'stocks'
|
||||||
|
|
||||||
|
c._clear_on_demand(reason='requested-stop')
|
||||||
|
assert c.current_mode_index == 0
|
||||||
|
assert c.current_display_mode == 'clock'
|
||||||
|
|
||||||
|
|
||||||
class TestRestartDoesNotStarveTheOtherPlugins:
|
class TestRestartDoesNotStarveTheOtherPlugins:
|
||||||
"""Restarting mid-on-demand used to load only the on-demand plugin.
|
"""Restarting mid-on-demand used to load only the on-demand plugin.
|
||||||
|
|
||||||
|
|||||||
@@ -430,3 +430,25 @@ class TestVegasWiring:
|
|||||||
pipeline._prefetch_thread.join(5)
|
pipeline._prefetch_thread.join(5)
|
||||||
assert seen == [(True, True)]
|
assert seen == [(True, True)]
|
||||||
assert pipeline._prepared_group == ["segment"]
|
assert pipeline._prepared_group == ["segment"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestBindingCheck:
|
||||||
|
"""swap_releases_gil() is frame_timing's check, not a second copy of it."""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("contents, expected", [
|
||||||
|
(b"...PyEval_SaveThread...", True),
|
||||||
|
(b"stock binding", False),
|
||||||
|
])
|
||||||
|
def test_agrees_with_frame_timing(self, monkeypatch, tmp_path, contents, expected):
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from src.common import frame_timing
|
||||||
|
binding = tmp_path / "core.so"
|
||||||
|
binding.write_bytes(contents)
|
||||||
|
monkeypatch.setitem(sys.modules, "rgbmatrix.core",
|
||||||
|
SimpleNamespace(__file__=str(binding)))
|
||||||
|
assert render_gate.swap_releases_gil() is expected
|
||||||
|
assert frame_timing.binding_releases_gil() is expected
|
||||||
|
|
||||||
|
def test_none_without_a_binding(self, monkeypatch):
|
||||||
|
monkeypatch.delitem(sys.modules, "rgbmatrix.core", raising=False)
|
||||||
|
assert render_gate.swap_releases_gil() is None
|
||||||
|
|||||||
@@ -875,9 +875,50 @@ class TestWriteStatusFile:
|
|||||||
|
|
||||||
def test_write_failure_is_swallowed(self, monkeypatch):
|
def test_write_failure_is_swallowed(self, monkeypatch):
|
||||||
mgr = make_manager(role=SyncRole.STANDALONE)
|
mgr = make_manager(role=SyncRole.STANDALONE)
|
||||||
monkeypatch.setattr("builtins.open", MagicMock(side_effect=OSError("disk full")))
|
monkeypatch.setattr(sync_manager.json, "dump",
|
||||||
|
MagicMock(side_effect=OSError("disk full")))
|
||||||
mgr.write_status_file() # must not raise
|
mgr.write_status_file() # must not raise
|
||||||
assert mgr.logger.debug.called
|
assert mgr.logger.debug.called
|
||||||
|
# ...and the half-written temp file is not left behind.
|
||||||
|
assert list(Path(sync_manager.STATUS_FILE).parent.iterdir()) == []
|
||||||
|
|
||||||
|
def test_concurrent_writers_each_use_their_own_temp_file(self):
|
||||||
|
"""The receive loop, watchdog and hello handler all write. With one
|
||||||
|
fixed ".tmp" name, one writer's os.replace() could move the other's
|
||||||
|
half-written file into place, or find it already gone."""
|
||||||
|
mgr = make_manager(role=SyncRole.LEADER)
|
||||||
|
errors = []
|
||||||
|
mgr.logger.debug.side_effect = lambda *a: errors.append(a)
|
||||||
|
|
||||||
|
def hammer():
|
||||||
|
for _ in range(50):
|
||||||
|
mgr.write_status_file()
|
||||||
|
threads = [threading.Thread(target=hammer) for _ in range(4)]
|
||||||
|
for t in threads:
|
||||||
|
t.start()
|
||||||
|
for t in threads:
|
||||||
|
t.join()
|
||||||
|
|
||||||
|
assert errors == []
|
||||||
|
assert [p.name for p in Path(sync_manager.STATUS_FILE).parent.iterdir()] == [
|
||||||
|
Path(sync_manager.STATUS_FILE).name]
|
||||||
|
assert json.loads(Path(sync_manager.STATUS_FILE).read_text())["role"] == "leader"
|
||||||
|
|
||||||
|
def test_stop_withdraws_the_status_file(self):
|
||||||
|
"""Left behind, the web UI kept reporting a connected peer after the
|
||||||
|
display service had stopped."""
|
||||||
|
mgr = make_manager(role=SyncRole.LEADER)
|
||||||
|
mgr.write_status_file()
|
||||||
|
assert Path(sync_manager.STATUS_FILE).exists()
|
||||||
|
mgr.stop()
|
||||||
|
assert not Path(sync_manager.STATUS_FILE).exists()
|
||||||
|
mgr.write_status_file() # a late write from a thread still winding down
|
||||||
|
assert not Path(sync_manager.STATUS_FILE).exists()
|
||||||
|
|
||||||
|
def test_standalone_removes_a_stale_status_file(self):
|
||||||
|
Path(sync_manager.STATUS_FILE).write_text('{"role": "leader", "state": "connected"}')
|
||||||
|
DisplaySyncManager("standalone", {}, {}, MagicMock())
|
||||||
|
assert not Path(sync_manager.STATUS_FILE).exists()
|
||||||
|
|
||||||
def test_web_status_endpoint_reads_the_file_that_was_written(self, api_v3_client):
|
def test_web_status_endpoint_reads_the_file_that_was_written(self, api_v3_client):
|
||||||
"""GET /sync/status reads STATUS_FILE, which lives under
|
"""GET /sync/status reads STATUS_FILE, which lives under
|
||||||
|
|||||||
@@ -55,3 +55,81 @@ def test_an_iteration_does_not_poll_every_plugin_for_its_mode(caplog):
|
|||||||
|
|
||||||
static.get_vegas_display_mode.assert_not_called()
|
static.get_vegas_display_mode.assert_not_called()
|
||||||
assert not any('Static mode plugins' in r.getMessage() for r in caplog.records)
|
assert not any('Static mode plugins' in r.getMessage() for r in caplog.records)
|
||||||
|
|
||||||
|
|
||||||
|
def _live_coordinator(live):
|
||||||
|
"""A coordinator running the real run_frame(), with a switchable live check."""
|
||||||
|
coord = _coordinator({})
|
||||||
|
del coord.run_frame # the real one: it is what refuses frames while paused
|
||||||
|
coord.vegas_config = VegasModeConfig.from_config({'display': {'vegas_scroll': {
|
||||||
|
'enabled': True, 'max_cycle_duration': 0, 'continuous_scroll': True}}})
|
||||||
|
coord.render_pipeline.has_deferred.return_value = False
|
||||||
|
coord.render_pipeline.needs_extension.return_value = False
|
||||||
|
coord.render_pipeline.render_frame.return_value = True
|
||||||
|
coord._pending_config_update = False
|
||||||
|
coord._last_live_check = float('-inf')
|
||||||
|
coord._live_priority_check = lambda: live[0]
|
||||||
|
return coord
|
||||||
|
|
||||||
|
|
||||||
|
def test_vegas_resumes_after_a_live_priority_pause():
|
||||||
|
"""The pause for live content was only ever lifted from inside run_frame(),
|
||||||
|
which returns before reaching that check while paused -- so once live
|
||||||
|
content had interrupted the ticker, it never came back until a restart.
|
||||||
|
The display controller only calls run_iteration() when nothing preempts
|
||||||
|
Vegas, so an iteration starting while paused for live content resumes."""
|
||||||
|
live = ['nfl_live']
|
||||||
|
coord = _live_coordinator(live)
|
||||||
|
|
||||||
|
assert coord.run_iteration() is False
|
||||||
|
assert coord._is_paused and coord._live_priority_active
|
||||||
|
coord.render_pipeline.render_frame.assert_not_called()
|
||||||
|
|
||||||
|
live[0] = None
|
||||||
|
coord._last_live_check = float('-inf')
|
||||||
|
assert coord.run_iteration() is True
|
||||||
|
assert not coord._is_paused and not coord._live_priority_active
|
||||||
|
coord.render_pipeline.render_frame.assert_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_stop_clears_a_live_priority_pause():
|
||||||
|
coord = _live_coordinator(['nfl_live'])
|
||||||
|
coord._start_time = None
|
||||||
|
coord._restore_switch_interval = lambda: None
|
||||||
|
coord._remove_render_gate = lambda: None
|
||||||
|
coord.run_iteration()
|
||||||
|
assert coord._is_paused
|
||||||
|
|
||||||
|
coord.stop()
|
||||||
|
assert not coord._is_paused and not coord._live_priority_active
|
||||||
|
|
||||||
|
|
||||||
|
def test_iteration_length_ignores_wall_clock_steps(monkeypatch):
|
||||||
|
"""No RTC: the wall clock jumps when NTP first syncs. A forward step used
|
||||||
|
to end the iteration on its first frame."""
|
||||||
|
from src.vegas_mode import coordinator as coordinator_module
|
||||||
|
coord = _coordinator({})
|
||||||
|
coord.vegas_config = VegasModeConfig.from_config({'display': {'vegas_scroll': {
|
||||||
|
'enabled': True, 'max_cycle_duration': 10}}})
|
||||||
|
frames = [0]
|
||||||
|
|
||||||
|
def frame():
|
||||||
|
frames[0] += 1
|
||||||
|
return True
|
||||||
|
coord.run_frame = frame
|
||||||
|
clock = [1000.0]
|
||||||
|
wall = [1000.0]
|
||||||
|
|
||||||
|
def monotonic():
|
||||||
|
clock[0] += 0.5
|
||||||
|
return clock[0]
|
||||||
|
|
||||||
|
def wall_time():
|
||||||
|
wall[0] += 3600.0 # NTP stepping the wall clock forward
|
||||||
|
return wall[0]
|
||||||
|
monkeypatch.setattr(coordinator_module.time, 'monotonic', monotonic)
|
||||||
|
monkeypatch.setattr(coordinator_module.time, 'time', wall_time)
|
||||||
|
monkeypatch.setattr(coordinator_module.time, 'sleep', lambda s: None)
|
||||||
|
|
||||||
|
assert coord.run_iteration() is True
|
||||||
|
assert frames[0] > 1
|
||||||
|
|||||||
@@ -1385,6 +1385,41 @@ class TestContinuousExtension:
|
|||||||
widths.append(p.scroll_helper.total_scroll_width)
|
widths.append(p.scroll_helper.total_scroll_width)
|
||||||
assert max(widths) < 6000, f"strip grew unbounded: {max(widths)}"
|
assert max(widths) < 6000, f"strip grew unbounded: {max(widths)}"
|
||||||
|
|
||||||
|
def test_reset_drops_the_previous_runs_prepared_content(self):
|
||||||
|
# Vegas off then on: the group lined up for the old run, and any
|
||||||
|
# canvas-bound plugins still queued, must not reach the new one.
|
||||||
|
groups = [[('a', [self._block(600)])], [('old', [self._block(600)])]]
|
||||||
|
p, _ = self._pipeline(groups, continuous_scroll=True)
|
||||||
|
p.compose_scroll_content()
|
||||||
|
p.start_prefetch()
|
||||||
|
if p._prefetch_thread:
|
||||||
|
p._prefetch_thread.join(timeout=5)
|
||||||
|
p._deferred_queue.append('old-canvas')
|
||||||
|
assert p._prepared_group is not None
|
||||||
|
|
||||||
|
p.reset()
|
||||||
|
assert p._prepared_group is None
|
||||||
|
assert not p.has_deferred()
|
||||||
|
|
||||||
|
def test_a_prefetch_in_flight_across_reset_is_discarded(self):
|
||||||
|
import threading
|
||||||
|
release = threading.Event()
|
||||||
|
groups = [[('a', [self._block(600)])], [('old', [self._block(600)])]]
|
||||||
|
p, stream = self._pipeline(groups, continuous_scroll=True)
|
||||||
|
p.compose_scroll_content()
|
||||||
|
take = stream.take_next_group
|
||||||
|
|
||||||
|
def slow_take(count=None, offscreen_only=False):
|
||||||
|
release.wait(timeout=5)
|
||||||
|
return take(count, offscreen_only)
|
||||||
|
stream.take_next_group = slow_take
|
||||||
|
|
||||||
|
p.start_prefetch()
|
||||||
|
p.reset() # Vegas stops while the fetch is still running
|
||||||
|
release.set()
|
||||||
|
p._prefetch_thread.join(timeout=5)
|
||||||
|
assert p._prepared_group is None
|
||||||
|
|
||||||
|
|
||||||
class TestDeferredDraining:
|
class TestDeferredDraining:
|
||||||
"""
|
"""
|
||||||
|
|||||||
Reference in New Issue
Block a user