mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-02 09:18:06 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0495476ef4 | ||
|
|
16a79cb9a8 | ||
|
|
fa3087609d |
@@ -248,6 +248,7 @@ test/
|
|||||||
├── test_config_service.py # Config service tests
|
├── test_config_service.py # Config service tests
|
||||||
├── test_config_validation_edge_cases.py # Config edge cases
|
├── test_config_validation_edge_cases.py # Config edge cases
|
||||||
├── test_font_manager.py # Font manager tests
|
├── test_font_manager.py # Font manager tests
|
||||||
|
├── test_layout_manager.py # Layout manager tests
|
||||||
├── test_text_helper.py # Text helper tests
|
├── test_text_helper.py # Text helper tests
|
||||||
├── test_error_handling.py # Error handling tests
|
├── test_error_handling.py # Error handling tests
|
||||||
├── test_error_aggregator.py # Error aggregation tests
|
├── test_error_aggregator.py # Error aggregation tests
|
||||||
|
|||||||
@@ -8,11 +8,16 @@ numpy>=1.24.0 # For fast array operations in ScrollHelper (compatible with 2.x)
|
|||||||
|
|
||||||
# Timezone handling
|
# Timezone handling
|
||||||
pytz>=2024.2,<2025.0 # Updated for latest timezone data
|
pytz>=2024.2,<2025.0 # Updated for latest timezone data
|
||||||
|
timezonefinder>=6.5.0,<7.0.0 # Updated for better performance and accuracy
|
||||||
|
geopy>=2.4.1,<3.0.0
|
||||||
|
|
||||||
# HTTP requests
|
# HTTP requests
|
||||||
requests>=2.33.0,<3.0.0
|
requests>=2.33.0,<3.0.0
|
||||||
|
|
||||||
# Google API integration
|
# Google API integration
|
||||||
|
google-auth-oauthlib>=1.2.0,<2.0.0
|
||||||
|
google-auth-httplib2>=0.2.0,<1.0.0
|
||||||
|
google-api-python-client>=2.147.0,<3.0.0
|
||||||
|
|
||||||
# Font rendering
|
# Font rendering
|
||||||
freetype-py>=2.5.1,<3.0.0
|
freetype-py>=2.5.1,<3.0.0
|
||||||
@@ -24,8 +29,10 @@ spotipy>=2.25.2,<3.0.0
|
|||||||
Flask>=3.1.3,<4.0.0
|
Flask>=3.1.3,<4.0.0
|
||||||
|
|
||||||
# Text processing
|
# Text processing
|
||||||
|
unidecode>=1.3.8,<2.0.0
|
||||||
|
|
||||||
# Calendar integration
|
# Calendar integration
|
||||||
|
icalevents>=0.1.27,<1.0.0
|
||||||
|
|
||||||
# WebSocket support
|
# WebSocket support
|
||||||
python-socketio>=5.14.0,<6.0.0
|
python-socketio>=5.14.0,<6.0.0
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ os.environ['EMULATOR'] = 'true'
|
|||||||
|
|
||||||
from src.logging_config import get_logger # noqa: E402
|
from src.logging_config import get_logger # noqa: E402
|
||||||
from src.plugin_system.testing.loading import ( # noqa: E402
|
from src.plugin_system.testing.loading import ( # noqa: E402
|
||||||
build_full_config, find_plugin_dir, load_harness_spec, load_manifest,
|
find_plugin_dir, load_config_defaults, load_harness_spec, load_manifest,
|
||||||
)
|
)
|
||||||
from src.plugin_system.testing.harness import ( # noqa: E402
|
from src.plugin_system.testing.harness import ( # noqa: E402
|
||||||
RenderResult, render_plugin_matrix, compare_to_goldens, write_goldens,
|
RenderResult, render_plugin_matrix, compare_to_goldens, write_goldens,
|
||||||
@@ -97,11 +97,12 @@ def check_one(plugin_id: str, search_dirs: List[str], sizes, mock_data: Dict,
|
|||||||
# matrix path does; explicit CLI flags still override the file.
|
# matrix path does; explicit CLI flags still override the file.
|
||||||
spec = load_harness_spec(plugin_dir)
|
spec = load_harness_spec(plugin_dir)
|
||||||
|
|
||||||
# config_schema defaults (real-install behavior, with enabled forced True
|
# config_schema defaults (real-install behavior), then harness.json config,
|
||||||
# so a plugin's own enabled:false default can't accidentally disable
|
# then CLI --config — most specific wins.
|
||||||
# testing), then harness.json config, then CLI --config — most specific
|
full_config = {"enabled": True}
|
||||||
# wins.
|
full_config.update(load_config_defaults(plugin_dir))
|
||||||
full_config = build_full_config(plugin_dir, spec, config)
|
full_config.update(spec.get("config", {}))
|
||||||
|
full_config.update(config)
|
||||||
|
|
||||||
# Precedence: CLI flag > LEDMATRIX_TEST_SIZES env > harness.json > default.
|
# Precedence: CLI flag > LEDMATRIX_TEST_SIZES env > harness.json > default.
|
||||||
effective_sizes = sizes if sizes else resolve_test_sizes(spec.get("sizes"))
|
effective_sizes = sizes if sizes else resolve_test_sizes(spec.get("sizes"))
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ def main():
|
|||||||
failures += not check("draw.textbbox",
|
failures += not check("draw.textbbox",
|
||||||
lambda: draw.textbbox((0, 0), "Test", font=font))
|
lambda: draw.textbbox((0, 0), "Test", font=font))
|
||||||
|
|
||||||
print("\nResampling (used in logo_helper, sports base):")
|
print("\nResampling (used in logo_helper, image_utils, sports base):")
|
||||||
logo = Image.new('RGBA', (200, 200), (255, 128, 0, 200))
|
logo = Image.new('RGBA', (200, 200), (255, 128, 0, 200))
|
||||||
failures += not check("Image.Resampling.LANCZOS exists",
|
failures += not check("Image.Resampling.LANCZOS exists",
|
||||||
lambda: str(Image.Resampling.LANCZOS))
|
lambda: str(Image.Resampling.LANCZOS))
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ os.environ['EMULATOR'] = 'true'
|
|||||||
# Import logger after path setup so src.logging_config is importable
|
# Import logger after path setup so src.logging_config is importable
|
||||||
from src.logging_config import get_logger # noqa: E402
|
from src.logging_config import get_logger # noqa: E402
|
||||||
from src.plugin_system.testing.loading import ( # noqa: E402
|
from src.plugin_system.testing.loading import ( # noqa: E402
|
||||||
build_full_config, find_plugin_dir, load_manifest,
|
find_plugin_dir, load_manifest, load_config_defaults,
|
||||||
)
|
)
|
||||||
logger = get_logger("[Render Plugin]")
|
logger = get_logger("[Render Plugin]")
|
||||||
|
|
||||||
@@ -83,13 +83,16 @@ def main() -> int:
|
|||||||
manifest = load_manifest(Path(plugin_dir))
|
manifest = load_manifest(Path(plugin_dir))
|
||||||
|
|
||||||
# Parse config: start with schema defaults, then apply overrides
|
# Parse config: start with schema defaults, then apply overrides
|
||||||
|
config_defaults = load_config_defaults(Path(plugin_dir))
|
||||||
try:
|
try:
|
||||||
user_config = json.loads(args.config)
|
user_config = json.loads(args.config)
|
||||||
except json.JSONDecodeError as e:
|
except json.JSONDecodeError as e:
|
||||||
logger.error("Invalid JSON config: %s", e)
|
logger.error("Invalid JSON config: %s", e)
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
config = build_full_config(Path(plugin_dir), cli_config=user_config)
|
config = {'enabled': True}
|
||||||
|
config.update(config_defaults)
|
||||||
|
config.update(user_config)
|
||||||
|
|
||||||
# Load mock data if provided
|
# Load mock data if provided
|
||||||
mock_data = {}
|
mock_data = {}
|
||||||
|
|||||||
@@ -78,17 +78,21 @@ class WiFiMonitorDaemon:
|
|||||||
|
|
||||||
while self.running:
|
while self.running:
|
||||||
try:
|
try:
|
||||||
# One combined check that also returns the state it observed —
|
# Get current status before checking
|
||||||
# the previous flow fetched status before AND after the check
|
status = self.wifi_manager.get_wifi_status()
|
||||||
# on top of the check's own internal fetch, each one several
|
ethernet_connected = self.wifi_manager._is_ethernet_connected()
|
||||||
# nmcli subprocess forks, every 30s, forever.
|
|
||||||
(state_changed, updated_status, updated_ethernet,
|
# Check WiFi status and manage AP mode
|
||||||
ap_active) = self.wifi_manager.check_and_manage_ap_mode_with_state()
|
state_changed = self.wifi_manager.check_and_manage_ap_mode()
|
||||||
|
|
||||||
|
# Get updated status after check
|
||||||
|
updated_status = self.wifi_manager.get_wifi_status()
|
||||||
|
updated_ethernet = self.wifi_manager._is_ethernet_connected()
|
||||||
|
|
||||||
current_state = {
|
current_state = {
|
||||||
'connected': updated_status.connected,
|
'connected': updated_status.connected,
|
||||||
'ethernet_connected': updated_ethernet,
|
'ethernet_connected': updated_ethernet,
|
||||||
'ap_active': ap_active,
|
'ap_active': updated_status.ap_mode_active,
|
||||||
'ssid': updated_status.ssid
|
'ssid': updated_status.ssid
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,7 +109,7 @@ class WiFiMonitorDaemon:
|
|||||||
else:
|
else:
|
||||||
logger.debug("Ethernet not connected")
|
logger.debug("Ethernet not connected")
|
||||||
|
|
||||||
if ap_active:
|
if updated_status.ap_mode_active:
|
||||||
logger.info(f"AP mode ACTIVE - SSID: {ap_ssid} (IP: 192.168.4.1)")
|
logger.info(f"AP mode ACTIVE - SSID: {ap_ssid} (IP: 192.168.4.1)")
|
||||||
else:
|
else:
|
||||||
logger.debug("AP mode inactive")
|
logger.debug("AP mode inactive")
|
||||||
@@ -119,16 +123,16 @@ class WiFiMonitorDaemon:
|
|||||||
# Log periodic status (less verbose)
|
# Log periodic status (less verbose)
|
||||||
if updated_status.connected:
|
if updated_status.connected:
|
||||||
logger.debug(f"Status check: WiFi={updated_status.ssid} ({updated_status.signal}%), "
|
logger.debug(f"Status check: WiFi={updated_status.ssid} ({updated_status.signal}%), "
|
||||||
f"Ethernet={updated_ethernet}, AP={ap_active}")
|
f"Ethernet={updated_ethernet}, AP={updated_status.ap_mode_active}")
|
||||||
else:
|
else:
|
||||||
logger.debug(f"Status check: WiFi=disconnected, Ethernet={updated_ethernet}, AP={ap_active}")
|
logger.debug(f"Status check: WiFi=disconnected, Ethernet={updated_ethernet}, AP={updated_status.ap_mode_active}")
|
||||||
|
|
||||||
# Escalating recovery: if nmcli reports connected but actual internet
|
# Escalating recovery: if nmcli reports connected but actual internet
|
||||||
# is unreachable for several consecutive checks, restart NetworkManager.
|
# is unreachable for several consecutive checks, restart NetworkManager.
|
||||||
# This is done HERE (not inside check_and_manage_ap_mode) to keep the
|
# This is done HERE (not inside check_and_manage_ap_mode) to keep the
|
||||||
# AP-enable trigger clean and avoid false-positive AP enables from
|
# AP-enable trigger clean and avoid false-positive AP enables from
|
||||||
# transient packet loss on otherwise working WiFi.
|
# transient packet loss on otherwise working WiFi.
|
||||||
if updated_status.connected and not ap_active:
|
if updated_status.connected and not updated_status.ap_mode_active:
|
||||||
if not self.wifi_manager.check_internet_connectivity():
|
if not self.wifi_manager.check_internet_connectivity():
|
||||||
self._consecutive_internet_failures += 1
|
self._consecutive_internet_failures += 1
|
||||||
logger.warning(
|
logger.warning(
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
"""
|
||||||
|
Background Cache Mixin for Sports Managers
|
||||||
|
|
||||||
|
This mixin provides common caching functionality to eliminate code duplication
|
||||||
|
across all sports managers. It implements the background service cache pattern
|
||||||
|
where Recent/Upcoming managers consume data from the background service cache.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
from typing import Dict, Optional, Any, Callable
|
||||||
|
|
||||||
|
|
||||||
|
class BackgroundCacheMixin:
|
||||||
|
"""
|
||||||
|
Mixin class that provides background service cache functionality to sports managers.
|
||||||
|
|
||||||
|
This mixin eliminates code duplication by providing a common implementation
|
||||||
|
for the background service cache pattern used across all sports managers.
|
||||||
|
|
||||||
|
Note: For non-sports managers (weather, stocks, news, etc.), use
|
||||||
|
GenericCacheMixin instead. See src/generic_cache_mixin.py for details.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _fetch_data_with_background_cache(self,
|
||||||
|
sport_key: str,
|
||||||
|
api_fetch_method: Callable,
|
||||||
|
live_manager_class: type = None) -> Optional[Dict]:
|
||||||
|
"""
|
||||||
|
Common logic for fetching data with background service cache support.
|
||||||
|
|
||||||
|
This method implements the background service cache pattern:
|
||||||
|
1. Live managers always fetch fresh data
|
||||||
|
2. Recent/Upcoming managers try background cache first
|
||||||
|
3. Fallback to direct API call if background data unavailable
|
||||||
|
|
||||||
|
Args:
|
||||||
|
sport_key: Sport identifier (e.g., 'nba', 'nfl', 'ncaa_fb')
|
||||||
|
api_fetch_method: Method to call for direct API fetch
|
||||||
|
live_manager_class: Class to check if this is a live manager
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Cached or fresh data from API
|
||||||
|
"""
|
||||||
|
start_time = time.time()
|
||||||
|
cache_hit = False
|
||||||
|
cache_source = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
# For Live managers, always fetch fresh data
|
||||||
|
if live_manager_class and isinstance(self, live_manager_class):
|
||||||
|
self.logger.info(f"[{sport_key.upper()}] Live manager - fetching fresh data")
|
||||||
|
result = api_fetch_method(use_cache=False)
|
||||||
|
cache_source = "live_fresh"
|
||||||
|
else:
|
||||||
|
# For Recent/Upcoming managers, try background service cache first
|
||||||
|
cache_key = self.cache_manager.generate_sport_cache_key(sport_key)
|
||||||
|
|
||||||
|
# Check if background service has fresh data
|
||||||
|
if self.cache_manager.is_background_data_available(cache_key, sport_key):
|
||||||
|
cached_data = self.cache_manager.get_background_cached_data(cache_key, sport_key)
|
||||||
|
if cached_data:
|
||||||
|
self.logger.info(f"[{sport_key.upper()}] Using background service cache for {cache_key}")
|
||||||
|
result = cached_data
|
||||||
|
cache_hit = True
|
||||||
|
cache_source = "background_cache"
|
||||||
|
else:
|
||||||
|
self.logger.warning(f"[{sport_key.upper()}] Background cache check passed but no data returned for {cache_key}")
|
||||||
|
result = None
|
||||||
|
cache_source = "background_miss"
|
||||||
|
else:
|
||||||
|
self.logger.info(f"[{sport_key.upper()}] Background data not available for {cache_key}")
|
||||||
|
result = None
|
||||||
|
cache_source = "background_unavailable"
|
||||||
|
|
||||||
|
# Fallback to direct API call if background data not available
|
||||||
|
if result is None:
|
||||||
|
self.logger.info(f"[{sport_key.upper()}] Fetching directly from API for {cache_key}")
|
||||||
|
result = api_fetch_method(use_cache=True)
|
||||||
|
cache_source = "api_fallback"
|
||||||
|
|
||||||
|
# Record performance metrics
|
||||||
|
duration = time.time() - start_time
|
||||||
|
self.cache_manager.record_fetch_time(duration)
|
||||||
|
|
||||||
|
# Log performance metrics
|
||||||
|
self._log_fetch_performance(sport_key, duration, cache_hit, cache_source)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
duration = time.time() - start_time
|
||||||
|
self.logger.error(f"[{sport_key.upper()}] Error in background cache fetch after {duration:.2f}s: {e}")
|
||||||
|
self.cache_manager.record_fetch_time(duration)
|
||||||
|
raise
|
||||||
|
|
||||||
|
def _log_fetch_performance(self, sport_key: str, duration: float, cache_hit: bool, cache_source: str):
|
||||||
|
"""
|
||||||
|
Log detailed performance metrics for fetch operations.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
sport_key: Sport identifier
|
||||||
|
duration: Fetch operation duration in seconds
|
||||||
|
cache_hit: Whether this was a cache hit
|
||||||
|
cache_source: Source of the data (background_cache, api_fallback, etc.)
|
||||||
|
"""
|
||||||
|
# Log basic performance info
|
||||||
|
self.logger.info(f"[{sport_key.upper()}] Fetch completed in {duration:.2f}s "
|
||||||
|
f"(cache_hit={cache_hit}, source={cache_source})")
|
||||||
|
|
||||||
|
# Log detailed metrics every 10 operations
|
||||||
|
if hasattr(self, '_fetch_count'):
|
||||||
|
self._fetch_count += 1
|
||||||
|
else:
|
||||||
|
self._fetch_count = 1
|
||||||
|
|
||||||
|
if self._fetch_count % 10 == 0:
|
||||||
|
metrics = self.cache_manager.get_cache_metrics()
|
||||||
|
self.logger.info(f"[{sport_key.upper()}] Cache Performance Summary - "
|
||||||
|
f"Hit Rate: {metrics['cache_hit_rate']:.2%}, "
|
||||||
|
f"Background Hit Rate: {metrics['background_hit_rate']:.2%}, "
|
||||||
|
f"API Calls Saved: {metrics['api_calls_saved']}")
|
||||||
|
|
||||||
|
def get_cache_performance_summary(self) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Get cache performance summary for this manager.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary containing cache performance metrics
|
||||||
|
"""
|
||||||
|
return self.cache_manager.get_cache_metrics()
|
||||||
|
|
||||||
|
def log_cache_performance(self):
|
||||||
|
"""Log current cache performance metrics."""
|
||||||
|
self.cache_manager.log_cache_metrics()
|
||||||
@@ -0,0 +1,328 @@
|
|||||||
|
"""
|
||||||
|
Example: Basketball Plugin using LEDMatrix Common Helpers
|
||||||
|
|
||||||
|
This example shows how to refactor the basketball plugin to use the
|
||||||
|
ledmatrix-common package for cleaner, more maintainable code.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
|
||||||
|
# Import common helpers
|
||||||
|
from src.common import (
|
||||||
|
LogoHelper, TextHelper, APIHelper, DisplayHelper,
|
||||||
|
GameHelper, ConfigHelper
|
||||||
|
)
|
||||||
|
from src.plugin_system.base_plugin import BasePlugin
|
||||||
|
|
||||||
|
|
||||||
|
class BasketballPluginManager(BasePlugin):
|
||||||
|
"""
|
||||||
|
Basketball scoreboard plugin using LEDMatrix Common helpers.
|
||||||
|
|
||||||
|
This version is much cleaner and more maintainable than the original
|
||||||
|
because it delegates common functionality to the shared helpers.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
plugin_id: str,
|
||||||
|
config: Dict[str, Any],
|
||||||
|
display_manager,
|
||||||
|
cache_manager,
|
||||||
|
plugin_manager
|
||||||
|
):
|
||||||
|
"""Initialize the basketball plugin with common helpers."""
|
||||||
|
super().__init__(plugin_id, config, display_manager, cache_manager, plugin_manager)
|
||||||
|
|
||||||
|
# Get display dimensions
|
||||||
|
self.display_width = display_manager.matrix.width
|
||||||
|
self.display_height = display_manager.matrix.height
|
||||||
|
|
||||||
|
# Initialize common helpers
|
||||||
|
self._init_helpers()
|
||||||
|
|
||||||
|
# Load configuration
|
||||||
|
self._load_config()
|
||||||
|
|
||||||
|
# State tracking
|
||||||
|
self.current_games = []
|
||||||
|
self.current_game = None
|
||||||
|
|
||||||
|
# Log initialization
|
||||||
|
enabled_leagues = [k for k, v in self.league_configs.items() if v['enabled']]
|
||||||
|
self.logger.info(f"Basketball plugin initialized with leagues: {enabled_leagues}")
|
||||||
|
|
||||||
|
def _init_helpers(self):
|
||||||
|
"""Initialize all common helpers."""
|
||||||
|
# Logo helper for team logos
|
||||||
|
self.logo_helper = LogoHelper(
|
||||||
|
display_width=self.display_width,
|
||||||
|
display_height=self.display_height,
|
||||||
|
logger=self.logger
|
||||||
|
)
|
||||||
|
|
||||||
|
# Text helper for rendering
|
||||||
|
self.text_helper = TextHelper(logger=self.logger)
|
||||||
|
self.fonts = self.text_helper.load_fonts()
|
||||||
|
|
||||||
|
# API helper for ESPN data
|
||||||
|
self.api_helper = APIHelper(
|
||||||
|
cache_manager=self.cache_manager,
|
||||||
|
logger=self.logger
|
||||||
|
)
|
||||||
|
|
||||||
|
# Display helper for layouts
|
||||||
|
self.display_helper = DisplayHelper(
|
||||||
|
display_width=self.display_width,
|
||||||
|
display_height=self.display_height,
|
||||||
|
logger=self.logger
|
||||||
|
)
|
||||||
|
|
||||||
|
# Game helper for data processing
|
||||||
|
self.game_helper = GameHelper(
|
||||||
|
timezone_str=self.config.get('timezone', 'UTC'),
|
||||||
|
logger=self.logger
|
||||||
|
)
|
||||||
|
|
||||||
|
# Config helper for configuration management
|
||||||
|
self.config_helper = ConfigHelper(logger=self.logger)
|
||||||
|
|
||||||
|
def _load_config(self):
|
||||||
|
"""Load and validate configuration."""
|
||||||
|
# Get basketball-specific config
|
||||||
|
basketball_config = self.config_helper.get_sports_config(self.config, 'basketball')
|
||||||
|
|
||||||
|
# Build league configurations
|
||||||
|
self.league_configs = {
|
||||||
|
'nba': {
|
||||||
|
'enabled': basketball_config.get('nba_enabled', True),
|
||||||
|
'url': 'https://site.api.espn.com/apis/site/v2/sports/basketball/nba/scoreboard',
|
||||||
|
'logo_dir': Path('assets/sports/nba_logos'),
|
||||||
|
'favorite_teams': basketball_config.get('nba_favorite_teams', []),
|
||||||
|
'display_modes': {
|
||||||
|
'nba_live': basketball_config.get('nba_display_modes_live', True),
|
||||||
|
'nba_recent': basketball_config.get('nba_display_modes_recent', True),
|
||||||
|
'nba_upcoming': basketball_config.get('nba_display_modes_upcoming', True),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'wnba': {
|
||||||
|
'enabled': basketball_config.get('wnba_enabled', False),
|
||||||
|
'url': 'https://site.api.espn.com/apis/site/v2/sports/basketball/wnba/scoreboard',
|
||||||
|
'logo_dir': Path('assets/sports/wnba_logos'),
|
||||||
|
'favorite_teams': basketball_config.get('wnba_favorite_teams', []),
|
||||||
|
'display_modes': {
|
||||||
|
'wnba_live': basketball_config.get('wnba_display_modes_live', True),
|
||||||
|
'wnba_recent': basketball_config.get('wnba_display_modes_recent', True),
|
||||||
|
'wnba_upcoming': basketball_config.get('wnba_display_modes_upcoming', True),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'ncaam': {
|
||||||
|
'enabled': basketball_config.get('ncaam_basketball_enabled', False),
|
||||||
|
'url': 'https://site.api.espn.com/apis/site/v2/sports/basketball/mens-college-basketball/scoreboard',
|
||||||
|
'logo_dir': Path('assets/sports/ncaa_logos'),
|
||||||
|
'favorite_teams': basketball_config.get('ncaam_basketball_favorite_teams', []),
|
||||||
|
'display_modes': {
|
||||||
|
'ncaam_basketball_live': basketball_config.get('ncaam_basketball_display_modes_live', True),
|
||||||
|
'ncaam_basketball_recent': basketball_config.get('ncaam_basketball_display_modes_recent', True),
|
||||||
|
'ncaam_basketball_upcoming': basketball_config.get('ncaam_basketball_display_modes_upcoming', True),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'ncaaw': {
|
||||||
|
'enabled': basketball_config.get('ncaaw_basketball_enabled', False),
|
||||||
|
'url': 'https://site.api.espn.com/apis/site/v2/sports/basketball/womens-college-basketball/scoreboard',
|
||||||
|
'logo_dir': Path('assets/sports/ncaa_logos'),
|
||||||
|
'favorite_teams': basketball_config.get('ncaaw_basketball_favorite_teams', []),
|
||||||
|
'display_modes': {
|
||||||
|
'ncaaw_basketball_live': basketball_config.get('ncaaw_basketball_display_modes_live', True),
|
||||||
|
'ncaaw_basketball_recent': basketball_config.get('ncaaw_basketball_display_modes_recent', True),
|
||||||
|
'ncaaw_basketball_upcoming': basketball_config.get('ncaaw_basketball_display_modes_upcoming', True),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def update(self) -> None:
|
||||||
|
"""Update game data for all enabled leagues."""
|
||||||
|
try:
|
||||||
|
all_games = []
|
||||||
|
|
||||||
|
for league_key, league_config in self.league_configs.items():
|
||||||
|
if not league_config['enabled']:
|
||||||
|
continue
|
||||||
|
|
||||||
|
games = self._fetch_league_games(league_key, league_config)
|
||||||
|
for game in games:
|
||||||
|
game['league_key'] = league_key
|
||||||
|
game['league_config'] = league_config
|
||||||
|
all_games.extend(games)
|
||||||
|
|
||||||
|
self.current_games = all_games
|
||||||
|
self.logger.debug(f"Updated basketball data: {len(all_games)} total games")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Error updating basketball data: {e}", exc_info=True)
|
||||||
|
|
||||||
|
def _fetch_league_games(self, league_key: str, league_config: Dict) -> List[Dict]:
|
||||||
|
"""Fetch games for a specific league using API helper."""
|
||||||
|
try:
|
||||||
|
# Use API helper to fetch ESPN data with caching
|
||||||
|
data = self.api_helper.fetch_espn_scoreboard(
|
||||||
|
sport='basketball',
|
||||||
|
league=league_key,
|
||||||
|
cache_key=f"basketball_{league_key}",
|
||||||
|
cache_ttl=300 # 5 minutes cache
|
||||||
|
)
|
||||||
|
|
||||||
|
if not data:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Use game helper to process events
|
||||||
|
events = data.get('events', [])
|
||||||
|
games = self.game_helper.process_games(events, sport='basketball')
|
||||||
|
|
||||||
|
# Add logo paths to games
|
||||||
|
for game in games:
|
||||||
|
logo_dir = league_config['logo_dir']
|
||||||
|
game['home_logo_path'] = logo_dir / f"{game['home_abbr']}.png"
|
||||||
|
game['away_logo_path'] = logo_dir / f"{game['away_abbr']}.png"
|
||||||
|
|
||||||
|
return games
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Error fetching {league_key} games: {e}", exc_info=True)
|
||||||
|
return []
|
||||||
|
|
||||||
|
def display(self, force_clear: bool = False, display_mode: str = None) -> None:
|
||||||
|
"""Display basketball games using display helper."""
|
||||||
|
try:
|
||||||
|
mode = display_mode or self._determine_display_mode()
|
||||||
|
|
||||||
|
if not mode:
|
||||||
|
self._display_no_games()
|
||||||
|
return
|
||||||
|
|
||||||
|
# Filter games for mode
|
||||||
|
filtered_games = self._filter_games_for_mode(mode)
|
||||||
|
|
||||||
|
if not filtered_games:
|
||||||
|
self._display_no_games()
|
||||||
|
return
|
||||||
|
|
||||||
|
# Display first game
|
||||||
|
self.current_game = filtered_games[0]
|
||||||
|
self._draw_scorebug_layout(self.current_game, force_clear)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Error displaying game: {e}", exc_info=True)
|
||||||
|
|
||||||
|
def _determine_display_mode(self) -> Optional[str]:
|
||||||
|
"""Determine display mode based on available games."""
|
||||||
|
# Priority: live > recent > upcoming
|
||||||
|
for game in self.current_games:
|
||||||
|
if game.get('is_live'):
|
||||||
|
return f"{game['league_key']}_live"
|
||||||
|
for game in self.current_games:
|
||||||
|
if game.get('is_final'):
|
||||||
|
return f"{game['league_key']}_recent"
|
||||||
|
for game in self.current_games:
|
||||||
|
if game.get('is_upcoming'):
|
||||||
|
return f"{game['league_key']}_upcoming"
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _filter_games_for_mode(self, mode: str) -> List[Dict]:
|
||||||
|
"""Filter games based on display mode."""
|
||||||
|
filtered = []
|
||||||
|
|
||||||
|
for game in self.current_games:
|
||||||
|
league_config = game.get('league_config', {})
|
||||||
|
display_modes = league_config.get('display_modes', {})
|
||||||
|
|
||||||
|
if mode in display_modes and display_modes[mode]:
|
||||||
|
if 'live' in mode and game.get('is_live'):
|
||||||
|
filtered.append(game)
|
||||||
|
elif 'recent' in mode and game.get('is_final'):
|
||||||
|
filtered.append(game)
|
||||||
|
elif 'upcoming' in mode and game.get('is_upcoming'):
|
||||||
|
filtered.append(game)
|
||||||
|
|
||||||
|
return filtered[:5]
|
||||||
|
|
||||||
|
def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None:
|
||||||
|
"""Draw the basketball scorebug layout using display helper."""
|
||||||
|
try:
|
||||||
|
# Load logos using logo helper
|
||||||
|
home_logo = self.logo_helper.load_logo(
|
||||||
|
game['home_abbr'],
|
||||||
|
game['home_logo_path']
|
||||||
|
)
|
||||||
|
away_logo = self.logo_helper.load_logo(
|
||||||
|
game['away_abbr'],
|
||||||
|
game['away_logo_path']
|
||||||
|
)
|
||||||
|
|
||||||
|
if not home_logo or not away_logo:
|
||||||
|
self.logger.error("Failed to load logos")
|
||||||
|
self._display_error("Logo Error")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Use display helper to create scorebug layout
|
||||||
|
final_img = self.display_helper.draw_scorebug_layout(
|
||||||
|
game_data=game,
|
||||||
|
fonts=self.fonts,
|
||||||
|
home_logo=home_logo,
|
||||||
|
away_logo=away_logo
|
||||||
|
)
|
||||||
|
|
||||||
|
# Display the image
|
||||||
|
self.display_manager.image.paste(final_img, (0, 0))
|
||||||
|
self.display_manager.update_display()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Error drawing scorebug: {e}", exc_info=True)
|
||||||
|
|
||||||
|
def _display_no_games(self) -> None:
|
||||||
|
"""Display 'no games' message using display helper."""
|
||||||
|
try:
|
||||||
|
img = self.display_helper.draw_no_data_message("No Games")
|
||||||
|
self.display_manager.image = img.copy()
|
||||||
|
self.display_manager.update_display()
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Error displaying no games: {e}", exc_info=True)
|
||||||
|
|
||||||
|
def _display_error(self, message: str) -> None:
|
||||||
|
"""Display error message using display helper."""
|
||||||
|
try:
|
||||||
|
img = self.display_helper.draw_error_message(message)
|
||||||
|
self.display_manager.image = img.copy()
|
||||||
|
self.display_manager.update_display()
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Error displaying error message: {e}", exc_info=True)
|
||||||
|
|
||||||
|
def get_display_duration(self) -> float:
|
||||||
|
"""Get display duration."""
|
||||||
|
return self.config.get('display_duration', 15)
|
||||||
|
|
||||||
|
def cleanup(self) -> None:
|
||||||
|
"""Cleanup resources."""
|
||||||
|
self.current_games = []
|
||||||
|
self.logger.info("Basketball plugin cleaned up")
|
||||||
|
|
||||||
|
|
||||||
|
# Example usage and benefits:
|
||||||
|
"""
|
||||||
|
Benefits of using LEDMatrix Common helpers:
|
||||||
|
|
||||||
|
1. **Cleaner Code**: The plugin is much shorter and more readable
|
||||||
|
2. **Reusable Components**: Common functionality is shared across plugins
|
||||||
|
3. **Better Testing**: Each helper can be tested independently
|
||||||
|
4. **Easier Maintenance**: Bug fixes in helpers benefit all plugins
|
||||||
|
5. **Consistent Behavior**: All plugins use the same underlying logic
|
||||||
|
6. **Reduced Dependencies**: Plugins don't need to import LEDMatrix core
|
||||||
|
7. **Better Error Handling**: Centralized error handling in helpers
|
||||||
|
8. **Configuration Management**: Consistent config handling across plugins
|
||||||
|
|
||||||
|
The original basketball plugin was 326 lines. This version is much cleaner
|
||||||
|
and delegates most functionality to the common helpers, making it easier to
|
||||||
|
maintain and extend.
|
||||||
|
"""
|
||||||
+1
-43
@@ -56,13 +56,6 @@ class ConfigManager:
|
|||||||
self.secrets_path: str = secrets_path or "config/config_secrets.json"
|
self.secrets_path: str = secrets_path or "config/config_secrets.json"
|
||||||
self.template_path: str = "config/config.template.json"
|
self.template_path: str = "config/config.template.json"
|
||||||
self.config: Dict[str, Any] = {}
|
self.config: Dict[str, Any] = {}
|
||||||
# (mtime_ns, size) signature of (config, secrets, template) at the
|
|
||||||
# last successful load. load_config() skips the full re-read (3 file
|
|
||||||
# parses + recursive template migration) when nothing changed —
|
|
||||||
# ~30 web request handlers call it, some 2-3x per request. Cross-
|
|
||||||
# process freshness is preserved: another process's save bumps the
|
|
||||||
# mtime, so the next load here re-reads.
|
|
||||||
self._loaded_sig: Optional[tuple] = None
|
|
||||||
self.logger: logging.Logger = get_logger(__name__)
|
self.logger: logging.Logger = get_logger(__name__)
|
||||||
|
|
||||||
# Initialize atomic config manager
|
# Initialize atomic config manager
|
||||||
@@ -129,14 +122,6 @@ class ConfigManager:
|
|||||||
# Update in-memory config if save was successful
|
# Update in-memory config if save was successful
|
||||||
if result.status == SaveResultStatus.SUCCESS:
|
if result.status == SaveResultStatus.SUCCESS:
|
||||||
self.config = new_config_data
|
self.config = new_config_data
|
||||||
# In-memory config now matches what was just written; refresh
|
|
||||||
# the load signature so the fast path stays valid. NOTE: the
|
|
||||||
# in-memory copy includes merged secrets; the on-disk file has
|
|
||||||
# them stripped — the fast path returning self.config preserves
|
|
||||||
# exactly the pre-cache behavior (load-after-save also returned
|
|
||||||
# the secret-merged self.config only after re-reading secrets;
|
|
||||||
# here secrets file is unchanged, so contents are equivalent).
|
|
||||||
self._loaded_sig = self._files_signature()
|
|
||||||
self.logger.info(f"Configuration successfully saved atomically to {os.path.abspath(self.config_path)}")
|
self.logger.info(f"Configuration successfully saved atomically to {os.path.abspath(self.config_path)}")
|
||||||
elif result.status == SaveResultStatus.ROLLED_BACK:
|
elif result.status == SaveResultStatus.ROLLED_BACK:
|
||||||
# Reload config from file after rollback
|
# Reload config from file after rollback
|
||||||
@@ -194,32 +179,9 @@ class ConfigManager:
|
|||||||
atomic_mgr = self._get_atomic_manager()
|
atomic_mgr = self._get_atomic_manager()
|
||||||
return atomic_mgr.validate_config_file(config_path)
|
return atomic_mgr.validate_config_file(config_path)
|
||||||
|
|
||||||
def _files_signature(self) -> tuple:
|
|
||||||
"""(mtime_ns, size) of config/secrets/template, None for missing —
|
|
||||||
cheap staleness probe (3 stats) for the load_config fast path."""
|
|
||||||
sig = []
|
|
||||||
for path in (self.config_path, self.secrets_path, self.template_path):
|
|
||||||
try:
|
|
||||||
st = os.stat(path)
|
|
||||||
sig.append((st.st_mtime_ns, st.st_size))
|
|
||||||
except OSError:
|
|
||||||
sig.append(None)
|
|
||||||
return tuple(sig)
|
|
||||||
|
|
||||||
def load_config(self) -> Dict[str, Any]:
|
def load_config(self) -> Dict[str, Any]:
|
||||||
"""Load configuration from JSON files.
|
"""Load configuration from JSON files."""
|
||||||
|
|
||||||
Fast path: when config.json, config_secrets.json and the template
|
|
||||||
are all unchanged since the last successful load (mtime_ns + size),
|
|
||||||
the already-parsed self.config is returned without touching the
|
|
||||||
files — same aliasing semantics as the full path, which also
|
|
||||||
returns self.config.
|
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
current_sig = self._files_signature()
|
|
||||||
if self.config and self._loaded_sig == current_sig:
|
|
||||||
return self.config
|
|
||||||
|
|
||||||
# Check if config file exists, if not create from template
|
# Check if config file exists, if not create from template
|
||||||
if not os.path.exists(self.config_path):
|
if not os.path.exists(self.config_path):
|
||||||
self._create_config_from_template()
|
self._create_config_from_template()
|
||||||
@@ -244,9 +206,6 @@ class ConfigManager:
|
|||||||
except (json.JSONDecodeError, OSError) as e:
|
except (json.JSONDecodeError, OSError) as e:
|
||||||
self.logger.warning(f"Error reading secrets file ({self.secrets_path}): {e}. Continuing without secrets.")
|
self.logger.warning(f"Error reading secrets file ({self.secrets_path}): {e}. Continuing without secrets.")
|
||||||
|
|
||||||
# Signature taken AFTER load + migration (migration may write the
|
|
||||||
# config back), so it reflects exactly what was read/written.
|
|
||||||
self._loaded_sig = self._files_signature()
|
|
||||||
return self.config
|
return self.config
|
||||||
|
|
||||||
except FileNotFoundError as e:
|
except FileNotFoundError as e:
|
||||||
@@ -306,7 +265,6 @@ class ConfigManager:
|
|||||||
|
|
||||||
# Update the in-memory config to the new state (which includes secrets for runtime)
|
# Update the in-memory config to the new state (which includes secrets for runtime)
|
||||||
self.config = new_config_data
|
self.config = new_config_data
|
||||||
self._loaded_sig = self._files_signature()
|
|
||||||
self.logger.info(f"Configuration successfully saved to {os.path.abspath(self.config_path)}")
|
self.logger.info(f"Configuration successfully saved to {os.path.abspath(self.config_path)}")
|
||||||
if secrets_content:
|
if secrets_content:
|
||||||
self.logger.info("Secret values were preserved in memory and not written to the main config file.")
|
self.logger.info("Secret values were preserved in memory and not written to the main config file.")
|
||||||
|
|||||||
+27
-131
@@ -23,9 +23,6 @@ Entry point: :func:`main` — instantiates :class:`DisplayController` and calls
|
|||||||
import time
|
import time
|
||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
import threading
|
|
||||||
import types
|
|
||||||
from contextlib import contextmanager
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, Any, List, Optional, Callable
|
from typing import Dict, Any, List, Optional, Callable
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@@ -895,30 +892,6 @@ class DisplayController:
|
|||||||
except Exception: # pylint: disable=broad-except
|
except Exception: # pylint: disable=broad-except
|
||||||
logger.exception("Error running scheduled plugin updates")
|
logger.exception("Error running scheduled plugin updates")
|
||||||
|
|
||||||
@contextmanager
|
|
||||||
def _display_lock_or_skip(self, plugin_id):
|
|
||||||
"""Try-lock guard keeping a plugin's display() off its in-flight update().
|
|
||||||
|
|
||||||
Yields True when display may run (lock held, released on exit) or
|
|
||||||
when no lock support exists (older plugin manager). Yields False when
|
|
||||||
the plugin's update() is currently executing on the background
|
|
||||||
worker — the caller should treat the frame as displayed (the panel
|
|
||||||
holds the last pushed frame) rather than as a plugin failure, so a
|
|
||||||
mid-update skip never advances the rotation.
|
|
||||||
"""
|
|
||||||
pm = self.plugin_manager
|
|
||||||
if not pm or not hasattr(pm, 'get_plugin_lock') or not plugin_id:
|
|
||||||
yield True
|
|
||||||
return
|
|
||||||
lock = pm.get_plugin_lock(plugin_id)
|
|
||||||
if not lock.acquire(blocking=False):
|
|
||||||
yield False
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
yield True
|
|
||||||
finally:
|
|
||||||
lock.release()
|
|
||||||
|
|
||||||
_FOLLOWER_SEND_INTERVAL = 1.0 / 90 # raw bytes are cheap; 90fps > follower render rate
|
_FOLLOWER_SEND_INTERVAL = 1.0 / 90 # raw bytes are cheap; 90fps > follower render rate
|
||||||
|
|
||||||
def _follower_rebuild_scroll_image(self) -> None:
|
def _follower_rebuild_scroll_image(self) -> None:
|
||||||
@@ -1906,7 +1879,6 @@ class DisplayController:
|
|||||||
plugin_id = getattr(manager_to_display, 'plugin_id', active_mode)
|
plugin_id = getattr(manager_to_display, 'plugin_id', active_mode)
|
||||||
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}")
|
||||||
can_display = False
|
|
||||||
if hasattr(manager_to_display, 'display'):
|
if hasattr(manager_to_display, 'display'):
|
||||||
# Opt #1: look up (or compute once) whether display() accepts display_mode
|
# Opt #1: look up (or compute once) whether display() accepts display_mode
|
||||||
_cache_key = plugin_id
|
_cache_key = plugin_id
|
||||||
@@ -1917,64 +1889,14 @@ class DisplayController:
|
|||||||
)
|
)
|
||||||
_accepts_display_mode = self._plugin_accepts_display_mode[_cache_key]
|
_accepts_display_mode = self._plugin_accepts_display_mode[_cache_key]
|
||||||
|
|
||||||
pm = self.plugin_manager
|
# Use PluginExecutor for safe execution with timeout
|
||||||
display_lock = None
|
if self.plugin_manager and hasattr(self.plugin_manager, 'plugin_executor'):
|
||||||
can_display = True
|
result = self.plugin_manager.plugin_executor.execute_display(
|
||||||
if pm and hasattr(pm, 'get_plugin_lock'):
|
manager_to_display,
|
||||||
display_lock = pm.get_plugin_lock(plugin_id)
|
plugin_id,
|
||||||
can_display = display_lock.acquire(blocking=False)
|
force_clear=self.force_change,
|
||||||
|
display_mode=active_mode if _accepts_display_mode else None
|
||||||
if not can_display:
|
)
|
||||||
# update() in flight on the worker — hold
|
|
||||||
# the last frame; not a plugin failure
|
|
||||||
result = True
|
|
||||||
elif pm and hasattr(pm, 'plugin_executor'):
|
|
||||||
# PluginExecutor's own thread.join(timeout) can
|
|
||||||
# return before the real display() call
|
|
||||||
# finishes (a lingering daemon thread keeps
|
|
||||||
# running it) -- so the lock is released from
|
|
||||||
# inside the wrapped call itself, whichever
|
|
||||||
# thread actually finishes it, rather than
|
|
||||||
# here when this dispatch merely returns.
|
|
||||||
release_guard = threading.Lock()
|
|
||||||
released = {'done': False}
|
|
||||||
|
|
||||||
def _release_display_lock():
|
|
||||||
with release_guard:
|
|
||||||
if released['done']:
|
|
||||||
return
|
|
||||||
released['done'] = True
|
|
||||||
if display_lock is not None:
|
|
||||||
display_lock.release()
|
|
||||||
|
|
||||||
if _accepts_display_mode:
|
|
||||||
def _display_target(display_mode=None, force_clear=False):
|
|
||||||
try:
|
|
||||||
return manager_to_display.display(
|
|
||||||
display_mode=display_mode, force_clear=force_clear)
|
|
||||||
finally:
|
|
||||||
_release_display_lock()
|
|
||||||
else:
|
|
||||||
def _display_target(force_clear=False):
|
|
||||||
try:
|
|
||||||
return manager_to_display.display(force_clear=force_clear)
|
|
||||||
finally:
|
|
||||||
_release_display_lock()
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = self.plugin_manager.plugin_executor.execute_display(
|
|
||||||
types.SimpleNamespace(display=_display_target),
|
|
||||||
plugin_id,
|
|
||||||
force_clear=self.force_change,
|
|
||||||
display_mode=active_mode if _accepts_display_mode else None
|
|
||||||
)
|
|
||||||
except Exception: # pragma: no cover - defensive;
|
|
||||||
# execute_display catches everything
|
|
||||||
# internally, but guarantee the lock is
|
|
||||||
# never leaked if something unexpected
|
|
||||||
# slips through.
|
|
||||||
_release_display_lock()
|
|
||||||
raise
|
|
||||||
# execute_display returns bool, convert to expected format
|
# execute_display returns bool, convert to expected format
|
||||||
if result:
|
if result:
|
||||||
result = True # Success
|
result = True # Success
|
||||||
@@ -1982,14 +1904,10 @@ 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
|
||||||
try:
|
if _accepts_display_mode:
|
||||||
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)
|
|
||||||
finally:
|
|
||||||
if display_lock is not None:
|
|
||||||
display_lock.release()
|
|
||||||
|
|
||||||
logger.debug(f"display() returned: {result} (type: {type(result)})")
|
logger.debug(f"display() returned: {result} (type: {type(result)})")
|
||||||
# Check if display() returned a boolean (new behavior)
|
# Check if display() returned a boolean (new behavior)
|
||||||
@@ -1998,15 +1916,11 @@ class DisplayController:
|
|||||||
if not display_result:
|
if not display_result:
|
||||||
logger.info("Plugin %s display() returned False for mode %s", plugin_id, active_mode)
|
logger.info("Plugin %s display() returned False for mode %s", plugin_id, active_mode)
|
||||||
|
|
||||||
# Record success only when display() actually ran this
|
# Record success if display completed without exception
|
||||||
# frame -- a skipped frame (lock busy) held the last
|
if self.plugin_manager and hasattr(self.plugin_manager, 'health_tracker') and self.plugin_manager.health_tracker:
|
||||||
# frame, not a real success, and must not clear
|
self.plugin_manager.health_tracker.record_success(plugin_id)
|
||||||
# force_change or the pending mode-switch clear will
|
|
||||||
# be lost when display() finally does run.
|
self.force_change = False
|
||||||
if can_display:
|
|
||||||
if self.plugin_manager and hasattr(self.plugin_manager, 'health_tracker') and self.plugin_manager.health_tracker:
|
|
||||||
self.plugin_manager.health_tracker.record_success(plugin_id)
|
|
||||||
self.force_change = False
|
|
||||||
except Exception as exc: # pylint: disable=broad-except
|
except Exception as exc: # pylint: disable=broad-except
|
||||||
logger.exception("Error displaying %s", self.current_display_mode)
|
logger.exception("Error displaying %s", self.current_display_mode)
|
||||||
# Record failure
|
# Record failure
|
||||||
@@ -2291,16 +2205,11 @@ class DisplayController:
|
|||||||
|
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
with self._display_lock_or_skip(plugin_id) as can_display:
|
# Pass display_mode to maintain sticky manager state
|
||||||
if can_display:
|
if _accepts_display_mode:
|
||||||
# Pass display_mode to maintain sticky manager state
|
result = manager_to_display.display(display_mode=active_mode, force_clear=False)
|
||||||
if _accepts_display_mode:
|
else:
|
||||||
result = manager_to_display.display(display_mode=active_mode, force_clear=False)
|
result = manager_to_display.display(force_clear=False)
|
||||||
else:
|
|
||||||
result = manager_to_display.display(force_clear=False)
|
|
||||||
else:
|
|
||||||
# update() in flight — hold the last frame
|
|
||||||
result = True
|
|
||||||
if isinstance(result, bool) and not result:
|
if isinstance(result, bool) and not result:
|
||||||
logger.debug("Display returned False, breaking early")
|
logger.debug("Display returned False, breaking early")
|
||||||
break
|
break
|
||||||
@@ -2360,16 +2269,11 @@ class DisplayController:
|
|||||||
break
|
break
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with self._display_lock_or_skip(plugin_id) as can_display:
|
# Pass display_mode to maintain sticky manager state
|
||||||
if can_display:
|
if _accepts_display_mode:
|
||||||
# Pass display_mode to maintain sticky manager state
|
result = manager_to_display.display(display_mode=active_mode, force_clear=False)
|
||||||
if _accepts_display_mode:
|
else:
|
||||||
result = manager_to_display.display(display_mode=active_mode, force_clear=False)
|
result = manager_to_display.display(force_clear=False)
|
||||||
else:
|
|
||||||
result = manager_to_display.display(force_clear=False)
|
|
||||||
else:
|
|
||||||
# update() in flight — hold the last frame
|
|
||||||
result = True
|
|
||||||
if isinstance(result, bool) and not result:
|
if isinstance(result, bool) and not result:
|
||||||
# For dynamic duration plugins, don't exit on False - keep looping
|
# For dynamic duration plugins, don't exit on False - keep looping
|
||||||
# until cycle is complete or max duration is reached
|
# until cycle is complete or max duration is reached
|
||||||
@@ -2887,14 +2791,6 @@ class DisplayController:
|
|||||||
|
|
||||||
def cleanup(self):
|
def cleanup(self):
|
||||||
"""Clean up resources."""
|
"""Clean up resources."""
|
||||||
# Stop the async update worker first so no in-flight update() call
|
|
||||||
# is still touching display/cache-backed resources while they're
|
|
||||||
# torn down below.
|
|
||||||
if self.plugin_manager and hasattr(self.plugin_manager, 'stop_update_worker'):
|
|
||||||
try:
|
|
||||||
self.plugin_manager.stop_update_worker()
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("Error stopping plugin update worker: %s", e)
|
|
||||||
# Shutdown config service if it exists
|
# Shutdown config service if it exists
|
||||||
if hasattr(self, 'config_service'):
|
if hasattr(self, 'config_service'):
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import os
|
||||||
|
import freetype
|
||||||
|
from PIL import ImageDraw, ImageFont
|
||||||
|
import logging
|
||||||
|
from typing import Dict, Any
|
||||||
|
from src.display_manager import DisplayManager
|
||||||
|
|
||||||
|
# Configure logging
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class FontTestManager:
|
||||||
|
"""Manager for testing fonts with easy BDF/TTF switching."""
|
||||||
|
|
||||||
|
def __init__(self, config: Dict[str, Any], display_manager: DisplayManager):
|
||||||
|
self.display_manager = display_manager
|
||||||
|
self.config = config
|
||||||
|
self.logger = logging.getLogger('FontTest')
|
||||||
|
|
||||||
|
# FONT CONFIGURATION - EASY SWITCHING
|
||||||
|
# Set to 'bdf' or 'ttf' to switch font types
|
||||||
|
self.font_type = 'bdf' # Change this to 'ttf' to use TTF font
|
||||||
|
|
||||||
|
# Font configurations
|
||||||
|
self.font_configs = {
|
||||||
|
'bdf': {
|
||||||
|
'path': "assets/fonts/cozette.bdf",
|
||||||
|
'display_name': "Cozette BTF",
|
||||||
|
'description': "BTF font Test"
|
||||||
|
},
|
||||||
|
'ttf': {
|
||||||
|
'path': "assets/fonts/5by7.regular.ttf",
|
||||||
|
'display_name': "5by7 TTF",
|
||||||
|
'description': "TTF font test"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get current font configuration
|
||||||
|
self.current_config = self.font_configs[self.font_type]
|
||||||
|
self.font_path = self.current_config['path']
|
||||||
|
|
||||||
|
# Verify font exists
|
||||||
|
if not os.path.exists(self.font_path):
|
||||||
|
self.logger.error(f"Font file not found: {self.font_path}")
|
||||||
|
raise FileNotFoundError(f"Font file not found: {self.font_path}")
|
||||||
|
|
||||||
|
# Load the font based on type
|
||||||
|
if self.font_type == 'bdf':
|
||||||
|
self._load_bdf_font()
|
||||||
|
else:
|
||||||
|
self._load_ttf_font()
|
||||||
|
|
||||||
|
self.logger.info(f"Initialized FontTestManager with {self.current_config['description']}")
|
||||||
|
|
||||||
|
def _load_bdf_font(self):
|
||||||
|
"""Load BDF font using freetype."""
|
||||||
|
try:
|
||||||
|
self.face = freetype.Face(self.font_path)
|
||||||
|
self.logger.info(f"Successfully loaded BDF font from {self.font_path}")
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Failed to load BDF font: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def _load_ttf_font(self):
|
||||||
|
"""Load TTF font using PIL."""
|
||||||
|
try:
|
||||||
|
self.font = ImageFont.truetype(self.font_path, 8) # Size 8 for 5x7 font
|
||||||
|
self.logger.info(f"Successfully loaded TTF font from {self.font_path}")
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Failed to load TTF font: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def update(self):
|
||||||
|
"""No update needed for static display."""
|
||||||
|
|
||||||
|
def display(self, force_clear: bool = False):
|
||||||
|
"""Display the font with sample text."""
|
||||||
|
try:
|
||||||
|
# Clear the display
|
||||||
|
self.display_manager.clear()
|
||||||
|
|
||||||
|
# Draw font name at the top
|
||||||
|
self.display_manager.draw_text(self.current_config['display_name'], y=2, color=(255, 255, 255))
|
||||||
|
|
||||||
|
# Draw sample text
|
||||||
|
draw = ImageDraw.Draw(self.display_manager.image)
|
||||||
|
sample_text = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||||
|
|
||||||
|
# Calculate starting position
|
||||||
|
x = 10 # Start 10 pixels from the left
|
||||||
|
y = 10 # Start 10 pixels from the top
|
||||||
|
|
||||||
|
# Draw text based on font type
|
||||||
|
if self.font_type == 'bdf':
|
||||||
|
self._draw_bdf_text(draw, sample_text, x, y)
|
||||||
|
else:
|
||||||
|
self._draw_ttf_text(draw, sample_text, x, y)
|
||||||
|
|
||||||
|
# Update the display once
|
||||||
|
self.display_manager.update_display()
|
||||||
|
|
||||||
|
# Log that display is complete
|
||||||
|
self.logger.info("Font test display complete.")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Error displaying font test: {e}", exc_info=True)
|
||||||
|
|
||||||
|
def _draw_bdf_text(self, draw, text, x, y):
|
||||||
|
"""Draw text using BDF font."""
|
||||||
|
for char in text:
|
||||||
|
# Load the glyph
|
||||||
|
self.face.load_char(char)
|
||||||
|
bitmap = self.face.glyph.bitmap
|
||||||
|
|
||||||
|
# Draw the glyph
|
||||||
|
for i in range(bitmap.rows):
|
||||||
|
for j in range(bitmap.width):
|
||||||
|
try:
|
||||||
|
# Get the byte containing the pixel
|
||||||
|
byte_index = i * bitmap.pitch + (j // 8)
|
||||||
|
if byte_index < len(bitmap.buffer):
|
||||||
|
byte = bitmap.buffer[byte_index]
|
||||||
|
# Check if the specific bit is set
|
||||||
|
if byte & (1 << (7 - (j % 8))):
|
||||||
|
draw.point((x + j, y + i), fill=(255, 255, 255))
|
||||||
|
except IndexError:
|
||||||
|
self.logger.warning(f"Index out of range for char '{char}' at position ({i}, {j})")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Move to next character position
|
||||||
|
x += self.face.glyph.advance.x >> 6
|
||||||
|
|
||||||
|
def _draw_ttf_text(self, draw, text, x, y):
|
||||||
|
"""Draw text using TTF font."""
|
||||||
|
draw.text((x, y), text, font=self.font, fill=(255, 255, 255))
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
"""
|
||||||
|
Generic Cache Mixin for Any Manager
|
||||||
|
|
||||||
|
This mixin provides caching functionality that can be used by any manager
|
||||||
|
that needs to cache data, not just sports managers. It's a more general
|
||||||
|
version of BackgroundCacheMixin that works for weather, stocks, news, etc.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
from typing import Dict, Optional, Any, Callable
|
||||||
|
|
||||||
|
|
||||||
|
class GenericCacheMixin:
|
||||||
|
"""
|
||||||
|
Generic mixin class that provides caching functionality to any manager.
|
||||||
|
|
||||||
|
This mixin can be used by weather, stock, news, or any other manager
|
||||||
|
that needs to cache data with performance monitoring.
|
||||||
|
|
||||||
|
Note: For sports managers that need background service cache integration,
|
||||||
|
use BackgroundCacheMixin instead. See src/background_cache_mixin.py for details.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _fetch_data_with_cache(self,
|
||||||
|
cache_key: str,
|
||||||
|
api_fetch_method: Callable,
|
||||||
|
cache_ttl: int = 300,
|
||||||
|
force_refresh: bool = False) -> Optional[Dict]:
|
||||||
|
"""
|
||||||
|
Generic caching pattern for any manager.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cache_key: Unique cache key for this data
|
||||||
|
api_fetch_method: Method to call for fresh data
|
||||||
|
cache_ttl: Time-to-live in seconds (default: 5 minutes)
|
||||||
|
force_refresh: Skip cache and fetch fresh data
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Cached or fresh data from API
|
||||||
|
"""
|
||||||
|
start_time = time.time()
|
||||||
|
cache_hit = False
|
||||||
|
cache_source = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Check cache first (unless forcing refresh)
|
||||||
|
if not force_refresh:
|
||||||
|
cached_data = self.cache_manager.get_cached_data(cache_key, cache_ttl)
|
||||||
|
if cached_data:
|
||||||
|
self.logger.info(f"Using cached data for {cache_key}")
|
||||||
|
cache_hit = True
|
||||||
|
cache_source = "cache"
|
||||||
|
self.cache_manager.record_cache_hit('regular')
|
||||||
|
|
||||||
|
# Record performance metrics
|
||||||
|
duration = time.time() - start_time
|
||||||
|
self.cache_manager.record_fetch_time(duration)
|
||||||
|
self._log_fetch_performance(cache_key, duration, cache_hit, cache_source)
|
||||||
|
|
||||||
|
return cached_data
|
||||||
|
|
||||||
|
# Fetch fresh data
|
||||||
|
self.logger.info(f"Fetching fresh data for {cache_key}")
|
||||||
|
result = api_fetch_method()
|
||||||
|
cache_source = "api_fresh"
|
||||||
|
|
||||||
|
# Store in cache if we got data
|
||||||
|
if result:
|
||||||
|
self.cache_manager.save_cache(cache_key, result)
|
||||||
|
self.cache_manager.record_cache_miss('regular')
|
||||||
|
else:
|
||||||
|
self.logger.warning(f"No data returned for {cache_key}")
|
||||||
|
|
||||||
|
# Record performance metrics
|
||||||
|
duration = time.time() - start_time
|
||||||
|
self.cache_manager.record_fetch_time(duration)
|
||||||
|
|
||||||
|
# Log performance
|
||||||
|
self._log_fetch_performance(cache_key, duration, cache_hit, cache_source)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
duration = time.time() - start_time
|
||||||
|
self.logger.error(f"Error fetching data for {cache_key} after {duration:.2f}s: {e}")
|
||||||
|
self.cache_manager.record_fetch_time(duration)
|
||||||
|
raise
|
||||||
|
|
||||||
|
def _log_fetch_performance(self, cache_key: str, duration: float, cache_hit: bool, cache_source: str):
|
||||||
|
"""
|
||||||
|
Log detailed performance metrics for fetch operations.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cache_key: Cache key that was accessed
|
||||||
|
duration: Fetch operation duration in seconds
|
||||||
|
cache_hit: Whether this was a cache hit
|
||||||
|
cache_source: Source of the data (cache, api_fresh, etc.)
|
||||||
|
"""
|
||||||
|
# Log basic performance info
|
||||||
|
self.logger.info(f"Fetch completed for {cache_key} in {duration:.2f}s "
|
||||||
|
f"(cache_hit={cache_hit}, source={cache_source})")
|
||||||
|
|
||||||
|
# Log detailed metrics every 10 operations
|
||||||
|
if hasattr(self, '_fetch_count'):
|
||||||
|
self._fetch_count += 1
|
||||||
|
else:
|
||||||
|
self._fetch_count = 1
|
||||||
|
|
||||||
|
if self._fetch_count % 10 == 0:
|
||||||
|
metrics = self.cache_manager.get_cache_metrics()
|
||||||
|
self.logger.info(f"Cache Performance Summary - "
|
||||||
|
f"Hit Rate: {metrics['cache_hit_rate']:.2%}, "
|
||||||
|
f"API Calls Saved: {metrics['api_calls_saved']}, "
|
||||||
|
f"Avg Fetch Time: {metrics['average_fetch_time']:.2f}s")
|
||||||
|
|
||||||
|
def get_cache_performance_summary(self) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Get cache performance summary for this manager.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary containing cache performance metrics
|
||||||
|
"""
|
||||||
|
return self.cache_manager.get_cache_metrics()
|
||||||
|
|
||||||
|
def log_cache_performance(self):
|
||||||
|
"""Log current cache performance metrics."""
|
||||||
|
self.cache_manager.log_cache_metrics()
|
||||||
|
|
||||||
|
def clear_cache_for_key(self, cache_key: str):
|
||||||
|
"""Clear cache for a specific key."""
|
||||||
|
self.cache_manager.clear_cache(cache_key)
|
||||||
|
self.logger.info(f"Cleared cache for {cache_key}")
|
||||||
|
|
||||||
|
def get_cache_info(self, cache_key: str) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Get information about a cached item.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cache_key: Cache key to check
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with cache information
|
||||||
|
"""
|
||||||
|
# This would need to be implemented in CacheManager
|
||||||
|
# For now, just return basic info
|
||||||
|
return {
|
||||||
|
'key': cache_key,
|
||||||
|
'exists': self.cache_manager.get_cached_data(cache_key, 0) is not None,
|
||||||
|
'ttl': 'unknown' # Would need to be implemented
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
"""Deprecated: use src/adaptive_images.py (fit_image) instead.
|
||||||
|
|
||||||
|
This module predates the adaptive image system and has no known callers.
|
||||||
|
It is kept only so any out-of-tree code importing it keeps working.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
def scale_to_max_dimensions(img, max_width, max_height):
|
||||||
|
h_to_w_ratio = img.height / img.width
|
||||||
|
w_to_h_ratio = img.width / img.height
|
||||||
|
|
||||||
|
if img.height > max_height:
|
||||||
|
img = img.resize((int(max_height * w_to_h_ratio), max_height), Image.Resampling.LANCZOS)
|
||||||
|
|
||||||
|
if img.width > max_width:
|
||||||
|
img = img.resize((max_width, int(max_width * h_to_w_ratio)), Image.Resampling.LANCZOS)
|
||||||
|
|
||||||
|
return img
|
||||||
@@ -0,0 +1,409 @@
|
|||||||
|
"""
|
||||||
|
Layout Manager for LED Matrix Display
|
||||||
|
Handles custom layouts, element positioning, and display composition.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
from typing import Dict, List, Any
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class LayoutManager:
|
||||||
|
def __init__(self, display_manager=None, config_path="config/custom_layouts.json"):
|
||||||
|
self.display_manager = display_manager
|
||||||
|
self.config_path = config_path
|
||||||
|
self.layouts = self.load_layouts()
|
||||||
|
self.current_layout = None
|
||||||
|
|
||||||
|
def load_layouts(self) -> Dict[str, Any]:
|
||||||
|
"""Load saved layouts from file."""
|
||||||
|
try:
|
||||||
|
if os.path.exists(self.config_path):
|
||||||
|
with open(self.config_path, 'r') as f:
|
||||||
|
return json.load(f)
|
||||||
|
return {}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error loading layouts: {e}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def save_layouts(self) -> bool:
|
||||||
|
"""Save layouts to file."""
|
||||||
|
try:
|
||||||
|
from pathlib import Path
|
||||||
|
from src.common.permission_utils import (
|
||||||
|
ensure_directory_permissions,
|
||||||
|
get_config_dir_mode
|
||||||
|
)
|
||||||
|
config_path_obj = Path(self.config_path)
|
||||||
|
ensure_directory_permissions(config_path_obj.parent, get_config_dir_mode())
|
||||||
|
with open(self.config_path, 'w') as f:
|
||||||
|
json.dump(self.layouts, f, indent=2)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error saving layouts: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def create_layout(self, name: str, elements: List[Dict], description: str = "") -> bool:
|
||||||
|
"""Create a new layout."""
|
||||||
|
try:
|
||||||
|
self.layouts[name] = {
|
||||||
|
'elements': elements,
|
||||||
|
'description': description,
|
||||||
|
'created': datetime.now().isoformat(),
|
||||||
|
'modified': datetime.now().isoformat()
|
||||||
|
}
|
||||||
|
return self.save_layouts()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error creating layout '{name}': {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def update_layout(self, name: str, elements: List[Dict], description: str = None) -> bool:
|
||||||
|
"""Update an existing layout."""
|
||||||
|
try:
|
||||||
|
if name not in self.layouts:
|
||||||
|
return False
|
||||||
|
|
||||||
|
self.layouts[name]['elements'] = elements
|
||||||
|
self.layouts[name]['modified'] = datetime.now().isoformat()
|
||||||
|
|
||||||
|
if description is not None:
|
||||||
|
self.layouts[name]['description'] = description
|
||||||
|
|
||||||
|
return self.save_layouts()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error updating layout '{name}': {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def delete_layout(self, name: str) -> bool:
|
||||||
|
"""Delete a layout."""
|
||||||
|
try:
|
||||||
|
if name in self.layouts:
|
||||||
|
del self.layouts[name]
|
||||||
|
return self.save_layouts()
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error deleting layout '{name}': {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_layout(self, name: str) -> Dict[str, Any]:
|
||||||
|
"""Get a specific layout."""
|
||||||
|
return self.layouts.get(name, {})
|
||||||
|
|
||||||
|
def list_layouts(self) -> List[str]:
|
||||||
|
"""Get list of all layout names."""
|
||||||
|
return list(self.layouts.keys())
|
||||||
|
|
||||||
|
def set_current_layout(self, name: str) -> bool:
|
||||||
|
"""Set the current active layout."""
|
||||||
|
if name in self.layouts:
|
||||||
|
self.current_layout = name
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def render_layout(self, layout_name: str = None, data_context: Dict = None) -> bool:
|
||||||
|
"""Render a layout to the display."""
|
||||||
|
if not self.display_manager:
|
||||||
|
logger.error("No display manager available")
|
||||||
|
return False
|
||||||
|
|
||||||
|
layout_name = layout_name or self.current_layout
|
||||||
|
if not layout_name or layout_name not in self.layouts:
|
||||||
|
logger.error(f"Layout '{layout_name}' not found")
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Clear the display
|
||||||
|
self.display_manager.clear()
|
||||||
|
|
||||||
|
# Get layout elements
|
||||||
|
elements = self.layouts[layout_name]['elements']
|
||||||
|
|
||||||
|
# Render each element
|
||||||
|
for element in elements:
|
||||||
|
self.render_element(element, data_context or {})
|
||||||
|
|
||||||
|
# Update the display
|
||||||
|
self.display_manager.update_display()
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error rendering layout '{layout_name}': {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def render_element(self, element: Dict, data_context: Dict) -> None:
|
||||||
|
"""Render a single element."""
|
||||||
|
element_type = element.get('type')
|
||||||
|
x = element.get('x', 0)
|
||||||
|
y = element.get('y', 0)
|
||||||
|
properties = element.get('properties', {})
|
||||||
|
|
||||||
|
try:
|
||||||
|
if element_type == 'text':
|
||||||
|
self._render_text_element(x, y, properties, data_context)
|
||||||
|
elif element_type == 'weather_icon':
|
||||||
|
self._render_weather_icon_element(x, y, properties, data_context)
|
||||||
|
elif element_type == 'rectangle':
|
||||||
|
self._render_rectangle_element(x, y, properties)
|
||||||
|
elif element_type == 'line':
|
||||||
|
self._render_line_element(x, y, properties)
|
||||||
|
elif element_type == 'clock':
|
||||||
|
self._render_clock_element(x, y, properties)
|
||||||
|
elif element_type == 'data_text':
|
||||||
|
self._render_data_text_element(x, y, properties, data_context)
|
||||||
|
else:
|
||||||
|
logger.warning(f"Unknown element type: {element_type}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error rendering element {element_type}: {e}")
|
||||||
|
|
||||||
|
def _render_text_element(self, x: int, y: int, properties: Dict, data_context: Dict) -> None:
|
||||||
|
"""Render a text element."""
|
||||||
|
text = properties.get('text', 'Sample Text')
|
||||||
|
color = tuple(properties.get('color', [255, 255, 255]))
|
||||||
|
font_size = properties.get('font_size', 'normal')
|
||||||
|
|
||||||
|
# Support template variables in text
|
||||||
|
text = self._process_template_text(text, data_context)
|
||||||
|
|
||||||
|
# Select font
|
||||||
|
if font_size == 'small':
|
||||||
|
font = self.display_manager.small_font
|
||||||
|
elif font_size == 'large':
|
||||||
|
font = self.display_manager.regular_font
|
||||||
|
else:
|
||||||
|
font = self.display_manager.regular_font
|
||||||
|
|
||||||
|
self.display_manager.draw_text(text, x, y, color, font=font)
|
||||||
|
|
||||||
|
def _render_weather_icon_element(self, x: int, y: int, properties: Dict, data_context: Dict) -> None:
|
||||||
|
"""Render a weather icon element."""
|
||||||
|
condition = properties.get('condition', 'sunny')
|
||||||
|
size = properties.get('size', 16)
|
||||||
|
|
||||||
|
# Use weather data from context if available
|
||||||
|
if 'weather' in data_context and 'condition' in data_context['weather']:
|
||||||
|
condition = data_context['weather']['condition'].lower()
|
||||||
|
|
||||||
|
self.display_manager.draw_weather_icon(condition, x, y, size)
|
||||||
|
|
||||||
|
def _render_rectangle_element(self, x: int, y: int, properties: Dict) -> None:
|
||||||
|
"""Render a rectangle element."""
|
||||||
|
width = properties.get('width', 10)
|
||||||
|
height = properties.get('height', 10)
|
||||||
|
color = tuple(properties.get('color', [255, 255, 255]))
|
||||||
|
filled = properties.get('filled', False)
|
||||||
|
|
||||||
|
if filled:
|
||||||
|
self.display_manager.draw.rectangle(
|
||||||
|
[x, y, x + width, y + height],
|
||||||
|
fill=color
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.display_manager.draw.rectangle(
|
||||||
|
[x, y, x + width, y + height],
|
||||||
|
outline=color
|
||||||
|
)
|
||||||
|
|
||||||
|
def _render_line_element(self, x: int, y: int, properties: Dict) -> None:
|
||||||
|
"""Render a line element."""
|
||||||
|
x2 = properties.get('x2', x + 10)
|
||||||
|
y2 = properties.get('y2', y)
|
||||||
|
color = tuple(properties.get('color', [255, 255, 255]))
|
||||||
|
width = properties.get('width', 1)
|
||||||
|
|
||||||
|
self.display_manager.draw.line([x, y, x2, y2], fill=color, width=width)
|
||||||
|
|
||||||
|
def _render_clock_element(self, x: int, y: int, properties: Dict) -> None:
|
||||||
|
"""Render a clock element."""
|
||||||
|
format_str = properties.get('format', '%H:%M')
|
||||||
|
color = tuple(properties.get('color', [255, 255, 255]))
|
||||||
|
|
||||||
|
current_time = datetime.now().strftime(format_str)
|
||||||
|
self.display_manager.draw_text(current_time, x, y, color)
|
||||||
|
|
||||||
|
def _render_data_text_element(self, x: int, y: int, properties: Dict, data_context: Dict) -> None:
|
||||||
|
"""Render a data-driven text element."""
|
||||||
|
data_key = properties.get('data_key', '')
|
||||||
|
format_str = properties.get('format', '{value}')
|
||||||
|
color = tuple(properties.get('color', [255, 255, 255]))
|
||||||
|
default_value = properties.get('default', 'N/A')
|
||||||
|
|
||||||
|
# Extract data from context
|
||||||
|
value = self._get_nested_value(data_context, data_key, default_value)
|
||||||
|
|
||||||
|
# Format the text
|
||||||
|
try:
|
||||||
|
text = format_str.format(value=value)
|
||||||
|
except (ValueError, TypeError, KeyError, IndexError):
|
||||||
|
text = str(value)
|
||||||
|
|
||||||
|
self.display_manager.draw_text(text, x, y, color)
|
||||||
|
|
||||||
|
def _process_template_text(self, text: str, data_context: Dict) -> str:
|
||||||
|
"""Process template variables in text."""
|
||||||
|
try:
|
||||||
|
# Simple template processing - replace {key} with values from context
|
||||||
|
for key, value in data_context.items():
|
||||||
|
placeholder = f"{{{key}}}"
|
||||||
|
if placeholder in text:
|
||||||
|
text = text.replace(placeholder, str(value))
|
||||||
|
return text
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error processing template text: {e}")
|
||||||
|
return text
|
||||||
|
|
||||||
|
def _get_nested_value(self, data: Dict, key: str, default=None):
|
||||||
|
"""Get a nested value from a dictionary using dot notation."""
|
||||||
|
try:
|
||||||
|
keys = key.split('.')
|
||||||
|
value = data
|
||||||
|
for k in keys:
|
||||||
|
value = value[k]
|
||||||
|
return value
|
||||||
|
except (KeyError, TypeError):
|
||||||
|
return default
|
||||||
|
|
||||||
|
def create_preset_layouts(self) -> None:
|
||||||
|
"""Create some preset layouts for common use cases."""
|
||||||
|
# Basic clock layout
|
||||||
|
clock_layout = [
|
||||||
|
{
|
||||||
|
'type': 'clock',
|
||||||
|
'x': 10,
|
||||||
|
'y': 10,
|
||||||
|
'properties': {
|
||||||
|
'format': '%H:%M',
|
||||||
|
'color': [255, 255, 255]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'type': 'clock',
|
||||||
|
'x': 10,
|
||||||
|
'y': 20,
|
||||||
|
'properties': {
|
||||||
|
'format': '%m/%d',
|
||||||
|
'color': [100, 100, 255]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
self.create_layout('basic_clock', clock_layout, 'Simple clock with date')
|
||||||
|
|
||||||
|
# Weather layout
|
||||||
|
weather_layout = [
|
||||||
|
{
|
||||||
|
'type': 'weather_icon',
|
||||||
|
'x': 5,
|
||||||
|
'y': 5,
|
||||||
|
'properties': {
|
||||||
|
'condition': 'sunny',
|
||||||
|
'size': 20
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'type': 'data_text',
|
||||||
|
'x': 30,
|
||||||
|
'y': 8,
|
||||||
|
'properties': {
|
||||||
|
'data_key': 'weather.temperature',
|
||||||
|
'format': '{value}°',
|
||||||
|
'color': [255, 200, 0],
|
||||||
|
'default': '--°'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'type': 'data_text',
|
||||||
|
'x': 30,
|
||||||
|
'y': 18,
|
||||||
|
'properties': {
|
||||||
|
'data_key': 'weather.condition',
|
||||||
|
'format': '{value}',
|
||||||
|
'color': [200, 200, 200],
|
||||||
|
'default': 'Unknown'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
self.create_layout('weather_display', weather_layout, 'Weather icon with temperature and condition')
|
||||||
|
|
||||||
|
# Mixed dashboard layout
|
||||||
|
dashboard_layout = [
|
||||||
|
{
|
||||||
|
'type': 'clock',
|
||||||
|
'x': 2,
|
||||||
|
'y': 2,
|
||||||
|
'properties': {
|
||||||
|
'format': '%H:%M',
|
||||||
|
'color': [255, 255, 255]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'type': 'weather_icon',
|
||||||
|
'x': 50,
|
||||||
|
'y': 2,
|
||||||
|
'properties': {
|
||||||
|
'size': 16
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'type': 'data_text',
|
||||||
|
'x': 70,
|
||||||
|
'y': 5,
|
||||||
|
'properties': {
|
||||||
|
'data_key': 'weather.temperature',
|
||||||
|
'format': '{value}°',
|
||||||
|
'color': [255, 200, 0],
|
||||||
|
'default': '--°'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'type': 'line',
|
||||||
|
'x': 0,
|
||||||
|
'y': 15,
|
||||||
|
'properties': {
|
||||||
|
'x2': 128,
|
||||||
|
'y2': 15,
|
||||||
|
'color': [100, 100, 100]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'type': 'data_text',
|
||||||
|
'x': 2,
|
||||||
|
'y': 18,
|
||||||
|
'properties': {
|
||||||
|
'data_key': 'stocks.AAPL.price',
|
||||||
|
'format': 'AAPL: ${value}',
|
||||||
|
'color': [0, 255, 0],
|
||||||
|
'default': 'AAPL: N/A'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
self.create_layout('dashboard', dashboard_layout, 'Mixed dashboard with clock, weather, and stocks')
|
||||||
|
|
||||||
|
logger.info("Created preset layouts")
|
||||||
|
|
||||||
|
def get_layout_preview(self, layout_name: str) -> Dict[str, Any]:
|
||||||
|
"""Get a preview representation of a layout."""
|
||||||
|
if layout_name not in self.layouts:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
layout = self.layouts[layout_name]
|
||||||
|
elements = layout['elements']
|
||||||
|
|
||||||
|
# Create a simple preview representation
|
||||||
|
preview = {
|
||||||
|
'name': layout_name,
|
||||||
|
'description': layout.get('description', ''),
|
||||||
|
'element_count': len(elements),
|
||||||
|
'elements': []
|
||||||
|
}
|
||||||
|
|
||||||
|
for element in elements:
|
||||||
|
preview['elements'].append({
|
||||||
|
'type': element.get('type'),
|
||||||
|
'position': f"({element.get('x', 0)}, {element.get('y', 0)})",
|
||||||
|
'properties': list(element.get('properties', {}).keys())
|
||||||
|
})
|
||||||
|
|
||||||
|
return preview
|
||||||
@@ -8,13 +8,12 @@ API Version: 1.0.0
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import queue
|
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
import threading
|
import threading
|
||||||
import types
|
import types
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, List, Optional, Any, Tuple
|
from typing import Dict, List, Optional, Any
|
||||||
import logging
|
import logging
|
||||||
from src.exceptions import PluginError, ConfigError
|
from src.exceptions import PluginError, ConfigError
|
||||||
from src.logging_config import get_logger
|
from src.logging_config import get_logger
|
||||||
@@ -99,50 +98,6 @@ class PluginManager:
|
|||||||
self.health_tracker = None
|
self.health_tracker = None
|
||||||
self.resource_monitor = None
|
self.resource_monitor = None
|
||||||
|
|
||||||
# --- Asynchronous plugin updates -------------------------------
|
|
||||||
# update() used to run inline in the render loop (execute_update's
|
|
||||||
# internal thread.join(timeout=30) blocked it), so one slow plugin
|
|
||||||
# HTTP fetch froze scrolling for the whole fetch. Scheduling still
|
|
||||||
# happens on the render thread (run_scheduled_updates), but
|
|
||||||
# execution moves to this single background worker. Per-plugin
|
|
||||||
# locks keep a plugin's update() and display() mutually exclusive —
|
|
||||||
# today's implicit guarantee, now explicit (and, unlike today,
|
|
||||||
# also held across the post-timeout window).
|
|
||||||
# Kill switch: plugin_system.synchronous_updates: true restores the
|
|
||||||
# inline path.
|
|
||||||
self._update_queue: "queue.Queue[Optional[Tuple[str, float]]]" = queue.Queue()
|
|
||||||
self._pending_updates: set = set()
|
|
||||||
self._pending_lock = threading.Lock()
|
|
||||||
self._plugin_locks: Dict[str, threading.Lock] = {}
|
|
||||||
self._plugin_locks_guard = threading.Lock()
|
|
||||||
self._update_worker: Optional[threading.Thread] = None
|
|
||||||
self._synchronous_updates = False
|
|
||||||
if self.config_manager is not None:
|
|
||||||
try:
|
|
||||||
cfg = self.config_manager.get_config() or {}
|
|
||||||
except (OSError, ValueError) as exc:
|
|
||||||
self.logger.warning(
|
|
||||||
"Could not load config to check plugin_system.synchronous_updates "
|
|
||||||
"(%s: %s); defaulting to synchronous updates", type(exc).__name__, exc)
|
|
||||||
self._synchronous_updates = True
|
|
||||||
else:
|
|
||||||
plugin_system_cfg = cfg.get('plugin_system', {})
|
|
||||||
if not isinstance(plugin_system_cfg, dict):
|
|
||||||
self.logger.warning(
|
|
||||||
"config plugin_system must be a mapping, got %s; "
|
|
||||||
"defaulting to synchronous updates",
|
|
||||||
type(plugin_system_cfg).__name__)
|
|
||||||
self._synchronous_updates = True
|
|
||||||
else:
|
|
||||||
sync_value = plugin_system_cfg.get('synchronous_updates', False)
|
|
||||||
if not isinstance(sync_value, bool):
|
|
||||||
self.logger.warning(
|
|
||||||
"config plugin_system.synchronous_updates must be a boolean, "
|
|
||||||
"got %r; defaulting to synchronous updates", sync_value)
|
|
||||||
self._synchronous_updates = True
|
|
||||||
else:
|
|
||||||
self._synchronous_updates = sync_value
|
|
||||||
|
|
||||||
# Ensure plugins directory exists with proper permissions
|
# Ensure plugins directory exists with proper permissions
|
||||||
try:
|
try:
|
||||||
ensure_directory_permissions(self.plugins_dir, get_plugin_dir_mode())
|
ensure_directory_permissions(self.plugins_dir, get_plugin_dir_mode())
|
||||||
@@ -789,189 +744,47 @@ class PluginManager:
|
|||||||
last_update = self.plugin_last_update.get(plugin_id, 0.0)
|
last_update = self.plugin_last_update.get(plugin_id, 0.0)
|
||||||
|
|
||||||
if last_update == 0.0 or (current_time - last_update) >= interval:
|
if last_update == 0.0 or (current_time - last_update) >= interval:
|
||||||
if self._synchronous_updates:
|
# Update state to RUNNING
|
||||||
# Kill-switch path: the original inline execution
|
self.state_manager.set_state(plugin_id, PluginState.RUNNING)
|
||||||
# (blocks the caller until update() completes/times out)
|
|
||||||
self.state_manager.set_state(plugin_id, PluginState.RUNNING)
|
|
||||||
self._execute_update_now(plugin_id, plugin_instance, current_time)
|
|
||||||
else:
|
|
||||||
self._enqueue_update(plugin_id, current_time)
|
|
||||||
|
|
||||||
def get_plugin_lock(self, plugin_id: str) -> threading.Lock:
|
try:
|
||||||
"""Per-plugin lock keeping update() and display() mutually exclusive.
|
# Use PluginExecutor for safe execution
|
||||||
|
success = False
|
||||||
|
if self.resource_monitor:
|
||||||
|
# If resource monitor exists, wrap the call
|
||||||
|
def monitored_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(
|
||||||
|
types.SimpleNamespace(update=monitored_update),
|
||||||
|
plugin_id
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
success = self.plugin_executor.execute_update(plugin_instance, plugin_id)
|
||||||
|
|
||||||
The update worker holds it for the duration of a plugin's update();
|
if success:
|
||||||
the display side acquires it non-blocking and skips that frame's
|
with self._plugin_last_update_lock:
|
||||||
display() call when the plugin is mid-update.
|
self.plugin_last_update[plugin_id] = current_time
|
||||||
"""
|
self.state_manager.record_update(plugin_id)
|
||||||
with self._plugin_locks_guard:
|
# Update state back to ENABLED
|
||||||
lock = self._plugin_locks.get(plugin_id)
|
self.state_manager.set_state(plugin_id, PluginState.ENABLED)
|
||||||
if lock is None:
|
# Record success
|
||||||
lock = threading.Lock()
|
if self.health_tracker:
|
||||||
self._plugin_locks[plugin_id] = lock
|
self.health_tracker.record_success(plugin_id)
|
||||||
return lock
|
else:
|
||||||
|
self._record_update_failure(plugin_id)
|
||||||
def _enqueue_update(self, plugin_id: str, scheduled_time: float) -> None:
|
except Exception as exc: # pylint: disable=broad-except
|
||||||
"""Queue a due update for the background worker (dedup while pending)."""
|
self.logger.exception("Error updating plugin %s: %s", plugin_id, exc)
|
||||||
with self._pending_lock:
|
|
||||||
if plugin_id in self._pending_updates:
|
|
||||||
return
|
|
||||||
self._pending_updates.add(plugin_id)
|
|
||||||
# RUNNING is set at enqueue time so can_execute() blocks re-entry and
|
|
||||||
# the web UI shows the truthful state while the item waits its turn.
|
|
||||||
self.state_manager.set_state(plugin_id, PluginState.RUNNING)
|
|
||||||
self._ensure_update_worker()
|
|
||||||
self._update_queue.put((plugin_id, scheduled_time))
|
|
||||||
|
|
||||||
def _ensure_update_worker(self) -> None:
|
|
||||||
if self._update_worker is not None and self._update_worker.is_alive():
|
|
||||||
return
|
|
||||||
self._update_worker = threading.Thread(
|
|
||||||
target=self._update_worker_loop, name='plugin-update-worker',
|
|
||||||
daemon=True)
|
|
||||||
self._update_worker.start()
|
|
||||||
|
|
||||||
def _update_worker_loop(self) -> None:
|
|
||||||
"""Single worker: dispatches queued updates off the render thread
|
|
||||||
(matching the old inline behavior — no thundering herd of
|
|
||||||
concurrent fetches).
|
|
||||||
|
|
||||||
The plugin's lock is acquired here, before its instance is looked
|
|
||||||
up, and the instance is re-fetched under the lock — a concurrent
|
|
||||||
unload_plugin() can't leave this loop about to run update() on an
|
|
||||||
instance that's already been torn down. The lock — and RUNNING/
|
|
||||||
pending lifecycle state — is released by the update itself once the
|
|
||||||
real update() call genuinely finishes (see _execute_update_now),
|
|
||||||
which can be after this dispatch returns if PluginExecutor's own
|
|
||||||
timeout elapses first.
|
|
||||||
"""
|
|
||||||
while True:
|
|
||||||
item = self._update_queue.get()
|
|
||||||
if item is None: # shutdown sentinel
|
|
||||||
return
|
|
||||||
plugin_id, scheduled_time = item
|
|
||||||
lock = self.get_plugin_lock(plugin_id)
|
|
||||||
lock.acquire()
|
|
||||||
plugin_instance = self.plugins.get(plugin_id)
|
|
||||||
if plugin_instance is None: # unloaded while queued; its
|
|
||||||
# lifecycle state was already cleared by unload_plugin —
|
|
||||||
# leave it alone rather than resurrecting it to ENABLED
|
|
||||||
lock.release()
|
|
||||||
with self._pending_lock:
|
|
||||||
self._pending_updates.discard(plugin_id)
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
self._execute_update_now(plugin_id, plugin_instance,
|
|
||||||
scheduled_time, lock=lock)
|
|
||||||
except Exception: # pylint: disable=broad-except
|
|
||||||
# _execute_update_now guarantees the lock/pending bookkeeping
|
|
||||||
# is released via its own _finish() before returning or
|
|
||||||
# raising; this is a last-resort log only.
|
|
||||||
self.logger.exception("update worker: unexpected error for %s",
|
|
||||||
plugin_id)
|
|
||||||
|
|
||||||
def stop_update_worker(self, timeout: float = 5.0) -> None:
|
|
||||||
"""Signal the worker to exit (used by cleanup; thread is a daemon)."""
|
|
||||||
if self._update_worker is not None and self._update_worker.is_alive():
|
|
||||||
self._update_queue.put(None)
|
|
||||||
self._update_worker.join(timeout=timeout)
|
|
||||||
if self._update_worker.is_alive():
|
|
||||||
self.logger.warning(
|
|
||||||
"Update worker did not stop within %.1fs; it is a daemon "
|
|
||||||
"thread and will be abandoned on shutdown", timeout)
|
|
||||||
|
|
||||||
def _execute_update_now(self, plugin_id: str, plugin_instance: Any,
|
|
||||||
scheduled_time: float,
|
|
||||||
lock: Optional[threading.Lock] = None) -> None:
|
|
||||||
"""Execute a plugin's update() via PluginExecutor, then bookkeep.
|
|
||||||
|
|
||||||
Caller is responsible for having set RUNNING state.
|
|
||||||
|
|
||||||
On the synchronous path (``lock=None``) this is the original,
|
|
||||||
unchanged inline behavior. On the async worker path, PluginExecutor's
|
|
||||||
internal thread.join(timeout) blocks only the calling thread -- on
|
|
||||||
timeout the lingering daemon update-thread keeps running the real
|
|
||||||
plugin.update() call unkillable in the background. So that the
|
|
||||||
plugin's lock (and its RUNNING/pending lifecycle state) stays held
|
|
||||||
for that real duration rather than just this bounded wait, ownership
|
|
||||||
of both is carried by the wrapped update callable itself, released
|
|
||||||
from whichever thread actually finishes it -- see _finish() below.
|
|
||||||
"""
|
|
||||||
finish_guard = threading.Lock()
|
|
||||||
finished = {'done': False}
|
|
||||||
|
|
||||||
def _finish(success: bool, exc: Optional[Exception] = None) -> None:
|
|
||||||
with finish_guard:
|
|
||||||
if finished['done']:
|
|
||||||
return
|
|
||||||
finished['done'] = True
|
|
||||||
try:
|
|
||||||
if success:
|
|
||||||
with self._plugin_last_update_lock:
|
|
||||||
self.plugin_last_update[plugin_id] = scheduled_time
|
|
||||||
self.state_manager.record_update(plugin_id)
|
|
||||||
self.state_manager.set_state(plugin_id, PluginState.ENABLED)
|
|
||||||
if self.health_tracker:
|
|
||||||
self.health_tracker.record_success(plugin_id)
|
|
||||||
else:
|
|
||||||
self._record_update_failure(plugin_id, exc=exc)
|
self._record_update_failure(plugin_id, exc=exc)
|
||||||
finally:
|
|
||||||
if lock is not None:
|
|
||||||
lock.release()
|
|
||||||
with self._pending_lock:
|
|
||||||
self._pending_updates.discard(plugin_id)
|
|
||||||
|
|
||||||
if lock is None:
|
|
||||||
# Synchronous / no-lock path: unchanged behavior.
|
|
||||||
try:
|
|
||||||
if self.resource_monitor:
|
|
||||||
def monitored_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(
|
|
||||||
types.SimpleNamespace(update=monitored_update),
|
|
||||||
plugin_id
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
success = self.plugin_executor.execute_update(plugin_instance, plugin_id)
|
|
||||||
_finish(success)
|
|
||||||
except Exception as exc: # pylint: disable=broad-except
|
|
||||||
self.logger.exception("Error updating plugin %s: %s", plugin_id, exc)
|
|
||||||
_finish(False, exc=exc)
|
|
||||||
return
|
|
||||||
|
|
||||||
# Async worker path: the real update() call -- through the resource
|
|
||||||
# monitor, if configured -- owns finishing the lock/lifecycle
|
|
||||||
# bookkeeping, from whichever thread actually runs it to completion.
|
|
||||||
def _target_update() -> None:
|
|
||||||
try:
|
|
||||||
if self.resource_monitor:
|
|
||||||
self.resource_monitor.monitor_call(plugin_id, plugin_instance.update)
|
|
||||||
else:
|
|
||||||
plugin_instance.update()
|
|
||||||
except Exception as exc:
|
|
||||||
_finish(False, exc=exc)
|
|
||||||
raise
|
|
||||||
else:
|
|
||||||
_finish(True)
|
|
||||||
|
|
||||||
try:
|
|
||||||
self.plugin_executor.execute_update(
|
|
||||||
types.SimpleNamespace(update=_target_update), plugin_id)
|
|
||||||
except Exception as exc: # pragma: no cover - defensive; execute_update
|
|
||||||
# catches everything internally, but guarantee _finish still
|
|
||||||
# runs (releasing the lock) if something unexpected slips through.
|
|
||||||
self.logger.exception("Unexpected error dispatching update for %s: %s", plugin_id, exc)
|
|
||||||
_finish(False, exc=exc)
|
|
||||||
|
|
||||||
def run_scheduled_updates_with_changes(self, current_time: Optional[float] = None) -> List[str]:
|
def run_scheduled_updates_with_changes(self, current_time: Optional[float] = None) -> List[str]:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -88,27 +88,3 @@ def load_harness_spec(plugin_dir: Union[str, Path]) -> Dict[str, Any]:
|
|||||||
with open(mock_path, 'r') as mf:
|
with open(mock_path, 'r') as mf:
|
||||||
spec['mock_data_contents'] = json.load(mf)
|
spec['mock_data_contents'] = json.load(mf)
|
||||||
return spec
|
return spec
|
||||||
|
|
||||||
|
|
||||||
def build_full_config(
|
|
||||||
plugin_dir: Union[str, Path],
|
|
||||||
spec: Optional[Dict[str, Any]] = None,
|
|
||||||
cli_config: Optional[Dict[str, Any]] = None,
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""Build the config a plugin sees under test.
|
|
||||||
|
|
||||||
Merge order: config_schema.json defaults, then a forced ``enabled: True``,
|
|
||||||
then harness.json's config overlay, then the caller's explicit config --
|
|
||||||
most specific wins. `enabled` is re-asserted *after* the schema defaults
|
|
||||||
so a plugin that reasonably ships `enabled: false` (e.g. a seasonal or
|
|
||||||
opt-in plugin) can't silently make every harness run test "disabled, do
|
|
||||||
nothing" by accident -- callers that genuinely want to test the disabled
|
|
||||||
path can still do so via `cli_config={"enabled": False}`.
|
|
||||||
"""
|
|
||||||
spec = spec or {}
|
|
||||||
config: Dict[str, Any] = {}
|
|
||||||
config.update(load_config_defaults(plugin_dir))
|
|
||||||
config["enabled"] = True
|
|
||||||
config.update(spec.get("config", {}))
|
|
||||||
config.update(cli_config or {})
|
|
||||||
return config
|
|
||||||
|
|||||||
@@ -71,7 +71,6 @@ class MockCacheManager:
|
|||||||
self.get_calls = []
|
self.get_calls = []
|
||||||
self.set_calls = []
|
self.set_calls = []
|
||||||
self.delete_calls = []
|
self.delete_calls = []
|
||||||
self.get_cached_data_with_strategy_calls = []
|
|
||||||
# Real temp dir for plugins that write/read files under cache_dir.
|
# Real temp dir for plugins that write/read files under cache_dir.
|
||||||
# Registered for cleanup so each mock instance doesn't leak a tmp dir.
|
# Registered for cleanup so each mock instance doesn't leak a tmp dir.
|
||||||
self.cache_dir = tempfile.mkdtemp(prefix="ledmatrix-mock-cache-")
|
self.cache_dir = tempfile.mkdtemp(prefix="ledmatrix-mock-cache-")
|
||||||
@@ -109,24 +108,6 @@ class MockCacheManager:
|
|||||||
self.delete_calls.append(key)
|
self.delete_calls.append(key)
|
||||||
if key in self._cache:
|
if key in self._cache:
|
||||||
del self._cache[key]
|
del self._cache[key]
|
||||||
|
|
||||||
def get_cached_data_with_strategy(self, key: str, data_type: str = 'default') -> Optional[Any]:
|
|
||||||
"""Mock of CacheManager.get_cached_data_with_strategy (src/cache_manager.py).
|
|
||||||
|
|
||||||
The real method picks a max_age/memory_ttl strategy per data_type
|
|
||||||
(and extends it during market-closed hours for market data) before
|
|
||||||
delegating to get_cached_data(). None of that timing nuance matters
|
|
||||||
for a mock -- plugins under test just need the method to exist and
|
|
||||||
return whatever was cached, so this delegates straight to get().
|
|
||||||
"""
|
|
||||||
self.get_cached_data_with_strategy_calls.append({'key': key, 'data_type': data_type})
|
|
||||||
return self.get(key)
|
|
||||||
|
|
||||||
def save_cache(self, key: str, data: Any) -> None:
|
|
||||||
"""Mock of CacheManager.save_cache (src/cache_manager.py) -- the
|
|
||||||
write-side counterpart to get_cached_data_with_strategy, used by the
|
|
||||||
same real-CacheManager-oriented plugins. Delegates to set()."""
|
|
||||||
self.set(key, data)
|
|
||||||
if key in self._cache_timestamps:
|
if key in self._cache_timestamps:
|
||||||
del self._cache_timestamps[key]
|
del self._cache_timestamps[key]
|
||||||
|
|
||||||
@@ -137,7 +118,6 @@ class MockCacheManager:
|
|||||||
self.get_calls = []
|
self.get_calls = []
|
||||||
self.set_calls = []
|
self.set_calls = []
|
||||||
self.delete_calls = []
|
self.delete_calls = []
|
||||||
self.get_cached_data_with_strategy_calls = []
|
|
||||||
|
|
||||||
|
|
||||||
class MockConfigManager:
|
class MockConfigManager:
|
||||||
|
|||||||
@@ -2564,35 +2564,11 @@ address=/detectportal.firefox.com/192.168.4.1
|
|||||||
Returns:
|
Returns:
|
||||||
True if AP mode state changed, False otherwise
|
True if AP mode state changed, False otherwise
|
||||||
"""
|
"""
|
||||||
changed, _status, _ethernet, _ap = self.check_and_manage_ap_mode_with_state()
|
|
||||||
return changed
|
|
||||||
|
|
||||||
def check_and_manage_ap_mode_with_state(self) -> Tuple[bool, WiFiStatus, bool, bool]:
|
|
||||||
"""Like check_and_manage_ap_mode, but also returns the state it
|
|
||||||
observed, so callers (the wifi monitor daemon) don't have to re-run
|
|
||||||
the same nmcli subprocess battery before AND after the check —
|
|
||||||
each status fetch is several process forks.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
(state_changed, WiFiStatus, ethernet_connected, ap_active_after)
|
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
# Get status with retry for more reliable detection
|
# Get status with retry for more reliable detection
|
||||||
status = self._get_wifi_status_with_retry()
|
status = self._get_wifi_status_with_retry()
|
||||||
ethernet_connected = self._is_ethernet_connected()
|
ethernet_connected = self._is_ethernet_connected()
|
||||||
ap_active = self._is_ap_mode_active()
|
ap_active = self._is_ap_mode_active()
|
||||||
changed = self._manage_ap_mode(status, ethernet_connected, ap_active)
|
|
||||||
# State only ever changes via one enable or one disable, so the
|
|
||||||
# post-state is the inverse of the pre-state when changed.
|
|
||||||
ap_after = (not ap_active) if changed else ap_active
|
|
||||||
return changed, status, ethernet_connected, ap_after
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error checking AP mode: {e}", exc_info=True)
|
|
||||||
return False, WiFiStatus(connected=False), False, False
|
|
||||||
|
|
||||||
def _manage_ap_mode(self, status: WiFiStatus, ethernet_connected: bool, ap_active: bool) -> bool:
|
|
||||||
"""AP-mode decision logic against an already-fetched state snapshot."""
|
|
||||||
try:
|
|
||||||
auto_enable = self.config.get("auto_enable_ap_mode", True) # Default: True (safe due to grace period)
|
auto_enable = self.config.get("auto_enable_ap_mode", True) # Default: True (safe due to grace period)
|
||||||
|
|
||||||
# Log current state for debugging
|
# Log current state for debugging
|
||||||
|
|||||||
@@ -253,61 +253,3 @@ class TestCheckPluginHonorsHarnessJson:
|
|||||||
)
|
)
|
||||||
assert captured["freeze_time"] == "2030-01-01 00:00:00"
|
assert captured["freeze_time"] == "2030-01-01 00:00:00"
|
||||||
assert captured["config"]["timezone"] == "America/New_York"
|
assert captured["config"]["timezone"] == "America/New_York"
|
||||||
|
|
||||||
|
|
||||||
class TestBuildFullConfigForcesEnabled:
|
|
||||||
"""Regression: a plugin's own config_schema.json may reasonably default
|
|
||||||
enabled to False (e.g. a seasonal or opt-in plugin) -- march-madness and
|
|
||||||
14 other real plugins do. The harness must still test it as enabled
|
|
||||||
unless a caller explicitly asks otherwise, or every render silently
|
|
||||||
becomes a same-shaped "disabled, do nothing" no-op."""
|
|
||||||
|
|
||||||
def _make_plugin_with_disabled_default(self, tmp_path):
|
|
||||||
pdir = tmp_path / "plugins" / "demo-seasonal"
|
|
||||||
pdir.mkdir(parents=True)
|
|
||||||
(pdir / "manifest.json").write_text(json.dumps({
|
|
||||||
"id": "demo-seasonal", "name": "Demo Seasonal", "version": "1.0.0",
|
|
||||||
"author": "test", "entry_point": "manager.py",
|
|
||||||
"class_name": "DemoSeasonal", "display_modes": ["demo-seasonal"],
|
|
||||||
"compatible_versions": ["*"],
|
|
||||||
}))
|
|
||||||
(pdir / "config_schema.json").write_text(json.dumps({
|
|
||||||
"type": "object",
|
|
||||||
"properties": {"enabled": {"type": "boolean", "default": False}},
|
|
||||||
}))
|
|
||||||
return pdir
|
|
||||||
|
|
||||||
def test_schema_disabled_default_does_not_win(self, tmp_path):
|
|
||||||
from src.plugin_system.testing.loading import build_full_config
|
|
||||||
plugin_dir = self._make_plugin_with_disabled_default(tmp_path)
|
|
||||||
config = build_full_config(plugin_dir)
|
|
||||||
assert config["enabled"] is True
|
|
||||||
|
|
||||||
def test_harness_json_config_can_still_disable(self, tmp_path):
|
|
||||||
from src.plugin_system.testing.loading import build_full_config
|
|
||||||
plugin_dir = self._make_plugin_with_disabled_default(tmp_path)
|
|
||||||
config = build_full_config(plugin_dir, spec={"config": {"enabled": False}})
|
|
||||||
assert config["enabled"] is False
|
|
||||||
|
|
||||||
def test_explicit_cli_config_can_still_disable(self, tmp_path):
|
|
||||||
from src.plugin_system.testing.loading import build_full_config
|
|
||||||
plugin_dir = self._make_plugin_with_disabled_default(tmp_path)
|
|
||||||
config = build_full_config(plugin_dir, cli_config={"enabled": False})
|
|
||||||
assert config["enabled"] is False
|
|
||||||
|
|
||||||
def test_check_one_renders_a_schema_disabled_plugin_as_enabled(self, tmp_path, monkeypatch):
|
|
||||||
"""End-to-end: check_plugin.py's check_one() must not blank-render a
|
|
||||||
plugin just because its own schema defaults enabled to False."""
|
|
||||||
mod = _load_check_plugin_cli()
|
|
||||||
plugin_dir = self._make_plugin_with_disabled_default(tmp_path)
|
|
||||||
captured = {}
|
|
||||||
monkeypatch.setattr(mod, "render_plugin_matrix",
|
|
||||||
lambda **kw: captured.update(kw) or [])
|
|
||||||
monkeypatch.setattr(mod, "compare_to_goldens", lambda *a, **k: [])
|
|
||||||
mod.check_one(
|
|
||||||
plugin_id="demo-seasonal", search_dirs=[str(tmp_path / "plugins")],
|
|
||||||
sizes=None, mock_data={}, config={}, run_update=True,
|
|
||||||
out_dir=None, update_golden=False, golden_dir_override=None,
|
|
||||||
freeze_time=None,
|
|
||||||
)
|
|
||||||
assert captured["config"]["enabled"] is True
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import pytest
|
|||||||
from src.plugin_system.testing.harness import (
|
from src.plugin_system.testing.harness import (
|
||||||
render_plugin_matrix, compare_to_goldens,
|
render_plugin_matrix, compare_to_goldens,
|
||||||
)
|
)
|
||||||
from src.plugin_system.testing.loading import build_full_config, load_harness_spec
|
from src.plugin_system.testing.loading import load_config_defaults, load_harness_spec
|
||||||
from src.plugin_system.testing.sizes import resolve_test_sizes
|
from src.plugin_system.testing.sizes import resolve_test_sizes
|
||||||
|
|
||||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
@@ -81,7 +81,9 @@ def test_plugin_renders_across_sizes_and_screens(plugin_id: str) -> None:
|
|||||||
plugin_dir = _PLUGINS[plugin_id]
|
plugin_dir = _PLUGINS[plugin_id]
|
||||||
spec = load_harness_spec(plugin_dir)
|
spec = load_harness_spec(plugin_dir)
|
||||||
|
|
||||||
config = build_full_config(plugin_dir, spec)
|
config = {"enabled": True}
|
||||||
|
config.update(load_config_defaults(plugin_dir))
|
||||||
|
config.update(spec.get("config", {}))
|
||||||
|
|
||||||
# Sizes: LEDMATRIX_TEST_SIZES env (test on real hardware) wins, then the
|
# Sizes: LEDMATRIX_TEST_SIZES env (test on real hardware) wins, then the
|
||||||
# plugin's own harness.json "sizes", else the default representative sample.
|
# plugin's own harness.json "sizes", else the default representative sample.
|
||||||
|
|||||||
@@ -1,270 +0,0 @@
|
|||||||
"""Tests for asynchronous plugin updates (plugin_manager background worker).
|
|
||||||
|
|
||||||
The invariants that keep this change safe:
|
|
||||||
1. run_scheduled_updates returns immediately — a slow update() can never
|
|
||||||
again freeze the render loop (the original defect: 30s scroll freezes).
|
|
||||||
2. A plugin's update() and display() are NEVER concurrent — the per-plugin
|
|
||||||
lock makes the old implicit no-overlap guarantee explicit, and it stays
|
|
||||||
held through PluginExecutor's own timeout: a lingering, still-running
|
|
||||||
update() keeps the lock even after PluginExecutor gives up waiting on it.
|
|
||||||
3. Failure/timeout bookkeeping is unchanged (same executor, same
|
|
||||||
_record_update_failure path, same last-update stamping).
|
|
||||||
4. The kill switch (plugin_system.synchronous_updates) restores the
|
|
||||||
inline path exactly.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
|
||||||
|
|
||||||
from src.plugin_system.plugin_manager import PluginManager # noqa: E402
|
|
||||||
from src.plugin_system.plugin_state import PluginState # noqa: E402
|
|
||||||
|
|
||||||
|
|
||||||
class SlowPlugin:
|
|
||||||
"""Fake plugin whose update() sleeps and records overlap violations."""
|
|
||||||
|
|
||||||
def __init__(self, update_seconds=0.5):
|
|
||||||
self.enabled = True
|
|
||||||
self.update_seconds = update_seconds
|
|
||||||
self.update_calls = 0
|
|
||||||
self.display_calls = 0
|
|
||||||
self.in_update = False
|
|
||||||
self.overlap_detected = False
|
|
||||||
|
|
||||||
def update(self):
|
|
||||||
self.in_update = True
|
|
||||||
self.update_calls += 1
|
|
||||||
time.sleep(self.update_seconds)
|
|
||||||
self.in_update = False
|
|
||||||
return True
|
|
||||||
|
|
||||||
def display(self, force_clear=False):
|
|
||||||
if self.in_update:
|
|
||||||
self.overlap_detected = True
|
|
||||||
self.display_calls += 1
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def pm(tmp_path):
|
|
||||||
manager = PluginManager(plugins_dir=str(tmp_path), config_manager=None,
|
|
||||||
display_manager=None, cache_manager=None)
|
|
||||||
yield manager
|
|
||||||
manager.stop_update_worker()
|
|
||||||
|
|
||||||
|
|
||||||
def _install(pm, plugin, plugin_id="slow-plugin"):
|
|
||||||
pm.plugins[plugin_id] = plugin
|
|
||||||
pm._update_interval_cache[plugin_id] = 0.01 # always due
|
|
||||||
# load_plugin normally registers state; can_execute() gates on it
|
|
||||||
pm.state_manager.set_state(plugin_id, PluginState.ENABLED)
|
|
||||||
return plugin_id
|
|
||||||
|
|
||||||
|
|
||||||
class TestSchedulerNonBlocking:
|
|
||||||
def test_run_scheduled_updates_returns_immediately(self, pm):
|
|
||||||
plugin_id = _install(pm, SlowPlugin(update_seconds=2.0))
|
|
||||||
start = time.monotonic()
|
|
||||||
pm.run_scheduled_updates()
|
|
||||||
elapsed = time.monotonic() - start
|
|
||||||
assert elapsed < 0.1, f"scheduler blocked for {elapsed:.2f}s"
|
|
||||||
# the update actually runs in the background
|
|
||||||
deadline = time.monotonic() + 5
|
|
||||||
while pm.plugins[plugin_id].update_calls == 0 and time.monotonic() < deadline:
|
|
||||||
time.sleep(0.05)
|
|
||||||
assert pm.plugins[plugin_id].update_calls == 1
|
|
||||||
|
|
||||||
def test_no_double_enqueue_while_pending(self, pm):
|
|
||||||
plugin_id = _install(pm, SlowPlugin(update_seconds=0.8))
|
|
||||||
for _ in range(20):
|
|
||||||
pm.run_scheduled_updates()
|
|
||||||
time.sleep(0.01)
|
|
||||||
time.sleep(1.5) # let the single queued update finish
|
|
||||||
assert pm.plugins[plugin_id].update_calls == 1
|
|
||||||
|
|
||||||
|
|
||||||
class TestUpdateDisplayExclusion:
|
|
||||||
def test_display_lock_held_during_update(self, pm):
|
|
||||||
plugin_id = _install(pm, SlowPlugin(update_seconds=0.6))
|
|
||||||
pm.run_scheduled_updates()
|
|
||||||
# give the worker a moment to take the lock and enter update()
|
|
||||||
deadline = time.monotonic() + 2
|
|
||||||
while not pm.plugins[plugin_id].in_update and time.monotonic() < deadline:
|
|
||||||
time.sleep(0.01)
|
|
||||||
lock = pm.get_plugin_lock(plugin_id)
|
|
||||||
assert lock.acquire(blocking=False) is False, \
|
|
||||||
"lock must be held while update() runs"
|
|
||||||
# and released afterwards
|
|
||||||
deadline = time.monotonic() + 3
|
|
||||||
while pm.plugins[plugin_id].in_update and time.monotonic() < deadline:
|
|
||||||
time.sleep(0.05)
|
|
||||||
time.sleep(0.1)
|
|
||||||
assert lock.acquire(blocking=False) is True
|
|
||||||
lock.release()
|
|
||||||
|
|
||||||
def test_no_overlap_under_hammering_display_loop(self, pm):
|
|
||||||
"""Simulate the render loop's try-lock display pattern at high rate
|
|
||||||
while updates fire — the plugin itself asserts no overlap."""
|
|
||||||
plugin = SlowPlugin(update_seconds=0.15)
|
|
||||||
plugin_id = _install(pm, plugin)
|
|
||||||
stop = threading.Event()
|
|
||||||
|
|
||||||
def render_loop():
|
|
||||||
while not stop.is_set():
|
|
||||||
lock = pm.get_plugin_lock(plugin_id)
|
|
||||||
if lock.acquire(blocking=False):
|
|
||||||
try:
|
|
||||||
plugin.display()
|
|
||||||
finally:
|
|
||||||
lock.release()
|
|
||||||
time.sleep(0.002)
|
|
||||||
|
|
||||||
renderer = threading.Thread(target=render_loop, daemon=True)
|
|
||||||
renderer.start()
|
|
||||||
try:
|
|
||||||
for _ in range(6):
|
|
||||||
pm.plugin_last_update.pop(plugin_id, None) # force due
|
|
||||||
pm.run_scheduled_updates()
|
|
||||||
time.sleep(0.3)
|
|
||||||
finally:
|
|
||||||
stop.set()
|
|
||||||
renderer.join(timeout=2)
|
|
||||||
assert plugin.update_calls >= 3
|
|
||||||
assert plugin.display_calls > 10
|
|
||||||
assert plugin.overlap_detected is False
|
|
||||||
|
|
||||||
def test_lock_held_through_timeout_until_real_update_finishes(self, pm):
|
|
||||||
"""PluginExecutor's join(timeout) can return before the real
|
|
||||||
update() call does -- the lingering daemon thread keeps running it.
|
|
||||||
The plugin's lock must stay held for that whole real duration, not
|
|
||||||
just PluginExecutor's bounded wait, or display() could run
|
|
||||||
concurrently with a still-executing update()."""
|
|
||||||
plugin = SlowPlugin(update_seconds=0.3)
|
|
||||||
plugin_id = _install(pm, plugin)
|
|
||||||
pm.plugin_executor.default_timeout = 0.05 # times out well before
|
|
||||||
# update_seconds elapses
|
|
||||||
|
|
||||||
pm.run_scheduled_updates()
|
|
||||||
|
|
||||||
deadline = time.monotonic() + 2
|
|
||||||
while not plugin.in_update and time.monotonic() < deadline:
|
|
||||||
time.sleep(0.01)
|
|
||||||
assert plugin.in_update is True
|
|
||||||
|
|
||||||
# PluginExecutor's own timeout has now elapsed, but the real
|
|
||||||
# update() (0.3s) is still running in its lingering daemon thread.
|
|
||||||
time.sleep(0.15)
|
|
||||||
assert plugin.in_update is True, "test setup: update should still be running"
|
|
||||||
lock = pm.get_plugin_lock(plugin_id)
|
|
||||||
assert lock.acquire(blocking=False) is False, \
|
|
||||||
"lock must stay held through PluginExecutor's timeout while the real update() runs"
|
|
||||||
|
|
||||||
# Once the real update() genuinely finishes, the lock is released.
|
|
||||||
deadline = time.monotonic() + 2
|
|
||||||
while plugin.in_update and time.monotonic() < deadline:
|
|
||||||
time.sleep(0.02)
|
|
||||||
time.sleep(0.1)
|
|
||||||
assert lock.acquire(blocking=False) is True
|
|
||||||
lock.release()
|
|
||||||
|
|
||||||
def test_state_returns_to_enabled_after_update(self, pm):
|
|
||||||
"""RUNNING is set at enqueue (blocks re-entry via can_execute) and
|
|
||||||
must return to an executable state once the update finishes."""
|
|
||||||
plugin_id = _install(pm, SlowPlugin(update_seconds=0.1))
|
|
||||||
pm.run_scheduled_updates()
|
|
||||||
# while queued/running, re-entry is blocked
|
|
||||||
assert pm.state_manager.can_execute(plugin_id) is False
|
|
||||||
deadline = time.monotonic() + 3
|
|
||||||
while time.monotonic() < deadline:
|
|
||||||
if (pm.plugins[plugin_id].update_calls
|
|
||||||
and pm.state_manager.can_execute(plugin_id)):
|
|
||||||
break
|
|
||||||
time.sleep(0.05)
|
|
||||||
assert pm.plugins[plugin_id].update_calls == 1
|
|
||||||
assert pm.state_manager.can_execute(plugin_id) is True
|
|
||||||
|
|
||||||
|
|
||||||
class TestFailurePaths:
|
|
||||||
def test_update_failure_routes_through_failure_bookkeeping(self, pm):
|
|
||||||
class FailingPlugin(SlowPlugin):
|
|
||||||
def update(self):
|
|
||||||
self.update_calls += 1
|
|
||||||
raise RuntimeError("boom")
|
|
||||||
|
|
||||||
plugin_id = _install(pm, FailingPlugin())
|
|
||||||
pm.run_scheduled_updates()
|
|
||||||
deadline = time.monotonic() + 3
|
|
||||||
while pm.plugins[plugin_id].update_calls == 0 and time.monotonic() < deadline:
|
|
||||||
time.sleep(0.05)
|
|
||||||
time.sleep(0.2)
|
|
||||||
# failure stamped so the interval gate holds (no hot retry loop)
|
|
||||||
assert pm.plugin_last_update.get(plugin_id, 0) > 0
|
|
||||||
# lock released after failure
|
|
||||||
assert pm.get_plugin_lock(plugin_id).acquire(blocking=False) is True
|
|
||||||
pm.get_plugin_lock(plugin_id).release()
|
|
||||||
|
|
||||||
def test_unloaded_while_queued_is_harmless(self, pm):
|
|
||||||
"""Exercise the public unload_plugin() lifecycle rather than
|
|
||||||
deleting pm.plugins directly: queue the target's update behind a
|
|
||||||
deterministic blocker (occupying the single worker), unload the
|
|
||||||
target while its item still sits queued, then release the blocker
|
|
||||||
and confirm the target's update never ran and its state stayed
|
|
||||||
unloaded rather than being resurrected to ENABLED."""
|
|
||||||
blocker_event = threading.Event()
|
|
||||||
|
|
||||||
class BlockerPlugin(SlowPlugin):
|
|
||||||
def update(self):
|
|
||||||
self.update_calls += 1
|
|
||||||
blocker_event.wait(timeout=5)
|
|
||||||
return True
|
|
||||||
|
|
||||||
blocker_id = _install(pm, BlockerPlugin(), plugin_id="blocker-plugin")
|
|
||||||
target = SlowPlugin(update_seconds=0.05)
|
|
||||||
target_id = _install(pm, target, plugin_id="slow-plugin")
|
|
||||||
|
|
||||||
# Dispatch the blocker first so it occupies the single worker
|
|
||||||
# thread, then enqueue the target behind it -- deterministically
|
|
||||||
# queued, not yet started.
|
|
||||||
pm._enqueue_update(blocker_id, time.time())
|
|
||||||
deadline = time.monotonic() + 2
|
|
||||||
while pm.plugins[blocker_id].update_calls == 0 and time.monotonic() < deadline:
|
|
||||||
time.sleep(0.01)
|
|
||||||
assert pm.plugins[blocker_id].update_calls == 1
|
|
||||||
|
|
||||||
pm._enqueue_update(target_id, time.time())
|
|
||||||
assert target_id in pm._pending_updates
|
|
||||||
|
|
||||||
assert pm.unload_plugin(target_id) is True
|
|
||||||
assert target_id not in pm.plugins
|
|
||||||
|
|
||||||
blocker_event.set() # let the blocker finish; worker moves on to
|
|
||||||
# the target's queued item
|
|
||||||
|
|
||||||
deadline = time.monotonic() + 3
|
|
||||||
while target_id in pm._pending_updates and time.monotonic() < deadline:
|
|
||||||
time.sleep(0.02)
|
|
||||||
|
|
||||||
assert target.update_calls == 0, "update() must not run for an unloaded plugin"
|
|
||||||
assert target_id not in pm._pending_updates
|
|
||||||
assert pm.state_manager.get_state(target_id) == PluginState.UNLOADED
|
|
||||||
|
|
||||||
|
|
||||||
class TestKillSwitch:
|
|
||||||
def test_synchronous_mode_blocks_like_before(self, pm):
|
|
||||||
pm._synchronous_updates = True
|
|
||||||
plugin_id = _install(pm, SlowPlugin(update_seconds=0.4))
|
|
||||||
start = time.monotonic()
|
|
||||||
pm.run_scheduled_updates()
|
|
||||||
elapsed = time.monotonic() - start
|
|
||||||
assert elapsed >= 0.4, "synchronous mode must run inline"
|
|
||||||
assert pm.plugins[plugin_id].update_calls == 1
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(pytest.main([__file__, "-v"]))
|
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
"""Tests for load_config's mtime fast path (src/config_manager.py).
|
|
||||||
|
|
||||||
load_config used to re-read + re-parse config.json, the template (with a
|
|
||||||
recursive migration diff) and secrets on EVERY call — ~30 web request
|
|
||||||
handlers call it, some 2-3x per request. The fast path skips all of it
|
|
||||||
when the three files' (mtime_ns, size) signatures are unchanged.
|
|
||||||
|
|
||||||
The invariant that matters most: cross-process freshness — a save from
|
|
||||||
the web process must be picked up by the display process's next load.
|
|
||||||
That's guaranteed because the signature is re-stat'd on every call.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
|
||||||
|
|
||||||
from src.config_manager import ConfigManager # noqa: E402
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def mgr(tmp_path):
|
|
||||||
config = tmp_path / "config.json"
|
|
||||||
secrets = tmp_path / "secrets.json"
|
|
||||||
template = tmp_path / "template.json"
|
|
||||||
config.write_text(json.dumps({"display": {"brightness": 90}, "timezone": "UTC"}))
|
|
||||||
secrets.write_text(json.dumps({"weather": {"api_key": "sek"}}))
|
|
||||||
template.write_text(json.dumps({"display": {"brightness": 90}, "timezone": "UTC"}))
|
|
||||||
m = ConfigManager(config_path=str(config), secrets_path=str(secrets))
|
|
||||||
m.template_path = str(template)
|
|
||||||
return m, config, secrets, template
|
|
||||||
|
|
||||||
|
|
||||||
def _count_opens(monkeypatch, mgr_paths):
|
|
||||||
"""Count open() calls hitting the config files."""
|
|
||||||
counts = {"n": 0}
|
|
||||||
real_open = open
|
|
||||||
|
|
||||||
def counting_open(file, *args, **kwargs):
|
|
||||||
if str(file) in mgr_paths:
|
|
||||||
counts["n"] += 1
|
|
||||||
return real_open(file, *args, **kwargs)
|
|
||||||
|
|
||||||
import builtins
|
|
||||||
monkeypatch.setattr(builtins, "open", counting_open)
|
|
||||||
return counts
|
|
||||||
|
|
||||||
|
|
||||||
class TestFastPath:
|
|
||||||
def test_unchanged_files_are_not_reread(self, mgr, monkeypatch):
|
|
||||||
m, config, secrets, template = mgr
|
|
||||||
first = m.load_config()
|
|
||||||
assert first["weather"]["api_key"] == "sek" # secrets merged
|
|
||||||
counts = _count_opens(monkeypatch, {str(config), str(secrets), str(template)})
|
|
||||||
for _ in range(10):
|
|
||||||
again = m.load_config()
|
|
||||||
assert counts["n"] == 0, "fast path must not re-open any config file"
|
|
||||||
assert again is first # same aliasing semantics as the full path
|
|
||||||
|
|
||||||
def test_config_change_triggers_reload(self, mgr):
|
|
||||||
m, config, secrets, template = mgr
|
|
||||||
m.load_config()
|
|
||||||
data = json.loads(config.read_text())
|
|
||||||
data["display"]["brightness"] = 55
|
|
||||||
config.write_text(json.dumps(data))
|
|
||||||
os.utime(config, (os.stat(config).st_atime, os.stat(config).st_mtime + 2))
|
|
||||||
assert m.load_config()["display"]["brightness"] == 55
|
|
||||||
|
|
||||||
def test_secrets_change_triggers_reload(self, mgr):
|
|
||||||
m, config, secrets, template = mgr
|
|
||||||
m.load_config()
|
|
||||||
secrets.write_text(json.dumps({"weather": {"api_key": "NEW"}}))
|
|
||||||
os.utime(secrets, (os.stat(secrets).st_atime, os.stat(secrets).st_mtime + 2))
|
|
||||||
assert m.load_config()["weather"]["api_key"] == "NEW"
|
|
||||||
|
|
||||||
def test_template_change_triggers_reload_and_migration(self, mgr):
|
|
||||||
m, config, secrets, template = mgr
|
|
||||||
m.load_config()
|
|
||||||
template.write_text(json.dumps({
|
|
||||||
"display": {"brightness": 90}, "timezone": "UTC",
|
|
||||||
"brand_new_key": {"added": True}}))
|
|
||||||
os.utime(template, (os.stat(template).st_atime, os.stat(template).st_mtime + 2))
|
|
||||||
reloaded = m.load_config()
|
|
||||||
assert reloaded.get("brand_new_key") == {"added": True}
|
|
||||||
|
|
||||||
def test_same_second_edit_detected_via_mtime_ns_or_size(self, mgr):
|
|
||||||
"""Coarse-mtime same-second edits: size difference still busts it."""
|
|
||||||
m, config, secrets, template = mgr
|
|
||||||
m.load_config()
|
|
||||||
st = os.stat(config)
|
|
||||||
data = json.loads(config.read_text())
|
|
||||||
data["timezone"] = "America/New_York" # different byte length
|
|
||||||
config.write_text(json.dumps(data))
|
|
||||||
os.utime(config, (st.st_atime, st.st_mtime)) # force same mtime
|
|
||||||
assert m.load_config()["timezone"] == "America/New_York"
|
|
||||||
|
|
||||||
|
|
||||||
class TestSaveCoherence:
|
|
||||||
def test_save_config_then_load_returns_saved_data(self, mgr, monkeypatch):
|
|
||||||
m, config, secrets, template = mgr
|
|
||||||
m.load_config()
|
|
||||||
new = {"display": {"brightness": 42}, "timezone": "UTC",
|
|
||||||
"weather": {"api_key": "sek"}}
|
|
||||||
m.save_config(new)
|
|
||||||
counts = _count_opens(monkeypatch, {str(config), str(secrets), str(template)})
|
|
||||||
loaded = m.load_config()
|
|
||||||
assert loaded["display"]["brightness"] == 42
|
|
||||||
assert loaded["weather"]["api_key"] == "sek" # secrets survive in memory
|
|
||||||
assert counts["n"] == 0 # signature refreshed by save; no re-read
|
|
||||||
|
|
||||||
def test_cross_process_save_is_picked_up(self, mgr):
|
|
||||||
"""Another process writing config.json (different mtime) must bust
|
|
||||||
this process's fast path — the core cross-process guarantee."""
|
|
||||||
m, config, secrets, template = mgr
|
|
||||||
m.load_config()
|
|
||||||
other = ConfigManager(config_path=str(config), secrets_path=str(secrets))
|
|
||||||
other.template_path = str(template)
|
|
||||||
other.load_config()
|
|
||||||
other.save_config({"display": {"brightness": 11}, "timezone": "UTC",
|
|
||||||
"weather": {"api_key": "sek"}})
|
|
||||||
os.utime(config, (os.stat(config).st_atime, os.stat(config).st_mtime + 2))
|
|
||||||
assert m.load_config()["display"]["brightness"] == 11
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(pytest.main([__file__, "-v"]))
|
|
||||||
@@ -0,0 +1,392 @@
|
|||||||
|
"""
|
||||||
|
Tests for LayoutManager.
|
||||||
|
|
||||||
|
Tests layout creation, management, rendering, and element positioning.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import json
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
from src.layout_manager import LayoutManager
|
||||||
|
|
||||||
|
|
||||||
|
class TestLayoutManager:
|
||||||
|
"""Test LayoutManager functionality."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def tmp_layout_file(self, tmp_path):
|
||||||
|
"""Create a temporary layout file."""
|
||||||
|
layout_file = tmp_path / "custom_layouts.json"
|
||||||
|
return str(layout_file)
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_display_manager(self):
|
||||||
|
"""Create a mock display manager."""
|
||||||
|
dm = MagicMock()
|
||||||
|
dm.clear = MagicMock()
|
||||||
|
dm.update_display = MagicMock()
|
||||||
|
dm.draw_text = MagicMock()
|
||||||
|
dm.draw_weather_icon = MagicMock()
|
||||||
|
dm.small_font = MagicMock()
|
||||||
|
dm.regular_font = MagicMock()
|
||||||
|
return dm
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def layout_manager(self, tmp_layout_file, mock_display_manager):
|
||||||
|
"""Create a LayoutManager instance."""
|
||||||
|
return LayoutManager(
|
||||||
|
display_manager=mock_display_manager,
|
||||||
|
config_path=tmp_layout_file
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_init(self, tmp_layout_file, mock_display_manager):
|
||||||
|
"""Test LayoutManager initialization."""
|
||||||
|
lm = LayoutManager(
|
||||||
|
display_manager=mock_display_manager,
|
||||||
|
config_path=tmp_layout_file
|
||||||
|
)
|
||||||
|
|
||||||
|
assert lm.display_manager == mock_display_manager
|
||||||
|
assert lm.config_path == tmp_layout_file
|
||||||
|
assert lm.layouts == {}
|
||||||
|
assert lm.current_layout is None
|
||||||
|
|
||||||
|
def test_load_layouts_file_exists(self, tmp_path, mock_display_manager):
|
||||||
|
"""Test loading layouts from existing file."""
|
||||||
|
layout_file = tmp_path / "custom_layouts.json"
|
||||||
|
layout_data = {
|
||||||
|
"test_layout": {
|
||||||
|
"elements": [{"type": "text", "x": 0, "y": 0}],
|
||||||
|
"description": "Test layout"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
with open(layout_file, 'w') as f:
|
||||||
|
json.dump(layout_data, f)
|
||||||
|
|
||||||
|
lm = LayoutManager(
|
||||||
|
display_manager=mock_display_manager,
|
||||||
|
config_path=str(layout_file)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "test_layout" in lm.layouts
|
||||||
|
assert lm.layouts["test_layout"]["description"] == "Test layout"
|
||||||
|
|
||||||
|
def test_load_layouts_file_not_exists(self, tmp_layout_file, mock_display_manager):
|
||||||
|
"""Test loading layouts when file doesn't exist."""
|
||||||
|
lm = LayoutManager(
|
||||||
|
display_manager=mock_display_manager,
|
||||||
|
config_path=tmp_layout_file
|
||||||
|
)
|
||||||
|
|
||||||
|
assert lm.layouts == {}
|
||||||
|
|
||||||
|
def test_create_layout(self, layout_manager):
|
||||||
|
"""Test creating a new layout."""
|
||||||
|
elements = [{"type": "text", "x": 10, "y": 20, "properties": {"text": "Hello"}}]
|
||||||
|
|
||||||
|
result = layout_manager.create_layout("test_layout", elements, "Test description")
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
assert "test_layout" in layout_manager.layouts
|
||||||
|
assert layout_manager.layouts["test_layout"]["elements"] == elements
|
||||||
|
assert layout_manager.layouts["test_layout"]["description"] == "Test description"
|
||||||
|
assert "created" in layout_manager.layouts["test_layout"]
|
||||||
|
assert "modified" in layout_manager.layouts["test_layout"]
|
||||||
|
|
||||||
|
def test_update_layout(self, layout_manager):
|
||||||
|
"""Test updating an existing layout."""
|
||||||
|
# Create a layout first
|
||||||
|
elements1 = [{"type": "text", "x": 0, "y": 0}]
|
||||||
|
layout_manager.create_layout("test_layout", elements1, "Original")
|
||||||
|
|
||||||
|
# Update it
|
||||||
|
elements2 = [{"type": "text", "x": 10, "y": 20}]
|
||||||
|
result = layout_manager.update_layout("test_layout", elements2, "Updated")
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
assert layout_manager.layouts["test_layout"]["elements"] == elements2
|
||||||
|
assert layout_manager.layouts["test_layout"]["description"] == "Updated"
|
||||||
|
assert "modified" in layout_manager.layouts["test_layout"]
|
||||||
|
|
||||||
|
def test_update_layout_not_exists(self, layout_manager):
|
||||||
|
"""Test updating a non-existent layout."""
|
||||||
|
elements = [{"type": "text", "x": 0, "y": 0}]
|
||||||
|
result = layout_manager.update_layout("nonexistent", elements)
|
||||||
|
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
def test_delete_layout(self, layout_manager):
|
||||||
|
"""Test deleting a layout."""
|
||||||
|
elements = [{"type": "text", "x": 0, "y": 0}]
|
||||||
|
layout_manager.create_layout("test_layout", elements)
|
||||||
|
|
||||||
|
result = layout_manager.delete_layout("test_layout")
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
assert "test_layout" not in layout_manager.layouts
|
||||||
|
|
||||||
|
def test_delete_layout_not_exists(self, layout_manager):
|
||||||
|
"""Test deleting a non-existent layout."""
|
||||||
|
result = layout_manager.delete_layout("nonexistent")
|
||||||
|
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
def test_get_layout(self, layout_manager):
|
||||||
|
"""Test getting a specific layout."""
|
||||||
|
elements = [{"type": "text", "x": 0, "y": 0}]
|
||||||
|
layout_manager.create_layout("test_layout", elements)
|
||||||
|
|
||||||
|
layout = layout_manager.get_layout("test_layout")
|
||||||
|
|
||||||
|
assert layout is not None
|
||||||
|
assert layout["elements"] == elements
|
||||||
|
|
||||||
|
def test_get_layout_not_exists(self, layout_manager):
|
||||||
|
"""Test getting a non-existent layout."""
|
||||||
|
layout = layout_manager.get_layout("nonexistent")
|
||||||
|
|
||||||
|
assert layout == {}
|
||||||
|
|
||||||
|
def test_list_layouts(self, layout_manager):
|
||||||
|
"""Test listing all layouts."""
|
||||||
|
layout_manager.create_layout("layout1", [])
|
||||||
|
layout_manager.create_layout("layout2", [])
|
||||||
|
layout_manager.create_layout("layout3", [])
|
||||||
|
|
||||||
|
layouts = layout_manager.list_layouts()
|
||||||
|
|
||||||
|
assert len(layouts) == 3
|
||||||
|
assert "layout1" in layouts
|
||||||
|
assert "layout2" in layouts
|
||||||
|
assert "layout3" in layouts
|
||||||
|
|
||||||
|
def test_set_current_layout(self, layout_manager):
|
||||||
|
"""Test setting the current layout."""
|
||||||
|
layout_manager.create_layout("test_layout", [])
|
||||||
|
|
||||||
|
result = layout_manager.set_current_layout("test_layout")
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
assert layout_manager.current_layout == "test_layout"
|
||||||
|
|
||||||
|
def test_set_current_layout_not_exists(self, layout_manager):
|
||||||
|
"""Test setting a non-existent layout as current."""
|
||||||
|
result = layout_manager.set_current_layout("nonexistent")
|
||||||
|
|
||||||
|
assert result is False
|
||||||
|
assert layout_manager.current_layout is None
|
||||||
|
|
||||||
|
def test_render_layout(self, layout_manager, mock_display_manager):
|
||||||
|
"""Test rendering a layout."""
|
||||||
|
elements = [
|
||||||
|
{"type": "text", "x": 0, "y": 0, "properties": {"text": "Hello"}},
|
||||||
|
{"type": "text", "x": 10, "y": 10, "properties": {"text": "World"}}
|
||||||
|
]
|
||||||
|
layout_manager.create_layout("test_layout", elements)
|
||||||
|
|
||||||
|
result = layout_manager.render_layout("test_layout")
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
mock_display_manager.clear.assert_called_once()
|
||||||
|
mock_display_manager.update_display.assert_called_once()
|
||||||
|
assert mock_display_manager.draw_text.call_count == 2
|
||||||
|
|
||||||
|
def test_render_layout_no_display_manager(self, tmp_layout_file):
|
||||||
|
"""Test rendering without display manager."""
|
||||||
|
lm = LayoutManager(display_manager=None, config_path=tmp_layout_file)
|
||||||
|
lm.create_layout("test_layout", [])
|
||||||
|
|
||||||
|
result = lm.render_layout("test_layout")
|
||||||
|
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
def test_render_layout_not_exists(self, layout_manager):
|
||||||
|
"""Test rendering a non-existent layout."""
|
||||||
|
result = layout_manager.render_layout("nonexistent")
|
||||||
|
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
def test_render_element_text(self, layout_manager, mock_display_manager):
|
||||||
|
"""Test rendering a text element."""
|
||||||
|
element = {
|
||||||
|
"type": "text",
|
||||||
|
"x": 10,
|
||||||
|
"y": 20,
|
||||||
|
"properties": {
|
||||||
|
"text": "Hello",
|
||||||
|
"color": [255, 0, 0],
|
||||||
|
"font_size": "small"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
layout_manager.render_element(element, {})
|
||||||
|
|
||||||
|
mock_display_manager.draw_text.assert_called_once()
|
||||||
|
call_args = mock_display_manager.draw_text.call_args
|
||||||
|
assert call_args[0][0] == "Hello" # text
|
||||||
|
assert call_args[0][1] == 10 # x
|
||||||
|
assert call_args[0][2] == 20 # y
|
||||||
|
|
||||||
|
def test_render_element_weather_icon(self, layout_manager, mock_display_manager):
|
||||||
|
"""Test rendering a weather icon element."""
|
||||||
|
element = {
|
||||||
|
"type": "weather_icon",
|
||||||
|
"x": 10,
|
||||||
|
"y": 20,
|
||||||
|
"properties": {
|
||||||
|
"condition": "sunny",
|
||||||
|
"size": 16
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
layout_manager.render_element(element, {})
|
||||||
|
|
||||||
|
mock_display_manager.draw_weather_icon.assert_called_once_with("sunny", 10, 20, 16)
|
||||||
|
|
||||||
|
def test_render_element_weather_icon_from_context(self, layout_manager, mock_display_manager):
|
||||||
|
"""Test rendering weather icon with data from context."""
|
||||||
|
element = {
|
||||||
|
"type": "weather_icon",
|
||||||
|
"x": 10,
|
||||||
|
"y": 20,
|
||||||
|
"properties": {"size": 16}
|
||||||
|
}
|
||||||
|
data_context = {
|
||||||
|
"weather": {
|
||||||
|
"condition": "cloudy"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
layout_manager.render_element(element, data_context)
|
||||||
|
|
||||||
|
mock_display_manager.draw_weather_icon.assert_called_once_with("cloudy", 10, 20, 16)
|
||||||
|
|
||||||
|
def test_render_element_rectangle(self, layout_manager, mock_display_manager):
|
||||||
|
"""Test rendering a rectangle element."""
|
||||||
|
element = {
|
||||||
|
"type": "rectangle",
|
||||||
|
"x": 10,
|
||||||
|
"y": 20,
|
||||||
|
"properties": {
|
||||||
|
"width": 50,
|
||||||
|
"height": 30,
|
||||||
|
"color": [255, 0, 0],
|
||||||
|
"filled": True
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Mock the draw object and rectangle method
|
||||||
|
mock_draw = MagicMock()
|
||||||
|
mock_display_manager.draw = mock_draw
|
||||||
|
|
||||||
|
layout_manager.render_element(element, {})
|
||||||
|
|
||||||
|
# Verify rectangle was drawn
|
||||||
|
mock_draw.rectangle.assert_called_once()
|
||||||
|
|
||||||
|
def test_render_element_unknown_type(self, layout_manager):
|
||||||
|
"""Test rendering an unknown element type."""
|
||||||
|
element = {
|
||||||
|
"type": "unknown_type",
|
||||||
|
"x": 0,
|
||||||
|
"y": 0,
|
||||||
|
"properties": {}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Should not raise an exception
|
||||||
|
layout_manager.render_element(element, {})
|
||||||
|
|
||||||
|
def test_process_template_text(self, layout_manager):
|
||||||
|
"""Test template text processing."""
|
||||||
|
text = "Hello {name}, temperature is {temp}°F"
|
||||||
|
data_context = {
|
||||||
|
"name": "World",
|
||||||
|
"temp": 72
|
||||||
|
}
|
||||||
|
|
||||||
|
result = layout_manager._process_template_text(text, data_context)
|
||||||
|
|
||||||
|
assert result == "Hello World, temperature is 72°F"
|
||||||
|
|
||||||
|
def test_process_template_text_no_context(self, layout_manager):
|
||||||
|
"""Test template text with missing context."""
|
||||||
|
text = "Hello {name}"
|
||||||
|
data_context = {}
|
||||||
|
|
||||||
|
result = layout_manager._process_template_text(text, data_context)
|
||||||
|
|
||||||
|
# Should leave template as-is or handle gracefully
|
||||||
|
assert "{name}" in result or result == "Hello "
|
||||||
|
|
||||||
|
def test_save_layouts_error_handling(self, layout_manager):
|
||||||
|
"""Test error handling when saving layouts."""
|
||||||
|
# Create a layout
|
||||||
|
layout_manager.create_layout("test", [])
|
||||||
|
|
||||||
|
# Make save fail by using invalid path
|
||||||
|
layout_manager.config_path = "/nonexistent/directory/layouts.json"
|
||||||
|
|
||||||
|
result = layout_manager.save_layouts()
|
||||||
|
|
||||||
|
# Should handle error gracefully
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
def test_render_element_line(self, layout_manager, mock_display_manager):
|
||||||
|
"""Test rendering a line element."""
|
||||||
|
element = {
|
||||||
|
"type": "line",
|
||||||
|
"x": 10,
|
||||||
|
"y": 20,
|
||||||
|
"properties": {
|
||||||
|
"x2": 50,
|
||||||
|
"y2": 30,
|
||||||
|
"color": [255, 0, 0],
|
||||||
|
"width": 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mock_draw = MagicMock()
|
||||||
|
mock_display_manager.draw = mock_draw
|
||||||
|
|
||||||
|
layout_manager.render_element(element, {})
|
||||||
|
|
||||||
|
mock_draw.line.assert_called_once()
|
||||||
|
|
||||||
|
def test_render_element_clock(self, layout_manager, mock_display_manager):
|
||||||
|
"""Test rendering a clock element."""
|
||||||
|
element = {
|
||||||
|
"type": "clock",
|
||||||
|
"x": 10,
|
||||||
|
"y": 20,
|
||||||
|
"properties": {
|
||||||
|
"format": "%H:%M",
|
||||||
|
"color": [255, 255, 255]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
layout_manager.render_element(element, {})
|
||||||
|
|
||||||
|
mock_display_manager.draw_text.assert_called_once()
|
||||||
|
|
||||||
|
def test_render_element_data_text(self, layout_manager, mock_display_manager):
|
||||||
|
"""Test rendering a data text element."""
|
||||||
|
element = {
|
||||||
|
"type": "data_text",
|
||||||
|
"x": 10,
|
||||||
|
"y": 20,
|
||||||
|
"properties": {
|
||||||
|
"data_key": "weather.temperature",
|
||||||
|
"format": "Temp: {value}°F",
|
||||||
|
"color": [255, 255, 255],
|
||||||
|
"default": "N/A"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
data_context = {
|
||||||
|
"weather": {
|
||||||
|
"temperature": 72
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
layout_manager.render_element(element, data_context)
|
||||||
|
|
||||||
|
mock_display_manager.draw_text.assert_called_once()
|
||||||
@@ -123,14 +123,6 @@ class TestPluginManager:
|
|||||||
|
|
||||||
pm.run_scheduled_updates(current_time=time.time())
|
pm.run_scheduled_updates(current_time=time.time())
|
||||||
|
|
||||||
# Updates now execute on the background worker (the scheduler
|
|
||||||
# returns immediately) — wait for completion before asserting.
|
|
||||||
deadline = time.time() + 5
|
|
||||||
while (plugin_instance.update.call_count == 0
|
|
||||||
and time.time() < deadline):
|
|
||||||
time.sleep(0.02)
|
|
||||||
pm.stop_update_worker()
|
|
||||||
|
|
||||||
plugin_instance.update.assert_called_once()
|
plugin_instance.update.assert_called_once()
|
||||||
assert "test_plugin" in pm.plugin_last_update
|
assert "test_plugin" in pm.plugin_last_update
|
||||||
assert pm.state_manager.get_state("test_plugin") == PluginState.ENABLED
|
assert pm.state_manager.get_state("test_plugin") == PluginState.ENABLED
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
"""
|
|
||||||
Unit tests for src/plugin_system/testing/mocks.py.
|
|
||||||
|
|
||||||
MockCacheManager/MockPluginManager stand in for the real production
|
|
||||||
managers under the plugin safety harness -- a missing method here isn't a
|
|
||||||
harness bug in the abstract, it's a plugin silently failing to render
|
|
||||||
under test (confirmed on ledmatrix-leaderboard, which calls
|
|
||||||
get_cached_data_with_strategy() and previously hit an AttributeError that
|
|
||||||
its own broad except swallowed, producing an empty-but-green render).
|
|
||||||
"""
|
|
||||||
|
|
||||||
from src.plugin_system.testing.mocks import MockCacheManager
|
|
||||||
|
|
||||||
|
|
||||||
class TestMockCacheManagerStrategyMethod:
|
|
||||||
def test_get_cached_data_with_strategy_returns_cached_value(self):
|
|
||||||
cm = MockCacheManager()
|
|
||||||
cm.set("standings_nfl", {"teams": ["KC", "BUF"]})
|
|
||||||
result = cm.get_cached_data_with_strategy("standings_nfl", "sports_live")
|
|
||||||
assert result == {"teams": ["KC", "BUF"]}
|
|
||||||
|
|
||||||
def test_get_cached_data_with_strategy_returns_none_when_missing(self):
|
|
||||||
cm = MockCacheManager()
|
|
||||||
assert cm.get_cached_data_with_strategy("missing_key") is None
|
|
||||||
|
|
||||||
def test_get_cached_data_with_strategy_defaults_data_type(self):
|
|
||||||
cm = MockCacheManager()
|
|
||||||
cm.set("k", "v")
|
|
||||||
assert cm.get_cached_data_with_strategy("k") == "v"
|
|
||||||
|
|
||||||
def test_calls_are_tracked(self):
|
|
||||||
cm = MockCacheManager()
|
|
||||||
cm.get_cached_data_with_strategy("k", "sports_live")
|
|
||||||
assert cm.get_cached_data_with_strategy_calls == [{"key": "k", "data_type": "sports_live"}]
|
|
||||||
|
|
||||||
def test_save_cache_is_readable_via_strategy_lookup(self):
|
|
||||||
cm = MockCacheManager()
|
|
||||||
cm.save_cache("standings_nfl", {"teams": ["KC", "BUF"]})
|
|
||||||
assert cm.get_cached_data_with_strategy("standings_nfl") == {"teams": ["KC", "BUF"]}
|
|
||||||
|
|
||||||
def test_reset_clears_strategy_call_tracking(self):
|
|
||||||
cm = MockCacheManager()
|
|
||||||
cm.get_cached_data_with_strategy("k", "sports_live")
|
|
||||||
cm.reset()
|
|
||||||
assert cm.get_cached_data_with_strategy_calls == []
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
"""Tests for check_and_manage_ap_mode_with_state (src/wifi_manager.py).
|
|
||||||
|
|
||||||
The wifi monitor daemon used to fetch WiFi status before AND after each
|
|
||||||
check on top of the check's own internal fetch — every fetch is several
|
|
||||||
nmcli subprocess forks, every 30s, forever. The new API returns the state
|
|
||||||
the check observed, so the daemon runs exactly one fetch battery per tick.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
from unittest.mock import MagicMock, patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
|
||||||
|
|
||||||
from src.wifi_manager import WiFiManager, WiFiStatus # noqa: E402
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def wm(tmp_path):
|
|
||||||
with patch.object(WiFiManager, "_load_config", return_value={}, create=True):
|
|
||||||
manager = WiFiManager.__new__(WiFiManager)
|
|
||||||
# minimal attribute setup without running the real __init__
|
|
||||||
manager.config = {"auto_enable_ap_mode": True}
|
|
||||||
manager._disconnected_checks = 0
|
|
||||||
manager._disconnected_checks_required = 3
|
|
||||||
manager._ap_enabled_at = None
|
|
||||||
return manager
|
|
||||||
|
|
||||||
|
|
||||||
def _wire(wm, connected, ethernet, ap_active):
|
|
||||||
wm._get_wifi_status_with_retry = MagicMock(
|
|
||||||
return_value=WiFiStatus(connected=connected, ssid="net" if connected else None))
|
|
||||||
wm._is_ethernet_connected = MagicMock(return_value=ethernet)
|
|
||||||
wm._is_ap_mode_active = MagicMock(return_value=ap_active)
|
|
||||||
wm.enable_ap_mode = MagicMock(return_value=(True, "ok"))
|
|
||||||
wm.disable_ap_mode = MagicMock(return_value=(True, "ok"))
|
|
||||||
wm.scan_networks = MagicMock(return_value=([], False))
|
|
||||||
wm._save_cached_scan = MagicMock()
|
|
||||||
wm._FORCE_AP_FLAG_PATH = MagicMock()
|
|
||||||
wm._FORCE_AP_FLAG_PATH.exists.return_value = False
|
|
||||||
|
|
||||||
|
|
||||||
class TestWithState:
|
|
||||||
def test_single_fetch_per_call(self, wm):
|
|
||||||
_wire(wm, connected=True, ethernet=False, ap_active=False)
|
|
||||||
wm.check_and_manage_ap_mode_with_state()
|
|
||||||
assert wm._get_wifi_status_with_retry.call_count == 1
|
|
||||||
assert wm._is_ethernet_connected.call_count == 1
|
|
||||||
assert wm._is_ap_mode_active.call_count == 1
|
|
||||||
|
|
||||||
def test_returns_observed_state(self, wm):
|
|
||||||
_wire(wm, connected=True, ethernet=True, ap_active=False)
|
|
||||||
changed, status, ethernet, ap_after = wm.check_and_manage_ap_mode_with_state()
|
|
||||||
assert changed is False
|
|
||||||
assert status.connected is True
|
|
||||||
assert ethernet is True
|
|
||||||
assert ap_after is False
|
|
||||||
|
|
||||||
def test_ap_after_inverts_on_disable(self, wm):
|
|
||||||
"""WiFi reconnects while AP is up -> auto-disable -> ap_after False."""
|
|
||||||
_wire(wm, connected=True, ethernet=False, ap_active=True)
|
|
||||||
changed, _status, _ethernet, ap_after = wm.check_and_manage_ap_mode_with_state()
|
|
||||||
assert changed is True
|
|
||||||
assert ap_after is False
|
|
||||||
wm.disable_ap_mode.assert_called_once()
|
|
||||||
|
|
||||||
def test_ap_after_inverts_on_enable(self, wm):
|
|
||||||
"""Grace period exhausted with nothing connected -> enable -> True."""
|
|
||||||
_wire(wm, connected=False, ethernet=False, ap_active=False)
|
|
||||||
wm._disconnected_checks = 2 # this call is the 3rd
|
|
||||||
changed, _status, _ethernet, ap_after = wm.check_and_manage_ap_mode_with_state()
|
|
||||||
assert changed is True
|
|
||||||
assert ap_after is True
|
|
||||||
wm.enable_ap_mode.assert_called_once()
|
|
||||||
|
|
||||||
def test_bool_wrapper_is_back_compatible(self, wm):
|
|
||||||
_wire(wm, connected=True, ethernet=False, ap_active=False)
|
|
||||||
assert wm.check_and_manage_ap_mode() is False
|
|
||||||
_wire(wm, connected=True, ethernet=False, ap_active=True)
|
|
||||||
assert wm.check_and_manage_ap_mode() is True
|
|
||||||
|
|
||||||
def test_exception_path_never_raises(self, wm):
|
|
||||||
wm._get_wifi_status_with_retry = MagicMock(side_effect=RuntimeError("nmcli gone"))
|
|
||||||
changed, status, _ethernet, _ap_after = wm.check_and_manage_ap_mode_with_state()
|
|
||||||
assert changed is False
|
|
||||||
assert status.connected is False
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(pytest.main([__file__, "-v"]))
|
|
||||||
Reference in New Issue
Block a user