Compare commits

..
Author SHA1 Message Date
Claude a0c80d934a fix(schedule): stop stray 'days' data from overriding Global schedule
save_schedule_config never persisted the schedule's 'mode' field, and
_check_schedule inferred per-day vs global purely from whether a 'days'
dict was present for the current day. Config migration
(_merge_template_defaults) re-adds the template's 'schedule.days' (all
days disabled by default) whenever it's missing from the user's saved
config - which is exactly the case after saving Global mode, since that
save path intentionally pops 'days'. The result: a user on Global mode
would get their schedule silently reinterpreted as per-day, with today's
day disabled, blanking the display.

Persist 'mode' on save and have _check_schedule honor it explicitly
(mirroring how _check_dim_schedule already does), so a resurrected
'days' dict can't override an explicit Global selection. Falls back to
the old inference behavior only when no 'mode' is recorded.
2026-07-12 14:12:23 +00:00
5 changed files with 33 additions and 66 deletions
+18 -8
View File
@@ -621,18 +621,28 @@ class DisplayController:
current_day = current_time.strftime('%A').lower() # e.g. 'monday' current_day = current_time.strftime('%A').lower() # e.g. 'monday'
current_time_only = current_time.time() current_time_only = current_time.time()
# Check if per-day schedule is configured # Check if per-day schedule is configured
days_config = schedule_config.get('days') days_config = schedule_config.get('days')
# Determine which schedule to use # Determine which schedule to use. Respect an explicit 'mode' field
# (like the dim schedule does) so a stray/legacy 'days' dict left over
# from config migration or a prior per-day setup can't silently
# override a user's Global schedule selection.
mode = schedule_config.get('mode')
mode_normalized = mode.replace('_', '-') if mode else None
use_per_day = False use_per_day = False
if days_config: if mode_normalized == 'global':
# Check if days dict is not empty and contains current day use_per_day = False
if days_config and current_day in days_config: elif mode_normalized == 'per-day':
use_per_day = bool(days_config and current_day in days_config)
elif days_config:
# No explicit mode recorded (legacy config) - fall back to
# inferring from presence of a 'days' dict for the current day.
if current_day in days_config:
use_per_day = True use_per_day = True
elif days_config: else:
# Days dict exists but doesn't have current day - fall back to global
logger.debug("Per-day schedule exists but %s not configured, using global schedule", current_day) logger.debug("Per-day schedule exists but %s not configured, using global schedule", current_day)
if use_per_day: if use_per_day:
+8 -35
View File
@@ -33,8 +33,7 @@ else:
from contextlib import contextmanager from contextlib import contextmanager
from PIL import Image, ImageDraw, ImageFont from PIL import Image, ImageDraw, ImageFont
import time import time
from collections import OrderedDict from typing import Dict, Any, List, Optional
from typing import Dict, Any, List, Optional, Tuple
import logging import logging
import math import math
import freetype import freetype
@@ -181,25 +180,14 @@ 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)) -> (width, font_ref) # Text-width measurement cache: (text, id(font)) -> pixel_width
# 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: "OrderedDict[tuple, Tuple[int, Any]]" = OrderedDict() self._text_width_cache: Dict[tuple, int] = {}
self._TEXT_WIDTH_CACHE_MAX = 1024
# 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" # 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._snapshot_min_interval_sec = 0.2 # max ~5 fps self._snapshot_min_interval_sec = 0.2 # max ~5 fps
self._last_snapshot_ts = 0.0 self._last_snapshot_ts = 0.0
# Snapshot failures are logged as warnings, rate-limited so a
# persistent failure (e.g. an unwritable file) can't spam the log —
# but is never silent: the snapshot's mtime doubles as the web UI's
# hardware-liveness signal, so a quiet failure makes health checks lie.
self._snapshot_fail_log_ts = 0.0
# Scrolling state tracking for graceful updates # Scrolling state tracking for graceful updates
self._scrolling_state = { self._scrolling_state = {
@@ -711,15 +699,12 @@ 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. The entry keeps the font measurement per unique (text, font) pair.
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:
self._text_width_cache.move_to_end(cache_key) return cached
return cached[0]
try: try:
if isinstance(font, freetype.Face): if isinstance(font, freetype.Face):
@@ -734,9 +719,7 @@ 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, font) self._text_width_cache[cache_key] = width
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):
@@ -1181,15 +1164,5 @@ class DisplayManager:
pass pass
self._last_snapshot_ts = now self._last_snapshot_ts = now
except Exception as e: except Exception as e:
# Snapshot failures must never break displaybut they must not # Snapshot failures should never break display; log at debug to avoid noise
# be silent either: the snapshot's mtime is the web UI's display logger.debug(f"Snapshot write skipped: {e}")
# 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}")
+5 -18
View File
@@ -35,7 +35,6 @@ 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
@@ -59,13 +58,7 @@ 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
# (text, id(font)) -> ((width, height, baseline), font_ref). self.metrics_cache: Dict[str, Tuple[int, int, int]] = {} # (text, font_id) -> (width, height, baseline)
# 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
@@ -514,14 +507,10 @@ class FontManager:
Returns: Returns:
Tuple of (width, height, baseline_offset) Tuple of (width, height, baseline_offset)
""" """
# Key on the text itself (hash(text) could collide) + font identity; cache_key = f"{hash(text)}_{id(font)}"
# the entry below keeps the font referenced so the id stays valid.
cache_key = (text, id(font))
cached = self.metrics_cache.get(cache_key) if cache_key in self.metrics_cache:
if cached is not None: return self.metrics_cache[cache_key]
self.metrics_cache.move_to_end(cache_key)
return cached[0]
try: try:
if isinstance(font, freetype.Face): if isinstance(font, freetype.Face):
@@ -558,9 +547,7 @@ class FontManager:
baseline = 10 baseline = 10
result = (width, height, baseline) result = (width, height, baseline)
self.metrics_cache[cache_key] = (result, font) self.metrics_cache[cache_key] = result
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:
+1 -5
View File
@@ -38,11 +38,7 @@ 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: Optional[int] = 300, def mock_get(key: str, max_age: int = 300) -> Optional[Dict]:
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
View File
@@ -329,6 +329,7 @@ def save_schedule_config():
} }
mode = data.get('mode', 'global') mode = data.get('mode', 'global')
schedule_config['mode'] = mode
if mode == 'global': if mode == 'global':
# Simple global schedule # Simple global schedule