mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 14:25:08 +00:00
refactor: delete dead Python code in the core (and stop storing Wi-Fi passwords) (#608)
* refactor(plugins): remove the no-op PluginHealthMonitor Its monitor loop did nothing (`if callbacks: pass`), register_health_check had no callers and api_v3.health_monitor was never read by any route. The live health data comes from PluginHealthTracker, which is untouched. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(store): drop the never-set uninstall tombstones Nothing in production called mark_recently_uninstalled, so the reconciler's was_recently_uninstalled check was always False. The persistent uninstall registry is what actually stops resurrection; the reconciler test now exercises that gate instead. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(common): delete unused config/display/game helpers, utils and error_handler Nothing in core, the web UI, scripts or the plugin monorepo imports config_helper, display_helper, game_helper, utils or error_handler; only their own tests did. The error_handler re-exports leave src.common's __all__; APIHelper, TextHelper, ScrollHelper, LogoHelper and the adaptive layout exports are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(config): drop ConfigService's unused versioning and save API ConfigVersion, get_version/get_version_history/get_version_config, rollback, save_config, reload, get_plugin_config and the backward-compat load_config/get_config_path/get_secrets_path had no callers. The display controller only uses get_config, subscribe, unsubscribe and shutdown, plus the file watcher. Change detection now compares against the current checksum instead of the last history entry. The subscriber tests asserted `callback.called or True`; they now reload the way the watcher does and assert the notification. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(plugins): drop unread plugin state history and callbacks plugin_state.PluginStateManager kept a bounded per-plugin transition history that only get_state_history (tests only) read; get_state_info reports a separate lifetime count, which stays. set_error_info and record_display had no callers, and set_state_with_error's `error` argument only fed the history. The web-side state_manager.PluginStateManager loses subscribe_to_state_changes, _notify_callbacks, set_plugin_error and get_state_version, none of which had callers; with no subscribers the old-state copy in update_plugin_state went with them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(plugins): remove unused PluginManager methods and attribute guards update_all_plugins was only called by a test (the display loop uses run_scheduled_updates); get_plugin_health_metrics, get_plugin_resource_metrics and get_plugin_state had no callers; and plugin_modules was written but never read. plugin_directories is now initialised in __init__, so the hasattr() guards around it go. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(plugins): remove unused executor, loader, store and package helpers - PluginExecutor.execute_safe: no callers. - PluginLoader._parse_semver: only its own tests; compatibility.parse_semver is the live copy and test_compatibility.py already covers it. - PluginStoreManager.get_installed_plugin_info: no callers. - PluginResourceMonitor._local: never read. - src.plugin_system.get_store_manager and __api_version__: no importers in core, scripts or the plugin monorepo. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(wifi): stop storing Wi-Fi passwords in wifi_config.json WiFiManager appended every joined network's SSID and password, in plaintext, to saved_networks in config/wifi_config.json, and nothing (web UI, backup restore, scripts) ever read them back: NetworkManager keeps its own credentials. The writes are gone, and loading the config now drops any saved_networks key and rewrites the file, so passwords already on disk are scrubbed. Also removes _check_dnsmasq_conflict (never called) and _detect_trixie, whose result only reached one log line, along with the NM_CONNECTIONS_PATHS constant only it used. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(display): remove unreachable and unused DisplayController code - _follower_rebuild_scroll_image: never called. - mode_duration (never read) and last_mode_change (write-only). - The `chosen_cap <= 0` branch: chosen_cap is either the minimum of caps already filtered to > 0 or DEFAULT_DYNAMIC_DURATION_CAP (180). - The `max_duration < min_duration` branch directly after `max_duration = max(min_duration, max_duration)`. - The circuit-breaker branch's `display_result = False` and `manager_to_display = None`: the first is overwritten a few lines later, the second is already None there. - The bool-to-bool conversion of execute_display's result, which is always a bool. - The `loaded_plugins` lookup in _update_modules: PluginManager has no such attribute, so it always fell through to `plugins`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(vegas): remove unused config update, boundary finder and refresh VegasModeConfig.update had no callers outside its own tests (the coordinator rebuilds the config with from_config on a change); geometry.find_item_boundary and StreamManager._refresh_plugin_content had no callers at all. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(run): drop the debug block that pretended to import the plugin system In debug mode run.py put src/plugin_system itself on sys.path and printed "Plugin system import successful" without importing anything. Nothing imports plugin_system modules by bare name, so the path entry did nothing either. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: delete tests that test nothing - test/plugins/test_{basketball_scoreboard,calendar,clock_simple, odds_ticker,soccer_scoreboard,text_display}.py skip everywhere the named plugins are not installed, including CI (LEDMATRIX_PLUGINS_DIR holds only the fixture plugin); test_plugin_matrix.py already covers every discovered plugin. Their PluginTestBase and the fixtures only it used (plugins_dir, mock_display_manager, mock_cache_manager, mock_plugin_manager, base_plugin_config in test/plugins/conftest.py) go with them. - test_plugin_system.py: test_discover_plugins (body was `pass`) and test_dependency_check (a comment), plus the test_plugin_manager fixture only the former requested. - test_display_manager.py: test_draw_image asserted that an image it had just assigned was not None. - test_display_controller.py: the rotation and schedule-override tests re-implemented the run-loop arithmetic inline and asserted on their own result without calling the controller. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: expect one plugin_last_update success stamp after update_all_plugins EveryStampRecordsACompletion required at least two success-path stamps; the second was update_all_plugins, removed as test-only. The worker and synchronous paths share the remaining stamp in _execute_update_now, and the check that every stamp calls _note_update_completed is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+2
-56
@@ -24,55 +24,10 @@ fractional scaling), `LayoutContext` (`fit_text`, `fit_image`, `by_tier`,
|
||||
`px`), and composite carvers `scoreboard_regions()` / `media_row()`.
|
||||
Full guide: [docs/ADAPTIVE_LAYOUT.md](../../docs/ADAPTIVE_LAYOUT.md).
|
||||
|
||||
## Error Handling (`error_handler.py`)
|
||||
|
||||
Common error handling patterns and utilities:
|
||||
|
||||
- `handle_file_operation()` - Handle file I/O with consistent error handling
|
||||
- `handle_json_operation()` - Handle JSON operations with error handling
|
||||
- `safe_execute()` - Safely execute operations with error handling
|
||||
- `retry_on_failure()` - Decorator for retrying failed operations
|
||||
- `log_and_continue()` - Log non-critical errors and continue
|
||||
- `log_and_raise()` - Log errors and raise exceptions
|
||||
|
||||
### Example Usage
|
||||
|
||||
```python
|
||||
from src.common.error_handler import handle_json_operation, safe_execute
|
||||
|
||||
# Handle JSON loading
|
||||
config = handle_json_operation(
|
||||
lambda: json.load(open('config.json')),
|
||||
"Failed to load config",
|
||||
logger,
|
||||
default={}
|
||||
)
|
||||
|
||||
# Safe execution with error handling
|
||||
result = safe_execute(
|
||||
lambda: risky_operation(),
|
||||
"Operation failed",
|
||||
logger,
|
||||
default=None
|
||||
)
|
||||
```
|
||||
|
||||
## API Helpers (`api_helper.py`)
|
||||
|
||||
Utilities for making HTTP requests and handling API responses.
|
||||
|
||||
## Configuration Helpers (`config_helper.py`)
|
||||
|
||||
Utilities for loading, saving, and validating configuration files.
|
||||
|
||||
## Display Helpers (`display_helper.py`)
|
||||
|
||||
Utilities for rendering content to the LED matrix display.
|
||||
|
||||
## Game Helpers (`game_helper.py`)
|
||||
|
||||
Utilities for processing game data and team information.
|
||||
|
||||
## Logo Helpers (`logo_helper.py`)
|
||||
|
||||
Utilities for loading and managing team logos.
|
||||
@@ -85,14 +40,6 @@ Utilities for text processing and formatting.
|
||||
|
||||
Utilities for scrolling text on the display.
|
||||
|
||||
## General Utilities (`utils.py`)
|
||||
|
||||
General-purpose utility functions:
|
||||
- Team abbreviation normalization
|
||||
- Time formatting
|
||||
- Boolean parsing
|
||||
- Logger creation (deprecated - use `src.logging_config.get_logger()`)
|
||||
|
||||
## Permission Utilities (`permission_utils.py`)
|
||||
|
||||
Helpers for ensuring directory permissions and ownership are correct
|
||||
@@ -102,6 +49,5 @@ persistent cache directory).
|
||||
## Best Practices
|
||||
|
||||
1. **Use centralized logging**: Import from `src.logging_config` instead of creating loggers directly
|
||||
2. **Use error handlers**: Use `error_handler` utilities for consistent error handling
|
||||
3. **Reuse utilities**: Check existing utilities before creating new ones
|
||||
4. **Document additions**: Add documentation when adding new utilities
|
||||
2. **Reuse utilities**: Check existing utilities before creating new ones
|
||||
3. **Document additions**: Add documentation when adding new utilities
|
||||
|
||||
+1
-19
@@ -2,25 +2,13 @@
|
||||
Common utilities and helpers for LEDMatrix.
|
||||
|
||||
This package provides reusable functionality for plugins and core modules:
|
||||
- Error handling utilities
|
||||
- API helpers
|
||||
- Configuration helpers
|
||||
- Display helpers
|
||||
- Game/team helpers
|
||||
- Logo helpers
|
||||
- Text/scroll helpers
|
||||
- General utilities
|
||||
- Adaptive layout and image helpers
|
||||
"""
|
||||
|
||||
# Export commonly used utilities
|
||||
from src.common.error_handler import (
|
||||
handle_file_operation,
|
||||
handle_json_operation,
|
||||
safe_execute,
|
||||
retry_on_failure,
|
||||
log_and_continue,
|
||||
log_and_raise
|
||||
)
|
||||
from src.common.api_helper import APIHelper
|
||||
from src.common.scroll_helper import ScrollHelper
|
||||
from src.common import scroll_config
|
||||
@@ -59,12 +47,6 @@ from src.adaptive_images import (
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
'handle_file_operation',
|
||||
'handle_json_operation',
|
||||
'safe_execute',
|
||||
'retry_on_failure',
|
||||
'log_and_continue',
|
||||
'log_and_raise',
|
||||
'APIHelper',
|
||||
'ScrollHelper',
|
||||
'scroll_config',
|
||||
|
||||
@@ -1,361 +0,0 @@
|
||||
"""
|
||||
Config Helper
|
||||
|
||||
Handles configuration management and validation for LED matrix plugins.
|
||||
Extracted from LEDMatrix core to provide reusable functionality for plugins.
|
||||
"""
|
||||
|
||||
import copy
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
|
||||
|
||||
class ConfigHelper:
|
||||
"""
|
||||
Helper class for configuration management and validation.
|
||||
|
||||
Provides functionality for:
|
||||
- Loading and saving configuration files
|
||||
- Validating configuration against schemas
|
||||
- Merging configurations
|
||||
- Getting configuration values with defaults
|
||||
- Configuration schema validation
|
||||
"""
|
||||
|
||||
def __init__(self, logger: Optional[logging.Logger] = None):
|
||||
"""
|
||||
Initialize the ConfigHelper.
|
||||
|
||||
Args:
|
||||
logger: Optional logger instance
|
||||
"""
|
||||
self.logger = logger or logging.getLogger(__name__)
|
||||
|
||||
def load_config(self, config_path: Union[str, Path]) -> Dict[str, Any]:
|
||||
"""
|
||||
Load configuration from a JSON file.
|
||||
|
||||
Args:
|
||||
config_path: Path to configuration file
|
||||
|
||||
Returns:
|
||||
Configuration dictionary
|
||||
"""
|
||||
config_path = Path(config_path)
|
||||
|
||||
try:
|
||||
if not config_path.exists():
|
||||
self.logger.warning(f"Configuration file not found: {config_path}")
|
||||
return {}
|
||||
|
||||
with open(config_path, 'r', encoding='utf-8') as f:
|
||||
config = json.load(f)
|
||||
|
||||
self.logger.debug(f"Loaded configuration from {config_path}")
|
||||
return config
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
self.logger.error(f"Invalid JSON in configuration file {config_path}: {e}")
|
||||
return {}
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error loading configuration from {config_path}: {e}")
|
||||
return {}
|
||||
|
||||
def save_config(self, config: Dict[str, Any], config_path: Union[str, Path]) -> bool:
|
||||
"""
|
||||
Save configuration to a JSON file.
|
||||
|
||||
Args:
|
||||
config: Configuration dictionary to save
|
||||
config_path: Path to save configuration file
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
config_path = Path(config_path)
|
||||
|
||||
try:
|
||||
# Ensure directory exists
|
||||
config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with open(config_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(config, f, indent=2, ensure_ascii=False)
|
||||
|
||||
self.logger.debug(f"Saved configuration to {config_path}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error saving configuration to {config_path}: {e}")
|
||||
return False
|
||||
|
||||
def get_config_value(self, config: Dict[str, Any], key: str,
|
||||
default: Any = None, required: bool = False) -> Any:
|
||||
"""
|
||||
Get a configuration value with optional default.
|
||||
|
||||
Args:
|
||||
config: Configuration dictionary
|
||||
key: Configuration key (supports dot notation like 'display.width')
|
||||
default: Default value if key not found
|
||||
required: If True, raise error if key not found
|
||||
|
||||
Returns:
|
||||
Configuration value or default
|
||||
"""
|
||||
try:
|
||||
# Support dot notation for nested keys
|
||||
keys = key.split('.')
|
||||
value = config
|
||||
|
||||
for k in keys:
|
||||
if isinstance(value, dict) and k in value:
|
||||
value = value[k]
|
||||
else:
|
||||
if required:
|
||||
raise KeyError(f"Required configuration key not found: {key}")
|
||||
return default
|
||||
|
||||
return value
|
||||
|
||||
except Exception as e:
|
||||
if required:
|
||||
raise
|
||||
self.logger.warning(f"Error getting config value for {key}: {e}")
|
||||
return default
|
||||
|
||||
def set_config_value(self, config: Dict[str, Any], key: str, value: Any) -> None:
|
||||
"""
|
||||
Set a configuration value.
|
||||
|
||||
Args:
|
||||
config: Configuration dictionary to modify
|
||||
key: Configuration key (supports dot notation)
|
||||
value: Value to set
|
||||
"""
|
||||
try:
|
||||
# Support dot notation for nested keys
|
||||
keys = key.split('.')
|
||||
current = config
|
||||
|
||||
# Navigate to parent of target key
|
||||
for k in keys[:-1]:
|
||||
if k not in current:
|
||||
current[k] = {}
|
||||
current = current[k]
|
||||
|
||||
# Set the value
|
||||
current[keys[-1]] = value
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error setting config value for {key}: {e}")
|
||||
|
||||
def merge_configs(self, base_config: Dict[str, Any],
|
||||
override_config: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Merge two configuration dictionaries.
|
||||
|
||||
Args:
|
||||
base_config: Base configuration
|
||||
override_config: Configuration to merge in (takes precedence)
|
||||
|
||||
Returns:
|
||||
Merged configuration dictionary (fully independent of both
|
||||
inputs — a shallow copy would alias un-overridden nested dicts,
|
||||
so mutating the result would mutate the caller's base config).
|
||||
"""
|
||||
merged = copy.deepcopy(base_config)
|
||||
|
||||
for key, value in override_config.items():
|
||||
if key in merged and isinstance(merged[key], dict) and isinstance(value, dict):
|
||||
# Recursively merge nested dictionaries
|
||||
merged[key] = self.merge_configs(merged[key], value)
|
||||
else:
|
||||
# Override with new value — deep-copied so mutating the
|
||||
# merged result can't reach back into override_config.
|
||||
merged[key] = copy.deepcopy(value)
|
||||
|
||||
return merged
|
||||
|
||||
def validate_config(self, config: Dict[str, Any],
|
||||
schema: Optional[Dict[str, Any]] = None) -> bool:
|
||||
"""
|
||||
Validate configuration against a schema.
|
||||
|
||||
Args:
|
||||
config: Configuration to validate
|
||||
schema: Validation schema (optional)
|
||||
|
||||
Returns:
|
||||
True if valid, False otherwise
|
||||
"""
|
||||
if schema is None:
|
||||
# Basic validation - just check if it's a dictionary
|
||||
return isinstance(config, dict)
|
||||
|
||||
try:
|
||||
return self._validate_against_schema(config, schema)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Configuration validation error: {e}")
|
||||
return False
|
||||
|
||||
def get_plugin_config(self, config: Dict[str, Any], plugin_id: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Get plugin-specific configuration.
|
||||
|
||||
Args:
|
||||
config: Full configuration dictionary
|
||||
plugin_id: Plugin identifier
|
||||
|
||||
Returns:
|
||||
Plugin-specific configuration
|
||||
"""
|
||||
plugin_key = f"{plugin_id}_config"
|
||||
return config.get(plugin_key, {})
|
||||
|
||||
def create_default_config(self, plugin_id: str,
|
||||
default_values: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Create a default configuration for a plugin.
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin identifier
|
||||
default_values: Default configuration values
|
||||
|
||||
Returns:
|
||||
Default configuration dictionary
|
||||
"""
|
||||
return {
|
||||
f"{plugin_id}_config": default_values
|
||||
}
|
||||
|
||||
def validate_required_keys(self, config: Dict[str, Any],
|
||||
required_keys: List[str]) -> List[str]:
|
||||
"""
|
||||
Validate that required keys are present in configuration.
|
||||
|
||||
Args:
|
||||
config: Configuration to validate
|
||||
required_keys: List of required keys
|
||||
|
||||
Returns:
|
||||
List of missing keys
|
||||
"""
|
||||
missing_keys = []
|
||||
|
||||
for key in required_keys:
|
||||
if not self._has_key(config, key):
|
||||
missing_keys.append(key)
|
||||
|
||||
return missing_keys
|
||||
|
||||
def get_display_config(self, config: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Get display-related configuration.
|
||||
|
||||
Args:
|
||||
config: Full configuration dictionary
|
||||
|
||||
Returns:
|
||||
Display configuration
|
||||
"""
|
||||
return config.get('display', {})
|
||||
|
||||
def get_sports_config(self, config: Dict[str, Any], sport: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Get sport-specific configuration.
|
||||
|
||||
Args:
|
||||
config: Full configuration dictionary
|
||||
sport: Sport name (e.g., 'basketball', 'football')
|
||||
|
||||
Returns:
|
||||
Sport-specific configuration
|
||||
"""
|
||||
return config.get(f"{sport}_scoreboard", {})
|
||||
|
||||
def is_plugin_enabled(self, config: Dict[str, Any], plugin_id: str) -> bool:
|
||||
"""
|
||||
Check if a plugin is enabled.
|
||||
|
||||
Args:
|
||||
config: Full configuration dictionary
|
||||
plugin_id: Plugin identifier
|
||||
|
||||
Returns:
|
||||
True if plugin is enabled
|
||||
"""
|
||||
plugin_config = self.get_plugin_config(config, plugin_id)
|
||||
return plugin_config.get('enabled', True)
|
||||
|
||||
def get_favorite_teams(self, config: Dict[str, Any], sport: str) -> List[str]:
|
||||
"""
|
||||
Get favorite teams for a sport.
|
||||
|
||||
Args:
|
||||
config: Full configuration dictionary
|
||||
sport: Sport name
|
||||
|
||||
Returns:
|
||||
List of favorite team abbreviations
|
||||
"""
|
||||
sport_config = self.get_sports_config(config, sport)
|
||||
return sport_config.get('favorite_teams', [])
|
||||
|
||||
def get_display_modes(self, config: Dict[str, Any], sport: str) -> Dict[str, bool]:
|
||||
"""
|
||||
Get display modes for a sport.
|
||||
|
||||
Args:
|
||||
config: Full configuration dictionary
|
||||
sport: Sport name
|
||||
|
||||
Returns:
|
||||
Dictionary of display modes and their enabled status
|
||||
"""
|
||||
sport_config = self.get_sports_config(config, sport)
|
||||
return sport_config.get('display_modes', {})
|
||||
|
||||
def _validate_against_schema(self, config: Dict[str, Any],
|
||||
schema: Dict[str, Any]) -> bool:
|
||||
"""Validate configuration against a schema."""
|
||||
# This is a simplified schema validation
|
||||
# In a real implementation, you might use a library like jsonschema
|
||||
|
||||
for key, schema_info in schema.items():
|
||||
if key not in config:
|
||||
if schema_info.get('required', False):
|
||||
self.logger.error(f"Missing required configuration key: {key}")
|
||||
return False
|
||||
continue
|
||||
|
||||
value = config[key]
|
||||
expected_type = schema_info.get('type')
|
||||
|
||||
if expected_type and not isinstance(value, expected_type):
|
||||
self.logger.error(f"Configuration key {key} has wrong type. Expected {expected_type}, got {type(value)}")
|
||||
return False
|
||||
|
||||
# Validate allowed values
|
||||
allowed_values = schema_info.get('allowed_values')
|
||||
if allowed_values and value not in allowed_values:
|
||||
self.logger.error(f"Configuration key {key} has invalid value: {value}. Allowed: {allowed_values}")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def _has_key(self, config: Dict[str, Any], key: str) -> bool:
|
||||
"""Check if a key exists in configuration (supports dot notation)."""
|
||||
try:
|
||||
keys = key.split('.')
|
||||
current = config
|
||||
|
||||
for k in keys:
|
||||
if not isinstance(current, dict) or k not in current:
|
||||
return False
|
||||
current = current[k]
|
||||
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
@@ -1,303 +0,0 @@
|
||||
"""
|
||||
Display Helper
|
||||
|
||||
Handles common display operations and layouts for LED matrix displays.
|
||||
Extracted from LEDMatrix core to provide reusable functionality for plugins.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
|
||||
class DisplayHelper:
|
||||
"""
|
||||
Helper class for common display operations and layouts.
|
||||
|
||||
Provides functionality for:
|
||||
- Creating base images and overlays
|
||||
- Common layout patterns (scorebug, ticker, etc.)
|
||||
- Image compositing and manipulation
|
||||
- Display dimension utilities
|
||||
"""
|
||||
|
||||
def __init__(self, display_width: int, display_height: int,
|
||||
logger: Optional[logging.Logger] = None):
|
||||
"""
|
||||
Initialize the DisplayHelper.
|
||||
|
||||
Args:
|
||||
display_width: Width of the LED matrix display
|
||||
display_height: Height of the LED matrix display
|
||||
logger: Optional logger instance
|
||||
"""
|
||||
self.display_width = display_width
|
||||
self.display_height = display_height
|
||||
self.logger = logger or logging.getLogger(__name__)
|
||||
|
||||
def create_base_image(self, background_color: Tuple[int, int, int] = (0, 0, 0),
|
||||
mode: str = 'RGB') -> Image.Image:
|
||||
"""
|
||||
Create a base image for the display.
|
||||
|
||||
Args:
|
||||
background_color: Background color (R, G, B)
|
||||
mode: Image mode ('RGB', 'RGBA', etc.)
|
||||
|
||||
Returns:
|
||||
PIL Image object
|
||||
"""
|
||||
return Image.new(mode, (self.display_width, self.display_height), background_color)
|
||||
|
||||
def create_overlay(self, background_color: Tuple[int, int, int, int] = (0, 0, 0, 0)) -> Image.Image:
|
||||
"""
|
||||
Create an overlay image for compositing.
|
||||
|
||||
Args:
|
||||
background_color: Background color with alpha (R, G, B, A)
|
||||
|
||||
Returns:
|
||||
PIL Image object with alpha channel
|
||||
"""
|
||||
return Image.new('RGBA', (self.display_width, self.display_height), background_color)
|
||||
|
||||
def composite_images(self, base_image: Image.Image, overlay_image: Image.Image) -> Image.Image:
|
||||
"""
|
||||
Composite overlay onto base image.
|
||||
|
||||
Args:
|
||||
base_image: Base image (RGB or RGBA)
|
||||
overlay_image: Overlay image (should be RGBA)
|
||||
|
||||
Returns:
|
||||
Composited image
|
||||
"""
|
||||
if base_image.mode != 'RGBA':
|
||||
base_image = base_image.convert('RGBA')
|
||||
|
||||
if overlay_image.mode != 'RGBA':
|
||||
overlay_image = overlay_image.convert('RGBA')
|
||||
|
||||
return Image.alpha_composite(base_image, overlay_image)
|
||||
|
||||
def draw_scorebug_layout(self, game_data: Dict[str, Any],
|
||||
fonts: Dict[str, ImageFont.ImageFont],
|
||||
home_logo: Optional[Image.Image] = None,
|
||||
away_logo: Optional[Image.Image] = None) -> Image.Image:
|
||||
"""
|
||||
Draw a standard scorebug layout for sports games.
|
||||
|
||||
Args:
|
||||
game_data: Dictionary containing game information
|
||||
fonts: Dictionary of loaded fonts
|
||||
home_logo: Home team logo (optional)
|
||||
away_logo: Away team logo (optional)
|
||||
|
||||
Returns:
|
||||
PIL Image with scorebug layout
|
||||
"""
|
||||
# Create base image and overlay
|
||||
main_img = self.create_base_image()
|
||||
overlay = self.create_overlay()
|
||||
draw = ImageDraw.Draw(overlay)
|
||||
|
||||
# Extract game data
|
||||
home_score = str(game_data.get('home_score', '0'))
|
||||
away_score = str(game_data.get('away_score', '0'))
|
||||
home_abbr = game_data.get('home_abbr', 'HOME')
|
||||
away_abbr = game_data.get('away_abbr', 'AWAY')
|
||||
status_text = game_data.get('status_text', '')
|
||||
period_text = game_data.get('period_text', '')
|
||||
clock = game_data.get('clock', '')
|
||||
|
||||
# Draw logos if provided
|
||||
if home_logo and away_logo:
|
||||
self._draw_logos(main_img, home_logo, away_logo)
|
||||
|
||||
# Draw one combined top line (period/status/clock all share y=1 —
|
||||
# drawing them separately overprinted each other).
|
||||
top_line = " ".join(p for p in [period_text, status_text, clock] if p)
|
||||
if top_line:
|
||||
self._draw_centered_text(draw, top_line,
|
||||
fonts.get('time', fonts.get('status')),
|
||||
y_position=1)
|
||||
|
||||
# Draw scores (center)
|
||||
score_text = f"{away_score}-{home_score}"
|
||||
self._draw_centered_text(draw, score_text, fonts.get('score'),
|
||||
y_position=self.display_height // 2 - 3)
|
||||
|
||||
# Draw team abbreviations (bottom)
|
||||
if away_abbr:
|
||||
self._draw_text_with_outline(draw, away_abbr, (0, self.display_height - 12),
|
||||
fonts.get('team'))
|
||||
if home_abbr:
|
||||
text_width = draw.textlength(home_abbr, font=fonts.get('team'))
|
||||
self._draw_text_with_outline(draw, home_abbr,
|
||||
(self.display_width - text_width, self.display_height - 12),
|
||||
fonts.get('team'))
|
||||
|
||||
# Composite and return
|
||||
final_img = self.composite_images(main_img, overlay)
|
||||
return final_img.convert('RGB')
|
||||
|
||||
def draw_ticker_layout(self, text: str, font: ImageFont.ImageFont,
|
||||
background_color: Tuple[int, int, int] = (0, 0, 0),
|
||||
text_color: Tuple[int, int, int] = (255, 255, 255),
|
||||
scroll_speed: int = 1) -> Image.Image:
|
||||
"""
|
||||
Draw a ticker/scrolling text layout.
|
||||
|
||||
Renders a single static frame with the text at the left edge; the
|
||||
caller advances the scroll by re-rendering or shifting. The
|
||||
scroll_speed parameter is accepted for API compatibility but does
|
||||
not affect this frame. (Previously the text was drawn starting at
|
||||
x=display_width — entirely off-canvas — so every frame was blank.)
|
||||
|
||||
Args:
|
||||
text: Text to display
|
||||
font: Font to use
|
||||
background_color: Background color
|
||||
text_color: Text color
|
||||
scroll_speed: Accepted for compatibility; unused per-frame
|
||||
|
||||
Returns:
|
||||
PIL Image with ticker layout
|
||||
"""
|
||||
img = self.create_base_image(background_color)
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
self._draw_text_with_outline(draw, text, (0, self.display_height // 2 - 6),
|
||||
font, fill=text_color)
|
||||
|
||||
return img
|
||||
|
||||
def draw_centered_text(self, text: str, font: ImageFont.ImageFont,
|
||||
background_color: Tuple[int, int, int] = (0, 0, 0),
|
||||
text_color: Tuple[int, int, int] = (255, 255, 255)) -> Image.Image:
|
||||
"""
|
||||
Draw centered text on the display.
|
||||
|
||||
Args:
|
||||
text: Text to display
|
||||
font: Font to use
|
||||
background_color: Background color
|
||||
text_color: Text color
|
||||
|
||||
Returns:
|
||||
PIL Image with centered text
|
||||
"""
|
||||
img = self.create_base_image(background_color)
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# Calculate center position
|
||||
text_width = draw.textlength(text, font=font)
|
||||
text_height = 12 # Approximate height
|
||||
x = (self.display_width - text_width) // 2
|
||||
y = (self.display_height - text_height) // 2
|
||||
|
||||
# Draw text
|
||||
self._draw_text_with_outline(draw, text, (x, y), font, fill=text_color)
|
||||
|
||||
return img
|
||||
|
||||
def draw_error_message(self, message: str = "Error") -> Image.Image:
|
||||
"""
|
||||
Draw a simple error message.
|
||||
|
||||
Args:
|
||||
message: Error message to display
|
||||
|
||||
Returns:
|
||||
PIL Image with error message
|
||||
"""
|
||||
# Dark red background, white text
|
||||
font = ImageFont.load_default()
|
||||
return self.draw_centered_text(message, font, (50, 0, 0), (255, 255, 255))
|
||||
|
||||
def draw_no_data_message(self, message: str = "No Data") -> Image.Image:
|
||||
"""
|
||||
Draw a no data message.
|
||||
|
||||
Args:
|
||||
message: Message to display
|
||||
|
||||
Returns:
|
||||
PIL Image with no data message
|
||||
"""
|
||||
font = ImageFont.load_default()
|
||||
return self.draw_centered_text(message, font, (0, 0, 0), (150, 150, 150))
|
||||
|
||||
def get_display_dimensions(self) -> Tuple[int, int]:
|
||||
"""
|
||||
Get display dimensions.
|
||||
|
||||
Returns:
|
||||
(width, height) tuple
|
||||
"""
|
||||
return (self.display_width, self.display_height)
|
||||
|
||||
def is_portrait(self) -> bool:
|
||||
"""
|
||||
Check if display is in portrait orientation.
|
||||
|
||||
Returns:
|
||||
True if height > width
|
||||
"""
|
||||
return self.display_height > self.display_width
|
||||
|
||||
def is_landscape(self) -> bool:
|
||||
"""
|
||||
Check if display is in landscape orientation.
|
||||
|
||||
Returns:
|
||||
True if width > height
|
||||
"""
|
||||
return self.display_width > self.display_height
|
||||
|
||||
def get_center_position(self) -> Tuple[int, int]:
|
||||
"""
|
||||
Get center position of the display.
|
||||
|
||||
Returns:
|
||||
(x, y) center position
|
||||
"""
|
||||
return (self.display_width // 2, self.display_height // 2)
|
||||
|
||||
def _draw_logos(self, img: Image.Image, home_logo: Image.Image, away_logo: Image.Image) -> None:
|
||||
"""Draw team logos on the image."""
|
||||
center_y = self.display_height // 2
|
||||
|
||||
# Home logo (right side)
|
||||
if home_logo:
|
||||
home_x = self.display_width - home_logo.width + 10
|
||||
home_y = center_y - (home_logo.height // 2)
|
||||
img.paste(home_logo, (home_x, home_y), home_logo)
|
||||
|
||||
# Away logo (left side)
|
||||
if away_logo:
|
||||
away_x = -10
|
||||
away_y = center_y - (away_logo.height // 2)
|
||||
img.paste(away_logo, (away_x, away_y), away_logo)
|
||||
|
||||
def _draw_centered_text(self, draw: ImageDraw.ImageDraw, text: str,
|
||||
font: ImageFont.ImageFont, y_position: int) -> None:
|
||||
"""Draw centered text at specified y position."""
|
||||
text_width = draw.textlength(text, font=font)
|
||||
x = (self.display_width - text_width) // 2
|
||||
self._draw_text_with_outline(draw, text, (x, y_position), font)
|
||||
|
||||
def _draw_text_with_outline(self, draw: ImageDraw.ImageDraw, text: str,
|
||||
position: Tuple[int, int], font: ImageFont.ImageFont,
|
||||
fill: Tuple[int, int, int] = (255, 255, 255),
|
||||
outline_color: Tuple[int, int, int] = (0, 0, 0)) -> None:
|
||||
"""Draw text with outline for better readability."""
|
||||
x, y = position
|
||||
|
||||
# Draw outline
|
||||
for dx, dy in [(-1, -1), (-1, 0), (-1, 1), (0, -1), (0, 1), (1, -1), (1, 0), (1, 1)]:
|
||||
draw.text((x + dx, y + dy), text, font=font, fill=outline_color)
|
||||
|
||||
# Draw main text
|
||||
draw.text((x, y), text, font=font, fill=fill)
|
||||
@@ -1,220 +0,0 @@
|
||||
"""
|
||||
Error Handling Utilities
|
||||
|
||||
Common error handling patterns and utilities for consistent error handling
|
||||
across the LEDMatrix codebase.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any, Callable, Optional, TypeVar, Dict
|
||||
from functools import wraps
|
||||
from src.exceptions import LEDMatrixError
|
||||
|
||||
T = TypeVar('T')
|
||||
|
||||
|
||||
def handle_file_operation(
|
||||
operation: Callable[[], T],
|
||||
error_message: str,
|
||||
logger: logging.Logger,
|
||||
default: Optional[T] = None,
|
||||
context: Optional[Dict[str, Any]] = None
|
||||
) -> Optional[T]:
|
||||
"""
|
||||
Handle file operations with consistent error handling.
|
||||
|
||||
Args:
|
||||
operation: Function to execute (file read/write)
|
||||
error_message: Base error message
|
||||
logger: Logger instance
|
||||
default: Default value to return on error
|
||||
context: Optional context dictionary for error details
|
||||
|
||||
Returns:
|
||||
Result of operation or default value
|
||||
"""
|
||||
try:
|
||||
return operation()
|
||||
except FileNotFoundError as e:
|
||||
logger.warning("%s: File not found: %s", error_message, e, exc_info=True)
|
||||
return default
|
||||
except PermissionError as e:
|
||||
logger.error("%s: Permission denied: %s", error_message, e, exc_info=True)
|
||||
return default
|
||||
except (IOError, OSError) as e:
|
||||
logger.error("%s: I/O error: %s", error_message, e, exc_info=True)
|
||||
return default
|
||||
except Exception as e:
|
||||
logger.error("%s: Unexpected error: %s", error_message, e, exc_info=True)
|
||||
return default
|
||||
|
||||
|
||||
def handle_json_operation(
|
||||
operation: Callable[[], T],
|
||||
error_message: str,
|
||||
logger: logging.Logger,
|
||||
default: Optional[T] = None,
|
||||
context: Optional[Dict[str, Any]] = None
|
||||
) -> Optional[T]:
|
||||
"""
|
||||
Handle JSON operations with consistent error handling.
|
||||
|
||||
Args:
|
||||
operation: Function to execute (JSON load/dump)
|
||||
error_message: Base error message
|
||||
logger: Logger instance
|
||||
default: Default value to return on error
|
||||
context: Optional context dictionary for error details
|
||||
|
||||
Returns:
|
||||
Result of operation or default value
|
||||
"""
|
||||
try:
|
||||
return operation()
|
||||
except FileNotFoundError as e:
|
||||
logger.warning("%s: File not found: %s", error_message, e, exc_info=True)
|
||||
return default
|
||||
except PermissionError as e:
|
||||
logger.error("%s: Permission denied: %s", error_message, e, exc_info=True)
|
||||
return default
|
||||
except ValueError as e:
|
||||
logger.error("%s: Invalid JSON: %s", error_message, e, exc_info=True)
|
||||
return default
|
||||
except (IOError, OSError) as e:
|
||||
logger.error("%s: I/O error: %s", error_message, e, exc_info=True)
|
||||
return default
|
||||
except Exception as e:
|
||||
logger.error("%s: Unexpected error: %s", error_message, e, exc_info=True)
|
||||
return default
|
||||
|
||||
|
||||
def safe_execute(
|
||||
operation: Callable[[], T],
|
||||
error_message: str,
|
||||
logger: logging.Logger,
|
||||
default: Optional[T] = None,
|
||||
raise_on_error: bool = False,
|
||||
exception_type: type = LEDMatrixError
|
||||
) -> Optional[T]:
|
||||
"""
|
||||
Safely execute an operation with error handling.
|
||||
|
||||
Args:
|
||||
operation: Function to execute
|
||||
error_message: Base error message
|
||||
logger: Logger instance
|
||||
default: Default value to return on error
|
||||
raise_on_error: If True, raise exception instead of returning default
|
||||
exception_type: Type of exception to raise if raise_on_error is True
|
||||
|
||||
Returns:
|
||||
Result of operation or default value (or raises exception)
|
||||
"""
|
||||
try:
|
||||
return operation()
|
||||
except LEDMatrixError:
|
||||
# Re-raise LEDMatrix errors as-is
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("%s: %s", error_message, e, exc_info=True)
|
||||
if raise_on_error:
|
||||
raise exception_type(error_message, context={'original_error': str(e)}) from e
|
||||
return default
|
||||
|
||||
|
||||
def retry_on_failure(
|
||||
max_attempts: int = 3,
|
||||
delay: float = 1.0,
|
||||
backoff: float = 2.0,
|
||||
exceptions: tuple = (Exception,),
|
||||
logger: Optional[logging.Logger] = None
|
||||
):
|
||||
"""
|
||||
Decorator to retry a function on failure.
|
||||
|
||||
Args:
|
||||
max_attempts: Maximum number of retry attempts
|
||||
delay: Initial delay between retries in seconds
|
||||
backoff: Multiplier for delay after each retry
|
||||
exceptions: Tuple of exceptions to catch and retry on
|
||||
logger: Optional logger instance
|
||||
|
||||
Returns:
|
||||
Decorator function
|
||||
"""
|
||||
def decorator(func: Callable) -> Callable:
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
current_delay = delay
|
||||
last_exception = None
|
||||
|
||||
for attempt in range(max_attempts):
|
||||
try:
|
||||
return func(*args, **kwargs)
|
||||
except exceptions as e:
|
||||
last_exception = e
|
||||
if attempt < max_attempts - 1:
|
||||
if logger:
|
||||
logger.warning(
|
||||
"%s failed (attempt %d/%d): %s. Retrying in %.1fs...",
|
||||
func.__name__, attempt + 1, max_attempts, e, current_delay
|
||||
)
|
||||
import time
|
||||
time.sleep(current_delay)
|
||||
current_delay *= backoff
|
||||
else:
|
||||
if logger:
|
||||
logger.error(
|
||||
"%s failed after %d attempts: %s",
|
||||
func.__name__, max_attempts, e, exc_info=True
|
||||
)
|
||||
|
||||
# If we get here, all attempts failed
|
||||
raise last_exception
|
||||
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
|
||||
def log_and_continue(
|
||||
logger: logging.Logger,
|
||||
message: str,
|
||||
level: int = logging.WARNING,
|
||||
context: Optional[Dict[str, Any]] = None
|
||||
):
|
||||
"""
|
||||
Log a message and continue execution (for non-critical errors).
|
||||
|
||||
Args:
|
||||
logger: Logger instance
|
||||
message: Log message
|
||||
level: Log level (default: WARNING)
|
||||
context: Optional context dictionary
|
||||
"""
|
||||
if context:
|
||||
logger.log(level, "%s (context: %s)", message, context)
|
||||
else:
|
||||
logger.log(level, message)
|
||||
|
||||
|
||||
def log_and_raise(
|
||||
logger: logging.Logger,
|
||||
message: str,
|
||||
exception_type: type = LEDMatrixError,
|
||||
context: Optional[Dict[str, Any]] = None
|
||||
):
|
||||
"""
|
||||
Log an error and raise an exception.
|
||||
|
||||
Args:
|
||||
logger: Logger instance
|
||||
message: Error message
|
||||
exception_type: Type of exception to raise
|
||||
context: Optional context dictionary
|
||||
|
||||
Raises:
|
||||
exception_type: The specified exception type
|
||||
"""
|
||||
logger.error(message, exc_info=True)
|
||||
raise exception_type(message, context=context)
|
||||
|
||||
@@ -1,452 +0,0 @@
|
||||
"""
|
||||
Game Helper
|
||||
|
||||
Handles common game data extraction and processing for LED matrix plugins.
|
||||
Extracted from LEDMatrix core to provide reusable functionality for plugins.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
import pytz
|
||||
|
||||
|
||||
class GameHelper:
|
||||
"""
|
||||
Helper class for game data extraction and processing.
|
||||
|
||||
Provides functionality for:
|
||||
- Extracting game details from ESPN API responses
|
||||
- Filtering games by various criteria
|
||||
- Processing game data for display
|
||||
- Time zone handling and date formatting
|
||||
"""
|
||||
|
||||
def __init__(self, timezone_str: str = 'UTC', logger: Optional[logging.Logger] = None):
|
||||
"""
|
||||
Initialize the GameHelper.
|
||||
|
||||
Args:
|
||||
timezone_str: Timezone string for date/time processing
|
||||
logger: Optional logger instance
|
||||
"""
|
||||
self.logger = logger or logging.getLogger(__name__)
|
||||
self.timezone = self._get_timezone(timezone_str)
|
||||
|
||||
def extract_game_details(self, event: Dict[str, Any], sport: str = None) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Extract game details from ESPN event data.
|
||||
|
||||
Args:
|
||||
event: ESPN event data
|
||||
sport: Sport type for sport-specific processing
|
||||
|
||||
Returns:
|
||||
Processed game details or None if extraction fails
|
||||
"""
|
||||
if not event:
|
||||
return None
|
||||
|
||||
try:
|
||||
competition = event.get("competitions", [{}])[0]
|
||||
status = competition.get("status", {})
|
||||
competitors = competition.get("competitors", [])
|
||||
game_date_str = event.get("date", "")
|
||||
|
||||
if not competitors or len(competitors) < 2:
|
||||
self.logger.warning(f"Insufficient competitor data in event: {event.get('id')}")
|
||||
return None
|
||||
|
||||
# Find home and away teams
|
||||
home_team = next((c for c in competitors if c.get("homeAway") == "home"), None)
|
||||
away_team = next((c for c in competitors if c.get("homeAway") == "away"), None)
|
||||
|
||||
if not home_team or not away_team:
|
||||
self.logger.warning(f"Could not find home/away teams in event: {event.get('id')}")
|
||||
return None
|
||||
|
||||
# Extract basic team info
|
||||
home_abbr = self._extract_team_abbreviation(home_team)
|
||||
away_abbr = self._extract_team_abbreviation(away_team)
|
||||
|
||||
# Parse game time
|
||||
start_time_utc = self._parse_game_time(game_date_str)
|
||||
game_time, game_date = self._format_game_time(start_time_utc)
|
||||
|
||||
# Extract records
|
||||
home_record = self._extract_team_record(home_team)
|
||||
away_record = self._extract_team_record(away_team)
|
||||
|
||||
# Determine game state
|
||||
game_state = self._determine_game_state(status)
|
||||
|
||||
# Build game details
|
||||
details = {
|
||||
"id": event.get("id"),
|
||||
"game_time": game_time,
|
||||
"game_date": game_date,
|
||||
"start_time_utc": start_time_utc,
|
||||
"status_text": status.get("type", {}).get("shortDetail", ""),
|
||||
"is_live": game_state["is_live"],
|
||||
"is_final": game_state["is_final"],
|
||||
"is_upcoming": game_state["is_upcoming"],
|
||||
"is_halftime": game_state["is_halftime"],
|
||||
"is_period_break": game_state["is_period_break"],
|
||||
"home_abbr": home_abbr,
|
||||
"home_id": home_team.get("id"),
|
||||
"home_score": str(home_team.get("score", "0")),
|
||||
"home_record": home_record,
|
||||
"away_abbr": away_abbr,
|
||||
"away_id": away_team.get("id"),
|
||||
"away_score": str(away_team.get("score", "0")),
|
||||
"away_record": away_record,
|
||||
"is_within_window": True,
|
||||
}
|
||||
|
||||
# Add sport-specific details
|
||||
if sport:
|
||||
details.update(self._extract_sport_specific_details(event, sport))
|
||||
|
||||
return details
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error extracting game details: {e} from event: {event.get('id')}", exc_info=True)
|
||||
return None
|
||||
|
||||
def filter_live_games(self, games: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Filter games to only include live games.
|
||||
|
||||
Args:
|
||||
games: List of game dictionaries
|
||||
|
||||
Returns:
|
||||
List of live games
|
||||
"""
|
||||
return [game for game in games if game.get('is_live', False)]
|
||||
|
||||
def filter_final_games(self, games: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Filter games to only include final games.
|
||||
|
||||
Args:
|
||||
games: List of game dictionaries
|
||||
|
||||
Returns:
|
||||
List of final games
|
||||
"""
|
||||
return [game for game in games if game.get('is_final', False)]
|
||||
|
||||
def filter_upcoming_games(self, games: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Filter games to only include upcoming games.
|
||||
|
||||
Args:
|
||||
games: List of game dictionaries
|
||||
|
||||
Returns:
|
||||
List of upcoming games
|
||||
"""
|
||||
return [game for game in games if game.get('is_upcoming', False)]
|
||||
|
||||
def filter_favorite_teams(self, games: List[Dict[str, Any]],
|
||||
favorite_teams: List[str]) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Filter games to only include games with favorite teams.
|
||||
|
||||
Args:
|
||||
games: List of game dictionaries
|
||||
favorite_teams: List of favorite team abbreviations
|
||||
|
||||
Returns:
|
||||
List of games involving favorite teams
|
||||
"""
|
||||
if not favorite_teams:
|
||||
return games
|
||||
|
||||
return [game for game in games
|
||||
if game.get('home_abbr') in favorite_teams or
|
||||
game.get('away_abbr') in favorite_teams]
|
||||
|
||||
def filter_recent_games(self, games: List[Dict[str, Any]],
|
||||
days_back: int = 7) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Filter games to only include recent games within specified days.
|
||||
|
||||
Args:
|
||||
games: List of game dictionaries
|
||||
days_back: Number of days to look back
|
||||
|
||||
Returns:
|
||||
List of recent games
|
||||
"""
|
||||
cutoff_date = datetime.now(timezone.utc) - timedelta(days=days_back)
|
||||
|
||||
recent_games = []
|
||||
for game in games:
|
||||
start_time = game.get('start_time_utc')
|
||||
if start_time and start_time >= cutoff_date:
|
||||
recent_games.append(game)
|
||||
|
||||
return recent_games
|
||||
|
||||
def sort_games_by_time(self, games: List[Dict[str, Any]],
|
||||
reverse: bool = False) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Sort games by start time.
|
||||
|
||||
Args:
|
||||
games: List of game dictionaries
|
||||
reverse: If True, sort in descending order (newest first)
|
||||
|
||||
Returns:
|
||||
Sorted list of games
|
||||
"""
|
||||
def get_start_time(game):
|
||||
start_time = game.get('start_time_utc')
|
||||
if start_time:
|
||||
return start_time
|
||||
# Fallback to current time for games without start time
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
return sorted(games, key=get_start_time, reverse=reverse)
|
||||
|
||||
def process_games(self, events: List[Dict[str, Any]], sport: str = None) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Process a list of ESPN events into game details.
|
||||
|
||||
Args:
|
||||
events: List of ESPN event data
|
||||
sport: Sport type for processing
|
||||
|
||||
Returns:
|
||||
List of processed game details
|
||||
"""
|
||||
games = []
|
||||
|
||||
for event in events:
|
||||
game = self.extract_game_details(event, sport)
|
||||
if game:
|
||||
games.append(game)
|
||||
|
||||
return games
|
||||
|
||||
def get_game_summary(self, game: Dict[str, Any]) -> str:
|
||||
"""
|
||||
Get a text summary of a game.
|
||||
|
||||
Args:
|
||||
game: Game dictionary
|
||||
|
||||
Returns:
|
||||
Text summary of the game
|
||||
"""
|
||||
home_abbr = game.get('home_abbr', 'HOME')
|
||||
away_abbr = game.get('away_abbr', 'AWAY')
|
||||
home_score = game.get('home_score', '0')
|
||||
away_score = game.get('away_score', '0')
|
||||
status = game.get('status_text', '')
|
||||
|
||||
if game.get('is_live'):
|
||||
return f"{away_abbr} {away_score} @ {home_abbr} {home_score} ({status})"
|
||||
elif game.get('is_final'):
|
||||
return f"{away_abbr} {away_score} @ {home_abbr} {home_score} (Final)"
|
||||
else:
|
||||
return f"{away_abbr} @ {home_abbr} ({status})"
|
||||
|
||||
def _extract_team_abbreviation(self, team_data: Dict[str, Any]) -> str:
|
||||
"""Extract team abbreviation from team data."""
|
||||
try:
|
||||
return team_data.get("team", {}).get("abbreviation", "")
|
||||
except (KeyError, AttributeError):
|
||||
# Fallback to first 3 characters of team name
|
||||
team_name = team_data.get("team", {}).get("name", "UNK")
|
||||
return team_name[:3].upper()
|
||||
|
||||
def _extract_team_record(self, team_data: Dict[str, Any]) -> str:
|
||||
"""Extract team record from team data."""
|
||||
try:
|
||||
records = team_data.get('records', [])
|
||||
if records and len(records) > 0:
|
||||
record = records[0].get('summary', '')
|
||||
# Don't show "0-0" records
|
||||
if record in {"0-0", "0-0-0"}:
|
||||
return ''
|
||||
return record
|
||||
except (KeyError, AttributeError, IndexError):
|
||||
pass
|
||||
return ''
|
||||
|
||||
def _parse_game_time(self, game_date_str: str) -> Optional[datetime]:
|
||||
"""Parse game time string to UTC datetime."""
|
||||
if not game_date_str:
|
||||
return None
|
||||
|
||||
try:
|
||||
# Handle ISO format with Z suffix
|
||||
if game_date_str.endswith('Z'):
|
||||
game_date_str = game_date_str.replace('Z', '+00:00')
|
||||
|
||||
dt = datetime.fromisoformat(game_date_str)
|
||||
# Ensure the datetime is UTC-aware (fromisoformat may create timezone-aware but not pytz.UTC)
|
||||
if dt.tzinfo is None:
|
||||
# If naive, assume it's UTC
|
||||
return dt.replace(tzinfo=pytz.UTC)
|
||||
else:
|
||||
# Convert to pytz.UTC for consistency
|
||||
return dt.astimezone(pytz.UTC)
|
||||
except ValueError:
|
||||
self.logger.warning(f"Could not parse game date: {game_date_str}")
|
||||
return None
|
||||
|
||||
def _format_game_time(self, start_time_utc: Optional[datetime]) -> Tuple[str, str]:
|
||||
"""Format game time for display."""
|
||||
if not start_time_utc:
|
||||
return "", ""
|
||||
|
||||
try:
|
||||
local_time = start_time_utc.astimezone(self.timezone)
|
||||
game_time = local_time.strftime("%I:%M%p").lstrip('0')
|
||||
game_date = local_time.strftime("%B %d")
|
||||
return game_time, game_date
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error formatting game time: {e}")
|
||||
return "", ""
|
||||
|
||||
def _determine_game_state(self, status: Dict[str, Any]) -> Dict[str, bool]:
|
||||
"""Determine game state from status data."""
|
||||
status_type = status.get("type", {})
|
||||
state = status_type.get("state", "")
|
||||
name = status_type.get("name", "").lower()
|
||||
|
||||
return {
|
||||
"is_live": state == "in",
|
||||
"is_final": state == "post",
|
||||
"is_upcoming": state == "pre" or name in ['scheduled', 'pre-game', 'status_scheduled'],
|
||||
"is_halftime": state == "halftime" or name == "status_halftime",
|
||||
"is_period_break": name == "status_end_period",
|
||||
}
|
||||
|
||||
def _extract_sport_specific_details(self, event: Dict[str, Any], sport: str) -> Dict[str, Any]:
|
||||
"""Extract sport-specific game details."""
|
||||
details = {}
|
||||
|
||||
if sport == "basketball":
|
||||
details.update(self._extract_basketball_details(event))
|
||||
elif sport == "football":
|
||||
details.update(self._extract_football_details(event))
|
||||
elif sport == "hockey":
|
||||
details.update(self._extract_hockey_details(event))
|
||||
elif sport == "baseball":
|
||||
details.update(self._extract_baseball_details(event))
|
||||
|
||||
return details
|
||||
|
||||
def _extract_basketball_details(self, event: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Extract basketball-specific details."""
|
||||
details = {}
|
||||
|
||||
try:
|
||||
competition = event.get("competitions", [{}])[0]
|
||||
status = competition.get("status", {})
|
||||
|
||||
# Period information
|
||||
period = status.get("period", 0)
|
||||
if period > 0:
|
||||
if period <= 4:
|
||||
details["period_text"] = f"Q{period}"
|
||||
else:
|
||||
details["period_text"] = f"OT{period - 4}"
|
||||
else:
|
||||
details["period_text"] = "Start"
|
||||
|
||||
# Clock
|
||||
details["clock"] = status.get("displayClock", "0:00")
|
||||
|
||||
except (KeyError, IndexError):
|
||||
pass
|
||||
|
||||
return details
|
||||
|
||||
def _extract_football_details(self, event: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Extract football-specific details."""
|
||||
details = {}
|
||||
|
||||
try:
|
||||
competition = event.get("competitions", [{}])[0]
|
||||
status = competition.get("status", {})
|
||||
|
||||
# Quarter information
|
||||
period = status.get("period", 0)
|
||||
if period > 0:
|
||||
if period <= 4:
|
||||
details["period_text"] = f"Q{period}"
|
||||
else:
|
||||
details["period_text"] = f"OT{period - 4}"
|
||||
else:
|
||||
details["period_text"] = "Start"
|
||||
|
||||
# Clock
|
||||
details["clock"] = status.get("displayClock", "0:00")
|
||||
|
||||
except (KeyError, IndexError):
|
||||
pass
|
||||
|
||||
return details
|
||||
|
||||
def _extract_hockey_details(self, event: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Extract hockey-specific details."""
|
||||
details = {}
|
||||
|
||||
try:
|
||||
competition = event.get("competitions", [{}])[0]
|
||||
status = competition.get("status", {})
|
||||
|
||||
# Period information
|
||||
period = status.get("period", 0)
|
||||
if period > 0:
|
||||
if period <= 3:
|
||||
details["period_text"] = f"P{period}"
|
||||
else:
|
||||
details["period_text"] = f"OT{period - 3}"
|
||||
else:
|
||||
details["period_text"] = "Start"
|
||||
|
||||
# Clock
|
||||
details["clock"] = status.get("displayClock", "0:00")
|
||||
|
||||
except (KeyError, IndexError):
|
||||
pass
|
||||
|
||||
return details
|
||||
|
||||
def _extract_baseball_details(self, event: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Extract baseball-specific details."""
|
||||
details = {}
|
||||
|
||||
try:
|
||||
competition = event.get("competitions", [{}])[0]
|
||||
status = competition.get("status", {})
|
||||
|
||||
# Inning information
|
||||
period = status.get("period", 0)
|
||||
if period > 0:
|
||||
details["period_text"] = f"INN {period}"
|
||||
else:
|
||||
details["period_text"] = "Start"
|
||||
|
||||
# Clock
|
||||
details["clock"] = status.get("displayClock", "0:00")
|
||||
|
||||
except (KeyError, IndexError):
|
||||
pass
|
||||
|
||||
return details
|
||||
|
||||
def _get_timezone(self, timezone_str: str) -> pytz.BaseTzInfo:
|
||||
"""Get timezone object from string."""
|
||||
try:
|
||||
return pytz.timezone(timezone_str)
|
||||
except pytz.UnknownTimeZoneError:
|
||||
self.logger.warning(f"Unknown timezone: {timezone_str}, using UTC")
|
||||
return pytz.utc
|
||||
@@ -1,331 +0,0 @@
|
||||
"""
|
||||
Utility Functions
|
||||
|
||||
Common utility functions for LED matrix plugins.
|
||||
Extracted from LEDMatrix core to provide reusable functionality for plugins.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from typing import Union
|
||||
import pytz
|
||||
|
||||
|
||||
def normalize_team_abbreviation(team_abbr: str) -> str:
|
||||
"""
|
||||
Normalize team abbreviation for consistent usage.
|
||||
|
||||
Args:
|
||||
team_abbr: Raw team abbreviation
|
||||
|
||||
Returns:
|
||||
Normalized abbreviation
|
||||
"""
|
||||
if not team_abbr:
|
||||
return ""
|
||||
|
||||
# Remove spaces and convert to uppercase
|
||||
normalized = team_abbr.strip().upper()
|
||||
|
||||
# Handle special characters
|
||||
normalized = normalized.replace('&', 'AND')
|
||||
normalized = normalized.replace(' ', '')
|
||||
normalized = normalized.replace('-', '')
|
||||
|
||||
return normalized
|
||||
|
||||
|
||||
def format_time(dt: datetime, timezone_str: str = 'UTC',
|
||||
format_str: str = "%I:%M%p") -> str:
|
||||
"""
|
||||
Format datetime for display.
|
||||
|
||||
Args:
|
||||
dt: Datetime object
|
||||
timezone_str: Target timezone
|
||||
format_str: Time format string
|
||||
|
||||
Returns:
|
||||
Formatted time string
|
||||
"""
|
||||
try:
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
|
||||
target_tz = pytz.timezone(timezone_str)
|
||||
local_time = dt.astimezone(target_tz)
|
||||
|
||||
formatted = local_time.strftime(format_str)
|
||||
# Remove leading zero from hour
|
||||
if formatted.startswith('0'):
|
||||
formatted = formatted[1:]
|
||||
|
||||
return formatted
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def format_date(dt: datetime, timezone_str: str = 'UTC',
|
||||
format_str: str = "%B %d") -> str:
|
||||
"""
|
||||
Format date for display.
|
||||
|
||||
Args:
|
||||
dt: Datetime object
|
||||
timezone_str: Target timezone
|
||||
format_str: Date format string
|
||||
|
||||
Returns:
|
||||
Formatted date string
|
||||
"""
|
||||
try:
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
|
||||
target_tz = pytz.timezone(timezone_str)
|
||||
local_time = dt.astimezone(target_tz)
|
||||
|
||||
return local_time.strftime(format_str)
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def get_timezone(timezone_str: str) -> pytz.BaseTzInfo:
|
||||
"""
|
||||
Get timezone object from string.
|
||||
|
||||
Args:
|
||||
timezone_str: Timezone string
|
||||
|
||||
Returns:
|
||||
Timezone object
|
||||
"""
|
||||
try:
|
||||
return pytz.timezone(timezone_str)
|
||||
except pytz.UnknownTimeZoneError:
|
||||
logging.getLogger(__name__).warning(f"Unknown timezone: {timezone_str}, using UTC")
|
||||
return pytz.utc
|
||||
|
||||
|
||||
def validate_dimensions(width: int, height: int) -> bool:
|
||||
"""
|
||||
Validate display dimensions.
|
||||
|
||||
Args:
|
||||
width: Display width
|
||||
height: Display height
|
||||
|
||||
Returns:
|
||||
True if dimensions are valid
|
||||
"""
|
||||
return (isinstance(width, int) and isinstance(height, int) and
|
||||
width > 0 and height > 0 and width <= 1000 and height <= 1000)
|
||||
|
||||
|
||||
def parse_team_abbreviation(text: str) -> str:
|
||||
"""
|
||||
Parse team abbreviation from various text formats.
|
||||
|
||||
Args:
|
||||
text: Text containing team abbreviation
|
||||
|
||||
Returns:
|
||||
Extracted team abbreviation
|
||||
"""
|
||||
if not text:
|
||||
return ""
|
||||
|
||||
# Remove common prefixes/suffixes
|
||||
text = re.sub(r'^(Team|Club|FC|SC)\s+', '', text, flags=re.IGNORECASE)
|
||||
text = re.sub(r'\s+(Team|Club|FC|SC)$', '', text, flags=re.IGNORECASE)
|
||||
|
||||
# Extract abbreviation (usually 2-4 uppercase letters)
|
||||
match = re.search(r'\b[A-Z]{2,4}\b', text.upper())
|
||||
if match:
|
||||
return match.group()
|
||||
|
||||
# Fallback to first 3 characters
|
||||
return text[:3].upper()
|
||||
|
||||
|
||||
def format_score(home_score: Union[str, int], away_score: Union[str, int]) -> str:
|
||||
"""
|
||||
Format score for display.
|
||||
|
||||
Args:
|
||||
home_score: Home team score
|
||||
away_score: Away team score
|
||||
|
||||
Returns:
|
||||
Formatted score string
|
||||
"""
|
||||
return f"{away_score}-{home_score}"
|
||||
|
||||
|
||||
def format_period(period: int, sport: str = "basketball") -> str:
|
||||
"""
|
||||
Format period/quarter/inning for display.
|
||||
|
||||
Args:
|
||||
period: Period number
|
||||
sport: Sport type
|
||||
|
||||
Returns:
|
||||
Formatted period string
|
||||
"""
|
||||
if sport == "basketball":
|
||||
if period <= 4:
|
||||
return f"Q{period}"
|
||||
else:
|
||||
return f"OT{period - 4}"
|
||||
elif sport == "football":
|
||||
if period <= 4:
|
||||
return f"Q{period}"
|
||||
else:
|
||||
return f"OT{period - 4}"
|
||||
elif sport == "hockey":
|
||||
if period <= 3:
|
||||
return f"P{period}"
|
||||
else:
|
||||
return f"OT{period - 3}"
|
||||
elif sport == "baseball":
|
||||
return f"INN {period}"
|
||||
else:
|
||||
return f"P{period}"
|
||||
|
||||
|
||||
def is_live_game(status: str) -> bool:
|
||||
"""
|
||||
Check if game status indicates live play.
|
||||
|
||||
Args:
|
||||
status: Game status string
|
||||
|
||||
Returns:
|
||||
True if game is live
|
||||
"""
|
||||
live_indicators = ['live', 'in progress', 'halftime', 'overtime', 'ot']
|
||||
return any(indicator in status.lower() for indicator in live_indicators)
|
||||
|
||||
|
||||
def is_final_game(status: str) -> bool:
|
||||
"""
|
||||
Check if game status indicates final.
|
||||
|
||||
Args:
|
||||
status: Game status string
|
||||
|
||||
Returns:
|
||||
True if game is final
|
||||
"""
|
||||
final_indicators = ['final', 'completed', 'finished', 'ended']
|
||||
return any(indicator in status.lower() for indicator in final_indicators)
|
||||
|
||||
|
||||
def is_upcoming_game(status: str) -> bool:
|
||||
"""
|
||||
Check if game status indicates upcoming.
|
||||
|
||||
Args:
|
||||
status: Game status string
|
||||
|
||||
Returns:
|
||||
True if game is upcoming
|
||||
"""
|
||||
upcoming_indicators = ['scheduled', 'upcoming', 'pre-game', 'not started']
|
||||
return any(indicator in status.lower() for indicator in upcoming_indicators)
|
||||
|
||||
|
||||
def sanitize_filename(filename: str) -> str:
|
||||
"""
|
||||
Sanitize filename for safe file operations.
|
||||
|
||||
Args:
|
||||
filename: Original filename
|
||||
|
||||
Returns:
|
||||
Sanitized filename
|
||||
"""
|
||||
# Remove or replace invalid characters
|
||||
filename = re.sub(r'[<>:"/\\|?*]', '_', filename)
|
||||
# Remove multiple underscores
|
||||
filename = re.sub(r'_+', '_', filename)
|
||||
# Remove leading/trailing underscores and dots
|
||||
filename = filename.strip('_.')
|
||||
|
||||
return filename
|
||||
|
||||
|
||||
def truncate_text(text: str, max_length: int, suffix: str = "...") -> str:
|
||||
"""
|
||||
Truncate text to maximum length.
|
||||
|
||||
Args:
|
||||
text: Text to truncate
|
||||
max_length: Maximum length
|
||||
suffix: Suffix to add when truncating
|
||||
|
||||
Returns:
|
||||
Truncated text
|
||||
"""
|
||||
if len(text) <= max_length:
|
||||
return text
|
||||
|
||||
return text[:max_length - len(suffix)] + suffix
|
||||
|
||||
|
||||
def parse_boolean(value: Union[str, bool, int]) -> bool:
|
||||
"""
|
||||
Parse various boolean representations.
|
||||
|
||||
Args:
|
||||
value: Value to parse
|
||||
|
||||
Returns:
|
||||
Boolean value
|
||||
"""
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
|
||||
if isinstance(value, int):
|
||||
return bool(value)
|
||||
|
||||
if isinstance(value, str):
|
||||
return value.lower() in ('true', '1', 'yes', 'on', 'enabled')
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def get_logger(name: str, level: int = logging.INFO) -> logging.Logger:
|
||||
"""
|
||||
Get a logger with consistent configuration.
|
||||
|
||||
Note: This function is deprecated. Use src.logging_config.get_logger() instead.
|
||||
This function is kept for backward compatibility.
|
||||
|
||||
Args:
|
||||
name: Logger name
|
||||
level: Log level
|
||||
|
||||
Returns:
|
||||
Configured logger
|
||||
"""
|
||||
# Use centralized logging configuration
|
||||
try:
|
||||
from src.logging_config import get_logger as get_logger_centralized
|
||||
return get_logger_centralized(name)
|
||||
except ImportError:
|
||||
# Fallback to basic logging if centralized config not available
|
||||
logger = logging.getLogger(name)
|
||||
logger.setLevel(level)
|
||||
|
||||
if not logger.handlers:
|
||||
handler = logging.StreamHandler()
|
||||
formatter = logging.Formatter(
|
||||
'%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
handler.setFormatter(formatter)
|
||||
logger.addHandler(handler)
|
||||
|
||||
return logger
|
||||
Reference in New Issue
Block a user