mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 14:25:08 +00:00
refactor: delete dead Python code in the core (and stop storing Wi-Fi passwords) (#608)
* refactor(plugins): remove the no-op PluginHealthMonitor Its monitor loop did nothing (`if callbacks: pass`), register_health_check had no callers and api_v3.health_monitor was never read by any route. The live health data comes from PluginHealthTracker, which is untouched. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(store): drop the never-set uninstall tombstones Nothing in production called mark_recently_uninstalled, so the reconciler's was_recently_uninstalled check was always False. The persistent uninstall registry is what actually stops resurrection; the reconciler test now exercises that gate instead. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(common): delete unused config/display/game helpers, utils and error_handler Nothing in core, the web UI, scripts or the plugin monorepo imports config_helper, display_helper, game_helper, utils or error_handler; only their own tests did. The error_handler re-exports leave src.common's __all__; APIHelper, TextHelper, ScrollHelper, LogoHelper and the adaptive layout exports are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(config): drop ConfigService's unused versioning and save API ConfigVersion, get_version/get_version_history/get_version_config, rollback, save_config, reload, get_plugin_config and the backward-compat load_config/get_config_path/get_secrets_path had no callers. The display controller only uses get_config, subscribe, unsubscribe and shutdown, plus the file watcher. Change detection now compares against the current checksum instead of the last history entry. The subscriber tests asserted `callback.called or True`; they now reload the way the watcher does and assert the notification. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(plugins): drop unread plugin state history and callbacks plugin_state.PluginStateManager kept a bounded per-plugin transition history that only get_state_history (tests only) read; get_state_info reports a separate lifetime count, which stays. set_error_info and record_display had no callers, and set_state_with_error's `error` argument only fed the history. The web-side state_manager.PluginStateManager loses subscribe_to_state_changes, _notify_callbacks, set_plugin_error and get_state_version, none of which had callers; with no subscribers the old-state copy in update_plugin_state went with them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(plugins): remove unused PluginManager methods and attribute guards update_all_plugins was only called by a test (the display loop uses run_scheduled_updates); get_plugin_health_metrics, get_plugin_resource_metrics and get_plugin_state had no callers; and plugin_modules was written but never read. plugin_directories is now initialised in __init__, so the hasattr() guards around it go. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(plugins): remove unused executor, loader, store and package helpers - PluginExecutor.execute_safe: no callers. - PluginLoader._parse_semver: only its own tests; compatibility.parse_semver is the live copy and test_compatibility.py already covers it. - PluginStoreManager.get_installed_plugin_info: no callers. - PluginResourceMonitor._local: never read. - src.plugin_system.get_store_manager and __api_version__: no importers in core, scripts or the plugin monorepo. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(wifi): stop storing Wi-Fi passwords in wifi_config.json WiFiManager appended every joined network's SSID and password, in plaintext, to saved_networks in config/wifi_config.json, and nothing (web UI, backup restore, scripts) ever read them back: NetworkManager keeps its own credentials. The writes are gone, and loading the config now drops any saved_networks key and rewrites the file, so passwords already on disk are scrubbed. Also removes _check_dnsmasq_conflict (never called) and _detect_trixie, whose result only reached one log line, along with the NM_CONNECTIONS_PATHS constant only it used. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(display): remove unreachable and unused DisplayController code - _follower_rebuild_scroll_image: never called. - mode_duration (never read) and last_mode_change (write-only). - The `chosen_cap <= 0` branch: chosen_cap is either the minimum of caps already filtered to > 0 or DEFAULT_DYNAMIC_DURATION_CAP (180). - The `max_duration < min_duration` branch directly after `max_duration = max(min_duration, max_duration)`. - The circuit-breaker branch's `display_result = False` and `manager_to_display = None`: the first is overwritten a few lines later, the second is already None there. - The bool-to-bool conversion of execute_display's result, which is always a bool. - The `loaded_plugins` lookup in _update_modules: PluginManager has no such attribute, so it always fell through to `plugins`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(vegas): remove unused config update, boundary finder and refresh VegasModeConfig.update had no callers outside its own tests (the coordinator rebuilds the config with from_config on a change); geometry.find_item_boundary and StreamManager._refresh_plugin_content had no callers at all. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(run): drop the debug block that pretended to import the plugin system In debug mode run.py put src/plugin_system itself on sys.path and printed "Plugin system import successful" without importing anything. Nothing imports plugin_system modules by bare name, so the path entry did nothing either. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: delete tests that test nothing - test/plugins/test_{basketball_scoreboard,calendar,clock_simple, odds_ticker,soccer_scoreboard,text_display}.py skip everywhere the named plugins are not installed, including CI (LEDMATRIX_PLUGINS_DIR holds only the fixture plugin); test_plugin_matrix.py already covers every discovered plugin. Their PluginTestBase and the fixtures only it used (plugins_dir, mock_display_manager, mock_cache_manager, mock_plugin_manager, base_plugin_config in test/plugins/conftest.py) go with them. - test_plugin_system.py: test_discover_plugins (body was `pass`) and test_dependency_check (a comment), plus the test_plugin_manager fixture only the former requested. - test_display_manager.py: test_draw_image asserted that an image it had just assigned was not None. - test_display_controller.py: the rotation and schedule-override tests re-implemented the run-loop arithmetic inline and asserted on their own result without calling the controller. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: expect one plugin_last_update success stamp after update_all_plugins EveryStampRecordsACompletion required at least two success-path stamps; the second was update_all_plugins, removed as test-only. The worker and synchronous paths share the remaining stamp in _execute_update_now, and the check that every stamp calls _note_update_completed is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+2
-56
@@ -24,55 +24,10 @@ fractional scaling), `LayoutContext` (`fit_text`, `fit_image`, `by_tier`,
|
||||
`px`), and composite carvers `scoreboard_regions()` / `media_row()`.
|
||||
Full guide: [docs/ADAPTIVE_LAYOUT.md](../../docs/ADAPTIVE_LAYOUT.md).
|
||||
|
||||
## Error Handling (`error_handler.py`)
|
||||
|
||||
Common error handling patterns and utilities:
|
||||
|
||||
- `handle_file_operation()` - Handle file I/O with consistent error handling
|
||||
- `handle_json_operation()` - Handle JSON operations with error handling
|
||||
- `safe_execute()` - Safely execute operations with error handling
|
||||
- `retry_on_failure()` - Decorator for retrying failed operations
|
||||
- `log_and_continue()` - Log non-critical errors and continue
|
||||
- `log_and_raise()` - Log errors and raise exceptions
|
||||
|
||||
### Example Usage
|
||||
|
||||
```python
|
||||
from src.common.error_handler import handle_json_operation, safe_execute
|
||||
|
||||
# Handle JSON loading
|
||||
config = handle_json_operation(
|
||||
lambda: json.load(open('config.json')),
|
||||
"Failed to load config",
|
||||
logger,
|
||||
default={}
|
||||
)
|
||||
|
||||
# Safe execution with error handling
|
||||
result = safe_execute(
|
||||
lambda: risky_operation(),
|
||||
"Operation failed",
|
||||
logger,
|
||||
default=None
|
||||
)
|
||||
```
|
||||
|
||||
## API Helpers (`api_helper.py`)
|
||||
|
||||
Utilities for making HTTP requests and handling API responses.
|
||||
|
||||
## Configuration Helpers (`config_helper.py`)
|
||||
|
||||
Utilities for loading, saving, and validating configuration files.
|
||||
|
||||
## Display Helpers (`display_helper.py`)
|
||||
|
||||
Utilities for rendering content to the LED matrix display.
|
||||
|
||||
## Game Helpers (`game_helper.py`)
|
||||
|
||||
Utilities for processing game data and team information.
|
||||
|
||||
## Logo Helpers (`logo_helper.py`)
|
||||
|
||||
Utilities for loading and managing team logos.
|
||||
@@ -85,14 +40,6 @@ Utilities for text processing and formatting.
|
||||
|
||||
Utilities for scrolling text on the display.
|
||||
|
||||
## General Utilities (`utils.py`)
|
||||
|
||||
General-purpose utility functions:
|
||||
- Team abbreviation normalization
|
||||
- Time formatting
|
||||
- Boolean parsing
|
||||
- Logger creation (deprecated - use `src.logging_config.get_logger()`)
|
||||
|
||||
## Permission Utilities (`permission_utils.py`)
|
||||
|
||||
Helpers for ensuring directory permissions and ownership are correct
|
||||
@@ -102,6 +49,5 @@ persistent cache directory).
|
||||
## Best Practices
|
||||
|
||||
1. **Use centralized logging**: Import from `src.logging_config` instead of creating loggers directly
|
||||
2. **Use error handlers**: Use `error_handler` utilities for consistent error handling
|
||||
3. **Reuse utilities**: Check existing utilities before creating new ones
|
||||
4. **Document additions**: Add documentation when adding new utilities
|
||||
2. **Reuse utilities**: Check existing utilities before creating new ones
|
||||
3. **Document additions**: Add documentation when adding new utilities
|
||||
|
||||
+1
-19
@@ -2,25 +2,13 @@
|
||||
Common utilities and helpers for LEDMatrix.
|
||||
|
||||
This package provides reusable functionality for plugins and core modules:
|
||||
- Error handling utilities
|
||||
- API helpers
|
||||
- Configuration helpers
|
||||
- Display helpers
|
||||
- Game/team helpers
|
||||
- Logo helpers
|
||||
- Text/scroll helpers
|
||||
- General utilities
|
||||
- Adaptive layout and image helpers
|
||||
"""
|
||||
|
||||
# Export commonly used utilities
|
||||
from src.common.error_handler import (
|
||||
handle_file_operation,
|
||||
handle_json_operation,
|
||||
safe_execute,
|
||||
retry_on_failure,
|
||||
log_and_continue,
|
||||
log_and_raise
|
||||
)
|
||||
from src.common.api_helper import APIHelper
|
||||
from src.common.scroll_helper import ScrollHelper
|
||||
from src.common import scroll_config
|
||||
@@ -59,12 +47,6 @@ from src.adaptive_images import (
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
'handle_file_operation',
|
||||
'handle_json_operation',
|
||||
'safe_execute',
|
||||
'retry_on_failure',
|
||||
'log_and_continue',
|
||||
'log_and_raise',
|
||||
'APIHelper',
|
||||
'ScrollHelper',
|
||||
'scroll_config',
|
||||
|
||||
@@ -1,361 +0,0 @@
|
||||
"""
|
||||
Config Helper
|
||||
|
||||
Handles configuration management and validation for LED matrix plugins.
|
||||
Extracted from LEDMatrix core to provide reusable functionality for plugins.
|
||||
"""
|
||||
|
||||
import copy
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
|
||||
|
||||
class ConfigHelper:
|
||||
"""
|
||||
Helper class for configuration management and validation.
|
||||
|
||||
Provides functionality for:
|
||||
- Loading and saving configuration files
|
||||
- Validating configuration against schemas
|
||||
- Merging configurations
|
||||
- Getting configuration values with defaults
|
||||
- Configuration schema validation
|
||||
"""
|
||||
|
||||
def __init__(self, logger: Optional[logging.Logger] = None):
|
||||
"""
|
||||
Initialize the ConfigHelper.
|
||||
|
||||
Args:
|
||||
logger: Optional logger instance
|
||||
"""
|
||||
self.logger = logger or logging.getLogger(__name__)
|
||||
|
||||
def load_config(self, config_path: Union[str, Path]) -> Dict[str, Any]:
|
||||
"""
|
||||
Load configuration from a JSON file.
|
||||
|
||||
Args:
|
||||
config_path: Path to configuration file
|
||||
|
||||
Returns:
|
||||
Configuration dictionary
|
||||
"""
|
||||
config_path = Path(config_path)
|
||||
|
||||
try:
|
||||
if not config_path.exists():
|
||||
self.logger.warning(f"Configuration file not found: {config_path}")
|
||||
return {}
|
||||
|
||||
with open(config_path, 'r', encoding='utf-8') as f:
|
||||
config = json.load(f)
|
||||
|
||||
self.logger.debug(f"Loaded configuration from {config_path}")
|
||||
return config
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
self.logger.error(f"Invalid JSON in configuration file {config_path}: {e}")
|
||||
return {}
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error loading configuration from {config_path}: {e}")
|
||||
return {}
|
||||
|
||||
def save_config(self, config: Dict[str, Any], config_path: Union[str, Path]) -> bool:
|
||||
"""
|
||||
Save configuration to a JSON file.
|
||||
|
||||
Args:
|
||||
config: Configuration dictionary to save
|
||||
config_path: Path to save configuration file
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
config_path = Path(config_path)
|
||||
|
||||
try:
|
||||
# Ensure directory exists
|
||||
config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with open(config_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(config, f, indent=2, ensure_ascii=False)
|
||||
|
||||
self.logger.debug(f"Saved configuration to {config_path}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error saving configuration to {config_path}: {e}")
|
||||
return False
|
||||
|
||||
def get_config_value(self, config: Dict[str, Any], key: str,
|
||||
default: Any = None, required: bool = False) -> Any:
|
||||
"""
|
||||
Get a configuration value with optional default.
|
||||
|
||||
Args:
|
||||
config: Configuration dictionary
|
||||
key: Configuration key (supports dot notation like 'display.width')
|
||||
default: Default value if key not found
|
||||
required: If True, raise error if key not found
|
||||
|
||||
Returns:
|
||||
Configuration value or default
|
||||
"""
|
||||
try:
|
||||
# Support dot notation for nested keys
|
||||
keys = key.split('.')
|
||||
value = config
|
||||
|
||||
for k in keys:
|
||||
if isinstance(value, dict) and k in value:
|
||||
value = value[k]
|
||||
else:
|
||||
if required:
|
||||
raise KeyError(f"Required configuration key not found: {key}")
|
||||
return default
|
||||
|
||||
return value
|
||||
|
||||
except Exception as e:
|
||||
if required:
|
||||
raise
|
||||
self.logger.warning(f"Error getting config value for {key}: {e}")
|
||||
return default
|
||||
|
||||
def set_config_value(self, config: Dict[str, Any], key: str, value: Any) -> None:
|
||||
"""
|
||||
Set a configuration value.
|
||||
|
||||
Args:
|
||||
config: Configuration dictionary to modify
|
||||
key: Configuration key (supports dot notation)
|
||||
value: Value to set
|
||||
"""
|
||||
try:
|
||||
# Support dot notation for nested keys
|
||||
keys = key.split('.')
|
||||
current = config
|
||||
|
||||
# Navigate to parent of target key
|
||||
for k in keys[:-1]:
|
||||
if k not in current:
|
||||
current[k] = {}
|
||||
current = current[k]
|
||||
|
||||
# Set the value
|
||||
current[keys[-1]] = value
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error setting config value for {key}: {e}")
|
||||
|
||||
def merge_configs(self, base_config: Dict[str, Any],
|
||||
override_config: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Merge two configuration dictionaries.
|
||||
|
||||
Args:
|
||||
base_config: Base configuration
|
||||
override_config: Configuration to merge in (takes precedence)
|
||||
|
||||
Returns:
|
||||
Merged configuration dictionary (fully independent of both
|
||||
inputs — a shallow copy would alias un-overridden nested dicts,
|
||||
so mutating the result would mutate the caller's base config).
|
||||
"""
|
||||
merged = copy.deepcopy(base_config)
|
||||
|
||||
for key, value in override_config.items():
|
||||
if key in merged and isinstance(merged[key], dict) and isinstance(value, dict):
|
||||
# Recursively merge nested dictionaries
|
||||
merged[key] = self.merge_configs(merged[key], value)
|
||||
else:
|
||||
# Override with new value — deep-copied so mutating the
|
||||
# merged result can't reach back into override_config.
|
||||
merged[key] = copy.deepcopy(value)
|
||||
|
||||
return merged
|
||||
|
||||
def validate_config(self, config: Dict[str, Any],
|
||||
schema: Optional[Dict[str, Any]] = None) -> bool:
|
||||
"""
|
||||
Validate configuration against a schema.
|
||||
|
||||
Args:
|
||||
config: Configuration to validate
|
||||
schema: Validation schema (optional)
|
||||
|
||||
Returns:
|
||||
True if valid, False otherwise
|
||||
"""
|
||||
if schema is None:
|
||||
# Basic validation - just check if it's a dictionary
|
||||
return isinstance(config, dict)
|
||||
|
||||
try:
|
||||
return self._validate_against_schema(config, schema)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Configuration validation error: {e}")
|
||||
return False
|
||||
|
||||
def get_plugin_config(self, config: Dict[str, Any], plugin_id: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Get plugin-specific configuration.
|
||||
|
||||
Args:
|
||||
config: Full configuration dictionary
|
||||
plugin_id: Plugin identifier
|
||||
|
||||
Returns:
|
||||
Plugin-specific configuration
|
||||
"""
|
||||
plugin_key = f"{plugin_id}_config"
|
||||
return config.get(plugin_key, {})
|
||||
|
||||
def create_default_config(self, plugin_id: str,
|
||||
default_values: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Create a default configuration for a plugin.
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin identifier
|
||||
default_values: Default configuration values
|
||||
|
||||
Returns:
|
||||
Default configuration dictionary
|
||||
"""
|
||||
return {
|
||||
f"{plugin_id}_config": default_values
|
||||
}
|
||||
|
||||
def validate_required_keys(self, config: Dict[str, Any],
|
||||
required_keys: List[str]) -> List[str]:
|
||||
"""
|
||||
Validate that required keys are present in configuration.
|
||||
|
||||
Args:
|
||||
config: Configuration to validate
|
||||
required_keys: List of required keys
|
||||
|
||||
Returns:
|
||||
List of missing keys
|
||||
"""
|
||||
missing_keys = []
|
||||
|
||||
for key in required_keys:
|
||||
if not self._has_key(config, key):
|
||||
missing_keys.append(key)
|
||||
|
||||
return missing_keys
|
||||
|
||||
def get_display_config(self, config: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Get display-related configuration.
|
||||
|
||||
Args:
|
||||
config: Full configuration dictionary
|
||||
|
||||
Returns:
|
||||
Display configuration
|
||||
"""
|
||||
return config.get('display', {})
|
||||
|
||||
def get_sports_config(self, config: Dict[str, Any], sport: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Get sport-specific configuration.
|
||||
|
||||
Args:
|
||||
config: Full configuration dictionary
|
||||
sport: Sport name (e.g., 'basketball', 'football')
|
||||
|
||||
Returns:
|
||||
Sport-specific configuration
|
||||
"""
|
||||
return config.get(f"{sport}_scoreboard", {})
|
||||
|
||||
def is_plugin_enabled(self, config: Dict[str, Any], plugin_id: str) -> bool:
|
||||
"""
|
||||
Check if a plugin is enabled.
|
||||
|
||||
Args:
|
||||
config: Full configuration dictionary
|
||||
plugin_id: Plugin identifier
|
||||
|
||||
Returns:
|
||||
True if plugin is enabled
|
||||
"""
|
||||
plugin_config = self.get_plugin_config(config, plugin_id)
|
||||
return plugin_config.get('enabled', True)
|
||||
|
||||
def get_favorite_teams(self, config: Dict[str, Any], sport: str) -> List[str]:
|
||||
"""
|
||||
Get favorite teams for a sport.
|
||||
|
||||
Args:
|
||||
config: Full configuration dictionary
|
||||
sport: Sport name
|
||||
|
||||
Returns:
|
||||
List of favorite team abbreviations
|
||||
"""
|
||||
sport_config = self.get_sports_config(config, sport)
|
||||
return sport_config.get('favorite_teams', [])
|
||||
|
||||
def get_display_modes(self, config: Dict[str, Any], sport: str) -> Dict[str, bool]:
|
||||
"""
|
||||
Get display modes for a sport.
|
||||
|
||||
Args:
|
||||
config: Full configuration dictionary
|
||||
sport: Sport name
|
||||
|
||||
Returns:
|
||||
Dictionary of display modes and their enabled status
|
||||
"""
|
||||
sport_config = self.get_sports_config(config, sport)
|
||||
return sport_config.get('display_modes', {})
|
||||
|
||||
def _validate_against_schema(self, config: Dict[str, Any],
|
||||
schema: Dict[str, Any]) -> bool:
|
||||
"""Validate configuration against a schema."""
|
||||
# This is a simplified schema validation
|
||||
# In a real implementation, you might use a library like jsonschema
|
||||
|
||||
for key, schema_info in schema.items():
|
||||
if key not in config:
|
||||
if schema_info.get('required', False):
|
||||
self.logger.error(f"Missing required configuration key: {key}")
|
||||
return False
|
||||
continue
|
||||
|
||||
value = config[key]
|
||||
expected_type = schema_info.get('type')
|
||||
|
||||
if expected_type and not isinstance(value, expected_type):
|
||||
self.logger.error(f"Configuration key {key} has wrong type. Expected {expected_type}, got {type(value)}")
|
||||
return False
|
||||
|
||||
# Validate allowed values
|
||||
allowed_values = schema_info.get('allowed_values')
|
||||
if allowed_values and value not in allowed_values:
|
||||
self.logger.error(f"Configuration key {key} has invalid value: {value}. Allowed: {allowed_values}")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def _has_key(self, config: Dict[str, Any], key: str) -> bool:
|
||||
"""Check if a key exists in configuration (supports dot notation)."""
|
||||
try:
|
||||
keys = key.split('.')
|
||||
current = config
|
||||
|
||||
for k in keys:
|
||||
if not isinstance(current, dict) or k not in current:
|
||||
return False
|
||||
current = current[k]
|
||||
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
@@ -1,303 +0,0 @@
|
||||
"""
|
||||
Display Helper
|
||||
|
||||
Handles common display operations and layouts for LED matrix displays.
|
||||
Extracted from LEDMatrix core to provide reusable functionality for plugins.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
|
||||
class DisplayHelper:
|
||||
"""
|
||||
Helper class for common display operations and layouts.
|
||||
|
||||
Provides functionality for:
|
||||
- Creating base images and overlays
|
||||
- Common layout patterns (scorebug, ticker, etc.)
|
||||
- Image compositing and manipulation
|
||||
- Display dimension utilities
|
||||
"""
|
||||
|
||||
def __init__(self, display_width: int, display_height: int,
|
||||
logger: Optional[logging.Logger] = None):
|
||||
"""
|
||||
Initialize the DisplayHelper.
|
||||
|
||||
Args:
|
||||
display_width: Width of the LED matrix display
|
||||
display_height: Height of the LED matrix display
|
||||
logger: Optional logger instance
|
||||
"""
|
||||
self.display_width = display_width
|
||||
self.display_height = display_height
|
||||
self.logger = logger or logging.getLogger(__name__)
|
||||
|
||||
def create_base_image(self, background_color: Tuple[int, int, int] = (0, 0, 0),
|
||||
mode: str = 'RGB') -> Image.Image:
|
||||
"""
|
||||
Create a base image for the display.
|
||||
|
||||
Args:
|
||||
background_color: Background color (R, G, B)
|
||||
mode: Image mode ('RGB', 'RGBA', etc.)
|
||||
|
||||
Returns:
|
||||
PIL Image object
|
||||
"""
|
||||
return Image.new(mode, (self.display_width, self.display_height), background_color)
|
||||
|
||||
def create_overlay(self, background_color: Tuple[int, int, int, int] = (0, 0, 0, 0)) -> Image.Image:
|
||||
"""
|
||||
Create an overlay image for compositing.
|
||||
|
||||
Args:
|
||||
background_color: Background color with alpha (R, G, B, A)
|
||||
|
||||
Returns:
|
||||
PIL Image object with alpha channel
|
||||
"""
|
||||
return Image.new('RGBA', (self.display_width, self.display_height), background_color)
|
||||
|
||||
def composite_images(self, base_image: Image.Image, overlay_image: Image.Image) -> Image.Image:
|
||||
"""
|
||||
Composite overlay onto base image.
|
||||
|
||||
Args:
|
||||
base_image: Base image (RGB or RGBA)
|
||||
overlay_image: Overlay image (should be RGBA)
|
||||
|
||||
Returns:
|
||||
Composited image
|
||||
"""
|
||||
if base_image.mode != 'RGBA':
|
||||
base_image = base_image.convert('RGBA')
|
||||
|
||||
if overlay_image.mode != 'RGBA':
|
||||
overlay_image = overlay_image.convert('RGBA')
|
||||
|
||||
return Image.alpha_composite(base_image, overlay_image)
|
||||
|
||||
def draw_scorebug_layout(self, game_data: Dict[str, Any],
|
||||
fonts: Dict[str, ImageFont.ImageFont],
|
||||
home_logo: Optional[Image.Image] = None,
|
||||
away_logo: Optional[Image.Image] = None) -> Image.Image:
|
||||
"""
|
||||
Draw a standard scorebug layout for sports games.
|
||||
|
||||
Args:
|
||||
game_data: Dictionary containing game information
|
||||
fonts: Dictionary of loaded fonts
|
||||
home_logo: Home team logo (optional)
|
||||
away_logo: Away team logo (optional)
|
||||
|
||||
Returns:
|
||||
PIL Image with scorebug layout
|
||||
"""
|
||||
# Create base image and overlay
|
||||
main_img = self.create_base_image()
|
||||
overlay = self.create_overlay()
|
||||
draw = ImageDraw.Draw(overlay)
|
||||
|
||||
# Extract game data
|
||||
home_score = str(game_data.get('home_score', '0'))
|
||||
away_score = str(game_data.get('away_score', '0'))
|
||||
home_abbr = game_data.get('home_abbr', 'HOME')
|
||||
away_abbr = game_data.get('away_abbr', 'AWAY')
|
||||
status_text = game_data.get('status_text', '')
|
||||
period_text = game_data.get('period_text', '')
|
||||
clock = game_data.get('clock', '')
|
||||
|
||||
# Draw logos if provided
|
||||
if home_logo and away_logo:
|
||||
self._draw_logos(main_img, home_logo, away_logo)
|
||||
|
||||
# Draw one combined top line (period/status/clock all share y=1 —
|
||||
# drawing them separately overprinted each other).
|
||||
top_line = " ".join(p for p in [period_text, status_text, clock] if p)
|
||||
if top_line:
|
||||
self._draw_centered_text(draw, top_line,
|
||||
fonts.get('time', fonts.get('status')),
|
||||
y_position=1)
|
||||
|
||||
# Draw scores (center)
|
||||
score_text = f"{away_score}-{home_score}"
|
||||
self._draw_centered_text(draw, score_text, fonts.get('score'),
|
||||
y_position=self.display_height // 2 - 3)
|
||||
|
||||
# Draw team abbreviations (bottom)
|
||||
if away_abbr:
|
||||
self._draw_text_with_outline(draw, away_abbr, (0, self.display_height - 12),
|
||||
fonts.get('team'))
|
||||
if home_abbr:
|
||||
text_width = draw.textlength(home_abbr, font=fonts.get('team'))
|
||||
self._draw_text_with_outline(draw, home_abbr,
|
||||
(self.display_width - text_width, self.display_height - 12),
|
||||
fonts.get('team'))
|
||||
|
||||
# Composite and return
|
||||
final_img = self.composite_images(main_img, overlay)
|
||||
return final_img.convert('RGB')
|
||||
|
||||
def draw_ticker_layout(self, text: str, font: ImageFont.ImageFont,
|
||||
background_color: Tuple[int, int, int] = (0, 0, 0),
|
||||
text_color: Tuple[int, int, int] = (255, 255, 255),
|
||||
scroll_speed: int = 1) -> Image.Image:
|
||||
"""
|
||||
Draw a ticker/scrolling text layout.
|
||||
|
||||
Renders a single static frame with the text at the left edge; the
|
||||
caller advances the scroll by re-rendering or shifting. The
|
||||
scroll_speed parameter is accepted for API compatibility but does
|
||||
not affect this frame. (Previously the text was drawn starting at
|
||||
x=display_width — entirely off-canvas — so every frame was blank.)
|
||||
|
||||
Args:
|
||||
text: Text to display
|
||||
font: Font to use
|
||||
background_color: Background color
|
||||
text_color: Text color
|
||||
scroll_speed: Accepted for compatibility; unused per-frame
|
||||
|
||||
Returns:
|
||||
PIL Image with ticker layout
|
||||
"""
|
||||
img = self.create_base_image(background_color)
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
self._draw_text_with_outline(draw, text, (0, self.display_height // 2 - 6),
|
||||
font, fill=text_color)
|
||||
|
||||
return img
|
||||
|
||||
def draw_centered_text(self, text: str, font: ImageFont.ImageFont,
|
||||
background_color: Tuple[int, int, int] = (0, 0, 0),
|
||||
text_color: Tuple[int, int, int] = (255, 255, 255)) -> Image.Image:
|
||||
"""
|
||||
Draw centered text on the display.
|
||||
|
||||
Args:
|
||||
text: Text to display
|
||||
font: Font to use
|
||||
background_color: Background color
|
||||
text_color: Text color
|
||||
|
||||
Returns:
|
||||
PIL Image with centered text
|
||||
"""
|
||||
img = self.create_base_image(background_color)
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# Calculate center position
|
||||
text_width = draw.textlength(text, font=font)
|
||||
text_height = 12 # Approximate height
|
||||
x = (self.display_width - text_width) // 2
|
||||
y = (self.display_height - text_height) // 2
|
||||
|
||||
# Draw text
|
||||
self._draw_text_with_outline(draw, text, (x, y), font, fill=text_color)
|
||||
|
||||
return img
|
||||
|
||||
def draw_error_message(self, message: str = "Error") -> Image.Image:
|
||||
"""
|
||||
Draw a simple error message.
|
||||
|
||||
Args:
|
||||
message: Error message to display
|
||||
|
||||
Returns:
|
||||
PIL Image with error message
|
||||
"""
|
||||
# Dark red background, white text
|
||||
font = ImageFont.load_default()
|
||||
return self.draw_centered_text(message, font, (50, 0, 0), (255, 255, 255))
|
||||
|
||||
def draw_no_data_message(self, message: str = "No Data") -> Image.Image:
|
||||
"""
|
||||
Draw a no data message.
|
||||
|
||||
Args:
|
||||
message: Message to display
|
||||
|
||||
Returns:
|
||||
PIL Image with no data message
|
||||
"""
|
||||
font = ImageFont.load_default()
|
||||
return self.draw_centered_text(message, font, (0, 0, 0), (150, 150, 150))
|
||||
|
||||
def get_display_dimensions(self) -> Tuple[int, int]:
|
||||
"""
|
||||
Get display dimensions.
|
||||
|
||||
Returns:
|
||||
(width, height) tuple
|
||||
"""
|
||||
return (self.display_width, self.display_height)
|
||||
|
||||
def is_portrait(self) -> bool:
|
||||
"""
|
||||
Check if display is in portrait orientation.
|
||||
|
||||
Returns:
|
||||
True if height > width
|
||||
"""
|
||||
return self.display_height > self.display_width
|
||||
|
||||
def is_landscape(self) -> bool:
|
||||
"""
|
||||
Check if display is in landscape orientation.
|
||||
|
||||
Returns:
|
||||
True if width > height
|
||||
"""
|
||||
return self.display_width > self.display_height
|
||||
|
||||
def get_center_position(self) -> Tuple[int, int]:
|
||||
"""
|
||||
Get center position of the display.
|
||||
|
||||
Returns:
|
||||
(x, y) center position
|
||||
"""
|
||||
return (self.display_width // 2, self.display_height // 2)
|
||||
|
||||
def _draw_logos(self, img: Image.Image, home_logo: Image.Image, away_logo: Image.Image) -> None:
|
||||
"""Draw team logos on the image."""
|
||||
center_y = self.display_height // 2
|
||||
|
||||
# Home logo (right side)
|
||||
if home_logo:
|
||||
home_x = self.display_width - home_logo.width + 10
|
||||
home_y = center_y - (home_logo.height // 2)
|
||||
img.paste(home_logo, (home_x, home_y), home_logo)
|
||||
|
||||
# Away logo (left side)
|
||||
if away_logo:
|
||||
away_x = -10
|
||||
away_y = center_y - (away_logo.height // 2)
|
||||
img.paste(away_logo, (away_x, away_y), away_logo)
|
||||
|
||||
def _draw_centered_text(self, draw: ImageDraw.ImageDraw, text: str,
|
||||
font: ImageFont.ImageFont, y_position: int) -> None:
|
||||
"""Draw centered text at specified y position."""
|
||||
text_width = draw.textlength(text, font=font)
|
||||
x = (self.display_width - text_width) // 2
|
||||
self._draw_text_with_outline(draw, text, (x, y_position), font)
|
||||
|
||||
def _draw_text_with_outline(self, draw: ImageDraw.ImageDraw, text: str,
|
||||
position: Tuple[int, int], font: ImageFont.ImageFont,
|
||||
fill: Tuple[int, int, int] = (255, 255, 255),
|
||||
outline_color: Tuple[int, int, int] = (0, 0, 0)) -> None:
|
||||
"""Draw text with outline for better readability."""
|
||||
x, y = position
|
||||
|
||||
# Draw outline
|
||||
for dx, dy in [(-1, -1), (-1, 0), (-1, 1), (0, -1), (0, 1), (1, -1), (1, 0), (1, 1)]:
|
||||
draw.text((x + dx, y + dy), text, font=font, fill=outline_color)
|
||||
|
||||
# Draw main text
|
||||
draw.text((x, y), text, font=font, fill=fill)
|
||||
@@ -1,220 +0,0 @@
|
||||
"""
|
||||
Error Handling Utilities
|
||||
|
||||
Common error handling patterns and utilities for consistent error handling
|
||||
across the LEDMatrix codebase.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any, Callable, Optional, TypeVar, Dict
|
||||
from functools import wraps
|
||||
from src.exceptions import LEDMatrixError
|
||||
|
||||
T = TypeVar('T')
|
||||
|
||||
|
||||
def handle_file_operation(
|
||||
operation: Callable[[], T],
|
||||
error_message: str,
|
||||
logger: logging.Logger,
|
||||
default: Optional[T] = None,
|
||||
context: Optional[Dict[str, Any]] = None
|
||||
) -> Optional[T]:
|
||||
"""
|
||||
Handle file operations with consistent error handling.
|
||||
|
||||
Args:
|
||||
operation: Function to execute (file read/write)
|
||||
error_message: Base error message
|
||||
logger: Logger instance
|
||||
default: Default value to return on error
|
||||
context: Optional context dictionary for error details
|
||||
|
||||
Returns:
|
||||
Result of operation or default value
|
||||
"""
|
||||
try:
|
||||
return operation()
|
||||
except FileNotFoundError as e:
|
||||
logger.warning("%s: File not found: %s", error_message, e, exc_info=True)
|
||||
return default
|
||||
except PermissionError as e:
|
||||
logger.error("%s: Permission denied: %s", error_message, e, exc_info=True)
|
||||
return default
|
||||
except (IOError, OSError) as e:
|
||||
logger.error("%s: I/O error: %s", error_message, e, exc_info=True)
|
||||
return default
|
||||
except Exception as e:
|
||||
logger.error("%s: Unexpected error: %s", error_message, e, exc_info=True)
|
||||
return default
|
||||
|
||||
|
||||
def handle_json_operation(
|
||||
operation: Callable[[], T],
|
||||
error_message: str,
|
||||
logger: logging.Logger,
|
||||
default: Optional[T] = None,
|
||||
context: Optional[Dict[str, Any]] = None
|
||||
) -> Optional[T]:
|
||||
"""
|
||||
Handle JSON operations with consistent error handling.
|
||||
|
||||
Args:
|
||||
operation: Function to execute (JSON load/dump)
|
||||
error_message: Base error message
|
||||
logger: Logger instance
|
||||
default: Default value to return on error
|
||||
context: Optional context dictionary for error details
|
||||
|
||||
Returns:
|
||||
Result of operation or default value
|
||||
"""
|
||||
try:
|
||||
return operation()
|
||||
except FileNotFoundError as e:
|
||||
logger.warning("%s: File not found: %s", error_message, e, exc_info=True)
|
||||
return default
|
||||
except PermissionError as e:
|
||||
logger.error("%s: Permission denied: %s", error_message, e, exc_info=True)
|
||||
return default
|
||||
except ValueError as e:
|
||||
logger.error("%s: Invalid JSON: %s", error_message, e, exc_info=True)
|
||||
return default
|
||||
except (IOError, OSError) as e:
|
||||
logger.error("%s: I/O error: %s", error_message, e, exc_info=True)
|
||||
return default
|
||||
except Exception as e:
|
||||
logger.error("%s: Unexpected error: %s", error_message, e, exc_info=True)
|
||||
return default
|
||||
|
||||
|
||||
def safe_execute(
|
||||
operation: Callable[[], T],
|
||||
error_message: str,
|
||||
logger: logging.Logger,
|
||||
default: Optional[T] = None,
|
||||
raise_on_error: bool = False,
|
||||
exception_type: type = LEDMatrixError
|
||||
) -> Optional[T]:
|
||||
"""
|
||||
Safely execute an operation with error handling.
|
||||
|
||||
Args:
|
||||
operation: Function to execute
|
||||
error_message: Base error message
|
||||
logger: Logger instance
|
||||
default: Default value to return on error
|
||||
raise_on_error: If True, raise exception instead of returning default
|
||||
exception_type: Type of exception to raise if raise_on_error is True
|
||||
|
||||
Returns:
|
||||
Result of operation or default value (or raises exception)
|
||||
"""
|
||||
try:
|
||||
return operation()
|
||||
except LEDMatrixError:
|
||||
# Re-raise LEDMatrix errors as-is
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("%s: %s", error_message, e, exc_info=True)
|
||||
if raise_on_error:
|
||||
raise exception_type(error_message, context={'original_error': str(e)}) from e
|
||||
return default
|
||||
|
||||
|
||||
def retry_on_failure(
|
||||
max_attempts: int = 3,
|
||||
delay: float = 1.0,
|
||||
backoff: float = 2.0,
|
||||
exceptions: tuple = (Exception,),
|
||||
logger: Optional[logging.Logger] = None
|
||||
):
|
||||
"""
|
||||
Decorator to retry a function on failure.
|
||||
|
||||
Args:
|
||||
max_attempts: Maximum number of retry attempts
|
||||
delay: Initial delay between retries in seconds
|
||||
backoff: Multiplier for delay after each retry
|
||||
exceptions: Tuple of exceptions to catch and retry on
|
||||
logger: Optional logger instance
|
||||
|
||||
Returns:
|
||||
Decorator function
|
||||
"""
|
||||
def decorator(func: Callable) -> Callable:
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
current_delay = delay
|
||||
last_exception = None
|
||||
|
||||
for attempt in range(max_attempts):
|
||||
try:
|
||||
return func(*args, **kwargs)
|
||||
except exceptions as e:
|
||||
last_exception = e
|
||||
if attempt < max_attempts - 1:
|
||||
if logger:
|
||||
logger.warning(
|
||||
"%s failed (attempt %d/%d): %s. Retrying in %.1fs...",
|
||||
func.__name__, attempt + 1, max_attempts, e, current_delay
|
||||
)
|
||||
import time
|
||||
time.sleep(current_delay)
|
||||
current_delay *= backoff
|
||||
else:
|
||||
if logger:
|
||||
logger.error(
|
||||
"%s failed after %d attempts: %s",
|
||||
func.__name__, max_attempts, e, exc_info=True
|
||||
)
|
||||
|
||||
# If we get here, all attempts failed
|
||||
raise last_exception
|
||||
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
|
||||
def log_and_continue(
|
||||
logger: logging.Logger,
|
||||
message: str,
|
||||
level: int = logging.WARNING,
|
||||
context: Optional[Dict[str, Any]] = None
|
||||
):
|
||||
"""
|
||||
Log a message and continue execution (for non-critical errors).
|
||||
|
||||
Args:
|
||||
logger: Logger instance
|
||||
message: Log message
|
||||
level: Log level (default: WARNING)
|
||||
context: Optional context dictionary
|
||||
"""
|
||||
if context:
|
||||
logger.log(level, "%s (context: %s)", message, context)
|
||||
else:
|
||||
logger.log(level, message)
|
||||
|
||||
|
||||
def log_and_raise(
|
||||
logger: logging.Logger,
|
||||
message: str,
|
||||
exception_type: type = LEDMatrixError,
|
||||
context: Optional[Dict[str, Any]] = None
|
||||
):
|
||||
"""
|
||||
Log an error and raise an exception.
|
||||
|
||||
Args:
|
||||
logger: Logger instance
|
||||
message: Error message
|
||||
exception_type: Type of exception to raise
|
||||
context: Optional context dictionary
|
||||
|
||||
Raises:
|
||||
exception_type: The specified exception type
|
||||
"""
|
||||
logger.error(message, exc_info=True)
|
||||
raise exception_type(message, context=context)
|
||||
|
||||
@@ -1,452 +0,0 @@
|
||||
"""
|
||||
Game Helper
|
||||
|
||||
Handles common game data extraction and processing for LED matrix plugins.
|
||||
Extracted from LEDMatrix core to provide reusable functionality for plugins.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
import pytz
|
||||
|
||||
|
||||
class GameHelper:
|
||||
"""
|
||||
Helper class for game data extraction and processing.
|
||||
|
||||
Provides functionality for:
|
||||
- Extracting game details from ESPN API responses
|
||||
- Filtering games by various criteria
|
||||
- Processing game data for display
|
||||
- Time zone handling and date formatting
|
||||
"""
|
||||
|
||||
def __init__(self, timezone_str: str = 'UTC', logger: Optional[logging.Logger] = None):
|
||||
"""
|
||||
Initialize the GameHelper.
|
||||
|
||||
Args:
|
||||
timezone_str: Timezone string for date/time processing
|
||||
logger: Optional logger instance
|
||||
"""
|
||||
self.logger = logger or logging.getLogger(__name__)
|
||||
self.timezone = self._get_timezone(timezone_str)
|
||||
|
||||
def extract_game_details(self, event: Dict[str, Any], sport: str = None) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Extract game details from ESPN event data.
|
||||
|
||||
Args:
|
||||
event: ESPN event data
|
||||
sport: Sport type for sport-specific processing
|
||||
|
||||
Returns:
|
||||
Processed game details or None if extraction fails
|
||||
"""
|
||||
if not event:
|
||||
return None
|
||||
|
||||
try:
|
||||
competition = event.get("competitions", [{}])[0]
|
||||
status = competition.get("status", {})
|
||||
competitors = competition.get("competitors", [])
|
||||
game_date_str = event.get("date", "")
|
||||
|
||||
if not competitors or len(competitors) < 2:
|
||||
self.logger.warning(f"Insufficient competitor data in event: {event.get('id')}")
|
||||
return None
|
||||
|
||||
# Find home and away teams
|
||||
home_team = next((c for c in competitors if c.get("homeAway") == "home"), None)
|
||||
away_team = next((c for c in competitors if c.get("homeAway") == "away"), None)
|
||||
|
||||
if not home_team or not away_team:
|
||||
self.logger.warning(f"Could not find home/away teams in event: {event.get('id')}")
|
||||
return None
|
||||
|
||||
# Extract basic team info
|
||||
home_abbr = self._extract_team_abbreviation(home_team)
|
||||
away_abbr = self._extract_team_abbreviation(away_team)
|
||||
|
||||
# Parse game time
|
||||
start_time_utc = self._parse_game_time(game_date_str)
|
||||
game_time, game_date = self._format_game_time(start_time_utc)
|
||||
|
||||
# Extract records
|
||||
home_record = self._extract_team_record(home_team)
|
||||
away_record = self._extract_team_record(away_team)
|
||||
|
||||
# Determine game state
|
||||
game_state = self._determine_game_state(status)
|
||||
|
||||
# Build game details
|
||||
details = {
|
||||
"id": event.get("id"),
|
||||
"game_time": game_time,
|
||||
"game_date": game_date,
|
||||
"start_time_utc": start_time_utc,
|
||||
"status_text": status.get("type", {}).get("shortDetail", ""),
|
||||
"is_live": game_state["is_live"],
|
||||
"is_final": game_state["is_final"],
|
||||
"is_upcoming": game_state["is_upcoming"],
|
||||
"is_halftime": game_state["is_halftime"],
|
||||
"is_period_break": game_state["is_period_break"],
|
||||
"home_abbr": home_abbr,
|
||||
"home_id": home_team.get("id"),
|
||||
"home_score": str(home_team.get("score", "0")),
|
||||
"home_record": home_record,
|
||||
"away_abbr": away_abbr,
|
||||
"away_id": away_team.get("id"),
|
||||
"away_score": str(away_team.get("score", "0")),
|
||||
"away_record": away_record,
|
||||
"is_within_window": True,
|
||||
}
|
||||
|
||||
# Add sport-specific details
|
||||
if sport:
|
||||
details.update(self._extract_sport_specific_details(event, sport))
|
||||
|
||||
return details
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error extracting game details: {e} from event: {event.get('id')}", exc_info=True)
|
||||
return None
|
||||
|
||||
def filter_live_games(self, games: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Filter games to only include live games.
|
||||
|
||||
Args:
|
||||
games: List of game dictionaries
|
||||
|
||||
Returns:
|
||||
List of live games
|
||||
"""
|
||||
return [game for game in games if game.get('is_live', False)]
|
||||
|
||||
def filter_final_games(self, games: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Filter games to only include final games.
|
||||
|
||||
Args:
|
||||
games: List of game dictionaries
|
||||
|
||||
Returns:
|
||||
List of final games
|
||||
"""
|
||||
return [game for game in games if game.get('is_final', False)]
|
||||
|
||||
def filter_upcoming_games(self, games: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Filter games to only include upcoming games.
|
||||
|
||||
Args:
|
||||
games: List of game dictionaries
|
||||
|
||||
Returns:
|
||||
List of upcoming games
|
||||
"""
|
||||
return [game for game in games if game.get('is_upcoming', False)]
|
||||
|
||||
def filter_favorite_teams(self, games: List[Dict[str, Any]],
|
||||
favorite_teams: List[str]) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Filter games to only include games with favorite teams.
|
||||
|
||||
Args:
|
||||
games: List of game dictionaries
|
||||
favorite_teams: List of favorite team abbreviations
|
||||
|
||||
Returns:
|
||||
List of games involving favorite teams
|
||||
"""
|
||||
if not favorite_teams:
|
||||
return games
|
||||
|
||||
return [game for game in games
|
||||
if game.get('home_abbr') in favorite_teams or
|
||||
game.get('away_abbr') in favorite_teams]
|
||||
|
||||
def filter_recent_games(self, games: List[Dict[str, Any]],
|
||||
days_back: int = 7) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Filter games to only include recent games within specified days.
|
||||
|
||||
Args:
|
||||
games: List of game dictionaries
|
||||
days_back: Number of days to look back
|
||||
|
||||
Returns:
|
||||
List of recent games
|
||||
"""
|
||||
cutoff_date = datetime.now(timezone.utc) - timedelta(days=days_back)
|
||||
|
||||
recent_games = []
|
||||
for game in games:
|
||||
start_time = game.get('start_time_utc')
|
||||
if start_time and start_time >= cutoff_date:
|
||||
recent_games.append(game)
|
||||
|
||||
return recent_games
|
||||
|
||||
def sort_games_by_time(self, games: List[Dict[str, Any]],
|
||||
reverse: bool = False) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Sort games by start time.
|
||||
|
||||
Args:
|
||||
games: List of game dictionaries
|
||||
reverse: If True, sort in descending order (newest first)
|
||||
|
||||
Returns:
|
||||
Sorted list of games
|
||||
"""
|
||||
def get_start_time(game):
|
||||
start_time = game.get('start_time_utc')
|
||||
if start_time:
|
||||
return start_time
|
||||
# Fallback to current time for games without start time
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
return sorted(games, key=get_start_time, reverse=reverse)
|
||||
|
||||
def process_games(self, events: List[Dict[str, Any]], sport: str = None) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Process a list of ESPN events into game details.
|
||||
|
||||
Args:
|
||||
events: List of ESPN event data
|
||||
sport: Sport type for processing
|
||||
|
||||
Returns:
|
||||
List of processed game details
|
||||
"""
|
||||
games = []
|
||||
|
||||
for event in events:
|
||||
game = self.extract_game_details(event, sport)
|
||||
if game:
|
||||
games.append(game)
|
||||
|
||||
return games
|
||||
|
||||
def get_game_summary(self, game: Dict[str, Any]) -> str:
|
||||
"""
|
||||
Get a text summary of a game.
|
||||
|
||||
Args:
|
||||
game: Game dictionary
|
||||
|
||||
Returns:
|
||||
Text summary of the game
|
||||
"""
|
||||
home_abbr = game.get('home_abbr', 'HOME')
|
||||
away_abbr = game.get('away_abbr', 'AWAY')
|
||||
home_score = game.get('home_score', '0')
|
||||
away_score = game.get('away_score', '0')
|
||||
status = game.get('status_text', '')
|
||||
|
||||
if game.get('is_live'):
|
||||
return f"{away_abbr} {away_score} @ {home_abbr} {home_score} ({status})"
|
||||
elif game.get('is_final'):
|
||||
return f"{away_abbr} {away_score} @ {home_abbr} {home_score} (Final)"
|
||||
else:
|
||||
return f"{away_abbr} @ {home_abbr} ({status})"
|
||||
|
||||
def _extract_team_abbreviation(self, team_data: Dict[str, Any]) -> str:
|
||||
"""Extract team abbreviation from team data."""
|
||||
try:
|
||||
return team_data.get("team", {}).get("abbreviation", "")
|
||||
except (KeyError, AttributeError):
|
||||
# Fallback to first 3 characters of team name
|
||||
team_name = team_data.get("team", {}).get("name", "UNK")
|
||||
return team_name[:3].upper()
|
||||
|
||||
def _extract_team_record(self, team_data: Dict[str, Any]) -> str:
|
||||
"""Extract team record from team data."""
|
||||
try:
|
||||
records = team_data.get('records', [])
|
||||
if records and len(records) > 0:
|
||||
record = records[0].get('summary', '')
|
||||
# Don't show "0-0" records
|
||||
if record in {"0-0", "0-0-0"}:
|
||||
return ''
|
||||
return record
|
||||
except (KeyError, AttributeError, IndexError):
|
||||
pass
|
||||
return ''
|
||||
|
||||
def _parse_game_time(self, game_date_str: str) -> Optional[datetime]:
|
||||
"""Parse game time string to UTC datetime."""
|
||||
if not game_date_str:
|
||||
return None
|
||||
|
||||
try:
|
||||
# Handle ISO format with Z suffix
|
||||
if game_date_str.endswith('Z'):
|
||||
game_date_str = game_date_str.replace('Z', '+00:00')
|
||||
|
||||
dt = datetime.fromisoformat(game_date_str)
|
||||
# Ensure the datetime is UTC-aware (fromisoformat may create timezone-aware but not pytz.UTC)
|
||||
if dt.tzinfo is None:
|
||||
# If naive, assume it's UTC
|
||||
return dt.replace(tzinfo=pytz.UTC)
|
||||
else:
|
||||
# Convert to pytz.UTC for consistency
|
||||
return dt.astimezone(pytz.UTC)
|
||||
except ValueError:
|
||||
self.logger.warning(f"Could not parse game date: {game_date_str}")
|
||||
return None
|
||||
|
||||
def _format_game_time(self, start_time_utc: Optional[datetime]) -> Tuple[str, str]:
|
||||
"""Format game time for display."""
|
||||
if not start_time_utc:
|
||||
return "", ""
|
||||
|
||||
try:
|
||||
local_time = start_time_utc.astimezone(self.timezone)
|
||||
game_time = local_time.strftime("%I:%M%p").lstrip('0')
|
||||
game_date = local_time.strftime("%B %d")
|
||||
return game_time, game_date
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error formatting game time: {e}")
|
||||
return "", ""
|
||||
|
||||
def _determine_game_state(self, status: Dict[str, Any]) -> Dict[str, bool]:
|
||||
"""Determine game state from status data."""
|
||||
status_type = status.get("type", {})
|
||||
state = status_type.get("state", "")
|
||||
name = status_type.get("name", "").lower()
|
||||
|
||||
return {
|
||||
"is_live": state == "in",
|
||||
"is_final": state == "post",
|
||||
"is_upcoming": state == "pre" or name in ['scheduled', 'pre-game', 'status_scheduled'],
|
||||
"is_halftime": state == "halftime" or name == "status_halftime",
|
||||
"is_period_break": name == "status_end_period",
|
||||
}
|
||||
|
||||
def _extract_sport_specific_details(self, event: Dict[str, Any], sport: str) -> Dict[str, Any]:
|
||||
"""Extract sport-specific game details."""
|
||||
details = {}
|
||||
|
||||
if sport == "basketball":
|
||||
details.update(self._extract_basketball_details(event))
|
||||
elif sport == "football":
|
||||
details.update(self._extract_football_details(event))
|
||||
elif sport == "hockey":
|
||||
details.update(self._extract_hockey_details(event))
|
||||
elif sport == "baseball":
|
||||
details.update(self._extract_baseball_details(event))
|
||||
|
||||
return details
|
||||
|
||||
def _extract_basketball_details(self, event: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Extract basketball-specific details."""
|
||||
details = {}
|
||||
|
||||
try:
|
||||
competition = event.get("competitions", [{}])[0]
|
||||
status = competition.get("status", {})
|
||||
|
||||
# Period information
|
||||
period = status.get("period", 0)
|
||||
if period > 0:
|
||||
if period <= 4:
|
||||
details["period_text"] = f"Q{period}"
|
||||
else:
|
||||
details["period_text"] = f"OT{period - 4}"
|
||||
else:
|
||||
details["period_text"] = "Start"
|
||||
|
||||
# Clock
|
||||
details["clock"] = status.get("displayClock", "0:00")
|
||||
|
||||
except (KeyError, IndexError):
|
||||
pass
|
||||
|
||||
return details
|
||||
|
||||
def _extract_football_details(self, event: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Extract football-specific details."""
|
||||
details = {}
|
||||
|
||||
try:
|
||||
competition = event.get("competitions", [{}])[0]
|
||||
status = competition.get("status", {})
|
||||
|
||||
# Quarter information
|
||||
period = status.get("period", 0)
|
||||
if period > 0:
|
||||
if period <= 4:
|
||||
details["period_text"] = f"Q{period}"
|
||||
else:
|
||||
details["period_text"] = f"OT{period - 4}"
|
||||
else:
|
||||
details["period_text"] = "Start"
|
||||
|
||||
# Clock
|
||||
details["clock"] = status.get("displayClock", "0:00")
|
||||
|
||||
except (KeyError, IndexError):
|
||||
pass
|
||||
|
||||
return details
|
||||
|
||||
def _extract_hockey_details(self, event: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Extract hockey-specific details."""
|
||||
details = {}
|
||||
|
||||
try:
|
||||
competition = event.get("competitions", [{}])[0]
|
||||
status = competition.get("status", {})
|
||||
|
||||
# Period information
|
||||
period = status.get("period", 0)
|
||||
if period > 0:
|
||||
if period <= 3:
|
||||
details["period_text"] = f"P{period}"
|
||||
else:
|
||||
details["period_text"] = f"OT{period - 3}"
|
||||
else:
|
||||
details["period_text"] = "Start"
|
||||
|
||||
# Clock
|
||||
details["clock"] = status.get("displayClock", "0:00")
|
||||
|
||||
except (KeyError, IndexError):
|
||||
pass
|
||||
|
||||
return details
|
||||
|
||||
def _extract_baseball_details(self, event: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Extract baseball-specific details."""
|
||||
details = {}
|
||||
|
||||
try:
|
||||
competition = event.get("competitions", [{}])[0]
|
||||
status = competition.get("status", {})
|
||||
|
||||
# Inning information
|
||||
period = status.get("period", 0)
|
||||
if period > 0:
|
||||
details["period_text"] = f"INN {period}"
|
||||
else:
|
||||
details["period_text"] = "Start"
|
||||
|
||||
# Clock
|
||||
details["clock"] = status.get("displayClock", "0:00")
|
||||
|
||||
except (KeyError, IndexError):
|
||||
pass
|
||||
|
||||
return details
|
||||
|
||||
def _get_timezone(self, timezone_str: str) -> pytz.BaseTzInfo:
|
||||
"""Get timezone object from string."""
|
||||
try:
|
||||
return pytz.timezone(timezone_str)
|
||||
except pytz.UnknownTimeZoneError:
|
||||
self.logger.warning(f"Unknown timezone: {timezone_str}, using UTC")
|
||||
return pytz.utc
|
||||
@@ -1,331 +0,0 @@
|
||||
"""
|
||||
Utility Functions
|
||||
|
||||
Common utility functions for LED matrix plugins.
|
||||
Extracted from LEDMatrix core to provide reusable functionality for plugins.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from typing import Union
|
||||
import pytz
|
||||
|
||||
|
||||
def normalize_team_abbreviation(team_abbr: str) -> str:
|
||||
"""
|
||||
Normalize team abbreviation for consistent usage.
|
||||
|
||||
Args:
|
||||
team_abbr: Raw team abbreviation
|
||||
|
||||
Returns:
|
||||
Normalized abbreviation
|
||||
"""
|
||||
if not team_abbr:
|
||||
return ""
|
||||
|
||||
# Remove spaces and convert to uppercase
|
||||
normalized = team_abbr.strip().upper()
|
||||
|
||||
# Handle special characters
|
||||
normalized = normalized.replace('&', 'AND')
|
||||
normalized = normalized.replace(' ', '')
|
||||
normalized = normalized.replace('-', '')
|
||||
|
||||
return normalized
|
||||
|
||||
|
||||
def format_time(dt: datetime, timezone_str: str = 'UTC',
|
||||
format_str: str = "%I:%M%p") -> str:
|
||||
"""
|
||||
Format datetime for display.
|
||||
|
||||
Args:
|
||||
dt: Datetime object
|
||||
timezone_str: Target timezone
|
||||
format_str: Time format string
|
||||
|
||||
Returns:
|
||||
Formatted time string
|
||||
"""
|
||||
try:
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
|
||||
target_tz = pytz.timezone(timezone_str)
|
||||
local_time = dt.astimezone(target_tz)
|
||||
|
||||
formatted = local_time.strftime(format_str)
|
||||
# Remove leading zero from hour
|
||||
if formatted.startswith('0'):
|
||||
formatted = formatted[1:]
|
||||
|
||||
return formatted
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def format_date(dt: datetime, timezone_str: str = 'UTC',
|
||||
format_str: str = "%B %d") -> str:
|
||||
"""
|
||||
Format date for display.
|
||||
|
||||
Args:
|
||||
dt: Datetime object
|
||||
timezone_str: Target timezone
|
||||
format_str: Date format string
|
||||
|
||||
Returns:
|
||||
Formatted date string
|
||||
"""
|
||||
try:
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
|
||||
target_tz = pytz.timezone(timezone_str)
|
||||
local_time = dt.astimezone(target_tz)
|
||||
|
||||
return local_time.strftime(format_str)
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def get_timezone(timezone_str: str) -> pytz.BaseTzInfo:
|
||||
"""
|
||||
Get timezone object from string.
|
||||
|
||||
Args:
|
||||
timezone_str: Timezone string
|
||||
|
||||
Returns:
|
||||
Timezone object
|
||||
"""
|
||||
try:
|
||||
return pytz.timezone(timezone_str)
|
||||
except pytz.UnknownTimeZoneError:
|
||||
logging.getLogger(__name__).warning(f"Unknown timezone: {timezone_str}, using UTC")
|
||||
return pytz.utc
|
||||
|
||||
|
||||
def validate_dimensions(width: int, height: int) -> bool:
|
||||
"""
|
||||
Validate display dimensions.
|
||||
|
||||
Args:
|
||||
width: Display width
|
||||
height: Display height
|
||||
|
||||
Returns:
|
||||
True if dimensions are valid
|
||||
"""
|
||||
return (isinstance(width, int) and isinstance(height, int) and
|
||||
width > 0 and height > 0 and width <= 1000 and height <= 1000)
|
||||
|
||||
|
||||
def parse_team_abbreviation(text: str) -> str:
|
||||
"""
|
||||
Parse team abbreviation from various text formats.
|
||||
|
||||
Args:
|
||||
text: Text containing team abbreviation
|
||||
|
||||
Returns:
|
||||
Extracted team abbreviation
|
||||
"""
|
||||
if not text:
|
||||
return ""
|
||||
|
||||
# Remove common prefixes/suffixes
|
||||
text = re.sub(r'^(Team|Club|FC|SC)\s+', '', text, flags=re.IGNORECASE)
|
||||
text = re.sub(r'\s+(Team|Club|FC|SC)$', '', text, flags=re.IGNORECASE)
|
||||
|
||||
# Extract abbreviation (usually 2-4 uppercase letters)
|
||||
match = re.search(r'\b[A-Z]{2,4}\b', text.upper())
|
||||
if match:
|
||||
return match.group()
|
||||
|
||||
# Fallback to first 3 characters
|
||||
return text[:3].upper()
|
||||
|
||||
|
||||
def format_score(home_score: Union[str, int], away_score: Union[str, int]) -> str:
|
||||
"""
|
||||
Format score for display.
|
||||
|
||||
Args:
|
||||
home_score: Home team score
|
||||
away_score: Away team score
|
||||
|
||||
Returns:
|
||||
Formatted score string
|
||||
"""
|
||||
return f"{away_score}-{home_score}"
|
||||
|
||||
|
||||
def format_period(period: int, sport: str = "basketball") -> str:
|
||||
"""
|
||||
Format period/quarter/inning for display.
|
||||
|
||||
Args:
|
||||
period: Period number
|
||||
sport: Sport type
|
||||
|
||||
Returns:
|
||||
Formatted period string
|
||||
"""
|
||||
if sport == "basketball":
|
||||
if period <= 4:
|
||||
return f"Q{period}"
|
||||
else:
|
||||
return f"OT{period - 4}"
|
||||
elif sport == "football":
|
||||
if period <= 4:
|
||||
return f"Q{period}"
|
||||
else:
|
||||
return f"OT{period - 4}"
|
||||
elif sport == "hockey":
|
||||
if period <= 3:
|
||||
return f"P{period}"
|
||||
else:
|
||||
return f"OT{period - 3}"
|
||||
elif sport == "baseball":
|
||||
return f"INN {period}"
|
||||
else:
|
||||
return f"P{period}"
|
||||
|
||||
|
||||
def is_live_game(status: str) -> bool:
|
||||
"""
|
||||
Check if game status indicates live play.
|
||||
|
||||
Args:
|
||||
status: Game status string
|
||||
|
||||
Returns:
|
||||
True if game is live
|
||||
"""
|
||||
live_indicators = ['live', 'in progress', 'halftime', 'overtime', 'ot']
|
||||
return any(indicator in status.lower() for indicator in live_indicators)
|
||||
|
||||
|
||||
def is_final_game(status: str) -> bool:
|
||||
"""
|
||||
Check if game status indicates final.
|
||||
|
||||
Args:
|
||||
status: Game status string
|
||||
|
||||
Returns:
|
||||
True if game is final
|
||||
"""
|
||||
final_indicators = ['final', 'completed', 'finished', 'ended']
|
||||
return any(indicator in status.lower() for indicator in final_indicators)
|
||||
|
||||
|
||||
def is_upcoming_game(status: str) -> bool:
|
||||
"""
|
||||
Check if game status indicates upcoming.
|
||||
|
||||
Args:
|
||||
status: Game status string
|
||||
|
||||
Returns:
|
||||
True if game is upcoming
|
||||
"""
|
||||
upcoming_indicators = ['scheduled', 'upcoming', 'pre-game', 'not started']
|
||||
return any(indicator in status.lower() for indicator in upcoming_indicators)
|
||||
|
||||
|
||||
def sanitize_filename(filename: str) -> str:
|
||||
"""
|
||||
Sanitize filename for safe file operations.
|
||||
|
||||
Args:
|
||||
filename: Original filename
|
||||
|
||||
Returns:
|
||||
Sanitized filename
|
||||
"""
|
||||
# Remove or replace invalid characters
|
||||
filename = re.sub(r'[<>:"/\\|?*]', '_', filename)
|
||||
# Remove multiple underscores
|
||||
filename = re.sub(r'_+', '_', filename)
|
||||
# Remove leading/trailing underscores and dots
|
||||
filename = filename.strip('_.')
|
||||
|
||||
return filename
|
||||
|
||||
|
||||
def truncate_text(text: str, max_length: int, suffix: str = "...") -> str:
|
||||
"""
|
||||
Truncate text to maximum length.
|
||||
|
||||
Args:
|
||||
text: Text to truncate
|
||||
max_length: Maximum length
|
||||
suffix: Suffix to add when truncating
|
||||
|
||||
Returns:
|
||||
Truncated text
|
||||
"""
|
||||
if len(text) <= max_length:
|
||||
return text
|
||||
|
||||
return text[:max_length - len(suffix)] + suffix
|
||||
|
||||
|
||||
def parse_boolean(value: Union[str, bool, int]) -> bool:
|
||||
"""
|
||||
Parse various boolean representations.
|
||||
|
||||
Args:
|
||||
value: Value to parse
|
||||
|
||||
Returns:
|
||||
Boolean value
|
||||
"""
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
|
||||
if isinstance(value, int):
|
||||
return bool(value)
|
||||
|
||||
if isinstance(value, str):
|
||||
return value.lower() in ('true', '1', 'yes', 'on', 'enabled')
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def get_logger(name: str, level: int = logging.INFO) -> logging.Logger:
|
||||
"""
|
||||
Get a logger with consistent configuration.
|
||||
|
||||
Note: This function is deprecated. Use src.logging_config.get_logger() instead.
|
||||
This function is kept for backward compatibility.
|
||||
|
||||
Args:
|
||||
name: Logger name
|
||||
level: Log level
|
||||
|
||||
Returns:
|
||||
Configured logger
|
||||
"""
|
||||
# Use centralized logging configuration
|
||||
try:
|
||||
from src.logging_config import get_logger as get_logger_centralized
|
||||
return get_logger_centralized(name)
|
||||
except ImportError:
|
||||
# Fallback to basic logging if centralized config not available
|
||||
logger = logging.getLogger(name)
|
||||
logger.setLevel(level)
|
||||
|
||||
if not logger.handlers:
|
||||
handler = logging.StreamHandler()
|
||||
formatter = logging.Formatter(
|
||||
'%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
handler.setFormatter(formatter)
|
||||
logger.addHandler(handler)
|
||||
|
||||
return logger
|
||||
+10
-187
@@ -1,12 +1,11 @@
|
||||
"""
|
||||
Configuration Service
|
||||
|
||||
Provides centralized configuration management with hot-reload support,
|
||||
versioning, and change notifications.
|
||||
Provides centralized configuration management with hot-reload support
|
||||
and change notifications.
|
||||
|
||||
This service wraps ConfigManager and adds:
|
||||
- File watching for automatic reload
|
||||
- Configuration versioning
|
||||
- Change notifications to subscribers
|
||||
- Thread-safe configuration access
|
||||
"""
|
||||
@@ -16,7 +15,6 @@ import time
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, Optional, List, Callable
|
||||
from datetime import datetime
|
||||
from collections import defaultdict
|
||||
import logging
|
||||
import hashlib
|
||||
@@ -26,51 +24,20 @@ from src.logging_config import get_logger
|
||||
from src.config_manager import ConfigManager
|
||||
|
||||
|
||||
class ConfigVersion:
|
||||
"""Represents a configuration version snapshot."""
|
||||
|
||||
def __init__(self, config: Dict[str, Any], version: int, timestamp: datetime, checksum: str):
|
||||
"""
|
||||
Initialize a configuration version.
|
||||
|
||||
Args:
|
||||
config: Configuration dictionary
|
||||
version: Version number
|
||||
timestamp: When this version was created
|
||||
checksum: SHA-256 hex digest of the config (for change detection)
|
||||
"""
|
||||
self.config: Dict[str, Any] = config
|
||||
self.version: int = version
|
||||
self.timestamp: datetime = timestamp
|
||||
self.checksum: str = checksum
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert version to dictionary."""
|
||||
return {
|
||||
'version': self.version,
|
||||
'timestamp': self.timestamp.isoformat(),
|
||||
'checksum': self.checksum,
|
||||
'config_size': len(json.dumps(self.config))
|
||||
}
|
||||
|
||||
|
||||
class ConfigService:
|
||||
"""
|
||||
Centralized configuration service with hot-reload and versioning.
|
||||
Centralized configuration service with hot-reload.
|
||||
|
||||
Features:
|
||||
- Automatic file watching and reload
|
||||
- Configuration versioning with history
|
||||
- Change notifications to subscribers
|
||||
- Thread-safe access
|
||||
- Backward compatible with ConfigManager
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config_manager: Optional[ConfigManager] = None,
|
||||
enable_hot_reload: bool = True,
|
||||
max_versions: int = 10
|
||||
enable_hot_reload: bool = True
|
||||
) -> None:
|
||||
"""
|
||||
Initialize the configuration service.
|
||||
@@ -78,24 +45,19 @@ class ConfigService:
|
||||
Args:
|
||||
config_manager: Optional ConfigManager instance (creates new if None)
|
||||
enable_hot_reload: Whether to enable automatic file watching
|
||||
max_versions: Maximum number of versions to keep in history
|
||||
"""
|
||||
self.logger: logging.Logger = get_logger(__name__)
|
||||
self.config_manager: ConfigManager = config_manager or ConfigManager()
|
||||
self.enable_hot_reload: bool = enable_hot_reload
|
||||
self.max_versions: int = max_versions
|
||||
|
||||
# Thread safety
|
||||
self._lock: threading.RLock = threading.RLock()
|
||||
|
||||
# Current configuration
|
||||
self._current_config: Dict[str, Any] = {}
|
||||
self._current_version: int = 0
|
||||
self._current_checksum: Optional[str] = None
|
||||
self._last_modified: Dict[str, float] = {}
|
||||
|
||||
# Version history
|
||||
self._versions: List[ConfigVersion] = []
|
||||
|
||||
# Subscribers for change notifications
|
||||
# Format: {plugin_id or component_name: [callbacks]}
|
||||
self._subscribers: Dict[str, List[Callable[[Dict[str, Any], Dict[str, Any]], None]]] = defaultdict(list)
|
||||
@@ -130,40 +92,22 @@ class ConfigService:
|
||||
|
||||
with self._lock:
|
||||
# Check if config actually changed
|
||||
if self._current_version > 0:
|
||||
old_checksum = self._versions[-1].checksum if self._versions else ""
|
||||
if new_checksum == old_checksum:
|
||||
self.logger.debug("Configuration unchanged, skipping reload")
|
||||
return False
|
||||
if new_checksum == self._current_checksum:
|
||||
self.logger.debug("Configuration unchanged, skipping reload")
|
||||
return False
|
||||
|
||||
# Store old config for change detection
|
||||
old_config = self._current_config.copy()
|
||||
|
||||
# Create new version
|
||||
self._current_version += 1
|
||||
version = ConfigVersion(
|
||||
config=new_config.copy(),
|
||||
version=self._current_version,
|
||||
timestamp=datetime.now(),
|
||||
checksum=new_checksum
|
||||
)
|
||||
|
||||
# Add to history
|
||||
self._versions.append(version)
|
||||
|
||||
# Trim history if needed
|
||||
if len(self._versions) > self.max_versions:
|
||||
self._versions.pop(0)
|
||||
|
||||
# Update current config
|
||||
self._current_config = new_config
|
||||
self._current_checksum = new_checksum
|
||||
|
||||
# Notify subscribers
|
||||
self._notify_subscribers(old_config, new_config)
|
||||
|
||||
self.logger.info(
|
||||
"Configuration reloaded (version %d, checksum: %s)",
|
||||
self._current_version,
|
||||
"Configuration reloaded (checksum: %s)",
|
||||
new_checksum[:8]
|
||||
)
|
||||
|
||||
@@ -303,19 +247,6 @@ class ConfigService:
|
||||
with self._lock:
|
||||
return self._current_config.copy()
|
||||
|
||||
def get_plugin_config(self, plugin_id: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Get configuration for a specific plugin.
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin identifier
|
||||
|
||||
Returns:
|
||||
Plugin configuration dictionary
|
||||
"""
|
||||
config = self.get_config()
|
||||
return config.get(plugin_id, {})
|
||||
|
||||
def subscribe(
|
||||
self,
|
||||
callback: Callable[[Dict[str, Any], Dict[str, Any]], None],
|
||||
@@ -354,95 +285,6 @@ class ConfigService:
|
||||
self._subscribers[key].remove(callback)
|
||||
self.logger.debug("Unsubscribed from config changes for %s", key)
|
||||
|
||||
def reload(self) -> bool:
|
||||
"""
|
||||
Manually reload configuration.
|
||||
|
||||
Returns:
|
||||
True if reloaded successfully, False otherwise
|
||||
"""
|
||||
self.logger.info("Manual configuration reload requested")
|
||||
return self._load_config()
|
||||
|
||||
def get_version(self) -> int:
|
||||
"""
|
||||
Get current configuration version.
|
||||
|
||||
Returns:
|
||||
Current version number
|
||||
"""
|
||||
with self._lock:
|
||||
return self._current_version
|
||||
|
||||
def get_version_history(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get configuration version history.
|
||||
|
||||
Returns:
|
||||
List of version dictionaries
|
||||
"""
|
||||
with self._lock:
|
||||
return [v.to_dict() for v in self._versions]
|
||||
|
||||
def get_version_config(self, version: int) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get configuration for a specific version.
|
||||
|
||||
Args:
|
||||
version: Version number
|
||||
|
||||
Returns:
|
||||
Configuration dictionary or None if version not found
|
||||
"""
|
||||
with self._lock:
|
||||
for v in self._versions:
|
||||
if v.version == version:
|
||||
return v.config.copy()
|
||||
return None
|
||||
|
||||
def rollback(self, version: int) -> bool:
|
||||
"""
|
||||
Rollback to a previous configuration version.
|
||||
|
||||
Args:
|
||||
version: Version number to rollback to
|
||||
|
||||
Returns:
|
||||
True if rollback successful, False otherwise
|
||||
"""
|
||||
config = self.get_version_config(version)
|
||||
if config is None:
|
||||
self.logger.error("Version %d not found in history", version)
|
||||
return False
|
||||
|
||||
try:
|
||||
# Save the rolled-back config
|
||||
self.config_manager.save_config(config)
|
||||
|
||||
# Reload
|
||||
return self._load_config()
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error("Error rolling back to version %d: %s", version, e, exc_info=True)
|
||||
return False
|
||||
|
||||
def save_config(self, new_config: Dict[str, Any]) -> bool:
|
||||
"""
|
||||
Save new configuration.
|
||||
|
||||
Args:
|
||||
new_config: New configuration dictionary
|
||||
|
||||
Returns:
|
||||
True if saved successfully, False otherwise
|
||||
"""
|
||||
try:
|
||||
self.config_manager.save_config(new_config)
|
||||
return self._load_config()
|
||||
except Exception as e:
|
||||
self.logger.error("Error saving configuration: %s", e, exc_info=True)
|
||||
return False
|
||||
|
||||
def shutdown(self) -> None:
|
||||
"""Shutdown the configuration service."""
|
||||
self.logger.info("Shutting down configuration service")
|
||||
@@ -450,22 +292,3 @@ class ConfigService:
|
||||
|
||||
with self._lock:
|
||||
self._subscribers.clear()
|
||||
|
||||
# Backward compatibility methods
|
||||
def load_config(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Load configuration (backward compatibility with ConfigManager).
|
||||
|
||||
Returns:
|
||||
Current configuration dictionary
|
||||
"""
|
||||
return self.get_config()
|
||||
|
||||
def get_config_path(self) -> str:
|
||||
"""Get config file path (backward compatibility)."""
|
||||
return self.config_manager.get_config_path()
|
||||
|
||||
def get_secrets_path(self) -> str:
|
||||
"""Get secrets file path (backward compatibility)."""
|
||||
return self.config_manager.get_secrets_path()
|
||||
|
||||
|
||||
@@ -400,8 +400,6 @@ class DisplayController:
|
||||
# Display rotation state
|
||||
self.current_mode_index = 0
|
||||
self.current_display_mode = None
|
||||
self.last_mode_change = time.time()
|
||||
self.mode_duration = 30 # Default duration
|
||||
self.global_dynamic_config = (
|
||||
self.config.get("display", {}).get("dynamic_duration", {}) or {}
|
||||
)
|
||||
@@ -828,7 +826,7 @@ class DisplayController:
|
||||
return
|
||||
|
||||
# Update all loaded plugins
|
||||
plugins_dict = getattr(self.plugin_manager, 'loaded_plugins', None) or getattr(self.plugin_manager, 'plugins', {})
|
||||
plugins_dict = self.plugin_manager.plugins
|
||||
deferred = []
|
||||
for plugin_id, plugin_instance in plugins_dict.items():
|
||||
update_timeout = None
|
||||
@@ -960,37 +958,6 @@ class DisplayController:
|
||||
|
||||
_FOLLOWER_SEND_INTERVAL = 1.0 / 90 # raw bytes are cheap; 90fps > follower render rate
|
||||
|
||||
def _follower_rebuild_scroll_image(self) -> None:
|
||||
"""Follower: rebuild the local Vegas scroll image so both Pis render from
|
||||
the same fresh plugin data. Called at startup (after Vegas initializes)
|
||||
and each time the leader broadcasts a new-cycle signal. Runs in a daemon
|
||||
thread so it never blocks the 60fps render loop.
|
||||
"""
|
||||
try:
|
||||
vc = getattr(self, 'vegas_coordinator', None)
|
||||
if not vc:
|
||||
logger.warning("Sync: follower has no vegas_coordinator — cannot build scroll image")
|
||||
return
|
||||
rp = vc.render_pipeline
|
||||
if not rp:
|
||||
logger.warning("Sync: follower vegas_coordinator has no render_pipeline")
|
||||
return
|
||||
logger.info("Sync: follower starting scroll image rebuild")
|
||||
ok = rp.start_new_cycle()
|
||||
if ok and rp.scroll_helper.cached_image is not None:
|
||||
logger.info(
|
||||
"Sync: follower scroll image ready — %dx%d",
|
||||
rp.scroll_helper.cached_image.width,
|
||||
rp.scroll_helper.cached_image.height,
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"Sync: follower scroll image rebuild FAILED (ok=%s, cached=%s)",
|
||||
ok, rp.scroll_helper.cached_image is not None,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("Sync: follower scroll image rebuild error: %s", exc, exc_info=True)
|
||||
|
||||
def _send_follower_frame(self, plugin_instance) -> None:
|
||||
"""Leader: generate and send the follower's portion of the current frame.
|
||||
|
||||
@@ -2089,9 +2056,6 @@ class DisplayController:
|
||||
should_skip = self.plugin_manager.health_tracker.should_skip_plugin(plugin_id)
|
||||
if should_skip:
|
||||
logger.info("Skipping plugin %s due to circuit breaker (mode: %s)", plugin_id, active_mode)
|
||||
display_result = False
|
||||
# Skip to next mode - let existing logic handle it
|
||||
manager_to_display = None
|
||||
|
||||
if not should_skip:
|
||||
manager_to_display = plugin_instance
|
||||
@@ -2187,11 +2151,6 @@ class DisplayController:
|
||||
# slips through.
|
||||
_release_display_lock()
|
||||
raise
|
||||
# execute_display returns bool, convert to expected format
|
||||
if result:
|
||||
result = True # Success
|
||||
else:
|
||||
result = False # Failed
|
||||
else:
|
||||
# Fallback to direct call if executor not available
|
||||
try:
|
||||
@@ -2281,7 +2240,6 @@ class DisplayController:
|
||||
if next_plugin_id != current_plugin_id:
|
||||
self.current_mode_index = next_index
|
||||
self.current_display_mode = next_mode
|
||||
self.last_mode_change = time.time()
|
||||
self.force_change = True
|
||||
logger.info("Switching to mode: %s (skipped plugin %s due to exception)",
|
||||
self.current_display_mode, current_plugin_id)
|
||||
@@ -2365,15 +2323,6 @@ class DisplayController:
|
||||
)
|
||||
min_duration = 15.0
|
||||
|
||||
if chosen_cap <= 0:
|
||||
logger.warning(
|
||||
"Invalid dynamic duration cap %s for mode %s, using default %ds",
|
||||
chosen_cap,
|
||||
active_mode,
|
||||
DEFAULT_DYNAMIC_DURATION_CAP,
|
||||
)
|
||||
chosen_cap = DEFAULT_DYNAMIC_DURATION_CAP
|
||||
|
||||
# Use plugin-calculated duration if available, capped by max
|
||||
if plugin_cycle_duration is not None and plugin_cycle_duration > 0:
|
||||
# Plugin provided a calculated duration - use it but respect cap
|
||||
@@ -2391,15 +2340,6 @@ class DisplayController:
|
||||
|
||||
# Ensure max_duration >= min_duration
|
||||
max_duration = max(min_duration, max_duration)
|
||||
|
||||
if max_duration < min_duration:
|
||||
logger.warning(
|
||||
"max_duration (%s) < min_duration (%s) for mode %s, adjusting max to min",
|
||||
max_duration,
|
||||
min_duration,
|
||||
active_mode,
|
||||
)
|
||||
max_duration = min_duration
|
||||
else:
|
||||
max_duration = base_duration
|
||||
|
||||
@@ -2741,7 +2681,6 @@ class DisplayController:
|
||||
if should_rotate and self.available_modes:
|
||||
self.current_mode_index = (self.current_mode_index + 1) % len(self.available_modes)
|
||||
self.current_display_mode = self.available_modes[self.current_mode_index]
|
||||
self.last_mode_change = time.time()
|
||||
self.force_change = True
|
||||
|
||||
logger.info("Switching to mode: %s", self.current_display_mode)
|
||||
|
||||
@@ -3,28 +3,15 @@ LEDMatrix Plugin System
|
||||
|
||||
This module provides the core plugin infrastructure for the LEDMatrix project.
|
||||
It enables dynamic loading, management, and discovery of display plugins.
|
||||
|
||||
API Version: 1.0.0
|
||||
"""
|
||||
|
||||
__version__ = "1.0.0"
|
||||
__api_version__ = "1.0.0"
|
||||
|
||||
from .base_plugin import BasePlugin
|
||||
from .plugin_manager import PluginManager
|
||||
|
||||
# Import store_manager only when needed to avoid dependency issues
|
||||
def get_store_manager():
|
||||
"""Get PluginStoreManager, importing only when needed."""
|
||||
try:
|
||||
from .store_manager import PluginStoreManager
|
||||
return PluginStoreManager
|
||||
except ImportError as e:
|
||||
raise ImportError("PluginStoreManager requires additional dependencies. Install requests: pip install requests") from e
|
||||
|
||||
__all__ = [
|
||||
'BasePlugin',
|
||||
'PluginManager',
|
||||
'get_store_manager',
|
||||
]
|
||||
|
||||
|
||||
@@ -1,319 +0,0 @@
|
||||
"""
|
||||
Enhanced plugin health monitoring with background checks and auto-recovery.
|
||||
|
||||
Builds on existing PluginHealthTracker to provide:
|
||||
- Background health checks
|
||||
- Health status determination (healthy/degraded/unhealthy)
|
||||
- Auto-recovery suggestions
|
||||
- Health metrics aggregation
|
||||
"""
|
||||
|
||||
import threading
|
||||
import time
|
||||
from typing import Dict, Any, Optional, List, Callable
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from dataclasses import dataclass
|
||||
|
||||
from src.logging_config import get_logger
|
||||
|
||||
|
||||
class HealthStatus(Enum):
|
||||
"""Overall health status of a plugin."""
|
||||
HEALTHY = "healthy"
|
||||
DEGRADED = "degraded"
|
||||
UNHEALTHY = "unhealthy"
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
|
||||
@dataclass
|
||||
class HealthMetrics:
|
||||
"""Health metrics for a plugin."""
|
||||
plugin_id: str
|
||||
status: HealthStatus
|
||||
last_successful_update: Optional[datetime]
|
||||
error_rate: float # 0.0 to 1.0
|
||||
average_response_time: Optional[float] # seconds
|
||||
consecutive_failures: int
|
||||
total_failures: int
|
||||
total_successes: int
|
||||
success_rate: float # 0.0 to 1.0
|
||||
last_error: Optional[str]
|
||||
circuit_breaker_state: str
|
||||
recovery_suggestions: List[str]
|
||||
|
||||
|
||||
class PluginHealthMonitor:
|
||||
"""
|
||||
Enhanced health monitoring for plugins.
|
||||
|
||||
Provides:
|
||||
- Background health checks
|
||||
- Health status determination
|
||||
- Auto-recovery suggestions
|
||||
- Health metrics aggregation
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
health_tracker,
|
||||
check_interval: float = 60.0,
|
||||
degraded_threshold: float = 0.5, # 50% error rate
|
||||
unhealthy_threshold: float = 0.8, # 80% error rate
|
||||
max_response_time: float = 5.0 # seconds
|
||||
):
|
||||
"""
|
||||
Initialize health monitor.
|
||||
|
||||
Args:
|
||||
health_tracker: PluginHealthTracker instance
|
||||
check_interval: Interval between background health checks (seconds)
|
||||
degraded_threshold: Error rate threshold for degraded status
|
||||
unhealthy_threshold: Error rate threshold for unhealthy status
|
||||
max_response_time: Maximum acceptable response time (seconds)
|
||||
"""
|
||||
self.health_tracker = health_tracker
|
||||
self.check_interval = check_interval
|
||||
self.degraded_threshold = degraded_threshold
|
||||
self.unhealthy_threshold = unhealthy_threshold
|
||||
self.max_response_time = max_response_time
|
||||
self.logger = get_logger(__name__)
|
||||
|
||||
# Background check thread
|
||||
self._monitor_thread: Optional[threading.Thread] = None
|
||||
self._stop_event = threading.Event()
|
||||
|
||||
# Health check callbacks
|
||||
self._health_check_callbacks: List[Callable[[str], Dict[str, Any]]] = []
|
||||
|
||||
def start_monitoring(self) -> None:
|
||||
"""Start background health monitoring."""
|
||||
if self._monitor_thread and self._monitor_thread.is_alive():
|
||||
return
|
||||
|
||||
self._stop_event.clear()
|
||||
self._monitor_thread = threading.Thread(
|
||||
target=self._monitor_loop,
|
||||
daemon=True,
|
||||
name="PluginHealthMonitor"
|
||||
)
|
||||
self._monitor_thread.start()
|
||||
self.logger.info("Started plugin health monitoring")
|
||||
|
||||
def stop_monitoring(self) -> None:
|
||||
"""Stop background health monitoring."""
|
||||
self._stop_event.set()
|
||||
if self._monitor_thread and self._monitor_thread.is_alive():
|
||||
self._monitor_thread.join(timeout=5.0)
|
||||
self.logger.info("Stopped plugin health monitoring")
|
||||
|
||||
def register_health_check(self, callback: Callable[[str], Dict[str, Any]]) -> None:
|
||||
"""
|
||||
Register a callback for health checks.
|
||||
|
||||
Callback should accept plugin_id and return dict with health info.
|
||||
"""
|
||||
self._health_check_callbacks.append(callback)
|
||||
|
||||
def get_plugin_health_status(self, plugin_id: str) -> HealthStatus:
|
||||
"""
|
||||
Determine overall health status for a plugin.
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin identifier
|
||||
|
||||
Returns:
|
||||
HealthStatus enum value
|
||||
"""
|
||||
if not self.health_tracker:
|
||||
return HealthStatus.UNKNOWN
|
||||
|
||||
summary = self.health_tracker.get_health_summary(plugin_id)
|
||||
|
||||
if not summary:
|
||||
return HealthStatus.UNKNOWN
|
||||
|
||||
# Check circuit breaker state
|
||||
circuit_state = summary.get('circuit_state', 'closed')
|
||||
if circuit_state == 'open':
|
||||
return HealthStatus.UNHEALTHY
|
||||
|
||||
# Check error rate
|
||||
success_rate = summary.get('success_rate', 100.0)
|
||||
error_rate = 1.0 - (success_rate / 100.0)
|
||||
|
||||
if error_rate >= self.unhealthy_threshold:
|
||||
return HealthStatus.UNHEALTHY
|
||||
elif error_rate >= self.degraded_threshold:
|
||||
return HealthStatus.DEGRADED
|
||||
else:
|
||||
return HealthStatus.HEALTHY
|
||||
|
||||
def get_plugin_health_metrics(self, plugin_id: str) -> HealthMetrics:
|
||||
"""
|
||||
Get comprehensive health metrics for a plugin.
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin identifier
|
||||
|
||||
Returns:
|
||||
HealthMetrics object
|
||||
"""
|
||||
if not self.health_tracker:
|
||||
return HealthMetrics(
|
||||
plugin_id=plugin_id,
|
||||
status=HealthStatus.UNKNOWN,
|
||||
last_successful_update=None,
|
||||
error_rate=0.0,
|
||||
average_response_time=None,
|
||||
consecutive_failures=0,
|
||||
total_failures=0,
|
||||
total_successes=0,
|
||||
success_rate=0.0,
|
||||
last_error=None,
|
||||
circuit_breaker_state="unknown",
|
||||
recovery_suggestions=[]
|
||||
)
|
||||
|
||||
summary = self.health_tracker.get_health_summary(plugin_id)
|
||||
|
||||
if not summary:
|
||||
return HealthMetrics(
|
||||
plugin_id=plugin_id,
|
||||
status=HealthStatus.UNKNOWN,
|
||||
last_successful_update=None,
|
||||
error_rate=0.0,
|
||||
average_response_time=None,
|
||||
consecutive_failures=0,
|
||||
total_failures=0,
|
||||
total_successes=0,
|
||||
success_rate=0.0,
|
||||
last_error=None,
|
||||
circuit_breaker_state="unknown",
|
||||
recovery_suggestions=[]
|
||||
)
|
||||
|
||||
# Calculate metrics
|
||||
success_rate = summary.get('success_rate', 100.0) / 100.0
|
||||
error_rate = 1.0 - success_rate
|
||||
|
||||
# Parse last success time
|
||||
last_success_time = None
|
||||
if summary.get('last_success_time'):
|
||||
try:
|
||||
last_success_time = datetime.fromisoformat(summary['last_success_time'])
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Determine status
|
||||
status = self.get_plugin_health_status(plugin_id)
|
||||
|
||||
# Get recovery suggestions
|
||||
recovery_suggestions = self._get_recovery_suggestions(plugin_id, summary, status)
|
||||
|
||||
return HealthMetrics(
|
||||
plugin_id=plugin_id,
|
||||
status=status,
|
||||
last_successful_update=last_success_time,
|
||||
error_rate=error_rate,
|
||||
average_response_time=None, # Would need resource monitor for this
|
||||
consecutive_failures=summary.get('consecutive_failures', 0),
|
||||
total_failures=summary.get('total_failures', 0),
|
||||
total_successes=summary.get('total_successes', 0),
|
||||
success_rate=success_rate,
|
||||
last_error=summary.get('last_error'),
|
||||
circuit_breaker_state=summary.get('circuit_state', 'closed'),
|
||||
recovery_suggestions=recovery_suggestions
|
||||
)
|
||||
|
||||
def get_all_plugin_health(self) -> Dict[str, HealthMetrics]:
|
||||
"""
|
||||
Get health metrics for all tracked plugins.
|
||||
|
||||
Returns:
|
||||
Dictionary mapping plugin_id to HealthMetrics
|
||||
"""
|
||||
if not self.health_tracker:
|
||||
return {}
|
||||
|
||||
summaries = self.health_tracker.get_all_health_summaries()
|
||||
health_metrics = {}
|
||||
|
||||
for plugin_id in summaries.keys():
|
||||
health_metrics[plugin_id] = self.get_plugin_health_metrics(plugin_id)
|
||||
|
||||
return health_metrics
|
||||
|
||||
def _get_recovery_suggestions(
|
||||
self,
|
||||
plugin_id: str,
|
||||
summary: Dict[str, Any],
|
||||
status: HealthStatus
|
||||
) -> List[str]:
|
||||
"""
|
||||
Generate recovery suggestions based on health status.
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin identifier
|
||||
summary: Health summary from tracker
|
||||
status: Current health status
|
||||
|
||||
Returns:
|
||||
List of suggested recovery actions
|
||||
"""
|
||||
suggestions = []
|
||||
|
||||
if status == HealthStatus.UNHEALTHY:
|
||||
suggestions.append("Plugin is unhealthy - check plugin logs for errors")
|
||||
suggestions.append("Verify plugin configuration is correct")
|
||||
suggestions.append("Check if plugin dependencies are installed")
|
||||
|
||||
if summary.get('circuit_state') == 'open':
|
||||
suggestions.append("Circuit breaker is open - plugin is being skipped")
|
||||
suggestions.append("Wait for cooldown period or manually reset health")
|
||||
|
||||
if summary.get('consecutive_failures', 0) > 0:
|
||||
suggestions.append(f"Plugin has {summary['consecutive_failures']} consecutive failures")
|
||||
suggestions.append("Consider disabling plugin temporarily")
|
||||
|
||||
elif status == HealthStatus.DEGRADED:
|
||||
suggestions.append("Plugin is degraded - experiencing intermittent failures")
|
||||
suggestions.append("Monitor plugin performance")
|
||||
suggestions.append("Check for resource constraints (CPU, memory)")
|
||||
|
||||
error_rate = (1.0 - (summary.get('success_rate', 100.0) / 100.0)) * 100
|
||||
suggestions.append(f"Current error rate: {error_rate:.1f}%")
|
||||
|
||||
elif status == HealthStatus.HEALTHY:
|
||||
suggestions.append("Plugin is healthy - no action needed")
|
||||
|
||||
# Add specific suggestions based on last error
|
||||
last_error = summary.get('last_error')
|
||||
if last_error:
|
||||
if "timeout" in last_error.lower():
|
||||
suggestions.append("Last error was a timeout - plugin may be slow or unresponsive")
|
||||
elif "import" in last_error.lower() or "module" in last_error.lower():
|
||||
suggestions.append("Last error suggests missing dependencies")
|
||||
elif "permission" in last_error.lower() or "access" in last_error.lower():
|
||||
suggestions.append("Last error suggests permission issues")
|
||||
|
||||
return suggestions
|
||||
|
||||
def _monitor_loop(self) -> None:
|
||||
"""Background monitoring loop."""
|
||||
while not self._stop_event.is_set():
|
||||
try:
|
||||
# Run health checks for all plugins
|
||||
if self._health_check_callbacks:
|
||||
# Get list of plugin IDs (would need plugin manager reference)
|
||||
# For now, just wait
|
||||
pass
|
||||
|
||||
# Sleep until next check
|
||||
self._stop_event.wait(self.check_interval)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error in health monitor loop: {e}", exc_info=True)
|
||||
# Continue monitoring even if there's an error
|
||||
time.sleep(self.check_interval)
|
||||
|
||||
@@ -238,40 +238,3 @@ class PluginExecutor:
|
||||
)
|
||||
record_error(e, plugin_id=plugin_id, operation="display")
|
||||
return False
|
||||
|
||||
def execute_safe(
|
||||
self,
|
||||
operation: Callable[[], Any],
|
||||
plugin_id: str,
|
||||
operation_name: str = "operation",
|
||||
timeout: Optional[float] = None,
|
||||
default_return: Any = None
|
||||
) -> Any:
|
||||
"""
|
||||
Execute an operation safely, returning default on error.
|
||||
|
||||
Args:
|
||||
operation: Function to execute
|
||||
plugin_id: Plugin identifier
|
||||
operation_name: Name of operation for logging
|
||||
timeout: Timeout in seconds (None = use default)
|
||||
default_return: Value to return on error
|
||||
|
||||
Returns:
|
||||
Result of operation or default_return on error
|
||||
"""
|
||||
try:
|
||||
return self.execute_with_timeout(
|
||||
operation,
|
||||
timeout=timeout,
|
||||
plugin_id=plugin_id
|
||||
)
|
||||
except Exception as e: # covers PluginTimeoutError, PluginError, and unexpected errors
|
||||
self.logger.warning(
|
||||
"Plugin %s %s failed, using default return: %s",
|
||||
plugin_id,
|
||||
operation_name,
|
||||
e
|
||||
)
|
||||
return default_return
|
||||
|
||||
|
||||
@@ -752,21 +752,6 @@ class PluginLoader:
|
||||
self.logger.error(error_msg, exc_info=True)
|
||||
raise PluginError(error_msg, plugin_id=plugin_id) from e
|
||||
|
||||
@staticmethod
|
||||
def _parse_semver(value: Any) -> Optional[Tuple[int, int, int]]:
|
||||
"""Parse 'X.Y.Z' (extra parts/suffixes ignored) into a comparable
|
||||
3-tuple, or None when unparseable."""
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
parts = value.strip().lstrip('v').split('.')
|
||||
try:
|
||||
nums = [int(''.join(ch for ch in p if ch.isdigit()) or 0) for p in parts[:3]]
|
||||
except ValueError:
|
||||
return None
|
||||
while len(nums) < 3:
|
||||
nums.append(0)
|
||||
return tuple(nums) # type: ignore[return-value]
|
||||
|
||||
def _warn_if_incompatible(self, plugin_id: str, manifest: Dict[str, Any]) -> None:
|
||||
"""Log one warning when a plugin declares a minimum LEDMatrix version
|
||||
newer than the running core. Advisory only — never raises — so a
|
||||
|
||||
@@ -86,15 +86,14 @@ class PluginManager:
|
||||
self._skip_reported: set = set()
|
||||
|
||||
# Lock protecting plugin_last_update from concurrent mutation/iteration.
|
||||
# It's written from run_scheduled_updates()/update_all_plugins() (main
|
||||
# loop) and read/diffed by run_scheduled_updates_with_changes(), which
|
||||
# It's written from run_scheduled_updates() (main loop) and read/diffed by run_scheduled_updates_with_changes(), which
|
||||
# Vegas mode calls from its own background update-tick thread.
|
||||
self._plugin_last_update_lock = threading.RLock()
|
||||
|
||||
# Active plugins
|
||||
self.plugins: Dict[str, Any] = {}
|
||||
self.plugin_manifests: Dict[str, Dict[str, Any]] = {}
|
||||
self.plugin_modules: Dict[str, Any] = {}
|
||||
self.plugin_directories: Dict[str, Path] = {}
|
||||
self.plugin_last_update: Dict[str, float] = {}
|
||||
|
||||
# Cached data-fetch intervals per plugin_id.
|
||||
@@ -263,10 +262,7 @@ class PluginManager:
|
||||
with self._discovery_lock:
|
||||
self.plugin_manifests.clear()
|
||||
self.plugin_manifests.update(new_manifests)
|
||||
if not hasattr(self, 'plugin_directories'):
|
||||
self.plugin_directories = {}
|
||||
else:
|
||||
self.plugin_directories.clear()
|
||||
self.plugin_directories.clear()
|
||||
self.plugin_directories.update(new_directories)
|
||||
|
||||
return plugin_ids
|
||||
@@ -327,11 +323,10 @@ class PluginManager:
|
||||
self.state_manager.set_state(plugin_id, PluginState.LOADED)
|
||||
|
||||
# Find plugin directory using PluginLoader
|
||||
plugin_directories = getattr(self, 'plugin_directories', None)
|
||||
plugin_dir = self.plugin_loader.find_plugin_directory(
|
||||
plugin_id,
|
||||
self.plugins_dir,
|
||||
plugin_directories
|
||||
self.plugin_directories
|
||||
)
|
||||
|
||||
if plugin_dir is None:
|
||||
@@ -341,9 +336,7 @@ class PluginManager:
|
||||
return False
|
||||
|
||||
# Update mapping if found via search
|
||||
if plugin_directories is None or plugin_id not in plugin_directories:
|
||||
if not hasattr(self, 'plugin_directories'):
|
||||
self.plugin_directories = {}
|
||||
if plugin_id not in self.plugin_directories:
|
||||
self.plugin_directories[plugin_id] = plugin_dir
|
||||
|
||||
# Get plugin config
|
||||
@@ -379,7 +372,7 @@ class PluginManager:
|
||||
config = self.prepare_plugin_config(plugin_id, config, schema=schema)
|
||||
|
||||
# Use PluginLoader to load plugin
|
||||
plugin_instance, module = self.plugin_loader.load_plugin(
|
||||
plugin_instance, _module = self.plugin_loader.load_plugin(
|
||||
plugin_id=plugin_id,
|
||||
manifest=manifest,
|
||||
plugin_dir=plugin_dir,
|
||||
@@ -391,9 +384,6 @@ class PluginManager:
|
||||
plugins_dir=self.plugins_dir,
|
||||
)
|
||||
|
||||
# Store module
|
||||
self.plugin_modules[plugin_id] = module
|
||||
|
||||
# Register plugin-shipped fonts with the FontManager (if any).
|
||||
# Plugin manifests can declare a "fonts" block that ships custom
|
||||
# fonts with the plugin; FontManager.register_plugin_fonts handles
|
||||
@@ -633,9 +623,6 @@ class PluginManager:
|
||||
# Delegate sub-module and cached-module cleanup to the loader
|
||||
self.plugin_loader.unregister_plugin_modules(plugin_id)
|
||||
|
||||
# Remove from plugin_modules
|
||||
self.plugin_modules.pop(plugin_id, None)
|
||||
|
||||
# Update state
|
||||
self.state_manager.set_state(plugin_id, PluginState.UNLOADED)
|
||||
self.state_manager.clear_state(plugin_id)
|
||||
@@ -778,7 +765,7 @@ class PluginManager:
|
||||
resolved further: dev plugins are symlinks into ``plugins_dir``.
|
||||
"""
|
||||
with self._discovery_lock:
|
||||
if hasattr(self, 'plugin_directories') and plugin_id in self.plugin_directories:
|
||||
if plugin_id in self.plugin_directories:
|
||||
return str(self.plugin_directories[plugin_id])
|
||||
|
||||
plugin_id = safe_path_component(plugin_id)
|
||||
@@ -967,7 +954,7 @@ class PluginManager:
|
||||
self.logger.warning("Plugin %s update() failed; will retry after interval", plugin_id)
|
||||
with self._plugin_last_update_lock:
|
||||
self.plugin_last_update[plugin_id] = failure_time
|
||||
self.state_manager.set_state_with_error(plugin_id, PluginState.ENABLED, error_info, error=err)
|
||||
self.state_manager.set_state_with_error(plugin_id, PluginState.ENABLED, error_info)
|
||||
if self.health_tracker:
|
||||
self.health_tracker.record_failure(plugin_id, err)
|
||||
|
||||
@@ -1299,97 +1286,3 @@ class PluginManager:
|
||||
done = sorted(self._completed_updates)
|
||||
self._completed_updates.clear()
|
||||
return done
|
||||
|
||||
def update_all_plugins(self) -> None:
|
||||
"""
|
||||
Update all enabled plugins.
|
||||
Calls update() on each enabled plugin using PluginExecutor.
|
||||
"""
|
||||
for plugin_id, plugin_instance in list(self.plugins.items()):
|
||||
if not getattr(plugin_instance, "enabled", True):
|
||||
continue
|
||||
|
||||
if not hasattr(plugin_instance, "update"):
|
||||
continue
|
||||
|
||||
# Eligibility check and the RUNNING transition together, so a
|
||||
# concurrent scheduler cannot claim the same plugin (see
|
||||
# _reserve_for_update).
|
||||
if not self._reserve_for_update(plugin_id):
|
||||
continue
|
||||
|
||||
try:
|
||||
success = self.plugin_executor.execute_update(plugin_instance, plugin_id)
|
||||
if success:
|
||||
with self._plugin_last_update_lock:
|
||||
self.plugin_last_update[plugin_id] = time.time()
|
||||
self._note_update_completed(plugin_id)
|
||||
self.state_manager.record_update(plugin_id)
|
||||
self.state_manager.set_state(plugin_id, PluginState.ENABLED)
|
||||
else:
|
||||
self._record_update_failure(plugin_id)
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
self.logger.exception("Error updating plugin %s: %s", plugin_id, exc)
|
||||
self._record_update_failure(plugin_id, exc=exc)
|
||||
|
||||
def get_plugin_health_metrics(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Get health metrics for all plugins.
|
||||
|
||||
Returns:
|
||||
Dictionary mapping plugin_id to health metrics
|
||||
"""
|
||||
metrics = {}
|
||||
for plugin_id in self.plugins.keys():
|
||||
plugin_metrics = {}
|
||||
|
||||
# Get state information
|
||||
state_info = self.state_manager.get_state_info(plugin_id)
|
||||
plugin_metrics.update(state_info)
|
||||
|
||||
# Get health tracker metrics if available
|
||||
if self.health_tracker:
|
||||
health_info = self.health_tracker.get_health_summary(plugin_id)
|
||||
plugin_metrics['health'] = health_info
|
||||
else:
|
||||
plugin_metrics['health'] = {'status': 'unknown'}
|
||||
|
||||
metrics[plugin_id] = plugin_metrics
|
||||
return metrics
|
||||
|
||||
def get_plugin_resource_metrics(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Get resource usage metrics for all plugins.
|
||||
|
||||
Returns:
|
||||
Dictionary mapping plugin_id to resource metrics
|
||||
"""
|
||||
metrics = {}
|
||||
for plugin_id in self.plugins.keys():
|
||||
plugin_metrics = {}
|
||||
|
||||
# Get state information
|
||||
state_info = self.state_manager.get_state_info(plugin_id)
|
||||
plugin_metrics.update(state_info)
|
||||
|
||||
# Get resource monitor metrics if available
|
||||
if self.resource_monitor:
|
||||
resource_info = self.resource_monitor.get_metrics_summary(plugin_id)
|
||||
plugin_metrics['resources'] = resource_info
|
||||
else:
|
||||
plugin_metrics['resources'] = {'status': 'unknown'}
|
||||
|
||||
metrics[plugin_id] = plugin_metrics
|
||||
return metrics
|
||||
|
||||
def get_plugin_state(self, plugin_id: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Get comprehensive state information for a plugin.
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin identifier
|
||||
|
||||
Returns:
|
||||
Dictionary with state information
|
||||
"""
|
||||
return self.state_manager.get_state_info(plugin_id)
|
||||
|
||||
@@ -6,40 +6,14 @@ with state transitions and queries.
|
||||
"""
|
||||
|
||||
import threading
|
||||
import time
|
||||
from collections import deque
|
||||
from enum import Enum
|
||||
from typing import Optional, Dict, Any, Deque, List, Tuple
|
||||
from typing import Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
import logging
|
||||
|
||||
from src.logging_config import get_logger
|
||||
|
||||
|
||||
# The history is diagnostic only -- nothing reads the entries themselves, just
|
||||
# their count -- but it is appended to on the hot scheduling path: every update
|
||||
# cycle records RUNNING on reserve and ENABLED on finish. Unbounded, that is
|
||||
# 2,880 entries per plugin per day at the default 60s interval, which on a 1 GB
|
||||
# Pi exhausts memory in weeks.
|
||||
#
|
||||
# Two limits, because a single entry count answers the wrong question. What a
|
||||
# reader wants is "the last couple of hours", and how many transitions that is
|
||||
# depends entirely on the plugin's update interval -- which on a real board
|
||||
# spans 2s to 3600s. A flat 200 entries is 4.2 days for the slowest plugin and
|
||||
# 3.3 minutes for the fastest, so the plugin churning hardest, the one worth
|
||||
# looking at, keeps the least history.
|
||||
#
|
||||
# So: trim by AGE first, which makes the retained window comparable across
|
||||
# plugins whatever their cadence...
|
||||
STATE_HISTORY_MAX_AGE_SECONDS = 2 * 60 * 60
|
||||
|
||||
# ...and cap by COUNT second, purely as a memory ceiling for the fast pollers
|
||||
# whose age window would otherwise run to thousands of entries. At ~230 bytes
|
||||
# an entry this is ~0.5 MB per plugin worst case, and only plugins updating
|
||||
# faster than roughly every 4s can reach it.
|
||||
MAX_STATE_HISTORY_PER_PLUGIN = 2000
|
||||
|
||||
|
||||
class PluginState(Enum):
|
||||
"""Plugin state enumeration."""
|
||||
UNLOADED = "unloaded" # Plugin not loaded
|
||||
@@ -63,39 +37,14 @@ class PluginStateManager:
|
||||
self.logger = logger or get_logger(__name__)
|
||||
self._lock = threading.RLock()
|
||||
self._states: Dict[str, PluginState] = {}
|
||||
# (monotonic timestamp, transition). The clock is monotonic so a DST
|
||||
# shift or an NTP step cannot make entries look old and flush the
|
||||
# history; the human-readable timestamp lives inside the transition.
|
||||
self._state_history: Dict[str, Deque[Tuple[float, Dict[str, Any]]]] = {}
|
||||
# Lifetime transition totals, kept separately so the count reported by
|
||||
# get_state_info() stays truthful once the history above starts rolling.
|
||||
# Lifetime transition totals, reported by get_state_info().
|
||||
self._state_transition_counts: Dict[str, int] = {}
|
||||
self._error_info: Dict[str, Dict[str, Any]] = {}
|
||||
self._last_update: Dict[str, datetime] = {}
|
||||
self._last_display: Dict[str, datetime] = {}
|
||||
|
||||
def _record_transition(
|
||||
self,
|
||||
plugin_id: str,
|
||||
transition: Dict[str, Any]
|
||||
) -> None:
|
||||
"""Append a transition to the plugin's bounded history.
|
||||
|
||||
Callers must already hold ``_lock``. The deque discards its oldest
|
||||
entry once it is full, so the history cannot grow without bound; the
|
||||
lifetime total is tracked separately for get_state_info().
|
||||
"""
|
||||
history = self._state_history.get(plugin_id)
|
||||
if history is None:
|
||||
history = deque(maxlen=MAX_STATE_HISTORY_PER_PLUGIN)
|
||||
self._state_history[plugin_id] = history
|
||||
now = time.monotonic()
|
||||
history.append((now, transition))
|
||||
# Age out first; the deque's maxlen is the backstop for plugins that
|
||||
# produce more than the ceiling within the window.
|
||||
cutoff = now - STATE_HISTORY_MAX_AGE_SECONDS
|
||||
while history and history[0][0] < cutoff:
|
||||
history.popleft()
|
||||
def _record_transition(self, plugin_id: str) -> None:
|
||||
"""Count a state transition. Callers must already hold ``_lock``."""
|
||||
self._state_transition_counts[plugin_id] = (
|
||||
self._state_transition_counts.get(plugin_id, 0) + 1
|
||||
)
|
||||
@@ -117,14 +66,7 @@ class PluginStateManager:
|
||||
with self._lock:
|
||||
old_state = self._states.get(plugin_id, PluginState.UNLOADED)
|
||||
self._states[plugin_id] = state
|
||||
|
||||
transition = {
|
||||
'timestamp': datetime.now(),
|
||||
'from': old_state.value,
|
||||
'to': state.value,
|
||||
'error': str(error) if error else None
|
||||
}
|
||||
self._record_transition(plugin_id, transition)
|
||||
self._record_transition(plugin_id)
|
||||
|
||||
# Store error info if transitioning to ERROR state
|
||||
if state == PluginState.ERROR and error:
|
||||
@@ -181,56 +123,16 @@ class PluginStateManager:
|
||||
state = self.get_state(plugin_id)
|
||||
return state == PluginState.ENABLED
|
||||
|
||||
def get_state_history(self, plugin_id: str) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get state transition history for a plugin.
|
||||
|
||||
Retention is by age first -- transitions older than
|
||||
STATE_HISTORY_MAX_AGE_SECONDS are dropped -- and by count second, at
|
||||
MAX_STATE_HISTORY_PER_PLUGIN, which only binds for plugins updating
|
||||
fast enough to exceed it inside that window.
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin identifier
|
||||
|
||||
Returns:
|
||||
List of recent state transitions, oldest first. Both the list and
|
||||
the transition dicts are copies, so callers cannot mutate the
|
||||
manager's own history. The values inside a transition are all
|
||||
immutable, so a shallow copy per entry is enough.
|
||||
"""
|
||||
with self._lock:
|
||||
return [
|
||||
dict(transition)
|
||||
for _stamp, transition in self._state_history.get(plugin_id, ())
|
||||
]
|
||||
|
||||
def set_error_info(self, plugin_id: str, error_info: Dict[str, Any]) -> None:
|
||||
"""
|
||||
Persist structured error context without changing plugin state.
|
||||
|
||||
Used for recoverable failures (e.g. update timeout) where the plugin
|
||||
stays ENABLED but the error details should remain queryable.
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin identifier
|
||||
error_info: Arbitrary dict describing the error
|
||||
"""
|
||||
with self._lock:
|
||||
self._error_info[plugin_id] = dict(error_info)
|
||||
|
||||
def set_state_with_error(
|
||||
self,
|
||||
plugin_id: str,
|
||||
state: PluginState,
|
||||
error_info: Dict[str, Any],
|
||||
error: Optional[Exception] = None,
|
||||
) -> None:
|
||||
"""Set plugin state and persist error context atomically.
|
||||
|
||||
Unlike calling set_state() then set_error_info() separately, this
|
||||
method holds ``_lock`` for both writes so no reader can observe the
|
||||
new state without the accompanying error context.
|
||||
Holds ``_lock`` for both writes so no reader can observe the new
|
||||
state without the accompanying error context.
|
||||
|
||||
Intentionally does not clear ``_error_info`` the way set_state() does
|
||||
for non-ERROR transitions — this is the recoverable-failure path where
|
||||
@@ -240,19 +142,11 @@ class PluginStateManager:
|
||||
plugin_id: Plugin identifier
|
||||
state: New state
|
||||
error_info: Structured error dict to persist alongside the state
|
||||
error: Optional exception recorded in the transition history
|
||||
"""
|
||||
with self._lock:
|
||||
old_state = self._states.get(plugin_id, PluginState.UNLOADED)
|
||||
self._states[plugin_id] = state
|
||||
|
||||
self._record_transition(plugin_id, {
|
||||
'timestamp': datetime.now(),
|
||||
'from': old_state.value,
|
||||
'to': state.value,
|
||||
'error': str(error) if error else None,
|
||||
})
|
||||
|
||||
self._record_transition(plugin_id)
|
||||
self._error_info[plugin_id] = dict(error_info)
|
||||
|
||||
self.logger.debug(
|
||||
@@ -284,10 +178,6 @@ class PluginStateManager:
|
||||
"""Record that plugin update() was called."""
|
||||
self._last_update[plugin_id] = datetime.now()
|
||||
|
||||
def record_display(self, plugin_id: str) -> None:
|
||||
"""Record that plugin display() was called."""
|
||||
self._last_display[plugin_id] = datetime.now()
|
||||
|
||||
def get_last_update(self, plugin_id: str) -> Optional[datetime]:
|
||||
"""Get timestamp of last update() call."""
|
||||
return self._last_update.get(plugin_id)
|
||||
@@ -331,13 +221,13 @@ class PluginStateManager:
|
||||
def clear_state(self, plugin_id: str) -> None:
|
||||
"""Clear all state information for a plugin.
|
||||
|
||||
Held under ``_lock`` so the five dicts are dropped as one unit: every
|
||||
Held under ``_lock`` so the dicts are dropped as one unit: every
|
||||
other mutator takes the lock, and without it a concurrent set_state()
|
||||
could interleave and leave a plugin with history but no state.
|
||||
could interleave and leave a plugin with a transition count but no
|
||||
state.
|
||||
"""
|
||||
with self._lock:
|
||||
self._states.pop(plugin_id, None)
|
||||
self._state_history.pop(plugin_id, None)
|
||||
self._state_transition_counts.pop(plugin_id, None)
|
||||
self._error_info.pop(plugin_id, None)
|
||||
self._last_update.pop(plugin_id, None)
|
||||
|
||||
@@ -94,9 +94,6 @@ class PluginResourceMonitor:
|
||||
# they are rate-limited instead. See _METRICS_PERSIST_INTERVAL.
|
||||
self._metrics_persisted_at: Dict[str, float] = {}
|
||||
|
||||
# Thread-local storage for execution tracking
|
||||
self._local = threading.local()
|
||||
|
||||
# Lock for thread-safe access
|
||||
self._lock = threading.Lock()
|
||||
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
Centralized plugin state management.
|
||||
|
||||
Provides a single source of truth for plugin state (installed, enabled, version, etc.)
|
||||
with state change events and persistence.
|
||||
with persistence.
|
||||
"""
|
||||
|
||||
import json
|
||||
import threading
|
||||
from typing import Dict, Any, Optional, List, Callable
|
||||
from typing import Dict, Any, Optional
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from dataclasses import dataclass, asdict
|
||||
@@ -75,9 +75,7 @@ class PluginStateManager:
|
||||
|
||||
Provides:
|
||||
- Single source of truth for plugin state
|
||||
- State change events/notifications
|
||||
- State persistence
|
||||
- State versioning
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
@@ -104,9 +102,6 @@ class PluginStateManager:
|
||||
self._states: Dict[str, PluginState] = {}
|
||||
self._state_version = 1
|
||||
|
||||
# State change callbacks
|
||||
self._callbacks: Dict[str, List[Callable[[str, PluginState, PluginState], None]]] = {}
|
||||
|
||||
# Threading
|
||||
self._lock = threading.RLock()
|
||||
|
||||
@@ -149,8 +144,7 @@ class PluginStateManager:
|
||||
def update_plugin_state(
|
||||
self,
|
||||
plugin_id: str,
|
||||
updates: Dict[str, Any],
|
||||
notify: bool = True
|
||||
updates: Dict[str, Any]
|
||||
) -> bool:
|
||||
"""
|
||||
Update plugin state.
|
||||
@@ -158,7 +152,6 @@ class PluginStateManager:
|
||||
Args:
|
||||
plugin_id: Plugin identifier
|
||||
updates: Dictionary of state updates
|
||||
notify: Whether to notify callbacks of changes
|
||||
|
||||
Returns:
|
||||
True if update successful
|
||||
@@ -174,18 +167,6 @@ class PluginStateManager:
|
||||
enabled=False
|
||||
)
|
||||
|
||||
# Create new state with updates
|
||||
old_state = PluginState(
|
||||
plugin_id=current_state.plugin_id,
|
||||
status=current_state.status,
|
||||
enabled=current_state.enabled,
|
||||
version=current_state.version,
|
||||
installed_at=current_state.installed_at,
|
||||
last_updated=current_state.last_updated,
|
||||
config_version=current_state.config_version,
|
||||
metadata=current_state.metadata.copy() if current_state.metadata else {}
|
||||
)
|
||||
|
||||
# Apply updates
|
||||
if 'status' in updates:
|
||||
if isinstance(updates['status'], str):
|
||||
@@ -218,10 +199,6 @@ class PluginStateManager:
|
||||
# Store updated state
|
||||
self._states[plugin_id] = current_state
|
||||
|
||||
# Notify callbacks
|
||||
if notify:
|
||||
self._notify_callbacks(plugin_id, old_state, current_state)
|
||||
|
||||
# Auto-save if enabled
|
||||
if self.auto_save:
|
||||
self._save_state()
|
||||
@@ -274,23 +251,6 @@ class PluginStateManager:
|
||||
}
|
||||
)
|
||||
|
||||
def set_plugin_error(self, plugin_id: str, error: Optional[str] = None) -> bool:
|
||||
"""
|
||||
Mark plugin as having an error.
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin identifier
|
||||
error: Optional error message
|
||||
|
||||
Returns:
|
||||
True if update successful
|
||||
"""
|
||||
updates = {'status': PluginStateStatus.ERROR}
|
||||
if error:
|
||||
updates['metadata'] = {'last_error': error}
|
||||
|
||||
return self.update_plugin_state(plugin_id, updates)
|
||||
|
||||
def remove_plugin_state(self, plugin_id: str) -> bool:
|
||||
"""
|
||||
Remove plugin state (e.g., after uninstall).
|
||||
@@ -304,12 +264,8 @@ class PluginStateManager:
|
||||
self._ensure_loaded()
|
||||
with self._lock:
|
||||
if plugin_id in self._states:
|
||||
old_state = self._states[plugin_id]
|
||||
del self._states[plugin_id]
|
||||
|
||||
# Notify callbacks
|
||||
self._notify_callbacks(plugin_id, old_state, None)
|
||||
|
||||
# Auto-save if enabled
|
||||
if self.auto_save:
|
||||
self._save_state()
|
||||
@@ -318,58 +274,6 @@ class PluginStateManager:
|
||||
|
||||
return False
|
||||
|
||||
def subscribe_to_state_changes(
|
||||
self,
|
||||
callback: Callable[[str, PluginState, Optional[PluginState]], None],
|
||||
plugin_id: Optional[str] = None
|
||||
) -> str:
|
||||
"""
|
||||
Subscribe to state changes.
|
||||
|
||||
Args:
|
||||
callback: Callback function (plugin_id, old_state, new_state)
|
||||
plugin_id: Optional plugin ID to filter on (None = all plugins)
|
||||
|
||||
Returns:
|
||||
Subscription ID
|
||||
"""
|
||||
import uuid
|
||||
subscription_id = str(uuid.uuid4())
|
||||
|
||||
with self._lock:
|
||||
key = plugin_id or '*'
|
||||
if key not in self._callbacks:
|
||||
self._callbacks[key] = []
|
||||
self._callbacks[key].append(callback)
|
||||
|
||||
return subscription_id
|
||||
|
||||
def _notify_callbacks(
|
||||
self,
|
||||
plugin_id: str,
|
||||
old_state: PluginState,
|
||||
new_state: Optional[PluginState]
|
||||
) -> None:
|
||||
"""Notify all relevant callbacks of state change."""
|
||||
# Get callbacks for this plugin and all plugins
|
||||
callbacks_to_notify = []
|
||||
|
||||
if plugin_id in self._callbacks:
|
||||
callbacks_to_notify.extend(self._callbacks[plugin_id])
|
||||
|
||||
if '*' in self._callbacks:
|
||||
callbacks_to_notify.extend(self._callbacks['*'])
|
||||
|
||||
# Call each callback
|
||||
for callback in callbacks_to_notify:
|
||||
try:
|
||||
callback(plugin_id, old_state, new_state)
|
||||
except Exception as e:
|
||||
self.logger.error(
|
||||
f"Error in state change callback: {e}",
|
||||
exc_info=True
|
||||
)
|
||||
|
||||
def _save_state(self) -> None:
|
||||
"""Save state to file."""
|
||||
if not self.state_file:
|
||||
@@ -430,8 +334,3 @@ class PluginStateManager:
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error loading plugin state: {e}", exc_info=True)
|
||||
|
||||
def get_state_version(self) -> int:
|
||||
"""Get current state version (for detecting corruption)."""
|
||||
return self._state_version
|
||||
|
||||
|
||||
@@ -425,18 +425,9 @@ class StateReconciliation:
|
||||
# error. The entry is still surfaced as MANUAL_FIX_REQUIRED so the
|
||||
# UI can show it, but no auto-repair will run.
|
||||
previously_unrecoverable = plugin_id in self._unrecoverable_missing_on_disk
|
||||
# Also refuse to re-install a plugin that the user just uninstalled
|
||||
# through the UI — prevents a race where the reconciler fires
|
||||
# between file removal and config cleanup and resurrects the
|
||||
# plugin the user just deleted.
|
||||
recently_uninstalled = (
|
||||
self.store_manager is not None
|
||||
and hasattr(self.store_manager, 'was_recently_uninstalled')
|
||||
and self.store_manager.was_recently_uninstalled(plugin_id)
|
||||
)
|
||||
# Also refuse to resurrect a plugin the user has persistently
|
||||
# uninstalled. Unlike the in-memory race guard above, this record
|
||||
# survives restarts, so the user's removal sticks across updates.
|
||||
# uninstalled. The record survives restarts, so the user's
|
||||
# removal sticks across updates.
|
||||
persistently_uninstalled = (
|
||||
self.store_manager is not None
|
||||
and hasattr(self.store_manager, 'is_plugin_uninstalled')
|
||||
@@ -445,7 +436,6 @@ class StateReconciliation:
|
||||
can_repair = (
|
||||
self.store_manager is not None
|
||||
and not previously_unrecoverable
|
||||
and not recently_uninstalled
|
||||
and not persistently_uninstalled
|
||||
)
|
||||
inconsistencies.append(Inconsistency(
|
||||
|
||||
@@ -91,15 +91,7 @@ class PluginStoreManager:
|
||||
self._token_validation_cache = {} # Cache for token validation results: {token: (is_valid, timestamp, error_message)}
|
||||
self._token_validation_cache_timeout = 300 # 5 minutes cache for token validation
|
||||
|
||||
# Per-plugin tombstone timestamps for plugins that were uninstalled
|
||||
# recently via the UI. Used by the state reconciler to avoid
|
||||
# resurrecting a plugin the user just deleted when reconciliation
|
||||
# races against the uninstall operation. Cleared after ``_uninstall_tombstone_ttl``.
|
||||
self._uninstall_tombstones: Dict[str, float] = {}
|
||||
self._uninstall_tombstone_ttl = 300 # 5 minutes
|
||||
|
||||
# Persistent record of plugins the user has uninstalled. Unlike the
|
||||
# in-memory tombstones above (a short-lived race guard), this survives
|
||||
# Persistent record of plugins the user has uninstalled. It survives
|
||||
# restarts so that a core ``git pull`` update cannot resurrect a
|
||||
# built-in plugin the user removed. Built-in plugins (e.g.
|
||||
# ``web-ui-info``, ``starlark-apps``) are committed into the repo under
|
||||
@@ -189,21 +181,6 @@ class PluginStoreManager:
|
||||
synthetic_ts = time.time() + self._failure_backoff_seconds - cache_timeout
|
||||
cache_dict[cache_key] = (synthetic_ts, payload)
|
||||
|
||||
def mark_recently_uninstalled(self, plugin_id: str) -> None:
|
||||
"""Record that ``plugin_id`` was just uninstalled by the user."""
|
||||
self._uninstall_tombstones[plugin_id] = time.time()
|
||||
|
||||
def was_recently_uninstalled(self, plugin_id: str) -> bool:
|
||||
"""Return True if ``plugin_id`` has an active uninstall tombstone."""
|
||||
ts = self._uninstall_tombstones.get(plugin_id)
|
||||
if ts is None:
|
||||
return False
|
||||
if time.time() - ts > self._uninstall_tombstone_ttl:
|
||||
# Expired — clean up so the dict doesn't grow unbounded.
|
||||
self._uninstall_tombstones.pop(plugin_id, None)
|
||||
return False
|
||||
return True
|
||||
|
||||
def _is_valid_plugin_id(self, plugin_id: Any) -> bool:
|
||||
"""Return True if ``plugin_id`` is a safe single-component plugin id.
|
||||
|
||||
@@ -3269,25 +3246,3 @@ class PluginStoreManager:
|
||||
installed.append(item.name)
|
||||
|
||||
return installed
|
||||
|
||||
def get_installed_plugin_info(self, plugin_id: str) -> Optional[Dict]:
|
||||
"""
|
||||
Get manifest information for an installed plugin.
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin identifier
|
||||
|
||||
Returns:
|
||||
Manifest data or None if not found
|
||||
"""
|
||||
manifest_path = self.plugins_dir / plugin_id / "manifest.json"
|
||||
|
||||
if not manifest_path.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(manifest_path, 'r') as f:
|
||||
return json.load(f)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error reading manifest for {plugin_id}: {e}")
|
||||
return None
|
||||
|
||||
@@ -399,85 +399,3 @@ class VegasModeConfig:
|
||||
f"(0 disables the cap), got {self.max_plugin_width_ratio}")
|
||||
|
||||
return errors
|
||||
|
||||
def update(self, new_config: Dict[str, Any]) -> None:
|
||||
"""
|
||||
Update configuration from new values.
|
||||
|
||||
Args:
|
||||
new_config: New configuration values to apply
|
||||
"""
|
||||
vegas_config = new_config.get('display', {}).get('vegas_scroll', {})
|
||||
|
||||
if 'enabled' in vegas_config:
|
||||
self.enabled = vegas_config['enabled']
|
||||
if 'live_in_ticker' in vegas_config:
|
||||
self.live_in_ticker = bool(vegas_config['live_in_ticker'])
|
||||
# Clamped exactly as from_config does: a weight below 1 would drop the
|
||||
# plugin from the rotation, and a huge one starves everything else.
|
||||
if 'live_weight' in vegas_config:
|
||||
self.live_weight = max(1, min(10, int(vegas_config['live_weight'])))
|
||||
if 'favorite_live_weight' in vegas_config:
|
||||
self.favorite_live_weight = max(
|
||||
1, min(10, int(vegas_config['favorite_live_weight'])))
|
||||
if 'scroll_speed' in vegas_config:
|
||||
self.scroll_speed = float(vegas_config['scroll_speed'])
|
||||
if 'separator_width' in vegas_config:
|
||||
self.separator_width = int(vegas_config['separator_width'])
|
||||
if 'intra_plugin_gap' in vegas_config:
|
||||
self.intra_plugin_gap = int(vegas_config['intra_plugin_gap'])
|
||||
if 'render_width_pct' in vegas_config:
|
||||
self.render_width_pct = int(vegas_config['render_width_pct'])
|
||||
if 'min_content_separation' in vegas_config:
|
||||
self.min_content_separation = int(
|
||||
vegas_config['min_content_separation'])
|
||||
if 'min_cut_gap' in vegas_config:
|
||||
self.min_cut_gap = int(vegas_config['min_cut_gap'])
|
||||
if 'smooth_scroll' in vegas_config:
|
||||
self.smooth_scroll = vegas_config['smooth_scroll']
|
||||
if 'continuous_scroll' in vegas_config:
|
||||
self.continuous_scroll = vegas_config['continuous_scroll']
|
||||
if 'extend_threshold_screens' in vegas_config:
|
||||
self.extend_threshold_screens = float(
|
||||
vegas_config['extend_threshold_screens'])
|
||||
if 'auto_trim' in vegas_config:
|
||||
self.auto_trim = vegas_config['auto_trim']
|
||||
if 'trim_threshold' in vegas_config:
|
||||
self.trim_threshold = int(vegas_config['trim_threshold'])
|
||||
if 'content_padding' in vegas_config:
|
||||
self.content_padding = int(vegas_config['content_padding'])
|
||||
if 'min_plugin_width' in vegas_config:
|
||||
self.min_plugin_width = int(vegas_config['min_plugin_width'])
|
||||
if 'lead_in_width' in vegas_config:
|
||||
self.lead_in_width = int(vegas_config['lead_in_width'])
|
||||
if 'plugins_per_cycle' in vegas_config:
|
||||
self.plugins_per_cycle = int(vegas_config['plugins_per_cycle'])
|
||||
if 'max_plugin_width_ratio' in vegas_config:
|
||||
self.max_plugin_width_ratio = float(
|
||||
vegas_config['max_plugin_width_ratio'])
|
||||
if 'overflow_mode' in vegas_config:
|
||||
self.overflow_mode = str(vegas_config['overflow_mode'])
|
||||
if 'plugin_order' in vegas_config:
|
||||
self.plugin_order = list(vegas_config['plugin_order'])
|
||||
if 'excluded_plugins' in vegas_config:
|
||||
self.excluded_plugins = set(vegas_config['excluded_plugins'])
|
||||
if 'target_fps' in vegas_config:
|
||||
self.target_fps = int(vegas_config['target_fps'])
|
||||
if 'buffer_ahead' in vegas_config:
|
||||
self.buffer_ahead = int(vegas_config['buffer_ahead'])
|
||||
if 'frame_based_scrolling' in vegas_config:
|
||||
self.frame_based_scrolling = vegas_config['frame_based_scrolling']
|
||||
if 'scroll_delay' in vegas_config:
|
||||
self.scroll_delay = float(vegas_config['scroll_delay'])
|
||||
if 'dynamic_duration_enabled' in vegas_config:
|
||||
self.dynamic_duration_enabled = vegas_config['dynamic_duration_enabled']
|
||||
if 'min_cycle_duration' in vegas_config:
|
||||
self.min_cycle_duration = int(vegas_config['min_cycle_duration'])
|
||||
if 'max_cycle_duration' in vegas_config:
|
||||
self.max_cycle_duration = int(vegas_config['max_cycle_duration'])
|
||||
|
||||
# Log config update
|
||||
logger.info(
|
||||
"Vegas mode config updated: enabled=%s, speed=%.1f, fps=%d, buffer=%d",
|
||||
self.enabled, self.scroll_speed, self.target_fps, self.buffer_ahead
|
||||
)
|
||||
|
||||
@@ -230,45 +230,6 @@ def blank_runs(
|
||||
return list(zip(starts[long_enough].tolist(), ends[long_enough].tolist()))
|
||||
|
||||
|
||||
def find_item_boundary(
|
||||
img: Image.Image,
|
||||
target: int,
|
||||
min_run: int,
|
||||
threshold: int = DEFAULT_INK_THRESHOLD,
|
||||
) -> Optional[int]:
|
||||
"""
|
||||
Find the column nearest ``target`` that sits inside a gap between items.
|
||||
|
||||
Used to narrow an oversized segment without cutting through a word. Only
|
||||
runs of at least ``min_run`` blank columns are considered, so the
|
||||
single-column gaps between characters are never chosen — cutting there
|
||||
orphaned the tail of a word into the following cycle, which is how a lone
|
||||
"y" from "Wednesday" ended up floating between two unrelated plugins.
|
||||
|
||||
Args:
|
||||
img: Image to cut
|
||||
target: Preferred cut column
|
||||
min_run: Minimum blank-run width that counts as an item boundary
|
||||
threshold: Ink threshold
|
||||
|
||||
Returns:
|
||||
A column inside a qualifying gap, or None when the image has no such
|
||||
gap at all — in which case the caller must not cut it.
|
||||
"""
|
||||
runs = blank_runs(img, min_run, threshold)
|
||||
if not runs:
|
||||
return None
|
||||
|
||||
# Nearest point of the nearest run. For a run left of target that is its
|
||||
# end (content resumes just after), for a run right of target its start
|
||||
# (content stopped just before) — the right choice in both directions.
|
||||
def clamp_to_run(run: Tuple[int, int]) -> int:
|
||||
start, end = run
|
||||
return max(start, min(target, end - 1))
|
||||
|
||||
return min((clamp_to_run(r) for r in runs), key=lambda c: abs(c - target))
|
||||
|
||||
|
||||
def find_blank_cut(
|
||||
img: Image.Image,
|
||||
target: int,
|
||||
|
||||
@@ -685,24 +685,6 @@ class StreamManager:
|
||||
self.stats['fetch_errors'] += 1
|
||||
return None
|
||||
|
||||
def _refresh_plugin_content(self, plugin_id: str) -> None:
|
||||
"""
|
||||
Refresh content for a specific plugin into staging buffer.
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin to refresh
|
||||
"""
|
||||
# Invalidate cached content
|
||||
self.plugin_adapter.invalidate_cache(plugin_id)
|
||||
|
||||
# Fetch fresh content
|
||||
segment = self._fetch_plugin_content(plugin_id)
|
||||
|
||||
if segment:
|
||||
with self._buffer_lock:
|
||||
self._staging_buffer.append(segment)
|
||||
logger.debug("Refreshed content for %s in staging buffer", plugin_id)
|
||||
|
||||
def _ensure_buffer_filled(self) -> None:
|
||||
"""
|
||||
Top the buffer back up after segments have been served.
|
||||
|
||||
+8
-105
@@ -81,12 +81,6 @@ DEFAULT_AP_CHANNEL = 7
|
||||
# LED status message file (for display_controller integration)
|
||||
LED_STATUS_FILE = None # Will be set dynamically
|
||||
|
||||
# NetworkManager connection file locations (Trixie uses /run, Bookworm uses /etc)
|
||||
NM_CONNECTIONS_PATHS = [
|
||||
Path("/etc/NetworkManager/system-connections"),
|
||||
Path("/run/NetworkManager/system-connections"), # Trixie with Netplan
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class WiFiNetwork:
|
||||
@@ -140,9 +134,6 @@ class WiFiManager:
|
||||
# Discover WiFi interface (don't hardcode wlan0)
|
||||
self._wifi_interface = self._discover_wifi_interface()
|
||||
|
||||
# Detect if we're running on Trixie (Netplan-based NetworkManager)
|
||||
self._is_trixie = self._detect_trixie()
|
||||
|
||||
# Initialize disconnected check counter for grace period
|
||||
# This prevents AP mode from enabling on transient network hiccups
|
||||
self._disconnected_checks = 0
|
||||
@@ -155,7 +146,7 @@ class WiFiManager:
|
||||
|
||||
logger.info(f"WiFi Manager initialized - nmcli: {self.has_nmcli}, iwlist: {self.has_iwlist}, "
|
||||
f"hostapd: {self.has_hostapd}, dnsmasq: {self.has_dnsmasq}, "
|
||||
f"interface: {self._wifi_interface}, trixie: {self._is_trixie}")
|
||||
f"interface: {self._wifi_interface}")
|
||||
|
||||
# Once per process: remove a stale force-AP flag left by a prior crash.
|
||||
# Guard with a class-level flag so the nmcli AP-state check only runs
|
||||
@@ -301,44 +292,6 @@ class WiFiManager:
|
||||
logger.warning("Could not discover WiFi interface, defaulting to wlan0")
|
||||
return "wlan0"
|
||||
|
||||
def _detect_trixie(self) -> bool:
|
||||
"""
|
||||
Detect if running on Raspberry Pi OS Trixie (Debian 13).
|
||||
|
||||
Trixie uses Netplan with NetworkManager, which changes behavior:
|
||||
- Connection files are stored in /run/NetworkManager/system-connections
|
||||
- nmcli hotspot requires different handling
|
||||
- PMF (Protected Management Frames) may need to be disabled
|
||||
"""
|
||||
try:
|
||||
# Check for Netplan (primary indicator of Trixie)
|
||||
netplan_path = Path("/etc/netplan")
|
||||
if netplan_path.exists() and any(netplan_path.glob("*.yaml")):
|
||||
logger.debug("Detected Trixie: Netplan configuration found")
|
||||
return True
|
||||
|
||||
# Check Debian version
|
||||
os_release = Path("/etc/os-release")
|
||||
if os_release.exists():
|
||||
content = os_release.read_text()
|
||||
if 'VERSION_CODENAME=trixie' in content or 'VERSION_ID="13"' in content:
|
||||
logger.debug("Detected Trixie: os-release indicates Debian 13")
|
||||
return True
|
||||
|
||||
# Check if NM connections are in /run (Trixie behavior)
|
||||
# NM_CONNECTIONS_PATHS[0] = /etc/..., NM_CONNECTIONS_PATHS[1] = /run/...
|
||||
etc_nm_path = NM_CONNECTIONS_PATHS[0] # Bookworm location
|
||||
run_nm_path = NM_CONNECTIONS_PATHS[1] # Trixie location
|
||||
if run_nm_path.exists() and any(run_nm_path.glob("*.nmconnection")):
|
||||
if not etc_nm_path.exists() or not any(etc_nm_path.glob("*.nmconnection")):
|
||||
logger.debug("Detected Trixie: NM connections in /run only")
|
||||
return True
|
||||
|
||||
except (OSError, PermissionError) as e:
|
||||
logger.debug(f"Could not detect Trixie: {e}")
|
||||
|
||||
return False
|
||||
|
||||
def _load_config(self):
|
||||
"""Load WiFi configuration from file"""
|
||||
if self.config_path.exists():
|
||||
@@ -353,8 +306,7 @@ class WiFiManager:
|
||||
self.config = {
|
||||
"ap_ssid": DEFAULT_AP_SSID,
|
||||
"ap_channel": DEFAULT_AP_CHANNEL,
|
||||
"auto_enable_ap_mode": True, # Default: auto-enable when no network (safe due to grace period)
|
||||
"saved_networks": []
|
||||
"auto_enable_ap_mode": True # Default: auto-enable when no network (safe due to grace period)
|
||||
}
|
||||
self._save_config()
|
||||
|
||||
@@ -362,6 +314,12 @@ class WiFiManager:
|
||||
if "auto_enable_ap_mode" not in self.config:
|
||||
self.config["auto_enable_ap_mode"] = True # Default: auto-enable when no network (safe due to grace period)
|
||||
self._save_config()
|
||||
|
||||
# Older versions stored every joined network's password here in
|
||||
# plaintext and never read it back; scrub it from existing files.
|
||||
if "saved_networks" in self.config:
|
||||
del self.config["saved_networks"]
|
||||
self._save_config()
|
||||
|
||||
def _save_config(self):
|
||||
"""Save WiFi configuration to file"""
|
||||
@@ -1621,9 +1579,6 @@ class WiFiManager:
|
||||
break
|
||||
|
||||
if connected:
|
||||
# Save network to config
|
||||
self._save_network(ssid, password)
|
||||
|
||||
ip = status.ip_address or "Unknown"
|
||||
self._show_led_message(f"Connected! {ip}", duration=5)
|
||||
logger.info(f"Successfully connected to {ssid} with IP {ip}")
|
||||
@@ -1635,7 +1590,6 @@ class WiFiManager:
|
||||
|
||||
# No existing connection or activation failed, create new connection
|
||||
logger.info(f"Creating new connection for {ssid}...")
|
||||
self._save_network(ssid, password)
|
||||
|
||||
# Connect using nmcli
|
||||
if password:
|
||||
@@ -1765,8 +1719,6 @@ class WiFiManager:
|
||||
def _connect_wpa_supplicant(self, ssid: str, password: str) -> Tuple[bool, str]:
|
||||
"""Connect using wpa_supplicant (fallback)"""
|
||||
try:
|
||||
self._save_network(ssid, password)
|
||||
|
||||
# This would require modifying /etc/wpa_supplicant/wpa_supplicant.conf
|
||||
# For now, return not implemented
|
||||
return False, "wpa_supplicant connection not yet implemented. Please use NetworkManager (nmcli)."
|
||||
@@ -1854,23 +1806,6 @@ class WiFiManager:
|
||||
logger.error(f"Error disconnecting from WiFi: {e}")
|
||||
return False, str(e)
|
||||
|
||||
def _save_network(self, ssid: str, password: str):
|
||||
"""Save network credentials to config"""
|
||||
# Remove existing entry for this SSID
|
||||
self.config["saved_networks"] = [
|
||||
n for n in self.config["saved_networks"]
|
||||
if n.get("ssid") != ssid
|
||||
]
|
||||
|
||||
# Add new entry
|
||||
self.config["saved_networks"].append({
|
||||
"ssid": ssid,
|
||||
"password": password,
|
||||
"saved_at": time.time()
|
||||
})
|
||||
|
||||
self._save_config()
|
||||
|
||||
def _ensure_wifi_radio_enabled(self, max_retries: int = 3) -> bool:
|
||||
"""
|
||||
Ensure WiFi radio is enabled (not soft-blocked) with retry logic and verification.
|
||||
@@ -2592,38 +2527,6 @@ ignore_broadcast_ssid=0
|
||||
logger.error(f"Error creating hostapd config: {e}")
|
||||
raise
|
||||
|
||||
def _check_dnsmasq_conflict(self) -> Tuple[bool, str]:
|
||||
"""
|
||||
Check if dnsmasq is already in use for other purposes (e.g., Pi-hole).
|
||||
|
||||
Returns:
|
||||
Tuple of (conflict_detected, description)
|
||||
"""
|
||||
try:
|
||||
# Check if dnsmasq service is active
|
||||
result = subprocess.run(
|
||||
["systemctl", "is-active", "dnsmasq"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
if result.stdout.strip() == "active":
|
||||
# Check if it's configured for something other than our AP
|
||||
if DNSMASQ_CONFIG_PATH.exists():
|
||||
try:
|
||||
content = DNSMASQ_CONFIG_PATH.read_text()
|
||||
# Check for Pi-hole or other common dnsmasq uses
|
||||
if 'pihole' in content.lower() or 'pi-hole' in content.lower():
|
||||
return True, "Pi-hole detected - dnsmasq is in use"
|
||||
if 'server=' in content and self._wifi_interface not in content:
|
||||
return True, "dnsmasq appears to be configured for DNS forwarding"
|
||||
except (OSError, PermissionError):
|
||||
pass
|
||||
|
||||
return False, ""
|
||||
except (subprocess.TimeoutExpired, subprocess.SubprocessError):
|
||||
return False, ""
|
||||
|
||||
def _create_dnsmasq_config(self):
|
||||
"""
|
||||
Create dnsmasq drop-in configuration for captive portal DNS redirection.
|
||||
|
||||
Reference in New Issue
Block a user