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:
Chuck
2026-09-23 12:36:26 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent e1ce7189f1
commit 84afa9d64f
57 changed files with 273 additions and 6254 deletions
+2 -56
View File
@@ -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
View File
@@ -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',
-361
View File
@@ -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
-303
View File
@@ -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)
-220
View File
@@ -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)
-452
View File
@@ -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
-331
View File
@@ -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
View File
@@ -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()
+1 -62
View File
@@ -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)
-13
View File
@@ -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',
]
-319
View File
@@ -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)
-37
View File
@@ -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
-15
View File
@@ -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
+8 -115
View File
@@ -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)
+11 -121
View File
@@ -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)
-3
View File
@@ -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()
+3 -104
View File
@@ -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
+2 -12
View File
@@ -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(
+1 -46
View File
@@ -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
-82
View File
@@ -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
)
-39
View File
@@ -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,
-18
View File
@@ -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
View File
@@ -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.