mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-03 01:38:06 +00:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd5bc93d87 | ||
|
|
512e38647a | ||
|
|
05e7c43b27 | ||
|
|
2ffc57cf40 | ||
|
|
aab0e9ade0 |
@@ -199,6 +199,10 @@ class DisplayController:
|
|||||||
self.wifi_status_file = WIFI_STATUS_FILE
|
self.wifi_status_file = WIFI_STATUS_FILE
|
||||||
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
|
||||||
|
# _check_wifi_status_message throttle state (checked at frame rate,
|
||||||
|
# stat'd at most once per second)
|
||||||
|
self._wifi_status_check_ts = 0.0
|
||||||
|
self._wifi_status_last_result: Optional[Dict[str, Any]] = None
|
||||||
|
|
||||||
# Plugin display() signature cache — must be initialised before the plugin
|
# Plugin display() signature cache — must be initialised before the plugin
|
||||||
# loading loop below so the .pop() invalidation at load time is always safe.
|
# loading loop below so the .pop() invalidation at load time is always safe.
|
||||||
@@ -1635,7 +1639,7 @@ class DisplayController:
|
|||||||
self._sleep_with_plugin_updates(60)
|
self._sleep_with_plugin_updates(60)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
logger.info(f"Display active, processing mode: {self.current_display_mode}")
|
logger.debug("Display active, processing mode: %s", self.current_display_mode)
|
||||||
|
|
||||||
# Plugins update on their own schedules - no forced sync updates needed
|
# Plugins update on their own schedules - no forced sync updates needed
|
||||||
# Each plugin has its own update_interval and background services
|
# Each plugin has its own update_interval and background services
|
||||||
@@ -1803,7 +1807,7 @@ class DisplayController:
|
|||||||
if self.plugin_manager and hasattr(self.plugin_manager, 'health_tracker') and self.plugin_manager.health_tracker:
|
if self.plugin_manager and hasattr(self.plugin_manager, 'health_tracker') and self.plugin_manager.health_tracker:
|
||||||
should_skip = self.plugin_manager.health_tracker.should_skip_plugin(plugin_id)
|
should_skip = self.plugin_manager.health_tracker.should_skip_plugin(plugin_id)
|
||||||
if should_skip:
|
if should_skip:
|
||||||
logger.info(f"Skipping plugin {plugin_id} due to circuit breaker (mode: {active_mode})")
|
logger.info("Skipping plugin %s due to circuit breaker (mode: %s)", plugin_id, active_mode)
|
||||||
display_result = False
|
display_result = False
|
||||||
# Skip to next mode - let existing logic handle it
|
# Skip to next mode - let existing logic handle it
|
||||||
manager_to_display = None
|
manager_to_display = None
|
||||||
@@ -1861,7 +1865,7 @@ class DisplayController:
|
|||||||
if isinstance(result, bool):
|
if isinstance(result, bool):
|
||||||
display_result = result
|
display_result = result
|
||||||
if not display_result:
|
if not display_result:
|
||||||
logger.info(f"Plugin {plugin_id} display() returned False for mode {active_mode}")
|
logger.info("Plugin %s display() returned False for mode %s", plugin_id, active_mode)
|
||||||
|
|
||||||
# Record success if display completed without exception
|
# Record success if display completed without exception
|
||||||
if self.plugin_manager and hasattr(self.plugin_manager, 'health_tracker') and self.plugin_manager.health_tracker:
|
if self.plugin_manager and hasattr(self.plugin_manager, 'health_tracker') and self.plugin_manager.health_tracker:
|
||||||
@@ -2354,6 +2358,16 @@ class DisplayController:
|
|||||||
Returns None on any error or if message is expired/invalid.
|
Returns None on any error or if message is expired/invalid.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
|
# Throttle the existence stat to ~1 Hz: this runs on every render
|
||||||
|
# iteration (60+ fps), and the file usually doesn't exist — the
|
||||||
|
# status message's lifetime is measured in seconds anyway.
|
||||||
|
# Both attributes are initialised in __init__.
|
||||||
|
now = time.time()
|
||||||
|
if (now - self._wifi_status_check_ts) < 1.0:
|
||||||
|
return self._wifi_status_last_result
|
||||||
|
self._wifi_status_check_ts = now
|
||||||
|
self._wifi_status_last_result = None
|
||||||
|
|
||||||
# Check if file exists
|
# Check if file exists
|
||||||
if not self.wifi_status_file or not self.wifi_status_file.exists():
|
if not self.wifi_status_file or not self.wifi_status_file.exists():
|
||||||
return None
|
return None
|
||||||
@@ -2404,13 +2418,14 @@ class DisplayController:
|
|||||||
pass
|
pass
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Message is valid and not expired
|
# Message is valid and not expired — cache for the throttle window
|
||||||
return {
|
self._wifi_status_last_result = {
|
||||||
'message': message,
|
'message': message,
|
||||||
'timestamp': timestamp,
|
'timestamp': timestamp,
|
||||||
'duration': duration,
|
'duration': duration,
|
||||||
'expires_at': expires_at
|
'expires_at': expires_at
|
||||||
}
|
}
|
||||||
|
return self._wifi_status_last_result
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Catch-all for any unexpected errors - log but don't break the display
|
# Catch-all for any unexpected errors - log but don't break the display
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import json
|
|||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
import threading
|
import threading
|
||||||
|
import types
|
||||||
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
|
||||||
@@ -743,8 +744,18 @@ class PluginManager:
|
|||||||
# If resource monitor exists, wrap the call
|
# If resource monitor exists, wrap the call
|
||||||
def monitored_update():
|
def monitored_update():
|
||||||
self.resource_monitor.monitor_call(plugin_id, plugin_instance.update)
|
self.resource_monitor.monitor_call(plugin_id, plugin_instance.update)
|
||||||
|
# SimpleNamespace stores `update` as an *instance*
|
||||||
|
# attribute, so attribute lookup returns the plain
|
||||||
|
# function object as-is. A dynamically-built class
|
||||||
|
# (`type(..., {'update': monitored_update})`) instead
|
||||||
|
# stores it as a *class* attribute, which the
|
||||||
|
# descriptor protocol turns into a bound method on
|
||||||
|
# access -- silently prepending the instance as an
|
||||||
|
# implicit first argument to a function that takes
|
||||||
|
# none, raising "monitored_update() takes 0
|
||||||
|
# positional arguments but 1 was given" on every call.
|
||||||
success = self.plugin_executor.execute_update(
|
success = self.plugin_executor.execute_update(
|
||||||
type('obj', (object,), {'update': monitored_update})(),
|
types.SimpleNamespace(update=monitored_update),
|
||||||
plugin_id
|
plugin_id
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -454,6 +454,18 @@ class VisualTestDisplayManager:
|
|||||||
"""Check if display is currently scrolling."""
|
"""Check if display is currently scrolling."""
|
||||||
return self._scrolling_state['is_scrolling']
|
return self._scrolling_state['is_scrolling']
|
||||||
|
|
||||||
|
def process_deferred_updates(self):
|
||||||
|
"""Process any deferred updates (no-op for testing).
|
||||||
|
|
||||||
|
Several ticker-style plugins (news, odds-ticker, leaderboard,
|
||||||
|
stock-news, stocks) call this unconditionally between
|
||||||
|
set_scrolling_state() and their scroll-position update, mirroring the
|
||||||
|
real display_manager's deferred-update queue. This double has no such
|
||||||
|
queue, so there is nothing to process — the no-op just lets those
|
||||||
|
plugins render under the harness instead of raising AttributeError.
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Utility methods
|
# Utility methods
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|||||||
@@ -66,6 +66,10 @@ class RenderPipeline:
|
|||||||
else display_manager.height
|
else display_manager.height
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Reusable blank frame for cycle-end pushes (allocated lazily,
|
||||||
|
# re-blacked before each reuse)
|
||||||
|
self._blank_frame = None
|
||||||
|
|
||||||
# ScrollHelper for optimized scrolling
|
# ScrollHelper for optimized scrolling
|
||||||
self.scroll_helper = ScrollHelper(
|
self.scroll_helper = ScrollHelper(
|
||||||
self.display_width,
|
self.display_width,
|
||||||
@@ -234,11 +238,19 @@ class RenderPipeline:
|
|||||||
)
|
)
|
||||||
# Push blank immediately so the hardware never shows any
|
# Push blank immediately so the hardware never shows any
|
||||||
# post-wrap content while the coordinator recomposes the
|
# post-wrap content while the coordinator recomposes the
|
||||||
# next cycle (~100 ms).
|
# next cycle (~100 ms). The blank is allocated once and
|
||||||
|
# reused across cycle wraps (fresh paste each time in case
|
||||||
|
# a consumer drew on the previous one).
|
||||||
try:
|
try:
|
||||||
from PIL import Image as _Image
|
if self._blank_frame is None or self._blank_frame.size != (
|
||||||
blank = _Image.new('RGB', (self.display_width, self.display_height))
|
self.display_width, self.display_height):
|
||||||
self.display_manager.image = blank
|
self._blank_frame = Image.new(
|
||||||
|
'RGB', (self.display_width, self.display_height))
|
||||||
|
else:
|
||||||
|
self._blank_frame.paste(
|
||||||
|
(0, 0, 0),
|
||||||
|
(0, 0, self.display_width, self.display_height))
|
||||||
|
self.display_manager.image = self._blank_frame
|
||||||
self.display_manager.update_display()
|
self.display_manager.update_display()
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Failed to write blank frame to display at cycle end")
|
logger.exception("Failed to write blank frame to display at cycle end")
|
||||||
|
|||||||
@@ -172,6 +172,16 @@ class TestVisualDisplayManager:
|
|||||||
vdm.set_scrolling_state(False)
|
vdm.set_scrolling_state(False)
|
||||||
assert vdm.is_currently_scrolling() is False
|
assert vdm.is_currently_scrolling() is False
|
||||||
|
|
||||||
|
def test_process_deferred_updates_is_noop(self):
|
||||||
|
# Ticker-style plugins (news, odds-ticker, leaderboard, stock-news,
|
||||||
|
# stocks) call this unconditionally alongside set_scrolling_state();
|
||||||
|
# it must exist and be harmless so those plugins render under the
|
||||||
|
# harness instead of raising AttributeError.
|
||||||
|
vdm = VisualTestDisplayManager(width=128, height=32)
|
||||||
|
vdm.set_scrolling_state(True)
|
||||||
|
vdm.process_deferred_updates() # should not raise
|
||||||
|
assert vdm.is_currently_scrolling() is True
|
||||||
|
|
||||||
def test_format_date_with_ordinal(self):
|
def test_format_date_with_ordinal(self):
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
vdm = VisualTestDisplayManager(width=128, height=32)
|
vdm = VisualTestDisplayManager(width=128, height=32)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from unittest.mock import MagicMock, patch
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from src.plugin_system.plugin_manager import PluginManager
|
from src.plugin_system.plugin_manager import PluginManager
|
||||||
from src.plugin_system.plugin_state import PluginState
|
from src.plugin_system.plugin_state import PluginState
|
||||||
|
from src.plugin_system.resource_monitor import PluginResourceMonitor
|
||||||
|
|
||||||
class TestPluginManager:
|
class TestPluginManager:
|
||||||
"""Test PluginManager functionality."""
|
"""Test PluginManager functionality."""
|
||||||
@@ -74,10 +75,58 @@ class TestPluginManager:
|
|||||||
|
|
||||||
# No manifest in pm.plugin_manifests
|
# No manifest in pm.plugin_manifests
|
||||||
result = pm.load_plugin("non_existent_plugin")
|
result = pm.load_plugin("non_existent_plugin")
|
||||||
|
|
||||||
assert result is False
|
assert result is False
|
||||||
assert pm.state_manager.get_state("non_existent_plugin") == PluginState.ERROR
|
assert pm.state_manager.get_state("non_existent_plugin") == PluginState.ERROR
|
||||||
|
|
||||||
|
def test_run_scheduled_updates_calls_update_with_resource_monitor(
|
||||||
|
self, mock_config_manager, mock_display_manager, mock_cache_manager
|
||||||
|
):
|
||||||
|
"""Regression test: run_scheduled_updates() must actually call a
|
||||||
|
plugin's update() when self.resource_monitor is set (as it is in
|
||||||
|
every real deployment -- display_controller.py and web_interface/
|
||||||
|
app.py both assign a real PluginResourceMonitor after construction).
|
||||||
|
|
||||||
|
Previously, the resource_monitor branch wrapped the call in a
|
||||||
|
function stored as a *class* attribute on a dynamically-built type
|
||||||
|
(`type('obj', (object,), {'update': monitored_update})()`), which
|
||||||
|
the descriptor protocol turns into a bound method on access --
|
||||||
|
silently passing the synthetic instance as an implicit first
|
||||||
|
argument to monitored_update(), which takes none. Every plugin's
|
||||||
|
scheduled update failed with "monitored_update() takes 0 positional
|
||||||
|
arguments but 1 was given" and was silently swallowed into a
|
||||||
|
circuit-breaker retry loop that never succeeded, so plugin data
|
||||||
|
(scores, odds, etc.) never refreshed.
|
||||||
|
"""
|
||||||
|
with patch('src.plugin_system.plugin_manager.ensure_directory_permissions'):
|
||||||
|
pm = PluginManager(
|
||||||
|
plugins_dir="plugins",
|
||||||
|
config_manager=mock_config_manager,
|
||||||
|
display_manager=mock_display_manager,
|
||||||
|
cache_manager=mock_cache_manager
|
||||||
|
)
|
||||||
|
|
||||||
|
plugin_instance = MagicMock()
|
||||||
|
plugin_instance.enabled = True
|
||||||
|
plugin_instance.update = MagicMock()
|
||||||
|
|
||||||
|
pm.plugins["test_plugin"] = plugin_instance
|
||||||
|
pm.plugin_manifests["test_plugin"] = {"update_interval": 10}
|
||||||
|
pm.state_manager.set_state("test_plugin", PluginState.ENABLED)
|
||||||
|
# Plain MagicMock, not the mock_cache_manager fixture: this test
|
||||||
|
# is about run_scheduled_updates() actually invoking update()
|
||||||
|
# through the resource-monitor wrapper, not about
|
||||||
|
# PluginResourceMonitor's own cache-backed metrics persistence
|
||||||
|
# (which calls cache_manager.get(..., memory_ttl=...) --
|
||||||
|
# a kwarg the fixture's mock_get() doesn't accept).
|
||||||
|
pm.resource_monitor = PluginResourceMonitor(MagicMock())
|
||||||
|
|
||||||
|
pm.run_scheduled_updates(current_time=time.time())
|
||||||
|
|
||||||
|
plugin_instance.update.assert_called_once()
|
||||||
|
assert "test_plugin" in pm.plugin_last_update
|
||||||
|
assert pm.state_manager.get_state("test_plugin") == PluginState.ENABLED
|
||||||
|
|
||||||
|
|
||||||
class TestPluginLoader:
|
class TestPluginLoader:
|
||||||
"""Test PluginLoader functionality."""
|
"""Test PluginLoader functionality."""
|
||||||
|
|||||||
Reference in New Issue
Block a user