mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-06-20 11:38:37 +00:00
Compare commits
4 Commits
v3.1.0
...
b9dcbb5152
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b9dcbb5152 | ||
|
|
f27fd260f7 | ||
|
|
eedf680a8c | ||
|
|
ac3a15bfaa |
@@ -1,3 +1,28 @@
|
|||||||
|
"""
|
||||||
|
Cache Manager — multi-tier response cache for the LEDMatrix application.
|
||||||
|
|
||||||
|
:class:`CacheManager` provides a unified caching layer used by all plugins
|
||||||
|
to reduce external API calls and survive network outages gracefully.
|
||||||
|
|
||||||
|
Two storage tiers
|
||||||
|
-----------------
|
||||||
|
* **Memory tier** (:class:`~src.cache.memory_cache.MemoryCache`): fast LRU
|
||||||
|
cache (up to 1 000 entries by default). Hit on this tier before touching
|
||||||
|
disk.
|
||||||
|
* **Disk tier** (:class:`~src.cache.disk_cache.DiskCache`): filesystem-backed
|
||||||
|
persistent store that survives process restarts.
|
||||||
|
|
||||||
|
Data written to cache is serialised as JSON. :class:`DateTimeEncoder` handles
|
||||||
|
``datetime`` objects transparently so callers don't have to pre-serialise them.
|
||||||
|
|
||||||
|
Typical plugin usage::
|
||||||
|
|
||||||
|
data = self.cache_manager.get_cached_data('my_key', max_age=300)
|
||||||
|
if data is None:
|
||||||
|
data = fetch_from_api()
|
||||||
|
self.cache_manager.save_cache('my_key', data)
|
||||||
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
@@ -15,7 +40,10 @@ from src.cache.cache_metrics import CacheMetrics
|
|||||||
from src.logging_config import get_logger
|
from src.logging_config import get_logger
|
||||||
|
|
||||||
class DateTimeEncoder(json.JSONEncoder):
|
class DateTimeEncoder(json.JSONEncoder):
|
||||||
|
"""JSON encoder that serialises ``datetime`` objects as ISO-8601 strings."""
|
||||||
|
|
||||||
def default(self, obj):
|
def default(self, obj):
|
||||||
|
"""Return ISO-8601 string for datetime; delegate all other types to the base encoder."""
|
||||||
if isinstance(obj, datetime):
|
if isinstance(obj, datetime):
|
||||||
return obj.isoformat()
|
return obj.isoformat()
|
||||||
return super().default(obj)
|
return super().default(obj)
|
||||||
|
|||||||
@@ -347,34 +347,40 @@ class ScrollHelper:
|
|||||||
return self._get_visible_portion_integer(start_x_int, end_x_int)
|
return self._get_visible_portion_integer(start_x_int, end_x_int)
|
||||||
|
|
||||||
def _get_visible_portion_integer(self, start_x: int, end_x: int) -> Image.Image:
|
def _get_visible_portion_integer(self, start_x: int, end_x: int) -> Image.Image:
|
||||||
"""Fast integer pixel extraction (no interpolation)."""
|
"""Fast integer pixel extraction (no interpolation).
|
||||||
# Fast numpy array slicing for normal case (no wrap-around)
|
|
||||||
if end_x <= self.cached_image.width:
|
Uses Image.frombytes instead of Image.fromarray: frombytes skips
|
||||||
# Normal case: single slice - fastest path
|
numpy's array-protocol overhead and is ~50% faster for the display-sized
|
||||||
frame_array = self.cached_array[:, start_x:end_x]
|
slices (128×32 = 12 KB) used here.
|
||||||
# Convert to PIL Image (minimal overhead)
|
"""
|
||||||
return Image.fromarray(frame_array)
|
_size = (self.display_width, self.display_height)
|
||||||
|
img_w = self.cached_image.width
|
||||||
|
|
||||||
|
if end_x <= img_w:
|
||||||
|
# Normal case: single contiguous slice (fastest path)
|
||||||
|
frame_array = np.ascontiguousarray(self.cached_array[:, start_x:end_x])
|
||||||
|
return Image.frombytes('RGB', _size, frame_array.tobytes())
|
||||||
else:
|
else:
|
||||||
# Wrap-around case: combine two slices using numpy
|
# Ensure frame buffer is allocated for all non-simple paths
|
||||||
width1 = self.cached_image.width - start_x
|
|
||||||
if width1 > 0:
|
|
||||||
# Use pre-allocated buffer for output
|
|
||||||
if self._frame_buffer is None or self._frame_buffer.shape != (self.display_height, self.display_width, 3):
|
if self._frame_buffer is None or self._frame_buffer.shape != (self.display_height, self.display_width, 3):
|
||||||
self._frame_buffer = np.zeros((self.display_height, self.display_width, 3), dtype=np.uint8)
|
self._frame_buffer = np.zeros((self.display_height, self.display_width, 3), dtype=np.uint8)
|
||||||
|
|
||||||
# First part from end of image (fast numpy slice)
|
width1 = img_w - start_x
|
||||||
|
if width1 > 0:
|
||||||
|
# Wrap-around: tail of image + head of image
|
||||||
self._frame_buffer[:, :width1] = self.cached_array[:, start_x:]
|
self._frame_buffer[:, :width1] = self.cached_array[:, start_x:]
|
||||||
|
|
||||||
# Second part from beginning of image
|
|
||||||
remaining_width = self.display_width - width1
|
remaining_width = self.display_width - width1
|
||||||
self._frame_buffer[:, width1:] = self.cached_array[:, :remaining_width]
|
self._frame_buffer[:, width1:] = self.cached_array[:, :remaining_width]
|
||||||
|
|
||||||
# Convert combined buffer to PIL Image
|
|
||||||
return Image.fromarray(self._frame_buffer)
|
|
||||||
else:
|
else:
|
||||||
# Edge case: start_x >= image width, wrap to beginning
|
# Edge case: start_x at or past image end — show from beginning,
|
||||||
frame_array = self.cached_array[:, :self.display_width]
|
# clamped to available width (scroll_position should wrap before
|
||||||
return Image.fromarray(frame_array)
|
# reaching this state in normal operation).
|
||||||
|
available = min(self.display_width, img_w)
|
||||||
|
self._frame_buffer[:, :available] = self.cached_array[:, :available]
|
||||||
|
if available < self.display_width:
|
||||||
|
self._frame_buffer[:, available:] = 0
|
||||||
|
|
||||||
|
return Image.frombytes('RGB', _size, self._frame_buffer.tobytes())
|
||||||
|
|
||||||
def _get_visible_portion_subpixel(self, start_x_int: int, fractional: float) -> Image.Image:
|
def _get_visible_portion_subpixel(self, start_x_int: int, fractional: float) -> Image.Image:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1,3 +1,29 @@
|
|||||||
|
"""
|
||||||
|
Config Manager — reads, writes, and validates ``config/config.json``.
|
||||||
|
|
||||||
|
:class:`ConfigManager` is the single owner of the on-disk configuration
|
||||||
|
files:
|
||||||
|
|
||||||
|
* ``config/config.json`` — main user-editable configuration.
|
||||||
|
* ``config/config_secrets.json`` — sensitive values (API keys, tokens).
|
||||||
|
|
||||||
|
All writes go through :class:`~src.config_manager_atomic.AtomicConfigManager`
|
||||||
|
which performs a backup before overwriting, validates the result, and rolls
|
||||||
|
back on error. This makes config corruption essentially impossible.
|
||||||
|
|
||||||
|
Plugin configuration
|
||||||
|
--------------------
|
||||||
|
Plugin configs are stored inside ``config.json`` under the plugin's ID key
|
||||||
|
and survive plugin reinstalls. Use :meth:`ConfigManager.update_plugin_config`
|
||||||
|
to write plugin settings; never write directly to the plugin directory.
|
||||||
|
|
||||||
|
Hot-reload
|
||||||
|
----------
|
||||||
|
:class:`~src.config_service.ConfigService` wraps ``ConfigManager`` and
|
||||||
|
detects file changes, broadcasting the new config to registered listeners
|
||||||
|
without requiring a restart.
|
||||||
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
@@ -17,6 +43,13 @@ from src.common.permission_utils import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
class ConfigManager:
|
class ConfigManager:
|
||||||
|
"""
|
||||||
|
Reads and writes the main application configuration files.
|
||||||
|
|
||||||
|
Wraps :class:`~src.config_manager_atomic.AtomicConfigManager` for safe
|
||||||
|
atomic writes with automatic backup and rollback. Also exposes helpers
|
||||||
|
for plugin configuration persistence and secret-field masking.
|
||||||
|
"""
|
||||||
def __init__(self, config_path: Optional[str] = None, secrets_path: Optional[str] = None) -> None:
|
def __init__(self, config_path: Optional[str] = None, secrets_path: Optional[str] = None) -> None:
|
||||||
# Use current working directory as base
|
# Use current working directory as base
|
||||||
self.config_path: str = config_path or "config/config.json"
|
self.config_path: str = config_path or "config/config.json"
|
||||||
@@ -29,9 +62,11 @@ class ConfigManager:
|
|||||||
self._atomic_manager: Optional[AtomicConfigManager] = None
|
self._atomic_manager: Optional[AtomicConfigManager] = None
|
||||||
|
|
||||||
def get_config_path(self) -> str:
|
def get_config_path(self) -> str:
|
||||||
|
"""Return the path to the main config file (``config/config.json``)."""
|
||||||
return self.config_path
|
return self.config_path
|
||||||
|
|
||||||
def get_secrets_path(self) -> str:
|
def get_secrets_path(self) -> str:
|
||||||
|
"""Return the path to the secrets file (``config/config_secrets.json``)."""
|
||||||
return self.secrets_path
|
return self.secrets_path
|
||||||
|
|
||||||
def _get_atomic_manager(self) -> AtomicConfigManager:
|
def _get_atomic_manager(self) -> AtomicConfigManager:
|
||||||
|
|||||||
@@ -1,3 +1,25 @@
|
|||||||
|
"""
|
||||||
|
Display Controller — top-level orchestration for the LEDMatrix application.
|
||||||
|
|
||||||
|
This module owns the main run loop that drives the LED display. It ties
|
||||||
|
together every major subsystem:
|
||||||
|
|
||||||
|
- ConfigManager / ConfigService — loads config.json, hot-reloads on change
|
||||||
|
- DisplayManager — hardware (or emulator) output interface
|
||||||
|
- FontManager — TTF/BDF font loading and caching
|
||||||
|
- CacheManager — multi-tier API response cache
|
||||||
|
- PluginManager — plugin lifecycle (load, update, display)
|
||||||
|
- DisplaySyncManager — optional leader/follower multi-Pi sync
|
||||||
|
- VegasModeCoordinator — optional continuous Vegas scroll mode
|
||||||
|
|
||||||
|
The main loop inside :meth:`DisplayController.run` rotates through enabled
|
||||||
|
plugin display modes, respecting schedule windows, brightness dim schedules,
|
||||||
|
on-demand overrides, and live-priority interrupts.
|
||||||
|
|
||||||
|
Entry point: :func:`main` — instantiates :class:`DisplayController` and calls
|
||||||
|
:meth:`~DisplayController.run`.
|
||||||
|
"""
|
||||||
|
|
||||||
import time
|
import time
|
||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
@@ -28,6 +50,24 @@ DEFAULT_DYNAMIC_DURATION_CAP = 180.0
|
|||||||
WIFI_STATUS_FILE = None # Will be initialized in __init__
|
WIFI_STATUS_FILE = None # Will be initialized in __init__
|
||||||
|
|
||||||
class DisplayController:
|
class DisplayController:
|
||||||
|
"""
|
||||||
|
Top-level controller that owns the LED display run loop.
|
||||||
|
|
||||||
|
Responsibilities
|
||||||
|
----------------
|
||||||
|
* Initialise and wire together all subsystems at startup.
|
||||||
|
* Rotate through plugin display modes in :meth:`run`.
|
||||||
|
* Honour schedule windows (active/inactive hours) and dim schedules.
|
||||||
|
* Handle on-demand override requests (external callers can pin a
|
||||||
|
specific plugin/mode for a fixed duration via the cache bus).
|
||||||
|
* Coordinate with a follower Pi when multi-display sync is configured.
|
||||||
|
* Delegate all actual content to the plugin system — this class contains
|
||||||
|
no display logic of its own.
|
||||||
|
|
||||||
|
There is exactly one instance per process; call :func:`main` to create
|
||||||
|
it and start the run loop.
|
||||||
|
"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
logger.info("Starting DisplayController initialization")
|
logger.info("Starting DisplayController initialization")
|
||||||
@@ -138,6 +178,10 @@ class DisplayController:
|
|||||||
self.on_demand_last_event: Optional[str] = None
|
self.on_demand_last_event: Optional[str] = None
|
||||||
self.on_demand_schedule_override = False
|
self.on_demand_schedule_override = False
|
||||||
self.rotation_resume_index: Optional[int] = None
|
self.rotation_resume_index: Optional[int] = None
|
||||||
|
# Saved rotation position when a live-priority plugin preempts the
|
||||||
|
# rotation, so it resumes where it left off (not after the live plugin)
|
||||||
|
# once live priority ends.
|
||||||
|
self._live_resume_index: Optional[int] = None
|
||||||
|
|
||||||
# WiFi status message tracking
|
# WiFi status message tracking
|
||||||
global WIFI_STATUS_FILE
|
global WIFI_STATUS_FILE
|
||||||
@@ -149,6 +193,10 @@ class DisplayController:
|
|||||||
self.wifi_status_active = False
|
self.wifi_status_active = False
|
||||||
self.wifi_status_expires_at: Optional[float] = None
|
self.wifi_status_expires_at: Optional[float] = None
|
||||||
|
|
||||||
|
# Plugin display() signature cache — must be initialised before the plugin
|
||||||
|
# loading loop below so the .pop() invalidation at load time is always safe.
|
||||||
|
self._plugin_accepts_display_mode: Dict[str, bool] = {}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
logger.info("Attempting to import plugin system...")
|
logger.info("Attempting to import plugin system...")
|
||||||
from src.plugin_system import PluginManager
|
from src.plugin_system import PluginManager
|
||||||
@@ -321,6 +369,8 @@ class DisplayController:
|
|||||||
self.plugin_modes[mode] = plugin_instance
|
self.plugin_modes[mode] = plugin_instance
|
||||||
self.mode_to_plugin_id[mode] = plugin_id
|
self.mode_to_plugin_id[mode] = plugin_id
|
||||||
logger.debug(" Added mode: %s", mode)
|
logger.debug(" Added mode: %s", mode)
|
||||||
|
# Invalidate signature cache so the new instance is re-inspected
|
||||||
|
self._plugin_accepts_display_mode.pop(plugin_id, None)
|
||||||
|
|
||||||
# Show progress
|
# Show progress
|
||||||
progress_pct = int((loaded_count / enabled_count) * 100)
|
progress_pct = int((loaded_count / enabled_count) * 100)
|
||||||
@@ -367,11 +417,39 @@ class DisplayController:
|
|||||||
self.is_display_active = True
|
self.is_display_active = True
|
||||||
self._was_display_active = True # Track previous state for schedule change detection
|
self._was_display_active = True # Track previous state for schedule change detection
|
||||||
|
|
||||||
|
# --- Opt #2: cached config values ---
|
||||||
|
# Avoids chained dict.get() with temporary {} defaults on every hot path call.
|
||||||
|
# Refreshed via _refresh_config_cache() on every hot-reload.
|
||||||
|
self._normal_brightness: int = (
|
||||||
|
self.config.get('display', {}).get('hardware', {}).get('brightness', 90)
|
||||||
|
)
|
||||||
|
self._scroll_speed: float = (
|
||||||
|
self.config.get('display', {}).get('vegas_scroll', {}).get('scroll_speed', 75)
|
||||||
|
)
|
||||||
|
|
||||||
# Brightness state tracking for dim schedule
|
# Brightness state tracking for dim schedule
|
||||||
self.current_brightness = self.config.get('display', {}).get('hardware', {}).get('brightness', 90)
|
self.current_brightness = self._normal_brightness
|
||||||
self.is_dimmed = False
|
self.is_dimmed = False
|
||||||
self._was_dimmed = False
|
self._was_dimmed = False
|
||||||
|
|
||||||
|
# --- Opt #3: schedule minute-gate ---
|
||||||
|
# Both _check_schedule and _check_dim_schedule re-evaluated at most once per
|
||||||
|
# clock minute. Storing the (hour, minute) tuple that was last evaluated lets
|
||||||
|
# the methods skip all timezone / strptime work within the same minute.
|
||||||
|
# Reset to None on config change so the next call re-evaluates immediately.
|
||||||
|
self._tz = None # pytz timezone, lazily built from config
|
||||||
|
self._schedule_checked_minute: Optional[tuple] = None
|
||||||
|
self._dim_checked_minute: Optional[tuple] = None
|
||||||
|
self._cached_target_brightness: int = self._normal_brightness
|
||||||
|
|
||||||
|
# Register controller-level hot-reload callback so cached config values
|
||||||
|
# (_normal_brightness, _scroll_speed, _tz, minute-gates) stay in sync
|
||||||
|
# when the user saves settings via the web UI.
|
||||||
|
def _controller_config_change(old_config: Dict[str, Any], new_config: Dict[str, Any]) -> None:
|
||||||
|
self._refresh_config_cache(new_config)
|
||||||
|
|
||||||
|
self.config_service.subscribe(_controller_config_change)
|
||||||
|
|
||||||
# Publish initial on-demand state
|
# Publish initial on-demand state
|
||||||
try:
|
try:
|
||||||
self._publish_on_demand_state()
|
self._publish_on_demand_state()
|
||||||
@@ -533,17 +611,24 @@ class DisplayController:
|
|||||||
logger.debug("Schedule is disabled - display always active")
|
logger.debug("Schedule is disabled - display always active")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Get configured timezone, default to UTC
|
# Lazily build the timezone object once; reuse on every subsequent call.
|
||||||
|
if self._tz is None:
|
||||||
timezone_str = self.config.get('timezone', 'UTC')
|
timezone_str = self.config.get('timezone', 'UTC')
|
||||||
try:
|
try:
|
||||||
tz = pytz.timezone(timezone_str)
|
self._tz = pytz.timezone(timezone_str)
|
||||||
except pytz.UnknownTimeZoneError:
|
except pytz.UnknownTimeZoneError:
|
||||||
logger.warning(f"Unknown timezone '{timezone_str}', using UTC")
|
logger.warning("Unknown timezone '%s', using UTC", timezone_str)
|
||||||
tz = pytz.UTC
|
self._tz = pytz.UTC
|
||||||
|
|
||||||
# Use timezone-aware current time
|
current_time = datetime.now(self._tz)
|
||||||
current_time = datetime.now(tz)
|
# Gate: schedule state can only change on a minute boundary, so skip
|
||||||
current_day = current_time.strftime('%A').lower() # Get day name (monday, tuesday, etc.)
|
# all the strptime / comparison work if we already evaluated this minute.
|
||||||
|
current_minute_key = (current_time.hour, current_time.minute)
|
||||||
|
if current_minute_key == self._schedule_checked_minute:
|
||||||
|
return
|
||||||
|
self._schedule_checked_minute = current_minute_key
|
||||||
|
|
||||||
|
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
|
||||||
@@ -632,8 +717,8 @@ 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 normal brightness from config
|
# Opt #2: use cached brightness rather than re-traversing config dict
|
||||||
normal_brightness = self.config.get('display', {}).get('hardware', {}).get('brightness', 90)
|
normal_brightness = self._normal_brightness
|
||||||
|
|
||||||
# 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:
|
||||||
@@ -647,15 +732,21 @@ class DisplayController:
|
|||||||
self.is_dimmed = False
|
self.is_dimmed = False
|
||||||
return normal_brightness
|
return normal_brightness
|
||||||
|
|
||||||
# Get configured timezone
|
# Opt #3: lazily build timezone; gate full re-parse to once per clock minute
|
||||||
|
if self._tz is None:
|
||||||
timezone_str = self.config.get('timezone', 'UTC')
|
timezone_str = self.config.get('timezone', 'UTC')
|
||||||
try:
|
try:
|
||||||
tz = pytz.timezone(timezone_str)
|
self._tz = pytz.timezone(timezone_str)
|
||||||
except pytz.UnknownTimeZoneError:
|
except pytz.UnknownTimeZoneError:
|
||||||
logger.warning(f"Unknown timezone '{timezone_str}' in dim schedule, using UTC")
|
logger.warning("Unknown timezone '%s' in dim schedule, using UTC", timezone_str)
|
||||||
tz = pytz.UTC
|
self._tz = pytz.UTC
|
||||||
|
|
||||||
|
current_time = datetime.now(self._tz)
|
||||||
|
current_minute_key = (current_time.hour, current_time.minute)
|
||||||
|
if current_minute_key == self._dim_checked_minute:
|
||||||
|
return self._cached_target_brightness
|
||||||
|
self._dim_checked_minute = current_minute_key
|
||||||
|
|
||||||
current_time = datetime.now(tz)
|
|
||||||
current_day = current_time.strftime('%A').lower()
|
current_day = current_time.strftime('%A').lower()
|
||||||
current_time_only = current_time.time()
|
current_time_only = current_time.time()
|
||||||
|
|
||||||
@@ -703,10 +794,12 @@ class DisplayController:
|
|||||||
logger.info(f"Dim schedule deactivated: brightness restored to {target_brightness}%")
|
logger.info(f"Dim schedule deactivated: brightness restored to {target_brightness}%")
|
||||||
|
|
||||||
self._was_dimmed = self.is_dimmed
|
self._was_dimmed = self.is_dimmed
|
||||||
|
self._cached_target_brightness = target_brightness # persist for minute-gate
|
||||||
return target_brightness
|
return target_brightness
|
||||||
|
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
logger.warning(f"Invalid dim schedule time format: {e}")
|
logger.warning("Invalid dim schedule time format: %s", e)
|
||||||
|
self._cached_target_brightness = normal_brightness # persist for minute-gate
|
||||||
return normal_brightness
|
return normal_brightness
|
||||||
|
|
||||||
def _update_modules(self):
|
def _update_modules(self):
|
||||||
@@ -1382,6 +1475,36 @@ class DisplayController:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"Error logging memory stats: {e}")
|
logger.debug(f"Error logging memory stats: {e}")
|
||||||
|
|
||||||
|
def _apply_live_priority(self, live_priority_mode):
|
||||||
|
"""Switch to a live-priority mode, or resume rotation when it ends.
|
||||||
|
|
||||||
|
When a live-priority plugin preempts the rotation, the position the
|
||||||
|
rotation had reached is saved so that, once live priority ends, the
|
||||||
|
rotation resumes from there instead of continuing after the live
|
||||||
|
plugin's mode (which would skip every mode between the two). The save
|
||||||
|
happens only on the initial switch, not on each re-check while the
|
||||||
|
live hold continues.
|
||||||
|
"""
|
||||||
|
if live_priority_mode:
|
||||||
|
if self.current_display_mode != live_priority_mode:
|
||||||
|
logger.info("Live content detected - switching immediately to %s", live_priority_mode)
|
||||||
|
if self._live_resume_index is None:
|
||||||
|
self._live_resume_index = self.current_mode_index
|
||||||
|
self.current_display_mode = live_priority_mode
|
||||||
|
self.force_change = True
|
||||||
|
# Update mode index to match the new mode
|
||||||
|
try:
|
||||||
|
self.current_mode_index = self.available_modes.index(live_priority_mode)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
elif self._live_resume_index is not None and self.available_modes:
|
||||||
|
# Live priority ended — resume rotation where it was interrupted.
|
||||||
|
self.current_mode_index = self._live_resume_index % len(self.available_modes)
|
||||||
|
self.current_display_mode = self.available_modes[self.current_mode_index]
|
||||||
|
self.force_change = True
|
||||||
|
logger.info("Live priority ended - resuming rotation at %s", self.current_display_mode)
|
||||||
|
self._live_resume_index = None
|
||||||
|
|
||||||
def _check_live_priority(self):
|
def _check_live_priority(self):
|
||||||
"""
|
"""
|
||||||
Check all plugins for live priority content.
|
Check all plugins for live priority content.
|
||||||
@@ -1483,12 +1606,8 @@ class DisplayController:
|
|||||||
rp = vc.render_pipeline if (vc and vc.render_pipeline) else None
|
rp = vc.render_pipeline if (vc and vc.render_pipeline) else None
|
||||||
width = self.display_manager.width
|
width = self.display_manager.width
|
||||||
|
|
||||||
# Advance local position at Vegas scroll speed (px/s → px/tick)
|
# Opt #2: use pre-cached scroll speed (constant for the run)
|
||||||
vegas_speed = (
|
vegas_speed = self._scroll_speed
|
||||||
self.config.get('display', {})
|
|
||||||
.get('vegas_scroll', {})
|
|
||||||
.get('scroll_speed', 75)
|
|
||||||
)
|
|
||||||
local_x = getattr(self, '_follower_local_x', None)
|
local_x = getattr(self, '_follower_local_x', None)
|
||||||
if local_x is None:
|
if local_x is None:
|
||||||
local_x = float(width) # safe start (past pre-roll guard)
|
local_x = float(width) # safe start (past pre-roll guard)
|
||||||
@@ -1573,15 +1692,7 @@ class DisplayController:
|
|||||||
# Check for live priority content and switch to it immediately
|
# Check for live priority content and switch to it immediately
|
||||||
if not self.on_demand_active and not wifi_status_data:
|
if not self.on_demand_active and not wifi_status_data:
|
||||||
live_priority_mode = self._check_live_priority()
|
live_priority_mode = self._check_live_priority()
|
||||||
if live_priority_mode and self.current_display_mode != live_priority_mode:
|
self._apply_live_priority(live_priority_mode)
|
||||||
logger.info("Live content detected - switching immediately to %s", live_priority_mode)
|
|
||||||
self.current_display_mode = live_priority_mode
|
|
||||||
self.force_change = True
|
|
||||||
# Update mode index to match the new mode
|
|
||||||
try:
|
|
||||||
self.current_mode_index = self.available_modes.index(live_priority_mode)
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Vegas scroll mode - continuous ticker across all plugins
|
# Vegas scroll mode - continuous ticker across all plugins
|
||||||
# Priority: on-demand > wifi-status > live-priority > vegas > normal rotation
|
# Priority: on-demand > wifi-status > live-priority > vegas > normal rotation
|
||||||
@@ -1628,7 +1739,8 @@ class DisplayController:
|
|||||||
|
|
||||||
manager_to_display = None
|
manager_to_display = None
|
||||||
|
|
||||||
logger.info(f"Processing mode: {active_mode}, available_modes: {len(self.available_modes)}, plugin_modes: {list(self.plugin_modes.keys())}")
|
logger.info("Processing mode: %s (%d available)", active_mode, len(self.available_modes))
|
||||||
|
logger.debug("Loaded plugin modes: %s", list(self.plugin_modes.keys()))
|
||||||
|
|
||||||
# Handle plugin-based display modes
|
# Handle plugin-based display modes
|
||||||
if active_mode in self.plugin_modes:
|
if active_mode in self.plugin_modes:
|
||||||
@@ -1664,9 +1776,14 @@ class DisplayController:
|
|||||||
try:
|
try:
|
||||||
logger.debug(f"Calling display() for {active_mode} with force_clear={self.force_change}")
|
logger.debug(f"Calling display() for {active_mode} with force_clear={self.force_change}")
|
||||||
if hasattr(manager_to_display, 'display'):
|
if hasattr(manager_to_display, 'display'):
|
||||||
# Check if plugin accepts display_mode parameter
|
# Opt #1: look up (or compute once) whether display() accepts display_mode
|
||||||
import inspect
|
_cache_key = plugin_id
|
||||||
sig = inspect.signature(manager_to_display.display)
|
if _cache_key not in self._plugin_accepts_display_mode:
|
||||||
|
import inspect as _inspect
|
||||||
|
self._plugin_accepts_display_mode[_cache_key] = (
|
||||||
|
'display_mode' in _inspect.signature(manager_to_display.display).parameters
|
||||||
|
)
|
||||||
|
_accepts_display_mode = self._plugin_accepts_display_mode[_cache_key]
|
||||||
|
|
||||||
# Use PluginExecutor for safe execution with timeout
|
# Use PluginExecutor for safe execution with timeout
|
||||||
if self.plugin_manager and hasattr(self.plugin_manager, 'plugin_executor'):
|
if self.plugin_manager and hasattr(self.plugin_manager, 'plugin_executor'):
|
||||||
@@ -1674,7 +1791,7 @@ class DisplayController:
|
|||||||
manager_to_display,
|
manager_to_display,
|
||||||
plugin_id,
|
plugin_id,
|
||||||
force_clear=self.force_change,
|
force_clear=self.force_change,
|
||||||
display_mode=active_mode if 'display_mode' in sig.parameters else None
|
display_mode=active_mode if _accepts_display_mode else None
|
||||||
)
|
)
|
||||||
# execute_display returns bool, convert to expected format
|
# execute_display returns bool, convert to expected format
|
||||||
if result:
|
if result:
|
||||||
@@ -1683,7 +1800,7 @@ class DisplayController:
|
|||||||
result = False # Failed
|
result = False # Failed
|
||||||
else:
|
else:
|
||||||
# Fallback to direct call if executor not available
|
# Fallback to direct call if executor not available
|
||||||
if 'display_mode' in sig.parameters:
|
if _accepts_display_mode:
|
||||||
result = manager_to_display.display(display_mode=active_mode, force_clear=self.force_change)
|
result = manager_to_display.display(display_mode=active_mode, force_clear=self.force_change)
|
||||||
else:
|
else:
|
||||||
result = manager_to_display.display(force_clear=self.force_change)
|
result = manager_to_display.display(force_clear=self.force_change)
|
||||||
@@ -1820,9 +1937,9 @@ class DisplayController:
|
|||||||
min_duration = base_duration
|
min_duration = base_duration
|
||||||
if dynamic_enabled:
|
if dynamic_enabled:
|
||||||
# Try to get plugin-calculated cycle duration first
|
# Try to get plugin-calculated cycle duration first
|
||||||
logger.info("Attempting to get cycle duration for mode %s", active_mode)
|
logger.debug("Attempting to get cycle duration for mode %s", active_mode)
|
||||||
plugin_cycle_duration = self._plugin_cycle_duration(manager_to_display, active_mode)
|
plugin_cycle_duration = self._plugin_cycle_duration(manager_to_display, active_mode)
|
||||||
logger.info("Got cycle duration: %s", plugin_cycle_duration)
|
logger.debug("Got cycle duration: %s", plugin_cycle_duration)
|
||||||
|
|
||||||
# Get caps for validation
|
# Get caps for validation
|
||||||
plugin_cap = self._plugin_dynamic_cap(manager_to_display)
|
plugin_cap = self._plugin_dynamic_cap(manager_to_display)
|
||||||
@@ -1962,7 +2079,7 @@ class DisplayController:
|
|||||||
if needs_high_fps:
|
if needs_high_fps:
|
||||||
# Ultra-smooth FPS for scrolling plugins (8ms = 125 FPS)
|
# Ultra-smooth FPS for scrolling plugins (8ms = 125 FPS)
|
||||||
display_interval = 0.008
|
display_interval = 0.008
|
||||||
logger.info(
|
logger.debug(
|
||||||
"Entering high-FPS loop for %s with display_interval=%.3fs (%.1f FPS)",
|
"Entering high-FPS loop for %s with display_interval=%.3fs (%.1f FPS)",
|
||||||
active_mode,
|
active_mode,
|
||||||
display_interval,
|
display_interval,
|
||||||
@@ -1972,7 +2089,7 @@ class DisplayController:
|
|||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
# Pass display_mode to maintain sticky manager state
|
# Pass display_mode to maintain sticky manager state
|
||||||
if 'display_mode' in sig.parameters:
|
if _accepts_display_mode:
|
||||||
result = manager_to_display.display(display_mode=active_mode, force_clear=False)
|
result = manager_to_display.display(display_mode=active_mode, force_clear=False)
|
||||||
else:
|
else:
|
||||||
result = manager_to_display.display(force_clear=False)
|
result = manager_to_display.display(force_clear=False)
|
||||||
@@ -2014,7 +2131,7 @@ class DisplayController:
|
|||||||
else:
|
else:
|
||||||
# Normal FPS for other plugins (1 second)
|
# Normal FPS for other plugins (1 second)
|
||||||
display_interval = 1.0
|
display_interval = 1.0
|
||||||
logger.info(
|
logger.debug(
|
||||||
"Entering normal FPS loop for %s with display_interval=%.3fs",
|
"Entering normal FPS loop for %s with display_interval=%.3fs",
|
||||||
active_mode,
|
active_mode,
|
||||||
display_interval
|
display_interval
|
||||||
@@ -2036,7 +2153,7 @@ class DisplayController:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
# Pass display_mode to maintain sticky manager state
|
# Pass display_mode to maintain sticky manager state
|
||||||
if 'display_mode' in sig.parameters:
|
if _accepts_display_mode:
|
||||||
result = manager_to_display.display(display_mode=active_mode, force_clear=False)
|
result = manager_to_display.display(display_mode=active_mode, force_clear=False)
|
||||||
else:
|
else:
|
||||||
result = manager_to_display.display(force_clear=False)
|
result = manager_to_display.display(force_clear=False)
|
||||||
@@ -2333,6 +2450,30 @@ class DisplayController:
|
|||||||
self.wifi_status_active = False
|
self.wifi_status_active = False
|
||||||
self.wifi_status_expires_at = None
|
self.wifi_status_expires_at = None
|
||||||
|
|
||||||
|
def _refresh_config_cache(self, new_config: Dict[str, Any]) -> None:
|
||||||
|
"""Refresh all config-derived caches when a hot-reload fires.
|
||||||
|
|
||||||
|
Called by the controller-level ConfigService subscriber. Keeps
|
||||||
|
``_normal_brightness``, ``_scroll_speed``, the cached timezone, and the
|
||||||
|
schedule minute-gates consistent with the live config so callers never
|
||||||
|
read stale values after the user saves settings via the web UI.
|
||||||
|
"""
|
||||||
|
self.config = new_config
|
||||||
|
self._normal_brightness = (
|
||||||
|
self.config.get('display', {}).get('hardware', {}).get('brightness', 90)
|
||||||
|
)
|
||||||
|
self._scroll_speed = (
|
||||||
|
self.config.get('display', {}).get('vegas_scroll', {}).get('scroll_speed', 75)
|
||||||
|
)
|
||||||
|
# Force the timezone to be re-derived from the new config on next schedule check
|
||||||
|
self._tz = None
|
||||||
|
# Invalidate minute-gates so the new schedule/dim times take effect immediately
|
||||||
|
self._schedule_checked_minute = None
|
||||||
|
self._dim_checked_minute = None
|
||||||
|
self._cached_target_brightness = self._normal_brightness
|
||||||
|
logger.debug("Config cache refreshed (brightness=%s, scroll_speed=%s)",
|
||||||
|
self._normal_brightness, self._scroll_speed)
|
||||||
|
|
||||||
def cleanup(self):
|
def cleanup(self):
|
||||||
"""Clean up resources."""
|
"""Clean up resources."""
|
||||||
# Shutdown config service if it exists
|
# Shutdown config service if it exists
|
||||||
@@ -2347,6 +2488,7 @@ class DisplayController:
|
|||||||
logger.info("Cleanup complete.")
|
logger.info("Cleanup complete.")
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
"""Application entry point — create a DisplayController and run until interrupted."""
|
||||||
controller = DisplayController()
|
controller = DisplayController()
|
||||||
controller.run()
|
controller.run()
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,28 @@
|
|||||||
|
"""
|
||||||
|
Display Manager — hardware abstraction layer for the RGB LED matrix.
|
||||||
|
|
||||||
|
This module provides :class:`DisplayManager`, the single interface between
|
||||||
|
application code and the physical (or emulated) LED panel.
|
||||||
|
|
||||||
|
Key responsibilities
|
||||||
|
--------------------
|
||||||
|
* Initialise the ``RGBMatrix`` (hardware) or ``RGBMatrixEmulator`` depending
|
||||||
|
on the ``EMULATOR`` environment variable.
|
||||||
|
* Expose a PIL ``Image``/``ImageDraw`` canvas that plugins draw into, then
|
||||||
|
flush it to the matrix via double-buffering (:meth:`DisplayManager.update_display`).
|
||||||
|
* Load and cache TTF/BDF fonts; expose ``draw_text`` for consistent text rendering.
|
||||||
|
* Provide ``width`` / ``height`` properties — always use these instead of
|
||||||
|
hard-coding display dimensions.
|
||||||
|
* Write periodic PNG snapshots to ``/tmp/led_matrix_preview.png`` for the
|
||||||
|
web-interface live preview.
|
||||||
|
* Track scrolling state and gate deferred updates so plugins don't race with
|
||||||
|
an in-progress scroll.
|
||||||
|
|
||||||
|
Singleton: only one ``DisplayManager`` instance exists per process. The
|
||||||
|
first call to ``DisplayManager(config)`` creates it; subsequent calls return
|
||||||
|
the same object.
|
||||||
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import tempfile
|
import tempfile
|
||||||
@@ -18,6 +43,24 @@ logger = logging.getLogger(__name__)
|
|||||||
logger.setLevel(logging.INFO) # Set to INFO level
|
logger.setLevel(logging.INFO) # Set to INFO level
|
||||||
|
|
||||||
class DisplayManager:
|
class DisplayManager:
|
||||||
|
"""
|
||||||
|
Singleton hardware abstraction layer for the RGB LED matrix.
|
||||||
|
|
||||||
|
Plugins should never interact with ``RGBMatrix`` directly; they use this
|
||||||
|
class to draw content and call :meth:`update_display` to push frames to
|
||||||
|
the panel.
|
||||||
|
|
||||||
|
Typical plugin usage::
|
||||||
|
|
||||||
|
canvas = Image.new('RGB', (self.display_manager.width,
|
||||||
|
self.display_manager.height), (0, 0, 0))
|
||||||
|
draw = ImageDraw.Draw(canvas)
|
||||||
|
# ... draw content ...
|
||||||
|
self.display_manager.image = canvas
|
||||||
|
self.display_manager.draw = ImageDraw.Draw(self.display_manager.image)
|
||||||
|
self.display_manager.update_display()
|
||||||
|
"""
|
||||||
|
|
||||||
_instance = None
|
_instance = None
|
||||||
_initialized = False
|
_initialized = False
|
||||||
|
|
||||||
@@ -33,6 +76,10 @@ class DisplayManager:
|
|||||||
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)
|
# When True, update_display() and clear() skip hardware writes (used during off-screen content capture)
|
||||||
self._capture_mode_active = False
|
self._capture_mode_active = False
|
||||||
|
# Text-width measurement cache: (text, id(font)) -> pixel_width
|
||||||
|
# Avoids re-measuring the same string+font on every display() call.
|
||||||
|
# Cleared on _load_fonts() so stale entries don't survive a font reload.
|
||||||
|
self._text_width_cache: Dict[tuple, int] = {}
|
||||||
# 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
|
||||||
@@ -437,6 +484,9 @@ class DisplayManager:
|
|||||||
|
|
||||||
def _load_fonts(self):
|
def _load_fonts(self):
|
||||||
"""Load fonts with proper error handling."""
|
"""Load fonts with proper error handling."""
|
||||||
|
# Font objects get new id()s after reload, so the text-width cache would
|
||||||
|
# return stale measurements keyed on the old ids. Clear it here.
|
||||||
|
self._text_width_cache.clear()
|
||||||
try:
|
try:
|
||||||
# Load Press Start 2P font
|
# Load Press Start 2P font
|
||||||
self.regular_font = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 8)
|
self.regular_font = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 8)
|
||||||
@@ -497,22 +547,32 @@ class DisplayManager:
|
|||||||
|
|
||||||
|
|
||||||
def get_text_width(self, text, font):
|
def get_text_width(self, text, font):
|
||||||
"""Get the width of text when rendered with the given font."""
|
"""Get the width of text when rendered with the given font.
|
||||||
|
|
||||||
|
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
|
||||||
|
measurement per unique (text, font) pair.
|
||||||
|
"""
|
||||||
|
cache_key = (text, id(font))
|
||||||
|
cached = self._text_width_cache.get(cache_key)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if isinstance(font, freetype.Face):
|
if isinstance(font, freetype.Face):
|
||||||
# For FreeType faces, calculate width using freetype
|
|
||||||
width = 0
|
width = 0
|
||||||
for char in text:
|
for char in text:
|
||||||
font.load_char(char)
|
font.load_char(char)
|
||||||
width += font.glyph.advance.x >> 6
|
width += font.glyph.advance.x >> 6
|
||||||
return width
|
|
||||||
else:
|
else:
|
||||||
# For PIL fonts, use textbbox
|
|
||||||
bbox = self.draw.textbbox((0, 0), text, font=font)
|
bbox = self.draw.textbbox((0, 0), text, font=font)
|
||||||
return bbox[2] - bbox[0]
|
width = bbox[2] - bbox[0]
|
||||||
except Exception as e:
|
except (AttributeError, TypeError, ValueError, OSError) as e:
|
||||||
logger.error(f"Error getting text width: {e}")
|
logger.error("Error getting text width: %s", e)
|
||||||
return 0 # Return 0 as fallback
|
return 0
|
||||||
|
|
||||||
|
self._text_width_cache[cache_key] = width
|
||||||
|
return width
|
||||||
|
|
||||||
def get_font_height(self, font):
|
def get_font_height(self, font):
|
||||||
"""Get the height of the given font for line spacing purposes."""
|
"""Get the height of the given font for line spacing purposes."""
|
||||||
|
|||||||
@@ -1,3 +1,30 @@
|
|||||||
|
"""
|
||||||
|
Font Manager — TTF/BDF font loading, caching, and dynamic registration.
|
||||||
|
|
||||||
|
:class:`FontManager` serves two purposes:
|
||||||
|
|
||||||
|
1. **System fonts** — loads the configured small/medium/large TTF fonts (and
|
||||||
|
their BDF bitmap equivalents) at startup, caches metrics, and exposes them
|
||||||
|
via ``DisplayManager`` attributes (``small_font``, ``medium_font``, etc.).
|
||||||
|
|
||||||
|
2. **Plugin fonts** — lets plugins register their own fonts at runtime via
|
||||||
|
:meth:`FontManager.register_manager_font` and resolve them later via
|
||||||
|
:meth:`FontManager.resolve_font`. Registered fonts are namespaced by
|
||||||
|
plugin ID so they cannot collide.
|
||||||
|
|
||||||
|
Font sources
|
||||||
|
------------
|
||||||
|
* Local paths relative to the project root.
|
||||||
|
* Remote URLs — downloaded once, cached to disk, and never re-fetched while
|
||||||
|
the cached copy is fresh.
|
||||||
|
|
||||||
|
BDF fallback
|
||||||
|
------------
|
||||||
|
Pixel-accurate LED fonts are stored as ``.bdf`` (Bitmap Distribution Format)
|
||||||
|
files. When PIL cannot measure BDF glyphs natively, ``freetype-py`` is used
|
||||||
|
for accurate width/height calculations.
|
||||||
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
import freetype
|
import freetype
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import threading
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, List, Optional, Any
|
from typing import Dict, List, Optional, Any
|
||||||
import logging
|
import logging
|
||||||
from src.exceptions import PluginError
|
from src.exceptions import PluginError, ConfigError
|
||||||
from src.logging_config import get_logger
|
from src.logging_config import get_logger
|
||||||
from src.plugin_system.plugin_loader import PluginLoader
|
from src.plugin_system.plugin_loader import PluginLoader
|
||||||
from src.plugin_system.plugin_executor import PluginExecutor
|
from src.plugin_system.plugin_executor import PluginExecutor
|
||||||
@@ -82,6 +82,12 @@ class PluginManager:
|
|||||||
self.plugin_modules: Dict[str, Any] = {}
|
self.plugin_modules: Dict[str, Any] = {}
|
||||||
self.plugin_last_update: Dict[str, float] = {}
|
self.plugin_last_update: Dict[str, float] = {}
|
||||||
|
|
||||||
|
# Cached data-fetch intervals per plugin_id.
|
||||||
|
# _get_plugin_update_interval falls back to config_manager.get_config()
|
||||||
|
# (a full dict copy) when the manifest lacks an interval — caching avoids
|
||||||
|
# that copy on every 30-fps tick. Cleared on load/unload.
|
||||||
|
self._update_interval_cache: Dict[str, Optional[float]] = {}
|
||||||
|
|
||||||
# Health tracking (optional, set by display_controller if available)
|
# Health tracking (optional, set by display_controller if available)
|
||||||
self.health_tracker = None
|
self.health_tracker = None
|
||||||
self.resource_monitor = None
|
self.resource_monitor = None
|
||||||
@@ -388,6 +394,8 @@ class PluginManager:
|
|||||||
# Store plugin instance
|
# Store plugin instance
|
||||||
self.plugins[plugin_id] = plugin_instance
|
self.plugins[plugin_id] = plugin_instance
|
||||||
self.plugin_last_update[plugin_id] = 0.0
|
self.plugin_last_update[plugin_id] = 0.0
|
||||||
|
# Invalidate cached interval so next tick re-derives it for this plugin
|
||||||
|
self._update_interval_cache.pop(plugin_id, None)
|
||||||
|
|
||||||
# Update state based on enabled status
|
# Update state based on enabled status
|
||||||
if config.get('enabled', True):
|
if config.get('enabled', True):
|
||||||
@@ -444,8 +452,8 @@ class PluginManager:
|
|||||||
|
|
||||||
# Remove from active plugins
|
# Remove from active plugins
|
||||||
del self.plugins[plugin_id]
|
del self.plugins[plugin_id]
|
||||||
if plugin_id in self.plugin_last_update:
|
self.plugin_last_update.pop(plugin_id, None)
|
||||||
del self.plugin_last_update[plugin_id]
|
self._update_interval_cache.pop(plugin_id, None)
|
||||||
|
|
||||||
# Remove main module from sys.modules if present
|
# Remove main module from sys.modules if present
|
||||||
module_name = f"plugin_{plugin_id.replace('-', '_')}"
|
module_name = f"plugin_{plugin_id.replace('-', '_')}"
|
||||||
@@ -639,41 +647,46 @@ class PluginManager:
|
|||||||
|
|
||||||
def _get_plugin_update_interval(self, plugin_id: str, plugin_instance: Any) -> Optional[float]:
|
def _get_plugin_update_interval(self, plugin_id: str, plugin_instance: Any) -> Optional[float]:
|
||||||
"""
|
"""
|
||||||
Get the update interval for a plugin.
|
Get the data-fetch interval for a plugin (seconds between update() calls).
|
||||||
|
|
||||||
Args:
|
Result is cached per plugin_id after the first lookup to avoid calling
|
||||||
plugin_id: Plugin identifier
|
config_manager.get_config() — which returns a full dict copy — on every
|
||||||
plugin_instance: Plugin instance
|
tick of the 30-fps display loop. The cache is invalidated when a plugin
|
||||||
|
is loaded or unloaded.
|
||||||
Returns:
|
|
||||||
Update interval in seconds or None if not configured
|
|
||||||
"""
|
"""
|
||||||
# Check manifest first
|
if plugin_id in self._update_interval_cache:
|
||||||
manifest = self.plugin_manifests.get(plugin_id, {})
|
return self._update_interval_cache[plugin_id]
|
||||||
update_interval = manifest.get('update_interval')
|
|
||||||
|
|
||||||
if update_interval:
|
interval: Optional[float] = None
|
||||||
|
|
||||||
|
# 1. Manifest (immutable after load — preferred source)
|
||||||
|
manifest = self.plugin_manifests.get(plugin_id, {})
|
||||||
|
raw = manifest.get('update_interval')
|
||||||
|
if raw is not None:
|
||||||
try:
|
try:
|
||||||
return float(update_interval)
|
interval = float(raw)
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Check plugin config
|
# 2. Plugin config (mutable; only read once and then cached)
|
||||||
if self.config_manager:
|
if interval is None and self.config_manager:
|
||||||
try:
|
try:
|
||||||
config = self.config_manager.get_config()
|
config = self.config_manager.get_config()
|
||||||
plugin_config = config.get(plugin_id, {})
|
raw = config.get(plugin_id, {}).get('update_interval')
|
||||||
update_interval = plugin_config.get('update_interval')
|
if raw is not None:
|
||||||
if update_interval:
|
|
||||||
try:
|
try:
|
||||||
return float(update_interval)
|
interval = float(raw)
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
pass
|
pass
|
||||||
except Exception as e:
|
except (ConfigError, OSError, ValueError, TypeError) as e:
|
||||||
self.logger.debug("Could not get update interval from config: %s", e)
|
self.logger.debug("Could not get update interval from config: %s", e)
|
||||||
|
|
||||||
# Default: 60 seconds
|
# 3. Default
|
||||||
return 60.0
|
if interval is None:
|
||||||
|
interval = 60.0
|
||||||
|
|
||||||
|
self._update_interval_cache[plugin_id] = interval
|
||||||
|
return interval
|
||||||
|
|
||||||
def _record_update_failure(
|
def _record_update_failure(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -49,9 +49,10 @@ class TestBasketballScoreboardPlugin(PluginTestBase):
|
|||||||
"""Test that plugin has display modes."""
|
"""Test that plugin has display modes."""
|
||||||
manifest = self.load_plugin_manifest(plugin_id)
|
manifest = self.load_plugin_manifest(plugin_id)
|
||||||
assert 'display_modes' in manifest
|
assert 'display_modes' in manifest
|
||||||
assert 'basketball_live' in manifest['display_modes']
|
# Manifest uses league-prefixed modes (nba_, wnba_, ncaam_, ncaaw_)
|
||||||
assert 'basketball_recent' in manifest['display_modes']
|
assert 'nba_live' in manifest['display_modes']
|
||||||
assert 'basketball_upcoming' in manifest['display_modes']
|
assert 'nba_recent' in manifest['display_modes']
|
||||||
|
assert 'nba_upcoming' in manifest['display_modes']
|
||||||
|
|
||||||
def test_plugin_has_get_display_modes(self, plugin_id):
|
def test_plugin_has_get_display_modes(self, plugin_id):
|
||||||
"""Test that plugin can return display modes."""
|
"""Test that plugin can return display modes."""
|
||||||
|
|||||||
@@ -167,6 +167,53 @@ class TestDisplayControllerLivePriority:
|
|||||||
assert controller.current_display_mode == "test_plugin_live"
|
assert controller.current_display_mode == "test_plugin_live"
|
||||||
assert controller.force_change is True
|
assert controller.force_change is True
|
||||||
|
|
||||||
|
def test_live_priority_resume_continues_rotation(self, test_display_controller):
|
||||||
|
"""Regression: when live priority ends, rotation resumes where it was
|
||||||
|
interrupted, not after the live plugin's mode.
|
||||||
|
|
||||||
|
Without the fix, _apply_live_priority left current_mode_index pointing at
|
||||||
|
the live plugin's slot, so the next rotation step skipped every mode
|
||||||
|
between the interrupted position and the live plugin (e.g. elections,
|
||||||
|
which sits just before a flights plugin in the order)."""
|
||||||
|
controller = test_display_controller
|
||||||
|
controller.available_modes = [
|
||||||
|
"weather", "forecast", "almanac", "election_ticker", "flight_live"
|
||||||
|
]
|
||||||
|
# Rotation is about to show the 3rd mode (index 2).
|
||||||
|
controller.current_mode_index = 2
|
||||||
|
controller.current_display_mode = "almanac"
|
||||||
|
controller._live_resume_index = None
|
||||||
|
|
||||||
|
# Live priority (e.g. planes overhead) preempts -> flight_live (index 4).
|
||||||
|
controller._apply_live_priority("flight_live")
|
||||||
|
assert controller.current_display_mode == "flight_live"
|
||||||
|
assert controller.current_mode_index == 4
|
||||||
|
assert controller._live_resume_index == 2 # saved rotation position
|
||||||
|
|
||||||
|
# Re-checks while the hold continues must not move the saved position.
|
||||||
|
controller._apply_live_priority("flight_live")
|
||||||
|
assert controller._live_resume_index == 2
|
||||||
|
|
||||||
|
# Live priority ends -> resume at the saved index (almanac), so the next
|
||||||
|
# rotation step lands on election_ticker (index 3) rather than skipping it.
|
||||||
|
controller._apply_live_priority(None)
|
||||||
|
assert controller.current_mode_index == 2
|
||||||
|
assert controller.current_display_mode == "almanac"
|
||||||
|
assert controller._live_resume_index is None
|
||||||
|
|
||||||
|
def test_live_priority_no_resume_when_idle(self, test_display_controller):
|
||||||
|
"""No saved position + no live content is a no-op (normal rotation)."""
|
||||||
|
controller = test_display_controller
|
||||||
|
controller.available_modes = ["a", "b", "c"]
|
||||||
|
controller.current_mode_index = 1
|
||||||
|
controller.current_display_mode = "b"
|
||||||
|
controller._live_resume_index = None
|
||||||
|
|
||||||
|
controller._apply_live_priority(None)
|
||||||
|
|
||||||
|
assert controller.current_mode_index == 1
|
||||||
|
assert controller.current_display_mode == "b"
|
||||||
|
|
||||||
|
|
||||||
class TestDisplayControllerDynamicDuration:
|
class TestDisplayControllerDynamicDuration:
|
||||||
"""Test dynamic duration handling."""
|
"""Test dynamic duration handling."""
|
||||||
@@ -229,18 +276,20 @@ class TestDisplayControllerSchedule:
|
|||||||
def test_inactive_hours(self, test_display_controller):
|
def test_inactive_hours(self, test_display_controller):
|
||||||
"""Test inactive hours check."""
|
"""Test inactive hours check."""
|
||||||
controller = test_display_controller
|
controller = test_display_controller
|
||||||
|
# Inject schedule directly into self.config (what _check_schedule actually reads)
|
||||||
|
# and reset the minute gate so the cached result from any prior call is cleared.
|
||||||
|
controller.config['schedule'] = {
|
||||||
|
"enabled": True,
|
||||||
|
"start_time": "09:00",
|
||||||
|
"end_time": "17:00",
|
||||||
|
}
|
||||||
|
controller._schedule_checked_minute = None
|
||||||
|
controller._tz = None
|
||||||
|
|
||||||
with patch('src.display_controller.datetime') as mock_datetime:
|
with patch('src.display_controller.datetime') as mock_datetime:
|
||||||
mock_datetime.now.return_value.strftime.return_value.lower.return_value = "monday"
|
mock_datetime.now.return_value.strftime.return_value.lower.return_value = "monday"
|
||||||
mock_datetime.now.return_value.time.return_value = datetime.strptime("20:00", "%H:%M").time()
|
mock_datetime.now.return_value.time.return_value = datetime.strptime("20:00", "%H:%M").time()
|
||||||
mock_datetime.strptime = datetime.strptime
|
mock_datetime.strptime = datetime.strptime
|
||||||
|
|
||||||
schedule_config = {
|
|
||||||
"schedule": {
|
|
||||||
"enabled": True,
|
|
||||||
"start_time": "09:00",
|
|
||||||
"end_time": "17:00"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
with patch.object(controller.config_service, 'get_config', return_value=schedule_config):
|
|
||||||
controller._check_schedule()
|
controller._check_schedule()
|
||||||
assert controller.is_display_active is False
|
assert controller.is_display_active is False
|
||||||
|
|||||||
322
test/test_display_controller_optimizations.py
Normal file
322
test/test_display_controller_optimizations.py
Normal file
@@ -0,0 +1,322 @@
|
|||||||
|
"""
|
||||||
|
Tests for the three display_controller.py optimizations:
|
||||||
|
|
||||||
|
Opt #1 — inspect.signature() caching per plugin_id
|
||||||
|
Opt #2 — pre-cached config values (_normal_brightness, _scroll_speed)
|
||||||
|
Opt #3 — schedule minute-gate (_check_schedule, _check_dim_schedule)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
from unittest.mock import MagicMock, patch, call
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Shared fixture
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def controller(test_display_controller):
|
||||||
|
"""Return a ready DisplayController from the existing suite fixture."""
|
||||||
|
return test_display_controller
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Opt #1 — signature cache
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestSignatureCache:
|
||||||
|
"""inspect.signature() should be called at most once per plugin_id."""
|
||||||
|
|
||||||
|
class _PluginWithMode:
|
||||||
|
"""Real class whose display() accepts display_mode — inspectable by signature."""
|
||||||
|
plugin_id = "mode_plugin"
|
||||||
|
def display(self, display_mode=None, force_clear=False):
|
||||||
|
return True
|
||||||
|
|
||||||
|
class _PluginNoMode:
|
||||||
|
"""Real class whose display() does NOT accept display_mode."""
|
||||||
|
plugin_id = "no_mode_plugin"
|
||||||
|
def display(self, force_clear=False):
|
||||||
|
return True
|
||||||
|
|
||||||
|
def test_cache_starts_empty(self, controller):
|
||||||
|
assert controller._plugin_accepts_display_mode == {}
|
||||||
|
|
||||||
|
def test_signature_computed_and_cached(self, controller):
|
||||||
|
"""After the first cache population, the dict holds a bool and stays unchanged
|
||||||
|
if queried again without explicitly deleting the key."""
|
||||||
|
import inspect as _inspect
|
||||||
|
plugin = self._PluginNoMode()
|
||||||
|
key = "sig_test"
|
||||||
|
if key not in controller._plugin_accepts_display_mode:
|
||||||
|
controller._plugin_accepts_display_mode[key] = (
|
||||||
|
"display_mode" in _inspect.signature(plugin.display).parameters
|
||||||
|
)
|
||||||
|
original = controller._plugin_accepts_display_mode[key]
|
||||||
|
|
||||||
|
# Accessing cache again should not change the value
|
||||||
|
second = controller._plugin_accepts_display_mode[key]
|
||||||
|
assert second == original
|
||||||
|
|
||||||
|
def test_cache_stores_false_for_no_display_mode(self, controller):
|
||||||
|
"""Plugin whose display() doesn't accept display_mode → cached False."""
|
||||||
|
import inspect as _inspect
|
||||||
|
plugin = self._PluginNoMode()
|
||||||
|
controller._plugin_accepts_display_mode["no_mode_plugin"] = (
|
||||||
|
"display_mode" in _inspect.signature(plugin.display).parameters
|
||||||
|
)
|
||||||
|
assert controller._plugin_accepts_display_mode["no_mode_plugin"] is False
|
||||||
|
|
||||||
|
def test_cache_stores_true_for_display_mode(self, controller):
|
||||||
|
"""Plugin whose display() accepts display_mode → cached True."""
|
||||||
|
import inspect as _inspect
|
||||||
|
plugin = self._PluginWithMode()
|
||||||
|
controller._plugin_accepts_display_mode["mode_plugin"] = (
|
||||||
|
"display_mode" in _inspect.signature(plugin.display).parameters
|
||||||
|
)
|
||||||
|
assert controller._plugin_accepts_display_mode["mode_plugin"] is True
|
||||||
|
|
||||||
|
def test_cache_cleared_on_plugin_reload(self, controller):
|
||||||
|
"""Populating plugin_modes for an id that's already cached must clear the entry."""
|
||||||
|
plugin = MagicMock()
|
||||||
|
controller._plugin_accepts_display_mode["reload_plugin"] = False
|
||||||
|
|
||||||
|
# Simulate the plugin_modes population code path (as in __init__)
|
||||||
|
plugin_id = "reload_plugin"
|
||||||
|
controller.plugin_modes["reload_plugin"] = plugin
|
||||||
|
if hasattr(controller, "_plugin_accepts_display_mode"):
|
||||||
|
controller._plugin_accepts_display_mode.pop(plugin_id, None)
|
||||||
|
|
||||||
|
assert "reload_plugin" not in controller._plugin_accepts_display_mode
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Opt #2 — cached config values
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestCachedConfigValues:
|
||||||
|
"""_normal_brightness and _scroll_speed are populated from config at init."""
|
||||||
|
|
||||||
|
def test_normal_brightness_cached(self, controller):
|
||||||
|
"""_normal_brightness must equal what the config says."""
|
||||||
|
expected = (
|
||||||
|
controller.config
|
||||||
|
.get("display", {})
|
||||||
|
.get("hardware", {})
|
||||||
|
.get("brightness", 90)
|
||||||
|
)
|
||||||
|
assert controller._normal_brightness == expected
|
||||||
|
|
||||||
|
def test_scroll_speed_cached(self, controller):
|
||||||
|
"""_scroll_speed must equal what the config says."""
|
||||||
|
expected = (
|
||||||
|
controller.config
|
||||||
|
.get("display", {})
|
||||||
|
.get("vegas_scroll", {})
|
||||||
|
.get("scroll_speed", 75)
|
||||||
|
)
|
||||||
|
assert controller._scroll_speed == expected
|
||||||
|
|
||||||
|
def test_current_brightness_uses_cached_value(self, controller):
|
||||||
|
"""current_brightness is initialised from _normal_brightness."""
|
||||||
|
assert controller.current_brightness == controller._normal_brightness
|
||||||
|
|
||||||
|
def test_cached_target_brightness_init(self, controller):
|
||||||
|
"""_cached_target_brightness starts equal to _normal_brightness."""
|
||||||
|
assert controller._cached_target_brightness == controller._normal_brightness
|
||||||
|
|
||||||
|
def test_normal_brightness_default_is_90(self, controller):
|
||||||
|
"""If config has no brightness key the default is 90."""
|
||||||
|
controller.config = {}
|
||||||
|
controller._normal_brightness = (
|
||||||
|
controller.config.get("display", {})
|
||||||
|
.get("hardware", {})
|
||||||
|
.get("brightness", 90)
|
||||||
|
)
|
||||||
|
assert controller._normal_brightness == 90
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Opt #3 — schedule minute-gate
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestScheduleMinuteGate:
|
||||||
|
"""_check_schedule and _check_dim_schedule skip re-evaluation within the same minute."""
|
||||||
|
|
||||||
|
# ── _check_schedule ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_schedule_checked_minute_starts_none(self, controller):
|
||||||
|
assert controller._schedule_checked_minute is None
|
||||||
|
|
||||||
|
def test_first_call_sets_checked_minute(self, controller):
|
||||||
|
"""After the first real evaluation the minute key is stored."""
|
||||||
|
controller.config["schedule"] = {
|
||||||
|
"enabled": True,
|
||||||
|
"start_time": "00:00",
|
||||||
|
"end_time": "23:59",
|
||||||
|
}
|
||||||
|
controller._schedule_checked_minute = None
|
||||||
|
controller._tz = None
|
||||||
|
|
||||||
|
controller._check_schedule()
|
||||||
|
assert controller._schedule_checked_minute is not None
|
||||||
|
|
||||||
|
def test_second_call_same_minute_does_not_re_evaluate(self, controller):
|
||||||
|
"""A second call with the same (hour, minute) returns without changing state."""
|
||||||
|
controller.config["schedule"] = {
|
||||||
|
"enabled": True,
|
||||||
|
"start_time": "00:00",
|
||||||
|
"end_time": "23:59",
|
||||||
|
}
|
||||||
|
controller._tz = None
|
||||||
|
controller._schedule_checked_minute = None
|
||||||
|
|
||||||
|
# First call — evaluates and marks as active (whole-day window)
|
||||||
|
controller._check_schedule()
|
||||||
|
assert controller.is_display_active is True
|
||||||
|
first_minute_key = controller._schedule_checked_minute
|
||||||
|
|
||||||
|
# Force is_display_active to False so we can tell if it gets re-evaluated
|
||||||
|
controller.is_display_active = False
|
||||||
|
|
||||||
|
# Second call within the same minute — gate fires, is_display_active unchanged
|
||||||
|
controller._schedule_checked_minute = first_minute_key # same minute
|
||||||
|
controller._check_schedule()
|
||||||
|
assert controller.is_display_active is False, (
|
||||||
|
"Second call in same minute should return immediately without re-evaluation"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_new_minute_forces_re_evaluation(self, controller):
|
||||||
|
"""A different (hour, minute) key causes a full re-evaluation."""
|
||||||
|
controller.config["schedule"] = {
|
||||||
|
"enabled": True,
|
||||||
|
"start_time": "00:00",
|
||||||
|
"end_time": "23:59",
|
||||||
|
}
|
||||||
|
controller._tz = None
|
||||||
|
|
||||||
|
# Plant a stale minute key from yesterday
|
||||||
|
controller._schedule_checked_minute = (-1, -1)
|
||||||
|
controller.is_display_active = False # wrong value to be corrected
|
||||||
|
|
||||||
|
controller._check_schedule()
|
||||||
|
assert controller.is_display_active is True, (
|
||||||
|
"A new minute key should trigger re-evaluation and correct is_display_active"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_gate_skipped_when_schedule_disabled(self, controller):
|
||||||
|
"""When schedule.enabled=False the method returns before reaching the gate."""
|
||||||
|
controller.config["schedule"] = {"enabled": False}
|
||||||
|
controller._schedule_checked_minute = None
|
||||||
|
controller._tz = None
|
||||||
|
|
||||||
|
controller._check_schedule()
|
||||||
|
# The early-return path doesn't set the minute key
|
||||||
|
assert controller._schedule_checked_minute is None
|
||||||
|
|
||||||
|
# ── _check_dim_schedule ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_dim_checked_minute_starts_none(self, controller):
|
||||||
|
assert controller._dim_checked_minute is None
|
||||||
|
|
||||||
|
def test_first_dim_call_sets_checked_minute(self, controller):
|
||||||
|
"""First call with dim schedule enabled stores the minute key."""
|
||||||
|
controller.config["dim_schedule"] = {
|
||||||
|
"enabled": True,
|
||||||
|
"start_time": "22:00",
|
||||||
|
"end_time": "06:00",
|
||||||
|
}
|
||||||
|
controller.is_display_active = True
|
||||||
|
controller._dim_checked_minute = None
|
||||||
|
controller._tz = None
|
||||||
|
|
||||||
|
controller._check_dim_schedule()
|
||||||
|
assert controller._dim_checked_minute is not None
|
||||||
|
|
||||||
|
def test_dim_second_call_returns_cached_brightness(self, controller):
|
||||||
|
"""Second call with same minute returns _cached_target_brightness immediately."""
|
||||||
|
controller.config["dim_schedule"] = {
|
||||||
|
"enabled": True,
|
||||||
|
"start_time": "22:00",
|
||||||
|
"end_time": "06:00",
|
||||||
|
}
|
||||||
|
controller.is_display_active = True
|
||||||
|
controller._dim_checked_minute = None
|
||||||
|
controller._tz = None
|
||||||
|
|
||||||
|
# First call stores the result
|
||||||
|
first_result = controller._check_dim_schedule()
|
||||||
|
assert controller._cached_target_brightness == first_result
|
||||||
|
minute_key = controller._dim_checked_minute
|
||||||
|
|
||||||
|
# Corrupt cached value to something recognisable
|
||||||
|
controller._cached_target_brightness = 42
|
||||||
|
|
||||||
|
# Second call in same minute — must return the cached 42
|
||||||
|
controller._dim_checked_minute = minute_key
|
||||||
|
second_result = controller._check_dim_schedule()
|
||||||
|
assert second_result == 42, (
|
||||||
|
"Same-minute call must return cached brightness, not re-compute"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_dim_gate_skipped_when_display_off(self, controller):
|
||||||
|
"""When display is off the method exits before the minute gate."""
|
||||||
|
controller.config["dim_schedule"] = {"enabled": True, "start_time": "22:00", "end_time": "06:00"}
|
||||||
|
controller.is_display_active = False
|
||||||
|
controller._dim_checked_minute = None
|
||||||
|
controller._tz = None
|
||||||
|
|
||||||
|
controller._check_dim_schedule()
|
||||||
|
# Early-exit path does not set the minute key
|
||||||
|
assert controller._dim_checked_minute is None
|
||||||
|
|
||||||
|
def test_dim_cached_target_brightness_updated_after_full_evaluation(self, controller):
|
||||||
|
"""After a full evaluation _cached_target_brightness reflects the result."""
|
||||||
|
controller.config["dim_schedule"] = {
|
||||||
|
"enabled": True,
|
||||||
|
"start_time": "22:00",
|
||||||
|
"end_time": "06:00",
|
||||||
|
}
|
||||||
|
controller.is_display_active = True
|
||||||
|
controller._dim_checked_minute = None # force full re-evaluation
|
||||||
|
controller._tz = None
|
||||||
|
|
||||||
|
result = controller._check_dim_schedule()
|
||||||
|
assert controller._cached_target_brightness == result
|
||||||
|
|
||||||
|
# ── timezone lazy init ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_tz_starts_none(self, controller):
|
||||||
|
assert controller._tz is None
|
||||||
|
|
||||||
|
def test_tz_lazily_initialised_on_first_schedule_check(self, controller):
|
||||||
|
"""_tz is None until _check_schedule or _check_dim_schedule is called."""
|
||||||
|
controller.config["schedule"] = {
|
||||||
|
"enabled": True,
|
||||||
|
"start_time": "00:00",
|
||||||
|
"end_time": "23:59",
|
||||||
|
}
|
||||||
|
controller._tz = None
|
||||||
|
controller._schedule_checked_minute = None
|
||||||
|
|
||||||
|
controller._check_schedule()
|
||||||
|
assert controller._tz is not None
|
||||||
|
|
||||||
|
def test_tz_shared_between_schedule_and_dim(self, controller):
|
||||||
|
"""Both methods use the same cached _tz instance."""
|
||||||
|
controller.config["schedule"] = {"enabled": True, "start_time": "00:00", "end_time": "23:59"}
|
||||||
|
controller.config["dim_schedule"] = {"enabled": True, "start_time": "22:00", "end_time": "06:00"}
|
||||||
|
controller.is_display_active = True
|
||||||
|
controller._tz = None
|
||||||
|
controller._schedule_checked_minute = None
|
||||||
|
controller._dim_checked_minute = None
|
||||||
|
|
||||||
|
controller._check_schedule()
|
||||||
|
tz_after_schedule = controller._tz
|
||||||
|
|
||||||
|
controller._check_dim_schedule()
|
||||||
|
assert controller._tz is tz_after_schedule, (
|
||||||
|
"_check_dim_schedule should reuse the _tz set by _check_schedule"
|
||||||
|
)
|
||||||
@@ -58,19 +58,15 @@ class TestGitInfoCache(unittest.TestCase):
|
|||||||
(self.plugin_path / ".git" / "HEAD").write_text("ref: refs/heads/main\n")
|
(self.plugin_path / ".git" / "HEAD").write_text("ref: refs/heads/main\n")
|
||||||
|
|
||||||
def _fake_subprocess_run(self, *args, **kwargs):
|
def _fake_subprocess_run(self, *args, **kwargs):
|
||||||
# Return different dummy values depending on which git subcommand
|
# _get_local_git_info now reads branch and remote_url directly from
|
||||||
# was invoked so the code paths that parse output all succeed.
|
# .git/HEAD and .git/config (no subprocess) and uses a single
|
||||||
|
# ``git log --format=%H%n%cI`` call that returns SHA on line 1 and
|
||||||
|
# ISO date on line 2. Adjust the fake accordingly.
|
||||||
cmd = args[0]
|
cmd = args[0]
|
||||||
result = MagicMock()
|
result = MagicMock()
|
||||||
result.returncode = 0
|
result.returncode = 0
|
||||||
if "rev-parse" in cmd and "HEAD" in cmd and "--abbrev-ref" not in cmd:
|
if "log" in cmd:
|
||||||
result.stdout = "abcdef1234567890\n"
|
result.stdout = "abcdef1234567890\n2026-04-08T12:00:00+00:00\n"
|
||||||
elif "--abbrev-ref" in cmd:
|
|
||||||
result.stdout = "main\n"
|
|
||||||
elif "config" in cmd:
|
|
||||||
result.stdout = "https://example.com/repo.git\n"
|
|
||||||
elif "log" in cmd:
|
|
||||||
result.stdout = "2026-04-08T12:00:00+00:00\n"
|
|
||||||
else:
|
else:
|
||||||
result.stdout = ""
|
result.stdout = ""
|
||||||
return result
|
return result
|
||||||
@@ -84,7 +80,8 @@ class TestGitInfoCache(unittest.TestCase):
|
|||||||
self.assertIsNotNone(first)
|
self.assertIsNotNone(first)
|
||||||
self.assertEqual(first["short_sha"], "abcdef1")
|
self.assertEqual(first["short_sha"], "abcdef1")
|
||||||
calls_after_first = mock_run.call_count
|
calls_after_first = mock_run.call_count
|
||||||
self.assertEqual(calls_after_first, 4)
|
# Production code now uses a single ``git log`` call.
|
||||||
|
self.assertEqual(calls_after_first, 1)
|
||||||
|
|
||||||
# Second call with unchanged HEAD: zero new subprocess calls.
|
# Second call with unchanged HEAD: zero new subprocess calls.
|
||||||
second = self.sm._get_local_git_info(self.plugin_path)
|
second = self.sm._get_local_git_info(self.plugin_path)
|
||||||
@@ -105,7 +102,8 @@ class TestGitInfoCache(unittest.TestCase):
|
|||||||
os.utime(head, (new_time, new_time))
|
os.utime(head, (new_time, new_time))
|
||||||
|
|
||||||
self.sm._get_local_git_info(self.plugin_path)
|
self.sm._get_local_git_info(self.plugin_path)
|
||||||
self.assertEqual(mock_run.call_count, calls_after_first + 4)
|
# One new ``git log`` call after cache invalidation.
|
||||||
|
self.assertEqual(mock_run.call_count, calls_after_first + 1)
|
||||||
|
|
||||||
def test_no_git_directory_returns_none(self):
|
def test_no_git_directory_returns_none(self):
|
||||||
non_git = self.plugins_dir / "no_git"
|
non_git = self.plugins_dir / "no_git"
|
||||||
@@ -192,14 +190,11 @@ class TestGitInfoCache(unittest.TestCase):
|
|||||||
result = MagicMock()
|
result = MagicMock()
|
||||||
result.returncode = 0
|
result.returncode = 0
|
||||||
cmd = args[0]
|
cmd = args[0]
|
||||||
if "rev-parse" in cmd and "--abbrev-ref" not in cmd:
|
# Production code now uses a single ``git log --format=%H%n%cI``.
|
||||||
result.stdout = branch_file.read_text().strip() + "\n"
|
# Branch and remote_url are read directly from .git/HEAD/.git/config.
|
||||||
elif "--abbrev-ref" in cmd:
|
if "log" in cmd:
|
||||||
result.stdout = "main\n"
|
sha = branch_file.read_text().strip()
|
||||||
elif "config" in cmd:
|
result.stdout = f"{sha}\n2026-04-08T12:00:00+00:00\n"
|
||||||
result.stdout = "https://example.com/repo.git\n"
|
|
||||||
elif "log" in cmd:
|
|
||||||
result.stdout = "2026-04-08T12:00:00+00:00\n"
|
|
||||||
else:
|
else:
|
||||||
result.stdout = ""
|
result.stdout = ""
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -617,7 +617,8 @@ class TestDottedKeyNormalization:
|
|||||||
'leagues': {'eng.1': {'enabled': True, 'favorite_teams': []}},
|
'leagues': {'eng.1': {'enabled': True, 'favorite_teams': []}},
|
||||||
}
|
}
|
||||||
schema_mgr.merge_with_defaults.side_effect = lambda config, defaults: {**defaults, **config}
|
schema_mgr.merge_with_defaults.side_effect = lambda config, defaults: {**defaults, **config}
|
||||||
schema_mgr.validate_config_against_schema.return_value = []
|
# Must be a (bool, list) tuple: the endpoint does is_valid, errors = validate_config_against_schema(...)
|
||||||
|
schema_mgr.validate_config_against_schema.return_value = (True, [])
|
||||||
api_v3.schema_manager = schema_mgr
|
api_v3.schema_manager = schema_mgr
|
||||||
|
|
||||||
request_data = {
|
request_data = {
|
||||||
@@ -679,7 +680,7 @@ class TestDottedKeyNormalization:
|
|||||||
'leagues': {'eng.1': {'favorite_teams': []}},
|
'leagues': {'eng.1': {'favorite_teams': []}},
|
||||||
}
|
}
|
||||||
schema_mgr.merge_with_defaults.side_effect = lambda config, defaults: {**defaults, **config}
|
schema_mgr.merge_with_defaults.side_effect = lambda config, defaults: {**defaults, **config}
|
||||||
schema_mgr.validate_config_against_schema.return_value = []
|
schema_mgr.validate_config_against_schema.return_value = (True, [])
|
||||||
api_v3.schema_manager = schema_mgr
|
api_v3.schema_manager = schema_mgr
|
||||||
|
|
||||||
request_data = {
|
request_data = {
|
||||||
|
|||||||
@@ -224,20 +224,14 @@ class TestStateReconciliation(unittest.TestCase):
|
|||||||
with open(manifest_path, 'w') as f:
|
with open(manifest_path, 'w') as f:
|
||||||
json.dump({"version": "1.0.0", "name": "Plugin 1"}, f)
|
json.dump({"version": "1.0.0", "name": "Plugin 1"}, f)
|
||||||
|
|
||||||
# Mock save_config to track calls
|
|
||||||
saved_configs = []
|
|
||||||
def save_config(config):
|
|
||||||
saved_configs.append(config)
|
|
||||||
|
|
||||||
self.config_manager.save_config = save_config
|
|
||||||
|
|
||||||
# Run reconciliation
|
# Run reconciliation
|
||||||
result = self.reconciler.reconcile_state()
|
result = self.reconciler.reconcile_state()
|
||||||
|
|
||||||
# Verify fix was attempted
|
# config.json is the source of truth for enabled state. The fix syncs
|
||||||
|
# the state manager to match config (config says True → state set True),
|
||||||
|
# rather than overwriting the config with the stale state value.
|
||||||
self.assertEqual(len(result.inconsistencies_fixed), 1)
|
self.assertEqual(len(result.inconsistencies_fixed), 1)
|
||||||
self.assertEqual(len(saved_configs), 1)
|
self.state_manager.set_plugin_enabled.assert_called_once_with("plugin1", True)
|
||||||
self.assertEqual(saved_configs[0]["plugin1"]["enabled"], False)
|
|
||||||
|
|
||||||
def test_multiple_inconsistencies(self):
|
def test_multiple_inconsistencies(self):
|
||||||
"""Test reconciliation with multiple inconsistencies."""
|
"""Test reconciliation with multiple inconsistencies."""
|
||||||
|
|||||||
@@ -391,6 +391,22 @@ def captive_portal_redirect():
|
|||||||
# Redirect to lightweight captive portal setup page (not the full UI)
|
# Redirect to lightweight captive portal setup page (not the full UI)
|
||||||
return redirect(url_for('pages_v3.captive_setup'), code=302)
|
return redirect(url_for('pages_v3.captive_setup'), code=302)
|
||||||
|
|
||||||
|
# Append a content-version query param (file mtime) to every static URL so the
|
||||||
|
# long-lived `immutable` cache (see add_security_headers below) is actually safe:
|
||||||
|
# when a static file changes its URL changes, so browsers refetch it. Without
|
||||||
|
# this, edited JS/CSS were served immutable under an unchanging URL and never
|
||||||
|
# reached clients until a manual cache clear.
|
||||||
|
@app.url_defaults
|
||||||
|
def add_static_version(endpoint, values):
|
||||||
|
if endpoint == 'static' and values.get('filename'):
|
||||||
|
try:
|
||||||
|
file_path = os.path.join(app.static_folder, values['filename'])
|
||||||
|
values['v'] = int(os.path.getmtime(file_path))
|
||||||
|
except OSError:
|
||||||
|
# File missing (e.g. plugin asset not yet installed) — skip versioning.
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
# Add security headers and caching to all responses
|
# Add security headers and caching to all responses
|
||||||
@app.after_request
|
@app.after_request
|
||||||
def add_security_headers(response):
|
def add_security_headers(response):
|
||||||
|
|||||||
@@ -2412,6 +2412,13 @@ def reconcile_plugin_state():
|
|||||||
|
|
||||||
from src.plugin_system.state_reconciliation import StateReconciliation
|
from src.plugin_system.state_reconciliation import StateReconciliation
|
||||||
|
|
||||||
|
# Parse optional `force` flag from request body, guarding against
|
||||||
|
# non-dict bodies (bare string, array, null) that would raise AttributeError.
|
||||||
|
payload = request.get_json(silent=True)
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
payload = {}
|
||||||
|
force = _coerce_to_bool(payload.get('force', False))
|
||||||
|
|
||||||
reconciler = StateReconciliation(
|
reconciler = StateReconciliation(
|
||||||
state_manager=api_v3.plugin_state_manager,
|
state_manager=api_v3.plugin_state_manager,
|
||||||
config_manager=api_v3.config_manager,
|
config_manager=api_v3.config_manager,
|
||||||
@@ -2419,7 +2426,7 @@ def reconcile_plugin_state():
|
|||||||
plugins_dir=Path(api_v3.plugin_manager.plugins_dir)
|
plugins_dir=Path(api_v3.plugin_manager.plugins_dir)
|
||||||
)
|
)
|
||||||
|
|
||||||
result = reconciler.reconcile_state()
|
result = reconciler.reconcile_state(force=force)
|
||||||
|
|
||||||
return success_response(
|
return success_response(
|
||||||
data={
|
data={
|
||||||
@@ -2846,6 +2853,89 @@ def update_plugin():
|
|||||||
status_code=500
|
status_code=500
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _do_transactional_uninstall(plugin_id, preserve_config):
|
||||||
|
"""Execute an uninstall with snapshot-based rollback.
|
||||||
|
|
||||||
|
Order of operations:
|
||||||
|
1. Snapshot main config + secrets (abort on unexpected errors, proceed on expected I/O errors).
|
||||||
|
2. Clean up plugin config (abort with 500 if this raises — avoids orphaned files).
|
||||||
|
3. Unload plugin from runtime if loaded (rollback + 500 if this raises).
|
||||||
|
4. Remove plugin files (rollback + 500 if this returns False or raises).
|
||||||
|
5. Finish (remove state, invalidate caches).
|
||||||
|
|
||||||
|
Rollback restores the config snapshot and, if the plugin had been
|
||||||
|
loaded before unload, calls load_plugin to restore runtime state.
|
||||||
|
|
||||||
|
Returns (True, None) on success or (False, error_message) on failure.
|
||||||
|
"""
|
||||||
|
from src.exceptions import ConfigError
|
||||||
|
|
||||||
|
# --- Step 1: snapshot main + secrets ---
|
||||||
|
main_snapshot = None
|
||||||
|
secrets_snapshot = None
|
||||||
|
try:
|
||||||
|
main_snapshot = api_v3.config_manager.get_raw_file_content('main')
|
||||||
|
except (OSError, ConfigError):
|
||||||
|
pass # Proceed without snapshot; narrow catch preserves TypeError/AttributeError
|
||||||
|
try:
|
||||||
|
secrets_snapshot = api_v3.config_manager.get_raw_file_content('secrets')
|
||||||
|
except (OSError, ConfigError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# --- Step 2: cleanup config first (abort before touching filesystem) ---
|
||||||
|
if not preserve_config:
|
||||||
|
api_v3.config_manager.cleanup_plugin_config(plugin_id, remove_secrets=True)
|
||||||
|
|
||||||
|
# Record whether the plugin was running before we touch anything.
|
||||||
|
was_loaded = (
|
||||||
|
api_v3.plugin_manager is not None
|
||||||
|
and plugin_id in api_v3.plugin_manager.plugins
|
||||||
|
)
|
||||||
|
|
||||||
|
def _rollback(reload_plugin):
|
||||||
|
if main_snapshot is not None:
|
||||||
|
try:
|
||||||
|
api_v3.config_manager.save_raw_file_content('main', main_snapshot)
|
||||||
|
except Exception as restore_err:
|
||||||
|
logger.error("Failed to restore main config snapshot for %s: %s", plugin_id, restore_err)
|
||||||
|
if secrets_snapshot is not None:
|
||||||
|
try:
|
||||||
|
api_v3.config_manager.save_raw_file_content('secrets', secrets_snapshot)
|
||||||
|
except Exception as restore_err:
|
||||||
|
logger.error("Failed to restore secrets snapshot for %s: %s", plugin_id, restore_err)
|
||||||
|
if reload_plugin and api_v3.plugin_manager is not None:
|
||||||
|
try:
|
||||||
|
api_v3.plugin_manager.load_plugin(plugin_id)
|
||||||
|
except Exception as reload_err:
|
||||||
|
logger.error("Failed to reload plugin %s during rollback: %s", plugin_id, reload_err)
|
||||||
|
|
||||||
|
# --- Step 3: unload ---
|
||||||
|
if was_loaded:
|
||||||
|
try:
|
||||||
|
api_v3.plugin_manager.unload_plugin(plugin_id)
|
||||||
|
except Exception as unload_err:
|
||||||
|
_rollback(reload_plugin=False) # unload failed — runtime state unchanged
|
||||||
|
return False, f"Failed to unload plugin {plugin_id}: {unload_err}"
|
||||||
|
|
||||||
|
# --- Step 4: remove files ---
|
||||||
|
try:
|
||||||
|
success = api_v3.plugin_store_manager.uninstall_plugin(plugin_id)
|
||||||
|
except Exception as remove_err:
|
||||||
|
_rollback(reload_plugin=was_loaded)
|
||||||
|
return False, f"Failed to remove plugin {plugin_id}: {remove_err}"
|
||||||
|
|
||||||
|
if not success:
|
||||||
|
_rollback(reload_plugin=was_loaded)
|
||||||
|
return False, f"Failed to uninstall plugin {plugin_id}"
|
||||||
|
|
||||||
|
# --- Step 5: finish ---
|
||||||
|
if api_v3.schema_manager:
|
||||||
|
api_v3.schema_manager.invalidate_cache(plugin_id)
|
||||||
|
if api_v3.plugin_state_manager:
|
||||||
|
api_v3.plugin_state_manager.remove_plugin_state(plugin_id)
|
||||||
|
return True, None
|
||||||
|
|
||||||
|
|
||||||
@api_v3.route('/plugins/uninstall', methods=['POST'])
|
@api_v3.route('/plugins/uninstall', methods=['POST'])
|
||||||
def uninstall_plugin():
|
def uninstall_plugin():
|
||||||
"""Uninstall plugin"""
|
"""Uninstall plugin"""
|
||||||
@@ -2865,19 +2955,13 @@ def uninstall_plugin():
|
|||||||
plugin_id = data['plugin_id']
|
plugin_id = data['plugin_id']
|
||||||
preserve_config = data.get('preserve_config', False)
|
preserve_config = data.get('preserve_config', False)
|
||||||
|
|
||||||
# Use operation queue if available
|
# Both queued and direct paths use the same transactional helper so
|
||||||
|
# snapshot/rollback behaviour is consistent regardless of deployment.
|
||||||
if api_v3.operation_queue:
|
if api_v3.operation_queue:
|
||||||
def uninstall_callback(operation):
|
def uninstall_callback(operation):
|
||||||
"""Callback to execute plugin uninstallation."""
|
"""Callback to execute plugin uninstallation via transactional helper."""
|
||||||
# Unload the plugin first if it's loaded
|
success, error_msg = _do_transactional_uninstall(plugin_id, preserve_config)
|
||||||
if api_v3.plugin_manager and plugin_id in api_v3.plugin_manager.plugins:
|
|
||||||
api_v3.plugin_manager.unload_plugin(plugin_id)
|
|
||||||
|
|
||||||
# Uninstall the plugin
|
|
||||||
success = api_v3.plugin_store_manager.uninstall_plugin(plugin_id)
|
|
||||||
|
|
||||||
if not success:
|
if not success:
|
||||||
error_msg = f'Failed to uninstall plugin {plugin_id}'
|
|
||||||
if api_v3.operation_history:
|
if api_v3.operation_history:
|
||||||
api_v3.operation_history.record_operation(
|
api_v3.operation_history.record_operation(
|
||||||
"uninstall",
|
"uninstall",
|
||||||
@@ -2885,24 +2969,7 @@ def uninstall_plugin():
|
|||||||
status="failed",
|
status="failed",
|
||||||
error=error_msg
|
error=error_msg
|
||||||
)
|
)
|
||||||
raise Exception(error_msg)
|
raise Exception(error_msg or f'Failed to uninstall plugin {plugin_id}')
|
||||||
|
|
||||||
# Invalidate schema cache
|
|
||||||
if api_v3.schema_manager:
|
|
||||||
api_v3.schema_manager.invalidate_cache(plugin_id)
|
|
||||||
|
|
||||||
# Clean up plugin configuration if not preserving
|
|
||||||
if not preserve_config:
|
|
||||||
try:
|
|
||||||
api_v3.config_manager.cleanup_plugin_config(plugin_id, remove_secrets=True)
|
|
||||||
except Exception as cleanup_err:
|
|
||||||
logger.warning("Failed to cleanup config after uninstall: %s", cleanup_err)
|
|
||||||
|
|
||||||
# Remove from state manager
|
|
||||||
if api_v3.plugin_state_manager:
|
|
||||||
api_v3.plugin_state_manager.remove_plugin_state(plugin_id)
|
|
||||||
|
|
||||||
# Record in history
|
|
||||||
if api_v3.operation_history:
|
if api_v3.operation_history:
|
||||||
api_v3.operation_history.record_operation(
|
api_v3.operation_history.record_operation(
|
||||||
"uninstall",
|
"uninstall",
|
||||||
@@ -2910,7 +2977,6 @@ def uninstall_plugin():
|
|||||||
status="success",
|
status="success",
|
||||||
details={"preserve_config": preserve_config}
|
details={"preserve_config": preserve_config}
|
||||||
)
|
)
|
||||||
|
|
||||||
return {'success': True, 'message': 'Plugin uninstalled successfully'}
|
return {'success': True, 'message': 'Plugin uninstalled successfully'}
|
||||||
|
|
||||||
# Enqueue operation
|
# Enqueue operation
|
||||||
@@ -2925,31 +2991,10 @@ def uninstall_plugin():
|
|||||||
message='Plugin uninstallation queued'
|
message='Plugin uninstallation queued'
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# Fallback to direct uninstall
|
# Direct (non-queued) transactional uninstall
|
||||||
# Unload the plugin first if it's loaded
|
success, error_msg = _do_transactional_uninstall(plugin_id, preserve_config)
|
||||||
if api_v3.plugin_manager and plugin_id in api_v3.plugin_manager.plugins:
|
|
||||||
api_v3.plugin_manager.unload_plugin(plugin_id)
|
|
||||||
|
|
||||||
# Uninstall the plugin
|
|
||||||
success = api_v3.plugin_store_manager.uninstall_plugin(plugin_id)
|
|
||||||
|
|
||||||
if success:
|
if success:
|
||||||
# Invalidate schema cache
|
|
||||||
if api_v3.schema_manager:
|
|
||||||
api_v3.schema_manager.invalidate_cache(plugin_id)
|
|
||||||
|
|
||||||
# Clean up plugin configuration if not preserving
|
|
||||||
if not preserve_config:
|
|
||||||
try:
|
|
||||||
api_v3.config_manager.cleanup_plugin_config(plugin_id, remove_secrets=True)
|
|
||||||
except Exception as cleanup_err:
|
|
||||||
logger.warning("Failed to cleanup config after uninstall: %s", cleanup_err)
|
|
||||||
|
|
||||||
# Remove from state manager
|
|
||||||
if api_v3.plugin_state_manager:
|
|
||||||
api_v3.plugin_state_manager.remove_plugin_state(plugin_id)
|
|
||||||
|
|
||||||
# Record in history
|
|
||||||
if api_v3.operation_history:
|
if api_v3.operation_history:
|
||||||
api_v3.operation_history.record_operation(
|
api_v3.operation_history.record_operation(
|
||||||
"uninstall",
|
"uninstall",
|
||||||
@@ -2957,7 +3002,6 @@ def uninstall_plugin():
|
|||||||
status="success",
|
status="success",
|
||||||
details={"preserve_config": preserve_config}
|
details={"preserve_config": preserve_config}
|
||||||
)
|
)
|
||||||
|
|
||||||
return success_response(message='Plugin uninstalled successfully')
|
return success_response(message='Plugin uninstalled successfully')
|
||||||
else:
|
else:
|
||||||
if api_v3.operation_history:
|
if api_v3.operation_history:
|
||||||
@@ -2965,12 +3009,11 @@ def uninstall_plugin():
|
|||||||
"uninstall",
|
"uninstall",
|
||||||
plugin_id=plugin_id,
|
plugin_id=plugin_id,
|
||||||
status="failed",
|
status="failed",
|
||||||
error='Plugin uninstall failed'
|
error=error_msg
|
||||||
)
|
)
|
||||||
|
|
||||||
return error_response(
|
return error_response(
|
||||||
ErrorCode.PLUGIN_UNINSTALL_FAILED,
|
ErrorCode.PLUGIN_UNINSTALL_FAILED,
|
||||||
'Plugin uninstall failed',
|
error_msg or 'Plugin uninstall failed',
|
||||||
status_code=500
|
status_code=500
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -4217,7 +4260,9 @@ def save_plugin_config():
|
|||||||
nested_dict = config_dict.get(prop_key)
|
nested_dict = config_dict.get(prop_key)
|
||||||
|
|
||||||
if isinstance(nested_dict, dict):
|
if isinstance(nested_dict, dict):
|
||||||
fix_array_structures(nested_dict, prop_schema['properties'], nested_prefix)
|
# Pass no prefix: config_dict is already the navigated sub-dict,
|
||||||
|
# so path segments from the parent would mis-navigate it.
|
||||||
|
fix_array_structures(nested_dict, prop_schema['properties'])
|
||||||
|
|
||||||
# Also ensure array fields that are None get converted to empty arrays
|
# Also ensure array fields that are None get converted to empty arrays
|
||||||
def ensure_array_defaults(config_dict, schema_props, prefix=''):
|
def ensure_array_defaults(config_dict, schema_props, prefix=''):
|
||||||
@@ -4277,7 +4322,8 @@ def save_plugin_config():
|
|||||||
nested_dict = config_dict[prop_key]
|
nested_dict = config_dict[prop_key]
|
||||||
|
|
||||||
if isinstance(nested_dict, dict):
|
if isinstance(nested_dict, dict):
|
||||||
ensure_array_defaults(nested_dict, prop_schema['properties'], nested_prefix)
|
# Pass no prefix: config_dict is already navigated.
|
||||||
|
ensure_array_defaults(nested_dict, prop_schema['properties'])
|
||||||
|
|
||||||
if schema and 'properties' in schema:
|
if schema and 'properties' in schema:
|
||||||
# First, fix any dict structures that should be arrays
|
# First, fix any dict structures that should be arrays
|
||||||
@@ -4377,6 +4423,21 @@ def save_plugin_config():
|
|||||||
defaults = schema_mgr.generate_default_config(plugin_id, use_cache=True)
|
defaults = schema_mgr.generate_default_config(plugin_id, use_cache=True)
|
||||||
plugin_config = schema_mgr.merge_with_defaults(plugin_config, defaults)
|
plugin_config = schema_mgr.merge_with_defaults(plugin_config, defaults)
|
||||||
|
|
||||||
|
# After merging defaults, replace any None array values with their schema defaults.
|
||||||
|
# merge_with_defaults gives user config higher priority, so a None submitted by
|
||||||
|
# the client can survive the merge — this pass cleans those up.
|
||||||
|
def _fix_none_arrays(cfg, props):
|
||||||
|
for k, pschema in props.items():
|
||||||
|
if pschema.get('type') == 'array':
|
||||||
|
if isinstance(cfg, dict) and (k not in cfg or cfg[k] is None):
|
||||||
|
cfg[k] = pschema.get('default', [])
|
||||||
|
elif pschema.get('type') == 'object' and 'properties' in pschema:
|
||||||
|
if isinstance(cfg, dict) and isinstance(cfg.get(k), dict):
|
||||||
|
_fix_none_arrays(cfg[k], pschema['properties'])
|
||||||
|
|
||||||
|
if schema and 'properties' in schema and isinstance(plugin_config, dict):
|
||||||
|
_fix_none_arrays(plugin_config, schema['properties'])
|
||||||
|
|
||||||
# Ensure enabled state is preserved after defaults merge
|
# Ensure enabled state is preserved after defaults merge
|
||||||
# Defaults should not overwrite an explicitly preserved enabled value
|
# Defaults should not overwrite an explicitly preserved enabled value
|
||||||
if preserved_enabled is not None:
|
if preserved_enabled is not None:
|
||||||
|
|||||||
@@ -440,7 +440,7 @@
|
|||||||
<h3 class="text-base font-semibold text-gray-900">Advanced Properties</h3>
|
<h3 class="text-base font-semibold text-gray-900">Advanced Properties</h3>
|
||||||
<button type="button" onclick="window.closeArrayTableRowEditor()"
|
<button type="button" onclick="window.closeArrayTableRowEditor()"
|
||||||
class="text-gray-400 hover:text-gray-600"><i class="fas fa-times"></i></button>
|
class="text-gray-400 hover:text-gray-600"><i class="fas fa-times"></i></button>
|
||||||
</div>`;
|
</div>`);
|
||||||
|
|
||||||
const body = document.createElement('div');
|
const body = document.createElement('div');
|
||||||
body.className = 'px-5 py-4 space-y-4';
|
body.className = 'px-5 py-4 space-y-4';
|
||||||
@@ -512,7 +512,7 @@
|
|||||||
<button type="button" onclick="window.closeArrayTableRowEditor()"
|
<button type="button" onclick="window.closeArrayTableRowEditor()"
|
||||||
class="px-4 py-2 text-sm text-gray-700 border border-gray-300 rounded-md hover:bg-gray-100">Cancel</button>
|
class="px-4 py-2 text-sm text-gray-700 border border-gray-300 rounded-md hover:bg-gray-100">Cancel</button>
|
||||||
<button type="button" id="array-row-editor-save"
|
<button type="button" id="array-row-editor-save"
|
||||||
class="px-4 py-2 text-sm bg-blue-600 hover:bg-blue-700 text-white rounded-md">Save</button>`;
|
class="px-4 py-2 text-sm bg-blue-600 hover:bg-blue-700 text-white rounded-md">Save</button>`);
|
||||||
|
|
||||||
// Save handler
|
// Save handler
|
||||||
footer.querySelector('#array-row-editor-save').onclick = function() {
|
footer.querySelector('#array-row-editor-save').onclick = function() {
|
||||||
|
|||||||
@@ -352,15 +352,14 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set data-loaded on tab containers after HTMX settles their content,
|
// Mark tab containers as loaded once their content settles, so switching
|
||||||
// preventing repeated re-fetches on every tab switch.
|
// away and back doesn't re-fetch. Scoped to the "loadtab" trigger (tab
|
||||||
// Scoped to elements with hx-trigger="revealed" (tab containers only) so
|
// containers only) so modals and plugin config panels can still reload.
|
||||||
// modals and plugin config panels that legitimately reload are unaffected.
|
|
||||||
document.body.addEventListener('htmx:afterSettle', function(event) {
|
document.body.addEventListener('htmx:afterSettle', function(event) {
|
||||||
if (event.detail && event.detail.target) {
|
if (event.detail && event.detail.target) {
|
||||||
var target = event.detail.target;
|
var target = event.detail.target;
|
||||||
var trigger = target.getAttribute('hx-trigger') || '';
|
var trigger = target.getAttribute('hx-trigger') || '';
|
||||||
if (trigger.includes('revealed')) {
|
if (trigger.includes('loadtab')) {
|
||||||
target.setAttribute('data-loaded', 'true');
|
target.setAttribute('data-loaded', 'true');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -867,7 +866,7 @@
|
|||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
||||||
|
|
||||||
<!-- Custom v3 styles -->
|
<!-- Custom v3 styles -->
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='v3/app.css') }}?v=20260216b">
|
<link rel="stylesheet" href="{{ url_for('static', filename='v3/app.css') }}">
|
||||||
</head>
|
</head>
|
||||||
<body x-data="app()" class="bg-gray-50 min-h-screen">
|
<body x-data="app()" class="bg-gray-50 min-h-screen">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
@@ -1030,7 +1029,7 @@
|
|||||||
<div id="tab-content" class="space-y-6">
|
<div id="tab-content" class="space-y-6">
|
||||||
<!-- Overview tab -->
|
<!-- Overview tab -->
|
||||||
<div x-show="activeTab === 'overview'" x-transition>
|
<div x-show="activeTab === 'overview'" x-transition>
|
||||||
<div id="overview-content" hx-get="/v3/partials/overview" hx-trigger="revealed" hx-swap="innerHTML" hx-on::htmx:response-error="loadOverviewDirect()">
|
<div id="overview-content" hx-get="/v3/partials/overview" hx-trigger="loadtab" hx-swap="innerHTML" hx-on::htmx:response-error="loadOverviewDirect()">
|
||||||
<div class="animate-pulse">
|
<div class="animate-pulse">
|
||||||
<div class="bg-white rounded-lg shadow p-6">
|
<div class="bg-white rounded-lg shadow p-6">
|
||||||
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
|
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
|
||||||
@@ -1098,7 +1097,7 @@
|
|||||||
|
|
||||||
<!-- General tab -->
|
<!-- General tab -->
|
||||||
<div x-show="activeTab === 'general'" x-transition>
|
<div x-show="activeTab === 'general'" x-transition>
|
||||||
<div id="general-content" hx-get="/v3/partials/general" hx-trigger="revealed" hx-swap="innerHTML">
|
<div id="general-content" hx-get="/v3/partials/general" hx-trigger="loadtab" hx-swap="innerHTML">
|
||||||
<div class="animate-pulse">
|
<div class="animate-pulse">
|
||||||
<div class="bg-white rounded-lg shadow p-6">
|
<div class="bg-white rounded-lg shadow p-6">
|
||||||
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
|
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
|
||||||
@@ -1116,7 +1115,7 @@
|
|||||||
<div x-show="activeTab === 'wifi'" x-transition>
|
<div x-show="activeTab === 'wifi'" x-transition>
|
||||||
<div id="wifi-content"
|
<div id="wifi-content"
|
||||||
hx-get="/v3/partials/wifi"
|
hx-get="/v3/partials/wifi"
|
||||||
hx-trigger="revealed"
|
hx-trigger="loadtab"
|
||||||
hx-swap="innerHTML"
|
hx-swap="innerHTML"
|
||||||
hx-on::htmx:response-error="loadWifiDirect()">
|
hx-on::htmx:response-error="loadWifiDirect()">
|
||||||
<div class="animate-pulse">
|
<div class="animate-pulse">
|
||||||
@@ -1167,7 +1166,7 @@
|
|||||||
|
|
||||||
<!-- Schedule tab -->
|
<!-- Schedule tab -->
|
||||||
<div x-show="activeTab === 'schedule'" x-transition>
|
<div x-show="activeTab === 'schedule'" x-transition>
|
||||||
<div id="schedule-content" hx-get="/v3/partials/schedule" hx-trigger="revealed" hx-swap="innerHTML">
|
<div id="schedule-content" hx-get="/v3/partials/schedule" hx-trigger="loadtab" hx-swap="innerHTML">
|
||||||
<div class="animate-pulse">
|
<div class="animate-pulse">
|
||||||
<div class="bg-white rounded-lg shadow p-6">
|
<div class="bg-white rounded-lg shadow p-6">
|
||||||
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
|
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
|
||||||
@@ -1183,7 +1182,7 @@
|
|||||||
|
|
||||||
<!-- Display tab -->
|
<!-- Display tab -->
|
||||||
<div x-show="activeTab === 'display'" x-transition>
|
<div x-show="activeTab === 'display'" x-transition>
|
||||||
<div id="display-content" hx-get="/v3/partials/display" hx-trigger="revealed" hx-swap="innerHTML">
|
<div id="display-content" hx-get="/v3/partials/display" hx-trigger="loadtab" hx-swap="innerHTML">
|
||||||
<div class="animate-pulse">
|
<div class="animate-pulse">
|
||||||
<div class="bg-white rounded-lg shadow p-6">
|
<div class="bg-white rounded-lg shadow p-6">
|
||||||
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
|
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
|
||||||
@@ -1198,7 +1197,7 @@
|
|||||||
|
|
||||||
<!-- Backup & Restore tab -->
|
<!-- Backup & Restore tab -->
|
||||||
<div x-show="activeTab === 'backup-restore'" x-transition>
|
<div x-show="activeTab === 'backup-restore'" x-transition>
|
||||||
<div id="backup-restore-content" hx-get="/v3/partials/backup-restore" hx-trigger="revealed" hx-swap="innerHTML">
|
<div id="backup-restore-content" hx-get="/v3/partials/backup-restore" hx-trigger="loadtab" hx-swap="innerHTML">
|
||||||
<div class="animate-pulse">
|
<div class="animate-pulse">
|
||||||
<div class="bg-white rounded-lg shadow p-6">
|
<div class="bg-white rounded-lg shadow p-6">
|
||||||
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
|
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
|
||||||
@@ -1210,7 +1209,7 @@
|
|||||||
|
|
||||||
<!-- Config Editor tab -->
|
<!-- Config Editor tab -->
|
||||||
<div x-show="activeTab === 'config-editor'" x-transition>
|
<div x-show="activeTab === 'config-editor'" x-transition>
|
||||||
<div id="config-editor-content" hx-get="/v3/partials/raw-json" hx-trigger="revealed" hx-swap="innerHTML">
|
<div id="config-editor-content" hx-get="/v3/partials/raw-json" hx-trigger="loadtab" hx-swap="innerHTML">
|
||||||
<div class="animate-pulse">
|
<div class="animate-pulse">
|
||||||
<div class="bg-white rounded-lg shadow p-6">
|
<div class="bg-white rounded-lg shadow p-6">
|
||||||
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
|
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
|
||||||
@@ -1225,7 +1224,7 @@
|
|||||||
|
|
||||||
<!-- Plugins tab -->
|
<!-- Plugins tab -->
|
||||||
<div x-show="activeTab === 'plugins'" x-transition>
|
<div x-show="activeTab === 'plugins'" x-transition>
|
||||||
<div id="plugins-content" hx-get="/v3/partials/plugins" hx-trigger="revealed" hx-swap="innerHTML"
|
<div id="plugins-content" hx-get="/v3/partials/plugins" hx-trigger="loadtab" hx-swap="innerHTML"
|
||||||
hx-on::response-error="loadPluginsDirect()">
|
hx-on::response-error="loadPluginsDirect()">
|
||||||
<div class="animate-pulse">
|
<div class="animate-pulse">
|
||||||
<div class="bg-white rounded-lg shadow p-6">
|
<div class="bg-white rounded-lg shadow p-6">
|
||||||
@@ -1242,7 +1241,7 @@
|
|||||||
|
|
||||||
<!-- Fonts tab -->
|
<!-- Fonts tab -->
|
||||||
<div x-show="activeTab === 'fonts'" x-transition>
|
<div x-show="activeTab === 'fonts'" x-transition>
|
||||||
<div id="fonts-content" hx-get="/v3/partials/fonts" hx-trigger="revealed" hx-swap="innerHTML">
|
<div id="fonts-content" hx-get="/v3/partials/fonts" hx-trigger="loadtab" hx-swap="innerHTML">
|
||||||
<div class="animate-pulse">
|
<div class="animate-pulse">
|
||||||
<div class="bg-white rounded-lg shadow p-6">
|
<div class="bg-white rounded-lg shadow p-6">
|
||||||
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
|
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
|
||||||
@@ -1257,7 +1256,7 @@
|
|||||||
|
|
||||||
<!-- Logs tab -->
|
<!-- Logs tab -->
|
||||||
<div x-show="activeTab === 'logs'" x-transition>
|
<div x-show="activeTab === 'logs'" x-transition>
|
||||||
<div id="logs-content" hx-get="/v3/partials/logs" hx-trigger="revealed" hx-swap="innerHTML">
|
<div id="logs-content" hx-get="/v3/partials/logs" hx-trigger="loadtab" hx-swap="innerHTML">
|
||||||
<div class="animate-pulse">
|
<div class="animate-pulse">
|
||||||
<div class="bg-white rounded-lg shadow p-6">
|
<div class="bg-white rounded-lg shadow p-6">
|
||||||
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
|
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
|
||||||
@@ -1269,7 +1268,7 @@
|
|||||||
|
|
||||||
<!-- Cache tab -->
|
<!-- Cache tab -->
|
||||||
<div x-show="activeTab === 'cache'" x-transition>
|
<div x-show="activeTab === 'cache'" x-transition>
|
||||||
<div id="cache-content" hx-get="/v3/partials/cache" hx-trigger="revealed" hx-swap="innerHTML">
|
<div id="cache-content" hx-get="/v3/partials/cache" hx-trigger="loadtab" hx-swap="innerHTML">
|
||||||
<div class="animate-pulse">
|
<div class="animate-pulse">
|
||||||
<div class="bg-white rounded-lg shadow p-6">
|
<div class="bg-white rounded-lg shadow p-6">
|
||||||
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
|
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
|
||||||
@@ -1281,7 +1280,7 @@
|
|||||||
|
|
||||||
<!-- Operation History tab -->
|
<!-- Operation History tab -->
|
||||||
<div x-show="activeTab === 'operation-history'" x-transition>
|
<div x-show="activeTab === 'operation-history'" x-transition>
|
||||||
<div id="operation-history-content" hx-get="/v3/partials/operation-history" hx-trigger="revealed" hx-swap="innerHTML">
|
<div id="operation-history-content" hx-get="/v3/partials/operation-history" hx-trigger="loadtab" hx-swap="innerHTML">
|
||||||
<div class="animate-pulse">
|
<div class="animate-pulse">
|
||||||
<div class="bg-white rounded-lg shadow p-6">
|
<div class="bg-white rounded-lg shadow p-6">
|
||||||
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
|
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
|
||||||
@@ -1861,28 +1860,53 @@
|
|||||||
},
|
},
|
||||||
|
|
||||||
loadTabContent(tab) {
|
loadTabContent(tab) {
|
||||||
// Try to load content for the active tab
|
const contentEl = document.getElementById(tab + '-content');
|
||||||
if (typeof htmx !== 'undefined') {
|
// data-loaded: already fetched. data-loading: a fetch is queued or in
|
||||||
const contentId = tab + '-content';
|
// flight. Both guard against re-entry so a panel loads exactly once, even
|
||||||
const contentEl = document.getElementById(contentId);
|
// if the tab is reopened before an in-progress (or polling) load settles.
|
||||||
if (contentEl && !contentEl.hasAttribute('data-loaded')) {
|
if (!contentEl || contentEl.hasAttribute('data-loaded') || contentEl.hasAttribute('data-loading')) return;
|
||||||
// Trigger HTMX load
|
const url = contentEl.getAttribute('hx-get');
|
||||||
htmx.trigger(contentEl, 'revealed');
|
if (!url) return;
|
||||||
|
|
||||||
|
contentEl.setAttribute('data-loading', 'true');
|
||||||
|
|
||||||
|
// htmx.ajax issues the request and swaps the response into the panel
|
||||||
|
// directly, so it works even before htmx has wired up the element's
|
||||||
|
// hx-trigger listeners. data-loaded is stamped on success so the panel
|
||||||
|
// loads once; the activeTab check drops loads for a tab the user navigated
|
||||||
|
// away from while htmx was still loading (avoids fetching hidden panels).
|
||||||
|
const swap = contentEl.getAttribute('hx-swap') || 'innerHTML';
|
||||||
|
const load = () => {
|
||||||
|
if (this.activeTab !== tab || contentEl.hasAttribute('data-loaded')) {
|
||||||
|
contentEl.removeAttribute('data-loading');
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
} else {
|
return htmx.ajax('GET', url, { target: contentEl, swap: swap })
|
||||||
// HTMX is still loading asynchronously — retry when it signals ready,
|
.then(() => contentEl.setAttribute('data-loaded', 'true'))
|
||||||
// or fall back to direct fetch if it fails to load entirely.
|
.catch(() => {}) // leave unstamped on failure so it can retry
|
||||||
const self = this;
|
.finally(() => contentEl.removeAttribute('data-loading'));
|
||||||
function onReady() { window.removeEventListener('htmx-load-failed', onFailed); self.loadTabContent(tab); }
|
};
|
||||||
function onFailed() {
|
|
||||||
window.removeEventListener('htmx:ready', onReady);
|
if (typeof htmx !== 'undefined') {
|
||||||
|
load();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// htmx is loaded from a CDN and may not be ready yet. Poll until it is,
|
||||||
|
// then load; if it never arrives, fall back to a direct fetch.
|
||||||
|
let tries = 0;
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
if (typeof htmx !== 'undefined') {
|
||||||
|
clearInterval(timer);
|
||||||
|
load();
|
||||||
|
} else if (++tries > 100) { // ~10s
|
||||||
|
clearInterval(timer);
|
||||||
|
contentEl.removeAttribute('data-loading');
|
||||||
if (tab === 'overview' && typeof loadOverviewDirect === 'function') loadOverviewDirect();
|
if (tab === 'overview' && typeof loadOverviewDirect === 'function') loadOverviewDirect();
|
||||||
else if (tab === 'wifi' && typeof loadWifiDirect === 'function') loadWifiDirect();
|
else if (tab === 'wifi' && typeof loadWifiDirect === 'function') loadWifiDirect();
|
||||||
else if (tab === 'plugins' && typeof loadPluginsDirect === 'function') loadPluginsDirect();
|
else if (tab === 'plugins' && typeof loadPluginsDirect === 'function') loadPluginsDirect();
|
||||||
}
|
}
|
||||||
window.addEventListener('htmx:ready', onReady, { once: true });
|
}, 100);
|
||||||
window.addEventListener('htmx-load-failed', onFailed, { once: true });
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async loadInstalledPlugins() {
|
async loadInstalledPlugins() {
|
||||||
|
|||||||
Reference in New Issue
Block a user