mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-02 09:18:06 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ced173e95a | ||
|
|
acfd704d13 |
@@ -1,68 +0,0 @@
|
|||||||
"""Snapshot write policy for the display preview mirror.
|
|
||||||
|
|
||||||
The display service mirrors frames to /tmp/led_matrix_preview.png, which
|
|
||||||
serves two consumers with different needs:
|
|
||||||
|
|
||||||
- The web UI's live preview (SSE reader in web_interface/app.py) wants
|
|
||||||
fresh frames — but only while a browser is actually watching.
|
|
||||||
- The health check (web_interface/blueprints/api_v3.py, hardware status)
|
|
||||||
uses the file's AGE as a liveness proxy: age >= 60s reads as degraded.
|
|
||||||
|
|
||||||
PNG-encoding every frame at 5 fps forever — identical frames, no viewers —
|
|
||||||
was one of the biggest fixed CPU costs on the Pi. This module is the pure
|
|
||||||
decision logic (extracted so it's unit-testable off-Pi; display_manager
|
|
||||||
imports rgbmatrix unconditionally and can't be):
|
|
||||||
|
|
||||||
WRITE — encode + atomically replace the snapshot file
|
|
||||||
TOUCH — os.utime only: keeps the health-check mtime fresh and lets
|
|
||||||
the SSE reader (mtime-gated) resend at a low rate, without
|
|
||||||
paying for a PNG encode of an unchanged frame
|
|
||||||
SKIP — do nothing
|
|
||||||
|
|
||||||
Policy:
|
|
||||||
- With a fresh viewer marker: changed frames write at up to 1/VIEWER_INTERVAL.
|
|
||||||
- Without viewers: changed frames still write at 1/IDLE_INTERVAL so the
|
|
||||||
preview page shows something recent on open.
|
|
||||||
- Unchanged frames are never re-encoded; the mtime is touched every
|
|
||||||
TOUCH_INTERVAL so the health check (60s threshold) never degrades.
|
|
||||||
|
|
||||||
If any constant here changes, re-check the health threshold in
|
|
||||||
api_v3.py (get_hardware_status) — TOUCH_INTERVAL must stay well under it.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from enum import Enum
|
|
||||||
|
|
||||||
# Snapshot cadence with a browser preview open (seconds).
|
|
||||||
VIEWER_INTERVAL = 0.2
|
|
||||||
# Snapshot cadence with no viewers — cheap freshness for page-open (seconds).
|
|
||||||
IDLE_INTERVAL = 30.0
|
|
||||||
# Max age of the last write/touch before bumping mtime for the health
|
|
||||||
# check. MUST stay well under api_v3's 60s degraded threshold.
|
|
||||||
TOUCH_INTERVAL = 20.0
|
|
||||||
# A viewer marker older than this no longer counts as a live viewer.
|
|
||||||
VIEWER_MARKER_FRESH_SEC = 5.0
|
|
||||||
|
|
||||||
|
|
||||||
class SnapshotAction(Enum):
|
|
||||||
WRITE = "write"
|
|
||||||
TOUCH = "touch"
|
|
||||||
SKIP = "skip"
|
|
||||||
|
|
||||||
|
|
||||||
def decide(now: float, last_write_ts: float, last_touch_ts: float,
|
|
||||||
viewer_fresh: bool, frame_changed: bool) -> SnapshotAction:
|
|
||||||
"""Decide what to do with the current frame.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
now: current monotonic-ish timestamp (same clock as the ts args)
|
|
||||||
last_write_ts: when a frame was last actually encoded+written
|
|
||||||
last_touch_ts: when the file mtime was last bumped (write or touch)
|
|
||||||
viewer_fresh: a browser preview is currently watching
|
|
||||||
frame_changed: the frame differs from the last WRITTEN frame
|
|
||||||
"""
|
|
||||||
interval = VIEWER_INTERVAL if viewer_fresh else IDLE_INTERVAL
|
|
||||||
if frame_changed and (now - last_write_ts) >= interval:
|
|
||||||
return SnapshotAction.WRITE
|
|
||||||
if (now - max(last_write_ts, last_touch_ts)) >= TOUCH_INTERVAL:
|
|
||||||
return SnapshotAction.TOUCH
|
|
||||||
return SnapshotAction.SKIP
|
|
||||||
+53
-76
@@ -31,23 +31,14 @@ if os.getenv("EMULATOR", "false") == "true":
|
|||||||
else:
|
else:
|
||||||
from rgbmatrix import RGBMatrix, RGBMatrixOptions
|
from rgbmatrix import RGBMatrix, RGBMatrixOptions
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from pathlib import Path
|
|
||||||
from PIL import Image, ImageDraw, ImageFont
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
import time
|
import time
|
||||||
from typing import Dict, Any, List, Optional
|
from collections import OrderedDict
|
||||||
|
from typing import Dict, Any, List, Optional, Tuple
|
||||||
import logging
|
import logging
|
||||||
import math
|
import math
|
||||||
import zlib
|
|
||||||
import freetype
|
import freetype
|
||||||
|
|
||||||
from src.common import snapshot_policy
|
|
||||||
from src.common.permission_utils import (
|
|
||||||
ensure_directory_permissions,
|
|
||||||
ensure_file_permissions,
|
|
||||||
get_assets_dir_mode,
|
|
||||||
get_assets_file_mode,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Get logger without configuring
|
# Get logger without configuring
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
logger.setLevel(logging.INFO) # Set to INFO level
|
logger.setLevel(logging.INFO) # Set to INFO level
|
||||||
@@ -190,23 +181,25 @@ class DisplayManager:
|
|||||||
# the logical image is blitted to the matrix unchanged.
|
# the logical image is blitted to the matrix unchanged.
|
||||||
self._double_sided = None # dict {copies, axis, logical_width, logical_height} or None
|
self._double_sided = None # dict {copies, axis, logical_width, logical_height} or None
|
||||||
self._physical_image = None # full-chain buffer reused each frame when tiling
|
self._physical_image = None # full-chain buffer reused each frame when tiling
|
||||||
# Text-width measurement cache: (text, id(font)) -> pixel_width
|
# Text-width measurement cache: (text, id(font)) -> (width, font_ref)
|
||||||
# Avoids re-measuring the same string+font on every display() call.
|
# Avoids re-measuring the same string+font on every display() call.
|
||||||
|
# LRU-bounded: keys embed the TEXT, so changing strings (a clock, a
|
||||||
|
# live score) would otherwise grow it forever on a 24/7 service.
|
||||||
|
# Entries hold a strong reference to the font so its id() can't be
|
||||||
|
# recycled by a different font object — an id-keyed cache without
|
||||||
|
# the reference can return the WRONG width after garbage collection.
|
||||||
# Cleared on _load_fonts() so stale entries don't survive a font reload.
|
# Cleared on _load_fonts() so stale entries don't survive a font reload.
|
||||||
self._text_width_cache: Dict[tuple, int] = {}
|
self._text_width_cache: "OrderedDict[tuple, Tuple[int, Any]]" = OrderedDict()
|
||||||
# Snapshot mirror for web preview + health check (service writes, web
|
self._TEXT_WIDTH_CACHE_MAX = 1024
|
||||||
# reads). Cadence/skip decisions live in src/common/snapshot_policy.py:
|
# Snapshot settings for web preview integration (service writes, web reads)
|
||||||
# full rate only while the web SSE broadcaster keeps the viewer marker
|
|
||||||
# fresh; unchanged frames are never re-encoded, only mtime-touched.
|
|
||||||
self._snapshot_path = "/tmp/led_matrix_preview.png" # nosec B108 - fixed path intentional; web UI reads same path
|
self._snapshot_path = "/tmp/led_matrix_preview.png" # nosec B108 - fixed path intentional; web UI reads same path
|
||||||
self._viewer_marker_path = "/tmp/led_matrix_preview_viewer" # nosec B108 - touched by web SSE broadcaster
|
self._snapshot_min_interval_sec = 0.2 # max ~5 fps
|
||||||
self._last_snapshot_ts = 0.0
|
self._last_snapshot_ts = 0.0
|
||||||
self._last_snapshot_touch_ts = 0.0
|
# Snapshot failures are logged as warnings, rate-limited so a
|
||||||
self._last_snapshot_digest: Optional[int] = None
|
# persistent failure (e.g. an unwritable file) can't spam the log —
|
||||||
self._snapshot_dir_prepared = False
|
# but is never silent: the snapshot's mtime doubles as the web UI's
|
||||||
self._viewer_check_ts = 0.0
|
# hardware-liveness signal, so a quiet failure makes health checks lie.
|
||||||
self._viewer_fresh = False
|
self._snapshot_fail_log_ts = 0.0
|
||||||
self._viewer_was_fresh = False
|
|
||||||
|
|
||||||
# Scrolling state tracking for graceful updates
|
# Scrolling state tracking for graceful updates
|
||||||
self._scrolling_state = {
|
self._scrolling_state = {
|
||||||
@@ -718,12 +711,15 @@ class DisplayManager:
|
|||||||
|
|
||||||
Results are cached by (text, font identity) so plugins that measure
|
Results are cached by (text, font identity) so plugins that measure
|
||||||
the same string every frame (e.g. to centre a score) pay only one
|
the same string every frame (e.g. to centre a score) pay only one
|
||||||
measurement per unique (text, font) pair.
|
measurement per unique (text, font) pair. The entry keeps the font
|
||||||
|
alive so its id() can't be recycled, and the cache is LRU-bounded so
|
||||||
|
ever-changing text (clocks, tickers) can't grow it without limit.
|
||||||
"""
|
"""
|
||||||
cache_key = (text, id(font))
|
cache_key = (text, id(font))
|
||||||
cached = self._text_width_cache.get(cache_key)
|
cached = self._text_width_cache.get(cache_key)
|
||||||
if cached is not None:
|
if cached is not None:
|
||||||
return cached
|
self._text_width_cache.move_to_end(cache_key)
|
||||||
|
return cached[0]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if isinstance(font, freetype.Face):
|
if isinstance(font, freetype.Face):
|
||||||
@@ -738,7 +734,9 @@ class DisplayManager:
|
|||||||
logger.error("Error getting text width: %s", e)
|
logger.error("Error getting text width: %s", e)
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
self._text_width_cache[cache_key] = width
|
self._text_width_cache[cache_key] = (width, font)
|
||||||
|
while len(self._text_width_cache) > self._TEXT_WIDTH_CACHE_MAX:
|
||||||
|
self._text_width_cache.popitem(last=False)
|
||||||
return width
|
return width
|
||||||
|
|
||||||
def get_font_height(self, font):
|
def get_font_height(self, font):
|
||||||
@@ -1147,56 +1145,27 @@ class DisplayManager:
|
|||||||
'deferred_update_ttl': self._scrolling_state['deferred_update_ttl']
|
'deferred_update_ttl': self._scrolling_state['deferred_update_ttl']
|
||||||
}
|
}
|
||||||
|
|
||||||
def _viewer_is_fresh(self, now: float) -> bool:
|
|
||||||
"""True when a browser preview is watching (marker file touched by
|
|
||||||
the web SSE broadcaster). The marker is stat'd at most once per
|
|
||||||
second — at 125 fps loops a per-call stat would be pure overhead."""
|
|
||||||
if (now - self._viewer_check_ts) >= 1.0:
|
|
||||||
self._viewer_check_ts = now
|
|
||||||
try:
|
|
||||||
marker_age = now - os.stat(self._viewer_marker_path).st_mtime
|
|
||||||
self._viewer_fresh = marker_age < snapshot_policy.VIEWER_MARKER_FRESH_SEC
|
|
||||||
except OSError:
|
|
||||||
self._viewer_fresh = False
|
|
||||||
return self._viewer_fresh
|
|
||||||
|
|
||||||
def _write_snapshot_if_due(self) -> None:
|
def _write_snapshot_if_due(self) -> None:
|
||||||
"""Mirror the current frame to the preview snapshot when the policy
|
"""Write the current image to a PNG snapshot file at a limited frequency."""
|
||||||
says it's worth it — see src/common/snapshot_policy.py. Unchanged
|
|
||||||
frames are never re-encoded; without viewers the cadence drops to
|
|
||||||
the idle keepalive."""
|
|
||||||
try:
|
try:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
viewer_fresh = self._viewer_is_fresh(now)
|
if (now - self._last_snapshot_ts) < self._snapshot_min_interval_sec:
|
||||||
if viewer_fresh and not self._viewer_was_fresh:
|
|
||||||
# A preview just opened: let the next changed frame through
|
|
||||||
# immediately instead of waiting out the idle interval.
|
|
||||||
self._last_snapshot_ts = 0.0
|
|
||||||
self._viewer_was_fresh = viewer_fresh
|
|
||||||
|
|
||||||
digest = zlib.adler32(self.image.tobytes())
|
|
||||||
action = snapshot_policy.decide(
|
|
||||||
now, self._last_snapshot_ts, self._last_snapshot_touch_ts,
|
|
||||||
viewer_fresh, digest != self._last_snapshot_digest)
|
|
||||||
if action is snapshot_policy.SnapshotAction.SKIP:
|
|
||||||
return
|
return
|
||||||
if action is snapshot_policy.SnapshotAction.TOUCH:
|
# Ensure directory exists with proper permissions
|
||||||
# mtime bump only: keeps the health check (snapshot age)
|
from pathlib import Path
|
||||||
# green without paying for a PNG encode of an unchanged frame
|
from src.common.permission_utils import (
|
||||||
os.utime(self._snapshot_path, None)
|
ensure_directory_permissions,
|
||||||
self._last_snapshot_touch_ts = now
|
ensure_file_permissions,
|
||||||
return
|
get_assets_dir_mode,
|
||||||
|
get_assets_file_mode
|
||||||
# WRITE: ensure directory permissions once, not per frame
|
)
|
||||||
snapshot_path_obj = Path(self._snapshot_path)
|
snapshot_path_obj = Path(self._snapshot_path)
|
||||||
if not self._snapshot_dir_prepared:
|
# Only ensure permissions on non-system directories
|
||||||
# Never modify /tmp permissions - it has special system
|
# Never modify /tmp permissions - it has special system permissions (1777)
|
||||||
# permissions (1777) that must not be changed or it breaks
|
# that must not be changed or it breaks apt and other system tools
|
||||||
# apt and other system tools
|
parent_dir = snapshot_path_obj.parent
|
||||||
parent_dir = snapshot_path_obj.parent
|
if parent_dir and str(parent_dir) != '/tmp': # nosec B108 - guard to skip /tmp for permission ops
|
||||||
if parent_dir and str(parent_dir) != '/tmp': # nosec B108 - guard to skip /tmp for permission ops
|
ensure_directory_permissions(parent_dir, get_assets_dir_mode())
|
||||||
ensure_directory_permissions(parent_dir, get_assets_dir_mode())
|
|
||||||
self._snapshot_dir_prepared = True
|
|
||||||
# Write atomically: temp then replace
|
# Write atomically: temp then replace
|
||||||
tmp_path = f"{self._snapshot_path}.tmp"
|
tmp_path = f"{self._snapshot_path}.tmp"
|
||||||
self.image.save(tmp_path, format='PNG')
|
self.image.save(tmp_path, format='PNG')
|
||||||
@@ -1211,8 +1180,16 @@ class DisplayManager:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
self._last_snapshot_ts = now
|
self._last_snapshot_ts = now
|
||||||
self._last_snapshot_touch_ts = now
|
|
||||||
self._last_snapshot_digest = digest
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Snapshot failures should never break display; log at debug to avoid noise
|
# Snapshot failures must never break display — but they must not
|
||||||
logger.debug(f"Snapshot write skipped: {e}")
|
# be silent either: the snapshot's mtime is the web UI's display
|
||||||
|
# mirror AND its hardware-liveness proxy, so a quietly failing
|
||||||
|
# write freezes the mirror and makes health checks lie (seen in
|
||||||
|
# the field: a stale root-owned /tmp file froze it for a day).
|
||||||
|
# Warn at most once per 5 minutes to avoid log spam.
|
||||||
|
if (now - self._snapshot_fail_log_ts) > 300:
|
||||||
|
self._snapshot_fail_log_ts = now
|
||||||
|
logger.warning("Snapshot write failing (web preview/health "
|
||||||
|
"mirror is stale): %s", e)
|
||||||
|
else:
|
||||||
|
logger.debug(f"Snapshot write skipped: {e}")
|
||||||
+18
-5
@@ -35,6 +35,7 @@ import urllib.request
|
|||||||
import zipfile
|
import zipfile
|
||||||
import tempfile
|
import tempfile
|
||||||
import time
|
import time
|
||||||
|
from collections import OrderedDict
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from PIL import ImageFont
|
from PIL import ImageFont
|
||||||
from typing import Dict, Tuple, Optional, Union, Any, List
|
from typing import Dict, Tuple, Optional, Union, Any, List
|
||||||
@@ -58,7 +59,13 @@ class FontManager:
|
|||||||
# Font discovery and catalog
|
# Font discovery and catalog
|
||||||
self.font_catalog: Dict[str, str] = {} # family_name -> file_path
|
self.font_catalog: Dict[str, str] = {} # family_name -> file_path
|
||||||
self.font_cache: Dict[str, Union[ImageFont.FreeTypeFont, freetype.Face]] = {} # (family, size) -> font
|
self.font_cache: Dict[str, Union[ImageFont.FreeTypeFont, freetype.Face]] = {} # (family, size) -> font
|
||||||
self.metrics_cache: Dict[str, Tuple[int, int, int]] = {} # (text, font_id) -> (width, height, baseline)
|
# (text, id(font)) -> ((width, height, baseline), font_ref).
|
||||||
|
# LRU-bounded — keys embed the measured TEXT, so changing strings
|
||||||
|
# (clocks, live scores) would otherwise grow it forever. Entries
|
||||||
|
# keep the font alive so its id() can't be recycled by a different
|
||||||
|
# font object (which would silently return wrong metrics).
|
||||||
|
self.metrics_cache: "OrderedDict[Any, Tuple[Tuple[int, int, int], Any]]" = OrderedDict()
|
||||||
|
self._METRICS_CACHE_MAX = 1024
|
||||||
|
|
||||||
# Plugin font management
|
# Plugin font management
|
||||||
self.plugin_fonts: Dict[str, Dict[str, Any]] = {} # plugin_id -> font_manifest
|
self.plugin_fonts: Dict[str, Dict[str, Any]] = {} # plugin_id -> font_manifest
|
||||||
@@ -507,10 +514,14 @@ class FontManager:
|
|||||||
Returns:
|
Returns:
|
||||||
Tuple of (width, height, baseline_offset)
|
Tuple of (width, height, baseline_offset)
|
||||||
"""
|
"""
|
||||||
cache_key = f"{hash(text)}_{id(font)}"
|
# Key on the text itself (hash(text) could collide) + font identity;
|
||||||
|
# the entry below keeps the font referenced so the id stays valid.
|
||||||
|
cache_key = (text, id(font))
|
||||||
|
|
||||||
if cache_key in self.metrics_cache:
|
cached = self.metrics_cache.get(cache_key)
|
||||||
return self.metrics_cache[cache_key]
|
if cached is not None:
|
||||||
|
self.metrics_cache.move_to_end(cache_key)
|
||||||
|
return cached[0]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if isinstance(font, freetype.Face):
|
if isinstance(font, freetype.Face):
|
||||||
@@ -547,7 +558,9 @@ class FontManager:
|
|||||||
baseline = 10
|
baseline = 10
|
||||||
|
|
||||||
result = (width, height, baseline)
|
result = (width, height, baseline)
|
||||||
self.metrics_cache[cache_key] = result
|
self.metrics_cache[cache_key] = (result, font)
|
||||||
|
while len(self.metrics_cache) > self._METRICS_CACHE_MAX:
|
||||||
|
self.metrics_cache.popitem(last=False)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def get_font_height(self, font: Union[ImageFont.FreeTypeFont, freetype.Face]) -> int:
|
def get_font_height(self, font: Union[ImageFont.FreeTypeFont, freetype.Face]) -> int:
|
||||||
|
|||||||
+5
-1
@@ -38,7 +38,11 @@ def mock_cache_manager():
|
|||||||
mock._memory_cache_timestamps = {}
|
mock._memory_cache_timestamps = {}
|
||||||
mock.cache_dir = "/tmp/test_cache"
|
mock.cache_dir = "/tmp/test_cache"
|
||||||
|
|
||||||
def mock_get(key: str, max_age: int = 300) -> Optional[Dict]:
|
def mock_get(key: str, max_age: Optional[int] = 300,
|
||||||
|
memory_ttl: Optional[int] = None) -> Optional[Dict]:
|
||||||
|
# Signature mirrors CacheManager.get — keep in sync or callers
|
||||||
|
# passing keyword args (health tracker, resource monitor) break
|
||||||
|
# only in tests, hiding real-API compatibility.
|
||||||
return mock._memory_cache.get(key)
|
return mock._memory_cache.get(key)
|
||||||
|
|
||||||
def mock_set(key: str, data: Dict, ttl: Optional[int] = None) -> None:
|
def mock_set(key: str, data: Dict, ttl: Optional[int] = None) -> None:
|
||||||
|
|||||||
@@ -1,93 +0,0 @@
|
|||||||
"""Tests for the snapshot write policy (src/common/snapshot_policy.py).
|
|
||||||
|
|
||||||
The invariants that matter:
|
|
||||||
- unchanged frames are NEVER re-encoded (the old code PNG-encoded identical
|
|
||||||
frames at 5 fps, 24/7)
|
|
||||||
- the file mtime never goes stale enough to trip the health check's 60s
|
|
||||||
degraded threshold (api_v3 get_hardware_status)
|
|
||||||
- a viewer gets full cadence; no viewer drops to the idle keepalive
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
|
||||||
|
|
||||||
from src.common.snapshot_policy import ( # noqa: E402
|
|
||||||
IDLE_INTERVAL,
|
|
||||||
TOUCH_INTERVAL,
|
|
||||||
VIEWER_INTERVAL,
|
|
||||||
SnapshotAction,
|
|
||||||
decide,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestViewerCadence:
|
|
||||||
def test_changed_frame_with_viewer_writes_at_full_rate(self):
|
|
||||||
assert decide(now=100.0, last_write_ts=100.0 - VIEWER_INTERVAL,
|
|
||||||
last_touch_ts=0, viewer_fresh=True,
|
|
||||||
frame_changed=True) is SnapshotAction.WRITE
|
|
||||||
|
|
||||||
def test_changed_frame_with_viewer_respects_min_interval(self):
|
|
||||||
assert decide(now=100.0, last_write_ts=100.0 - VIEWER_INTERVAL / 2,
|
|
||||||
last_touch_ts=100.0, viewer_fresh=True,
|
|
||||||
frame_changed=True) is SnapshotAction.SKIP
|
|
||||||
|
|
||||||
def test_unchanged_frame_with_viewer_never_writes(self):
|
|
||||||
"""A static screen with a viewer must not burn PNG encodes."""
|
|
||||||
assert decide(now=100.0, last_write_ts=90.0, last_touch_ts=90.0,
|
|
||||||
viewer_fresh=True,
|
|
||||||
frame_changed=False) is SnapshotAction.SKIP
|
|
||||||
|
|
||||||
|
|
||||||
class TestIdleCadence:
|
|
||||||
def test_changed_frame_without_viewer_waits_for_idle_interval(self):
|
|
||||||
assert decide(now=100.0, last_write_ts=100.0 - IDLE_INTERVAL / 2,
|
|
||||||
last_touch_ts=100.0, viewer_fresh=False,
|
|
||||||
frame_changed=True) is SnapshotAction.SKIP
|
|
||||||
|
|
||||||
def test_changed_frame_without_viewer_writes_at_idle_rate(self):
|
|
||||||
assert decide(now=100.0, last_write_ts=100.0 - IDLE_INTERVAL,
|
|
||||||
last_touch_ts=0, viewer_fresh=False,
|
|
||||||
frame_changed=True) is SnapshotAction.WRITE
|
|
||||||
|
|
||||||
|
|
||||||
class TestHealthKeepalive:
|
|
||||||
def test_stale_mtime_gets_touched(self):
|
|
||||||
"""Whatever else happens, mtime must be bumped within TOUCH_INTERVAL
|
|
||||||
so the health check (60s threshold) never reads the display as dead."""
|
|
||||||
assert decide(now=100.0, last_write_ts=100.0 - TOUCH_INTERVAL,
|
|
||||||
last_touch_ts=100.0 - TOUCH_INTERVAL, viewer_fresh=False,
|
|
||||||
frame_changed=False) is SnapshotAction.TOUCH
|
|
||||||
|
|
||||||
def test_touch_applies_with_viewer_too(self):
|
|
||||||
"""Viewer watching a static screen: no writes, but health stays green."""
|
|
||||||
assert decide(now=100.0, last_write_ts=100.0 - TOUCH_INTERVAL - 1,
|
|
||||||
last_touch_ts=100.0 - TOUCH_INTERVAL - 1, viewer_fresh=True,
|
|
||||||
frame_changed=False) is SnapshotAction.TOUCH
|
|
||||||
|
|
||||||
def test_recent_touch_suppresses_another(self):
|
|
||||||
assert decide(now=100.0, last_write_ts=0.0,
|
|
||||||
last_touch_ts=100.0 - TOUCH_INTERVAL / 2, viewer_fresh=False,
|
|
||||||
frame_changed=False) is SnapshotAction.SKIP
|
|
||||||
|
|
||||||
def test_touch_interval_stays_under_health_threshold(self):
|
|
||||||
"""api_v3's hardware status treats snapshot age >= 60s as degraded.
|
|
||||||
Keep a 2x margin so scheduling jitter can't trip it."""
|
|
||||||
assert TOUCH_INTERVAL <= 30
|
|
||||||
|
|
||||||
def test_worst_case_mtime_age_is_bounded(self):
|
|
||||||
"""Simulate any interleaving: from any state, within one policy call
|
|
||||||
after TOUCH_INTERVAL elapses, mtime gets refreshed (WRITE or TOUCH)."""
|
|
||||||
for viewer in (True, False):
|
|
||||||
for changed in (True, False):
|
|
||||||
action = decide(now=1000.0, last_write_ts=900.0,
|
|
||||||
last_touch_ts=900.0, viewer_fresh=viewer,
|
|
||||||
frame_changed=changed)
|
|
||||||
assert action in (SnapshotAction.WRITE, SnapshotAction.TOUCH)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(pytest.main([__file__, "-v"]))
|
|
||||||
+1
-16
@@ -608,23 +608,9 @@ def display_preview_generator():
|
|||||||
import base64
|
import base64
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
import io
|
import io
|
||||||
|
|
||||||
snapshot_path = "/tmp/led_matrix_preview.png" # nosec B108 - fixed path matches display_manager; only read here
|
snapshot_path = "/tmp/led_matrix_preview.png" # nosec B108 - fixed path matches display_manager; only read here
|
||||||
# Viewer marker: this generator only runs while the broadcaster has
|
|
||||||
# subscribers (it exits with no clients), so touching the marker each
|
|
||||||
# loop tells the DISPLAY service a browser is actually watching — it
|
|
||||||
# only pays for full-rate PNG snapshot encodes while this stays fresh
|
|
||||||
# (see src/common/snapshot_policy.py).
|
|
||||||
viewer_marker_path = "/tmp/led_matrix_preview_viewer" # nosec B108 - fixed path matches display_manager
|
|
||||||
last_modified = None
|
last_modified = None
|
||||||
|
|
||||||
def _touch_viewer_marker():
|
|
||||||
try:
|
|
||||||
with open(viewer_marker_path, 'a'):
|
|
||||||
pass
|
|
||||||
os.utime(viewer_marker_path, None)
|
|
||||||
except OSError:
|
|
||||||
pass # display side treats a missing marker as "no viewer"
|
|
||||||
|
|
||||||
# Get display dimensions from config
|
# Get display dimensions from config
|
||||||
try:
|
try:
|
||||||
@@ -641,7 +627,6 @@ def display_preview_generator():
|
|||||||
|
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
_touch_viewer_marker()
|
|
||||||
# Check if snapshot file exists and has been modified
|
# Check if snapshot file exists and has been modified
|
||||||
if os.path.exists(snapshot_path):
|
if os.path.exists(snapshot_path):
|
||||||
current_modified = os.path.getmtime(snapshot_path)
|
current_modified = os.path.getmtime(snapshot_path)
|
||||||
|
|||||||
Reference in New Issue
Block a user