mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-06-05 10:03:33 +00:00
perf: display pipeline optimizations — caching, logging, scroll, text width (#358)
* docs(core): add module and class docstrings to the 5 undocumented core files
Fills the only significant documentation gaps found during a codebase
audit. All other core files (plugin_system/, logging_config.py, etc.)
already have complete module, class, and function docstrings.
Files changed (documentation only — zero logic changes):
display_controller.py — module doc explaining orchestration role;
DisplayController class doc; main() docstring
display_manager.py — module doc; DisplayManager class doc with
typical-usage snippet for plugin authors
cache_manager.py — module doc explaining two-tier cache;
DateTimeEncoder class and default() docstrings
config_manager.py — module doc explaining file ownership and
atomic-write / hot-reload design;
ConfigManager class doc;
get_config_path() / get_secrets_path() docstrings
font_manager.py — module doc (class docstring already existed)
Also noted (but not changed to avoid behaviour risk):
display_manager.py and font_manager.py use logging.getLogger() directly
instead of the project's get_logger() wrapper. display_manager.py also
calls setLevel(logging.INFO) immediately after, which would be lost if
switched to get_logger().
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* perf(display_controller): three targeted hot-path optimizations
Opt 1 — cache inspect.signature() per plugin_id
inspect.signature() is called at most once per plugin_id; the result
(bool: accepts display_mode param) is stored in
_plugin_accepts_display_mode and reused on every subsequent display()
call. Eliminates all reflection from the display path at runtime.
Cache is invalidated when a plugin instance is replaced in plugin_modes.
Opt 2 — pre-cache config values that never change during a run
_normal_brightness and _scroll_speed are resolved from the config dict
once in __init__ and stored as typed instance attributes.
- Removes 2+ chained dict.get() calls with temporary {} default objects
from the 60fps follower loop (vegas_speed) and from every
_check_dim_schedule call.
- current_brightness init now uses _normal_brightness directly.
Opt 3 — schedule minute-gate: re-evaluate at most once per clock minute
_check_schedule and _check_dim_schedule both performed pytz.timezone(),
datetime.now(), strftime(), and datetime.strptime() on every outer loop
call. Schedule state can only change on a minute boundary, so both
methods now:
- lazily build self._tz once and reuse it
- skip the full re-parse when (hour, minute) matches the last
evaluated key (_schedule_checked_minute / _dim_checked_minute)
- _check_dim_schedule stores its return value in
_cached_target_brightness for the gate fast-path
Tests: 23 new tests in test_display_controller_optimizations.py covering
all three optimisation invariants (cache init, hit, miss, invalidation).
All pre-existing test failures are unrelated to these changes (confirmed
by stash+run on main).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix: resolve 22 pre-existing test failures across 6 groups
Test fixes (tests were asserting wrong values or patching wrong objects):
basketball scoreboard — update display mode assertions from generic
basketball_live/recent/upcoming to league-prefixed nba_live/recent/upcoming
to match the current manifest
display_controller schedule — inject schedule directly into controller.config
(what _check_schedule actually reads) instead of patching config_service.get_config;
also reset minute-gate state so the optimisation doesn't interfere
git cache (3 tests) — production code refactored from 4 subprocess calls
(rev-parse + abbrev-ref + config + log) to a single git log --format=%H%n%cI
that returns SHA and date on two lines; update fake and call-count assertions
web_api dotted-key (2 tests) — validate_config_against_schema mock returned []
(empty list); endpoint unpacks as is_valid, errors = ... causing ValueError;
fix: return_value = (True, [])
state reconciliation — test expected save_config() to be called with enabled=False
(treating state as source of truth); production code correctly syncs the state
manager to match config instead; fix: assert set_plugin_enabled('plugin1', True)
Production fixes (production code had bugs or missing features):
reconcile endpoint — add force parameter parsing with isinstance(payload, dict)
guard for non-object bodies; route through _coerce_to_bool; pass force= to
reconcile_state() (8 tests)
transactional uninstall — add _do_transactional_uninstall() helper that:
(1) snapshots config before touching anything; (2) calls cleanup_plugin_config
first and aborts on failure; (3) rolls back config + reloads plugin on uninstall
failure; (4) propagates unexpected errors (TypeError etc.) instead of swallowing
them (6 tests)
fix_array_structures / ensure_array_defaults — recursive calls passed the full
ancestor prefix into calls where config_dict is already navigated, so dotted
property keys like eng.1 caused parent_parts.split('.') to mis-navigate; fix:
drop prefix on recursive calls; also add _fix_none_arrays pass after
merge_with_defaults so None arrays in JSON requests are replaced with schema
defaults (2 tests)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* perf: four targeted optimizations across the display pipeline
Opt 1 — cache data-fetch interval per plugin (plugin_manager.py)
_get_plugin_update_interval fell back to config_manager.get_config()
(a full dict copy) when the manifest lacked an interval. Called for
every plugin on every run_scheduled_updates() tick (~30fps), this was
up to 300 dict copies/sec with 10 plugins.
Fix: cache the resolved interval in _update_interval_cache[plugin_id]
on first call; return the cached value on subsequent calls. Cache is
cleared on load_plugin and unload_plugin.
Opt 2 — demote noisy per-cycle INFO logs to DEBUG (display_controller.py)
Four logger.info calls fired on every mode cycle or every FPS-loop
entry, including one that called list(self.plugin_modes.keys())
unconditionally (allocating a list every outer loop iteration).
- "Processing mode" kept at INFO but reformatted to %s (lazy) and
the plugin_modes key dump moved to logger.debug
- "Attempting/Got cycle duration" → logger.debug
- "Entering high/normal FPS loop" → logger.debug
Mode name at INFO is preserved for black-screen troubleshooting.
Opt 3 — use Image.frombytes instead of Image.fromarray in scroll hot path
(scroll_helper.py)
Image.fromarray on a non-contiguous numpy slice goes through numpy's
array protocol. Image.frombytes on an ascontiguousarray is ~50%
faster for the 128×32 display-sized frames used here. Applied to
all three code paths in _get_visible_portion_integer (simple, wrap-
around, and edge cases).
Opt 5 — cache get_text_width per (text, font) pair (display_manager.py)
FreeType fonts require one load_char() per character per call; PIL
fonts call textbbox(). Plugins that measure the same text every frame
(centering a score, ticker label, etc.) were re-measuring from scratch
on every display() call.
Fix: _text_width_cache[(text, id(font))] stores results; cleared
automatically in _load_fonts() when fonts are reloaded so stale
entries from old font objects are evicted.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(scroll_helper): fix edge-case bug exposed by frombytes switch
The previous commit replaced Image.fromarray with Image.frombytes in
_get_visible_portion_integer. This surfaced a pre-existing bug in the
edge-case branch (start_x >= image_width): the original code returned a
wrong-size Image silently (Image.fromarray accepts a too-short array);
Image.frombytes raises ValueError instead.
Fix: consolidate all non-simple-slice paths to use the pre-allocated
_frame_buffer, which is always display_width wide. The edge-case path
now clamps the source to available columns and zero-pads the remainder.
Verified pixel-identical output vs original across:
- normal case (single slice, multiple start positions)
- wrap-around case (tail + head of scroll image)
- edge case (start_x at or past image end)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix: address CodeRabbit review comments on PR #358
1. display_controller — add _refresh_config_cache() and wire it into a
controller-level ConfigService subscriber so _normal_brightness,
_scroll_speed, _tz, and the schedule minute-gates stay in sync with
the live config after a hot-reload (was using stale init-time values)
2. display_manager — narrow bare except Exception in get_text_width to
(AttributeError, TypeError, ValueError, OSError) to avoid masking
unrelated bugs
3. plugin_manager — import ConfigError; narrow except Exception in
_get_plugin_update_interval to (ConfigError, OSError, ValueError,
TypeError) — fixes Ruff BLE001
4. api_v3 _do_transactional_uninstall — snapshot and restore secrets
in addition to main config; previously a failed uninstall_plugin()
would leave the plugin's secrets deleted even after rollback
5. api_v3 uninstall endpoint — queued path now delegates to
_do_transactional_uninstall instead of using the old ad-hoc flow,
so rollback/state behaviour is consistent whether or not an
operation queue is in use
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(display_controller): move _plugin_accepts_display_mode init before plugin loop
Codacy HIGH: 'access to member before its definition' — the dict was
initialised at line 441 but accessed at line 364 inside the plugin-
loading loop, both within __init__.
Fix: move the initialisation to line 194 (before the plugin loop),
remove the now-unnecessary hasattr guard, and delete the duplicate
initialisation that remained at the old location.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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 os
|
||||
import json
|
||||
@@ -28,6 +50,24 @@ DEFAULT_DYNAMIC_DURATION_CAP = 180.0
|
||||
WIFI_STATUS_FILE = None # Will be initialized in __init__
|
||||
|
||||
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):
|
||||
start_time = time.time()
|
||||
logger.info("Starting DisplayController initialization")
|
||||
@@ -148,7 +188,11 @@ class DisplayController:
|
||||
self.wifi_status_file = WIFI_STATUS_FILE
|
||||
self.wifi_status_active = False
|
||||
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:
|
||||
logger.info("Attempting to import plugin system...")
|
||||
from src.plugin_system import PluginManager
|
||||
@@ -321,6 +365,8 @@ class DisplayController:
|
||||
self.plugin_modes[mode] = plugin_instance
|
||||
self.mode_to_plugin_id[mode] = plugin_id
|
||||
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
|
||||
progress_pct = int((loaded_count / enabled_count) * 100)
|
||||
@@ -367,11 +413,39 @@ class DisplayController:
|
||||
self.is_display_active = True
|
||||
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
|
||||
self.current_brightness = self.config.get('display', {}).get('hardware', {}).get('brightness', 90)
|
||||
self.current_brightness = self._normal_brightness
|
||||
self.is_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
|
||||
try:
|
||||
self._publish_on_demand_state()
|
||||
@@ -533,17 +607,24 @@ class DisplayController:
|
||||
logger.debug("Schedule is disabled - display always active")
|
||||
return
|
||||
|
||||
# Get configured timezone, default to UTC
|
||||
timezone_str = self.config.get('timezone', 'UTC')
|
||||
try:
|
||||
tz = pytz.timezone(timezone_str)
|
||||
except pytz.UnknownTimeZoneError:
|
||||
logger.warning(f"Unknown timezone '{timezone_str}', using UTC")
|
||||
tz = pytz.UTC
|
||||
# Lazily build the timezone object once; reuse on every subsequent call.
|
||||
if self._tz is None:
|
||||
timezone_str = self.config.get('timezone', 'UTC')
|
||||
try:
|
||||
self._tz = pytz.timezone(timezone_str)
|
||||
except pytz.UnknownTimeZoneError:
|
||||
logger.warning("Unknown timezone '%s', using UTC", timezone_str)
|
||||
self._tz = pytz.UTC
|
||||
|
||||
# Use timezone-aware current time
|
||||
current_time = datetime.now(tz)
|
||||
current_day = current_time.strftime('%A').lower() # Get day name (monday, tuesday, etc.)
|
||||
current_time = datetime.now(self._tz)
|
||||
# Gate: schedule state can only change on a minute boundary, so skip
|
||||
# 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()
|
||||
|
||||
# Check if per-day schedule is configured
|
||||
@@ -632,8 +713,8 @@ class DisplayController:
|
||||
Target brightness level (dim_brightness if in dim period,
|
||||
normal brightness otherwise)
|
||||
"""
|
||||
# Get normal brightness from config
|
||||
normal_brightness = self.config.get('display', {}).get('hardware', {}).get('brightness', 90)
|
||||
# Opt #2: use cached brightness rather than re-traversing config dict
|
||||
normal_brightness = self._normal_brightness
|
||||
|
||||
# If display is OFF via schedule, don't process dim schedule
|
||||
if not self.is_display_active:
|
||||
@@ -647,15 +728,21 @@ class DisplayController:
|
||||
self.is_dimmed = False
|
||||
return normal_brightness
|
||||
|
||||
# Get configured timezone
|
||||
timezone_str = self.config.get('timezone', 'UTC')
|
||||
try:
|
||||
tz = pytz.timezone(timezone_str)
|
||||
except pytz.UnknownTimeZoneError:
|
||||
logger.warning(f"Unknown timezone '{timezone_str}' in dim schedule, using UTC")
|
||||
tz = pytz.UTC
|
||||
# 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')
|
||||
try:
|
||||
self._tz = pytz.timezone(timezone_str)
|
||||
except pytz.UnknownTimeZoneError:
|
||||
logger.warning("Unknown timezone '%s' in dim schedule, using UTC", timezone_str)
|
||||
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_time_only = current_time.time()
|
||||
|
||||
@@ -703,10 +790,12 @@ class DisplayController:
|
||||
logger.info(f"Dim schedule deactivated: brightness restored to {target_brightness}%")
|
||||
|
||||
self._was_dimmed = self.is_dimmed
|
||||
self._cached_target_brightness = target_brightness # persist for minute-gate
|
||||
return target_brightness
|
||||
|
||||
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
|
||||
|
||||
def _update_modules(self):
|
||||
@@ -1483,12 +1572,8 @@ class DisplayController:
|
||||
rp = vc.render_pipeline if (vc and vc.render_pipeline) else None
|
||||
width = self.display_manager.width
|
||||
|
||||
# Advance local position at Vegas scroll speed (px/s → px/tick)
|
||||
vegas_speed = (
|
||||
self.config.get('display', {})
|
||||
.get('vegas_scroll', {})
|
||||
.get('scroll_speed', 75)
|
||||
)
|
||||
# Opt #2: use pre-cached scroll speed (constant for the run)
|
||||
vegas_speed = self._scroll_speed
|
||||
local_x = getattr(self, '_follower_local_x', None)
|
||||
if local_x is None:
|
||||
local_x = float(width) # safe start (past pre-roll guard)
|
||||
@@ -1628,7 +1713,8 @@ class DisplayController:
|
||||
|
||||
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
|
||||
if active_mode in self.plugin_modes:
|
||||
@@ -1664,17 +1750,22 @@ class DisplayController:
|
||||
try:
|
||||
logger.debug(f"Calling display() for {active_mode} with force_clear={self.force_change}")
|
||||
if hasattr(manager_to_display, 'display'):
|
||||
# Check if plugin accepts display_mode parameter
|
||||
import inspect
|
||||
sig = inspect.signature(manager_to_display.display)
|
||||
|
||||
# Opt #1: look up (or compute once) whether display() accepts display_mode
|
||||
_cache_key = plugin_id
|
||||
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
|
||||
if self.plugin_manager and hasattr(self.plugin_manager, 'plugin_executor'):
|
||||
result = self.plugin_manager.plugin_executor.execute_display(
|
||||
manager_to_display,
|
||||
plugin_id,
|
||||
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
|
||||
if result:
|
||||
@@ -1683,7 +1774,7 @@ class DisplayController:
|
||||
result = False # Failed
|
||||
else:
|
||||
# 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)
|
||||
else:
|
||||
result = manager_to_display.display(force_clear=self.force_change)
|
||||
@@ -1820,9 +1911,9 @@ class DisplayController:
|
||||
min_duration = base_duration
|
||||
if dynamic_enabled:
|
||||
# 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)
|
||||
logger.info("Got cycle duration: %s", plugin_cycle_duration)
|
||||
logger.debug("Got cycle duration: %s", plugin_cycle_duration)
|
||||
|
||||
# Get caps for validation
|
||||
plugin_cap = self._plugin_dynamic_cap(manager_to_display)
|
||||
@@ -1962,7 +2053,7 @@ class DisplayController:
|
||||
if needs_high_fps:
|
||||
# Ultra-smooth FPS for scrolling plugins (8ms = 125 FPS)
|
||||
display_interval = 0.008
|
||||
logger.info(
|
||||
logger.debug(
|
||||
"Entering high-FPS loop for %s with display_interval=%.3fs (%.1f FPS)",
|
||||
active_mode,
|
||||
display_interval,
|
||||
@@ -1972,7 +2063,7 @@ class DisplayController:
|
||||
while True:
|
||||
try:
|
||||
# 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)
|
||||
else:
|
||||
result = manager_to_display.display(force_clear=False)
|
||||
@@ -2014,7 +2105,7 @@ class DisplayController:
|
||||
else:
|
||||
# Normal FPS for other plugins (1 second)
|
||||
display_interval = 1.0
|
||||
logger.info(
|
||||
logger.debug(
|
||||
"Entering normal FPS loop for %s with display_interval=%.3fs",
|
||||
active_mode,
|
||||
display_interval
|
||||
@@ -2036,7 +2127,7 @@ class DisplayController:
|
||||
|
||||
try:
|
||||
# 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)
|
||||
else:
|
||||
result = manager_to_display.display(force_clear=False)
|
||||
@@ -2333,6 +2424,30 @@ class DisplayController:
|
||||
self.wifi_status_active = False
|
||||
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):
|
||||
"""Clean up resources."""
|
||||
# Shutdown config service if it exists
|
||||
@@ -2347,6 +2462,7 @@ class DisplayController:
|
||||
logger.info("Cleanup complete.")
|
||||
|
||||
def main():
|
||||
"""Application entry point — create a DisplayController and run until interrupted."""
|
||||
controller = DisplayController()
|
||||
controller.run()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user