mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-06-22 12:28:38 +00:00
Plugins (#145)
Chaotic mega-merge into main. THINGS WILL PROBABLY BE BROKEN
* chore: Update soccer-scoreboard submodule to merged commit
- Update submodule reference to include manifest.json v2 registry format
- Version updated to 1.0.1
* refactor: Remove test_mode and logo_dir config reading from base SportsCore
- Remove test_mode initialization and usage
- Remove logo_dir reading from mode_config
- Use LogoDownloader defaults directly for logo directories
* chore: Update plugin submodules after removing global properties
- Update basketball-scoreboard submodule (removed global test_mode, live_priority, dynamic_duration, logo_dir)
- Update soccer-scoreboard submodule (removed global test_mode, live_priority, dynamic_duration, logo_dir)
* feat(calendar): Add credentials.json file upload via web interface
- Add API endpoint /api/v3/plugins/calendar/upload-credentials for file upload
- Validate JSON format and Google OAuth structure
- Save file to plugin directory with secure permissions (0o600)
- Backup existing credentials.json before overwriting
- Add file upload widget support for string fields in config forms
- Add frontend handler handleCredentialsUpload() for single file uploads
- Update .gitignore to allow calendar submodule
- Update calendar submodule reference
* fix(web): Improve spacing for nested configuration sections
- Add dynamic margin based on nesting depth (mb-6 for deeply nested sections)
- Increase padding in nested content areas (py-3 to py-4)
- Add extra spacing after nested sections to prevent overlap
- Enhance CSS spacing for nested sections (1.5rem for nested, 2rem for deeply nested)
- Add padding-bottom to expanded nested content to prevent cutoff
- Fixes issue where game_limits and other nested settings were hidden under next section header
* chore(plugins): Update sports scoreboard plugins with live update interval fix
- Updated hockey-scoreboard, football-scoreboard, basketball-scoreboard, and soccer-scoreboard submodules
- All plugins now fix the interval selection bug that caused live games to update every 5 minutes instead of 30 seconds
- Ensures all live games update at the configured live_update_interval (30s) for timely score updates
* fix: Initialize test_mode in SportsLive and fix config migration
- Add test_mode initialization in SportsLive.__init__() to prevent AttributeError
- Remove invalid new_secrets parameter from save_config_atomic() call in config migration
- Fixes errors: 'NBALiveManager' object has no attribute 'test_mode'
- Fixes errors: ConfigManager.save_config_atomic() got unexpected keyword argument 'new_secrets'
* chore: Update submodules with test_mode initialization fixes
- Update basketball-scoreboard submodule
- Update soccer-scoreboard submodule
* fix(plugins): Auto-stash local changes before plugin updates
- Automatically stash uncommitted changes before git pull during plugin updates
- Prevents update failures when plugins have local modifications
- Improves error messages for git update failures
- Matches behavior of main LEDMatrix update process
* fix(basketball-scoreboard): Update submodule with timeout fix
- Updated basketball-scoreboard plugin to fix update() timeout issue
- Plugin now uses fire-and-forget odds fetching for upcoming games
- Prevents 30-second timeout when processing many upcoming games
Also fixed permission issue on devpi:
- Changed /var/cache/ledmatrix/display_on_demand_state.json permissions
from 600 to 660 to allow web service (devpi user) to read the file
* fix(cache): Ensure cache files use 660 permissions for group access
- Updated setup_cache.sh to set file permissions to 660 (not 775)
- Updated first_time_install.sh to properly set cache file permissions
- Modified DiskCache to set 660 permissions when creating cache files
- Ensures display_on_demand_state.json and other cache files are readable
by web service (devpi user) which is in ledmatrix group
This fixes permission issues where cache files were created with 600
permissions, preventing the web service from reading them. Now files
are created with 660 (rw-rw----) allowing group read access.
* fix(soccer-scoreboard): Update submodule with manifest fix
- Updated soccer-scoreboard plugin submodule
- Added missing entry_point and class_name to manifest.json
- Fixes plugin loading error: 'No class_name in manifest'
Also fixed cache file permissions on devpi server:
- Changed display_on_demand_state.json from 600 to 660 permissions
- Allows web service (devpi user) to read cache files
* fix(display): Remove update_display() calls from clear() to prevent black flash
Previously, display_manager.clear() was calling update_display() twice,
which immediately showed a black screen on the hardware before new
content could be drawn. This caused visible black flashes when switching
between modes, especially when plugins switch from general modes (e.g.,
football_upcoming) to specific sub-modes (e.g., nfl_upcoming).
Now clear() only prepares the buffer without updating the hardware.
Callers can decide when to update the display, allowing smooth transitions
from clear → draw → update_display() without intermediate black flashes.
Places that intentionally show a cleared screen (error cases) already
explicitly call update_display() after clear(), so backward compatibility
is maintained.
* fix(scroll): Prevent wrap-around before cycle completion in dynamic duration
- Check scroll completion BEFORE allowing wrap-around
- Clamp scroll_position when complete to prevent visual loop
- Only wrap-around if cycle is not complete yet
- Fixes issue where stocks plugin showed first stock again at end
- Completion logged only once to avoid spam
- Ensures smooth transition to next mode without visual repeat
* fix(on-demand): Ensure on-demand buttons work and display service runs correctly
- Add early stub functions for on-demand modal to ensure availability when Alpine.js initializes
- Increase on-demand request cache max_age from 5min to 1hr to prevent premature expiration
- Fixes issue where on-demand buttons were not functional due to timing issues
- Ensures display service properly picks up on-demand requests when started
* test: Add comprehensive test coverage (30%+)
- Add 100+ new tests across core components
- Add tests for LayoutManager (27 tests)
- Add tests for PluginLoader (14 tests)
- Add tests for SchemaManager (20 tests)
- Add tests for MemoryCache and DiskCache (24 tests)
- Add tests for TextHelper (9 tests)
- Expand error handling tests (7 new tests)
- Improve coverage from 25.63% to 30.26%
- All 237 tests passing
Test files added:
- test/test_layout_manager.py
- test/test_plugin_loader.py
- test/test_schema_manager.py
- test/test_text_helper.py
- test/test_config_service.py
- test/test_display_controller.py
- test/test_display_manager.py
- test/test_error_handling.py
- test/test_font_manager.py
- test/test_plugin_system.py
Updated:
- pytest.ini: Enable coverage reporting with 30% threshold
- test/conftest.py: Enhanced fixtures for better test isolation
- test/test_cache_manager.py: Expanded cache component tests
- test/test_config_manager.py: Additional config tests
Documentation:
- HOW_TO_RUN_TESTS.md: Guide for running and understanding tests
* test(web): Add comprehensive API endpoint tests
- Add 30 new tests for Flask API endpoints in test/test_web_api.py
- Cover config, system, display, plugins, fonts, and error handling APIs
- Increase test coverage from 30.26% to 30.87%
- All 267 tests passing
Tests cover:
- Config API: GET/POST main config, schedule, secrets
- System API: Status, version, system actions
- Display API: Current display, on-demand start/stop
- Plugins API: Installed plugins, health, config, operations, state
- Fonts API: Catalog, tokens, overrides
- Error handling: Invalid JSON, missing fields, 404s
* test(plugins): Add comprehensive integration tests for all plugins
- Add base test class for plugin integration tests
- Create integration tests for all 6 plugins:
- basketball-scoreboard (11 tests)
- calendar (10 tests)
- clock-simple (11 tests)
- odds-ticker (9 tests)
- soccer-scoreboard (11 tests)
- text-display (12 tests)
- Total: 64 new plugin integration tests
- Increase test coverage from 30.87% to 33.38%
- All 331 tests passing
Tests verify:
- Plugin loading and instantiation
- Required methods (update, display)
- Manifest validation
- Display modes
- Config schema validation
- Graceful handling of missing API credentials
Uses hybrid approach: integration tests in main repo,
plugin-specific unit tests remain in plugin submodules.
* Add mqtt-notifications plugin as submodule
* fix(sports): Respect games_to_show settings for favorite teams
- Fix upcoming games to show N games per team (not just 1)
- Fix recent games to show N games per team (not just 1)
- Add duplicate removal for games involving multiple favorite teams
- Match behavior of basketball-scoreboard plugin
- Affects NFL, NHL, and other sports using base_classes/sports.py
* chore: Remove debug instrumentation logs
- Remove temporary debug logging added during fix verification
- Fix confirmed working by user
* debug: Add instrumentation to debug configuration header visibility issue
* fix: Resolve nested section content sliding under next header
- Remove overflow-hidden from nested-section to allow proper document flow
- Add proper z-index and positioning to prevent overlap
- Add margin-top to nested sections for better spacing
- Remove debug instrumentation that was causing ERR_BLOCKED_BY_CLIENT errors
* fix: Prevent unnecessary plugin tab redraws
- Add check to only update tabs when plugin list actually changes
- Increase debounce timeout to batch rapid changes
- Compare plugin IDs before updating to avoid redundant redraws
- Fix setter to check for actual changes before triggering updates
* fix: Prevent form-groups from sliding out of view when nested sections expand
- Increase margin-bottom on nested-sections for better spacing
- Add clear: both to nested-sections to ensure proper document flow
- Change overflow to visible when expanded to allow natural flow
- Add margin-bottom to expanded content
- Add spacing rules for form-groups that follow nested sections
- Add clear spacer div after nested sections
* fix: Reduce excessive debug logging in generateConfigForm
- Only log once per plugin instead of on every function call
- Prevents log spam when Alpine.js re-renders the form multiple times
- Reduces console noise from 10+ logs per plugin to 1 log per plugin
* fix: Prevent nested section content from sliding out of view when expanded
- Remove overflow-hidden from nested-section in base.html (was causing clipping)
- Add scrollIntoView to scroll expanded sections into view within modal
- Set nested-section overflow to visible to prevent content clipping
- Add min-height to nested-content to ensure proper rendering
- Wait for animation to complete before scrolling into view
* fix: Prevent form-groups from overlapping and appearing outside view
- Change nested-section overflow to hidden by default, visible when expanded
- Add :has() selector to allow overflow when content is expanded
- Ensure form-groups after nested sections have proper spacing and positioning
- Add clear: both and width: 100% to prevent overlap
- Use !important for margin-top to ensure spacing is applied
- Ensure form-groups are in normal document flow with float: none
* fix: Use JavaScript to toggle overflow instead of :has() selector
- :has() selector may not be supported in all browsers
- Use JavaScript to set overflow: visible when expanded, hidden when collapsed
- This ensures better browser compatibility while maintaining functionality
* fix: Make parent sections expand when nested sections expand
- Add updateParentNestedContentHeight() helper to recursively update parent heights
- When a nested section expands, recalculate all parent nested-content max-heights
- Ensures parent sections (like NFL) expand to accommodate expanded child sections
- Updates parent heights both on expand and collapse for proper animation
* refactor: Simplify parent section expansion using CSS max-height: none
- Remove complex recursive parent height update function
- Use CSS max-height: none when expanded to allow natural expansion
- Parent sections automatically expand because nested-content has no height constraint
- Simpler and more maintainable solution
* refactor: Remove complex recursive parent height update function
- CSS max-height: none already handles parent expansion automatically
- No need for JavaScript to manually update parent heights
- Much simpler and cleaner solution
* debug: Add instrumentation to debug auto-collapse issue
- Add logging to track toggle calls and state changes
- Add guard to prevent multiple simultaneous toggles
- Pass event object to prevent bubbling
- Improve state detection logic
- Add return false to onclick handlers
* chore: Remove debug instrumentation from toggleNestedSection
- Remove all debug logging code
- Keep functional fixes: event handling, toggle guard, improved state detection
- Code is now clean and production-ready
* fix(web): Add browser refresh note to plugin fetch errors
* refactor(text-display): Update submodule to use ScrollHelper
* fix(text-display): Fix scrolling display issue - update position in display()
* feat(text-display): Add scroll_loop option and improve scroll speed control
* debug: Add instrumentation to track plugin enabled state changes
Added debug logging to investigate why plugins appear to disable themselves:
- Track enabled state during plugin load (before/after schema merge)
- Track enabled state during plugin reload
- Track enabled state preservation during config save
- Track state reconciliation fixes
- Track enabled state updates in on_config_change
This will help identify which code path is causing plugins to disable.
* debug: Fix debug log path to work on Pi
Changed hardcoded log path to use dynamic project root detection:
- Uses LEDMATRIX_ROOT env var if set
- Falls back to detecting project root by looking for config directory
- Creates .cursor directory if it doesn't exist
- Falls back to /tmp/ledmatrix_debug.log if all else fails
- Added better error handling with logger fallback
* Remove debug instrumentation for plugin enabled state tracking
Removed all debug logging that was added to track plugin enabled state changes.
The instrumentation has been removed as requested.
* Reorganize documentation and cleanup test files
- Move documentation files to docs/ directory
- Remove obsolete test files
- Update .gitignore and README
* feat(text-display): Switch to frame-based scrolling with high FPS support
* fix(text-display): Add backward compatibility for ScrollHelper sub-pixel scrolling
* feat(scroll_helper): Add sub-pixel scrolling support for smooth movement
- Add sub-pixel interpolation using scipy (if available) or numpy fallback
- Add set_sub_pixel_scrolling() method to enable/disable feature
- Implement _get_visible_portion_subpixel() for fractional pixel positioning
- Implement _interpolate_subpixel() for linear interpolation
- Prevents pixel skipping at slow scroll speeds
- Maintains backward compatibility with integer pixel path
* fix(scroll_helper): Reset last_update_time in reset_scroll() to prevent jump-ahead
- Reset last_update_time when resetting scroll position
- Prevents large delta_time on next update after reset
- Fixes issue where scroll would immediately complete again after reset
- Ensures smooth scrolling continuation after loop reset
* fix(scroll_helper): Fix numpy broadcasting error in sub-pixel interpolation
- Add output_width parameter to _interpolate_subpixel() for variable widths
- Fix wrap-around case to use correct widths for interpolation
- Handle edge cases where source array is smaller than expected
- Prevent 'could not broadcast input array' errors in sub-pixel scrolling
- Ensure proper width matching in all interpolation paths
* feat(scroll): Add frame-based scrolling mode for smooth LED matrix movement
- Add frame_based_scrolling flag to ScrollHelper
- When enabled, moves fixed pixels per step, throttled by scroll_delay
- Eliminates time-based jitter by ignoring frame timing variations
- Provides stock-ticker-like smooth, predictable scrolling
- Update text-display plugin to use frame-based mode
This addresses stuttering issues where time-based scrolling caused
visual jitter due to frame timing variations in the main display loop.
* fix(scroll): Fix NumPy broadcasting errors in sub-pixel wrap-around
- Ensure _interpolate_subpixel always returns exactly requested width
- Handle cases where scipy.ndimage.shift produces smaller arrays
- Add padding logic for wrap-around cases when arrays are smaller than expected
- Prevents 'could not broadcast input array' errors during scrolling
* refactor(scroll): Remove sub-pixel interpolation, use high FPS integer scrolling
- Disable sub-pixel scrolling by default in ScrollHelper
- Simplify get_visible_portion to always use integer pixel positioning
- Restore frame-based scrolling logic for smooth high FPS movement
- Use high frame rate (like stock ticker) for smoothness instead of interpolation
- Reduces complexity and eliminates broadcasting errors
* fix(scroll): Prevent large pixel jumps in frame-based scrolling
- Initialize last_step_time properly to prevent huge initial jumps
- Clamp scroll_speed to max 5 pixels/frame in frame-based mode
- Prevents 60-pixel jumps when scroll_speed is misconfigured
- Simplified step calculation to avoid lag catch-up jumps
* fix(text-display): Align config schema and add validation
- Update submodule reference
- Adds warning and logging for scroll_speed config issues
* fix(scroll): Simplify frame-based scrolling to match stock ticker behavior
- Remove throttling logic from frame-based scrolling
- Move pixels every call (DisplayController's loop timing controls rate)
- Add enable_scrolling attribute to text-display plugin for high-FPS treatment
- Matches stock ticker: simple, predictable movement every frame
- Eliminates jitter from timing mismatches between DisplayController and ScrollHelper
* fix(scroll): Restore scroll_delay throttling in frame-based mode
- Restore time-based throttling using scroll_delay
- Move pixels only when scroll_delay has passed
- Handle lag catch-up with reasonable caps to prevent huge jumps
- Preserve fractional timing for smooth operation
- Now scroll_delay actually controls the scroll speed as intended
* feat(text-display): Add FPS counter logging
- Update submodule reference
- Adds FPS tracking and logging every 5 seconds
* fix(text-display): Add display-width buffer so text scrolls completely off
- Update submodule reference
- Adds end buffer to ensure text exits viewport before looping
* fix: Prevent premature game switching in SportsLive
- Set last_game_switch when games load even if current_game already exists
- Set last_game_switch when same games update but it's still 0
- Add guard to prevent switching check when last_game_switch is 0
- Fixes issue where first game shows for only ~2 seconds before switching
- Also fixes random screen flickering when games change prematurely
* feat(plugins): Add branch selection support for plugin installation
- Add optional branch parameter to install_plugin() and install_from_url() in store_manager
- Update API endpoints to accept and pass branch parameter
- Update frontend JavaScript to support branch selection in install calls
- Maintain backward compatibility - branch parameter is optional everywhere
- Falls back to default branch logic if specified branch doesn't exist
* feat(plugins): Add UI for branch selection in plugin installation
- Add branch input field in 'Install Single Plugin' section
- Add global branch input for store installations
- Update JavaScript to read branch from input fields
- Branch input applies to all store installations when specified
* feat(plugins): Change branch selection to be per-plugin instead of global
- Remove global store branch input field
- Add individual branch input field to each plugin card in store
- Add branch input to custom registry plugin cards
- Each plugin can now have its own branch specified independently
* debug: Add logging to _should_exit_dynamic
* feat(display_controller): Add universal get_cycle_duration support for all plugins
UNIVERSAL FEATURE: Any plugin can now implement get_cycle_duration() to dynamically
calculate the total time needed to show all content for a mode.
New method:
- _plugin_cycle_duration(plugin, display_mode): Queries plugin for calculated duration
Integration:
- Display controller calls plugin.get_cycle_duration(display_mode)
- Uses returned duration as target (respecting max cap)
- Falls back to cap if not provided
Benefits:
- Football plugin: Show all games (3 games × 15s = 45s total)
- Basketball plugin: Could implement same logic
- Hockey/Baseball/any sport: Universal support
- Stock ticker: Could calculate based on number of stocks
- Weather: Could calculate based on forecast days
Example plugin implementation:
Result: Plugins control their own display duration based on actual content,
creating a smooth user experience where all content is shown before switching.
* debug: Add logging to cycle duration call
* debug: Change loop exit logs to INFO level
* fix: Change cycle duration logs to INFO level
* fix: Don't exit loop on False for dynamic duration plugins
For plugins with dynamic duration enabled, keep the display loop running
even when display() returns False. This allows games to continue rotating
within the calculated duration.
The loop will only exit when:
- Cycle is complete (plugin reports all content shown)
- Max duration is reached
- Mode is changed externally
* fix(schedule): Improve display scheduling functionality
- Add GET endpoint for schedule configuration retrieval
- Fix mode switching to clean up old config keys (days/start_time/end_time)
- Improve error handling with consistent error_response() usage
- Enhance display controller schedule checking with better edge case handling
- Add validation for time formats and ensure at least one day enabled in per-day mode
- Add debug logging for schedule state changes
Fixes issues where schedule mode switching left stale config causing incorrect behavior.
* fix(install): Add cmake and ninja-build to system dependencies
Resolves h3 package build failure during first-time installation.
The h3 package (dependency of timezonefinder) requires CMake and
Ninja to build from source. Adding these build tools ensures
successful installation of all Python dependencies.
* fix: Pass display_mode in ALL loop calls to maintain sticky manager
CRITICAL FIX: Display controller was only passing display_mode on first call,
causing plugins to fall back to internal mode cycling and bypass sticky
manager logic.
Now consistently passes display_mode=active_mode on every display() call in
both high-FPS and normal loops. This ensures plugins maintain mode context
and sticky manager state throughout the entire display duration.
* feat(install): Add OS check for Raspberry Pi OS Lite (Trixie)
- Verify OS is Raspberry Pi OS (raspbian/debian)
- Require Debian 13 (Trixie) specifically
- Check for Lite version (no desktop environment)
- Exit with clear error message if requirements not met
- Provide instructions for obtaining correct OS version
* fix(web-ui): Add missing notification handlers to quick action buttons
- Added hx-on:htmx:after-request handlers to all quick action buttons in overview.html
- Added hx-ext='json-enc' for proper JSON encoding
- Added missing notification handler for reboot button in index.html
- Users will now see toast notifications when actions complete or fail
* fix(display): Ensure consistent display mode handling in all plugin calls
- Updated display controller to consistently pass display_mode in all plugin display() calls.
- This change maintains the sticky manager state and ensures plugins retain their mode context throughout the display duration.
- Addresses issues with mode cycling and improves overall display reliability.
* fix(display): Enhance display mode persistence across plugin updates
- Updated display controller to ensure display_mode is consistently maintained during plugin updates.
- This change prevents unintended mode resets and improves the reliability of display transitions.
- Addresses issues with mode persistence, ensuring a smoother user experience across all plugins.
* feat: Add Olympics countdown plugin as submodule
- Add olympics-countdown plugin submodule
- Update .gitignore to allow olympics-countdown plugin
- Plugin automatically determines next Olympics and counts down to opening/closing ceremonies
* feat(web-ui): Add checkbox-group widget support for multi-select arrays
- Add checkbox-group widget rendering in plugins_manager.js
- Update form processing to handle checkbox groups with [] naming
- Support for friendly labels via x-options in config schemas
- Update odds-ticker submodule with checkbox-group implementation
* fix(plugins): Preserve enabled state when saving plugin config from main config endpoint
When saving plugin configuration through save_main_config endpoint, the enabled
field was not preserved if missing from the form data. This caused plugins to
be automatically disabled when users saved their configuration from the plugin
manager tab.
This fix adds the same enabled state preservation logic that exists in
save_plugin_config endpoint, ensuring consistent behavior across both endpoints.
The enabled state is preserved from current config, plugin instance, or defaults
to True to prevent unexpected disabling of plugins.
* fix(git): Resolve git status timeout and exclude plugins from base project updates
- Add --untracked-files=no flag to git status for faster execution
- Increase timeout from 5s to 30s for git status operations
- Add timeout exception handling for git status and stash operations
- Filter out plugins directory from git status checks (plugins are separate repos)
- Exclude plugins from stash operations using :!plugins pathspec
- Apply same fixes to plugin store manager update operations
* feat(plugins): Add granular scroll speed control to odds-ticker and leaderboard plugins
- Add display object to both plugins' config schemas with scroll_speed and scroll_delay
- Enable frame-based scrolling mode for precise FPS control (100 FPS for leaderboard)
- Add set_scroll_speed() and set_scroll_delay() methods to both plugins
- Maintain backward compatibility with scroll_pixels_per_second config
- Leaderboard plugin now explicitly sets target_fps to 100 for high-performance scrolling
* fix(scroll): Correct dynamic duration calculation for frame-based scrolling
- Fix calculate_dynamic_duration() to properly handle frame-based scrolling mode
- Convert scroll_speed from pixels/frame to pixels/second when in frame-based mode
- Prevents incorrect duration calculations (e.g., 2609s instead of 52s)
- Affects all plugins using ScrollHelper: odds-ticker, leaderboard, stocks, text-display
- Add debug logging to show scroll mode and effective speed
* Remove version logic from plugin system, use git commits instead
- Remove version parameter from install_plugin() method
- Rename fetch_latest_versions to fetch_commit_info throughout codebase
- Remove version fields from plugins.json registry (versions, latest_version, download_url_template)
- Remove version logging from plugin manager
- Update web UI to use fetch_commit_info parameter
- Update .gitignore to ignore all plugin folders (remove whitelist exceptions)
- Remove plugin directories from git index (plugins now installed via plugin store only)
Plugins now always install latest commit from default branch. Version fields
replaced with git commit SHA and commit dates. System uses git-based approach
for all plugin metadata.
* feat(plugins): Normalize all plugins as git submodules
- Convert all 18 plugins to git submodules for uniform management
- Add submodules for: baseball-scoreboard, christmas-countdown, football-scoreboard, hockey-scoreboard, ledmatrix-flights, ledmatrix-leaderboard, ledmatrix-music, ledmatrix-stocks, ledmatrix-weather, static-image
- Re-initialize mqtt-notifications as proper submodule
- Update .gitignore to allow all plugin submodules
- Add normalize_plugin_submodules.sh script for future plugin management
All plugins with GitHub repositories are now managed as git submodules,
ensuring consistent version control and easier updates.
* refactor(repository): Reorganize scripts and files into organized directory structure
- Move installation scripts to scripts/install/ (except first_time_install.sh)
- Move development scripts to scripts/dev/
- Move utility scripts to scripts/utils/
- Move systemd service files to systemd/
- Keep first_time_install.sh, start_display.sh, stop_display.sh in root
- Update all path references in scripts, documentation, and service files
- Add README.md files to new directories explaining their purpose
- Remove empty tools/ directory (contents moved to scripts/dev/)
- Add .gitkeep to data/ directory
* fix(scripts): Fix PROJECT_DIR path in start_web_conditionally.py after move to scripts/utils/
* fix(scripts): Fix PROJECT_DIR/PROJECT_ROOT path resolution in moved scripts
- Fix wifi_monitor_daemon.py to use project root instead of scripts/utils/
- Fix shell scripts in scripts/ to correctly resolve project root (go up one more level)
- Fix scripts in scripts/fix_perms/ to correctly resolve project root
- Update diagnose_web_interface.sh to reference moved start_web_conditionally.py path
All scripts now correctly determine project root after reorganization.
* fix(install): Update first_time_install.sh to detect and update service files with old paths
- Check for old paths in service files and reinstall if needed
- Always reinstall main service (install_service.sh is idempotent)
- This ensures existing installations get updated paths after reorganization
* fix(install): Update install_service.sh message to indicate it updates existing services
* fix(wifi): Enable WiFi scan to work when AP mode is active
- Temporarily disable AP mode during network scanning
- Automatically re-enable AP mode after scan completes
- Add proper error handling with try/finally to ensure AP mode restoration
- Add user notification when AP mode is temporarily disabled
- Improve error messages for common scanning failures
- Add timing delays for interface mode switching
* fix(wifi): Fix network parsing to handle frequency with 'MHz' suffix
- Strip 'MHz' suffix from frequency field before float conversion
- Add better error logging for parsing failures
- Fixes issue where all networks were silently skipped due to ValueError
* debug(wifi): Add console logging and Alpine.js reactivity fixes for network display
- Add console.log statements to debug network scanning
- Add x-effect to force Alpine.js reactivity updates
- Add unique keys to x-for template
- Add debug display showing network count
- Improve error handling and user feedback
* fix(wifi): Manually update select options instead of using Alpine.js x-for
- Replace Alpine.js x-for template with manual DOM manipulation
- Add updateSelectOptions() method to directly update select dropdown
- This fixes issue where networks weren't appearing in dropdown
- Alpine.js x-for inside select elements can be unreliable
* feat(web-ui): Add patternProperties support for dynamic key-value pairs
- Add UI support for patternProperties objects (custom_feeds, feed_logo_map)
- Implement key-value pair editor with add/remove functionality
- Add JavaScript functions for managing dynamic key-value pairs
- Update form submission to handle patternProperties JSON data
- Enable easy configuration of feed_logo_map in web UI
* chore: Update ledmatrix-news submodule to latest commit
* fix(plugins): Handle arrays of objects in config normalization
Fix configuration validation failure for static-image plugin by adding
recursive normalization support for arrays of objects. The normalize_config_values
function now properly handles arrays containing objects (like image_config.images)
by recursively normalizing each object in the array using the items schema properties.
This resolves the 'configuration validation failed' error when saving static
image plugin configuration with multiple images.
* fix(plugins): Handle union types in config normalization and form generation
Fix configuration validation for fields with union types like ['integer', 'null'].
The normalization function now properly handles:
- Union types in top-level fields (e.g., random_seed: ['integer', 'null'])
- Union types in array items
- Empty string to None conversion for nullable fields
- Form generation and submission for union types
This resolves validation errors when saving plugin configs with nullable
integer/number fields (e.g., rotation_settings.random_seed in static-image plugin).
Also improves UX by:
- Adding placeholder text for nullable fields explaining empty = use default
- Properly handling empty values in form submission for union types
* fix(plugins): Improve union type normalization with better edge case handling
Enhanced normalization for union types like ['integer', 'null']:
- Better handling of whitespace in string values
- More robust empty string to None conversion
- Fallback to None when conversion fails and null is allowed
- Added debug logging for troubleshooting normalization issues
- Improved handling of nested object fields with union types
This should resolve remaining validation errors for nullable integer/number
fields in nested objects (e.g., rotation_settings.random_seed).
* chore: Add ledmatrix-news plugin to .gitignore exceptions
* Fix web interface service script path in install_service.sh
- Updated ExecStart path from start_web_conditionally.py to scripts/utils/start_web_conditionally.py
- Updated diagnose_web_ui.sh to check for correct script path
- Fixes issue where web UI service fails to start due to incorrect script path
* Fix nested configuration section headers not expanding
Fixed toggleNestedSection function to properly calculate scrollHeight when
expanding nested configuration sections. The issue occurred when sections
started with display:none - the scrollHeight was being measured before the
browser had a chance to lay out the element, resulting in a value of 0.
Changes:
- Added setTimeout to delay scrollHeight measurement until after layout
- Added overflow handling during animations to prevent content jumping
- Added fallback for edge cases where scrollHeight might still be 0
- Set maxHeight to 'none' after expansion completes for natural growth
- Updated function in both base.html and plugins_manager.js
This fix applies to all plugins with nested configuration sections, including:
- Hockey/Football/Basketball/Baseball/Soccer scoreboards (customization, global sections)
- All plugins with transition, display, and other nested configuration objects
Fixes configuration header expansion issues across all plugins.
* Fix syntax error in first_time_install.sh step 8.5
Added missing 'fi' statement to close the if block in the WiFi monitor
service installation section. This resolves the 'unexpected end of file'
error that occurred at line 1385 during step 8.5.
* Fix WiFi UI: Display correct SSID and accurate signal strength
- Fix WiFi network selection dropdown not showing available networks
- Replace manual DOM manipulation with Alpine.js x-for directive
- Add fallback watcher to ensure select updates reactively
- Fix WiFi status display showing netplan connection name instead of SSID
- Query actual SSID from device properties (802-11-wireless.ssid)
- Add fallback methods to get SSID from active WiFi connection list
- Improve signal strength accuracy
- Get signal directly from device properties (WIFI.SIGNAL)
- Add multiple fallback methods for robust signal retrieval
- Ensure signal percentage is accurate and up-to-date
* Improve WiFi connection UI and error handling
- Fix connect button disabled condition to check both selectedSSID and manualSSID
- Improve error handling to display actual server error messages from 400 responses
- Add step-by-step labels (Step 1, Step 2, Step 3) to clarify connection workflow
- Add visual feedback showing selected network in blue highlight box
- Improve password field labeling with helpful instructions
- Add auto-clear logic between dropdown and manual SSID entry
- Enhance backend validation with better error messages and logging
- Trim SSID whitespace before processing to prevent validation errors
* Add WiFi disconnect functionality for AP mode testing
- Add disconnect_from_network() method to WiFiManager
- Disconnects from current WiFi network using nmcli
- Automatically triggers AP mode check if auto_enable_ap_mode is enabled
- Returns success/error status with descriptive messages
- Add /api/v3/wifi/disconnect API endpoint
- POST endpoint to disconnect from current WiFi network
- Includes proper error handling and logging
- Add disconnect button to WiFi status section
- Only visible when connected to a network
- Red styling to indicate disconnection action
- Shows 'Disconnecting...' state during operation
- Automatically refreshes status after disconnect
- Integrates with AP mode auto-enable functionality
- When disconnected, automatically enables AP mode if configured
- Perfect for testing captive portal and AP mode features
* Add explicit handling for broken pipe errors during plugin dependency installation
- Catch BrokenPipeError and OSError (errno 32) explicitly in all dependency installation methods
- Add clear error messages explaining network interruption or buffer overflow causes
- Improves error handling in store_manager, plugin_loader, and plugin_manager
- Helps diagnose 'Errno 32 Broken Pipe' errors during pip install operations
* Add WiFi permissions configuration script and integrate into first-time install
- Create configure_wifi_permissions.sh script
- Configures passwordless sudo for nmcli commands
- Configures PolicyKit rules for NetworkManager control
- Fixes 'Not Authorized to control Networking' error
- Allows web interface to connect/disconnect WiFi without password prompts
- Integrate WiFi permissions configuration into first_time_install.sh
- Added as Step 10.1 after passwordless sudo configuration
- Runs automatically during first-time installation
- Ensures WiFi management works out of the box
- Resolves authorization errors when connecting/disconnecting WiFi networks
- NetworkManager requires both sudo and PolicyKit permissions
- Script configures both automatically for seamless WiFi management
* Add WiFi status LED message display integration
- Integrate WiFi status messages from wifi_manager into display_controller
- WiFi status messages interrupt normal rotation (but respect on-demand)
- Priority: on-demand > wifi-status > live-priority > normal rotation
- Safe implementation with comprehensive error handling
- Automatic cleanup of expired/corrupted status files
- Word-wrapping for long messages (max 2 lines)
- Centered text display with small font
- Non-intrusive: all errors are caught and logged, never crash controller
* Fix display loop issues: reduce log spam and handle missing plugins
- Change _should_exit_dynamic logging from INFO to DEBUG to reduce log spam
in tight loops (every 8ms) that was causing high CPU usage
- Fix display loop not running when manager_to_display is None
- Add explicit check to set display_result=False when no plugin manager found
- Fix logic bug where manager_to_display was overwritten after circuit breaker skip
- Ensure proper mode rotation when plugins have no content or aren't found
* Add debug logging to diagnose display loop stuck issue
* Change debug logs to INFO level to diagnose display loop stuck
* Add schedule activation logging and ensure display is blanked when inactive
- Add clear INFO-level log message when schedule makes display inactive
- Track previous display state to detect schedule transitions
- Clear display when schedule makes it inactive to ensure blank screen
(prevents showing initialization screen when schedule kicks in)
- Initialize _was_display_active state tracking in __init__
* Fix indentation errors in schedule state tracking
* Add rotation between hostname and IP address every 10 seconds
- Added _get_local_ip() method to detect device IP address
- Implemented automatic rotation between hostname and IP every 10 seconds
- Enhanced logging to include both hostname and IP in initialization
- Updated get_info() to expose device_ip and current_display_mode
* Add WiFi connection failsafe system
- Save original connection before attempting new connection
- Automatically restore original connection if new connection fails
- Enable AP mode as last resort if restoration fails
- Enhanced connection verification with multiple attempts
- Verify correct SSID (not just 'connected' status)
- Better error handling and exception recovery
- Prevents Pi from becoming unresponsive on connection failure
- Always ensures device remains accessible via original WiFi or AP mode
* feat(web): Improve web UI startup speed and fix cache permissions
- Defer plugin discovery until first API request (removed from startup)
- Add lazy loading to operation queue, state manager, and operation history
- Defer health monitor initialization until first request
- Fix cache directory permission issue:
- Add systemd CacheDirectory feature for automatic cache dir creation
- Add manual cache directory creation in install script as fallback
- Improve cache manager logging (reduce alarming warnings)
- Fix syntax errors in wifi_manager.py (unclosed try blocks)
These changes significantly improve web UI startup time, especially with many
plugins installed, while maintaining full backward compatibility.
* feat(plugins): Improve GitHub token pop-up UX and combine warning/settings
- Fix visibility toggle to handle inline styles properly
- Remove redundant inline styles from HTML elements
- Combine warning banner and settings panel into unified component
- Add loading states to save/load token buttons
- Improve error handling with better user feedback
- Add token format validation (ghp_ or github_pat_ prefix)
- Auto-refresh GitHub auth status after saving token
- Hide warning banner when settings panel opens
- Clear input field after successful save for security
This creates a smoother UX flow where clicking 'Configure Token'
transitions from warning directly to configuration form.
* fix(wifi): Prevent WiFi radio disabling during AP mode disable
- Make NetworkManager restart conditional (only for hostapd mode)
- Add enhanced WiFi radio enable with retry and verification logic
- Add connectivity safety check before NetworkManager restart
- Ensure WiFi radio enabled after all AP mode disable operations
- Fix indentation bug in dnsmasq backup restoration logic
- Add pre-connection WiFi radio check for safety
Fixes issue where WiFi radio was being disabled when disabling AP mode,
especially when connected via Ethernet, making it impossible to enable
WiFi from the web UI.
* fix(plugin-templates): Fix unreachable fallback to expired cache in update() method
The exception handler in update() checked the cached variable, which would
always be None or falsy at that point. If fresh cached data existed, the
method returned early. If cached data was expired, it was filtered out by
max_age constraint. The fix retrieves cached data again in the exception
handler with a very large max_age (1 year) to effectively bypass expiration
check and allow fallback to expired data when fetch fails.
* fix(plugin-templates): Resolve plugin_id mismatch in test template setUp method
* feat(plugins): Standardize manifest version fields schema
- Consolidate version fields to use consistent naming:
- compatible_versions: array of semver ranges (required)
- min_ledmatrix_version: string (optional)
- max_ledmatrix_version: string (optional)
- versions[].ledmatrix_min_version: renamed from ledmatrix_min
- Add manifest schema validation (schema/manifest_schema.json)
- Update store_manager to validate version fields and schema
- Update template and all documentation examples to use standardized fields
- Add deprecation warnings for ledmatrix_version and ledmatrix_min fields
* fix(templates): Update plugin README template script path to correct location
* docs(plugin): Resolve conflicting version management guidance in .cursorrules
* chore(.gitignore): Consolidate plugin exclusion patterns
Remove unnecessary !plugins/*/.git pattern and consolidate duplicate
negations by keeping only trailing-slash directory exclusions.
* docs: Add language specifiers to code blocks in STATIC_IMAGE_MULTI_UPLOAD_PLAN.md
* fix(templates): Remove api_key from config.json example in plugin README template
Remove api_key field from config.json example to prevent credential leakage.
API keys should only be stored in config_secrets.json. Added clarifying note
about proper credential storage.
* docs(README): Add plugin installation and migration information
- Add plugin installation instructions via web interface and GitHub URL
- Add plugin migration guide for users upgrading from old managers
- Improve plugin documentation for new users
* docs(readme): Update donation links and add Discord acknowledgment
* docs: Add comprehensive API references and consolidate documentation
- Add API_REFERENCE.md with complete REST API documentation (50+ endpoints)
- Add PLUGIN_API_REFERENCE.md documenting Display Manager, Cache Manager, and Plugin Manager APIs
- Add ADVANCED_PLUGIN_DEVELOPMENT.md with advanced patterns and examples
- Add DEVELOPER_QUICK_REFERENCE.md for quick developer reference
- Consolidate plugin configuration docs into single PLUGIN_CONFIGURATION_GUIDE.md
- Archive completed implementation summaries to docs/archive/
- Enhance PLUGIN_DEVELOPMENT_GUIDE.md with API links and 3rd party submission guidelines
- Update docs/README.md with new API reference sections
- Update root README.md with documentation links
* fix(install): Fix IP detection and network diagnostics after fresh install
- Fix web-ui-info plugin IP detection to handle no internet, AP mode, and network state changes
- Replace socket-based detection with robust interface scanning using hostname -I and ip addr
- Add AP mode detection returning 192.168.4.1 when AP mode is active
- Add periodic IP refresh every 30 seconds to handle network state changes
- Improve network diagnostics in first_time_install.sh showing actual IPs, WiFi status, and AP mode
- Add WiFi connection check in WiFi monitor installation with warnings
- Enhance web service startup logging to show accessible IP addresses
- Update README with network troubleshooting section and fix port references (5001->5000)
Fixes issue where display showed incorrect IP (127.0.11:5000) and users couldn't access web UI after fresh install.
* chore: Add GitHub sponsor button configuration
* fix(wifi): Fix aggressive AP mode enabling and improve WiFi detection
Critical fixes:
- Change auto_enable_ap_mode default from True to False (manual enable only)
- Fixes issue where Pi would disconnect from network after code updates
- Matches documented behavior (was incorrectly defaulting to True in code)
Improvements:
- Add grace period: require 3 consecutive disconnected checks (90s) before enabling AP mode
- Prevents AP mode from enabling on transient network hiccups
- Improve WiFi status detection with retry logic and better nmcli parsing
- Enhanced logging for debugging WiFi connection issues
- Better handling of WiFi device detection (works with any wlan device)
This prevents the WiFi monitor from aggressively enabling AP mode and
disconnecting the Pi from the network when there are brief network issues
or during system initialization.
* fix(wifi): Revert auto_enable_ap_mode default to True with grace period protection
Change default back to True for auto_enable_ap_mode while keeping the grace
period protection that prevents interrupting valid WiFi connections.
- Default auto_enable_ap_mode back to True (useful for setup scenarios)
- Grace period (3 consecutive checks = 90s) prevents false positives
- Improved WiFi detection with retry logic ensures accurate status
- AP mode will auto-enable when truly disconnected, but won't interrupt
valid connections due to transient detection issues
* fix(news): Update submodule reference for manifest fix
Update ledmatrix-news submodule to include the fixed manifest.json with
required entry_point and class_name fields.
* fix(news): Update submodule reference with validate_config addition
Update ledmatrix-news submodule to include validate_config method for
proper configuration validation.
* feat: Add of-the-day plugin as git submodule
- Add ledmatrix-of-the-day plugin as git submodule
- Rename submodule path from plugins/of-the-day to plugins/ledmatrix-of-the-day to match repository naming convention
- Update .gitignore to allow ledmatrix-of-the-day submodule
- Plugin includes fixes for display rendering and web UI configuration support
* fix(wifi): Make AP mode open network and fix WiFi page loading in AP mode
AP Mode Changes:
- Remove password requirement from AP mode (open network for easier setup)
- Update hostapd config to create open network (no WPA/WPA2)
- Update nmcli hotspot to create open network (no password parameter)
WiFi Page Loading Fixes:
- Download local copies of HTMX and Alpine.js libraries
- Auto-detect AP mode (192.168.4.x) and use local JS files instead of CDN
- Auto-open WiFi tab when accessing via AP mode IP
- Add fallback loading if HTMX fails to load
- Ensures WiFi setup page works in AP mode without internet access
This fixes the issue where the WiFi page wouldn't load on iPhone when
accessing via AP mode (192.168.4.1:5000) because CDN resources couldn't
be fetched without internet connectivity.
* feat(wifi): Add explicit network switching support with clean disconnection
WiFi Manager Improvements:
- Explicitly disconnect from current network before connecting to a new one
- Add skip_ap_check parameter to disconnect_from_network() to prevent AP mode
from activating during network switches
- Check if already connected to target network to avoid unnecessary work
- Improved logging for network switching operations
Web UI Improvements:
- Detect and display network switching status in UI
- Show 'Switching from [old] to [new]...' message when switching networks
- Enhanced status reloading after connection (multiple checks at 2s, 5s, 10s)
- Better user feedback during network transitions
This ensures clean network switching without AP mode interruptions and
provides clear feedback to users when changing WiFi networks.
* fix(web-ui): Add fallback content loading when HTMX fails to load
Problem:
- After recent updates, web UI showed navigation and CPU status but main
content tabs never loaded
- Content tabs depend on HTMX's 'revealed' trigger to load
- If HTMX failed to load or initialize, content would never appear
Solutions:
- Enhanced HTMX loading verification with timeout checks
- Added fallback direct fetch for overview tab if HTMX fails
- Added automatic tab content loading when tabs change
- Added loadTabContent() method to manually trigger content loading
- Added global 'htmx-load-failed' event for error handling
- Automatic retry after 5 seconds if HTMX isn't available
- Better error messages and console logging for debugging
This ensures the web UI loads content even if HTMX has issues,
providing graceful degradation and better user experience.
* feat(web-ui): Add support for plugin custom HTML widgets and static file serving
- Add x-widget: custom-html support in config schema generation
- Add loadCustomHtmlWidget() function to load HTML from plugin directories
- Add /api/v3/plugins/<plugin_id>/static/<file_path> endpoint for serving plugin static files
- Enhance execute_plugin_action() to pass params via stdin as JSON for scripts
- Add JSON output parsing for script action responses
These changes enable plugins to provide custom UI components while keeping
all functionality plugin-scoped. Used by of-the-day plugin for file management.
* fix(web-ui): Resolve Alpine.js initialization errors
- Prevent Alpine.js from auto-initializing before app() function is defined
- Add deferLoadingAlpine to ensure proper initialization order
- Make app() function globally available via window.app
- Fix 'app is not defined' and 'activeTab is not defined' errors
- Remove duplicate Alpine.start() calls that caused double initialization warnings
* fix(web-ui): Fix IndentationError in api_v3.py OAuth flow
- Fix indentation in if action_def.get('oauth_flow') block
- Properly indent try/except block and all nested code
- Resolves IndentationError that prevented web interface from starting
* fix(web-ui): Fix SyntaxError in api_v3.py else block
- Fix indentation of OAuth flow code inside else block
- Properly indent else block for simple script execution
- Resolves SyntaxError at line 3458 that prevented web interface from starting
* fix(web-ui): Restructure OAuth flow check to fix SyntaxError
- Move OAuth flow check before script execution in else block
- Remove unreachable code that was causing syntax error
- OAuth check now happens first, then falls back to script execution
- Resolves SyntaxError at line 3458
* fix(web-ui): Define app() function in head for Alpine.js initialization
- Define minimal app() function in head before Alpine.js loads
- Ensures app() is available when Alpine initializes
- Full implementation in body enhances/replaces the stub
- Fixes 'app is not defined' and 'activeTab is not defined' errors
* fix(web-ui): Ensure plugin tabs load when full app() implementation is available
- Update stub init() to detect and use full implementation when available
- Ensure full implementation properly replaces stub methods
- Call init() after merging to load plugins and set up watchers
- Fixes issue where installed plugins weren't showing in navigation bar
* fix(web-ui): Prevent 'Cannot redefine property' error for installedPlugins
- Check if window.installedPlugins property already exists before defining
- Make property configurable to allow redefinition if needed
- Add _initialized flag to prevent multiple init() calls
- Fixes TypeError when stub tries to enhance with full implementation
* fix(web-ui): Fix variable redeclaration errors in logs tab
- Replace let/const declarations with window properties to avoid redeclaration
- Use window._logsEventSource, window._allLogs, etc. to persist across HTMX reloads
- Clean up existing event source before reinitializing
- Remove and re-add event listeners to prevent duplicates
- Fixes 'Identifier has already been declared' error when accessing logs tab multiple times
* feat(web-ui): Add support for additionalProperties object rendering
- Add handler for objects with additionalProperties containing object schemas
- Render dynamic category controls with enable/disable toggles
- Display category metadata (display name, data file path)
- Used by of-the-day plugin for category management
* fix(wifi): Ensure AP mode hotspot is always open (no password)
Problem:
- LEDMatrix-Setup WiFi AP was still asking for password despite code changes
- Existing hotspot connections with passwords weren't being fully cleaned up
- NetworkManager might reuse old connection profiles with passwords
Solutions:
- More thorough cleanup: Delete all hotspot-related connections, not just known names
- Verification: Check if hotspot has password after creation
- Automatic fix: Remove password and restart connection if security is detected
- Better logging: Log when password is detected and removed
This ensures the AP mode hotspot is always open for easy setup access,
even if there were previously saved connections with passwords.
* fix(wifi): Improve network switching reliability and device state handling
Problem:
- Pi failing to switch WiFi networks via web UI
- Connection attempts happening before device is ready
- Disconnect not fully completing before new connection attempt
- Connection name lookup issues when SSID doesn't match connection name
Solutions:
- Improved disconnect logic: Disconnect specific connection first, then device
- Device state verification: Wait for device to be ready (disconnected/unavailable) before connecting
- Better connection lookup: Search by SSID, not just connection name
- Increased wait times: 2 seconds for disconnect to complete
- State checking before activating existing connections
- Enhanced error handling and logging throughout
This ensures network switching works reliably by properly managing device
state transitions and using correct connection identifiers.
* debug(web-ui): Add debug logging for custom HTML widget loading
- Add console logging to track widget generation
- Improve error messages with missing configuration details
- Help diagnose why file manager widget may not be appearing
* fix(web-ui): Fix [object Object] display in categories field
- Add type checking to ensure category values are strings before rendering
- Safely extract data_file and display_name properties
- Prevent object coercion issues in category display
* perf(web-ui): Optimize plugin loading in navigation bar
- Reduce stub init timeout from 100ms to 10ms for faster enhancement
- Change full implementation merge from 50ms setTimeout to requestAnimationFrame
- Add direct plugin loading in stub while waiting for full implementation
- Skip plugin reload in full implementation if already loaded by stub
- Significantly improves plugin tab loading speed in navigation bar
* feat(web-ui): Adapt file-upload widget for JSON files in of-the-day plugin
- Add specialized JSON upload/delete endpoints for of-the-day plugin
- Modify file-upload widget to support JSON files (file_type: json)
- Render JSON files with file-code icon instead of image preview
- Show entry count for JSON files
- Store files in plugins/ledmatrix-of-the-day/of_the_day/ directory
- Automatically update categories config when files are uploaded/deleted
- Populate uploaded_files array from categories on form load
- Remove custom HTML widget, use standard file-upload widget instead
* fix(web-ui): Add working updatePluginTabs to stub for immediate plugin tab rendering
- Stub's updatePluginTabs was empty, preventing tabs from showing
- Add basic implementation that creates plugin tabs in navigation bar
- Ensures plugin tabs appear immediately when plugins load, even before full implementation merges
- Fixes issue where plugin navigation bar wasn't working
* feat(api): Populate uploaded_files and categories from disk for of-the-day plugin
- Scan of_the_day directory for existing JSON files when loading config
- Populate uploaded_files array from files on disk
- Populate categories from files on disk if not in config
- Categories default to disabled, user can enable them
- Ensures existing JSON files (word_of_the_day.json, slovenian_word_of_the_day.json) appear in UI
* fix(api): Improve category merging logic for of-the-day plugin
- Preserve existing category enabled state when merging with files from disk
- Ensure all JSON files from disk appear in categories section
- Categories from files default to disabled, preserving user choices
- Properly merge existing config with scanned files
* fix(wifi): More aggressive password removal for AP mode hotspot
Problem:
- LEDMatrix-Setup network still asking for password despite previous fixes
- NetworkManager may add default security settings to hotspots
- Existing connections with passwords may not be fully cleaned up
Solutions:
- Always remove ALL security settings after creating hotspot (not just when detected)
- Remove multiple security settings: key-mgmt, psk, wep-key, auth-alg
- Verify security was removed and recreate connection if verification fails
- Improved cleanup: Delete connections by SSID match, not just by name
- Disconnect connections before deleting them
- Always restart connection after removing security to apply changes
- Better logging for debugging
This ensures the AP mode hotspot is always open, even if NetworkManager
tries to add default security settings.
* perf(web): Optimize web interface performance and fix JavaScript errors
- Add resource hints (preconnect, dns-prefetch) for CDN resources to reduce DNS lookup delays
- Fix duplicate response parsing bug in loadPluginConfig that was parsing JSON twice
- Replace direct fetch() calls with PluginAPI.getInstalledPlugins() to leverage caching and throttling
- Fix Alpine.js function availability issues with defensive checks and $nextTick
- Enhance request deduplication with debug logging and statistics
- Add response caching headers for static assets and API responses
- Add performance monitoring utilities with detailed metrics
Fixes console errors for loadPluginConfig and generateConfigForm not being defined.
Reduces duplicate API calls to /api/v3/plugins/installed endpoint.
Improves initial page load time with resource hints and optimized JavaScript loading.
* perf(web-ui): optimize CSS for Raspberry Pi performance
- Remove backdrop-filter blur from modal-backdrop
- Remove box-shadow transitions (use transform/opacity only)
- Remove button ::before pseudo-element animation
- Simplify skeleton loader (gradient to opacity pulse)
- Optimize transition utility (specific properties, not 'all')
- Improve color contrast for WCAG AA compliance
- Add CSS containment to cards, plugin-cards, modals
- Remove unused CSS classes (duration-300, divider, divider-light)
- Remove duplicate spacing utility classes
All animations now GPU-accelerated (transform/opacity only).
Optimized for low-powered Raspberry Pi devices.
* fix(web): Resolve ReferenceError for getInstalledPluginsSafe in v3 stub initialization
Move getInstalledPluginsSafe() function definition before the app() stub code that uses it. The function was previously defined at line 3756 but was being called at line 849 during Alpine.js initialization, causing a ReferenceError when loadInstalledPluginsDirectly() attempted to load plugins before the full implementation was ready.
* fix(web): Resolve TypeError for installedPlugins.map in plugin loading
Fix PluginAPI.getInstalledPlugins() to properly extract plugins array from API response structure. The API returns {status: 'success', data: {plugins: [...]}}, but the method was returning response.data (the object) instead of response.data.plugins (the array).
Changes:
- api_client.js: Extract plugins array from response.data.plugins
- plugins_manager.js: Add defensive array checks and handle array return value correctly
- base.html: Add defensive check in getInstalledPluginsSafe() to ensure plugins is always an array
This prevents 'installedPlugins.map is not a function' errors when loading plugins.
* style(web-ui): Enhance navigation bar styling for better readability
- Improve contrast: Change inactive tab text from gray-500 to gray-700
- Add gradient background and thicker border for active tabs
- Enhance hover states with background highlights
- Add smooth transitions using GPU-accelerated properties
- Update all navigation buttons (system tabs and plugin tabs)
- Add updatePluginTabStates() method for dynamic tab state management
All changes are CSS-only with zero performance overhead.
* fix(web-ui): Optimize plugin loading and reduce initialization errors
- Make generateConfigForm accessible to inline Alpine components via parent scope
- Consolidate plugin initialization to prevent duplicate API calls
- Fix script execution from HTMX-loaded content by extracting scripts before DOM insertion
- Add request deduplication to loadInstalledPlugins() to prevent concurrent requests
- Improve Alpine component initialization with proper guards and fallbacks
This eliminates 'generateConfigForm is not defined' errors and reduces plugin
API calls from 3-4 duplicate calls to 1 per page load, significantly improving
page load performance.
* fix(web-ui): Add guard check for generateConfigForm to prevent Alpine errors
Add typeof check in x-show to prevent Alpine from evaluating generateConfigForm
before the component methods are fully initialized. This eliminates the
'generateConfigForm is not defined' error that was occurring during component
initialization.
* fix(web-ui): Fix try-catch block structure in script execution code
Correct the nesting of try-catch block inside the if statement for script execution.
The catch block was incorrectly placed after the else clause, causing a syntax error.
* fix(web-ui): Escape quotes in querySelector to avoid HTML attribute conflicts
Change double quotes to single quotes in the CSS selector to prevent conflicts
with HTML attribute parsing when the x-data expression is embedded.
* style(web): Improve button text readability in Quick Actions section
* fix(web): Resolve Alpine.js expression errors in plugin configuration component
- Capture plugin from parent scope into component data to fix parsing errors
- Update all plugin references to use this.plugin in component methods
- Fix x-init to properly call loadPluginConfig method
- Resolves 'Uncaught ReferenceError' for isOnDemandLoading, onDemandLastUpdated, and other component properties
* fix(web): Fix remaining Alpine.js scope issues in plugin configuration
- Use this.generateConfigForm in typeof checks and method calls
- Fix form submission to use this.plugin.id
- Use $root. prefix for parent scope function calls (refreshPlugin, updatePlugin, etc.)
- Fix confirm dialog string interpolation
- Ensures all component methods and properties are properly scoped
* fix(web): Add this. prefix to all Alpine.js component property references
- Fix all template expressions to use this. prefix for component properties
- Update isOnDemandLoading, onDemandLastUpdated, onDemandRefreshing references
- Update onDemandStatusClass, onDemandStatusText, onDemandServiceClass, onDemandServiceText
- Update disableRunButton, canStopOnDemand, showEnableHint, loading references
- Ensures Alpine.js can properly resolve all component getters and properties
* fix(web): Resolve Alpine.js expression errors in plugin configuration
- Move complex x-data object to pluginConfigData() function for better parsing
- Fix all template expressions to use this.plugin instead of plugin
- Add this. prefix to all method calls in event handlers
- Fix duplicate x-on:click attribute on uninstall button
- Add proper loading state management in loadPluginConfig method
This resolves the 'Invalid or unexpected token' and 'Uncaught ReferenceError'
errors in the browser console.
* fix(web): Fix plugin undefined errors in Alpine.js plugin configuration
- Change x-data initialization to capture plugin from loop scope first
- Use Object.assign in x-init to merge pluginConfigData properties
- Add safety check in pluginConfigData function for undefined plugins
- Ensure plugin is available before accessing properties in expressions
This resolves the 'Cannot read properties of undefined' errors by ensuring
the plugin object is properly captured from the x-for loop scope before
any template expressions try to access it.
* style(web): Make Quick Actions button text styling consistent
- Update Start Display, Stop Display, and Reboot System buttons
- Change from text-sm font-medium to text-base font-semibold
- All Quick Actions buttons now have consistent bold, larger text
- Matches the styling of Update Code, Restart Display Service, and Restart Web Service buttons
* fix(wifi): Properly handle AP mode disable during WiFi connection
- Check return value of disable_ap_mode() before proceeding with connection
- Add verification loop to ensure AP mode is actually disabled
- Increase wait time to 5 seconds for NetworkManager restart stabilization
- Return clear error messages if AP mode cannot be disabled
- Prevents connection failures when switching networks from web UI or AP mode
This fixes the issue where WiFi network switching would fail silently when
AP mode disable failed, leaving the system in an inconsistent state.
* fix(web): Handle API response errors in plugin configuration loading
- Add null/undefined checks before accessing API response status
- Set fallback defaults when API responses don't have status 'success'
- Add error handling for batch API requests with fallback to individual requests
- Add .catch() handlers to individual fetch calls to prevent unhandled rejections
- Add console warnings to help debug API response failures
- Fix applies to both main loadPluginConfig and PluginConfigHelpers.loadPluginConfig
This fixes the issue where plugin configuration sections would get stuck
showing the loading animation when API responses failed or returned error status.
* fix(web): Fix Alpine.js reactivity for plugin config by using direct x-data
Changed from Object.assign pattern to direct x-data assignment to ensure
Alpine.js properly tracks reactive properties. The previous approach used
Object.assign to merge properties into the component after initialization,
which caused Alpine to not detect changes to config/schema properties.
The fix uses pluginConfigData(plugin) directly as x-data, ensuring all
properties including config, schema, loading, etc. are reactive from
component initialization.
* fix(web): Ensure plugin variable is captured in x-data scope
Use spread operator to merge pluginConfigData properties while explicitly
capturing the plugin variable from outer x-for scope. This fixes undefined
plugin errors when Alpine evaluates the component data.
* fix(web): Use $data for Alpine.js reactivity when merging plugin config
Use Object.assign with Alpine's $data reactive proxy instead of this to
ensure added properties are properly reactive. This fixes the issue where
plugin variable scoping from x-for wasn't accessible in x-data expressions.
* fix(web): Remove incorrect 'this.' prefix in Alpine.js template expressions
Alpine.js template expressions (x-show, x-html, x-text, x-on) use the
component data as the implicit context, so 'this.' prefix is incorrect.
In template expressions, 'this' refers to the DOM element, not the
component data.
Changes:
- Replace 'this.plugin.' with 'plugin.' in all template expressions (19 instances)
- Replace 'this.loading' with 'loading' in x-show directives
- Replace 'this.generateConfigForm' with 'generateConfigForm' in x-show/x-html
- Replace 'this.savePluginConfig' with 'savePluginConfig' in x-on:submit
- Replace 'this.config/schema/webUiActions' with direct property access
- Use '$data.loadPluginConfig' in x-init for explicit method call
Note: 'this.' is still correct inside JavaScript method definitions within
pluginConfigData() function since those run with proper object context.
* fix(web): Prevent infinite recursion in plugin config methods
Add 'parent !== this' check to loadPluginConfig, generateConfigForm, and
savePluginConfig methods in pluginConfigData to prevent infinite recursion
when the component tries to delegate to a parent that resolves to itself.
This fixes the 'Maximum call stack size exceeded' error that occurred when
the nested Alpine component's $root reference resolved to a component that
had the same delegating methods via Object.assign.
* fix(web): Resolve infinite recursion in plugin config by calling $root directly
The previous implementation had delegating methods (generateConfigForm,
savePluginConfig) in pluginConfigData that tried to call parent.method(),
but the parent detection via getParentApp() was causing circular calls
because multiple components had the same methods.
Changes:
- Template now calls $root.generateConfigForm() and $root.savePluginConfig()
directly instead of going through nested component delegation
- Removed delegating generateConfigForm and savePluginConfig from pluginConfigData
- Removed getParentApp() helper that was enabling the circular calls
- Simplified loadPluginConfig to use PluginConfigHelpers directly
This fixes the 'Maximum call stack size exceeded' error when rendering
plugin configuration forms.
* fix(web): Use window.PluginConfigHelpers instead of $root for plugin config
The $root magic variable in Alpine.js doesn't correctly reference the
app() component's data scope from nested x-data contexts. This causes
generateConfigForm and savePluginConfig to be undefined.
Changed to use window.PluginConfigHelpers which has explicit logic to
find and use the app component's methods.
* fix(web): Use direct x-data initialization for plugin config reactivity
Changed from Object.assign($data, pluginConfigData(plugin)) to
x-data="pluginConfigData(plugin)" to ensure Alpine.js properly
tracks reactivity for all plugin config properties. This fixes
the issue where all plugin tabs were showing the same config.
* refactor(web): Implement server-side plugin config rendering with HTMX
Major architectural improvement to plugin configuration management:
- Add server-side Jinja2 template for plugin config forms
(web_interface/templates/v3/partials/plugin_config.html)
- Add Flask route to serve plugin config partials on-demand
- Replace complex client-side form generation with HTMX lazy loading
- Add Alpine.js store for centralized plugin state management
- Mark old pluginConfigData and PluginConfigHelpers as deprecated
Benefits:
- Lazy loading: configs only load when tab is accessed
- Server-side rendering: reduces client-side complexity
- Better performance: especially on Raspberry Pi
- Cleaner code: Jinja2 macros replace JS string templates
- More maintainable: form logic in one place (server)
The old client-side code is preserved for backwards compatibility
but is no longer used by the main plugin configuration UI.
* fix(web): Trigger HTMX manually after Alpine renders plugin tabs
HTMX processes attributes at page load time, before Alpine.js
renders dynamic content. Changed from :hx-get attribute to
x-init with htmx.ajax() to properly trigger the request after
the element is rendered.
* fix(web): Remove duplicate 'enabled' toggle from plugin config form
The 'enabled' field was appearing twice in plugin configuration:
1. Header toggle (quick action, uses HTMX)
2. Configuration form (from schema, requires save)
Now only the header toggle is shown, avoiding user confusion.
The 'enabled' key is explicitly skipped when rendering schema properties.
* perf(web): Optimize plugin manager with request caching and init guards
Major performance improvements to plugins_manager.js:
1. Request Deduplication & Caching
- Added pluginLoadCache with 3-second TTL
- Subsequent calls return cached data instead of making API requests
- In-flight request deduplication prevents parallel duplicate fetches
- Added refreshInstalledPlugins() for explicit force-refresh
2. Initialization Guards
- Added pluginsInitialized flag to prevent multiple initializePlugins() calls
- Added _eventDelegationSetup guard on container to prevent duplicate listeners
- Added _listenerSetup guards on search/category inputs
3. Debug Logging Control
- Added PLUGIN_DEBUG flag (localStorage.setItem('pluginDebug', 'true'))
- Most console.log calls now use pluginLog() which only logs when debug enabled
- Reduces console noise from ~150 logs to ~10 in production
Expected improvements:
- API calls reduced from 6+ to 2 on page load
- Event listeners no longer duplicated
- Cleaner console output
- Faster perceived performance
* fix(web): Handle missing search elements in searchPluginStore
The searchPluginStore function was failing silently when called before
the plugin-search and plugin-category elements existed in the DOM.
This caused the plugin store to never load.
Now safely checks if elements exist before accessing their values.
* fix(web): Ensure plugin store loads via pluginManager.searchPluginStore
- Exposed searchPluginStore on window.pluginManager for easier access
- Updated base.html to fallback to pluginManager.searchPluginStore
- Added logging when loading plugin store
* fix(web): Expose searchPluginStore from inside the IIFE
The function was defined inside the IIFE but only exposed after the IIFE
ended, where the function was out of scope. Now exposed immediately after
definition inside the IIFE.
* fix(web): Add cache-busting version to plugins_manager.js URL
Static JS files were being aggressively cached, preventing updates
from being loaded by browsers.
* fix(web): Fix pluginLog reference error outside IIFE
pluginLog is defined inside the IIFE, so use _PLUGIN_DEBUG_EARLY and
console.log directly for code outside the IIFE.
* chore(web): Update plugins_manager.js cache version
* fix(web): Defer plugin store render when grid not ready
Instead of showing an error when plugin-store-grid doesn't exist,
store plugins in window.__pendingStorePlugins for later rendering
when the tab loads (consistent with how installed plugins work).
* chore: Bump JS cache version
* fix(web): Restore enabledBool variable in plugin render
Variable was removed during debug logging optimization but was still
being used in the template string for toggle switch rendering.
* fix(ui): Add header and improve categories section rendering
- Add proper header (h4) to categories section with label
- Add debug logging to diagnose categories field rendering
- Improve additionalProperties condition check readability
* fix(ui): Improve additionalProperties condition check
- Explicitly exclude objects with properties to avoid conflicts
- Ensure categories section is properly detected and rendered
- Categories should show as header with toggles, not text box
* fix(web-ui): Fix JSON parsing errors and default value loading for plugin configs
- Fix JSON parsing errors when saving file upload fields by properly unescaping HTML entities
- Merge config with schema defaults when loading plugin config so form shows default values
- Improve default value handling in form generation for nested objects and arrays
- Add better error handling for malformed JSON in file upload fields
* fix(plugins): Return plugins array from getInstalledPlugins() instead of data object
Fixed PluginAPI.getInstalledPlugins() to return response.data.plugins (array)
instead of response.data (object). This was preventing window.installedPlugins
from being set correctly, which caused plugin configuration tabs to not appear
and prevented users from saving plugin configurations via the web UI.
The fix ensures that:
- window.installedPlugins is properly populated with plugin array
- Plugin tabs are created automatically on page load
- Configuration forms and save buttons are rendered correctly
- Save functionality works as expected
* fix(api): Support form data submission for plugin config saves
The HTMX form submissions use application/x-www-form-urlencoded format
instead of JSON. This update allows the /api/v3/plugins/config POST
endpoint to accept both formats:
- JSON: plugin_id and config in request body (existing behavior)
- Form data: plugin_id from query string, config fields from form
Added _parse_form_value helper to properly convert form strings to
appropriate Python types (bool, int, float, JSON arrays/objects).
* debug: Add form data logging to diagnose config save issue
* fix(web): Re-discover plugins before loading config partial
The plugin config partial was returning 'not found' for plugins
because the plugin manifests weren't loaded. The installed plugins
API was working because it calls discover_plugins() first.
Changes:
- Add discover_plugins() call in _load_plugin_config_partial when
plugin info is not found on first try
- Remove debug logging from form data handling
* fix(web): Comprehensive plugin config save improvements
SWEEPING FIX for plugin configuration saving issues:
1. Form data now MERGES with existing config instead of replacing
- Partial form submissions (missing fields) no longer wipe out
existing config values
- Fixes plugins with complex schemas (football, clock, etc.)
2. Improved nested value handling with _set_nested_value helper
- Correctly handles deeply nested structures like customization
- Properly merges when intermediate objects already exist
3. Better JSON parsing for arrays
- RGB color arrays like [255, 0, 0] now parse correctly
- Parse JSON before trying number conversion
4. Bump cache version to force JS reload
* fix(web): Add early stubs for updatePlugin and uninstallPlugin
Ensures these functions are available immediately when the page loads,
even before the full IIFE executes. Provides immediate user feedback
and makes API calls directly.
This fixes the 'Update button does not work' issue by ensuring the
function is always defined and callable.
* fix(web): Support form data in toggle endpoint
The toggle endpoint now accepts both JSON and HTMX form submissions.
Also updated the plugin config template to send the enabled state
via hx-vals when the checkbox changes.
Fixes: 415 Unsupported Media Type error when toggling plugins
* fix(web): Prevent config duplication when toggling plugins
Changed handleToggleResponse to update UI in place instead of
refreshing the entire config partial, which was causing duplication.
Also improved refreshPluginConfig with proper container targeting
and concurrent refresh prevention (though it's no longer needed
for toggles since we update in place).
* fix(api): Schema-aware form value parsing for plugin configs
Major fix for plugin config saving issues:
1. Load schema BEFORE processing form data to enable type-aware parsing
2. New _parse_form_value_with_schema() function that:
- Converts comma-separated strings to arrays when schema says 'array'
- Parses JSON strings for arrays/objects
- Handles empty strings for arrays (returns [] instead of None)
- Uses schema to determine correct number types
3. Post-processing to ensure None arrays get converted to empty arrays
4. Proper handling of nested object fields
Fixes validation errors:
- 'category_order': Expected type array, got str
- 'categories': Expected type object, got str
- 'uploaded_files': Expected type array, got NoneType
- RGB color arrays: Expected type array, got str
* fix(web): Make plugin config handlers idempotent and remove scripts from HTMX partials
CRITICAL FIX for script redeclaration errors:
1. Removed all <script> tags from plugin_config.html partial
- Scripts were being re-executed on every HTMX swap
- Caused 'Identifier already declared' errors
2. Moved all handler functions to base.html with idempotent initialization
- Added window.__pluginConfigHandlersInitialized guard
- Functions only initialized once, even if script runs multiple times
- All state stored on window object (e.g., window.pluginConfigRefreshInProgress)
3. Enhanced error logging:
- Client-side: Logs form payload, response status, and parsed error details
- Server-side: Logs raw form data and parsed config on validation failures
4. Functions moved to window scope:
- toggleSection
- handleConfigSave (with detailed error logging)
- handleToggleResponse (updates UI in place, no refresh)
- handlePluginUpdate
- refreshPluginConfig (with duplicate prevention)
- runPluginOnDemand
- stopOnDemand
- executePluginAction
This ensures HTMX-swapped fragments only contain HTML, and all
scripts run once in the base layout.
* fix(api): Filter config to only schema-defined fields before validation
When merging with existing_config, fields not in the plugin's schema
(like high_performance_transitions, transition, dynamic_duration)
were being preserved, causing validation failures when
additionalProperties is false.
Add _filter_config_by_schema() function to recursively filter config
to only include fields defined in the schema before validation.
This fixes validation errors like:
- 'Additional properties are not allowed (high_performance_transitions, transition were unexpected)'
* fix(web): Improve update plugin error handling and support form data
1. Enhanced updatePlugin JavaScript function:
- Validates pluginId before sending request
- Checks response.ok before parsing JSON
- Better error logging with request/response details
- Handles both successful and error responses properly
2. Update endpoint now supports both JSON and form data:
- Similar to config endpoint, accepts plugin_id from query string or form
- Better error messages and debug logging
3. Prevent duplicate function definitions:
- Second updatePlugin definition checks if improved version exists
- Both definitions now have consistent error handling
Fixes: 400 BAD REQUEST 'Request body must be valid JSON' error
* fix(web): Show correct 'update' message instead of 'save' for plugin updates
The handlePluginUpdate function now:
1. Checks actual HTTP status code (not just event.detail.successful)
2. Parses JSON response to get server's actual message
3. Replaces 'save' with 'update' if message incorrectly says 'save'
Fixes: Update button showing 'saved successfully' instead of
'updated successfully'
* fix(web): Execute plugin updates immediately instead of queuing
Plugin updates are now executed directly (synchronously) instead of
being queued for async processing. This provides immediate feedback
to users about whether the update succeeded or failed.
Updates are fast git pull operations, so they don't need async
processing. The operation queue is reserved for longer operations
like install/uninstall.
Fixes: Update button not actually updating plugins (operations were
queued but users didn't see results)
* fix(web): Ensure toggleSection function is always available for collapsible headers
Moved toggleSection outside the initialization guard block so it's
always defined, even if the plugin config handlers have already been
initialized. This ensures collapsible sections in plugin config forms
work correctly.
Added debug logging to help diagnose if sections/icons aren't found.
Fixes: Collapsible headers in plugin config schema not collapsing
* fix(web): Improve toggleSection to explicitly show/hide collapsible content
Changed from classList.toggle() to explicit add/remove of 'hidden' class
based on current state. This ensures the content visibility is properly
controlled when collapsing/expanding sections.
Added better error checking and state detection for more reliable
collapsible section behavior.
* fix(web): Load plugin tabs on page load instead of waiting for plugin manager tab click
The stub's loadInstalledPlugins was an empty function, so plugin tabs
weren't loading until the plugin manager tab was clicked. Now the stub
implementation:
1. Tries to use global window.loadInstalledPlugins if available
2. Falls back to window.pluginManager.loadInstalledPlugins
3. Finally falls back to direct loading via loadInstalledPluginsDirectly
4. Always updates tabs after loading plugins
This ensures plugin navigation tabs are available immediately on page load.
Fixes: Plugin tabs only loading after clicking plugin manager tab
* fix(web): Ensure plugin navigation tabs load on any page regardless of active tab
Multiple improvements to ensure plugin tabs are always visible:
1. Stub's loadInstalledPluginsDirectly now waits for DOM to be ready
before updating tabs, using requestAnimationFrame for proper timing
2. Stub's init() now has a retry mechanism that periodically checks
if plugins have been loaded by plugins_manager.js and updates tabs
accordingly (checks for 2 seconds)
3. Full implementation's init() now properly handles async plugin loading
and ensures tabs are updated after loading completes, checking
window.installedPlugins first before attempting to load
4. Both stub and full implementation ensure tabs update using $nextTick
to wait for Alpine.js rendering cycle
This ensures plugin navigation tabs are visible immediately when the
page loads, regardless of whether the user is on overview, plugin manager,
or any other tab.
Fixes: Plugin tabs only appearing after clicking plugin manager tab
* fix(web): Fix restart display button not working
The initPluginsPage function was returning early before event listeners
were set up, making all the event listener code unreachable. Moved the
return statement to after all event listeners are attached.
This fixes the restart display button and all other buttons in the
plugin manager (refresh plugins, update all, search, etc.) that depend
on event listeners being set up.
Fixes: Restart Display button not working in plugin manager
* fix(web-ui): Improve categories field rendering for of-the-day plugin
- Add more explicit condition checking for additionalProperties objects
- Add debug logging specifically for categories field
- Add fallback handler for objects that don't match special cases (render as JSON textarea)
- Ensure categories section displays correctly with toggle cards instead of plain text
* fix(install): Prevent following broken symlinks during file ownership setup
- Add -P flag to find commands to prevent following symlinks when traversing
- Add -h flag to chown to operate on symlinks themselves rather than targets
- Exclude scripts/dev/plugins directory which contains development symlinks
- Fixes error when chown tries to dereference broken symlinks with extra LEDMatrix in path
* fix(scroll): Ensure scroll completes fully before switching displays
- Add display_width to total scroll distance calculation
- Scroll now continues until content is completely off screen
- Update scroll completion check to use total_scroll_width + display_width
- Prevents scroll from being cut off mid-way when switching to next display
* fix(install): Remove unsupported -P flag from find commands
- Remove -P flag which is not supported on all find versions
- Keep -h flag on chown to operate on symlinks themselves
- Change to {} \; syntax for better error handling
- Add error suppression to continue on broken symlinks
- Exclude scripts/dev/plugins directory to prevent traversal into broken symlinks
* docs(wifi): Add trailing newline to WiFi AP failover setup guide
* fix(web): Suppress non-critical socket errors and fix WiFi permissions script
- Add error filtering in web interface to suppress harmless client disconnection errors
- Downgrade 'No route to host' and broken pipe errors from ERROR to DEBUG level
- Fix WiFi permissions script to use mktemp instead of manual temp file creation
- Add cleanup trap to ensure temp files are removed on script exit
- Resolves permission denied errors when creating temp files during installation
* fix(web): Ensure plugin navigation tabs load on any page by dispatching events
The issue was that when plugins_manager.js loaded and called
loadInstalledPlugins(), it would set window.installedPlugins but the
Alpine.js component wouldn't know to update its tabs unless the plugin
manager tab was clicked.
Changes:
1. loadInstalledPlugins() now always dispatches a 'pluginsUpdated' event
when it sets window.installedPlugins, not just when plugin IDs change
2. renderInstalledPlugins() also dispatches the event and always updates
window.installedPlugins for reactivity
3. Cached plugin data also dispatches the event when returned
The Alpine component already listens for the 'pluginsUpdated' event in
its init() method, so tabs will now update immediately when plugins are
loaded, regardless of which tab is active.
Fixes: Plugin navigation tabs only loading after clicking plugin manager tab
* fix(web): Improve input field contrast in plugin configuration forms
Changed input backgrounds from bg-gray-800 to bg-gray-900 (darker) to
ensure high contrast with white text. Added placeholder:text-gray-400
for better placeholder text visibility.
Updated in both server-side template (plugin_config.html) and client-side
form generation (plugins_manager.js):
- Number inputs
- Text inputs
- Array inputs (comma-separated)
- Select dropdowns
- Textareas (JSON objects)
- Fallback inputs without schema
This ensures all form inputs have high contrast white text on dark
background, making them clearly visible and readable.
Fixes: White text on white background in plugin config inputs
* fix(web): Change plugin config input text from white to black
Changed all input fields in plugin configuration forms to use black text
on white background instead of white text on dark background for better
readability and standard form appearance.
Updated:
- Input backgrounds: bg-gray-900 -> bg-white
- Text color: text-white -> text-black
- Placeholder color: text-gray-400 -> text-gray-500
Applied to both server-side template and client-side form generation
for all input types (number, text, select, textarea).
* fix(web): Ensure toggleSection function is available for plugin config collapsible sections
Moved toggleSection function definition to an early script block so it's
available immediately when HTMX loads plugin configuration content. The
function was previously defined later in the page which could cause it
to not be accessible when inline onclick handlers try to call it.
The function toggles the 'hidden' class on collapsible section content
divs and rotates the chevron icon between right (collapsed) and down
(expanded) states.
Fixes: Plugin configuration section headers not collapsing/expanding
* fix(web): Fix collapsible section toggle to properly hide/show content
Updated toggleSection function to explicitly set display style in addition
to toggling the hidden class. This ensures the content is properly hidden
even if CSS specificity or other styles might interfere with just the
hidden class.
The function now:
- Checks both the hidden class and computed display style
- Explicitly sets display: '' when showing and display: 'none' when hiding
- Rotates chevron icon between right (collapsed) and down (expanded)
This ensures collapsible sections in plugin configuration forms properly
hide and show their content when the header is clicked.
Fixes: Collapsible section headers rotate chevron but don't hide content
* fix(web): Fix collapsible section toggle to work on first click
Simplified the toggle logic to rely primarily on the 'hidden' class check
rather than mixing it with computed display styles. When hiding, we now
remove any inline display style to let Tailwind's 'hidden' class properly
control the display property.
This ensures sections respond correctly on the first click, whether they're
starting in a collapsed or expanded state.
Fixes: Sections requiring 2 clicks to collapse
* fix(web): Ensure collapsible sections start collapsed by default
Added explicit display: none style to nested content divs in plugin config
template to ensure they start collapsed. The hidden class should handle this,
but adding the inline style ensures sections are definitely collapsed on
initial page load.
Sections now:
- Start collapsed (hidden) with chevron pointing right
- Expand when clicked (chevron points down)
- Collapse when clicked again (chevron points right)
This ensures a consistent collapsed initial state across all plugin
configuration sections.
* fix(web): Fix collapsible section toggle to properly collapse on second click
Fixed the toggle logic to explicitly set display: block when showing and
display: none when hiding, rather than clearing the display style. This
ensures the section state is properly tracked and the toggle works correctly
on both expand and collapse clicks.
The function now:
- When hidden: removes hidden class, sets display: block, chevron down
- When visible: adds hidden class, sets display: none, chevron right
This fixes the issue where sections would expand but not collapse again.
Fixes: Sections not collapsing on second click
* feat(web): Ensure plugin navigation tabs load automatically on any page
Implemented comprehensive solution to ensure plugin navigation tabs load
automatically without requiring a visit to the plugin manager page:
1. Global event listener for 'pluginsUpdated' - works even if Alpine isn't
ready yet, updates tabs directly when plugins_manager.js loads plugins
2. Enhanced stub's loadInstalledPluginsDirectly():
- Sets window.installedPlugins after loading
- Dispatches 'pluginsUpdated' event for global listener
- Adds console logging for debugging
3. Event listener in stub's init() method:
- Listens for 'pluginsUpdated' events
- Updates component state and tabs when events fire
4. Fallback timer:
- If plugins_manager.js hasn't loaded after 2 seconds, fetches
plugins directly via API
- Ensures tabs appear even if plugins_manager.js fails
5. Improved checkAndUpdateTabs():
- Better logging
- Fallback to direct fetch after timeout
6. Enhanced logging throughout plugin loading flow for debugging
This ensures plugin tabs are visible immediately on page load, regardless
of which tab is active or when plugins_manager.js loads.
Fixes: Plugin navigation tabs only loading after visiting plugin manager
* fix(web): Improve plugin tabs update logging and ensure immediate execution
Enhanced logging in updatePluginTabs() and _doUpdatePluginTabs() to help
debug why tabs aren't appearing. Changed debounce behavior to execute
immediately on first call to ensure tabs appear quickly.
Added detailed console logging with [FULL] prefix to track:
- When updatePluginTabs() is called
- When _doUpdatePluginTabs() executes
- DOM element availability
- Tab creation process
- Final tab count
This will help identify if tabs are being created but not visible, or if
the update function isn't being called at all.
Fixes: Plugin tabs loading but not visible in navigation bar
* fix(web): Prevent duplicate plugin tab updates and clearing
Added debouncing and duplicate prevention to stub's updatePluginTabs() to
prevent tabs from being cleared and re-added multiple times. Also checks
if tabs already match before clearing them.
Changes:
1. Debounce stub's updatePluginTabs() with 100ms delay
2. Check if existing tabs match current plugin list before clearing
3. Global event listener only triggers full implementation's updatePluginTabs
4. Stub's event listener only works in stub mode (before enhancement)
This prevents the issue where tabs were being cleared and re-added
multiple times in rapid succession, which could leave tabs empty.
Fixes: Plugin tabs being cleared and not re-added properly
* fix(web): Fix plugin tabs not rendering when plugins are loaded
Fixed _doUpdatePluginTabs() to properly use component's installedPlugins
instead of checking window.installedPlugins first. Also fixed the 'unchanged'
check to not skip when both lists are empty (first load scenario).
Changes:
1. Check component's installedPlugins first (most up-to-date)
2. Only skip update if plugins exist AND match (don't skip empty lists)
3. Retry if no plugins found (in case they're still loading)
4. Ensure window.installedPlugins is set when loading directly
5. Better logging to show which plugin source is being used
This ensures tabs are rendered when plugins are loaded, even on first page load.
Fixes: Plugin tabs not being drawn despite plugins being loaded
* fix(config): Fix array field parsing and validation for plugin config forms
- Added logic to detect and combine indexed array fields (text_color.0, text_color.1, etc.)
- Fixed array fields incorrectly stored as dicts with numeric keys
- Improved handling of comma-separated array values from form submissions
- Ensures array fields meet minItems requirements before validation
- Resolves 400 BAD REQUEST errors when saving plugin config with RGB color arrays
* fix(config): Improve array field handling and secrets error handling
- Use schema defaults when array fields don't meet minItems requirement
- Add debug logging for array field parsing
- Improve error handling for secrets file writes
- Fix arrays stored as dicts with numeric keys conversion
- Better handling of incomplete array values from form submissions
* fix(config): Convert array elements to correct types (numbers not strings)
- Fix array element type conversion when converting dicts to arrays
- Ensure RGB color arrays have integer elements, not strings
- Apply type conversion for both nested and top-level array fields
- Fixes validation errors: 'Expected type number, got str'
* fix(config): Fix array fields showing 'none' when value is null
- Handle None/null values in array field templates properly
- Use schema defaults when array values are None/null
- Fix applies to both Jinja2 template and JavaScript form generation
- Resolves issue where stock ticker plugin shows 'none' instead of default values
* fix(config): Add novalidate to plugin config form to prevent HTML5 validation blocking saves
- Prevents browser HTML5 validation from blocking form submission
- Allows custom validation logic to handle form data properly
- Fixes issue where save button appears unclickable due to invalid form controls
- Resolves problems with plugins like clock-simple that have nested/array fields
* feat(config): Add helpful form validation with detailed error messages
- Keep HTML5 validation enabled (removed novalidate) to prevent broken configs
- Add validatePluginConfigForm function that shows which fields fail and why
- Automatically expands collapsed sections containing invalid fields
- Focuses first invalid field and scrolls to it
- Shows user-friendly error messages with field names and specific issues
- Prevents form submission until all fields are valid
* fix(schema): Remove core properties from required array during validation
- Core properties (enabled, display_duration, live_priority) are system-managed
- SchemaManager now removes them from required array after injection
- Added default values for core properties (enabled=True, display_duration=15, live_priority=False)
- Updated generate_default_config() to ensure live_priority has default
- Resolves 186 validation issues, reducing to 3 non-blocking warnings (98.4% reduction)
- All 19 of 20 plugins now pass validation without errors
Documentation:
- Created docs/PLUGIN_CONFIG_CORE_PROPERTIES.md explaining core property handling
- Updated existing docs to reflect core property behavior
- Removed temporary audit files and scripts
* fix(ui): Improve button text contrast on white backgrounds
- Changed Screenshot button text from text-gray-700 to text-gray-900
- Added global CSS rule to ensure all buttons with white backgrounds use dark text (text-gray-900) for better readability
- Fixes contrast issues where light text on light backgrounds was illegible
* fix(ui): Add explicit text color to form-control inputs
- Added color: #111827 to .form-control class to ensure dark text on white backgrounds
- Fixes issue where input fields had white text on white background after button contrast fix
- Ensures all form inputs are readable with proper contrast
* docs: Update impact explanation and plugin config documentation
* docs: Improve documentation and fix template inconsistencies
- Add migration guide for script path reorganization (scripts moved to scripts/install/ and scripts/fix_perms/)
- Add breaking changes section to README with migration guidance
- Fix config template: set plugins_directory to 'plugins' to match actual plugin locations
- Fix test template: replace Jinja2 placeholders with plain text to match other templates
- Fix markdown linting: add language identifiers to code blocks (python, text, javascript)
- Update permission guide: document setgid bit (0o2775) for directory modes
- Fix example JSON: pin dependency versions and fix compatible_versions range
- Improve readability: reduce repetition in IMPACT_EXPLANATION.md
* feat(web): Make v3 interface production-ready for local deployment
- Phase 2: Real Service Integration
- Replace sample data with real psutil system monitoring (CPU, memory, disk, temp, uptime)
- Integrate display controller to read from /tmp/led_matrix_preview.png snapshot
- Scan assets/fonts directory and extract font metadata with freetype
- Phase 1: Security & Input Validation
- Add input validation module with URL, file upload, and config sanitization
- Add optional CSRF protection (gracefully degrades if flask-wtf missing)
- Add rate limiting (lenient for local use, prevents accidental abuse)
- Add file upload validation to font upload endpoint
- Phase 3: Error Handling
- Add global error handlers for 404, 500, and unhandled exceptions
- All endpoints have comprehensive try/except blocks
- Phase 4: Monitoring & Observability
- Add structured logging with JSON format support
- Add request logging middleware (tracks method, path, status, duration, IP)
- Add /api/v3/health endpoint with service status checks
- Phase 5: Performance & Caching
- Add in-memory caching system (separate module to avoid circular imports)
- Cache font catalog (5 minute TTL)
- Cache system status (10 second TTL)
- Invalidate cache on config changes
- All changes are non-blocking with graceful error handling
- Optional dependencies (flask-wtf, flask-limiter) degrade gracefully
- All imports protected with try/except blocks
- Verified compilation and import tests pass
* docs: Fix caching pattern logic flaw and merge conflict resolution plan
- Fix Basic Caching Pattern: Replace broken stale cache fallback with correct pattern
- Re-fetch cache with large max_age (31536000) in except block instead of checking already-falsy cached variable
- Fixes both instances in ADVANCED_PLUGIN_DEVELOPMENT.md
- Matches correct pattern from manager.py.template
- Fix MERGE_CONFLICT_RESOLUTION_PLAN.md merge direction
- Correct Step 1 to checkout main and merge plugins into it (not vice versa)
- Update commit message to reflect 'Merge plugins into main' direction
- Fixes workflow to match documented plugins → main merge
---------
Co-authored-by: Chuck <chuck@example.com>
This commit is contained in:
8
src/__init__.py
Normal file
8
src/__init__.py
Normal file
@@ -0,0 +1,8 @@
|
||||
"""
|
||||
LEDMatrix Display System
|
||||
|
||||
Core source package for the LED Matrix Display project.
|
||||
"""
|
||||
|
||||
__version__ = "1.0.0"
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
import spotipy
|
||||
from spotipy.oauth2 import SpotifyOAuth
|
||||
import logging
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Setup basic logging
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
||||
|
||||
# Define paths relative to this file's location (assuming it's in src)
|
||||
CONFIG_DIR = os.path.join(os.path.dirname(__file__), '..', 'config')
|
||||
SECRETS_PATH = os.path.join(CONFIG_DIR, 'config_secrets.json')
|
||||
SPOTIFY_AUTH_CACHE_PATH = os.path.join(CONFIG_DIR, 'spotify_auth.json') # Explicit cache path
|
||||
|
||||
# Resolve to absolute paths
|
||||
CONFIG_DIR = os.path.abspath(CONFIG_DIR)
|
||||
SECRETS_PATH = os.path.abspath(SECRETS_PATH)
|
||||
SPOTIFY_AUTH_CACHE_PATH = os.path.abspath(SPOTIFY_AUTH_CACHE_PATH)
|
||||
|
||||
SCOPE = "user-read-currently-playing user-read-playback-state"
|
||||
|
||||
def load_spotify_credentials():
|
||||
"""Loads Spotify credentials from config_secrets.json."""
|
||||
if not os.path.exists(SECRETS_PATH):
|
||||
logging.error(f"Secrets file not found at {SECRETS_PATH}")
|
||||
return None, None, None
|
||||
|
||||
try:
|
||||
with open(SECRETS_PATH, 'r') as f:
|
||||
secrets = json.load(f)
|
||||
music_secrets = secrets.get("music", {})
|
||||
client_id = music_secrets.get("SPOTIFY_CLIENT_ID")
|
||||
client_secret = music_secrets.get("SPOTIFY_CLIENT_SECRET")
|
||||
redirect_uri = music_secrets.get("SPOTIFY_REDIRECT_URI")
|
||||
if not all([client_id, client_secret, redirect_uri]):
|
||||
logging.error("One or more Spotify credentials missing in config_secrets.json under the 'music' key.")
|
||||
return None, None, None
|
||||
return client_id, client_secret, redirect_uri
|
||||
except json.JSONDecodeError:
|
||||
logging.error(f"Error decoding JSON from {SECRETS_PATH}")
|
||||
return None, None, None
|
||||
except Exception as e:
|
||||
logging.error(f"Error loading Spotify credentials: {e}")
|
||||
return None, None, None
|
||||
|
||||
if __name__ == "__main__":
|
||||
logging.info("Starting Spotify Authentication Process...")
|
||||
|
||||
client_id, client_secret, redirect_uri = load_spotify_credentials()
|
||||
|
||||
if not all([client_id, client_secret, redirect_uri]):
|
||||
logging.error("Could not load Spotify credentials. Please check config/config_secrets.json. Exiting.")
|
||||
sys.exit(1)
|
||||
|
||||
# Ensure the config directory exists for the cache file
|
||||
if not os.path.exists(CONFIG_DIR):
|
||||
try:
|
||||
logging.info(f"Config directory {CONFIG_DIR} not found. Attempting to create it.")
|
||||
os.makedirs(CONFIG_DIR)
|
||||
logging.info(f"Successfully created config directory: {CONFIG_DIR}")
|
||||
except OSError as e:
|
||||
logging.error(f"Fatal: Could not create config directory {CONFIG_DIR}: {e}. Please create it manually. Exiting.")
|
||||
sys.exit(1)
|
||||
|
||||
sp_oauth = SpotifyOAuth(
|
||||
client_id=client_id,
|
||||
client_secret=client_secret,
|
||||
redirect_uri=redirect_uri,
|
||||
scope=SCOPE,
|
||||
cache_path=SPOTIFY_AUTH_CACHE_PATH, # Use explicit cache path
|
||||
open_browser=False
|
||||
)
|
||||
|
||||
# Step 1: Get the authorization URL
|
||||
auth_url = sp_oauth.get_authorize_url()
|
||||
print("-" * 50)
|
||||
print("SPOTIFY AUTHORIZATION NEEDED:")
|
||||
print("1. Please visit this URL in a browser (on any device):")
|
||||
print(f" {auth_url}")
|
||||
print("2. Authorize the application.")
|
||||
print("3. You will be redirected to a URL (likely showing an error). Copy that FULL redirected URL.")
|
||||
print("-" * 50)
|
||||
|
||||
# Step 2: Get the redirected URL from the user
|
||||
redirected_url = input("4. Paste the full redirected URL here and press Enter: ").strip()
|
||||
|
||||
if not redirected_url:
|
||||
logging.error("No redirected URL provided. Exiting.")
|
||||
sys.exit(1)
|
||||
|
||||
# Step 3: Parse the code from the redirected URL
|
||||
try:
|
||||
# Spotipy's parse_auth_response_url is not directly part of the public API of SpotifyOAuth
|
||||
# for this specific flow where we manually handle the redirect.
|
||||
# We need to extract the 'code' query parameter.
|
||||
# A more robust way would be to use urllib.parse, but for simplicity:
|
||||
if "?code=" in redirected_url:
|
||||
auth_code = redirected_url.split("?code=")[1].split("&")[0]
|
||||
elif "&code=" in redirected_url: # Should not happen if code is first param
|
||||
auth_code = redirected_url.split("&code=")[1].split("&")[0]
|
||||
else:
|
||||
logging.error("Could not find 'code=' in the redirected URL. Please ensure you copied the full URL.")
|
||||
logging.error(f"Received URL: {redirected_url}")
|
||||
sys.exit(1)
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error parsing authorization code from redirected URL: {e}")
|
||||
logging.error(f"Received URL: {redirected_url}")
|
||||
sys.exit(1)
|
||||
|
||||
# Step 4: Get the access token using the code and cache it
|
||||
try:
|
||||
# check_cache=False forces it to use the provided code rather than a potentially stale cached one for this specific step.
|
||||
# The token will still be written to the cache_path.
|
||||
token_info = sp_oauth.get_access_token(auth_code, check_cache=False)
|
||||
if token_info:
|
||||
logging.info(f"Spotify authentication successful. Token info cached at {SPOTIFY_AUTH_CACHE_PATH}")
|
||||
else:
|
||||
logging.error("Failed to obtain Spotify token info with the provided code.")
|
||||
logging.error("Please ensure the code was correct and not expired.")
|
||||
sys.exit(1)
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error obtaining Spotify access token: {e}")
|
||||
logging.error("This can happen if the authorization code is incorrect, expired, or already used.")
|
||||
sys.exit(1)
|
||||
|
||||
logging.info("Spotify Authentication Process Finished.")
|
||||
@@ -1,173 +0,0 @@
|
||||
import requests
|
||||
import json
|
||||
import os
|
||||
import logging
|
||||
|
||||
# Setup basic logging
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
||||
|
||||
# Define paths relative to this file's location (assuming it's in src)
|
||||
CONFIG_DIR = os.path.join(os.path.dirname(__file__), '..', 'config')
|
||||
CONFIG_PATH = os.path.join(CONFIG_DIR, 'config.json')
|
||||
YTM_AUTH_CONFIG_PATH = os.path.join(CONFIG_DIR, 'ytm_auth.json')
|
||||
|
||||
# Resolve to absolute paths
|
||||
CONFIG_DIR = os.path.abspath(CONFIG_DIR)
|
||||
CONFIG_PATH = os.path.abspath(CONFIG_PATH)
|
||||
YTM_AUTH_CONFIG_PATH = os.path.abspath(YTM_AUTH_CONFIG_PATH)
|
||||
|
||||
# YTM Companion App Constants (copied from ytm_client.py)
|
||||
YTM_APP_ID = "ledmatrixcontroller"
|
||||
YTM_APP_NAME = "LEDMatrixController"
|
||||
YTM_APP_VERSION = "1.0.0"
|
||||
|
||||
def load_ytm_companion_url():
|
||||
"""Loads YTM_COMPANION_URL from config.json"""
|
||||
default_url = "http://localhost:9863"
|
||||
base_url = default_url
|
||||
|
||||
if not os.path.exists(CONFIG_PATH):
|
||||
logging.warning(f"Main config file not found at {CONFIG_PATH}. Using default YTM URL: {base_url}")
|
||||
return base_url
|
||||
|
||||
try:
|
||||
with open(CONFIG_PATH, 'r') as f:
|
||||
loaded_config = json.load(f)
|
||||
music_config = loaded_config.get("music", {})
|
||||
base_url = music_config.get("YTM_COMPANION_URL", default_url)
|
||||
if not base_url:
|
||||
logging.warning("YTM_COMPANION_URL missing or empty in config.json music section. Using default.")
|
||||
base_url = default_url
|
||||
except json.JSONDecodeError:
|
||||
logging.error(f"Error decoding JSON from main config {CONFIG_PATH}. Using default YTM URL.")
|
||||
base_url = default_url
|
||||
except Exception as e:
|
||||
logging.error(f"Error loading YTM_COMPANION_URL from main config {CONFIG_PATH}: {e}. Using default YTM URL.")
|
||||
base_url = default_url
|
||||
|
||||
logging.info(f"YTM Companion URL set to: {base_url}")
|
||||
|
||||
if base_url.startswith("ws://"):
|
||||
base_url = "http://" + base_url[5:]
|
||||
elif base_url.startswith("wss://"):
|
||||
base_url = "https://" + base_url[6:]
|
||||
return base_url
|
||||
|
||||
def _request_auth_code(base_url):
|
||||
"""Requests an authentication code from the YTM Companion server."""
|
||||
url = f"{base_url}/api/v1/auth/requestcode"
|
||||
payload = {
|
||||
"appId": YTM_APP_ID,
|
||||
"appName": YTM_APP_NAME,
|
||||
"appVersion": YTM_APP_VERSION
|
||||
}
|
||||
try:
|
||||
logging.info(f"Requesting auth code from {url} with appId: {YTM_APP_ID}")
|
||||
response = requests.post(url, json=payload, timeout=10)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
auth_code = data.get('code')
|
||||
if auth_code:
|
||||
logging.info(f"Received auth code: {auth_code}")
|
||||
else:
|
||||
logging.error("Auth code not found in response.")
|
||||
return auth_code
|
||||
except requests.exceptions.RequestException as e:
|
||||
logging.error(f"Error requesting YTM auth code: {e}")
|
||||
return None
|
||||
except json.JSONDecodeError:
|
||||
logging.error("Error decoding JSON response when requesting auth code.")
|
||||
return None
|
||||
|
||||
def _request_auth_token(base_url, code):
|
||||
"""Requests an authentication token using the provided code."""
|
||||
if not code:
|
||||
return None
|
||||
url = f"{base_url}/api/v1/auth/request"
|
||||
payload = {
|
||||
"appId": YTM_APP_ID,
|
||||
"code": code
|
||||
}
|
||||
try:
|
||||
logging.info("Requesting auth token. PLEASE CHECK YOUR YTM DESKTOP APP TO APPROVE THIS REQUEST.")
|
||||
logging.info("You have 30 seconds to approve in the YTM Desktop App.")
|
||||
response = requests.post(url, json=payload, timeout=35)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
token = data.get('token')
|
||||
if token:
|
||||
logging.info("Successfully received YTM auth token.")
|
||||
else:
|
||||
logging.warning("Auth token not found in response.")
|
||||
return token
|
||||
except requests.exceptions.Timeout:
|
||||
logging.error("Timeout waiting for YTM auth token. Did you approve the request in YTM Desktop App?")
|
||||
return None
|
||||
except requests.exceptions.RequestException as e:
|
||||
logging.error(f"Error requesting YTM auth token: {e}")
|
||||
return None
|
||||
except json.JSONDecodeError:
|
||||
logging.error("Error decoding JSON response when requesting auth token.")
|
||||
return None
|
||||
|
||||
def save_ytm_token(token):
|
||||
"""Saves the YTM token to ytm_auth.json."""
|
||||
if not token:
|
||||
logging.warning("No YTM token provided to save.")
|
||||
return False
|
||||
|
||||
if not os.path.exists(CONFIG_DIR):
|
||||
try:
|
||||
os.makedirs(CONFIG_DIR)
|
||||
logging.info(f"Created config directory: {CONFIG_DIR}")
|
||||
except OSError as e:
|
||||
logging.error(f"Could not create config directory {CONFIG_DIR}: {e}")
|
||||
return False
|
||||
|
||||
token_data = {"YTM_COMPANION_TOKEN": token}
|
||||
|
||||
try:
|
||||
with open(YTM_AUTH_CONFIG_PATH, 'w') as f:
|
||||
json.dump(token_data, f, indent=4)
|
||||
logging.info(f"YTM Companion token saved to {YTM_AUTH_CONFIG_PATH}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logging.error(f"Error saving YTM token to {YTM_AUTH_CONFIG_PATH}: {e}")
|
||||
return False
|
||||
|
||||
if __name__ == "__main__":
|
||||
logging.info("Starting YTM Authentication Process...")
|
||||
|
||||
# Ensure the config directory exists, create if not
|
||||
# This is important because this script is run as the user.
|
||||
if not os.path.exists(CONFIG_DIR):
|
||||
try:
|
||||
logging.info(f"Config directory {CONFIG_DIR} not found. Attempting to create it.")
|
||||
os.makedirs(CONFIG_DIR)
|
||||
logging.info(f"Successfully created config directory: {CONFIG_DIR}")
|
||||
except OSError as e:
|
||||
logging.error(f"Fatal: Could not create config directory {CONFIG_DIR}: {e}")
|
||||
logging.error("Please ensure the path is correct and you have permissions to create this directory.")
|
||||
exit(1) # Exit if we can't create the config directory
|
||||
|
||||
ytm_url = load_ytm_companion_url()
|
||||
if not ytm_url:
|
||||
logging.error("Could not determine YTM Companion URL. Exiting.")
|
||||
exit(1)
|
||||
|
||||
auth_code = _request_auth_code(ytm_url)
|
||||
if not auth_code:
|
||||
logging.error("Failed to get YTM auth code. Cannot proceed with authentication. Exiting.")
|
||||
exit(1)
|
||||
|
||||
auth_token = _request_auth_token(ytm_url, auth_code)
|
||||
if auth_token:
|
||||
if save_ytm_token(auth_token):
|
||||
logging.info("YTM authentication successful and token saved.")
|
||||
else:
|
||||
logging.error("YTM authentication successful, but FAILED to save token.")
|
||||
logging.error(f"Please check permissions for the directory {CONFIG_DIR} and the file {YTM_AUTH_CONFIG_PATH} if it exists.")
|
||||
else:
|
||||
logging.error("Failed to get YTM auth token. Authentication unsuccessful.")
|
||||
|
||||
logging.info("YTM Authentication Process Finished.")
|
||||
@@ -19,6 +19,9 @@ class BackgroundCacheMixin:
|
||||
|
||||
This mixin eliminates code duplication by providing a common implementation
|
||||
for the background service cache pattern used across all sports managers.
|
||||
|
||||
Note: For non-sports managers (weather, stocks, news, etc.), use
|
||||
GenericCacheMixin instead. See src/generic_cache_mixin.py for details.
|
||||
"""
|
||||
|
||||
def _fetch_data_with_background_cache(self,
|
||||
|
||||
@@ -69,6 +69,7 @@ class FetchResult:
|
||||
cached: bool = False
|
||||
fetch_time: float = 0.0
|
||||
retry_count: int = 0
|
||||
completed_at: float = field(default_factory=time.time) # Timestamp when request completed
|
||||
|
||||
class BackgroundDataService:
|
||||
"""
|
||||
@@ -101,6 +102,11 @@ class BackgroundDataService:
|
||||
self._lock = threading.RLock()
|
||||
self._shutdown = False
|
||||
|
||||
# Cleanup tracking
|
||||
self._max_completed_requests = 500 # Maximum completed requests to keep
|
||||
self._completed_requests_cleanup_interval = 600.0 # Cleanup every 10 minutes
|
||||
self._last_completed_requests_cleanup = time.time()
|
||||
|
||||
# Statistics
|
||||
self.stats = {
|
||||
'total_requests': 0,
|
||||
@@ -322,6 +328,9 @@ class BackgroundDataService:
|
||||
(self.stats['completed_requests'] + self.stats['failed_requests'])
|
||||
)
|
||||
|
||||
# Periodic cleanup after storing result
|
||||
self._cleanup_completed_requests()
|
||||
|
||||
# Call callback if provided
|
||||
if request.callback:
|
||||
try:
|
||||
@@ -380,6 +389,9 @@ class BackgroundDataService:
|
||||
Returns:
|
||||
Fetch result if available, None otherwise
|
||||
"""
|
||||
# Periodic cleanup
|
||||
self._cleanup_completed_requests()
|
||||
|
||||
with self._lock:
|
||||
return self.completed_requests.get(request_id)
|
||||
|
||||
@@ -393,6 +405,9 @@ class BackgroundDataService:
|
||||
Returns:
|
||||
True if request is complete, False otherwise
|
||||
"""
|
||||
# Periodic cleanup
|
||||
self._cleanup_completed_requests()
|
||||
|
||||
with self._lock:
|
||||
return request_id in self.completed_requests
|
||||
|
||||
@@ -445,9 +460,78 @@ class BackgroundDataService:
|
||||
**self.stats,
|
||||
'active_requests': len(self.active_requests),
|
||||
'completed_requests_count': len(self.completed_requests),
|
||||
'queue_size': self.request_queue.qsize()
|
||||
'max_completed_requests': self._max_completed_requests,
|
||||
'completed_requests_usage_percent': (len(self.completed_requests) / self._max_completed_requests * 100) if self._max_completed_requests > 0 else 0,
|
||||
'queue_size': self.request_queue.qsize(),
|
||||
'last_cleanup': self._last_completed_requests_cleanup,
|
||||
'cleanup_interval': self._completed_requests_cleanup_interval
|
||||
}
|
||||
|
||||
def log_memory_stats(self):
|
||||
"""Log current memory usage statistics."""
|
||||
stats = self.get_statistics()
|
||||
logger.info(f"BackgroundDataService Memory - Active: {stats['active_requests']}, "
|
||||
f"Completed: {stats['completed_requests_count']}/{stats['max_completed_requests']} "
|
||||
f"({stats['completed_requests_usage_percent']:.1f}%), "
|
||||
f"Last cleanup: {time.time() - stats['last_cleanup']:.1f}s ago")
|
||||
|
||||
def _cleanup_completed_requests(self, force: bool = False) -> int:
|
||||
"""
|
||||
Automatically clean up old completed requests.
|
||||
|
||||
Args:
|
||||
force: If True, perform cleanup regardless of time interval
|
||||
|
||||
Returns:
|
||||
Number of requests removed
|
||||
"""
|
||||
now = time.time()
|
||||
|
||||
# Check if cleanup is needed
|
||||
if not force and (now - self._last_completed_requests_cleanup) < self._completed_requests_cleanup_interval:
|
||||
return 0
|
||||
|
||||
with self._lock:
|
||||
removed_count = 0
|
||||
current_time = time.time()
|
||||
|
||||
# Remove requests older than 1 hour
|
||||
cutoff_time = current_time - 3600 # 1 hour
|
||||
|
||||
to_remove = []
|
||||
for request_id, result in self.completed_requests.items():
|
||||
# Check if request is old enough to remove
|
||||
if result.completed_at < cutoff_time:
|
||||
to_remove.append(request_id)
|
||||
|
||||
# Also enforce size limit if we have too many requests
|
||||
if len(self.completed_requests) > self._max_completed_requests:
|
||||
# Sort by completion time (oldest first)
|
||||
sorted_requests = sorted(
|
||||
self.completed_requests.items(),
|
||||
key=lambda x: x[1].completed_at
|
||||
)
|
||||
|
||||
# Remove oldest entries until we're under the limit
|
||||
excess_count = len(self.completed_requests) - self._max_completed_requests
|
||||
for i in range(excess_count):
|
||||
if i < len(sorted_requests):
|
||||
request_id = sorted_requests[i][0]
|
||||
if request_id not in to_remove:
|
||||
to_remove.append(request_id)
|
||||
|
||||
# Remove the requests
|
||||
for request_id in to_remove:
|
||||
del self.completed_requests[request_id]
|
||||
removed_count += 1
|
||||
|
||||
self._last_completed_requests_cleanup = current_time
|
||||
|
||||
if removed_count > 0:
|
||||
logger.debug(f"Cleaned up {removed_count} old completed requests (remaining: {len(self.completed_requests)})")
|
||||
|
||||
return removed_count
|
||||
|
||||
def clear_completed_requests(self, older_than_hours: int = 24):
|
||||
"""
|
||||
Clear completed requests older than specified time.
|
||||
@@ -460,9 +544,7 @@ class BackgroundDataService:
|
||||
with self._lock:
|
||||
to_remove = []
|
||||
for request_id, result in self.completed_requests.items():
|
||||
# We don't store creation time in results, so we'll use a simple count-based approach
|
||||
# In a real implementation, you'd want to store timestamps
|
||||
if len(self.completed_requests) > 1000: # Keep last 1000 results
|
||||
if result.completed_at < cutoff_time:
|
||||
to_remove.append(request_id)
|
||||
|
||||
for request_id in to_remove:
|
||||
|
||||
@@ -43,7 +43,17 @@ class APIDataExtractor(ABC):
|
||||
# Parse game time
|
||||
start_time_utc = None
|
||||
try:
|
||||
start_time_utc = datetime.fromisoformat(game_date_str.replace("Z", "+00:00"))
|
||||
# Parse the datetime string
|
||||
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
|
||||
start_time_utc = dt.replace(tzinfo=pytz.UTC)
|
||||
else:
|
||||
# Convert to pytz.UTC for consistency
|
||||
start_time_utc = dt.astimezone(pytz.UTC)
|
||||
except ValueError:
|
||||
self.logger.warning(f"Could not parse game date: {game_date_str}")
|
||||
|
||||
|
||||
@@ -111,18 +111,34 @@ class ESPNDataSource(DataSource):
|
||||
|
||||
def fetch_standings(self, sport: str, league: str) -> Dict:
|
||||
"""Fetch standings from ESPN API."""
|
||||
# Try standings endpoint first (for professional leagues like NFL, NBA, etc.)
|
||||
try:
|
||||
url = f"{self.base_url}/{sport}/{league}/rankings"
|
||||
url = f"{self.base_url}/{sport}/{league}/standings"
|
||||
response = self.session.get(url, headers=self.get_headers(), timeout=15)
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
self.logger.debug(f"Fetched standings for {sport}/{league}")
|
||||
return data
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error fetching standings from ESPN: {e}")
|
||||
return {}
|
||||
# If standings doesn't exist, try rankings (for college sports)
|
||||
if hasattr(e, 'response') and hasattr(e.response, 'status_code') and e.response.status_code == 404:
|
||||
try:
|
||||
url = f"{self.base_url}/{sport}/{league}/rankings"
|
||||
response = self.session.get(url, headers=self.get_headers(), timeout=15)
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
self.logger.debug(f"Fetched rankings for {sport}/{league}")
|
||||
return data
|
||||
except Exception:
|
||||
# Both endpoints failed - standings/rankings may not be available for this sport/league
|
||||
self.logger.debug(f"Standings/rankings not available for {sport}/{league} from ESPN API")
|
||||
return {}
|
||||
else:
|
||||
# Non-404 error - log at debug level since standings are optional
|
||||
self.logger.debug(f"Error fetching standings from ESPN for {sport}/{league}: {e}")
|
||||
return {}
|
||||
|
||||
|
||||
class MLBAPIDataSource(DataSource):
|
||||
|
||||
@@ -387,8 +387,43 @@ class FootballLive(Football, SportsLive):
|
||||
main_img = main_img.convert('RGB') # Convert for display
|
||||
|
||||
# Display the final image
|
||||
# #region agent log
|
||||
import json
|
||||
import time
|
||||
try:
|
||||
with open('/home/chuck/Github/LEDMatrix/.cursor/debug.log', 'a') as f:
|
||||
f.write(json.dumps({
|
||||
"sessionId": "debug-session",
|
||||
"runId": "run1",
|
||||
"hypothesisId": "C",
|
||||
"location": "football.py:390",
|
||||
"message": "About to update display",
|
||||
"data": {
|
||||
"force_clear": force_clear,
|
||||
"game": game.get('away_abbr', '') + "@" + game.get('home_abbr', '')
|
||||
},
|
||||
"timestamp": int(time.time() * 1000)
|
||||
}) + "\n")
|
||||
except: pass
|
||||
# #endregion
|
||||
self.display_manager.image.paste(main_img, (0, 0))
|
||||
self.display_manager.update_display() # Update display here for live
|
||||
# #region agent log
|
||||
try:
|
||||
with open('/home/chuck/Github/LEDMatrix/.cursor/debug.log', 'a') as f:
|
||||
f.write(json.dumps({
|
||||
"sessionId": "debug-session",
|
||||
"runId": "run1",
|
||||
"hypothesisId": "C",
|
||||
"location": "football.py:392",
|
||||
"message": "After update display",
|
||||
"data": {
|
||||
"force_clear": force_clear
|
||||
},
|
||||
"timestamp": int(time.time() * 1000)
|
||||
}) + "\n")
|
||||
except: pass
|
||||
# #endregion
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error displaying live Football game: {e}", exc_info=True) # Changed log prefix
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
import time
|
||||
from abc import ABC, abstractmethod
|
||||
from datetime import datetime, timedelta, timezone
|
||||
@@ -21,7 +22,10 @@ from src.cache_manager import CacheManager
|
||||
from src.display_manager import DisplayManager
|
||||
from src.dynamic_team_resolver import DynamicTeamResolver
|
||||
from src.logo_downloader import LogoDownloader, download_missing_logo
|
||||
from src.odds_manager import OddsManager
|
||||
try:
|
||||
from src.base_odds_manager import BaseOddsManager as OddsManager
|
||||
except ImportError:
|
||||
OddsManager = None
|
||||
|
||||
|
||||
class SportsCore(ABC):
|
||||
@@ -30,8 +34,16 @@ class SportsCore(ABC):
|
||||
self.config = config
|
||||
self.cache_manager = cache_manager
|
||||
self.config_manager = self.cache_manager.config_manager
|
||||
self.odds_manager = OddsManager(
|
||||
self.cache_manager, self.config_manager)
|
||||
if OddsManager:
|
||||
try:
|
||||
self.odds_manager = OddsManager(
|
||||
self.cache_manager, self.config_manager)
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Failed to initialize OddsManager: {e}")
|
||||
self.odds_manager = None
|
||||
else:
|
||||
self.odds_manager = None
|
||||
self.logger.warning("OddsManager not available - odds functionality disabled")
|
||||
self.display_manager = display_manager
|
||||
self.display_width = self.display_manager.matrix.width
|
||||
self.display_height = self.display_manager.matrix.height
|
||||
@@ -47,8 +59,9 @@ class SportsCore(ABC):
|
||||
self.mode_config = config.get(f"{sport_key}_scoreboard", {}) # Changed config key
|
||||
self.is_enabled: bool = self.mode_config.get("enabled", False)
|
||||
self.show_odds: bool = self.mode_config.get("show_odds", False)
|
||||
self.test_mode: bool = self.mode_config.get("test_mode", False)
|
||||
self.logo_dir = Path(self.mode_config.get("logo_dir", "assets/sports/ncaa_logos")) # Changed logo dir
|
||||
# Use LogoDownloader to get the correct default logo directory for this sport
|
||||
default_logo_dir = Path(LogoDownloader().get_logo_directory(sport_key))
|
||||
self.logo_dir = self._initialize_logo_dir(default_logo_dir)
|
||||
self.update_interval: int = self.mode_config.get(
|
||||
"update_interval_seconds", 60)
|
||||
self.show_records: bool = self.mode_config.get('show_records', False)
|
||||
@@ -112,6 +125,69 @@ class SportsCore(ABC):
|
||||
self.background_enabled = True
|
||||
self.logger.info("Background service enabled with 1 worker (memory optimized)")
|
||||
|
||||
def _initialize_logo_dir(self, configured_path: Path) -> Path:
|
||||
"""Resolve and ensure a writable logo directory, falling back when necessary."""
|
||||
downloader = LogoDownloader()
|
||||
resolved_configured = self._resolve_project_path(configured_path)
|
||||
candidates = [resolved_configured] + self._get_logo_directory_fallbacks(resolved_configured)
|
||||
|
||||
for candidate in candidates:
|
||||
candidate_path = self._resolve_project_path(candidate)
|
||||
if downloader.ensure_logo_directory(str(candidate_path)):
|
||||
if candidate_path != resolved_configured:
|
||||
self.logger.warning(
|
||||
"Configured logo directory '%s' is not writable; using fallback '%s'",
|
||||
resolved_configured,
|
||||
candidate_path,
|
||||
)
|
||||
return candidate_path
|
||||
|
||||
self.logger.error(
|
||||
"Unable to find a writable logo directory. Logos may fail to download (last attempted: %s)",
|
||||
resolved_configured,
|
||||
)
|
||||
return resolved_configured
|
||||
|
||||
def _resolve_project_path(self, path: Path) -> Path:
|
||||
"""Convert relative paths to absolute ones rooted at the project directory."""
|
||||
if path.is_absolute():
|
||||
return path
|
||||
project_root = Path(__file__).resolve().parents[2]
|
||||
return (project_root / path).resolve()
|
||||
|
||||
def _get_logo_directory_fallbacks(self, configured_dir: Path) -> List[Path]:
|
||||
"""Return fallback directories to try when the configured directory is not writable."""
|
||||
fallbacks: List[Path] = []
|
||||
|
||||
env_override = os.environ.get("LEDMATRIX_LOGO_DIR")
|
||||
if env_override:
|
||||
env_path = Path(env_override)
|
||||
if not env_path.is_absolute():
|
||||
env_path = self._resolve_project_path(env_path)
|
||||
fallbacks.append(env_path / self.sport_key)
|
||||
|
||||
cache_dir = getattr(self.cache_manager, "cache_dir", None)
|
||||
if cache_dir:
|
||||
fallbacks.append(Path(cache_dir) / "logos" / self.sport_key)
|
||||
|
||||
try:
|
||||
fallbacks.append(Path.home() / ".ledmatrix" / "logos" / self.sport_key)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
fallbacks.append(Path(tempfile.gettempdir()) / "ledmatrix_logos" / self.sport_key)
|
||||
|
||||
unique_fallbacks: List[Path] = []
|
||||
seen = set()
|
||||
for candidate in fallbacks:
|
||||
if candidate == configured_dir:
|
||||
continue
|
||||
if candidate not in seen:
|
||||
unique_fallbacks.append(candidate)
|
||||
seen.add(candidate)
|
||||
|
||||
return unique_fallbacks
|
||||
|
||||
def _get_season_schedule_dates(self) -> tuple[str, str]:
|
||||
return "", ""
|
||||
|
||||
@@ -129,26 +205,89 @@ class SportsCore(ABC):
|
||||
self.logger.error(f"Error in base _draw_scorebug_layout: {e}", exc_info=True)
|
||||
|
||||
|
||||
def display(self, force_clear: bool = False) -> None:
|
||||
def display(self, force_clear: bool = False) -> bool:
|
||||
"""Common display method for all NCAA FB managers""" # Updated docstring
|
||||
# #region agent log
|
||||
import json
|
||||
try:
|
||||
with open('/home/chuck/Github/LEDMatrix/.cursor/debug.log', 'a') as f:
|
||||
f.write(json.dumps({
|
||||
"sessionId": "debug-session",
|
||||
"runId": "run1",
|
||||
"hypothesisId": "D",
|
||||
"location": "sports.py:208",
|
||||
"message": "Display called",
|
||||
"data": {
|
||||
"force_clear": force_clear,
|
||||
"has_current_game": self.current_game is not None,
|
||||
"current_game": self.current_game['away_abbr'] + "@" + self.current_game['home_abbr'] if self.current_game else None
|
||||
},
|
||||
"timestamp": int(time.time() * 1000)
|
||||
}) + "\n")
|
||||
except: pass
|
||||
# #endregion
|
||||
if not self.is_enabled: # Check if module is enabled
|
||||
return
|
||||
return False
|
||||
|
||||
if not self.current_game:
|
||||
# Clear display if force_clear is True, even when there's no content
|
||||
# This prevents black screens when switching to modes with no content
|
||||
if force_clear:
|
||||
try:
|
||||
self.display_manager.clear()
|
||||
self.display_manager.update_display()
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Error clearing display when no content: {e}")
|
||||
|
||||
current_time = time.time()
|
||||
if not hasattr(self, '_last_warning_time'):
|
||||
self._last_warning_time = 0
|
||||
if current_time - getattr(self, '_last_warning_time', 0) > 300:
|
||||
self.logger.warning(f"No game data available to display in {self.__class__.__name__}")
|
||||
setattr(self, '_last_warning_time', current_time)
|
||||
return
|
||||
return False
|
||||
|
||||
try:
|
||||
# #region agent log
|
||||
try:
|
||||
with open('/home/chuck/Github/LEDMatrix/.cursor/debug.log', 'a') as f:
|
||||
f.write(json.dumps({
|
||||
"sessionId": "debug-session",
|
||||
"runId": "run1",
|
||||
"hypothesisId": "D",
|
||||
"location": "sports.py:232",
|
||||
"message": "About to draw scorebug",
|
||||
"data": {
|
||||
"force_clear": force_clear,
|
||||
"game": self.current_game['away_abbr'] + "@" + self.current_game['home_abbr'] if self.current_game else None
|
||||
},
|
||||
"timestamp": int(time.time() * 1000)
|
||||
}) + "\n")
|
||||
except: pass
|
||||
# #endregion
|
||||
self._draw_scorebug_layout(self.current_game, force_clear)
|
||||
# #region agent log
|
||||
try:
|
||||
with open('/home/chuck/Github/LEDMatrix/.cursor/debug.log', 'a') as f:
|
||||
f.write(json.dumps({
|
||||
"sessionId": "debug-session",
|
||||
"runId": "run1",
|
||||
"hypothesisId": "D",
|
||||
"location": "sports.py:235",
|
||||
"message": "After draw scorebug",
|
||||
"data": {
|
||||
"force_clear": force_clear
|
||||
},
|
||||
"timestamp": int(time.time() * 1000)
|
||||
}) + "\n")
|
||||
except: pass
|
||||
# #endregion
|
||||
# display_manager.update_display() should be called within subclass draw methods
|
||||
# or after calling display() in the main loop. Let's keep it out of the base display.
|
||||
return True
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error during display call in {self.__class__.__name__}: {e}", exc_info=True)
|
||||
return False
|
||||
|
||||
|
||||
def _load_fonts(self):
|
||||
@@ -315,6 +454,9 @@ class SportsCore(ABC):
|
||||
if not self.show_odds:
|
||||
return
|
||||
|
||||
if not self.odds_manager:
|
||||
return
|
||||
|
||||
# Determine update interval based on game state
|
||||
is_live = game.get('is_live', False)
|
||||
update_interval = self.mode_config.get("live_odds_update_interval", 60) if is_live \
|
||||
@@ -402,7 +544,17 @@ class SportsCore(ABC):
|
||||
situation = competition.get("situation")
|
||||
start_time_utc = None
|
||||
try:
|
||||
start_time_utc = datetime.fromisoformat(game_date_str.replace("Z", "+00:00"))
|
||||
# Parse the datetime string
|
||||
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
|
||||
start_time_utc = dt.replace(tzinfo=pytz.UTC)
|
||||
else:
|
||||
# Convert to pytz.UTC for consistency
|
||||
start_time_utc = dt.astimezone(pytz.UTC)
|
||||
except ValueError:
|
||||
logging.warning(f"Could not parse game date: {game_date_str}")
|
||||
|
||||
@@ -622,18 +774,28 @@ class SportsUpcoming(SportsCore):
|
||||
self.logger.info(f"Found {favorite_games_found} favorite team upcoming games")
|
||||
|
||||
# Filter for favorite teams only if the config is set
|
||||
if self.show_favorite_teams_only:
|
||||
# Select one game per favorite team (earliest upcoming game for each team)
|
||||
if self.show_favorite_teams_only:
|
||||
# Select N games per favorite team (where N = upcoming_games_to_show)
|
||||
# Example: upcoming_games_to_show=2 with 3 favorite teams = up to 6 games total
|
||||
team_games = []
|
||||
for team in self.favorite_teams:
|
||||
# Find games where this team is playing
|
||||
if team_specific_games := [game for game in processed_games if game['home_abbr'] == team or game['away_abbr'] == team]:
|
||||
# Sort by game time and take the earliest
|
||||
# Sort by game time and take the earliest N games
|
||||
team_specific_games.sort(key=lambda g: g.get('start_time_utc') or datetime.max.replace(tzinfo=timezone.utc))
|
||||
team_games.append(team_specific_games[0])
|
||||
# Take up to upcoming_games_to_show games for this team
|
||||
team_games.extend(team_specific_games[:self.upcoming_games_to_show])
|
||||
|
||||
# Sort the final list by game time
|
||||
# Sort the final list by game time (earliest first)
|
||||
team_games.sort(key=lambda g: g.get('start_time_utc') or datetime.max.replace(tzinfo=timezone.utc))
|
||||
# Remove duplicates (in case a game involves multiple favorite teams)
|
||||
seen_ids = set()
|
||||
unique_team_games = []
|
||||
for game in team_games:
|
||||
if game['id'] not in seen_ids:
|
||||
seen_ids.add(game['id'])
|
||||
unique_team_games.append(game)
|
||||
team_games = unique_team_games
|
||||
else:
|
||||
team_games = processed_games # Show all upcoming if no favorites
|
||||
# Sort by game time, earliest first
|
||||
@@ -835,9 +997,9 @@ class SportsUpcoming(SportsCore):
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error displaying upcoming game: {e}", exc_info=True) # Changed log prefix
|
||||
|
||||
def display(self, force_clear=False):
|
||||
def display(self, force_clear=False) -> bool:
|
||||
"""Display upcoming games, handling switching."""
|
||||
if not self.is_enabled: return
|
||||
if not self.is_enabled: return False
|
||||
|
||||
if not self.games_list:
|
||||
if self.current_game: self.current_game = None # Clear state if list empty
|
||||
@@ -846,7 +1008,7 @@ class SportsUpcoming(SportsCore):
|
||||
if current_time - self.last_warning_time > self.warning_cooldown:
|
||||
self.logger.info("No upcoming games found for favorite teams to display.") # Changed log prefix
|
||||
self.last_warning_time = current_time
|
||||
return # Skip display update
|
||||
return False # Skip display update
|
||||
|
||||
try:
|
||||
current_time = time.time()
|
||||
@@ -869,10 +1031,13 @@ class SportsUpcoming(SportsCore):
|
||||
|
||||
if self.current_game:
|
||||
self._draw_scorebug_layout(self.current_game, force_clear)
|
||||
return True
|
||||
# update_display() is called within _draw_scorebug_layout for upcoming
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error in display loop: {e}", exc_info=True) # Changed log prefix
|
||||
return False
|
||||
|
||||
|
||||
class SportsRecent(SportsCore):
|
||||
@@ -925,15 +1090,16 @@ class SportsRecent(SportsCore):
|
||||
game_time = game.get('start_time_utc')
|
||||
if game_time and game_time >= recent_cutoff:
|
||||
processed_games.append(game)
|
||||
# Filter for favorite teams
|
||||
if self.favorite_teams:
|
||||
# Filter for favorite teams only if the config is set
|
||||
if self.show_favorite_teams_only:
|
||||
# Get all games involving favorite teams
|
||||
favorite_team_games = [game for game in processed_games
|
||||
if game['home_abbr'] in self.favorite_teams or
|
||||
game['away_abbr'] in self.favorite_teams]
|
||||
self.logger.info(f"Found {len(favorite_team_games)} favorite team games out of {len(processed_games)} total final games within last 21 days")
|
||||
|
||||
# Select one game per favorite team (most recent game for each team)
|
||||
# Select N games per favorite team (where N = recent_games_to_show)
|
||||
# Example: recent_games_to_show=1 with 2 favorite teams = 2 games total
|
||||
team_games = []
|
||||
for team in self.favorite_teams:
|
||||
# Find games where this team is playing
|
||||
@@ -941,23 +1107,31 @@ class SportsRecent(SportsCore):
|
||||
if game['home_abbr'] == team or game['away_abbr'] == team]
|
||||
|
||||
if team_specific_games:
|
||||
# Sort by game time and take the most recent
|
||||
# Sort by game time and take the most recent N games
|
||||
team_specific_games.sort(key=lambda g: g.get('start_time_utc') or datetime.min.replace(tzinfo=timezone.utc), reverse=True)
|
||||
team_games.append(team_specific_games[0])
|
||||
# Take up to recent_games_to_show games for this team
|
||||
team_games.extend(team_specific_games[:self.recent_games_to_show])
|
||||
|
||||
# Sort the final list by game time (most recent first)
|
||||
team_games.sort(key=lambda g: g.get('start_time_utc') or datetime.min.replace(tzinfo=timezone.utc), reverse=True)
|
||||
# Remove duplicates (in case a game involves multiple favorite teams)
|
||||
seen_ids = set()
|
||||
unique_team_games = []
|
||||
for game in team_games:
|
||||
if game['id'] not in seen_ids:
|
||||
seen_ids.add(game['id'])
|
||||
unique_team_games.append(game)
|
||||
team_games = unique_team_games
|
||||
|
||||
# Debug: Show which games are selected for display
|
||||
for i, game in enumerate(team_games):
|
||||
self.logger.info(f"Game {i+1} for display: {game['away_abbr']} @ {game['home_abbr']} - {game.get('start_time_utc')} - Score: {game['away_score']}-{game['home_score']}")
|
||||
else:
|
||||
team_games = processed_games # Show all recent games if no favorites defined
|
||||
self.logger.info(f"Found {len(processed_games)} total final games within last 21 days (no favorite teams configured)")
|
||||
# Sort by game time, most recent first
|
||||
team_games.sort(key=lambda g: g.get('start_time_utc') or datetime.min.replace(tzinfo=timezone.utc), reverse=True)
|
||||
# Limit to the specified number of recent games
|
||||
team_games = team_games[:self.recent_games_to_show]
|
||||
team_games = processed_games # Show all recent games if no favorites defined
|
||||
self.logger.info(f"Found {len(processed_games)} total final games within last 21 days (no favorite teams filtering)")
|
||||
# Sort games by start time, most recent first, and limit to recent_games_to_show
|
||||
team_games.sort(key=lambda g: g.get('start_time_utc') or datetime.min.replace(tzinfo=timezone.utc), reverse=True)
|
||||
team_games = team_games[:self.recent_games_to_show]
|
||||
|
||||
# Check if the list of games to display has changed
|
||||
new_game_ids = {g['id'] for g in team_games}
|
||||
@@ -1133,14 +1307,14 @@ class SportsRecent(SportsCore):
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error displaying recent game: {e}", exc_info=True) # Changed log prefix
|
||||
|
||||
def display(self, force_clear=False):
|
||||
def display(self, force_clear=False) -> bool:
|
||||
"""Display recent games, handling switching."""
|
||||
if not self.is_enabled or not self.games_list:
|
||||
# If disabled or no games, ensure display might be cleared by main loop if needed
|
||||
# Or potentially clear it here? For now, rely on main loop/other managers.
|
||||
if not self.games_list and self.current_game:
|
||||
self.current_game = None # Clear internal state if list becomes empty
|
||||
return
|
||||
return False
|
||||
|
||||
try:
|
||||
current_time = time.time()
|
||||
@@ -1163,10 +1337,13 @@ class SportsRecent(SportsCore):
|
||||
|
||||
if self.current_game:
|
||||
self._draw_scorebug_layout(self.current_game, force_clear)
|
||||
return True
|
||||
# update_display() is called within _draw_scorebug_layout for recent
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error in display loop: {e}", exc_info=True) # Changed log prefix
|
||||
return False
|
||||
|
||||
class SportsLive(SportsCore):
|
||||
|
||||
@@ -1177,13 +1354,15 @@ class SportsLive(SportsCore):
|
||||
self.last_update = 0
|
||||
self.live_games = []
|
||||
self.current_game_index = 0
|
||||
self.last_game_switch = 0
|
||||
self.last_game_switch = 0 # Will be set to current_time when games are first loaded
|
||||
self.game_display_duration = self.mode_config.get("live_game_duration", 20)
|
||||
self.last_display_update = 0
|
||||
self.last_log_time = 0
|
||||
self.log_interval = 300
|
||||
self.last_count_log_time = 0 # Track when we last logged count data
|
||||
self.count_log_interval = 5 # Only log count data every 5 seconds
|
||||
# Initialize test_mode - defaults to False (live mode)
|
||||
self.test_mode = self.mode_config.get("test_mode", False)
|
||||
|
||||
@abstractmethod
|
||||
def _test_mode_update(self) -> None:
|
||||
@@ -1264,14 +1443,56 @@ class SportsLive(SportsCore):
|
||||
self.live_games = sorted(new_live_games, key=lambda g: g.get('start_time_utc') or datetime.now(timezone.utc)) # Sort by start time
|
||||
# Reset index if current game is gone or list is new
|
||||
if not self.current_game or self.current_game['id'] not in new_game_ids:
|
||||
# #region agent log
|
||||
import json
|
||||
try:
|
||||
with open('/home/chuck/Github/LEDMatrix/.cursor/debug.log', 'a') as f:
|
||||
f.write(json.dumps({
|
||||
"sessionId": "debug-session",
|
||||
"runId": "run1",
|
||||
"hypothesisId": "B",
|
||||
"location": "sports.py:1393",
|
||||
"message": "Games loaded - resetting index and last_game_switch",
|
||||
"data": {
|
||||
"current_game_before": self.current_game['id'] if self.current_game else None,
|
||||
"live_games_count": len(self.live_games),
|
||||
"last_game_switch_before": self.last_game_switch,
|
||||
"current_time": current_time,
|
||||
"time_since_init": current_time - self.last_game_switch if self.last_game_switch > 0 else None
|
||||
},
|
||||
"timestamp": int(time.time() * 1000)
|
||||
}) + "\n")
|
||||
except: pass
|
||||
# #endregion
|
||||
self.current_game_index = 0
|
||||
self.current_game = self.live_games[0] if self.live_games else None
|
||||
self.last_game_switch = current_time
|
||||
# #region agent log
|
||||
try:
|
||||
with open('/home/chuck/Github/LEDMatrix/.cursor/debug.log', 'a') as f:
|
||||
f.write(json.dumps({
|
||||
"sessionId": "debug-session",
|
||||
"runId": "run1",
|
||||
"hypothesisId": "B",
|
||||
"location": "sports.py:1396",
|
||||
"message": "Games loaded - after setting last_game_switch",
|
||||
"data": {
|
||||
"current_game_after": self.current_game['id'] if self.current_game else None,
|
||||
"last_game_switch_after": self.last_game_switch,
|
||||
"first_game": self.current_game['away_abbr'] + "@" + self.current_game['home_abbr'] if self.current_game else None
|
||||
},
|
||||
"timestamp": int(time.time() * 1000)
|
||||
}) + "\n")
|
||||
except: pass
|
||||
# #endregion
|
||||
else:
|
||||
# Find current game's new index if it still exists
|
||||
try:
|
||||
self.current_game_index = next(i for i, g in enumerate(self.live_games) if g['id'] == self.current_game['id'])
|
||||
self.current_game = self.live_games[self.current_game_index] # Update current_game with fresh data
|
||||
# Fix: Set last_game_switch if it's still 0 (initialized) to prevent immediate switching
|
||||
if self.last_game_switch == 0:
|
||||
self.last_game_switch = current_time
|
||||
except StopIteration: # Should not happen if check above passed, but safety first
|
||||
self.current_game_index = 0
|
||||
self.current_game = self.live_games[0]
|
||||
@@ -1283,6 +1504,10 @@ class SportsLive(SportsCore):
|
||||
self.live_games = [temp_game_dict.get(g['id'], g) for g in self.live_games] # Update in place
|
||||
if self.current_game:
|
||||
self.current_game = temp_game_dict.get(self.current_game['id'], self.current_game)
|
||||
# Fix: Set last_game_switch if it's still 0 (initialized) to prevent immediate switching
|
||||
# This handles the case where games were loaded previously but last_game_switch was never set
|
||||
if self.last_game_switch == 0:
|
||||
self.last_game_switch = current_time
|
||||
|
||||
# Display update handled by main loop based on interval
|
||||
|
||||
@@ -1303,9 +1528,72 @@ class SportsLive(SportsCore):
|
||||
self.current_game = None # Clear current game if fetch fails and no games were active
|
||||
|
||||
# Handle game switching (outside test mode check)
|
||||
if not self.test_mode and len(self.live_games) > 1 and (current_time - self.last_game_switch) >= self.game_display_duration:
|
||||
# Fix: Don't check for switching if last_game_switch is still 0 (games haven't been loaded yet)
|
||||
# This prevents immediate switching when the system has been running for a while before games load
|
||||
# #region agent log
|
||||
import json
|
||||
try:
|
||||
with open('/home/chuck/Github/LEDMatrix/.cursor/debug.log', 'a') as f:
|
||||
f.write(json.dumps({
|
||||
"sessionId": "debug-session",
|
||||
"runId": "run1",
|
||||
"hypothesisId": "A",
|
||||
"location": "sports.py:1432",
|
||||
"message": "Game switch check - before condition",
|
||||
"data": {
|
||||
"test_mode": self.test_mode,
|
||||
"live_games_count": len(self.live_games),
|
||||
"current_time": current_time,
|
||||
"last_game_switch": self.last_game_switch,
|
||||
"time_since_switch": current_time - self.last_game_switch,
|
||||
"game_display_duration": self.game_display_duration,
|
||||
"current_game_index": self.current_game_index,
|
||||
"will_switch": not self.test_mode and len(self.live_games) > 1 and self.last_game_switch > 0 and (current_time - self.last_game_switch) >= self.game_display_duration
|
||||
},
|
||||
"timestamp": int(time.time() * 1000)
|
||||
}) + "\n")
|
||||
except: pass
|
||||
# #endregion
|
||||
if not self.test_mode and len(self.live_games) > 1 and self.last_game_switch > 0 and (current_time - self.last_game_switch) >= self.game_display_duration:
|
||||
# #region agent log
|
||||
try:
|
||||
with open('/home/chuck/Github/LEDMatrix/.cursor/debug.log', 'a') as f:
|
||||
f.write(json.dumps({
|
||||
"sessionId": "debug-session",
|
||||
"runId": "run1",
|
||||
"hypothesisId": "A",
|
||||
"location": "sports.py:1433",
|
||||
"message": "Game switch triggered",
|
||||
"data": {
|
||||
"old_index": self.current_game_index,
|
||||
"old_game": self.current_game['away_abbr'] + "@" + self.current_game['home_abbr'] if self.current_game else None,
|
||||
"time_since_switch": current_time - self.last_game_switch,
|
||||
"last_game_switch_before": self.last_game_switch
|
||||
},
|
||||
"timestamp": int(time.time() * 1000)
|
||||
}) + "\n")
|
||||
except: pass
|
||||
# #endregion
|
||||
self.current_game_index = (self.current_game_index + 1) % len(self.live_games)
|
||||
self.current_game = self.live_games[self.current_game_index]
|
||||
self.last_game_switch = current_time
|
||||
# #region agent log
|
||||
try:
|
||||
with open('/home/chuck/Github/LEDMatrix/.cursor/debug.log', 'a') as f:
|
||||
f.write(json.dumps({
|
||||
"sessionId": "debug-session",
|
||||
"runId": "run1",
|
||||
"hypothesisId": "A",
|
||||
"location": "sports.py:1436",
|
||||
"message": "Game switch completed",
|
||||
"data": {
|
||||
"new_index": self.current_game_index,
|
||||
"new_game": self.current_game['away_abbr'] + "@" + self.current_game['home_abbr'] if self.current_game else None,
|
||||
"last_game_switch_after": self.last_game_switch
|
||||
},
|
||||
"timestamp": int(time.time() * 1000)
|
||||
}) + "\n")
|
||||
except: pass
|
||||
# #endregion
|
||||
self.logger.info(f"Switched live view to: {self.current_game['away_abbr']}@{self.current_game['home_abbr']}") # Changed log prefix
|
||||
# Force display update via flag or direct call if needed, but usually let main loop handle
|
||||
|
||||
300
src/base_odds_manager.py
Normal file
300
src/base_odds_manager.py
Normal file
@@ -0,0 +1,300 @@
|
||||
"""
|
||||
BaseOddsManager - Base class for odds data fetching and management.
|
||||
|
||||
This base class provides core odds fetching functionality that can be inherited
|
||||
by plugins that need odds data (odds ticker, scoreboards, etc.).
|
||||
|
||||
Follows LEDMatrix configuration management patterns:
|
||||
- Single responsibility: Data fetching only
|
||||
- Reusable: Other plugins can inherit from it
|
||||
- Clean configuration: Separate config sections
|
||||
- Maintainable: Changes to odds logic affect all plugins
|
||||
"""
|
||||
|
||||
import time
|
||||
import logging
|
||||
import requests
|
||||
import json
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Dict, Any, Optional, List
|
||||
import pytz
|
||||
|
||||
# Import the API counter function from web interface
|
||||
try:
|
||||
from web_interface_v2 import increment_api_counter
|
||||
except ImportError:
|
||||
# Fallback if web interface is not available
|
||||
def increment_api_counter(kind: str, count: int = 1):
|
||||
pass
|
||||
|
||||
|
||||
class BaseOddsManager:
|
||||
"""
|
||||
Base class for odds data fetching and management.
|
||||
|
||||
Provides core functionality for:
|
||||
- ESPN API odds fetching
|
||||
- Caching and data processing
|
||||
- Error handling and timeouts
|
||||
- League mapping and data extraction
|
||||
|
||||
Plugins can inherit from this class to get odds functionality.
|
||||
"""
|
||||
|
||||
def __init__(self, cache_manager, config_manager=None):
|
||||
"""
|
||||
Initialize the base odds manager.
|
||||
|
||||
Args:
|
||||
cache_manager: Cache manager instance for data persistence
|
||||
config_manager: Configuration manager (optional)
|
||||
"""
|
||||
self.cache_manager = cache_manager
|
||||
self.config_manager = config_manager
|
||||
self.logger = logging.getLogger(__name__)
|
||||
self.base_url = "https://sports.core.api.espn.com/v2/sports"
|
||||
|
||||
# Configuration with defaults
|
||||
self.update_interval = 3600 # 1 hour default
|
||||
self.request_timeout = 30 # 30 seconds default
|
||||
self.cache_ttl = 1800 # 30 minutes default
|
||||
|
||||
# Load configuration if available
|
||||
if config_manager:
|
||||
self._load_configuration()
|
||||
|
||||
def _load_configuration(self):
|
||||
"""Load configuration from config manager."""
|
||||
if not self.config_manager:
|
||||
return
|
||||
|
||||
try:
|
||||
config = self.config_manager.get_config()
|
||||
odds_config = config.get('base_odds_manager', {})
|
||||
|
||||
self.update_interval = odds_config.get('update_interval', self.update_interval)
|
||||
self.request_timeout = odds_config.get('timeout', self.request_timeout)
|
||||
self.cache_ttl = odds_config.get('cache_ttl', self.cache_ttl)
|
||||
|
||||
self.logger.debug(f"BaseOddsManager configuration loaded: "
|
||||
f"update_interval={self.update_interval}s, "
|
||||
f"timeout={self.request_timeout}s, "
|
||||
f"cache_ttl={self.cache_ttl}s")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Failed to load BaseOddsManager configuration: {e}")
|
||||
|
||||
def get_odds(self, sport: str | None, league: str | None, event_id: str,
|
||||
update_interval_seconds: int = None) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Fetch odds data for a specific game.
|
||||
|
||||
Args:
|
||||
sport: Sport name (e.g., 'football', 'basketball')
|
||||
league: League name (e.g., 'nfl', 'nba')
|
||||
event_id: ESPN event ID
|
||||
update_interval_seconds: Override default update interval
|
||||
|
||||
Returns:
|
||||
Dictionary containing odds data or None if unavailable
|
||||
"""
|
||||
if sport is None or league is None:
|
||||
raise ValueError("Sport and League cannot be None")
|
||||
|
||||
# Use provided interval or default
|
||||
interval = update_interval_seconds or self.update_interval
|
||||
cache_key = f"odds_espn_{sport}_{league}_{event_id}"
|
||||
|
||||
# Check cache first
|
||||
cached_data = self.cache_manager.get_with_auto_strategy(cache_key)
|
||||
|
||||
if cached_data:
|
||||
self.logger.info(f"Using cached odds from ESPN for {cache_key}")
|
||||
return cached_data
|
||||
|
||||
self.logger.info(f"Cache miss - fetching fresh odds from ESPN for {cache_key}")
|
||||
|
||||
try:
|
||||
# Map league names to ESPN API format
|
||||
league_mapping = {
|
||||
'ncaa_fb': 'college-football',
|
||||
'nfl': 'nfl',
|
||||
'nba': 'nba',
|
||||
'mlb': 'mlb',
|
||||
'nhl': 'nhl'
|
||||
}
|
||||
|
||||
espn_league = league_mapping.get(league, league)
|
||||
url = f"{self.base_url}/{sport}/leagues/{espn_league}/events/{event_id}/competitions/{event_id}/odds"
|
||||
self.logger.info(f"Requesting odds from URL: {url}")
|
||||
|
||||
response = requests.get(url, timeout=self.request_timeout)
|
||||
response.raise_for_status()
|
||||
raw_data = response.json()
|
||||
|
||||
# Increment API counter for odds data
|
||||
increment_api_counter('odds', 1)
|
||||
self.logger.debug(f"Received raw odds data from ESPN: {json.dumps(raw_data, indent=2)}")
|
||||
|
||||
odds_data = self._extract_espn_data(raw_data)
|
||||
if odds_data:
|
||||
self.logger.info(f"Successfully extracted odds data: {odds_data}")
|
||||
else:
|
||||
self.logger.debug("No odds data available for this game")
|
||||
|
||||
if odds_data:
|
||||
self.cache_manager.set(cache_key, odds_data)
|
||||
self.logger.info(f"Saved odds data to cache for {cache_key}")
|
||||
else:
|
||||
self.logger.debug(f"No odds data available for {cache_key}")
|
||||
# Cache the fact that no odds are available to avoid repeated API calls
|
||||
self.cache_manager.set(cache_key, {"no_odds": True})
|
||||
|
||||
return odds_data
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
self.logger.error(f"Error fetching odds from ESPN API for {cache_key}: {e}")
|
||||
except json.JSONDecodeError:
|
||||
self.logger.error(f"Error decoding JSON response from ESPN API for {cache_key}.")
|
||||
|
||||
return self.cache_manager.get_with_auto_strategy(cache_key)
|
||||
|
||||
def _extract_espn_data(self, data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Extract and format odds data from ESPN API response.
|
||||
|
||||
Args:
|
||||
data: Raw ESPN API response data
|
||||
|
||||
Returns:
|
||||
Formatted odds data dictionary or None
|
||||
"""
|
||||
self.logger.debug(f"Extracting ESPN odds data. Data keys: {list(data.keys())}")
|
||||
|
||||
if "items" in data and data["items"]:
|
||||
self.logger.debug(f"Found {len(data['items'])} items in odds data")
|
||||
item = data["items"][0]
|
||||
self.logger.debug(f"First item keys: {list(item.keys())}")
|
||||
|
||||
# The ESPN API returns odds data directly in the item, not in a providers array
|
||||
# Extract the odds data directly from the item
|
||||
extracted_data = {
|
||||
"details": item.get("details"),
|
||||
"over_under": item.get("overUnder"),
|
||||
"spread": item.get("spread"),
|
||||
"home_team_odds": {
|
||||
"money_line": item.get("homeTeamOdds", {}).get("moneyLine"),
|
||||
"spread_odds": item.get("homeTeamOdds", {}).get("current", {}).get("pointSpread", {}).get("value")
|
||||
},
|
||||
"away_team_odds": {
|
||||
"money_line": item.get("awayTeamOdds", {}).get("moneyLine"),
|
||||
"spread_odds": item.get("awayTeamOdds", {}).get("current", {}).get("pointSpread", {}).get("value")
|
||||
}
|
||||
}
|
||||
self.logger.debug(f"Returning extracted odds data: {json.dumps(extracted_data, indent=2)}")
|
||||
return extracted_data
|
||||
|
||||
# Check if this is a valid empty response or an unexpected structure
|
||||
if "count" in data and data["count"] == 0 and "items" in data and data["items"] == []:
|
||||
# This is a valid empty response - no odds available for this game
|
||||
self.logger.debug(f"No odds available for this game. Response: {json.dumps(data, indent=2)}")
|
||||
return None
|
||||
else:
|
||||
# This is an unexpected response structure
|
||||
self.logger.warning("No 'items' found in ESPN odds data.")
|
||||
self.logger.warning(f"Unexpected response structure: {json.dumps(data, indent=2)}")
|
||||
return None
|
||||
|
||||
def get_odds_for_games(self, games: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Fetch odds for multiple games efficiently.
|
||||
|
||||
Args:
|
||||
games: List of game dictionaries with sport, league, and id
|
||||
|
||||
Returns:
|
||||
List of games with odds data added
|
||||
"""
|
||||
games_with_odds = []
|
||||
|
||||
for game in games:
|
||||
try:
|
||||
sport = game.get('sport')
|
||||
league = game.get('league')
|
||||
event_id = game.get('id')
|
||||
|
||||
if sport and league and event_id:
|
||||
odds_data = self.get_odds(sport, league, event_id)
|
||||
game['odds'] = odds_data
|
||||
else:
|
||||
game['odds'] = None
|
||||
|
||||
games_with_odds.append(game)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error fetching odds for game {game.get('id', 'unknown')}: {e}")
|
||||
game['odds'] = None
|
||||
games_with_odds.append(game)
|
||||
|
||||
return games_with_odds
|
||||
|
||||
def is_odds_available(self, odds_data: Optional[Dict[str, Any]]) -> bool:
|
||||
"""
|
||||
Check if odds data contains valid odds information.
|
||||
|
||||
Args:
|
||||
odds_data: Odds data dictionary
|
||||
|
||||
Returns:
|
||||
True if valid odds are available, False otherwise
|
||||
"""
|
||||
if not odds_data or odds_data.get('no_odds'):
|
||||
return False
|
||||
|
||||
# Check for any valid odds data
|
||||
if odds_data.get('spread') is not None:
|
||||
return True
|
||||
if odds_data.get('home_team_odds', {}).get('spread_odds') is not None:
|
||||
return True
|
||||
if odds_data.get('away_team_odds', {}).get('spread_odds') is not None:
|
||||
return True
|
||||
if odds_data.get('over_under') is not None:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def format_odds_summary(self, odds_data: Optional[Dict[str, Any]]) -> str:
|
||||
"""
|
||||
Format odds data into a human-readable summary.
|
||||
|
||||
Args:
|
||||
odds_data: Odds data dictionary
|
||||
|
||||
Returns:
|
||||
Formatted odds summary string
|
||||
"""
|
||||
if not self.is_odds_available(odds_data):
|
||||
return "No odds available"
|
||||
|
||||
parts = []
|
||||
|
||||
# Add spread information
|
||||
spread = odds_data.get('spread')
|
||||
if spread is not None:
|
||||
parts.append(f"Spread: {spread}")
|
||||
|
||||
# Add over/under
|
||||
over_under = odds_data.get('over_under')
|
||||
if over_under is not None:
|
||||
parts.append(f"O/U: {over_under}")
|
||||
|
||||
# Add money lines
|
||||
home_ml = odds_data.get('home_team_odds', {}).get('money_line')
|
||||
away_ml = odds_data.get('away_team_odds', {}).get('money_line')
|
||||
|
||||
if home_ml is not None:
|
||||
parts.append(f"Home ML: {home_ml}")
|
||||
if away_ml is not None:
|
||||
parts.append(f"Away ML: {away_ml}")
|
||||
|
||||
return " | ".join(parts) if parts else "No odds available"
|
||||
10
src/cache/__init__.py
vendored
Normal file
10
src/cache/__init__.py
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
"""
|
||||
Cache module for LEDMatrix.
|
||||
|
||||
Provides specialized cache components:
|
||||
- MemoryCache: In-memory caching
|
||||
- DiskCache: Persistent disk caching
|
||||
- CacheStrategy: Cache strategy management
|
||||
- CacheMetrics: Performance metrics tracking
|
||||
"""
|
||||
|
||||
108
src/cache/cache_metrics.py
vendored
Normal file
108
src/cache/cache_metrics.py
vendored
Normal file
@@ -0,0 +1,108 @@
|
||||
"""
|
||||
Cache Metrics
|
||||
|
||||
Tracks cache performance metrics including hit rates, miss rates, and fetch times.
|
||||
"""
|
||||
|
||||
import threading
|
||||
import logging
|
||||
from typing import Dict, Any, Optional
|
||||
|
||||
|
||||
class CacheMetrics:
|
||||
"""Tracks cache performance metrics."""
|
||||
|
||||
def __init__(self, logger: Optional[logging.Logger] = None) -> None:
|
||||
"""
|
||||
Initialize cache metrics tracker.
|
||||
|
||||
Args:
|
||||
logger: Optional logger instance
|
||||
"""
|
||||
self.logger = logger or logging.getLogger(__name__)
|
||||
self._lock = threading.Lock()
|
||||
self._metrics: Dict[str, Any] = {
|
||||
'hits': 0,
|
||||
'misses': 0,
|
||||
'api_calls_saved': 0,
|
||||
'background_hits': 0,
|
||||
'background_misses': 0,
|
||||
'total_fetch_time': 0.0,
|
||||
'fetch_count': 0
|
||||
}
|
||||
|
||||
def record_hit(self, cache_type: str = 'regular') -> None:
|
||||
"""
|
||||
Record a cache hit.
|
||||
|
||||
Args:
|
||||
cache_type: Type of cache hit ('regular' or 'background')
|
||||
"""
|
||||
with self._lock:
|
||||
if cache_type == 'background':
|
||||
self._metrics['background_hits'] += 1
|
||||
else:
|
||||
self._metrics['hits'] += 1
|
||||
|
||||
def record_miss(self, cache_type: str = 'regular') -> None:
|
||||
"""
|
||||
Record a cache miss.
|
||||
|
||||
Args:
|
||||
cache_type: Type of cache miss ('regular' or 'background')
|
||||
"""
|
||||
with self._lock:
|
||||
if cache_type == 'background':
|
||||
self._metrics['background_misses'] += 1
|
||||
else:
|
||||
self._metrics['misses'] += 1
|
||||
self._metrics['api_calls_saved'] += 1
|
||||
|
||||
def record_fetch_time(self, duration: float) -> None:
|
||||
"""
|
||||
Record fetch operation duration.
|
||||
|
||||
Args:
|
||||
duration: Duration in seconds
|
||||
"""
|
||||
with self._lock:
|
||||
self._metrics['total_fetch_time'] += duration
|
||||
self._metrics['fetch_count'] += 1
|
||||
|
||||
def get_metrics(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Get current cache performance metrics.
|
||||
|
||||
Returns:
|
||||
Dictionary with cache metrics
|
||||
"""
|
||||
with self._lock:
|
||||
total_hits = self._metrics['hits'] + self._metrics['background_hits']
|
||||
total_misses = self._metrics['misses'] + self._metrics['background_misses']
|
||||
total_requests = total_hits + total_misses
|
||||
|
||||
avg_fetch_time = (self._metrics['total_fetch_time'] /
|
||||
self._metrics['fetch_count']) if self._metrics['fetch_count'] > 0 else 0.0
|
||||
|
||||
return {
|
||||
'total_requests': total_requests,
|
||||
'cache_hit_rate': total_hits / total_requests if total_requests > 0 else 0.0,
|
||||
'background_hit_rate': (self._metrics['background_hits'] /
|
||||
(self._metrics['background_hits'] + self._metrics['background_misses'])
|
||||
if (self._metrics['background_hits'] + self._metrics['background_misses']) > 0 else 0.0),
|
||||
'api_calls_saved': self._metrics['api_calls_saved'],
|
||||
'average_fetch_time': avg_fetch_time,
|
||||
'total_fetch_time': self._metrics['total_fetch_time'],
|
||||
'fetch_count': self._metrics['fetch_count']
|
||||
}
|
||||
|
||||
def log_metrics(self) -> None:
|
||||
"""Log current cache performance metrics."""
|
||||
metrics = self.get_metrics()
|
||||
self.logger.info("Cache Performance - Hit Rate: %.2f%%, Background Hit Rate: %.2f%%, "
|
||||
"API Calls Saved: %d, Avg Fetch Time: %.2fs",
|
||||
metrics['cache_hit_rate'] * 100,
|
||||
metrics['background_hit_rate'] * 100,
|
||||
metrics['api_calls_saved'],
|
||||
metrics['average_fetch_time'])
|
||||
|
||||
294
src/cache/cache_strategy.py
vendored
Normal file
294
src/cache/cache_strategy.py
vendored
Normal file
@@ -0,0 +1,294 @@
|
||||
"""
|
||||
Cache Strategy
|
||||
|
||||
Manages cache strategies for different data types with sport-specific configurations.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Dict, Any, Optional
|
||||
from datetime import datetime
|
||||
import pytz
|
||||
|
||||
|
||||
class CacheStrategy:
|
||||
"""Manages cache strategies for different data types."""
|
||||
|
||||
def __init__(self, config_manager: Optional[Any] = None, logger: Optional[logging.Logger] = None) -> None:
|
||||
"""
|
||||
Initialize cache strategy manager.
|
||||
|
||||
Args:
|
||||
config_manager: Optional ConfigManager instance for sport-specific configs
|
||||
logger: Optional logger instance
|
||||
"""
|
||||
self.config_manager = config_manager
|
||||
self.logger = logger or logging.getLogger(__name__)
|
||||
|
||||
def get_sport_live_interval(self, sport_key: str) -> int:
|
||||
"""
|
||||
Get the live_update_interval for a specific sport from config.
|
||||
Falls back to default values if config is not available.
|
||||
|
||||
Args:
|
||||
sport_key: Sport identifier (e.g., 'nba', 'nfl')
|
||||
|
||||
Returns:
|
||||
Live update interval in seconds
|
||||
"""
|
||||
if not self.config_manager:
|
||||
# Default intervals - all sports use 60 seconds as default
|
||||
default_intervals = {
|
||||
'soccer': 60,
|
||||
'nfl': 60,
|
||||
'nhl': 60,
|
||||
'nba': 60,
|
||||
'mlb': 60,
|
||||
'milb': 60,
|
||||
'ncaa_fb': 60,
|
||||
'ncaa_baseball': 60,
|
||||
'ncaam_basketball': 60,
|
||||
}
|
||||
return default_intervals.get(sport_key, 60)
|
||||
|
||||
try:
|
||||
config = self.config_manager.config
|
||||
# All sports now use _scoreboard suffix
|
||||
sport_config = config.get(f"{sport_key}_scoreboard", {})
|
||||
return sport_config.get("live_update_interval", 60) # Default to 60 seconds
|
||||
except (KeyError, AttributeError, TypeError) as e:
|
||||
self.logger.warning("Could not get live_update_interval for %s: %s", sport_key, e, exc_info=True)
|
||||
return 60 # Default to 60 seconds
|
||||
|
||||
def get_cache_strategy(self, data_type: str, sport_key: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Get cache strategy for different data types.
|
||||
Now respects sport-specific live_update_interval configurations.
|
||||
|
||||
Args:
|
||||
data_type: Type of data (e.g., 'live_scores', 'stocks', 'weather_current')
|
||||
sport_key: Optional sport key for sport-specific intervals
|
||||
|
||||
Returns:
|
||||
Dictionary with cache strategy (max_age, memory_ttl, etc.)
|
||||
"""
|
||||
# Get sport-specific live interval if provided
|
||||
live_interval = None
|
||||
if sport_key and data_type in ['sports_live', 'live_scores']:
|
||||
live_interval = self.get_sport_live_interval(sport_key)
|
||||
|
||||
# Try to read sport-specific config for recent/upcoming
|
||||
recent_interval = None
|
||||
upcoming_interval = None
|
||||
if self.config_manager and sport_key:
|
||||
try:
|
||||
# All sports now use _scoreboard suffix
|
||||
sport_cfg = self.config_manager.config.get(f"{sport_key}_scoreboard", {})
|
||||
recent_interval = sport_cfg.get('recent_update_interval')
|
||||
upcoming_interval = sport_cfg.get('upcoming_update_interval')
|
||||
except (KeyError, AttributeError, TypeError) as e:
|
||||
self.logger.debug("Could not read sport-specific recent/upcoming intervals for %s: %s",
|
||||
sport_key, e, exc_info=True)
|
||||
|
||||
strategies = {
|
||||
# Ultra time-sensitive data (live scores, current weather)
|
||||
'live_scores': {
|
||||
'max_age': live_interval or 15, # Use sport-specific interval
|
||||
'memory_ttl': (live_interval or 15) * 2, # 2x for memory cache
|
||||
'force_refresh': True
|
||||
},
|
||||
'sports_live': {
|
||||
'max_age': live_interval or 30, # Use sport-specific interval
|
||||
'memory_ttl': (live_interval or 30) * 2,
|
||||
'force_refresh': True
|
||||
},
|
||||
'weather_current': {
|
||||
'max_age': 300, # 5 minutes
|
||||
'memory_ttl': 600,
|
||||
'force_refresh': False
|
||||
},
|
||||
|
||||
# Market data (stocks, crypto)
|
||||
'stocks': {
|
||||
'max_age': 600, # 10 minutes
|
||||
'memory_ttl': 1200,
|
||||
'market_hours_only': True,
|
||||
'force_refresh': False
|
||||
},
|
||||
'crypto': {
|
||||
'max_age': 300, # 5 minutes (crypto trades 24/7)
|
||||
'memory_ttl': 600,
|
||||
'force_refresh': False
|
||||
},
|
||||
|
||||
# Sports data
|
||||
'sports_recent': {
|
||||
'max_age': recent_interval or 1800, # 30 minutes default; override by config
|
||||
'memory_ttl': (recent_interval or 1800) * 2,
|
||||
'force_refresh': False
|
||||
},
|
||||
'sports_upcoming': {
|
||||
'max_age': upcoming_interval or 10800, # 3 hours default; override by config
|
||||
'memory_ttl': (upcoming_interval or 10800) * 2,
|
||||
'force_refresh': False
|
||||
},
|
||||
'sports_schedules': {
|
||||
'max_age': 86400, # 24 hours
|
||||
'memory_ttl': 172800,
|
||||
'force_refresh': False
|
||||
},
|
||||
'leaderboard': {
|
||||
'max_age': 604800, # 7 days (1 week) - football rankings updated weekly
|
||||
'memory_ttl': 1209600, # 14 days in memory
|
||||
'force_refresh': False
|
||||
},
|
||||
|
||||
# News and odds
|
||||
'news': {
|
||||
'max_age': 3600, # 1 hour
|
||||
'memory_ttl': 7200,
|
||||
'force_refresh': False
|
||||
},
|
||||
'odds': {
|
||||
'max_age': 1800, # 30 minutes for upcoming games
|
||||
'memory_ttl': 3600,
|
||||
'force_refresh': False
|
||||
},
|
||||
'odds_live': {
|
||||
'max_age': 120, # 2 minutes for live games (odds change rapidly)
|
||||
'memory_ttl': 240,
|
||||
'force_refresh': False
|
||||
},
|
||||
|
||||
# Static/stable data
|
||||
'team_info': {
|
||||
'max_age': 604800, # 1 week
|
||||
'memory_ttl': 1209600,
|
||||
'force_refresh': False
|
||||
},
|
||||
'logos': {
|
||||
'max_age': 2592000, # 30 days
|
||||
'memory_ttl': 5184000,
|
||||
'force_refresh': False
|
||||
},
|
||||
|
||||
# Default fallback
|
||||
'default': {
|
||||
'max_age': 300, # 5 minutes
|
||||
'memory_ttl': 600,
|
||||
'force_refresh': False
|
||||
}
|
||||
}
|
||||
|
||||
return strategies.get(data_type, strategies['default'])
|
||||
|
||||
def get_data_type_from_key(self, key: str) -> str:
|
||||
"""
|
||||
Determine the appropriate cache strategy based on the cache key.
|
||||
This helps automatically select the right cache duration.
|
||||
|
||||
Args:
|
||||
key: Cache key
|
||||
|
||||
Returns:
|
||||
Data type string for strategy lookup
|
||||
"""
|
||||
key_lower = key.lower()
|
||||
|
||||
# Live sports data
|
||||
if any(x in key_lower for x in ['live', 'current', 'scoreboard']):
|
||||
if 'soccer' in key_lower:
|
||||
return 'sports_live' # Soccer live data is very time-sensitive
|
||||
return 'sports_live'
|
||||
|
||||
# Weather data
|
||||
if 'weather' in key_lower:
|
||||
return 'weather_current'
|
||||
|
||||
# Market data
|
||||
if 'stock' in key_lower or 'crypto' in key_lower:
|
||||
if 'crypto' in key_lower:
|
||||
return 'crypto'
|
||||
return 'stocks'
|
||||
|
||||
# News data
|
||||
if 'news' in key_lower:
|
||||
return 'news'
|
||||
|
||||
# Odds data - differentiate between live and upcoming games
|
||||
if 'odds' in key_lower:
|
||||
# For live games, use shorter cache; for upcoming games, use longer cache
|
||||
if any(x in key_lower for x in ['live', 'current']):
|
||||
return 'odds_live' # Live odds change more frequently
|
||||
return 'odds' # Regular odds for upcoming games
|
||||
|
||||
# Sports schedules and team info
|
||||
if any(x in key_lower for x in ['schedule', 'team_map', 'league']):
|
||||
return 'sports_schedules'
|
||||
|
||||
# Recent games (last few hours)
|
||||
if 'recent' in key_lower:
|
||||
return 'sports_recent'
|
||||
|
||||
# Upcoming games
|
||||
if 'upcoming' in key_lower:
|
||||
return 'sports_upcoming'
|
||||
|
||||
# Static data like logos, team info
|
||||
if any(x in key_lower for x in ['logo', 'team_info', 'config']):
|
||||
return 'team_info'
|
||||
|
||||
# Default fallback
|
||||
return 'default'
|
||||
|
||||
def get_sport_key_from_cache_key(self, key: str) -> Optional[str]:
|
||||
"""
|
||||
Extract sport key from cache key to determine appropriate live_update_interval.
|
||||
|
||||
Args:
|
||||
key: Cache key
|
||||
|
||||
Returns:
|
||||
Sport key or None if not found
|
||||
"""
|
||||
key_lower = key.lower()
|
||||
|
||||
# Map cache key patterns to sport keys
|
||||
sport_patterns = {
|
||||
'nfl': ['nfl'],
|
||||
'nba': ['nba', 'basketball'],
|
||||
'mlb': ['mlb', 'baseball'],
|
||||
'nhl': ['nhl', 'hockey'],
|
||||
'soccer': ['soccer'],
|
||||
'ncaa_fb': ['ncaa_fb', 'ncaafb', 'college_football'],
|
||||
'ncaa_baseball': ['ncaa_baseball', 'college_baseball'],
|
||||
'ncaam_basketball': ['ncaam_basketball', 'college_basketball'],
|
||||
'milb': ['milb', 'minor_league'],
|
||||
}
|
||||
|
||||
for sport_key, patterns in sport_patterns.items():
|
||||
if any(pattern in key_lower for pattern in patterns):
|
||||
return sport_key
|
||||
|
||||
return None
|
||||
|
||||
def is_market_open(self) -> bool:
|
||||
"""
|
||||
Check if the US stock market is currently open.
|
||||
|
||||
Returns:
|
||||
True if market is open, False otherwise
|
||||
"""
|
||||
et_tz = pytz.timezone('America/New_York')
|
||||
now = datetime.now(et_tz)
|
||||
|
||||
# Check if it's a weekday
|
||||
if now.weekday() >= 5: # 5 = Saturday, 6 = Sunday
|
||||
return False
|
||||
|
||||
# Convert current time to ET
|
||||
current_time = now.time()
|
||||
market_open = datetime.strptime('09:30', '%H:%M').time()
|
||||
market_close = datetime.strptime('16:00', '%H:%M').time()
|
||||
|
||||
return market_open <= current_time <= market_close
|
||||
|
||||
272
src/cache/disk_cache.py
vendored
Normal file
272
src/cache/disk_cache.py
vendored
Normal file
@@ -0,0 +1,272 @@
|
||||
"""
|
||||
Disk Cache
|
||||
|
||||
Handles persistent disk-based caching with atomic writes and error recovery.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import tempfile
|
||||
import logging
|
||||
import threading
|
||||
from typing import Dict, Any, Optional
|
||||
from datetime import datetime
|
||||
|
||||
from src.exceptions import CacheError
|
||||
|
||||
|
||||
class DateTimeEncoder(json.JSONEncoder):
|
||||
"""JSON encoder that handles datetime objects."""
|
||||
def default(self, obj: Any) -> Any:
|
||||
if isinstance(obj, datetime):
|
||||
return obj.isoformat()
|
||||
return super().default(obj)
|
||||
|
||||
|
||||
class DiskCache:
|
||||
"""Manages persistent disk-based cache."""
|
||||
|
||||
def __init__(self, cache_dir: Optional[str], logger: Optional[logging.Logger] = None) -> None:
|
||||
"""
|
||||
Initialize disk cache.
|
||||
|
||||
Args:
|
||||
cache_dir: Directory for cache files (None = disabled)
|
||||
logger: Optional logger instance
|
||||
"""
|
||||
self.cache_dir = cache_dir
|
||||
self.logger = logger or logging.getLogger(__name__)
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def get_cache_path(self, key: str) -> Optional[str]:
|
||||
"""
|
||||
Get the path for a cache file.
|
||||
|
||||
Args:
|
||||
key: Cache key
|
||||
|
||||
Returns:
|
||||
Path to cache file or None if cache is disabled
|
||||
"""
|
||||
if not self.cache_dir:
|
||||
return None
|
||||
return os.path.join(self.cache_dir, f"{key}.json")
|
||||
|
||||
def get(self, key: str, max_age: int = 300) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get data from disk cache.
|
||||
|
||||
Args:
|
||||
key: Cache key
|
||||
max_age: Maximum age in seconds
|
||||
|
||||
Returns:
|
||||
Cached data or None if not found or expired
|
||||
"""
|
||||
cache_path = self.get_cache_path(key)
|
||||
if not cache_path or not os.path.exists(cache_path):
|
||||
return None
|
||||
|
||||
try:
|
||||
with self._lock:
|
||||
with open(cache_path, 'r', encoding='utf-8') as f:
|
||||
record = json.load(f)
|
||||
|
||||
# Determine record timestamp (prefer embedded, else file mtime)
|
||||
record_ts = None
|
||||
if isinstance(record, dict):
|
||||
record_ts = record.get('timestamp')
|
||||
if record_ts is None:
|
||||
try:
|
||||
record_ts = os.path.getmtime(cache_path)
|
||||
except OSError:
|
||||
record_ts = None
|
||||
|
||||
if record_ts is not None:
|
||||
try:
|
||||
record_ts = float(record_ts)
|
||||
except (TypeError, ValueError):
|
||||
record_ts = None
|
||||
|
||||
now = time.time()
|
||||
if record_ts is None or (now - record_ts) <= max_age:
|
||||
return record
|
||||
else:
|
||||
# Stale on disk; keep file for potential diagnostics but treat as miss
|
||||
return None
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
self.logger.error("Error parsing cache file for %s at %s: %s", key, cache_path, e, exc_info=True)
|
||||
# If the file is corrupted, remove it
|
||||
try:
|
||||
os.remove(cache_path)
|
||||
self.logger.info("Removed corrupted cache file: %s", cache_path)
|
||||
except OSError as remove_error:
|
||||
self.logger.warning("Could not remove corrupted cache file %s: %s", cache_path, remove_error)
|
||||
return None
|
||||
except PermissionError as e:
|
||||
# Permission errors are recoverable - cache just won't be available
|
||||
self.logger.warning("Permission denied loading cache for %s from %s: %s. Cache unavailable for this key.", key, cache_path, e)
|
||||
return None
|
||||
except (IOError, OSError) as e:
|
||||
self.logger.error("Error loading cache for %s from %s: %s", key, cache_path, e, exc_info=True)
|
||||
return None
|
||||
except Exception as e:
|
||||
self.logger.error("Unexpected error loading cache for %s from %s: %s", key, cache_path, e, exc_info=True)
|
||||
return None
|
||||
|
||||
def set(self, key: str, data: Dict[str, Any]) -> None:
|
||||
"""
|
||||
Save data to disk cache with atomic write.
|
||||
|
||||
This method gracefully handles permission errors. If the cache directory
|
||||
is not writable, it will log a warning and return silently rather than
|
||||
raising an exception. This allows the application to continue functioning
|
||||
even when running as a non-root user without write access to system cache
|
||||
directories.
|
||||
|
||||
Args:
|
||||
key: Cache key
|
||||
data: Data to cache
|
||||
"""
|
||||
cache_path = self.get_cache_path(key)
|
||||
if not cache_path:
|
||||
return
|
||||
|
||||
try:
|
||||
# Atomic write to avoid partial/corrupt files
|
||||
with self._lock:
|
||||
tmp_dir = os.path.dirname(cache_path)
|
||||
# Try to create temp file in cache directory first
|
||||
# If that fails due to permissions, fall back to direct write
|
||||
tmp_path = None
|
||||
fd = None
|
||||
try:
|
||||
# First try the cache directory
|
||||
if os.access(tmp_dir, os.W_OK):
|
||||
try:
|
||||
fd, tmp_path = tempfile.mkstemp(prefix=f".{os.path.basename(cache_path)}.", dir=tmp_dir)
|
||||
except (IOError, OSError, PermissionError):
|
||||
# If temp file creation fails, try direct write as fallback
|
||||
self.logger.warning("Could not create temp file in %s, using direct write for %s", tmp_dir, key)
|
||||
tmp_path = None
|
||||
fd = None
|
||||
else:
|
||||
# Directory not writable, use direct write
|
||||
self.logger.warning("Cache directory %s not writable, using direct write for %s", tmp_dir, key)
|
||||
tmp_path = None
|
||||
fd = None
|
||||
|
||||
if tmp_path and fd is not None:
|
||||
# Use atomic write with temp file
|
||||
try:
|
||||
with os.fdopen(fd, 'w', encoding='utf-8') as tmp_file:
|
||||
json.dump(data, tmp_file, indent=4, cls=DateTimeEncoder)
|
||||
tmp_file.flush()
|
||||
os.fsync(tmp_file.fileno())
|
||||
os.replace(tmp_path, cache_path)
|
||||
# Set proper permissions: 660 (rw-rw----) for group-readable cache files
|
||||
try:
|
||||
os.chmod(cache_path, 0o660)
|
||||
except OSError:
|
||||
pass # Non-critical if chmod fails
|
||||
finally:
|
||||
if os.path.exists(tmp_path):
|
||||
try:
|
||||
os.remove(tmp_path)
|
||||
except OSError:
|
||||
pass
|
||||
else:
|
||||
# Fallback: direct write (not atomic, but better than failing)
|
||||
try:
|
||||
with open(cache_path, 'w', encoding='utf-8') as cache_file:
|
||||
json.dump(data, cache_file, indent=4, cls=DateTimeEncoder)
|
||||
cache_file.flush()
|
||||
os.fsync(cache_file.fileno())
|
||||
# Set proper permissions: 660 (rw-rw----) for group-readable cache files
|
||||
try:
|
||||
os.chmod(cache_path, 0o660)
|
||||
except OSError:
|
||||
pass # Non-critical if chmod fails
|
||||
self.logger.debug("Wrote cache for %s directly (non-atomic)", key)
|
||||
except (IOError, OSError, PermissionError) as write_error:
|
||||
# If direct write also fails, try fallback location
|
||||
self.logger.warning("Direct write failed for key '%s' to %s: %s", key, cache_path, write_error)
|
||||
raise # Re-raise to trigger fallback logic
|
||||
except (IOError, OSError, PermissionError) as e:
|
||||
# Attempt one-time fallback write to user's home cache directory
|
||||
try:
|
||||
# Try user's home cache directory as fallback
|
||||
home_dir = os.path.expanduser('~')
|
||||
fallback_dir = os.path.join(home_dir, '.ledmatrix_cache')
|
||||
# Ensure fallback directory exists
|
||||
try:
|
||||
os.makedirs(fallback_dir, exist_ok=True)
|
||||
except (OSError, PermissionError):
|
||||
pass
|
||||
|
||||
if os.path.isdir(fallback_dir) and os.access(fallback_dir, os.W_OK):
|
||||
fallback_path = os.path.join(fallback_dir, os.path.basename(cache_path))
|
||||
with open(fallback_path, 'w', encoding='utf-8') as tmp_file:
|
||||
json.dump(data, tmp_file, indent=4, cls=DateTimeEncoder)
|
||||
# Set proper permissions: 660 (rw-rw----) for group-readable cache files
|
||||
try:
|
||||
os.chmod(fallback_path, 0o660)
|
||||
except OSError:
|
||||
pass # Non-critical if chmod fails
|
||||
self.logger.debug("Cache wrote to fallback location: %s", fallback_path)
|
||||
return # Successfully wrote to fallback, exit gracefully
|
||||
except (IOError, OSError, PermissionError) as e2:
|
||||
self.logger.debug("Fallback cache write also failed for key '%s': %s", key, e2)
|
||||
|
||||
# If all write attempts failed, log warning but don't raise exception
|
||||
# Cache is a performance optimization, not critical for operation
|
||||
self.logger.warning(
|
||||
"Could not write cache for key '%s' to %s (permission denied). "
|
||||
"Cache will be unavailable for this key, but application will continue.",
|
||||
key, cache_path
|
||||
)
|
||||
return # Exit gracefully without raising exception
|
||||
|
||||
except Exception as e:
|
||||
# For any other unexpected errors, log but don't crash
|
||||
self.logger.warning(
|
||||
"Unexpected error saving cache for key '%s' to %s: %s. "
|
||||
"Application will continue without caching for this key.",
|
||||
key, cache_path, e, exc_info=True
|
||||
)
|
||||
return # Exit gracefully without raising exception
|
||||
|
||||
def clear(self, key: Optional[str] = None) -> None:
|
||||
"""
|
||||
Clear cache entry or all entries.
|
||||
|
||||
Args:
|
||||
key: Specific key to clear, or None to clear all
|
||||
"""
|
||||
if not self.cache_dir:
|
||||
return
|
||||
|
||||
with self._lock:
|
||||
if key:
|
||||
cache_path = self.get_cache_path(key)
|
||||
if cache_path and os.path.exists(cache_path):
|
||||
try:
|
||||
os.remove(cache_path)
|
||||
except OSError as e:
|
||||
self.logger.warning("Could not remove cache file %s: %s", cache_path, e)
|
||||
else:
|
||||
# Clear all cache files
|
||||
if os.path.exists(self.cache_dir):
|
||||
for filename in os.listdir(self.cache_dir):
|
||||
if filename.endswith('.json'):
|
||||
try:
|
||||
os.remove(os.path.join(self.cache_dir, filename))
|
||||
except OSError as e:
|
||||
self.logger.warning("Could not remove cache file %s: %s", filename, e)
|
||||
|
||||
def get_cache_dir(self) -> Optional[str]:
|
||||
"""Get the cache directory path."""
|
||||
return self.cache_dir
|
||||
|
||||
185
src/cache/memory_cache.py
vendored
Normal file
185
src/cache/memory_cache.py
vendored
Normal file
@@ -0,0 +1,185 @@
|
||||
"""
|
||||
Memory Cache
|
||||
|
||||
Handles in-memory caching with TTL support, size limits, and automatic cleanup.
|
||||
"""
|
||||
|
||||
import time
|
||||
import threading
|
||||
import logging
|
||||
from typing import Dict, Any, Optional
|
||||
|
||||
|
||||
class MemoryCache:
|
||||
"""Manages in-memory cache with TTL and size limits."""
|
||||
|
||||
def __init__(self, max_size: int = 1000, cleanup_interval: float = 300.0) -> None:
|
||||
"""
|
||||
Initialize memory cache.
|
||||
|
||||
Args:
|
||||
max_size: Maximum number of entries in cache
|
||||
cleanup_interval: Seconds between automatic cleanups
|
||||
"""
|
||||
self.logger = logging.getLogger(__name__)
|
||||
self._cache: Dict[str, Dict[str, Any]] = {}
|
||||
self._timestamps: Dict[str, float] = {}
|
||||
self._lock = threading.Lock()
|
||||
self._max_size = max_size
|
||||
self._cleanup_interval = cleanup_interval
|
||||
self._last_cleanup = time.time()
|
||||
|
||||
def get(self, key: str, max_age: Optional[int] = None) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get value from memory cache.
|
||||
|
||||
Args:
|
||||
key: Cache key
|
||||
max_age: Maximum age in seconds (None = no expiration)
|
||||
|
||||
Returns:
|
||||
Cached value or None if not found or expired
|
||||
"""
|
||||
now = time.time()
|
||||
|
||||
with self._lock:
|
||||
if key not in self._cache:
|
||||
return None
|
||||
|
||||
timestamp = self._timestamps.get(key)
|
||||
if isinstance(timestamp, str):
|
||||
try:
|
||||
timestamp = float(timestamp)
|
||||
except ValueError:
|
||||
self.logger.error(f"Invalid timestamp format for key {key}: {timestamp}")
|
||||
timestamp = None
|
||||
|
||||
if timestamp is None:
|
||||
return None
|
||||
|
||||
# Check expiration
|
||||
if max_age is not None and (now - timestamp) > max_age:
|
||||
# Expired - remove it
|
||||
self._cache.pop(key, None)
|
||||
self._timestamps.pop(key, None)
|
||||
return None
|
||||
|
||||
return self._cache[key]
|
||||
|
||||
def set(self, key: str, value: Dict[str, Any]) -> None:
|
||||
"""
|
||||
Set value in memory cache.
|
||||
|
||||
Args:
|
||||
key: Cache key
|
||||
value: Value to cache
|
||||
"""
|
||||
with self._lock:
|
||||
self._cache[key] = value
|
||||
self._timestamps[key] = time.time()
|
||||
|
||||
def clear(self, key: Optional[str] = None) -> None:
|
||||
"""
|
||||
Clear cache entry or all entries.
|
||||
|
||||
Args:
|
||||
key: Specific key to clear, or None to clear all
|
||||
"""
|
||||
with self._lock:
|
||||
if key:
|
||||
self._cache.pop(key, None)
|
||||
self._timestamps.pop(key, None)
|
||||
else:
|
||||
self._cache.clear()
|
||||
self._timestamps.clear()
|
||||
|
||||
def cleanup(self, force: bool = False) -> int:
|
||||
"""
|
||||
Clean up expired entries and enforce size limits.
|
||||
|
||||
Args:
|
||||
force: If True, perform cleanup regardless of time interval
|
||||
|
||||
Returns:
|
||||
Number of entries removed
|
||||
"""
|
||||
now = time.time()
|
||||
|
||||
# Check if cleanup is needed
|
||||
if not force and (now - self._last_cleanup) < self._cleanup_interval:
|
||||
return 0
|
||||
|
||||
with self._lock:
|
||||
removed_count = 0
|
||||
current_time = time.time()
|
||||
|
||||
# Remove expired entries (entries older than 1 hour without access are considered expired)
|
||||
max_age_for_cleanup = 3600 # 1 hour
|
||||
|
||||
expired_keys = []
|
||||
for key, timestamp in list(self._timestamps.items()):
|
||||
if isinstance(timestamp, str):
|
||||
try:
|
||||
timestamp = float(timestamp)
|
||||
except ValueError:
|
||||
timestamp = None
|
||||
|
||||
if timestamp is None or (current_time - timestamp) > max_age_for_cleanup:
|
||||
expired_keys.append(key)
|
||||
|
||||
# Remove expired entries
|
||||
for key in expired_keys:
|
||||
self._cache.pop(key, None)
|
||||
self._timestamps.pop(key, None)
|
||||
removed_count += 1
|
||||
|
||||
# Enforce size limit by removing oldest entries if cache is too large
|
||||
if len(self._cache) > self._max_size:
|
||||
# Sort by timestamp (oldest first)
|
||||
sorted_entries = sorted(
|
||||
self._timestamps.items(),
|
||||
key=lambda x: float(x[1]) if isinstance(x[1], (int, float)) else 0
|
||||
)
|
||||
|
||||
# Remove oldest entries until we're under the limit
|
||||
excess_count = len(self._cache) - self._max_size
|
||||
for i in range(excess_count):
|
||||
if i < len(sorted_entries):
|
||||
key = sorted_entries[i][0]
|
||||
self._cache.pop(key, None)
|
||||
self._timestamps.pop(key, None)
|
||||
removed_count += 1
|
||||
|
||||
self._last_cleanup = current_time
|
||||
|
||||
if removed_count > 0:
|
||||
self.logger.debug("Memory cache cleanup: removed %d entries (current size: %d)",
|
||||
removed_count, len(self._cache))
|
||||
|
||||
return removed_count
|
||||
|
||||
def size(self) -> int:
|
||||
"""Get current cache size."""
|
||||
with self._lock:
|
||||
return len(self._cache)
|
||||
|
||||
def max_size(self) -> int:
|
||||
"""Get maximum cache size."""
|
||||
return self._max_size
|
||||
|
||||
def get_stats(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Get cache statistics.
|
||||
|
||||
Returns:
|
||||
Dictionary with cache statistics
|
||||
"""
|
||||
with self._lock:
|
||||
return {
|
||||
'size': len(self._cache),
|
||||
'max_size': self._max_size,
|
||||
'usage_percent': (len(self._cache) / self._max_size * 100) if self._max_size > 0 else 0,
|
||||
'last_cleanup': self._last_cleanup,
|
||||
'cleanup_interval': self._cleanup_interval
|
||||
}
|
||||
|
||||
@@ -3,12 +3,17 @@ import os
|
||||
import time
|
||||
from datetime import datetime
|
||||
import pytz
|
||||
from typing import Any, Dict, Optional
|
||||
from typing import Any, Dict, List, Optional
|
||||
import logging
|
||||
import stat
|
||||
import threading
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from src.exceptions import CacheError
|
||||
from src.cache.memory_cache import MemoryCache
|
||||
from src.cache.disk_cache import DiskCache
|
||||
from src.cache.cache_strategy import CacheStrategy
|
||||
from src.cache.cache_metrics import CacheMetrics
|
||||
from src.logging_config import get_logger
|
||||
|
||||
class DateTimeEncoder(json.JSONEncoder):
|
||||
def default(self, obj):
|
||||
@@ -19,12 +24,12 @@ class DateTimeEncoder(json.JSONEncoder):
|
||||
class CacheManager:
|
||||
"""Manages caching of API responses to reduce API calls."""
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self) -> None:
|
||||
# Initialize logger first
|
||||
self.logger = logging.getLogger(__name__)
|
||||
self.logger: logging.Logger = get_logger(__name__)
|
||||
|
||||
# Determine the most reliable writable directory
|
||||
self.cache_dir = self._get_writable_cache_dir()
|
||||
self.cache_dir: Optional[str] = self._get_writable_cache_dir()
|
||||
if self.cache_dir:
|
||||
self.logger.info(f"Using cache directory: {self.cache_dir}")
|
||||
else:
|
||||
@@ -32,29 +37,28 @@ class CacheManager:
|
||||
self.logger.error("Could not find or create a writable cache directory. Caching will be disabled.")
|
||||
self.cache_dir = None
|
||||
|
||||
self._memory_cache = {} # In-memory cache for faster access
|
||||
self._memory_cache_timestamps = {}
|
||||
self._cache_lock = threading.Lock()
|
||||
|
||||
# Initialize config manager for sport-specific intervals
|
||||
try:
|
||||
from src.config_manager import ConfigManager
|
||||
self.config_manager = ConfigManager()
|
||||
self.config_manager: Optional[Any] = ConfigManager()
|
||||
self.config_manager.load_config()
|
||||
except ImportError:
|
||||
self.config_manager = None
|
||||
self.config_manager: Optional[Any] = None
|
||||
self.logger.warning("ConfigManager not available, using default cache intervals")
|
||||
|
||||
# Initialize performance metrics
|
||||
self._cache_metrics = {
|
||||
'hits': 0,
|
||||
'misses': 0,
|
||||
'api_calls_saved': 0,
|
||||
'background_hits': 0,
|
||||
'background_misses': 0,
|
||||
'total_fetch_time': 0.0,
|
||||
'fetch_count': 0
|
||||
}
|
||||
# Initialize cache components using composition
|
||||
self._memory_cache_component = MemoryCache(max_size=1000, cleanup_interval=300.0)
|
||||
self._disk_cache_component = DiskCache(cache_dir=self.cache_dir, logger=self.logger)
|
||||
self._strategy_component = CacheStrategy(config_manager=self.config_manager, logger=self.logger)
|
||||
self._metrics_component = CacheMetrics(logger=self.logger)
|
||||
|
||||
# Keep old attributes for backward compatibility (delegated to components)
|
||||
self._memory_cache = self._memory_cache_component._cache
|
||||
self._memory_cache_timestamps = self._memory_cache_component._timestamps
|
||||
self._cache_lock = self._memory_cache_component._lock
|
||||
self._max_memory_cache_size = self._memory_cache_component._max_size
|
||||
self._memory_cache_cleanup_interval = self._memory_cache_component._cleanup_interval
|
||||
self._last_memory_cache_cleanup = self._memory_cache_component._last_cleanup
|
||||
|
||||
def _get_writable_cache_dir(self) -> Optional[str]:
|
||||
"""Tries to find or create a writable cache directory, preferring a system path when available."""
|
||||
@@ -67,15 +71,27 @@ class CacheManager:
|
||||
with open(test_file, 'w') as f:
|
||||
f.write('test')
|
||||
os.remove(test_file)
|
||||
self.logger.info(f"Using system cache directory: {system_cache_dir}")
|
||||
return system_cache_dir
|
||||
except (IOError, OSError):
|
||||
self.logger.warning(f"Directory exists but is not writable: {system_cache_dir}")
|
||||
self.logger.debug(f"System cache directory exists but is not writable: {system_cache_dir}")
|
||||
else:
|
||||
os.makedirs(system_cache_dir, exist_ok=True)
|
||||
if os.access(system_cache_dir, os.W_OK):
|
||||
return system_cache_dir
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Could not use /var/cache/ledmatrix: {e}")
|
||||
from pathlib import Path
|
||||
from src.common.permission_utils import (
|
||||
ensure_directory_permissions,
|
||||
get_cache_dir_mode
|
||||
)
|
||||
try:
|
||||
ensure_directory_permissions(Path(system_cache_dir), get_cache_dir_mode())
|
||||
if os.access(system_cache_dir, os.W_OK):
|
||||
self.logger.info(f"Using system cache directory: {system_cache_dir}")
|
||||
return system_cache_dir
|
||||
except (OSError, IOError, PermissionError) as perm_error:
|
||||
# Permission errors are expected when running as non-root
|
||||
self.logger.debug(f"Could not create system cache directory (permission denied): {system_cache_dir}")
|
||||
except (OSError, IOError, PermissionError) as e:
|
||||
# Permission errors are expected when running as non-root, log at DEBUG level
|
||||
self.logger.debug(f"System cache directory not available: {e}")
|
||||
|
||||
# Attempt 2: User's home directory (handling sudo), but avoid /root preference
|
||||
try:
|
||||
@@ -86,13 +102,19 @@ class CacheManager:
|
||||
# When running as root and /var/cache/ledmatrix failed, still allow fallback to /root
|
||||
home_dir = os.path.expanduser('~')
|
||||
user_cache_dir = os.path.join(home_dir, '.ledmatrix_cache')
|
||||
os.makedirs(user_cache_dir, exist_ok=True)
|
||||
from pathlib import Path
|
||||
from src.common.permission_utils import (
|
||||
ensure_directory_permissions,
|
||||
get_cache_dir_mode
|
||||
)
|
||||
ensure_directory_permissions(Path(user_cache_dir), get_cache_dir_mode())
|
||||
test_file = os.path.join(user_cache_dir, '.writetest')
|
||||
with open(test_file, 'w') as f:
|
||||
f.write('test')
|
||||
os.remove(test_file)
|
||||
self.logger.info(f"Using user cache directory: {user_cache_dir}")
|
||||
return user_cache_dir
|
||||
except Exception as e:
|
||||
except (OSError, IOError, PermissionError) as e:
|
||||
self.logger.warning(f"Could not use user-specific cache directory: {e}")
|
||||
|
||||
# Attempt 3: /opt/ledmatrix/cache (alternative persistent location)
|
||||
@@ -112,215 +134,255 @@ class CacheManager:
|
||||
self.logger.warning(f"Directory exists but is not writable: {opt_cache_dir}")
|
||||
else:
|
||||
# Try to create the directory
|
||||
os.makedirs(opt_cache_dir, exist_ok=True)
|
||||
from pathlib import Path
|
||||
from src.common.permission_utils import (
|
||||
ensure_directory_permissions,
|
||||
get_cache_dir_mode
|
||||
)
|
||||
ensure_directory_permissions(Path(opt_cache_dir), get_cache_dir_mode())
|
||||
if os.access(opt_cache_dir, os.W_OK):
|
||||
return opt_cache_dir
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Could not use /opt/ledmatrix/cache: {e}")
|
||||
except (OSError, IOError, PermissionError) as e:
|
||||
self.logger.warning(f"Could not use /opt/ledmatrix/cache: {e}", exc_info=True)
|
||||
|
||||
# Attempt 4: System-wide temporary directory (fallback, not persistent)
|
||||
try:
|
||||
temp_cache_dir = os.path.join(tempfile.gettempdir(), 'ledmatrix_cache')
|
||||
os.makedirs(temp_cache_dir, exist_ok=True)
|
||||
from pathlib import Path
|
||||
from src.common.permission_utils import (
|
||||
ensure_directory_permissions,
|
||||
get_cache_dir_mode
|
||||
)
|
||||
ensure_directory_permissions(Path(temp_cache_dir), get_cache_dir_mode())
|
||||
if os.access(temp_cache_dir, os.W_OK):
|
||||
self.logger.warning("Using temporary cache directory - cache will NOT persist across restarts")
|
||||
return temp_cache_dir
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Could not use system-wide temporary cache directory: {e}")
|
||||
except (OSError, IOError, PermissionError) as e:
|
||||
self.logger.warning(f"Could not use system-wide temporary cache directory: {e}", exc_info=True)
|
||||
|
||||
# Return None if no directory is writable
|
||||
return None
|
||||
|
||||
def _ensure_cache_dir(self):
|
||||
"""This method is deprecated and no longer needed."""
|
||||
pass
|
||||
|
||||
def _cleanup_memory_cache(self, force: bool = False) -> int:
|
||||
"""
|
||||
Clean up expired entries from memory cache and enforce size limits.
|
||||
|
||||
Args:
|
||||
force: If True, perform cleanup regardless of time interval
|
||||
|
||||
Returns:
|
||||
Number of entries removed
|
||||
"""
|
||||
now = time.time()
|
||||
|
||||
# Check if cleanup is needed
|
||||
if not force and (now - self._last_memory_cache_cleanup) < self._memory_cache_cleanup_interval:
|
||||
return 0
|
||||
|
||||
with self._cache_lock:
|
||||
removed_count = 0
|
||||
current_time = time.time()
|
||||
|
||||
# Remove expired entries (entries older than 1 hour without access are considered expired)
|
||||
# We use a conservative TTL of 1 hour for cleanup
|
||||
max_age_for_cleanup = 3600 # 1 hour
|
||||
|
||||
expired_keys = []
|
||||
for key, timestamp in list(self._memory_cache_timestamps.items()):
|
||||
if isinstance(timestamp, str):
|
||||
try:
|
||||
timestamp = float(timestamp)
|
||||
except ValueError:
|
||||
timestamp = None
|
||||
|
||||
if timestamp is None or (current_time - timestamp) > max_age_for_cleanup:
|
||||
expired_keys.append(key)
|
||||
|
||||
# Remove expired entries
|
||||
for key in expired_keys:
|
||||
self._memory_cache.pop(key, None)
|
||||
self._memory_cache_timestamps.pop(key, None)
|
||||
removed_count += 1
|
||||
|
||||
# Enforce size limit by removing oldest entries if cache is too large
|
||||
if len(self._memory_cache) > self._max_memory_cache_size:
|
||||
# Sort by timestamp (oldest first)
|
||||
sorted_entries = sorted(
|
||||
self._memory_cache_timestamps.items(),
|
||||
key=lambda x: float(x[1]) if isinstance(x[1], (int, float)) else 0
|
||||
)
|
||||
|
||||
# Remove oldest entries until we're under the limit
|
||||
excess_count = len(self._memory_cache) - self._max_memory_cache_size
|
||||
for i in range(excess_count):
|
||||
if i < len(sorted_entries):
|
||||
key = sorted_entries[i][0]
|
||||
self._memory_cache.pop(key, None)
|
||||
self._memory_cache_timestamps.pop(key, None)
|
||||
removed_count += 1
|
||||
|
||||
self._last_memory_cache_cleanup = current_time
|
||||
|
||||
if removed_count > 0:
|
||||
self.logger.debug(f"Memory cache cleanup: removed {removed_count} entries (current size: {len(self._memory_cache)})")
|
||||
|
||||
return removed_count
|
||||
|
||||
def _get_cache_path(self, key: str) -> Optional[str]:
|
||||
"""Get the path for a cache file."""
|
||||
if not self.cache_dir:
|
||||
return None
|
||||
return os.path.join(self.cache_dir, f"{key}.json")
|
||||
return self._disk_cache_component.get_cache_path(key)
|
||||
|
||||
def get_cached_data(self, key: str, max_age: int = 300, memory_ttl: Optional[int] = None) -> Optional[Dict]:
|
||||
def get_cached_data(self, key: str, max_age: int = 300, memory_ttl: Optional[int] = None) -> Optional[Dict[str, Any]]:
|
||||
"""Get data from cache (memory first, then disk) honoring TTLs.
|
||||
|
||||
- memory_ttl: TTL for in-memory entry; defaults to max_age if not provided
|
||||
- max_age: TTL for persisted (on-disk) entry based on the stored timestamp
|
||||
"""
|
||||
now = time.time()
|
||||
# Periodic cleanup of memory cache
|
||||
self._cleanup_memory_cache()
|
||||
|
||||
in_memory_ttl = memory_ttl if memory_ttl is not None else max_age
|
||||
|
||||
# 1) Memory cache
|
||||
if key in self._memory_cache:
|
||||
timestamp = self._memory_cache_timestamps.get(key)
|
||||
if isinstance(timestamp, str):
|
||||
try:
|
||||
timestamp = float(timestamp)
|
||||
except ValueError:
|
||||
self.logger.error(f"Invalid timestamp format for key {key}: {timestamp}")
|
||||
timestamp = None
|
||||
if timestamp is not None and (now - float(timestamp) <= in_memory_ttl):
|
||||
return self._memory_cache[key]
|
||||
# Expired memory entry → evict and fall through to disk
|
||||
self._memory_cache.pop(key, None)
|
||||
self._memory_cache_timestamps.pop(key, None)
|
||||
cached = self._memory_cache_component.get(key, max_age=in_memory_ttl)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
# 2) Disk cache
|
||||
cache_path = self._get_cache_path(key)
|
||||
if cache_path and os.path.exists(cache_path):
|
||||
try:
|
||||
with self._cache_lock:
|
||||
with open(cache_path, 'r') as f:
|
||||
record = json.load(f)
|
||||
# Determine record timestamp (prefer embedded, else file mtime)
|
||||
record_ts = None
|
||||
if isinstance(record, dict):
|
||||
record_ts = record.get('timestamp')
|
||||
if record_ts is None:
|
||||
try:
|
||||
record_ts = os.path.getmtime(cache_path)
|
||||
except OSError:
|
||||
record_ts = None
|
||||
if record_ts is not None:
|
||||
try:
|
||||
record_ts = float(record_ts)
|
||||
except (TypeError, ValueError):
|
||||
record_ts = None
|
||||
|
||||
if record_ts is None or (now - record_ts) <= max_age:
|
||||
# Hydrate memory cache (use current time to start memory TTL window)
|
||||
self._memory_cache[key] = record
|
||||
self._memory_cache_timestamps[key] = now
|
||||
return record
|
||||
else:
|
||||
# Stale on disk; keep file for potential diagnostics but treat as miss
|
||||
return None
|
||||
except json.JSONDecodeError as e:
|
||||
self.logger.error(f"Error parsing cache file for {key}: {e}")
|
||||
# If the file is corrupted, remove it
|
||||
try:
|
||||
os.remove(cache_path)
|
||||
except OSError:
|
||||
pass
|
||||
return None
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error loading cache for {key}: {e}")
|
||||
return None
|
||||
record = self._disk_cache_component.get(key, max_age=max_age)
|
||||
if record is not None:
|
||||
# Hydrate memory cache (use current time to start memory TTL window)
|
||||
self._memory_cache_component.set(key, record)
|
||||
return record
|
||||
|
||||
# 3) Miss
|
||||
return None
|
||||
|
||||
def save_cache(self, key: str, data: Dict) -> None:
|
||||
def save_cache(self, key: str, data: Dict[str, Any]) -> None:
|
||||
"""
|
||||
Save data to cache.
|
||||
Args:
|
||||
key: Cache key
|
||||
data: Data to cache
|
||||
"""
|
||||
# Periodic cleanup before adding new entries
|
||||
self._cleanup_memory_cache()
|
||||
|
||||
# Update memory cache first
|
||||
self._memory_cache_component.set(key, data)
|
||||
|
||||
# Save to disk cache
|
||||
try:
|
||||
# Update memory cache first
|
||||
self._memory_cache[key] = data
|
||||
self._memory_cache_timestamps[key] = time.time()
|
||||
|
||||
# Save to file if a cache directory is available
|
||||
cache_path = self._get_cache_path(key)
|
||||
if cache_path:
|
||||
# Atomic write to avoid partial/corrupt files
|
||||
with self._cache_lock:
|
||||
tmp_dir = os.path.dirname(cache_path)
|
||||
try:
|
||||
fd, tmp_path = tempfile.mkstemp(prefix=f".{os.path.basename(cache_path)}.", dir=tmp_dir)
|
||||
try:
|
||||
with os.fdopen(fd, 'w') as tmp_file:
|
||||
json.dump(data, tmp_file, indent=4, cls=DateTimeEncoder)
|
||||
tmp_file.flush()
|
||||
os.fsync(tmp_file.fileno())
|
||||
os.replace(tmp_path, cache_path)
|
||||
finally:
|
||||
if os.path.exists(tmp_path):
|
||||
try:
|
||||
os.remove(tmp_path)
|
||||
except OSError:
|
||||
pass
|
||||
except Exception as e:
|
||||
self.logger.error(f"Atomic write failed for key '{key}': {e}")
|
||||
# Attempt one-time fallback write directly into /var/cache/ledmatrix if available
|
||||
try:
|
||||
fallback_dir = '/var/cache/ledmatrix'
|
||||
if os.path.isdir(fallback_dir) and os.access(fallback_dir, os.W_OK):
|
||||
fallback_path = os.path.join(fallback_dir, os.path.basename(cache_path))
|
||||
with open(fallback_path, 'w') as tmp_file:
|
||||
json.dump(data, tmp_file, indent=4, cls=DateTimeEncoder)
|
||||
self.logger.warning(f"Cache wrote to fallback location: {fallback_path}")
|
||||
except Exception as e2:
|
||||
self.logger.error(f"Fallback cache write also failed: {e2}")
|
||||
|
||||
except (IOError, OSError) as e:
|
||||
self.logger.error(f"Failed to save cache for key '{key}': {e}")
|
||||
except Exception as e:
|
||||
self.logger.error(f"An unexpected error occurred while saving cache for key '{key}': {e}")
|
||||
self._disk_cache_component.set(key, data)
|
||||
except CacheError:
|
||||
# Disk cache errors are already logged and raised by DiskCache
|
||||
raise
|
||||
|
||||
def load_cache(self, key: str) -> Optional[Dict[str, Any]]:
|
||||
"""Load data from cache with memory caching."""
|
||||
current_time = time.time()
|
||||
# Check memory cache first (1 minute TTL)
|
||||
cached = self._memory_cache_component.get(key, max_age=60)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
# Check memory cache first
|
||||
if key in self._memory_cache:
|
||||
if current_time - self._memory_cache_timestamps.get(key, 0) < 60: # 1 minute TTL
|
||||
return self._memory_cache[key]
|
||||
else:
|
||||
# Clear expired memory cache
|
||||
if key in self._memory_cache:
|
||||
del self._memory_cache[key]
|
||||
if key in self._memory_cache_timestamps:
|
||||
del self._memory_cache_timestamps[key]
|
||||
|
||||
cache_path = self._get_cache_path(key)
|
||||
if not cache_path or not os.path.exists(cache_path):
|
||||
return None
|
||||
|
||||
try:
|
||||
with self._cache_lock:
|
||||
with open(cache_path, 'r') as f:
|
||||
try:
|
||||
data = json.load(f)
|
||||
# Update memory cache
|
||||
self._memory_cache[key] = data
|
||||
self._memory_cache_timestamps[key] = current_time
|
||||
return data
|
||||
except json.JSONDecodeError as e:
|
||||
self.logger.error(f"Error parsing cache file for {key}: {e}")
|
||||
# If the file is corrupted, remove it
|
||||
os.remove(cache_path)
|
||||
return None
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error loading cache for {key}: {e}")
|
||||
return None
|
||||
# Check disk cache
|
||||
data = self._disk_cache_component.get(key, max_age=3600) # 1 hour for load_cache
|
||||
if data is not None:
|
||||
# Update memory cache
|
||||
self._memory_cache_component.set(key, data)
|
||||
return data
|
||||
|
||||
return None
|
||||
|
||||
def clear_cache(self, key: Optional[str] = None) -> None:
|
||||
"""Clear cache for a specific key or all keys."""
|
||||
with self._cache_lock:
|
||||
if key:
|
||||
# Clear specific key
|
||||
if key in self._memory_cache:
|
||||
del self._memory_cache[key]
|
||||
del self._memory_cache_timestamps[key]
|
||||
cache_path = self._get_cache_path(key)
|
||||
if cache_path and os.path.exists(cache_path):
|
||||
os.remove(cache_path)
|
||||
self.logger.info(f"Cleared cache for key: {key}")
|
||||
else:
|
||||
# Clear all keys
|
||||
memory_count = len(self._memory_cache)
|
||||
self._memory_cache.clear()
|
||||
self._memory_cache_timestamps.clear()
|
||||
file_count = 0
|
||||
if self.cache_dir:
|
||||
for file in os.listdir(self.cache_dir):
|
||||
if file.endswith('.json'):
|
||||
os.remove(os.path.join(self.cache_dir, file))
|
||||
file_count += 1
|
||||
self.logger.info(f"Cleared all cache: {memory_count} memory entries, {file_count} cache files")
|
||||
if key:
|
||||
# Clear specific key
|
||||
self._memory_cache_component.clear(key)
|
||||
self._disk_cache_component.clear(key)
|
||||
self.logger.info("Cleared cache for key: %s", key)
|
||||
else:
|
||||
# Clear all keys
|
||||
memory_count = self._memory_cache_component.size()
|
||||
self._memory_cache_component.clear()
|
||||
self._disk_cache_component.clear()
|
||||
self.logger.info("Cleared all cache: %d memory entries", memory_count)
|
||||
|
||||
def list_cache_files(self) -> List[Dict[str, Any]]:
|
||||
"""List all cache files with metadata (key, age, size, path).
|
||||
|
||||
Returns:
|
||||
List of dicts with keys: 'key', 'filename', 'age_seconds', 'age_display',
|
||||
'size_bytes', 'size_display', 'path', 'modified_time'
|
||||
"""
|
||||
if not self.cache_dir or not os.path.exists(self.cache_dir):
|
||||
return []
|
||||
|
||||
cache_files = []
|
||||
current_time = time.time()
|
||||
|
||||
try:
|
||||
with self._cache_lock:
|
||||
for filename in os.listdir(self.cache_dir):
|
||||
if not filename.endswith('.json'):
|
||||
continue
|
||||
|
||||
# Extract key from filename (remove .json extension)
|
||||
key = filename[:-5] # Remove '.json'
|
||||
|
||||
file_path = os.path.join(self.cache_dir, filename)
|
||||
|
||||
try:
|
||||
# Get file stats
|
||||
stat_info = os.stat(file_path)
|
||||
size_bytes = stat_info.st_size
|
||||
modified_time = stat_info.st_mtime
|
||||
age_seconds = current_time - modified_time
|
||||
|
||||
# Format age display
|
||||
if age_seconds < 60:
|
||||
age_display = f"{int(age_seconds)}s"
|
||||
elif age_seconds < 3600:
|
||||
age_display = f"{int(age_seconds / 60)}m"
|
||||
elif age_seconds < 86400:
|
||||
age_display = f"{int(age_seconds / 3600)}h"
|
||||
else:
|
||||
age_display = f"{int(age_seconds / 86400)}d"
|
||||
|
||||
# Format size display
|
||||
if size_bytes < 1024:
|
||||
size_display = f"{size_bytes}B"
|
||||
elif size_bytes < 1024 * 1024:
|
||||
size_display = f"{size_bytes / 1024:.1f}KB"
|
||||
else:
|
||||
size_display = f"{size_bytes / (1024 * 1024):.1f}MB"
|
||||
|
||||
cache_files.append({
|
||||
'key': key,
|
||||
'filename': filename,
|
||||
'age_seconds': age_seconds,
|
||||
'age_display': age_display,
|
||||
'size_bytes': size_bytes,
|
||||
'size_display': size_display,
|
||||
'path': file_path,
|
||||
'modified_time': modified_time,
|
||||
'modified_datetime': datetime.fromtimestamp(modified_time).isoformat()
|
||||
})
|
||||
except OSError as e:
|
||||
self.logger.warning(f"Error getting stats for cache file {filename} at {file_path}: {e}", exc_info=True)
|
||||
continue
|
||||
|
||||
except OSError as e:
|
||||
self.logger.error(f"Error listing cache directory {self.cache_dir}: {e}", exc_info=True)
|
||||
return []
|
||||
|
||||
# Sort by modified time (newest first)
|
||||
cache_files.sort(key=lambda x: x['modified_time'], reverse=True)
|
||||
return cache_files
|
||||
|
||||
def get_cache_dir(self) -> Optional[str]:
|
||||
"""Get the cache directory path."""
|
||||
return self.cache_dir
|
||||
|
||||
def has_data_changed(self, data_type: str, new_data: Dict[str, Any]) -> bool:
|
||||
"""Check if data has changed from cached version."""
|
||||
@@ -425,19 +487,7 @@ class CacheManager:
|
||||
|
||||
def _is_market_open(self) -> bool:
|
||||
"""Check if the US stock market is currently open."""
|
||||
et_tz = pytz.timezone('America/New_York')
|
||||
now = datetime.now(et_tz)
|
||||
|
||||
# Check if it's a weekday
|
||||
if now.weekday() >= 5: # 5 = Saturday, 6 = Sunday
|
||||
return False
|
||||
|
||||
# Convert current time to ET
|
||||
current_time = now.time()
|
||||
market_open = datetime.strptime('09:30', '%H:%M').time()
|
||||
market_close = datetime.strptime('16:00', '%H:%M').time()
|
||||
|
||||
return market_open <= current_time <= market_close
|
||||
return self._strategy_component.is_market_open()
|
||||
|
||||
def update_cache(self, data_type: str, data: Dict[str, Any]) -> bool:
|
||||
"""Update cache with new data."""
|
||||
@@ -447,19 +497,29 @@ class CacheManager:
|
||||
}
|
||||
return self.save_cache(data_type, cache_data)
|
||||
|
||||
def get(self, key: str, max_age: int = 300) -> Optional[Dict]:
|
||||
def get(self, key: str, max_age: int = 300) -> Optional[Dict[str, Any]]:
|
||||
"""Get data from cache if it exists and is not stale."""
|
||||
cached_data = self.get_cached_data(key, max_age)
|
||||
if cached_data and 'data' in cached_data:
|
||||
return cached_data['data']
|
||||
return cached_data
|
||||
|
||||
def set(self, key: str, data: Dict) -> None:
|
||||
"""Store data in cache with current timestamp."""
|
||||
def set(self, key: str, data: Dict[str, Any], ttl: Optional[int] = None) -> None:
|
||||
"""
|
||||
Store data in cache with current timestamp.
|
||||
|
||||
Args:
|
||||
key: Cache key
|
||||
data: Data to cache
|
||||
ttl: Optional time-to-live in seconds (stored for compatibility but
|
||||
expiration is still controlled via max_age when reading)
|
||||
"""
|
||||
cache_data = {
|
||||
'data': data,
|
||||
'timestamp': time.time()
|
||||
}
|
||||
if ttl is not None:
|
||||
cache_data['ttl'] = ttl
|
||||
self.save_cache(key, cache_data)
|
||||
|
||||
def setup_persistent_cache(self) -> bool:
|
||||
@@ -469,8 +529,14 @@ class CacheManager:
|
||||
"""
|
||||
try:
|
||||
# Try to create /var/cache/ledmatrix with proper permissions
|
||||
from pathlib import Path
|
||||
from src.common.permission_utils import (
|
||||
ensure_directory_permissions,
|
||||
get_cache_dir_mode
|
||||
)
|
||||
cache_dir = '/var/cache/ledmatrix'
|
||||
os.makedirs(cache_dir, exist_ok=True)
|
||||
cache_dir_path = Path(cache_dir)
|
||||
ensure_directory_permissions(cache_dir_path, get_cache_dir_mode())
|
||||
|
||||
# Set ownership to the real user (not root)
|
||||
real_user = os.environ.get('SUDO_USER')
|
||||
@@ -481,17 +547,14 @@ class CacheManager:
|
||||
gid = pwd.getpwnam(real_user).pw_gid
|
||||
os.chown(cache_dir, uid, gid)
|
||||
self.logger.info(f"Set ownership of {cache_dir} to {real_user}")
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Could not set ownership: {e}")
|
||||
|
||||
# Set permissions to 755 (rwxr-xr-x)
|
||||
os.chmod(cache_dir, 0o755)
|
||||
except (OSError, KeyError) as e:
|
||||
self.logger.warning(f"Could not set ownership for {cache_dir}: {e}", exc_info=True)
|
||||
|
||||
self.logger.info(f"Successfully set up persistent cache directory: {cache_dir}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to set up persistent cache directory: {e}")
|
||||
except (OSError, IOError, PermissionError) as e:
|
||||
self.logger.error(f"Failed to set up persistent cache directory {cache_dir}: {e}", exc_info=True)
|
||||
return False
|
||||
|
||||
def get_sport_live_interval(self, sport_key: str) -> int:
|
||||
@@ -499,223 +562,29 @@ class CacheManager:
|
||||
Get the live_update_interval for a specific sport from config.
|
||||
Falls back to default values if config is not available.
|
||||
"""
|
||||
if not self.config_manager:
|
||||
# Default intervals - all sports use 60 seconds as default
|
||||
default_intervals = {
|
||||
'soccer': 60, # Soccer default
|
||||
'nfl': 60, # NFL default
|
||||
'nhl': 60, # NHL default
|
||||
'nba': 60, # NBA default
|
||||
'mlb': 60, # MLB default
|
||||
'milb': 60, # Minor league default
|
||||
'ncaa_fb': 60, # College football default
|
||||
'ncaa_baseball': 60, # College baseball default
|
||||
'ncaam_basketball': 60, # College basketball default
|
||||
}
|
||||
return default_intervals.get(sport_key, 60)
|
||||
|
||||
try:
|
||||
config = self.config_manager.config
|
||||
# All sports now use _scoreboard suffix
|
||||
sport_config = config.get(f"{sport_key}_scoreboard", {})
|
||||
return sport_config.get("live_update_interval", 60) # Default to 60 seconds
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Could not get live_update_interval for {sport_key}: {e}")
|
||||
return 60 # Default to 60 seconds
|
||||
return self._strategy_component.get_sport_live_interval(sport_key)
|
||||
|
||||
def get_cache_strategy(self, data_type: str, sport_key: str = None) -> Dict[str, Any]:
|
||||
def get_cache_strategy(self, data_type: str, sport_key: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Get cache strategy for different data types.
|
||||
Now respects sport-specific live_update_interval configurations.
|
||||
"""
|
||||
# Get sport-specific live interval if provided
|
||||
live_interval = None
|
||||
if sport_key and data_type in ['sports_live', 'live_scores']:
|
||||
live_interval = self.get_sport_live_interval(sport_key)
|
||||
|
||||
# Try to read sport-specific config for recent/upcoming
|
||||
recent_interval = None
|
||||
upcoming_interval = None
|
||||
if self.config_manager and sport_key:
|
||||
try:
|
||||
# All sports now use _scoreboard suffix
|
||||
sport_cfg = self.config_manager.config.get(f"{sport_key}_scoreboard", {})
|
||||
recent_interval = sport_cfg.get('recent_update_interval')
|
||||
upcoming_interval = sport_cfg.get('upcoming_update_interval')
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Could not read sport-specific recent/upcoming intervals for {sport_key}: {e}")
|
||||
|
||||
strategies = {
|
||||
# Ultra time-sensitive data (live scores, current weather)
|
||||
'live_scores': {
|
||||
'max_age': live_interval or 15, # Use sport-specific interval
|
||||
'memory_ttl': (live_interval or 15) * 2, # 2x for memory cache
|
||||
'force_refresh': True
|
||||
},
|
||||
'sports_live': {
|
||||
'max_age': live_interval or 30, # Use sport-specific interval
|
||||
'memory_ttl': (live_interval or 30) * 2,
|
||||
'force_refresh': True
|
||||
},
|
||||
'weather_current': {
|
||||
'max_age': 300, # 5 minutes
|
||||
'memory_ttl': 600,
|
||||
'force_refresh': False
|
||||
},
|
||||
|
||||
# Market data (stocks, crypto)
|
||||
'stocks': {
|
||||
'max_age': 600, # 10 minutes
|
||||
'memory_ttl': 1200,
|
||||
'market_hours_only': True,
|
||||
'force_refresh': False
|
||||
},
|
||||
'crypto': {
|
||||
'max_age': 300, # 5 minutes (crypto trades 24/7)
|
||||
'memory_ttl': 600,
|
||||
'force_refresh': False
|
||||
},
|
||||
|
||||
# Sports data
|
||||
'sports_recent': {
|
||||
'max_age': recent_interval or 1800, # 30 minutes default; override by config
|
||||
'memory_ttl': (recent_interval or 1800) * 2,
|
||||
'force_refresh': False
|
||||
},
|
||||
'sports_upcoming': {
|
||||
'max_age': upcoming_interval or 10800, # 3 hours default; override by config
|
||||
'memory_ttl': (upcoming_interval or 10800) * 2,
|
||||
'force_refresh': False
|
||||
},
|
||||
'sports_schedules': {
|
||||
'max_age': 86400, # 24 hours
|
||||
'memory_ttl': 172800,
|
||||
'force_refresh': False
|
||||
},
|
||||
'leaderboard': {
|
||||
'max_age': 604800, # 7 days (1 week) - football rankings updated weekly
|
||||
'memory_ttl': 1209600, # 14 days in memory
|
||||
'force_refresh': False
|
||||
},
|
||||
|
||||
# News and odds
|
||||
'news': {
|
||||
'max_age': 3600, # 1 hour
|
||||
'memory_ttl': 7200,
|
||||
'force_refresh': False
|
||||
},
|
||||
'odds': {
|
||||
'max_age': 1800, # 30 minutes for upcoming games
|
||||
'memory_ttl': 3600,
|
||||
'force_refresh': False
|
||||
},
|
||||
'odds_live': {
|
||||
'max_age': 120, # 2 minutes for live games (odds change rapidly)
|
||||
'memory_ttl': 240,
|
||||
'force_refresh': False
|
||||
},
|
||||
|
||||
# Static/stable data
|
||||
'team_info': {
|
||||
'max_age': 604800, # 1 week
|
||||
'memory_ttl': 1209600,
|
||||
'force_refresh': False
|
||||
},
|
||||
'logos': {
|
||||
'max_age': 2592000, # 30 days
|
||||
'memory_ttl': 5184000,
|
||||
'force_refresh': False
|
||||
},
|
||||
|
||||
# Default fallback
|
||||
'default': {
|
||||
'max_age': 300, # 5 minutes
|
||||
'memory_ttl': 600,
|
||||
'force_refresh': False
|
||||
}
|
||||
}
|
||||
|
||||
return strategies.get(data_type, strategies['default'])
|
||||
return self._strategy_component.get_cache_strategy(data_type, sport_key)
|
||||
|
||||
def get_data_type_from_key(self, key: str) -> str:
|
||||
"""
|
||||
Determine the appropriate cache strategy based on the cache key.
|
||||
This helps automatically select the right cache duration.
|
||||
"""
|
||||
key_lower = key.lower()
|
||||
|
||||
# Live sports data
|
||||
if any(x in key_lower for x in ['live', 'current', 'scoreboard']):
|
||||
if 'soccer' in key_lower:
|
||||
return 'sports_live' # Soccer live data is very time-sensitive
|
||||
return 'sports_live'
|
||||
|
||||
# Weather data
|
||||
if 'weather' in key_lower:
|
||||
return 'weather_current'
|
||||
|
||||
# Market data
|
||||
if 'stock' in key_lower or 'crypto' in key_lower:
|
||||
if 'crypto' in key_lower:
|
||||
return 'crypto'
|
||||
return 'stocks'
|
||||
|
||||
# News data
|
||||
if 'news' in key_lower:
|
||||
return 'news'
|
||||
|
||||
# Odds data - differentiate between live and upcoming games
|
||||
if 'odds' in key_lower:
|
||||
# For live games, use shorter cache; for upcoming games, use longer cache
|
||||
if any(x in key_lower for x in ['live', 'current']):
|
||||
return 'odds_live' # Live odds change more frequently
|
||||
return 'odds' # Regular odds for upcoming games
|
||||
|
||||
# Sports schedules and team info
|
||||
if any(x in key_lower for x in ['schedule', 'team_map', 'league']):
|
||||
return 'sports_schedules'
|
||||
|
||||
# Recent games (last few hours)
|
||||
if 'recent' in key_lower:
|
||||
return 'sports_recent'
|
||||
|
||||
# Upcoming games
|
||||
if 'upcoming' in key_lower:
|
||||
return 'sports_upcoming'
|
||||
|
||||
# Static data like logos, team info
|
||||
if any(x in key_lower for x in ['logo', 'team_info', 'config']):
|
||||
return 'team_info'
|
||||
|
||||
# Default fallback
|
||||
return 'default'
|
||||
return self._strategy_component.get_data_type_from_key(key)
|
||||
|
||||
def get_sport_key_from_cache_key(self, key: str) -> Optional[str]:
|
||||
"""
|
||||
Extract sport key from cache key to determine appropriate live_update_interval.
|
||||
"""
|
||||
key_lower = key.lower()
|
||||
|
||||
# Map cache key patterns to sport keys
|
||||
sport_patterns = {
|
||||
'nfl': ['nfl'],
|
||||
'nba': ['nba', 'basketball'],
|
||||
'mlb': ['mlb', 'baseball'],
|
||||
'nhl': ['nhl', 'hockey'],
|
||||
'soccer': ['soccer'],
|
||||
'ncaa_fb': ['ncaa_fb', 'ncaafb', 'college_football'],
|
||||
'ncaa_baseball': ['ncaa_baseball', 'college_baseball'],
|
||||
'ncaam_basketball': ['ncaam_basketball', 'college_basketball'],
|
||||
'milb': ['milb', 'minor_league'],
|
||||
}
|
||||
|
||||
for sport_key, patterns in sport_patterns.items():
|
||||
if any(pattern in key_lower for pattern in patterns):
|
||||
return sport_key
|
||||
|
||||
return None
|
||||
return self._strategy_component.get_sport_key_from_cache_key(key)
|
||||
|
||||
def get_cached_data_with_strategy(self, key: str, data_type: str = 'default') -> Optional[Dict]:
|
||||
def get_cached_data_with_strategy(self, key: str, data_type: str = 'default') -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get data from cache using data-type-specific strategy.
|
||||
Now respects sport-specific live_update_interval configurations.
|
||||
@@ -723,14 +592,14 @@ class CacheManager:
|
||||
# Extract sport key for live sports data
|
||||
sport_key = None
|
||||
if data_type in ['sports_live', 'live_scores']:
|
||||
sport_key = self.get_sport_key_from_cache_key(key)
|
||||
sport_key = self._strategy_component.get_sport_key_from_cache_key(key)
|
||||
|
||||
strategy = self.get_cache_strategy(data_type, sport_key)
|
||||
strategy = self._strategy_component.get_cache_strategy(data_type, sport_key)
|
||||
max_age = strategy['max_age']
|
||||
memory_ttl = strategy.get('memory_ttl', max_age)
|
||||
|
||||
# For market data, check if market is open
|
||||
if strategy.get('market_hours_only', False) and not self._is_market_open():
|
||||
if strategy.get('market_hours_only', False) and not self._strategy_component.is_market_open():
|
||||
# During off-hours, extend cache duration
|
||||
max_age *= 4 # 4x longer cache during off-hours
|
||||
|
||||
@@ -740,7 +609,7 @@ class CacheManager:
|
||||
return record['data']
|
||||
return record
|
||||
|
||||
def get_with_auto_strategy(self, key: str) -> Optional[Dict]:
|
||||
def get_with_auto_strategy(self, key: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get cached data using automatically determined strategy.
|
||||
Now respects sport-specific live_update_interval configurations.
|
||||
@@ -748,7 +617,7 @@ class CacheManager:
|
||||
data_type = self.get_data_type_from_key(key)
|
||||
return self.get_cached_data_with_strategy(key, data_type)
|
||||
|
||||
def get_background_cached_data(self, key: str, sport_key: str = None) -> Optional[Dict]:
|
||||
def get_background_cached_data(self, key: str, sport_key: Optional[str] = None) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get data from background service cache with appropriate strategy.
|
||||
This method is specifically designed for Recent/Upcoming managers
|
||||
@@ -785,7 +654,7 @@ class CacheManager:
|
||||
self.record_cache_miss('background')
|
||||
return None
|
||||
|
||||
def is_background_data_available(self, key: str, sport_key: str = None) -> bool:
|
||||
def is_background_data_available(self, key: str, sport_key: Optional[str] = None) -> bool:
|
||||
"""
|
||||
Check if background service has fresh data available.
|
||||
This helps Recent/Upcoming managers determine if they should
|
||||
@@ -798,7 +667,7 @@ class CacheManager:
|
||||
cached_data = self.get_cached_data(key, strategy['max_age'])
|
||||
return cached_data is not None
|
||||
|
||||
def generate_sport_cache_key(self, sport: str, date_str: str = None) -> str:
|
||||
def generate_sport_cache_key(self, sport: str, date_str: Optional[str] = None) -> str:
|
||||
"""
|
||||
Centralized cache key generation for sports data.
|
||||
This ensures consistent cache keys across background service and managers.
|
||||
@@ -814,7 +683,7 @@ class CacheManager:
|
||||
date_str = datetime.now(pytz.utc).strftime('%Y%m%d')
|
||||
return f"{sport}_{date_str}"
|
||||
|
||||
def record_cache_hit(self, cache_type: str = 'regular'):
|
||||
def record_cache_hit(self, cache_type: str = 'regular') -> None:
|
||||
"""Record a cache hit for performance monitoring."""
|
||||
with self._cache_lock:
|
||||
if cache_type == 'background':
|
||||
@@ -822,7 +691,7 @@ class CacheManager:
|
||||
else:
|
||||
self._cache_metrics['hits'] += 1
|
||||
|
||||
def record_cache_miss(self, cache_type: str = 'regular'):
|
||||
def record_cache_miss(self, cache_type: str = 'regular') -> None:
|
||||
"""Record a cache miss for performance monitoring."""
|
||||
with self._cache_lock:
|
||||
if cache_type == 'background':
|
||||
@@ -831,7 +700,7 @@ class CacheManager:
|
||||
self._cache_metrics['misses'] += 1
|
||||
self._cache_metrics['api_calls_saved'] += 1
|
||||
|
||||
def record_fetch_time(self, duration: float):
|
||||
def record_fetch_time(self, duration: float) -> None:
|
||||
"""Record fetch operation duration for performance monitoring."""
|
||||
with self._cache_lock:
|
||||
self._cache_metrics['total_fetch_time'] += duration
|
||||
@@ -859,10 +728,33 @@ class CacheManager:
|
||||
'fetch_count': self._cache_metrics['fetch_count']
|
||||
}
|
||||
|
||||
def log_cache_metrics(self):
|
||||
def log_cache_metrics(self) -> None:
|
||||
"""Log current cache performance metrics."""
|
||||
metrics = self.get_cache_metrics()
|
||||
self.logger.info(f"Cache Performance - Hit Rate: {metrics['cache_hit_rate']:.2%}, "
|
||||
f"Background Hit Rate: {metrics['background_hit_rate']:.2%}, "
|
||||
f"API Calls Saved: {metrics['api_calls_saved']}, "
|
||||
f"Avg Fetch Time: {metrics['average_fetch_time']:.2f}s")
|
||||
f"Avg Fetch Time: {metrics['average_fetch_time']:.2f}s")
|
||||
|
||||
def get_memory_cache_stats(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Get statistics about the memory cache.
|
||||
|
||||
Returns:
|
||||
Dictionary with memory cache statistics
|
||||
"""
|
||||
with self._cache_lock:
|
||||
return {
|
||||
'size': len(self._memory_cache),
|
||||
'max_size': self._max_memory_cache_size,
|
||||
'usage_percent': (len(self._memory_cache) / self._max_memory_cache_size * 100) if self._max_memory_cache_size > 0 else 0,
|
||||
'last_cleanup': self._last_memory_cache_cleanup,
|
||||
'cleanup_interval': self._memory_cache_cleanup_interval
|
||||
}
|
||||
|
||||
def log_memory_cache_stats(self) -> None:
|
||||
"""Log current memory cache statistics."""
|
||||
stats = self.get_memory_cache_stats()
|
||||
self.logger.info(f"Memory Cache - Size: {stats['size']}/{stats['max_size']} "
|
||||
f"({stats['usage_percent']:.1f}%), "
|
||||
f"Last cleanup: {time.time() - stats['last_cleanup']:.1f}s ago")
|
||||
@@ -1,349 +0,0 @@
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from google.oauth2.credentials import Credentials
|
||||
from google_auth_oauthlib.flow import InstalledAppFlow
|
||||
from google.auth.transport.requests import Request
|
||||
from googleapiclient.discovery import build
|
||||
import pickle
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
import numpy as np
|
||||
import pytz
|
||||
from src.config_manager import ConfigManager
|
||||
import time
|
||||
|
||||
# Configure logger for this module
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.setLevel(logging.INFO) # Set to INFO level
|
||||
|
||||
class CalendarManager:
|
||||
def __init__(self, display_manager, config):
|
||||
logger.info("Initializing CalendarManager")
|
||||
self.display_manager = display_manager
|
||||
self.config = config
|
||||
self.calendar_config = config.get('calendar', {})
|
||||
self.enabled = self.calendar_config.get('enabled', False)
|
||||
self.update_interval = self.calendar_config.get('update_interval', 300)
|
||||
self.max_events = self.calendar_config.get('max_events', 3)
|
||||
self.calendars = self.calendar_config.get('calendars', ['birthdays'])
|
||||
self.last_update = 0
|
||||
self.last_display_log = 0 # Add timestamp for display message throttling
|
||||
self.events = []
|
||||
self.service = None
|
||||
|
||||
# Log font information during initialization
|
||||
logger.info(f"Display Manager fonts:")
|
||||
logger.info(f" Small font: {self.display_manager.small_font}")
|
||||
logger.info(f" Calendar font: {self.display_manager.calendar_font}")
|
||||
logger.info(f" Font types - Small: {type(self.display_manager.small_font)}, Calendar: {type(self.display_manager.calendar_font)}")
|
||||
|
||||
logger.info(f"Calendar configuration: enabled={self.enabled}, update_interval={self.update_interval}, max_events={self.max_events}, calendars={self.calendars}")
|
||||
|
||||
# Get timezone from config
|
||||
timezone_str = self.config.get('timezone', 'UTC')
|
||||
logger.info(f"Loading timezone from config: {timezone_str}")
|
||||
try:
|
||||
self.timezone = pytz.timezone(timezone_str)
|
||||
logger.info(f"Successfully loaded timezone: {self.timezone}")
|
||||
except pytz.UnknownTimeZoneError:
|
||||
logger.warning(f"Unknown timezone '{timezone_str}' in config, defaulting to UTC.")
|
||||
self.timezone = pytz.utc
|
||||
|
||||
if self.enabled:
|
||||
self.authenticate()
|
||||
else:
|
||||
logger.warning("Calendar manager is disabled in configuration")
|
||||
|
||||
# Display properties
|
||||
self.text_color = (255, 255, 255) # White
|
||||
self.time_color = (255, 255, 255) # White
|
||||
self.date_color = (255, 255, 255) # White
|
||||
|
||||
# State management
|
||||
self.current_event_index = 0
|
||||
self.force_clear = False
|
||||
|
||||
def authenticate(self):
|
||||
"""Authenticate with Google Calendar API."""
|
||||
logger.info("Starting calendar authentication")
|
||||
creds = None
|
||||
token_file = self.calendar_config.get('token_file', 'token.pickle')
|
||||
|
||||
if os.path.exists(token_file):
|
||||
logger.info(f"Loading credentials from {token_file}")
|
||||
with open(token_file, 'rb') as token:
|
||||
creds = pickle.load(token)
|
||||
|
||||
if not creds or not creds.valid:
|
||||
logger.info("Credentials not found or invalid")
|
||||
if creds and creds.expired and creds.refresh_token:
|
||||
logger.info("Refreshing expired credentials")
|
||||
creds.refresh(Request())
|
||||
else:
|
||||
logger.info("Requesting new credentials")
|
||||
flow = InstalledAppFlow.from_client_secrets_file(
|
||||
self.calendar_config.get('credentials_file', 'credentials.json'),
|
||||
['https://www.googleapis.com/auth/calendar.readonly'])
|
||||
creds = flow.run_local_server(port=0)
|
||||
|
||||
logger.info(f"Saving credentials to {token_file}")
|
||||
with open(token_file, 'wb') as token:
|
||||
pickle.dump(creds, token)
|
||||
|
||||
self.service = build('calendar', 'v3', credentials=creds)
|
||||
logger.info("Calendar service built successfully")
|
||||
|
||||
def get_events(self):
|
||||
"""Fetch upcoming calendar events."""
|
||||
if not self.enabled or not self.service:
|
||||
return []
|
||||
|
||||
try:
|
||||
now = datetime.utcnow().isoformat() + 'Z'
|
||||
events_result = self.service.events().list(
|
||||
calendarId='primary',
|
||||
timeMin=now,
|
||||
maxResults=self.max_events,
|
||||
singleEvents=True,
|
||||
orderBy='startTime'
|
||||
).execute()
|
||||
|
||||
events = events_result.get('items', [])
|
||||
|
||||
# Log event details
|
||||
if events:
|
||||
logger.info(f"Found {len(events)} calendar events:")
|
||||
for event in events:
|
||||
summary = event.get('summary', 'No Title')
|
||||
start = event.get('start', {}).get('dateTime', event.get('start', {}).get('date'))
|
||||
end = event.get('end', {}).get('dateTime', event.get('end', {}).get('date'))
|
||||
logger.info(f" Event: {summary}")
|
||||
logger.info(f" Start: {start}")
|
||||
logger.info(f" End: {end}")
|
||||
else:
|
||||
logger.info("No upcoming calendar events found")
|
||||
|
||||
return events
|
||||
except Exception as e:
|
||||
logging.error(f"Error fetching calendar events: {str(e)}")
|
||||
return []
|
||||
|
||||
def draw_event(self, event, y_position=2):
|
||||
"""Draw a single calendar event with proper spacing to avoid overlap."""
|
||||
try:
|
||||
# Get date, time, and summary
|
||||
date_text = self._format_event_date(event)
|
||||
time_text = self._format_event_time(event)
|
||||
datetime_text = f"{date_text} {time_text}".strip()
|
||||
summary = event.get('summary', 'No Title')
|
||||
|
||||
# Ensure BDF calendar font has a usable pixel height
|
||||
if hasattr(self.display_manager.calendar_font, 'set_char_size'):
|
||||
# 7px height for 5x7 BDF; FreeType expects 64 units per pixel
|
||||
try:
|
||||
self.display_manager.calendar_font.set_char_size(height=7 * 64)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Measurements
|
||||
matrix_width = self.display_manager.width
|
||||
date_font = self.display_manager.regular_font
|
||||
title_font = self.display_manager.calendar_font
|
||||
date_line_height = self.display_manager.get_font_height(date_font)
|
||||
title_line_height = self.display_manager.get_font_height(title_font)
|
||||
|
||||
# Draw centered date/time on first line
|
||||
datetime_width = self.display_manager.get_text_width(datetime_text, date_font)
|
||||
datetime_x = (matrix_width - datetime_width) // 2
|
||||
self.display_manager.draw_text(
|
||||
datetime_text,
|
||||
datetime_x,
|
||||
y_position,
|
||||
color=self.time_color,
|
||||
font=date_font,
|
||||
)
|
||||
|
||||
# Wrap summary to fit width with margins
|
||||
available_width = matrix_width - 4 # 2px margin on each side
|
||||
title_lines = self._wrap_text(summary, available_width, title_font, max_lines=2)
|
||||
|
||||
# Start summary beneath date/time with small spacing
|
||||
y_summary_top = y_position + date_line_height + 2
|
||||
for i, line in enumerate(title_lines):
|
||||
line_width = self.display_manager.get_text_width(line, title_font)
|
||||
line_x = (matrix_width - line_width) // 2
|
||||
line_y = y_summary_top + (i * title_line_height)
|
||||
self.display_manager.draw_text(
|
||||
line,
|
||||
line_x,
|
||||
line_y,
|
||||
color=self.text_color,
|
||||
font=title_font,
|
||||
)
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error drawing calendar event: {e}", exc_info=True)
|
||||
return False
|
||||
|
||||
def _wrap_text(self, text, max_width, font, max_lines=2):
|
||||
"""Wrap text to fit within max_width using the provided font."""
|
||||
if not text:
|
||||
return [""]
|
||||
|
||||
lines = []
|
||||
current_line = []
|
||||
words = text.split()
|
||||
|
||||
for word in words:
|
||||
# Try adding the word to the current line
|
||||
test_line = ' '.join(current_line + [word]) if current_line else word
|
||||
# Use display_manager's draw_text method to measure text width
|
||||
text_width = self.display_manager.get_text_width(test_line, font)
|
||||
|
||||
if text_width <= max_width:
|
||||
# Word fits, add it to current line
|
||||
current_line.append(word)
|
||||
else:
|
||||
# Word doesn't fit, start a new line
|
||||
if current_line:
|
||||
lines.append(' '.join(current_line))
|
||||
current_line = [word]
|
||||
else:
|
||||
# Single word too long, truncate it
|
||||
truncated = word
|
||||
while len(truncated) > 0:
|
||||
if self.display_manager.get_text_width(truncated + "...", font) <= max_width:
|
||||
lines.append(truncated + "...")
|
||||
break
|
||||
truncated = truncated[:-1]
|
||||
if not truncated:
|
||||
lines.append(word[:10] + "...")
|
||||
|
||||
# Check if we've filled all lines
|
||||
if len(lines) >= max_lines:
|
||||
break
|
||||
|
||||
# Handle any remaining text in current_line
|
||||
if current_line and len(lines) < max_lines:
|
||||
remaining_text = ' '.join(current_line)
|
||||
if len(words) > len(current_line): # More words remain
|
||||
# Try to fit with ellipsis
|
||||
while len(remaining_text) > 0:
|
||||
if self.display_manager.get_text_width(remaining_text + "...", font) <= max_width:
|
||||
lines.append(remaining_text + "...")
|
||||
break
|
||||
remaining_text = remaining_text[:-1]
|
||||
else:
|
||||
lines.append(remaining_text)
|
||||
|
||||
# Ensure we have exactly max_lines
|
||||
while len(lines) < max_lines:
|
||||
lines.append("")
|
||||
|
||||
return lines[:max_lines]
|
||||
|
||||
def update(self, current_time):
|
||||
"""Update calendar events if needed."""
|
||||
if not self.enabled:
|
||||
logger.debug("Calendar manager is disabled, skipping update")
|
||||
return
|
||||
|
||||
if current_time - self.last_update > self.update_interval:
|
||||
logger.info("Updating calendar events")
|
||||
self.events = self.get_events()
|
||||
self.last_update = current_time
|
||||
if not self.events:
|
||||
logger.info("No upcoming calendar events found.")
|
||||
else:
|
||||
logger.info(f"Fetched {len(self.events)} calendar events.")
|
||||
# Reset index if events change
|
||||
self.current_event_index = 0
|
||||
else:
|
||||
# Only log debug message every 5 seconds
|
||||
if current_time - self.last_display_log > 5:
|
||||
logger.debug("Skipping calendar update - not enough time has passed")
|
||||
self.last_display_log = current_time
|
||||
|
||||
def _format_event_date(self, event):
|
||||
"""Format event date for display"""
|
||||
start = event.get('start', {}).get('dateTime', event.get('start', {}).get('date'))
|
||||
if not start:
|
||||
return ""
|
||||
|
||||
try:
|
||||
# Handle both date and dateTime formats
|
||||
if 'T' in start:
|
||||
# The datetime string already includes timezone info (-05:00)
|
||||
dt = datetime.fromisoformat(start)
|
||||
else:
|
||||
dt = datetime.strptime(start, '%Y-%m-%d')
|
||||
# Make date object timezone-aware (assume UTC if no tz info)
|
||||
dt = pytz.utc.localize(dt)
|
||||
|
||||
# No need to convert timezone since it's already in the correct one
|
||||
return dt.strftime("%a %-m/%-d") # e.g., "Mon 4/21"
|
||||
except ValueError as e:
|
||||
logging.error(f"Could not parse date string: {start} - {e}")
|
||||
return ""
|
||||
|
||||
def _format_event_time(self, event):
|
||||
"""Format event time for display"""
|
||||
start = event.get('start', {}).get('dateTime', event.get('start', {}).get('date'))
|
||||
if not start or 'T' not in start: # Only show time for dateTime events
|
||||
return "All Day"
|
||||
|
||||
try:
|
||||
# The datetime string already includes timezone info (-05:00)
|
||||
dt = datetime.fromisoformat(start)
|
||||
# No need to convert timezone since it's already in the correct one
|
||||
return dt.strftime("%I:%M%p")
|
||||
except ValueError as e:
|
||||
logging.error(f"Could not parse time string: {start} - {e}")
|
||||
return "Invalid Time"
|
||||
|
||||
def display(self, force_clear=False):
|
||||
"""Display calendar events on the LED matrix."""
|
||||
if not self.enabled or not self.events:
|
||||
return
|
||||
|
||||
try:
|
||||
if force_clear:
|
||||
self.display_manager.clear()
|
||||
self.force_clear = True
|
||||
|
||||
if self.current_event_index >= len(self.events):
|
||||
self.current_event_index = 0
|
||||
|
||||
event = self.events[self.current_event_index]
|
||||
|
||||
# Log the event being displayed, but only every 5 seconds
|
||||
current_time = time.time()
|
||||
if current_time - self.last_display_log > 5:
|
||||
summary = event.get('summary', 'No Title')
|
||||
date_text = self._format_event_date(event)
|
||||
time_text = self._format_event_time(event)
|
||||
logger.info(f"Displaying calendar event: {summary}")
|
||||
logger.info(f" Date: {date_text}")
|
||||
logger.info(f" Time: {time_text}")
|
||||
self.last_display_log = current_time
|
||||
|
||||
# Draw the event
|
||||
self.draw_event(event)
|
||||
|
||||
# Update the display
|
||||
self.display_manager.update_display()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error displaying calendar event: {e}", exc_info=True)
|
||||
|
||||
def advance_event(self):
|
||||
"""Advance to the next event. Called by DisplayManager when calendar display time is up."""
|
||||
if not self.enabled:
|
||||
logger.debug("Calendar manager is disabled, skipping event advance")
|
||||
return
|
||||
self.current_event_index += 1
|
||||
if self.current_event_index >= len(self.events):
|
||||
self.current_event_index = 0
|
||||
logger.debug(f"CalendarManager advanced to event index {self.current_event_index}")
|
||||
149
src/clock.py
149
src/clock.py
@@ -1,149 +0,0 @@
|
||||
import time
|
||||
import logging
|
||||
from datetime import datetime
|
||||
import pytz
|
||||
from typing import Dict, Any
|
||||
from src.config_manager import ConfigManager
|
||||
from src.display_manager import DisplayManager
|
||||
|
||||
# Get logger without configuring
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class Clock:
|
||||
def __init__(self, display_manager: DisplayManager = None, config: Dict[str, Any] = None):
|
||||
if config is not None:
|
||||
# Use provided config
|
||||
self.config = config
|
||||
self.config_manager = None # Not needed when config is provided
|
||||
else:
|
||||
# Fallback: create ConfigManager and load config (for standalone usage)
|
||||
self.config_manager = ConfigManager()
|
||||
self.config = self.config_manager.load_config()
|
||||
# Use the provided display_manager or create a new one if none provided
|
||||
self.display_manager = display_manager or DisplayManager(self.config.get('display', {}))
|
||||
logger.info("Clock initialized with display_manager: %s", id(self.display_manager))
|
||||
self.location = self.config.get('location', {})
|
||||
self.clock_config = self.config.get('clock', {})
|
||||
# Use configured timezone if available, otherwise try to determine it
|
||||
self.timezone = self._get_timezone()
|
||||
self.last_time = None
|
||||
self.last_date = None
|
||||
self.ampm_padding = 4
|
||||
# Colors for different elements - using super bright colors
|
||||
self.COLORS = {
|
||||
'time': (255, 255, 255), # Pure white for time
|
||||
'ampm': (255, 255, 128), # Bright warm yellow for AM/PM
|
||||
'date': (255, 128, 64) # Bright orange for date
|
||||
}
|
||||
|
||||
def _get_timezone(self) -> pytz.timezone:
|
||||
"""Get timezone from the config file."""
|
||||
config_timezone = self.config.get('timezone', 'UTC')
|
||||
try:
|
||||
return pytz.timezone(config_timezone)
|
||||
except pytz.exceptions.UnknownTimeZoneError:
|
||||
logger.warning(
|
||||
f"Invalid timezone '{config_timezone}' in config. "
|
||||
"Falling back to UTC. Please check your config.json file. "
|
||||
"A list of valid timezones can be found at "
|
||||
"https://en.wikipedia.org/wiki/List_of_tz_database_time_zones"
|
||||
)
|
||||
return pytz.utc
|
||||
|
||||
def _get_ordinal_suffix(self, day: int) -> str:
|
||||
"""Get the ordinal suffix for a day number (1st, 2nd, 3rd, etc.)."""
|
||||
if 10 <= day % 100 <= 20:
|
||||
suffix = 'th'
|
||||
else:
|
||||
suffix = {1: 'st', 2: 'nd', 3: 'rd'}.get(day % 10, 'th')
|
||||
return suffix
|
||||
|
||||
def get_current_time(self) -> tuple:
|
||||
"""Get the current time and date in the configured timezone."""
|
||||
current = datetime.now(self.timezone)
|
||||
|
||||
# Format time in 12-hour format with AM/PM
|
||||
time_str = current.strftime('%I:%M') # Remove leading zero from hour
|
||||
if time_str.startswith('0'):
|
||||
time_str = time_str[1:]
|
||||
|
||||
# Get AM/PM
|
||||
ampm = current.strftime('%p')
|
||||
|
||||
# Format date with ordinal suffix - split into two lines
|
||||
day_suffix = self._get_ordinal_suffix(current.day)
|
||||
# Full weekday on first line, full month and day on second line
|
||||
weekday = current.strftime('%A')
|
||||
date_str = current.strftime(f'%B %-d{day_suffix}')
|
||||
|
||||
return time_str, ampm, weekday, date_str
|
||||
|
||||
def display_time(self, force_clear: bool = False) -> None:
|
||||
"""Display the current time and date."""
|
||||
time_str, ampm, weekday, date_str = self.get_current_time()
|
||||
|
||||
# Only update if something has changed
|
||||
if time_str != self.last_time or date_str != self.last_date or force_clear:
|
||||
# Clear the display
|
||||
self.display_manager.clear()
|
||||
|
||||
# Calculate positions
|
||||
display_width = self.display_manager.matrix.width
|
||||
display_height = self.display_manager.matrix.height
|
||||
|
||||
# Center time based on full width of time + ampm
|
||||
time_width = self.display_manager.font.getlength(f"{time_str}")
|
||||
ampm_width = self.display_manager.font.getlength(f"{ampm}")
|
||||
time_x = (display_width - (time_width + self.ampm_padding + ampm_width)) // 2
|
||||
# Draw time (large, centered, near top)
|
||||
self.display_manager.draw_text(
|
||||
time_str,
|
||||
x=time_x,
|
||||
y=4, # Move up slightly to make room for two lines of date
|
||||
color=self.COLORS['time'],
|
||||
small_font=True
|
||||
)
|
||||
|
||||
# Draw AM/PM (small, next to time)
|
||||
ampm_x = time_x + self.ampm_padding + time_width
|
||||
self.display_manager.draw_text(
|
||||
ampm,
|
||||
x=ampm_x,
|
||||
y=4, # Align with time
|
||||
color=self.COLORS['ampm'],
|
||||
small_font=True
|
||||
)
|
||||
|
||||
# Draw weekday on first line (small font)
|
||||
self.display_manager.draw_text(
|
||||
weekday,
|
||||
y=display_height - 18, # First line of date
|
||||
color=self.COLORS['date'],
|
||||
small_font=True
|
||||
)
|
||||
|
||||
# Draw month and day on second line (small font)
|
||||
self.display_manager.draw_text(
|
||||
date_str,
|
||||
y=display_height - 9, # Second line of date
|
||||
color=self.COLORS['date'],
|
||||
small_font=True
|
||||
)
|
||||
|
||||
# Update the display after drawing everything
|
||||
self.display_manager.update_display()
|
||||
|
||||
# Update cache
|
||||
self.last_time = time_str
|
||||
self.last_date = date_str
|
||||
|
||||
if __name__ == "__main__":
|
||||
clock = Clock()
|
||||
try:
|
||||
while True:
|
||||
clock.display_time()
|
||||
time.sleep(clock.clock_config.get('update_interval', 1))
|
||||
except KeyboardInterrupt:
|
||||
print("\nClock stopped by user")
|
||||
finally:
|
||||
clock.display_manager.cleanup()
|
||||
79
src/common/README.md
Normal file
79
src/common/README.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# Common Utilities
|
||||
|
||||
This directory contains reusable utilities and helpers for LEDMatrix plugins and core modules.
|
||||
|
||||
## 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.
|
||||
|
||||
## Text Helpers (`text_helper.py`)
|
||||
|
||||
Utilities for text processing and formatting.
|
||||
|
||||
## Scroll Helpers (`scroll_helper.py`)
|
||||
|
||||
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()`)
|
||||
|
||||
## 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
|
||||
40
src/common/__init__.py
Normal file
40
src/common/__init__.py
Normal file
@@ -0,0 +1,40 @@
|
||||
"""
|
||||
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
|
||||
"""
|
||||
|
||||
# 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.logo_helper import LogoHelper
|
||||
from src.common.text_helper import TextHelper
|
||||
|
||||
__all__ = [
|
||||
'handle_file_operation',
|
||||
'handle_json_operation',
|
||||
'safe_execute',
|
||||
'retry_on_failure',
|
||||
'log_and_continue',
|
||||
'log_and_raise',
|
||||
'APIHelper',
|
||||
'ScrollHelper',
|
||||
'LogoHelper',
|
||||
'TextHelper',
|
||||
]
|
||||
334
src/common/api_helper.py
Normal file
334
src/common/api_helper.py
Normal file
@@ -0,0 +1,334 @@
|
||||
"""
|
||||
API Helper
|
||||
|
||||
Handles HTTP requests, caching, and ESPN API integration for LED matrix plugins.
|
||||
Extracted from LEDMatrix core to provide reusable functionality for plugins.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import requests
|
||||
from requests.adapters import HTTPAdapter
|
||||
from urllib3.util.retry import Retry
|
||||
|
||||
|
||||
class APIHelper:
|
||||
"""
|
||||
Helper class for HTTP requests, caching, and ESPN API integration.
|
||||
|
||||
Provides functionality for:
|
||||
- HTTP requests with retry logic and timeouts
|
||||
- Response caching with TTL support
|
||||
- ESPN API integration for sports data
|
||||
- Request rate limiting and throttling
|
||||
"""
|
||||
|
||||
def __init__(self, cache_manager=None, default_timeout: int = 30,
|
||||
max_retries: int = 3, logger: Optional[logging.Logger] = None):
|
||||
"""
|
||||
Initialize the APIHelper.
|
||||
|
||||
Args:
|
||||
cache_manager: Optional cache manager for response caching
|
||||
default_timeout: Default timeout for requests in seconds
|
||||
max_retries: Maximum number of retry attempts
|
||||
logger: Optional logger instance
|
||||
"""
|
||||
self.cache_manager = cache_manager
|
||||
self.default_timeout = default_timeout
|
||||
self.max_retries = max_retries
|
||||
self.logger = logger or logging.getLogger(__name__)
|
||||
|
||||
# Setup session with retry strategy
|
||||
self.session = requests.Session()
|
||||
retry_strategy = Retry(
|
||||
total=max_retries,
|
||||
backoff_factor=1,
|
||||
status_forcelist=[429, 500, 502, 503, 504],
|
||||
allowed_methods=["GET", "HEAD", "OPTIONS"]
|
||||
)
|
||||
adapter = HTTPAdapter(max_retries=retry_strategy)
|
||||
self.session.mount("https://", adapter)
|
||||
self.session.mount("http://", adapter)
|
||||
|
||||
# Default headers
|
||||
self.session.headers.update({
|
||||
'User-Agent': 'LEDMatrix-Common/1.0',
|
||||
'Accept': 'application/json',
|
||||
'Accept-Language': 'en-US,en;q=0.9',
|
||||
'Accept-Encoding': 'gzip, deflate, br',
|
||||
'Connection': 'keep-alive'
|
||||
})
|
||||
|
||||
# Rate limiting
|
||||
self._last_request_time = 0
|
||||
self._min_request_interval = 1.0 # Minimum seconds between requests
|
||||
|
||||
def get(self, url: str, params: Optional[Dict] = None,
|
||||
headers: Optional[Dict] = None, timeout: Optional[int] = None,
|
||||
cache_key: Optional[str] = None, cache_ttl: int = 3600) -> Optional[Dict]:
|
||||
"""
|
||||
Make a GET request with optional caching.
|
||||
|
||||
Args:
|
||||
url: URL to request
|
||||
params: Query parameters
|
||||
headers: Additional headers
|
||||
timeout: Request timeout (uses default if None)
|
||||
cache_key: Key for caching response
|
||||
cache_ttl: Cache time-to-live in seconds
|
||||
|
||||
Returns:
|
||||
Response data as dictionary or None if request fails
|
||||
"""
|
||||
# Check cache first
|
||||
if cache_key and self.cache_manager:
|
||||
cached = self._get_from_cache(cache_key)
|
||||
if cached is not None:
|
||||
self.logger.debug(f"Using cached response for {cache_key}")
|
||||
return cached
|
||||
|
||||
# Rate limiting
|
||||
self._enforce_rate_limit()
|
||||
|
||||
try:
|
||||
# Prepare request
|
||||
request_headers = self.session.headers.copy()
|
||||
if headers:
|
||||
request_headers.update(headers)
|
||||
|
||||
# Make request
|
||||
response = self.session.get(
|
||||
url,
|
||||
params=params,
|
||||
headers=request_headers,
|
||||
timeout=timeout or self.default_timeout
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
# Parse JSON response
|
||||
data = response.json()
|
||||
|
||||
# Cache response if cache key provided
|
||||
if cache_key and self.cache_manager:
|
||||
self._set_cache(cache_key, data, cache_ttl)
|
||||
|
||||
self.logger.debug(f"Successfully fetched {url}")
|
||||
return data
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
self.logger.error(f"Request failed for {url}: {e}")
|
||||
return None
|
||||
|
||||
def fetch_espn_scoreboard(self, sport: str, league: str,
|
||||
date: Optional[str] = None,
|
||||
cache_key: Optional[str] = None,
|
||||
cache_ttl: int = 300) -> Optional[Dict]:
|
||||
"""
|
||||
Fetch ESPN scoreboard data for a specific sport and league.
|
||||
|
||||
Args:
|
||||
sport: Sport name (e.g., 'basketball', 'football')
|
||||
league: League name (e.g., 'nba', 'nfl')
|
||||
date: Date in YYYYMMDD format (defaults to today)
|
||||
cache_key: Cache key for response
|
||||
cache_ttl: Cache time-to-live in seconds
|
||||
|
||||
Returns:
|
||||
ESPN API response data or None if request fails
|
||||
"""
|
||||
if date is None:
|
||||
date = datetime.now().strftime('%Y%m%d')
|
||||
|
||||
# Build URL
|
||||
url = f"https://site.api.espn.com/apis/site/v2/sports/{sport}/{league}/scoreboard"
|
||||
|
||||
# Build cache key if not provided
|
||||
if cache_key is None:
|
||||
cache_key = f"espn_{sport}_{league}_{date}"
|
||||
|
||||
# Set parameters
|
||||
params = {
|
||||
'dates': date,
|
||||
'limit': 1000
|
||||
}
|
||||
|
||||
return self.get(url, params=params, cache_key=cache_key, cache_ttl=cache_ttl)
|
||||
|
||||
def fetch_espn_standings(self, sport: str, league: str,
|
||||
cache_key: Optional[str] = None,
|
||||
cache_ttl: int = 3600) -> Optional[Dict]:
|
||||
"""
|
||||
Fetch ESPN standings data for a specific sport and league.
|
||||
|
||||
Args:
|
||||
sport: Sport name
|
||||
league: League name
|
||||
cache_key: Cache key for response
|
||||
cache_ttl: Cache time-to-live in seconds
|
||||
|
||||
Returns:
|
||||
ESPN standings data or None if request fails
|
||||
"""
|
||||
url = f"https://site.api.espn.com/apis/site/v2/sports/{sport}/{league}/standings"
|
||||
|
||||
if cache_key is None:
|
||||
cache_key = f"espn_standings_{sport}_{league}"
|
||||
|
||||
return self.get(url, cache_key=cache_key, cache_ttl=cache_ttl)
|
||||
|
||||
def fetch_espn_rankings(self, sport: str, league: str,
|
||||
cache_key: Optional[str] = None,
|
||||
cache_ttl: int = 3600) -> Optional[Dict]:
|
||||
"""
|
||||
Fetch ESPN rankings data for a specific sport and league.
|
||||
|
||||
Args:
|
||||
sport: Sport name
|
||||
league: League name
|
||||
cache_key: Cache key for response
|
||||
cache_ttl: Cache time-to-live in seconds
|
||||
|
||||
Returns:
|
||||
ESPN rankings data or None if request fails
|
||||
"""
|
||||
url = f"https://site.api.espn.com/apis/site/v2/sports/{sport}/{league}/rankings"
|
||||
|
||||
if cache_key is None:
|
||||
cache_key = f"espn_rankings_{sport}_{league}"
|
||||
|
||||
return self.get(url, cache_key=cache_key, cache_ttl=cache_ttl)
|
||||
|
||||
def post(self, url: str, data: Optional[Dict] = None,
|
||||
json_data: Optional[Dict] = None,
|
||||
headers: Optional[Dict] = None,
|
||||
timeout: Optional[int] = None) -> Optional[Dict]:
|
||||
"""
|
||||
Make a POST request.
|
||||
|
||||
Args:
|
||||
url: URL to request
|
||||
data: Form data
|
||||
json_data: JSON data
|
||||
headers: Additional headers
|
||||
timeout: Request timeout
|
||||
|
||||
Returns:
|
||||
Response data as dictionary or None if request fails
|
||||
"""
|
||||
self._enforce_rate_limit()
|
||||
|
||||
try:
|
||||
request_headers = self.session.headers.copy()
|
||||
if headers:
|
||||
request_headers.update(headers)
|
||||
|
||||
response = self.session.post(
|
||||
url,
|
||||
data=data,
|
||||
json=json_data,
|
||||
headers=request_headers,
|
||||
timeout=timeout or self.default_timeout
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
return response.json()
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
self.logger.error(f"POST request failed for {url}: {e}")
|
||||
return None
|
||||
|
||||
def set_cache(self, key: str, data: Any, ttl: int = 3600) -> None:
|
||||
"""
|
||||
Set cache data.
|
||||
|
||||
Args:
|
||||
key: Cache key
|
||||
data: Data to cache
|
||||
ttl: Time-to-live in seconds (ignored - CacheManager doesn't support TTL)
|
||||
"""
|
||||
if self.cache_manager:
|
||||
self.cache_manager.set(key, data)
|
||||
|
||||
def get_cache(self, key: str) -> Optional[Any]:
|
||||
"""
|
||||
Get cached data.
|
||||
|
||||
Args:
|
||||
key: Cache key
|
||||
|
||||
Returns:
|
||||
Cached data or None if not found
|
||||
"""
|
||||
if self.cache_manager:
|
||||
return self.cache_manager.get(key)
|
||||
return None
|
||||
|
||||
def clear_cache(self, pattern: Optional[str] = None) -> None:
|
||||
"""
|
||||
Clear cache data.
|
||||
|
||||
Args:
|
||||
pattern: Optional pattern to match cache keys
|
||||
"""
|
||||
if self.cache_manager:
|
||||
if hasattr(self.cache_manager, 'clear'):
|
||||
if pattern:
|
||||
# Clear only keys matching pattern
|
||||
keys = self.cache_manager.keys()
|
||||
for key in keys:
|
||||
if pattern in key:
|
||||
self.cache_manager.delete(key)
|
||||
else:
|
||||
self.cache_manager.clear()
|
||||
|
||||
def _get_from_cache(self, key: str) -> Optional[Any]:
|
||||
"""Get data from cache."""
|
||||
if self.cache_manager:
|
||||
return self.cache_manager.get(key)
|
||||
return None
|
||||
|
||||
def _set_cache(self, key: str, data: Any, ttl: int) -> None:
|
||||
"""Set data in cache."""
|
||||
if self.cache_manager:
|
||||
self.cache_manager.set(key, data)
|
||||
|
||||
def _enforce_rate_limit(self) -> None:
|
||||
"""Enforce rate limiting between requests."""
|
||||
current_time = time.time()
|
||||
time_since_last = current_time - self._last_request_time
|
||||
|
||||
if time_since_last < self._min_request_interval:
|
||||
sleep_time = self._min_request_interval - time_since_last
|
||||
time.sleep(sleep_time)
|
||||
|
||||
self._last_request_time = time.time()
|
||||
|
||||
def set_rate_limit(self, min_interval: float) -> None:
|
||||
"""
|
||||
Set minimum interval between requests.
|
||||
|
||||
Args:
|
||||
min_interval: Minimum seconds between requests
|
||||
"""
|
||||
self._min_request_interval = min_interval
|
||||
self.logger.debug(f"Rate limit set to {min_interval} seconds")
|
||||
|
||||
def get_request_stats(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Get request statistics.
|
||||
|
||||
Returns:
|
||||
Dictionary with request statistics
|
||||
"""
|
||||
return {
|
||||
'min_request_interval': self._min_request_interval,
|
||||
'last_request_time': self._last_request_time,
|
||||
'time_since_last_request': time.time() - self._last_request_time
|
||||
}
|
||||
330
src/common/basketball_plugin_example.py
Normal file
330
src/common/basketball_plugin_example.py
Normal file
@@ -0,0 +1,330 @@
|
||||
"""
|
||||
Example: Basketball Plugin using LEDMatrix Common Helpers
|
||||
|
||||
This example shows how to refactor the basketball plugin to use the
|
||||
ledmatrix-common package for cleaner, more maintainable code.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
# Import common helpers
|
||||
from src.common import (
|
||||
LogoHelper, TextHelper, APIHelper, DisplayHelper,
|
||||
GameHelper, ConfigHelper
|
||||
)
|
||||
from src.plugin_system.base_plugin import BasePlugin
|
||||
|
||||
|
||||
class BasketballPluginManager(BasePlugin):
|
||||
"""
|
||||
Basketball scoreboard plugin using LEDMatrix Common helpers.
|
||||
|
||||
This version is much cleaner and more maintainable than the original
|
||||
because it delegates common functionality to the shared helpers.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
plugin_id: str,
|
||||
config: Dict[str, Any],
|
||||
display_manager,
|
||||
cache_manager,
|
||||
plugin_manager
|
||||
):
|
||||
"""Initialize the basketball plugin with common helpers."""
|
||||
super().__init__(plugin_id, config, display_manager, cache_manager, plugin_manager)
|
||||
|
||||
# Get display dimensions
|
||||
self.display_width = display_manager.matrix.width
|
||||
self.display_height = display_manager.matrix.height
|
||||
|
||||
# Initialize common helpers
|
||||
self._init_helpers()
|
||||
|
||||
# Load configuration
|
||||
self._load_config()
|
||||
|
||||
# State tracking
|
||||
self.current_games = []
|
||||
self.current_game = None
|
||||
|
||||
# Log initialization
|
||||
enabled_leagues = [k for k, v in self.league_configs.items() if v['enabled']]
|
||||
self.logger.info(f"Basketball plugin initialized with leagues: {enabled_leagues}")
|
||||
|
||||
def _init_helpers(self):
|
||||
"""Initialize all common helpers."""
|
||||
# Logo helper for team logos
|
||||
self.logo_helper = LogoHelper(
|
||||
display_width=self.display_width,
|
||||
display_height=self.display_height,
|
||||
logger=self.logger
|
||||
)
|
||||
|
||||
# Text helper for rendering
|
||||
self.text_helper = TextHelper(logger=self.logger)
|
||||
self.fonts = self.text_helper.load_fonts()
|
||||
|
||||
# API helper for ESPN data
|
||||
self.api_helper = APIHelper(
|
||||
cache_manager=self.cache_manager,
|
||||
logger=self.logger
|
||||
)
|
||||
|
||||
# Display helper for layouts
|
||||
self.display_helper = DisplayHelper(
|
||||
display_width=self.display_width,
|
||||
display_height=self.display_height,
|
||||
logger=self.logger
|
||||
)
|
||||
|
||||
# Game helper for data processing
|
||||
self.game_helper = GameHelper(
|
||||
timezone_str=self.config.get('timezone', 'UTC'),
|
||||
logger=self.logger
|
||||
)
|
||||
|
||||
# Config helper for configuration management
|
||||
self.config_helper = ConfigHelper(logger=self.logger)
|
||||
|
||||
def _load_config(self):
|
||||
"""Load and validate configuration."""
|
||||
# Get basketball-specific config
|
||||
basketball_config = self.config_helper.get_sports_config(self.config, 'basketball')
|
||||
|
||||
# Build league configurations
|
||||
self.league_configs = {
|
||||
'nba': {
|
||||
'enabled': basketball_config.get('nba_enabled', True),
|
||||
'url': 'https://site.api.espn.com/apis/site/v2/sports/basketball/nba/scoreboard',
|
||||
'logo_dir': Path('assets/sports/nba_logos'),
|
||||
'favorite_teams': basketball_config.get('nba_favorite_teams', []),
|
||||
'display_modes': {
|
||||
'nba_live': basketball_config.get('nba_display_modes_live', True),
|
||||
'nba_recent': basketball_config.get('nba_display_modes_recent', True),
|
||||
'nba_upcoming': basketball_config.get('nba_display_modes_upcoming', True),
|
||||
},
|
||||
},
|
||||
'wnba': {
|
||||
'enabled': basketball_config.get('wnba_enabled', False),
|
||||
'url': 'https://site.api.espn.com/apis/site/v2/sports/basketball/wnba/scoreboard',
|
||||
'logo_dir': Path('assets/sports/wnba_logos'),
|
||||
'favorite_teams': basketball_config.get('wnba_favorite_teams', []),
|
||||
'display_modes': {
|
||||
'wnba_live': basketball_config.get('wnba_display_modes_live', True),
|
||||
'wnba_recent': basketball_config.get('wnba_display_modes_recent', True),
|
||||
'wnba_upcoming': basketball_config.get('wnba_display_modes_upcoming', True),
|
||||
},
|
||||
},
|
||||
'ncaam': {
|
||||
'enabled': basketball_config.get('ncaam_basketball_enabled', False),
|
||||
'url': 'https://site.api.espn.com/apis/site/v2/sports/basketball/mens-college-basketball/scoreboard',
|
||||
'logo_dir': Path('assets/sports/ncaa_logos'),
|
||||
'favorite_teams': basketball_config.get('ncaam_basketball_favorite_teams', []),
|
||||
'display_modes': {
|
||||
'ncaam_basketball_live': basketball_config.get('ncaam_basketball_display_modes_live', True),
|
||||
'ncaam_basketball_recent': basketball_config.get('ncaam_basketball_display_modes_recent', True),
|
||||
'ncaam_basketball_upcoming': basketball_config.get('ncaam_basketball_display_modes_upcoming', True),
|
||||
},
|
||||
},
|
||||
'ncaaw': {
|
||||
'enabled': basketball_config.get('ncaaw_basketball_enabled', False),
|
||||
'url': 'https://site.api.espn.com/apis/site/v2/sports/basketball/womens-college-basketball/scoreboard',
|
||||
'logo_dir': Path('assets/sports/ncaa_logos'),
|
||||
'favorite_teams': basketball_config.get('ncaaw_basketball_favorite_teams', []),
|
||||
'display_modes': {
|
||||
'ncaaw_basketball_live': basketball_config.get('ncaaw_basketball_display_modes_live', True),
|
||||
'ncaaw_basketball_recent': basketball_config.get('ncaaw_basketball_display_modes_recent', True),
|
||||
'ncaaw_basketball_upcoming': basketball_config.get('ncaaw_basketball_display_modes_upcoming', True),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
def update(self) -> None:
|
||||
"""Update game data for all enabled leagues."""
|
||||
try:
|
||||
all_games = []
|
||||
|
||||
for league_key, league_config in self.league_configs.items():
|
||||
if not league_config['enabled']:
|
||||
continue
|
||||
|
||||
games = self._fetch_league_games(league_key, league_config)
|
||||
for game in games:
|
||||
game['league_key'] = league_key
|
||||
game['league_config'] = league_config
|
||||
all_games.extend(games)
|
||||
|
||||
self.current_games = all_games
|
||||
self.logger.debug(f"Updated basketball data: {len(all_games)} total games")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error updating basketball data: {e}", exc_info=True)
|
||||
|
||||
def _fetch_league_games(self, league_key: str, league_config: Dict) -> List[Dict]:
|
||||
"""Fetch games for a specific league using API helper."""
|
||||
try:
|
||||
# Use API helper to fetch ESPN data with caching
|
||||
data = self.api_helper.fetch_espn_scoreboard(
|
||||
sport='basketball',
|
||||
league=league_key,
|
||||
cache_key=f"basketball_{league_key}",
|
||||
cache_ttl=300 # 5 minutes cache
|
||||
)
|
||||
|
||||
if not data:
|
||||
return []
|
||||
|
||||
# Use game helper to process events
|
||||
events = data.get('events', [])
|
||||
games = self.game_helper.process_games(events, sport='basketball')
|
||||
|
||||
# Add logo paths to games
|
||||
for game in games:
|
||||
logo_dir = league_config['logo_dir']
|
||||
game['home_logo_path'] = logo_dir / f"{game['home_abbr']}.png"
|
||||
game['away_logo_path'] = logo_dir / f"{game['away_abbr']}.png"
|
||||
|
||||
return games
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error fetching {league_key} games: {e}", exc_info=True)
|
||||
return []
|
||||
|
||||
def display(self, force_clear: bool = False, display_mode: str = None) -> None:
|
||||
"""Display basketball games using display helper."""
|
||||
try:
|
||||
mode = display_mode or self._determine_display_mode()
|
||||
|
||||
if not mode:
|
||||
self._display_no_games()
|
||||
return
|
||||
|
||||
# Filter games for mode
|
||||
filtered_games = self._filter_games_for_mode(mode)
|
||||
|
||||
if not filtered_games:
|
||||
self._display_no_games()
|
||||
return
|
||||
|
||||
# Display first game
|
||||
self.current_game = filtered_games[0]
|
||||
self._draw_scorebug_layout(self.current_game, force_clear)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error displaying game: {e}", exc_info=True)
|
||||
|
||||
def _determine_display_mode(self) -> Optional[str]:
|
||||
"""Determine display mode based on available games."""
|
||||
# Priority: live > recent > upcoming
|
||||
for game in self.current_games:
|
||||
if game.get('is_live'):
|
||||
return f"{game['league_key']}_live"
|
||||
for game in self.current_games:
|
||||
if game.get('is_final'):
|
||||
return f"{game['league_key']}_recent"
|
||||
for game in self.current_games:
|
||||
if game.get('is_upcoming'):
|
||||
return f"{game['league_key']}_upcoming"
|
||||
return None
|
||||
|
||||
def _filter_games_for_mode(self, mode: str) -> List[Dict]:
|
||||
"""Filter games based on display mode."""
|
||||
filtered = []
|
||||
|
||||
for game in self.current_games:
|
||||
league_config = game.get('league_config', {})
|
||||
display_modes = league_config.get('display_modes', {})
|
||||
|
||||
if mode in display_modes and display_modes[mode]:
|
||||
if 'live' in mode and game.get('is_live'):
|
||||
filtered.append(game)
|
||||
elif 'recent' in mode and game.get('is_final'):
|
||||
filtered.append(game)
|
||||
elif 'upcoming' in mode and game.get('is_upcoming'):
|
||||
filtered.append(game)
|
||||
|
||||
return filtered[:5]
|
||||
|
||||
def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None:
|
||||
"""Draw the basketball scorebug layout using display helper."""
|
||||
try:
|
||||
# Load logos using logo helper
|
||||
home_logo = self.logo_helper.load_logo(
|
||||
game['home_abbr'],
|
||||
game['home_logo_path']
|
||||
)
|
||||
away_logo = self.logo_helper.load_logo(
|
||||
game['away_abbr'],
|
||||
game['away_logo_path']
|
||||
)
|
||||
|
||||
if not home_logo or not away_logo:
|
||||
self.logger.error("Failed to load logos")
|
||||
self._display_error("Logo Error")
|
||||
return
|
||||
|
||||
# Use display helper to create scorebug layout
|
||||
final_img = self.display_helper.draw_scorebug_layout(
|
||||
game_data=game,
|
||||
fonts=self.fonts,
|
||||
home_logo=home_logo,
|
||||
away_logo=away_logo
|
||||
)
|
||||
|
||||
# Display the image
|
||||
self.display_manager.image.paste(final_img, (0, 0))
|
||||
self.display_manager.update_display()
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error drawing scorebug: {e}", exc_info=True)
|
||||
|
||||
def _display_no_games(self) -> None:
|
||||
"""Display 'no games' message using display helper."""
|
||||
try:
|
||||
img = self.display_helper.draw_no_data_message("No Games")
|
||||
self.display_manager.image = img.copy()
|
||||
self.display_manager.update_display()
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error displaying no games: {e}", exc_info=True)
|
||||
|
||||
def _display_error(self, message: str) -> None:
|
||||
"""Display error message using display helper."""
|
||||
try:
|
||||
img = self.display_helper.draw_error_message(message)
|
||||
self.display_manager.image = img.copy()
|
||||
self.display_manager.update_display()
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error displaying error message: {e}", exc_info=True)
|
||||
|
||||
def get_display_duration(self) -> float:
|
||||
"""Get display duration."""
|
||||
return self.config.get('display_duration', 15)
|
||||
|
||||
def cleanup(self) -> None:
|
||||
"""Cleanup resources."""
|
||||
self.current_games = []
|
||||
self.logger.info("Basketball plugin cleaned up")
|
||||
|
||||
|
||||
# Example usage and benefits:
|
||||
"""
|
||||
Benefits of using LEDMatrix Common helpers:
|
||||
|
||||
1. **Cleaner Code**: The plugin is much shorter and more readable
|
||||
2. **Reusable Components**: Common functionality is shared across plugins
|
||||
3. **Better Testing**: Each helper can be tested independently
|
||||
4. **Easier Maintenance**: Bug fixes in helpers benefit all plugins
|
||||
5. **Consistent Behavior**: All plugins use the same underlying logic
|
||||
6. **Reduced Dependencies**: Plugins don't need to import LEDMatrix core
|
||||
7. **Better Error Handling**: Centralized error handling in helpers
|
||||
8. **Configuration Management**: Consistent config handling across plugins
|
||||
|
||||
The original basketball plugin was 326 lines. This version is much cleaner
|
||||
and delegates most functionality to the common helpers, making it easier to
|
||||
maintain and extend.
|
||||
"""
|
||||
111
src/common/cli.py
Normal file
111
src/common/cli.py
Normal file
@@ -0,0 +1,111 @@
|
||||
"""
|
||||
LEDMatrix Common CLI
|
||||
|
||||
Command-line interface for LEDMatrix Common utilities.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def main():
|
||||
"""Main CLI entry point."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="LEDMatrix Common Utilities",
|
||||
prog="ledmatrix-common"
|
||||
)
|
||||
|
||||
subparsers = parser.add_subparsers(dest='command', help='Available commands')
|
||||
|
||||
# Test command
|
||||
test_parser = subparsers.add_parser('test', help='Test common utilities')
|
||||
test_parser.add_argument('--display-width', type=int, default=128, help='Display width')
|
||||
test_parser.add_argument('--display-height', type=int, default=64, help='Display height')
|
||||
|
||||
# Validate command
|
||||
validate_parser = subparsers.add_parser('validate', help='Validate configuration')
|
||||
validate_parser.add_argument('config_file', help='Configuration file to validate')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.command == 'test':
|
||||
test_utilities(args.display_width, args.display_height)
|
||||
elif args.command == 'validate':
|
||||
validate_config(args.config_file)
|
||||
else:
|
||||
parser.print_help()
|
||||
|
||||
|
||||
def test_utilities(display_width: int, display_height: int):
|
||||
"""Test common utilities."""
|
||||
print(f"Testing LEDMatrix Common utilities with {display_width}x{display_height} display")
|
||||
|
||||
try:
|
||||
from ledmatrix_common import LogoHelper, TextHelper, APIHelper, DisplayHelper, GameHelper, ConfigHelper
|
||||
|
||||
# Test LogoHelper
|
||||
print("Testing LogoHelper...")
|
||||
logo_helper = LogoHelper(display_width, display_height)
|
||||
print(f"Logo cache stats: {logo_helper.get_cache_stats()}")
|
||||
|
||||
# Test TextHelper
|
||||
print("Testing TextHelper...")
|
||||
text_helper = TextHelper()
|
||||
fonts = text_helper.load_fonts()
|
||||
print(f"Loaded {len(fonts)} fonts")
|
||||
|
||||
# Test DisplayHelper
|
||||
print("Testing DisplayHelper...")
|
||||
display_helper = DisplayHelper(display_width, display_height)
|
||||
img = display_helper.create_base_image()
|
||||
print(f"Created {img.size} base image")
|
||||
|
||||
# Test GameHelper
|
||||
print("Testing GameHelper...")
|
||||
game_helper = GameHelper()
|
||||
print("GameHelper initialized")
|
||||
|
||||
# Test ConfigHelper
|
||||
print("Testing ConfigHelper...")
|
||||
config_helper = ConfigHelper()
|
||||
print("ConfigHelper initialized")
|
||||
|
||||
print("All tests passed!")
|
||||
|
||||
except ImportError as e:
|
||||
print(f"Import error: {e}")
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
print(f"Test error: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def validate_config(config_file: str):
|
||||
"""Validate configuration file."""
|
||||
config_path = Path(config_file)
|
||||
|
||||
if not config_path.exists():
|
||||
print(f"Configuration file not found: {config_file}")
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
from ledmatrix_common import ConfigHelper
|
||||
|
||||
config_helper = ConfigHelper()
|
||||
config = config_helper.load_config(config_path)
|
||||
|
||||
if config:
|
||||
print(f"Configuration loaded successfully from {config_file}")
|
||||
print(f"Found {len(config)} top-level keys")
|
||||
else:
|
||||
print(f"Failed to load configuration from {config_file}")
|
||||
sys.exit(1)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Validation error: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
357
src/common/config_helper.py
Normal file
357
src/common/config_helper.py
Normal file
@@ -0,0 +1,357 @@
|
||||
"""
|
||||
Config Helper
|
||||
|
||||
Handles configuration management and validation for LED matrix plugins.
|
||||
Extracted from LEDMatrix core to provide reusable functionality for plugins.
|
||||
"""
|
||||
|
||||
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
|
||||
"""
|
||||
merged = base_config.copy()
|
||||
|
||||
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
|
||||
merged[key] = 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
|
||||
318
src/common/display_helper.py
Normal file
318
src/common/display_helper.py
Normal file
@@ -0,0 +1,318 @@
|
||||
"""
|
||||
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, List, Optional, Tuple, Union
|
||||
|
||||
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 status/period text (top center)
|
||||
if status_text or period_text:
|
||||
status_display = f"{period_text} {status_text}".strip()
|
||||
if status_display:
|
||||
self._draw_centered_text(draw, status_display,
|
||||
fonts.get('time', fonts.get('status')),
|
||||
y_position=1)
|
||||
|
||||
# Draw clock if available
|
||||
if clock:
|
||||
self._draw_centered_text(draw, clock, fonts.get('time'), 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.
|
||||
|
||||
Args:
|
||||
text: Text to display
|
||||
font: Font to use
|
||||
background_color: Background color
|
||||
text_color: Text color
|
||||
scroll_speed: Pixels to scroll per frame
|
||||
|
||||
Returns:
|
||||
PIL Image with ticker layout
|
||||
"""
|
||||
img = self.create_base_image(background_color)
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# Calculate text position (start off-screen to the right)
|
||||
text_width = draw.textlength(text, font=font)
|
||||
x_position = self.display_width
|
||||
|
||||
# Draw text
|
||||
self._draw_text_with_outline(draw, text, (x_position, 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
|
||||
"""
|
||||
img = self.create_base_image((50, 0, 0)) # Dark red background
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# Use default font
|
||||
font = ImageFont.load_default()
|
||||
|
||||
# Draw centered error message
|
||||
self._draw_centered_text(message, font, (50, 0, 0), (255, 255, 255))
|
||||
|
||||
return img
|
||||
|
||||
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
|
||||
"""
|
||||
img = self.create_base_image((0, 0, 0))
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
font = ImageFont.load_default()
|
||||
self._draw_centered_text(message, font, (0, 0, 0), (150, 150, 150))
|
||||
|
||||
return img
|
||||
|
||||
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
src/common/error_handler.py
Normal file
220
src/common/error_handler.py
Normal file
@@ -0,0 +1,220 @@
|
||||
"""
|
||||
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
src/common/game_helper.py
Normal file
452
src/common/game_helper.py
Normal file
@@ -0,0 +1,452 @@
|
||||
"""
|
||||
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
|
||||
299
src/common/logo_helper.py
Normal file
299
src/common/logo_helper.py
Normal file
@@ -0,0 +1,299 @@
|
||||
"""
|
||||
Logo Helper
|
||||
|
||||
Handles logo loading, caching, resizing, and management for LED matrix displays.
|
||||
Extracted from LEDMatrix core to provide reusable functionality for plugins.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Union
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import requests
|
||||
from PIL import Image
|
||||
from src.common.permission_utils import (
|
||||
ensure_directory_permissions,
|
||||
ensure_file_permissions,
|
||||
get_assets_dir_mode,
|
||||
get_assets_file_mode
|
||||
)
|
||||
|
||||
|
||||
class LogoHelper:
|
||||
"""
|
||||
Helper class for logo loading, caching, and resizing.
|
||||
|
||||
Provides functionality for:
|
||||
- Loading logos from files
|
||||
- Caching loaded logos in memory
|
||||
- Resizing logos to fit display dimensions
|
||||
- Handling logo variations and fallbacks
|
||||
- Downloading missing logos from URLs
|
||||
"""
|
||||
|
||||
def __init__(self, display_width: int, display_height: int,
|
||||
cache_size: int = 100, logger: Optional[logging.Logger] = None):
|
||||
"""
|
||||
Initialize the LogoHelper.
|
||||
|
||||
Args:
|
||||
display_width: Width of the LED matrix display
|
||||
display_height: Height of the LED matrix display
|
||||
cache_size: Maximum number of logos to cache in memory
|
||||
logger: Optional logger instance
|
||||
"""
|
||||
self.display_width = display_width
|
||||
self.display_height = display_height
|
||||
self.cache_size = cache_size
|
||||
self.logger = logger or logging.getLogger(__name__)
|
||||
|
||||
# In-memory logo cache
|
||||
self._logo_cache: Dict[str, Image.Image] = {}
|
||||
self._cache_order: List[str] = [] # For LRU cache management
|
||||
|
||||
# Session for HTTP requests
|
||||
self.session = requests.Session()
|
||||
self.session.headers.update({
|
||||
'User-Agent': 'LEDMatrix-Common/1.0',
|
||||
'Accept': 'image/*',
|
||||
})
|
||||
|
||||
def load_logo(self, team_abbr: str, logo_path: Union[str, Path],
|
||||
max_width: Optional[int] = None,
|
||||
max_height: Optional[int] = None) -> Optional[Image.Image]:
|
||||
"""
|
||||
Load and resize a team logo.
|
||||
|
||||
Args:
|
||||
team_abbr: Team abbreviation for caching
|
||||
logo_path: Path to the logo file
|
||||
max_width: Maximum width (defaults to display_width * 1.5)
|
||||
max_height: Maximum height (defaults to display_height * 1.5)
|
||||
|
||||
Returns:
|
||||
PIL Image object or None if loading fails
|
||||
"""
|
||||
# Check cache first
|
||||
cache_key = f"{team_abbr}_{logo_path}"
|
||||
if cache_key in self._logo_cache:
|
||||
self.logger.debug(f"Using cached logo for {team_abbr}")
|
||||
# Update LRU order (move to end)
|
||||
if cache_key in self._cache_order:
|
||||
self._cache_order.remove(cache_key)
|
||||
self._cache_order.append(cache_key)
|
||||
return self._logo_cache[cache_key]
|
||||
|
||||
try:
|
||||
logo_path = Path(logo_path)
|
||||
if not logo_path.exists():
|
||||
self.logger.warning(f"Logo not found for {team_abbr} at {logo_path}")
|
||||
return None
|
||||
|
||||
# Load image
|
||||
logo = Image.open(logo_path)
|
||||
if logo.mode != 'RGBA':
|
||||
logo = logo.convert('RGBA')
|
||||
|
||||
# Resize if needed
|
||||
logo = self._resize_logo(logo, max_width, max_height)
|
||||
|
||||
# Cache the logo
|
||||
self._cache_logo(cache_key, logo)
|
||||
|
||||
self.logger.debug(f"Loaded logo for {team_abbr} from {logo_path}")
|
||||
return logo
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error loading logo for {team_abbr}: {e}")
|
||||
return None
|
||||
|
||||
def load_logo_with_download(self, team_abbr: str, logo_path: Union[str, Path],
|
||||
logo_url: Optional[str] = None,
|
||||
max_width: Optional[int] = None,
|
||||
max_height: Optional[int] = None) -> Optional[Image.Image]:
|
||||
"""
|
||||
Load logo with automatic download if missing.
|
||||
|
||||
Args:
|
||||
team_abbr: Team abbreviation
|
||||
logo_path: Local path to store/load logo
|
||||
logo_url: URL to download logo from if local file missing
|
||||
max_width: Maximum width for resizing
|
||||
max_height: Maximum height for resizing
|
||||
|
||||
Returns:
|
||||
PIL Image object or None if loading fails
|
||||
"""
|
||||
logo_path = Path(logo_path)
|
||||
|
||||
# Try to load existing logo first
|
||||
if logo_path.exists():
|
||||
return self.load_logo(team_abbr, logo_path, max_width, max_height)
|
||||
|
||||
# Download if URL provided and file doesn't exist
|
||||
if logo_url:
|
||||
try:
|
||||
self.logger.info(f"Downloading logo for {team_abbr} from {logo_url}")
|
||||
self._download_logo(logo_url, logo_path)
|
||||
return self.load_logo(team_abbr, logo_path, max_width, max_height)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to download logo for {team_abbr}: {e}")
|
||||
|
||||
# Create placeholder if all else fails
|
||||
return self._create_placeholder_logo(team_abbr, max_width, max_height)
|
||||
|
||||
def get_logo_variations(self, team_abbr: str) -> List[str]:
|
||||
"""
|
||||
Get possible filename variations for a team abbreviation.
|
||||
|
||||
Args:
|
||||
team_abbr: Team abbreviation
|
||||
|
||||
Returns:
|
||||
List of possible filename variations
|
||||
"""
|
||||
variations = [team_abbr]
|
||||
|
||||
# Common variations
|
||||
if '&' in team_abbr:
|
||||
variations.append(team_abbr.replace('&', 'AND'))
|
||||
if 'AND' in team_abbr:
|
||||
variations.append(team_abbr.replace('AND', '&'))
|
||||
|
||||
# Handle special cases
|
||||
special_cases = {
|
||||
'TA&M': ['TAMU', 'TEXASAM'],
|
||||
'UCLA': ['UCLA'],
|
||||
'USC': ['USC'],
|
||||
'LSU': ['LSU'],
|
||||
}
|
||||
|
||||
if team_abbr in special_cases:
|
||||
variations.extend(special_cases[team_abbr])
|
||||
|
||||
return variations
|
||||
|
||||
def normalize_abbreviation(self, team_abbr: str) -> str:
|
||||
"""
|
||||
Normalize team abbreviation for consistent filename usage.
|
||||
|
||||
Args:
|
||||
team_abbr: Raw team abbreviation
|
||||
|
||||
Returns:
|
||||
Normalized abbreviation
|
||||
"""
|
||||
# Remove spaces and convert to uppercase
|
||||
normalized = team_abbr.strip().upper()
|
||||
|
||||
# Handle special characters
|
||||
normalized = normalized.replace('&', 'AND')
|
||||
normalized = normalized.replace(' ', '')
|
||||
|
||||
return normalized
|
||||
|
||||
def clear_cache(self) -> None:
|
||||
"""Clear the logo cache."""
|
||||
self._logo_cache.clear()
|
||||
self._cache_order.clear()
|
||||
self.logger.debug("Logo cache cleared")
|
||||
|
||||
def get_cache_stats(self) -> Dict[str, int]:
|
||||
"""
|
||||
Get cache statistics.
|
||||
|
||||
Returns:
|
||||
Dictionary with cache statistics
|
||||
"""
|
||||
return {
|
||||
'cached_logos': len(self._logo_cache),
|
||||
'cache_size_limit': self.cache_size,
|
||||
'cache_usage_percent': (len(self._logo_cache) / self.cache_size) * 100
|
||||
}
|
||||
|
||||
def _resize_logo(self, logo: Image.Image, max_width: Optional[int] = None,
|
||||
max_height: Optional[int] = None) -> Image.Image:
|
||||
"""Resize logo to fit display dimensions."""
|
||||
if max_width is None:
|
||||
max_width = int(self.display_width * 1.5)
|
||||
if max_height is None:
|
||||
max_height = int(self.display_height * 1.5)
|
||||
|
||||
# Only resize if necessary
|
||||
if logo.width <= max_width and logo.height <= max_height:
|
||||
return logo
|
||||
|
||||
# Maintain aspect ratio
|
||||
logo.thumbnail((max_width, max_height), Image.Resampling.LANCZOS)
|
||||
return logo
|
||||
|
||||
def _cache_logo(self, cache_key: str, logo: Image.Image) -> None:
|
||||
"""Cache a logo with LRU eviction."""
|
||||
# Remove oldest if cache is full
|
||||
if len(self._logo_cache) >= self.cache_size:
|
||||
if self._cache_order:
|
||||
oldest_key = self._cache_order.pop(0)
|
||||
del self._logo_cache[oldest_key]
|
||||
|
||||
# Add to cache
|
||||
self._logo_cache[cache_key] = logo
|
||||
self._cache_order.append(cache_key)
|
||||
|
||||
def _download_logo(self, url: str, file_path: Path) -> None:
|
||||
"""Download logo from URL."""
|
||||
# Ensure directory exists with proper permissions
|
||||
ensure_directory_permissions(file_path.parent, get_assets_dir_mode())
|
||||
|
||||
# Download with timeout
|
||||
response = self.session.get(url, timeout=30)
|
||||
response.raise_for_status()
|
||||
|
||||
# Save to file
|
||||
with open(file_path, 'wb') as f:
|
||||
f.write(response.content)
|
||||
|
||||
# Set proper file permissions after saving
|
||||
ensure_file_permissions(file_path, get_assets_file_mode())
|
||||
|
||||
self.logger.debug(f"Downloaded logo to {file_path}")
|
||||
|
||||
def _create_placeholder_logo(self, team_abbr: str,
|
||||
max_width: Optional[int] = None,
|
||||
max_height: Optional[int] = None) -> Optional[Image.Image]:
|
||||
"""
|
||||
Create a placeholder logo with team abbreviation.
|
||||
|
||||
Args:
|
||||
team_abbr: Team abbreviation to display
|
||||
max_width: Maximum width
|
||||
max_height: Maximum height
|
||||
|
||||
Returns:
|
||||
PIL Image with placeholder logo
|
||||
"""
|
||||
try:
|
||||
if max_width is None:
|
||||
max_width = int(self.display_width * 1.5)
|
||||
if max_height is None:
|
||||
max_height = int(self.display_height * 1.5)
|
||||
|
||||
# Create placeholder image
|
||||
placeholder = Image.new('RGBA', (max_width, max_height), (0, 0, 0, 0))
|
||||
|
||||
# This would require a font, so we'll create a simple colored rectangle
|
||||
# In a real implementation, you'd want to add text rendering here
|
||||
from PIL import ImageDraw
|
||||
draw = ImageDraw.Draw(placeholder)
|
||||
|
||||
# Draw a simple rectangle with team abbreviation
|
||||
draw.rectangle([0, 0, max_width-1, max_height-1],
|
||||
fill=(100, 100, 100, 200), outline=(200, 200, 200, 255))
|
||||
|
||||
self.logger.debug(f"Created placeholder logo for {team_abbr}")
|
||||
return placeholder
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error creating placeholder for {team_abbr}: {e}")
|
||||
return None
|
||||
164
src/common/permission_utils.py
Normal file
164
src/common/permission_utils.py
Normal file
@@ -0,0 +1,164 @@
|
||||
"""
|
||||
Permission Utilities
|
||||
|
||||
Centralized utility functions for managing file and directory permissions
|
||||
across the LEDMatrix codebase. Ensures consistent permission handling for
|
||||
files that need to be accessible by both root service and web user.
|
||||
"""
|
||||
|
||||
import os
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def ensure_directory_permissions(path: Path, mode: int = 0o775) -> None:
|
||||
"""
|
||||
Create directory and set permissions.
|
||||
|
||||
If the directory already exists and we cannot change its permissions,
|
||||
we check if it's usable (readable/writable). If so, we continue without
|
||||
raising an exception. This allows the system to work even when running
|
||||
as a non-root user who cannot change permissions on existing directories.
|
||||
|
||||
Args:
|
||||
path: Directory path to create/ensure
|
||||
mode: Permission mode (default: 0o775 for group-writable directories)
|
||||
|
||||
Raises:
|
||||
OSError: If directory creation fails or directory exists but is not usable
|
||||
"""
|
||||
try:
|
||||
# Create directory if it doesn't exist
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Try to set permissions
|
||||
try:
|
||||
os.chmod(path, mode)
|
||||
logger.debug(f"Set directory permissions {oct(mode)} on {path}")
|
||||
except (OSError, PermissionError) as perm_error:
|
||||
# If we can't set permissions, check if directory is usable
|
||||
if path.exists():
|
||||
# Check if directory is readable and writable
|
||||
if os.access(path, os.R_OK | os.W_OK):
|
||||
logger.warning(
|
||||
f"Could not set permissions on {path} (may be owned by different user), "
|
||||
f"but directory is usable (readable/writable). Continuing."
|
||||
)
|
||||
return
|
||||
else:
|
||||
# Directory exists but is not usable
|
||||
logger.error(
|
||||
f"Directory {path} exists but is not readable/writable. "
|
||||
f"Permission change failed: {perm_error}"
|
||||
)
|
||||
raise OSError(
|
||||
f"Directory {path} exists but is not usable: {perm_error}"
|
||||
) from perm_error
|
||||
else:
|
||||
# Directory doesn't exist and we couldn't create it
|
||||
raise
|
||||
except OSError as e:
|
||||
logger.error(f"Failed to ensure directory {path}: {e}")
|
||||
raise
|
||||
|
||||
|
||||
def ensure_file_permissions(path: Path, mode: int = 0o644) -> None:
|
||||
"""
|
||||
Set file permissions after creation.
|
||||
|
||||
Args:
|
||||
path: File path to set permissions on
|
||||
mode: Permission mode (default: 0o644 for readable files)
|
||||
|
||||
Raises:
|
||||
OSError: If permission setting fails
|
||||
"""
|
||||
try:
|
||||
if path.exists():
|
||||
os.chmod(path, mode)
|
||||
logger.debug(f"Set file permissions {oct(mode)} on {path}")
|
||||
else:
|
||||
logger.warning(f"File does not exist, cannot set permissions: {path}")
|
||||
except OSError as e:
|
||||
logger.error(f"Failed to set file permissions on {path}: {e}")
|
||||
raise
|
||||
|
||||
|
||||
def get_config_file_mode(file_path: Path) -> int:
|
||||
"""
|
||||
Return appropriate permission mode for config files.
|
||||
|
||||
Args:
|
||||
file_path: Path to config file
|
||||
|
||||
Returns:
|
||||
Permission mode: 0o640 for secrets files, 0o644 for regular config
|
||||
"""
|
||||
if 'secrets' in str(file_path):
|
||||
return 0o640 # rw-r-----
|
||||
else:
|
||||
return 0o644 # rw-r--r--
|
||||
|
||||
|
||||
def get_assets_file_mode() -> int:
|
||||
"""
|
||||
Return permission mode for asset files (logos, images, etc.).
|
||||
|
||||
Returns:
|
||||
Permission mode: 0o664 (rw-rw-r--) for group-writable assets
|
||||
"""
|
||||
return 0o664 # rw-rw-r--
|
||||
|
||||
|
||||
def get_assets_dir_mode() -> int:
|
||||
"""
|
||||
Return permission mode for asset directories.
|
||||
|
||||
Returns:
|
||||
Permission mode: 0o2775 (rwxrwxr-x + sticky bit) for group-writable directories
|
||||
"""
|
||||
return 0o2775 # rwxrwsr-x (setgid + group writable)
|
||||
|
||||
|
||||
def get_config_dir_mode() -> int:
|
||||
"""
|
||||
Return permission mode for config directory.
|
||||
|
||||
Returns:
|
||||
Permission mode: 0o2775 (rwxrwxr-x + sticky bit) for group-writable directories
|
||||
"""
|
||||
return 0o2775 # rwxrwsr-x (setgid + group writable)
|
||||
|
||||
|
||||
def get_plugin_file_mode() -> int:
|
||||
"""
|
||||
Return permission mode for plugin files.
|
||||
|
||||
Returns:
|
||||
Permission mode: 0o664 (rw-rw-r--) for group-writable plugin files
|
||||
"""
|
||||
return 0o664 # rw-rw-r--
|
||||
|
||||
|
||||
def get_plugin_dir_mode() -> int:
|
||||
"""
|
||||
Return permission mode for plugin directories.
|
||||
|
||||
Returns:
|
||||
Permission mode: 0o2775 (rwxrwxr-x + sticky bit) for group-writable directories
|
||||
"""
|
||||
return 0o2775 # rwxrwsr-x (setgid + group writable)
|
||||
|
||||
|
||||
def get_cache_dir_mode() -> int:
|
||||
"""
|
||||
Return permission mode for cache directories.
|
||||
|
||||
Returns:
|
||||
Permission mode: 0o2775 (rwxrwxr-x + sticky bit) for group-writable cache directories
|
||||
"""
|
||||
return 0o2775 # rwxrwsr-x (setgid + group writable)
|
||||
|
||||
834
src/common/scroll_helper.py
Normal file
834
src/common/scroll_helper.py
Normal file
@@ -0,0 +1,834 @@
|
||||
"""
|
||||
Scroll Helper
|
||||
|
||||
Handles scrolling text and image content for LED matrix displays.
|
||||
Extracted from LEDMatrix core to provide reusable functionality for plugins.
|
||||
|
||||
Features:
|
||||
- Pre-rendered scrolling image caching with numpy array optimization
|
||||
- Fast numpy-based image slicing for high-performance scrolling (100+ FPS)
|
||||
- Scroll position management with wrap-around
|
||||
- Dynamic duration calculation based on content width
|
||||
- Frame rate tracking and logging
|
||||
- Scrolling state management integration with display_manager
|
||||
- Support for both continuous and bounded scrolling modes
|
||||
- Pre-allocated buffers to minimize memory allocations
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import Optional, Dict, Any
|
||||
from PIL import Image
|
||||
import numpy as np
|
||||
|
||||
# Try to import scipy for sub-pixel interpolation, fallback to simpler method if not available
|
||||
try:
|
||||
from scipy.ndimage import shift
|
||||
HAS_SCIPY = True
|
||||
except ImportError:
|
||||
HAS_SCIPY = False
|
||||
|
||||
|
||||
class ScrollHelper:
|
||||
"""
|
||||
Helper class for scrolling text and image content on LED displays.
|
||||
|
||||
Provides functionality for:
|
||||
- Creating and caching scrolling images (with numpy array optimization)
|
||||
- Fast numpy-based image slicing for high-performance scrolling
|
||||
- Managing scroll position with wrap-around
|
||||
- Calculating dynamic display duration
|
||||
- Frame rate tracking and performance monitoring
|
||||
- Integration with display manager scrolling state
|
||||
- Pre-allocated buffers for minimal memory allocations
|
||||
|
||||
Performance optimizations:
|
||||
- Uses numpy arrays for fast array slicing instead of PIL crop operations
|
||||
- Pre-computes numpy array from PIL image to avoid repeated conversions
|
||||
- Reuses pre-allocated frame buffer to minimize allocations
|
||||
- Optimized for 100+ FPS scrolling performance
|
||||
"""
|
||||
|
||||
def __init__(self, display_width: int, display_height: int,
|
||||
logger: Optional[logging.Logger] = None):
|
||||
"""
|
||||
Initialize the ScrollHelper.
|
||||
|
||||
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__)
|
||||
|
||||
# Scrolling state
|
||||
self.scroll_position = 0.0
|
||||
self.total_distance_scrolled = 0.0 # Track total distance including wrap-arounds
|
||||
self.scroll_speed = 1.0
|
||||
self.scroll_delay = 0.001 # Minimal delay for high FPS (1ms)
|
||||
self.cached_image: Optional[Image.Image] = None
|
||||
self.cached_array: Optional[np.ndarray] = None # Numpy array cache for fast operations
|
||||
self.total_scroll_width = 0
|
||||
|
||||
# Pre-allocated buffer for output frame (reused to avoid allocations)
|
||||
self._frame_buffer: Optional[np.ndarray] = None
|
||||
|
||||
# Sub-pixel scrolling settings (disabled - using high FPS integer scrolling instead)
|
||||
self.sub_pixel_scrolling = False # Disabled - use high frame rate for smoothness
|
||||
self._last_integer_position = 0 # Cache for integer position to avoid repeated calculations
|
||||
|
||||
# Frame-based scrolling settings
|
||||
self.frame_based_scrolling = False # If True, use scroll_delay to throttle and move scroll_speed pixels
|
||||
self.last_step_time = 0.0 # Track last step time for frame-based throttling
|
||||
|
||||
# Time tracking for scroll updates
|
||||
self.last_update_time: Optional[float] = None
|
||||
|
||||
# High FPS settings
|
||||
self.target_fps = 120 # Target 120 FPS for smooth scrolling
|
||||
self.frame_time_target = 1.0 / self.target_fps
|
||||
|
||||
# Dynamic duration settings
|
||||
self.dynamic_duration_enabled = True
|
||||
self.min_duration = 30
|
||||
self.max_duration = 300
|
||||
self.duration_buffer = 0.1
|
||||
self.calculated_duration = 60
|
||||
self.scroll_start_time: Optional[float] = None
|
||||
self.last_progress_log_time: Optional[float] = None
|
||||
self.progress_log_interval = 5.0 # seconds
|
||||
|
||||
# Frame rate tracking
|
||||
self.frame_count = 0
|
||||
self.last_frame_time = time.time()
|
||||
self.last_fps_log_time = time.time()
|
||||
self.frame_times = []
|
||||
|
||||
# Scrolling state management
|
||||
self.is_scrolling = False
|
||||
self.scroll_complete = False
|
||||
|
||||
def create_scrolling_image(self, content_items: list,
|
||||
item_gap: int = 32,
|
||||
element_gap: int = 16) -> Image.Image:
|
||||
"""
|
||||
Create a wide image containing all content items for scrolling.
|
||||
|
||||
Args:
|
||||
content_items: List of PIL Images to include in scroll
|
||||
item_gap: Gap between different items
|
||||
element_gap: Gap between elements within an item
|
||||
|
||||
Returns:
|
||||
PIL Image containing all content arranged horizontally
|
||||
"""
|
||||
if not content_items:
|
||||
# Create empty image if no content
|
||||
# Still set total_scroll_width to 0 to indicate no scrollable content
|
||||
self.total_scroll_width = 0
|
||||
self.cached_image = Image.new('RGB', (self.display_width, self.display_height), (0, 0, 0))
|
||||
self.cached_array = np.array(self.cached_image)
|
||||
self.scroll_position = 0.0
|
||||
self.total_distance_scrolled = 0.0
|
||||
self.scroll_complete = False
|
||||
return self.cached_image
|
||||
|
||||
# Calculate total width needed
|
||||
total_width = sum(img.width for img in content_items)
|
||||
total_width += item_gap * (len(content_items) - 1)
|
||||
total_width += element_gap * (len(content_items) * 2 - 1)
|
||||
|
||||
# Add initial gap before first item
|
||||
total_width += self.display_width
|
||||
|
||||
# Create the full scrolling image
|
||||
full_image = Image.new('RGB', (total_width, self.display_height), (0, 0, 0))
|
||||
|
||||
# Position items
|
||||
current_x = self.display_width # Start with initial gap
|
||||
|
||||
for i, img in enumerate(content_items):
|
||||
# Paste the item image
|
||||
full_image.paste(img, (current_x, 0))
|
||||
current_x += img.width + element_gap
|
||||
|
||||
# Add gap between items (except after last item)
|
||||
if i < len(content_items) - 1:
|
||||
current_x += item_gap
|
||||
|
||||
# Store the image and update scroll width
|
||||
self.cached_image = full_image
|
||||
# Convert to numpy array for fast operations
|
||||
self.cached_array = np.array(full_image)
|
||||
self.total_scroll_width = total_width
|
||||
self.scroll_position = 0.0
|
||||
self.total_distance_scrolled = 0.0
|
||||
self.scroll_complete = False
|
||||
|
||||
# Pre-allocate frame buffer if needed
|
||||
if self._frame_buffer is None or self._frame_buffer.shape != (self.display_height, self.display_width, 3):
|
||||
self._frame_buffer = np.zeros((self.display_height, self.display_width, 3), dtype=np.uint8)
|
||||
|
||||
# Calculate dynamic duration
|
||||
self._calculate_dynamic_duration()
|
||||
now = time.time()
|
||||
self.scroll_start_time = now
|
||||
self.last_progress_log_time = now
|
||||
self.logger.info(
|
||||
"Dynamic duration target set to %ds (min=%ds, max=%ds, buffer=%.2f)",
|
||||
self.calculated_duration,
|
||||
self.min_duration,
|
||||
self.max_duration,
|
||||
self.duration_buffer,
|
||||
)
|
||||
|
||||
self.logger.debug(f"Created scrolling image: {total_width}x{self.display_height}")
|
||||
return full_image
|
||||
|
||||
def update_scroll_position(self) -> None:
|
||||
"""
|
||||
Update scroll position with high FPS control and handle wrap-around.
|
||||
"""
|
||||
if not self.cached_image:
|
||||
return
|
||||
|
||||
# Calculate frame time for consistent scroll speed regardless of FPS
|
||||
current_time = time.time()
|
||||
if self.last_update_time is None:
|
||||
self.last_update_time = current_time
|
||||
|
||||
delta_time = current_time - self.last_update_time
|
||||
self.last_update_time = current_time
|
||||
|
||||
if self.scroll_start_time is None:
|
||||
self.scroll_start_time = current_time
|
||||
self.last_progress_log_time = current_time
|
||||
|
||||
# Update scroll position
|
||||
if self.frame_based_scrolling:
|
||||
# Frame-based: move fixed amount when scroll_delay has passed
|
||||
# This matches stock ticker behavior: move pixels, then wait scroll_delay
|
||||
# Initialize last_step_time on first call to prevent huge initial jump
|
||||
if self.last_step_time == 0.0:
|
||||
self.last_step_time = current_time
|
||||
|
||||
# Check if scroll_delay has passed
|
||||
time_since_last_step = current_time - self.last_step_time
|
||||
if time_since_last_step >= self.scroll_delay:
|
||||
# Move pixels (can move multiple steps if lag occurred, but cap to prevent huge jumps)
|
||||
steps = int(time_since_last_step / self.scroll_delay)
|
||||
# Cap at reasonable number to prevent huge jumps from lag
|
||||
max_steps = max(1, int(0.1 / self.scroll_delay)) # Allow up to 0.1s of catch-up
|
||||
steps = min(steps, max_steps)
|
||||
pixels_to_move = self.scroll_speed * steps
|
||||
# Update last_step_time, preserving fractional delay for smooth timing
|
||||
self.last_step_time = current_time - (time_since_last_step % self.scroll_delay)
|
||||
else:
|
||||
pixels_to_move = 0.0
|
||||
else:
|
||||
# Time-based: move based on time delta (correct speed over time)
|
||||
# scroll_speed is pixels per second
|
||||
pixels_to_move = self.scroll_speed * delta_time
|
||||
|
||||
self.scroll_position += pixels_to_move
|
||||
self.total_distance_scrolled += pixels_to_move
|
||||
|
||||
# Calculate required total distance: total_scroll_width + display_width
|
||||
# The image already includes display_width padding at the start, so we need
|
||||
# to scroll total_scroll_width pixels to show all content, plus display_width
|
||||
# more pixels to ensure the last content scrolls completely off the screen
|
||||
required_total_distance = self.total_scroll_width + self.display_width
|
||||
|
||||
# Check completion FIRST (before wrap-around) to prevent visual loop
|
||||
# When dynamic duration is enabled and cycle is complete, stop at end instead of wrapping
|
||||
is_complete = self.total_distance_scrolled >= required_total_distance
|
||||
|
||||
if is_complete:
|
||||
# Only log completion once to avoid spam
|
||||
if not self.scroll_complete:
|
||||
elapsed = current_time - self.scroll_start_time
|
||||
self.logger.info(
|
||||
"Scroll cycle COMPLETE: scrolled %.0f/%d px (elapsed %.2fs, target %.2fs)",
|
||||
self.total_distance_scrolled,
|
||||
required_total_distance,
|
||||
elapsed,
|
||||
self.calculated_duration,
|
||||
)
|
||||
self.scroll_complete = True
|
||||
|
||||
# Clamp position to prevent wrap when complete
|
||||
if self.scroll_position >= self.total_scroll_width:
|
||||
self.scroll_position = self.total_scroll_width - 1
|
||||
else:
|
||||
self.scroll_complete = False
|
||||
|
||||
# Only wrap-around if cycle is not complete yet
|
||||
if self.scroll_position >= self.total_scroll_width:
|
||||
elapsed = current_time - self.scroll_start_time
|
||||
self.scroll_position = self.scroll_position - self.total_scroll_width
|
||||
self.logger.info(
|
||||
"Scroll wrap-around detected: position reset, total_distance=%.0f/%d px (elapsed %.2fs, target %.2fs)",
|
||||
self.total_distance_scrolled,
|
||||
required_total_distance,
|
||||
elapsed,
|
||||
self.calculated_duration,
|
||||
)
|
||||
|
||||
if (
|
||||
self.dynamic_duration_enabled
|
||||
and self.last_progress_log_time is not None
|
||||
and current_time - self.last_progress_log_time >= self.progress_log_interval
|
||||
):
|
||||
elapsed_time = current_time - (self.scroll_start_time or current_time)
|
||||
# The image already includes display_width padding, so we only need total_scroll_width
|
||||
required_total_distance = self.total_scroll_width
|
||||
self.logger.info(
|
||||
"Scroll progress: elapsed=%.2fs, target=%.2fs, total_scrolled=%.0f/%d px (%.1f%%)",
|
||||
elapsed_time,
|
||||
self.calculated_duration,
|
||||
self.total_distance_scrolled,
|
||||
required_total_distance,
|
||||
(self.total_distance_scrolled / required_total_distance * 100) if required_total_distance > 0 else 0.0,
|
||||
)
|
||||
self.last_progress_log_time = current_time
|
||||
|
||||
def get_visible_portion(self) -> Optional[Image.Image]:
|
||||
"""
|
||||
Get the currently visible portion of the scrolling image using fast numpy operations.
|
||||
Uses integer pixel positioning for high-performance scrolling.
|
||||
|
||||
Returns:
|
||||
PIL Image showing the visible portion, or None if no cached image
|
||||
"""
|
||||
if not self.cached_image or self.cached_array is None:
|
||||
return None
|
||||
|
||||
# Use integer pixel positioning for high FPS scrolling (like stock ticker)
|
||||
start_x_int = int(self.scroll_position)
|
||||
end_x_int = start_x_int + self.display_width
|
||||
|
||||
# Fast integer pixel path (no interpolation - high frame rate provides smoothness)
|
||||
return self._get_visible_portion_integer(start_x_int, end_x_int)
|
||||
|
||||
def _get_visible_portion_integer(self, start_x: int, end_x: int) -> Image.Image:
|
||||
"""Fast integer pixel extraction (no interpolation)."""
|
||||
# Fast numpy array slicing for normal case (no wrap-around)
|
||||
if end_x <= self.cached_image.width:
|
||||
# Normal case: single slice - fastest path
|
||||
frame_array = self.cached_array[:, start_x:end_x]
|
||||
# Convert to PIL Image (minimal overhead)
|
||||
return Image.fromarray(frame_array)
|
||||
else:
|
||||
# Wrap-around case: combine two slices using numpy
|
||||
width1 = self.cached_image.width - start_x
|
||||
if width1 > 0:
|
||||
# Use pre-allocated buffer for output
|
||||
if self._frame_buffer is None or self._frame_buffer.shape != (self.display_height, self.display_width, 3):
|
||||
self._frame_buffer = np.zeros((self.display_height, self.display_width, 3), dtype=np.uint8)
|
||||
|
||||
# First part from end of image (fast numpy slice)
|
||||
self._frame_buffer[:, :width1] = self.cached_array[:, start_x:]
|
||||
|
||||
# Second part from beginning of image
|
||||
remaining_width = self.display_width - width1
|
||||
self._frame_buffer[:, width1:] = self.cached_array[:, :remaining_width]
|
||||
|
||||
# Convert combined buffer to PIL Image
|
||||
return Image.fromarray(self._frame_buffer)
|
||||
else:
|
||||
# Edge case: start_x >= image width, wrap to beginning
|
||||
frame_array = self.cached_array[:, :self.display_width]
|
||||
return Image.fromarray(frame_array)
|
||||
|
||||
def _get_visible_portion_subpixel(self, start_x_int: int, fractional: float) -> Image.Image:
|
||||
"""
|
||||
Get visible portion with sub-pixel interpolation for smooth scrolling.
|
||||
Uses bilinear interpolation to blend between pixels.
|
||||
"""
|
||||
# We need to extract a region that's 1 pixel wider to allow for interpolation
|
||||
start_x = start_x_int
|
||||
end_x = start_x_int + self.display_width + 1
|
||||
|
||||
# Check if we need wrap-around
|
||||
if end_x <= self.cached_image.width:
|
||||
# Normal case: extract region with 1 extra pixel for interpolation
|
||||
source_region = self.cached_array[:, start_x:end_x]
|
||||
|
||||
# Use bilinear interpolation for sub-pixel shifting
|
||||
if HAS_SCIPY:
|
||||
# Use scipy for high-quality sub-pixel shifting
|
||||
shifted = shift(source_region, (0, -fractional, 0), mode='nearest', order=1, prefilter=False)
|
||||
# Extract the display_width portion
|
||||
frame_array = shifted[:, :self.display_width].astype(np.uint8)
|
||||
else:
|
||||
# Fallback: simple linear interpolation using numpy
|
||||
# Blend between current and next pixel based on fractional part
|
||||
frame_array = self._interpolate_subpixel(source_region, fractional)
|
||||
|
||||
return Image.fromarray(frame_array)
|
||||
else:
|
||||
# Wrap-around case with sub-pixel
|
||||
# Use pre-allocated buffer
|
||||
if self._frame_buffer is None or self._frame_buffer.shape != (self.display_height, self.display_width, 3):
|
||||
self._frame_buffer = np.zeros((self.display_height, self.display_width, 3), dtype=np.uint8)
|
||||
|
||||
width1 = self.cached_image.width - start_x
|
||||
if width1 > 0:
|
||||
# First part from end of image
|
||||
# Need width1 + 1 pixels for interpolation
|
||||
source1_width = min(width1 + 1, self.cached_image.width - start_x)
|
||||
source1 = self.cached_array[:, start_x:start_x + source1_width]
|
||||
if HAS_SCIPY:
|
||||
shifted1 = shift(source1, (0, -fractional, 0), mode='nearest', order=1, prefilter=False)
|
||||
# Ensure we get exactly width1 pixels, padding if necessary
|
||||
if shifted1.shape[1] >= width1:
|
||||
self._frame_buffer[:, :width1] = shifted1[:, :width1].astype(np.uint8)
|
||||
else:
|
||||
# Shifted array is smaller - pad with zeros or repeat last pixel
|
||||
actual_width = shifted1.shape[1]
|
||||
self._frame_buffer[:, :actual_width] = shifted1.astype(np.uint8)
|
||||
if actual_width < width1:
|
||||
# Pad with last pixel
|
||||
self._frame_buffer[:, actual_width:width1] = shifted1[:, -1:].astype(np.uint8)
|
||||
else:
|
||||
interpolated1 = self._interpolate_subpixel(source1, fractional, output_width=width1)
|
||||
# Ensure exact width match
|
||||
if interpolated1.shape[1] == width1:
|
||||
self._frame_buffer[:, :width1] = interpolated1
|
||||
else:
|
||||
# Handle size mismatch
|
||||
copy_width = min(width1, interpolated1.shape[1])
|
||||
self._frame_buffer[:, :copy_width] = interpolated1[:, :copy_width]
|
||||
if copy_width < width1:
|
||||
self._frame_buffer[:, copy_width:width1] = interpolated1[:, -1:]
|
||||
|
||||
# Second part from beginning
|
||||
remaining_width = self.display_width - width1
|
||||
if remaining_width > 0:
|
||||
source2 = self.cached_array[:, :remaining_width + 1]
|
||||
if HAS_SCIPY:
|
||||
shifted2 = shift(source2, (0, -fractional, 0), mode='nearest', order=1, prefilter=False)
|
||||
# Ensure we get exactly remaining_width pixels
|
||||
if shifted2.shape[1] >= remaining_width:
|
||||
self._frame_buffer[:, width1:width1 + remaining_width] = shifted2[:, :remaining_width].astype(np.uint8)
|
||||
else:
|
||||
# Shifted array is smaller - pad if necessary
|
||||
actual_width = shifted2.shape[1]
|
||||
self._frame_buffer[:, width1:width1 + actual_width] = shifted2.astype(np.uint8)
|
||||
if actual_width < remaining_width:
|
||||
self._frame_buffer[:, width1 + actual_width:width1 + remaining_width] = shifted2[:, -1:].astype(np.uint8)
|
||||
else:
|
||||
interpolated2 = self._interpolate_subpixel(source2, fractional, output_width=remaining_width)
|
||||
# Ensure exact width match
|
||||
if interpolated2.shape[1] == remaining_width:
|
||||
self._frame_buffer[:, width1:] = interpolated2
|
||||
else:
|
||||
copy_width = min(remaining_width, interpolated2.shape[1])
|
||||
self._frame_buffer[:, width1:width1 + copy_width] = interpolated2[:, :copy_width]
|
||||
if copy_width < remaining_width:
|
||||
self._frame_buffer[:, width1 + copy_width:width1 + remaining_width] = interpolated2[:, -1:]
|
||||
else:
|
||||
# Edge case: wrap to beginning
|
||||
source = self.cached_array[:, :self.display_width + 1]
|
||||
if HAS_SCIPY:
|
||||
shifted = shift(source, (0, -fractional, 0), mode='nearest', order=1, prefilter=False)
|
||||
# Ensure we get exactly display_width pixels
|
||||
if shifted.shape[1] >= self.display_width:
|
||||
self._frame_buffer = shifted[:, :self.display_width].astype(np.uint8)
|
||||
else:
|
||||
# Shifted array is smaller - pad if necessary
|
||||
actual_width = shifted.shape[1]
|
||||
self._frame_buffer[:, :actual_width] = shifted.astype(np.uint8)
|
||||
if actual_width < self.display_width:
|
||||
self._frame_buffer[:, actual_width:] = shifted[:, -1:].astype(np.uint8)
|
||||
else:
|
||||
interpolated = self._interpolate_subpixel(source, fractional, output_width=self.display_width)
|
||||
# _interpolate_subpixel now always returns exact width, so this should work
|
||||
self._frame_buffer = interpolated
|
||||
|
||||
return Image.fromarray(self._frame_buffer)
|
||||
|
||||
def _interpolate_subpixel(self, source: np.ndarray, fractional: float, output_width: Optional[int] = None) -> np.ndarray:
|
||||
"""
|
||||
Simple linear interpolation for sub-pixel positioning.
|
||||
Blends between adjacent pixels based on fractional offset.
|
||||
|
||||
Args:
|
||||
source: Source array to interpolate (width should be at least output_width + 1)
|
||||
fractional: Fractional part of scroll position (0.0-1.0)
|
||||
output_width: Desired output width (defaults to display_width)
|
||||
|
||||
Returns:
|
||||
Interpolated array of shape (height, output_width, 3) - ALWAYS exactly output_width
|
||||
"""
|
||||
if output_width is None:
|
||||
output_width = self.display_width
|
||||
|
||||
# Always return exactly output_width pixels, padding if necessary
|
||||
result = np.zeros((source.shape[0], output_width, 3), dtype=np.uint8)
|
||||
|
||||
# Ensure we have enough source pixels for interpolation
|
||||
if source.shape[1] < 2:
|
||||
# Very small source - just copy what we have and pad
|
||||
copy_width = min(source.shape[1], output_width)
|
||||
result[:, :copy_width] = source[:, :copy_width].astype(np.uint8)
|
||||
if copy_width < output_width:
|
||||
# Pad with last pixel
|
||||
result[:, copy_width:] = source[:, -1:].astype(np.uint8)
|
||||
return result
|
||||
|
||||
# Calculate how many pixels we can actually interpolate
|
||||
# Need at least 2 pixels to interpolate, so max output is source.shape[1] - 1
|
||||
max_interpolated_width = source.shape[1] - 1
|
||||
interpolated_width = min(output_width, max_interpolated_width)
|
||||
|
||||
if interpolated_width > 0:
|
||||
# Extract pixels at x and x+1 for interpolation
|
||||
pixels_x = source[:, :interpolated_width].astype(np.float32)
|
||||
pixels_x1 = source[:, 1:interpolated_width + 1].astype(np.float32)
|
||||
|
||||
# Linear interpolation
|
||||
interpolated = pixels_x * (1.0 - fractional) + pixels_x1 * fractional
|
||||
|
||||
# Clip and convert back to uint8
|
||||
interpolated = np.clip(interpolated, 0, 255).astype(np.uint8)
|
||||
|
||||
# Copy interpolated portion to result
|
||||
result[:, :interpolated_width] = interpolated
|
||||
|
||||
# If we need more pixels than we can interpolate, pad with last pixel
|
||||
if interpolated_width < output_width:
|
||||
result[:, interpolated_width:] = source[:, -1:].astype(np.uint8)
|
||||
|
||||
return result
|
||||
|
||||
def calculate_dynamic_duration(self) -> int:
|
||||
"""
|
||||
Calculate display duration based on content width and scroll settings.
|
||||
|
||||
Returns:
|
||||
Duration in seconds
|
||||
"""
|
||||
if not self.dynamic_duration_enabled:
|
||||
return self.min_duration
|
||||
|
||||
# Validate total_scroll_width is set and valid
|
||||
if not self.total_scroll_width or self.total_scroll_width <= 0:
|
||||
if self.total_scroll_width == 0:
|
||||
self.logger.warning(
|
||||
"Dynamic duration calculation skipped: total_scroll_width is 0. "
|
||||
"Ensure create_scrolling_image() or set_scrolling_image() has been called. "
|
||||
"Using minimum duration: %ds",
|
||||
self.min_duration
|
||||
)
|
||||
else:
|
||||
self.logger.warning(
|
||||
"Dynamic duration calculation skipped: total_scroll_width is invalid (%s). "
|
||||
"Using minimum duration: %ds",
|
||||
self.total_scroll_width,
|
||||
self.min_duration
|
||||
)
|
||||
return self.min_duration
|
||||
|
||||
try:
|
||||
# Calculate total scroll distance needed
|
||||
# The image already includes display_width padding at the start, so we need
|
||||
# to scroll total_scroll_width pixels to show all content, plus display_width
|
||||
# more pixels to ensure the last content scrolls completely off the screen
|
||||
total_scroll_distance = self.total_scroll_width + self.display_width
|
||||
|
||||
# Calculate effective pixels per second based on scrolling mode
|
||||
if self.frame_based_scrolling:
|
||||
# Frame-based mode: scroll_speed is pixels per frame, scroll_delay is seconds per frame
|
||||
# Effective pixels per second = pixels per frame / seconds per frame
|
||||
if self.scroll_delay > 0:
|
||||
pixels_per_second = self.scroll_speed / self.scroll_delay
|
||||
else:
|
||||
# Fallback if scroll_delay is invalid
|
||||
pixels_per_second = self.scroll_speed * 50 # Assume 50 FPS default
|
||||
self.logger.warning("Invalid scroll_delay (%s), using fallback calculation", self.scroll_delay)
|
||||
scroll_mode_str = "frame-based"
|
||||
else:
|
||||
# Time-based mode: scroll_speed is already pixels per second
|
||||
pixels_per_second = self.scroll_speed
|
||||
scroll_mode_str = "time-based"
|
||||
|
||||
# Calculate time based on effective pixels per second
|
||||
total_time = total_scroll_distance / pixels_per_second
|
||||
|
||||
# Add buffer time for smooth cycling
|
||||
buffer_time = total_time * self.duration_buffer
|
||||
calculated_duration = int(total_time + buffer_time)
|
||||
|
||||
# Apply min/max limits
|
||||
if calculated_duration < self.min_duration:
|
||||
self.calculated_duration = self.min_duration
|
||||
elif calculated_duration > self.max_duration:
|
||||
self.calculated_duration = self.max_duration
|
||||
else:
|
||||
self.calculated_duration = calculated_duration
|
||||
|
||||
self.logger.debug("Dynamic duration calculation (%s mode):", scroll_mode_str)
|
||||
self.logger.debug(" Display width: %dpx", self.display_width)
|
||||
self.logger.debug(" Content width: %dpx", self.total_scroll_width)
|
||||
self.logger.debug(" Total scroll distance: %dpx", total_scroll_distance)
|
||||
if self.frame_based_scrolling:
|
||||
self.logger.debug(" Scroll speed: %.2f px/frame, delay: %.3fs", self.scroll_speed, self.scroll_delay)
|
||||
self.logger.debug(" Effective speed: %.1f px/second", pixels_per_second)
|
||||
else:
|
||||
self.logger.debug(" Scroll speed: %.1f px/second", pixels_per_second)
|
||||
self.logger.debug(" Base time: %.2fs", total_time)
|
||||
self.logger.debug(" Buffer time: %.2fs", buffer_time)
|
||||
self.logger.debug(" Final duration: %ds", self.calculated_duration)
|
||||
|
||||
return self.calculated_duration
|
||||
|
||||
except (ValueError, ZeroDivisionError, TypeError) as e:
|
||||
self.logger.error("Error calculating dynamic duration: %s", e)
|
||||
return self.min_duration
|
||||
|
||||
def is_scroll_complete(self) -> bool:
|
||||
"""
|
||||
Check if the current scroll cycle is complete.
|
||||
|
||||
Returns:
|
||||
True if scroll has wrapped around to the beginning
|
||||
"""
|
||||
return self.scroll_complete
|
||||
|
||||
def reset_scroll(self) -> None:
|
||||
"""
|
||||
Reset scroll position to beginning.
|
||||
"""
|
||||
self.scroll_position = 0.0
|
||||
self.total_distance_scrolled = 0.0
|
||||
self.scroll_complete = False
|
||||
now = time.time()
|
||||
self.scroll_start_time = now
|
||||
self.last_progress_log_time = now
|
||||
self.last_step_time = now # Reset step timer
|
||||
# Reset last_update_time to prevent large delta_time on next update
|
||||
# This ensures smooth scrolling after reset without jumping ahead
|
||||
self.last_update_time = now
|
||||
self.logger.debug("Scroll position reset")
|
||||
|
||||
def set_scrolling_image(self, image: Image.Image) -> None:
|
||||
"""
|
||||
Set a pre-rendered scrolling image and initialize all required state.
|
||||
|
||||
This method should be used when plugins create their own scrolling image
|
||||
instead of using create_scrolling_image(). It properly initializes both
|
||||
cached_image and cached_array, and updates all related state.
|
||||
|
||||
Args:
|
||||
image: PIL Image containing the scrolling content
|
||||
"""
|
||||
if image is None:
|
||||
self.logger.warning("Attempted to set None as scrolling image, clearing cache instead")
|
||||
self.clear_cache()
|
||||
return
|
||||
|
||||
# Set the cached image
|
||||
self.cached_image = image
|
||||
|
||||
# Convert to numpy array for fast operations (required for get_visible_portion)
|
||||
self.cached_array = np.array(image)
|
||||
|
||||
# Update scroll width
|
||||
self.total_scroll_width = image.width
|
||||
|
||||
# Reset scroll position
|
||||
self.scroll_position = 0.0
|
||||
self.total_distance_scrolled = 0.0
|
||||
self.scroll_complete = False
|
||||
|
||||
# Pre-allocate frame buffer if needed
|
||||
if self._frame_buffer is None or self._frame_buffer.shape != (self.display_height, self.display_width, 3):
|
||||
self._frame_buffer = np.zeros((self.display_height, self.display_width, 3), dtype=np.uint8)
|
||||
|
||||
# Calculate dynamic duration
|
||||
self._calculate_dynamic_duration()
|
||||
|
||||
# Reset timing
|
||||
now = time.time()
|
||||
self.scroll_start_time = now
|
||||
self.last_progress_log_time = now
|
||||
self.last_step_time = now # Initialize step timer for frame-based scrolling
|
||||
|
||||
self.logger.debug("Set scrolling image: %dx%d, total_scroll_width=%d",
|
||||
image.width, image.height, self.total_scroll_width)
|
||||
|
||||
def set_scroll_speed(self, speed: float) -> None:
|
||||
"""
|
||||
Set the scroll speed.
|
||||
|
||||
In time-based mode: pixels per second (typically 10-200)
|
||||
In frame-based mode: pixels per frame (typically 0.5-5 for smooth scrolling)
|
||||
|
||||
Args:
|
||||
speed: Scroll speed (interpretation depends on frame_based_scrolling mode)
|
||||
"""
|
||||
if self.frame_based_scrolling:
|
||||
# In frame-based mode, clamp to reasonable pixels per frame (0.1-5)
|
||||
# Higher values cause visible jumps - 1-2 pixels/frame is ideal for smoothness
|
||||
self.scroll_speed = max(0.1, min(5.0, speed))
|
||||
self.logger.debug(f"Scroll speed set to: {self.scroll_speed} pixels/frame (frame-based mode)")
|
||||
else:
|
||||
# In time-based mode, clamp to pixels per second (1-500)
|
||||
self.scroll_speed = max(1.0, min(500.0, speed))
|
||||
self.logger.debug(f"Scroll speed set to: {self.scroll_speed} pixels/second (time-based mode)")
|
||||
|
||||
def set_scroll_delay(self, delay: float) -> None:
|
||||
"""
|
||||
Set the delay between scroll frames.
|
||||
|
||||
Args:
|
||||
delay: Delay in seconds (typically 0.001-0.1)
|
||||
"""
|
||||
self.scroll_delay = max(0.001, min(1.0, delay))
|
||||
self.logger.debug(f"Scroll delay set to: {self.scroll_delay}")
|
||||
|
||||
def set_target_fps(self, fps: float) -> None:
|
||||
"""
|
||||
Set the target frames per second for scrolling.
|
||||
|
||||
Args:
|
||||
fps: Target FPS (typically 30-200, default 120)
|
||||
"""
|
||||
self.target_fps = max(30.0, min(200.0, fps))
|
||||
self.frame_time_target = 1.0 / self.target_fps
|
||||
self.logger.debug(f"Target FPS set to: {self.target_fps} FPS (frame_time_target: {self.frame_time_target:.4f}s)")
|
||||
|
||||
def set_sub_pixel_scrolling(self, enabled: bool) -> None:
|
||||
"""
|
||||
Enable or disable sub-pixel scrolling for smoother movement.
|
||||
|
||||
When enabled, uses interpolation to blend between pixels for fractional
|
||||
scroll positions, resulting in smooth scrolling even at slow speeds.
|
||||
When disabled, uses integer pixel positioning (faster but may skip pixels).
|
||||
|
||||
Args:
|
||||
enabled: True to enable sub-pixel scrolling (default: True)
|
||||
"""
|
||||
self.sub_pixel_scrolling = enabled
|
||||
self.logger.debug(f"Sub-pixel scrolling {'enabled' if enabled else 'disabled'}")
|
||||
|
||||
def set_frame_based_scrolling(self, enabled: bool) -> None:
|
||||
"""
|
||||
Enable or disable frame-based scrolling.
|
||||
|
||||
When enabled, update_scroll_position() respects scroll_delay and moves
|
||||
scroll_speed pixels per step. This provides a "stepped" look similar to
|
||||
traditional tickers and can be visually smoother on LED matrices.
|
||||
|
||||
Args:
|
||||
enabled: True to enable frame-based scrolling (default: False)
|
||||
"""
|
||||
self.frame_based_scrolling = enabled
|
||||
self.last_step_time = time.time() # Reset step timer
|
||||
self.logger.debug(f"Frame-based scrolling {'enabled' if enabled else 'disabled'}")
|
||||
|
||||
def set_dynamic_duration_settings(self, enabled: bool = True,
|
||||
min_duration: int = 30,
|
||||
max_duration: int = 300,
|
||||
buffer: float = 0.1) -> None:
|
||||
"""
|
||||
Configure dynamic duration calculation.
|
||||
|
||||
Args:
|
||||
enabled: Enable dynamic duration calculation
|
||||
min_duration: Minimum duration in seconds
|
||||
max_duration: Maximum duration in seconds
|
||||
buffer: Buffer percentage (0.0-1.0)
|
||||
"""
|
||||
self.dynamic_duration_enabled = enabled
|
||||
self.min_duration = max(10, min_duration)
|
||||
self.max_duration = max(self.min_duration, max_duration)
|
||||
self.duration_buffer = max(0.0, min(1.0, buffer))
|
||||
|
||||
self.logger.debug(f"Dynamic duration settings: enabled={enabled}, "
|
||||
f"min={self.min_duration}s, max={self.max_duration}s, "
|
||||
f"buffer={self.duration_buffer*100}%")
|
||||
|
||||
def get_dynamic_duration(self) -> int:
|
||||
"""
|
||||
Get the calculated dynamic duration.
|
||||
|
||||
Returns:
|
||||
Duration in seconds
|
||||
"""
|
||||
return self.calculated_duration
|
||||
|
||||
def _calculate_dynamic_duration(self) -> None:
|
||||
"""Internal method to calculate dynamic duration."""
|
||||
self.calculated_duration = self.calculate_dynamic_duration()
|
||||
|
||||
def log_frame_rate(self) -> None:
|
||||
"""
|
||||
Log frame rate statistics for performance monitoring.
|
||||
"""
|
||||
current_time = time.time()
|
||||
|
||||
# Calculate instantaneous frame time
|
||||
frame_time = current_time - self.last_frame_time
|
||||
self.frame_times.append(frame_time)
|
||||
|
||||
# Keep only last 100 frames for average
|
||||
if len(self.frame_times) > 100:
|
||||
self.frame_times.pop(0)
|
||||
|
||||
# Log FPS every 5 seconds to avoid spam
|
||||
if current_time - self.last_fps_log_time >= 5.0:
|
||||
avg_frame_time = sum(self.frame_times) / len(self.frame_times)
|
||||
avg_fps = 1.0 / avg_frame_time if avg_frame_time > 0 else 0
|
||||
instant_fps = 1.0 / frame_time if frame_time > 0 else 0
|
||||
|
||||
self.logger.info(f"Scroll frame stats - Avg FPS: {avg_fps:.1f}, "
|
||||
f"Current FPS: {instant_fps:.1f}, "
|
||||
f"Frame time: {frame_time*1000:.2f}ms")
|
||||
self.last_fps_log_time = current_time
|
||||
self.frame_count = 0
|
||||
|
||||
self.last_frame_time = current_time
|
||||
self.frame_count += 1
|
||||
|
||||
def clear_cache(self) -> None:
|
||||
"""
|
||||
Clear the cached scrolling image.
|
||||
"""
|
||||
self.cached_image = None
|
||||
self.cached_array = None
|
||||
self.total_scroll_width = 0
|
||||
self.scroll_position = 0.0
|
||||
self.total_distance_scrolled = 0.0
|
||||
self.scroll_complete = False
|
||||
self.scroll_start_time = None
|
||||
self.last_progress_log_time = None
|
||||
self.logger.debug("Scroll cache cleared")
|
||||
|
||||
def get_scroll_info(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Get current scroll state information.
|
||||
|
||||
Returns:
|
||||
Dictionary with scroll state information
|
||||
"""
|
||||
# The image already includes display_width padding, so we only need total_scroll_width
|
||||
required_total_distance = self.total_scroll_width if self.total_scroll_width > 0 else 0
|
||||
return {
|
||||
'scroll_position': self.scroll_position,
|
||||
'total_distance_scrolled': self.total_distance_scrolled,
|
||||
'required_total_distance': required_total_distance,
|
||||
'scroll_speed': self.scroll_speed,
|
||||
'scroll_delay': self.scroll_delay,
|
||||
'total_width': self.total_scroll_width,
|
||||
'is_scrolling': self.is_scrolling,
|
||||
'scroll_complete': self.scroll_complete,
|
||||
'dynamic_duration': self.calculated_duration,
|
||||
'elapsed_time': (time.time() - self.scroll_start_time)
|
||||
if self.scroll_start_time
|
||||
else None,
|
||||
'cached_image_size': (self.cached_image.width, self.cached_image.height) if self.cached_image else None
|
||||
}
|
||||
312
src/common/text_helper.py
Normal file
312
src/common/text_helper.py
Normal file
@@ -0,0 +1,312 @@
|
||||
"""
|
||||
Text Helper
|
||||
|
||||
Handles text rendering with outlines, fonts, and positioning for LED matrix displays.
|
||||
Extracted from LEDMatrix core to provide reusable functionality for plugins.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Tuple, Union
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
|
||||
class TextHelper:
|
||||
"""
|
||||
Helper class for text rendering with outlines and font management.
|
||||
|
||||
Provides functionality for:
|
||||
- Loading and managing fonts
|
||||
- Drawing text with outlines for better readability
|
||||
- Calculating text dimensions and positioning
|
||||
- Managing font resources
|
||||
"""
|
||||
|
||||
def __init__(self, font_dir: Optional[Union[str, Path]] = None,
|
||||
logger: Optional[logging.Logger] = None):
|
||||
"""
|
||||
Initialize the TextHelper.
|
||||
|
||||
Args:
|
||||
font_dir: Directory containing font files (defaults to assets/fonts)
|
||||
logger: Optional logger instance
|
||||
"""
|
||||
self.logger = logger or logging.getLogger(__name__)
|
||||
self.font_dir = Path(font_dir) if font_dir else Path("assets/fonts")
|
||||
self._font_cache: Dict[str, ImageFont.ImageFont] = {}
|
||||
|
||||
def load_fonts(self, font_config: Optional[Dict[str, Dict]] = None) -> Dict[str, ImageFont.ImageFont]:
|
||||
"""
|
||||
Load fonts for different text elements.
|
||||
|
||||
Args:
|
||||
font_config: Custom font configuration dictionary
|
||||
|
||||
Returns:
|
||||
Dictionary mapping font names to PIL ImageFont objects
|
||||
"""
|
||||
if font_config is None:
|
||||
font_config = self._get_default_font_config()
|
||||
|
||||
fonts = {}
|
||||
|
||||
for font_name, config in font_config.items():
|
||||
try:
|
||||
font_path = self.font_dir / config['file']
|
||||
size = config['size']
|
||||
|
||||
if font_path.exists():
|
||||
font = ImageFont.truetype(str(font_path), size)
|
||||
fonts[font_name] = font
|
||||
self.logger.debug(f"Loaded font: {font_name} ({font_path}, size {size})")
|
||||
else:
|
||||
# Fallback to default font
|
||||
font = ImageFont.load_default()
|
||||
fonts[font_name] = font
|
||||
self.logger.warning(f"Font file not found: {font_path}, using default")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error loading font {font_name}: {e}")
|
||||
fonts[font_name] = ImageFont.load_default()
|
||||
|
||||
return fonts
|
||||
|
||||
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),
|
||||
outline_width: int = 1) -> None:
|
||||
"""
|
||||
Draw text with an outline for better readability on LED displays.
|
||||
|
||||
Args:
|
||||
draw: PIL ImageDraw object
|
||||
text: Text to draw
|
||||
position: (x, y) position tuple
|
||||
font: PIL ImageFont object
|
||||
fill: Text color (R, G, B)
|
||||
outline_color: Outline color (R, G, B)
|
||||
outline_width: Width of outline in pixels
|
||||
"""
|
||||
x, y = position
|
||||
|
||||
# Draw outline by drawing text in outline color at offset positions
|
||||
for dx in range(-outline_width, outline_width + 1):
|
||||
for dy in range(-outline_width, outline_width + 1):
|
||||
if dx != 0 or dy != 0: # Skip center position
|
||||
draw.text((x + dx, y + dy), text, font=font, fill=outline_color)
|
||||
|
||||
# Draw main text
|
||||
draw.text((x, y), text, font=font, fill=fill)
|
||||
|
||||
def get_text_width(self, text: str, font: ImageFont.ImageFont) -> int:
|
||||
"""
|
||||
Get the width of text when rendered with the given font.
|
||||
|
||||
Args:
|
||||
text: Text to measure
|
||||
font: PIL ImageFont object
|
||||
|
||||
Returns:
|
||||
Width in pixels
|
||||
"""
|
||||
try:
|
||||
return draw.textlength(text, font=font)
|
||||
except AttributeError:
|
||||
# Fallback for older PIL versions
|
||||
bbox = draw.textbbox((0, 0), text, font=font)
|
||||
return bbox[2] - bbox[0]
|
||||
|
||||
def get_text_height(self, text: str, font: ImageFont.ImageFont) -> int:
|
||||
"""
|
||||
Get the height of text when rendered with the given font.
|
||||
|
||||
Args:
|
||||
text: Text to measure
|
||||
font: PIL ImageFont object
|
||||
|
||||
Returns:
|
||||
Height in pixels
|
||||
"""
|
||||
try:
|
||||
bbox = draw.textbbox((0, 0), text, font=font)
|
||||
return bbox[3] - bbox[1]
|
||||
except AttributeError:
|
||||
# Fallback for older PIL versions
|
||||
bbox = draw.textbbox((0, 0), text, font=font)
|
||||
return bbox[3] - bbox[1]
|
||||
|
||||
def get_text_dimensions(self, text: str, font: ImageFont.ImageFont) -> Tuple[int, int]:
|
||||
"""
|
||||
Get both width and height of text.
|
||||
|
||||
Args:
|
||||
text: Text to measure
|
||||
font: PIL ImageFont object
|
||||
|
||||
Returns:
|
||||
(width, height) tuple
|
||||
"""
|
||||
return (self.get_text_width(text, font), self.get_text_height(text, font))
|
||||
|
||||
def center_text(self, text: str, font: ImageFont.ImageFont,
|
||||
container_width: int, container_height: int) -> Tuple[int, int]:
|
||||
"""
|
||||
Calculate position to center text within a container.
|
||||
|
||||
Args:
|
||||
text: Text to center
|
||||
font: PIL ImageFont object
|
||||
container_width: Width of container
|
||||
container_height: Height of container
|
||||
|
||||
Returns:
|
||||
(x, y) position tuple for centered text
|
||||
"""
|
||||
text_width, text_height = self.get_text_dimensions(text, font)
|
||||
x = (container_width - text_width) // 2
|
||||
y = (container_height - text_height) // 2
|
||||
return (x, y)
|
||||
|
||||
def wrap_text(self, text: str, font: ImageFont.ImageFont,
|
||||
max_width: int, max_lines: Optional[int] = None) -> List[str]:
|
||||
"""
|
||||
Wrap text to fit within specified width.
|
||||
|
||||
Args:
|
||||
text: Text to wrap
|
||||
font: PIL ImageFont object
|
||||
max_width: Maximum width in pixels
|
||||
max_lines: Maximum number of lines (None for unlimited)
|
||||
|
||||
Returns:
|
||||
List of text lines
|
||||
"""
|
||||
words = text.split()
|
||||
lines = []
|
||||
current_line = []
|
||||
|
||||
for word in words:
|
||||
# Test if adding this word would exceed width
|
||||
test_line = ' '.join(current_line + [word])
|
||||
if self.get_text_width(test_line, font) <= max_width:
|
||||
current_line.append(word)
|
||||
else:
|
||||
# Start new line
|
||||
if current_line:
|
||||
lines.append(' '.join(current_line))
|
||||
current_line = [word]
|
||||
else:
|
||||
# Single word is too long, add it anyway
|
||||
lines.append(word)
|
||||
|
||||
# Add remaining words
|
||||
if current_line:
|
||||
lines.append(' '.join(current_line))
|
||||
|
||||
# Limit lines if specified
|
||||
if max_lines is not None:
|
||||
lines = lines[:max_lines]
|
||||
|
||||
return lines
|
||||
|
||||
def draw_multiline_text(self, draw: ImageDraw.ImageDraw, text: str,
|
||||
position: Tuple[int, int], font: ImageFont.ImageFont,
|
||||
line_spacing: int = 2, **kwargs) -> None:
|
||||
"""
|
||||
Draw multiline text with proper spacing.
|
||||
|
||||
Args:
|
||||
draw: PIL ImageDraw object
|
||||
text: Text to draw (can contain newlines)
|
||||
position: Starting (x, y) position
|
||||
font: PIL ImageFont object
|
||||
line_spacing: Pixels between lines
|
||||
**kwargs: Additional arguments for draw_text_with_outline
|
||||
"""
|
||||
x, y = position
|
||||
lines = text.split('\n')
|
||||
|
||||
for line in lines:
|
||||
if line.strip(): # Skip empty lines
|
||||
self.draw_text_with_outline(draw, line, (x, y), font, **kwargs)
|
||||
y += self.get_text_height(line, font) + line_spacing
|
||||
|
||||
def create_text_image(self, text: str, font: ImageFont.ImageFont,
|
||||
background_color: Tuple[int, int, int] = (0, 0, 0),
|
||||
text_color: Tuple[int, int, int] = (255, 255, 255),
|
||||
padding: int = 5) -> Image.Image:
|
||||
"""
|
||||
Create an image containing only the specified text.
|
||||
|
||||
Args:
|
||||
text: Text to render
|
||||
font: PIL ImageFont object
|
||||
background_color: Background color (R, G, B)
|
||||
text_color: Text color (R, G, B)
|
||||
padding: Padding around text in pixels
|
||||
|
||||
Returns:
|
||||
PIL Image containing the text
|
||||
"""
|
||||
# Calculate dimensions
|
||||
text_width, text_height = self.get_text_dimensions(text, font)
|
||||
img_width = text_width + (padding * 2)
|
||||
img_height = text_height + (padding * 2)
|
||||
|
||||
# Create image
|
||||
img = Image.new('RGB', (img_width, img_height), background_color)
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# Draw text
|
||||
self.draw_text_with_outline(draw, text, (padding, padding), font,
|
||||
fill=text_color)
|
||||
|
||||
return img
|
||||
|
||||
def _get_default_font_config(self) -> Dict[str, Dict]:
|
||||
"""Get default font configuration."""
|
||||
return {
|
||||
'score': {
|
||||
'file': 'PressStart2P-Regular.ttf',
|
||||
'size': 10
|
||||
},
|
||||
'time': {
|
||||
'file': 'PressStart2P-Regular.ttf',
|
||||
'size': 8
|
||||
},
|
||||
'team': {
|
||||
'file': 'PressStart2P-Regular.ttf',
|
||||
'size': 8
|
||||
},
|
||||
'status': {
|
||||
'file': '4x6-font.ttf',
|
||||
'size': 6
|
||||
},
|
||||
'detail': {
|
||||
'file': '4x6-font.ttf',
|
||||
'size': 6
|
||||
},
|
||||
'rank': {
|
||||
'file': 'PressStart2P-Regular.ttf',
|
||||
'size': 10
|
||||
}
|
||||
}
|
||||
|
||||
def clear_font_cache(self) -> None:
|
||||
"""Clear the font cache."""
|
||||
self._font_cache.clear()
|
||||
self.logger.debug("Font cache cleared")
|
||||
|
||||
def get_font_cache_stats(self) -> Dict[str, int]:
|
||||
"""
|
||||
Get font cache statistics.
|
||||
|
||||
Returns:
|
||||
Dictionary with cache statistics
|
||||
"""
|
||||
return {
|
||||
'cached_fonts': len(self._font_cache)
|
||||
}
|
||||
331
src/common/utils.py
Normal file
331
src/common/utils.py
Normal file
@@ -0,0 +1,331 @@
|
||||
"""
|
||||
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 Optional, Tuple, 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
|
||||
@@ -1,20 +1,148 @@
|
||||
import json
|
||||
import os
|
||||
from typing import Dict, Any, Optional
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, Optional, List
|
||||
from src.exceptions import ConfigError
|
||||
from src.logging_config import get_logger
|
||||
from src.config_manager_atomic import (
|
||||
AtomicConfigManager, SaveResult, SaveResultStatus,
|
||||
BackupInfo, ValidationResult
|
||||
)
|
||||
from src.common.permission_utils import (
|
||||
ensure_directory_permissions,
|
||||
ensure_file_permissions,
|
||||
get_config_file_mode,
|
||||
get_config_dir_mode
|
||||
)
|
||||
|
||||
class ConfigManager:
|
||||
def __init__(self, config_path: str = None, secrets_path: str = None):
|
||||
def __init__(self, config_path: Optional[str] = None, secrets_path: Optional[str] = None) -> None:
|
||||
# Use current working directory as base
|
||||
self.config_path = config_path or "config/config.json"
|
||||
self.secrets_path = secrets_path or "config/config_secrets.json"
|
||||
self.template_path = "config/config.template.json"
|
||||
self.config_path: str = config_path or "config/config.json"
|
||||
self.secrets_path: str = secrets_path or "config/config_secrets.json"
|
||||
self.template_path: str = "config/config.template.json"
|
||||
self.config: Dict[str, Any] = {}
|
||||
self.logger: logging.Logger = get_logger(__name__)
|
||||
|
||||
# Initialize atomic config manager
|
||||
self._atomic_manager: Optional[AtomicConfigManager] = None
|
||||
|
||||
def get_config_path(self) -> str:
|
||||
return self.config_path
|
||||
|
||||
def get_secrets_path(self) -> str:
|
||||
return self.secrets_path
|
||||
|
||||
def _get_atomic_manager(self) -> AtomicConfigManager:
|
||||
"""Get or create atomic config manager instance."""
|
||||
if self._atomic_manager is None:
|
||||
self._atomic_manager = AtomicConfigManager(
|
||||
config_path=self.config_path,
|
||||
secrets_path=self.secrets_path
|
||||
)
|
||||
return self._atomic_manager
|
||||
|
||||
def save_config_atomic(
|
||||
self,
|
||||
new_config_data: Dict[str, Any],
|
||||
create_backup: bool = True,
|
||||
validate_after_write: bool = True
|
||||
) -> SaveResult:
|
||||
"""
|
||||
Save configuration atomically with backup and rollback support.
|
||||
|
||||
This method provides atomic file operations to prevent corruption
|
||||
and enables recovery from failed saves.
|
||||
|
||||
Args:
|
||||
new_config_data: New configuration data to save
|
||||
create_backup: Whether to create backup before saving (default: True)
|
||||
validate_after_write: Whether to validate after writing (default: True)
|
||||
|
||||
Returns:
|
||||
SaveResult with status and details
|
||||
"""
|
||||
# Load current secrets to preserve them
|
||||
secrets_content = {}
|
||||
if os.path.exists(self.secrets_path):
|
||||
try:
|
||||
with open(self.secrets_path, 'r') as f_secrets:
|
||||
secrets_content = json.load(f_secrets)
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Could not load secrets file {self.secrets_path} during save: {e}")
|
||||
|
||||
# Strip secrets from main config before saving
|
||||
config_to_write = self._strip_secrets_recursive(new_config_data, secrets_content)
|
||||
|
||||
# Use atomic manager to save
|
||||
atomic_mgr = self._get_atomic_manager()
|
||||
result = atomic_mgr.save_config_atomic(
|
||||
new_config=config_to_write,
|
||||
new_secrets=secrets_content if secrets_content else None,
|
||||
create_backup=create_backup,
|
||||
validate_after_write=validate_after_write
|
||||
)
|
||||
|
||||
# Update in-memory config if save was successful
|
||||
if result.status == SaveResultStatus.SUCCESS:
|
||||
self.config = new_config_data
|
||||
self.logger.info(f"Configuration successfully saved atomically to {os.path.abspath(self.config_path)}")
|
||||
elif result.status == SaveResultStatus.ROLLED_BACK:
|
||||
# Reload config from file after rollback
|
||||
try:
|
||||
self.load_config()
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error reloading config after rollback: {e}")
|
||||
|
||||
return result
|
||||
|
||||
def rollback_config(self, backup_version: Optional[str] = None) -> bool:
|
||||
"""
|
||||
Rollback configuration to a previous backup.
|
||||
|
||||
Args:
|
||||
backup_version: Specific backup version to restore (timestamp string).
|
||||
If None, restores most recent backup.
|
||||
|
||||
Returns:
|
||||
True if rollback successful, False otherwise
|
||||
"""
|
||||
atomic_mgr = self._get_atomic_manager()
|
||||
success = atomic_mgr.rollback_config(backup_version)
|
||||
|
||||
if success:
|
||||
# Reload config after rollback
|
||||
try:
|
||||
self.load_config()
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error reloading config after rollback: {e}")
|
||||
return False
|
||||
|
||||
return success
|
||||
|
||||
def list_backups(self) -> List[BackupInfo]:
|
||||
"""
|
||||
List all available configuration backups.
|
||||
|
||||
Returns:
|
||||
List of BackupInfo objects, sorted by timestamp (newest first)
|
||||
"""
|
||||
atomic_mgr = self._get_atomic_manager()
|
||||
return atomic_mgr.list_backups()
|
||||
|
||||
def validate_config_file(self, config_path: Optional[str] = None) -> ValidationResult:
|
||||
"""
|
||||
Validate a configuration file.
|
||||
|
||||
Args:
|
||||
config_path: Path to config file. If None, validates current config_path.
|
||||
|
||||
Returns:
|
||||
ValidationResult with validation status and errors
|
||||
"""
|
||||
atomic_mgr = self._get_atomic_manager()
|
||||
return atomic_mgr.validate_config_file(config_path)
|
||||
|
||||
def load_config(self) -> Dict[str, Any]:
|
||||
"""Load configuration from JSON files."""
|
||||
@@ -24,7 +152,7 @@ class ConfigManager:
|
||||
self._create_config_from_template()
|
||||
|
||||
# Load main config
|
||||
print(f"Attempting to load config from: {os.path.abspath(self.config_path)}")
|
||||
self.logger.info(f"Attempting to load config from: {os.path.abspath(self.config_path)}")
|
||||
with open(self.config_path, 'r') as f:
|
||||
self.config = json.load(f)
|
||||
|
||||
@@ -39,23 +167,30 @@ class ConfigManager:
|
||||
# Deep merge secrets into config
|
||||
self._deep_merge(self.config, secrets)
|
||||
except PermissionError as e:
|
||||
print(f"Secrets file not readable ({self.secrets_path}): {e}. Continuing without secrets.")
|
||||
self.logger.warning(f"Secrets file not readable ({self.secrets_path}): {e}. Continuing without secrets.")
|
||||
except (json.JSONDecodeError, OSError) as e:
|
||||
print(f"Error reading secrets file ({self.secrets_path}): {e}. Continuing without secrets.")
|
||||
self.logger.warning(f"Error reading secrets file ({self.secrets_path}): {e}. Continuing without secrets.")
|
||||
|
||||
return self.config
|
||||
|
||||
except FileNotFoundError as e:
|
||||
if str(e).find('config_secrets.json') == -1: # Only raise if main config is missing
|
||||
print(f"Configuration file not found at {os.path.abspath(self.config_path)}")
|
||||
raise
|
||||
error_msg = f"Configuration file not found at {os.path.abspath(self.config_path)}"
|
||||
self.logger.error(error_msg, exc_info=True)
|
||||
raise ConfigError(error_msg, config_path=self.config_path) from e
|
||||
return self.config
|
||||
except json.JSONDecodeError:
|
||||
print("Error parsing configuration file")
|
||||
raise
|
||||
except json.JSONDecodeError as e:
|
||||
error_msg = f"Error parsing configuration file {os.path.abspath(self.config_path)}"
|
||||
self.logger.error(error_msg, exc_info=True)
|
||||
raise ConfigError(error_msg, config_path=self.config_path) from e
|
||||
except (IOError, OSError, PermissionError) as e:
|
||||
error_msg = f"Error loading configuration from {os.path.abspath(self.config_path)}"
|
||||
self.logger.error(error_msg, exc_info=True)
|
||||
raise ConfigError(error_msg, config_path=self.config_path) from e
|
||||
except Exception as e:
|
||||
print(f"Error loading configuration: {str(e)}")
|
||||
raise
|
||||
error_msg = f"Unexpected error loading configuration: {str(e)}"
|
||||
self.logger.error(error_msg, exc_info=True)
|
||||
raise ConfigError(error_msg, config_path=self.config_path) from e
|
||||
|
||||
def _strip_secrets_recursive(self, data_to_filter: Dict[str, Any], secrets: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Recursively remove secret keys from a dictionary."""
|
||||
@@ -81,7 +216,7 @@ class ConfigManager:
|
||||
with open(self.secrets_path, 'r') as f_secrets:
|
||||
secrets_content = json.load(f_secrets)
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not load secrets file {self.secrets_path} during save: {e}")
|
||||
self.logger.warning(f"Could not load secrets file {self.secrets_path} during save: {e}")
|
||||
# Continue without stripping if secrets can't be loaded, or handle as critical error
|
||||
# For now, we'll proceed cautiously and save the full new_config_data if secrets are unreadable
|
||||
# to prevent accidental data loss if the secrets file is temporarily corrupt.
|
||||
@@ -95,16 +230,18 @@ class ConfigManager:
|
||||
|
||||
# Update the in-memory config to the new state (which includes secrets for runtime)
|
||||
self.config = new_config_data
|
||||
print(f"Configuration successfully saved to {os.path.abspath(self.config_path)}")
|
||||
self.logger.info(f"Configuration successfully saved to {os.path.abspath(self.config_path)}")
|
||||
if secrets_content:
|
||||
print("Secret values were preserved in memory and not written to the main config file.")
|
||||
self.logger.info("Secret values were preserved in memory and not written to the main config file.")
|
||||
|
||||
except IOError as e:
|
||||
print(f"Error writing configuration to file {os.path.abspath(self.config_path)}: {e}")
|
||||
raise
|
||||
except (IOError, OSError, PermissionError) as e:
|
||||
error_msg = f"Error writing configuration to file {os.path.abspath(self.config_path)}"
|
||||
self.logger.error(error_msg, exc_info=True)
|
||||
raise ConfigError(error_msg, config_path=self.config_path) from e
|
||||
except Exception as e:
|
||||
print(f"An unexpected error occurred while saving configuration: {str(e)}")
|
||||
raise
|
||||
error_msg = f"Unexpected error occurred while saving configuration: {str(e)}"
|
||||
self.logger.error(error_msg, exc_info=True)
|
||||
raise ConfigError(error_msg, config_path=self.config_path) from e
|
||||
|
||||
def get_secret(self, key: str) -> Optional[Any]:
|
||||
"""Get a secret value by key."""
|
||||
@@ -115,10 +252,10 @@ class ConfigManager:
|
||||
secrets = json.load(f)
|
||||
return secrets.get(key)
|
||||
except (json.JSONDecodeError, IOError) as e:
|
||||
print(f"Error reading secrets file: {e}")
|
||||
self.logger.error(f"Error reading secrets file: {e}")
|
||||
return None
|
||||
|
||||
def _deep_merge(self, target: Dict, source: Dict) -> None:
|
||||
def _deep_merge(self, target: Dict[str, Any], source: Dict[str, Any]) -> None:
|
||||
"""Deep merge source dict into target dict."""
|
||||
for key, value in source.items():
|
||||
if key in target and isinstance(target[key], dict) and isinstance(value, dict):
|
||||
@@ -129,12 +266,15 @@ class ConfigManager:
|
||||
def _create_config_from_template(self) -> None:
|
||||
"""Create config.json from template if it doesn't exist."""
|
||||
if not os.path.exists(self.template_path):
|
||||
raise FileNotFoundError(f"Template file not found at {os.path.abspath(self.template_path)}")
|
||||
error_msg = f"Template file not found at {os.path.abspath(self.template_path)}"
|
||||
self.logger.error(error_msg)
|
||||
raise ConfigError(error_msg, config_path=self.template_path)
|
||||
|
||||
print(f"Creating config.json from template at {os.path.abspath(self.template_path)}")
|
||||
self.logger.info(f"Creating config.json from template at {os.path.abspath(self.template_path)}")
|
||||
|
||||
# Ensure config directory exists
|
||||
os.makedirs(os.path.dirname(self.config_path), exist_ok=True)
|
||||
# Ensure config directory exists with proper permissions
|
||||
config_dir = Path(self.config_path).parent
|
||||
ensure_directory_permissions(config_dir, get_config_dir_mode())
|
||||
|
||||
# Copy template to config
|
||||
with open(self.template_path, 'r') as template_file:
|
||||
@@ -143,12 +283,16 @@ class ConfigManager:
|
||||
with open(self.config_path, 'w') as config_file:
|
||||
json.dump(template_data, config_file, indent=4)
|
||||
|
||||
print(f"Created config.json from template at {os.path.abspath(self.config_path)}")
|
||||
# Set proper file permissions after creation
|
||||
config_path_obj = Path(self.config_path)
|
||||
ensure_file_permissions(config_path_obj, get_config_file_mode(config_path_obj))
|
||||
|
||||
self.logger.info(f"Created config.json from template at {os.path.abspath(self.config_path)}")
|
||||
|
||||
def _migrate_config(self) -> None:
|
||||
"""Migrate config to add new items from template with defaults."""
|
||||
if not os.path.exists(self.template_path):
|
||||
print(f"Template file not found at {os.path.abspath(self.template_path)}, skipping migration")
|
||||
self.logger.warning(f"Template file not found at {os.path.abspath(self.template_path)}, skipping migration")
|
||||
return
|
||||
|
||||
try:
|
||||
@@ -157,27 +301,44 @@ class ConfigManager:
|
||||
|
||||
# Check if migration is needed
|
||||
if self._config_needs_migration(self.config, template_config):
|
||||
print("Config migration needed - adding new configuration items with defaults")
|
||||
self.logger.info("Config migration needed - adding new configuration items with defaults")
|
||||
|
||||
# Create backup of current config
|
||||
backup_path = f"{self.config_path}.backup"
|
||||
with open(backup_path, 'w') as backup_file:
|
||||
json.dump(self.config, backup_file, indent=4)
|
||||
print(f"Created backup of current config at {os.path.abspath(backup_path)}")
|
||||
self.logger.info(f"Created backup of current config at {os.path.abspath(backup_path)}")
|
||||
|
||||
# Merge template defaults into current config
|
||||
self._merge_template_defaults(self.config, template_config)
|
||||
|
||||
# Save migrated config
|
||||
with open(self.config_path, 'w') as f:
|
||||
json.dump(self.config, f, indent=4)
|
||||
# Save migrated config using atomic save to preserve permissions
|
||||
# Load secrets if they exist to pass to atomic save
|
||||
secrets_content = {}
|
||||
if os.path.exists(self.secrets_path):
|
||||
try:
|
||||
with open(self.secrets_path, 'r') as f_secrets:
|
||||
secrets_content = json.load(f_secrets)
|
||||
except Exception:
|
||||
pass # Continue without secrets if can't load
|
||||
|
||||
print(f"Config migration completed and saved to {os.path.abspath(self.config_path)}")
|
||||
# Use atomic save to preserve file permissions
|
||||
# Note: save_config_atomic handles secrets internally, no need to pass new_secrets
|
||||
result = self.save_config_atomic(
|
||||
new_config_data=self.config,
|
||||
create_backup=False, # Already created backup above
|
||||
validate_after_write=False # Skip validation for migration
|
||||
)
|
||||
|
||||
if result.status.value == "success":
|
||||
self.logger.info(f"Config migration completed and saved to {os.path.abspath(self.config_path)}")
|
||||
else:
|
||||
self.logger.warning(f"Config migration completed but save had issues: {result.message}")
|
||||
else:
|
||||
print("Config is up to date, no migration needed")
|
||||
self.logger.debug("Config is up to date, no migration needed")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error during config migration: {e}")
|
||||
self.logger.error(f"Error during config migration: {e}")
|
||||
# Don't raise - continue with current config
|
||||
|
||||
def _config_needs_migration(self, current_config: Dict[str, Any], template_config: Dict[str, Any]) -> bool:
|
||||
@@ -200,7 +361,7 @@ class ConfigManager:
|
||||
if key not in current:
|
||||
# Add new key with template value
|
||||
current[key] = value
|
||||
print(f"Added new config key: {key}")
|
||||
self.logger.debug(f"Added new config key: {key}")
|
||||
elif isinstance(value, dict) and isinstance(current[key], dict):
|
||||
# Recursively merge nested dictionaries
|
||||
self._merge_template_defaults(current[key], value)
|
||||
@@ -217,6 +378,17 @@ class ConfigManager:
|
||||
"""Get clock configuration."""
|
||||
return self.config.get('clock', {})
|
||||
|
||||
def get_config(self) -> Dict[str, Any]:
|
||||
"""Get the full configuration dictionary.
|
||||
|
||||
Returns:
|
||||
The complete configuration dictionary. If config hasn't been loaded yet,
|
||||
it will be loaded first.
|
||||
"""
|
||||
if not self.config:
|
||||
self.load_config()
|
||||
return self.config
|
||||
|
||||
def get_raw_file_content(self, file_type: str) -> Dict[str, Any]:
|
||||
"""Load raw content of 'main' config or 'secrets' config file."""
|
||||
path_to_load = ""
|
||||
@@ -231,18 +403,25 @@ class ConfigManager:
|
||||
# If a secrets file doesn't exist, it's not an error, just return empty
|
||||
if file_type == "secrets":
|
||||
return {}
|
||||
print(f"{file_type.capitalize()} configuration file not found at {os.path.abspath(path_to_load)}")
|
||||
raise FileNotFoundError(f"{file_type.capitalize()} configuration file not found at {os.path.abspath(path_to_load)}")
|
||||
error_msg = f"{file_type.capitalize()} configuration file not found at {os.path.abspath(path_to_load)}"
|
||||
self.logger.error(error_msg)
|
||||
raise ConfigError(error_msg, config_path=path_to_load)
|
||||
|
||||
try:
|
||||
with open(path_to_load, 'r') as f:
|
||||
return json.load(f)
|
||||
except json.JSONDecodeError:
|
||||
print(f"Error parsing {file_type} configuration file: {path_to_load}")
|
||||
raise
|
||||
except json.JSONDecodeError as e:
|
||||
error_msg = f"Error parsing {file_type} configuration file: {path_to_load}"
|
||||
self.logger.error(error_msg, exc_info=True)
|
||||
raise ConfigError(error_msg, config_path=path_to_load) from e
|
||||
except (IOError, OSError, PermissionError) as e:
|
||||
error_msg = f"Error loading {file_type} configuration file {path_to_load}: {str(e)}"
|
||||
self.logger.error(error_msg, exc_info=True)
|
||||
raise ConfigError(error_msg, config_path=path_to_load) from e
|
||||
except Exception as e:
|
||||
print(f"Error loading {file_type} configuration file {path_to_load}: {str(e)}")
|
||||
raise
|
||||
error_msg = f"Unexpected error loading {file_type} configuration file {path_to_load}: {str(e)}"
|
||||
self.logger.error(error_msg, exc_info=True)
|
||||
raise ConfigError(error_msg, config_path=path_to_load) from e
|
||||
|
||||
def save_raw_file_content(self, file_type: str, data: Dict[str, Any]) -> None:
|
||||
"""Save data directly to 'main' config or 'secrets' config file."""
|
||||
@@ -256,19 +435,158 @@ class ConfigManager:
|
||||
|
||||
try:
|
||||
# Create directory if it doesn't exist, especially for config/
|
||||
os.makedirs(os.path.dirname(path_to_save), exist_ok=True)
|
||||
path_obj = Path(path_to_save)
|
||||
ensure_directory_permissions(path_obj.parent, get_config_dir_mode())
|
||||
|
||||
with open(path_to_save, 'w') as f:
|
||||
json.dump(data, f, indent=4)
|
||||
print(f"{file_type.capitalize()} configuration successfully saved to {os.path.abspath(path_to_save)}")
|
||||
|
||||
# Set proper file permissions after writing
|
||||
ensure_file_permissions(path_obj, get_config_file_mode(path_obj))
|
||||
|
||||
self.logger.info(f"{file_type.capitalize()} configuration successfully saved to {os.path.abspath(path_to_save)}")
|
||||
|
||||
# If we just saved the main config or secrets, the merged self.config might be stale.
|
||||
# Reload it to reflect the new state.
|
||||
if file_type == "main" or file_type == "secrets":
|
||||
self.load_config()
|
||||
|
||||
except IOError as e:
|
||||
print(f"Error writing {file_type} configuration to file {os.path.abspath(path_to_save)}: {e}")
|
||||
raise
|
||||
except (IOError, OSError, PermissionError) as e:
|
||||
error_msg = f"Error writing {file_type} configuration to file {os.path.abspath(path_to_save)}"
|
||||
self.logger.error(error_msg, exc_info=True)
|
||||
raise ConfigError(error_msg, config_path=path_to_save) from e
|
||||
except Exception as e:
|
||||
print(f"An unexpected error occurred while saving {file_type} configuration: {str(e)}")
|
||||
raise
|
||||
error_msg = f"Unexpected error occurred while saving {file_type} configuration: {str(e)}"
|
||||
self.logger.error(error_msg, exc_info=True)
|
||||
raise ConfigError(error_msg, config_path=path_to_save) from e
|
||||
|
||||
def cleanup_plugin_config(self, plugin_id: str, remove_secrets: bool = True) -> None:
|
||||
"""
|
||||
Remove plugin configuration from both main config and secrets config.
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin identifier to remove
|
||||
remove_secrets: If True, also remove plugin secrets
|
||||
"""
|
||||
try:
|
||||
# Load current configs
|
||||
main_config = self.get_raw_file_content('main')
|
||||
secrets_config = self.get_raw_file_content('secrets') if os.path.exists(self.secrets_path) else {}
|
||||
|
||||
# Remove plugin from main config
|
||||
if plugin_id in main_config:
|
||||
del main_config[plugin_id]
|
||||
self.save_raw_file_content('main', main_config)
|
||||
self.logger.info(f"Removed plugin {plugin_id} from main configuration")
|
||||
|
||||
# Remove plugin from secrets config if requested
|
||||
if remove_secrets and plugin_id in secrets_config:
|
||||
del secrets_config[plugin_id]
|
||||
self.save_raw_file_content('secrets', secrets_config)
|
||||
self.logger.info(f"Removed plugin {plugin_id} from secrets configuration")
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Error cleaning up plugin config for {plugin_id}"
|
||||
self.logger.error(error_msg, exc_info=True)
|
||||
raise ConfigError(error_msg, config_path=self.config_path, field=plugin_id) from e
|
||||
|
||||
def cleanup_orphaned_plugin_configs(self, valid_plugin_ids: List[str]) -> List[str]:
|
||||
"""
|
||||
Remove configuration sections for plugins that are no longer installed.
|
||||
|
||||
Args:
|
||||
valid_plugin_ids: List of currently installed plugin IDs
|
||||
|
||||
Returns:
|
||||
List of plugin IDs that were removed
|
||||
"""
|
||||
removed = []
|
||||
try:
|
||||
# Load current configs
|
||||
main_config = self.get_raw_file_content('main')
|
||||
secrets_config = self.get_raw_file_content('secrets') if os.path.exists(self.secrets_path) else {}
|
||||
|
||||
valid_set = set(valid_plugin_ids)
|
||||
|
||||
# Find orphaned plugins in main config
|
||||
main_plugins = set(main_config.keys())
|
||||
orphaned_main = main_plugins - valid_set
|
||||
|
||||
# Find orphaned plugins in secrets config
|
||||
secrets_plugins = set(secrets_config.keys())
|
||||
orphaned_secrets = secrets_plugins - valid_set
|
||||
|
||||
all_orphaned = orphaned_main | orphaned_secrets
|
||||
|
||||
if all_orphaned:
|
||||
# Remove from main config
|
||||
for plugin_id in orphaned_main:
|
||||
del main_config[plugin_id]
|
||||
removed.append(plugin_id)
|
||||
|
||||
# Remove from secrets config
|
||||
for plugin_id in orphaned_secrets:
|
||||
del secrets_config[plugin_id]
|
||||
|
||||
# Save updated configs
|
||||
if orphaned_main:
|
||||
self.save_raw_file_content('main', main_config)
|
||||
if orphaned_secrets:
|
||||
self.save_raw_file_content('secrets', secrets_config)
|
||||
|
||||
self.logger.info(f"Cleaned up orphaned plugin configs: {', '.join(all_orphaned)}")
|
||||
|
||||
return removed
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error cleaning up orphaned plugin configs: {e}")
|
||||
return removed
|
||||
|
||||
def validate_all_plugin_configs(self, plugin_schema_manager=None) -> Dict[str, Dict[str, Any]]:
|
||||
"""
|
||||
Validate all plugin configurations against their schemas.
|
||||
|
||||
Args:
|
||||
plugin_schema_manager: Optional SchemaManager instance for validation
|
||||
|
||||
Returns:
|
||||
Dict mapping plugin_id to validation results: {
|
||||
'valid': bool,
|
||||
'errors': list of error messages
|
||||
}
|
||||
"""
|
||||
results = {}
|
||||
|
||||
if not plugin_schema_manager:
|
||||
return results
|
||||
|
||||
try:
|
||||
main_config = self.get_raw_file_content('main')
|
||||
|
||||
for plugin_id, plugin_config in main_config.items():
|
||||
if not isinstance(plugin_config, dict):
|
||||
continue
|
||||
|
||||
# Skip non-plugin config sections
|
||||
if plugin_id in ['display', 'schedule', 'timezone', 'plugin_system']:
|
||||
continue
|
||||
|
||||
schema = plugin_schema_manager.load_schema(plugin_id, use_cache=True)
|
||||
if schema:
|
||||
is_valid, errors = plugin_schema_manager.validate_config_against_schema(
|
||||
plugin_config, schema, plugin_id
|
||||
)
|
||||
results[plugin_id] = {
|
||||
'valid': is_valid,
|
||||
'errors': errors
|
||||
}
|
||||
else:
|
||||
results[plugin_id] = {
|
||||
'valid': True, # No schema = can't validate, but not an error
|
||||
'errors': []
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error validating plugin configs: {e}")
|
||||
|
||||
return results
|
||||
547
src/config_manager_atomic.py
Normal file
547
src/config_manager_atomic.py
Normal file
@@ -0,0 +1,547 @@
|
||||
"""
|
||||
Atomic configuration save manager with backup and rollback support.
|
||||
|
||||
Provides atomic file operations for configuration files to prevent corruption
|
||||
and enable recovery from failed saves.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, Optional, List, Tuple
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
|
||||
from src.exceptions import ConfigError
|
||||
from src.logging_config import get_logger
|
||||
|
||||
|
||||
class SaveResultStatus(Enum):
|
||||
"""Status of a save operation."""
|
||||
SUCCESS = "success"
|
||||
FAILED = "failed"
|
||||
VALIDATION_FAILED = "validation_failed"
|
||||
ROLLED_BACK = "rolled_back"
|
||||
|
||||
|
||||
@dataclass
|
||||
class SaveResult:
|
||||
"""Result of an atomic save operation."""
|
||||
status: SaveResultStatus
|
||||
message: str
|
||||
backup_path: Optional[str] = None
|
||||
validation_errors: Optional[List[str]] = None
|
||||
error: Optional[Exception] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class BackupInfo:
|
||||
"""Information about a configuration backup."""
|
||||
version: str
|
||||
path: str
|
||||
timestamp: datetime
|
||||
size: int
|
||||
is_valid: bool
|
||||
|
||||
|
||||
@dataclass
|
||||
class ValidationResult:
|
||||
"""Result of configuration file validation."""
|
||||
is_valid: bool
|
||||
errors: List[str]
|
||||
warnings: List[str]
|
||||
|
||||
|
||||
class AtomicConfigManager:
|
||||
"""
|
||||
Manages atomic configuration saves with backup and rollback support.
|
||||
|
||||
Provides:
|
||||
- Atomic file writes (write to temp, validate, atomic move)
|
||||
- Automatic backups before saves
|
||||
- Backup rotation (keep last N backups)
|
||||
- Rollback functionality
|
||||
- Post-write validation
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config_path: str,
|
||||
secrets_path: Optional[str] = None,
|
||||
backup_dir: Optional[str] = None,
|
||||
max_backups: int = 5
|
||||
):
|
||||
"""
|
||||
Initialize atomic config manager.
|
||||
|
||||
Args:
|
||||
config_path: Path to main configuration file
|
||||
secrets_path: Optional path to secrets file (saved atomically with main config)
|
||||
backup_dir: Directory to store backups (default: config/backups/)
|
||||
max_backups: Maximum number of backups to keep
|
||||
"""
|
||||
self.config_path = Path(config_path)
|
||||
self.secrets_path = Path(secrets_path) if secrets_path else None
|
||||
|
||||
# Determine backup directory
|
||||
if backup_dir:
|
||||
self.backup_dir = Path(backup_dir)
|
||||
else:
|
||||
# Default to config/backups/ relative to config file
|
||||
self.backup_dir = self.config_path.parent / "backups"
|
||||
|
||||
self.max_backups = max_backups
|
||||
self.logger = get_logger(__name__)
|
||||
|
||||
# Ensure backup directory exists
|
||||
self.backup_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def save_config_atomic(
|
||||
self,
|
||||
new_config: Dict[str, Any],
|
||||
new_secrets: Optional[Dict[str, Any]] = None,
|
||||
create_backup: bool = True,
|
||||
validate_after_write: bool = True
|
||||
) -> SaveResult:
|
||||
"""
|
||||
Save configuration atomically with optional backup.
|
||||
|
||||
Process:
|
||||
1. Create backup if requested
|
||||
2. Write to temporary files
|
||||
3. Validate written files
|
||||
4. Atomically move temp files to final locations
|
||||
5. If validation fails, rollback
|
||||
|
||||
Args:
|
||||
new_config: New configuration data for main config file
|
||||
new_secrets: Optional new secrets data
|
||||
create_backup: Whether to create backup before saving
|
||||
validate_after_write: Whether to validate after writing
|
||||
|
||||
Returns:
|
||||
SaveResult with status and details
|
||||
"""
|
||||
backup_path = None
|
||||
|
||||
try:
|
||||
# Step 1: Create backup if requested
|
||||
if create_backup:
|
||||
backup_result = self._create_backup()
|
||||
if backup_result:
|
||||
backup_path = backup_result
|
||||
self.logger.info(f"Created backup: {backup_path}")
|
||||
else:
|
||||
self.logger.warning("Failed to create backup, continuing with save")
|
||||
|
||||
# Step 2: Write to temporary files
|
||||
temp_config_path, temp_secrets_path = self._write_to_temp_files(
|
||||
new_config, new_secrets
|
||||
)
|
||||
|
||||
# Step 3: Validate written files
|
||||
if validate_after_write:
|
||||
validation_result = self._validate_config_file(temp_config_path)
|
||||
if not validation_result.is_valid:
|
||||
# Clean up temp files
|
||||
self._cleanup_temp_files(temp_config_path, temp_secrets_path)
|
||||
|
||||
# Rollback if backup was created
|
||||
if backup_path:
|
||||
self._rollback_from_backup(backup_path)
|
||||
|
||||
return SaveResult(
|
||||
status=SaveResultStatus.VALIDATION_FAILED,
|
||||
message="Configuration validation failed after write",
|
||||
backup_path=backup_path,
|
||||
validation_errors=validation_result.errors
|
||||
)
|
||||
|
||||
# Step 4: Atomically move temp files to final locations
|
||||
self._atomic_move(temp_config_path, self.config_path)
|
||||
if temp_secrets_path and self.secrets_path:
|
||||
self._atomic_move(temp_secrets_path, self.secrets_path)
|
||||
|
||||
self.logger.info(f"Configuration saved atomically to {self.config_path}")
|
||||
|
||||
return SaveResult(
|
||||
status=SaveResultStatus.SUCCESS,
|
||||
message="Configuration saved successfully",
|
||||
backup_path=backup_path
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error during atomic save: {e}", exc_info=True)
|
||||
|
||||
# Attempt rollback if backup exists
|
||||
if backup_path:
|
||||
try:
|
||||
self._rollback_from_backup(backup_path)
|
||||
return SaveResult(
|
||||
status=SaveResultStatus.ROLLED_BACK,
|
||||
message=f"Save failed and rolled back: {str(e)}",
|
||||
backup_path=backup_path,
|
||||
error=e
|
||||
)
|
||||
except Exception as rollback_error:
|
||||
self.logger.error(f"Rollback also failed: {rollback_error}", exc_info=True)
|
||||
|
||||
return SaveResult(
|
||||
status=SaveResultStatus.FAILED,
|
||||
message=f"Save failed: {str(e)}",
|
||||
backup_path=backup_path,
|
||||
error=e
|
||||
)
|
||||
|
||||
def rollback_config(self, backup_version: Optional[str] = None) -> bool:
|
||||
"""
|
||||
Rollback configuration to a previous backup.
|
||||
|
||||
Args:
|
||||
backup_version: Specific backup version to restore (timestamp string).
|
||||
If None, restores most recent backup.
|
||||
|
||||
Returns:
|
||||
True if rollback successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
backups = self.list_backups()
|
||||
if not backups:
|
||||
self.logger.error("No backups available for rollback")
|
||||
return False
|
||||
|
||||
# Find backup to restore
|
||||
if backup_version:
|
||||
backup = next((b for b in backups if b.version == backup_version), None)
|
||||
if not backup:
|
||||
self.logger.error(f"Backup version {backup_version} not found")
|
||||
return False
|
||||
else:
|
||||
# Use most recent valid backup
|
||||
valid_backups = [b for b in backups if b.is_valid]
|
||||
if not valid_backups:
|
||||
self.logger.error("No valid backups available for rollback")
|
||||
return False
|
||||
backup = valid_backups[0] # Most recent
|
||||
|
||||
return self._rollback_from_backup(backup.path)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error during rollback: {e}", exc_info=True)
|
||||
return False
|
||||
|
||||
def list_backups(self) -> List[BackupInfo]:
|
||||
"""
|
||||
List all available backups.
|
||||
|
||||
Returns:
|
||||
List of BackupInfo objects, sorted by timestamp (newest first)
|
||||
"""
|
||||
backups = []
|
||||
|
||||
if not self.backup_dir.exists():
|
||||
return backups
|
||||
|
||||
# Look for backup files (format: config.json.backup.YYYYMMDD_HHMMSS)
|
||||
config_name = self.config_path.name
|
||||
backup_pattern = f"{config_name}.backup.*"
|
||||
|
||||
for backup_file in self.backup_dir.glob(backup_pattern):
|
||||
try:
|
||||
# Extract timestamp from filename
|
||||
# Format: config.json.backup.20240101_120000
|
||||
parts = backup_file.stem.split('.')
|
||||
if len(parts) >= 3 and parts[-2] == 'backup':
|
||||
timestamp_str = parts[-1]
|
||||
timestamp = datetime.strptime(timestamp_str, "%Y%m%d_%H%M%S")
|
||||
else:
|
||||
# Fallback: use file modification time
|
||||
timestamp = datetime.fromtimestamp(backup_file.stat().st_mtime)
|
||||
timestamp_str = timestamp.strftime("%Y%m%d_%H%M%S")
|
||||
|
||||
# Validate backup file
|
||||
is_valid = self._validate_backup_file(backup_file)
|
||||
|
||||
backup_info = BackupInfo(
|
||||
version=timestamp_str,
|
||||
path=str(backup_file),
|
||||
timestamp=timestamp,
|
||||
size=backup_file.stat().st_size,
|
||||
is_valid=is_valid
|
||||
)
|
||||
backups.append(backup_info)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Error reading backup {backup_file}: {e}")
|
||||
|
||||
# Sort by timestamp (newest first)
|
||||
backups.sort(key=lambda b: b.timestamp, reverse=True)
|
||||
|
||||
return backups
|
||||
|
||||
def validate_config_file(self, config_path: Optional[str] = None) -> ValidationResult:
|
||||
"""
|
||||
Validate a configuration file.
|
||||
|
||||
Args:
|
||||
config_path: Path to config file. If None, validates current config_path.
|
||||
|
||||
Returns:
|
||||
ValidationResult with validation status and errors
|
||||
"""
|
||||
path = Path(config_path) if config_path else self.config_path
|
||||
return self._validate_config_file(path)
|
||||
|
||||
def _create_backup(self) -> Optional[str]:
|
||||
"""Create a backup of the current configuration file."""
|
||||
if not self.config_path.exists():
|
||||
self.logger.warning(f"Config file {self.config_path} does not exist, skipping backup")
|
||||
return None
|
||||
|
||||
try:
|
||||
# Generate backup filename with timestamp
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
config_name = self.config_path.name
|
||||
backup_filename = f"{config_name}.backup.{timestamp}"
|
||||
backup_path = self.backup_dir / backup_filename
|
||||
|
||||
# Copy config file to backup
|
||||
shutil.copy2(self.config_path, backup_path)
|
||||
|
||||
# Also backup secrets file if it exists
|
||||
if self.secrets_path and self.secrets_path.exists():
|
||||
secrets_backup_filename = f"{self.secrets_path.name}.backup.{timestamp}"
|
||||
secrets_backup_path = self.backup_dir / secrets_backup_filename
|
||||
shutil.copy2(self.secrets_path, secrets_backup_path)
|
||||
|
||||
# Rotate old backups
|
||||
self._rotate_backups()
|
||||
|
||||
return str(backup_path)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error creating backup: {e}", exc_info=True)
|
||||
return None
|
||||
|
||||
def _write_to_temp_files(
|
||||
self,
|
||||
config_data: Dict[str, Any],
|
||||
secrets_data: Optional[Dict[str, Any]] = None
|
||||
) -> Tuple[Path, Optional[Path]]:
|
||||
"""
|
||||
Write configuration data to temporary files.
|
||||
|
||||
Returns:
|
||||
Tuple of (temp_config_path, temp_secrets_path)
|
||||
"""
|
||||
# Create temp file in same directory as config (for atomic move)
|
||||
temp_config = tempfile.NamedTemporaryFile(
|
||||
mode='w',
|
||||
dir=self.config_path.parent,
|
||||
prefix=f".{self.config_path.name}.tmp.",
|
||||
delete=False,
|
||||
suffix='.json'
|
||||
)
|
||||
temp_config_path = Path(temp_config.name)
|
||||
|
||||
try:
|
||||
json.dump(config_data, temp_config, indent=4)
|
||||
temp_config.close()
|
||||
except Exception as e:
|
||||
temp_config.close()
|
||||
if temp_config_path.exists():
|
||||
temp_config_path.unlink()
|
||||
raise ConfigError(f"Error writing temp config file: {e}") from e
|
||||
|
||||
# Write secrets to temp file if provided
|
||||
temp_secrets_path = None
|
||||
if secrets_data is not None and self.secrets_path:
|
||||
temp_secrets = tempfile.NamedTemporaryFile(
|
||||
mode='w',
|
||||
dir=self.secrets_path.parent,
|
||||
prefix=f".{self.secrets_path.name}.tmp.",
|
||||
delete=False,
|
||||
suffix='.json'
|
||||
)
|
||||
temp_secrets_path = Path(temp_secrets.name)
|
||||
|
||||
try:
|
||||
json.dump(secrets_data, temp_secrets, indent=4)
|
||||
temp_secrets.close()
|
||||
except Exception as e:
|
||||
temp_secrets.close()
|
||||
if temp_secrets_path.exists():
|
||||
temp_secrets_path.unlink()
|
||||
# Clean up config temp file too
|
||||
if temp_config_path.exists():
|
||||
temp_config_path.unlink()
|
||||
raise ConfigError(f"Error writing temp secrets file: {e}") from e
|
||||
|
||||
return temp_config_path, temp_secrets_path
|
||||
|
||||
def _atomic_move(self, source: Path, destination: Path) -> None:
|
||||
"""
|
||||
Atomically move a file (rename operation).
|
||||
|
||||
On most filesystems, rename is atomic, which prevents corruption
|
||||
if the process is interrupted.
|
||||
|
||||
Sets appropriate file permissions after move to ensure service can read config.
|
||||
"""
|
||||
try:
|
||||
# Ensure destination directory exists
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Determine target permissions based on file type
|
||||
# config.json should be 644 (readable by all, including root service)
|
||||
# config_secrets.json should be 640 (readable by owner and group)
|
||||
if 'secrets' in str(destination):
|
||||
target_mode = 0o640 # rw-r-----
|
||||
else:
|
||||
target_mode = 0o644 # rw-r--r--
|
||||
|
||||
# Atomic move (rename)
|
||||
source.replace(destination)
|
||||
|
||||
# Set permissions after move to ensure they're correct
|
||||
# This is important because temp files may have different permissions
|
||||
# and we need root service to be able to read config.json
|
||||
os.chmod(destination, target_mode)
|
||||
|
||||
except Exception as e:
|
||||
raise ConfigError(f"Error during atomic move: {e}") from e
|
||||
|
||||
def _validate_config_file(self, config_path: Path) -> ValidationResult:
|
||||
"""
|
||||
Validate a configuration file.
|
||||
|
||||
Checks:
|
||||
- File exists and is readable
|
||||
- Valid JSON format
|
||||
- Can be parsed successfully
|
||||
"""
|
||||
errors = []
|
||||
warnings = []
|
||||
|
||||
if not config_path.exists():
|
||||
errors.append(f"Config file does not exist: {config_path}")
|
||||
return ValidationResult(is_valid=False, errors=errors, warnings=warnings)
|
||||
|
||||
try:
|
||||
with open(config_path, 'r') as f:
|
||||
data = json.load(f)
|
||||
|
||||
# Basic validation: should be a dict
|
||||
if not isinstance(data, dict):
|
||||
errors.append("Configuration must be a JSON object")
|
||||
|
||||
# Check file is not empty
|
||||
if not data:
|
||||
warnings.append("Configuration file is empty")
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
errors.append(f"Invalid JSON: {str(e)}")
|
||||
except Exception as e:
|
||||
errors.append(f"Error reading config file: {str(e)}")
|
||||
|
||||
return ValidationResult(
|
||||
is_valid=len(errors) == 0,
|
||||
errors=errors,
|
||||
warnings=warnings
|
||||
)
|
||||
|
||||
def _validate_backup_file(self, backup_path: Path) -> bool:
|
||||
"""Validate that a backup file is readable and valid JSON."""
|
||||
try:
|
||||
result = self._validate_config_file(backup_path)
|
||||
return result.is_valid
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _rollback_from_backup(self, backup_path: str) -> bool:
|
||||
"""
|
||||
Rollback configuration from a backup file.
|
||||
|
||||
Args:
|
||||
backup_path: Path to backup file to restore
|
||||
|
||||
Returns:
|
||||
True if rollback successful
|
||||
"""
|
||||
backup_file = Path(backup_path)
|
||||
|
||||
if not backup_file.exists():
|
||||
self.logger.error(f"Backup file not found: {backup_path}")
|
||||
return False
|
||||
|
||||
# Validate backup before restoring
|
||||
if not self._validate_backup_file(backup_file):
|
||||
self.logger.error(f"Backup file is invalid: {backup_path}")
|
||||
return False
|
||||
|
||||
try:
|
||||
# Restore main config
|
||||
shutil.copy2(backup_file, self.config_path)
|
||||
self.logger.info(f"Restored config from backup: {backup_path}")
|
||||
|
||||
# Try to restore secrets backup if it exists
|
||||
if self.secrets_path:
|
||||
# Look for corresponding secrets backup
|
||||
# Format: config_secrets.json.backup.TIMESTAMP
|
||||
backup_name = backup_file.name
|
||||
if '.backup.' in backup_name:
|
||||
timestamp = backup_name.split('.backup.')[-1]
|
||||
secrets_backup_name = f"{self.secrets_path.name}.backup.{timestamp}"
|
||||
secrets_backup_path = self.backup_dir / secrets_backup_name
|
||||
|
||||
if secrets_backup_path.exists():
|
||||
shutil.copy2(secrets_backup_path, self.secrets_path)
|
||||
self.logger.info(f"Restored secrets from backup: {secrets_backup_path}")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error during rollback: {e}", exc_info=True)
|
||||
return False
|
||||
|
||||
def _rotate_backups(self) -> None:
|
||||
"""Remove old backups, keeping only the most recent N backups."""
|
||||
backups = self.list_backups()
|
||||
|
||||
if len(backups) <= self.max_backups:
|
||||
return
|
||||
|
||||
# Sort by timestamp (oldest first) and remove excess
|
||||
backups.sort(key=lambda b: b.timestamp)
|
||||
backups_to_remove = backups[:-self.max_backups]
|
||||
|
||||
for backup in backups_to_remove:
|
||||
try:
|
||||
Path(backup.path).unlink()
|
||||
self.logger.debug(f"Removed old backup: {backup.path}")
|
||||
|
||||
# Also remove corresponding secrets backup if it exists
|
||||
if self.secrets_path:
|
||||
backup_name = Path(backup.path).name
|
||||
if '.backup.' in backup_name:
|
||||
timestamp = backup_name.split('.backup.')[-1]
|
||||
secrets_backup_name = f"{self.secrets_path.name}.backup.{timestamp}"
|
||||
secrets_backup_path = self.backup_dir / secrets_backup_name
|
||||
if secrets_backup_path.exists():
|
||||
secrets_backup_path.unlink()
|
||||
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Error removing old backup {backup.path}: {e}")
|
||||
|
||||
def _cleanup_temp_files(self, *temp_paths: Path) -> None:
|
||||
"""Clean up temporary files."""
|
||||
for temp_path in temp_paths:
|
||||
if temp_path and temp_path.exists():
|
||||
try:
|
||||
temp_path.unlink()
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Error cleaning up temp file {temp_path}: {e}")
|
||||
|
||||
472
src/config_service.py
Normal file
472
src/config_service.py
Normal file
@@ -0,0 +1,472 @@
|
||||
"""
|
||||
Configuration Service
|
||||
|
||||
Provides centralized configuration management with hot-reload support,
|
||||
versioning, and change notifications.
|
||||
|
||||
This service wraps ConfigManager and adds:
|
||||
- File watching for automatic reload
|
||||
- Configuration versioning
|
||||
- Change notifications to subscribers
|
||||
- Thread-safe configuration access
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, Optional, List, Callable, Set
|
||||
from datetime import datetime
|
||||
from collections import defaultdict
|
||||
import logging
|
||||
import hashlib
|
||||
|
||||
from src.exceptions import ConfigError
|
||||
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: MD5 checksum of the config
|
||||
"""
|
||||
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.
|
||||
|
||||
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
|
||||
) -> None:
|
||||
"""
|
||||
Initialize the configuration service.
|
||||
|
||||
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._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)
|
||||
|
||||
# File watching
|
||||
self._watch_thread: Optional[threading.Thread] = None
|
||||
self._watch_interval: float = 2.0 # Check every 2 seconds
|
||||
self._stop_watching: bool = False
|
||||
|
||||
# Load initial configuration
|
||||
self._load_config()
|
||||
|
||||
# Start file watching if enabled
|
||||
if self.enable_hot_reload:
|
||||
self._start_file_watching()
|
||||
|
||||
def _calculate_checksum(self, config: Dict[str, Any]) -> str:
|
||||
"""Calculate MD5 checksum of configuration."""
|
||||
config_str = json.dumps(config, sort_keys=True)
|
||||
return hashlib.md5(config_str.encode()).hexdigest()
|
||||
|
||||
def _load_config(self) -> bool:
|
||||
"""
|
||||
Load configuration from ConfigManager.
|
||||
|
||||
Returns:
|
||||
True if config changed, False otherwise
|
||||
"""
|
||||
try:
|
||||
new_config = self.config_manager.load_config()
|
||||
new_checksum = self._calculate_checksum(new_config)
|
||||
|
||||
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
|
||||
|
||||
# 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
|
||||
|
||||
# Notify subscribers
|
||||
self._notify_subscribers(old_config, new_config)
|
||||
|
||||
self.logger.info(
|
||||
"Configuration reloaded (version %d, checksum: %s)",
|
||||
self._current_version,
|
||||
new_checksum[:8]
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
except ConfigError as e:
|
||||
self.logger.error("Error loading configuration: %s", e, exc_info=True)
|
||||
return False
|
||||
except Exception as e:
|
||||
self.logger.error("Unexpected error loading configuration: %s", e, exc_info=True)
|
||||
return False
|
||||
|
||||
def _notify_subscribers(self, old_config: Dict[str, Any], new_config: Dict[str, Any]) -> None:
|
||||
"""
|
||||
Notify all subscribers of configuration changes.
|
||||
|
||||
Args:
|
||||
old_config: Previous configuration
|
||||
new_config: New configuration
|
||||
"""
|
||||
# Notify global subscribers (key: '*')
|
||||
for callback in self._subscribers.get('*', []):
|
||||
try:
|
||||
callback(old_config, new_config)
|
||||
except Exception as e:
|
||||
self.logger.error("Error in global config change callback: %s", e, exc_info=True)
|
||||
|
||||
# Notify plugin-specific subscribers
|
||||
for plugin_id in self._subscribers.keys():
|
||||
if plugin_id == '*':
|
||||
continue
|
||||
|
||||
old_plugin_config = old_config.get(plugin_id, {})
|
||||
new_plugin_config = new_config.get(plugin_id, {})
|
||||
|
||||
# Only notify if plugin config actually changed
|
||||
if old_plugin_config != new_plugin_config:
|
||||
for callback in self._subscribers[plugin_id]:
|
||||
try:
|
||||
callback(old_plugin_config, new_plugin_config)
|
||||
except Exception as e:
|
||||
self.logger.error(
|
||||
"Error in config change callback for %s: %s",
|
||||
plugin_id,
|
||||
e,
|
||||
exc_info=True
|
||||
)
|
||||
|
||||
def _check_file_changes(self) -> bool:
|
||||
"""
|
||||
Check if configuration files have been modified.
|
||||
|
||||
Returns:
|
||||
True if files changed, False otherwise
|
||||
"""
|
||||
config_path = Path(self.config_manager.get_config_path())
|
||||
secrets_path = Path(self.config_manager.get_secrets_path())
|
||||
|
||||
changed = False
|
||||
|
||||
# Check main config file
|
||||
if config_path.exists():
|
||||
mtime = config_path.stat().st_mtime
|
||||
if mtime != self._last_modified.get(str(config_path), 0):
|
||||
self._last_modified[str(config_path)] = mtime
|
||||
changed = True
|
||||
|
||||
# Check secrets file
|
||||
if secrets_path.exists():
|
||||
mtime = secrets_path.stat().st_mtime
|
||||
if mtime != self._last_modified.get(str(secrets_path), 0):
|
||||
self._last_modified[str(secrets_path)] = mtime
|
||||
changed = True
|
||||
|
||||
return changed
|
||||
|
||||
def _file_watcher_loop(self) -> None:
|
||||
"""Main loop for file watching."""
|
||||
self.logger.info("Configuration file watcher started")
|
||||
|
||||
# Initialize last modified times
|
||||
config_path = Path(self.config_manager.get_config_path())
|
||||
secrets_path = Path(self.config_manager.get_secrets_path())
|
||||
|
||||
if config_path.exists():
|
||||
self._last_modified[str(config_path)] = config_path.stat().st_mtime
|
||||
if secrets_path.exists():
|
||||
self._last_modified[str(secrets_path)] = secrets_path.stat().st_mtime
|
||||
|
||||
while not self._stop_watching:
|
||||
try:
|
||||
if self._check_file_changes():
|
||||
self.logger.info("Configuration files changed, reloading...")
|
||||
self._load_config()
|
||||
|
||||
# Sleep with periodic checks for stop signal
|
||||
for _ in range(int(self._watch_interval)):
|
||||
if self._stop_watching:
|
||||
break
|
||||
time.sleep(1)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error("Error in file watcher loop: %s", e, exc_info=True)
|
||||
time.sleep(self._watch_interval)
|
||||
|
||||
self.logger.info("Configuration file watcher stopped")
|
||||
|
||||
def _start_file_watching(self) -> None:
|
||||
"""Start the file watching thread."""
|
||||
if self._watch_thread and self._watch_thread.is_alive():
|
||||
return
|
||||
|
||||
self._stop_watching = False
|
||||
self._watch_thread = threading.Thread(
|
||||
target=self._file_watcher_loop,
|
||||
name="ConfigService-Watcher",
|
||||
daemon=True
|
||||
)
|
||||
self._watch_thread.start()
|
||||
self.logger.debug("File watching thread started")
|
||||
|
||||
def _stop_file_watching(self) -> None:
|
||||
"""Stop the file watching thread."""
|
||||
if self._watch_thread and self._watch_thread.is_alive():
|
||||
self._stop_watching = True
|
||||
self._watch_thread.join(timeout=5.0)
|
||||
if self._watch_thread.is_alive():
|
||||
self.logger.warning("File watching thread did not stop gracefully")
|
||||
|
||||
def get_config(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Get current configuration (thread-safe).
|
||||
|
||||
Returns:
|
||||
Current configuration dictionary
|
||||
"""
|
||||
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],
|
||||
plugin_id: Optional[str] = None
|
||||
) -> None:
|
||||
"""
|
||||
Subscribe to configuration changes.
|
||||
|
||||
Args:
|
||||
callback: Function to call when config changes
|
||||
Signature: callback(old_config, new_config)
|
||||
plugin_id: Optional plugin ID to subscribe to specific plugin changes
|
||||
If None, subscribes to all changes
|
||||
"""
|
||||
key = plugin_id or '*'
|
||||
with self._lock:
|
||||
if callback not in self._subscribers[key]:
|
||||
self._subscribers[key].append(callback)
|
||||
self.logger.debug("Subscribed to config changes for %s", key)
|
||||
|
||||
def unsubscribe(
|
||||
self,
|
||||
callback: Callable[[Dict[str, Any], Dict[str, Any]], None],
|
||||
plugin_id: Optional[str] = None
|
||||
) -> None:
|
||||
"""
|
||||
Unsubscribe from configuration changes.
|
||||
|
||||
Args:
|
||||
callback: Callback function to remove
|
||||
plugin_id: Optional plugin ID (must match subscription)
|
||||
"""
|
||||
key = plugin_id or '*'
|
||||
with self._lock:
|
||||
if callback in self._subscribers[key]:
|
||||
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")
|
||||
self._stop_file_watching()
|
||||
|
||||
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()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,8 +8,6 @@ import time
|
||||
from typing import Dict, Any, List, Tuple
|
||||
import logging
|
||||
import math
|
||||
from .weather_icons import WeatherIcons
|
||||
import os
|
||||
import freetype
|
||||
|
||||
# Get logger without configuring
|
||||
@@ -89,6 +87,10 @@ class DisplayManager:
|
||||
options.limit_refresh_rate_hz = hardware_config.get('limit_refresh_rate_hz', 90)
|
||||
options.gpio_slowdown = runtime_config.get('gpio_slowdown', 2)
|
||||
|
||||
# Disable internal privilege dropping - we manage this via systemd or remain root
|
||||
# This prevents the library from dropping to 'daemon' user which breaks file permissions
|
||||
options.drop_privileges = False
|
||||
|
||||
# Additional settings from config
|
||||
if 'scan_mode' in hardware_config:
|
||||
options.scan_mode = hardware_config.get('scan_mode')
|
||||
@@ -215,7 +217,7 @@ class DisplayManager:
|
||||
self.offscreen_canvas.SetImage(self.image)
|
||||
|
||||
# Swap buffers immediately
|
||||
self.matrix.SwapOnVSync(self.offscreen_canvas, False)
|
||||
self.matrix.SwapOnVSync(self.offscreen_canvas)
|
||||
|
||||
# Swap our canvas references
|
||||
self.offscreen_canvas, self.current_canvas = self.current_canvas, self.offscreen_canvas
|
||||
@@ -230,11 +232,23 @@ class DisplayManager:
|
||||
try:
|
||||
if self.matrix is None:
|
||||
# Fallback mode - just clear the image
|
||||
self.image = Image.new('RGB', (self.image.width, self.image.height))
|
||||
# Explicitly clear old image reference to help garbage collection
|
||||
old_image = getattr(self, 'image', None)
|
||||
width = old_image.width if old_image else 64
|
||||
height = old_image.height if old_image else 64
|
||||
if old_image is not None:
|
||||
del old_image
|
||||
|
||||
self.image = Image.new('RGB', (width, height))
|
||||
self.draw = ImageDraw.Draw(self.image)
|
||||
logger.debug("Cleared display in fallback mode")
|
||||
return
|
||||
|
||||
# Explicitly clear old image reference to help garbage collection
|
||||
old_image = getattr(self, 'image', None)
|
||||
if old_image is not None:
|
||||
del old_image
|
||||
|
||||
# Create a new black image
|
||||
self.image = Image.new('RGB', (self.matrix.width, self.matrix.height))
|
||||
self.draw = ImageDraw.Draw(self.image)
|
||||
@@ -254,10 +268,10 @@ class DisplayManager:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Update the display to show the clear. Swap twice to flush any latent frame.
|
||||
self.update_display()
|
||||
time.sleep(0.01)
|
||||
self.update_display()
|
||||
# Note: We do NOT call update_display() here to avoid black flashes.
|
||||
# The caller should call update_display() after drawing new content.
|
||||
# If an immediate clear is needed, the caller can explicitly call
|
||||
# clear() followed by update_display().
|
||||
except Exception as e:
|
||||
logger.error(f"Error clearing display: {e}")
|
||||
|
||||
@@ -400,8 +414,18 @@ class DisplayManager:
|
||||
return 8 # A reasonable default for an 8px font.
|
||||
|
||||
def draw_text(self, text: str, x: int = None, y: int = None, color: tuple = (255, 255, 255),
|
||||
small_font: bool = False, font: ImageFont = None):
|
||||
"""Draw text on the canvas with optional font selection."""
|
||||
small_font: bool = False, font: ImageFont = None, centered: bool = False):
|
||||
"""Draw text on the canvas with optional font selection.
|
||||
|
||||
Args:
|
||||
text: Text to display
|
||||
x: X position (None to auto-center, or used as center point if centered=True)
|
||||
y: Y position (None defaults to 0)
|
||||
color: RGB color tuple
|
||||
small_font: Use small font if True
|
||||
font: Custom font object (overrides small_font)
|
||||
centered: If True, x is treated as center point; if False, x is left edge
|
||||
"""
|
||||
try:
|
||||
# Select font based on parameters
|
||||
if font:
|
||||
@@ -409,10 +433,15 @@ class DisplayManager:
|
||||
else:
|
||||
current_font = self.small_font if small_font else self.regular_font
|
||||
|
||||
# Calculate x position if not provided (center text)
|
||||
# Calculate x position
|
||||
if x is None:
|
||||
# No x provided - center text
|
||||
text_width = self.get_text_width(text, current_font)
|
||||
x = (self.width - text_width) // 2
|
||||
elif centered:
|
||||
# x is provided as center point - adjust to left edge
|
||||
text_width = self.get_text_width(text, current_font)
|
||||
x = x - (text_width // 2)
|
||||
|
||||
# Set default y position if not provided
|
||||
if y is None:
|
||||
@@ -704,16 +733,17 @@ class DisplayManager:
|
||||
|
||||
def process_deferred_updates(self):
|
||||
"""Process any deferred updates if not currently scrolling."""
|
||||
current_time = time.time()
|
||||
|
||||
# Always clean up expired updates, even if scrolling
|
||||
# This prevents memory leaks from accumulated expired updates
|
||||
self._cleanup_expired_deferred_updates(current_time)
|
||||
|
||||
if self.is_currently_scrolling():
|
||||
return
|
||||
|
||||
if not self._scrolling_state['deferred_updates']:
|
||||
return
|
||||
|
||||
current_time = time.time()
|
||||
|
||||
# Clean up expired updates first
|
||||
self._cleanup_expired_deferred_updates(current_time)
|
||||
|
||||
if not self._scrolling_state['deferred_updates']:
|
||||
return
|
||||
@@ -777,10 +807,17 @@ class DisplayManager:
|
||||
now = time.time()
|
||||
if (now - self._last_snapshot_ts) < self._snapshot_min_interval_sec:
|
||||
return
|
||||
# Ensure directory exists
|
||||
snapshot_dir = os.path.dirname(self._snapshot_path)
|
||||
if snapshot_dir and not os.path.exists(snapshot_dir):
|
||||
os.makedirs(snapshot_dir, exist_ok=True)
|
||||
# Ensure directory exists with proper permissions
|
||||
from pathlib import Path
|
||||
from src.common.permission_utils import (
|
||||
ensure_directory_permissions,
|
||||
ensure_file_permissions,
|
||||
get_assets_dir_mode,
|
||||
get_assets_file_mode
|
||||
)
|
||||
snapshot_path_obj = Path(self._snapshot_path)
|
||||
if snapshot_path_obj.parent:
|
||||
ensure_directory_permissions(snapshot_path_obj.parent, get_assets_dir_mode())
|
||||
# Write atomically: temp then replace
|
||||
tmp_path = f"{self._snapshot_path}.tmp"
|
||||
self.image.save(tmp_path, format='PNG')
|
||||
@@ -789,9 +826,9 @@ class DisplayManager:
|
||||
except Exception:
|
||||
# Fallback to direct save if replace not supported
|
||||
self.image.save(self._snapshot_path, format='PNG')
|
||||
# Try to make the snapshot world-readable so the web UI can read it regardless of user
|
||||
# Set proper file permissions after saving
|
||||
try:
|
||||
os.chmod(self._snapshot_path, 0o644)
|
||||
ensure_file_permissions(snapshot_path_obj, get_assets_file_mode())
|
||||
except Exception:
|
||||
pass
|
||||
self._last_snapshot_ts = now
|
||||
|
||||
111
src/exceptions.py
Normal file
111
src/exceptions.py
Normal file
@@ -0,0 +1,111 @@
|
||||
"""
|
||||
Custom exception hierarchy for LEDMatrix.
|
||||
|
||||
Provides specific exception types for different error categories,
|
||||
enabling better error handling and debugging.
|
||||
"""
|
||||
|
||||
|
||||
class LEDMatrixError(Exception):
|
||||
"""Base exception for all LEDMatrix errors."""
|
||||
|
||||
def __init__(self, message: str, context: dict = None):
|
||||
"""
|
||||
Initialize the exception.
|
||||
|
||||
Args:
|
||||
message: Error message
|
||||
context: Optional context dictionary with additional error details
|
||||
"""
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
self.context = context or {}
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""Return formatted error message with context."""
|
||||
if self.context:
|
||||
context_str = ", ".join(f"{k}={v}" for k, v in self.context.items())
|
||||
return f"{self.message} ({context_str})"
|
||||
return self.message
|
||||
|
||||
|
||||
class CacheError(LEDMatrixError):
|
||||
"""Exception raised for cache-related errors."""
|
||||
|
||||
def __init__(self, message: str, cache_key: str = None, context: dict = None):
|
||||
"""
|
||||
Initialize cache error.
|
||||
|
||||
Args:
|
||||
message: Error message
|
||||
cache_key: Optional cache key that caused the error
|
||||
context: Optional context dictionary
|
||||
"""
|
||||
if cache_key:
|
||||
context = context or {}
|
||||
context['cache_key'] = cache_key
|
||||
super().__init__(message, context)
|
||||
self.cache_key = cache_key
|
||||
|
||||
|
||||
class ConfigError(LEDMatrixError):
|
||||
"""Exception raised for configuration-related errors."""
|
||||
|
||||
def __init__(self, message: str, config_path: str = None, field: str = None, context: dict = None):
|
||||
"""
|
||||
Initialize config error.
|
||||
|
||||
Args:
|
||||
message: Error message
|
||||
config_path: Optional path to config file
|
||||
field: Optional field name that caused the error
|
||||
context: Optional context dictionary
|
||||
"""
|
||||
if config_path or field:
|
||||
context = context or {}
|
||||
if config_path:
|
||||
context['config_path'] = config_path
|
||||
if field:
|
||||
context['field'] = field
|
||||
super().__init__(message, context)
|
||||
self.config_path = config_path
|
||||
self.field = field
|
||||
|
||||
|
||||
class PluginError(LEDMatrixError):
|
||||
"""Exception raised for plugin-related errors."""
|
||||
|
||||
def __init__(self, message: str, plugin_id: str = None, context: dict = None):
|
||||
"""
|
||||
Initialize plugin error.
|
||||
|
||||
Args:
|
||||
message: Error message
|
||||
plugin_id: Optional plugin ID that caused the error
|
||||
context: Optional context dictionary
|
||||
"""
|
||||
if plugin_id:
|
||||
context = context or {}
|
||||
context['plugin_id'] = plugin_id
|
||||
super().__init__(message, context)
|
||||
self.plugin_id = plugin_id
|
||||
|
||||
|
||||
class DisplayError(LEDMatrixError):
|
||||
"""Exception raised for display-related errors."""
|
||||
|
||||
def __init__(self, message: str, display_mode: str = None, context: dict = None):
|
||||
"""
|
||||
Initialize display error.
|
||||
|
||||
Args:
|
||||
message: Error message
|
||||
display_mode: Optional display mode that caused the error
|
||||
context: Optional context dictionary
|
||||
"""
|
||||
if display_mode:
|
||||
context = context or {}
|
||||
context['display_mode'] = display_mode
|
||||
super().__init__(message, context)
|
||||
self.display_mode = display_mode
|
||||
|
||||
757
src/font_manager.py
Normal file
757
src/font_manager.py
Normal file
@@ -0,0 +1,757 @@
|
||||
import os
|
||||
import logging
|
||||
import freetype
|
||||
import json
|
||||
import hashlib
|
||||
import urllib.request
|
||||
import zipfile
|
||||
import tempfile
|
||||
import shutil
|
||||
import time
|
||||
from pathlib import Path
|
||||
from PIL import ImageFont
|
||||
from typing import Dict, Tuple, Optional, Union, Any, List
|
||||
from functools import lru_cache
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class FontManager:
|
||||
"""
|
||||
Comprehensive font management supporting TTF and BDF fonts with caching,
|
||||
measurement, plugin support, and manager font registration.
|
||||
|
||||
This FontManager serves dual purposes:
|
||||
1. Utility functions for font loading, caching, and measurement
|
||||
2. Dynamic detection and override of fonts used by managers/plugins
|
||||
"""
|
||||
|
||||
def __init__(self, config: Dict[str, Any]):
|
||||
self.config = config
|
||||
self.fonts_config = config.get("fonts", {})
|
||||
|
||||
# Font discovery and catalog
|
||||
self.font_catalog: Dict[str, str] = {} # family_name -> file_path
|
||||
self.font_cache: Dict[str, Union[ImageFont.FreeTypeFont, freetype.Face]] = {} # (family, size) -> font
|
||||
self.metrics_cache: Dict[str, Tuple[int, int, int]] = {} # (text, font_id) -> (width, height, baseline)
|
||||
|
||||
# Plugin font management
|
||||
self.plugin_fonts: Dict[str, Dict[str, Any]] = {} # plugin_id -> font_manifest
|
||||
self.plugin_font_catalogs: Dict[str, Dict[str, str]] = {} # plugin_id -> {family_name -> file_path}
|
||||
self.font_metadata: Dict[str, Dict[str, Any]] = {} # family_name -> metadata
|
||||
self.font_dependencies: Dict[str, List[str]] = {} # family_name -> [required_families]
|
||||
|
||||
# Manager font registration - NEW for manager-centric model
|
||||
self.manager_fonts: Dict[str, Dict[str, Any]] = {} # manager_id -> {element_key: {family, size_px, color}}
|
||||
self.detected_fonts: Dict[str, Dict[str, Any]] = {} # element_key -> {family, size_px, color, manager_id, usage_count}
|
||||
|
||||
# Dynamic font loading
|
||||
self.temp_font_dir = Path(tempfile.gettempdir()) / "ledmatrix_fonts"
|
||||
self.temp_font_dir.mkdir(exist_ok=True)
|
||||
|
||||
# Performance monitoring
|
||||
self.performance_stats = {
|
||||
"font_load_times": {},
|
||||
"cache_hits": 0,
|
||||
"cache_misses": 0,
|
||||
"render_times": {},
|
||||
"total_renders": 0,
|
||||
"failed_loads": 0,
|
||||
"start_time": time.time()
|
||||
}
|
||||
|
||||
# Common font paths for convenience
|
||||
self.common_fonts = {
|
||||
"press_start": "assets/fonts/PressStart2P-Regular.ttf",
|
||||
"four_by_six": "assets/fonts/4x6-font.ttf",
|
||||
"five_by_seven": "assets/fonts/5x7.bdf",
|
||||
"cozette_bdf": "assets/fonts/cozette.bdf"
|
||||
}
|
||||
|
||||
# Size tokens for convenience
|
||||
self.size_tokens = {
|
||||
"xs": 6, "sm": 8, "md": 10, "lg": 12, "xl": 14, "xxl": 16
|
||||
}
|
||||
|
||||
# Font overrides storage (for manual overrides)
|
||||
self.font_overrides_file = "config/font_overrides.json"
|
||||
self.font_overrides: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
self._initialize_fonts()
|
||||
|
||||
def reload_config(self, new_config: Dict[str, Any]):
|
||||
"""Reload configuration and refresh font catalog."""
|
||||
self.config = new_config
|
||||
self.fonts_config = new_config.get("fonts", {})
|
||||
self.font_cache.clear() # Clear cache to force reload
|
||||
self.metrics_cache.clear() # Clear metrics cache
|
||||
self._initialize_fonts()
|
||||
logger.info("FontManager configuration reloaded successfully")
|
||||
|
||||
# ==================== Manager Font Registration ====================
|
||||
# NEW: Support for managers to register their font choices dynamically
|
||||
|
||||
def register_manager_font(self, manager_id: str, element_key: str,
|
||||
family: str, size_px: int, color: Optional[Tuple[int, int, int]] = None):
|
||||
"""
|
||||
Register a font choice made by a manager for a specific element.
|
||||
This allows us to detect and track which fonts managers are using.
|
||||
|
||||
Args:
|
||||
manager_id: Identifier for the manager (e.g., 'nfl_live', 'nba_recent')
|
||||
element_key: Element key (e.g., 'nfl.live.score')
|
||||
family: Font family name
|
||||
size_px: Font size in pixels
|
||||
color: Optional RGB color tuple
|
||||
"""
|
||||
if manager_id not in self.manager_fonts:
|
||||
self.manager_fonts[manager_id] = {}
|
||||
|
||||
font_spec = {
|
||||
"family": family,
|
||||
"size_px": size_px,
|
||||
"manager_id": manager_id
|
||||
}
|
||||
if color:
|
||||
font_spec["color"] = color
|
||||
|
||||
self.manager_fonts[manager_id][element_key] = font_spec
|
||||
|
||||
# Track usage in detected_fonts
|
||||
if element_key not in self.detected_fonts:
|
||||
self.detected_fonts[element_key] = font_spec.copy()
|
||||
self.detected_fonts[element_key]["usage_count"] = 1
|
||||
else:
|
||||
self.detected_fonts[element_key]["usage_count"] += 1
|
||||
|
||||
logger.debug(f"Registered font for {manager_id}.{element_key}: {family}@{size_px}px")
|
||||
|
||||
def get_manager_fonts(self, manager_id: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Get registered fonts for a specific manager or all managers.
|
||||
|
||||
Args:
|
||||
manager_id: Optional manager ID, if None returns all
|
||||
|
||||
Returns:
|
||||
Dictionary of registered fonts
|
||||
"""
|
||||
if manager_id:
|
||||
return self.manager_fonts.get(manager_id, {})
|
||||
return self.manager_fonts.copy()
|
||||
|
||||
def get_detected_fonts(self) -> Dict[str, Dict[str, Any]]:
|
||||
"""Get all detected font usage across managers."""
|
||||
return self.detected_fonts.copy()
|
||||
|
||||
# ==================== Plugin Font Management ====================
|
||||
|
||||
def register_plugin_fonts(self, plugin_id: str, font_manifest: Dict[str, Any]) -> bool:
|
||||
"""
|
||||
Register fonts for a specific plugin.
|
||||
|
||||
Args:
|
||||
plugin_id: Unique identifier for the plugin
|
||||
font_manifest: Font manifest from plugin's manifest.json
|
||||
|
||||
Returns:
|
||||
True if registration successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
# Validate font manifest structure
|
||||
if not self._validate_font_manifest(font_manifest):
|
||||
logger.error(f"Invalid font manifest for plugin {plugin_id}")
|
||||
return False
|
||||
|
||||
# Store plugin font manifest
|
||||
self.plugin_fonts[plugin_id] = font_manifest
|
||||
|
||||
# Create plugin-specific font catalog
|
||||
self.plugin_font_catalogs[plugin_id] = {}
|
||||
|
||||
# Process font definitions
|
||||
fonts = font_manifest.get("fonts", [])
|
||||
for font_def in fonts:
|
||||
if self._register_plugin_font(plugin_id, font_def):
|
||||
logger.info(f"Successfully registered font {font_def.get('family')} for plugin {plugin_id}")
|
||||
|
||||
logger.info(f"Registered {len(fonts)} fonts for plugin {plugin_id}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error registering fonts for plugin {plugin_id}: {e}", exc_info=True)
|
||||
return False
|
||||
|
||||
def _validate_font_manifest(self, font_manifest: Dict[str, Any]) -> bool:
|
||||
"""Validate the structure of a plugin's font manifest."""
|
||||
required_fields = ["fonts"]
|
||||
|
||||
# Check required top-level fields
|
||||
for field in required_fields:
|
||||
if field not in font_manifest:
|
||||
logger.error(f"Missing required field '{field}' in font manifest")
|
||||
return False
|
||||
|
||||
# Validate each font definition
|
||||
fonts = font_manifest.get("fonts", [])
|
||||
for font_def in fonts:
|
||||
if not isinstance(font_def, dict):
|
||||
logger.error("Font definition must be a dictionary")
|
||||
return False
|
||||
|
||||
required_font_fields = ["family", "source"]
|
||||
for field in required_font_fields:
|
||||
if field not in font_def:
|
||||
logger.error(f"Missing required field '{field}' in font definition")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def _register_plugin_font(self, plugin_id: str, font_def: Dict[str, Any]) -> bool:
|
||||
"""Register a single font from a plugin."""
|
||||
try:
|
||||
family = font_def["family"]
|
||||
source = font_def["source"]
|
||||
|
||||
# Handle different source types
|
||||
font_path = None
|
||||
if source.startswith(("http://", "https://")):
|
||||
# Download from URL
|
||||
font_path = self._download_font(source, font_def)
|
||||
elif source.startswith("plugin://"):
|
||||
# Relative to plugin directory
|
||||
relative_path = source.replace("plugin://", "")
|
||||
font_path = self._resolve_plugin_font_path(plugin_id, relative_path)
|
||||
else:
|
||||
# Absolute or relative path
|
||||
font_path = source
|
||||
|
||||
if not font_path or not os.path.exists(font_path):
|
||||
logger.error(f"Font file not found: {font_path}")
|
||||
return False
|
||||
|
||||
# Add to plugin catalog with namespaced family name
|
||||
namespaced_family = f"{plugin_id}::{family}"
|
||||
self.plugin_font_catalogs[plugin_id][family] = font_path
|
||||
self.font_catalog[namespaced_family] = font_path
|
||||
|
||||
# Store metadata
|
||||
if "metadata" in font_def:
|
||||
self.font_metadata[namespaced_family] = font_def["metadata"]
|
||||
|
||||
# Store dependencies
|
||||
if "dependencies" in font_def:
|
||||
self.font_dependencies[namespaced_family] = font_def["dependencies"]
|
||||
|
||||
logger.info(f"Registered plugin font: {namespaced_family} -> {font_path}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error registering plugin font: {e}", exc_info=True)
|
||||
return False
|
||||
|
||||
def _download_font(self, url: str, font_def: Dict[str, Any]) -> Optional[str]:
|
||||
"""Download a font from a URL."""
|
||||
try:
|
||||
family = font_def["family"]
|
||||
|
||||
# Generate cache filename based on URL hash
|
||||
url_hash = hashlib.sha256(url.encode()).hexdigest()[:16]
|
||||
extension = self._get_font_extension(url)
|
||||
cache_filename = f"{family}_{url_hash}{extension}"
|
||||
cache_path = self.temp_font_dir / cache_filename
|
||||
|
||||
# Check if already downloaded
|
||||
if cache_path.exists():
|
||||
logger.info(f"Using cached font: {cache_path}")
|
||||
return str(cache_path)
|
||||
|
||||
# Download font
|
||||
logger.info(f"Downloading font from {url}")
|
||||
urllib.request.urlretrieve(url, cache_path)
|
||||
|
||||
# Handle zip files
|
||||
if url.endswith('.zip'):
|
||||
extract_dir = self.temp_font_dir / f"{family}_{url_hash}"
|
||||
extract_dir.mkdir(exist_ok=True)
|
||||
|
||||
with zipfile.ZipFile(cache_path, 'r') as zip_ref:
|
||||
zip_ref.extractall(extract_dir)
|
||||
|
||||
# Find the actual font file
|
||||
for file in extract_dir.iterdir():
|
||||
if file.suffix.lower() in ['.ttf', '.otf', '.bdf']:
|
||||
return str(file)
|
||||
|
||||
return str(cache_path)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error downloading font from {url}: {e}")
|
||||
return None
|
||||
|
||||
def _get_font_extension(self, url: str) -> str:
|
||||
"""Extract font file extension from URL."""
|
||||
if '.ttf' in url.lower():
|
||||
return '.ttf'
|
||||
elif '.otf' in url.lower():
|
||||
return '.otf'
|
||||
elif '.bdf' in url.lower():
|
||||
return '.bdf'
|
||||
elif '.zip' in url.lower():
|
||||
return '.zip'
|
||||
return '.ttf' # default
|
||||
|
||||
def _resolve_plugin_font_path(self, plugin_id: str, relative_path: str) -> Optional[str]:
|
||||
"""Resolve a plugin-relative font path."""
|
||||
# Assume plugins are in a 'plugins' directory
|
||||
plugin_dir = Path("plugins") / plugin_id
|
||||
font_path = plugin_dir / relative_path
|
||||
|
||||
if font_path.exists():
|
||||
return str(font_path)
|
||||
|
||||
logger.error(f"Plugin font not found: {font_path}")
|
||||
return None
|
||||
|
||||
def unregister_plugin_fonts(self, plugin_id: str) -> bool:
|
||||
"""Unregister all fonts for a plugin."""
|
||||
try:
|
||||
if plugin_id in self.plugin_fonts:
|
||||
# Remove from plugin catalogs
|
||||
if plugin_id in self.plugin_font_catalogs:
|
||||
for family in self.plugin_font_catalogs[plugin_id]:
|
||||
namespaced_family = f"{plugin_id}::{family}"
|
||||
if namespaced_family in self.font_catalog:
|
||||
del self.font_catalog[namespaced_family]
|
||||
if namespaced_family in self.font_metadata:
|
||||
del self.font_metadata[namespaced_family]
|
||||
|
||||
del self.plugin_font_catalogs[plugin_id]
|
||||
|
||||
# Remove plugin manifest
|
||||
del self.plugin_fonts[plugin_id]
|
||||
|
||||
# Clear related cache entries
|
||||
self._clear_plugin_font_cache(plugin_id)
|
||||
|
||||
logger.info(f"Unregistered fonts for plugin {plugin_id}")
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error unregistering plugin fonts: {e}")
|
||||
return False
|
||||
|
||||
def _clear_plugin_font_cache(self, plugin_id: str):
|
||||
"""Clear font cache entries for a specific plugin."""
|
||||
keys_to_remove = [key for key in self.font_cache.keys() if key.startswith(f"{plugin_id}::")]
|
||||
for key in keys_to_remove:
|
||||
del self.font_cache[key]
|
||||
|
||||
def get_plugin_fonts(self, plugin_id: str) -> List[str]:
|
||||
"""Get list of font families registered by a plugin."""
|
||||
if plugin_id in self.plugin_font_catalogs:
|
||||
return list(self.plugin_font_catalogs[plugin_id].keys())
|
||||
return []
|
||||
|
||||
# ==================== Font Resolution ====================
|
||||
|
||||
def resolve_font(self, element_key: str, family: str, size_px: int,
|
||||
plugin_id: Optional[str] = None) -> Union[ImageFont.FreeTypeFont, freetype.Face]:
|
||||
"""
|
||||
Resolve font for an element, checking for overrides.
|
||||
|
||||
This is the main method managers should call to get fonts.
|
||||
It checks for manual overrides first, then uses the manager's choice.
|
||||
|
||||
Args:
|
||||
element_key: Element key (e.g., 'nfl.live.score')
|
||||
family: Font family name (manager's choice)
|
||||
size_px: Font size in pixels (manager's choice)
|
||||
plugin_id: Optional plugin context for namespaced fonts
|
||||
|
||||
Returns:
|
||||
Resolved font object
|
||||
"""
|
||||
start_time = time.time()
|
||||
|
||||
try:
|
||||
# Check for manual overrides first
|
||||
if element_key in self.font_overrides:
|
||||
override = self.font_overrides[element_key]
|
||||
if override.get("family"):
|
||||
family = override["family"]
|
||||
if override.get("size_px"):
|
||||
size_px = override["size_px"]
|
||||
logger.debug(f"Applied override for {element_key}: {family}@{size_px}px")
|
||||
|
||||
# Handle namespaced plugin fonts
|
||||
if plugin_id and "::" not in family:
|
||||
# Check if plugin has this font
|
||||
if plugin_id in self.plugin_font_catalogs and family in self.plugin_font_catalogs[plugin_id]:
|
||||
family = f"{plugin_id}::{family}"
|
||||
|
||||
# Get the font
|
||||
font = self.get_font(family, size_px)
|
||||
|
||||
# Record performance
|
||||
duration = time.time() - start_time
|
||||
self._record_performance_metric("resolve", f"{family}_{size_px}", duration)
|
||||
|
||||
return font
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error resolving font for {element_key}: {e}", exc_info=True)
|
||||
return self._get_fallback_font()
|
||||
|
||||
def get_font(self, family: str, size_px: int) -> Union[ImageFont.FreeTypeFont, freetype.Face]:
|
||||
"""
|
||||
Get a font object for the specified family and size.
|
||||
|
||||
Args:
|
||||
family: Font family name (can include plugin namespace like "plugin_id::family")
|
||||
size_px: Font size in pixels
|
||||
|
||||
Returns:
|
||||
Font object (PIL Font for TTF, freetype.Face for BDF)
|
||||
"""
|
||||
# Check cache first
|
||||
cache_key = f"{family}_{size_px}"
|
||||
if cache_key in self.font_cache:
|
||||
self.performance_stats["cache_hits"] += 1
|
||||
return self.font_cache[cache_key]
|
||||
|
||||
self.performance_stats["cache_misses"] += 1
|
||||
start_time = time.time()
|
||||
|
||||
# Load font
|
||||
font_path = self.font_catalog.get(family)
|
||||
if not font_path:
|
||||
logger.warning(f"Font family '{family}' not found")
|
||||
self.performance_stats["failed_loads"] += 1
|
||||
font = ImageFont.load_default()
|
||||
else:
|
||||
try:
|
||||
if font_path.endswith('.bdf'):
|
||||
font = self._load_bdf_font(font_path, size_px)
|
||||
else:
|
||||
font = ImageFont.truetype(font_path, size_px)
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading font {font_path}: {e}")
|
||||
self.performance_stats["failed_loads"] += 1
|
||||
font = ImageFont.load_default()
|
||||
|
||||
# Cache and record performance
|
||||
self.font_cache[cache_key] = font
|
||||
duration = time.time() - start_time
|
||||
self.performance_stats["font_load_times"][cache_key] = duration
|
||||
|
||||
return font
|
||||
|
||||
def _load_bdf_font(self, font_path: str, size_px: int) -> freetype.Face:
|
||||
"""Load a BDF font using FreeType."""
|
||||
try:
|
||||
face = freetype.Face(font_path)
|
||||
# Set character size (width, height) in 1/64th of points
|
||||
face.set_char_size(size_px * 64, size_px * 64, 72, 72)
|
||||
return face
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading BDF font {font_path}: {e}")
|
||||
raise
|
||||
|
||||
def _get_fallback_font(self) -> ImageFont.ImageFont:
|
||||
"""Get a fallback font when loading fails."""
|
||||
return ImageFont.load_default()
|
||||
|
||||
# ==================== Font Measurement ====================
|
||||
|
||||
def measure_text(self, text: str, font: Union[ImageFont.FreeTypeFont, freetype.Face]) -> Tuple[int, int, int]:
|
||||
"""
|
||||
Measure text dimensions and baseline.
|
||||
|
||||
Args:
|
||||
text: Text to measure
|
||||
font: Font to use for measurement
|
||||
|
||||
Returns:
|
||||
Tuple of (width, height, baseline_offset)
|
||||
"""
|
||||
cache_key = f"{hash(text)}_{id(font)}"
|
||||
|
||||
if cache_key in self.metrics_cache:
|
||||
return self.metrics_cache[cache_key]
|
||||
|
||||
try:
|
||||
if isinstance(font, freetype.Face):
|
||||
# BDF font measurement using FreeType
|
||||
width = 0
|
||||
height = 0
|
||||
baseline = 0
|
||||
max_ascender = 0
|
||||
|
||||
for char in text:
|
||||
font.load_char(char)
|
||||
width += font.glyph.advance.x >> 6 # Convert from 26.6 fixed point
|
||||
glyph_height = font.glyph.bitmap.rows
|
||||
height = max(height, glyph_height)
|
||||
|
||||
# Get ascender for baseline calculation
|
||||
ascender = font.size.ascender >> 6
|
||||
max_ascender = max(max_ascender, ascender)
|
||||
|
||||
baseline = max_ascender
|
||||
|
||||
else:
|
||||
# TTF font measurement with PIL
|
||||
bbox = font.getbbox(text)
|
||||
width = bbox[2] - bbox[0]
|
||||
height = bbox[3] - bbox[1]
|
||||
baseline = -bbox[1] # Distance from top to baseline
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error measuring text '{text}': {e}", exc_info=True)
|
||||
# Fallback measurements
|
||||
width = len(text) * 8 # Rough estimate
|
||||
height = 12
|
||||
baseline = 10
|
||||
|
||||
result = (width, height, baseline)
|
||||
self.metrics_cache[cache_key] = result
|
||||
return result
|
||||
|
||||
def get_font_height(self, font: Union[ImageFont.FreeTypeFont, freetype.Face]) -> int:
|
||||
"""Get the height of a font."""
|
||||
try:
|
||||
if isinstance(font, freetype.Face):
|
||||
return font.size.height >> 6
|
||||
else:
|
||||
# Use a common character to measure height
|
||||
bbox = font.getbbox("Ay")
|
||||
return bbox[3] - bbox[1]
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting font height: {e}", exc_info=True)
|
||||
return 12 # Default height
|
||||
|
||||
# ==================== Override Management ====================
|
||||
|
||||
def set_override(self, element_key: str, family: str = None, size_px: int = None):
|
||||
"""Set font override for a specific element."""
|
||||
if element_key not in self.font_overrides:
|
||||
self.font_overrides[element_key] = {}
|
||||
|
||||
if family is not None:
|
||||
self.font_overrides[element_key]["family"] = family
|
||||
if size_px is not None:
|
||||
self.font_overrides[element_key]["size_px"] = size_px
|
||||
|
||||
# Remove empty overrides
|
||||
if not self.font_overrides[element_key]:
|
||||
del self.font_overrides[element_key]
|
||||
else:
|
||||
self._save_overrides()
|
||||
|
||||
self.clear_cache()
|
||||
logger.info(f"Font override set for {element_key}: {self.font_overrides.get(element_key, {})}")
|
||||
|
||||
def remove_override(self, element_key: str):
|
||||
"""Remove font override for a specific element."""
|
||||
if element_key in self.font_overrides:
|
||||
del self.font_overrides[element_key]
|
||||
self._save_overrides()
|
||||
self.clear_cache()
|
||||
logger.info(f"Font override removed for {element_key}")
|
||||
|
||||
def get_overrides(self) -> Dict[str, Dict[str, str]]:
|
||||
"""Get current font overrides."""
|
||||
return self.font_overrides.copy()
|
||||
|
||||
# ==================== Font Discovery ====================
|
||||
|
||||
def _initialize_fonts(self):
|
||||
"""Initialize font catalog and validate configuration."""
|
||||
self._scan_fonts_directory()
|
||||
self._register_common_fonts()
|
||||
self._load_overrides()
|
||||
|
||||
def _scan_fonts_directory(self):
|
||||
"""Scan assets/fonts directory for available fonts."""
|
||||
fonts_dir = "assets/fonts"
|
||||
if not os.path.exists(fonts_dir):
|
||||
logger.warning(f"Fonts directory not found: {fonts_dir}")
|
||||
return
|
||||
|
||||
for filename in os.listdir(fonts_dir):
|
||||
if filename.endswith(('.ttf', '.bdf')):
|
||||
filepath = os.path.join(fonts_dir, filename)
|
||||
# Generate family name from filename (without extension)
|
||||
family_name = filename.rsplit('.', 1)[0].lower()
|
||||
self.font_catalog[family_name] = filepath
|
||||
logger.debug(f"Found font: {family_name} -> {filepath}")
|
||||
|
||||
def _register_common_fonts(self):
|
||||
"""Register common font aliases from common_fonts dictionary."""
|
||||
for family_name, font_path in self.common_fonts.items():
|
||||
# Check if font file exists
|
||||
if os.path.exists(font_path):
|
||||
# Register the common font name (overrides auto-generated name if exists)
|
||||
self.font_catalog[family_name] = font_path
|
||||
logger.debug(f"Registered common font: {family_name} -> {font_path}")
|
||||
else:
|
||||
logger.warning(f"Common font file not found: {font_path} (family: {family_name})")
|
||||
|
||||
def _load_overrides(self):
|
||||
"""Load font overrides from configuration."""
|
||||
try:
|
||||
if os.path.exists(self.font_overrides_file):
|
||||
with open(self.font_overrides_file, 'r') as f:
|
||||
self.font_overrides = json.load(f)
|
||||
logger.info(f"Loaded {len(self.font_overrides)} font overrides")
|
||||
else:
|
||||
self.font_overrides = {}
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not load font overrides: {e}")
|
||||
self.font_overrides = {}
|
||||
|
||||
def _save_overrides(self):
|
||||
"""Save current font overrides to file."""
|
||||
try:
|
||||
from pathlib import Path
|
||||
from src.common.permission_utils import (
|
||||
ensure_directory_permissions,
|
||||
get_config_dir_mode
|
||||
)
|
||||
font_overrides_path = Path(self.font_overrides_file)
|
||||
ensure_directory_permissions(font_overrides_path.parent, get_config_dir_mode())
|
||||
with open(self.font_overrides_file, 'w') as f:
|
||||
json.dump(self.font_overrides, f, indent=2)
|
||||
logger.info(f"Saved {len(self.font_overrides)} font overrides")
|
||||
except Exception as e:
|
||||
logger.error(f"Could not save font overrides: {e}")
|
||||
|
||||
# ==================== Utility Methods ====================
|
||||
|
||||
def clear_cache(self):
|
||||
"""Clear font and metrics cache."""
|
||||
self.font_cache.clear()
|
||||
self.metrics_cache.clear()
|
||||
logger.info("Font cache cleared")
|
||||
|
||||
def get_available_fonts(self) -> Dict[str, str]:
|
||||
"""Get dictionary of available font families and their paths."""
|
||||
return self.font_catalog.copy()
|
||||
|
||||
def get_size_tokens(self) -> Dict[str, int]:
|
||||
"""Get available size tokens."""
|
||||
return self.size_tokens.copy()
|
||||
|
||||
def _record_performance_metric(self, operation: str, font_key: str, duration: float):
|
||||
"""Record a performance metric."""
|
||||
if operation not in self.performance_stats:
|
||||
self.performance_stats[operation] = {}
|
||||
self.performance_stats[operation][font_key] = duration
|
||||
|
||||
def get_performance_stats(self) -> Dict[str, Any]:
|
||||
"""Get performance statistics."""
|
||||
uptime = time.time() - self.performance_stats["start_time"]
|
||||
return {
|
||||
"uptime_seconds": uptime,
|
||||
"cache_hits": self.performance_stats["cache_hits"],
|
||||
"cache_misses": self.performance_stats["cache_misses"],
|
||||
"cache_hit_rate": (
|
||||
self.performance_stats["cache_hits"] /
|
||||
(self.performance_stats["cache_hits"] + self.performance_stats["cache_misses"])
|
||||
if (self.performance_stats["cache_hits"] + self.performance_stats["cache_misses"]) > 0 else 0
|
||||
),
|
||||
"total_fonts_cached": len(self.font_cache),
|
||||
"total_metrics_cached": len(self.metrics_cache),
|
||||
"failed_loads": self.performance_stats["failed_loads"],
|
||||
"total_fonts_available": len(self.font_catalog),
|
||||
"plugin_fonts": len(self.plugin_fonts),
|
||||
"manager_fonts": len(self.manager_fonts),
|
||||
"detected_fonts": len(self.detected_fonts)
|
||||
}
|
||||
|
||||
def get_font_catalog(self) -> Dict[str, str]:
|
||||
"""Get the current font catalog."""
|
||||
return self.font_catalog.copy()
|
||||
|
||||
def add_font(self, font_file_path: str, family_name: str) -> bool:
|
||||
"""Add a new font to the catalog."""
|
||||
try:
|
||||
# Validate font file
|
||||
if not os.path.exists(font_file_path):
|
||||
logger.error(f"Font file not found: {font_file_path}")
|
||||
return False
|
||||
|
||||
# Check if family name already exists
|
||||
if family_name in self.font_catalog:
|
||||
logger.warning(f"Font family '{family_name}' already exists")
|
||||
return False
|
||||
|
||||
# Copy font to assets/fonts directory
|
||||
from pathlib import Path
|
||||
from src.common.permission_utils import (
|
||||
ensure_directory_permissions,
|
||||
get_assets_dir_mode
|
||||
)
|
||||
fonts_dir = Path("assets/fonts")
|
||||
ensure_directory_permissions(fonts_dir, get_assets_dir_mode())
|
||||
|
||||
target_path = os.path.join(fonts_dir, f"{family_name}.{font_file_path.rsplit('.', 1)[-1]}")
|
||||
|
||||
# Add to catalog
|
||||
self.font_catalog[family_name] = font_file_path
|
||||
self.clear_cache()
|
||||
logger.info(f"Added font {family_name}: {font_file_path}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding font {family_name}: {e}")
|
||||
return False
|
||||
|
||||
def remove_font(self, family_name: str) -> bool:
|
||||
"""Remove a font from the catalog."""
|
||||
try:
|
||||
if family_name not in self.font_catalog:
|
||||
logger.warning(f"Font family '{family_name}' not found")
|
||||
return False
|
||||
|
||||
# Check if font is currently in use
|
||||
in_use = False
|
||||
for override in self.font_overrides.values():
|
||||
if override.get("family") == family_name:
|
||||
in_use = True
|
||||
break
|
||||
|
||||
if in_use:
|
||||
logger.error(f"Cannot remove font '{family_name}' - it is currently in use")
|
||||
return False
|
||||
|
||||
del self.font_catalog[family_name]
|
||||
self.clear_cache()
|
||||
logger.info(f"Removed font {family_name}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error removing font {family_name}: {e}")
|
||||
return False
|
||||
|
||||
def validate_font(self, font_path: str) -> Dict[str, Any]:
|
||||
"""Validate a font file."""
|
||||
try:
|
||||
if not os.path.exists(font_path):
|
||||
return {"valid": False, "error": "Font file not found"}
|
||||
|
||||
if font_path.endswith('.bdf'):
|
||||
# Try to load BDF font
|
||||
face = freetype.Face(font_path)
|
||||
return {"valid": True, "type": "bdf", "family": "unknown"}
|
||||
elif font_path.endswith('.ttf'):
|
||||
# Try to load TTF font
|
||||
font = ImageFont.truetype(font_path, 12)
|
||||
return {"valid": True, "type": "ttf", "family": "unknown"}
|
||||
else:
|
||||
return {"valid": False, "error": "Unsupported font format"}
|
||||
|
||||
except Exception as e:
|
||||
return {"valid": False, "error": str(e)}
|
||||
@@ -17,6 +17,9 @@ class GenericCacheMixin:
|
||||
|
||||
This mixin can be used by weather, stock, news, or any other manager
|
||||
that needs to cache data with performance monitoring.
|
||||
|
||||
Note: For sports managers that need background service cache integration,
|
||||
use BackgroundCacheMixin instead. See src/background_cache_mixin.py for details.
|
||||
"""
|
||||
|
||||
def _fetch_data_with_cache(self,
|
||||
|
||||
@@ -33,7 +33,13 @@ class LayoutManager:
|
||||
def save_layouts(self) -> bool:
|
||||
"""Save layouts to file."""
|
||||
try:
|
||||
os.makedirs(os.path.dirname(self.config_path), exist_ok=True)
|
||||
from pathlib import Path
|
||||
from src.common.permission_utils import (
|
||||
ensure_directory_permissions,
|
||||
get_config_dir_mode
|
||||
)
|
||||
config_path_obj = Path(self.config_path)
|
||||
ensure_directory_permissions(config_path_obj.parent, get_config_dir_mode())
|
||||
with open(self.config_path, 'w') as f:
|
||||
json.dump(self.layouts, f, indent=2)
|
||||
return True
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
216
src/logging_config.py
Normal file
216
src/logging_config.py
Normal file
@@ -0,0 +1,216 @@
|
||||
"""
|
||||
Centralized Logging Configuration
|
||||
|
||||
Provides consistent logging configuration across the LEDMatrix application.
|
||||
Supports structured logging with context information and appropriate log levels.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import sys
|
||||
import os
|
||||
import json
|
||||
from typing import Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class StructuredFormatter(logging.Formatter):
|
||||
"""JSON formatter for structured logging in production."""
|
||||
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
"""Format log record as JSON."""
|
||||
log_data = {
|
||||
'timestamp': datetime.fromtimestamp(record.created).isoformat(),
|
||||
'level': record.levelname,
|
||||
'logger': record.name,
|
||||
'message': record.getMessage(),
|
||||
'module': record.module,
|
||||
'function': record.funcName,
|
||||
'line': record.lineno,
|
||||
}
|
||||
|
||||
# Add exception info if present
|
||||
if record.exc_info:
|
||||
log_data['exception'] = self.formatException(record.exc_info)
|
||||
|
||||
# Add extra context if present
|
||||
if hasattr(record, 'context'):
|
||||
log_data['context'] = record.context
|
||||
|
||||
if hasattr(record, 'plugin_id'):
|
||||
log_data['plugin_id'] = record.plugin_id
|
||||
|
||||
if hasattr(record, 'operation_id'):
|
||||
log_data['operation_id'] = record.operation_id
|
||||
|
||||
return json.dumps(log_data)
|
||||
|
||||
|
||||
class ContextualFormatter(logging.Formatter):
|
||||
"""Human-readable formatter with context information."""
|
||||
|
||||
def __init__(self, include_context: bool = True, include_location: bool = False):
|
||||
"""
|
||||
Initialize formatter.
|
||||
|
||||
Args:
|
||||
include_context: Include context information in log messages
|
||||
include_location: Include module/function/line information
|
||||
"""
|
||||
if include_location:
|
||||
fmt = '%(asctime)s.%(msecs)03d - %(levelname)s - %(name)s - %(module)s.%(funcName)s:%(lineno)d - %(message)s'
|
||||
else:
|
||||
fmt = '%(asctime)s.%(msecs)03d - %(levelname)s - %(name)s - %(message)s'
|
||||
|
||||
super().__init__(fmt=fmt, datefmt='%Y-%m-%d %H:%M:%S')
|
||||
self.include_context = include_context
|
||||
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
"""Format log record with context."""
|
||||
# Add context to message if present
|
||||
if self.include_context:
|
||||
context_parts = []
|
||||
|
||||
if hasattr(record, 'plugin_id'):
|
||||
context_parts.append(f"[Plugin: {record.plugin_id}]")
|
||||
|
||||
if hasattr(record, 'operation_id'):
|
||||
context_parts.append(f"[Op: {record.operation_id}]")
|
||||
|
||||
if hasattr(record, 'context') and isinstance(record.context, dict):
|
||||
for key, value in record.context.items():
|
||||
context_parts.append(f"[{key}: {value}]")
|
||||
|
||||
if context_parts:
|
||||
record.msg = ' '.join(context_parts) + ' ' + str(record.msg)
|
||||
|
||||
return super().format(record)
|
||||
|
||||
|
||||
def setup_logging(
|
||||
level: Optional[int] = None,
|
||||
format_type: str = 'readable',
|
||||
include_location: bool = False,
|
||||
log_file: Optional[str] = None
|
||||
) -> None:
|
||||
"""
|
||||
Set up centralized logging configuration.
|
||||
|
||||
Args:
|
||||
level: Log level (defaults to INFO, or DEBUG if LEDMATRIX_DEBUG is set)
|
||||
format_type: 'readable' for human-readable, 'json' for structured JSON
|
||||
include_location: Include module/function/line in readable format
|
||||
log_file: Optional file path for file logging
|
||||
"""
|
||||
# Determine log level
|
||||
if level is None:
|
||||
if os.environ.get('LEDMATRIX_DEBUG', '').lower() == 'true':
|
||||
level = logging.DEBUG
|
||||
else:
|
||||
level = logging.INFO
|
||||
|
||||
# Get root logger
|
||||
root_logger = logging.getLogger()
|
||||
root_logger.setLevel(level)
|
||||
|
||||
# Remove existing handlers to avoid duplicates
|
||||
root_logger.handlers.clear()
|
||||
|
||||
# Create formatter based on type
|
||||
if format_type == 'json':
|
||||
formatter = StructuredFormatter()
|
||||
else:
|
||||
formatter = ContextualFormatter(include_context=True, include_location=include_location)
|
||||
|
||||
# Console handler (always add)
|
||||
console_handler = logging.StreamHandler(sys.stdout)
|
||||
console_handler.setLevel(level)
|
||||
console_handler.setFormatter(formatter)
|
||||
root_logger.addHandler(console_handler)
|
||||
|
||||
# File handler (if specified)
|
||||
if log_file:
|
||||
try:
|
||||
file_handler = logging.FileHandler(log_file)
|
||||
file_handler.setLevel(level)
|
||||
file_handler.setFormatter(formatter)
|
||||
root_logger.addHandler(file_handler)
|
||||
except (IOError, OSError, PermissionError) as e:
|
||||
# Log to stderr since file logging failed
|
||||
sys.stderr.write(f"Warning: Could not set up file logging to {log_file}: {e}\n")
|
||||
|
||||
|
||||
def get_logger(name: str, plugin_id: Optional[str] = None) -> logging.Logger:
|
||||
"""
|
||||
Get a logger with consistent configuration.
|
||||
|
||||
Args:
|
||||
name: Logger name (typically __name__)
|
||||
plugin_id: Optional plugin ID for automatic context
|
||||
|
||||
Returns:
|
||||
Configured logger instance
|
||||
"""
|
||||
logger = logging.getLogger(name)
|
||||
|
||||
# Add plugin_id as attribute for formatters
|
||||
if plugin_id:
|
||||
logger.plugin_id = plugin_id
|
||||
|
||||
return logger
|
||||
|
||||
|
||||
def log_with_context(
|
||||
logger: logging.Logger,
|
||||
level: int,
|
||||
message: str,
|
||||
context: Optional[Dict[str, Any]] = None,
|
||||
plugin_id: Optional[str] = None,
|
||||
operation_id: Optional[str] = None,
|
||||
exc_info: Optional[Any] = None
|
||||
) -> None:
|
||||
"""
|
||||
Log a message with context information.
|
||||
|
||||
Args:
|
||||
logger: Logger instance
|
||||
level: Log level (logging.INFO, logging.ERROR, etc.)
|
||||
message: Log message
|
||||
context: Optional context dictionary
|
||||
plugin_id: Optional plugin ID
|
||||
operation_id: Optional operation ID for request tracking
|
||||
exc_info: Optional exception info for error logging
|
||||
"""
|
||||
extra = {}
|
||||
|
||||
if context:
|
||||
extra['context'] = context
|
||||
|
||||
if plugin_id:
|
||||
extra['plugin_id'] = plugin_id
|
||||
|
||||
if operation_id:
|
||||
extra['operation_id'] = operation_id
|
||||
|
||||
logger.log(level, message, extra=extra, exc_info=exc_info)
|
||||
|
||||
|
||||
# Convenience functions for common log operations
|
||||
def log_info(logger: logging.Logger, message: str, **kwargs) -> None:
|
||||
"""Log info message with context."""
|
||||
log_with_context(logger, logging.INFO, message, **kwargs)
|
||||
|
||||
|
||||
def log_warning(logger: logging.Logger, message: str, **kwargs) -> None:
|
||||
"""Log warning message with context."""
|
||||
log_with_context(logger, logging.WARNING, message, **kwargs)
|
||||
|
||||
|
||||
def log_error(logger: logging.Logger, message: str, **kwargs) -> None:
|
||||
"""Log error message with context."""
|
||||
log_with_context(logger, logging.ERROR, message, **kwargs, exc_info=True)
|
||||
|
||||
|
||||
def log_debug(logger: logging.Logger, message: str, **kwargs) -> None:
|
||||
"""Log debug message with context."""
|
||||
log_with_context(logger, logging.DEBUG, message, **kwargs)
|
||||
|
||||
@@ -15,6 +15,12 @@ from pathlib import Path
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
from requests.adapters import HTTPAdapter
|
||||
from urllib3.util.retry import Retry
|
||||
from src.common.permission_utils import (
|
||||
ensure_directory_permissions,
|
||||
ensure_file_permissions,
|
||||
get_assets_dir_mode,
|
||||
get_assets_file_mode
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -136,31 +142,38 @@ class LogoDownloader:
|
||||
|
||||
def get_logo_directory(self, league: str) -> str:
|
||||
"""Get the logo directory for a given league."""
|
||||
return self.LOGO_DIRECTORIES.get(league, f'assets/sports/{league}_logos')
|
||||
directory = LogoDownloader.LOGO_DIRECTORIES.get(league, f'assets/sports/{league}_logos')
|
||||
path = Path(directory)
|
||||
if not path.is_absolute():
|
||||
project_root = Path(__file__).resolve().parents[1]
|
||||
path = (project_root / path).resolve()
|
||||
return str(path)
|
||||
|
||||
def ensure_logo_directory(self, logo_dir: str) -> bool:
|
||||
def ensure_logo_directory(self, logo_dir: str | Path) -> bool:
|
||||
"""Ensure the logo directory exists, create if necessary."""
|
||||
path = Path(logo_dir)
|
||||
try:
|
||||
os.makedirs(logo_dir, exist_ok=True)
|
||||
# Create directory with proper permissions
|
||||
ensure_directory_permissions(path, get_assets_dir_mode())
|
||||
|
||||
# Check if we can actually write to the directory
|
||||
test_file = os.path.join(logo_dir, '.write_test')
|
||||
test_file = path / '.write_test'
|
||||
try:
|
||||
with open(test_file, 'w') as f:
|
||||
f.write('test')
|
||||
os.remove(test_file)
|
||||
logger.debug(f"Directory {logo_dir} is writable")
|
||||
test_file.unlink(missing_ok=True)
|
||||
logger.debug(f"Directory {path} is writable")
|
||||
return True
|
||||
except PermissionError:
|
||||
logger.error(f"Permission denied: Cannot write to directory {logo_dir}")
|
||||
logger.error(f"Please run: sudo ./fix_assets_permissions.sh")
|
||||
logger.error(f"Permission denied: Cannot write to directory {path}")
|
||||
logger.error(f"Please run: sudo ./scripts/fix_perms/fix_assets_permissions.sh")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to test write access to directory {logo_dir}: {e}")
|
||||
logger.error(f"Failed to test write access to directory {path}: {e}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create logo directory {logo_dir}: {e}")
|
||||
logger.error(f"Failed to create logo directory {path}: {e}")
|
||||
return False
|
||||
|
||||
def download_logo(self, logo_url: str, filepath: Path, team_abbreviation: str) -> bool:
|
||||
@@ -195,6 +208,9 @@ class LogoDownloader:
|
||||
# Save the converted image
|
||||
img.save(filepath, 'PNG')
|
||||
|
||||
# Set proper file permissions after saving
|
||||
ensure_file_permissions(filepath, get_assets_file_mode())
|
||||
|
||||
logger.info(f"Successfully downloaded and converted logo for {team_abbreviation} -> {filepath.name}")
|
||||
return True
|
||||
except Exception as e:
|
||||
@@ -207,7 +223,7 @@ class LogoDownloader:
|
||||
|
||||
except PermissionError as e:
|
||||
logger.error(f"Permission denied downloading logo for {team_abbreviation}: {e}")
|
||||
logger.error(f"Please run: sudo ./fix_assets_permissions.sh")
|
||||
logger.error(f"Please run: sudo ./scripts/fix_perms/fix_assets_permissions.sh")
|
||||
return False
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"Failed to download logo for {team_abbreviation}: {e}")
|
||||
@@ -612,6 +628,10 @@ class LogoDownloader:
|
||||
draw.text((16, 24), text, fill=(255, 255, 255, 255))
|
||||
|
||||
logo.save(filepath)
|
||||
|
||||
# Set proper file permissions after saving
|
||||
ensure_file_permissions(filepath, get_assets_file_mode())
|
||||
|
||||
logger.info(f"Created placeholder logo for {team_abbreviation} at {filepath}")
|
||||
return True
|
||||
|
||||
@@ -678,7 +698,8 @@ def download_missing_logo(league: str, team_id: str, team_abbreviation: str, log
|
||||
Args:
|
||||
team_abbreviation: Team abbreviation (e.g., 'UGA', 'BAMA', 'TA&M')
|
||||
league: League identifier (e.g., 'ncaa_fb', 'nfl')
|
||||
team_name: Optional team name for logging
|
||||
logo_path: Full path to where the logo should be saved
|
||||
logo_url: Optional direct URL to the logo
|
||||
create_placeholder: Whether to create a placeholder if download fails
|
||||
|
||||
Returns:
|
||||
@@ -686,24 +707,35 @@ def download_missing_logo(league: str, team_id: str, team_abbreviation: str, log
|
||||
"""
|
||||
downloader = LogoDownloader()
|
||||
|
||||
# Check if logo already exists
|
||||
logo_dir = downloader.get_logo_directory(league)
|
||||
# Use the directory from the logo_path parameter (respects config settings)
|
||||
logo_path = Path(logo_path)
|
||||
if not logo_path.is_absolute():
|
||||
project_root = Path(__file__).resolve().parents[1]
|
||||
logo_path = (project_root / logo_path).resolve()
|
||||
|
||||
logo_dir = str(logo_path.parent)
|
||||
|
||||
# Ensure the directory exists and is writable
|
||||
if not downloader.ensure_logo_directory(logo_dir):
|
||||
logger.error(f"Cannot download logo for {team_abbreviation}: directory {logo_dir} is not writable")
|
||||
return False
|
||||
filename = f"{downloader.normalize_abbreviation(team_abbreviation)}.png"
|
||||
filepath = Path(logo_dir) / filename
|
||||
|
||||
# Use the exact filepath that was passed in (respects config settings)
|
||||
filepath = logo_path
|
||||
|
||||
if filepath.exists():
|
||||
logger.debug(f"Logo already exists for {team_abbreviation} ({league})")
|
||||
return True
|
||||
|
||||
# Try to download the real logo first
|
||||
logger.info(f"Attempting to download logo for {team_abbreviation} from {league}")
|
||||
logger.info(f"Attempting to download logo for {team_abbreviation} from {league}")
|
||||
if logo_url:
|
||||
success = downloader.download_logo(logo_url, filepath, team_abbreviation)
|
||||
if success:
|
||||
time.sleep(0.1) # Small delay
|
||||
if not success and create_placeholder:
|
||||
logger.info(f"Creating placeholder logo for {team_abbreviation}")
|
||||
success = downloader.create_placeholder_logo(team_abbreviation, logo_dir)
|
||||
return success
|
||||
|
||||
success = downloader.download_missing_logo_for_team(league, team_id, team_abbreviation, logo_path)
|
||||
|
||||
1834
src/milb_manager.py
1834
src/milb_manager.py
File diff suppressed because it is too large
Load Diff
@@ -1,207 +0,0 @@
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import pytz
|
||||
from PIL import ImageDraw
|
||||
|
||||
# Import baseball and standard sports classes
|
||||
from src.base_classes.baseball import Baseball, BaseballLive, BaseballRecent
|
||||
from src.base_classes.sports import SportsUpcoming
|
||||
from src.cache_manager import CacheManager
|
||||
from src.display_manager import DisplayManager
|
||||
|
||||
# Import the API counter function from web interface
|
||||
try:
|
||||
from web_interface_v2 import increment_api_counter
|
||||
except ImportError:
|
||||
# Fallback if web interface is not available
|
||||
def increment_api_counter(kind: str, count: int = 1):
|
||||
pass
|
||||
|
||||
|
||||
ESPN_MLB_SCOREBOARD_URL = "https://site.api.espn.com/apis/site/v2/sports/baseball/mlb/scoreboard" # Changed URL for NCAA FB
|
||||
|
||||
|
||||
class BaseMLBManager(Baseball):
|
||||
"""Base class for MLB managers using new baseball architecture."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: Dict[str, Any],
|
||||
display_manager: DisplayManager,
|
||||
cache_manager: CacheManager,
|
||||
):
|
||||
# Initialize with sport_key for MLB
|
||||
self.logger = logging.getLogger("MLB")
|
||||
super().__init__(config, display_manager, cache_manager, self.logger, "mlb")
|
||||
|
||||
# MLB-specific configuration
|
||||
self.show_odds = self.mode_config.get("show_odds", False)
|
||||
self.favorite_teams = self.mode_config.get("favorite_teams", [])
|
||||
self.show_records = self.mode_config.get("show_records", False)
|
||||
self.league = "mlb"
|
||||
|
||||
def _fetch_mlb_api_data(self, use_cache: bool = True) -> Optional[Dict]:
|
||||
"""
|
||||
Fetches the full season schedule for NCAAFB using week-by-week approach to ensure
|
||||
we get all games, then caches the complete dataset.
|
||||
|
||||
This method now uses background threading to prevent blocking the display.
|
||||
"""
|
||||
now = datetime.now(pytz.utc)
|
||||
start_of_last_month = now.replace(day=1, month=now.month - 1)
|
||||
last_day_of_next_month = now.replace(day=1, month=now.month + 2) - timedelta(
|
||||
days=1
|
||||
)
|
||||
start_of_last_month_str = start_of_last_month.strftime("%Y%m%d")
|
||||
last_day_of_next_month_str = last_day_of_next_month.strftime("%Y%m%d")
|
||||
datestring = f"{start_of_last_month_str}-{last_day_of_next_month_str}"
|
||||
cache_key = f"mlb_schedule_{datestring}"
|
||||
|
||||
if use_cache:
|
||||
cached_data = self.cache_manager.get(cache_key)
|
||||
if cached_data:
|
||||
# Validate cached data structure
|
||||
if isinstance(cached_data, dict) and "events" in cached_data:
|
||||
self.logger.info(f"Using cached schedule for {datestring}")
|
||||
return cached_data
|
||||
elif isinstance(cached_data, list):
|
||||
# Handle old cache format (list of events)
|
||||
self.logger.info(
|
||||
f"Using cached schedule for {datestring} (legacy format)"
|
||||
)
|
||||
return {"events": cached_data}
|
||||
else:
|
||||
self.logger.warning(
|
||||
f"Invalid cached data format for {datestring}: {type(cached_data)}"
|
||||
)
|
||||
# Clear invalid cache
|
||||
self.cache_manager.clear_cache(cache_key)
|
||||
|
||||
self.logger.info(f"Fetching full {datestring} season schedule from ESPN API...")
|
||||
|
||||
# Start background fetch
|
||||
self.logger.info(
|
||||
f"Starting background fetch for {datestring} season schedule..."
|
||||
)
|
||||
|
||||
def fetch_callback(result):
|
||||
"""Callback when background fetch completes."""
|
||||
if result.success:
|
||||
self.logger.info(
|
||||
f"Background fetch completed for {datestring}: {len(result.data.get('events'))} events"
|
||||
)
|
||||
else:
|
||||
self.logger.error(
|
||||
f"Background fetch failed for {datestring}: {result.error}"
|
||||
)
|
||||
|
||||
# Clean up request tracking
|
||||
if datestring in self.background_fetch_requests:
|
||||
del self.background_fetch_requests[datestring]
|
||||
|
||||
# Get background service configuration
|
||||
background_config = self.mode_config.get("background_service", {})
|
||||
timeout = background_config.get("request_timeout", 30)
|
||||
max_retries = background_config.get("max_retries", 3)
|
||||
priority = background_config.get("priority", 2)
|
||||
|
||||
# Submit background fetch request
|
||||
request_id = self.background_service.submit_fetch_request(
|
||||
sport="mlb",
|
||||
year=now.year,
|
||||
url=ESPN_MLB_SCOREBOARD_URL,
|
||||
cache_key=cache_key,
|
||||
params={"dates": datestring, "limit": 1000},
|
||||
headers=self.headers,
|
||||
timeout=timeout,
|
||||
max_retries=max_retries,
|
||||
priority=priority,
|
||||
callback=fetch_callback,
|
||||
)
|
||||
|
||||
# Track the request
|
||||
self.background_fetch_requests[datestring] = request_id
|
||||
|
||||
# For immediate response, try to get partial data
|
||||
partial_data = self._get_weeks_data()
|
||||
if partial_data:
|
||||
return partial_data
|
||||
return None
|
||||
|
||||
def _fetch_data(self) -> Optional[Dict]:
|
||||
"""Fetch data using shared data mechanism or direct fetch for live."""
|
||||
if isinstance(self, MLBLiveManager):
|
||||
return self._fetch_todays_games()
|
||||
else:
|
||||
return self._fetch_mlb_api_data(use_cache=True)
|
||||
|
||||
|
||||
class MLBLiveManager(BaseMLBManager, BaseballLive):
|
||||
"""Manager for displaying live MLB games."""
|
||||
|
||||
def __init__(
|
||||
self, config: Dict[str, Any], display_manager, cache_manager: CacheManager
|
||||
):
|
||||
super().__init__(config, display_manager, cache_manager)
|
||||
self.logger.info("Initialized MLB Live Manager")
|
||||
|
||||
# Initialize with test game only if test mode is enabled
|
||||
if self.test_mode:
|
||||
self.current_game = {
|
||||
"home_abbr": "TB",
|
||||
"home_id": "234",
|
||||
"away_abbr": "TEX",
|
||||
"away_id": "234",
|
||||
"home_score": "3",
|
||||
"away_score": "2",
|
||||
"inning": 5,
|
||||
"inning_half": "top",
|
||||
"balls": 2,
|
||||
"strikes": 1,
|
||||
"outs": 1,
|
||||
"bases_occupied": [True, False, True],
|
||||
"home_logo_path": Path(self.logo_dir, "TB.png"),
|
||||
"away_logo_path": Path(self.logo_dir, "TEX.png"),
|
||||
"start_time": datetime.now(timezone.utc).isoformat(),
|
||||
"is_live": True, "is_final": False, "is_upcoming": False,
|
||||
}
|
||||
self.live_games = [self.current_game]
|
||||
self.logger.info("Initialized MLBLiveManager with test game: TB vs TEX")
|
||||
else:
|
||||
self.logger.info("Initialized MLBLiveManager in live mode")
|
||||
|
||||
|
||||
class MLBRecentManager(BaseMLBManager, BaseballRecent):
|
||||
"""Manager for displaying recent MLB games."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: Dict[str, Any],
|
||||
display_manager: DisplayManager,
|
||||
cache_manager: CacheManager,
|
||||
):
|
||||
super().__init__(config, display_manager, cache_manager)
|
||||
self.logger = logging.getLogger("MLBRecentManager") # Changed logger name
|
||||
self.logger.info(
|
||||
f"Initialized MLBRecentManager with {len(self.favorite_teams)} favorite teams"
|
||||
) # Changed log prefix
|
||||
|
||||
class MLBUpcomingManager(BaseMLBManager, SportsUpcoming):
|
||||
"""Manager for displaying upcoming MLB games."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: Dict[str, Any],
|
||||
display_manager: DisplayManager,
|
||||
cache_manager: CacheManager,
|
||||
):
|
||||
super().__init__(config, display_manager, cache_manager)
|
||||
self.logger = logging.getLogger("MLBUpcomingManager") # Changed logger name
|
||||
self.logger.info(
|
||||
f"Initialized MLBUpcomingManager with {len(self.favorite_teams)} favorite teams"
|
||||
) # Changed log prefix
|
||||
1011
src/music_manager.py
1011
src/music_manager.py
File diff suppressed because it is too large
Load Diff
@@ -1,194 +0,0 @@
|
||||
import time
|
||||
import logging
|
||||
import requests
|
||||
from typing import Dict, Any, Optional
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from PIL import Image, ImageFont
|
||||
from src.display_manager import DisplayManager
|
||||
from src.cache_manager import CacheManager
|
||||
from src.base_classes.basketball import Basketball, BasketballLive
|
||||
from src.base_classes.sports import SportsRecent, SportsUpcoming
|
||||
import pytz
|
||||
|
||||
# Import the API counter function from web interface
|
||||
try:
|
||||
from web_interface_v2 import increment_api_counter
|
||||
except ImportError:
|
||||
# Fallback if web interface is not available
|
||||
def increment_api_counter(kind: str, count: int = 1):
|
||||
pass
|
||||
|
||||
# Constants
|
||||
ESPN_NBA_SCOREBOARD_URL = "https://site.api.espn.com/apis/site/v2/sports/basketball/nba/scoreboard"
|
||||
|
||||
class BaseNBAManager(Basketball):
|
||||
"""Base class for NBA managers with common functionality."""
|
||||
# Class variables for warning tracking
|
||||
_no_data_warning_logged = False
|
||||
_last_warning_time = 0
|
||||
_warning_cooldown = 60 # Only log warnings once per minute
|
||||
_last_log_times = {}
|
||||
_shared_data = None
|
||||
_last_shared_update = 0
|
||||
|
||||
def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager):
|
||||
self.logger = logging.getLogger('NBA') # Changed logger name
|
||||
super().__init__(config=config, display_manager=display_manager, cache_manager=cache_manager, logger=self.logger, sport_key="nba")
|
||||
|
||||
# Check display modes to determine what data to fetch
|
||||
display_modes = self.mode_config.get("display_modes", {})
|
||||
self.recent_enabled = display_modes.get("nba_recent", False)
|
||||
self.upcoming_enabled = display_modes.get("nba_upcoming", False)
|
||||
self.live_enabled = display_modes.get("nba_live", False)
|
||||
|
||||
self.logger.info(f"Initialized NBA manager with display dimensions: {self.display_width}x{self.display_height}")
|
||||
self.logger.info(f"Logo directory: {self.logo_dir}")
|
||||
self.logger.info(f"Display modes - Recent: {self.recent_enabled}, Upcoming: {self.upcoming_enabled}, Live: {self.live_enabled}")
|
||||
self.league = "nba"
|
||||
|
||||
def _fetch_nba_api_data(self, use_cache: bool = True) -> Optional[Dict]:
|
||||
"""
|
||||
Fetches the full season schedule for NBA using background threading.
|
||||
Returns cached data immediately if available, otherwise starts background fetch.
|
||||
"""
|
||||
now = datetime.now(pytz.utc)
|
||||
season_year = now.year
|
||||
if now.month < 7:
|
||||
season_year = now.year - 1
|
||||
datestring = f"{season_year}1001-{season_year+1}0701"
|
||||
cache_key = f"{self.sport_key}_schedule_{season_year}"
|
||||
|
||||
# Check cache first
|
||||
if use_cache:
|
||||
cached_data = self.cache_manager.get(cache_key)
|
||||
if cached_data:
|
||||
# Validate cached data structure
|
||||
if isinstance(cached_data, dict) and 'events' in cached_data:
|
||||
self.logger.info(f"Using cached schedule for {season_year}")
|
||||
return cached_data
|
||||
elif isinstance(cached_data, list):
|
||||
# Handle old cache format (list of events)
|
||||
self.logger.info(f"Using cached schedule for {season_year} (legacy format)")
|
||||
return {'events': cached_data}
|
||||
else:
|
||||
self.logger.warning(f"Invalid cached data format for {season_year}: {type(cached_data)}")
|
||||
# Clear invalid cache
|
||||
self.cache_manager.clear_cache(cache_key)
|
||||
|
||||
# Start background fetch
|
||||
self.logger.info(f"Starting background fetch for {season_year} season schedule...")
|
||||
|
||||
def fetch_callback(result):
|
||||
"""Callback when background fetch completes."""
|
||||
if result.success:
|
||||
self.logger.info(f"Background fetch completed for {season_year}: {len(result.data.get('events'))} events")
|
||||
else:
|
||||
self.logger.error(f"Background fetch failed for {season_year}: {result.error}")
|
||||
|
||||
# Clean up request tracking
|
||||
if season_year in self.background_fetch_requests:
|
||||
del self.background_fetch_requests[season_year]
|
||||
|
||||
# Get background service configuration
|
||||
background_config = self.mode_config.get("background_service", {})
|
||||
timeout = background_config.get("request_timeout", 30)
|
||||
max_retries = background_config.get("max_retries", 3)
|
||||
priority = background_config.get("priority", 2)
|
||||
|
||||
# Submit background fetch request
|
||||
request_id = self.background_service.submit_fetch_request(
|
||||
sport="nba",
|
||||
year=season_year,
|
||||
url=ESPN_NBA_SCOREBOARD_URL,
|
||||
cache_key=cache_key,
|
||||
params={"dates": datestring, "limit": 1000},
|
||||
headers=self.headers,
|
||||
timeout=timeout,
|
||||
max_retries=max_retries,
|
||||
priority=priority,
|
||||
callback=fetch_callback
|
||||
)
|
||||
|
||||
# Track the request
|
||||
self.background_fetch_requests[season_year] = request_id
|
||||
|
||||
# For immediate response, try to get partial data
|
||||
partial_data = self._get_weeks_data()
|
||||
if partial_data:
|
||||
return partial_data
|
||||
|
||||
return None
|
||||
|
||||
def _fetch_data(self) -> Optional[Dict]:
|
||||
"""Fetch data using shared data mechanism or direct fetch for live."""
|
||||
if isinstance(self, NBALiveManager):
|
||||
# Live games should fetch only current games, not entire season
|
||||
return self._fetch_todays_games()
|
||||
else:
|
||||
# Recent and Upcoming managers should use cached season data
|
||||
return self._fetch_nba_api_data(use_cache=True)
|
||||
|
||||
class NBALiveManager(BaseNBAManager, BasketballLive):
|
||||
"""Manager for live NBA games."""
|
||||
def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager):
|
||||
super().__init__(config, display_manager, cache_manager)
|
||||
self.logger = logging.getLogger('NBALiveManager') # Changed logger name
|
||||
|
||||
if self.test_mode:
|
||||
# More detailed test game for NBA
|
||||
self.current_game = {
|
||||
"id": "test001",
|
||||
"home_abbr": "LAL", "home_id": "123", "away_abbr": "GS", "away_id":"asdf",
|
||||
"home_score": "21", "away_score": "17",
|
||||
"period": 3, "period_text": "Q3", "clock": "5:24",
|
||||
"home_logo_path": Path(self.logo_dir, "LAL.png"),
|
||||
"away_logo_path": Path(self.logo_dir, "GS.png"),
|
||||
"is_live": True, "is_final": False, "is_upcoming": False, "is_halftime": False,
|
||||
}
|
||||
self.live_games = [self.current_game]
|
||||
self.logger.info("Initialized NBALiveManager with test game: BUF vs KC")
|
||||
else:
|
||||
self.logger.info(" Initialized NBALiveManager in live mode")
|
||||
|
||||
|
||||
class NBARecentManager(BaseNBAManager, SportsRecent):
|
||||
"""Manager for recently completed NBA games."""
|
||||
def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager):
|
||||
super().__init__(config, display_manager, cache_manager)
|
||||
self.logger = logging.getLogger('NBARecentManager') # Changed logger name
|
||||
self.logger.info(f"Initialized NBARecentManager with {len(self.favorite_teams)} favorite teams")
|
||||
|
||||
class NBAUpcomingManager(BaseNBAManager, SportsUpcoming):
|
||||
"""Manager for upcoming NBA games."""
|
||||
def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager):
|
||||
super().__init__(config, display_manager, cache_manager)
|
||||
self.logger = logging.getLogger('NBAUpcomingManager') # Changed logger name
|
||||
self.logger.info(f"Initialized NBAUpcomingManager with {len(self.favorite_teams)} favorite teams")
|
||||
|
||||
"""Display upcoming games."""
|
||||
if not self.upcoming_games:
|
||||
return
|
||||
|
||||
try:
|
||||
current_time = time.time()
|
||||
|
||||
# Check if it's time to switch games
|
||||
if len(self.upcoming_games) > 1 and current_time - self.last_game_switch >= self.game_display_duration:
|
||||
# Move to next game
|
||||
self.current_game_index = (self.current_game_index + 1) % len(self.upcoming_games)
|
||||
self.current_game = self.upcoming_games[self.current_game_index]
|
||||
self.last_game_switch = current_time
|
||||
force_clear = True
|
||||
|
||||
# Log team switching
|
||||
if self.current_game:
|
||||
away_abbr = self.current_game.get('away_abbr', 'UNK')
|
||||
home_abbr = self.current_game.get('home_abbr', 'UNK')
|
||||
self.logger.info(f"[NBA Upcoming] Showing {away_abbr} vs {home_abbr}")
|
||||
|
||||
# Draw the scorebug layout
|
||||
self._draw_scorebug_layout(self.current_game, force_clear)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"[NBA] Error displaying upcoming game: {e}", exc_info=True)
|
||||
@@ -1,161 +0,0 @@
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import pytz
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
# Import baseball and standard sports classes
|
||||
from src.base_classes.baseball import Baseball, BaseballLive
|
||||
from src.base_classes.sports import SportsRecent, SportsUpcoming
|
||||
from src.cache_manager import CacheManager
|
||||
from src.display_manager import DisplayManager
|
||||
|
||||
# Constants for NCAA Baseball API URL
|
||||
ESPN_NCAABB_SCOREBOARD_URL = "https://site.api.espn.com/apis/site/v2/sports/baseball/college-baseball/scoreboard"
|
||||
|
||||
class BaseNCAABaseballManager(Baseball):
|
||||
"""Base class for NCAA Baseball managers using new baseball architecture."""
|
||||
def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager):
|
||||
# Initialize with sport_key for NCAABB
|
||||
self.logger = logging.getLogger("NCAA Baseball")
|
||||
super().__init__(config, display_manager, cache_manager, self.logger, "ncaa_baseball")
|
||||
|
||||
# NCAA Baseball-specific configuration
|
||||
self.show_odds = self.mode_config.get("show_odds", False)
|
||||
self.favorite_teams = self.mode_config.get('favorite_teams', [])
|
||||
self.show_records = self.mode_config.get('show_records', False)
|
||||
self.league = "college-baseball"
|
||||
|
||||
def _fetch_ncaa_baseball_api_data(self, use_cache: bool = True) -> Optional[Dict]:
|
||||
"""
|
||||
Fetches the full season schedule for NCAA Baseball using week-by-week approach to ensure
|
||||
we get all games, then caches the complete dataset.
|
||||
|
||||
This method now uses background threading to prevent blocking the display.
|
||||
"""
|
||||
now = datetime.now(pytz.utc)
|
||||
start_of_last_month = now.replace(day=1, month=now.month - 1)
|
||||
last_day_of_next_month = now.replace(day=1, month=now.month + 2) - timedelta(days=1)
|
||||
start_of_last_month_str = start_of_last_month.strftime("%Y%m%d")
|
||||
last_day_of_next_month_str = last_day_of_next_month.strftime("%Y%m%d")
|
||||
datestring = f"{start_of_last_month_str}-{last_day_of_next_month_str}"
|
||||
cache_key = f"ncaa_baseball_schedule_{datestring}"
|
||||
|
||||
if use_cache:
|
||||
cached_data = self.cache_manager.get(cache_key)
|
||||
if cached_data:
|
||||
# Validate cached data structure
|
||||
if isinstance(cached_data, dict) and 'events' in cached_data:
|
||||
self.logger.info(f"Using cached schedule for {datestring}")
|
||||
return cached_data
|
||||
elif isinstance(cached_data, list):
|
||||
# Handle old cache format (list of events)
|
||||
self.logger.info(f"Using cached schedule for {datestring} (legacy format)")
|
||||
return {'events': cached_data}
|
||||
else:
|
||||
self.logger.warning(f"Invalid cached data format for {datestring}: {type(cached_data)}")
|
||||
# Clear invalid cache
|
||||
self.cache_manager.clear_cache(cache_key)
|
||||
|
||||
self.logger.info(f"Fetching full {datestring} season schedule from ESPN API...")
|
||||
|
||||
# Start background fetch
|
||||
self.logger.info(f"Starting background fetch for {datestring} season schedule...")
|
||||
|
||||
def fetch_callback(result):
|
||||
"""Callback when background fetch completes."""
|
||||
if result.success:
|
||||
self.logger.info(f"Background fetch completed for {datestring}: {len(result.data.get('events'))} events")
|
||||
else:
|
||||
self.logger.error(f"Background fetch failed for {datestring}: {result.error}")
|
||||
|
||||
# Clean up request tracking
|
||||
if datestring in self.background_fetch_requests:
|
||||
del self.background_fetch_requests[datestring]
|
||||
|
||||
# Get background service configuration
|
||||
background_config = self.mode_config.get("background_service", {})
|
||||
timeout = background_config.get("request_timeout", 30)
|
||||
max_retries = background_config.get("max_retries", 3)
|
||||
priority = background_config.get("priority", 2)
|
||||
|
||||
# Submit background fetch request
|
||||
request_id = self.background_service.submit_fetch_request(
|
||||
sport="ncaa_baseball",
|
||||
year=now.year,
|
||||
url=ESPN_NCAABB_SCOREBOARD_URL,
|
||||
cache_key=cache_key,
|
||||
params={"dates": datestring, "limit": 1000},
|
||||
headers=self.headers,
|
||||
timeout=timeout,
|
||||
max_retries=max_retries,
|
||||
priority=priority,
|
||||
callback=fetch_callback
|
||||
)
|
||||
|
||||
# Track the request
|
||||
self.background_fetch_requests[datestring] = request_id
|
||||
|
||||
# For immediate response, try to get partial data
|
||||
partial_data = self._get_weeks_data()
|
||||
if partial_data:
|
||||
return partial_data
|
||||
return None
|
||||
|
||||
|
||||
def _fetch_data(self) -> Optional[Dict]:
|
||||
"""Fetch data using shared data mechanism or direct fetch for live."""
|
||||
if isinstance(self, NCAABaseballLiveManager):
|
||||
return self._fetch_todays_games()
|
||||
else:
|
||||
return self._fetch_ncaa_baseball_api_data(use_cache=True)
|
||||
|
||||
class NCAABaseballLiveManager(BaseNCAABaseballManager, BaseballLive):
|
||||
"""Manager for displaying live NCAA Baseball games."""
|
||||
def __init__(self, config: Dict[str, Any], display_manager, cache_manager: CacheManager):
|
||||
super().__init__(config, display_manager, cache_manager)
|
||||
self.logger.info("Initialized NCAA Baseball Live Manager")
|
||||
|
||||
if self.test_mode:
|
||||
self.current_game = {
|
||||
"home_abbr": "FLA",
|
||||
"home_id": "234",
|
||||
"away_abbr": "LSU",
|
||||
"away_id": "234",
|
||||
"home_score": "4",
|
||||
"away_score": "5",
|
||||
"status": "live",
|
||||
"status_state": "in",
|
||||
"inning": 8,
|
||||
"inning_half": "top",
|
||||
"balls": 1,
|
||||
"strikes": 2,
|
||||
"outs": 2,
|
||||
"bases_occupied": [True, True, False],
|
||||
"home_logo_path": Path(self.logo_dir, "FLA.png"),
|
||||
"away_logo_path": Path(self.logo_dir, "LSU.png"),
|
||||
"start_time": datetime.now(timezone.utc).isoformat(),
|
||||
"is_live": True, "is_final": False, "is_upcoming": False,
|
||||
}
|
||||
self.live_games = [self.current_game]
|
||||
self.logger.info("Initialized NCAABaseballLiveManager with test game: LSU vs FLA")
|
||||
else:
|
||||
self.logger.info("Initialized NCAABaseballLiveManager in live mode")
|
||||
|
||||
|
||||
class NCAABaseballRecentManager(BaseNCAABaseballManager, SportsRecent):
|
||||
"""Manager for displaying recent NCAA Baseball games."""
|
||||
def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager):
|
||||
super().__init__(config, display_manager, cache_manager)
|
||||
self.logger = logging.getLogger('NCAABaseballRecentManager') # Changed logger name
|
||||
self.logger.info(f"Initialized NCAABaseballRecentManager with {len(self.favorite_teams)} favorite teams") # Changed log prefix
|
||||
|
||||
class NCAABaseballUpcomingManager(BaseNCAABaseballManager, SportsUpcoming):
|
||||
"""Manager for displaying upcoming NCAA Baseball games."""
|
||||
def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager):
|
||||
super().__init__(config, display_manager, cache_manager)
|
||||
self.logger = logging.getLogger('NCAABaseballUpcomingManager') # Changed logger name
|
||||
self.logger.info(f"Initialized NCAABaseballUpcomingManager with {len(self.favorite_teams)} favorite teams") # Changed log prefix
|
||||
@@ -1,168 +0,0 @@
|
||||
import os
|
||||
import time
|
||||
import logging
|
||||
import requests
|
||||
import json
|
||||
from typing import Dict, Any, Optional, List
|
||||
from datetime import datetime, timedelta
|
||||
from src.display_manager import DisplayManager
|
||||
from src.cache_manager import CacheManager # Keep CacheManager import
|
||||
import pytz
|
||||
from src.base_classes.sports import SportsRecent, SportsUpcoming
|
||||
from src.base_classes.football import Football, FootballLive
|
||||
from pathlib import Path
|
||||
# Constants
|
||||
ESPN_NCAAFB_SCOREBOARD_URL = "https://site.api.espn.com/apis/site/v2/sports/football/college-football/scoreboard" # Changed URL for NCAA FB
|
||||
|
||||
class BaseNCAAFBManager(Football): # Renamed class
|
||||
"""Base class for NCAA FB managers with common functionality.""" # Updated docstring
|
||||
# Class variables for warning tracking
|
||||
_no_data_warning_logged = False
|
||||
_last_warning_time = 0
|
||||
_warning_cooldown = 60 # Only log warnings once per minute
|
||||
_shared_data = None
|
||||
_last_shared_update = 0
|
||||
_processed_games_cache = {} # Cache for processed game data
|
||||
_processed_games_timestamp = 0
|
||||
|
||||
def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager):
|
||||
self.logger = logging.getLogger('NCAAFB') # Changed logger name
|
||||
super().__init__(config=config, display_manager=display_manager, cache_manager=cache_manager, logger=self.logger, sport_key="ncaa_fb")
|
||||
|
||||
# Configuration is already set in base class
|
||||
# self.logo_dir and self.update_interval are already configured
|
||||
|
||||
# Check display modes to determine what data to fetch
|
||||
display_modes = self.mode_config.get("display_modes", {})
|
||||
self.recent_enabled = display_modes.get("ncaa_fb_recent", False)
|
||||
self.upcoming_enabled = display_modes.get("ncaa_fb_upcoming", False)
|
||||
self.live_enabled = display_modes.get("ncaa_fb_live", False)
|
||||
self.league = "college-football"
|
||||
|
||||
self.logger.info(f"Initialized NCAAFB manager with display dimensions: {self.display_width}x{self.display_height}")
|
||||
self.logger.info(f"Logo directory: {self.logo_dir}")
|
||||
self.logger.info(f"Display modes - Recent: {self.recent_enabled}, Upcoming: {self.upcoming_enabled}, Live: {self.live_enabled}")
|
||||
|
||||
def _fetch_ncaa_fb_api_data(self, use_cache: bool = True) -> Optional[Dict]:
|
||||
"""
|
||||
Fetches the full season schedule for NCAAFB using week-by-week approach to ensure
|
||||
we get all games, then caches the complete dataset.
|
||||
|
||||
This method now uses background threading to prevent blocking the display.
|
||||
"""
|
||||
now = datetime.now(pytz.utc)
|
||||
season_year = now.year
|
||||
if now.month < 8:
|
||||
season_year = now.year - 1
|
||||
datestring = f"{season_year}0801-{season_year+1}0201"
|
||||
cache_key = f"ncaafb_schedule_{season_year}"
|
||||
|
||||
if use_cache:
|
||||
cached_data = self.cache_manager.get(cache_key)
|
||||
if cached_data:
|
||||
# Validate cached data structure
|
||||
if isinstance(cached_data, dict) and 'events' in cached_data:
|
||||
self.logger.info(f"Using cached schedule for {season_year}")
|
||||
return cached_data
|
||||
elif isinstance(cached_data, list):
|
||||
# Handle old cache format (list of events)
|
||||
self.logger.info(f"Using cached schedule for {season_year} (legacy format)")
|
||||
return {'events': cached_data}
|
||||
else:
|
||||
self.logger.warning(f"Invalid cached data format for {season_year}: {type(cached_data)}")
|
||||
# Clear invalid cache
|
||||
self.cache_manager.clear_cache(cache_key)
|
||||
|
||||
self.logger.info(f"Fetching full {season_year} season schedule from ESPN API...")
|
||||
|
||||
# Start background fetch
|
||||
self.logger.info(f"Starting background fetch for {season_year} season schedule...")
|
||||
|
||||
def fetch_callback(result):
|
||||
"""Callback when background fetch completes."""
|
||||
if result.success:
|
||||
self.logger.info(f"Background fetch completed for {season_year}: {len(result.data.get('events'))} events")
|
||||
else:
|
||||
self.logger.error(f"Background fetch failed for {season_year}: {result.error}")
|
||||
|
||||
# Clean up request tracking
|
||||
if season_year in self.background_fetch_requests:
|
||||
del self.background_fetch_requests[season_year]
|
||||
|
||||
# Get background service configuration
|
||||
background_config = self.mode_config.get("background_service", {})
|
||||
timeout = background_config.get("request_timeout", 30)
|
||||
max_retries = background_config.get("max_retries", 3)
|
||||
priority = background_config.get("priority", 2)
|
||||
|
||||
# Submit background fetch request
|
||||
request_id = self.background_service.submit_fetch_request(
|
||||
sport="ncaa_fb",
|
||||
year=season_year,
|
||||
url=ESPN_NCAAFB_SCOREBOARD_URL,
|
||||
cache_key=cache_key,
|
||||
params={"dates": datestring, "limit": 1000},
|
||||
headers=self.headers,
|
||||
timeout=timeout,
|
||||
max_retries=max_retries,
|
||||
priority=priority,
|
||||
callback=fetch_callback
|
||||
)
|
||||
|
||||
# Track the request
|
||||
self.background_fetch_requests[season_year] = request_id
|
||||
|
||||
# For immediate response, try to get partial data
|
||||
partial_data = self._get_weeks_data()
|
||||
if partial_data:
|
||||
return partial_data
|
||||
return None
|
||||
|
||||
def _fetch_data(self) -> Optional[Dict]:
|
||||
"""Fetch data using shared data mechanism or direct fetch for live."""
|
||||
if isinstance(self, NCAAFBLiveManager):
|
||||
return self._fetch_todays_games()
|
||||
else:
|
||||
return self._fetch_ncaa_fb_api_data(use_cache=True)
|
||||
|
||||
class NCAAFBLiveManager(BaseNCAAFBManager, FootballLive): # Renamed class
|
||||
"""Manager for live NCAA FB games.""" # Updated docstring
|
||||
def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager):
|
||||
super().__init__(config=config, display_manager=display_manager, cache_manager=cache_manager)
|
||||
self.logger = logging.getLogger('NCAAFBLiveManager') # Changed logger name
|
||||
|
||||
if self.test_mode:
|
||||
# More detailed test game for NCAA FB
|
||||
self.current_game = {
|
||||
"id": "testNCAAFB001",
|
||||
"home_id": "343", "away_id": "567",
|
||||
"home_abbr": "UGA", "away_abbr": "AUB", # NCAA Examples
|
||||
"home_score": "28", "away_score": "21",
|
||||
"period": 4, "period_text": "Q4", "clock": "01:15",
|
||||
"down_distance_text": "2nd & 5",
|
||||
"possession": "UGA", # Placeholder ID for home team
|
||||
"possession_indicator": "home", # Explicitly set for test
|
||||
"home_timeouts": 1, "away_timeouts": 2,
|
||||
"home_logo_path": Path(self.logo_dir, "UGA.png"),
|
||||
"away_logo_path": Path(self.logo_dir, "AUB.png"),
|
||||
"is_live": True, "is_final": False, "is_upcoming": False, "is_halftime": False,
|
||||
"status_text": "Q4 01:15"
|
||||
}
|
||||
self.live_games = [self.current_game]
|
||||
logging.info("Initialized NCAAFBLiveManager with test game: AUB vs UGA") # Updated log message
|
||||
else:
|
||||
logging.info("Initialized NCAAFBLiveManager in live mode") # Updated log message
|
||||
|
||||
class NCAAFBRecentManager(BaseNCAAFBManager, SportsRecent): # Renamed class
|
||||
"""Manager for recently completed NCAA FB games.""" # Updated docstring
|
||||
def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager):
|
||||
super().__init__(config, display_manager, cache_manager)
|
||||
self.logger = logging.getLogger('NCAAFBRecentManager') # Changed logger name
|
||||
self.logger.info(f"Initialized NCAAFBRecentManager with {len(self.favorite_teams)} favorite teams") # Changed log prefix
|
||||
|
||||
class NCAAFBUpcomingManager(BaseNCAAFBManager, SportsUpcoming): # Renamed class
|
||||
"""Manager for upcoming NCAA FB games.""" # Updated docstring
|
||||
def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager):
|
||||
super().__init__(config, display_manager, cache_manager)
|
||||
self.logger = logging.getLogger('NCAAFBUpcomingManager') # Changed logger name
|
||||
self.logger.info(f"Initialized NCAAFBUpcomingManager with {len(self.favorite_teams)} favorite teams") # Changed log prefix
|
||||
@@ -1,235 +0,0 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import pytz
|
||||
import requests
|
||||
|
||||
from src.base_classes.basketball import Basketball, BasketballLive
|
||||
from src.base_classes.sports import SportsRecent, SportsUpcoming
|
||||
from src.cache_manager import CacheManager
|
||||
from src.display_manager import DisplayManager
|
||||
|
||||
# Import the API counter function from web interface
|
||||
try:
|
||||
from web_interface_v2 import increment_api_counter
|
||||
except ImportError:
|
||||
# Fallback if web interface is not available
|
||||
def increment_api_counter(kind: str, count: int = 1):
|
||||
pass
|
||||
|
||||
|
||||
# Constants
|
||||
ESPN_NCAAMB_SCOREBOARD_URL = "https://site.api.espn.com/apis/site/v2/sports/basketball/mens-college-basketball/scoreboard"
|
||||
|
||||
|
||||
class BaseNCAAMBasketballManager(Basketball):
|
||||
"""Base class for NCAA MB managers with common functionality."""
|
||||
|
||||
# Class variables for warning tracking
|
||||
_no_data_warning_logged = False
|
||||
_last_warning_time = 0
|
||||
_warning_cooldown = 60 # Only log warnings once per minute
|
||||
_last_log_times = {}
|
||||
_shared_data = None
|
||||
_last_shared_update = 0
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: Dict[str, Any],
|
||||
display_manager: DisplayManager,
|
||||
cache_manager: CacheManager,
|
||||
):
|
||||
self.logger = logging.getLogger("NCAAMB") # Changed logger name
|
||||
super().__init__(
|
||||
config=config,
|
||||
display_manager=display_manager,
|
||||
cache_manager=cache_manager,
|
||||
logger=self.logger,
|
||||
sport_key="ncaam_basketball",
|
||||
)
|
||||
|
||||
# Check display modes to determine what data to fetch
|
||||
display_modes = self.mode_config.get("display_modes", {})
|
||||
self.recent_enabled = display_modes.get("ncaam_basketball_recent", False)
|
||||
self.upcoming_enabled = display_modes.get("ncaam_basketball_upcoming", False)
|
||||
self.live_enabled = display_modes.get("ncaam_basketball_live", False)
|
||||
|
||||
self.logger.info(
|
||||
f"Initialized NCAA Mens Basketball manager with display dimensions: {self.display_width}x{self.display_height}"
|
||||
)
|
||||
self.logger.info(f"Logo directory: {self.logo_dir}")
|
||||
self.logger.info(
|
||||
f"Display modes - Recent: {self.recent_enabled}, Upcoming: {self.upcoming_enabled}, Live: {self.live_enabled}"
|
||||
)
|
||||
self.league = "mens-college-basketball"
|
||||
|
||||
def _fetch_ncaam_basketball_api_data(
|
||||
self, use_cache: bool = True
|
||||
) -> Optional[Dict]:
|
||||
"""
|
||||
Fetches the full season schedule for NCAA Mens Basketball using background threading.
|
||||
Returns cached data immediately if available, otherwise starts background fetch.
|
||||
"""
|
||||
now = datetime.now(pytz.utc)
|
||||
season_year = now.year
|
||||
cache_key = f"{self.sport_key}_schedule_{season_year}"
|
||||
|
||||
# Check cache first
|
||||
if use_cache:
|
||||
cached_data = self.cache_manager.get(cache_key)
|
||||
if cached_data:
|
||||
# Validate cached data structure
|
||||
if isinstance(cached_data, dict) and "events" in cached_data:
|
||||
self.logger.info(f"Using cached schedule for {season_year}")
|
||||
return cached_data
|
||||
elif isinstance(cached_data, list):
|
||||
# Handle old cache format (list of events)
|
||||
self.logger.info(
|
||||
f"Using cached schedule for {season_year} (legacy format)"
|
||||
)
|
||||
return {"events": cached_data}
|
||||
else:
|
||||
self.logger.warning(
|
||||
f"Invalid cached data format for {season_year}: {type(cached_data)}"
|
||||
)
|
||||
# Clear invalid cache
|
||||
self.cache_manager.clear_cache(cache_key)
|
||||
|
||||
# Start background fetch
|
||||
self.logger.info(
|
||||
f"Starting background fetch for {season_year} season schedule..."
|
||||
)
|
||||
|
||||
def fetch_callback(result):
|
||||
"""Callback when background fetch completes."""
|
||||
if result.success:
|
||||
self.logger.info(
|
||||
f"Background fetch completed for {season_year}: {len(result.data.get('events'))} events"
|
||||
)
|
||||
else:
|
||||
self.logger.error(
|
||||
f"Background fetch failed for {season_year}: {result.error}"
|
||||
)
|
||||
|
||||
# Clean up request tracking
|
||||
if season_year in self.background_fetch_requests:
|
||||
del self.background_fetch_requests[season_year]
|
||||
|
||||
# Get background service configuration
|
||||
background_config = self.mode_config.get("background_service", {})
|
||||
timeout = background_config.get("request_timeout", 30)
|
||||
max_retries = background_config.get("max_retries", 3)
|
||||
priority = background_config.get("priority", 2)
|
||||
|
||||
# Submit background fetch request
|
||||
request_id = self.background_service.submit_fetch_request(
|
||||
sport="ncaa_mens_basketball",
|
||||
year=season_year,
|
||||
url=ESPN_NCAAMB_SCOREBOARD_URL,
|
||||
cache_key=cache_key,
|
||||
params={"dates": season_year, "limit": 1000},
|
||||
headers=self.headers,
|
||||
timeout=timeout,
|
||||
max_retries=max_retries,
|
||||
priority=priority,
|
||||
callback=fetch_callback,
|
||||
)
|
||||
|
||||
# Track the request
|
||||
self.background_fetch_requests[season_year] = request_id
|
||||
|
||||
# For immediate response, try to get partial data
|
||||
partial_data = self._get_weeks_data()
|
||||
if partial_data:
|
||||
return partial_data
|
||||
|
||||
return None
|
||||
|
||||
def _fetch_data(self) -> Optional[Dict]:
|
||||
"""Fetch data using shared data mechanism or direct fetch for live."""
|
||||
if isinstance(self, NCAAMBasketballLiveManager):
|
||||
# Live games should fetch only current games, not entire season
|
||||
return self._fetch_todays_games()
|
||||
else:
|
||||
# Recent and Upcoming managers should use cached season data
|
||||
return self._fetch_ncaam_basketball_api_data(use_cache=True)
|
||||
|
||||
|
||||
class NCAAMBasketballLiveManager(BaseNCAAMBasketballManager, BasketballLive):
|
||||
"""Manager for live NCAA MB games."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: Dict[str, Any],
|
||||
display_manager: DisplayManager,
|
||||
cache_manager: CacheManager,
|
||||
):
|
||||
super().__init__(config, display_manager, cache_manager)
|
||||
self.logger = logging.getLogger(
|
||||
"NCAAMBasketballLiveManager"
|
||||
) # Changed logger name
|
||||
|
||||
if self.test_mode:
|
||||
# More detailed test game for NCAA MB
|
||||
self.current_game = {
|
||||
"id": "test001",
|
||||
"home_abbr": "AUB",
|
||||
"home_id": "123",
|
||||
"away_abbr": "GT",
|
||||
"away_id": "asdf",
|
||||
"home_score": "21",
|
||||
"away_score": "17",
|
||||
"period": 3,
|
||||
"period_text": "Q3",
|
||||
"clock": "5:24",
|
||||
"home_logo_path": Path(self.logo_dir, "AUB.png"),
|
||||
"away_logo_path": Path(self.logo_dir, "GT.png"),
|
||||
"is_live": True,
|
||||
"is_final": False,
|
||||
"is_upcoming": False,
|
||||
"is_halftime": False,
|
||||
}
|
||||
self.live_games = [self.current_game]
|
||||
self.logger.info(
|
||||
"Initialized NCAAMBasketballLiveManager with test game: GT vs AUB"
|
||||
)
|
||||
else:
|
||||
self.logger.info(" Initialized NCAAMBasketballLiveManager in live mode")
|
||||
|
||||
|
||||
class NCAAMBasketballRecentManager(BaseNCAAMBasketballManager, SportsRecent):
|
||||
"""Manager for recently completed NCAA MB games."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: Dict[str, Any],
|
||||
display_manager: DisplayManager,
|
||||
cache_manager: CacheManager,
|
||||
):
|
||||
super().__init__(config, display_manager, cache_manager)
|
||||
self.logger = logging.getLogger(
|
||||
"NCAAMBasketballRecentManager"
|
||||
) # Changed logger name
|
||||
self.logger.info(
|
||||
f"Initialized NCAAMBasketballRecentManager with {len(self.favorite_teams)} favorite teams"
|
||||
)
|
||||
|
||||
|
||||
class NCAAMBasketballUpcomingManager(BaseNCAAMBasketballManager, SportsUpcoming):
|
||||
"""Manager for upcoming NCAA MB games."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: Dict[str, Any],
|
||||
display_manager: DisplayManager,
|
||||
cache_manager: CacheManager,
|
||||
):
|
||||
super().__init__(config, display_manager, cache_manager)
|
||||
self.logger = logging.getLogger(
|
||||
"NCAAMBasketballUpcomingManager"
|
||||
) # Changed logger name
|
||||
self.logger.info(
|
||||
f"Initialized NCAAMBasketballUpcomingManager with {len(self.favorite_teams)} favorite teams"
|
||||
)
|
||||
@@ -1,175 +0,0 @@
|
||||
import os
|
||||
import time
|
||||
import logging
|
||||
import requests
|
||||
import json
|
||||
from typing import Dict, Any, Optional
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
from datetime import datetime, timezone
|
||||
from src.display_manager import DisplayManager
|
||||
from src.cache_manager import CacheManager # Keep CacheManager import
|
||||
from src.odds_manager import OddsManager
|
||||
from src.logo_downloader import download_missing_logo
|
||||
import pytz
|
||||
from requests.adapters import HTTPAdapter
|
||||
from urllib3.util.retry import Retry
|
||||
from src.base_classes.sports import SportsRecent, SportsUpcoming
|
||||
from src.base_classes.hockey import Hockey, HockeyLive
|
||||
from pathlib import Path
|
||||
# Constants
|
||||
ESPN_NCAAMH_SCOREBOARD_URL = "https://site.api.espn.com/apis/site/v2/sports/hockey/mens-college-hockey/scoreboard"
|
||||
|
||||
class BaseNCAAMHockeyManager(Hockey): # Renamed class
|
||||
"""Base class for NCAA Mens Hockey managers with common functionality.""" # Updated docstring
|
||||
# Class variables for warning tracking
|
||||
_no_data_warning_logged = False
|
||||
_last_warning_time = 0
|
||||
_warning_cooldown = 60 # Only log warnings once per minute
|
||||
_shared_data = None
|
||||
_last_shared_update = 0
|
||||
_processed_games_cache = {} # Cache for processed game data
|
||||
_processed_games_timestamp = 0
|
||||
|
||||
def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager):
|
||||
self.logger = logging.getLogger('NCAAMH') # Changed logger name
|
||||
super().__init__(config=config, display_manager=display_manager, cache_manager=cache_manager, logger=self.logger, sport_key="ncaam_hockey")
|
||||
|
||||
# Configuration is already set in base class
|
||||
# self.logo_dir and self.update_interval are already configured
|
||||
|
||||
# Check display modes to determine what data to fetch
|
||||
display_modes = self.mode_config.get("display_modes", {})
|
||||
self.recent_enabled = display_modes.get("ncaam_hockey_recent", False)
|
||||
self.upcoming_enabled = display_modes.get("ncaam_hockey_upcoming", False)
|
||||
self.live_enabled = display_modes.get("ncaam_hockey_live", False)
|
||||
self.league = "mens-college-hockey"
|
||||
|
||||
self.logger.info(f"Initialized NCAAMHockey manager with display dimensions: {self.display_width}x{self.display_height}")
|
||||
self.logger.info(f"Logo directory: {self.logo_dir}")
|
||||
self.logger.info(f"Display modes - Recent: {self.recent_enabled}, Upcoming: {self.upcoming_enabled}, Live: {self.live_enabled}")
|
||||
|
||||
|
||||
def _fetch_ncaa_hockey_api_data(self, use_cache: bool = True) -> Optional[Dict]:
|
||||
"""
|
||||
Fetches the full season schedule for NCAAMH, caches it, and then filters
|
||||
for relevant games based on the current configuration.
|
||||
"""
|
||||
now = datetime.now(pytz.utc)
|
||||
season_year = now.year
|
||||
if now.month < 8:
|
||||
season_year = now.year - 1
|
||||
datestring = f"{season_year}0901-{season_year+1}0501"
|
||||
cache_key = f"ncaa_mens_hockey_schedule_{season_year}"
|
||||
|
||||
if use_cache:
|
||||
cached_data = self.cache_manager.get(cache_key)
|
||||
if cached_data:
|
||||
# Validate cached data structure
|
||||
if isinstance(cached_data, dict) and 'events' in cached_data:
|
||||
self.logger.info(f"Using cached schedule for {season_year}")
|
||||
return cached_data
|
||||
elif isinstance(cached_data, list):
|
||||
# Handle old cache format (list of events)
|
||||
self.logger.info(f"Using cached schedule for {season_year} (legacy format)")
|
||||
return {'events': cached_data}
|
||||
else:
|
||||
self.logger.warning(f"Invalid cached data format for {season_year}: {type(cached_data)}")
|
||||
# Clear invalid cache
|
||||
self.cache_manager.clear_cache(cache_key)
|
||||
|
||||
self.logger.info(f"Fetching full {season_year} season schedule from ESPN API...")
|
||||
|
||||
# Start background fetch
|
||||
self.logger.info(f"Starting background fetch for {season_year} season schedule...")
|
||||
|
||||
def fetch_callback(result):
|
||||
"""Callback when background fetch completes."""
|
||||
if result.success:
|
||||
self.logger.info(f"Background fetch completed for {season_year}: {len(result.data.get('events'))} events")
|
||||
else:
|
||||
self.logger.error(f"Background fetch failed for {season_year}: {result.error}")
|
||||
|
||||
# Clean up request tracking
|
||||
if season_year in self.background_fetch_requests:
|
||||
del self.background_fetch_requests[season_year]
|
||||
|
||||
# Get background service configuration
|
||||
background_config = self.mode_config.get("background_service", {})
|
||||
timeout = background_config.get("request_timeout", 30)
|
||||
max_retries = background_config.get("max_retries", 3)
|
||||
priority = background_config.get("priority", 2)
|
||||
|
||||
# Submit background fetch request
|
||||
request_id = self.background_service.submit_fetch_request(
|
||||
sport="ncaa_mens_hockey",
|
||||
year=season_year,
|
||||
url=ESPN_NCAAMH_SCOREBOARD_URL,
|
||||
cache_key=cache_key,
|
||||
params={"dates": datestring, "limit": 1000},
|
||||
headers=self.headers,
|
||||
timeout=timeout,
|
||||
max_retries=max_retries,
|
||||
priority=priority,
|
||||
callback=fetch_callback
|
||||
)
|
||||
|
||||
# Track the request
|
||||
self.background_fetch_requests[season_year] = request_id
|
||||
|
||||
# For immediate response, try to get partial data
|
||||
partial_data = self._get_weeks_data()
|
||||
if partial_data:
|
||||
return partial_data
|
||||
return None
|
||||
|
||||
def _fetch_data(self) -> Optional[Dict]:
|
||||
"""Fetch data using shared data mechanism or direct fetch for live."""
|
||||
if isinstance(self, NCAAMHockeyLiveManager):
|
||||
return self._fetch_todays_games()
|
||||
else:
|
||||
return self._fetch_ncaa_hockey_api_data(use_cache=True)
|
||||
|
||||
class NCAAMHockeyLiveManager(BaseNCAAMHockeyManager, HockeyLive): # Renamed class
|
||||
"""Manager for live NCAA Mens Hockey games."""
|
||||
def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager):
|
||||
super().__init__(config, display_manager, cache_manager)
|
||||
self.logger = logging.getLogger('NCAAMHockeyLiveManager') # Changed logger name
|
||||
|
||||
# Initialize with test game only if test mode is enabled
|
||||
if self.test_mode:
|
||||
self.current_game = {
|
||||
"id": "401596361",
|
||||
"home_abbr": "RIT",
|
||||
"away_abbr": "CLAR ",
|
||||
"home_score": "3",
|
||||
"away_score": "2",
|
||||
"period": 2,
|
||||
"period_text": "1st",
|
||||
"home_id": "178",
|
||||
"away_id": "2137",
|
||||
"clock": "12:34",
|
||||
"home_logo_path": Path(self.logo_dir, "RIT.png"),
|
||||
"away_logo_path": Path(self.logo_dir, "CLAR .png"),
|
||||
"game_time": "7:30 PM",
|
||||
"game_date": "Apr 17",
|
||||
"is_live": True, "is_final": False, "is_upcoming": False,
|
||||
}
|
||||
self.live_games = [self.current_game]
|
||||
self.logger.info("Initialized NCAAMHockeyLiveManager with test game: RIT vs CLAR ")
|
||||
else:
|
||||
self.logger.info("Initialized NCAAMHockeyLiveManager in live mode")
|
||||
|
||||
class NCAAMHockeyRecentManager(BaseNCAAMHockeyManager, SportsRecent):
|
||||
"""Manager for recently completed NCAAMH games."""
|
||||
def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager):
|
||||
super().__init__(config, display_manager, cache_manager)
|
||||
self.logger = logging.getLogger('NCAAMHockeyRecentManager') # Changed logger name
|
||||
self.logger.info(f"Initialized NCAAMHRecentManager with {len(self.favorite_teams)} favorite teams")
|
||||
|
||||
class NCAAMHockeyUpcomingManager(BaseNCAAMHockeyManager, SportsUpcoming):
|
||||
"""Manager for upcoming NCAA Mens Hockey games."""
|
||||
def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager):
|
||||
super().__init__(config, display_manager, cache_manager)
|
||||
self.logger = logging.getLogger('NCAAMHockeyUpcomingManager') # Changed logger name
|
||||
self.logger.info(f"Initialized NCAAMHUpcomingManager with {len(self.favorite_teams)} favorite teams")
|
||||
|
||||
@@ -1,235 +0,0 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import pytz
|
||||
import requests
|
||||
|
||||
from src.base_classes.basketball import Basketball, BasketballLive
|
||||
from src.base_classes.sports import SportsRecent, SportsUpcoming
|
||||
from src.cache_manager import CacheManager
|
||||
from src.display_manager import DisplayManager
|
||||
|
||||
# Import the API counter function from web interface
|
||||
try:
|
||||
from web_interface_v2 import increment_api_counter
|
||||
except ImportError:
|
||||
# Fallback if web interface is not available
|
||||
def increment_api_counter(kind: str, count: int = 1):
|
||||
pass
|
||||
|
||||
|
||||
# Constants
|
||||
ESPN_NCAAWB_SCOREBOARD_URL = "https://site.api.espn.com/apis/site/v2/sports/basketball/womens-college-basketball/scoreboard"
|
||||
|
||||
|
||||
class BaseNCAAWBasketballManager(Basketball):
|
||||
"""Base class for NCAA WB managers with common functionality."""
|
||||
|
||||
# Class variables for warning tracking
|
||||
_no_data_warning_logged = False
|
||||
_last_warning_time = 0
|
||||
_warning_cooldown = 60 # Only log warnings once per minute
|
||||
_last_log_times = {}
|
||||
_shared_data = None
|
||||
_last_shared_update = 0
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: Dict[str, Any],
|
||||
display_manager: DisplayManager,
|
||||
cache_manager: CacheManager,
|
||||
):
|
||||
self.logger = logging.getLogger("NCAAWB") # Changed logger name
|
||||
super().__init__(
|
||||
config=config,
|
||||
display_manager=display_manager,
|
||||
cache_manager=cache_manager,
|
||||
logger=self.logger,
|
||||
sport_key="ncaaw_basketball",
|
||||
)
|
||||
|
||||
# Check display modes to determine what data to fetch
|
||||
display_modes = self.mode_config.get("display_modes", {})
|
||||
self.recent_enabled = display_modes.get("ncaaw_basketball_recent", False)
|
||||
self.upcoming_enabled = display_modes.get("ncaaw_basketball_upcoming", False)
|
||||
self.live_enabled = display_modes.get("ncaaw_basketball_live", False)
|
||||
|
||||
self.logger.info(
|
||||
f"Initialized NCAA Womens Basketball manager with display dimensions: {self.display_width}x{self.display_height}"
|
||||
)
|
||||
self.logger.info(f"Logo directory: {self.logo_dir}")
|
||||
self.logger.info(
|
||||
f"Display modes - Recent: {self.recent_enabled}, Upcoming: {self.upcoming_enabled}, Live: {self.live_enabled}"
|
||||
)
|
||||
self.league = "womens-college-basketball"
|
||||
|
||||
def _fetch_ncaaw_basketball_api_data(
|
||||
self, use_cache: bool = True
|
||||
) -> Optional[Dict]:
|
||||
"""
|
||||
Fetches the full season schedule for NCAA Womens Basketball using background threading.
|
||||
Returns cached data immediately if available, otherwise starts background fetch.
|
||||
"""
|
||||
now = datetime.now(pytz.utc)
|
||||
season_year = now.year
|
||||
cache_key = f"{self.sport_key}_schedule_{season_year}"
|
||||
|
||||
# Check cache first
|
||||
if use_cache:
|
||||
cached_data = self.cache_manager.get(cache_key)
|
||||
if cached_data:
|
||||
# Validate cached data structure
|
||||
if isinstance(cached_data, dict) and "events" in cached_data:
|
||||
self.logger.info(f"Using cached schedule for {season_year}")
|
||||
return cached_data
|
||||
elif isinstance(cached_data, list):
|
||||
# Handle old cache format (list of events)
|
||||
self.logger.info(
|
||||
f"Using cached schedule for {season_year} (legacy format)"
|
||||
)
|
||||
return {"events": cached_data}
|
||||
else:
|
||||
self.logger.warning(
|
||||
f"Invalid cached data format for {season_year}: {type(cached_data)}"
|
||||
)
|
||||
# Clear invalid cache
|
||||
self.cache_manager.clear_cache(cache_key)
|
||||
|
||||
# Start background fetch
|
||||
self.logger.info(
|
||||
f"Starting background fetch for {season_year} season schedule..."
|
||||
)
|
||||
|
||||
def fetch_callback(result):
|
||||
"""Callback when background fetch completes."""
|
||||
if result.success:
|
||||
self.logger.info(
|
||||
f"Background fetch completed for {season_year}: {len(result.data.get('events'))} events"
|
||||
)
|
||||
else:
|
||||
self.logger.error(
|
||||
f"Background fetch failed for {season_year}: {result.error}"
|
||||
)
|
||||
|
||||
# Clean up request tracking
|
||||
if season_year in self.background_fetch_requests:
|
||||
del self.background_fetch_requests[season_year]
|
||||
|
||||
# Get background service configuration
|
||||
background_config = self.mode_config.get("background_service", {})
|
||||
timeout = background_config.get("request_timeout", 30)
|
||||
max_retries = background_config.get("max_retries", 3)
|
||||
priority = background_config.get("priority", 2)
|
||||
|
||||
# Submit background fetch request
|
||||
request_id = self.background_service.submit_fetch_request(
|
||||
sport="ncaa_womens_basketball",
|
||||
year=season_year,
|
||||
url=ESPN_NCAAWB_SCOREBOARD_URL,
|
||||
cache_key=cache_key,
|
||||
params={"dates": season_year, "limit": 1000},
|
||||
headers=self.headers,
|
||||
timeout=timeout,
|
||||
max_retries=max_retries,
|
||||
priority=priority,
|
||||
callback=fetch_callback,
|
||||
)
|
||||
|
||||
# Track the request
|
||||
self.background_fetch_requests[season_year] = request_id
|
||||
|
||||
# For immediate response, try to get partial data
|
||||
partial_data = self._get_weeks_data()
|
||||
if partial_data:
|
||||
return partial_data
|
||||
|
||||
return None
|
||||
|
||||
def _fetch_data(self) -> Optional[Dict]:
|
||||
"""Fetch data using shared data mechanism or direct fetch for live."""
|
||||
if isinstance(self, NCAAWBasketballLiveManager):
|
||||
# Live games should fetch only current games, not entire season
|
||||
return self._fetch_todays_games()
|
||||
else:
|
||||
# Recent and Upcoming managers should use cached season data
|
||||
return self._fetch_ncaaw_basketball_api_data(use_cache=True)
|
||||
|
||||
|
||||
class NCAAWBasketballLiveManager(BaseNCAAWBasketballManager, BasketballLive):
|
||||
"""Manager for live NCAA WB games."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: Dict[str, Any],
|
||||
display_manager: DisplayManager,
|
||||
cache_manager: CacheManager,
|
||||
):
|
||||
super().__init__(config, display_manager, cache_manager)
|
||||
self.logger = logging.getLogger(
|
||||
"NCAAWBasketballLiveManager"
|
||||
) # Changed logger name
|
||||
|
||||
if self.test_mode:
|
||||
# More detailed test game for NCAA WB
|
||||
self.current_game = {
|
||||
"id": "test001",
|
||||
"home_abbr": "AUB",
|
||||
"home_id": "123",
|
||||
"away_abbr": "GT",
|
||||
"away_id": "asdf",
|
||||
"home_score": "21",
|
||||
"away_score": "17",
|
||||
"period": 3,
|
||||
"period_text": "Q3",
|
||||
"clock": "5:24",
|
||||
"home_logo_path": Path(self.logo_dir, "AUB.png"),
|
||||
"away_logo_path": Path(self.logo_dir, "GT.png"),
|
||||
"is_live": True,
|
||||
"is_final": False,
|
||||
"is_upcoming": False,
|
||||
"is_halftime": False,
|
||||
}
|
||||
self.live_games = [self.current_game]
|
||||
self.logger.info(
|
||||
"Initialized NCAAWBasketballLiveManager with test game: GT vs AUB"
|
||||
)
|
||||
else:
|
||||
self.logger.info(" Initialized NCAAWBasketballLiveManager in live mode")
|
||||
|
||||
|
||||
class NCAAWBasketballRecentManager(BaseNCAAWBasketballManager, SportsRecent):
|
||||
"""Manager for recently completed NCAA WB games."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: Dict[str, Any],
|
||||
display_manager: DisplayManager,
|
||||
cache_manager: CacheManager,
|
||||
):
|
||||
super().__init__(config, display_manager, cache_manager)
|
||||
self.logger = logging.getLogger(
|
||||
"NCAAWBasketballRecentManager"
|
||||
) # Changed logger name
|
||||
self.logger.info(
|
||||
f"Initialized NCAAWBasketballRecentManager with {len(self.favorite_teams)} favorite teams"
|
||||
)
|
||||
|
||||
|
||||
class NCAAWBasketballUpcomingManager(BaseNCAAWBasketballManager, SportsUpcoming):
|
||||
"""Manager for upcoming NCAA WB games."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: Dict[str, Any],
|
||||
display_manager: DisplayManager,
|
||||
cache_manager: CacheManager,
|
||||
):
|
||||
super().__init__(config, display_manager, cache_manager)
|
||||
self.logger = logging.getLogger(
|
||||
"NCAAWBasketballUpcomingManager"
|
||||
) # Changed logger name
|
||||
self.logger.info(
|
||||
f"Initialized NCAAWBasketballUpcomingManager with {len(self.favorite_teams)} favorite teams"
|
||||
)
|
||||
@@ -1,230 +0,0 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import pytz
|
||||
import requests
|
||||
|
||||
from src.base_classes.hockey import Hockey, HockeyLive
|
||||
from src.base_classes.sports import SportsRecent, SportsUpcoming
|
||||
from src.cache_manager import CacheManager # Keep CacheManager import
|
||||
from src.display_manager import DisplayManager
|
||||
|
||||
# Constants
|
||||
ESPN_NCAAWH_SCOREBOARD_URL = "https://site.api.espn.com/apis/site/v2/sports/hockey/womens-college-hockey/scoreboard"
|
||||
|
||||
|
||||
class BaseNCAAWHockeyManager(Hockey): # Renamed class
|
||||
"""Base class for NCAA Womens Hockey managers with common functionality.""" # Updated docstring
|
||||
|
||||
# Class variables for warning tracking
|
||||
_no_data_warning_logged = False
|
||||
_last_warning_time = 0
|
||||
_warning_cooldown = 60 # Only log warnings once per minute
|
||||
_shared_data = None
|
||||
_last_shared_update = 0
|
||||
_processed_games_cache = {} # Cache for processed game data
|
||||
_processed_games_timestamp = 0
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: Dict[str, Any],
|
||||
display_manager: DisplayManager,
|
||||
cache_manager: CacheManager,
|
||||
):
|
||||
self.logger = logging.getLogger("NCAAWH") # Changed logger name
|
||||
super().__init__(
|
||||
config=config,
|
||||
display_manager=display_manager,
|
||||
cache_manager=cache_manager,
|
||||
logger=self.logger,
|
||||
sport_key="ncaaw_hockey",
|
||||
)
|
||||
|
||||
# Configuration is already set in base class
|
||||
# self.logo_dir and self.update_interval are already configured
|
||||
|
||||
# Check display modes to determine what data to fetch
|
||||
display_modes = self.mode_config.get("display_modes", {})
|
||||
self.recent_enabled = display_modes.get("ncaaw_hockey_recent", False)
|
||||
self.upcoming_enabled = display_modes.get("ncaaw_hockey_upcoming", False)
|
||||
self.live_enabled = display_modes.get("ncaaw_hockey_live", False)
|
||||
self.league = "womens-college-hockey"
|
||||
|
||||
self.logger.info(
|
||||
f"Initialized NCAAWHockey manager with display dimensions: {self.display_width}x{self.display_height}"
|
||||
)
|
||||
self.logger.info(f"Logo directory: {self.logo_dir}")
|
||||
self.logger.info(
|
||||
f"Display modes - Recent: {self.recent_enabled}, Upcoming: {self.upcoming_enabled}, Live: {self.live_enabled}"
|
||||
)
|
||||
|
||||
def _fetch_ncaa_hockey_api_data(self, use_cache: bool = True) -> Optional[Dict]:
|
||||
"""
|
||||
Fetches the full season schedule for NCAAWH, caches it, and then filters
|
||||
for relevant games based on the current configuration.
|
||||
"""
|
||||
now = datetime.now(pytz.utc)
|
||||
season_year = now.year
|
||||
if now.month < 8:
|
||||
season_year = now.year - 1
|
||||
datestring = f"{season_year}0901-{season_year+1}0501"
|
||||
cache_key = f"ncaa_womens_hockey_schedule_{season_year}"
|
||||
|
||||
if use_cache:
|
||||
cached_data = self.cache_manager.get(cache_key)
|
||||
if cached_data:
|
||||
# Validate cached data structure
|
||||
if isinstance(cached_data, dict) and "events" in cached_data:
|
||||
self.logger.info(f"Using cached schedule for {season_year}")
|
||||
return cached_data
|
||||
elif isinstance(cached_data, list):
|
||||
# Handle old cache format (list of events)
|
||||
self.logger.info(
|
||||
f"Using cached schedule for {season_year} (legacy format)"
|
||||
)
|
||||
return {"events": cached_data}
|
||||
else:
|
||||
self.logger.warning(
|
||||
f"Invalid cached data format for {season_year}: {type(cached_data)}"
|
||||
)
|
||||
# Clear invalid cache
|
||||
self.cache_manager.clear_cache(cache_key)
|
||||
|
||||
self.logger.info(
|
||||
f"Fetching full {season_year} season schedule from ESPN API..."
|
||||
)
|
||||
|
||||
# Start background fetch
|
||||
self.logger.info(
|
||||
f"Starting background fetch for {season_year} season schedule..."
|
||||
)
|
||||
|
||||
def fetch_callback(result):
|
||||
"""Callback when background fetch completes."""
|
||||
if result.success:
|
||||
self.logger.info(
|
||||
f"Background fetch completed for {season_year}: {len(result.data.get('events'))} events"
|
||||
)
|
||||
else:
|
||||
self.logger.error(
|
||||
f"Background fetch failed for {season_year}: {result.error}"
|
||||
)
|
||||
|
||||
# Clean up request tracking
|
||||
if season_year in self.background_fetch_requests:
|
||||
del self.background_fetch_requests[season_year]
|
||||
|
||||
# Get background service configuration
|
||||
background_config = self.mode_config.get("background_service", {})
|
||||
timeout = background_config.get("request_timeout", 30)
|
||||
max_retries = background_config.get("max_retries", 3)
|
||||
priority = background_config.get("priority", 2)
|
||||
|
||||
# Submit background fetch request
|
||||
request_id = self.background_service.submit_fetch_request(
|
||||
sport="ncaa_womens_hockey",
|
||||
year=season_year,
|
||||
url=ESPN_NCAAWH_SCOREBOARD_URL,
|
||||
cache_key=cache_key,
|
||||
params={"dates": datestring, "limit": 1000},
|
||||
headers=self.headers,
|
||||
timeout=timeout,
|
||||
max_retries=max_retries,
|
||||
priority=priority,
|
||||
callback=fetch_callback,
|
||||
)
|
||||
|
||||
# Track the request
|
||||
self.background_fetch_requests[season_year] = request_id
|
||||
|
||||
# For immediate response, try to get partial data
|
||||
partial_data = self._get_weeks_data()
|
||||
if partial_data:
|
||||
return partial_data
|
||||
return None
|
||||
|
||||
def _fetch_data(self) -> Optional[Dict]:
|
||||
"""Fetch data using shared data mechanism or direct fetch for live."""
|
||||
if isinstance(self, NCAAWHockeyLiveManager):
|
||||
return self._fetch_todays_games()
|
||||
else:
|
||||
return self._fetch_ncaa_hockey_api_data(use_cache=True)
|
||||
|
||||
|
||||
class NCAAWHockeyLiveManager(BaseNCAAWHockeyManager, HockeyLive): # Renamed class
|
||||
"""Manager for live NCAA Mens Hockey games."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: Dict[str, Any],
|
||||
display_manager: DisplayManager,
|
||||
cache_manager: CacheManager,
|
||||
):
|
||||
super().__init__(config, display_manager, cache_manager)
|
||||
self.logger = logging.getLogger("NCAAWHockeyLiveManager") # Changed logger name
|
||||
|
||||
# Initialize with test game only if test mode is enabled
|
||||
if self.test_mode:
|
||||
self.current_game = {
|
||||
"id": "401596361",
|
||||
"home_abbr": "RIT",
|
||||
"away_abbr": "CLAR ",
|
||||
"home_score": "3",
|
||||
"away_score": "2",
|
||||
"period": 2,
|
||||
"period_text": "1st",
|
||||
"home_id": "178",
|
||||
"away_id": "2137",
|
||||
"clock": "12:34",
|
||||
"home_logo_path": Path(self.logo_dir, "RIT.png"),
|
||||
"away_logo_path": Path(self.logo_dir, "CLAR .png"),
|
||||
"game_time": "7:30 PM",
|
||||
"game_date": "Apr 17",
|
||||
"is_live": True,
|
||||
"is_final": False,
|
||||
"is_upcoming": False,
|
||||
}
|
||||
self.live_games = [self.current_game]
|
||||
self.logger.info(
|
||||
"Initialized NCAAWHockeyLiveManager with test game: RIT vs CLAR "
|
||||
)
|
||||
else:
|
||||
self.logger.info("Initialized NCAAWHockeyLiveManager in live mode")
|
||||
|
||||
|
||||
class NCAAWHockeyRecentManager(BaseNCAAWHockeyManager, SportsRecent):
|
||||
"""Manager for recently completed NCAAWH games."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: Dict[str, Any],
|
||||
display_manager: DisplayManager,
|
||||
cache_manager: CacheManager,
|
||||
):
|
||||
super().__init__(config, display_manager, cache_manager)
|
||||
self.logger = logging.getLogger(
|
||||
"NCAAWHockeyRecentManager"
|
||||
) # Changed logger name
|
||||
self.logger.info(
|
||||
f"Initialized NCAAWHockeyRecentManager with {len(self.favorite_teams)} favorite teams"
|
||||
)
|
||||
|
||||
|
||||
class NCAAWHockeyUpcomingManager(BaseNCAAWHockeyManager, SportsUpcoming):
|
||||
"""Manager for upcoming NCAA Womens Hockey games."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: Dict[str, Any],
|
||||
display_manager: DisplayManager,
|
||||
cache_manager: CacheManager,
|
||||
):
|
||||
super().__init__(config, display_manager, cache_manager)
|
||||
self.logger = logging.getLogger(
|
||||
"NCAAWHockeyUpcomingManager"
|
||||
) # Changed logger name
|
||||
self.logger.info(
|
||||
f"Initialized NCAAWHockeyUpcomingManager with {len(self.favorite_teams)} favorite teams"
|
||||
)
|
||||
@@ -1,576 +0,0 @@
|
||||
import html
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import requests
|
||||
import time
|
||||
import xml.etree.ElementTree as ET
|
||||
from typing import Dict, Any, List
|
||||
from datetime import datetime
|
||||
from src.image_utils import scale_to_max_dimensions
|
||||
from src.config_manager import ConfigManager
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
from src.cache_manager import CacheManager
|
||||
from requests.adapters import HTTPAdapter
|
||||
from urllib3.util.retry import Retry
|
||||
|
||||
# Import the API counter function from web interface
|
||||
try:
|
||||
from web_interface_v2 import increment_api_counter
|
||||
except ImportError:
|
||||
# Fallback if web interface is not available
|
||||
def increment_api_counter(kind: str, count: int = 1):
|
||||
pass
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class NewsManager:
|
||||
def __init__(self, config: Dict[str, Any], display_manager, config_manager=None):
|
||||
self.config = config
|
||||
# Use provided config_manager or create a new one if none provided
|
||||
self.config_manager = config_manager or ConfigManager()
|
||||
self.display_manager = display_manager
|
||||
self.news_config = config.get('news_manager', {})
|
||||
self.last_update = time.time() # Initialize to current time
|
||||
self.news_data = {}
|
||||
self.favicons = {}
|
||||
self.current_headline_index = 0
|
||||
self.scroll_position = 0
|
||||
self.scrolling_image = None # Pre-rendered image for smooth scrolling
|
||||
self.cached_images = []
|
||||
self.cache_manager = CacheManager()
|
||||
self.current_headlines = []
|
||||
self.headline_start_times = []
|
||||
self.total_scroll_width = 0
|
||||
self.headlines_displayed = set() # Track displayed headlines for rotation
|
||||
self.dynamic_duration = 60 # Default duration in seconds
|
||||
self.is_fetching = False # Flag to prevent multiple simultaneous fetches
|
||||
|
||||
# Default RSS feeds
|
||||
self.default_feeds = {
|
||||
'MLB': 'http://espn.com/espn/rss/mlb/news',
|
||||
'NFL': 'http://espn.go.com/espn/rss/nfl/news',
|
||||
'NCAA FB': 'https://www.espn.com/espn/rss/ncf/news',
|
||||
'NHL': 'https://www.espn.com/espn/rss/nhl/news',
|
||||
'NBA': 'https://www.espn.com/espn/rss/nba/news',
|
||||
'TOP SPORTS': 'https://www.espn.com/espn/rss/news',
|
||||
'BIG10': 'https://www.espn.com/blog/feed?blog=bigten',
|
||||
'NCAA': 'https://www.espn.com/espn/rss/ncaa/news',
|
||||
'Other': 'https://www.coveringthecorner.com/rss/current.xml'
|
||||
}
|
||||
|
||||
# Get scroll settings from config
|
||||
self.scroll_speed = self.news_config.get('scroll_speed', 2)
|
||||
self.scroll_delay = self.news_config.get('scroll_delay', 0.01) # Reduced from 0.02 to 0.01 for smoother scrolling
|
||||
self.update_interval = self.news_config.get('update_interval', 300) # 5 minutes
|
||||
|
||||
# Get headline settings from config
|
||||
self.headlines_per_feed = self.news_config.get('headlines_per_feed', 2)
|
||||
self.enabled_feeds = self.news_config.get('enabled_feeds', ['NFL', 'NCAA FB'])
|
||||
self.custom_feeds = self.news_config.get('custom_feeds', {})
|
||||
|
||||
# Rotation settings
|
||||
self.rotation_enabled = self.news_config.get('rotation_enabled', True)
|
||||
self.rotation_threshold = self.news_config.get('rotation_threshold', 3) # After 3 full cycles
|
||||
self.rotation_count = 0
|
||||
|
||||
# Dynamic duration settings
|
||||
self.dynamic_duration_enabled = self.news_config.get('dynamic_duration', True)
|
||||
self.min_duration = self.news_config.get('min_duration', 30)
|
||||
self.max_duration = self.news_config.get('max_duration', 300)
|
||||
self.duration_buffer = self.news_config.get('duration_buffer', 0.1)
|
||||
|
||||
# Font settings
|
||||
self.font_size = self.news_config.get('font_size', 12)
|
||||
self.font_path = self.news_config.get('font_path', '/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf')
|
||||
|
||||
# Colors
|
||||
self.text_color = tuple(self.news_config.get('text_color', [255, 255, 255]))
|
||||
self.separator_color = tuple(self.news_config.get('separator_color', [255, 0, 0]))
|
||||
|
||||
# Initialize session with retry strategy
|
||||
self.session = requests.Session()
|
||||
retry_strategy = Retry(
|
||||
total=3,
|
||||
backoff_factor=1,
|
||||
status_forcelist=[429, 500, 502, 503, 504],
|
||||
)
|
||||
adapter = HTTPAdapter(max_retries=retry_strategy)
|
||||
self.session.mount("http://", adapter)
|
||||
self.session.mount("https://", adapter)
|
||||
|
||||
try:
|
||||
self.font = ImageFont.truetype(self.font_path, self.font_size)
|
||||
logger.debug(f"Successfully loaded custom font: {self.font_path}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load custom font '{self.font_path}': {e}. Using default font.")
|
||||
self.font = ImageFont.load_default()
|
||||
|
||||
logger.debug(f"NewsManager initialized with feeds: {self.enabled_feeds}")
|
||||
logger.debug(f"Headlines per feed: {self.headlines_per_feed}")
|
||||
logger.debug(f"Scroll settings - Speed: {self.scroll_speed} pixels/frame, Delay: {self.scroll_delay*1000:.2f}ms")
|
||||
|
||||
def parse_rss_feed(self, url: str, feed_name: str) -> List[Dict[str, Any]]:
|
||||
"""Parse RSS feed and return list of headlines"""
|
||||
try:
|
||||
headers = {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
|
||||
}
|
||||
response = self.session.get(url, headers=headers, timeout=10)
|
||||
response.raise_for_status()
|
||||
|
||||
# Increment API counter for news data call
|
||||
increment_api_counter('news', 1)
|
||||
|
||||
root = ET.fromstring(response.content)
|
||||
headlines = []
|
||||
|
||||
# Handle different RSS formats
|
||||
items = root.findall('.//item')
|
||||
if not items:
|
||||
items = root.findall('.//entry') # Atom feed format
|
||||
|
||||
for item in items[:self.headlines_per_feed * 2]: # Get extra to allow for filtering
|
||||
title_elem = item.find('title')
|
||||
if title_elem is not None:
|
||||
title = html.unescape(title_elem.text or '').strip()
|
||||
|
||||
# Clean up title
|
||||
title = re.sub(r'<[^>]+>', '', title) # Remove HTML tags
|
||||
title = re.sub(r'\s+', ' ', title) # Normalize whitespace
|
||||
|
||||
if title and len(title) > 10: # Filter out very short titles
|
||||
pub_date_elem = item.find('pubDate')
|
||||
if pub_date_elem is None:
|
||||
pub_date_elem = item.find('published') # Atom format
|
||||
|
||||
pub_date = pub_date_elem.text if pub_date_elem is not None else None
|
||||
|
||||
headlines.append({
|
||||
'title': title,
|
||||
'feed': feed_name,
|
||||
'pub_date': pub_date,
|
||||
'timestamp': datetime.now().isoformat()
|
||||
})
|
||||
|
||||
logger.debug(f"Parsed {len(headlines)} headlines from {feed_name}")
|
||||
return headlines[:self.headlines_per_feed]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error parsing RSS feed {feed_name} ({url}): {e}")
|
||||
return []
|
||||
|
||||
def load_favicon(self, feed_name):
|
||||
try:
|
||||
img_path = os.path.join('assets', 'news_logos', f"{feed_name.lower()}.png")
|
||||
with Image.open(img_path) as img:
|
||||
img = scale_to_max_dimensions(img, 32, int(self.display_manager.height * 0.8)).convert('RGBA')
|
||||
self.favicons[feed_name] = img.copy()
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading favicon for {feed_name}: {e}")
|
||||
return
|
||||
|
||||
def fetch_news_data(self):
|
||||
"""Fetch news from all enabled feeds"""
|
||||
try:
|
||||
all_headlines = []
|
||||
|
||||
# Combine default and custom feeds
|
||||
all_feeds = {**self.default_feeds, **self.custom_feeds}
|
||||
|
||||
for feed_name in self.enabled_feeds:
|
||||
if feed_name in all_feeds:
|
||||
url = all_feeds[feed_name]
|
||||
headlines = self.parse_rss_feed(url, feed_name)
|
||||
all_headlines.extend(headlines)
|
||||
self.load_favicon(feed_name)
|
||||
else:
|
||||
logger.warning(f"Feed '{feed_name}' not found in available feeds")
|
||||
|
||||
# Store headlines by feed for rotation management
|
||||
self.news_data = {}
|
||||
for headline in all_headlines:
|
||||
feed = headline['feed']
|
||||
if feed not in self.news_data:
|
||||
self.news_data[feed] = []
|
||||
self.news_data[feed].append(headline)
|
||||
|
||||
# Prepare current headlines for display
|
||||
self.prepare_headlines_for_display()
|
||||
|
||||
self.last_update = time.time()
|
||||
logger.debug(f"Fetched {len(all_headlines)} total headlines from {len(self.enabled_feeds)} feeds")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching news data: {e}")
|
||||
|
||||
def prepare_headlines_for_display(self):
|
||||
"""Prepare headlines for scrolling display with rotation"""
|
||||
if not self.news_data:
|
||||
return
|
||||
|
||||
# Get headlines for display, applying rotation if enabled
|
||||
display_headlines = []
|
||||
|
||||
for feed_name in self.enabled_feeds:
|
||||
if feed_name in self.news_data:
|
||||
feed_headlines = self.news_data[feed_name]
|
||||
|
||||
if self.rotation_enabled and len(feed_headlines) > self.headlines_per_feed:
|
||||
# Rotate headlines to show different ones
|
||||
start_idx = (self.rotation_count * self.headlines_per_feed) % len(feed_headlines)
|
||||
selected = []
|
||||
for i in range(self.headlines_per_feed):
|
||||
idx = (start_idx + i) % len(feed_headlines)
|
||||
selected.append(feed_headlines[idx])
|
||||
display_headlines.extend(selected)
|
||||
else:
|
||||
display_headlines.extend(feed_headlines[:self.headlines_per_feed])
|
||||
|
||||
# Create scrolling text with separators
|
||||
if display_headlines:
|
||||
self.cached_images = []
|
||||
for i, headline in enumerate(display_headlines):
|
||||
favicon = self.favicons.get(headline['feed'])
|
||||
|
||||
# Use backup separator and prefix if no logo for feed
|
||||
separator = " • " if not favicon and i > 0 else ''
|
||||
feed_prefix = f"[{headline['feed']}] " if not favicon else ''
|
||||
text = separator + feed_prefix + headline['title']
|
||||
|
||||
# Calculate text width and X value
|
||||
text_width = self._get_text_width(text, self.font)
|
||||
headline_width = text_width
|
||||
text_x_pos = 0
|
||||
if favicon:
|
||||
text_x_pos = favicon.width + 16
|
||||
headline_width += text_x_pos
|
||||
|
||||
# Draw Image
|
||||
img = Image.new('RGB', (headline_width, self.display_manager.height), (0, 0, 0))
|
||||
draw = ImageDraw.Draw(img)
|
||||
if favicon:
|
||||
logo_x = 10
|
||||
logo_y = (self.display_manager.height - favicon.height) // 2
|
||||
img.paste(favicon, (logo_x, logo_y), favicon)
|
||||
|
||||
# Draw text
|
||||
text_height = self.font_size
|
||||
y_pos = (self.display_manager.height - text_height) // 2
|
||||
draw.text((text_x_pos, y_pos), text, font=self.font, fill=self.text_color)
|
||||
|
||||
# Append to cached images for rendering in `create_scrolling_image()`
|
||||
self.cached_images.append(img)
|
||||
|
||||
self.current_headlines = display_headlines
|
||||
|
||||
# Calculate text dimensions for perfect scrolling
|
||||
self.calculate_scroll_dimensions()
|
||||
self.create_scrolling_image()
|
||||
|
||||
logger.debug(f"Prepared {len(display_headlines)} headlines for display")
|
||||
|
||||
def calculate_scroll_dimensions(self):
|
||||
"""Calculate exact dimensions needed for smooth scrolling"""
|
||||
if not self.cached_images:
|
||||
return
|
||||
|
||||
try:
|
||||
display_width = self.display_manager.width
|
||||
self.total_scroll_width = display_width
|
||||
for img in self.cached_images:
|
||||
self.total_scroll_width += img.width
|
||||
|
||||
# Calculate dynamic display duration
|
||||
self.calculate_dynamic_duration()
|
||||
|
||||
logger.debug(f"Image width calculated: {self.total_scroll_width} pixels")
|
||||
logger.debug(f"Dynamic duration calculated: {self.dynamic_duration} seconds")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error calculating scroll dimensions: {e}")
|
||||
self.total_scroll_width = sum(len(x['title']) for x in self.current_headlines) * 8 # Fallback estimate
|
||||
self.calculate_dynamic_duration()
|
||||
|
||||
def _get_text_width(self, text, font):
|
||||
temp_img = Image.new('RGB', (1, 1))
|
||||
temp_draw = ImageDraw.Draw(temp_img)
|
||||
|
||||
# Get text dimensions
|
||||
bbox = temp_draw.textbbox((0, 0), text, font=font)
|
||||
return bbox[2] - bbox[0]
|
||||
|
||||
|
||||
def create_scrolling_image(self):
|
||||
"""Create a pre-rendered image for smooth scrolling."""
|
||||
if not self.cached_images:
|
||||
self.scrolling_image = None
|
||||
return
|
||||
|
||||
height = self.display_manager.height
|
||||
width = self.total_scroll_width
|
||||
|
||||
self.scrolling_image = Image.new('RGB', (width, height), (0, 0, 0))
|
||||
|
||||
# Draw text starting after display width gap (simulates blank screen)
|
||||
x_pos = self.display_manager.width
|
||||
for img in self.cached_images:
|
||||
# Render each cached image and advance the cursor by the width of the image
|
||||
self.scrolling_image.paste(img, (x_pos, 0))
|
||||
x_pos += img.width
|
||||
|
||||
logger.debug("Pre-rendered scrolling news image created.")
|
||||
|
||||
def calculate_dynamic_duration(self):
|
||||
"""Calculate the exact time needed to display all headlines"""
|
||||
# If dynamic duration is disabled, use fixed duration from config
|
||||
if not self.dynamic_duration_enabled:
|
||||
self.dynamic_duration = self.news_config.get('fixed_duration', 60)
|
||||
logger.debug(f"Dynamic duration disabled, using fixed duration: {self.dynamic_duration}s")
|
||||
return
|
||||
|
||||
if not self.total_scroll_width:
|
||||
self.dynamic_duration = self.min_duration # Use configured minimum
|
||||
return
|
||||
|
||||
try:
|
||||
# Get display width (assume full width of display)
|
||||
display_width = getattr(self.display_manager, 'width', 128) # Default to 128 if not available
|
||||
|
||||
# Calculate total scroll distance needed
|
||||
# Text needs to scroll from right edge to completely off left edge
|
||||
total_scroll_distance = display_width + self.total_scroll_width
|
||||
|
||||
# Calculate time based on scroll speed and delay
|
||||
# scroll_speed = pixels per frame, scroll_delay = seconds per frame
|
||||
frames_needed = total_scroll_distance / self.scroll_speed
|
||||
total_time = frames_needed * self.scroll_delay
|
||||
|
||||
# Add buffer time for smooth cycling (configurable %)
|
||||
buffer_time = total_time * self.duration_buffer
|
||||
calculated_duration = int(total_time + buffer_time)
|
||||
|
||||
# Apply configured min/max limits
|
||||
if calculated_duration < self.min_duration:
|
||||
self.dynamic_duration = self.min_duration
|
||||
logger.debug(f"Duration capped to minimum: {self.min_duration}s")
|
||||
elif calculated_duration > self.max_duration:
|
||||
self.dynamic_duration = self.max_duration
|
||||
logger.debug(f"Duration capped to maximum: {self.max_duration}s")
|
||||
else:
|
||||
self.dynamic_duration = calculated_duration
|
||||
|
||||
logger.debug(f"Dynamic duration calculation:")
|
||||
logger.debug(f" Display width: {display_width}px")
|
||||
logger.debug(f" Text width: {self.total_scroll_width}px")
|
||||
logger.debug(f" Total scroll distance: {total_scroll_distance}px")
|
||||
logger.debug(f" Frames needed: {frames_needed:.1f}")
|
||||
logger.debug(f" Base time: {total_time:.2f}s")
|
||||
logger.debug(f" Buffer time: {buffer_time:.2f}s ({self.duration_buffer*100}%)")
|
||||
logger.debug(f" Calculated duration: {calculated_duration}s")
|
||||
logger.debug(f" Final duration: {self.dynamic_duration}s")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error calculating dynamic duration: {e}")
|
||||
self.dynamic_duration = self.min_duration # Use configured minimum as fallback
|
||||
|
||||
def should_update(self) -> bool:
|
||||
"""Check if news data should be updated"""
|
||||
return (time.time() - self.last_update) > self.update_interval
|
||||
|
||||
def get_news_display(self) -> Image.Image:
|
||||
"""Generate the scrolling news ticker display by cropping the pre-rendered image."""
|
||||
try:
|
||||
if not self.scrolling_image:
|
||||
logger.debug("No pre-rendered image available, showing loading image.")
|
||||
return self.create_no_news_image()
|
||||
|
||||
width = self.display_manager.width
|
||||
height = self.display_manager.height
|
||||
|
||||
# Use modulo for continuous scrolling
|
||||
self.scroll_position = (self.scroll_position + self.scroll_speed) % self.total_scroll_width
|
||||
|
||||
# Crop the visible part of the image
|
||||
x = self.scroll_position
|
||||
visible_end = x + width
|
||||
|
||||
if visible_end <= self.total_scroll_width:
|
||||
# No wrap-around needed
|
||||
img = self.scrolling_image.crop((x, 0, visible_end, height))
|
||||
else:
|
||||
# Handle wrap-around
|
||||
img = Image.new('RGB', (width, height))
|
||||
|
||||
width1 = self.total_scroll_width - x
|
||||
portion1 = self.scrolling_image.crop((x, 0, self.total_scroll_width, height))
|
||||
img.paste(portion1, (0, 0))
|
||||
|
||||
width2 = width - width1
|
||||
portion2 = self.scrolling_image.crop((0, 0, width2, height))
|
||||
img.paste(portion2, (width1, 0))
|
||||
|
||||
# Check for rotation when scroll completes a cycle
|
||||
if self.scroll_position < self.scroll_speed: # Check if we just wrapped around
|
||||
self.rotation_count += 1
|
||||
if (self.rotation_enabled and
|
||||
self.rotation_count >= self.rotation_threshold and
|
||||
any(len(headlines) > self.headlines_per_feed for headlines in self.news_data.values())):
|
||||
logger.info("News rotation threshold reached. Preparing new headlines.")
|
||||
self.prepare_headlines_for_display()
|
||||
self.rotation_count = 0
|
||||
|
||||
return img
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error generating news display: {e}")
|
||||
return self.create_error_image(str(e))
|
||||
|
||||
def create_no_news_image(self) -> Image.Image:
|
||||
"""Create image when no news is available"""
|
||||
width = self.display_manager.width
|
||||
height = self.display_manager.height
|
||||
|
||||
img = Image.new('RGB', (width, height), (0, 0, 0))
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
text = "Loading news..."
|
||||
bbox = draw.textbbox((0, 0), text, font=self.font)
|
||||
text_width = bbox[2] - bbox[0]
|
||||
text_height = bbox[3] - bbox[1]
|
||||
|
||||
x = (width - text_width) // 2
|
||||
y = (height - text_height) // 2
|
||||
|
||||
draw.text((x, y), text, font=self.font, fill=self.text_color)
|
||||
return img
|
||||
|
||||
def create_error_image(self, error_msg: str) -> Image.Image:
|
||||
"""Create image for error display"""
|
||||
width = self.display_manager.width
|
||||
height = self.display_manager.height
|
||||
|
||||
img = Image.new('RGB', (width, height), (0, 0, 0))
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
text = f"News Error: {error_msg[:50]}..."
|
||||
bbox = draw.textbbox((0, 0), text, font=self.font)
|
||||
text_width = bbox[2] - bbox[0]
|
||||
text_height = bbox[3] - bbox[1]
|
||||
|
||||
x = max(0, (width - text_width) // 2)
|
||||
y = (height - text_height) // 2
|
||||
|
||||
draw.text((x, y), text, font=self.font, fill=(255, 0, 0))
|
||||
return img
|
||||
|
||||
def display_news(self, force_clear: bool = False):
|
||||
"""Display method for news ticker - called by display controller"""
|
||||
try:
|
||||
# Only fetch data once when we start displaying
|
||||
if not self.current_headlines and not self.is_fetching:
|
||||
logger.debug("Initializing news display - fetching data")
|
||||
self.is_fetching = True
|
||||
try:
|
||||
self.fetch_news_data()
|
||||
finally:
|
||||
self.is_fetching = False
|
||||
|
||||
# Get the current news display image
|
||||
img = self.get_news_display()
|
||||
|
||||
# Set the image and update display
|
||||
self.display_manager.image = img
|
||||
self.display_manager.update_display()
|
||||
|
||||
# Add scroll delay to control speed
|
||||
time.sleep(self.scroll_delay)
|
||||
|
||||
# Debug: log scroll position
|
||||
if hasattr(self, 'scroll_position') and hasattr(self, 'total_scroll_width'):
|
||||
logger.debug(f"Scroll position: {self.scroll_position}/{self.total_scroll_width}")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in news display: {e}")
|
||||
# Create error image
|
||||
error_img = self.create_error_image(str(e))
|
||||
self.display_manager.image = error_img
|
||||
self.display_manager.update_display()
|
||||
return False
|
||||
|
||||
def run_news_display(self):
|
||||
"""Standalone method to run news display in its own loop"""
|
||||
try:
|
||||
while True:
|
||||
img = self.get_news_display()
|
||||
self.display_manager.image = img
|
||||
self.display_manager.update_display()
|
||||
time.sleep(self.scroll_delay)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
logger.debug("News display interrupted by user")
|
||||
except Exception as e:
|
||||
logger.error(f"Error in news display loop: {e}")
|
||||
|
||||
def add_custom_feed(self, name: str, url: str):
|
||||
"""Add a custom RSS feed"""
|
||||
if name not in self.custom_feeds:
|
||||
self.custom_feeds[name] = url
|
||||
# Update config
|
||||
if 'news_manager' not in self.config:
|
||||
self.config['news_manager'] = {}
|
||||
self.config['news_manager']['custom_feeds'] = self.custom_feeds
|
||||
self.config_manager.save_config(self.config)
|
||||
logger.debug(f"Added custom feed: {name} -> {url}")
|
||||
|
||||
def remove_custom_feed(self, name: str):
|
||||
"""Remove a custom RSS feed"""
|
||||
if name in self.custom_feeds:
|
||||
del self.custom_feeds[name]
|
||||
# Update config
|
||||
self.config['news_manager']['custom_feeds'] = self.custom_feeds
|
||||
self.config_manager.save_config(self.config)
|
||||
logger.debug(f"Removed custom feed: {name}")
|
||||
|
||||
def set_enabled_feeds(self, feeds: List[str]):
|
||||
"""Set which feeds are enabled"""
|
||||
self.enabled_feeds = feeds
|
||||
# Update config
|
||||
if 'news_manager' not in self.config:
|
||||
self.config['news_manager'] = {}
|
||||
self.config['news_manager']['enabled_feeds'] = self.enabled_feeds
|
||||
self.config_manager.save_config(self.config)
|
||||
logger.debug(f"Updated enabled feeds: {self.enabled_feeds}")
|
||||
|
||||
# Refresh headlines
|
||||
self.fetch_news_data()
|
||||
|
||||
def get_available_feeds(self) -> Dict[str, str]:
|
||||
"""Get all available feeds (default + custom)"""
|
||||
return {**self.default_feeds, **self.custom_feeds}
|
||||
|
||||
def get_feed_status(self) -> Dict[str, Any]:
|
||||
"""Get status information about feeds"""
|
||||
status = {
|
||||
'enabled_feeds': self.enabled_feeds,
|
||||
'available_feeds': list(self.get_available_feeds().keys()),
|
||||
'headlines_per_feed': self.headlines_per_feed,
|
||||
'last_update': self.last_update,
|
||||
'total_headlines': sum(len(headlines) for headlines in self.news_data.values()),
|
||||
'rotation_enabled': self.rotation_enabled,
|
||||
'rotation_count': self.rotation_count,
|
||||
'dynamic_duration': self.dynamic_duration
|
||||
}
|
||||
return status
|
||||
|
||||
def get_dynamic_duration(self) -> int:
|
||||
"""Get the calculated dynamic duration for display"""
|
||||
# For smooth scrolling, use a very short duration so display controller calls us frequently
|
||||
# The scroll_speed controls how many pixels we move per call
|
||||
# Return the current calculated duration without fetching data
|
||||
return self.dynamic_duration # 0.1 second duration - display controller will call us 10 times per second
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import pytz
|
||||
import requests
|
||||
|
||||
from src.base_classes.football import Football, FootballLive
|
||||
from src.base_classes.sports import SportsRecent, SportsUpcoming
|
||||
from src.cache_manager import CacheManager
|
||||
from src.display_manager import DisplayManager
|
||||
|
||||
# Constants
|
||||
ESPN_NFL_SCOREBOARD_URL = "https://site.api.espn.com/apis/site/v2/sports/football/nfl/scoreboard"
|
||||
|
||||
class BaseNFLManager(Football): # Renamed class
|
||||
"""Base class for NFL managers with common functionality."""
|
||||
# Class variables for warning tracking
|
||||
_no_data_warning_logged = False
|
||||
_last_warning_time = 0
|
||||
_warning_cooldown = 60 # Only log warnings once per minute
|
||||
_shared_data = None
|
||||
_last_shared_update = 0
|
||||
|
||||
def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager):
|
||||
self.logger = logging.getLogger('NFL') # Changed logger name
|
||||
super().__init__(config=config, display_manager=display_manager, cache_manager=cache_manager, logger=self.logger, sport_key="nfl")
|
||||
|
||||
# Check display modes to determine what data to fetch
|
||||
display_modes = self.mode_config.get("display_modes", {})
|
||||
self.recent_enabled = display_modes.get("nfl_recent", False)
|
||||
self.upcoming_enabled = display_modes.get("nfl_upcoming", False)
|
||||
self.live_enabled = display_modes.get("nfl_live", False)
|
||||
|
||||
self.logger.info(f"Initialized NFL manager with display dimensions: {self.display_width}x{self.display_height}")
|
||||
self.logger.info(f"Logo directory: {self.logo_dir}")
|
||||
self.logger.info(f"Display modes - Recent: {self.recent_enabled}, Upcoming: {self.upcoming_enabled}, Live: {self.live_enabled}")
|
||||
self.league = "nfl"
|
||||
|
||||
def _fetch_nfl_api_data(self, use_cache: bool = True) -> Optional[Dict]:
|
||||
"""
|
||||
Fetches the full season schedule for NFL using background threading.
|
||||
Returns cached data immediately if available, otherwise starts background fetch.
|
||||
"""
|
||||
now = datetime.now(pytz.utc)
|
||||
season_year = now.year
|
||||
if now.month < 8:
|
||||
season_year = now.year - 1
|
||||
datestring = f"{season_year}0801-{season_year+1}0301"
|
||||
cache_key = f"{self.sport_key}_schedule_{season_year}"
|
||||
|
||||
# Check cache first
|
||||
if use_cache:
|
||||
cached_data = self.cache_manager.get(cache_key)
|
||||
if cached_data:
|
||||
# Validate cached data structure
|
||||
if isinstance(cached_data, dict) and 'events' in cached_data:
|
||||
self.logger.info(f"Using cached schedule for {season_year}")
|
||||
return cached_data
|
||||
elif isinstance(cached_data, list):
|
||||
# Handle old cache format (list of events)
|
||||
self.logger.info(f"Using cached schedule for {season_year} (legacy format)")
|
||||
return {'events': cached_data}
|
||||
else:
|
||||
self.logger.warning(f"Invalid cached data format for {season_year}: {type(cached_data)}")
|
||||
# Clear invalid cache
|
||||
self.cache_manager.clear_cache(cache_key)
|
||||
|
||||
# Start background fetch
|
||||
self.logger.info(f"Starting background fetch for {season_year} season schedule...")
|
||||
|
||||
def fetch_callback(result):
|
||||
"""Callback when background fetch completes."""
|
||||
if result.success:
|
||||
self.logger.info(f"Background fetch completed for {season_year}: {len(result.data.get('events'))} events")
|
||||
else:
|
||||
self.logger.error(f"Background fetch failed for {season_year}: {result.error}")
|
||||
|
||||
# Clean up request tracking
|
||||
if season_year in self.background_fetch_requests:
|
||||
del self.background_fetch_requests[season_year]
|
||||
|
||||
# Get background service configuration
|
||||
background_config = self.mode_config.get("background_service", {})
|
||||
timeout = background_config.get("request_timeout", 30)
|
||||
max_retries = background_config.get("max_retries", 3)
|
||||
priority = background_config.get("priority", 2)
|
||||
|
||||
# Submit background fetch request
|
||||
request_id = self.background_service.submit_fetch_request(
|
||||
sport="nfl",
|
||||
year=season_year,
|
||||
url=ESPN_NFL_SCOREBOARD_URL,
|
||||
cache_key=cache_key,
|
||||
params={"dates": datestring, "limit": 1000},
|
||||
headers=self.headers,
|
||||
timeout=timeout,
|
||||
max_retries=max_retries,
|
||||
priority=priority,
|
||||
callback=fetch_callback
|
||||
)
|
||||
|
||||
# Track the request
|
||||
self.background_fetch_requests[season_year] = request_id
|
||||
|
||||
# For immediate response, try to get partial data
|
||||
partial_data = self._get_weeks_data()
|
||||
if partial_data:
|
||||
return partial_data
|
||||
|
||||
return None
|
||||
|
||||
def _fetch_data(self) -> Optional[Dict]:
|
||||
"""Fetch data using shared data mechanism or direct fetch for live."""
|
||||
if isinstance(self, NFLLiveManager):
|
||||
# Live games should fetch only current games, not entire season
|
||||
return self._fetch_todays_games()
|
||||
else:
|
||||
# Recent and Upcoming managers should use cached season data
|
||||
return self._fetch_nfl_api_data(use_cache=True)
|
||||
|
||||
class NFLLiveManager(BaseNFLManager, FootballLive): # Renamed class
|
||||
"""Manager for live NFL games."""
|
||||
def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager):
|
||||
super().__init__(config, display_manager, cache_manager)
|
||||
self.logger = logging.getLogger('NFLLiveManager') # Changed logger name
|
||||
|
||||
if self.test_mode:
|
||||
# More detailed test game for NFL
|
||||
self.current_game = {
|
||||
"id": "test001",
|
||||
"home_abbr": "TB", "home_id": "123", "away_abbr": "DAL", "away_id":"asdf",
|
||||
"home_score": "21", "away_score": "17",
|
||||
"period": 4, "period_text": "Q4", "clock": "02:35",
|
||||
"down_distance_text": "1st & 10",
|
||||
"possession": "TB", # Placeholder ID for home team
|
||||
"possession_indicator": "home", # Explicitly set for test
|
||||
"home_timeouts": 2, "away_timeouts": 3,
|
||||
"home_logo_path": Path(self.logo_dir, "TB.png"),
|
||||
"away_logo_path": Path(self.logo_dir, "DAL.png"),
|
||||
"is_redzone": False,
|
||||
"is_live": True, "is_final": False, "is_upcoming": False, "is_halftime": False,
|
||||
"status_text": "Q4 02:35"
|
||||
}
|
||||
self.live_games = [self.current_game]
|
||||
self.logger.info("Initialized NFLLiveManager with test game: BUF vs KC")
|
||||
else:
|
||||
self.logger.info(" Initialized NFLLiveManager in live mode")
|
||||
|
||||
class NFLRecentManager(BaseNFLManager, SportsRecent): # Renamed class
|
||||
"""Manager for recently completed NFL games."""
|
||||
def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager):
|
||||
super().__init__(config, display_manager, cache_manager)
|
||||
self.logger = logging.getLogger('NFLRecentManager') # Changed logger name
|
||||
self.logger.info(f"Initialized NFLRecentManager with {len(self.favorite_teams)} favorite teams")
|
||||
|
||||
class NFLUpcomingManager(BaseNFLManager, SportsUpcoming): # Renamed class
|
||||
"""Manager for upcoming NFL games."""
|
||||
def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager):
|
||||
super().__init__(config, display_manager, cache_manager)
|
||||
self.logger = logging.getLogger('NFLUpcomingManager') # Changed logger name
|
||||
self.logger.info(f"Initialized NFLUpcomingManager with {len(self.favorite_teams)} favorite teams")
|
||||
@@ -1,175 +0,0 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import pytz
|
||||
import requests
|
||||
|
||||
from src.base_classes.hockey import Hockey, HockeyLive
|
||||
from src.base_classes.sports import SportsRecent, SportsUpcoming
|
||||
from src.cache_manager import CacheManager
|
||||
from src.display_manager import DisplayManager
|
||||
|
||||
# Import the API counter function from web interface
|
||||
try:
|
||||
from web_interface_v2 import increment_api_counter
|
||||
except ImportError:
|
||||
# Fallback if web interface is not available
|
||||
def increment_api_counter(kind: str, count: int = 1):
|
||||
pass
|
||||
|
||||
# Constants
|
||||
ESPN_NHL_SCOREBOARD_URL = "https://site.api.espn.com/apis/site/v2/sports/hockey/nhl/scoreboard"
|
||||
|
||||
class BaseNHLManager(Hockey):
|
||||
"""Base class for NHL managers with common functionality."""
|
||||
# Class variables for warning tracking
|
||||
_no_data_warning_logged = False
|
||||
_last_warning_time = 0
|
||||
_warning_cooldown = 60 # Only log warnings once per minute
|
||||
_shared_data = None
|
||||
_last_shared_update = 0
|
||||
_processed_games_cache = {} # Cache for processed game data
|
||||
_processed_games_timestamp = 0
|
||||
|
||||
def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager):
|
||||
self.logger = logging.getLogger('NHL') # Changed logger name
|
||||
super().__init__(config=config, display_manager=display_manager, cache_manager=cache_manager, logger=self.logger, sport_key="nhl")
|
||||
|
||||
# Check display modes to determine what data to fetch
|
||||
display_modes = self.mode_config.get("display_modes", {})
|
||||
self.recent_enabled = display_modes.get("ncaam_hockey_recent", False)
|
||||
self.upcoming_enabled = display_modes.get("ncaam_hockey_upcoming", False)
|
||||
self.live_enabled = display_modes.get("ncaam_hockey_live", False)
|
||||
self.league = "nhl"
|
||||
|
||||
self.logger.info(f"Initialized NHL manager with display dimensions: {self.display_width}x{self.display_height}")
|
||||
self.logger.info(f"Logo directory: {self.logo_dir}")
|
||||
self.logger.info(f"Display modes - Recent: {self.recent_enabled}, Upcoming: {self.upcoming_enabled}, Live: {self.live_enabled}")
|
||||
|
||||
def _fetch_nhl_api_data(self, use_cache: bool = True) -> Optional[Dict]:
|
||||
"""
|
||||
Fetches NHL data using background threading.
|
||||
Returns cached data immediately if available, otherwise starts background fetch.
|
||||
"""
|
||||
now = datetime.now(pytz.utc)
|
||||
season_year = now.year
|
||||
if now.month < 8:
|
||||
season_year = now.year - 1
|
||||
datestring = f"{season_year}0901-{season_year+1}0801"
|
||||
cache_key = f"nhl_schedule_{season_year}"
|
||||
|
||||
# Check cache first
|
||||
if use_cache:
|
||||
cached_data = self.cache_manager.get(cache_key)
|
||||
if cached_data:
|
||||
# Validate cached data structure
|
||||
if isinstance(cached_data, dict) and 'events' in cached_data:
|
||||
self.logger.info(f"Using cached data for {season_year}")
|
||||
return cached_data
|
||||
elif isinstance(cached_data, list):
|
||||
# Handle old cache format (list of events)
|
||||
self.logger.info(f"Using cached data for {season_year} (legacy format)")
|
||||
return {'events': cached_data}
|
||||
else:
|
||||
self.logger.warning(f"Invalid cached data format for {season_year}: {type(cached_data)}")
|
||||
# Clear invalid cache
|
||||
self.cache_manager.clear_cache(cache_key)
|
||||
|
||||
# Start background fetch
|
||||
self.logger.info(f"Starting background fetch for {season_year} season schedule...")
|
||||
|
||||
def fetch_callback(result):
|
||||
"""Callback when background fetch completes."""
|
||||
if result.success:
|
||||
self.logger.info(f"Background fetch completed for {season_year}: {len(result.data.get('events'))} events")
|
||||
else:
|
||||
self.logger.error(f"Background fetch failed for {season_year}: {result.error}")
|
||||
|
||||
# Clean up request tracking
|
||||
if season_year in self.background_fetch_requests:
|
||||
del self.background_fetch_requests[season_year]
|
||||
|
||||
# Get background service configuration
|
||||
background_config = self.mode_config.get("background_service", {})
|
||||
timeout = background_config.get("request_timeout", 30)
|
||||
max_retries = background_config.get("max_retries", 3)
|
||||
priority = background_config.get("priority", 2)
|
||||
|
||||
# Submit background fetch request
|
||||
request_id = self.background_service.submit_fetch_request(
|
||||
sport="nhl",
|
||||
year=season_year,
|
||||
url=ESPN_NHL_SCOREBOARD_URL,
|
||||
cache_key=cache_key,
|
||||
params={"dates": datestring, "limit": 1000},
|
||||
headers=self.headers,
|
||||
timeout=timeout,
|
||||
max_retries=max_retries,
|
||||
priority=priority,
|
||||
callback=fetch_callback
|
||||
)
|
||||
|
||||
# Track the request
|
||||
self.background_fetch_requests[season_year] = request_id
|
||||
|
||||
# For immediate response, try to get partial data from cache
|
||||
partial_data = self._get_weeks_data()
|
||||
if partial_data:
|
||||
return partial_data
|
||||
|
||||
return None
|
||||
|
||||
def _fetch_data(self, date_str: str = None) -> Optional[Dict]:
|
||||
"""Fetch data using shared data mechanism or direct fetch for live."""
|
||||
if isinstance(self, NHLLiveManager):
|
||||
# Live games should fetch only current games, not entire season
|
||||
return self._fetch_todays_games()
|
||||
else:
|
||||
# Recent and Upcoming managers should use cached season data
|
||||
return self._fetch_nhl_api_data(use_cache=True)
|
||||
|
||||
class NHLLiveManager(BaseNHLManager, HockeyLive):
|
||||
"""Manager for live NHL games."""
|
||||
def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager):
|
||||
super().__init__(config, display_manager, cache_manager)
|
||||
self.logger = logging.getLogger('NHLLiveManager') # Changed logger name
|
||||
|
||||
# Initialize with test game only if test mode is enabled
|
||||
if self.test_mode:
|
||||
self.current_game = {
|
||||
"id": "401596361",
|
||||
"home_abbr": "TB",
|
||||
"away_abbr": "DAL",
|
||||
"home_id": "178",
|
||||
"away_id": "2137",
|
||||
"home_score": "3",
|
||||
"away_score": "2",
|
||||
"period": 2,
|
||||
"clock": "12:34",
|
||||
"home_logo_path": Path(self.logo_dir, "TB.png"),
|
||||
"away_logo_path": Path(self.logo_dir, "DAL .png"),
|
||||
"game_time": "7:30 PM",
|
||||
"game_date": "Apr 17",
|
||||
"is_live": True, "is_final": False, "is_upcoming": False,
|
||||
}
|
||||
self.live_games = [self.current_game]
|
||||
logging.info("Initialized NHLLiveManager with test game: TB vs DAL")
|
||||
else:
|
||||
logging.info("Initialized NHLLiveManager in live mode")
|
||||
|
||||
class NHLRecentManager(BaseNHLManager, SportsRecent):
|
||||
"""Manager for recently completed NHL games."""
|
||||
def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager):
|
||||
super().__init__(config, display_manager, cache_manager)
|
||||
self.logger = logging.getLogger('NHLRecentManager') # Changed logger name
|
||||
self.logger.info(f"Initialized NHLRecentManager with {len(self.favorite_teams)} favorite teams")
|
||||
|
||||
|
||||
class NHLUpcomingManager(BaseNHLManager, SportsUpcoming):
|
||||
"""Manager for upcoming NHL games."""
|
||||
def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager):
|
||||
super().__init__(config, display_manager, cache_manager)
|
||||
self.logger = logging.getLogger('NHLUpcomingManager') # Changed logger name
|
||||
self.logger.info(f"Initialized NHLUpcomingManager with {len(self.favorite_teams)} favorite teams")
|
||||
@@ -1,118 +0,0 @@
|
||||
import time
|
||||
import logging
|
||||
import requests
|
||||
import json
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from src.cache_manager import CacheManager
|
||||
import pytz
|
||||
from typing import Dict, Any, Optional, List
|
||||
|
||||
# Import the API counter function from web interface
|
||||
try:
|
||||
from web_interface_v2 import increment_api_counter
|
||||
except ImportError:
|
||||
# Fallback if web interface is not available
|
||||
def increment_api_counter(kind: str, count: int = 1):
|
||||
pass
|
||||
|
||||
class OddsManager:
|
||||
def __init__(self, cache_manager: CacheManager, config_manager=None):
|
||||
self.cache_manager = cache_manager
|
||||
self.config_manager = config_manager
|
||||
self.logger = logging.getLogger(__name__)
|
||||
self.base_url = "https://sports.core.api.espn.com/v2/sports"
|
||||
|
||||
def get_odds(self, sport: str | None, league: str | None, event_id: str, update_interval_seconds=3600):
|
||||
if sport is None or league is None:
|
||||
raise ValueError("Sport and League cannot be None")
|
||||
cache_key = f"odds_espn_{sport}_{league}_{event_id}"
|
||||
|
||||
# Check cache first
|
||||
cached_data = self.cache_manager.get_with_auto_strategy(cache_key)
|
||||
|
||||
if cached_data:
|
||||
self.logger.info(f"Using cached odds from ESPN for {cache_key}")
|
||||
return cached_data
|
||||
|
||||
self.logger.info(f"Cache miss - fetching fresh odds from ESPN for {cache_key}")
|
||||
|
||||
try:
|
||||
# Map league names to ESPN API format
|
||||
league_mapping = {
|
||||
'ncaa_fb': 'college-football',
|
||||
'nfl': 'nfl',
|
||||
'nba': 'nba',
|
||||
'mlb': 'mlb',
|
||||
'nhl': 'nhl'
|
||||
}
|
||||
|
||||
espn_league = league_mapping.get(league, league)
|
||||
url = f"{self.base_url}/{sport}/leagues/{espn_league}/events/{event_id}/competitions/{event_id}/odds"
|
||||
self.logger.info(f"Requesting odds from URL: {url}")
|
||||
response = requests.get(url, timeout=10)
|
||||
response.raise_for_status()
|
||||
raw_data = response.json()
|
||||
|
||||
# Increment API counter for odds data
|
||||
increment_api_counter('odds', 1)
|
||||
self.logger.debug(f"Received raw odds data from ESPN: {json.dumps(raw_data, indent=2)}")
|
||||
|
||||
odds_data = self._extract_espn_data(raw_data)
|
||||
if odds_data:
|
||||
self.logger.info(f"Successfully extracted odds data: {odds_data}")
|
||||
else:
|
||||
self.logger.debug("No odds data available for this game")
|
||||
|
||||
if odds_data:
|
||||
self.cache_manager.set(cache_key, odds_data)
|
||||
self.logger.info(f"Saved odds data to cache for {cache_key}")
|
||||
else:
|
||||
self.logger.debug(f"No odds data available for {cache_key}")
|
||||
# Cache the fact that no odds are available to avoid repeated API calls
|
||||
self.cache_manager.set(cache_key, {"no_odds": True})
|
||||
|
||||
return odds_data
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
self.logger.error(f"Error fetching odds from ESPN API for {cache_key}: {e}")
|
||||
except json.JSONDecodeError:
|
||||
self.logger.error(f"Error decoding JSON response from ESPN API for {cache_key}.")
|
||||
|
||||
return self.cache_manager.get_with_auto_strategy(cache_key)
|
||||
|
||||
def _extract_espn_data(self, data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
self.logger.debug(f"Extracting ESPN odds data. Data keys: {list(data.keys())}")
|
||||
|
||||
if "items" in data and data["items"]:
|
||||
self.logger.debug(f"Found {len(data['items'])} items in odds data")
|
||||
item = data["items"][0]
|
||||
self.logger.debug(f"First item keys: {list(item.keys())}")
|
||||
|
||||
# The ESPN API returns odds data directly in the item, not in a providers array
|
||||
# Extract the odds data directly from the item
|
||||
extracted_data = {
|
||||
"details": item.get("details"),
|
||||
"over_under": item.get("overUnder"),
|
||||
"spread": item.get("spread"),
|
||||
"home_team_odds": {
|
||||
"money_line": item.get("homeTeamOdds", {}).get("moneyLine"),
|
||||
"spread_odds": item.get("homeTeamOdds", {}).get("current", {}).get("pointSpread", {}).get("value")
|
||||
},
|
||||
"away_team_odds": {
|
||||
"money_line": item.get("awayTeamOdds", {}).get("moneyLine"),
|
||||
"spread_odds": item.get("awayTeamOdds", {}).get("current", {}).get("pointSpread", {}).get("value")
|
||||
}
|
||||
}
|
||||
self.logger.debug(f"Returning extracted odds data: {json.dumps(extracted_data, indent=2)}")
|
||||
return extracted_data
|
||||
|
||||
# Check if this is a valid empty response or an unexpected structure
|
||||
if "count" in data and data["count"] == 0 and "items" in data and data["items"] == []:
|
||||
# This is a valid empty response - no odds available for this game
|
||||
self.logger.debug(f"No odds available for this game. Response: {json.dumps(data, indent=2)}")
|
||||
return None
|
||||
else:
|
||||
# This is an unexpected response structure
|
||||
self.logger.warning("No 'items' found in ESPN odds data.")
|
||||
self.logger.warning(f"Unexpected response structure: {json.dumps(data, indent=2)}")
|
||||
return None
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,671 +0,0 @@
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
from datetime import date
|
||||
from PIL import ImageDraw, ImageFont
|
||||
from src.config_manager import ConfigManager
|
||||
import time
|
||||
try:
|
||||
import freetype
|
||||
except ImportError:
|
||||
freetype = None
|
||||
|
||||
# Configure logger for this module
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.setLevel(logging.DEBUG)
|
||||
|
||||
class OfTheDayManager:
|
||||
def __init__(self, display_manager, config):
|
||||
logger.info("Initializing OfTheDayManager")
|
||||
self.display_manager = display_manager
|
||||
self.config = config
|
||||
self.of_the_day_config = config.get('of_the_day', {})
|
||||
self.enabled = self.of_the_day_config.get('enabled', False)
|
||||
self.update_interval = self.of_the_day_config.get('update_interval', 3600) # 1 hour default
|
||||
self.subtitle_rotate_interval = self.of_the_day_config.get('subtitle_rotate_interval', 10) # 10 seconds default
|
||||
self.display_rotate_interval = self.of_the_day_config.get('display_rotate_interval', 30) # 30 seconds default
|
||||
self.last_update = 0
|
||||
self.last_display_log = 0
|
||||
self.current_day = None
|
||||
self.current_items = {}
|
||||
self.current_item_index = 0
|
||||
self.current_category_index = 0
|
||||
self.last_drawn_category_index = -1
|
||||
self.last_drawn_day = None
|
||||
self.force_clear = False
|
||||
self.rotation_state = 0 # 0 = subtitle, 1 = description
|
||||
self.last_rotation_time = time.time()
|
||||
self.last_category_rotation_time = time.time()
|
||||
|
||||
# Load fonts with robust path resolution and fallbacks
|
||||
try:
|
||||
# Try multiple font directory locations
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
possible_font_dirs = [
|
||||
os.path.abspath(os.path.join(script_dir, '..', 'assets', 'fonts')), # Relative to src/
|
||||
os.path.abspath(os.path.join(os.getcwd(), 'assets', 'fonts')), # Relative to project root
|
||||
os.path.abspath('assets/fonts'), # Simple relative path made absolute
|
||||
'assets/fonts' # Simple relative path
|
||||
]
|
||||
|
||||
font_dir = None
|
||||
for potential_dir in possible_font_dirs:
|
||||
if os.path.exists(potential_dir):
|
||||
font_dir = potential_dir
|
||||
logger.debug(f"Found font directory at: {font_dir}")
|
||||
break
|
||||
|
||||
if font_dir is None:
|
||||
logger.warning("No font directory found, using fallback fonts")
|
||||
raise FileNotFoundError("Font directory not found")
|
||||
|
||||
def _safe_load_bdf_font(filename):
|
||||
try:
|
||||
# Try multiple font paths
|
||||
font_paths = [
|
||||
os.path.abspath(os.path.join(font_dir, filename)),
|
||||
os.path.join(font_dir, filename),
|
||||
os.path.join(script_dir, '..', 'assets', 'fonts', filename),
|
||||
os.path.join(script_dir, '..', 'assets', 'fonts', filename)
|
||||
]
|
||||
|
||||
for font_path in font_paths:
|
||||
abs_font_path = os.path.abspath(font_path)
|
||||
if os.path.exists(abs_font_path):
|
||||
logger.debug(f"Loading BDF font: {abs_font_path}")
|
||||
if freetype is not None:
|
||||
return freetype.Face(abs_font_path)
|
||||
else:
|
||||
logger.warning("freetype module not available, cannot load BDF fonts")
|
||||
return None
|
||||
|
||||
logger.debug(f"Font file not found: {filename}")
|
||||
# List available fonts for debugging
|
||||
try:
|
||||
available_fonts = [f for f in os.listdir(font_dir) if f.endswith('.bdf')]
|
||||
logger.debug(f"Available BDF fonts in {font_dir}: {available_fonts}")
|
||||
except:
|
||||
pass
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to load BDF font '{filename}': {e}")
|
||||
return None
|
||||
|
||||
self.title_font = _safe_load_bdf_font('ic8x8u.bdf')
|
||||
self.body_font = _safe_load_bdf_font('MatrixLight6.bdf')
|
||||
|
||||
# Fallbacks if BDF fonts aren't available
|
||||
if self.title_font is None:
|
||||
self.title_font = getattr(self.display_manager, 'bdf_5x7_font', None) or getattr(self.display_manager, 'small_font', ImageFont.load_default())
|
||||
logger.info("Using fallback font for title in OfTheDayManager")
|
||||
if self.body_font is None:
|
||||
self.body_font = getattr(self.display_manager, 'bdf_5x7_font', None) or getattr(self.display_manager, 'small_font', ImageFont.load_default())
|
||||
logger.info("Using fallback font for body in OfTheDayManager")
|
||||
|
||||
# Log font types for debugging
|
||||
logger.debug(f"Title font type: {type(self.title_font).__name__}")
|
||||
logger.debug(f"Body font type: {type(self.body_font).__name__}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Error during font initialization, using fallbacks: {e}")
|
||||
# Last-resort fallback
|
||||
self.title_font = getattr(self.display_manager, 'small_font', ImageFont.load_default())
|
||||
self.body_font = getattr(self.display_manager, 'small_font', ImageFont.load_default())
|
||||
|
||||
# Load categories and their data
|
||||
self.categories = self.of_the_day_config.get('categories', {})
|
||||
self.category_order = self.of_the_day_config.get('category_order', [])
|
||||
|
||||
# Display properties
|
||||
self.title_color = (255, 255, 255) # White
|
||||
self.subtitle_color = (200, 200, 200) # Light gray
|
||||
self.background_color = (0, 0, 0) # Black
|
||||
|
||||
# State management
|
||||
self.force_clear = False
|
||||
self.last_drawn_category_index = -1
|
||||
self.last_drawn_day = None
|
||||
|
||||
# Load data files
|
||||
self.data_files = {}
|
||||
logger.info("Loading data files for OfTheDayManager...")
|
||||
self._load_data_files()
|
||||
logger.info(f"Loaded {len(self.data_files)} data files: {list(self.data_files.keys())}")
|
||||
|
||||
logger.info(f"OfTheDayManager configuration: enabled={self.enabled}, categories={list(self.categories.keys())}")
|
||||
|
||||
if self.enabled:
|
||||
logger.info("OfTheDayManager is enabled, loading today's items...")
|
||||
self._load_todays_items()
|
||||
logger.info(f"After loading, current_items has {len(self.current_items)} items: {list(self.current_items.keys())}")
|
||||
else:
|
||||
logger.warning("OfTheDayManager is disabled in configuration")
|
||||
|
||||
def _load_data_files(self):
|
||||
"""Load all data files for enabled categories."""
|
||||
if not self.enabled:
|
||||
logger.debug("OfTheDayManager is disabled, skipping data file loading")
|
||||
return
|
||||
|
||||
logger.info(f"Loading data files for {len(self.categories)} categories")
|
||||
logger.info(f"Current working directory: {os.getcwd()}")
|
||||
logger.info(f"Script directory: {os.path.dirname(__file__)}")
|
||||
|
||||
# Additional debugging for Pi environment
|
||||
logger.debug(f"Absolute script directory: {os.path.abspath(os.path.dirname(__file__))}")
|
||||
logger.debug(f"Absolute working directory: {os.path.abspath(os.getcwd())}")
|
||||
|
||||
# Check if we're running on Pi
|
||||
if os.path.exists('/home/ledpi'):
|
||||
logger.debug("Detected Pi environment (/home/ledpi exists)")
|
||||
else:
|
||||
logger.debug("Not running on Pi environment")
|
||||
|
||||
for category_name, category_config in self.categories.items():
|
||||
logger.debug(f"Processing category: {category_name}")
|
||||
if not category_config.get('enabled', True):
|
||||
logger.debug(f"Skipping disabled category: {category_name}")
|
||||
continue
|
||||
|
||||
data_file = category_config.get('data_file')
|
||||
if not data_file:
|
||||
logger.warning(f"No data file specified for category: {category_name}")
|
||||
continue
|
||||
|
||||
try:
|
||||
# Try multiple possible paths for data files
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
current_dir = os.getcwd()
|
||||
project_root = os.path.dirname(script_dir) # Go up one level from src/ to project root
|
||||
possible_paths = []
|
||||
|
||||
logger.debug(f"Script directory: {script_dir}")
|
||||
logger.debug(f"Current working directory: {current_dir}")
|
||||
logger.debug(f"Project root directory: {project_root}")
|
||||
logger.debug(f"Data file from config: {data_file}")
|
||||
|
||||
if os.path.isabs(data_file):
|
||||
possible_paths.append(data_file)
|
||||
else:
|
||||
# Always try multiple paths regardless of how data_file is specified
|
||||
possible_paths.extend([
|
||||
os.path.join(current_dir, data_file), # Current working directory first
|
||||
os.path.join(project_root, data_file), # Project root directory
|
||||
os.path.join(script_dir, '..', data_file), # Relative to script directory
|
||||
data_file # Direct path
|
||||
])
|
||||
|
||||
# If data_file doesn't already contain 'of_the_day/', also try with it
|
||||
if not data_file.startswith('of_the_day/'):
|
||||
possible_paths.extend([
|
||||
os.path.join(current_dir, 'of_the_day', os.path.basename(data_file)),
|
||||
os.path.join(project_root, 'of_the_day', os.path.basename(data_file)),
|
||||
os.path.join(script_dir, '..', 'of_the_day', os.path.basename(data_file)),
|
||||
os.path.join('of_the_day', os.path.basename(data_file))
|
||||
])
|
||||
else:
|
||||
# If data_file already contains 'of_the_day/', try extracting just the filename
|
||||
filename = os.path.basename(data_file)
|
||||
possible_paths.extend([
|
||||
os.path.join(current_dir, 'of_the_day', filename),
|
||||
os.path.join(project_root, 'of_the_day', filename),
|
||||
os.path.join(script_dir, '..', 'of_the_day', filename),
|
||||
os.path.join('of_the_day', filename)
|
||||
])
|
||||
|
||||
# Debug: Show all paths before deduplication
|
||||
logger.debug(f"All possible paths before deduplication: {possible_paths}")
|
||||
|
||||
# Remove duplicates while preserving order
|
||||
seen = set()
|
||||
unique_paths = []
|
||||
for path in possible_paths:
|
||||
abs_path = os.path.abspath(path)
|
||||
if abs_path not in seen:
|
||||
seen.add(abs_path)
|
||||
unique_paths.append(abs_path)
|
||||
possible_paths = unique_paths
|
||||
|
||||
# Debug: Show paths after deduplication
|
||||
logger.debug(f"Unique paths after deduplication: {possible_paths}")
|
||||
|
||||
file_path = None
|
||||
for potential_path in possible_paths:
|
||||
abs_path = os.path.abspath(potential_path)
|
||||
if os.path.exists(abs_path):
|
||||
file_path = abs_path
|
||||
logger.debug(f"Found data file for {category_name} at: {file_path}")
|
||||
break
|
||||
|
||||
# Final fallback - try the direct path relative to current working directory
|
||||
if file_path is None:
|
||||
direct_path = os.path.join(current_dir, 'of_the_day', os.path.basename(data_file))
|
||||
if os.path.exists(direct_path):
|
||||
file_path = direct_path
|
||||
logger.debug(f"Found data file for {category_name} using direct fallback: {file_path}")
|
||||
|
||||
if file_path is None:
|
||||
# Use the first attempted path for error reporting
|
||||
file_path = os.path.abspath(possible_paths[0])
|
||||
logger.debug(f"No data file found for {category_name}, tried: {[os.path.abspath(p) for p in possible_paths]}")
|
||||
|
||||
# Additional debugging - check if parent directory exists
|
||||
parent_dir = os.path.dirname(file_path)
|
||||
logger.debug(f"Parent directory: {parent_dir}")
|
||||
logger.debug(f"Parent directory exists: {os.path.exists(parent_dir)}")
|
||||
if os.path.exists(parent_dir):
|
||||
try:
|
||||
parent_contents = os.listdir(parent_dir)
|
||||
logger.debug(f"Parent directory contents: {parent_contents}")
|
||||
except PermissionError:
|
||||
logger.debug(f"Permission denied accessing parent directory: {parent_dir}")
|
||||
except Exception as e:
|
||||
logger.debug(f"Error listing parent directory: {e}")
|
||||
|
||||
logger.debug(f"Attempting to load {category_name} from: {file_path}")
|
||||
|
||||
if os.path.exists(file_path):
|
||||
logger.debug(f"File exists, checking permissions...")
|
||||
if not os.access(file_path, os.R_OK):
|
||||
logger.error(f"File exists but is not readable: {file_path}")
|
||||
self.data_files[category_name] = {}
|
||||
continue
|
||||
|
||||
# Get file size for debugging
|
||||
file_size = os.path.getsize(file_path)
|
||||
logger.debug(f"File size: {file_size} bytes")
|
||||
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
self.data_files[category_name] = json.load(f)
|
||||
|
||||
logger.info(f"Loaded data file for {category_name}: {len(self.data_files[category_name])} items")
|
||||
logger.debug(f"Sample keys from {category_name}: {list(self.data_files[category_name].keys())[:5]}")
|
||||
|
||||
# Validate that we have data
|
||||
if not self.data_files[category_name]:
|
||||
logger.warning(f"Loaded data file for {category_name} but it's empty!")
|
||||
|
||||
else:
|
||||
logger.error(f"Data file not found for {category_name}: {file_path}")
|
||||
parent_dir = os.path.dirname(file_path)
|
||||
if os.path.exists(parent_dir):
|
||||
try:
|
||||
dir_contents = os.listdir(parent_dir)
|
||||
logger.error(f"Directory contents of {parent_dir}: {dir_contents}")
|
||||
except PermissionError:
|
||||
logger.error(f"Permission denied accessing directory: {parent_dir}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error listing directory {parent_dir}: {e}")
|
||||
else:
|
||||
logger.error(f"Parent directory does not exist: {parent_dir}")
|
||||
logger.error(f"Tried paths: {[os.path.abspath(p) for p in possible_paths]}")
|
||||
self.data_files[category_name] = {}
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"JSON decode error loading data file for {category_name}: {e}")
|
||||
logger.error(f"File path: {file_path}")
|
||||
self.data_files[category_name] = {}
|
||||
except UnicodeDecodeError as e:
|
||||
logger.error(f"Unicode decode error loading data file for {category_name}: {e}")
|
||||
logger.error(f"File path: {file_path}")
|
||||
self.data_files[category_name] = {}
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error loading data file for {category_name}: {e}")
|
||||
logger.error(f"File path: {file_path}")
|
||||
import traceback
|
||||
logger.error(f"Traceback: {traceback.format_exc()}")
|
||||
self.data_files[category_name] = {}
|
||||
|
||||
def _load_todays_items(self):
|
||||
"""Load items for today based on the current date."""
|
||||
if not self.enabled:
|
||||
return
|
||||
|
||||
today = date.today()
|
||||
day_of_year = today.timetuple().tm_yday
|
||||
logger.info(f"Loading items for day {day_of_year} of the year")
|
||||
logger.debug(f"Available data files: {list(self.data_files.keys())}")
|
||||
|
||||
self.current_items = {}
|
||||
|
||||
for category_name, category_config in self.categories.items():
|
||||
if not category_config.get('enabled', True):
|
||||
logger.debug(f"Skipping disabled category: {category_name}")
|
||||
continue
|
||||
|
||||
data = self.data_files.get(category_name, {})
|
||||
if not data:
|
||||
logger.warning(f"No data loaded for category: {category_name}")
|
||||
logger.debug(f"Data files available: {list(self.data_files.keys())}")
|
||||
logger.debug(f"Category config: {category_config}")
|
||||
continue
|
||||
|
||||
logger.debug(f"Checking category {category_name} for day {day_of_year}")
|
||||
logger.debug(f"Data file contains {len(data)} items")
|
||||
|
||||
# Get item for today (day of year)
|
||||
item = data.get(str(day_of_year))
|
||||
if item:
|
||||
self.current_items[category_name] = item
|
||||
logger.info(f"Loaded {category_name} item for day {day_of_year}: {item.get('title', 'No title')}")
|
||||
else:
|
||||
logger.warning(f"No item found for {category_name} on day {day_of_year}")
|
||||
# Show more detailed information about available days
|
||||
available_days = [k for k in data.keys() if k.isdigit()]
|
||||
nearby_days = [k for k in available_days if abs(int(k) - day_of_year) <= 5]
|
||||
logger.debug(f"Available days in {category_name}: {sorted(available_days)[:10]}...")
|
||||
logger.debug(f"Days near {day_of_year}: {sorted(nearby_days)}")
|
||||
|
||||
self.current_day = today
|
||||
self.current_category_index = 0
|
||||
self.current_item_index = 0
|
||||
|
||||
def update(self, current_time):
|
||||
"""Update items if needed (daily or on interval)."""
|
||||
if not self.enabled:
|
||||
logger.debug("OfTheDayManager is disabled, skipping update")
|
||||
return
|
||||
|
||||
today = date.today()
|
||||
|
||||
# Check if we need to load new items (new day or first time)
|
||||
if self.current_day != today:
|
||||
logger.info("New day detected, loading new items")
|
||||
self._load_todays_items()
|
||||
|
||||
# Check if we need to update based on interval
|
||||
if current_time - self.last_update > self.update_interval:
|
||||
logger.debug("OfTheDayManager update interval reached")
|
||||
self.last_update = current_time
|
||||
|
||||
def _draw_bdf_text(self, draw, face, text, x, y, color=(255,255,255)):
|
||||
"""Draw text for both BDF (FreeType Face) and PIL TTF fonts."""
|
||||
try:
|
||||
# If we have a PIL font, use native text rendering
|
||||
if freetype is None or not isinstance(face, freetype.Face):
|
||||
draw.text((x, y), text, fill=color, font=face)
|
||||
return
|
||||
|
||||
# Compute baseline from font ascender so caller can pass top-left y
|
||||
try:
|
||||
ascender_px = face.size.ascender >> 6
|
||||
except Exception:
|
||||
ascender_px = 0
|
||||
baseline_y = y + ascender_px
|
||||
|
||||
# Otherwise, render BDF glyphs manually
|
||||
for char in text:
|
||||
face.load_char(char)
|
||||
bitmap = face.glyph.bitmap
|
||||
|
||||
# Get glyph metrics
|
||||
glyph_left = face.glyph.bitmap_left
|
||||
glyph_top = face.glyph.bitmap_top
|
||||
|
||||
for i in range(bitmap.rows):
|
||||
for j in range(bitmap.width):
|
||||
try:
|
||||
byte_index = i * bitmap.pitch + (j // 8)
|
||||
if byte_index < len(bitmap.buffer):
|
||||
byte = bitmap.buffer[byte_index]
|
||||
if byte & (1 << (7 - (j % 8))):
|
||||
# Calculate actual pixel position
|
||||
pixel_x = x + glyph_left + j
|
||||
pixel_y = baseline_y - glyph_top + i
|
||||
# Only draw if within bounds
|
||||
if (0 <= pixel_x < self.display_manager.width and 0 <= pixel_y < self.display_manager.height):
|
||||
draw.point((pixel_x, pixel_y), fill=color)
|
||||
except IndexError:
|
||||
logger.warning(f"Index out of range for char '{char}' at position ({i}, {j})")
|
||||
continue
|
||||
x += face.glyph.advance.x >> 6
|
||||
except Exception as e:
|
||||
logger.error(f"Error in _draw_bdf_text: {e}", exc_info=True)
|
||||
|
||||
def draw_item(self, category_name, item):
|
||||
try:
|
||||
title = item.get('title', 'No Title')
|
||||
subtitle = item.get('subtitle', '')
|
||||
description = item.get('description', '')
|
||||
draw = ImageDraw.Draw(self.display_manager.image)
|
||||
matrix_width = self.display_manager.matrix.width
|
||||
matrix_height = self.display_manager.matrix.height
|
||||
title_font = self.title_font
|
||||
body_font = self.body_font
|
||||
|
||||
# Get font heights using DisplayManager helpers (handles BDF and PIL fonts)
|
||||
try:
|
||||
title_height = self.display_manager.get_font_height(title_font)
|
||||
except Exception:
|
||||
title_height = 8
|
||||
try:
|
||||
body_height = self.display_manager.get_font_height(body_font)
|
||||
except Exception:
|
||||
body_height = 8
|
||||
|
||||
# --- Dynamic Spacing Calculation ---
|
||||
# Calculate how much space we need and distribute it evenly
|
||||
margin_top = 8 # Shift everything down by 6 pixels
|
||||
margin_bottom = 1
|
||||
underline_space = 1 # Space for underline
|
||||
|
||||
# Determine current content
|
||||
current_text = subtitle if (self.rotation_state == 0 and subtitle) else description
|
||||
if not current_text:
|
||||
current_text = ""
|
||||
|
||||
# Pre-wrap the body text to determine how many lines we'll need
|
||||
available_width = matrix_width - 4 # Leave some margin
|
||||
wrapped_lines = self._wrap_text(current_text, available_width, body_font, max_lines=10,
|
||||
line_height=body_height, max_height=matrix_height)
|
||||
# Filter out empty lines for spacing calculation
|
||||
actual_body_lines = [line for line in wrapped_lines if line.strip()]
|
||||
num_body_lines = len(actual_body_lines)
|
||||
|
||||
# Calculate total content height needed
|
||||
title_content_height = title_height
|
||||
underline_content_height = underline_space
|
||||
body_content_height = num_body_lines * body_height if num_body_lines > 0 else 0
|
||||
|
||||
total_content_height = title_content_height + underline_content_height + body_content_height
|
||||
available_space = matrix_height - margin_top - margin_bottom
|
||||
|
||||
# Calculate dynamic spacing
|
||||
if total_content_height < available_space:
|
||||
# We have extra space - distribute it
|
||||
extra_space = available_space - total_content_height
|
||||
if num_body_lines > 0:
|
||||
# Distribute space: 30% after title, 70% between body lines
|
||||
space_after_title = max(2, int(extra_space * 0.3))
|
||||
space_between_lines = max(1, int(extra_space * 0.7 / max(1, num_body_lines - 1))) if num_body_lines > 1 else 0
|
||||
else:
|
||||
# No body text - just center the title
|
||||
space_after_title = extra_space // 2
|
||||
space_between_lines = 0
|
||||
else:
|
||||
# Tight spacing
|
||||
space_after_title = 4
|
||||
space_between_lines = 1
|
||||
|
||||
# --- Draw Title ---
|
||||
title_y = margin_top
|
||||
|
||||
# Calculate title width for centering
|
||||
try:
|
||||
title_width = self.display_manager.get_text_width(title, title_font)
|
||||
except Exception:
|
||||
title_width = len(title) * 6
|
||||
|
||||
# Center the title
|
||||
title_x = (matrix_width - title_width) // 2
|
||||
self._draw_bdf_text(draw, title_font, title, title_x, title_y, color=self.title_color)
|
||||
|
||||
# --- Draw Underline ---
|
||||
underline_y = title_y + title_height + 1 # Reduced space after title
|
||||
underline_x_start = title_x
|
||||
underline_x_end = title_x + title_width
|
||||
draw.line([(underline_x_start, underline_y), (underline_x_end, underline_y)], fill=self.title_color, width=1)
|
||||
|
||||
# --- Draw Body Text with Dynamic Spacing ---
|
||||
if num_body_lines > 0:
|
||||
body_start_y = underline_y + space_after_title + 1 # Shift description down 1 pixel
|
||||
current_y = body_start_y
|
||||
|
||||
for i, line in enumerate(actual_body_lines):
|
||||
if line.strip(): # Only draw non-empty lines
|
||||
# Center each line of body text
|
||||
try:
|
||||
line_width = self.display_manager.get_text_width(line, body_font)
|
||||
except Exception:
|
||||
line_width = len(line) * 6
|
||||
line_x = (matrix_width - line_width) // 2
|
||||
|
||||
# Draw the line
|
||||
self._draw_bdf_text(draw, body_font, line, line_x, current_y, color=self.subtitle_color)
|
||||
|
||||
# Move to next line position
|
||||
if i < len(actual_body_lines) - 1: # Not the last line
|
||||
current_y += body_height + space_between_lines
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error drawing 'of the day' item: {e}", exc_info=True)
|
||||
return False
|
||||
|
||||
def _wrap_text(self, text, max_width, face, max_lines=3, line_height=8, max_height=24):
|
||||
if not text:
|
||||
return [""]
|
||||
lines = []
|
||||
current_line = []
|
||||
words = text.split()
|
||||
for word in words:
|
||||
test_line = ' '.join(current_line + [word]) if current_line else word
|
||||
try:
|
||||
text_width = self.display_manager.get_text_width(test_line, face)
|
||||
except Exception:
|
||||
text_width = len(test_line) * 6
|
||||
if text_width <= max_width:
|
||||
current_line.append(word)
|
||||
else:
|
||||
if current_line:
|
||||
lines.append(' '.join(current_line))
|
||||
current_line = [word]
|
||||
else:
|
||||
truncated = word
|
||||
while len(truncated) > 0:
|
||||
try:
|
||||
test_width = self.display_manager.get_text_width(truncated + "...", face)
|
||||
except Exception:
|
||||
test_width = len(truncated) * 6
|
||||
if test_width <= max_width:
|
||||
lines.append(truncated + "...")
|
||||
break
|
||||
truncated = truncated[:-1]
|
||||
if not truncated:
|
||||
lines.append(word[:10] + "...")
|
||||
# Check if we've filled all lines (accounting for line spacing)
|
||||
if len(lines) * line_height >= max_height or len(lines) >= max_lines:
|
||||
break
|
||||
if current_line and (len(lines) * line_height < max_height and len(lines) < max_lines):
|
||||
lines.append(' '.join(current_line))
|
||||
while len(lines) < max_lines:
|
||||
lines.append("")
|
||||
return lines[:max_lines]
|
||||
|
||||
def display(self, force_clear=False):
|
||||
if not self.enabled:
|
||||
return
|
||||
if not self.current_items:
|
||||
current_time = time.time()
|
||||
if not hasattr(self, 'last_warning_time') or current_time - self.last_warning_time > 10:
|
||||
logger.warning(f"OfTheDayManager has no current items.")
|
||||
self.last_warning_time = current_time
|
||||
return
|
||||
now = time.time()
|
||||
# Handle subtitle/description rotation
|
||||
if now - self.last_rotation_time > self.subtitle_rotate_interval:
|
||||
self.rotation_state = (self.rotation_state + 1) % 2
|
||||
self.last_rotation_time = now
|
||||
# Force redraw
|
||||
self.last_drawn_category_index = -1
|
||||
self.last_drawn_day = None
|
||||
# Handle OTD category rotation
|
||||
if now - self.last_category_rotation_time > self.display_rotate_interval:
|
||||
# Find the next category with valid data
|
||||
original_index = self.current_category_index
|
||||
self.current_category_index = (self.current_category_index + 1) % len(self.current_items)
|
||||
|
||||
# If we've cycled through all categories and none have data, reset to first
|
||||
if self.current_category_index == original_index:
|
||||
logger.warning("No categories have valid data, staying on current category")
|
||||
else:
|
||||
logger.info(f"Internal rotation: from category index {original_index} to {self.current_category_index}")
|
||||
logger.info(f"Available categories with data: {list(self.current_items.keys())}")
|
||||
|
||||
self.last_category_rotation_time = now
|
||||
# Reset subtitle/description rotation when switching category
|
||||
self.rotation_state = 0
|
||||
self.last_rotation_time = now
|
||||
# Force redraw
|
||||
self.last_drawn_category_index = -1
|
||||
self.last_drawn_day = None
|
||||
content_has_changed = self.current_category_index != self.last_drawn_category_index or self.current_day != self.last_drawn_day
|
||||
if not content_has_changed and not force_clear:
|
||||
return
|
||||
try:
|
||||
category_names = list(self.current_items.keys())
|
||||
if not category_names or self.current_category_index >= len(category_names):
|
||||
self.current_category_index = 0
|
||||
if not category_names: return
|
||||
current_category = category_names[self.current_category_index]
|
||||
current_item = self.current_items[current_category]
|
||||
current_time = time.time()
|
||||
if current_time - self.last_display_log > 5:
|
||||
logger.info(f"Displaying {current_category}: {current_item.get('title', 'No Title')}")
|
||||
self.last_display_log = current_time
|
||||
self.display_manager.clear()
|
||||
self.draw_item(current_category, current_item)
|
||||
self.display_manager.update_display()
|
||||
self.last_drawn_category_index = self.current_category_index
|
||||
self.last_drawn_day = self.current_day
|
||||
except Exception as e:
|
||||
logger.error(f"Error displaying 'of the day' item: {e}", exc_info=True)
|
||||
|
||||
def advance_item(self):
|
||||
"""Advance to the next item. Called by DisplayController when display time is up."""
|
||||
if not self.enabled:
|
||||
logger.debug("OfTheDayManager is disabled, skipping item advance")
|
||||
return
|
||||
|
||||
# Check if internal rotation should happen first
|
||||
now = time.time()
|
||||
if now - self.last_category_rotation_time > self.display_rotate_interval:
|
||||
# Let the internal rotation handle it
|
||||
logger.debug("Internal rotation timer triggered, skipping external advance")
|
||||
return
|
||||
|
||||
# Only advance if internal rotation hasn't happened recently
|
||||
# Add a buffer to prevent conflicts
|
||||
if now - self.last_category_rotation_time < self.display_rotate_interval - 5:
|
||||
logger.debug("Too close to internal rotation time, skipping external advance")
|
||||
return
|
||||
|
||||
category_names = list(self.current_items.keys())
|
||||
if not category_names:
|
||||
return
|
||||
|
||||
# Advance to next category
|
||||
original_index = self.current_category_index
|
||||
self.current_category_index = (self.current_category_index + 1) % len(category_names)
|
||||
|
||||
# Update rotation time to prevent immediate internal rotation
|
||||
self.last_category_rotation_time = now
|
||||
|
||||
# Reset subtitle/description rotation when switching category
|
||||
self.rotation_state = 0
|
||||
self.last_rotation_time = now
|
||||
|
||||
# Force redraw
|
||||
self.last_drawn_category_index = -1
|
||||
self.last_drawn_day = None
|
||||
|
||||
logger.debug(f"OfTheDayManager externally advanced from category index {original_index} to {self.current_category_index}")
|
||||
30
src/plugin_system/__init__.py
Normal file
30
src/plugin_system/__init__.py
Normal file
@@ -0,0 +1,30 @@
|
||||
"""
|
||||
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',
|
||||
]
|
||||
|
||||
400
src/plugin_system/base_plugin.py
Normal file
400
src/plugin_system/base_plugin.py
Normal file
@@ -0,0 +1,400 @@
|
||||
"""
|
||||
Base Plugin Interface
|
||||
|
||||
All LEDMatrix plugins must inherit from BasePlugin and implement
|
||||
the required abstract methods: update() and display().
|
||||
|
||||
API Version: 1.0.0
|
||||
Stability: Stable - maintains backward compatibility
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Dict, Any, Optional, List
|
||||
import logging
|
||||
from src.logging_config import get_logger
|
||||
|
||||
|
||||
class BasePlugin(ABC):
|
||||
"""
|
||||
Base class that all plugins must inherit from.
|
||||
Provides standard interface and helper methods.
|
||||
|
||||
This is the core plugin interface that all plugins must implement.
|
||||
Provides common functionality for logging, configuration, and
|
||||
integration with the LEDMatrix core system.
|
||||
"""
|
||||
|
||||
API_VERSION = "1.0.0"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
plugin_id: str,
|
||||
config: Dict[str, Any],
|
||||
display_manager: Any,
|
||||
cache_manager: Any,
|
||||
plugin_manager: Any,
|
||||
) -> None:
|
||||
"""
|
||||
Standard initialization for all plugins.
|
||||
|
||||
Args:
|
||||
plugin_id: Unique identifier for this plugin instance
|
||||
config: Plugin-specific configuration dictionary
|
||||
display_manager: Shared display manager instance for rendering
|
||||
cache_manager: Shared cache manager instance for data persistence
|
||||
plugin_manager: Reference to plugin manager for inter-plugin communication
|
||||
"""
|
||||
self.plugin_id: str = plugin_id
|
||||
self.config: Dict[str, Any] = config
|
||||
self.display_manager: Any = display_manager
|
||||
self.cache_manager: Any = cache_manager
|
||||
self.plugin_manager: Any = plugin_manager
|
||||
self.logger: logging.Logger = get_logger(f"plugin.{plugin_id}", plugin_id=plugin_id)
|
||||
self.enabled: bool = config.get("enabled", True)
|
||||
|
||||
self.logger.info("Initialized plugin: %s", plugin_id)
|
||||
|
||||
@abstractmethod
|
||||
def update(self) -> None:
|
||||
"""
|
||||
Fetch/update data for this plugin.
|
||||
|
||||
This method is called based on update_interval specified in the
|
||||
plugin's manifest. It should fetch any necessary data from APIs,
|
||||
databases, or other sources and prepare it for display.
|
||||
|
||||
Use the cache_manager for caching API responses to avoid
|
||||
excessive requests.
|
||||
|
||||
Example:
|
||||
def update(self):
|
||||
cache_key = f"{self.plugin_id}_data"
|
||||
cached = self.cache_manager.get(cache_key, max_age=3600)
|
||||
if cached:
|
||||
self.data = cached
|
||||
return
|
||||
|
||||
self.data = self._fetch_from_api()
|
||||
self.cache_manager.set(cache_key, self.data)
|
||||
"""
|
||||
raise NotImplementedError("Plugins must implement update()")
|
||||
|
||||
@abstractmethod
|
||||
def display(self, force_clear: bool = False) -> None:
|
||||
"""
|
||||
Render this plugin's display.
|
||||
|
||||
This method is called during the display rotation or when the plugin
|
||||
is explicitly requested to render. It should use the display_manager
|
||||
to draw content on the LED matrix.
|
||||
|
||||
Args:
|
||||
force_clear: If True, clear display before rendering
|
||||
|
||||
Example:
|
||||
def display(self, force_clear=False):
|
||||
if force_clear:
|
||||
self.display_manager.clear()
|
||||
|
||||
self.display_manager.draw_text(
|
||||
"Hello, World!",
|
||||
x=5, y=15,
|
||||
color=(255, 255, 255)
|
||||
)
|
||||
|
||||
self.display_manager.update_display()
|
||||
"""
|
||||
raise NotImplementedError("Plugins must implement display()")
|
||||
|
||||
def get_display_duration(self) -> float:
|
||||
"""
|
||||
Get the display duration for this plugin instance.
|
||||
|
||||
Automatically detects duration from:
|
||||
1. self.display_duration instance variable (if exists)
|
||||
2. self.config.get("display_duration", 15.0) (fallback)
|
||||
|
||||
Can be overridden by plugins to provide dynamic durations based
|
||||
on content (e.g., longer duration for more complex displays).
|
||||
|
||||
Returns:
|
||||
Duration in seconds to display this plugin's content
|
||||
"""
|
||||
# Check for instance variable first (common pattern in scoreboard plugins)
|
||||
if hasattr(self, 'display_duration'):
|
||||
try:
|
||||
duration = getattr(self, 'display_duration')
|
||||
# Handle None case
|
||||
if duration is None:
|
||||
pass # Fall through to config
|
||||
# Try to convert to float if it's a number or numeric string
|
||||
elif isinstance(duration, (int, float)):
|
||||
if duration > 0:
|
||||
return float(duration)
|
||||
# Try converting string representations of numbers
|
||||
elif isinstance(duration, str):
|
||||
try:
|
||||
duration_float = float(duration)
|
||||
if duration_float > 0:
|
||||
return duration_float
|
||||
except (ValueError, TypeError):
|
||||
pass # Fall through to config
|
||||
except (TypeError, ValueError, AttributeError):
|
||||
pass # Fall through to config
|
||||
|
||||
# Fall back to config
|
||||
config_duration = self.config.get("display_duration", 15.0)
|
||||
try:
|
||||
# Ensure config value is also a valid float
|
||||
if isinstance(config_duration, (int, float)):
|
||||
return float(config_duration) if config_duration > 0 else 15.0
|
||||
elif isinstance(config_duration, str):
|
||||
return float(config_duration) if float(config_duration) > 0 else 15.0
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
return 15.0
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# Dynamic duration support hooks
|
||||
# ---------------------------------------------------------------------
|
||||
def _get_dynamic_duration_config(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Retrieve dynamic duration configuration block from plugin config.
|
||||
|
||||
Returns:
|
||||
Dict with configuration values or empty dict if not configured.
|
||||
"""
|
||||
value = self.config.get("dynamic_duration", {})
|
||||
if isinstance(value, dict):
|
||||
return value
|
||||
return {}
|
||||
|
||||
def supports_dynamic_duration(self) -> bool:
|
||||
"""
|
||||
Determine whether this plugin should use dynamic display durations.
|
||||
|
||||
Plugins can override to implement custom logic. By default this reads the
|
||||
`dynamic_duration.enabled` flag from plugin configuration.
|
||||
"""
|
||||
config = self._get_dynamic_duration_config()
|
||||
return bool(config.get("enabled", False))
|
||||
|
||||
def get_dynamic_duration_cap(self) -> Optional[float]:
|
||||
"""
|
||||
Return the maximum duration (in seconds) the controller should wait for
|
||||
this plugin to complete its display cycle when using dynamic duration.
|
||||
|
||||
Returns:
|
||||
Positive float value for explicit cap, or None to indicate no
|
||||
additional cap beyond global defaults.
|
||||
"""
|
||||
config = self._get_dynamic_duration_config()
|
||||
cap_value = config.get("max_duration_seconds")
|
||||
if cap_value is None:
|
||||
return None
|
||||
try:
|
||||
cap = float(cap_value)
|
||||
if cap <= 0:
|
||||
return None
|
||||
return cap
|
||||
except (TypeError, ValueError):
|
||||
self.logger.warning(
|
||||
"Invalid dynamic_duration.max_duration_seconds for %s: %s",
|
||||
self.plugin_id,
|
||||
cap_value,
|
||||
)
|
||||
return None
|
||||
|
||||
def is_cycle_complete(self) -> bool:
|
||||
"""
|
||||
Indicate whether the plugin has completed a full display cycle.
|
||||
|
||||
The display controller calls this after each display iteration when
|
||||
dynamic duration is enabled. Plugins that render multi-step content
|
||||
should override this method and return True only after all content has
|
||||
been shown once.
|
||||
|
||||
Returns:
|
||||
True if the plugin cycle is complete (default behaviour).
|
||||
"""
|
||||
return True
|
||||
|
||||
def reset_cycle_state(self) -> None:
|
||||
"""
|
||||
Reset any internal counters/state related to cycle tracking.
|
||||
|
||||
Called by the display controller before beginning a new dynamic-duration
|
||||
session. Override in plugins that maintain custom tracking data.
|
||||
"""
|
||||
return
|
||||
|
||||
def has_live_priority(self) -> bool:
|
||||
"""
|
||||
Check if this plugin has live priority enabled.
|
||||
|
||||
Live priority allows a plugin to take over the display when it has
|
||||
live/urgent content (e.g., live sports games, breaking news).
|
||||
|
||||
Returns:
|
||||
True if live priority is enabled in config, False otherwise
|
||||
"""
|
||||
return self.config.get("live_priority", False)
|
||||
|
||||
def has_live_content(self) -> bool:
|
||||
"""
|
||||
Check if this plugin currently has live content to display.
|
||||
|
||||
Override this method in your plugin to implement live content detection.
|
||||
This is called by the display controller to determine if a live priority
|
||||
plugin should take over the display.
|
||||
|
||||
Returns:
|
||||
True if plugin has live content, False otherwise
|
||||
|
||||
Example (sports plugin):
|
||||
def has_live_content(self):
|
||||
# Check if there are any live games
|
||||
return hasattr(self, 'live_games') and len(self.live_games) > 0
|
||||
|
||||
Example (news plugin):
|
||||
def has_live_content(self):
|
||||
# Check if there's breaking news
|
||||
return hasattr(self, 'breaking_news') and self.breaking_news
|
||||
"""
|
||||
return False
|
||||
|
||||
def get_live_modes(self) -> List[str]:
|
||||
"""
|
||||
Get list of display modes that should be used during live priority takeover.
|
||||
|
||||
Override this method to specify which modes should be shown when this
|
||||
plugin has live content. By default, returns all display modes from manifest.
|
||||
|
||||
Returns:
|
||||
List of mode names to display during live priority
|
||||
|
||||
Example:
|
||||
def get_live_modes(self):
|
||||
# Only show live game mode, not upcoming/recent
|
||||
return ['nhl_live', 'nba_live']
|
||||
"""
|
||||
# Get display modes from manifest via plugin manager
|
||||
if self.plugin_manager and hasattr(self.plugin_manager, "plugin_manifests"):
|
||||
manifest = self.plugin_manager.plugin_manifests.get(self.plugin_id, {})
|
||||
return manifest.get("display_modes", [self.plugin_id])
|
||||
return [self.plugin_id]
|
||||
|
||||
def validate_config(self) -> bool:
|
||||
"""
|
||||
Validate plugin configuration against schema.
|
||||
|
||||
Called during plugin loading to ensure configuration is valid.
|
||||
Override this method to implement custom validation logic.
|
||||
|
||||
Returns:
|
||||
True if config is valid, False otherwise
|
||||
|
||||
Example:
|
||||
def validate_config(self):
|
||||
required_fields = ['api_key', 'city']
|
||||
for field in required_fields:
|
||||
if field not in self.config:
|
||||
self.logger.error("Missing required field: %s", field)
|
||||
return False
|
||||
return True
|
||||
"""
|
||||
# Basic validation - check that enabled is a boolean if present
|
||||
if "enabled" in self.config:
|
||||
if not isinstance(self.config["enabled"], bool):
|
||||
self.logger.error("'enabled' must be a boolean")
|
||||
return False
|
||||
|
||||
# Check display_duration if present
|
||||
if "display_duration" in self.config:
|
||||
duration = self.config["display_duration"]
|
||||
if not isinstance(duration, (int, float)) or duration <= 0:
|
||||
self.logger.error("'display_duration' must be a positive number")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def cleanup(self) -> None:
|
||||
"""
|
||||
Cleanup resources when plugin is unloaded.
|
||||
|
||||
Override this method to clean up any resources (e.g., close
|
||||
file handles, terminate threads, close network connections).
|
||||
|
||||
This method is called when the plugin is unloaded or when the
|
||||
system is shutting down.
|
||||
|
||||
Example:
|
||||
def cleanup(self):
|
||||
if hasattr(self, 'api_client'):
|
||||
self.api_client.close()
|
||||
if hasattr(self, 'worker_thread'):
|
||||
self.worker_thread.stop()
|
||||
"""
|
||||
self.logger.info("Cleaning up plugin: %s", self.plugin_id)
|
||||
|
||||
def on_config_change(self, new_config: Dict[str, Any]) -> None:
|
||||
"""
|
||||
Called after the plugin configuration has been updated via the web API.
|
||||
|
||||
Plugins may override this to apply changes immediately without a restart.
|
||||
The default implementation updates the in-memory config.
|
||||
|
||||
Args:
|
||||
new_config: The full, merged configuration for this plugin (including
|
||||
any secret-derived values that are merged at runtime).
|
||||
"""
|
||||
# Update config reference
|
||||
self.config = new_config or {}
|
||||
|
||||
# Update simple flags
|
||||
self.enabled = self.config.get("enabled", self.enabled)
|
||||
|
||||
def get_info(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Return plugin info for display in web UI.
|
||||
|
||||
Override this method to provide additional information about
|
||||
the plugin's current state.
|
||||
|
||||
Returns:
|
||||
Dict with plugin information including id, enabled status, and config
|
||||
|
||||
Example:
|
||||
def get_info(self):
|
||||
info = super().get_info()
|
||||
info['games_count'] = len(self.games)
|
||||
info['last_update'] = self.last_update_time
|
||||
return info
|
||||
"""
|
||||
return {
|
||||
"id": self.plugin_id,
|
||||
"enabled": self.enabled,
|
||||
"config": self.config,
|
||||
"api_version": self.API_VERSION,
|
||||
}
|
||||
|
||||
def on_enable(self) -> None:
|
||||
"""
|
||||
Called when plugin is enabled.
|
||||
|
||||
Override this method to perform any actions needed when the
|
||||
plugin is enabled (e.g., start background tasks, open connections).
|
||||
"""
|
||||
self.enabled = True
|
||||
self.logger.info("Plugin enabled: %s", self.plugin_id)
|
||||
|
||||
def on_disable(self) -> None:
|
||||
"""
|
||||
Called when plugin is disabled.
|
||||
|
||||
Override this method to perform any actions needed when the
|
||||
plugin is disabled (e.g., stop background tasks, close connections).
|
||||
"""
|
||||
self.enabled = False
|
||||
self.logger.info("Plugin disabled: %s", self.plugin_id)
|
||||
319
src/plugin_system/health_monitor.py
Normal file
319
src/plugin_system/health_monitor.py
Normal file
@@ -0,0 +1,319 @@
|
||||
"""
|
||||
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, timedelta
|
||||
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)
|
||||
|
||||
208
src/plugin_system/operation_history.py
Normal file
208
src/plugin_system/operation_history.py
Normal file
@@ -0,0 +1,208 @@
|
||||
"""
|
||||
Operation history and audit log.
|
||||
|
||||
Tracks all plugin operations and configuration changes for debugging and auditing.
|
||||
"""
|
||||
|
||||
import json
|
||||
import threading
|
||||
from typing import Dict, Any, List, Optional
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from dataclasses import dataclass, asdict
|
||||
|
||||
from src.logging_config import get_logger
|
||||
|
||||
|
||||
@dataclass
|
||||
class OperationRecord:
|
||||
"""Record of an operation."""
|
||||
operation_id: str
|
||||
operation_type: str
|
||||
plugin_id: Optional[str]
|
||||
timestamp: datetime
|
||||
status: str
|
||||
user: Optional[str] = None
|
||||
details: Optional[Dict[str, Any]] = None
|
||||
error: Optional[str] = None
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary for serialization."""
|
||||
result = asdict(self)
|
||||
result['timestamp'] = self.timestamp.isoformat()
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'OperationRecord':
|
||||
"""Create from dictionary."""
|
||||
if isinstance(data.get('timestamp'), str):
|
||||
data['timestamp'] = datetime.fromisoformat(data['timestamp'])
|
||||
return cls(**data)
|
||||
|
||||
|
||||
class OperationHistory:
|
||||
"""
|
||||
Operation history and audit log manager.
|
||||
|
||||
Tracks all plugin operations and configuration changes.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
history_file: Optional[str] = None,
|
||||
max_records: int = 1000,
|
||||
lazy_load: bool = False
|
||||
):
|
||||
"""
|
||||
Initialize operation history.
|
||||
|
||||
Args:
|
||||
history_file: Path to file for persisting history
|
||||
max_records: Maximum number of records to keep
|
||||
lazy_load: If True, defer loading history file until first access
|
||||
"""
|
||||
self.logger = get_logger(__name__)
|
||||
self.history_file = Path(history_file) if history_file else None
|
||||
self.max_records = max_records
|
||||
self._lazy_load = lazy_load
|
||||
self._history_loaded = False
|
||||
|
||||
# In-memory history
|
||||
self._history: List[OperationRecord] = []
|
||||
self._lock = threading.RLock()
|
||||
|
||||
# Load history from file if it exists (unless lazy loading)
|
||||
if not self._lazy_load and self.history_file and self.history_file.exists():
|
||||
self._load_history()
|
||||
self._history_loaded = True
|
||||
|
||||
def _ensure_loaded(self) -> None:
|
||||
"""Ensure history is loaded (for lazy loading)."""
|
||||
if not self._history_loaded and self.history_file and self.history_file.exists():
|
||||
self._load_history()
|
||||
self._history_loaded = True
|
||||
|
||||
def record_operation(
|
||||
self,
|
||||
operation_type: str,
|
||||
plugin_id: Optional[str] = None,
|
||||
status: str = "completed",
|
||||
user: Optional[str] = None,
|
||||
details: Optional[Dict[str, Any]] = None,
|
||||
error: Optional[str] = None,
|
||||
operation_id: Optional[str] = None
|
||||
) -> str:
|
||||
"""
|
||||
Record an operation in history.
|
||||
|
||||
Args:
|
||||
operation_type: Type of operation (install, update, uninstall, etc.)
|
||||
plugin_id: Plugin identifier
|
||||
status: Operation status
|
||||
user: User who performed operation
|
||||
details: Optional operation details
|
||||
error: Optional error message
|
||||
operation_id: Optional operation ID
|
||||
|
||||
Returns:
|
||||
Operation record ID
|
||||
"""
|
||||
self._ensure_loaded()
|
||||
import uuid
|
||||
record_id = operation_id or str(uuid.uuid4())
|
||||
|
||||
record = OperationRecord(
|
||||
operation_id=record_id,
|
||||
operation_type=operation_type,
|
||||
plugin_id=plugin_id,
|
||||
timestamp=datetime.now(),
|
||||
status=status,
|
||||
user=user,
|
||||
details=details,
|
||||
error=error
|
||||
)
|
||||
|
||||
with self._lock:
|
||||
self._history.append(record)
|
||||
|
||||
# Trim history if needed
|
||||
if len(self._history) > self.max_records:
|
||||
self._history = self._history[-self.max_records:]
|
||||
|
||||
# Save to file
|
||||
self._save_history()
|
||||
|
||||
return record_id
|
||||
|
||||
def get_history(
|
||||
self,
|
||||
limit: int = 100,
|
||||
plugin_id: Optional[str] = None,
|
||||
operation_type: Optional[str] = None
|
||||
) -> List[OperationRecord]:
|
||||
"""
|
||||
Get operation history.
|
||||
|
||||
Args:
|
||||
limit: Maximum number of records to return
|
||||
plugin_id: Optional filter by plugin ID
|
||||
operation_type: Optional filter by operation type
|
||||
|
||||
Returns:
|
||||
List of operation records, sorted by timestamp (newest first)
|
||||
"""
|
||||
self._ensure_loaded()
|
||||
with self._lock:
|
||||
history = self._history.copy()
|
||||
|
||||
# Apply filters
|
||||
if plugin_id:
|
||||
history = [r for r in history if r.plugin_id == plugin_id]
|
||||
|
||||
if operation_type:
|
||||
history = [r for r in history if r.operation_type == operation_type]
|
||||
|
||||
# Sort by timestamp (newest first)
|
||||
history.sort(key=lambda r: r.timestamp, reverse=True)
|
||||
|
||||
return history[:limit]
|
||||
|
||||
def _save_history(self) -> None:
|
||||
"""Save history to file."""
|
||||
if not self.history_file:
|
||||
return
|
||||
|
||||
try:
|
||||
with self._lock:
|
||||
history_data = [record.to_dict() for record in self._history]
|
||||
|
||||
# Ensure directory exists
|
||||
self.history_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Write to file
|
||||
with open(self.history_file, 'w') as f:
|
||||
json.dump(history_data, f, indent=2)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error saving operation history: {e}", exc_info=True)
|
||||
|
||||
def _load_history(self) -> None:
|
||||
"""Load history from file."""
|
||||
if not self.history_file or not self.history_file.exists():
|
||||
return
|
||||
|
||||
try:
|
||||
with open(self.history_file, 'r') as f:
|
||||
history_data = json.load(f)
|
||||
|
||||
with self._lock:
|
||||
self._history = [
|
||||
OperationRecord.from_dict(record_data)
|
||||
for record_data in history_data
|
||||
]
|
||||
|
||||
self.logger.info(f"Loaded {len(self._history)} operation records from file")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error loading operation history: {e}", exc_info=True)
|
||||
|
||||
384
src/plugin_system/operation_queue.py
Normal file
384
src/plugin_system/operation_queue.py
Normal file
@@ -0,0 +1,384 @@
|
||||
"""
|
||||
Plugin operation queue manager.
|
||||
|
||||
Serializes plugin operations to prevent conflicts and provides
|
||||
status tracking and cancellation support.
|
||||
"""
|
||||
|
||||
import threading
|
||||
import queue
|
||||
import time
|
||||
from typing import Dict, Optional, List, Callable, Any
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
import json
|
||||
|
||||
from src.plugin_system.operation_types import (
|
||||
PluginOperation, OperationType, OperationStatus
|
||||
)
|
||||
from src.logging_config import get_logger
|
||||
|
||||
|
||||
class PluginOperationQueue:
|
||||
"""
|
||||
Manages a queue of plugin operations, executing them serially
|
||||
to prevent conflicts.
|
||||
|
||||
Features:
|
||||
- Serialized execution (one operation at a time)
|
||||
- Prevents concurrent operations on same plugin
|
||||
- Operation status tracking
|
||||
- Operation cancellation
|
||||
- Operation history
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
history_file: Optional[str] = None,
|
||||
max_history: int = 100,
|
||||
lazy_load: bool = False
|
||||
):
|
||||
"""
|
||||
Initialize operation queue.
|
||||
|
||||
Args:
|
||||
history_file: Optional path to file for persisting operation history
|
||||
max_history: Maximum number of operations to keep in history
|
||||
lazy_load: If True, defer loading history file until first access
|
||||
"""
|
||||
self.logger = get_logger(__name__)
|
||||
self.history_file = Path(history_file) if history_file else None
|
||||
self.max_history = max_history
|
||||
self._lazy_load = lazy_load
|
||||
self._history_loaded = False
|
||||
|
||||
# Operation tracking
|
||||
self._operations: Dict[str, PluginOperation] = {}
|
||||
self._operation_queue: queue.Queue = queue.Queue()
|
||||
self._active_operations: Dict[str, PluginOperation] = {} # plugin_id -> operation
|
||||
self._operation_history: List[PluginOperation] = []
|
||||
|
||||
# Threading
|
||||
self._lock = threading.RLock()
|
||||
self._worker_thread: Optional[threading.Thread] = None
|
||||
self._stop_event = threading.Event()
|
||||
|
||||
# Load history from file if it exists (unless lazy loading)
|
||||
if not self._lazy_load and self.history_file and self.history_file.exists():
|
||||
self._load_history()
|
||||
self._history_loaded = True
|
||||
|
||||
# Start worker thread
|
||||
self._start_worker()
|
||||
|
||||
def _ensure_loaded(self) -> None:
|
||||
"""Ensure history is loaded (for lazy loading)."""
|
||||
if not self._history_loaded and self.history_file and self.history_file.exists():
|
||||
self._load_history()
|
||||
self._history_loaded = True
|
||||
|
||||
def enqueue_operation(
|
||||
self,
|
||||
operation_type: OperationType,
|
||||
plugin_id: str,
|
||||
parameters: Optional[Dict] = None,
|
||||
operation_callback: Optional[Callable[[PluginOperation], Dict[str, Any]]] = None
|
||||
) -> str:
|
||||
"""
|
||||
Enqueue a plugin operation.
|
||||
|
||||
Args:
|
||||
operation_type: Type of operation to perform
|
||||
plugin_id: Plugin identifier
|
||||
parameters: Optional operation parameters
|
||||
operation_callback: Optional callback function to execute the operation.
|
||||
If None, operation will be queued but not executed.
|
||||
|
||||
Returns:
|
||||
Operation ID for tracking
|
||||
"""
|
||||
with self._lock:
|
||||
# Check if plugin already has an active operation
|
||||
if plugin_id in self._active_operations:
|
||||
active_op = self._active_operations[plugin_id]
|
||||
if active_op.status in [OperationStatus.PENDING, OperationStatus.RUNNING]:
|
||||
raise ValueError(
|
||||
f"Plugin {plugin_id} already has an active operation: "
|
||||
f"{active_op.operation_id} ({active_op.operation_type.value})"
|
||||
)
|
||||
|
||||
# Create operation
|
||||
operation = PluginOperation(
|
||||
operation_type=operation_type,
|
||||
plugin_id=plugin_id,
|
||||
parameters=parameters or {},
|
||||
)
|
||||
|
||||
# Store callback if provided
|
||||
if operation_callback:
|
||||
operation.parameters['_callback'] = operation_callback
|
||||
|
||||
# Store operation
|
||||
self._operations[operation.operation_id] = operation
|
||||
|
||||
# Enqueue
|
||||
self._operation_queue.put(operation)
|
||||
self.logger.info(
|
||||
f"Enqueued {operation_type.value} operation for plugin {plugin_id} "
|
||||
f"(operation_id: {operation.operation_id})"
|
||||
)
|
||||
|
||||
return operation.operation_id
|
||||
|
||||
def get_operation_status(self, operation_id: str) -> Optional[PluginOperation]:
|
||||
"""
|
||||
Get status of an operation.
|
||||
|
||||
Args:
|
||||
operation_id: Operation identifier
|
||||
|
||||
Returns:
|
||||
PluginOperation if found, None otherwise
|
||||
"""
|
||||
self._ensure_loaded()
|
||||
with self._lock:
|
||||
return self._operations.get(operation_id)
|
||||
|
||||
def cancel_operation(self, operation_id: str) -> bool:
|
||||
"""
|
||||
Cancel a pending operation.
|
||||
|
||||
Args:
|
||||
operation_id: Operation identifier
|
||||
|
||||
Returns:
|
||||
True if operation was cancelled, False if not found or already running
|
||||
"""
|
||||
with self._lock:
|
||||
operation = self._operations.get(operation_id)
|
||||
if not operation:
|
||||
return False
|
||||
|
||||
if operation.status == OperationStatus.RUNNING:
|
||||
self.logger.warning(
|
||||
f"Cannot cancel running operation {operation_id}"
|
||||
)
|
||||
return False
|
||||
|
||||
if operation.status == OperationStatus.PENDING:
|
||||
operation.status = OperationStatus.CANCELLED
|
||||
operation.completed_at = datetime.now()
|
||||
operation.message = "Operation cancelled by user"
|
||||
self._add_to_history(operation)
|
||||
self.logger.info(f"Cancelled operation {operation_id}")
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def get_operation_history(self, limit: int = 50) -> List[PluginOperation]:
|
||||
"""
|
||||
Get operation history.
|
||||
|
||||
Args:
|
||||
limit: Maximum number of operations to return
|
||||
|
||||
Returns:
|
||||
List of operations, sorted by creation time (newest first)
|
||||
"""
|
||||
self._ensure_loaded()
|
||||
with self._lock:
|
||||
# Sort by creation time (newest first)
|
||||
history = sorted(
|
||||
self._operation_history,
|
||||
key=lambda op: op.created_at,
|
||||
reverse=True
|
||||
)
|
||||
return history[:limit]
|
||||
|
||||
def get_active_operations(self) -> List[PluginOperation]:
|
||||
"""
|
||||
Get all currently active operations (pending or running).
|
||||
|
||||
Returns:
|
||||
List of active operations
|
||||
"""
|
||||
with self._lock:
|
||||
active = []
|
||||
for operation in self._operations.values():
|
||||
if operation.status in [OperationStatus.PENDING, OperationStatus.RUNNING]:
|
||||
active.append(operation)
|
||||
return active
|
||||
|
||||
def _start_worker(self) -> None:
|
||||
"""Start the worker thread that processes operations."""
|
||||
if self._worker_thread and self._worker_thread.is_alive():
|
||||
return
|
||||
|
||||
self._stop_event.clear()
|
||||
self._worker_thread = threading.Thread(
|
||||
target=self._worker_loop,
|
||||
daemon=True,
|
||||
name="PluginOperationQueueWorker"
|
||||
)
|
||||
self._worker_thread.start()
|
||||
self.logger.info("Started plugin operation queue worker thread")
|
||||
|
||||
def _worker_loop(self) -> None:
|
||||
"""Worker thread loop that processes queued operations."""
|
||||
while not self._stop_event.is_set():
|
||||
try:
|
||||
# Get next operation (with timeout to allow checking stop event)
|
||||
try:
|
||||
operation = self._operation_queue.get(timeout=1.0)
|
||||
except queue.Empty:
|
||||
continue
|
||||
|
||||
# Check if operation was cancelled
|
||||
if operation.status == OperationStatus.CANCELLED:
|
||||
self._operation_queue.task_done()
|
||||
continue
|
||||
|
||||
# Execute operation
|
||||
self._execute_operation(operation)
|
||||
|
||||
self._operation_queue.task_done()
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error in operation queue worker: {e}", exc_info=True)
|
||||
|
||||
def _execute_operation(self, operation: PluginOperation) -> None:
|
||||
"""
|
||||
Execute a plugin operation.
|
||||
|
||||
Args:
|
||||
operation: Operation to execute
|
||||
"""
|
||||
with self._lock:
|
||||
# Check if plugin already has active operation
|
||||
if operation.plugin_id in self._active_operations:
|
||||
active_op = self._active_operations[operation.plugin_id]
|
||||
if active_op.operation_id != operation.operation_id:
|
||||
# Different operation for same plugin - mark as failed
|
||||
operation.status = OperationStatus.FAILED
|
||||
operation.error = f"Plugin {operation.plugin_id} has another active operation"
|
||||
operation.completed_at = datetime.now()
|
||||
self._add_to_history(operation)
|
||||
return
|
||||
|
||||
# Mark as running
|
||||
operation.status = OperationStatus.RUNNING
|
||||
operation.started_at = datetime.now()
|
||||
operation.progress = 0.0
|
||||
self._active_operations[operation.plugin_id] = operation
|
||||
|
||||
try:
|
||||
self.logger.info(
|
||||
f"Executing {operation.operation_type.value} operation for "
|
||||
f"plugin {operation.plugin_id} (operation_id: {operation.operation_id})"
|
||||
)
|
||||
|
||||
# Get callback from parameters
|
||||
callback = operation.parameters.pop('_callback', None)
|
||||
|
||||
if callback:
|
||||
# Execute callback
|
||||
operation.progress = 0.1
|
||||
result = callback(operation)
|
||||
|
||||
# Update operation with result
|
||||
operation.progress = 1.0
|
||||
operation.status = OperationStatus.COMPLETED
|
||||
operation.result = result
|
||||
operation.message = result.get('message', 'Operation completed successfully')
|
||||
|
||||
else:
|
||||
# No callback - mark as completed (operation was just queued)
|
||||
operation.progress = 1.0
|
||||
operation.status = OperationStatus.COMPLETED
|
||||
operation.message = "Operation queued (no callback provided)"
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(
|
||||
f"Error executing operation {operation.operation_id}: {e}",
|
||||
exc_info=True
|
||||
)
|
||||
operation.status = OperationStatus.FAILED
|
||||
operation.error = str(e)
|
||||
operation.message = f"Operation failed: {str(e)}"
|
||||
|
||||
finally:
|
||||
with self._lock:
|
||||
operation.completed_at = datetime.now()
|
||||
|
||||
# Remove from active operations
|
||||
if operation.plugin_id in self._active_operations:
|
||||
if self._active_operations[operation.plugin_id].operation_id == operation.operation_id:
|
||||
del self._active_operations[operation.plugin_id]
|
||||
|
||||
# Add to history
|
||||
self._add_to_history(operation)
|
||||
|
||||
# Save history to file
|
||||
self._save_history()
|
||||
|
||||
def _add_to_history(self, operation: PluginOperation) -> None:
|
||||
"""Add operation to history, maintaining max_history limit."""
|
||||
self._operation_history.append(operation)
|
||||
|
||||
# Trim history if needed
|
||||
if len(self._operation_history) > self.max_history:
|
||||
# Remove oldest operations
|
||||
self._operation_history.sort(key=lambda op: op.created_at)
|
||||
self._operation_history = self._operation_history[-self.max_history:]
|
||||
|
||||
def _save_history(self) -> None:
|
||||
"""Save operation history to file."""
|
||||
if not self.history_file:
|
||||
return
|
||||
|
||||
try:
|
||||
with self._lock:
|
||||
# Convert operations to dicts
|
||||
history_data = [op.to_dict() for op in self._operation_history]
|
||||
|
||||
# Ensure directory exists
|
||||
self.history_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Write to file
|
||||
with open(self.history_file, 'w') as f:
|
||||
json.dump(history_data, f, indent=2)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Error saving operation history: {e}")
|
||||
|
||||
def _load_history(self) -> None:
|
||||
"""Load operation history from file."""
|
||||
if not self.history_file or not self.history_file.exists():
|
||||
return
|
||||
|
||||
try:
|
||||
with open(self.history_file, 'r') as f:
|
||||
history_data = json.load(f)
|
||||
|
||||
with self._lock:
|
||||
self._operation_history = [
|
||||
PluginOperation.from_dict(op_data)
|
||||
for op_data in history_data
|
||||
]
|
||||
|
||||
self.logger.info(f"Loaded {len(self._operation_history)} operations from history")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Error loading operation history: {e}")
|
||||
|
||||
def shutdown(self) -> None:
|
||||
"""Shutdown the operation queue and worker thread."""
|
||||
self.logger.info("Shutting down plugin operation queue")
|
||||
self._stop_event.set()
|
||||
|
||||
if self._worker_thread and self._worker_thread.is_alive():
|
||||
self._worker_thread.join(timeout=5.0)
|
||||
|
||||
# Save history one last time
|
||||
self._save_history()
|
||||
|
||||
91
src/plugin_system/operation_types.py
Normal file
91
src/plugin_system/operation_types.py
Normal file
@@ -0,0 +1,91 @@
|
||||
"""
|
||||
Plugin operation type definitions.
|
||||
|
||||
Defines the types of operations that can be performed on plugins
|
||||
and their associated data structures.
|
||||
"""
|
||||
|
||||
from enum import Enum
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, Any, Optional, List
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
|
||||
|
||||
class OperationType(Enum):
|
||||
"""Types of plugin operations."""
|
||||
INSTALL = "install"
|
||||
UPDATE = "update"
|
||||
UNINSTALL = "uninstall"
|
||||
ENABLE = "enable"
|
||||
DISABLE = "disable"
|
||||
CONFIGURE = "configure"
|
||||
|
||||
|
||||
class OperationStatus(Enum):
|
||||
"""Status of an operation."""
|
||||
PENDING = "pending"
|
||||
RUNNING = "running"
|
||||
COMPLETED = "completed"
|
||||
FAILED = "failed"
|
||||
CANCELLED = "cancelled"
|
||||
|
||||
|
||||
@dataclass
|
||||
class PluginOperation:
|
||||
"""Represents a plugin operation to be executed."""
|
||||
operation_type: OperationType
|
||||
plugin_id: str
|
||||
operation_id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
||||
parameters: Dict[str, Any] = field(default_factory=dict)
|
||||
created_at: datetime = field(default_factory=datetime.now)
|
||||
status: OperationStatus = OperationStatus.PENDING
|
||||
progress: float = 0.0 # 0.0 to 1.0
|
||||
message: str = ""
|
||||
error: Optional[str] = None
|
||||
result: Optional[Dict[str, Any]] = None
|
||||
started_at: Optional[datetime] = None
|
||||
completed_at: Optional[datetime] = None
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert operation to dictionary for serialization."""
|
||||
return {
|
||||
'operation_id': self.operation_id,
|
||||
'operation_type': self.operation_type.value,
|
||||
'plugin_id': self.plugin_id,
|
||||
'parameters': self.parameters,
|
||||
'status': self.status.value,
|
||||
'progress': self.progress,
|
||||
'message': self.message,
|
||||
'error': self.error,
|
||||
'result': self.result,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||
'started_at': self.started_at.isoformat() if self.started_at else None,
|
||||
'completed_at': self.completed_at.isoformat() if self.completed_at else None,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'PluginOperation':
|
||||
"""Create operation from dictionary."""
|
||||
op = cls(
|
||||
operation_type=OperationType(data['operation_type']),
|
||||
plugin_id=data['plugin_id'],
|
||||
operation_id=data.get('operation_id', str(uuid.uuid4())),
|
||||
parameters=data.get('parameters', {}),
|
||||
status=OperationStatus(data.get('status', 'pending')),
|
||||
progress=data.get('progress', 0.0),
|
||||
message=data.get('message', ''),
|
||||
error=data.get('error'),
|
||||
result=data.get('result'),
|
||||
)
|
||||
|
||||
# Parse datetime fields
|
||||
if data.get('created_at'):
|
||||
op.created_at = datetime.fromisoformat(data['created_at'])
|
||||
if data.get('started_at'):
|
||||
op.started_at = datetime.fromisoformat(data['started_at'])
|
||||
if data.get('completed_at'):
|
||||
op.completed_at = datetime.fromisoformat(data['completed_at'])
|
||||
|
||||
return op
|
||||
|
||||
252
src/plugin_system/plugin_executor.py
Normal file
252
src/plugin_system/plugin_executor.py
Normal file
@@ -0,0 +1,252 @@
|
||||
"""
|
||||
Plugin Executor
|
||||
|
||||
Handles plugin execution (update() and display() calls) with timeout handling,
|
||||
error isolation, and performance monitoring.
|
||||
"""
|
||||
|
||||
import time
|
||||
import signal
|
||||
from typing import Any, Optional, Dict, Callable
|
||||
from threading import Thread, Event
|
||||
import logging
|
||||
|
||||
from src.exceptions import PluginError
|
||||
from src.logging_config import get_logger
|
||||
|
||||
|
||||
class TimeoutError(Exception):
|
||||
"""Raised when a plugin operation times out."""
|
||||
pass
|
||||
|
||||
|
||||
class PluginExecutor:
|
||||
"""Handles plugin execution with timeout and error isolation."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
default_timeout: float = 30.0,
|
||||
logger: Optional[logging.Logger] = None
|
||||
) -> None:
|
||||
"""
|
||||
Initialize the plugin executor.
|
||||
|
||||
Args:
|
||||
default_timeout: Default timeout in seconds for plugin operations
|
||||
logger: Optional logger instance
|
||||
"""
|
||||
self.default_timeout = default_timeout
|
||||
self.logger = logger or get_logger(__name__)
|
||||
|
||||
def execute_with_timeout(
|
||||
self,
|
||||
operation: Callable[[], Any],
|
||||
timeout: Optional[float] = None,
|
||||
plugin_id: Optional[str] = None
|
||||
) -> Any:
|
||||
"""
|
||||
Execute a plugin operation with timeout.
|
||||
|
||||
Args:
|
||||
operation: Function to execute
|
||||
timeout: Timeout in seconds (None = use default)
|
||||
plugin_id: Optional plugin ID for logging
|
||||
|
||||
Returns:
|
||||
Result of operation
|
||||
|
||||
Raises:
|
||||
TimeoutError: If operation times out
|
||||
PluginError: If operation raises an exception
|
||||
"""
|
||||
timeout = timeout or self.default_timeout
|
||||
plugin_context = f"plugin {plugin_id}" if plugin_id else "plugin"
|
||||
|
||||
# Use threading-based timeout (more reliable than signal-based)
|
||||
result_container = {'value': None, 'exception': None, 'completed': False}
|
||||
|
||||
def target():
|
||||
try:
|
||||
result_container['value'] = operation()
|
||||
result_container['completed'] = True
|
||||
except Exception as e:
|
||||
result_container['exception'] = e
|
||||
result_container['completed'] = True
|
||||
|
||||
thread = Thread(target=target, daemon=True)
|
||||
thread.start()
|
||||
thread.join(timeout=timeout)
|
||||
|
||||
if not result_container['completed']:
|
||||
error_msg = f"{plugin_context} operation timed out after {timeout}s"
|
||||
self.logger.error(error_msg)
|
||||
raise TimeoutError(error_msg)
|
||||
|
||||
if result_container['exception']:
|
||||
error = result_container['exception']
|
||||
error_msg = f"{plugin_context} operation failed: {error}"
|
||||
self.logger.error(error_msg, exc_info=True)
|
||||
raise PluginError(error_msg, plugin_id=plugin_id) from error
|
||||
|
||||
return result_container['value']
|
||||
|
||||
def execute_update(
|
||||
self,
|
||||
plugin: Any,
|
||||
plugin_id: str,
|
||||
timeout: Optional[float] = None
|
||||
) -> bool:
|
||||
"""
|
||||
Execute plugin update() method with error handling.
|
||||
|
||||
Args:
|
||||
plugin: Plugin instance
|
||||
plugin_id: Plugin identifier
|
||||
timeout: Timeout in seconds (None = use default)
|
||||
|
||||
Returns:
|
||||
True if update succeeded, False otherwise
|
||||
"""
|
||||
try:
|
||||
start_time = time.time()
|
||||
self.execute_with_timeout(
|
||||
lambda: plugin.update(),
|
||||
timeout=timeout,
|
||||
plugin_id=plugin_id
|
||||
)
|
||||
duration = time.time() - start_time
|
||||
|
||||
if duration > 5.0: # Warn if update takes more than 5 seconds
|
||||
self.logger.warning(
|
||||
"Plugin %s update() took %.2fs (consider optimizing)",
|
||||
plugin_id,
|
||||
duration
|
||||
)
|
||||
|
||||
return True
|
||||
except TimeoutError:
|
||||
self.logger.error("Plugin %s update() timed out", plugin_id)
|
||||
return False
|
||||
except PluginError:
|
||||
# Already logged in execute_with_timeout
|
||||
return False
|
||||
except Exception as e:
|
||||
self.logger.error(
|
||||
"Unexpected error executing update() for plugin %s: %s",
|
||||
plugin_id,
|
||||
e,
|
||||
exc_info=True
|
||||
)
|
||||
return False
|
||||
|
||||
def execute_display(
|
||||
self,
|
||||
plugin: Any,
|
||||
plugin_id: str,
|
||||
force_clear: bool = False,
|
||||
display_mode: Optional[str] = None,
|
||||
timeout: Optional[float] = None
|
||||
) -> bool:
|
||||
"""
|
||||
Execute plugin display() method with error handling.
|
||||
|
||||
Args:
|
||||
plugin: Plugin instance
|
||||
plugin_id: Plugin identifier
|
||||
force_clear: Whether to force clear display
|
||||
display_mode: Optional display mode parameter
|
||||
timeout: Timeout in seconds (None = use default)
|
||||
|
||||
Returns:
|
||||
True if display succeeded, False otherwise
|
||||
"""
|
||||
try:
|
||||
start_time = time.time()
|
||||
|
||||
# Check if plugin accepts display_mode parameter
|
||||
import inspect
|
||||
sig = inspect.signature(plugin.display)
|
||||
has_display_mode = 'display_mode' in sig.parameters
|
||||
|
||||
# Capture the return value from the plugin's display() method
|
||||
if has_display_mode and display_mode:
|
||||
result = self.execute_with_timeout(
|
||||
lambda: plugin.display(display_mode=display_mode, force_clear=force_clear),
|
||||
timeout=timeout,
|
||||
plugin_id=plugin_id
|
||||
)
|
||||
else:
|
||||
result = self.execute_with_timeout(
|
||||
lambda: plugin.display(force_clear=force_clear),
|
||||
timeout=timeout,
|
||||
plugin_id=plugin_id
|
||||
)
|
||||
|
||||
duration = time.time() - start_time
|
||||
|
||||
if duration > 2.0: # Warn if display takes more than 2 seconds
|
||||
self.logger.warning(
|
||||
"Plugin %s display() took %.2fs (consider optimizing)",
|
||||
plugin_id,
|
||||
duration
|
||||
)
|
||||
|
||||
# Return the actual result from the plugin's display() method
|
||||
# If it's a boolean, use it directly. Otherwise, treat None/other as True for backward compatibility
|
||||
if isinstance(result, bool):
|
||||
self.logger.debug(f"Plugin {plugin_id} display() returned boolean: {result}")
|
||||
return result
|
||||
# For backward compatibility: if plugin returns None or something else, treat as success
|
||||
self.logger.debug(f"Plugin {plugin_id} display() returned non-boolean: {result}, treating as True")
|
||||
return True
|
||||
except TimeoutError:
|
||||
self.logger.error("Plugin %s display() timed out", plugin_id)
|
||||
return False
|
||||
except PluginError:
|
||||
# Already logged in execute_with_timeout
|
||||
return False
|
||||
except Exception as e:
|
||||
self.logger.error(
|
||||
"Unexpected error executing display() for plugin %s: %s",
|
||||
plugin_id,
|
||||
e,
|
||||
exc_info=True
|
||||
)
|
||||
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 (TimeoutError, PluginError, Exception) as e:
|
||||
self.logger.warning(
|
||||
"Plugin %s %s failed, using default return: %s",
|
||||
plugin_id,
|
||||
operation_name,
|
||||
e
|
||||
)
|
||||
return default_return
|
||||
|
||||
224
src/plugin_system/plugin_health.py
Normal file
224
src/plugin_system/plugin_health.py
Normal file
@@ -0,0 +1,224 @@
|
||||
"""
|
||||
Plugin Health Tracker
|
||||
|
||||
Tracks plugin health metrics including success/failure rates, consecutive failures,
|
||||
and circuit breaker state. Provides automatic recovery mechanisms.
|
||||
"""
|
||||
|
||||
import time
|
||||
import logging
|
||||
from typing import Dict, Optional, Any
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class CircuitState(Enum):
|
||||
"""Circuit breaker states."""
|
||||
CLOSED = "closed" # Normal operation
|
||||
OPEN = "open" # Circuit open, skipping calls
|
||||
HALF_OPEN = "half_open" # Testing if plugin recovered
|
||||
|
||||
|
||||
class PluginHealthTracker:
|
||||
"""
|
||||
Tracks plugin health and manages circuit breaker state.
|
||||
|
||||
Circuit breaker pattern:
|
||||
- CLOSED: Plugin is healthy, calls proceed normally
|
||||
- OPEN: Plugin has failed too many times, calls are skipped
|
||||
- HALF_OPEN: Testing if plugin has recovered (after cooldown)
|
||||
"""
|
||||
|
||||
def __init__(self, cache_manager, failure_threshold: int = 3,
|
||||
cooldown_period: float = 300.0, half_open_timeout: float = 60.0):
|
||||
"""
|
||||
Initialize plugin health tracker.
|
||||
|
||||
Args:
|
||||
cache_manager: Cache manager instance for persistence
|
||||
failure_threshold: Number of consecutive failures before opening circuit
|
||||
cooldown_period: Seconds to wait before attempting recovery (default: 5 minutes)
|
||||
half_open_timeout: Seconds to wait in half-open state before closing (default: 1 minute)
|
||||
"""
|
||||
self.cache_manager = cache_manager
|
||||
self.failure_threshold = failure_threshold
|
||||
self.cooldown_period = cooldown_period
|
||||
self.half_open_timeout = half_open_timeout
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
# In-memory health state (also persisted to cache)
|
||||
self._health_state: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
def _get_health_key(self, plugin_id: str) -> str:
|
||||
"""Get cache key for plugin health data."""
|
||||
return f"plugin_health:{plugin_id}"
|
||||
|
||||
def _load_health_state(self, plugin_id: str) -> Dict[str, Any]:
|
||||
"""Load health state from cache or return defaults."""
|
||||
cache_key = self._get_health_key(plugin_id)
|
||||
cached = self.cache_manager.get(cache_key, max_age=None)
|
||||
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
# Default state
|
||||
return {
|
||||
'consecutive_failures': 0,
|
||||
'total_failures': 0,
|
||||
'total_successes': 0,
|
||||
'last_success_time': None,
|
||||
'last_failure_time': None,
|
||||
'circuit_state': CircuitState.CLOSED.value,
|
||||
'circuit_opened_time': None,
|
||||
'half_open_start_time': None,
|
||||
'last_error': None
|
||||
}
|
||||
|
||||
def _save_health_state(self, plugin_id: str, state: Dict[str, Any]) -> None:
|
||||
"""Save health state to cache."""
|
||||
cache_key = self._get_health_key(plugin_id)
|
||||
self.cache_manager.set(cache_key, state) # Persist indefinitely
|
||||
self._health_state[plugin_id] = state
|
||||
|
||||
def get_health_state(self, plugin_id: str) -> Dict[str, Any]:
|
||||
"""Get current health state for a plugin."""
|
||||
if plugin_id not in self._health_state:
|
||||
self._health_state[plugin_id] = self._load_health_state(plugin_id)
|
||||
return self._health_state[plugin_id]
|
||||
|
||||
def record_success(self, plugin_id: str) -> None:
|
||||
"""Record a successful plugin execution."""
|
||||
state = self.get_health_state(plugin_id)
|
||||
current_time = time.time()
|
||||
|
||||
# Reset consecutive failures
|
||||
state['consecutive_failures'] = 0
|
||||
state['total_successes'] = state.get('total_successes', 0) + 1
|
||||
state['last_success_time'] = current_time
|
||||
|
||||
# Update circuit state
|
||||
if state['circuit_state'] == CircuitState.HALF_OPEN.value:
|
||||
# Success in half-open state, close the circuit
|
||||
state['circuit_state'] = CircuitState.CLOSED.value
|
||||
state['half_open_start_time'] = None
|
||||
self.logger.info(f"Plugin {plugin_id} recovered, circuit closed")
|
||||
elif state['circuit_state'] == CircuitState.OPEN.value:
|
||||
# Shouldn't happen, but handle it
|
||||
state['circuit_state'] = CircuitState.CLOSED.value
|
||||
state['circuit_opened_time'] = None
|
||||
|
||||
self._save_health_state(plugin_id, state)
|
||||
|
||||
def record_failure(self, plugin_id: str, error: Optional[Exception] = None) -> None:
|
||||
"""Record a failed plugin execution."""
|
||||
state = self.get_health_state(plugin_id)
|
||||
current_time = time.time()
|
||||
|
||||
# Increment failure counters
|
||||
state['consecutive_failures'] = state.get('consecutive_failures', 0) + 1
|
||||
state['total_failures'] = state.get('total_failures', 0) + 1
|
||||
state['last_failure_time'] = current_time
|
||||
|
||||
# Store error message
|
||||
if error:
|
||||
state['last_error'] = str(error)
|
||||
|
||||
# Check if we should open the circuit
|
||||
if state['consecutive_failures'] >= self.failure_threshold:
|
||||
if state['circuit_state'] == CircuitState.CLOSED.value:
|
||||
state['circuit_state'] = CircuitState.OPEN.value
|
||||
state['circuit_opened_time'] = current_time
|
||||
self.logger.warning(
|
||||
f"Plugin {plugin_id} circuit opened after {state['consecutive_failures']} consecutive failures"
|
||||
)
|
||||
elif state['circuit_state'] == CircuitState.HALF_OPEN.value:
|
||||
# Failed again in half-open, reopen circuit
|
||||
state['circuit_state'] = CircuitState.OPEN.value
|
||||
state['circuit_opened_time'] = current_time
|
||||
state['half_open_start_time'] = None
|
||||
self.logger.warning(f"Plugin {plugin_id} failed in half-open state, circuit reopened")
|
||||
|
||||
self._save_health_state(plugin_id, state)
|
||||
|
||||
def should_skip_plugin(self, plugin_id: str) -> bool:
|
||||
"""
|
||||
Check if plugin should be skipped due to circuit breaker.
|
||||
|
||||
Returns:
|
||||
True if plugin should be skipped, False if it should be called
|
||||
"""
|
||||
state = self.get_health_state(plugin_id)
|
||||
current_time = time.time()
|
||||
circuit_state = state.get('circuit_state', CircuitState.CLOSED.value)
|
||||
|
||||
if circuit_state == CircuitState.CLOSED.value:
|
||||
return False
|
||||
|
||||
if circuit_state == CircuitState.OPEN.value:
|
||||
# Check if cooldown period has passed
|
||||
circuit_opened_time = state.get('circuit_opened_time')
|
||||
if circuit_opened_time and (current_time - circuit_opened_time) >= self.cooldown_period:
|
||||
# Move to half-open state
|
||||
state['circuit_state'] = CircuitState.HALF_OPEN.value
|
||||
state['half_open_start_time'] = current_time
|
||||
state['circuit_opened_time'] = None
|
||||
self._save_health_state(plugin_id, state)
|
||||
self.logger.info(f"Plugin {plugin_id} circuit moved to half-open state for testing")
|
||||
return False # Allow one attempt
|
||||
return True # Still in cooldown
|
||||
|
||||
if circuit_state == CircuitState.HALF_OPEN.value:
|
||||
# In half-open state, allow calls but check timeout
|
||||
half_open_start = state.get('half_open_start_time')
|
||||
if half_open_start and (current_time - half_open_start) >= self.half_open_timeout:
|
||||
# Timeout in half-open, close circuit if no failures
|
||||
if state.get('consecutive_failures', 0) == 0:
|
||||
state['circuit_state'] = CircuitState.CLOSED.value
|
||||
state['half_open_start_time'] = None
|
||||
self._save_health_state(plugin_id, state)
|
||||
self.logger.info(f"Plugin {plugin_id} circuit closed after successful half-open period")
|
||||
return False
|
||||
return False # Allow calls in half-open
|
||||
|
||||
return False
|
||||
|
||||
def get_health_summary(self, plugin_id: str) -> Dict[str, Any]:
|
||||
"""Get health summary for a plugin."""
|
||||
state = self.get_health_state(plugin_id)
|
||||
|
||||
total_calls = state.get('total_successes', 0) + state.get('total_failures', 0)
|
||||
success_rate = 0.0
|
||||
if total_calls > 0:
|
||||
success_rate = state.get('total_successes', 0) / total_calls * 100
|
||||
|
||||
return {
|
||||
'plugin_id': plugin_id,
|
||||
'circuit_state': state.get('circuit_state', CircuitState.CLOSED.value),
|
||||
'consecutive_failures': state.get('consecutive_failures', 0),
|
||||
'total_failures': state.get('total_failures', 0),
|
||||
'total_successes': state.get('total_successes', 0),
|
||||
'success_rate': round(success_rate, 2),
|
||||
'last_success_time': state.get('last_success_time'),
|
||||
'last_failure_time': state.get('last_failure_time'),
|
||||
'last_error': state.get('last_error'),
|
||||
'is_healthy': state.get('circuit_state') == CircuitState.CLOSED.value,
|
||||
'circuit_opened_time': state.get('circuit_opened_time'),
|
||||
'half_open_start_time': state.get('half_open_start_time')
|
||||
}
|
||||
|
||||
def get_all_health_summaries(self) -> Dict[str, Dict[str, Any]]:
|
||||
"""Get health summaries for all tracked plugins."""
|
||||
summaries = {}
|
||||
for plugin_id in self._health_state.keys():
|
||||
summaries[plugin_id] = self.get_health_summary(plugin_id)
|
||||
return summaries
|
||||
|
||||
def reset_health(self, plugin_id: str) -> None:
|
||||
"""Reset health state for a plugin (manual recovery)."""
|
||||
state = self._load_health_state(plugin_id)
|
||||
state['consecutive_failures'] = 0
|
||||
state['circuit_state'] = CircuitState.CLOSED.value
|
||||
state['circuit_opened_time'] = None
|
||||
state['half_open_start_time'] = None
|
||||
self._save_health_state(plugin_id, state)
|
||||
self.logger.info(f"Health state reset for plugin {plugin_id}")
|
||||
|
||||
382
src/plugin_system/plugin_loader.py
Normal file
382
src/plugin_system/plugin_loader.py
Normal file
@@ -0,0 +1,382 @@
|
||||
"""
|
||||
Plugin Loader
|
||||
|
||||
Handles plugin module imports, dependency installation, and class instantiation.
|
||||
Extracted from PluginManager to improve separation of concerns.
|
||||
"""
|
||||
|
||||
import json
|
||||
import importlib
|
||||
import importlib.util
|
||||
import sys
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, Optional, Tuple, Type
|
||||
import logging
|
||||
|
||||
from src.exceptions import PluginError
|
||||
from src.logging_config import get_logger
|
||||
from src.common.permission_utils import (
|
||||
ensure_file_permissions,
|
||||
get_plugin_file_mode
|
||||
)
|
||||
|
||||
|
||||
class PluginLoader:
|
||||
"""Handles plugin module loading and class instantiation."""
|
||||
|
||||
def __init__(self, logger: Optional[logging.Logger] = None) -> None:
|
||||
"""
|
||||
Initialize the plugin loader.
|
||||
|
||||
Args:
|
||||
logger: Optional logger instance
|
||||
"""
|
||||
self.logger = logger or get_logger(__name__)
|
||||
self._loaded_modules: Dict[str, Any] = {}
|
||||
|
||||
def find_plugin_directory(
|
||||
self,
|
||||
plugin_id: str,
|
||||
plugins_dir: Path,
|
||||
plugin_directories: Optional[Dict[str, Path]] = None
|
||||
) -> Optional[Path]:
|
||||
"""
|
||||
Find the plugin directory for a given plugin ID.
|
||||
|
||||
Tries multiple strategies:
|
||||
1. Use plugin_directories mapping if available
|
||||
2. Direct path matching
|
||||
3. Case-insensitive directory matching
|
||||
4. Manifest-based search
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin identifier
|
||||
plugins_dir: Base plugins directory
|
||||
plugin_directories: Optional mapping of plugin_id to directory
|
||||
|
||||
Returns:
|
||||
Path to plugin directory or None if not found
|
||||
"""
|
||||
# Strategy 1: Use mapping from discovery
|
||||
if plugin_directories and plugin_id in plugin_directories:
|
||||
plugin_dir = plugin_directories[plugin_id]
|
||||
if plugin_dir.exists():
|
||||
self.logger.debug("Using plugin directory from discovery mapping: %s", plugin_dir)
|
||||
return plugin_dir
|
||||
|
||||
# Strategy 2: Direct paths
|
||||
plugin_dir = plugins_dir / plugin_id
|
||||
if plugin_dir.exists():
|
||||
return plugin_dir
|
||||
|
||||
plugin_dir = plugins_dir / f"ledmatrix-{plugin_id}"
|
||||
if plugin_dir.exists():
|
||||
return plugin_dir
|
||||
|
||||
# Strategy 3: Case-insensitive search
|
||||
normalized_id = plugin_id.lower()
|
||||
for item in plugins_dir.iterdir():
|
||||
if not item.is_dir():
|
||||
continue
|
||||
|
||||
item_name = item.name
|
||||
if item_name.lower() == normalized_id:
|
||||
return item
|
||||
|
||||
if item_name.lower() == f"ledmatrix-{plugin_id}".lower():
|
||||
return item
|
||||
|
||||
# Strategy 4: Manifest-based search
|
||||
self.logger.debug("Directory name search failed for %s, searching by manifest...", plugin_id)
|
||||
for item in plugins_dir.iterdir():
|
||||
if not item.is_dir():
|
||||
continue
|
||||
|
||||
# Skip if already checked
|
||||
if item.name.lower() == normalized_id or item.name.lower() == f"ledmatrix-{plugin_id}".lower():
|
||||
continue
|
||||
|
||||
manifest_path = item / "manifest.json"
|
||||
if manifest_path.exists():
|
||||
try:
|
||||
with open(manifest_path, 'r', encoding='utf-8') as f:
|
||||
item_manifest = json.load(f)
|
||||
item_manifest_id = item_manifest.get('id')
|
||||
if item_manifest_id == plugin_id:
|
||||
self.logger.info(
|
||||
"Found plugin %s in directory %s (manifest ID matches)",
|
||||
plugin_id,
|
||||
item.name
|
||||
)
|
||||
return item
|
||||
except (json.JSONDecodeError, Exception) as e:
|
||||
self.logger.debug("Skipping %s due to manifest error: %s", item.name, e)
|
||||
continue
|
||||
|
||||
return None
|
||||
|
||||
def install_dependencies(
|
||||
self,
|
||||
plugin_dir: Path,
|
||||
plugin_id: str,
|
||||
timeout: int = 300
|
||||
) -> bool:
|
||||
"""
|
||||
Install plugin dependencies from requirements.txt.
|
||||
|
||||
Args:
|
||||
plugin_dir: Plugin directory path
|
||||
plugin_id: Plugin identifier
|
||||
timeout: Installation timeout in seconds
|
||||
|
||||
Returns:
|
||||
True if dependencies installed or not needed, False on error
|
||||
"""
|
||||
requirements_file = plugin_dir / "requirements.txt"
|
||||
if not requirements_file.exists():
|
||||
return True # No dependencies needed
|
||||
|
||||
# Check if already installed
|
||||
marker_path = plugin_dir / ".dependencies_installed"
|
||||
if marker_path.exists():
|
||||
self.logger.debug("Dependencies already installed for %s", plugin_id)
|
||||
return True
|
||||
|
||||
try:
|
||||
self.logger.info("Installing dependencies for plugin %s...", plugin_id)
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "pip", "install", "--break-system-packages", "-r", str(requirements_file)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
check=False
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
# Mark as installed
|
||||
marker_path.touch()
|
||||
# Set proper file permissions after creating marker
|
||||
ensure_file_permissions(marker_path, get_plugin_file_mode())
|
||||
self.logger.info("Dependencies installed successfully for %s", plugin_id)
|
||||
return True
|
||||
else:
|
||||
self.logger.warning(
|
||||
"Dependency installation returned non-zero exit code for %s: %s",
|
||||
plugin_id,
|
||||
result.stderr
|
||||
)
|
||||
return False
|
||||
except subprocess.TimeoutExpired:
|
||||
self.logger.error("Dependency installation timed out for %s", plugin_id)
|
||||
return False
|
||||
except FileNotFoundError:
|
||||
self.logger.warning("pip not found. Skipping dependency installation for %s", plugin_id)
|
||||
return True
|
||||
except (BrokenPipeError, OSError) as e:
|
||||
# Handle broken pipe errors (errno 32) which can occur during pip downloads
|
||||
# Often caused by network interruptions or output buffer issues
|
||||
if isinstance(e, OSError) and e.errno == 32:
|
||||
self.logger.error(
|
||||
"Broken pipe error during dependency installation for %s. "
|
||||
"This usually indicates a network interruption or pip output buffer issue. "
|
||||
"Try installing again or check your network connection.", plugin_id
|
||||
)
|
||||
else:
|
||||
self.logger.error("OS error during dependency installation for %s: %s", plugin_id, e)
|
||||
return False
|
||||
except Exception as e:
|
||||
self.logger.error("Unexpected error installing dependencies for %s: %s", plugin_id, e, exc_info=True)
|
||||
return False
|
||||
|
||||
def load_module(
|
||||
self,
|
||||
plugin_id: str,
|
||||
plugin_dir: Path,
|
||||
entry_point: str
|
||||
) -> Optional[Any]:
|
||||
"""
|
||||
Load a plugin module from file.
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin identifier
|
||||
plugin_dir: Plugin directory path
|
||||
entry_point: Entry point filename (e.g., 'manager.py')
|
||||
|
||||
Returns:
|
||||
Loaded module or None on error
|
||||
"""
|
||||
entry_file = plugin_dir / entry_point
|
||||
if not entry_file.exists():
|
||||
error_msg = f"Entry point file not found: {entry_file} for plugin {plugin_id}"
|
||||
self.logger.error(error_msg)
|
||||
raise PluginError(error_msg, plugin_id=plugin_id, context={'entry_file': str(entry_file)})
|
||||
|
||||
# Add plugin directory to sys.path if not already there
|
||||
plugin_dir_str = str(plugin_dir)
|
||||
if plugin_dir_str not in sys.path:
|
||||
sys.path.insert(0, plugin_dir_str)
|
||||
self.logger.debug("Added plugin directory to sys.path: %s", plugin_dir_str)
|
||||
|
||||
# Import the plugin module
|
||||
module_name = f"plugin_{plugin_id.replace('-', '_')}"
|
||||
|
||||
# Check if already loaded
|
||||
if module_name in sys.modules:
|
||||
self.logger.debug("Module %s already loaded, reusing", module_name)
|
||||
return sys.modules[module_name]
|
||||
|
||||
spec = importlib.util.spec_from_file_location(module_name, entry_file)
|
||||
if spec is None or spec.loader is None:
|
||||
error_msg = f"Could not create module spec for {entry_file}"
|
||||
self.logger.error(error_msg)
|
||||
raise PluginError(error_msg, plugin_id=plugin_id, context={'entry_file': str(entry_file)})
|
||||
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[module_name] = module
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
self._loaded_modules[plugin_id] = module
|
||||
self.logger.debug("Loaded module %s for plugin %s", module_name, plugin_id)
|
||||
|
||||
return module
|
||||
|
||||
def get_plugin_class(
|
||||
self,
|
||||
plugin_id: str,
|
||||
module: Any,
|
||||
class_name: str
|
||||
) -> Type[Any]:
|
||||
"""
|
||||
Get the plugin class from a loaded module.
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin identifier
|
||||
module: Loaded module
|
||||
class_name: Name of the plugin class
|
||||
|
||||
Returns:
|
||||
Plugin class
|
||||
|
||||
Raises:
|
||||
PluginError: If class not found
|
||||
"""
|
||||
if not hasattr(module, class_name):
|
||||
error_msg = f"Class {class_name} not found in module for plugin {plugin_id}"
|
||||
self.logger.error(error_msg)
|
||||
raise PluginError(
|
||||
error_msg,
|
||||
plugin_id=plugin_id,
|
||||
context={'class_name': class_name, 'module': module.__name__}
|
||||
)
|
||||
|
||||
plugin_class = getattr(module, class_name)
|
||||
|
||||
# Verify it's a class
|
||||
if not isinstance(plugin_class, type):
|
||||
error_msg = f"{class_name} is not a class in module for plugin {plugin_id}"
|
||||
self.logger.error(error_msg)
|
||||
raise PluginError(error_msg, plugin_id=plugin_id, context={'class_name': class_name})
|
||||
|
||||
return plugin_class
|
||||
|
||||
def instantiate_plugin(
|
||||
self,
|
||||
plugin_id: str,
|
||||
plugin_class: Type[Any],
|
||||
config: Dict[str, Any],
|
||||
display_manager: Any,
|
||||
cache_manager: Any,
|
||||
plugin_manager: Any
|
||||
) -> Any:
|
||||
"""
|
||||
Instantiate a plugin class.
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin identifier
|
||||
plugin_class: Plugin class to instantiate
|
||||
config: Plugin configuration
|
||||
display_manager: Display manager instance
|
||||
cache_manager: Cache manager instance
|
||||
plugin_manager: Plugin manager instance
|
||||
|
||||
Returns:
|
||||
Plugin instance
|
||||
|
||||
Raises:
|
||||
PluginError: If instantiation fails
|
||||
"""
|
||||
try:
|
||||
plugin_instance = plugin_class(
|
||||
plugin_id=plugin_id,
|
||||
config=config,
|
||||
display_manager=display_manager,
|
||||
cache_manager=cache_manager,
|
||||
plugin_manager=plugin_manager
|
||||
)
|
||||
self.logger.debug("Instantiated plugin %s", plugin_id)
|
||||
return plugin_instance
|
||||
except Exception as e:
|
||||
error_msg = f"Failed to instantiate plugin {plugin_id}: {e}"
|
||||
self.logger.error(error_msg, exc_info=True)
|
||||
raise PluginError(error_msg, plugin_id=plugin_id) from e
|
||||
|
||||
def load_plugin(
|
||||
self,
|
||||
plugin_id: str,
|
||||
manifest: Dict[str, Any],
|
||||
plugin_dir: Path,
|
||||
config: Dict[str, Any],
|
||||
display_manager: Any,
|
||||
cache_manager: Any,
|
||||
plugin_manager: Any,
|
||||
install_deps: bool = True
|
||||
) -> Tuple[Any, Any]:
|
||||
"""
|
||||
Complete plugin loading process.
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin identifier
|
||||
manifest: Plugin manifest
|
||||
plugin_dir: Plugin directory path
|
||||
config: Plugin configuration
|
||||
display_manager: Display manager instance
|
||||
cache_manager: Cache manager instance
|
||||
plugin_manager: Plugin manager instance
|
||||
install_deps: Whether to install dependencies
|
||||
|
||||
Returns:
|
||||
Tuple of (plugin_instance, module)
|
||||
|
||||
Raises:
|
||||
PluginError: If loading fails
|
||||
"""
|
||||
# Install dependencies if needed
|
||||
if install_deps:
|
||||
self.install_dependencies(plugin_dir, plugin_id)
|
||||
|
||||
# Load module
|
||||
entry_point = manifest.get('entry_point', 'manager.py')
|
||||
module = self.load_module(plugin_id, plugin_dir, entry_point)
|
||||
if module is None:
|
||||
raise PluginError(f"Failed to load module for plugin {plugin_id}", plugin_id=plugin_id)
|
||||
|
||||
# Get plugin class
|
||||
class_name = manifest.get('class_name')
|
||||
if not class_name:
|
||||
raise PluginError(f"No class_name in manifest for plugin {plugin_id}", plugin_id=plugin_id)
|
||||
|
||||
plugin_class = self.get_plugin_class(plugin_id, module, class_name)
|
||||
|
||||
# Instantiate plugin
|
||||
plugin_instance = self.instantiate_plugin(
|
||||
plugin_id,
|
||||
plugin_class,
|
||||
config,
|
||||
display_manager,
|
||||
cache_manager,
|
||||
plugin_manager
|
||||
)
|
||||
|
||||
return (plugin_instance, module)
|
||||
|
||||
767
src/plugin_system/plugin_manager.py
Normal file
767
src/plugin_system/plugin_manager.py
Normal file
@@ -0,0 +1,767 @@
|
||||
"""
|
||||
Plugin Manager
|
||||
|
||||
Manages plugin discovery, loading, and lifecycle for the LEDMatrix system.
|
||||
Handles dynamic plugin loading from the plugins/ directory.
|
||||
|
||||
API Version: 1.0.0
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import importlib
|
||||
import importlib.util
|
||||
import sys
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Any
|
||||
import logging
|
||||
from src.exceptions import PluginError
|
||||
from src.logging_config import get_logger
|
||||
from src.plugin_system.plugin_loader import PluginLoader
|
||||
from src.plugin_system.plugin_executor import PluginExecutor
|
||||
from src.plugin_system.plugin_state import PluginStateManager, PluginState
|
||||
from src.plugin_system.schema_manager import SchemaManager
|
||||
from src.common.permission_utils import (
|
||||
ensure_directory_permissions,
|
||||
get_plugin_dir_mode
|
||||
)
|
||||
|
||||
|
||||
class PluginManager:
|
||||
"""
|
||||
Manages plugin discovery, loading, and lifecycle.
|
||||
|
||||
The PluginManager is responsible for:
|
||||
- Discovering plugins in the plugins/ directory
|
||||
- Loading plugin modules and instantiating plugin classes
|
||||
- Managing plugin lifecycle (load, unload, reload)
|
||||
- Providing access to loaded plugins
|
||||
- Maintaining plugin manifests
|
||||
|
||||
Uses composition with specialized components:
|
||||
- PluginLoader: Handles module loading and dependency installation
|
||||
- PluginExecutor: Handles plugin execution with timeout and error isolation
|
||||
- PluginStateManager: Manages plugin state machine
|
||||
"""
|
||||
|
||||
def __init__(self, plugins_dir: str = "plugins",
|
||||
config_manager: Optional[Any] = None,
|
||||
display_manager: Optional[Any] = None,
|
||||
cache_manager: Optional[Any] = None,
|
||||
font_manager: Optional[Any] = None) -> None:
|
||||
"""
|
||||
Initialize the Plugin Manager.
|
||||
|
||||
Args:
|
||||
plugins_dir: Path to the plugins directory
|
||||
config_manager: Configuration manager instance
|
||||
display_manager: Display manager instance
|
||||
cache_manager: Cache manager instance
|
||||
font_manager: Font manager instance
|
||||
"""
|
||||
self.plugins_dir: Path = Path(plugins_dir)
|
||||
self.config_manager: Optional[Any] = config_manager
|
||||
self.display_manager: Optional[Any] = display_manager
|
||||
self.cache_manager: Optional[Any] = cache_manager
|
||||
self.font_manager: Optional[Any] = font_manager
|
||||
self.logger: logging.Logger = get_logger(__name__)
|
||||
|
||||
# Initialize plugin system components
|
||||
self.plugin_loader = PluginLoader(logger=self.logger)
|
||||
self.plugin_executor = PluginExecutor(default_timeout=30.0, logger=self.logger)
|
||||
self.state_manager = PluginStateManager(logger=self.logger)
|
||||
self.schema_manager = SchemaManager(plugins_dir=self.plugins_dir, logger=self.logger)
|
||||
|
||||
# Active plugins
|
||||
self.plugins: Dict[str, Any] = {}
|
||||
self.plugin_manifests: Dict[str, Dict[str, Any]] = {}
|
||||
self.plugin_modules: Dict[str, Any] = {}
|
||||
self.plugin_last_update: Dict[str, float] = {}
|
||||
|
||||
# Health tracking (optional, set by display_controller if available)
|
||||
self.health_tracker = None
|
||||
self.resource_monitor = None
|
||||
|
||||
# Ensure plugins directory exists with proper permissions
|
||||
try:
|
||||
ensure_directory_permissions(self.plugins_dir, get_plugin_dir_mode())
|
||||
except (OSError, PermissionError) as e:
|
||||
self.logger.error("Could not create plugins directory %s: %s", self.plugins_dir, e, exc_info=True)
|
||||
raise PluginError(f"Could not create plugins directory: {self.plugins_dir}", context={'error': str(e)}) from e
|
||||
|
||||
def _scan_directory_for_plugins(self, directory: Path) -> List[str]:
|
||||
"""
|
||||
Scan a directory for plugins.
|
||||
|
||||
Args:
|
||||
directory: Directory to scan
|
||||
|
||||
Returns:
|
||||
List of plugin IDs found
|
||||
"""
|
||||
plugin_ids = []
|
||||
|
||||
if not directory.exists():
|
||||
return plugin_ids
|
||||
|
||||
try:
|
||||
for item in directory.iterdir():
|
||||
if not item.is_dir():
|
||||
continue
|
||||
|
||||
manifest_path = item / "manifest.json"
|
||||
if manifest_path.exists():
|
||||
try:
|
||||
with open(manifest_path, 'r', encoding='utf-8') as f:
|
||||
manifest = json.load(f)
|
||||
plugin_id = manifest.get('id')
|
||||
if plugin_id:
|
||||
plugin_ids.append(plugin_id)
|
||||
self.plugin_manifests[plugin_id] = manifest
|
||||
|
||||
# Store directory mapping
|
||||
if not hasattr(self, 'plugin_directories'):
|
||||
self.plugin_directories = {}
|
||||
self.plugin_directories[plugin_id] = item
|
||||
except (json.JSONDecodeError, PermissionError, OSError) as e:
|
||||
self.logger.warning("Error reading manifest from %s: %s", manifest_path, e, exc_info=True)
|
||||
continue
|
||||
except (OSError, PermissionError) as e:
|
||||
self.logger.error("Error scanning directory %s: %s", directory, e, exc_info=True)
|
||||
|
||||
return plugin_ids
|
||||
|
||||
def discover_plugins(self) -> List[str]:
|
||||
"""
|
||||
Discover all plugins in the plugins directory.
|
||||
|
||||
Returns:
|
||||
List of plugin IDs
|
||||
"""
|
||||
self.logger.info("Discovering plugins in %s", self.plugins_dir)
|
||||
plugin_ids = self._scan_directory_for_plugins(self.plugins_dir)
|
||||
self.logger.info("Discovered %d plugin(s)", len(plugin_ids))
|
||||
return plugin_ids
|
||||
|
||||
def _get_dependency_marker_path(self, plugin_id: str) -> Path:
|
||||
"""Get path to dependency installation marker file."""
|
||||
plugin_dir = self.plugins_dir / plugin_id
|
||||
if not plugin_dir.exists():
|
||||
# Try with ledmatrix- prefix
|
||||
plugin_dir = self.plugins_dir / f"ledmatrix-{plugin_id}"
|
||||
return plugin_dir / ".dependencies_installed"
|
||||
|
||||
def _check_dependencies_installed(self, plugin_id: str) -> bool:
|
||||
"""Check if dependencies are already installed for a plugin."""
|
||||
marker_path = self._get_dependency_marker_path(plugin_id)
|
||||
return marker_path.exists()
|
||||
|
||||
def _mark_dependencies_installed(self, plugin_id: str) -> None:
|
||||
"""Mark dependencies as installed for a plugin."""
|
||||
marker_path = self._get_dependency_marker_path(plugin_id)
|
||||
try:
|
||||
marker_path.touch()
|
||||
# Set proper file permissions after creating marker
|
||||
from src.common.permission_utils import (
|
||||
ensure_file_permissions,
|
||||
get_plugin_file_mode
|
||||
)
|
||||
ensure_file_permissions(marker_path, get_plugin_file_mode())
|
||||
except (OSError, PermissionError) as e:
|
||||
self.logger.warning("Could not create dependency marker for %s: %s", plugin_id, e)
|
||||
|
||||
def _remove_dependency_marker(self, plugin_id: str) -> None:
|
||||
"""Remove dependency installation marker."""
|
||||
marker_path = self._get_dependency_marker_path(plugin_id)
|
||||
try:
|
||||
if marker_path.exists():
|
||||
marker_path.unlink()
|
||||
except (OSError, PermissionError) as e:
|
||||
self.logger.warning("Could not remove dependency marker for %s: %s", plugin_id, e)
|
||||
|
||||
def _install_plugin_dependencies(self, requirements_file: Path) -> bool:
|
||||
"""
|
||||
Install plugin dependencies from requirements.txt.
|
||||
|
||||
Args:
|
||||
requirements_file: Path to requirements.txt
|
||||
|
||||
Returns:
|
||||
True if installation succeeded or not needed, False on error
|
||||
"""
|
||||
try:
|
||||
self.logger.info("Installing dependencies from %s", requirements_file)
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "pip", "install", "--break-system-packages", "--no-cache-dir", "-r", str(requirements_file)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=300,
|
||||
check=False
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
self.logger.info("Dependencies installed successfully")
|
||||
return True
|
||||
else:
|
||||
self.logger.warning("Dependency installation returned non-zero exit code: %s", result.stderr)
|
||||
return False
|
||||
except subprocess.TimeoutExpired:
|
||||
self.logger.error("Dependency installation timed out")
|
||||
return False
|
||||
except FileNotFoundError as e:
|
||||
self.logger.warning("Command not found: %s. Skipping dependency installation", e)
|
||||
return True
|
||||
except (BrokenPipeError, OSError) as e:
|
||||
# Handle broken pipe errors (errno 32) which can occur during pip downloads
|
||||
# Often caused by network interruptions or output buffer issues
|
||||
if isinstance(e, OSError) and e.errno == 32:
|
||||
self.logger.error(
|
||||
"Broken pipe error during dependency installation. "
|
||||
"This usually indicates a network interruption or pip output buffer issue. "
|
||||
"Try installing again or check your network connection."
|
||||
)
|
||||
else:
|
||||
self.logger.error("OS error during dependency installation: %s", e)
|
||||
return False
|
||||
except Exception as e:
|
||||
self.logger.error("Unexpected error installing dependencies: %s", e, exc_info=True)
|
||||
return True
|
||||
|
||||
def load_plugin(self, plugin_id: str) -> bool:
|
||||
"""
|
||||
Load a plugin by ID.
|
||||
|
||||
This method:
|
||||
1. Checks if plugin is already loaded
|
||||
2. Validates the manifest exists
|
||||
3. Uses PluginLoader to import module and instantiate plugin
|
||||
4. Validates the plugin configuration
|
||||
5. Stores the plugin instance
|
||||
6. Updates plugin state
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin identifier
|
||||
|
||||
Returns:
|
||||
True if loaded successfully, False otherwise
|
||||
"""
|
||||
if plugin_id in self.plugins:
|
||||
self.logger.warning("Plugin %s already loaded", plugin_id)
|
||||
return True
|
||||
|
||||
manifest = self.plugin_manifests.get(plugin_id)
|
||||
if not manifest:
|
||||
self.logger.error("No manifest found for plugin: %s", plugin_id)
|
||||
self.state_manager.set_state(plugin_id, PluginState.ERROR)
|
||||
return False
|
||||
|
||||
try:
|
||||
# Update state to LOADED
|
||||
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
|
||||
)
|
||||
|
||||
if plugin_dir is None:
|
||||
self.logger.error("Plugin directory not found: %s", plugin_id)
|
||||
self.logger.error("Searched in: %s", self.plugins_dir)
|
||||
self.state_manager.set_state(plugin_id, PluginState.ERROR)
|
||||
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 = {}
|
||||
self.plugin_directories[plugin_id] = plugin_dir
|
||||
|
||||
# Get plugin config
|
||||
if self.config_manager:
|
||||
full_config = self.config_manager.load_config()
|
||||
config = full_config.get(plugin_id, {})
|
||||
else:
|
||||
config = {}
|
||||
|
||||
# Merge config with schema defaults to ensure all defaults are applied
|
||||
try:
|
||||
defaults = self.schema_manager.generate_default_config(plugin_id, use_cache=True)
|
||||
config = self.schema_manager.merge_with_defaults(config, defaults)
|
||||
self.logger.debug(f"Merged config with schema defaults for {plugin_id}")
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Could not apply schema defaults for {plugin_id}: {e}")
|
||||
# Continue with original config if defaults can't be applied
|
||||
|
||||
# Use PluginLoader to load plugin
|
||||
plugin_instance, module = self.plugin_loader.load_plugin(
|
||||
plugin_id=plugin_id,
|
||||
manifest=manifest,
|
||||
plugin_dir=plugin_dir,
|
||||
config=config,
|
||||
display_manager=self.display_manager,
|
||||
cache_manager=self.cache_manager,
|
||||
plugin_manager=self,
|
||||
install_deps=True
|
||||
)
|
||||
|
||||
# Store module
|
||||
self.plugin_modules[plugin_id] = module
|
||||
|
||||
# Validate configuration
|
||||
if hasattr(plugin_instance, 'validate_config'):
|
||||
try:
|
||||
if not plugin_instance.validate_config():
|
||||
self.logger.error("Plugin %s configuration validation failed", plugin_id)
|
||||
self.state_manager.set_state(plugin_id, PluginState.ERROR)
|
||||
return False
|
||||
except Exception as e:
|
||||
self.logger.error("Error validating plugin %s config: %s", plugin_id, e, exc_info=True)
|
||||
self.state_manager.set_state(plugin_id, PluginState.ERROR, error=e)
|
||||
return False
|
||||
|
||||
# Store plugin instance
|
||||
self.plugins[plugin_id] = plugin_instance
|
||||
self.plugin_last_update[plugin_id] = 0.0
|
||||
|
||||
# Update state based on enabled status
|
||||
if config.get('enabled', True):
|
||||
self.state_manager.set_state(plugin_id, PluginState.ENABLED)
|
||||
# Call on_enable if plugin is enabled
|
||||
if hasattr(plugin_instance, 'on_enable'):
|
||||
plugin_instance.on_enable()
|
||||
else:
|
||||
self.state_manager.set_state(plugin_id, PluginState.DISABLED)
|
||||
|
||||
self.logger.info("Loaded plugin: %s", plugin_id)
|
||||
|
||||
return True
|
||||
|
||||
except PluginError as e:
|
||||
self.logger.error("Plugin error loading %s: %s", plugin_id, e, exc_info=True)
|
||||
self.state_manager.set_state(plugin_id, PluginState.ERROR, error=e)
|
||||
return False
|
||||
except Exception as e:
|
||||
self.logger.error("Unexpected error loading plugin %s: %s", plugin_id, e, exc_info=True)
|
||||
self.state_manager.set_state(plugin_id, PluginState.ERROR, error=e)
|
||||
return False
|
||||
|
||||
def unload_plugin(self, plugin_id: str) -> bool:
|
||||
"""
|
||||
Unload a plugin by ID.
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin identifier
|
||||
|
||||
Returns:
|
||||
True if unloaded successfully, False otherwise
|
||||
"""
|
||||
if plugin_id not in self.plugins:
|
||||
self.logger.warning("Plugin %s not loaded", plugin_id)
|
||||
return False
|
||||
|
||||
try:
|
||||
plugin = self.plugins[plugin_id]
|
||||
|
||||
# Call cleanup if available
|
||||
if hasattr(plugin, 'cleanup'):
|
||||
try:
|
||||
plugin.cleanup()
|
||||
except Exception as e:
|
||||
self.logger.warning("Error during plugin cleanup: %s", e)
|
||||
|
||||
# Call on_disable if available
|
||||
if hasattr(plugin, 'on_disable'):
|
||||
try:
|
||||
plugin.on_disable()
|
||||
except Exception as e:
|
||||
self.logger.warning("Error during plugin on_disable: %s", e)
|
||||
|
||||
# Remove from active plugins
|
||||
del self.plugins[plugin_id]
|
||||
if plugin_id in self.plugin_last_update:
|
||||
del self.plugin_last_update[plugin_id]
|
||||
|
||||
# Remove module from sys.modules if present
|
||||
module_name = f"plugin_{plugin_id.replace('-', '_')}"
|
||||
if module_name in sys.modules:
|
||||
del sys.modules[module_name]
|
||||
|
||||
# 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)
|
||||
|
||||
self.logger.info("Unloaded plugin: %s", plugin_id)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error("Error unloading plugin %s: %s", plugin_id, e, exc_info=True)
|
||||
self.state_manager.set_state(plugin_id, PluginState.ERROR, error=e)
|
||||
return False
|
||||
|
||||
def reload_plugin(self, plugin_id: str) -> bool:
|
||||
"""
|
||||
Reload a plugin (unload and load).
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin identifier
|
||||
|
||||
Returns:
|
||||
True if reloaded successfully, False otherwise
|
||||
"""
|
||||
self.logger.info("Reloading plugin: %s", plugin_id)
|
||||
|
||||
# Unload first
|
||||
if plugin_id in self.plugins:
|
||||
if not self.unload_plugin(plugin_id):
|
||||
return False
|
||||
|
||||
# Re-discover to get updated manifest
|
||||
manifest_path = self.plugins_dir / plugin_id / "manifest.json"
|
||||
if manifest_path.exists():
|
||||
try:
|
||||
with open(manifest_path, 'r', encoding='utf-8') as f:
|
||||
self.plugin_manifests[plugin_id] = json.load(f)
|
||||
except Exception as e:
|
||||
self.logger.error("Error reading manifest: %s", e, exc_info=True)
|
||||
return False
|
||||
|
||||
return self.load_plugin(plugin_id)
|
||||
|
||||
def get_plugin(self, plugin_id: str) -> Optional[Any]:
|
||||
"""
|
||||
Get a loaded plugin instance by ID.
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin identifier
|
||||
|
||||
Returns:
|
||||
Plugin instance or None if not loaded
|
||||
"""
|
||||
return self.plugins.get(plugin_id)
|
||||
|
||||
def get_all_plugins(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Get all loaded plugins.
|
||||
|
||||
Returns:
|
||||
Dict of plugin_id: plugin_instance
|
||||
"""
|
||||
return self.plugins.copy()
|
||||
|
||||
def get_enabled_plugins(self) -> List[str]:
|
||||
"""
|
||||
Get list of enabled plugin IDs.
|
||||
|
||||
Returns:
|
||||
List of plugin IDs that are currently enabled
|
||||
"""
|
||||
return [pid for pid, plugin in self.plugins.items() if plugin.enabled]
|
||||
|
||||
def get_plugin_info(self, plugin_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get information about a plugin (manifest + runtime info).
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin identifier
|
||||
|
||||
Returns:
|
||||
Dict with plugin information or None if not found
|
||||
"""
|
||||
manifest = self.plugin_manifests.get(plugin_id)
|
||||
if not manifest:
|
||||
return None
|
||||
|
||||
info = manifest.copy()
|
||||
|
||||
# Add runtime information if plugin is loaded
|
||||
plugin = self.plugins.get(plugin_id)
|
||||
if plugin:
|
||||
info['loaded'] = True
|
||||
if hasattr(plugin, 'get_info'):
|
||||
info['runtime_info'] = plugin.get_info()
|
||||
else:
|
||||
info['loaded'] = False
|
||||
|
||||
# Add state information
|
||||
info['state'] = self.state_manager.get_state_info(plugin_id)
|
||||
|
||||
return info
|
||||
|
||||
def get_all_plugin_info(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get information about all plugins.
|
||||
|
||||
Returns:
|
||||
List of plugin info dictionaries
|
||||
"""
|
||||
return [info for info in [self.get_plugin_info(pid) for pid in self.plugin_manifests.keys()] if info]
|
||||
|
||||
def get_plugin_directory(self, plugin_id: str) -> Optional[str]:
|
||||
"""
|
||||
Get the directory path for a plugin.
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin identifier
|
||||
|
||||
Returns:
|
||||
Directory path as string or None if not found
|
||||
"""
|
||||
if hasattr(self, 'plugin_directories') and plugin_id in self.plugin_directories:
|
||||
return str(self.plugin_directories[plugin_id])
|
||||
|
||||
plugin_dir = self.plugins_dir / plugin_id
|
||||
if plugin_dir.exists():
|
||||
return str(plugin_dir)
|
||||
|
||||
plugin_dir = self.plugins_dir / f"ledmatrix-{plugin_id}"
|
||||
if plugin_dir.exists():
|
||||
return str(plugin_dir)
|
||||
|
||||
return None
|
||||
|
||||
def get_plugin_display_modes(self, plugin_id: str) -> List[str]:
|
||||
"""
|
||||
Get display modes provided by a plugin.
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin identifier
|
||||
|
||||
Returns:
|
||||
List of display mode names
|
||||
"""
|
||||
manifest = self.plugin_manifests.get(plugin_id)
|
||||
if not manifest:
|
||||
return []
|
||||
|
||||
display_modes = manifest.get('display_modes', [])
|
||||
if isinstance(display_modes, list):
|
||||
return display_modes
|
||||
return []
|
||||
|
||||
def find_plugin_for_mode(self, mode: str) -> Optional[str]:
|
||||
"""
|
||||
Find which plugin provides a given display mode.
|
||||
|
||||
Args:
|
||||
mode: Display mode identifier
|
||||
|
||||
Returns:
|
||||
Plugin identifier or None if not found.
|
||||
"""
|
||||
normalized_mode = mode.strip().lower()
|
||||
for plugin_id, manifest in self.plugin_manifests.items():
|
||||
display_modes = manifest.get('display_modes')
|
||||
if isinstance(display_modes, list) and display_modes:
|
||||
if any(m.lower() == normalized_mode for m in display_modes):
|
||||
return plugin_id
|
||||
|
||||
return None
|
||||
|
||||
def _get_plugin_update_interval(self, plugin_id: str, plugin_instance: Any) -> Optional[float]:
|
||||
"""
|
||||
Get the update interval for a plugin.
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin identifier
|
||||
plugin_instance: Plugin instance
|
||||
|
||||
Returns:
|
||||
Update interval in seconds or None if not configured
|
||||
"""
|
||||
# Check manifest first
|
||||
manifest = self.plugin_manifests.get(plugin_id, {})
|
||||
update_interval = manifest.get('update_interval')
|
||||
|
||||
if update_interval:
|
||||
try:
|
||||
return float(update_interval)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Check plugin config
|
||||
if self.config_manager:
|
||||
try:
|
||||
config = self.config_manager.get_config()
|
||||
plugin_config = config.get(plugin_id, {})
|
||||
update_interval = plugin_config.get('update_interval')
|
||||
if update_interval:
|
||||
try:
|
||||
return float(update_interval)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
except Exception as e:
|
||||
self.logger.debug("Could not get update interval from config: %s", e)
|
||||
|
||||
# Default: 60 seconds
|
||||
return 60.0
|
||||
|
||||
def run_scheduled_updates(self, current_time: Optional[float] = None) -> None:
|
||||
"""
|
||||
Trigger plugin updates based on their defined update intervals.
|
||||
Includes health tracking and circuit breaker logic.
|
||||
Uses PluginExecutor for safe execution with timeout.
|
||||
"""
|
||||
if current_time is None:
|
||||
current_time = time.time()
|
||||
|
||||
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
|
||||
|
||||
# Check circuit breaker before attempting update
|
||||
if self.health_tracker and self.health_tracker.should_skip_plugin(plugin_id):
|
||||
continue
|
||||
|
||||
# Check if plugin can execute
|
||||
if not self.state_manager.can_execute(plugin_id):
|
||||
continue
|
||||
|
||||
interval = self._get_plugin_update_interval(plugin_id, plugin_instance)
|
||||
if interval is None:
|
||||
continue
|
||||
|
||||
last_update = self.plugin_last_update.get(plugin_id, 0.0)
|
||||
|
||||
if last_update == 0.0 or (current_time - last_update) >= interval:
|
||||
# Update state to RUNNING
|
||||
self.state_manager.set_state(plugin_id, PluginState.RUNNING)
|
||||
|
||||
try:
|
||||
# Use PluginExecutor for safe execution
|
||||
success = False
|
||||
if self.resource_monitor:
|
||||
# If resource monitor exists, wrap the call
|
||||
def monitored_update():
|
||||
self.resource_monitor.monitor_call(plugin_id, plugin_instance.update)
|
||||
success = self.plugin_executor.execute_update(
|
||||
type('obj', (object,), {'update': monitored_update})(),
|
||||
plugin_id
|
||||
)
|
||||
else:
|
||||
success = self.plugin_executor.execute_update(plugin_instance, plugin_id)
|
||||
|
||||
if success:
|
||||
self.plugin_last_update[plugin_id] = current_time
|
||||
self.state_manager.record_update(plugin_id)
|
||||
# Update state back to ENABLED
|
||||
self.state_manager.set_state(plugin_id, PluginState.ENABLED)
|
||||
# Record success
|
||||
if self.health_tracker:
|
||||
self.health_tracker.record_success(plugin_id)
|
||||
else:
|
||||
# Execution failed (timeout or error)
|
||||
self.state_manager.set_state(plugin_id, PluginState.ERROR)
|
||||
if self.health_tracker:
|
||||
self.health_tracker.record_failure(plugin_id, Exception("Plugin execution failed"))
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
self.logger.exception("Error updating plugin %s: %s", plugin_id, exc)
|
||||
self.state_manager.set_state(plugin_id, PluginState.ERROR, error=exc)
|
||||
# Record failure
|
||||
if self.health_tracker:
|
||||
self.health_tracker.record_failure(plugin_id, exc)
|
||||
|
||||
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
|
||||
|
||||
# Check if plugin can execute
|
||||
if not self.state_manager.can_execute(plugin_id):
|
||||
continue
|
||||
|
||||
# Update state to RUNNING
|
||||
self.state_manager.set_state(plugin_id, PluginState.RUNNING)
|
||||
|
||||
try:
|
||||
success = self.plugin_executor.execute_update(plugin_instance, plugin_id)
|
||||
if success:
|
||||
self.plugin_last_update[plugin_id] = time.time()
|
||||
self.state_manager.record_update(plugin_id)
|
||||
# Update state back to ENABLED
|
||||
self.state_manager.set_state(plugin_id, PluginState.ENABLED)
|
||||
else:
|
||||
# Execution failed
|
||||
self.state_manager.set_state(plugin_id, PluginState.ERROR)
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
self.logger.exception("Error updating plugin %s: %s", plugin_id, exc)
|
||||
self.state_manager.set_state(plugin_id, PluginState.ERROR, error=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_plugin_health(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_plugin_metrics(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)
|
||||
199
src/plugin_system/plugin_state.py
Normal file
199
src/plugin_system/plugin_state.py
Normal file
@@ -0,0 +1,199 @@
|
||||
"""
|
||||
Plugin State Management
|
||||
|
||||
Manages plugin state machine (loaded → enabled → running → error)
|
||||
with state transitions and queries.
|
||||
"""
|
||||
|
||||
from enum import Enum
|
||||
from typing import Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
import logging
|
||||
|
||||
from src.logging_config import get_logger
|
||||
|
||||
|
||||
class PluginState(Enum):
|
||||
"""Plugin state enumeration."""
|
||||
UNLOADED = "unloaded" # Plugin not loaded
|
||||
LOADED = "loaded" # Plugin module loaded but not instantiated
|
||||
ENABLED = "enabled" # Plugin instantiated and enabled
|
||||
RUNNING = "running" # Plugin is currently executing
|
||||
ERROR = "error" # Plugin encountered an error
|
||||
DISABLED = "disabled" # Plugin is disabled in config
|
||||
|
||||
|
||||
class PluginStateManager:
|
||||
"""Manages plugin state transitions and queries."""
|
||||
|
||||
def __init__(self, logger: Optional[logging.Logger] = None) -> None:
|
||||
"""
|
||||
Initialize the plugin state manager.
|
||||
|
||||
Args:
|
||||
logger: Optional logger instance
|
||||
"""
|
||||
self.logger = logger or get_logger(__name__)
|
||||
self._states: Dict[str, PluginState] = {}
|
||||
self._state_history: Dict[str, list] = {}
|
||||
self._error_info: Dict[str, Dict[str, Any]] = {}
|
||||
self._last_update: Dict[str, datetime] = {}
|
||||
self._last_display: Dict[str, datetime] = {}
|
||||
|
||||
def set_state(
|
||||
self,
|
||||
plugin_id: str,
|
||||
state: PluginState,
|
||||
error: Optional[Exception] = None
|
||||
) -> None:
|
||||
"""
|
||||
Set plugin state and record transition.
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin identifier
|
||||
state: New state
|
||||
error: Optional error if transitioning to ERROR state
|
||||
"""
|
||||
old_state = self._states.get(plugin_id, PluginState.UNLOADED)
|
||||
self._states[plugin_id] = state
|
||||
|
||||
# Record state transition
|
||||
if plugin_id not in self._state_history:
|
||||
self._state_history[plugin_id] = []
|
||||
|
||||
transition = {
|
||||
'timestamp': datetime.now(),
|
||||
'from': old_state.value,
|
||||
'to': state.value,
|
||||
'error': str(error) if error else None
|
||||
}
|
||||
self._state_history[plugin_id].append(transition)
|
||||
|
||||
# Store error info if transitioning to ERROR state
|
||||
if state == PluginState.ERROR and error:
|
||||
self._error_info[plugin_id] = {
|
||||
'error': str(error),
|
||||
'error_type': type(error).__name__,
|
||||
'timestamp': datetime.now()
|
||||
}
|
||||
elif state != PluginState.ERROR:
|
||||
# Clear error info when leaving ERROR state
|
||||
self._error_info.pop(plugin_id, None)
|
||||
|
||||
self.logger.debug(
|
||||
"Plugin %s state transition: %s → %s",
|
||||
plugin_id,
|
||||
old_state.value,
|
||||
state.value
|
||||
)
|
||||
|
||||
def get_state(self, plugin_id: str) -> PluginState:
|
||||
"""
|
||||
Get current state of a plugin.
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin identifier
|
||||
|
||||
Returns:
|
||||
Current plugin state
|
||||
"""
|
||||
return self._states.get(plugin_id, PluginState.UNLOADED)
|
||||
|
||||
def is_loaded(self, plugin_id: str) -> bool:
|
||||
"""Check if plugin is loaded."""
|
||||
state = self.get_state(plugin_id)
|
||||
return state in [PluginState.LOADED, PluginState.ENABLED, PluginState.RUNNING]
|
||||
|
||||
def is_enabled(self, plugin_id: str) -> bool:
|
||||
"""Check if plugin is enabled."""
|
||||
state = self.get_state(plugin_id)
|
||||
return state == PluginState.ENABLED
|
||||
|
||||
def is_running(self, plugin_id: str) -> bool:
|
||||
"""Check if plugin is currently running."""
|
||||
state = self.get_state(plugin_id)
|
||||
return state == PluginState.RUNNING
|
||||
|
||||
def is_error(self, plugin_id: str) -> bool:
|
||||
"""Check if plugin is in error state."""
|
||||
state = self.get_state(plugin_id)
|
||||
return state == PluginState.ERROR
|
||||
|
||||
def can_execute(self, plugin_id: str) -> bool:
|
||||
"""Check if plugin can execute (update/display)."""
|
||||
state = self.get_state(plugin_id)
|
||||
return state == PluginState.ENABLED
|
||||
|
||||
def get_state_history(self, plugin_id: str) -> list:
|
||||
"""
|
||||
Get state transition history for a plugin.
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin identifier
|
||||
|
||||
Returns:
|
||||
List of state transitions
|
||||
"""
|
||||
return self._state_history.get(plugin_id, [])
|
||||
|
||||
def get_error_info(self, plugin_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get error information for a plugin in ERROR state.
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin identifier
|
||||
|
||||
Returns:
|
||||
Error information dict or None
|
||||
"""
|
||||
return self._error_info.get(plugin_id)
|
||||
|
||||
def record_update(self, plugin_id: str) -> None:
|
||||
"""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)
|
||||
|
||||
def get_last_display(self, plugin_id: str) -> Optional[datetime]:
|
||||
"""Get timestamp of last display() call."""
|
||||
return self._last_display.get(plugin_id)
|
||||
|
||||
def get_state_info(self, plugin_id: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Get comprehensive state information for a plugin.
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin identifier
|
||||
|
||||
Returns:
|
||||
Dictionary with state information
|
||||
"""
|
||||
state = self.get_state(plugin_id)
|
||||
info = {
|
||||
'state': state.value,
|
||||
'is_loaded': self.is_loaded(plugin_id),
|
||||
'is_enabled': self.is_enabled(plugin_id),
|
||||
'is_running': self.is_running(plugin_id),
|
||||
'is_error': self.is_error(plugin_id),
|
||||
'can_execute': self.can_execute(plugin_id),
|
||||
'last_update': self.get_last_update(plugin_id),
|
||||
'last_display': self.get_last_display(plugin_id),
|
||||
'error_info': self.get_error_info(plugin_id),
|
||||
'state_history_count': len(self.get_state_history(plugin_id))
|
||||
}
|
||||
return info
|
||||
|
||||
def clear_state(self, plugin_id: str) -> None:
|
||||
"""Clear all state information for a plugin."""
|
||||
self._states.pop(plugin_id, None)
|
||||
self._state_history.pop(plugin_id, None)
|
||||
self._error_info.pop(plugin_id, None)
|
||||
self._last_update.pop(plugin_id, None)
|
||||
self._last_display.pop(plugin_id, None)
|
||||
|
||||
344
src/plugin_system/resource_monitor.py
Normal file
344
src/plugin_system/resource_monitor.py
Normal file
@@ -0,0 +1,344 @@
|
||||
"""
|
||||
Plugin Resource Monitor
|
||||
|
||||
Tracks resource usage (memory, CPU, execution time) for plugins.
|
||||
Provides resource limits and performance monitoring.
|
||||
"""
|
||||
|
||||
import time
|
||||
import logging
|
||||
import threading
|
||||
from typing import Dict, Optional, Any, Callable
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
try:
|
||||
import psutil
|
||||
PSUTIL_AVAILABLE = True
|
||||
except ImportError:
|
||||
PSUTIL_AVAILABLE = False
|
||||
|
||||
|
||||
class ResourceLimitExceeded(Exception):
|
||||
"""Raised when a plugin exceeds its resource limits."""
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class ResourceLimits:
|
||||
"""Resource limits for a plugin."""
|
||||
max_memory_mb: Optional[float] = None # Maximum memory in MB
|
||||
max_cpu_percent: Optional[float] = None # Maximum CPU percentage
|
||||
max_execution_time: Optional[float] = None # Maximum execution time in seconds
|
||||
warning_threshold: float = 0.8 # Warning at 80% of limit
|
||||
|
||||
|
||||
@dataclass
|
||||
class ResourceMetrics:
|
||||
"""Resource usage metrics for a plugin."""
|
||||
memory_mb: float = 0.0
|
||||
cpu_percent: float = 0.0
|
||||
execution_time: float = 0.0
|
||||
call_count: int = 0
|
||||
total_execution_time: float = 0.0
|
||||
max_execution_time: float = 0.0
|
||||
min_execution_time: float = float('inf')
|
||||
last_update_time: float = field(default_factory=time.time)
|
||||
|
||||
def update_average_execution_time(self):
|
||||
"""Update average execution time."""
|
||||
if self.call_count > 0:
|
||||
self.total_execution_time = self.total_execution_time / self.call_count
|
||||
|
||||
|
||||
class PluginResourceMonitor:
|
||||
"""
|
||||
Monitors resource usage for plugins.
|
||||
|
||||
Tracks:
|
||||
- Memory usage (if psutil available)
|
||||
- CPU usage (if psutil available)
|
||||
- Execution time for update() and display() calls
|
||||
- Call counts and statistics
|
||||
"""
|
||||
|
||||
def __init__(self, cache_manager, enable_monitoring: bool = True):
|
||||
"""
|
||||
Initialize resource monitor.
|
||||
|
||||
Args:
|
||||
cache_manager: Cache manager for persisting metrics
|
||||
enable_monitoring: Enable resource monitoring (requires psutil)
|
||||
"""
|
||||
self.cache_manager = cache_manager
|
||||
self.enable_monitoring = enable_monitoring and PSUTIL_AVAILABLE
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
# Resource metrics per plugin
|
||||
self._metrics: Dict[str, ResourceMetrics] = {}
|
||||
self._limits: Dict[str, ResourceLimits] = {}
|
||||
|
||||
# Thread-local storage for execution tracking
|
||||
self._local = threading.local()
|
||||
|
||||
# Lock for thread-safe access
|
||||
self._lock = threading.Lock()
|
||||
|
||||
if not PSUTIL_AVAILABLE and enable_monitoring:
|
||||
self.logger.warning(
|
||||
"psutil not available - resource monitoring will be limited to execution time only"
|
||||
)
|
||||
|
||||
def _get_metrics_key(self, plugin_id: str) -> str:
|
||||
"""Get cache key for plugin metrics."""
|
||||
return f"plugin_metrics:{plugin_id}"
|
||||
|
||||
def _get_limits_key(self, plugin_id: str) -> str:
|
||||
"""Get cache key for plugin limits."""
|
||||
return f"plugin_limits:{plugin_id}"
|
||||
|
||||
def get_metrics(self, plugin_id: str) -> ResourceMetrics:
|
||||
"""Get current metrics for a plugin."""
|
||||
with self._lock:
|
||||
if plugin_id not in self._metrics:
|
||||
# Try to load from cache
|
||||
cache_key = self._get_metrics_key(plugin_id)
|
||||
cached = self.cache_manager.get(cache_key, max_age=None)
|
||||
if cached:
|
||||
metrics = ResourceMetrics(**cached)
|
||||
else:
|
||||
metrics = ResourceMetrics()
|
||||
self._metrics[plugin_id] = metrics
|
||||
return self._metrics[plugin_id]
|
||||
|
||||
def set_limits(self, plugin_id: str, limits: ResourceLimits) -> None:
|
||||
"""Set resource limits for a plugin."""
|
||||
with self._lock:
|
||||
self._limits[plugin_id] = limits
|
||||
# Persist to cache
|
||||
cache_key = self._get_limits_key(plugin_id)
|
||||
self.cache_manager.set(cache_key, {
|
||||
'max_memory_mb': limits.max_memory_mb,
|
||||
'max_cpu_percent': limits.max_cpu_percent,
|
||||
'max_execution_time': limits.max_execution_time,
|
||||
'warning_threshold': limits.warning_threshold
|
||||
})
|
||||
|
||||
def get_limits(self, plugin_id: str) -> Optional[ResourceLimits]:
|
||||
"""Get resource limits for a plugin."""
|
||||
with self._lock:
|
||||
if plugin_id not in self._limits:
|
||||
# Try to load from cache
|
||||
cache_key = self._get_limits_key(plugin_id)
|
||||
cached = self.cache_manager.get(cache_key, max_age=None)
|
||||
if cached:
|
||||
self._limits[plugin_id] = ResourceLimits(**cached)
|
||||
else:
|
||||
return None
|
||||
return self._limits[plugin_id]
|
||||
|
||||
def _get_process_memory_mb(self) -> float:
|
||||
"""Get current process memory usage in MB."""
|
||||
if not self.enable_monitoring:
|
||||
return 0.0
|
||||
try:
|
||||
process = psutil.Process()
|
||||
return process.memory_info().rss / 1024 / 1024
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
def _get_process_cpu_percent(self, interval: float = 0.1) -> float:
|
||||
"""Get current process CPU usage percentage."""
|
||||
if not self.enable_monitoring:
|
||||
return 0.0
|
||||
try:
|
||||
process = psutil.Process()
|
||||
return process.cpu_percent(interval=interval)
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
def monitor_call(self, plugin_id: str, func: Callable, *args, **kwargs) -> Any:
|
||||
"""
|
||||
Monitor a plugin method call.
|
||||
|
||||
Tracks execution time and resource usage, enforces limits.
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin identifier
|
||||
func: Function to call
|
||||
*args: Function arguments
|
||||
**kwargs: Function keyword arguments
|
||||
|
||||
Returns:
|
||||
Function return value
|
||||
|
||||
Raises:
|
||||
ResourceLimitExceeded: If resource limits are exceeded
|
||||
"""
|
||||
metrics = self.get_metrics(plugin_id)
|
||||
limits = self.get_limits(plugin_id)
|
||||
|
||||
# Record start time and memory
|
||||
start_time = time.time()
|
||||
start_memory = self._get_process_memory_mb()
|
||||
|
||||
try:
|
||||
# Execute the function
|
||||
result = func(*args, **kwargs)
|
||||
|
||||
# Calculate execution time
|
||||
execution_time = time.time() - start_time
|
||||
|
||||
# Update metrics
|
||||
with self._lock:
|
||||
metrics.execution_time = execution_time
|
||||
metrics.call_count += 1
|
||||
metrics.total_execution_time += execution_time
|
||||
metrics.max_execution_time = max(metrics.max_execution_time, execution_time)
|
||||
if metrics.min_execution_time == float('inf'):
|
||||
metrics.min_execution_time = execution_time
|
||||
else:
|
||||
metrics.min_execution_time = min(metrics.min_execution_time, execution_time)
|
||||
metrics.last_update_time = time.time()
|
||||
|
||||
# Update memory and CPU if monitoring enabled
|
||||
if self.enable_monitoring:
|
||||
end_memory = self._get_process_memory_mb()
|
||||
metrics.memory_mb = max(metrics.memory_mb, end_memory - start_memory)
|
||||
# CPU is harder to measure per-call, so we track it separately
|
||||
metrics.cpu_percent = self._get_process_cpu_percent()
|
||||
|
||||
# Persist metrics
|
||||
cache_key = self._get_metrics_key(plugin_id)
|
||||
self.cache_manager.set(cache_key, {
|
||||
'memory_mb': metrics.memory_mb,
|
||||
'cpu_percent': metrics.cpu_percent,
|
||||
'execution_time': metrics.execution_time,
|
||||
'call_count': metrics.call_count,
|
||||
'total_execution_time': metrics.total_execution_time,
|
||||
'max_execution_time': metrics.max_execution_time,
|
||||
'min_execution_time': metrics.min_execution_time if metrics.min_execution_time != float('inf') else 0.0,
|
||||
'last_update_time': metrics.last_update_time
|
||||
})
|
||||
|
||||
# Check limits
|
||||
if limits:
|
||||
self._check_limits(plugin_id, metrics, limits, execution_time)
|
||||
|
||||
return result
|
||||
|
||||
except ResourceLimitExceeded:
|
||||
raise
|
||||
except Exception as e:
|
||||
# Still record execution time even on error
|
||||
execution_time = time.time() - start_time
|
||||
with self._lock:
|
||||
metrics.execution_time = execution_time
|
||||
metrics.last_update_time = time.time()
|
||||
raise
|
||||
|
||||
def _check_limits(self, plugin_id: str, metrics: ResourceMetrics,
|
||||
limits: ResourceLimits, execution_time: float) -> None:
|
||||
"""Check if plugin has exceeded resource limits."""
|
||||
warnings = []
|
||||
errors = []
|
||||
|
||||
# Check execution time
|
||||
if limits.max_execution_time and execution_time > limits.max_execution_time:
|
||||
errors.append(
|
||||
f"Execution time {execution_time:.2f}s exceeds limit {limits.max_execution_time:.2f}s"
|
||||
)
|
||||
elif limits.max_execution_time and execution_time > limits.max_execution_time * limits.warning_threshold:
|
||||
warnings.append(
|
||||
f"Execution time {execution_time:.2f}s approaching limit {limits.max_execution_time:.2f}s"
|
||||
)
|
||||
|
||||
# Check memory
|
||||
if limits.max_memory_mb and metrics.memory_mb > limits.max_memory_mb:
|
||||
errors.append(
|
||||
f"Memory usage {metrics.memory_mb:.2f}MB exceeds limit {limits.max_memory_mb:.2f}MB"
|
||||
)
|
||||
elif limits.max_memory_mb and metrics.memory_mb > limits.max_memory_mb * limits.warning_threshold:
|
||||
warnings.append(
|
||||
f"Memory usage {metrics.memory_mb:.2f}MB approaching limit {limits.max_memory_mb:.2f}MB"
|
||||
)
|
||||
|
||||
# Check CPU
|
||||
if limits.max_cpu_percent and metrics.cpu_percent > limits.max_cpu_percent:
|
||||
errors.append(
|
||||
f"CPU usage {metrics.cpu_percent:.2f}% exceeds limit {limits.max_cpu_percent:.2f}%"
|
||||
)
|
||||
elif limits.max_cpu_percent and metrics.cpu_percent > limits.max_cpu_percent * limits.warning_threshold:
|
||||
warnings.append(
|
||||
f"CPU usage {metrics.cpu_percent:.2f}% approaching limit {limits.max_cpu_percent:.2f}%"
|
||||
)
|
||||
|
||||
# Log warnings
|
||||
for warning in warnings:
|
||||
self.logger.warning(f"Plugin {plugin_id}: {warning}")
|
||||
|
||||
# Raise exception for errors
|
||||
if errors:
|
||||
error_msg = f"Plugin {plugin_id} exceeded resource limits: {'; '.join(errors)}"
|
||||
self.logger.error(error_msg)
|
||||
raise ResourceLimitExceeded(error_msg)
|
||||
|
||||
def get_metrics_summary(self, plugin_id: str) -> Dict[str, Any]:
|
||||
"""Get metrics summary for a plugin."""
|
||||
metrics = self.get_metrics(plugin_id)
|
||||
limits = self.get_limits(plugin_id)
|
||||
|
||||
avg_execution_time = 0.0
|
||||
if metrics.call_count > 0:
|
||||
avg_execution_time = metrics.total_execution_time / metrics.call_count
|
||||
|
||||
summary = {
|
||||
'plugin_id': plugin_id,
|
||||
'memory_mb': round(metrics.memory_mb, 2),
|
||||
'cpu_percent': round(metrics.cpu_percent, 2),
|
||||
'execution_time': round(metrics.execution_time, 3),
|
||||
'avg_execution_time': round(avg_execution_time, 3),
|
||||
'min_execution_time': round(metrics.min_execution_time if metrics.min_execution_time != float('inf') else 0.0, 3),
|
||||
'max_execution_time': round(metrics.max_execution_time, 3),
|
||||
'call_count': metrics.call_count,
|
||||
'last_update_time': metrics.last_update_time
|
||||
}
|
||||
|
||||
if limits:
|
||||
summary['limits'] = {
|
||||
'max_memory_mb': limits.max_memory_mb,
|
||||
'max_cpu_percent': limits.max_cpu_percent,
|
||||
'max_execution_time': limits.max_execution_time,
|
||||
'warning_threshold': limits.warning_threshold
|
||||
}
|
||||
|
||||
# Calculate usage percentages
|
||||
if limits.max_memory_mb:
|
||||
summary['memory_usage_percent'] = round(
|
||||
(metrics.memory_mb / limits.max_memory_mb) * 100, 2
|
||||
)
|
||||
if limits.max_cpu_percent:
|
||||
summary['cpu_usage_percent'] = round(
|
||||
(metrics.cpu_percent / limits.max_cpu_percent) * 100, 2
|
||||
)
|
||||
if limits.max_execution_time:
|
||||
summary['execution_time_usage_percent'] = round(
|
||||
(avg_execution_time / limits.max_execution_time) * 100, 2
|
||||
)
|
||||
|
||||
return summary
|
||||
|
||||
def get_all_metrics_summaries(self) -> Dict[str, Dict[str, Any]]:
|
||||
"""Get metrics summaries for all tracked plugins."""
|
||||
summaries = {}
|
||||
for plugin_id in self._metrics.keys():
|
||||
summaries[plugin_id] = self.get_metrics_summary(plugin_id)
|
||||
return summaries
|
||||
|
||||
def reset_metrics(self, plugin_id: str) -> None:
|
||||
"""Reset metrics for a plugin."""
|
||||
with self._lock:
|
||||
if plugin_id in self._metrics:
|
||||
self._metrics[plugin_id] = ResourceMetrics()
|
||||
cache_key = self._get_metrics_key(plugin_id)
|
||||
self.cache_manager.delete(cache_key)
|
||||
|
||||
132
src/plugin_system/saved_repositories.py
Normal file
132
src/plugin_system/saved_repositories.py
Normal file
@@ -0,0 +1,132 @@
|
||||
"""
|
||||
Saved Repositories Manager for LEDMatrix
|
||||
|
||||
Manages saved GitHub repository URLs for easy plugin discovery and installation.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Optional
|
||||
|
||||
|
||||
class SavedRepositoriesManager:
|
||||
"""Manages saved GitHub repository URLs."""
|
||||
|
||||
def __init__(self, config_path: str = "config/saved_repositories.json"):
|
||||
"""
|
||||
Initialize the saved repositories manager.
|
||||
|
||||
Args:
|
||||
config_path: Path to JSON file storing saved repositories
|
||||
"""
|
||||
self.config_path = Path(config_path)
|
||||
self.logger = logging.getLogger(__name__)
|
||||
self.repositories = self._load_repositories()
|
||||
|
||||
def _load_repositories(self) -> List[Dict[str, str]]:
|
||||
"""Load saved repositories from file."""
|
||||
try:
|
||||
if self.config_path.exists():
|
||||
with open(self.config_path, 'r') as f:
|
||||
data = json.load(f)
|
||||
# Ensure it's a list
|
||||
if isinstance(data, list):
|
||||
return data
|
||||
elif isinstance(data, dict) and 'repositories' in data:
|
||||
return data['repositories']
|
||||
else:
|
||||
return []
|
||||
return []
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error loading saved repositories: {e}")
|
||||
return []
|
||||
|
||||
def _save_repositories(self) -> bool:
|
||||
"""Save repositories to file."""
|
||||
try:
|
||||
# Ensure directory exists
|
||||
self.config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with open(self.config_path, 'w') as f:
|
||||
json.dump(self.repositories, f, indent=2)
|
||||
|
||||
self.logger.info(f"Saved {len(self.repositories)} repositories to {self.config_path}")
|
||||
return True
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error saving repositories: {e}")
|
||||
return False
|
||||
|
||||
def get_all(self) -> List[Dict[str, str]]:
|
||||
"""Get all saved repositories."""
|
||||
return self.repositories.copy()
|
||||
|
||||
def add(self, repo_url: str, name: Optional[str] = None) -> bool:
|
||||
"""
|
||||
Add a repository to saved list.
|
||||
|
||||
Args:
|
||||
repo_url: GitHub repository URL
|
||||
name: Optional friendly name for the repository
|
||||
|
||||
Returns:
|
||||
True if added successfully
|
||||
"""
|
||||
# Clean URL
|
||||
repo_url = repo_url.strip().rstrip('/').replace('.git', '')
|
||||
|
||||
# Check if already exists
|
||||
for repo in self.repositories:
|
||||
if repo.get('url') == repo_url:
|
||||
self.logger.warning(f"Repository already exists: {repo_url}")
|
||||
return False
|
||||
|
||||
# Extract name from URL if not provided
|
||||
if not name:
|
||||
parts = repo_url.split('/')
|
||||
if len(parts) >= 2:
|
||||
name = parts[-1]
|
||||
else:
|
||||
name = repo_url
|
||||
|
||||
# Add repository
|
||||
self.repositories.append({
|
||||
'url': repo_url,
|
||||
'name': name,
|
||||
'type': 'registry' if 'plugins.json' in repo_url or 'ledmatrix-plugins' in repo_url.lower() else 'single'
|
||||
})
|
||||
|
||||
return self._save_repositories()
|
||||
|
||||
def remove(self, repo_url: str) -> bool:
|
||||
"""
|
||||
Remove a repository from saved list.
|
||||
|
||||
Args:
|
||||
repo_url: GitHub repository URL to remove
|
||||
|
||||
Returns:
|
||||
True if removed successfully
|
||||
"""
|
||||
# Clean URL
|
||||
repo_url = repo_url.strip().rstrip('/').replace('.git', '')
|
||||
|
||||
original_count = len(self.repositories)
|
||||
self.repositories = [r for r in self.repositories if r.get('url') != repo_url]
|
||||
|
||||
if len(self.repositories) < original_count:
|
||||
return self._save_repositories()
|
||||
else:
|
||||
self.logger.warning(f"Repository not found: {repo_url}")
|
||||
return False
|
||||
|
||||
def has(self, repo_url: str) -> bool:
|
||||
"""Check if a repository is already saved."""
|
||||
repo_url = repo_url.strip().rstrip('/').replace('.git', '')
|
||||
return any(r.get('url') == repo_url for r in self.repositories)
|
||||
|
||||
def get_registry_repositories(self) -> List[Dict[str, str]]:
|
||||
"""Get only registry-style repositories."""
|
||||
return [r for r in self.repositories if r.get('type') == 'registry']
|
||||
|
||||
417
src/plugin_system/schema_manager.py
Normal file
417
src/plugin_system/schema_manager.py
Normal file
@@ -0,0 +1,417 @@
|
||||
"""
|
||||
Schema Manager
|
||||
|
||||
Manages plugin configuration schemas with caching, validation, and reliable path resolution.
|
||||
Provides utilities for extracting defaults, validating configurations, and managing schema lifecycle.
|
||||
"""
|
||||
|
||||
import copy
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
import jsonschema
|
||||
from jsonschema import Draft7Validator, ValidationError
|
||||
|
||||
|
||||
class SchemaManager:
|
||||
"""
|
||||
Manages plugin configuration schemas with caching and validation.
|
||||
|
||||
Features:
|
||||
- Schema loading and caching
|
||||
- Default value extraction from schemas
|
||||
- Configuration validation against schemas
|
||||
- Reliable path resolution for schema files
|
||||
- Cache invalidation on plugin changes
|
||||
"""
|
||||
|
||||
def __init__(self, plugins_dir: Optional[Path] = None, project_root: Optional[Path] = None, logger: Optional[logging.Logger] = None):
|
||||
"""
|
||||
Initialize the Schema Manager.
|
||||
|
||||
Args:
|
||||
plugins_dir: Base plugins directory path
|
||||
project_root: Project root directory path
|
||||
logger: Optional logger instance
|
||||
"""
|
||||
self.logger = logger or logging.getLogger(__name__)
|
||||
self.plugins_dir = plugins_dir
|
||||
self.project_root = project_root or Path.cwd()
|
||||
|
||||
# Schema cache: plugin_id -> schema dict
|
||||
self._schema_cache: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
# Default config cache: plugin_id -> default config dict
|
||||
self._defaults_cache: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
def get_schema_path(self, plugin_id: str) -> Optional[Path]:
|
||||
"""
|
||||
Get the path to a plugin's config_schema.json file.
|
||||
|
||||
Tries multiple locations in order:
|
||||
1. plugins_dir / plugin_id / config_schema.json
|
||||
2. PROJECT_ROOT / plugins / plugin_id / config_schema.json
|
||||
3. PROJECT_ROOT / plugin-repos / plugin_id / config_schema.json
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin identifier
|
||||
|
||||
Returns:
|
||||
Path to schema file or None if not found
|
||||
"""
|
||||
possible_paths = []
|
||||
|
||||
# Try plugins_dir if set
|
||||
if self.plugins_dir:
|
||||
possible_paths.append(self.plugins_dir / plugin_id / 'config_schema.json')
|
||||
|
||||
# Try standard locations relative to project root
|
||||
possible_paths.extend([
|
||||
self.project_root / 'plugins' / plugin_id / 'config_schema.json',
|
||||
self.project_root / 'plugin-repos' / plugin_id / 'config_schema.json',
|
||||
])
|
||||
|
||||
# Try case-insensitive directory matching
|
||||
for base_dir in [self.project_root / 'plugins', self.project_root / 'plugin-repos']:
|
||||
if base_dir.exists():
|
||||
for item in base_dir.iterdir():
|
||||
if item.is_dir() and item.name.lower() == plugin_id.lower():
|
||||
possible_paths.append(item / 'config_schema.json')
|
||||
|
||||
# Try each path
|
||||
for path in possible_paths:
|
||||
if path.exists():
|
||||
self.logger.debug(f"Found schema for {plugin_id} at {path}")
|
||||
return path
|
||||
|
||||
self.logger.warning(f"Schema file not found for plugin {plugin_id}")
|
||||
return None
|
||||
|
||||
def load_schema(self, plugin_id: str, use_cache: bool = True) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Load a plugin's configuration schema.
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin identifier
|
||||
use_cache: If True, return cached schema if available
|
||||
|
||||
Returns:
|
||||
Schema dictionary or None if not found
|
||||
"""
|
||||
# Check cache first
|
||||
if use_cache and plugin_id in self._schema_cache:
|
||||
return self._schema_cache[plugin_id]
|
||||
|
||||
schema_path = self.get_schema_path(plugin_id)
|
||||
if not schema_path:
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(schema_path, 'r', encoding='utf-8') as f:
|
||||
schema = json.load(f)
|
||||
|
||||
# Validate schema structure (basic check)
|
||||
if not isinstance(schema, dict):
|
||||
self.logger.error(f"Invalid schema format for {plugin_id}: not a dictionary")
|
||||
return None
|
||||
|
||||
# Cache the schema
|
||||
self._schema_cache[plugin_id] = schema
|
||||
|
||||
# Invalidate defaults cache when schema changes
|
||||
if plugin_id in self._defaults_cache:
|
||||
del self._defaults_cache[plugin_id]
|
||||
|
||||
return schema
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
self.logger.error(f"Invalid JSON in schema file for {plugin_id}: {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error loading schema for {plugin_id}: {e}")
|
||||
return None
|
||||
|
||||
def invalidate_cache(self, plugin_id: Optional[str] = None) -> None:
|
||||
"""
|
||||
Invalidate schema cache for a plugin or all plugins.
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin identifier to invalidate, or None to clear all
|
||||
"""
|
||||
if plugin_id:
|
||||
self._schema_cache.pop(plugin_id, None)
|
||||
self._defaults_cache.pop(plugin_id, None)
|
||||
self.logger.debug(f"Invalidated cache for plugin {plugin_id}")
|
||||
else:
|
||||
self._schema_cache.clear()
|
||||
self._defaults_cache.clear()
|
||||
self.logger.debug("Invalidated all schema caches")
|
||||
|
||||
def extract_defaults_from_schema(self, schema: Dict[str, Any], prefix: str = '') -> Dict[str, Any]:
|
||||
"""
|
||||
Recursively extract default values from a JSON Schema.
|
||||
|
||||
Handles nested objects, arrays, and all schema types.
|
||||
|
||||
Args:
|
||||
schema: JSON Schema dictionary
|
||||
prefix: Optional prefix for logging/debugging
|
||||
|
||||
Returns:
|
||||
Dictionary of default values
|
||||
"""
|
||||
defaults = {}
|
||||
|
||||
# Handle schema with properties
|
||||
properties = schema.get('properties', {})
|
||||
if not properties:
|
||||
return defaults
|
||||
|
||||
for key, prop_schema in properties.items():
|
||||
field_path = f"{prefix}.{key}" if prefix else key
|
||||
|
||||
# If property has a default, use it
|
||||
if 'default' in prop_schema:
|
||||
defaults[key] = prop_schema['default']
|
||||
self.logger.debug(f"Found default for {field_path}: {prop_schema['default']}")
|
||||
continue
|
||||
|
||||
# Handle nested objects
|
||||
if prop_schema.get('type') == 'object' and 'properties' in prop_schema:
|
||||
nested_defaults = self.extract_defaults_from_schema(prop_schema, field_path)
|
||||
if nested_defaults:
|
||||
defaults[key] = nested_defaults
|
||||
|
||||
# Handle arrays with object items
|
||||
elif prop_schema.get('type') == 'array' and 'items' in prop_schema:
|
||||
items_schema = prop_schema['items']
|
||||
if items_schema.get('type') == 'object' and 'properties' in items_schema:
|
||||
# For arrays of objects, use empty array as default
|
||||
# Individual objects will use their defaults when created
|
||||
defaults[key] = []
|
||||
elif 'default' in items_schema:
|
||||
# Array with default item value
|
||||
defaults[key] = [items_schema['default']]
|
||||
else:
|
||||
# Empty array as default
|
||||
defaults[key] = []
|
||||
|
||||
# For other types without defaults, don't add to defaults dict
|
||||
# This allows plugins to handle missing values as needed
|
||||
|
||||
return defaults
|
||||
|
||||
def generate_default_config(self, plugin_id: str, use_cache: bool = True) -> Dict[str, Any]:
|
||||
"""
|
||||
Generate default configuration for a plugin from its schema.
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin identifier
|
||||
use_cache: If True, return cached defaults if available
|
||||
|
||||
Returns:
|
||||
Dictionary of default configuration values
|
||||
"""
|
||||
# Check cache first
|
||||
if use_cache and plugin_id in self._defaults_cache:
|
||||
return self._defaults_cache[plugin_id].copy()
|
||||
|
||||
schema = self.load_schema(plugin_id, use_cache=use_cache)
|
||||
if not schema:
|
||||
# Return minimal defaults if no schema
|
||||
return {
|
||||
'enabled': False,
|
||||
'display_duration': 15
|
||||
}
|
||||
|
||||
# Extract defaults from schema
|
||||
defaults = self.extract_defaults_from_schema(schema)
|
||||
|
||||
# Ensure core properties have defaults (they may not be in the schema)
|
||||
# These match BasePlugin behavior
|
||||
if 'enabled' not in defaults:
|
||||
defaults['enabled'] = schema.get('properties', {}).get('enabled', {}).get('default', True)
|
||||
|
||||
if 'display_duration' not in defaults:
|
||||
defaults['display_duration'] = schema.get('properties', {}).get('display_duration', {}).get('default', 15)
|
||||
|
||||
if 'live_priority' not in defaults:
|
||||
defaults['live_priority'] = schema.get('properties', {}).get('live_priority', {}).get('default', False)
|
||||
|
||||
# Cache the defaults
|
||||
self._defaults_cache[plugin_id] = defaults.copy()
|
||||
|
||||
return defaults
|
||||
|
||||
def validate_config_against_schema(self, config: Dict[str, Any], schema: Dict[str, Any],
|
||||
plugin_id: Optional[str] = None) -> Tuple[bool, List[str]]:
|
||||
"""
|
||||
Validate configuration against a JSON Schema.
|
||||
|
||||
Uses jsonschema library for comprehensive validation.
|
||||
Automatically injects core plugin properties (enabled, display_duration, etc.)
|
||||
into the schema before validation to ensure they're always allowed.
|
||||
|
||||
Args:
|
||||
config: Configuration dictionary to validate
|
||||
schema: JSON Schema dictionary
|
||||
plugin_id: Optional plugin ID for error messages
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, list_of_error_messages)
|
||||
"""
|
||||
errors = []
|
||||
|
||||
try:
|
||||
# Core plugin properties that should always be allowed
|
||||
# These are handled by the base plugin system and should not cause validation failures
|
||||
# Defaults match BasePlugin behavior: enabled=True, display_duration=15, live_priority=False
|
||||
core_properties = {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"default": True,
|
||||
"description": "Enable or disable this plugin"
|
||||
},
|
||||
"display_duration": {
|
||||
"type": "number",
|
||||
"default": 15,
|
||||
"minimum": 1,
|
||||
"maximum": 300,
|
||||
"description": "How long to display this plugin in seconds"
|
||||
},
|
||||
"live_priority": {
|
||||
"type": "boolean",
|
||||
"default": False,
|
||||
"description": "Enable live priority takeover when plugin has live content"
|
||||
}
|
||||
}
|
||||
|
||||
# Create a deep copy of the schema to modify (to avoid mutating the original)
|
||||
enhanced_schema = copy.deepcopy(schema)
|
||||
if "properties" not in enhanced_schema:
|
||||
enhanced_schema["properties"] = {}
|
||||
|
||||
# Inject core properties if they're not already defined in the schema
|
||||
# This ensures core properties are always allowed even if not in the plugin's schema
|
||||
properties_added = []
|
||||
for prop_name, prop_def in core_properties.items():
|
||||
if prop_name not in enhanced_schema["properties"]:
|
||||
enhanced_schema["properties"][prop_name] = copy.deepcopy(prop_def)
|
||||
properties_added.append(prop_name)
|
||||
|
||||
# Log if we added any core properties (for debugging)
|
||||
if properties_added and plugin_id:
|
||||
self.logger.debug(
|
||||
f"Injected core properties into schema for {plugin_id}: {properties_added}"
|
||||
)
|
||||
|
||||
# Remove core properties from required array (they're system-managed)
|
||||
# Core properties should be allowed but not required for validation
|
||||
if "required" in enhanced_schema:
|
||||
core_prop_names = list(core_properties.keys())
|
||||
removed_from_required = [
|
||||
field for field in enhanced_schema["required"]
|
||||
if field in core_prop_names
|
||||
]
|
||||
enhanced_schema["required"] = [
|
||||
field for field in enhanced_schema["required"]
|
||||
if field not in core_prop_names
|
||||
]
|
||||
|
||||
# Log if we removed any core properties from required (for debugging)
|
||||
if removed_from_required and plugin_id:
|
||||
self.logger.debug(
|
||||
f"Removed core properties from required array for {plugin_id}: {removed_from_required}"
|
||||
)
|
||||
|
||||
# Create validator with enhanced schema
|
||||
validator = Draft7Validator(enhanced_schema)
|
||||
|
||||
# Collect all validation errors
|
||||
for error in validator.iter_errors(config):
|
||||
error_msg = self._format_validation_error(error, plugin_id)
|
||||
errors.append(error_msg)
|
||||
|
||||
# Check required fields
|
||||
required_fields = enhanced_schema.get('required', [])
|
||||
for field in required_fields:
|
||||
if field not in config:
|
||||
errors.append(f"Missing required field: '{field}'")
|
||||
|
||||
if errors:
|
||||
return False, errors
|
||||
|
||||
return True, []
|
||||
|
||||
except jsonschema.SchemaError as e:
|
||||
error_msg = f"Schema error{' for ' + plugin_id if plugin_id else ''}: {str(e)}"
|
||||
self.logger.error(error_msg)
|
||||
return False, [error_msg]
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Validation error{' for ' + plugin_id if plugin_id else ''}: {str(e)}"
|
||||
self.logger.error(error_msg)
|
||||
return False, [error_msg]
|
||||
|
||||
def _format_validation_error(self, error: ValidationError, plugin_id: Optional[str] = None) -> str:
|
||||
"""
|
||||
Format a validation error into a readable message.
|
||||
|
||||
Args:
|
||||
error: ValidationError from jsonschema
|
||||
plugin_id: Optional plugin ID for context
|
||||
|
||||
Returns:
|
||||
Formatted error message
|
||||
"""
|
||||
path = '.'.join(str(p) for p in error.path)
|
||||
field_path = f"'{path}'" if path else "root"
|
||||
|
||||
if error.validator == 'required':
|
||||
missing = error.validator_value
|
||||
return f"Field {field_path}: Missing required property '{missing}'"
|
||||
elif error.validator == 'type':
|
||||
expected = error.validator_value
|
||||
actual = type(error.instance).__name__
|
||||
return f"Field {field_path}: Expected type {expected}, got {actual}"
|
||||
elif error.validator == 'enum':
|
||||
allowed = error.validator_value
|
||||
return f"Field {field_path}: Value '{error.instance}' not in allowed values {allowed}"
|
||||
elif error.validator in ['minimum', 'maximum']:
|
||||
limit = error.validator_value
|
||||
return f"Field {field_path}: Value {error.instance} violates {error.validator} constraint ({limit})"
|
||||
elif error.validator in ['minLength', 'maxLength']:
|
||||
limit = error.validator_value
|
||||
return f"Field {field_path}: Length {len(error.instance)} violates {error.validator} constraint ({limit})"
|
||||
elif error.validator in ['minItems', 'maxItems']:
|
||||
limit = error.validator_value
|
||||
return f"Field {field_path}: Array length {len(error.instance)} violates {error.validator} constraint ({limit})"
|
||||
else:
|
||||
return f"Field {field_path}: {error.message}"
|
||||
|
||||
def merge_with_defaults(self, config: Dict[str, Any], defaults: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Merge configuration with defaults, preserving user values.
|
||||
|
||||
Args:
|
||||
config: User configuration
|
||||
defaults: Default values from schema
|
||||
|
||||
Returns:
|
||||
Merged configuration with defaults applied where missing
|
||||
"""
|
||||
merged = defaults.copy()
|
||||
|
||||
def deep_merge(target: Dict[str, Any], source: Dict[str, Any]) -> None:
|
||||
"""Recursively merge source into target."""
|
||||
for key, value in source.items():
|
||||
if key in target and isinstance(target[key], dict) and isinstance(value, dict):
|
||||
deep_merge(target[key], value)
|
||||
else:
|
||||
target[key] = value
|
||||
|
||||
deep_merge(merged, config)
|
||||
return merged
|
||||
|
||||
437
src/plugin_system/state_manager.py
Normal file
437
src/plugin_system/state_manager.py
Normal file
@@ -0,0 +1,437 @@
|
||||
"""
|
||||
Centralized plugin state management.
|
||||
|
||||
Provides a single source of truth for plugin state (installed, enabled, version, etc.)
|
||||
with state change events and persistence.
|
||||
"""
|
||||
|
||||
import json
|
||||
import threading
|
||||
from typing import Dict, Any, Optional, List, Callable
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from dataclasses import dataclass, asdict
|
||||
from enum import Enum
|
||||
|
||||
from src.logging_config import get_logger
|
||||
|
||||
|
||||
class PluginStateStatus(Enum):
|
||||
"""Status of a plugin."""
|
||||
INSTALLED = "installed"
|
||||
ENABLED = "enabled"
|
||||
DISABLED = "disabled"
|
||||
ERROR = "error"
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
|
||||
@dataclass
|
||||
class PluginState:
|
||||
"""Represents the state of a plugin."""
|
||||
plugin_id: str
|
||||
status: PluginStateStatus
|
||||
enabled: bool
|
||||
version: Optional[str] = None
|
||||
installed_at: Optional[datetime] = None
|
||||
last_updated: Optional[datetime] = None
|
||||
config_version: int = 1 # For detecting state corruption
|
||||
metadata: Dict[str, Any] = None
|
||||
|
||||
def __post_init__(self):
|
||||
if self.metadata is None:
|
||||
self.metadata = {}
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert state to dictionary for serialization."""
|
||||
result = asdict(self)
|
||||
# Convert enum to string
|
||||
result['status'] = self.status.value
|
||||
# Convert datetime to ISO string
|
||||
if result.get('installed_at'):
|
||||
result['installed_at'] = self.installed_at.isoformat()
|
||||
if result.get('last_updated'):
|
||||
result['last_updated'] = self.last_updated.isoformat()
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'PluginState':
|
||||
"""Create state from dictionary."""
|
||||
# Parse enum
|
||||
if isinstance(data.get('status'), str):
|
||||
data['status'] = PluginStateStatus(data['status'])
|
||||
|
||||
# Parse datetime
|
||||
if data.get('installed_at') and isinstance(data['installed_at'], str):
|
||||
data['installed_at'] = datetime.fromisoformat(data['installed_at'])
|
||||
if data.get('last_updated') and isinstance(data['last_updated'], str):
|
||||
data['last_updated'] = datetime.fromisoformat(data['last_updated'])
|
||||
|
||||
return cls(**data)
|
||||
|
||||
|
||||
class PluginStateManager:
|
||||
"""
|
||||
Centralized plugin state manager.
|
||||
|
||||
Provides:
|
||||
- Single source of truth for plugin state
|
||||
- State change events/notifications
|
||||
- State persistence
|
||||
- State versioning
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
state_file: Optional[str] = None,
|
||||
auto_save: bool = True,
|
||||
lazy_load: bool = False
|
||||
):
|
||||
"""
|
||||
Initialize state manager.
|
||||
|
||||
Args:
|
||||
state_file: Path to file for persisting state
|
||||
auto_save: Whether to automatically save state on changes
|
||||
lazy_load: If True, defer loading state file until first access
|
||||
"""
|
||||
self.logger = get_logger(__name__)
|
||||
self.state_file = Path(state_file) if state_file else None
|
||||
self.auto_save = auto_save
|
||||
self._lazy_load = lazy_load
|
||||
self._state_loaded = False
|
||||
|
||||
# State storage
|
||||
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()
|
||||
|
||||
# Load state from file if it exists (unless lazy loading)
|
||||
if not self._lazy_load and self.state_file and self.state_file.exists():
|
||||
self._load_state()
|
||||
self._state_loaded = True
|
||||
|
||||
def _ensure_loaded(self) -> None:
|
||||
"""Ensure state is loaded (for lazy loading)."""
|
||||
if not self._state_loaded and self.state_file and self.state_file.exists():
|
||||
self._load_state()
|
||||
self._state_loaded = True
|
||||
|
||||
def get_plugin_state(self, plugin_id: str) -> Optional[PluginState]:
|
||||
"""
|
||||
Get state for a plugin.
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin identifier
|
||||
|
||||
Returns:
|
||||
PluginState if found, None otherwise
|
||||
"""
|
||||
self._ensure_loaded()
|
||||
with self._lock:
|
||||
return self._states.get(plugin_id)
|
||||
|
||||
def get_all_states(self) -> Dict[str, PluginState]:
|
||||
"""
|
||||
Get all plugin states.
|
||||
|
||||
Returns:
|
||||
Dictionary mapping plugin_id to PluginState
|
||||
"""
|
||||
self._ensure_loaded()
|
||||
with self._lock:
|
||||
return self._states.copy()
|
||||
|
||||
def update_plugin_state(
|
||||
self,
|
||||
plugin_id: str,
|
||||
updates: Dict[str, Any],
|
||||
notify: bool = True
|
||||
) -> bool:
|
||||
"""
|
||||
Update plugin state.
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin identifier
|
||||
updates: Dictionary of state updates
|
||||
notify: Whether to notify callbacks of changes
|
||||
|
||||
Returns:
|
||||
True if update successful
|
||||
"""
|
||||
self._ensure_loaded()
|
||||
with self._lock:
|
||||
# Get current state or create new
|
||||
current_state = self._states.get(plugin_id)
|
||||
if not current_state:
|
||||
current_state = PluginState(
|
||||
plugin_id=plugin_id,
|
||||
status=PluginStateStatus.UNKNOWN,
|
||||
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):
|
||||
current_state.status = PluginStateStatus(updates['status'])
|
||||
else:
|
||||
current_state.status = updates['status']
|
||||
|
||||
if 'enabled' in updates:
|
||||
current_state.enabled = bool(updates['enabled'])
|
||||
|
||||
if 'version' in updates:
|
||||
current_state.version = updates['version']
|
||||
|
||||
if 'installed_at' in updates:
|
||||
current_state.installed_at = updates['installed_at']
|
||||
|
||||
if 'last_updated' in updates:
|
||||
current_state.last_updated = updates['last_updated']
|
||||
else:
|
||||
current_state.last_updated = datetime.now()
|
||||
|
||||
if 'metadata' in updates:
|
||||
if current_state.metadata is None:
|
||||
current_state.metadata = {}
|
||||
current_state.metadata.update(updates['metadata'])
|
||||
|
||||
# Increment config version
|
||||
current_state.config_version += 1
|
||||
|
||||
# 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()
|
||||
|
||||
return True
|
||||
|
||||
def set_plugin_enabled(self, plugin_id: str, enabled: bool) -> bool:
|
||||
"""
|
||||
Set plugin enabled/disabled state.
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin identifier
|
||||
enabled: Whether plugin is enabled
|
||||
|
||||
Returns:
|
||||
True if update successful
|
||||
"""
|
||||
status = PluginStateStatus.ENABLED if enabled else PluginStateStatus.DISABLED
|
||||
return self.update_plugin_state(
|
||||
plugin_id,
|
||||
{
|
||||
'enabled': enabled,
|
||||
'status': status
|
||||
}
|
||||
)
|
||||
|
||||
def set_plugin_installed(
|
||||
self,
|
||||
plugin_id: str,
|
||||
version: Optional[str] = None,
|
||||
installed_at: Optional[datetime] = None
|
||||
) -> bool:
|
||||
"""
|
||||
Mark plugin as installed.
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin identifier
|
||||
version: Plugin version
|
||||
installed_at: Installation timestamp
|
||||
|
||||
Returns:
|
||||
True if update successful
|
||||
"""
|
||||
return self.update_plugin_state(
|
||||
plugin_id,
|
||||
{
|
||||
'status': PluginStateStatus.INSTALLED,
|
||||
'version': version,
|
||||
'installed_at': installed_at or datetime.now()
|
||||
}
|
||||
)
|
||||
|
||||
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).
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin identifier
|
||||
|
||||
Returns:
|
||||
True if removal successful
|
||||
"""
|
||||
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()
|
||||
|
||||
return True
|
||||
|
||||
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:
|
||||
return
|
||||
|
||||
try:
|
||||
with self._lock:
|
||||
# Convert states to dicts
|
||||
states_data = {
|
||||
plugin_id: state.to_dict()
|
||||
for plugin_id, state in self._states.items()
|
||||
}
|
||||
|
||||
state_data = {
|
||||
'version': self._state_version,
|
||||
'states': states_data,
|
||||
'last_updated': datetime.now().isoformat()
|
||||
}
|
||||
|
||||
# Ensure directory exists with proper permissions
|
||||
from src.common.permission_utils import (
|
||||
ensure_directory_permissions,
|
||||
get_config_dir_mode
|
||||
)
|
||||
ensure_directory_permissions(self.state_file.parent, get_config_dir_mode())
|
||||
|
||||
# Write to file
|
||||
with open(self.state_file, 'w') as f:
|
||||
json.dump(state_data, f, indent=2)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error saving plugin state: {e}", exc_info=True)
|
||||
|
||||
def _load_state(self) -> None:
|
||||
"""Load state from file."""
|
||||
if not self.state_file or not self.state_file.exists():
|
||||
return
|
||||
|
||||
try:
|
||||
with open(self.state_file, 'r') as f:
|
||||
state_data = json.load(f)
|
||||
|
||||
with self._lock:
|
||||
# Load state version
|
||||
self._state_version = state_data.get('version', 1)
|
||||
|
||||
# Load states
|
||||
states_data = state_data.get('states', {})
|
||||
for plugin_id, state_dict in states_data.items():
|
||||
try:
|
||||
self._states[plugin_id] = PluginState.from_dict(state_dict)
|
||||
except Exception as e:
|
||||
self.logger.warning(
|
||||
f"Error loading state for plugin {plugin_id}: {e}"
|
||||
)
|
||||
|
||||
self.logger.info(f"Loaded {len(self._states)} plugin states from file")
|
||||
|
||||
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
|
||||
|
||||
322
src/plugin_system/state_reconciliation.py
Normal file
322
src/plugin_system/state_reconciliation.py
Normal file
@@ -0,0 +1,322 @@
|
||||
"""
|
||||
State reconciliation system.
|
||||
|
||||
Detects and fixes inconsistencies between:
|
||||
- Config file state
|
||||
- Plugin manager state
|
||||
- Disk state (installed plugins)
|
||||
- State manager state
|
||||
"""
|
||||
|
||||
from typing import Dict, Any, List, Optional
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
|
||||
from src.plugin_system.state_manager import PluginStateManager, PluginState, PluginStateStatus
|
||||
from src.logging_config import get_logger
|
||||
|
||||
|
||||
class InconsistencyType(Enum):
|
||||
"""Types of state inconsistencies."""
|
||||
PLUGIN_MISSING_IN_CONFIG = "plugin_missing_in_config"
|
||||
PLUGIN_MISSING_ON_DISK = "plugin_missing_on_disk"
|
||||
PLUGIN_ENABLED_MISMATCH = "plugin_enabled_mismatch"
|
||||
PLUGIN_VERSION_MISMATCH = "plugin_version_mismatch"
|
||||
PLUGIN_STATE_CORRUPTED = "plugin_state_corrupted"
|
||||
|
||||
|
||||
class FixAction(Enum):
|
||||
"""Actions that can be taken to fix inconsistencies."""
|
||||
AUTO_FIX = "auto_fix"
|
||||
MANUAL_FIX_REQUIRED = "manual_fix_required"
|
||||
NO_ACTION = "no_action"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Inconsistency:
|
||||
"""Represents a state inconsistency."""
|
||||
plugin_id: str
|
||||
inconsistency_type: InconsistencyType
|
||||
description: str
|
||||
fix_action: FixAction
|
||||
current_state: Dict[str, Any]
|
||||
expected_state: Dict[str, Any]
|
||||
can_auto_fix: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class ReconciliationResult:
|
||||
"""Result of state reconciliation."""
|
||||
inconsistencies_found: List[Inconsistency]
|
||||
inconsistencies_fixed: List[Inconsistency]
|
||||
inconsistencies_manual: List[Inconsistency]
|
||||
reconciliation_successful: bool
|
||||
message: str
|
||||
|
||||
|
||||
class StateReconciliation:
|
||||
"""
|
||||
State reconciliation system.
|
||||
|
||||
Compares state from multiple sources and detects/fixes inconsistencies.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
state_manager: PluginStateManager,
|
||||
config_manager,
|
||||
plugin_manager,
|
||||
plugins_dir: Path
|
||||
):
|
||||
"""
|
||||
Initialize reconciliation system.
|
||||
|
||||
Args:
|
||||
state_manager: PluginStateManager instance
|
||||
config_manager: ConfigManager instance
|
||||
plugin_manager: PluginManager instance
|
||||
plugins_dir: Path to plugins directory
|
||||
"""
|
||||
self.state_manager = state_manager
|
||||
self.config_manager = config_manager
|
||||
self.plugin_manager = plugin_manager
|
||||
self.plugins_dir = Path(plugins_dir)
|
||||
self.logger = get_logger(__name__)
|
||||
|
||||
def reconcile_state(self) -> ReconciliationResult:
|
||||
"""
|
||||
Perform state reconciliation.
|
||||
|
||||
Compares state from all sources and fixes safe inconsistencies.
|
||||
|
||||
Returns:
|
||||
ReconciliationResult with findings and fixes
|
||||
"""
|
||||
self.logger.info("Starting state reconciliation")
|
||||
|
||||
inconsistencies = []
|
||||
fixed = []
|
||||
manual_fix_required = []
|
||||
|
||||
try:
|
||||
# Get state from all sources
|
||||
config_state = self._get_config_state()
|
||||
disk_state = self._get_disk_state()
|
||||
manager_state = self._get_manager_state()
|
||||
state_manager_state = self._get_state_manager_state()
|
||||
|
||||
# Find all unique plugin IDs
|
||||
all_plugin_ids = set()
|
||||
all_plugin_ids.update(config_state.keys())
|
||||
all_plugin_ids.update(disk_state.keys())
|
||||
all_plugin_ids.update(manager_state.keys())
|
||||
all_plugin_ids.update(state_manager_state.keys())
|
||||
|
||||
# Check each plugin for inconsistencies
|
||||
for plugin_id in all_plugin_ids:
|
||||
plugin_inconsistencies = self._check_plugin_consistency(
|
||||
plugin_id,
|
||||
config_state,
|
||||
disk_state,
|
||||
manager_state,
|
||||
state_manager_state
|
||||
)
|
||||
inconsistencies.extend(plugin_inconsistencies)
|
||||
|
||||
# Attempt to fix auto-fixable inconsistencies
|
||||
for inconsistency in inconsistencies:
|
||||
if inconsistency.can_auto_fix and inconsistency.fix_action == FixAction.AUTO_FIX:
|
||||
if self._fix_inconsistency(inconsistency):
|
||||
fixed.append(inconsistency)
|
||||
else:
|
||||
manual_fix_required.append(inconsistency)
|
||||
elif inconsistency.fix_action == FixAction.MANUAL_FIX_REQUIRED:
|
||||
manual_fix_required.append(inconsistency)
|
||||
|
||||
# Build result
|
||||
success = len(manual_fix_required) == 0
|
||||
|
||||
message = (
|
||||
f"Reconciliation complete: {len(inconsistencies)} inconsistencies found, "
|
||||
f"{len(fixed)} fixed automatically, {len(manual_fix_required)} require manual attention"
|
||||
)
|
||||
|
||||
return ReconciliationResult(
|
||||
inconsistencies_found=inconsistencies,
|
||||
inconsistencies_fixed=fixed,
|
||||
inconsistencies_manual=manual_fix_required,
|
||||
reconciliation_successful=success,
|
||||
message=message
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error during state reconciliation: {e}", exc_info=True)
|
||||
return ReconciliationResult(
|
||||
inconsistencies_found=inconsistencies,
|
||||
inconsistencies_fixed=fixed,
|
||||
inconsistencies_manual=manual_fix_required,
|
||||
reconciliation_successful=False,
|
||||
message=f"Reconciliation failed: {str(e)}"
|
||||
)
|
||||
|
||||
def _get_config_state(self) -> Dict[str, Dict[str, Any]]:
|
||||
"""Get plugin state from config file."""
|
||||
state = {}
|
||||
try:
|
||||
config = self.config_manager.load_config()
|
||||
for plugin_id, plugin_config in config.items():
|
||||
if isinstance(plugin_config, dict):
|
||||
state[plugin_id] = {
|
||||
'enabled': plugin_config.get('enabled', False),
|
||||
'version': plugin_config.get('version'),
|
||||
'exists_in_config': True
|
||||
}
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Error reading config state: {e}")
|
||||
return state
|
||||
|
||||
def _get_disk_state(self) -> Dict[str, Dict[str, Any]]:
|
||||
"""Get plugin state from disk (installed plugins)."""
|
||||
state = {}
|
||||
try:
|
||||
if self.plugins_dir.exists():
|
||||
for plugin_dir in self.plugins_dir.iterdir():
|
||||
if plugin_dir.is_dir():
|
||||
plugin_id = plugin_dir.name
|
||||
manifest_path = plugin_dir / "manifest.json"
|
||||
if manifest_path.exists():
|
||||
import json
|
||||
try:
|
||||
with open(manifest_path, 'r') as f:
|
||||
manifest = json.load(f)
|
||||
state[plugin_id] = {
|
||||
'exists_on_disk': True,
|
||||
'version': manifest.get('version'),
|
||||
'name': manifest.get('name')
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Error reading disk state: {e}")
|
||||
return state
|
||||
|
||||
def _get_manager_state(self) -> Dict[str, Dict[str, Any]]:
|
||||
"""Get plugin state from plugin manager."""
|
||||
state = {}
|
||||
try:
|
||||
if self.plugin_manager:
|
||||
# Get discovered plugins
|
||||
if hasattr(self.plugin_manager, 'plugin_manifests'):
|
||||
for plugin_id in self.plugin_manager.plugin_manifests.keys():
|
||||
state[plugin_id] = {
|
||||
'exists_in_manager': True,
|
||||
'loaded': plugin_id in getattr(self.plugin_manager, 'plugins', {})
|
||||
}
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Error reading manager state: {e}")
|
||||
return state
|
||||
|
||||
def _get_state_manager_state(self) -> Dict[str, Dict[str, Any]]:
|
||||
"""Get plugin state from state manager."""
|
||||
state = {}
|
||||
try:
|
||||
all_states = self.state_manager.get_all_states()
|
||||
for plugin_id, plugin_state in all_states.items():
|
||||
state[plugin_id] = {
|
||||
'enabled': plugin_state.enabled,
|
||||
'status': plugin_state.status.value,
|
||||
'version': plugin_state.version,
|
||||
'exists_in_state_manager': True
|
||||
}
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Error reading state manager state: {e}")
|
||||
return state
|
||||
|
||||
def _check_plugin_consistency(
|
||||
self,
|
||||
plugin_id: str,
|
||||
config_state: Dict[str, Dict[str, Any]],
|
||||
disk_state: Dict[str, Dict[str, Any]],
|
||||
manager_state: Dict[str, Dict[str, Any]],
|
||||
state_manager_state: Dict[str, Dict[str, Any]]
|
||||
) -> List[Inconsistency]:
|
||||
"""Check consistency for a single plugin."""
|
||||
inconsistencies = []
|
||||
|
||||
config = config_state.get(plugin_id, {})
|
||||
disk = disk_state.get(plugin_id, {})
|
||||
manager = manager_state.get(plugin_id, {})
|
||||
state_mgr = state_manager_state.get(plugin_id, {})
|
||||
|
||||
# Check: Plugin exists on disk but not in config
|
||||
if disk.get('exists_on_disk') and not config.get('exists_in_config'):
|
||||
inconsistencies.append(Inconsistency(
|
||||
plugin_id=plugin_id,
|
||||
inconsistency_type=InconsistencyType.PLUGIN_MISSING_IN_CONFIG,
|
||||
description=f"Plugin {plugin_id} exists on disk but not in config",
|
||||
fix_action=FixAction.AUTO_FIX,
|
||||
current_state={'exists_in_config': False},
|
||||
expected_state={'exists_in_config': True, 'enabled': False},
|
||||
can_auto_fix=True
|
||||
))
|
||||
|
||||
# Check: Plugin in config but not on disk
|
||||
if config.get('exists_in_config') and not disk.get('exists_on_disk'):
|
||||
inconsistencies.append(Inconsistency(
|
||||
plugin_id=plugin_id,
|
||||
inconsistency_type=InconsistencyType.PLUGIN_MISSING_ON_DISK,
|
||||
description=f"Plugin {plugin_id} in config but not on disk",
|
||||
fix_action=FixAction.MANUAL_FIX_REQUIRED,
|
||||
current_state={'exists_on_disk': False},
|
||||
expected_state={'exists_on_disk': True},
|
||||
can_auto_fix=False
|
||||
))
|
||||
|
||||
# Check: Enabled state mismatch
|
||||
config_enabled = config.get('enabled', False)
|
||||
state_mgr_enabled = state_mgr.get('enabled')
|
||||
|
||||
if state_mgr_enabled is not None and config_enabled != state_mgr_enabled:
|
||||
inconsistencies.append(Inconsistency(
|
||||
plugin_id=plugin_id,
|
||||
inconsistency_type=InconsistencyType.PLUGIN_ENABLED_MISMATCH,
|
||||
description=f"Plugin {plugin_id} enabled state mismatch: config={config_enabled}, state_manager={state_mgr_enabled}",
|
||||
fix_action=FixAction.AUTO_FIX,
|
||||
current_state={'enabled': config_enabled},
|
||||
expected_state={'enabled': state_mgr_enabled},
|
||||
can_auto_fix=True
|
||||
))
|
||||
|
||||
return inconsistencies
|
||||
|
||||
def _fix_inconsistency(self, inconsistency: Inconsistency) -> bool:
|
||||
"""Attempt to fix an inconsistency."""
|
||||
try:
|
||||
if inconsistency.inconsistency_type == InconsistencyType.PLUGIN_MISSING_IN_CONFIG:
|
||||
# Add plugin to config with default disabled state
|
||||
config = self.config_manager.load_config()
|
||||
config[inconsistency.plugin_id] = {
|
||||
'enabled': False
|
||||
}
|
||||
self.config_manager.save_config(config)
|
||||
self.logger.info(f"Fixed: Added {inconsistency.plugin_id} to config")
|
||||
return True
|
||||
|
||||
elif inconsistency.inconsistency_type == InconsistencyType.PLUGIN_ENABLED_MISMATCH:
|
||||
# Sync enabled state from state manager to config
|
||||
expected_enabled = inconsistency.expected_state.get('enabled')
|
||||
config = self.config_manager.load_config()
|
||||
if inconsistency.plugin_id not in config:
|
||||
config[inconsistency.plugin_id] = {}
|
||||
config[inconsistency.plugin_id]['enabled'] = expected_enabled
|
||||
self.config_manager.save_config(config)
|
||||
self.logger.info(f"Fixed: Synced enabled state for {inconsistency.plugin_id}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error fixing inconsistency: {e}", exc_info=True)
|
||||
return False
|
||||
|
||||
return False
|
||||
|
||||
1606
src/plugin_system/store_manager.py
Normal file
1606
src/plugin_system/store_manager.py
Normal file
File diff suppressed because it is too large
Load Diff
17
src/plugin_system/testing/__init__.py
Normal file
17
src/plugin_system/testing/__init__.py
Normal file
@@ -0,0 +1,17 @@
|
||||
"""
|
||||
Plugin Testing Framework
|
||||
|
||||
Provides base classes and utilities for testing LEDMatrix plugins.
|
||||
"""
|
||||
|
||||
from .plugin_test_base import PluginTestCase
|
||||
from .mocks import MockDisplayManager, MockCacheManager, MockConfigManager, MockPluginManager
|
||||
|
||||
__all__ = [
|
||||
'PluginTestCase',
|
||||
'MockDisplayManager',
|
||||
'MockCacheManager',
|
||||
'MockConfigManager',
|
||||
'MockPluginManager'
|
||||
]
|
||||
|
||||
181
src/plugin_system/testing/mocks.py
Normal file
181
src/plugin_system/testing/mocks.py
Normal file
@@ -0,0 +1,181 @@
|
||||
"""
|
||||
Mock objects for plugin testing.
|
||||
|
||||
Provides mock implementations of display_manager, cache_manager, config_manager,
|
||||
and plugin_manager for use in plugin unit tests.
|
||||
"""
|
||||
|
||||
from typing import Dict, Any, Optional
|
||||
from unittest.mock import MagicMock
|
||||
from PIL import Image
|
||||
|
||||
|
||||
class MockDisplayManager:
|
||||
"""Mock display manager for testing."""
|
||||
|
||||
def __init__(self, width: int = 128, height: int = 32):
|
||||
self.width = width
|
||||
self.display_width = width
|
||||
self.height = height
|
||||
self.display_height = height
|
||||
self.image = Image.new('RGB', (width, height), color=(0, 0, 0))
|
||||
self.clear_called = False
|
||||
self.update_called = False
|
||||
self.draw_calls = []
|
||||
|
||||
def clear(self):
|
||||
"""Clear the display."""
|
||||
self.clear_called = True
|
||||
self.image = Image.new('RGB', (self.width, self.height), color=(0, 0, 0))
|
||||
|
||||
def update_display(self):
|
||||
"""Update the display."""
|
||||
self.update_called = True
|
||||
|
||||
def draw_text(self, text: str, x: int, y: int, color: tuple = (255, 255, 255), font=None):
|
||||
"""Draw text on the display."""
|
||||
self.draw_calls.append({
|
||||
'type': 'text',
|
||||
'text': text,
|
||||
'x': x,
|
||||
'y': y,
|
||||
'color': color,
|
||||
'font': font
|
||||
})
|
||||
|
||||
def draw_image(self, image: Image.Image, x: int, y: int):
|
||||
"""Draw an image on the display."""
|
||||
self.draw_calls.append({
|
||||
'type': 'image',
|
||||
'image': image,
|
||||
'x': x,
|
||||
'y': y
|
||||
})
|
||||
|
||||
def reset(self):
|
||||
"""Reset mock state."""
|
||||
self.clear_called = False
|
||||
self.update_called = False
|
||||
self.draw_calls = []
|
||||
self.image = Image.new('RGB', (self.width, self.height), color=(0, 0, 0))
|
||||
|
||||
|
||||
class MockCacheManager:
|
||||
"""Mock cache manager for testing."""
|
||||
|
||||
def __init__(self):
|
||||
self._cache: Dict[str, Any] = {}
|
||||
self._cache_timestamps: Dict[str, float] = {}
|
||||
self.get_calls = []
|
||||
self.set_calls = []
|
||||
self.delete_calls = []
|
||||
|
||||
def get(self, key: str, max_age: Optional[float] = None) -> Optional[Any]:
|
||||
"""Get a value from cache."""
|
||||
import time
|
||||
self.get_calls.append({'key': key, 'max_age': max_age})
|
||||
|
||||
if key not in self._cache:
|
||||
return None
|
||||
|
||||
if max_age is not None:
|
||||
timestamp = self._cache_timestamps.get(key, 0)
|
||||
if time.time() - timestamp > max_age:
|
||||
return None
|
||||
|
||||
return self._cache.get(key)
|
||||
|
||||
def set(self, key: str, value: Any, ttl: Optional[float] = None) -> None:
|
||||
"""Set a value in cache."""
|
||||
import time
|
||||
self.set_calls.append({'key': key, 'value': value, 'ttl': ttl})
|
||||
self._cache[key] = value
|
||||
self._cache_timestamps[key] = time.time()
|
||||
|
||||
def delete(self, key: str) -> None:
|
||||
"""Delete a value from cache."""
|
||||
self.delete_calls.append(key)
|
||||
if key in self._cache:
|
||||
del self._cache[key]
|
||||
if key in self._cache_timestamps:
|
||||
del self._cache_timestamps[key]
|
||||
|
||||
def reset(self):
|
||||
"""Reset mock state."""
|
||||
self._cache.clear()
|
||||
self._cache_timestamps.clear()
|
||||
self.get_calls = []
|
||||
self.set_calls = []
|
||||
self.delete_calls = []
|
||||
|
||||
|
||||
class MockConfigManager:
|
||||
"""Mock config manager for testing."""
|
||||
|
||||
def __init__(self, config: Optional[Dict[str, Any]] = None):
|
||||
self._config = config or {}
|
||||
self.load_config_calls = []
|
||||
self.save_config_calls = []
|
||||
|
||||
def load_config(self) -> Dict[str, Any]:
|
||||
"""Load configuration."""
|
||||
self.load_config_calls.append({})
|
||||
return self._config.copy()
|
||||
|
||||
def save_config(self, config: Dict[str, Any]) -> None:
|
||||
"""Save configuration."""
|
||||
self.save_config_calls.append(config)
|
||||
self._config = config.copy()
|
||||
|
||||
def get_config(self, key: str, default: Any = None) -> Any:
|
||||
"""Get a config value."""
|
||||
return self._config.get(key, default)
|
||||
|
||||
def set_config(self, key: str, value: Any) -> None:
|
||||
"""Set a config value."""
|
||||
self._config[key] = value
|
||||
|
||||
def reset(self):
|
||||
"""Reset mock state."""
|
||||
self._config = {}
|
||||
self.load_config_calls = []
|
||||
self.save_config_calls = []
|
||||
|
||||
|
||||
class MockPluginManager:
|
||||
"""Mock plugin manager for testing."""
|
||||
|
||||
def __init__(self):
|
||||
self.plugins: Dict[str, Any] = {}
|
||||
self.plugin_manifests: Dict[str, Dict] = {}
|
||||
self.get_plugin_calls = []
|
||||
self.get_all_plugins_calls = []
|
||||
|
||||
def get_plugin(self, plugin_id: str) -> Optional[Any]:
|
||||
"""Get a plugin instance."""
|
||||
self.get_plugin_calls.append(plugin_id)
|
||||
return self.plugins.get(plugin_id)
|
||||
|
||||
def get_all_plugins(self) -> Dict[str, Any]:
|
||||
"""Get all plugin instances."""
|
||||
self.get_all_plugins_calls.append({})
|
||||
return self.plugins.copy()
|
||||
|
||||
def get_plugin_info(self, plugin_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Get plugin information."""
|
||||
manifest = self.plugin_manifests.get(plugin_id, {})
|
||||
plugin = self.plugins.get(plugin_id)
|
||||
if plugin:
|
||||
manifest['loaded'] = True
|
||||
manifest['runtime_info'] = getattr(plugin, 'get_info', lambda: {})()
|
||||
else:
|
||||
manifest['loaded'] = False
|
||||
return manifest
|
||||
|
||||
def reset(self):
|
||||
"""Reset mock state."""
|
||||
self.plugins.clear()
|
||||
self.plugin_manifests.clear()
|
||||
self.get_plugin_calls = []
|
||||
self.get_all_plugins_calls = []
|
||||
|
||||
153
src/plugin_system/testing/plugin_test_base.py
Normal file
153
src/plugin_system/testing/plugin_test_base.py
Normal file
@@ -0,0 +1,153 @@
|
||||
"""
|
||||
Base test class for LEDMatrix plugins.
|
||||
|
||||
Provides common fixtures and helper methods for plugin testing.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, Optional
|
||||
|
||||
# Add project root to path for imports
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent.parent
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from src.plugin_system.testing.mocks import (
|
||||
MockDisplayManager,
|
||||
MockCacheManager,
|
||||
MockConfigManager,
|
||||
MockPluginManager
|
||||
)
|
||||
|
||||
|
||||
class PluginTestCase(unittest.TestCase):
|
||||
"""
|
||||
Base test case for plugin testing.
|
||||
|
||||
Provides common fixtures and helper methods.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test fixtures."""
|
||||
# Create mock managers
|
||||
self.display_manager = MockDisplayManager(width=128, height=32)
|
||||
self.cache_manager = MockCacheManager()
|
||||
self.config_manager = MockConfigManager()
|
||||
self.plugin_manager = MockPluginManager()
|
||||
|
||||
# Default plugin configuration
|
||||
self.plugin_config = {
|
||||
'enabled': True,
|
||||
'display_duration': 15.0
|
||||
}
|
||||
|
||||
# Plugin ID for tests
|
||||
self.plugin_id = 'test-plugin'
|
||||
|
||||
def tearDown(self):
|
||||
"""Clean up after tests."""
|
||||
# Reset all mocks
|
||||
self.display_manager.reset()
|
||||
self.cache_manager.reset()
|
||||
self.config_manager.reset()
|
||||
self.plugin_manager.reset()
|
||||
|
||||
def create_plugin_instance(self, plugin_class, plugin_id: Optional[str] = None,
|
||||
config: Optional[Dict[str, Any]] = None):
|
||||
"""
|
||||
Create a plugin instance with mock dependencies.
|
||||
|
||||
Args:
|
||||
plugin_class: Plugin class to instantiate
|
||||
plugin_id: Optional plugin ID (defaults to self.plugin_id)
|
||||
config: Optional config dict (defaults to self.plugin_config)
|
||||
|
||||
Returns:
|
||||
Plugin instance
|
||||
"""
|
||||
pid = plugin_id or self.plugin_id
|
||||
cfg = config or self.plugin_config.copy()
|
||||
|
||||
return plugin_class(
|
||||
plugin_id=pid,
|
||||
config=cfg,
|
||||
display_manager=self.display_manager,
|
||||
cache_manager=self.cache_manager,
|
||||
plugin_manager=self.plugin_manager
|
||||
)
|
||||
|
||||
def assert_plugin_initialized(self, plugin):
|
||||
"""Assert that plugin was initialized correctly."""
|
||||
self.assertIsNotNone(plugin)
|
||||
self.assertEqual(plugin.plugin_id, self.plugin_id)
|
||||
self.assertEqual(plugin.config, self.plugin_config)
|
||||
self.assertEqual(plugin.display_manager, self.display_manager)
|
||||
self.assertEqual(plugin.cache_manager, self.cache_manager)
|
||||
self.assertEqual(plugin.plugin_manager, self.plugin_manager)
|
||||
|
||||
def assert_display_cleared(self):
|
||||
"""Assert that display was cleared."""
|
||||
self.assertTrue(self.display_manager.clear_called)
|
||||
|
||||
def assert_display_updated(self):
|
||||
"""Assert that display was updated."""
|
||||
self.assertTrue(self.display_manager.update_called)
|
||||
|
||||
def assert_text_drawn(self, text: Optional[str] = None):
|
||||
"""
|
||||
Assert that text was drawn on display.
|
||||
|
||||
Args:
|
||||
text: Optional text to check for
|
||||
"""
|
||||
text_calls = [c for c in self.display_manager.draw_calls if c['type'] == 'text']
|
||||
self.assertGreater(len(text_calls), 0, "No text was drawn")
|
||||
if text:
|
||||
texts = [c['text'] for c in text_calls]
|
||||
self.assertIn(text, texts, f"Text '{text}' not found in drawn texts: {texts}")
|
||||
|
||||
def assert_image_drawn(self):
|
||||
"""Assert that an image was drawn on display."""
|
||||
image_calls = [c for c in self.display_manager.draw_calls if c['type'] == 'image']
|
||||
self.assertGreater(len(image_calls), 0, "No image was drawn")
|
||||
|
||||
def assert_cache_get(self, key: Optional[str] = None):
|
||||
"""
|
||||
Assert that cache.get was called.
|
||||
|
||||
Args:
|
||||
key: Optional key to check for
|
||||
"""
|
||||
self.assertGreater(len(self.cache_manager.get_calls), 0, "cache.get was not called")
|
||||
if key:
|
||||
keys = [c['key'] for c in self.cache_manager.get_calls]
|
||||
self.assertIn(key, keys, f"Key '{key}' not found in cache.get calls: {keys}")
|
||||
|
||||
def assert_cache_set(self, key: Optional[str] = None):
|
||||
"""
|
||||
Assert that cache.set was called.
|
||||
|
||||
Args:
|
||||
key: Optional key to check for
|
||||
"""
|
||||
self.assertGreater(len(self.cache_manager.set_calls), 0, "cache.set was not called")
|
||||
if key:
|
||||
keys = [c['key'] for c in self.cache_manager.set_calls]
|
||||
self.assertIn(key, keys, f"Key '{key}' not found in cache.set calls: {keys}")
|
||||
|
||||
def get_mock_config(self, **overrides) -> Dict[str, Any]:
|
||||
"""
|
||||
Get mock configuration with optional overrides.
|
||||
|
||||
Args:
|
||||
**overrides: Config values to override
|
||||
|
||||
Returns:
|
||||
Configuration dictionary
|
||||
"""
|
||||
config = self.plugin_config.copy()
|
||||
config.update(overrides)
|
||||
return config
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,156 +0,0 @@
|
||||
import spotipy
|
||||
from spotipy.oauth2 import SpotifyOAuth
|
||||
import logging
|
||||
import json
|
||||
import os
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
||||
|
||||
# Suppress spotipy.cache_handler warnings about not being able to write cache
|
||||
logging.getLogger('spotipy.cache_handler').setLevel(logging.ERROR)
|
||||
|
||||
# Define paths relative to this file's location
|
||||
CONFIG_DIR = os.path.join(os.path.dirname(__file__), '..', 'config')
|
||||
SECRETS_PATH = os.path.join(CONFIG_DIR, 'config_secrets.json')
|
||||
SPOTIFY_AUTH_CACHE_PATH = os.path.join(CONFIG_DIR, 'spotify_auth.json') # Explicit cache path for token
|
||||
|
||||
# Resolve to absolute paths
|
||||
CONFIG_DIR = os.path.abspath(CONFIG_DIR)
|
||||
SECRETS_PATH = os.path.abspath(SECRETS_PATH)
|
||||
SPOTIFY_AUTH_CACHE_PATH = os.path.abspath(SPOTIFY_AUTH_CACHE_PATH)
|
||||
|
||||
class SpotifyClient:
|
||||
def __init__(self):
|
||||
self.client_id = None
|
||||
self.client_secret = None
|
||||
self.redirect_uri = None
|
||||
self.scope = "user-read-currently-playing user-read-playback-state"
|
||||
self.sp = None
|
||||
self.load_credentials()
|
||||
if self.client_id and self.client_secret and self.redirect_uri:
|
||||
# Attempt to authenticate once using the cache path
|
||||
self._authenticate()
|
||||
else:
|
||||
logging.warning("Spotify credentials not loaded. Spotify client will not be functional.")
|
||||
|
||||
def load_credentials(self):
|
||||
if not os.path.exists(SECRETS_PATH):
|
||||
logging.error(f"Secrets file not found at {SECRETS_PATH}. Spotify features will be unavailable.")
|
||||
return
|
||||
|
||||
try:
|
||||
with open(SECRETS_PATH, 'r') as f:
|
||||
secrets = json.load(f)
|
||||
music_secrets = secrets.get("music", {})
|
||||
self.client_id = music_secrets.get("SPOTIFY_CLIENT_ID")
|
||||
self.client_secret = music_secrets.get("SPOTIFY_CLIENT_SECRET")
|
||||
self.redirect_uri = music_secrets.get("SPOTIFY_REDIRECT_URI")
|
||||
if not all([self.client_id, self.client_secret, self.redirect_uri]):
|
||||
logging.warning("One or more Spotify credentials missing in config_secrets.json. Spotify will be unavailable.")
|
||||
except json.JSONDecodeError:
|
||||
logging.error(f"Error decoding JSON from {SECRETS_PATH}. Spotify will be unavailable.")
|
||||
except Exception as e:
|
||||
logging.error(f"Error loading Spotify credentials: {e}. Spotify will be unavailable.")
|
||||
|
||||
def _authenticate(self):
|
||||
"""Initializes Spotipy with SpotifyOAuth, relying on a cached token."""
|
||||
if not self.client_id or not self.client_secret or not self.redirect_uri:
|
||||
logging.warning("Cannot authenticate Spotify: credentials missing.")
|
||||
return
|
||||
|
||||
# ---- START DIAGNOSTIC BLOCK ----
|
||||
logging.info(f"SpotifyClient using cache path: {SPOTIFY_AUTH_CACHE_PATH}")
|
||||
if os.path.exists(SPOTIFY_AUTH_CACHE_PATH):
|
||||
logging.info(f"DIAG: Cache file {SPOTIFY_AUTH_CACHE_PATH} EXISTS.")
|
||||
# Log effective UID of the current process
|
||||
euid = os.geteuid()
|
||||
logging.info(f"DIAG: Current process Effective UID: {euid}")
|
||||
|
||||
try:
|
||||
stat_info = os.stat(SPOTIFY_AUTH_CACHE_PATH)
|
||||
logging.info(f"DIAG: Cache file stat: UID={stat_info.st_uid}, GID={stat_info.st_gid}, Mode={oct(stat_info.st_mode)}")
|
||||
|
||||
# Explicit check if EUID is the owner and has read permission
|
||||
if euid == stat_info.st_uid and (stat_info.st_mode & 0o400): # 0o400 is S_IRUSR
|
||||
logging.info("DIAG: Effective UID is owner AND has read permission (stat).")
|
||||
elif (stat_info.st_mode & 0o040) and euid in os.getgroups(): # Check group read
|
||||
logging.info("DIAG: Effective UID is in group AND group has read permission (stat).")
|
||||
elif stat_info.st_mode & 0o004: # Check other read
|
||||
logging.info("DIAG: Others have read permission (stat).")
|
||||
else:
|
||||
logging.warning("DIAG: Stat check indicates NO READ PERMISSION for effective UID.")
|
||||
|
||||
# Attempt to open and read directly
|
||||
with open(SPOTIFY_AUTH_CACHE_PATH, 'r') as f_test:
|
||||
content_preview = f_test.read(120) # Read a bit more
|
||||
logging.info(f"DIAG: Cache file manual read successful. Content (first 120 chars): '{content_preview}'")
|
||||
if not content_preview.strip():
|
||||
logging.warning("DIAG: Cache file IS EMPTY or whitespace only (manual inspection).")
|
||||
except Exception as e_diag:
|
||||
logging.error(f"DIAG: Error during diagnostic check/read of cache file: {e_diag}")
|
||||
else:
|
||||
logging.warning(f"DIAG: Cache file {SPOTIFY_AUTH_CACHE_PATH} does NOT exist when _authenticate is called.")
|
||||
# ---- END DIAGNOSTIC BLOCK ----
|
||||
|
||||
try:
|
||||
# Use the explicit cache path. Spotipy will try to load/refresh token from here.
|
||||
auth_manager = SpotifyOAuth(
|
||||
client_id=self.client_id,
|
||||
client_secret=self.client_secret,
|
||||
redirect_uri=self.redirect_uri,
|
||||
scope=self.scope,
|
||||
cache_path=SPOTIFY_AUTH_CACHE_PATH, # Use the defined cache path
|
||||
open_browser=False
|
||||
)
|
||||
self.sp = spotipy.Spotify(auth_manager=auth_manager)
|
||||
|
||||
# Try making a lightweight call to verify if the token from cache is valid or can be refreshed.
|
||||
self.sp.current_user() # This will raise an exception if token is invalid/expired and cannot be refreshed.
|
||||
logging.info("Spotify client initialized and authenticated using cached token.")
|
||||
except Exception as e:
|
||||
logging.warning(f"Spotify client initialization/authentication failed: {e}. Run authenticate_spotify.py if needed.")
|
||||
self.sp = None # Ensure sp is None if auth fails
|
||||
|
||||
def is_authenticated(self):
|
||||
"""Checks if the client is currently considered authenticated and usable."""
|
||||
return self.sp is not None # Relies on _authenticate setting sp to None on failure
|
||||
|
||||
# Removed get_auth_url method - this is now handled by authenticate_spotify.py
|
||||
|
||||
def get_current_track(self):
|
||||
"""Fetches the currently playing track from Spotify."""
|
||||
if not self.is_authenticated(): # Check our internal state
|
||||
# Do not attempt to re-authenticate here. User must run authenticate_spotify.py
|
||||
# logging.debug("Spotify not authenticated. Cannot fetch track. Run authenticate_spotify.py if needed.")
|
||||
return None
|
||||
|
||||
try:
|
||||
track_info = self.sp.current_playback()
|
||||
if track_info and track_info['item']:
|
||||
return track_info
|
||||
else:
|
||||
return None
|
||||
except spotipy.exceptions.SpotifyException as e:
|
||||
logging.error(f"Spotify API error when fetching current track: {e}")
|
||||
# If it's an auth error (e.g. token revoked server-side), set sp to None so is_authenticated reflects it.
|
||||
if e.http_status == 401 or e.http_status == 403:
|
||||
logging.warning("Spotify authentication error (token may be revoked or expired). Please re-run authenticate_spotify.py.")
|
||||
self.sp = None # Mark as not authenticated
|
||||
return None
|
||||
except Exception as e: # Catch other potential errors (network, etc.)
|
||||
logging.error(f"Unexpected error fetching current track from Spotify: {e}")
|
||||
return None
|
||||
|
||||
# Example Usage (for testing, adapt to new auth flow)
|
||||
# if __name__ == '__main__':
|
||||
# # First, ensure you have run authenticate_spotify.py successfully as the user.
|
||||
# client = SpotifyClient()
|
||||
# if client.is_authenticated():
|
||||
# print("Spotify client is authenticated.")
|
||||
# track = client.get_current_track()
|
||||
# if track:
|
||||
# print(json.dumps(track, indent=2))
|
||||
# else:
|
||||
# print("No track currently playing or error fetching.")
|
||||
# else:
|
||||
# print("Spotify client not authenticated. Please run src/authenticate_spotify.py as the correct user.")
|
||||
210
src/startup_validator.py
Normal file
210
src/startup_validator.py
Normal file
@@ -0,0 +1,210 @@
|
||||
"""
|
||||
Startup Validator
|
||||
|
||||
Validates system configuration, plugins, and dependencies on startup.
|
||||
Fails fast with clear error messages to prevent runtime issues.
|
||||
"""
|
||||
|
||||
import os
|
||||
import logging
|
||||
from typing import Dict, Any, List, Optional, Tuple
|
||||
from pathlib import Path
|
||||
from src.exceptions import ConfigError, PluginError, CacheError
|
||||
from src.logging_config import get_logger
|
||||
|
||||
|
||||
class StartupValidator:
|
||||
"""Validates system state on startup."""
|
||||
|
||||
def __init__(self, config_manager: Any, plugin_manager: Optional[Any] = None) -> None:
|
||||
"""
|
||||
Initialize the startup validator.
|
||||
|
||||
Args:
|
||||
config_manager: ConfigManager instance
|
||||
plugin_manager: Optional PluginManager instance
|
||||
"""
|
||||
self.config_manager = config_manager
|
||||
self.plugin_manager = plugin_manager
|
||||
self.logger = get_logger(__name__)
|
||||
self.errors: List[str] = []
|
||||
self.warnings: List[str] = []
|
||||
|
||||
def validate_all(self) -> Tuple[bool, List[str], List[str]]:
|
||||
"""
|
||||
Run all validation checks.
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, errors, warnings)
|
||||
"""
|
||||
self.logger.info("Starting startup validation...")
|
||||
|
||||
# Validate configuration
|
||||
self._validate_config()
|
||||
|
||||
# Validate cache directory
|
||||
self._validate_cache_directory()
|
||||
|
||||
# Validate display configuration
|
||||
self._validate_display_config()
|
||||
|
||||
# Validate plugins if plugin manager is available
|
||||
if self.plugin_manager:
|
||||
self._validate_plugins()
|
||||
|
||||
is_valid = len(self.errors) == 0
|
||||
|
||||
if is_valid:
|
||||
self.logger.info("Startup validation passed")
|
||||
if self.warnings:
|
||||
self.logger.warning(f"Startup validation completed with {len(self.warnings)} warning(s)")
|
||||
else:
|
||||
self.logger.error(f"Startup validation failed with {len(self.errors)} error(s)")
|
||||
|
||||
return (is_valid, self.errors.copy(), self.warnings.copy())
|
||||
|
||||
def _validate_config(self) -> None:
|
||||
"""Validate configuration files."""
|
||||
try:
|
||||
config = self.config_manager.load_config()
|
||||
|
||||
# Check for required top-level keys
|
||||
required_keys = ['display', 'timezone']
|
||||
for key in required_keys:
|
||||
if key not in config:
|
||||
self.errors.append(f"Missing required configuration key: {key}")
|
||||
|
||||
# Validate display configuration
|
||||
display_config = config.get('display', {})
|
||||
if not display_config:
|
||||
self.errors.append("Display configuration is missing or empty")
|
||||
|
||||
except ConfigError as e:
|
||||
self.errors.append(f"Configuration error: {e}")
|
||||
except Exception as e:
|
||||
self.errors.append(f"Unexpected error validating configuration: {e}")
|
||||
|
||||
def _validate_cache_directory(self) -> None:
|
||||
"""Validate cache directory permissions."""
|
||||
try:
|
||||
from src.cache_manager import CacheManager
|
||||
cache_manager = CacheManager()
|
||||
cache_dir = cache_manager.get_cache_dir()
|
||||
|
||||
if not cache_dir:
|
||||
self.warnings.append("Cache directory not available - caching will be disabled")
|
||||
return
|
||||
|
||||
# Check if directory exists and is writable
|
||||
if not os.path.exists(cache_dir):
|
||||
self.errors.append(f"Cache directory does not exist: {cache_dir}")
|
||||
return
|
||||
|
||||
if not os.access(cache_dir, os.W_OK):
|
||||
self.errors.append(f"Cache directory is not writable: {cache_dir}")
|
||||
return
|
||||
|
||||
# Test write access
|
||||
test_file = os.path.join(cache_dir, '.startup_test')
|
||||
try:
|
||||
with open(test_file, 'w') as f:
|
||||
f.write('test')
|
||||
os.remove(test_file)
|
||||
except (IOError, OSError) as e:
|
||||
self.errors.append(f"Cannot write to cache directory {cache_dir}: {e}")
|
||||
|
||||
except Exception as e:
|
||||
self.warnings.append(f"Could not validate cache directory: {e}")
|
||||
|
||||
def _validate_display_config(self) -> None:
|
||||
"""Validate display configuration."""
|
||||
try:
|
||||
config = self.config_manager.get_config()
|
||||
display_config = config.get('display', {})
|
||||
|
||||
if not display_config:
|
||||
self.errors.append("Display configuration is missing")
|
||||
return
|
||||
|
||||
hardware_config = display_config.get('hardware', {})
|
||||
if not hardware_config:
|
||||
self.errors.append("Display hardware configuration is missing")
|
||||
return
|
||||
|
||||
# Check required hardware settings
|
||||
required_hardware = ['rows', 'cols']
|
||||
for key in required_hardware:
|
||||
if key not in hardware_config:
|
||||
self.warnings.append(f"Display hardware setting '{key}' not specified, using default")
|
||||
|
||||
except Exception as e:
|
||||
self.warnings.append(f"Could not validate display configuration: {e}")
|
||||
|
||||
def _validate_plugins(self) -> None:
|
||||
"""Validate plugin configurations and dependencies."""
|
||||
if not self.plugin_manager:
|
||||
return
|
||||
|
||||
try:
|
||||
# Get enabled plugins from config
|
||||
config = self.config_manager.get_config()
|
||||
discovered_plugins = self.plugin_manager.discover_plugins()
|
||||
|
||||
# Check for enabled plugins that don't exist
|
||||
for plugin_id, plugin_config in config.items():
|
||||
# Skip non-plugin config sections
|
||||
if plugin_id in ['display', 'schedule', 'timezone', 'plugin_system']:
|
||||
continue
|
||||
|
||||
if not isinstance(plugin_config, dict):
|
||||
continue
|
||||
|
||||
if plugin_config.get('enabled', False):
|
||||
if plugin_id not in discovered_plugins:
|
||||
self.warnings.append(f"Plugin '{plugin_id}' is enabled but not found in plugins directory")
|
||||
|
||||
# Validate plugin configurations
|
||||
for plugin_id in discovered_plugins:
|
||||
plugin_config = config.get(plugin_id, {})
|
||||
if plugin_config.get('enabled', False):
|
||||
# Check if plugin can be loaded (without actually loading it)
|
||||
plugin_dir = self.plugin_manager.get_plugin_directory(plugin_id)
|
||||
if plugin_dir:
|
||||
manifest_path = Path(plugin_dir) / "manifest.json"
|
||||
if not manifest_path.exists():
|
||||
self.errors.append(f"Plugin '{plugin_id}' manifest.json not found")
|
||||
|
||||
except Exception as e:
|
||||
self.warnings.append(f"Could not validate plugins: {e}")
|
||||
|
||||
def raise_on_errors(self) -> None:
|
||||
"""
|
||||
Raise exceptions if validation errors exist.
|
||||
|
||||
Raises:
|
||||
ConfigError: If configuration validation fails
|
||||
CacheError: If cache validation fails
|
||||
PluginError: If plugin validation fails
|
||||
"""
|
||||
if not self.errors:
|
||||
return
|
||||
|
||||
# Group errors by type
|
||||
config_errors = [e for e in self.errors if 'configuration' in e.lower() or 'config' in e.lower()]
|
||||
cache_errors = [e for e in self.errors if 'cache' in e.lower()]
|
||||
plugin_errors = [e for e in self.errors if 'plugin' in e.lower()]
|
||||
other_errors = [e for e in self.errors if e not in config_errors + cache_errors + plugin_errors]
|
||||
|
||||
# Raise appropriate exceptions
|
||||
if config_errors:
|
||||
raise ConfigError("Configuration validation failed", context={'errors': config_errors})
|
||||
|
||||
if cache_errors:
|
||||
raise CacheError("Cache validation failed", context={'errors': cache_errors})
|
||||
|
||||
if plugin_errors:
|
||||
raise PluginError("Plugin validation failed", context={'errors': plugin_errors})
|
||||
|
||||
if other_errors:
|
||||
raise ConfigError("Startup validation failed", context={'errors': other_errors})
|
||||
|
||||
@@ -1,211 +0,0 @@
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from typing import Optional, Tuple
|
||||
from PIL import Image, ImageOps
|
||||
import json
|
||||
|
||||
from .display_manager import DisplayManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class StaticImageManager:
|
||||
"""
|
||||
Manager for displaying static images on the LED matrix.
|
||||
Supports image scaling, transparency, and configurable display duration.
|
||||
"""
|
||||
|
||||
def __init__(self, display_manager: DisplayManager, config: dict):
|
||||
self.display_manager = display_manager
|
||||
self.config = config.get('static_image', {})
|
||||
|
||||
# Configuration
|
||||
self.enabled = self.config.get('enabled', False)
|
||||
self.image_path = self.config.get('image_path', '')
|
||||
# Get display duration from main display_durations block
|
||||
self.display_duration = config.get('display', {}).get('display_durations', {}).get('static_image', 10)
|
||||
self.fit_to_display = self.config.get('fit_to_display', True) # Auto-fit to display dimensions
|
||||
self.preserve_aspect_ratio = self.config.get('preserve_aspect_ratio', True)
|
||||
self.background_color = tuple(self.config.get('background_color', [0, 0, 0]))
|
||||
|
||||
# State
|
||||
self.current_image = None
|
||||
self.image_loaded = False
|
||||
self.last_update_time = 0
|
||||
|
||||
# Load initial image if enabled
|
||||
if self.enabled and self.image_path:
|
||||
self._load_image()
|
||||
|
||||
def _load_image(self) -> bool:
|
||||
"""
|
||||
Load and process the image for display.
|
||||
Returns True if successful, False otherwise.
|
||||
"""
|
||||
if not self.image_path or not os.path.exists(self.image_path):
|
||||
logger.warning(f"[Static Image] Image file not found: {self.image_path}")
|
||||
return False
|
||||
|
||||
try:
|
||||
# Load the image
|
||||
img = Image.open(self.image_path)
|
||||
|
||||
# Convert to RGBA to handle transparency
|
||||
if img.mode != 'RGBA':
|
||||
img = img.convert('RGBA')
|
||||
|
||||
# Get display dimensions
|
||||
display_width = self.display_manager.matrix.width
|
||||
display_height = self.display_manager.matrix.height
|
||||
|
||||
# Calculate target size - always fit to display while preserving aspect ratio
|
||||
target_size = self._calculate_fit_size(img.size, (display_width, display_height))
|
||||
|
||||
# Resize image
|
||||
if self.preserve_aspect_ratio:
|
||||
img = img.resize(target_size, Image.Resampling.LANCZOS)
|
||||
else:
|
||||
img = img.resize((display_width, display_height), Image.Resampling.LANCZOS)
|
||||
|
||||
# Create display-sized canvas with background color
|
||||
canvas = Image.new('RGB', (display_width, display_height), self.background_color)
|
||||
|
||||
# Calculate position to center the image
|
||||
paste_x = (display_width - img.width) // 2
|
||||
paste_y = (display_height - img.height) // 2
|
||||
|
||||
# Handle transparency by compositing
|
||||
if img.mode == 'RGBA':
|
||||
# Create a temporary image with the background color
|
||||
temp_canvas = Image.new('RGB', (display_width, display_height), self.background_color)
|
||||
temp_canvas.paste(img, (paste_x, paste_y), img)
|
||||
canvas = temp_canvas
|
||||
else:
|
||||
canvas.paste(img, (paste_x, paste_y))
|
||||
|
||||
self.current_image = canvas
|
||||
self.image_loaded = True
|
||||
self.last_update_time = time.time()
|
||||
|
||||
logger.info(f"[Static Image] Successfully loaded and processed image: {self.image_path}")
|
||||
logger.info(f"[Static Image] Original size: {Image.open(self.image_path).size}, "
|
||||
f"Display size: {target_size}, Position: ({paste_x}, {paste_y})")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Static Image] Error loading image {self.image_path}: {e}")
|
||||
self.image_loaded = False
|
||||
return False
|
||||
|
||||
def _calculate_fit_size(self, image_size: Tuple[int, int], display_size: Tuple[int, int]) -> Tuple[int, int]:
|
||||
"""
|
||||
Calculate the size to fit an image within display bounds while preserving aspect ratio.
|
||||
"""
|
||||
img_width, img_height = image_size
|
||||
display_width, display_height = display_size
|
||||
|
||||
# Calculate scaling factor to fit within display
|
||||
scale_x = display_width / img_width
|
||||
scale_y = display_height / img_height
|
||||
scale = min(scale_x, scale_y)
|
||||
|
||||
return (int(img_width * scale), int(img_height * scale))
|
||||
|
||||
def update(self):
|
||||
"""
|
||||
Update method - no continuous updates needed for static images.
|
||||
"""
|
||||
pass
|
||||
|
||||
def display(self, force_clear: bool = False):
|
||||
"""
|
||||
Display the static image on the LED matrix.
|
||||
"""
|
||||
if not self.enabled or not self.image_loaded or not self.current_image:
|
||||
if self.enabled:
|
||||
logger.warning("[Static Image] Manager enabled but no image loaded")
|
||||
return
|
||||
|
||||
# Clear display if requested
|
||||
if force_clear:
|
||||
self.display_manager.clear()
|
||||
|
||||
# Set the image on the display manager
|
||||
self.display_manager.image = self.current_image.copy()
|
||||
|
||||
# Update the display
|
||||
self.display_manager.update_display()
|
||||
|
||||
logger.debug(f"[Static Image] Displayed image: {self.image_path}")
|
||||
|
||||
def set_image_path(self, image_path: str) -> bool:
|
||||
"""
|
||||
Set a new image path and load it.
|
||||
Returns True if successful, False otherwise.
|
||||
"""
|
||||
self.image_path = image_path
|
||||
return self._load_image()
|
||||
|
||||
def set_fit_to_display(self, fit_to_display: bool):
|
||||
"""
|
||||
Set the fit to display option and reload the image.
|
||||
"""
|
||||
self.fit_to_display = fit_to_display
|
||||
if self.image_path:
|
||||
self._load_image()
|
||||
logger.info(f"[Static Image] Fit to display set to: {self.fit_to_display}")
|
||||
|
||||
def set_display_duration(self, duration: int):
|
||||
"""
|
||||
Set the display duration in seconds.
|
||||
"""
|
||||
self.display_duration = max(1, duration) # Minimum 1 second
|
||||
logger.info(f"[Static Image] Display duration set to: {self.display_duration} seconds")
|
||||
|
||||
def set_background_color(self, color: Tuple[int, int, int]):
|
||||
"""
|
||||
Set the background color and reload the image.
|
||||
"""
|
||||
self.background_color = color
|
||||
if self.image_path:
|
||||
self._load_image()
|
||||
logger.info(f"[Static Image] Background color set to: {self.background_color}")
|
||||
|
||||
def get_image_info(self) -> dict:
|
||||
"""
|
||||
Get information about the currently loaded image.
|
||||
"""
|
||||
if not self.image_loaded or not self.current_image:
|
||||
return {"loaded": False}
|
||||
|
||||
return {
|
||||
"loaded": True,
|
||||
"path": self.image_path,
|
||||
"display_size": self.current_image.size,
|
||||
"fit_to_display": self.fit_to_display,
|
||||
"display_duration": self.display_duration,
|
||||
"background_color": self.background_color
|
||||
}
|
||||
|
||||
def reload_image(self) -> bool:
|
||||
"""
|
||||
Reload the current image.
|
||||
"""
|
||||
if not self.image_path:
|
||||
logger.warning("[Static Image] No image path set for reload")
|
||||
return False
|
||||
|
||||
return self._load_image()
|
||||
|
||||
def is_enabled(self) -> bool:
|
||||
"""
|
||||
Check if the manager is enabled.
|
||||
"""
|
||||
return self.enabled
|
||||
|
||||
def get_display_duration(self) -> int:
|
||||
"""
|
||||
Get the display duration in seconds.
|
||||
"""
|
||||
return self.display_duration
|
||||
@@ -1,824 +0,0 @@
|
||||
import time
|
||||
import logging
|
||||
import requests
|
||||
import json
|
||||
import random
|
||||
from typing import Dict, Any, List, Tuple
|
||||
from datetime import datetime
|
||||
import os
|
||||
import urllib.parse
|
||||
import re
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
import numpy as np
|
||||
import hashlib
|
||||
from .cache_manager import CacheManager
|
||||
from requests.adapters import HTTPAdapter
|
||||
from urllib3.util.retry import Retry
|
||||
|
||||
# Import the API counter function from web interface
|
||||
try:
|
||||
from web_interface_v2 import increment_api_counter
|
||||
except ImportError:
|
||||
# Fallback if web interface is not available
|
||||
def increment_api_counter(kind: str, count: int = 1):
|
||||
pass
|
||||
|
||||
# Get logger without configuring
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class StockManager:
|
||||
def __init__(self, config: Dict[str, Any], display_manager):
|
||||
self.config = config
|
||||
self.display_manager = display_manager
|
||||
self.stocks_config = config.get('stocks', {})
|
||||
self.crypto_config = config.get('crypto', {})
|
||||
self.last_update = 0
|
||||
self.stock_data = {}
|
||||
self.current_stock_index = 0
|
||||
self.scroll_position = 0
|
||||
self.cached_text_image = None
|
||||
self.cached_text = None
|
||||
self.cache_manager = CacheManager()
|
||||
|
||||
# Get scroll settings from config with faster defaults
|
||||
self.scroll_speed = self.stocks_config.get('scroll_speed', 1)
|
||||
self.scroll_delay = self.stocks_config.get('scroll_delay', 0.01)
|
||||
|
||||
# Get chart toggle setting from config
|
||||
self.toggle_chart = self.stocks_config.get('toggle_chart', False)
|
||||
|
||||
# Dynamic duration settings
|
||||
self.dynamic_duration_enabled = self.stocks_config.get('dynamic_duration', True)
|
||||
self.min_duration = self.stocks_config.get('min_duration', 30)
|
||||
self.max_duration = self.stocks_config.get('max_duration', 300)
|
||||
self.duration_buffer = self.stocks_config.get('duration_buffer', 0.1)
|
||||
self.dynamic_duration = 60 # Default duration in seconds
|
||||
self.total_scroll_width = 0 # Track total width for dynamic duration calculation
|
||||
|
||||
# Initialize frame rate tracking
|
||||
self.frame_count = 0
|
||||
self.last_frame_time = time.time()
|
||||
self.last_fps_log_time = time.time()
|
||||
self.frame_times = []
|
||||
|
||||
# Set up the ticker icons directory
|
||||
self.ticker_icons_dir = os.path.join('assets', 'stocks', 'ticker_icons')
|
||||
if not os.path.exists(self.ticker_icons_dir):
|
||||
logger.warning(f"Ticker icons directory not found: {self.ticker_icons_dir}")
|
||||
|
||||
# Set up the crypto icons directory
|
||||
self.crypto_icons_dir = os.path.join('assets', 'stocks', 'crypto_icons')
|
||||
if not os.path.exists(self.crypto_icons_dir):
|
||||
logger.warning(f"Crypto icons directory not found: {self.crypto_icons_dir}")
|
||||
|
||||
# Set up the logo directory for external logos
|
||||
self.logo_dir = os.path.join('assets', 'stocks')
|
||||
if not os.path.exists(self.logo_dir):
|
||||
try:
|
||||
os.makedirs(self.logo_dir, mode=0o755, exist_ok=True)
|
||||
logger.info(f"Created logo directory: {self.logo_dir}")
|
||||
except (PermissionError, OSError) as e:
|
||||
logger.error(f"Cannot create logo directory '{self.logo_dir}': {str(e)}")
|
||||
self.logo_dir = None
|
||||
|
||||
self.headers = {
|
||||
'User-Agent': 'LEDMatrix/1.0 (https://github.com/yourusername/LEDMatrix; contact@example.com)',
|
||||
'Accept': 'application/json',
|
||||
'Accept-Language': 'en-US,en;q=0.9',
|
||||
'Accept-Encoding': 'gzip, deflate, br',
|
||||
'Connection': 'keep-alive'
|
||||
}
|
||||
|
||||
# Set up session with retry logic
|
||||
self.session = requests.Session()
|
||||
retry_strategy = Retry(
|
||||
total=5, # increased number of retries
|
||||
backoff_factor=1, # increased backoff factor
|
||||
status_forcelist=[429, 500, 502, 503, 504], # added 429 to retry list
|
||||
allowed_methods=["GET", "HEAD", "OPTIONS"]
|
||||
)
|
||||
adapter = HTTPAdapter(max_retries=retry_strategy)
|
||||
self.session.mount("https://", adapter)
|
||||
self.session.mount("http://", adapter)
|
||||
|
||||
# Initialize with first update
|
||||
self.update_stock_data()
|
||||
|
||||
def _get_stock_color(self, symbol: str) -> Tuple[int, int, int]:
|
||||
"""Get color based on stock performance."""
|
||||
if symbol not in self.stock_data:
|
||||
return (255, 255, 255) # White for unknown
|
||||
|
||||
change = self.stock_data[symbol].get('change', 0)
|
||||
if change > 0:
|
||||
return (0, 255, 0) # Green for positive
|
||||
elif change < 0:
|
||||
return (255, 0, 0) # Red for negative
|
||||
return (255, 255, 0) # Yellow for no change
|
||||
|
||||
def _extract_json_from_html(self, html: str) -> Dict:
|
||||
"""Extract the JSON data from Yahoo Finance HTML."""
|
||||
try:
|
||||
# Look for the finance data in the HTML
|
||||
patterns = [
|
||||
r'root\.App\.main = (.*?);\s*</script>',
|
||||
r'"QuotePageStore":\s*({.*?}),\s*"',
|
||||
r'{"regularMarketPrice":.*?"regularMarketChangePercent".*?}'
|
||||
]
|
||||
|
||||
for pattern in patterns:
|
||||
match = re.search(pattern, html, re.DOTALL)
|
||||
if match:
|
||||
json_str = match.group(1)
|
||||
try:
|
||||
data = json.loads(json_str)
|
||||
if isinstance(data, dict):
|
||||
if 'context' in data:
|
||||
# First pattern matched
|
||||
context = data.get('context', {})
|
||||
dispatcher = context.get('dispatcher', {})
|
||||
stores = dispatcher.get('stores', {})
|
||||
quote_data = stores.get('QuoteSummaryStore', {})
|
||||
if quote_data:
|
||||
return quote_data
|
||||
else:
|
||||
# Direct quote data
|
||||
return data
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
# If we get here, try one last attempt to find the price data directly
|
||||
price_match = re.search(r'"regularMarketPrice":{"raw":([\d.]+)', html)
|
||||
change_match = re.search(r'"regularMarketChangePercent":{"raw":([-\d.]+)', html)
|
||||
prev_close_match = re.search(r'"regularMarketPreviousClose":{"raw":([\d.]+)', html)
|
||||
name_match = re.search(r'"shortName":"([^"]+)"', html)
|
||||
|
||||
if price_match:
|
||||
return {
|
||||
"price": {
|
||||
"regularMarketPrice": {"raw": float(price_match.group(1))},
|
||||
"regularMarketChangePercent": {"raw": float(change_match.group(1)) if change_match else 0},
|
||||
"regularMarketPreviousClose": {"raw": float(prev_close_match.group(1)) if prev_close_match else 0},
|
||||
"shortName": name_match.group(1) if name_match else None
|
||||
}
|
||||
}
|
||||
|
||||
return {}
|
||||
except Exception as e:
|
||||
logger.error(f"Error extracting JSON data: {e}")
|
||||
return {}
|
||||
|
||||
def _fetch_stock_data(self, symbol: str, is_crypto: bool = False) -> Dict[str, Any]:
|
||||
"""Fetch stock or crypto data from Yahoo Finance public API."""
|
||||
# Try to get cached data first
|
||||
cache_key = 'crypto' if is_crypto else 'stocks'
|
||||
cached_data = self.cache_manager.get(cache_key)
|
||||
if cached_data and symbol in cached_data:
|
||||
logger.info(f"Using cached data for {symbol}")
|
||||
return cached_data[symbol]
|
||||
|
||||
try:
|
||||
# For crypto, we need to append -USD if not already present
|
||||
if is_crypto and not symbol.endswith('-USD'):
|
||||
encoded_symbol = urllib.parse.quote(f"{symbol}-USD")
|
||||
else:
|
||||
encoded_symbol = urllib.parse.quote(symbol)
|
||||
|
||||
url = f"https://query1.finance.yahoo.com/v8/finance/chart/{encoded_symbol}"
|
||||
params = {
|
||||
'interval': '5m', # 5-minute intervals
|
||||
'range': '1d' # 1 day of data
|
||||
}
|
||||
|
||||
# Use session with retry logic
|
||||
response = self.session.get(
|
||||
url,
|
||||
headers=self.headers,
|
||||
params=params,
|
||||
timeout=10, # Increased timeout
|
||||
verify=True # Enable SSL verification
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.error(f"Failed to fetch data for {symbol}: HTTP {response.status_code}")
|
||||
return None
|
||||
|
||||
data = response.json()
|
||||
|
||||
# Increment API counter for stock/crypto data call
|
||||
increment_api_counter('stocks', 1)
|
||||
|
||||
# Extract the relevant data from the response
|
||||
chart_data = data.get('chart', {}).get('result', [{}])[0]
|
||||
meta = chart_data.get('meta', {})
|
||||
|
||||
if not meta:
|
||||
logger.error(f"No meta data found for {symbol}")
|
||||
return None
|
||||
|
||||
current_price = meta.get('regularMarketPrice', 0)
|
||||
prev_close = meta.get('previousClose', current_price)
|
||||
|
||||
# Get price history
|
||||
timestamps = chart_data.get('timestamp', [])
|
||||
indicators = chart_data.get('indicators', {}).get('quote', [{}])[0]
|
||||
close_prices = indicators.get('close', [])
|
||||
|
||||
# Build price history
|
||||
price_history = []
|
||||
for i, ts in enumerate(timestamps):
|
||||
if i < len(close_prices) and close_prices[i] is not None:
|
||||
price_history.append({
|
||||
'timestamp': datetime.fromtimestamp(ts),
|
||||
'price': close_prices[i]
|
||||
})
|
||||
|
||||
# Calculate change percentage
|
||||
change_pct = ((current_price - prev_close) / prev_close) * 100 if prev_close > 0 else 0
|
||||
|
||||
# Get company name (symbol will be used if name not available)
|
||||
name = meta.get('symbol', symbol)
|
||||
|
||||
logger.debug(f"Processed data for {symbol}: price={current_price}, change={change_pct}%")
|
||||
|
||||
# Remove -USD suffix from crypto symbols for display
|
||||
display_symbol = symbol.replace('-USD', '') if is_crypto else symbol
|
||||
|
||||
stock_data = {
|
||||
"symbol": display_symbol, # Use the display symbol without -USD
|
||||
"name": name,
|
||||
"price": current_price,
|
||||
"change": change_pct,
|
||||
"open": prev_close,
|
||||
"price_history": price_history,
|
||||
"is_crypto": is_crypto
|
||||
}
|
||||
|
||||
# Cache the new data
|
||||
if cached_data is None:
|
||||
cached_data = {}
|
||||
cached_data[symbol] = stock_data
|
||||
self.cache_manager.update_cache(cache_key, cached_data)
|
||||
|
||||
# Add a longer delay between requests to avoid rate limiting
|
||||
time.sleep(random.uniform(1.0, 2.0)) # increased delay between requests
|
||||
return stock_data
|
||||
|
||||
except requests.exceptions.SSLError as e:
|
||||
logger.error(f"SSL error fetching data for {symbol}: {e}")
|
||||
# Try to use cached data as fallback
|
||||
if cached_data and symbol in cached_data:
|
||||
logger.info(f"Using cached data as fallback for {symbol} after SSL error")
|
||||
return cached_data[symbol]
|
||||
return None
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"Network error fetching data for {symbol}: {e}")
|
||||
# Try to use cached data as fallback
|
||||
if cached_data and symbol in cached_data:
|
||||
logger.info(f"Using cached data as fallback for {symbol}")
|
||||
return cached_data[symbol]
|
||||
return None
|
||||
except (ValueError, IndexError, KeyError) as e:
|
||||
logger.error(f"Error parsing data for {symbol}: {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error fetching data for {symbol}: {e}")
|
||||
return None
|
||||
|
||||
def _draw_chart(self, symbol: str, data: Dict[str, Any]):
|
||||
"""Draw a price chart for the stock."""
|
||||
if not data.get('price_history'):
|
||||
return
|
||||
|
||||
# Clear the display
|
||||
self.display_manager.clear()
|
||||
|
||||
# Draw the symbol at the top with small font
|
||||
self.display_manager.draw_text(
|
||||
symbol,
|
||||
y=1, # Moved up slightly
|
||||
color=(255, 255, 255),
|
||||
small_font=True # Use small font
|
||||
)
|
||||
|
||||
# Calculate chart dimensions
|
||||
chart_height = 16 # Reduced from 22 to make chart smaller
|
||||
chart_y = 8 # Slightly adjusted starting position
|
||||
width = self.display_manager.matrix.width
|
||||
|
||||
# Get min and max prices for scaling
|
||||
prices = [p['price'] for p in data['price_history']]
|
||||
if not prices:
|
||||
return
|
||||
min_price = min(prices)
|
||||
max_price = max(prices)
|
||||
price_range = max_price - min_price
|
||||
|
||||
if price_range == 0:
|
||||
return
|
||||
|
||||
# Draw chart points
|
||||
points = []
|
||||
color = self._get_stock_color(symbol)
|
||||
|
||||
for i, point in enumerate(data['price_history']):
|
||||
x = int((i / len(data['price_history'])) * width)
|
||||
y = chart_y + chart_height - int(((point['price'] - min_price) / price_range) * chart_height)
|
||||
points.append((x, y))
|
||||
|
||||
# Draw lines between points
|
||||
for i in range(len(points) - 1):
|
||||
x1, y1 = points[i]
|
||||
x2, y2 = points[i + 1]
|
||||
self.display_manager.draw.line([x1, y1, x2, y2], fill=color, width=1)
|
||||
|
||||
# Draw current price at the bottom with small font
|
||||
price_text = f"${data['price']:.2f} ({data['change']:+.1f}%)"
|
||||
self.display_manager.draw_text(
|
||||
price_text,
|
||||
y=28, # Moved down slightly from 30 to give more space
|
||||
color=color,
|
||||
small_font=True # Use small font
|
||||
)
|
||||
|
||||
# Update the display
|
||||
self.display_manager.update_display()
|
||||
|
||||
def _reload_config(self):
|
||||
"""Reload configuration from file."""
|
||||
# Reset stock data if symbols have changed
|
||||
new_symbols = set(self.stocks_config.get('symbols', []))
|
||||
current_symbols = set(self.stock_data.keys())
|
||||
if new_symbols != current_symbols:
|
||||
self.stock_data = {}
|
||||
self.current_stock_index = 0
|
||||
self.last_update = 0 # Force immediate update
|
||||
logger.info(f"Stock symbols changed. New symbols: {new_symbols}")
|
||||
|
||||
# Update scroll and chart settings
|
||||
self.scroll_speed = self.stocks_config.get('scroll_speed', 1)
|
||||
self.scroll_delay = self.stocks_config.get('scroll_delay', 0.01)
|
||||
self.toggle_chart = self.stocks_config.get('toggle_chart', False)
|
||||
|
||||
# Clear cached image if settings changed
|
||||
if self.cached_text_image is not None:
|
||||
self.cached_text_image = None
|
||||
logger.info("Stock display settings changed, clearing cache")
|
||||
|
||||
def update_stock_data(self):
|
||||
"""Update stock data from API."""
|
||||
current_time = time.time()
|
||||
update_interval = self.stocks_config.get('update_interval', 600)
|
||||
|
||||
# Check if we're currently scrolling and defer the update if so
|
||||
if self.display_manager.is_currently_scrolling():
|
||||
logger.debug("Stock display is currently scrolling, deferring update")
|
||||
self.display_manager.defer_update(self._perform_stock_update, priority=2)
|
||||
return
|
||||
|
||||
self._perform_stock_update()
|
||||
|
||||
def _perform_stock_update(self):
|
||||
"""Internal method to perform the actual stock update."""
|
||||
current_time = time.time()
|
||||
update_interval = self.stocks_config.get('update_interval', 600)
|
||||
|
||||
if current_time - self.last_update < update_interval:
|
||||
return
|
||||
|
||||
try:
|
||||
logger.debug("Updating stock data")
|
||||
symbols = self.stocks_config.get('symbols', [])
|
||||
|
||||
if not symbols:
|
||||
logger.warning("No stock symbols configured")
|
||||
return
|
||||
|
||||
# Fetch stock data
|
||||
for symbol in symbols:
|
||||
try:
|
||||
data = self._fetch_stock_data(symbol)
|
||||
if data:
|
||||
self.stock_data[symbol] = data
|
||||
logger.debug(f"Updated data for {symbol}: {data}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching data for {symbol}: {e}")
|
||||
|
||||
self.last_update = current_time
|
||||
|
||||
# Clear cached text to force regeneration
|
||||
self.cached_text = None
|
||||
self.cached_text_image = None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating stock data: {e}")
|
||||
|
||||
def _get_stock_logo(self, symbol: str, is_crypto: bool = False) -> Image.Image:
|
||||
"""Get stock or crypto logo image from local directory."""
|
||||
try:
|
||||
# Try crypto icons first if it's a crypto symbol
|
||||
if is_crypto:
|
||||
# Remove -USD suffix for crypto symbols
|
||||
base_symbol = symbol.replace('-USD', '')
|
||||
icon_path = os.path.join(self.crypto_icons_dir, f"{base_symbol}.png")
|
||||
if os.path.exists(icon_path):
|
||||
with Image.open(icon_path) as img:
|
||||
if img.mode != 'RGBA':
|
||||
img = img.convert('RGBA')
|
||||
max_size = min(int(self.display_manager.matrix.width / 1.2),
|
||||
int(self.display_manager.matrix.height / 1.2))
|
||||
img = img.resize((max_size, max_size), Image.Resampling.LANCZOS)
|
||||
return img.copy()
|
||||
|
||||
# Fall back to stock icons if not crypto or crypto icon not found
|
||||
icon_path = os.path.join(self.ticker_icons_dir, f"{symbol}.png")
|
||||
if os.path.exists(icon_path):
|
||||
with Image.open(icon_path) as img:
|
||||
if img.mode != 'RGBA':
|
||||
img = img.convert('RGBA')
|
||||
max_size = min(int(self.display_manager.matrix.width / 1.2),
|
||||
int(self.display_manager.matrix.height / 1.2))
|
||||
img = img.resize((max_size, max_size), Image.Resampling.LANCZOS)
|
||||
return img.copy()
|
||||
except Exception as e:
|
||||
logger.warning(f"Error loading local icon for {symbol}: {e}")
|
||||
|
||||
# If local icon not found or failed to load, create text-based fallback
|
||||
logger.warning(f"No local icon found for {symbol}. Using text fallback.")
|
||||
fallback = Image.new('RGBA', (32, 32), (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(fallback)
|
||||
try:
|
||||
# Try to load OpenSans font first, fall back to PS2P if missing
|
||||
try:
|
||||
font = ImageFont.truetype("assets/fonts/OpenSans-Regular.ttf", 16)
|
||||
except Exception:
|
||||
font = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 8)
|
||||
except:
|
||||
font = ImageFont.load_default()
|
||||
|
||||
# Draw the symbol text
|
||||
text = symbol[:3] # Limit to first 3 characters
|
||||
text_bbox = draw.textbbox((0, 0), text, font=font)
|
||||
text_width = text_bbox[2] - text_bbox[0]
|
||||
text_height = text_bbox[3] - text_bbox[1]
|
||||
x = (32 - text_width) // 2
|
||||
y = (32 - text_height) // 2
|
||||
draw.text((x, y), text, font=font, fill=(255, 255, 255, 255))
|
||||
return fallback
|
||||
|
||||
def _create_stock_display(self, symbol: str, price: float, change: float, change_percent: float, is_crypto: bool = False) -> Image.Image:
|
||||
"""Create a display image for a stock or crypto with logo, symbol, price, and change."""
|
||||
# Create a wider image for scrolling - adjust width based on chart toggle
|
||||
width = int(self.display_manager.matrix.width * (2 if self.toggle_chart else 1.5)) # Reduced width when no chart
|
||||
height = self.display_manager.matrix.height
|
||||
image = Image.new('RGB', (width, height), color=(0, 0, 0))
|
||||
draw = ImageDraw.Draw(image)
|
||||
|
||||
# Draw large stock/crypto logo on the left
|
||||
logo = self._get_stock_logo(symbol, is_crypto)
|
||||
if logo:
|
||||
# Position logo on the left side with minimal spacing
|
||||
logo_x = 2 # Small margin from left edge
|
||||
logo_y = (height - logo.height) // 2
|
||||
image.paste(logo, (logo_x, logo_y), logo)
|
||||
|
||||
# Draw symbol, price, and change in a centered column
|
||||
# Use the fonts from display_manager
|
||||
regular_font = self.display_manager.regular_font
|
||||
small_font = self.display_manager.small_font
|
||||
|
||||
# Create smaller versions of the fonts for symbol and price
|
||||
symbol_font = ImageFont.truetype(self.display_manager.regular_font.path,
|
||||
int(self.display_manager.regular_font.size))
|
||||
price_font = ImageFont.truetype(self.display_manager.regular_font.path,
|
||||
int(self.display_manager.regular_font.size))
|
||||
|
||||
# Calculate text dimensions for proper spacing
|
||||
display_symbol = symbol.replace('-USD', '') if is_crypto else symbol
|
||||
symbol_text = display_symbol
|
||||
price_text = f"${price:.2f}"
|
||||
change_text = f"{change:+.2f} ({change_percent:+.1f}%)"
|
||||
|
||||
# Get the height of each text element
|
||||
symbol_bbox = draw.textbbox((0, 0), symbol_text, font=symbol_font)
|
||||
price_bbox = draw.textbbox((0, 0), price_text, font=price_font)
|
||||
change_bbox = draw.textbbox((0, 0), change_text, font=small_font)
|
||||
|
||||
# Calculate total height needed - adjust gaps based on chart toggle
|
||||
text_gap = 2 if self.toggle_chart else 1 # Reduced gap when no chart
|
||||
total_text_height = (symbol_bbox[3] - symbol_bbox[1]) + \
|
||||
(price_bbox[3] - price_bbox[1]) + \
|
||||
(change_bbox[3] - change_bbox[1]) + \
|
||||
(text_gap * 2) # Account for gaps between elements
|
||||
|
||||
# Calculate starting y position to center all text
|
||||
start_y = (height - total_text_height) // 2
|
||||
|
||||
# Calculate center x position for the column - adjust based on chart toggle
|
||||
if self.toggle_chart:
|
||||
# When chart is enabled, center text more to the left
|
||||
column_x = width // 2.85
|
||||
else:
|
||||
# When chart is disabled, position text with more space from logo
|
||||
column_x = width // 2.2
|
||||
|
||||
# Draw symbol
|
||||
symbol_width = symbol_bbox[2] - symbol_bbox[0]
|
||||
symbol_x = column_x - (symbol_width // 2)
|
||||
draw.text((symbol_x, start_y), symbol_text, font=symbol_font, fill=(255, 255, 255))
|
||||
|
||||
# Draw price
|
||||
price_width = price_bbox[2] - price_bbox[0]
|
||||
price_x = column_x - (price_width // 2)
|
||||
price_y = start_y + (symbol_bbox[3] - symbol_bbox[1]) + text_gap # Adjusted gap
|
||||
draw.text((price_x, price_y), price_text, font=price_font, fill=(255, 255, 255))
|
||||
|
||||
# Draw change with color based on value
|
||||
change_width = change_bbox[2] - change_bbox[0]
|
||||
change_x = column_x - (change_width // 2)
|
||||
change_y = price_y + (price_bbox[3] - price_bbox[1]) + text_gap # Adjusted gap
|
||||
change_color = (0, 255, 0) if change >= 0 else (255, 0, 0)
|
||||
draw.text((change_x, change_y), change_text, font=small_font, fill=change_color)
|
||||
|
||||
# Draw mini chart on the right only if toggle_chart is enabled
|
||||
if self.toggle_chart and symbol in self.stock_data and 'price_history' in self.stock_data[symbol]:
|
||||
price_history = self.stock_data[symbol]['price_history']
|
||||
if len(price_history) >= 2:
|
||||
# Extract prices from price history
|
||||
chart_data = [p['price'] for p in price_history]
|
||||
|
||||
# Calculate chart dimensions
|
||||
chart_width = int(width // 2.5) # Reduced from width//2.5 to prevent overlap
|
||||
chart_height = height // 1.5
|
||||
chart_x = width - chart_width - 4 # 4px margin from right edge
|
||||
chart_y = (height - chart_height) // 2
|
||||
|
||||
# Find min and max prices for scaling
|
||||
min_price = min(chart_data)
|
||||
max_price = max(chart_data)
|
||||
|
||||
# Add padding to avoid flat lines when prices are very close
|
||||
price_range = max_price - min_price
|
||||
if price_range < 0.01:
|
||||
min_price -= 0.01
|
||||
max_price += 0.01
|
||||
price_range = 0.02
|
||||
|
||||
# Calculate points for the line
|
||||
points = []
|
||||
for i, price in enumerate(chart_data):
|
||||
x = chart_x + (i * chart_width) // (len(chart_data) - 1)
|
||||
y = chart_y + chart_height - int(((price - min_price) / price_range) * chart_height)
|
||||
points.append((x, y))
|
||||
|
||||
# Draw lines between points
|
||||
color = self._get_stock_color(symbol)
|
||||
for i in range(len(points) - 1):
|
||||
draw.line([points[i], points[i + 1]], fill=color, width=1)
|
||||
|
||||
return image
|
||||
|
||||
def _update_stock_display(self, symbol: str, data: Dict[str, Any], width: int, height: int) -> None:
|
||||
"""Update the stock display with smooth scrolling animation."""
|
||||
try:
|
||||
# Create the full scrolling image
|
||||
full_image = self._create_stock_display(symbol, data['price'], data['change'], data['change'] / data['open'] * 100)
|
||||
scroll_width = width * 2 # Double width for smooth scrolling
|
||||
|
||||
# Scroll the image smoothly
|
||||
for scroll_pos in range(0, scroll_width - width, 15): # Increased scroll speed to match news ticker
|
||||
# Create visible portion
|
||||
visible_image = full_image.crop((scroll_pos, 0, scroll_pos + width, height))
|
||||
|
||||
# Convert to RGB and create numpy array
|
||||
rgb_image = visible_image.convert('RGB')
|
||||
image_array = np.array(rgb_image)
|
||||
|
||||
# Update display
|
||||
self.display_manager.update_display(image_array)
|
||||
|
||||
# Small delay for smooth animation
|
||||
time.sleep(0.005) # Reduced delay to 5ms for smoother scrolling
|
||||
|
||||
# Show final position briefly
|
||||
final_image = full_image.crop((scroll_width - width, 0, scroll_width, height))
|
||||
rgb_image = final_image.convert('RGB')
|
||||
image_array = np.array(rgb_image)
|
||||
self.display_manager.update_display(image_array)
|
||||
time.sleep(0.2) # Reduced pause at the end for better performance
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating stock display for {symbol}: {str(e)}")
|
||||
# Show error state
|
||||
self._show_error_state(width, height)
|
||||
|
||||
def _log_frame_rate(self):
|
||||
"""Log frame rate statistics."""
|
||||
current_time = time.time()
|
||||
|
||||
# Calculate instantaneous frame time
|
||||
frame_time = current_time - self.last_frame_time
|
||||
self.frame_times.append(frame_time)
|
||||
|
||||
# Keep only last 100 frames for average
|
||||
if len(self.frame_times) > 100:
|
||||
self.frame_times.pop(0)
|
||||
|
||||
# Log FPS every second
|
||||
if current_time - self.last_fps_log_time >= 1.0:
|
||||
avg_frame_time = sum(self.frame_times) / len(self.frame_times)
|
||||
avg_fps = 1.0 / avg_frame_time if avg_frame_time > 0 else 0
|
||||
instant_fps = 1.0 / frame_time if frame_time > 0 else 0
|
||||
|
||||
logger.info(f"Frame stats - Avg FPS: {avg_fps:.1f}, Current FPS: {instant_fps:.1f}, Frame time: {frame_time*1000:.2f}ms")
|
||||
self.last_fps_log_time = current_time
|
||||
self.frame_count = 0
|
||||
|
||||
self.last_frame_time = current_time
|
||||
self.frame_count += 1
|
||||
|
||||
def display_stocks(self, force_clear: bool = False):
|
||||
"""Display stock and crypto information with continuous scrolling animation."""
|
||||
if not self.stocks_config.get('enabled', False) and not self.crypto_config.get('enabled', False):
|
||||
return
|
||||
|
||||
# Start update in background if needed
|
||||
if time.time() - self.last_update >= self.stocks_config.get('update_interval', 60):
|
||||
self.update_stock_data()
|
||||
|
||||
if not self.stock_data:
|
||||
logger.warning("No stock or crypto data available to display")
|
||||
return
|
||||
|
||||
# Get all symbols
|
||||
symbols = list(self.stock_data.keys())
|
||||
if not symbols:
|
||||
return
|
||||
|
||||
# Create a continuous scrolling image if needed
|
||||
if self.cached_text_image is None or force_clear:
|
||||
# Create a very wide image that contains all stocks in sequence
|
||||
width = self.display_manager.matrix.width
|
||||
height = self.display_manager.matrix.height
|
||||
|
||||
# Calculate total width needed for all stocks
|
||||
# Each stock needs width*2 for scrolling, plus consistent gaps between elements
|
||||
stock_gap = width // 6 # Reduced gap between stocks
|
||||
element_gap = width // 8 # Reduced gap between elements within a stock
|
||||
total_width = sum(width * 2 for _ in symbols) + stock_gap * (len(symbols) - 1) + element_gap * (len(symbols) * 2 - 1)
|
||||
|
||||
# Create the full image
|
||||
full_image = Image.new('RGB', (total_width, height), (0, 0, 0))
|
||||
draw = ImageDraw.Draw(full_image)
|
||||
|
||||
# Add initial gap before the first stock
|
||||
current_x = width
|
||||
|
||||
# Draw each stock in sequence with consistent spacing
|
||||
for symbol in symbols:
|
||||
data = self.stock_data[symbol]
|
||||
is_crypto = data.get('is_crypto', False)
|
||||
|
||||
# Create stock display for this symbol
|
||||
stock_image = self._create_stock_display(
|
||||
symbol,
|
||||
data['price'],
|
||||
data['change'],
|
||||
data['change'] / data['open'] * 100,
|
||||
is_crypto
|
||||
)
|
||||
|
||||
# Paste this stock image into the full image
|
||||
full_image.paste(stock_image, (current_x, 0))
|
||||
|
||||
# Move to next position with consistent spacing
|
||||
current_x += width * 2 + element_gap
|
||||
|
||||
# Add extra gap between stocks
|
||||
if symbol != symbols[-1]: # Don't add gap after the last stock
|
||||
current_x += stock_gap
|
||||
|
||||
# Cache the full image
|
||||
self.cached_text_image = full_image
|
||||
self.scroll_position = 0
|
||||
self.last_update = time.time()
|
||||
|
||||
# Calculate total scroll width for dynamic duration
|
||||
self.total_scroll_width = total_width
|
||||
self.calculate_dynamic_duration()
|
||||
|
||||
# Clear the display if requested
|
||||
if force_clear:
|
||||
self.display_manager.clear()
|
||||
self.scroll_position = 0
|
||||
|
||||
# Calculate the visible portion of the image
|
||||
width = self.display_manager.matrix.width
|
||||
total_width = self.cached_text_image.width
|
||||
|
||||
# Check if we should be scrolling
|
||||
should_scroll = True # Stock display always scrolls continuously
|
||||
|
||||
# Signal scrolling state to display manager
|
||||
self.display_manager.set_scrolling_state(True)
|
||||
|
||||
# Process any deferred updates (though stocks are always scrolling)
|
||||
self.display_manager.process_deferred_updates()
|
||||
|
||||
# Update scroll position with small increments
|
||||
self.scroll_position = (self.scroll_position + self.scroll_speed) % total_width
|
||||
|
||||
# Calculate the visible portion
|
||||
visible_portion = self.cached_text_image.crop((
|
||||
self.scroll_position, 0,
|
||||
self.scroll_position + width, self.display_manager.matrix.height
|
||||
))
|
||||
|
||||
# Copy the visible portion to the display
|
||||
self.display_manager.image.paste(visible_portion, (0, 0))
|
||||
self.display_manager.update_display()
|
||||
|
||||
# Log frame rate
|
||||
self._log_frame_rate()
|
||||
|
||||
# Add a small delay between frames
|
||||
time.sleep(self.scroll_delay)
|
||||
|
||||
# If we've scrolled through the entire image, reset
|
||||
if self.scroll_position == 0:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def calculate_dynamic_duration(self):
|
||||
"""Calculate the exact time needed to display all stocks"""
|
||||
# If dynamic duration is disabled, use fixed duration from config
|
||||
if not self.dynamic_duration_enabled:
|
||||
self.dynamic_duration = self.stocks_config.get('fixed_duration', 60)
|
||||
logger.debug(f"Dynamic duration disabled, using fixed duration: {self.dynamic_duration}s")
|
||||
return
|
||||
|
||||
if not self.total_scroll_width:
|
||||
self.dynamic_duration = self.min_duration # Use configured minimum
|
||||
return
|
||||
|
||||
try:
|
||||
# Get display width (assume full width of display)
|
||||
display_width = getattr(self.display_manager, 'width', 128) # Default to 128 if not available
|
||||
|
||||
# Calculate total scroll distance needed
|
||||
# Text needs to scroll from right edge to completely off left edge
|
||||
total_scroll_distance = display_width + self.total_scroll_width
|
||||
|
||||
# Calculate time based on scroll speed and delay
|
||||
# scroll_speed = pixels per frame, scroll_delay = seconds per frame
|
||||
frames_needed = total_scroll_distance / self.scroll_speed
|
||||
total_time = frames_needed * self.scroll_delay
|
||||
|
||||
# Add buffer time for smooth cycling (configurable %)
|
||||
buffer_time = total_time * self.duration_buffer
|
||||
calculated_duration = int(total_time + buffer_time)
|
||||
|
||||
# Apply configured min/max limits
|
||||
if calculated_duration < self.min_duration:
|
||||
self.dynamic_duration = self.min_duration
|
||||
logger.debug(f"Duration capped to minimum: {self.min_duration}s")
|
||||
elif calculated_duration > self.max_duration:
|
||||
self.dynamic_duration = self.max_duration
|
||||
logger.debug(f"Duration capped to maximum: {self.max_duration}s")
|
||||
else:
|
||||
self.dynamic_duration = calculated_duration
|
||||
|
||||
logger.debug(f"Stock dynamic duration calculation:")
|
||||
logger.debug(f" Display width: {display_width}px")
|
||||
logger.debug(f" Text width: {self.total_scroll_width}px")
|
||||
logger.debug(f" Total scroll distance: {total_scroll_distance}px")
|
||||
logger.debug(f" Frames needed: {frames_needed:.1f}")
|
||||
logger.debug(f" Base time: {total_time:.2f}s")
|
||||
logger.debug(f" Buffer time: {buffer_time:.2f}s ({self.duration_buffer*100}%)")
|
||||
logger.debug(f" Calculated duration: {calculated_duration}s")
|
||||
logger.debug(f" Final duration: {self.dynamic_duration}s")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error calculating dynamic duration: {e}")
|
||||
self.dynamic_duration = self.min_duration # Use configured minimum as fallback
|
||||
|
||||
def get_dynamic_duration(self) -> int:
|
||||
"""Get the calculated dynamic duration for display"""
|
||||
return self.dynamic_duration
|
||||
|
||||
def set_toggle_chart(self, enabled: bool):
|
||||
"""Enable or disable chart display in the scrolling ticker."""
|
||||
self.toggle_chart = enabled
|
||||
self.cached_text_image = None # Clear cache when switching modes
|
||||
logger.info(f"Chart toggle set to: {enabled}")
|
||||
|
||||
def set_scroll_speed(self, speed: int):
|
||||
"""Set the scroll speed for the ticker."""
|
||||
self.scroll_speed = speed
|
||||
logger.info(f"Scroll speed set to: {speed}")
|
||||
|
||||
def set_scroll_delay(self, delay: float):
|
||||
"""Set the scroll delay for the ticker."""
|
||||
self.scroll_delay = delay
|
||||
logger.info(f"Scroll delay set to: {delay}")
|
||||
@@ -1,515 +0,0 @@
|
||||
import time
|
||||
import logging
|
||||
import requests
|
||||
import json
|
||||
import random
|
||||
from typing import Dict, Any, List, Tuple
|
||||
from datetime import datetime
|
||||
import os
|
||||
import urllib.parse
|
||||
import re
|
||||
from src.config_manager import ConfigManager
|
||||
from PIL import Image, ImageDraw
|
||||
from .cache_manager import CacheManager
|
||||
from requests.adapters import HTTPAdapter
|
||||
from urllib3.util.retry import Retry
|
||||
|
||||
# Import the API counter function from web interface
|
||||
try:
|
||||
from web_interface_v2 import increment_api_counter
|
||||
except ImportError:
|
||||
# Fallback if web interface is not available
|
||||
def increment_api_counter(kind: str, count: int = 1):
|
||||
pass
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class StockNewsManager:
|
||||
def __init__(self, config: Dict[str, Any], display_manager):
|
||||
self.config = config
|
||||
# Store reference to config instead of creating new ConfigManager
|
||||
self.config_manager = None # Not used in this class
|
||||
self.display_manager = display_manager
|
||||
self.stocks_config = config.get('stocks', {})
|
||||
self.stock_news_config = config.get('stock_news', {})
|
||||
self.last_update = 0
|
||||
self.news_data = {}
|
||||
self.current_news_group = 0 # Track which group of headlines we're showing
|
||||
self.scroll_position = 0
|
||||
self.cached_text_image = None # Cache for the text image
|
||||
self.cached_text = None # Cache for the text string
|
||||
self.cache_manager = CacheManager()
|
||||
|
||||
# Get scroll settings from config with faster defaults
|
||||
self.scroll_speed = self.stock_news_config.get('scroll_speed', 1)
|
||||
self.scroll_delay = self.stock_news_config.get('scroll_delay', 0.01) # Default to 10ms for 100 FPS
|
||||
|
||||
# Get headline settings from config
|
||||
self.max_headlines_per_symbol = self.stock_news_config.get('max_headlines_per_symbol', 1)
|
||||
self.headlines_per_rotation = self.stock_news_config.get('headlines_per_rotation', 2)
|
||||
|
||||
# Dynamic duration settings
|
||||
self.dynamic_duration_enabled = self.stock_news_config.get('dynamic_duration', True)
|
||||
self.min_duration = self.stock_news_config.get('min_duration', 30)
|
||||
self.max_duration = self.stock_news_config.get('max_duration', 300)
|
||||
self.duration_buffer = self.stock_news_config.get('duration_buffer', 0.1)
|
||||
self.dynamic_duration = 60 # Default duration in seconds
|
||||
self.total_scroll_width = 0 # Track total width for dynamic duration calculation
|
||||
|
||||
# Log the actual values being used
|
||||
logger.info(f"Scroll settings - Speed: {self.scroll_speed} pixels/frame, Delay: {self.scroll_delay*1000:.2f}ms")
|
||||
logger.info(f"Headline settings - Max per symbol: {self.max_headlines_per_symbol}, Per rotation: {self.headlines_per_rotation}")
|
||||
|
||||
# Initialize frame rate tracking
|
||||
self.frame_count = 0
|
||||
self.last_frame_time = time.time()
|
||||
self.last_fps_log_time = time.time()
|
||||
self.frame_times = [] # Keep track of recent frame times for average FPS
|
||||
|
||||
# Background image generation
|
||||
self.background_image = None # The image being generated in background
|
||||
self.is_generating_image = False # Flag to track if we're currently generating
|
||||
self.last_generation_start = 0 # When we started generating
|
||||
self.generation_timeout = 5 # Max seconds to spend generating
|
||||
|
||||
# Rotation tracking
|
||||
self.all_news_items = [] # Store all available news items
|
||||
self.current_rotation_index = 0 # Track which rotation we're on
|
||||
self.rotation_complete = False # Flag to indicate when a full rotation is complete
|
||||
|
||||
self.headers = {
|
||||
'User-Agent': 'LEDMatrix/1.0 (https://github.com/yourusername/LEDMatrix; contact@example.com)',
|
||||
'Accept': 'application/json',
|
||||
'Accept-Language': 'en-US,en;q=0.9',
|
||||
'Accept-Encoding': 'gzip, deflate, br',
|
||||
'Connection': 'keep-alive'
|
||||
}
|
||||
|
||||
# Set up session with retry logic
|
||||
self.session = requests.Session()
|
||||
retry_strategy = Retry(
|
||||
total=5, # increased number of retries
|
||||
backoff_factor=1, # increased backoff factor
|
||||
status_forcelist=[429, 500, 502, 503, 504], # added 429 to retry list
|
||||
allowed_methods=["GET", "HEAD", "OPTIONS"]
|
||||
)
|
||||
adapter = HTTPAdapter(max_retries=retry_strategy)
|
||||
self.session.mount("https://", adapter)
|
||||
self.session.mount("http://", adapter)
|
||||
|
||||
# Initialize with first update
|
||||
self.update_news_data()
|
||||
|
||||
def _fetch_news(self, symbol: str) -> List[Dict[str, Any]]:
|
||||
"""Fetch news data for a stock from Yahoo Finance."""
|
||||
try:
|
||||
# Use Yahoo Finance query1 API for news data
|
||||
url = f"https://query1.finance.yahoo.com/v1/finance/search"
|
||||
params = {
|
||||
'q': symbol,
|
||||
'lang': 'en-US',
|
||||
'region': 'US',
|
||||
'quotesCount': 0,
|
||||
'newsCount': 10,
|
||||
'enableFuzzyQuery': False,
|
||||
'quotesQueryId': 'tss_match_phrase_query',
|
||||
'multiQuoteQueryId': 'multi_quote_single_token_query',
|
||||
'newsQueryId': 'news_cie_vespa',
|
||||
'enableCb': True,
|
||||
}
|
||||
|
||||
# Use session with retry logic
|
||||
response = self.session.get(
|
||||
url,
|
||||
headers=self.headers,
|
||||
params=params,
|
||||
timeout=10, # Increased timeout
|
||||
verify=True # Enable SSL verification
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.error(f"Failed to fetch news for {symbol}: HTTP {response.status_code}")
|
||||
return []
|
||||
|
||||
data = response.json()
|
||||
|
||||
# Increment API counter for news data call
|
||||
increment_api_counter('news', 1)
|
||||
news_items = data.get('news', [])
|
||||
|
||||
processed_news = []
|
||||
for item in news_items:
|
||||
try:
|
||||
processed_news.append({
|
||||
'title': item.get('title', ''),
|
||||
'link': item.get('link', ''),
|
||||
'publisher': item.get('publisher', ''),
|
||||
'published': datetime.fromtimestamp(item.get('providerPublishTime', 0)),
|
||||
'summary': item.get('summary', '')
|
||||
})
|
||||
except (ValueError, TypeError) as e:
|
||||
logger.error(f"Error processing news item for {symbol}: {e}")
|
||||
continue
|
||||
|
||||
logger.debug(f"Fetched {len(processed_news)} news items for {symbol}")
|
||||
return processed_news
|
||||
|
||||
except requests.exceptions.SSLError as e:
|
||||
logger.error(f"SSL error fetching news for {symbol}: {e}")
|
||||
return []
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"Network error fetching news for {symbol}: {e}")
|
||||
return []
|
||||
except (ValueError, KeyError) as e:
|
||||
logger.error(f"Error parsing news data for {symbol}: {e}")
|
||||
return []
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error fetching news for {symbol}: {e}")
|
||||
return []
|
||||
|
||||
def update_news_data(self):
|
||||
"""Update news data from API."""
|
||||
current_time = time.time()
|
||||
update_interval = self.stock_news_config.get('update_interval', 3600)
|
||||
|
||||
# Check if we're currently scrolling and defer the update if so
|
||||
if self.display_manager.is_currently_scrolling():
|
||||
logger.debug("Stock news display is currently scrolling, deferring update")
|
||||
self.display_manager.defer_update(self._perform_news_update, priority=2)
|
||||
return
|
||||
|
||||
self._perform_news_update()
|
||||
|
||||
def _perform_news_update(self):
|
||||
"""Internal method to perform the actual news update."""
|
||||
current_time = time.time()
|
||||
update_interval = self.stock_news_config.get('update_interval', 3600)
|
||||
|
||||
if current_time - self.last_update < update_interval:
|
||||
return
|
||||
|
||||
try:
|
||||
logger.debug("Updating stock news data")
|
||||
symbols = self.stocks_config.get('symbols', [])
|
||||
|
||||
if not symbols:
|
||||
logger.warning("No stock symbols configured for news")
|
||||
return
|
||||
|
||||
# Fetch news for each symbol
|
||||
for symbol in symbols:
|
||||
try:
|
||||
news = self._fetch_news(symbol)
|
||||
if news:
|
||||
self.news_data[symbol] = news
|
||||
logger.debug(f"Updated news for {symbol}: {len(news)} headlines")
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching news for {symbol}: {e}")
|
||||
|
||||
self.last_update = current_time
|
||||
|
||||
# Clear cached text to force regeneration
|
||||
self.cached_text = None
|
||||
self.cached_text_image = None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating stock news data: {e}")
|
||||
|
||||
def _create_text_image(self, text: str, color: Tuple[int, int, int] = (255, 255, 255)) -> Image.Image:
|
||||
"""Create an image containing the text for efficient scrolling."""
|
||||
# Get text dimensions
|
||||
bbox = self.display_manager.draw.textbbox((0, 0), text, font=self.display_manager.small_font)
|
||||
text_width = bbox[2] - bbox[0]
|
||||
text_height = bbox[3] - bbox[1]
|
||||
|
||||
# Create a new image with the text
|
||||
text_image = Image.new('RGB', (text_width, self.display_manager.matrix.height), (0, 0, 0))
|
||||
text_draw = ImageDraw.Draw(text_image)
|
||||
|
||||
# Draw the text centered vertically
|
||||
y = (self.display_manager.matrix.height - text_height) // 2
|
||||
text_draw.text((0, y), text, font=self.display_manager.small_font, fill=color)
|
||||
|
||||
return text_image
|
||||
|
||||
def _log_frame_rate(self):
|
||||
"""Log frame rate statistics."""
|
||||
current_time = time.time()
|
||||
|
||||
# Calculate instantaneous frame time
|
||||
frame_time = current_time - self.last_frame_time
|
||||
self.frame_times.append(frame_time)
|
||||
|
||||
# Keep only last 100 frames for average
|
||||
if len(self.frame_times) > 100:
|
||||
self.frame_times.pop(0)
|
||||
|
||||
# Log FPS every second
|
||||
if current_time - self.last_fps_log_time >= 1.0:
|
||||
avg_frame_time = sum(self.frame_times) / len(self.frame_times)
|
||||
avg_fps = 1.0 / avg_frame_time if avg_frame_time > 0 else 0
|
||||
instant_fps = 1.0 / frame_time if frame_time > 0 else 0
|
||||
|
||||
logger.info(f"Frame stats - Avg FPS: {avg_fps:.1f}, Current FPS: {instant_fps:.1f}, Frame time: {frame_time*1000:.2f}ms")
|
||||
self.last_fps_log_time = current_time
|
||||
self.frame_count = 0
|
||||
|
||||
self.last_frame_time = current_time
|
||||
self.frame_count += 1
|
||||
|
||||
def _generate_background_image(self, all_news, width, height):
|
||||
"""Generate the full image in the background without disrupting display."""
|
||||
if self.is_generating_image:
|
||||
# Check if we've been generating too long
|
||||
if time.time() - self.last_generation_start > self.generation_timeout:
|
||||
logger.warning("[StockNews] Background image generation timed out, resetting")
|
||||
self.is_generating_image = False
|
||||
self.background_image = None
|
||||
return False
|
||||
|
||||
# Still generating, return False to indicate not ready
|
||||
return False
|
||||
|
||||
# Start a new background generation
|
||||
self.is_generating_image = True
|
||||
self.last_generation_start = time.time()
|
||||
|
||||
try:
|
||||
# Log the number of headlines being displayed
|
||||
logger.info(f"[StockNews] Generating image for {len(all_news)} headlines")
|
||||
|
||||
# First, create all news images to calculate total width needed
|
||||
news_images = []
|
||||
total_width = 0
|
||||
screen_width_gap = width # Use a full screen width as the gap
|
||||
|
||||
# Add initial gap
|
||||
total_width += screen_width_gap
|
||||
|
||||
for news in all_news:
|
||||
news_text = f"{news['symbol']}: {news['title']} "
|
||||
news_image = self._create_text_image(news_text)
|
||||
news_images.append(news_image)
|
||||
# Add width of news image plus gap
|
||||
total_width += news_image.width + screen_width_gap
|
||||
|
||||
# Create the full image with calculated width
|
||||
full_image = Image.new('RGB', (total_width, height), (0, 0, 0))
|
||||
draw = ImageDraw.Draw(full_image)
|
||||
|
||||
# Now paste all news images with proper spacing
|
||||
current_x = screen_width_gap # Start after initial gap
|
||||
|
||||
for news_image in news_images:
|
||||
# Paste this news image into the full image
|
||||
full_image.paste(news_image, (current_x, 0))
|
||||
|
||||
# Move to next position: text width + screen width gap
|
||||
current_x += news_image.width + screen_width_gap
|
||||
|
||||
# Store the generated image
|
||||
self.background_image = full_image
|
||||
self.is_generating_image = False
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[StockNews] Error generating background image: {e}")
|
||||
self.is_generating_image = False
|
||||
return False
|
||||
|
||||
def display_news(self):
|
||||
"""Display news headlines by scrolling them across the screen."""
|
||||
if not self.stock_news_config.get('enabled', False):
|
||||
return
|
||||
|
||||
# Start update in background if needed
|
||||
if time.time() - self.last_update >= self.stock_news_config.get('update_interval', 300):
|
||||
self.update_news_data()
|
||||
|
||||
if not self.news_data:
|
||||
logger.warning("No news data available to display")
|
||||
return
|
||||
|
||||
# Get all news items from all symbols, respecting max_headlines_per_symbol
|
||||
if not self.all_news_items:
|
||||
self.all_news_items = []
|
||||
for symbol, news_items in self.news_data.items():
|
||||
# Limit the number of headlines per symbol
|
||||
limited_items = news_items[:self.max_headlines_per_symbol]
|
||||
for item in limited_items:
|
||||
self.all_news_items.append({
|
||||
"symbol": symbol,
|
||||
"title": item["title"],
|
||||
"publisher": item["publisher"]
|
||||
})
|
||||
|
||||
# Shuffle the news items for variety
|
||||
random.shuffle(self.all_news_items)
|
||||
logger.info(f"Prepared {len(self.all_news_items)} news items for rotation")
|
||||
|
||||
if not self.all_news_items:
|
||||
return
|
||||
|
||||
# Get the current rotation of headlines
|
||||
start_idx = self.current_rotation_index * self.headlines_per_rotation
|
||||
end_idx = min(start_idx + self.headlines_per_rotation, len(self.all_news_items))
|
||||
|
||||
# If we've reached the end, shuffle and start over
|
||||
if start_idx >= len(self.all_news_items):
|
||||
self.current_rotation_index = 0
|
||||
random.shuffle(self.all_news_items) # Reshuffle when we've shown all headlines
|
||||
start_idx = 0
|
||||
end_idx = min(self.headlines_per_rotation, len(self.all_news_items))
|
||||
self.rotation_complete = True
|
||||
logger.info("Completed a full rotation of news headlines, reshuffling for next round")
|
||||
|
||||
# Get the current batch of headlines
|
||||
current_news = self.all_news_items[start_idx:end_idx]
|
||||
|
||||
# Define width and height here, so they are always available
|
||||
width = self.display_manager.matrix.width
|
||||
height = self.display_manager.matrix.height
|
||||
|
||||
# Check if we need to generate a new image
|
||||
if self.cached_text_image is None or self.rotation_complete:
|
||||
# Reset rotation complete flag
|
||||
self.rotation_complete = False
|
||||
|
||||
# Try to generate the image in the background
|
||||
if self._generate_background_image(current_news, width, height):
|
||||
# If generation completed successfully, use the background image
|
||||
self.cached_text_image = self.background_image
|
||||
self.scroll_position = 0
|
||||
self.background_image = None # Clear the background image
|
||||
|
||||
# Calculate total scroll width for dynamic duration
|
||||
self.total_scroll_width = self.cached_text_image.width
|
||||
self.calculate_dynamic_duration()
|
||||
|
||||
# Move to next rotation for next time
|
||||
self.current_rotation_index += 1
|
||||
else:
|
||||
# If still generating or failed, show a simple message
|
||||
self.display_manager.image.paste(Image.new('RGB', (width, height), (0, 0, 0)), (0, 0))
|
||||
draw = ImageDraw.Draw(self.display_manager.image)
|
||||
draw.text((width//4, height//2), "Loading news...", font=self.display_manager.small_font, fill=(255, 255, 255))
|
||||
self.display_manager.update_display()
|
||||
# Removed sleep delay to improve scrolling performance
|
||||
return True
|
||||
|
||||
# --- Scrolling logic remains the same ---
|
||||
if self.cached_text_image is None:
|
||||
logger.warning("[StockNews] Cached image is None, cannot scroll.")
|
||||
return False
|
||||
|
||||
total_width = self.cached_text_image.width
|
||||
|
||||
# If total_width is somehow less than screen width, don't scroll
|
||||
if total_width <= width:
|
||||
# Signal that we're not scrolling for this frame
|
||||
self.display_manager.set_scrolling_state(False)
|
||||
# Process any deferred updates
|
||||
self.display_manager.process_deferred_updates()
|
||||
|
||||
self.display_manager.image.paste(self.cached_text_image, (0, 0))
|
||||
self.display_manager.update_display()
|
||||
time.sleep(self.stock_news_config.get('item_display_duration', 5)) # Hold static image
|
||||
self.cached_text_image = None # Force recreation next cycle
|
||||
return True
|
||||
|
||||
# Signal that we're scrolling
|
||||
self.display_manager.set_scrolling_state(True)
|
||||
|
||||
# Process any deferred updates (though news is usually always scrolling)
|
||||
self.display_manager.process_deferred_updates()
|
||||
|
||||
# Update scroll position
|
||||
self.scroll_position += self.scroll_speed
|
||||
if self.scroll_position >= total_width:
|
||||
self.scroll_position = 0 # Wrap around
|
||||
# When we wrap around, move to next rotation
|
||||
self.cached_text_image = None
|
||||
return True
|
||||
|
||||
# Calculate the visible portion
|
||||
# Handle wrap-around drawing
|
||||
visible_end = self.scroll_position + width
|
||||
if visible_end <= total_width:
|
||||
# Normal case: Paste single crop
|
||||
visible_portion = self.cached_text_image.crop((
|
||||
self.scroll_position, 0,
|
||||
visible_end, height
|
||||
))
|
||||
self.display_manager.image.paste(visible_portion, (0, 0))
|
||||
else:
|
||||
# Wrap-around case: Paste two parts
|
||||
width1 = total_width - self.scroll_position
|
||||
width2 = width - width1
|
||||
portion1 = self.cached_text_image.crop((self.scroll_position, 0, total_width, height))
|
||||
portion2 = self.cached_text_image.crop((0, 0, width2, height))
|
||||
self.display_manager.image.paste(portion1, (0, 0))
|
||||
self.display_manager.image.paste(portion2, (width1, 0))
|
||||
|
||||
self.display_manager.update_display()
|
||||
self._log_frame_rate()
|
||||
time.sleep(self.scroll_delay)
|
||||
return True
|
||||
|
||||
def calculate_dynamic_duration(self):
|
||||
"""Calculate the exact time needed to display all news headlines"""
|
||||
# If dynamic duration is disabled, use fixed duration from config
|
||||
if not self.dynamic_duration_enabled:
|
||||
self.dynamic_duration = self.stock_news_config.get('fixed_duration', 60)
|
||||
logger.debug(f"Dynamic duration disabled, using fixed duration: {self.dynamic_duration}s")
|
||||
return
|
||||
|
||||
if not self.total_scroll_width:
|
||||
self.dynamic_duration = self.min_duration # Use configured minimum
|
||||
return
|
||||
|
||||
try:
|
||||
# Get display width (assume full width of display)
|
||||
display_width = getattr(self.display_manager, 'width', 128) # Default to 128 if not available
|
||||
|
||||
# Calculate total scroll distance needed
|
||||
# Text needs to scroll from right edge to completely off left edge
|
||||
total_scroll_distance = display_width + self.total_scroll_width
|
||||
|
||||
# Calculate time based on scroll speed and delay
|
||||
# scroll_speed = pixels per frame, scroll_delay = seconds per frame
|
||||
frames_needed = total_scroll_distance / self.scroll_speed
|
||||
total_time = frames_needed * self.scroll_delay
|
||||
|
||||
# Add buffer time for smooth cycling (configurable %)
|
||||
buffer_time = total_time * self.duration_buffer
|
||||
calculated_duration = int(total_time + buffer_time)
|
||||
|
||||
# Apply configured min/max limits
|
||||
if calculated_duration < self.min_duration:
|
||||
self.dynamic_duration = self.min_duration
|
||||
logger.debug(f"Duration capped to minimum: {self.min_duration}s")
|
||||
elif calculated_duration > self.max_duration:
|
||||
self.dynamic_duration = self.max_duration
|
||||
logger.debug(f"Duration capped to maximum: {self.max_duration}s")
|
||||
else:
|
||||
self.dynamic_duration = calculated_duration
|
||||
|
||||
logger.debug(f"Stock news dynamic duration calculation:")
|
||||
logger.debug(f" Display width: {display_width}px")
|
||||
logger.debug(f" Text width: {self.total_scroll_width}px")
|
||||
logger.debug(f" Total scroll distance: {total_scroll_distance}px")
|
||||
logger.debug(f" Frames needed: {frames_needed:.1f}")
|
||||
logger.debug(f" Base time: {total_time:.2f}s")
|
||||
logger.debug(f" Buffer time: {buffer_time:.2f}s ({self.duration_buffer*100}%)")
|
||||
logger.debug(f" Calculated duration: {calculated_duration}s")
|
||||
logger.debug(f" Final duration: {self.dynamic_duration}s")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error calculating dynamic duration: {e}")
|
||||
self.dynamic_duration = self.min_duration # Use configured minimum as fallback
|
||||
|
||||
def get_dynamic_duration(self) -> int:
|
||||
"""Get the calculated dynamic duration for display"""
|
||||
return self.dynamic_duration
|
||||
@@ -1,259 +0,0 @@
|
||||
import logging
|
||||
import time
|
||||
from PIL import ImageFont, Image, ImageDraw
|
||||
import freetype
|
||||
import os
|
||||
|
||||
from .display_manager import DisplayManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class TextDisplay:
|
||||
def __init__(self, display_manager: DisplayManager, config: dict):
|
||||
self.display_manager = display_manager
|
||||
self.config = config.get('text_display', {})
|
||||
|
||||
self.text = self.config.get('text', "Hello, World!")
|
||||
self.font_path = self.config.get('font_path', "assets/fonts/PressStart2P-Regular.ttf")
|
||||
self.font_size = self.config.get('font_size', 8)
|
||||
self.scroll_enabled = self.config.get('scroll', False)
|
||||
self.text_color = tuple(self.config.get('text_color', [255, 255, 255]))
|
||||
self.bg_color = tuple(self.config.get('background_color', [0, 0, 0]))
|
||||
# scroll_gap_width defaults to the width of the display matrix
|
||||
self.scroll_gap_width = self.config.get('scroll_gap_width', self.display_manager.matrix.width)
|
||||
|
||||
self.font = self._load_font()
|
||||
|
||||
self.text_content_width = 0 # Pixel width of the actual text string
|
||||
self.text_image_cache = None # For pre-rendered text (PIL.Image)
|
||||
self.cached_total_scroll_width = 0 # Total width of the cache: text_content_width + scroll_gap_width
|
||||
|
||||
self._regenerate_renderings() # Initial creation of cache and width calculation
|
||||
|
||||
self.scroll_pos = 0.0 # Use float for precision
|
||||
self.last_update_time = time.time()
|
||||
self.scroll_speed = self.config.get('scroll_speed', 30) # Pixels per second
|
||||
|
||||
def _regenerate_renderings(self):
|
||||
"""Calculate text width and attempt to create/update the text image cache."""
|
||||
if not self.text or not self.font:
|
||||
self.text_content_width = 0
|
||||
self.text_image_cache = None
|
||||
self.cached_total_scroll_width = 0
|
||||
return
|
||||
|
||||
try:
|
||||
self.text_content_width = self.display_manager.get_text_width(self.text, self.font)
|
||||
except Exception as e:
|
||||
logger.error(f"Error calculating text content width: {e}")
|
||||
self.text_content_width = 0
|
||||
self.text_image_cache = None
|
||||
self.cached_total_scroll_width = 0
|
||||
return
|
||||
|
||||
self._create_text_image_cache()
|
||||
self.scroll_pos = 0.0 # Reset scroll position when text/font/colors change
|
||||
|
||||
def _create_text_image_cache(self):
|
||||
"""Pre-render the text onto an image if using a TTF font. Includes a trailing gap."""
|
||||
self.text_image_cache = None # Clear previous cache
|
||||
self.cached_total_scroll_width = 0
|
||||
|
||||
if not self.text or not self.font or self.text_content_width == 0:
|
||||
return
|
||||
|
||||
if isinstance(self.font, freetype.Face):
|
||||
logger.info("TextDisplay: Pre-rendering cache is not used for BDF/freetype fonts. Will use direct drawing.")
|
||||
# For BDF, the "scroll width" for reset purposes is handled by the direct drawing logic's conditions
|
||||
return
|
||||
|
||||
# --- TTF Caching Path ---
|
||||
try:
|
||||
dummy_img = Image.new('RGB', (1, 1))
|
||||
dummy_draw = ImageDraw.Draw(dummy_img)
|
||||
bbox = dummy_draw.textbbox((0, 0), self.text, font=self.font)
|
||||
actual_text_render_height = bbox[3] - bbox[1]
|
||||
|
||||
# Total width of the cache is the text width plus the configured gap
|
||||
self.cached_total_scroll_width = self.text_content_width + self.scroll_gap_width
|
||||
cache_height = self.display_manager.matrix.height
|
||||
|
||||
self.text_image_cache = Image.new('RGB', (self.cached_total_scroll_width, cache_height), self.bg_color)
|
||||
draw_cache = ImageDraw.Draw(self.text_image_cache)
|
||||
|
||||
desired_top_edge = (cache_height - actual_text_render_height) // 2
|
||||
y_draw_on_cache = desired_top_edge - bbox[1]
|
||||
|
||||
# Draw the text at the beginning of the cache
|
||||
draw_cache.text((0, y_draw_on_cache), self.text, font=self.font, fill=self.text_color)
|
||||
# The rest of the image (the gap) is already bg_color
|
||||
logger.info(f"TextDisplay: Created text cache for '{self.text[:30]}...' (TTF). Text width: {self.text_content_width}, Gap: {self.scroll_gap_width}, Total cache width: {self.cached_total_scroll_width}x{cache_height}")
|
||||
except Exception as e:
|
||||
logger.error(f"TextDisplay: Failed to create text image cache: {e}", exc_info=True)
|
||||
self.text_image_cache = None
|
||||
self.cached_total_scroll_width = 0
|
||||
|
||||
def _load_font(self):
|
||||
"""Load the specified font file (TTF or BDF)."""
|
||||
font_path = self.font_path
|
||||
# Resolve relative paths against project root based on this file location
|
||||
if not os.path.isabs(font_path):
|
||||
base_path = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
|
||||
font_path = os.path.join(base_path, font_path)
|
||||
|
||||
logger.info(f"Attempting to load font: {font_path} at size {self.font_size}")
|
||||
|
||||
if not os.path.exists(font_path):
|
||||
logger.error(f"Font file not found: {font_path}. Falling back to default.")
|
||||
return self.display_manager.regular_font
|
||||
|
||||
try:
|
||||
if font_path.lower().endswith('.ttf'):
|
||||
font = ImageFont.truetype(font_path, self.font_size)
|
||||
logger.info(f"Loaded TTF font: {self.font_path}")
|
||||
return font
|
||||
elif font_path.lower().endswith('.bdf'):
|
||||
face = freetype.Face(font_path)
|
||||
face.set_pixel_sizes(0, self.font_size)
|
||||
logger.info(f"Loaded BDF font: {self.font_path} with freetype")
|
||||
return face
|
||||
else:
|
||||
logger.warning(f"Unsupported font type: {font_path}. Falling back.")
|
||||
return self.display_manager.regular_font
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load font {font_path}: {e}", exc_info=True)
|
||||
return self.display_manager.regular_font
|
||||
|
||||
# _calculate_text_width is effectively replaced by logic in _regenerate_renderings
|
||||
# but kept for direct calls if ever needed, or as a reference to DisplayManager's method
|
||||
def _calculate_text_width(self):
|
||||
"""DEPRECATED somewhat: Get text width. Relies on self.text_content_width set by _regenerate_renderings."""
|
||||
try:
|
||||
return self.display_manager.get_text_width(self.text, self.font)
|
||||
except Exception as e:
|
||||
logger.error(f"Error calculating text width: {e}")
|
||||
return 0
|
||||
|
||||
def update(self):
|
||||
"""Update scroll position if scrolling is enabled."""
|
||||
# Scrolling is only meaningful if the actual text content is wider than the screen,
|
||||
# or if a cache is used (which implies scrolling over text + gap).
|
||||
# The condition self.text_content_width <= self.display_manager.matrix.width handles non-scrolling for static text.
|
||||
if not self.scroll_enabled or (not self.text_image_cache and self.text_content_width <= self.display_manager.matrix.width):
|
||||
self.scroll_pos = 0.0
|
||||
return
|
||||
|
||||
current_time = time.time()
|
||||
delta_time = current_time - self.last_update_time
|
||||
self.last_update_time = current_time
|
||||
|
||||
scroll_delta = delta_time * self.scroll_speed
|
||||
self.scroll_pos += scroll_delta
|
||||
|
||||
if self.text_image_cache:
|
||||
# Using cached image: scroll_pos loops over the total cache width (text + gap)
|
||||
if self.cached_total_scroll_width > 0 and self.scroll_pos >= self.cached_total_scroll_width:
|
||||
self.scroll_pos %= self.cached_total_scroll_width
|
||||
else:
|
||||
# Not using cache (e.g., BDF direct drawing):
|
||||
# Reset when text fully scrolled past left edge + matrix width (original behavior creating a conceptual gap)
|
||||
# self.text_content_width is used here as it refers to the actual text being drawn directly.
|
||||
if self.text_content_width > 0 and self.scroll_pos > self.text_content_width + self.display_manager.matrix.width:
|
||||
self.scroll_pos = 0.0
|
||||
|
||||
def display(self):
|
||||
"""Draw the text onto the display manager's canvas."""
|
||||
dm = self.display_manager
|
||||
matrix_width = dm.matrix.width
|
||||
matrix_height = dm.matrix.height
|
||||
|
||||
dm.image = Image.new('RGB', (matrix_width, matrix_height), self.bg_color)
|
||||
dm.draw = ImageDraw.Draw(dm.image)
|
||||
|
||||
if not self.text or self.text_content_width == 0:
|
||||
dm.update_display()
|
||||
return
|
||||
|
||||
# Use pre-rendered cache if available and scrolling is active
|
||||
# Scrolling via cache is only relevant if the actual text content itself is wider than the matrix,
|
||||
# or if we want to scroll a short text with a large gap.
|
||||
# The self.cached_total_scroll_width > matrix_width implies the content (text+gap) is scrollable.
|
||||
if self.text_image_cache and self.scroll_enabled and self.cached_total_scroll_width > matrix_width :
|
||||
current_scroll_int = int(self.scroll_pos)
|
||||
|
||||
source_x1 = current_scroll_int
|
||||
source_x2 = current_scroll_int + matrix_width
|
||||
|
||||
if source_x2 <= self.cached_total_scroll_width:
|
||||
segment = self.text_image_cache.crop((source_x1, 0, source_x2, matrix_height))
|
||||
dm.image.paste(segment, (0, 0))
|
||||
else:
|
||||
# Wrap-around: paste two parts from cache
|
||||
width1 = self.cached_total_scroll_width - source_x1
|
||||
if width1 > 0:
|
||||
segment1 = self.text_image_cache.crop((source_x1, 0, self.cached_total_scroll_width, matrix_height))
|
||||
dm.image.paste(segment1, (0, 0))
|
||||
|
||||
remaining_width_for_screen = matrix_width - width1
|
||||
if remaining_width_for_screen > 0:
|
||||
segment2 = self.text_image_cache.crop((0, 0, remaining_width_for_screen, matrix_height))
|
||||
dm.image.paste(segment2, (width1 if width1 > 0 else 0, 0))
|
||||
else:
|
||||
# Fallback: Direct drawing (BDF, static TTF, or TTF text that fits screen and isn't forced to scroll by gap)
|
||||
final_y_for_draw = 0
|
||||
try:
|
||||
if isinstance(self.font, freetype.Face):
|
||||
text_render_height = self.font.size.height >> 6
|
||||
final_y_for_draw = (matrix_height - text_render_height) // 2
|
||||
else:
|
||||
pil_bbox = dm.draw.textbbox((0, 0), self.text, font=self.font)
|
||||
text_render_height = pil_bbox[3] - pil_bbox[1]
|
||||
final_y_for_draw = (matrix_height - text_render_height) // 2 - pil_bbox[1]
|
||||
except Exception as e:
|
||||
logger.warning(f"TextDisplay: Could not calculate text height for direct drawing: {e}. Using y=0.", exc_info=True)
|
||||
final_y_for_draw = 0
|
||||
|
||||
if self.scroll_enabled and self.text_content_width > matrix_width:
|
||||
# Scrolling text (direct drawing path, e.g., for BDF)
|
||||
x_draw_pos = matrix_width - int(self.scroll_pos) # scroll_pos for BDF already considers a type of gap for reset
|
||||
dm.draw_text(
|
||||
text=self.text, x=x_draw_pos, y=final_y_for_draw,
|
||||
color=self.text_color, font=self.font
|
||||
)
|
||||
else:
|
||||
# Static text (centered horizontally)
|
||||
x_draw_pos = (matrix_width - self.text_content_width) // 2
|
||||
dm.draw_text(
|
||||
text=self.text, x=x_draw_pos, y=final_y_for_draw,
|
||||
color=self.text_color, font=self.font
|
||||
)
|
||||
|
||||
dm.update_display()
|
||||
|
||||
def set_text(self, new_text: str):
|
||||
self.text = new_text
|
||||
self._regenerate_renderings()
|
||||
|
||||
def set_font(self, font_path: str, font_size: int):
|
||||
self.font_path = font_path
|
||||
self.font_size = font_size
|
||||
self.font = self._load_font()
|
||||
self._regenerate_renderings()
|
||||
|
||||
def set_color(self, text_color: tuple, bg_color: tuple):
|
||||
self.text_color = text_color
|
||||
self.bg_color = bg_color
|
||||
self._regenerate_renderings()
|
||||
|
||||
def set_scroll_enabled(self, enabled: bool):
|
||||
self.scroll_enabled = enabled
|
||||
self.scroll_pos = 0.0
|
||||
# Cache regeneration is not strictly needed, display logic handles scroll_enabled.
|
||||
|
||||
def set_scroll_speed(self, speed: float):
|
||||
self.scroll_speed = speed
|
||||
|
||||
def set_scroll_gap_width(self, gap_width: int):
|
||||
self.scroll_gap_width = gap_width
|
||||
self._regenerate_renderings() # Gap change requires cache rebuild
|
||||
|
||||
@@ -1,228 +0,0 @@
|
||||
import os
|
||||
from typing import Union
|
||||
from PIL import Image, ImageDraw
|
||||
# math is no longer needed for drawing, remove if not used elsewhere
|
||||
# import math
|
||||
|
||||
class WeatherIcons:
|
||||
ICON_DIR = "assets/weather/" # Path where PNG icons are stored
|
||||
DEFAULT_ICON = "not-available.png"
|
||||
DEFAULT_SIZE = 64 # Default size, should match icons but can be overridden
|
||||
|
||||
# Mapping from OpenWeatherMap icon codes to our filenames
|
||||
# See: https://openweathermap.org/weather-conditions#Icon-list
|
||||
ICON_MAP = {
|
||||
# Day icons
|
||||
"01d": "clear-day.png",
|
||||
"02d": "partly-cloudy-day.png", # Few clouds
|
||||
"03d": "cloudy.png", # Scattered clouds
|
||||
"04d": "overcast-day.png", # Broken clouds / Overcast
|
||||
"09d": "drizzle.png", # Shower rain (using drizzle)
|
||||
"10d": "partly-cloudy-day-rain.png", # Rain
|
||||
"11d": "thunderstorms-day.png", # Thunderstorm
|
||||
"13d": "partly-cloudy-day-snow.png", # Snow
|
||||
"50d": "mist.png", # Mist (can use fog, haze etc. too)
|
||||
|
||||
# Night icons
|
||||
"01n": "clear-night.png",
|
||||
"02n": "partly-cloudy-night.png",# Few clouds
|
||||
"03n": "cloudy.png", # Scattered clouds (same as day)
|
||||
"04n": "overcast-night.png", # Broken clouds / Overcast
|
||||
"09n": "drizzle.png", # Shower rain (using drizzle, same as day)
|
||||
"10n": "partly-cloudy-night-rain.png", # Rain
|
||||
"11n": "thunderstorms-night.png", # Thunderstorm
|
||||
"13n": "partly-cloudy-night-snow.png", # Snow
|
||||
"50n": "mist.png", # Mist (same as day)
|
||||
|
||||
# Add mappings for specific conditions if needed, although OWM codes are preferred
|
||||
"tornado": "tornado.png",
|
||||
"hurricane": "hurricane.png",
|
||||
"wind": "wind.png", # Generic wind if code is not specific enough
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _get_icon_filename(icon_code: str) -> str:
|
||||
"""Maps an OpenWeatherMap icon code (e.g., '01d', '10n') to an icon filename."""
|
||||
filename = WeatherIcons.ICON_MAP.get(icon_code, WeatherIcons.DEFAULT_ICON)
|
||||
print(f"[WeatherIcons] Mapping icon code '{icon_code}' to filename: '{filename}'")
|
||||
|
||||
# Check if the mapped filename exists, otherwise use default
|
||||
potential_path = os.path.join(WeatherIcons.ICON_DIR, filename)
|
||||
if not os.path.exists(potential_path):
|
||||
# If a specific icon was determined but not found, log warning and use default
|
||||
if filename != WeatherIcons.DEFAULT_ICON:
|
||||
print(f"Warning: Mapped icon file '{filename}' not found at '{potential_path}'. Falling back to default.")
|
||||
filename = WeatherIcons.DEFAULT_ICON
|
||||
|
||||
# Check if default exists
|
||||
default_path = os.path.join(WeatherIcons.ICON_DIR, WeatherIcons.DEFAULT_ICON)
|
||||
if not os.path.exists(default_path):
|
||||
print(f"Error: Default icon file also not found: {default_path}")
|
||||
# Allow filename to remain DEFAULT_ICON name, load_weather_icon handles FileNotFoundError
|
||||
|
||||
return filename
|
||||
|
||||
@staticmethod
|
||||
def load_weather_icon(icon_code: str, size: int = DEFAULT_SIZE) -> Union[Image.Image, None]:
|
||||
"""Loads, converts, and resizes the appropriate weather icon based on the OWM code. Returns None on failure."""
|
||||
filename = WeatherIcons._get_icon_filename(icon_code)
|
||||
icon_path = os.path.join(WeatherIcons.ICON_DIR, filename)
|
||||
|
||||
try:
|
||||
# Open image and ensure it's RGBA for transparency handling
|
||||
icon_img = Image.open(icon_path).convert("RGBA")
|
||||
|
||||
# Resize if necessary using high-quality downsampling (LANCZOS/ANTIALIAS)
|
||||
if icon_img.width != size or icon_img.height != size:
|
||||
icon_img = icon_img.resize((size, size), Image.Resampling.LANCZOS)
|
||||
|
||||
return icon_img
|
||||
except FileNotFoundError:
|
||||
print(f"Error: Icon file not found: {icon_path}")
|
||||
# Don't try to load default here, _get_icon_filename already handled fallback logic
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f"Error processing icon {icon_path}: {e}")
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def draw_weather_icon(image: Image.Image, icon_code: str, x: int, y: int, size: int = DEFAULT_SIZE):
|
||||
"""Loads the appropriate weather icon based on OWM code and pastes it onto the target PIL Image object."""
|
||||
icon_to_draw = WeatherIcons.load_weather_icon(icon_code, size)
|
||||
if icon_to_draw:
|
||||
# Create a thresholded mask from the icon's alpha channel
|
||||
# to remove faint anti-aliasing pixels when pasting on black bg.
|
||||
# Pixels with alpha > 200 will be fully opaque, others fully transparent.
|
||||
try:
|
||||
# alpha = icon_to_draw.getchannel('A')
|
||||
# # Apply threshold: lambda function returns 255 if input > 200, else 0
|
||||
# threshold_mask = alpha.point(lambda p: 255 if p > 200 else 0)
|
||||
|
||||
# Paste the icon using the thresholded mask
|
||||
# image.paste(icon_to_draw, (x, y), threshold_mask)
|
||||
|
||||
# Paste the icon directly with its original alpha channel
|
||||
image.paste(icon_to_draw, (x, y), icon_to_draw)
|
||||
except Exception as e:
|
||||
print(f"Error processing or pasting icon for code '{icon_code}' at ({x},{y}): {e}")
|
||||
# Fallback or alternative handling if needed
|
||||
# try:
|
||||
# # Fallback: Try pasting with original alpha if thresholding fails
|
||||
# image.paste(icon_to_draw, (x, y), icon_to_draw)
|
||||
# except Exception as e2:
|
||||
# print(f"Error during fallback paste: {e2}")
|
||||
pass
|
||||
else:
|
||||
# Optional: Draw a placeholder if icon loading fails completely
|
||||
print(f"Could not load icon for code '{icon_code}' to draw at ({x},{y})")
|
||||
|
||||
@staticmethod
|
||||
def draw_sun(draw: ImageDraw, x: int, y: int, size: int = 16, color: tuple = (255, 200, 0)):
|
||||
"""Draw a sun icon with rays."""
|
||||
center_x = x + size // 2
|
||||
center_y = y + size // 2
|
||||
radius = size // 3
|
||||
|
||||
# Draw main sun circle
|
||||
draw.ellipse([
|
||||
center_x - radius, center_y - radius,
|
||||
center_x + radius, center_y + radius
|
||||
], fill=color)
|
||||
|
||||
# Draw rays
|
||||
ray_length = size // 4
|
||||
for angle in range(0, 360, 45):
|
||||
rad = math.radians(angle)
|
||||
start_x = center_x + (radius * math.cos(rad))
|
||||
start_y = center_y + (radius * math.sin(rad))
|
||||
end_x = center_x + ((radius + ray_length) * math.cos(rad))
|
||||
end_y = center_y + ((radius + ray_length) * math.sin(rad))
|
||||
draw.line([start_x, start_y, end_x, end_y], fill=color, width=2)
|
||||
|
||||
@staticmethod
|
||||
def draw_cloud(draw: ImageDraw, x: int, y: int, size: int = 16, color: tuple = (200, 200, 200)):
|
||||
"""Draw a cloud icon."""
|
||||
# Draw multiple circles to form cloud shape
|
||||
circle_size = size // 2
|
||||
positions = [
|
||||
(x + size//4, y + size//3),
|
||||
(x + size//2, y + size//3),
|
||||
(x + size//3, y + size//6)
|
||||
]
|
||||
|
||||
for pos_x, pos_y in positions:
|
||||
draw.ellipse([
|
||||
pos_x, pos_y,
|
||||
pos_x + circle_size, pos_y + circle_size
|
||||
], fill=color)
|
||||
|
||||
@staticmethod
|
||||
def draw_rain(draw: ImageDraw, x: int, y: int, size: int = 16):
|
||||
"""Draw rain icon with cloud and droplets."""
|
||||
# Draw cloud first
|
||||
WeatherIcons.draw_cloud(draw, x, y, size)
|
||||
|
||||
# Draw rain drops
|
||||
drop_color = (0, 150, 255) # Light blue
|
||||
drop_length = size // 3
|
||||
drop_spacing = size // 4
|
||||
|
||||
for i in range(3):
|
||||
drop_x = x + size//4 + (i * drop_spacing)
|
||||
drop_y = y + size//2
|
||||
draw.line([
|
||||
drop_x, drop_y,
|
||||
drop_x - 2, drop_y + drop_length
|
||||
], fill=drop_color, width=2)
|
||||
|
||||
@staticmethod
|
||||
def draw_snow(draw: ImageDraw, x: int, y: int, size: int = 16):
|
||||
"""Draw snow icon with cloud and snowflakes."""
|
||||
# Draw cloud first
|
||||
WeatherIcons.draw_cloud(draw, x, y, size)
|
||||
|
||||
# Draw snowflakes
|
||||
snow_color = (200, 200, 255) # Light blue-white
|
||||
flake_size = size // 6
|
||||
flake_spacing = size // 4
|
||||
|
||||
for i in range(3):
|
||||
center_x = x + size//4 + (i * flake_spacing)
|
||||
center_y = y + size//2
|
||||
|
||||
# Draw 6-point snowflake
|
||||
for angle in range(0, 360, 60):
|
||||
rad = math.radians(angle)
|
||||
end_x = center_x + (flake_size * math.cos(rad))
|
||||
end_y = center_y + (flake_size * math.sin(rad))
|
||||
draw.line([center_x, center_y, end_x, end_y], fill=snow_color, width=1)
|
||||
|
||||
@staticmethod
|
||||
def draw_thunderstorm(draw: ImageDraw, x: int, y: int, size: int = 16):
|
||||
"""Draw thunderstorm icon with cloud and lightning."""
|
||||
# Draw dark cloud
|
||||
WeatherIcons.draw_cloud(draw, x, y, size, color=(100, 100, 100))
|
||||
|
||||
# Draw lightning bolt
|
||||
lightning_color = (255, 255, 0) # Yellow
|
||||
bolt_points = [
|
||||
(x + size//2, y + size//3),
|
||||
(x + size//2 - size//4, y + size//2),
|
||||
(x + size//2, y + size//2),
|
||||
(x + size//2 - size//4, y + size//2 + size//4)
|
||||
]
|
||||
draw.line(bolt_points, fill=lightning_color, width=2)
|
||||
|
||||
@staticmethod
|
||||
def draw_mist(draw: ImageDraw, x: int, y: int, size: int = 16):
|
||||
"""Draw mist/fog icon."""
|
||||
mist_color = (200, 200, 200) # Light gray
|
||||
wave_height = size // 4
|
||||
wave_spacing = size // 3
|
||||
|
||||
for i in range(3):
|
||||
wave_y = y + size//3 + (i * wave_spacing)
|
||||
draw.line([
|
||||
x + size//4, wave_y,
|
||||
x + size//4 + size//2, wave_y + wave_height
|
||||
], fill=mist_color, width=2)
|
||||
@@ -1,577 +0,0 @@
|
||||
import requests
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any, List
|
||||
from PIL import Image, ImageDraw
|
||||
import freetype
|
||||
from .weather_icons import WeatherIcons
|
||||
from .cache_manager import CacheManager
|
||||
|
||||
# Import the API counter function from web interface
|
||||
try:
|
||||
from web_interface_v2 import increment_api_counter
|
||||
except ImportError:
|
||||
# Fallback if web interface is not available
|
||||
def increment_api_counter(kind: str, count: int = 1):
|
||||
pass
|
||||
|
||||
class WeatherManager:
|
||||
|
||||
def __init__(self, config: Dict[str, Any], display_manager):
|
||||
self.config = config
|
||||
self.display_manager = display_manager
|
||||
self.weather_config = config.get('weather', {})
|
||||
self.location = config.get('location', {})
|
||||
self.last_update = 0
|
||||
self.weather_data = None
|
||||
self.forecast_data = None
|
||||
self.hourly_forecast = None
|
||||
self.daily_forecast = None
|
||||
self.last_draw_time = 0
|
||||
self.cache_manager = CacheManager()
|
||||
|
||||
# Error handling and throttling
|
||||
self.consecutive_errors = 0
|
||||
self.last_error_time = 0
|
||||
self.error_backoff_time = 60 # Start with 1 minute backoff
|
||||
self.max_consecutive_errors = 5 # Stop trying after 5 consecutive errors
|
||||
self.error_log_throttle = 300 # Only log errors every 5 minutes
|
||||
self.last_error_log_time = 0
|
||||
|
||||
# Layout constants
|
||||
self.PADDING = 1
|
||||
self.ICON_SIZE = {
|
||||
'extra_large': 40, # Changed from 30
|
||||
'large': 30,
|
||||
'medium': 24,
|
||||
'small': 14
|
||||
}
|
||||
self.COLORS = {
|
||||
'text': (255, 255, 255),
|
||||
'highlight': (255, 200, 0),
|
||||
'separator': (64, 64, 64),
|
||||
'temp_high': (255, 100, 100),
|
||||
'temp_low': (100, 100, 255),
|
||||
'dim': (180, 180, 180),
|
||||
'extra_dim': (120, 120, 120), # Even dimmer for smallest text
|
||||
'uv_low': (0, 150, 0), # Green
|
||||
'uv_moderate': (255, 200, 0), # Yellow
|
||||
'uv_high': (255, 120, 0), # Orange
|
||||
'uv_very_high': (200, 0, 0), # Red
|
||||
'uv_extreme': (150, 0, 200) # Purple
|
||||
}
|
||||
# Add caching for last drawn states
|
||||
self.last_weather_state = None
|
||||
self.last_hourly_state = None
|
||||
self.last_daily_state = None
|
||||
|
||||
def _fetch_weather(self) -> None:
|
||||
"""Fetch current weather and forecast data from OpenWeatherMap API."""
|
||||
current_time = time.time()
|
||||
|
||||
# Check if we're in error backoff period
|
||||
if self.consecutive_errors >= self.max_consecutive_errors:
|
||||
if current_time - self.last_error_time < self.error_backoff_time:
|
||||
# Still in backoff period, don't attempt fetch
|
||||
if current_time - self.last_error_log_time > self.error_log_throttle:
|
||||
print(f"Weather API disabled due to {self.consecutive_errors} consecutive errors. Retrying in {self.error_backoff_time - (current_time - self.last_error_time):.0f} seconds")
|
||||
self.last_error_log_time = current_time
|
||||
return
|
||||
else:
|
||||
# Backoff period expired, reset error count and try again
|
||||
self.consecutive_errors = 0
|
||||
self.error_backoff_time = 60 # Reset to initial backoff
|
||||
|
||||
api_key = self.weather_config.get('api_key')
|
||||
if not api_key or api_key == "YOUR_OPENWEATHERMAP_API_KEY":
|
||||
if current_time - self.last_error_log_time > self.error_log_throttle:
|
||||
print("No valid API key configured for weather")
|
||||
self.last_error_log_time = current_time
|
||||
return
|
||||
|
||||
# Try to get cached data first
|
||||
cached_data = self.cache_manager.get('weather')
|
||||
if cached_data:
|
||||
self.weather_data = cached_data.get('current')
|
||||
self.forecast_data = cached_data.get('forecast')
|
||||
if self.weather_data and self.forecast_data:
|
||||
self._process_forecast_data(self.forecast_data)
|
||||
self.last_update = time.time()
|
||||
# Reset error count on successful cache usage
|
||||
self.consecutive_errors = 0
|
||||
print("Using cached weather data")
|
||||
return
|
||||
|
||||
city = self.location['city']
|
||||
state = self.location['state']
|
||||
country = self.location['country']
|
||||
units = self.weather_config.get('units', 'imperial')
|
||||
|
||||
# First get coordinates using geocoding API
|
||||
geo_url = f"http://api.openweathermap.org/geo/1.0/direct?q={city},{state},{country}&limit=1&appid={api_key}"
|
||||
|
||||
try:
|
||||
# Get coordinates
|
||||
response = requests.get(geo_url)
|
||||
response.raise_for_status()
|
||||
geo_data = response.json()
|
||||
|
||||
# Increment API counter for geocoding call
|
||||
increment_api_counter('weather', 1)
|
||||
|
||||
if not geo_data:
|
||||
print(f"Could not find coordinates for {city}, {state}")
|
||||
return
|
||||
|
||||
lat = geo_data[0]['lat']
|
||||
lon = geo_data[0]['lon']
|
||||
|
||||
# Get current weather and daily forecast using One Call API
|
||||
one_call_url = f"https://api.openweathermap.org/data/3.0/onecall?lat={lat}&lon={lon}&exclude=minutely,alerts&appid={api_key}&units={units}"
|
||||
|
||||
# Fetch current weather and daily forecast
|
||||
response = requests.get(one_call_url)
|
||||
response.raise_for_status()
|
||||
one_call_data = response.json()
|
||||
|
||||
# Increment API counter for weather data call
|
||||
increment_api_counter('weather', 1)
|
||||
|
||||
# Store current weather data
|
||||
self.weather_data = {
|
||||
'main': {
|
||||
'temp': one_call_data['current']['temp'],
|
||||
'temp_max': one_call_data['daily'][0]['temp']['max'],
|
||||
'temp_min': one_call_data['daily'][0]['temp']['min'],
|
||||
'humidity': one_call_data['current']['humidity'],
|
||||
'pressure': one_call_data['current']['pressure'],
|
||||
'uvi': one_call_data['current'].get('uvi', 0)
|
||||
},
|
||||
'weather': one_call_data['current']['weather'],
|
||||
'wind': {
|
||||
'speed': one_call_data['current'].get('wind_speed', 0),
|
||||
'deg': one_call_data['current'].get('wind_deg', 0)
|
||||
}
|
||||
}
|
||||
|
||||
# Store forecast data (for hourly and daily forecasts)
|
||||
self.forecast_data = one_call_data
|
||||
|
||||
# Process forecast data
|
||||
self._process_forecast_data(self.forecast_data)
|
||||
|
||||
# Cache the new data
|
||||
cache_data = {
|
||||
'current': self.weather_data,
|
||||
'forecast': self.forecast_data
|
||||
}
|
||||
self.cache_manager.update_cache('weather', cache_data)
|
||||
|
||||
self.last_update = time.time()
|
||||
# Reset error count on successful fetch
|
||||
self.consecutive_errors = 0
|
||||
print("Weather data updated successfully")
|
||||
|
||||
except Exception as e:
|
||||
self.consecutive_errors += 1
|
||||
self.last_error_time = current_time
|
||||
|
||||
# Exponential backoff: double the backoff time (max 1 hour)
|
||||
self.error_backoff_time = min(self.error_backoff_time * 2, 3600)
|
||||
|
||||
# Only log errors periodically to avoid spam
|
||||
if current_time - self.last_error_log_time > self.error_log_throttle:
|
||||
print(f"Error fetching weather data (attempt {self.consecutive_errors}/{self.max_consecutive_errors}): {e}")
|
||||
if self.consecutive_errors >= self.max_consecutive_errors:
|
||||
print(f"Weather API disabled for {self.error_backoff_time} seconds due to repeated failures")
|
||||
self.last_error_log_time = current_time
|
||||
|
||||
# If we have cached data, use it as fallback
|
||||
if cached_data:
|
||||
self.weather_data = cached_data.get('current')
|
||||
self.forecast_data = cached_data.get('forecast')
|
||||
if self.weather_data and self.forecast_data:
|
||||
self._process_forecast_data(self.forecast_data)
|
||||
print("Using cached weather data as fallback")
|
||||
else:
|
||||
self.weather_data = None
|
||||
self.forecast_data = None
|
||||
|
||||
def _process_forecast_data(self, forecast_data: Dict[str, Any]) -> None:
|
||||
"""Process forecast data into hourly and daily forecasts."""
|
||||
if not forecast_data:
|
||||
return
|
||||
|
||||
# Process hourly forecast (next 5 hours)
|
||||
hourly_list = forecast_data.get('hourly', [])[:5] # Get next 5 hours
|
||||
self.hourly_forecast = []
|
||||
|
||||
for hour_data in hourly_list:
|
||||
dt = datetime.fromtimestamp(hour_data['dt'])
|
||||
temp = round(hour_data['temp'])
|
||||
condition = hour_data['weather'][0]['main']
|
||||
icon_code = hour_data['weather'][0]['icon']
|
||||
self.hourly_forecast.append({
|
||||
'hour': dt.strftime('%I:00 %p').lstrip('0'), # Format as "2:00 PM"
|
||||
'temp': temp,
|
||||
'condition': condition,
|
||||
'icon': icon_code
|
||||
})
|
||||
|
||||
# Process daily forecast
|
||||
daily_list = forecast_data.get('daily', [])[1:4] # Skip today (index 0) and get next 3 days
|
||||
self.daily_forecast = []
|
||||
|
||||
for day_data in daily_list:
|
||||
dt = datetime.fromtimestamp(day_data['dt'])
|
||||
temp_high = round(day_data['temp']['max'])
|
||||
temp_low = round(day_data['temp']['min'])
|
||||
condition = day_data['weather'][0]['main']
|
||||
icon_code = day_data['weather'][0]['icon']
|
||||
|
||||
self.daily_forecast.append({
|
||||
'date': dt.strftime('%a'), # Day name (Mon, Tue, etc.)
|
||||
'date_str': dt.strftime('%m/%d'), # Date (4/8, 4/9, etc.)
|
||||
'temp_high': temp_high,
|
||||
'temp_low': temp_low,
|
||||
'condition': condition,
|
||||
'icon': icon_code
|
||||
})
|
||||
|
||||
def get_weather(self) -> Dict[str, Any]:
|
||||
"""Get current weather data, fetching new data if needed."""
|
||||
current_time = time.time()
|
||||
update_interval = self.weather_config.get('update_interval', 300)
|
||||
# Add a throttle for log spam
|
||||
log_throttle_interval = 600 # 10 minutes
|
||||
if not hasattr(self, '_last_weather_log_time'):
|
||||
self._last_weather_log_time = 0
|
||||
# Check if we need to update based on time or if we have no data
|
||||
if (not self.weather_data or
|
||||
current_time - self.last_update > update_interval):
|
||||
# Check if data has changed before fetching
|
||||
current_state = self._get_weather_state()
|
||||
if current_state and not self.cache_manager.has_data_changed('weather', current_state):
|
||||
if current_time - self._last_weather_log_time > log_throttle_interval:
|
||||
print("Weather data hasn't changed, using existing data")
|
||||
self._last_weather_log_time = current_time
|
||||
return self.weather_data
|
||||
self._fetch_weather()
|
||||
return self.weather_data
|
||||
|
||||
def _get_weather_state(self) -> Dict[str, Any]:
|
||||
"""Get current weather state for comparison."""
|
||||
if not self.weather_data:
|
||||
return None
|
||||
return {
|
||||
'temp': round(self.weather_data['main']['temp']),
|
||||
'condition': self.weather_data['weather'][0]['main'],
|
||||
'humidity': self.weather_data['main']['humidity'],
|
||||
'uvi': self.weather_data['main'].get('uvi', 0)
|
||||
}
|
||||
|
||||
def _get_hourly_state(self) -> List[Dict[str, Any]]:
|
||||
"""Get current hourly forecast state for comparison."""
|
||||
if not self.hourly_forecast:
|
||||
return None
|
||||
return [
|
||||
{'hour': f['hour'], 'temp': round(f['temp']), 'condition': f['condition']}
|
||||
for f in self.hourly_forecast[:3]
|
||||
]
|
||||
|
||||
def _get_daily_state(self) -> List[Dict[str, Any]]:
|
||||
"""Get current daily forecast state for comparison."""
|
||||
if not self.daily_forecast:
|
||||
return None
|
||||
return [
|
||||
{
|
||||
'date': f['date'],
|
||||
'temp_high': round(f['temp_high']),
|
||||
'temp_low': round(f['temp_low']),
|
||||
'condition': f['condition']
|
||||
}
|
||||
for f in self.daily_forecast[:4] # Changed to 4 days
|
||||
]
|
||||
|
||||
def display_weather(self, force_clear: bool = False) -> None:
|
||||
"""Display current weather information using a modern layout."""
|
||||
try:
|
||||
weather_data = self.get_weather()
|
||||
if not weather_data:
|
||||
print("No weather data available")
|
||||
return
|
||||
|
||||
# Check if state has changed
|
||||
current_state = self._get_weather_state()
|
||||
if not force_clear and current_state == self.last_weather_state:
|
||||
return # No need to redraw if nothing changed
|
||||
|
||||
# Clear the display once at the start
|
||||
self.display_manager.clear()
|
||||
|
||||
# Create a new image for drawing
|
||||
image = Image.new('RGB', (self.display_manager.matrix.width, self.display_manager.matrix.height))
|
||||
draw = ImageDraw.Draw(image)
|
||||
|
||||
# --- Top Left: Icon ---
|
||||
condition = weather_data['weather'][0]['main']
|
||||
icon_code = weather_data['weather'][0]['icon']
|
||||
icon_size = self.ICON_SIZE['extra_large'] # Use extra_large size
|
||||
icon_x = 1 # Small padding from left edge
|
||||
# Center the icon vertically in the top two-thirds of the display
|
||||
available_height = (self.display_manager.matrix.height * 2) // 3 # Use top 2/3 of screen
|
||||
icon_y = (available_height - icon_size) // 2
|
||||
WeatherIcons.draw_weather_icon(image, icon_code, icon_x, icon_y, size=icon_size)
|
||||
|
||||
# --- Top Right: Condition Text ---
|
||||
condition_text = condition
|
||||
condition_font = self.display_manager.small_font
|
||||
condition_text_width = draw.textlength(condition_text, font=condition_font)
|
||||
condition_x = self.display_manager.matrix.width - condition_text_width - 1 # Align right
|
||||
condition_y = 1 # Align top
|
||||
draw.text((condition_x, condition_y),
|
||||
condition_text,
|
||||
font=condition_font,
|
||||
fill=self.COLORS['text'])
|
||||
|
||||
# --- Right Side (Below Condition): Current Temp ---
|
||||
temp = round(weather_data['main']['temp'])
|
||||
temp_text = f"{temp}°"
|
||||
# Use the small font from DisplayManager as before
|
||||
temp_font = self.display_manager.small_font
|
||||
temp_text_width = draw.textlength(temp_text, font=temp_font)
|
||||
temp_x = self.display_manager.matrix.width - temp_text_width - 1 # Align right
|
||||
temp_y = condition_y + 8 # Position below condition text (adjust 8 based on font size)
|
||||
draw.text((temp_x, temp_y),
|
||||
temp_text,
|
||||
font=temp_font,
|
||||
fill=self.COLORS['highlight'])
|
||||
|
||||
# --- Right Side (Below Current Temp): High/Low Temp ---
|
||||
temp_max = round(weather_data['main']['temp_max'])
|
||||
temp_min = round(weather_data['main']['temp_min'])
|
||||
high_low_text = f"{temp_min}°/{temp_max}°"
|
||||
high_low_font = self.display_manager.small_font # Using small font
|
||||
high_low_width = draw.textlength(high_low_text, font=high_low_font)
|
||||
high_low_x = self.display_manager.matrix.width - high_low_width - 1 # Align right
|
||||
high_low_y = temp_y + 8 # Position below current temp text (adjust 8 based on font size)
|
||||
draw.text((high_low_x, high_low_y),
|
||||
high_low_text,
|
||||
font=high_low_font,
|
||||
fill=self.COLORS['dim'])
|
||||
|
||||
# --- Bottom: Additional Metrics (Unchanged) ---
|
||||
display_width = self.display_manager.matrix.width
|
||||
section_width = display_width // 3
|
||||
y_pos = self.display_manager.matrix.height - 7 # Position near bottom for 6px font
|
||||
font = self.display_manager.extra_small_font # The 4x6 font
|
||||
|
||||
# --- UV Index (Section 1) ---
|
||||
uv_index = weather_data['main'].get('uvi', 0)
|
||||
uv_prefix = "UV:"
|
||||
uv_value_text = f"{uv_index:.0f}"
|
||||
|
||||
prefix_width = draw.textlength(uv_prefix, font=font)
|
||||
value_width = draw.textlength(uv_value_text, font=font)
|
||||
total_width = prefix_width + value_width
|
||||
|
||||
start_x = (section_width - total_width) // 2
|
||||
|
||||
# Draw "UV:" prefix
|
||||
draw.text((start_x, y_pos),
|
||||
uv_prefix,
|
||||
font=font,
|
||||
fill=self.COLORS['dim'])
|
||||
|
||||
# Draw UV value with color
|
||||
uv_color = self._get_uv_color(uv_index)
|
||||
draw.text((start_x + prefix_width, y_pos),
|
||||
uv_value_text,
|
||||
font=font,
|
||||
fill=uv_color)
|
||||
|
||||
# --- Humidity (Section 2) ---
|
||||
humidity = weather_data['main']['humidity']
|
||||
humidity_text = f"H:{humidity}%"
|
||||
humidity_width = draw.textlength(humidity_text, font=font)
|
||||
humidity_x = section_width + (section_width - humidity_width) // 2 # Center in second third
|
||||
draw.text((humidity_x, y_pos),
|
||||
humidity_text,
|
||||
font=font,
|
||||
fill=self.COLORS['dim'])
|
||||
|
||||
# --- Wind (Section 3) ---
|
||||
wind_speed = weather_data['wind']['speed']
|
||||
wind_deg = weather_data['wind']['deg']
|
||||
wind_dir = self._get_wind_direction(wind_deg)
|
||||
wind_text = f"W:{wind_speed:.0f}{wind_dir}"
|
||||
wind_width = draw.textlength(wind_text, font=font)
|
||||
wind_x = (2 * section_width) + (section_width - wind_width) // 2 # Center in third third
|
||||
draw.text((wind_x, y_pos),
|
||||
wind_text,
|
||||
font=font,
|
||||
fill=self.COLORS['dim'])
|
||||
|
||||
# Update the display
|
||||
self.display_manager.image = image
|
||||
self.display_manager.update_display()
|
||||
self.last_weather_state = current_state
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error displaying weather: {e}")
|
||||
|
||||
def _get_wind_direction(self, degrees: float) -> str:
|
||||
"""Convert wind degrees to cardinal direction."""
|
||||
directions = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW']
|
||||
index = round(degrees / 45) % 8
|
||||
return directions[index]
|
||||
|
||||
def _get_uv_color(self, uv_index: float) -> tuple:
|
||||
"""Get color based on UV index value."""
|
||||
if uv_index <= 2:
|
||||
return self.COLORS['uv_low']
|
||||
elif uv_index <= 5:
|
||||
return self.COLORS['uv_moderate']
|
||||
elif uv_index <= 7:
|
||||
return self.COLORS['uv_high']
|
||||
elif uv_index <= 10:
|
||||
return self.COLORS['uv_very_high']
|
||||
else:
|
||||
return self.COLORS['uv_extreme']
|
||||
|
||||
def display_hourly_forecast(self, force_clear: bool = False):
|
||||
"""Display the next few hours of weather forecast."""
|
||||
try:
|
||||
if not self.hourly_forecast:
|
||||
print("No hourly forecast data available")
|
||||
return
|
||||
|
||||
# Check if state has changed
|
||||
current_state = self._get_hourly_state()
|
||||
if not force_clear and current_state == self.last_hourly_state:
|
||||
return
|
||||
|
||||
# Clear the display
|
||||
self.display_manager.clear()
|
||||
|
||||
# Create a new image for drawing
|
||||
image = Image.new('RGB', (self.display_manager.matrix.width, self.display_manager.matrix.height))
|
||||
draw = ImageDraw.Draw(image)
|
||||
|
||||
# Calculate layout based on matrix dimensions
|
||||
hours_to_show = min(4, len(self.hourly_forecast))
|
||||
total_width = self.display_manager.matrix.width
|
||||
section_width = total_width // hours_to_show
|
||||
padding = max(2, section_width // 6) # Increased padding for more space
|
||||
|
||||
for i in range(hours_to_show):
|
||||
forecast = self.hourly_forecast[i]
|
||||
x = i * section_width + padding
|
||||
center_x = x + (section_width - 2 * padding) // 2
|
||||
|
||||
# Draw hour at top
|
||||
hour_text = forecast['hour']
|
||||
hour_text = hour_text.replace(":00 ", "").replace("PM", "p").replace("AM", "a")
|
||||
hour_width = draw.textlength(hour_text, font=self.display_manager.small_font)
|
||||
draw.text((center_x - hour_width // 2, 1),
|
||||
hour_text,
|
||||
font=self.display_manager.small_font,
|
||||
fill=self.COLORS['text'])
|
||||
|
||||
# Draw weather icon centered vertically between top/bottom text
|
||||
icon_size = self.ICON_SIZE['large'] # Changed from medium to large (28)
|
||||
top_text_height = 8 # Approx height reservation for top text
|
||||
bottom_text_y = self.display_manager.matrix.height - 8 # Starting Y for bottom text
|
||||
available_height_for_icon = bottom_text_y - top_text_height
|
||||
# Ensure calculated y is not negative if space is very tight
|
||||
calculated_y = top_text_height + (available_height_for_icon - icon_size) // 2
|
||||
icon_y = (self.display_manager.matrix.height // 2) - 16
|
||||
icon_x = center_x - icon_size // 2
|
||||
WeatherIcons.draw_weather_icon(image, forecast['icon'], icon_x, icon_y, icon_size)
|
||||
|
||||
# Draw temperature at bottom
|
||||
temp_text = f"{forecast['temp']}°"
|
||||
temp_width = draw.textlength(temp_text, font=self.display_manager.small_font)
|
||||
temp_y = self.display_manager.matrix.height - 8 # Position at bottom with small margin
|
||||
draw.text((center_x - temp_width // 2, temp_y),
|
||||
temp_text,
|
||||
font=self.display_manager.small_font,
|
||||
fill=self.COLORS['text'])
|
||||
|
||||
# Update the display
|
||||
self.display_manager.image = image
|
||||
self.display_manager.update_display()
|
||||
self.last_hourly_state = current_state
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error displaying hourly forecast: {e}")
|
||||
|
||||
def display_daily_forecast(self, force_clear: bool = False):
|
||||
"""Display the daily weather forecast."""
|
||||
try:
|
||||
if not self.daily_forecast:
|
||||
print("No daily forecast data available")
|
||||
return
|
||||
|
||||
# Check if state has changed
|
||||
current_state = self._get_daily_state()
|
||||
if not force_clear and current_state == self.last_daily_state:
|
||||
return
|
||||
|
||||
# Clear the display
|
||||
self.display_manager.clear()
|
||||
|
||||
# Create a new image for drawing
|
||||
image = Image.new('RGB', (self.display_manager.matrix.width, self.display_manager.matrix.height))
|
||||
draw = ImageDraw.Draw(image)
|
||||
|
||||
# Calculate layout based on matrix dimensions for 3 days
|
||||
days_to_show = min(3, len(self.daily_forecast)) # Changed from 4 to 3
|
||||
if days_to_show == 0:
|
||||
# Handle case where there's no forecast data after filtering
|
||||
draw.text((2, 2), "No daily forecast", font=self.display_manager.small_font, fill=self.COLORS['dim'])
|
||||
else:
|
||||
total_width = self.display_manager.matrix.width
|
||||
section_width = total_width // days_to_show # Divide by 3 (or fewer if less data)
|
||||
padding = max(2, section_width // 6)
|
||||
|
||||
for i in range(days_to_show):
|
||||
forecast = self.daily_forecast[i]
|
||||
x = i * section_width # No need for padding here, centering handles spacing
|
||||
center_x = x + section_width // 2 # Center within the section
|
||||
|
||||
# Draw day name at top
|
||||
day_text = forecast['date']
|
||||
day_width = draw.textlength(day_text, font=self.display_manager.small_font)
|
||||
draw.text((center_x - day_width // 2, 1),
|
||||
day_text,
|
||||
font=self.display_manager.small_font,
|
||||
fill=self.COLORS['text'])
|
||||
|
||||
# Draw weather icon centered vertically between top/bottom text
|
||||
icon_size = self.ICON_SIZE['large'] # Changed from medium to large (28)
|
||||
top_text_height = 8 # Approx height reservation for top text
|
||||
bottom_text_y = self.display_manager.matrix.height - 8 # Starting Y for bottom text
|
||||
available_height_for_icon = bottom_text_y - top_text_height
|
||||
# Ensure calculated y is not negative if space is very tight
|
||||
calculated_y = top_text_height + (available_height_for_icon - icon_size) // 2
|
||||
icon_y = (self.display_manager.matrix.height // 2) - 16
|
||||
icon_x = center_x - icon_size // 2
|
||||
WeatherIcons.draw_weather_icon(image, forecast['icon'], icon_x, icon_y, icon_size)
|
||||
|
||||
# Draw high/low temperatures at bottom (without degree symbol)
|
||||
temp_text = f"{forecast['temp_low']} / {forecast['temp_high']}" # Removed degree symbols
|
||||
temp_width = draw.textlength(temp_text, font=self.display_manager.extra_small_font)
|
||||
temp_y = self.display_manager.matrix.height - 8 # Position at bottom with small margin
|
||||
draw.text((center_x - temp_width // 2, temp_y),
|
||||
temp_text,
|
||||
font=self.display_manager.extra_small_font,
|
||||
fill=self.COLORS['text'])
|
||||
|
||||
# Update the display
|
||||
self.display_manager.image = image
|
||||
self.display_manager.update_display()
|
||||
self.last_daily_state = current_state
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error displaying daily forecast: {e}")
|
||||
133
src/web_interface/api_helpers.py
Normal file
133
src/web_interface/api_helpers.py
Normal file
@@ -0,0 +1,133 @@
|
||||
"""
|
||||
Standardized API response helpers.
|
||||
|
||||
Provides consistent API response formatting across all endpoints.
|
||||
"""
|
||||
|
||||
import time
|
||||
from typing import Any, Optional, Dict, Tuple, Union
|
||||
from flask import jsonify, request
|
||||
|
||||
from src.web_interface.error_handler import create_error_response, create_success_response
|
||||
from src.web_interface.errors import ErrorCode, ErrorCategory
|
||||
|
||||
|
||||
def success_response(
|
||||
data: Any = None,
|
||||
message: Optional[str] = None,
|
||||
metadata: Optional[Dict] = None
|
||||
):
|
||||
"""
|
||||
Create a standardized success response.
|
||||
|
||||
Args:
|
||||
data: Response data
|
||||
message: Optional success message
|
||||
metadata: Optional metadata (timing, version, etc.)
|
||||
|
||||
Returns:
|
||||
Flask jsonify response
|
||||
"""
|
||||
response_data = create_success_response(data, message, metadata)
|
||||
|
||||
# Add request metadata if available
|
||||
if metadata is None:
|
||||
metadata = {}
|
||||
|
||||
# Add timing if request start time is available
|
||||
if hasattr(request, 'start_time'):
|
||||
metadata['response_time_ms'] = int((time.time() - request.start_time) * 1000)
|
||||
|
||||
if metadata:
|
||||
response_data['metadata'] = metadata
|
||||
|
||||
return jsonify(response_data)
|
||||
|
||||
|
||||
def error_response(
|
||||
error_code: ErrorCode,
|
||||
message: str,
|
||||
details: Optional[str] = None,
|
||||
context: Optional[Dict] = None,
|
||||
suggested_fixes: Optional[list] = None,
|
||||
status_code: int = 500
|
||||
):
|
||||
"""
|
||||
Create a standardized error response.
|
||||
|
||||
Args:
|
||||
error_code: Error code
|
||||
message: Error message
|
||||
details: Optional detailed error information
|
||||
context: Optional context dictionary
|
||||
suggested_fixes: Optional list of suggested fixes
|
||||
status_code: HTTP status code
|
||||
|
||||
Returns:
|
||||
Flask jsonify response with status code
|
||||
"""
|
||||
return create_error_response(
|
||||
error_code=error_code,
|
||||
message=message,
|
||||
details=details,
|
||||
context=context,
|
||||
suggested_fixes=suggested_fixes,
|
||||
status_code=status_code
|
||||
)
|
||||
|
||||
|
||||
def validate_request_json(required_fields: list, data: Optional[Dict] = None) -> Tuple[Optional[Dict], Optional[Any]]:
|
||||
"""
|
||||
Validate request JSON has required fields.
|
||||
|
||||
Args:
|
||||
required_fields: List of required field names
|
||||
data: Optional data dict (if None, reads from request)
|
||||
|
||||
Returns:
|
||||
Tuple of (data_dict, error_response) or (data_dict, None) if valid
|
||||
"""
|
||||
if data is None:
|
||||
data = request.get_json(silent=True)
|
||||
|
||||
if not data:
|
||||
return None, error_response(
|
||||
ErrorCode.INVALID_INPUT,
|
||||
"Request body must be valid JSON",
|
||||
status_code=400
|
||||
)
|
||||
|
||||
missing_fields = [field for field in required_fields if field not in data]
|
||||
if missing_fields:
|
||||
return None, error_response(
|
||||
ErrorCode.INVALID_INPUT,
|
||||
f"Missing required fields: {', '.join(missing_fields)}",
|
||||
context={'missing_fields': missing_fields},
|
||||
status_code=400
|
||||
)
|
||||
|
||||
return data, None
|
||||
|
||||
|
||||
def validate_request_params(required_params: list) -> Tuple[Optional[Dict], Optional[Any]]:
|
||||
"""
|
||||
Validate request has required query parameters.
|
||||
|
||||
Args:
|
||||
required_params: List of required parameter names
|
||||
|
||||
Returns:
|
||||
Tuple of (params_dict, error_response) or (params_dict, None) if valid
|
||||
"""
|
||||
missing_params = [param for param in required_params if param not in request.args]
|
||||
if missing_params:
|
||||
return None, error_response(
|
||||
ErrorCode.INVALID_INPUT,
|
||||
f"Missing required parameters: {', '.join(missing_params)}",
|
||||
context={'missing_params': missing_params},
|
||||
status_code=400
|
||||
)
|
||||
|
||||
params = {param: request.args.get(param) for param in required_params}
|
||||
return params, None
|
||||
|
||||
148
src/web_interface/error_handler.py
Normal file
148
src/web_interface/error_handler.py
Normal file
@@ -0,0 +1,148 @@
|
||||
"""
|
||||
Centralized error handling for web interface.
|
||||
|
||||
Provides decorators and helpers for consistent error handling across API endpoints.
|
||||
"""
|
||||
|
||||
import functools
|
||||
import traceback
|
||||
from typing import Callable, Any, Optional
|
||||
from flask import jsonify
|
||||
|
||||
from src.web_interface.errors import (
|
||||
WebInterfaceError, ErrorCode, ErrorCategory
|
||||
)
|
||||
from src.logging_config import get_logger
|
||||
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def handle_errors(
|
||||
default_error_code: Optional[ErrorCode] = None,
|
||||
default_category: Optional[ErrorCategory] = None,
|
||||
log_error: bool = True
|
||||
):
|
||||
"""
|
||||
Decorator to handle errors in API endpoints.
|
||||
|
||||
Catches exceptions and converts them to structured error responses.
|
||||
|
||||
Args:
|
||||
default_error_code: Default error code if exception doesn't match known types
|
||||
default_category: Default error category
|
||||
log_error: Whether to log the error
|
||||
"""
|
||||
def decorator(func: Callable) -> Callable:
|
||||
@functools.wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
try:
|
||||
return func(*args, **kwargs)
|
||||
except WebInterfaceError as e:
|
||||
# Already a structured error
|
||||
if log_error:
|
||||
logger.error(
|
||||
f"Error in {func.__name__}: {e.message}",
|
||||
extra={
|
||||
'error_code': e.error_code.value,
|
||||
'category': e.category.value,
|
||||
'context': e.context
|
||||
}
|
||||
)
|
||||
return jsonify(e.to_dict()), 500
|
||||
|
||||
except Exception as e:
|
||||
# Convert to structured error
|
||||
web_error = WebInterfaceError.from_exception(
|
||||
e,
|
||||
error_code=default_error_code,
|
||||
context={
|
||||
'function': func.__name__,
|
||||
'endpoint': getattr(func, '__name__', 'unknown')
|
||||
}
|
||||
)
|
||||
|
||||
if default_category:
|
||||
web_error.category = default_category
|
||||
|
||||
if log_error:
|
||||
logger.error(
|
||||
f"Unhandled error in {func.__name__}: {e}",
|
||||
exc_info=True,
|
||||
extra={
|
||||
'error_code': web_error.error_code.value,
|
||||
'category': web_error.category.value,
|
||||
'context': web_error.context
|
||||
}
|
||||
)
|
||||
|
||||
return jsonify(web_error.to_dict()), 500
|
||||
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
|
||||
def create_error_response(
|
||||
error_code: ErrorCode,
|
||||
message: str,
|
||||
details: Optional[str] = None,
|
||||
context: Optional[dict] = None,
|
||||
suggested_fixes: Optional[list] = None,
|
||||
status_code: int = 500
|
||||
) -> tuple:
|
||||
"""
|
||||
Create a standardized error response.
|
||||
|
||||
Args:
|
||||
error_code: Error code
|
||||
message: Error message
|
||||
details: Optional detailed error information
|
||||
context: Optional context dictionary
|
||||
suggested_fixes: Optional list of suggested fixes
|
||||
status_code: HTTP status code
|
||||
|
||||
Returns:
|
||||
Tuple of (jsonify response, status_code)
|
||||
"""
|
||||
error = WebInterfaceError(
|
||||
error_code=error_code,
|
||||
message=message,
|
||||
details=details,
|
||||
context=context or {},
|
||||
suggested_fixes=suggested_fixes
|
||||
)
|
||||
|
||||
return jsonify(error.to_dict()), status_code
|
||||
|
||||
|
||||
def create_success_response(
|
||||
data: Any = None,
|
||||
message: Optional[str] = None,
|
||||
metadata: Optional[dict] = None
|
||||
) -> dict:
|
||||
"""
|
||||
Create a standardized success response.
|
||||
|
||||
Args:
|
||||
data: Response data
|
||||
message: Optional success message
|
||||
metadata: Optional metadata (timing, version, etc.)
|
||||
|
||||
Returns:
|
||||
Dictionary for jsonify
|
||||
"""
|
||||
response = {
|
||||
"status": "success"
|
||||
}
|
||||
|
||||
if data is not None:
|
||||
response["data"] = data
|
||||
|
||||
if message:
|
||||
response["message"] = message
|
||||
|
||||
if metadata:
|
||||
response["metadata"] = metadata
|
||||
|
||||
return response
|
||||
|
||||
256
src/web_interface/errors.py
Normal file
256
src/web_interface/errors.py
Normal file
@@ -0,0 +1,256 @@
|
||||
"""
|
||||
Structured error handling for web interface.
|
||||
|
||||
Provides error codes, categories, and consistent error response formatting.
|
||||
"""
|
||||
|
||||
from enum import Enum
|
||||
from typing import Dict, Any, Optional, List
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
class ErrorCategory(Enum):
|
||||
"""Error categories for classification."""
|
||||
CONFIGURATION = "configuration"
|
||||
PLUGIN = "plugin"
|
||||
VALIDATION = "validation"
|
||||
NETWORK = "network"
|
||||
PERMISSION = "permission"
|
||||
SYSTEM = "system"
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
|
||||
class ErrorCode(Enum):
|
||||
"""Error codes for specific error types."""
|
||||
# Configuration errors
|
||||
CONFIG_SAVE_FAILED = "CONFIG_SAVE_FAILED"
|
||||
CONFIG_LOAD_FAILED = "CONFIG_LOAD_FAILED"
|
||||
CONFIG_VALIDATION_FAILED = "CONFIG_VALIDATION_FAILED"
|
||||
CONFIG_ROLLBACK_FAILED = "CONFIG_ROLLBACK_FAILED"
|
||||
|
||||
# Plugin errors
|
||||
PLUGIN_NOT_FOUND = "PLUGIN_NOT_FOUND"
|
||||
PLUGIN_INSTALL_FAILED = "PLUGIN_INSTALL_FAILED"
|
||||
PLUGIN_UPDATE_FAILED = "PLUGIN_UPDATE_FAILED"
|
||||
PLUGIN_UNINSTALL_FAILED = "PLUGIN_UNINSTALL_FAILED"
|
||||
PLUGIN_LOAD_FAILED = "PLUGIN_LOAD_FAILED"
|
||||
PLUGIN_OPERATION_CONFLICT = "PLUGIN_OPERATION_CONFLICT"
|
||||
|
||||
# Validation errors
|
||||
VALIDATION_ERROR = "VALIDATION_ERROR"
|
||||
SCHEMA_VALIDATION_FAILED = "SCHEMA_VALIDATION_FAILED"
|
||||
INVALID_INPUT = "INVALID_INPUT"
|
||||
|
||||
# Network errors
|
||||
NETWORK_ERROR = "NETWORK_ERROR"
|
||||
API_ERROR = "API_ERROR"
|
||||
TIMEOUT = "TIMEOUT"
|
||||
|
||||
# Permission errors
|
||||
PERMISSION_DENIED = "PERMISSION_DENIED"
|
||||
FILE_PERMISSION_ERROR = "FILE_PERMISSION_ERROR"
|
||||
|
||||
# System errors
|
||||
SYSTEM_ERROR = "SYSTEM_ERROR"
|
||||
SERVICE_UNAVAILABLE = "SERVICE_UNAVAILABLE"
|
||||
|
||||
# Unknown errors
|
||||
UNKNOWN_ERROR = "UNKNOWN_ERROR"
|
||||
|
||||
|
||||
@dataclass
|
||||
class WebInterfaceError:
|
||||
"""
|
||||
Structured error for web interface responses.
|
||||
|
||||
Provides consistent error format with error codes, categories,
|
||||
messages, and context.
|
||||
"""
|
||||
error_code: ErrorCode
|
||||
message: str
|
||||
category: ErrorCategory
|
||||
details: Optional[str] = None
|
||||
context: Optional[Dict[str, Any]] = None
|
||||
suggested_fixes: Optional[List[str]] = None
|
||||
original_error: Optional[Exception] = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
error_code: ErrorCode,
|
||||
message: str,
|
||||
category: Optional[ErrorCategory] = None,
|
||||
details: Optional[str] = None,
|
||||
context: Optional[Dict[str, Any]] = None,
|
||||
suggested_fixes: Optional[List[str]] = None,
|
||||
original_error: Optional[Exception] = None
|
||||
):
|
||||
self.error_code = error_code
|
||||
self.message = message
|
||||
self.category = category or self._infer_category(error_code)
|
||||
self.details = details
|
||||
self.context = context or {}
|
||||
self.suggested_fixes = suggested_fixes or self._get_default_suggestions(error_code)
|
||||
self.original_error = original_error
|
||||
|
||||
def _infer_category(self, error_code: ErrorCode) -> ErrorCategory:
|
||||
"""Infer error category from error code."""
|
||||
code_str = error_code.value
|
||||
|
||||
if code_str.startswith("CONFIG_"):
|
||||
return ErrorCategory.CONFIGURATION
|
||||
elif code_str.startswith("PLUGIN_"):
|
||||
return ErrorCategory.PLUGIN
|
||||
elif code_str.startswith("VALIDATION_") or code_str.startswith("SCHEMA_") or code_str == "INVALID_INPUT":
|
||||
return ErrorCategory.VALIDATION
|
||||
elif code_str.startswith("NETWORK_") or code_str == "API_ERROR" or code_str == "TIMEOUT":
|
||||
return ErrorCategory.NETWORK
|
||||
elif code_str.startswith("PERMISSION_") or code_str == "FILE_PERMISSION_ERROR":
|
||||
return ErrorCategory.PERMISSION
|
||||
elif code_str.startswith("SYSTEM_") or code_str == "SERVICE_UNAVAILABLE":
|
||||
return ErrorCategory.SYSTEM
|
||||
else:
|
||||
return ErrorCategory.UNKNOWN
|
||||
|
||||
def _get_default_suggestions(self, error_code: ErrorCode) -> List[str]:
|
||||
"""Get default suggested fixes for error code."""
|
||||
suggestions_map = {
|
||||
ErrorCode.CONFIG_SAVE_FAILED: [
|
||||
"Check file permissions on config directory",
|
||||
"Check available disk space",
|
||||
"Verify config file is not locked by another process"
|
||||
],
|
||||
ErrorCode.CONFIG_LOAD_FAILED: [
|
||||
"Check config file exists and is readable",
|
||||
"Verify config file is valid JSON",
|
||||
"Check file permissions"
|
||||
],
|
||||
ErrorCode.CONFIG_VALIDATION_FAILED: [
|
||||
"Review validation errors above",
|
||||
"Check config against schema",
|
||||
"Verify all required fields are present"
|
||||
],
|
||||
ErrorCode.PLUGIN_NOT_FOUND: [
|
||||
"Verify plugin is installed",
|
||||
"Check plugin ID is correct",
|
||||
"Refresh plugin list"
|
||||
],
|
||||
ErrorCode.PLUGIN_INSTALL_FAILED: [
|
||||
"Check internet connection",
|
||||
"Verify plugin repository URL is correct",
|
||||
"Check available disk space",
|
||||
"Review plugin installation logs"
|
||||
],
|
||||
ErrorCode.PLUGIN_OPERATION_CONFLICT: [
|
||||
"Wait for current operation to complete",
|
||||
"Cancel conflicting operation if needed",
|
||||
"Check operation status"
|
||||
],
|
||||
ErrorCode.VALIDATION_ERROR: [
|
||||
"Review validation errors",
|
||||
"Check input format and types",
|
||||
"Verify required fields are provided"
|
||||
],
|
||||
ErrorCode.PERMISSION_DENIED: [
|
||||
"Check file/directory permissions",
|
||||
"Verify user has required access",
|
||||
"Check if running with correct user"
|
||||
],
|
||||
ErrorCode.NETWORK_ERROR: [
|
||||
"Check internet connection",
|
||||
"Verify API endpoint is accessible",
|
||||
"Check firewall settings"
|
||||
],
|
||||
ErrorCode.TIMEOUT: [
|
||||
"Retry the operation",
|
||||
"Check network connection",
|
||||
"Verify service is responding"
|
||||
],
|
||||
}
|
||||
|
||||
return suggestions_map.get(error_code, ["Review error details and try again"])
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert error to dictionary for JSON response."""
|
||||
result = {
|
||||
"status": "error",
|
||||
"error_code": self.error_code.value,
|
||||
"error_category": self.category.value,
|
||||
"message": self.message,
|
||||
}
|
||||
|
||||
if self.details:
|
||||
result["details"] = self.details
|
||||
|
||||
if self.context:
|
||||
result["context"] = self.context
|
||||
|
||||
if self.suggested_fixes:
|
||||
result["suggested_fixes"] = self.suggested_fixes
|
||||
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def from_exception(
|
||||
cls,
|
||||
exception: Exception,
|
||||
error_code: Optional[ErrorCode] = None,
|
||||
context: Optional[Dict[str, Any]] = None
|
||||
) -> 'WebInterfaceError':
|
||||
"""
|
||||
Create WebInterfaceError from an exception.
|
||||
|
||||
Args:
|
||||
exception: Exception to convert
|
||||
error_code: Optional specific error code
|
||||
context: Optional additional context
|
||||
"""
|
||||
# Infer error code from exception type if not provided
|
||||
if not error_code:
|
||||
error_code = cls._infer_error_code(exception)
|
||||
|
||||
# Build context
|
||||
error_context = context or {}
|
||||
error_context['exception_type'] = type(exception).__name__
|
||||
|
||||
return cls(
|
||||
error_code=error_code,
|
||||
message=str(exception),
|
||||
details=cls._get_exception_details(exception),
|
||||
context=error_context,
|
||||
original_error=exception
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _infer_error_code(cls, exception: Exception) -> ErrorCode:
|
||||
"""Infer error code from exception type."""
|
||||
exception_name = type(exception).__name__
|
||||
|
||||
if "Config" in exception_name:
|
||||
return ErrorCode.CONFIG_SAVE_FAILED
|
||||
elif "Plugin" in exception_name:
|
||||
return ErrorCode.PLUGIN_LOAD_FAILED
|
||||
elif "Permission" in exception_name or "Access" in exception_name:
|
||||
return ErrorCode.PERMISSION_DENIED
|
||||
elif "Validation" in exception_name or "Schema" in exception_name:
|
||||
return ErrorCode.VALIDATION_ERROR
|
||||
elif "Network" in exception_name or "Connection" in exception_name:
|
||||
return ErrorCode.NETWORK_ERROR
|
||||
elif "Timeout" in exception_name:
|
||||
return ErrorCode.TIMEOUT
|
||||
else:
|
||||
return ErrorCode.UNKNOWN_ERROR
|
||||
|
||||
@classmethod
|
||||
def _get_exception_details(cls, exception: Exception) -> Optional[str]:
|
||||
"""Get additional details from exception."""
|
||||
if hasattr(exception, 'context') and isinstance(exception.context, dict):
|
||||
# Extract relevant details from exception context
|
||||
details_parts = []
|
||||
for key, value in exception.context.items():
|
||||
if key not in ['exception_type']:
|
||||
details_parts.append(f"{key}: {value}")
|
||||
if details_parts:
|
||||
return "; ".join(details_parts)
|
||||
|
||||
return None
|
||||
|
||||
160
src/web_interface/logging_config.py
Normal file
160
src/web_interface/logging_config.py
Normal file
@@ -0,0 +1,160 @@
|
||||
"""
|
||||
Structured logging configuration for web interface.
|
||||
|
||||
Provides JSON-formatted structured logging for better debugging and monitoring.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any, Optional
|
||||
|
||||
|
||||
class StructuredFormatter(logging.Formatter):
|
||||
"""
|
||||
JSON formatter for structured logging.
|
||||
|
||||
Formats log records as JSON for easy parsing and analysis.
|
||||
"""
|
||||
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
"""Format log record as JSON."""
|
||||
log_data = {
|
||||
'timestamp': datetime.utcnow().isoformat(),
|
||||
'level': record.levelname,
|
||||
'logger': record.name,
|
||||
'message': record.getMessage(),
|
||||
'module': record.module,
|
||||
'function': record.funcName,
|
||||
'line': record.lineno
|
||||
}
|
||||
|
||||
# Add exception info if present
|
||||
if record.exc_info:
|
||||
log_data['exception'] = self.formatException(record.exc_info)
|
||||
|
||||
# Add extra fields from record
|
||||
if hasattr(record, 'extra'):
|
||||
log_data.update(record.extra)
|
||||
|
||||
# Add context from record
|
||||
if hasattr(record, 'context'):
|
||||
log_data['context'] = record.context
|
||||
|
||||
return json.dumps(log_data)
|
||||
|
||||
def formatException(self, exc_info) -> Dict[str, Any]:
|
||||
"""Format exception as structured data."""
|
||||
import traceback
|
||||
return {
|
||||
'type': exc_info[0].__name__ if exc_info[0] else None,
|
||||
'message': str(exc_info[1]) if exc_info[1] else None,
|
||||
'traceback': traceback.format_exception(*exc_info)
|
||||
}
|
||||
|
||||
|
||||
def setup_structured_logging(
|
||||
level: int = logging.INFO,
|
||||
use_json: bool = False,
|
||||
output_stream = sys.stdout
|
||||
) -> None:
|
||||
"""
|
||||
Set up structured logging for web interface.
|
||||
|
||||
Args:
|
||||
level: Logging level
|
||||
use_json: Whether to use JSON formatting
|
||||
output_stream: Output stream for logs
|
||||
"""
|
||||
root_logger = logging.getLogger()
|
||||
root_logger.setLevel(level)
|
||||
|
||||
# Remove existing handlers
|
||||
for handler in root_logger.handlers[:]:
|
||||
root_logger.removeHandler(handler)
|
||||
|
||||
# Create handler
|
||||
handler = logging.StreamHandler(output_stream)
|
||||
handler.setLevel(level)
|
||||
|
||||
# Set formatter
|
||||
if use_json:
|
||||
formatter = StructuredFormatter()
|
||||
else:
|
||||
formatter = logging.Formatter(
|
||||
'%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
|
||||
handler.setFormatter(formatter)
|
||||
root_logger.addHandler(handler)
|
||||
|
||||
|
||||
def log_plugin_operation(
|
||||
logger: logging.Logger,
|
||||
operation: str,
|
||||
plugin_id: str,
|
||||
status: str,
|
||||
context: Optional[Dict[str, Any]] = None
|
||||
) -> None:
|
||||
"""
|
||||
Log a plugin operation with structured data.
|
||||
|
||||
Args:
|
||||
logger: Logger instance
|
||||
operation: Operation name (install, update, uninstall, etc.)
|
||||
plugin_id: Plugin identifier
|
||||
status: Operation status (success, failed, etc.)
|
||||
context: Optional additional context
|
||||
"""
|
||||
extra = {
|
||||
'operation': operation,
|
||||
'plugin_id': plugin_id,
|
||||
'status': status
|
||||
}
|
||||
|
||||
if context:
|
||||
extra['context'] = context
|
||||
|
||||
logger.info(
|
||||
f"Plugin operation: {operation} for {plugin_id} - {status}",
|
||||
extra=extra
|
||||
)
|
||||
|
||||
|
||||
def log_config_change(
|
||||
logger: logging.Logger,
|
||||
config_key: str,
|
||||
action: str,
|
||||
before: Optional[Dict[str, Any]] = None,
|
||||
after: Optional[Dict[str, Any]] = None,
|
||||
context: Optional[Dict[str, Any]] = None
|
||||
) -> None:
|
||||
"""
|
||||
Log a configuration change with before/after values.
|
||||
|
||||
Args:
|
||||
logger: Logger instance
|
||||
config_key: Configuration key that changed
|
||||
action: Action performed (save, update, delete, etc.)
|
||||
before: Configuration before change
|
||||
after: Configuration after change
|
||||
context: Optional additional context
|
||||
"""
|
||||
extra = {
|
||||
'config_key': config_key,
|
||||
'action': action
|
||||
}
|
||||
|
||||
if before:
|
||||
extra['before'] = before
|
||||
if after:
|
||||
extra['after'] = after
|
||||
if context:
|
||||
extra['context'] = context
|
||||
|
||||
logger.info(
|
||||
f"Config change: {action} on {config_key}",
|
||||
extra=extra
|
||||
)
|
||||
|
||||
217
src/web_interface/validators.py
Normal file
217
src/web_interface/validators.py
Normal file
@@ -0,0 +1,217 @@
|
||||
"""
|
||||
Input validation utilities for the web interface.
|
||||
Provides validation functions for user inputs to prevent XSS, invalid data, and security issues.
|
||||
"""
|
||||
import re
|
||||
import os
|
||||
from typing import Optional, Tuple, List
|
||||
from urllib.parse import urlparse
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def escape_html(text: str) -> str:
|
||||
"""Escape HTML entities in text to prevent XSS."""
|
||||
if not isinstance(text, str):
|
||||
text = str(text)
|
||||
# Use basic HTML entity escaping
|
||||
text = text.replace('&', '&')
|
||||
text = text.replace('<', '<')
|
||||
text = text.replace('>', '>')
|
||||
text = text.replace('"', '"')
|
||||
text = text.replace("'", ''')
|
||||
return text
|
||||
|
||||
|
||||
def validate_image_url(url: str) -> Tuple[bool, Optional[str]]:
|
||||
"""
|
||||
Validate and sanitize image URLs to prevent XSS and protocol injection.
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, error_message)
|
||||
"""
|
||||
if not url or not isinstance(url, str):
|
||||
return False, "URL must be a non-empty string"
|
||||
|
||||
url_lower = url.lower().strip()
|
||||
|
||||
# Reject dangerous protocols
|
||||
dangerous_protocols = ['javascript:', 'data:', 'vbscript:', 'file:']
|
||||
for protocol in dangerous_protocols:
|
||||
if url_lower.startswith(protocol):
|
||||
return False, f"Dangerous protocol '{protocol}' not allowed"
|
||||
|
||||
# Reject event handlers
|
||||
if any(handler in url_lower for handler in ['onerror=', 'onload=', 'onclick=']):
|
||||
return False, "Event handlers not allowed in URLs"
|
||||
|
||||
# Allow relative paths starting with /
|
||||
if url.startswith('/'):
|
||||
# Validate it's a safe relative path (no directory traversal)
|
||||
if '..' in url or url.startswith('//'):
|
||||
return False, "Invalid relative path"
|
||||
return True, None
|
||||
|
||||
# Validate absolute URLs
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
allowed_protocols = ['http', 'https']
|
||||
if parsed.scheme not in allowed_protocols:
|
||||
return False, f"Only http:// and https:// protocols are allowed"
|
||||
return True, None
|
||||
except Exception as e:
|
||||
return False, f"Invalid URL format: {str(e)}"
|
||||
|
||||
|
||||
def validate_font_awesome_class(class_name: str) -> Tuple[bool, Optional[str]]:
|
||||
"""
|
||||
Validate Font Awesome class names to prevent XSS.
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, error_message)
|
||||
"""
|
||||
if not isinstance(class_name, str):
|
||||
return False, "Class name must be a string"
|
||||
|
||||
# Whitelist pattern: only allow alphanumeric, dash, underscore, and spaces
|
||||
# Must contain 'fa-' for Font Awesome
|
||||
fa_pattern = re.compile(r'^[a-zA-Z0-9\s_-]*fa-[a-zA-Z0-9-]+[a-zA-Z0-9\s_-]*$')
|
||||
|
||||
if not fa_pattern.match(class_name):
|
||||
return False, "Invalid Font Awesome class name format"
|
||||
|
||||
if 'fa-' not in class_name:
|
||||
return False, "Font Awesome class must contain 'fa-'"
|
||||
|
||||
return True, None
|
||||
|
||||
|
||||
def validate_file_upload(filename: str, max_size_mb: int = 10,
|
||||
allowed_extensions: Optional[List[str]] = None) -> Tuple[bool, Optional[str]]:
|
||||
"""
|
||||
Validate file upload parameters.
|
||||
|
||||
Args:
|
||||
filename: Name of the file
|
||||
max_size_mb: Maximum file size in MB
|
||||
allowed_extensions: List of allowed file extensions (e.g., ['.ttf', '.otf'])
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, error_message)
|
||||
"""
|
||||
if not filename or not isinstance(filename, str):
|
||||
return False, "Filename must be a non-empty string"
|
||||
|
||||
# Check for directory traversal
|
||||
if '..' in filename or '/' in filename or '\\' in filename:
|
||||
return False, "Filename contains invalid characters"
|
||||
|
||||
# Check extension if specified
|
||||
if allowed_extensions:
|
||||
file_ext = Path(filename).suffix.lower()
|
||||
if file_ext not in allowed_extensions:
|
||||
return False, f"File extension must be one of: {', '.join(allowed_extensions)}"
|
||||
|
||||
return True, None
|
||||
|
||||
|
||||
def validate_mime_type(file_path: str, allowed_types: List[str]) -> Tuple[bool, Optional[str]]:
|
||||
"""
|
||||
Validate file MIME type.
|
||||
|
||||
Args:
|
||||
file_path: Path to the file
|
||||
allowed_types: List of allowed MIME types (e.g., ['image/png', 'image/jpeg'])
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, error_message)
|
||||
"""
|
||||
try:
|
||||
import mimetypes
|
||||
mime_type, _ = mimetypes.guess_type(file_path)
|
||||
|
||||
if not mime_type:
|
||||
return False, "Could not determine file type"
|
||||
|
||||
if mime_type not in allowed_types:
|
||||
return False, f"File type '{mime_type}' not allowed. Allowed types: {', '.join(allowed_types)}"
|
||||
|
||||
return True, None
|
||||
except Exception as e:
|
||||
return False, f"Error validating MIME type: {str(e)}"
|
||||
|
||||
|
||||
def validate_numeric_range(value: float, min_val: Optional[float] = None,
|
||||
max_val: Optional[float] = None) -> Tuple[bool, Optional[str]]:
|
||||
"""
|
||||
Validate numeric value is within range.
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, error_message)
|
||||
"""
|
||||
if not isinstance(value, (int, float)):
|
||||
return False, "Value must be a number"
|
||||
|
||||
if min_val is not None and value < min_val:
|
||||
return False, f"Value must be at least {min_val}"
|
||||
|
||||
if max_val is not None and value > max_val:
|
||||
return False, f"Value must be at most {max_val}"
|
||||
|
||||
return True, None
|
||||
|
||||
|
||||
def validate_string_length(text: str, min_length: Optional[int] = None,
|
||||
max_length: Optional[int] = None) -> Tuple[bool, Optional[str]]:
|
||||
"""
|
||||
Validate string length.
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, error_message)
|
||||
"""
|
||||
if not isinstance(text, str):
|
||||
return False, "Value must be a string"
|
||||
|
||||
length = len(text)
|
||||
|
||||
if min_length is not None and length < min_length:
|
||||
return False, f"String must be at least {min_length} characters"
|
||||
|
||||
if max_length is not None and length > max_length:
|
||||
return False, f"String must be at most {max_length} characters"
|
||||
|
||||
return True, None
|
||||
|
||||
|
||||
def sanitize_plugin_config(config: dict) -> dict:
|
||||
"""
|
||||
Sanitize plugin configuration input to prevent injection.
|
||||
|
||||
Args:
|
||||
config: Configuration dictionary
|
||||
|
||||
Returns:
|
||||
Sanitized configuration dictionary
|
||||
"""
|
||||
sanitized = {}
|
||||
|
||||
for key, value in config.items():
|
||||
# Sanitize keys (no special characters)
|
||||
if not isinstance(key, str) or not re.match(r'^[a-zA-Z0-9_]+$', key):
|
||||
continue # Skip invalid keys
|
||||
|
||||
# Sanitize values based on type
|
||||
if isinstance(value, str):
|
||||
# For string values, escape HTML but preserve the string
|
||||
sanitized[key] = value # Don't escape - let templates handle it
|
||||
elif isinstance(value, (int, float, bool)):
|
||||
sanitized[key] = value
|
||||
elif isinstance(value, list):
|
||||
sanitized[key] = [sanitize_plugin_config(item) if isinstance(item, dict) else item for item in value]
|
||||
elif isinstance(value, dict):
|
||||
sanitized[key] = sanitize_plugin_config(value)
|
||||
else:
|
||||
# Skip unknown types
|
||||
continue
|
||||
|
||||
return sanitized
|
||||
|
||||
2118
src/wifi_manager.py
Normal file
2118
src/wifi_manager.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,267 +0,0 @@
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import pytz
|
||||
import requests
|
||||
|
||||
from src.base_classes.basketball import Basketball, BasketballLive
|
||||
from src.base_classes.sports import SportsRecent, SportsUpcoming
|
||||
from src.cache_manager import CacheManager
|
||||
from src.display_manager import DisplayManager
|
||||
|
||||
# Import the API counter function from web interface
|
||||
try:
|
||||
from web_interface_v2 import increment_api_counter
|
||||
except ImportError:
|
||||
# Fallback if web interface is not available
|
||||
def increment_api_counter(kind: str, count: int = 1):
|
||||
pass
|
||||
|
||||
|
||||
# Constants
|
||||
ESPN_WNBA_SCOREBOARD_URL = (
|
||||
"https://site.api.espn.com/apis/site/v2/sports/basketball/wnba/scoreboard"
|
||||
)
|
||||
|
||||
|
||||
class BaseWNBAManager(Basketball):
|
||||
"""Base class for WNBA managers with common functionality."""
|
||||
|
||||
# Class variables for warning tracking
|
||||
_no_data_warning_logged = False
|
||||
_last_warning_time = 0
|
||||
_warning_cooldown = 60 # Only log warnings once per minute
|
||||
_last_log_times = {}
|
||||
_shared_data = None
|
||||
_last_shared_update = 0
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: Dict[str, Any],
|
||||
display_manager: DisplayManager,
|
||||
cache_manager: CacheManager,
|
||||
):
|
||||
self.logger = logging.getLogger("WNBA") # Changed logger name
|
||||
super().__init__(
|
||||
config=config,
|
||||
display_manager=display_manager,
|
||||
cache_manager=cache_manager,
|
||||
logger=self.logger,
|
||||
sport_key="wnba",
|
||||
)
|
||||
|
||||
# Check display modes to determine what data to fetch
|
||||
display_modes = self.mode_config.get("display_modes", {})
|
||||
self.recent_enabled = display_modes.get("wnba_recent", False)
|
||||
self.upcoming_enabled = display_modes.get("wnba_upcoming", False)
|
||||
self.live_enabled = display_modes.get("wnba_live", False)
|
||||
|
||||
self.logger.info(
|
||||
f"Initialized WNBA manager with display dimensions: {self.display_width}x{self.display_height}"
|
||||
)
|
||||
self.logger.info(f"Logo directory: {self.logo_dir}")
|
||||
self.logger.info(
|
||||
f"Display modes - Recent: {self.recent_enabled}, Upcoming: {self.upcoming_enabled}, Live: {self.live_enabled}"
|
||||
)
|
||||
self.league = "wnba"
|
||||
|
||||
def _fetch_wnba_api_data(self, use_cache: bool = True) -> Optional[Dict]:
|
||||
"""
|
||||
Fetches the full season schedule for WNBA using background threading.
|
||||
Returns cached data immediately if available, otherwise starts background fetch.
|
||||
"""
|
||||
now = datetime.now(pytz.utc)
|
||||
season_year = now.year
|
||||
if now.month < 2:
|
||||
season_year = now.year - 1
|
||||
datestring = f"{season_year}0401-{season_year+1}1101"
|
||||
cache_key = f"{self.sport_key}_schedule_{season_year}"
|
||||
|
||||
# Check cache first
|
||||
if use_cache:
|
||||
cached_data = self.cache_manager.get(cache_key)
|
||||
if cached_data:
|
||||
# Validate cached data structure
|
||||
if isinstance(cached_data, dict) and "events" in cached_data:
|
||||
self.logger.info(f"Using cached schedule for {season_year}")
|
||||
return cached_data
|
||||
elif isinstance(cached_data, list):
|
||||
# Handle old cache format (list of events)
|
||||
self.logger.info(
|
||||
f"Using cached schedule for {season_year} (legacy format)"
|
||||
)
|
||||
return {"events": cached_data}
|
||||
else:
|
||||
self.logger.warning(
|
||||
f"Invalid cached data format for {season_year}: {type(cached_data)}"
|
||||
)
|
||||
# Clear invalid cache
|
||||
self.cache_manager.clear_cache(cache_key)
|
||||
|
||||
# Start background fetch
|
||||
self.logger.info(
|
||||
f"Starting background fetch for {season_year} season schedule..."
|
||||
)
|
||||
|
||||
def fetch_callback(result):
|
||||
"""Callback when background fetch completes."""
|
||||
if result.success:
|
||||
self.logger.info(
|
||||
f"Background fetch completed for {season_year}: {len(result.data.get('events'))} events"
|
||||
)
|
||||
else:
|
||||
self.logger.error(
|
||||
f"Background fetch failed for {season_year}: {result.error}"
|
||||
)
|
||||
|
||||
# Clean up request tracking
|
||||
if season_year in self.background_fetch_requests:
|
||||
del self.background_fetch_requests[season_year]
|
||||
|
||||
# Get background service configuration
|
||||
background_config = self.mode_config.get("background_service", {})
|
||||
timeout = background_config.get("request_timeout", 30)
|
||||
max_retries = background_config.get("max_retries", 3)
|
||||
priority = background_config.get("priority", 2)
|
||||
|
||||
# Submit background fetch request
|
||||
request_id = self.background_service.submit_fetch_request(
|
||||
sport="nba",
|
||||
year=season_year,
|
||||
url=ESPN_WNBA_SCOREBOARD_URL,
|
||||
cache_key=cache_key,
|
||||
params={"dates": datestring, "limit": 1000},
|
||||
headers=self.headers,
|
||||
timeout=timeout,
|
||||
max_retries=max_retries,
|
||||
priority=priority,
|
||||
callback=fetch_callback,
|
||||
)
|
||||
|
||||
# Track the request
|
||||
self.background_fetch_requests[season_year] = request_id
|
||||
|
||||
# For immediate response, try to get partial data
|
||||
partial_data = self._get_weeks_data()
|
||||
if partial_data:
|
||||
return partial_data
|
||||
|
||||
return None
|
||||
|
||||
def _fetch_data(self) -> Optional[Dict]:
|
||||
"""Fetch data using shared data mechanism or direct fetch for live."""
|
||||
if isinstance(self, WNBALiveManager):
|
||||
# Live games should fetch only current games, not entire season
|
||||
return self._fetch_todays_games()
|
||||
else:
|
||||
# Recent and Upcoming managers should use cached season data
|
||||
return self._fetch_wnba_api_data(use_cache=True)
|
||||
|
||||
|
||||
class WNBALiveManager(BaseWNBAManager, BasketballLive):
|
||||
"""Manager for live NBA games."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: Dict[str, Any],
|
||||
display_manager: DisplayManager,
|
||||
cache_manager: CacheManager,
|
||||
):
|
||||
super().__init__(config, display_manager, cache_manager)
|
||||
self.logger = logging.getLogger("WNBALiveManager") # Changed logger name
|
||||
|
||||
if self.test_mode:
|
||||
# More detailed test game for NBA
|
||||
self.current_game = {
|
||||
"id": "test001",
|
||||
"home_abbr": "CHI",
|
||||
"home_id": "123",
|
||||
"away_abbr": "ATL",
|
||||
"away_id": "asdf",
|
||||
"home_score": "21",
|
||||
"away_score": "17",
|
||||
"period": 3,
|
||||
"period_text": "Q3",
|
||||
"clock": "5:24",
|
||||
"home_logo_path": Path(self.logo_dir, "CHI.png"),
|
||||
"away_logo_path": Path(self.logo_dir, "ATL.png"),
|
||||
"is_live": True,
|
||||
"is_final": False,
|
||||
"is_upcoming": False,
|
||||
"is_halftime": False,
|
||||
}
|
||||
self.live_games = [self.current_game]
|
||||
self.logger.info("Initialized WNBALiveManager with test game: BUF vs KC")
|
||||
else:
|
||||
self.logger.info(" Initialized WNBALiveManager in live mode")
|
||||
|
||||
|
||||
class WNBARecentManager(BaseWNBAManager, SportsRecent):
|
||||
"""Manager for recently completed WNBA games."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: Dict[str, Any],
|
||||
display_manager: DisplayManager,
|
||||
cache_manager: CacheManager,
|
||||
):
|
||||
super().__init__(config, display_manager, cache_manager)
|
||||
self.logger = logging.getLogger("WNBARecentManager") # Changed logger name
|
||||
self.logger.info(
|
||||
f"Initialized WNBARecentManager with {len(self.favorite_teams)} favorite teams"
|
||||
)
|
||||
|
||||
|
||||
class WNBAUpcomingManager(BaseWNBAManager, SportsUpcoming):
|
||||
"""Manager for upcoming WNBA games."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: Dict[str, Any],
|
||||
display_manager: DisplayManager,
|
||||
cache_manager: CacheManager,
|
||||
):
|
||||
super().__init__(config, display_manager, cache_manager)
|
||||
self.logger = logging.getLogger("WNBAUpcomingManager") # Changed logger name
|
||||
self.logger.info(
|
||||
f"Initialized WNBAUpcomingManager with {len(self.favorite_teams)} favorite teams"
|
||||
)
|
||||
|
||||
"""Display upcoming games."""
|
||||
if not self.upcoming_games:
|
||||
return
|
||||
|
||||
try:
|
||||
current_time = time.time()
|
||||
|
||||
# Check if it's time to switch games
|
||||
if (
|
||||
len(self.upcoming_games) > 1
|
||||
and current_time - self.last_game_switch >= self.game_display_duration
|
||||
):
|
||||
# Move to next game
|
||||
self.current_game_index = (self.current_game_index + 1) % len(
|
||||
self.upcoming_games
|
||||
)
|
||||
self.current_game = self.upcoming_games[self.current_game_index]
|
||||
self.last_game_switch = current_time
|
||||
force_clear = True
|
||||
|
||||
# Log team switching
|
||||
if self.current_game:
|
||||
away_abbr = self.current_game.get("away_abbr", "UNK")
|
||||
home_abbr = self.current_game.get("home_abbr", "UNK")
|
||||
self.logger.info(
|
||||
f"[NBA Upcoming] Showing {away_abbr} vs {home_abbr}"
|
||||
)
|
||||
|
||||
# Draw the scorebug layout
|
||||
self._draw_scorebug_layout(self.current_game, force_clear)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(
|
||||
f"[NBA] Error displaying upcoming game: {e}", exc_info=True
|
||||
)
|
||||
@@ -1,178 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
import json
|
||||
import time
|
||||
import logging
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
import requests
|
||||
from typing import Dict, Any
|
||||
|
||||
# Import the API counter function from web interface
|
||||
try:
|
||||
from web_interface_v2 import increment_api_counter
|
||||
except ImportError:
|
||||
# Fallback if web interface is not available
|
||||
def increment_api_counter(kind: str, count: int = 1):
|
||||
pass
|
||||
|
||||
# Get logger without configuring
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class YouTubeDisplay:
|
||||
def __init__(self, display_manager, config: Dict[str, Any]):
|
||||
self.display_manager = display_manager
|
||||
self.config = config
|
||||
self.youtube_config = config.get('youtube', {})
|
||||
self.enabled = self.youtube_config.get('enabled', False)
|
||||
self.update_interval = self.youtube_config.get('update_interval', 300)
|
||||
self.last_update = 0
|
||||
self.channel_stats = None
|
||||
|
||||
# Load secrets file
|
||||
try:
|
||||
with open('config/config_secrets.json', 'r') as f:
|
||||
self.secrets = json.load(f)
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading secrets file: {e}")
|
||||
self.secrets = {}
|
||||
self.enabled = False
|
||||
|
||||
if self.enabled:
|
||||
logger.info("YouTube display enabled")
|
||||
self._initialize_display()
|
||||
else:
|
||||
logger.info("YouTube display disabled")
|
||||
|
||||
def _initialize_display(self):
|
||||
"""Initialize display components."""
|
||||
self.font = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 8)
|
||||
try:
|
||||
self.youtube_logo = Image.open("assets/youtube_logo.png")
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading YouTube logo: {e}")
|
||||
self.enabled = False
|
||||
|
||||
def _get_channel_stats(self, channel_id):
|
||||
"""Fetch channel statistics from YouTube API."""
|
||||
api_key = self.secrets.get('youtube', {}).get('api_key')
|
||||
if not api_key:
|
||||
logger.error("YouTube API key not configured in secrets file")
|
||||
return None
|
||||
|
||||
url = f"https://www.googleapis.com/youtube/v3/channels?part=statistics,snippet&id={channel_id}&key={api_key}"
|
||||
|
||||
try:
|
||||
response = requests.get(url)
|
||||
data = response.json()
|
||||
|
||||
# Increment API counter for YouTube data
|
||||
increment_api_counter('youtube', 1)
|
||||
|
||||
if data['items']:
|
||||
channel = data['items'][0]
|
||||
return {
|
||||
'title': channel['snippet']['title'],
|
||||
'subscribers': int(channel['statistics']['subscriberCount']),
|
||||
'views': int(channel['statistics']['viewCount'])
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching YouTube stats: {e}")
|
||||
return None
|
||||
|
||||
def _create_display(self, channel_stats):
|
||||
"""Create the display image with channel statistics."""
|
||||
if not channel_stats:
|
||||
return None
|
||||
|
||||
# Create a new image with the matrix dimensions
|
||||
image = Image.new('RGB', (self.display_manager.matrix.width, self.display_manager.matrix.height))
|
||||
draw = ImageDraw.Draw(image)
|
||||
|
||||
# Calculate logo dimensions - 60% of display height to ensure text fits
|
||||
logo_height = int(self.display_manager.matrix.height * 0.6)
|
||||
logo_width = int(self.youtube_logo.width * (logo_height / self.youtube_logo.height))
|
||||
resized_logo = self.youtube_logo.resize((logo_width, logo_height))
|
||||
|
||||
# Position logo on the left with padding
|
||||
logo_x = 2 # Small padding from left edge
|
||||
logo_y = (self.display_manager.matrix.height - logo_height) // 2 # Center vertically
|
||||
|
||||
# Paste the logo
|
||||
image.paste(resized_logo, (logo_x, logo_y))
|
||||
|
||||
# Calculate right section width and starting position
|
||||
right_section_x = logo_x + logo_width + 4 # Start after logo with some padding
|
||||
|
||||
# Calculate text positions
|
||||
line_height = 10 # Approximate line height for PressStart2P font at size 8
|
||||
total_text_height = line_height * 3 # 3 lines of text
|
||||
start_y = (self.display_manager.matrix.height - total_text_height) // 2
|
||||
|
||||
# Draw channel name (top)
|
||||
channel_name = channel_stats['title']
|
||||
# Truncate channel name if too long
|
||||
max_chars = (self.display_manager.matrix.width - right_section_x - 4) // 8 # 8 pixels per character
|
||||
if len(channel_name) > max_chars:
|
||||
channel_name = channel_name[:max_chars-3] + "..."
|
||||
name_bbox = draw.textbbox((0, 0), channel_name, font=self.font)
|
||||
name_width = name_bbox[2] - name_bbox[0]
|
||||
name_x = right_section_x + ((self.display_manager.matrix.width - right_section_x - name_width) // 2)
|
||||
draw.text((name_x, start_y), channel_name, font=self.font, fill=(255, 255, 255))
|
||||
|
||||
# Draw subscriber count (middle)
|
||||
subs_text = f"{channel_stats['subscribers']:,}subs"
|
||||
subs_bbox = draw.textbbox((0, 0), subs_text, font=self.font)
|
||||
subs_width = subs_bbox[2] - subs_bbox[0]
|
||||
subs_x = right_section_x + ((self.display_manager.matrix.width - right_section_x - subs_width) // 2)
|
||||
draw.text((subs_x, start_y + line_height), subs_text, font=self.font, fill=(255, 255, 255))
|
||||
|
||||
# Draw view count (bottom)
|
||||
views_text = f"{channel_stats['views']:,}views"
|
||||
views_bbox = draw.textbbox((0, 0), views_text, font=self.font)
|
||||
views_width = views_bbox[2] - views_bbox[0]
|
||||
views_x = right_section_x + ((self.display_manager.matrix.width - right_section_x - views_width) // 2)
|
||||
draw.text((views_x, start_y + (line_height * 2)), views_text, font=self.font, fill=(255, 255, 255))
|
||||
|
||||
return image
|
||||
|
||||
def update(self):
|
||||
"""Update YouTube channel stats if needed."""
|
||||
if not self.enabled:
|
||||
return
|
||||
|
||||
current_time = time.time()
|
||||
if current_time - self.last_update >= self.update_interval:
|
||||
channel_id = self.config.get('youtube', {}).get('channel_id')
|
||||
if not channel_id:
|
||||
logger.error("YouTube channel ID not configured")
|
||||
return
|
||||
|
||||
self.channel_stats = self._get_channel_stats(channel_id)
|
||||
self.last_update = current_time
|
||||
|
||||
def display(self, force_clear: bool = False):
|
||||
"""Display YouTube channel stats."""
|
||||
if not self.enabled:
|
||||
return
|
||||
|
||||
if not self.channel_stats:
|
||||
self.update()
|
||||
|
||||
if self.channel_stats:
|
||||
if force_clear:
|
||||
self.display_manager.clear()
|
||||
|
||||
display_image = self._create_display(self.channel_stats)
|
||||
if display_image:
|
||||
self.display_manager.image = display_image
|
||||
self.display_manager.update_display()
|
||||
|
||||
def cleanup(self):
|
||||
"""Clean up resources."""
|
||||
if self.enabled:
|
||||
self.display_manager.clear()
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Example usage
|
||||
youtube_display = YouTubeDisplay()
|
||||
youtube_display.display()
|
||||
youtube_display.cleanup()
|
||||
@@ -1,219 +0,0 @@
|
||||
import socketio
|
||||
import logging
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import threading
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
# Ensure application-level logging is configured (as it is)
|
||||
# logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
||||
|
||||
# Reduce verbosity of socketio and engineio libraries
|
||||
logging.getLogger('socketio.client').setLevel(logging.WARNING)
|
||||
logging.getLogger('socketio.server').setLevel(logging.WARNING)
|
||||
logging.getLogger('engineio.client').setLevel(logging.WARNING)
|
||||
logging.getLogger('engineio.server').setLevel(logging.WARNING)
|
||||
|
||||
# Define paths relative to this file's location
|
||||
CONFIG_DIR = os.path.join(os.path.dirname(__file__), '..', 'config')
|
||||
CONFIG_PATH = os.path.join(CONFIG_DIR, 'config.json')
|
||||
# Resolve to an absolute path
|
||||
CONFIG_PATH = os.path.abspath(CONFIG_PATH)
|
||||
|
||||
# Path for the separate YTM authentication token file
|
||||
YTM_AUTH_CONFIG_PATH = os.path.join(CONFIG_DIR, 'ytm_auth.json')
|
||||
YTM_AUTH_CONFIG_PATH = os.path.abspath(YTM_AUTH_CONFIG_PATH)
|
||||
|
||||
class YTMClient:
|
||||
def __init__(self, update_callback=None):
|
||||
self.base_url = None
|
||||
self.ytm_token = None
|
||||
self.load_config() # Loads URL and token
|
||||
self.sio = socketio.Client(
|
||||
logger=False,
|
||||
engineio_logger=False,
|
||||
reconnection=True,
|
||||
reconnection_attempts=0, # Infinite attempts
|
||||
reconnection_delay=1, # Initial delay in seconds
|
||||
reconnection_delay_max=10 # Maximum delay in seconds
|
||||
)
|
||||
self.last_known_track_data = None
|
||||
self.is_connected = False
|
||||
self._data_lock = threading.Lock()
|
||||
self._connection_event = threading.Event()
|
||||
self.external_update_callback = update_callback
|
||||
# For offloading external_update_callback to prevent blocking socketio thread
|
||||
self._callback_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix='ytm_callback_worker')
|
||||
|
||||
@self.sio.event(namespace='/api/v1/realtime')
|
||||
def connect():
|
||||
logging.info(f"Successfully connected to YTM Companion Socket.IO server at {self.base_url} on namespace /api/v1/realtime")
|
||||
self.is_connected = True
|
||||
self._connection_event.set()
|
||||
|
||||
@self.sio.event(namespace='/api/v1/realtime')
|
||||
def connect_error(data):
|
||||
logging.error(f"YTM Companion Socket.IO connection failed for namespace /api/v1/realtime: {data}")
|
||||
self.is_connected = False
|
||||
self._connection_event.set()
|
||||
|
||||
@self.sio.event(namespace='/api/v1/realtime')
|
||||
def disconnect():
|
||||
logging.info(f"Disconnected from YTM Companion Socket.IO server at {self.base_url} on namespace /api/v1/realtime")
|
||||
self.is_connected = False
|
||||
|
||||
@self.sio.on('state-update', namespace='/api/v1/realtime')
|
||||
def on_state_update(data):
|
||||
# --- TEMPORARY DIAGNOSTIC LOGGING ---
|
||||
# --- END TEMPORARY DIAGNOSTIC LOGGING ---
|
||||
|
||||
# Always update the full last_known_track_data for polling purposes
|
||||
with self._data_lock:
|
||||
self.last_known_track_data = data
|
||||
|
||||
title = data.get('video', {}).get('title', 'N/A') if isinstance(data, dict) else 'N/A'
|
||||
logging.debug(f"YTM state update received. Title: {title}. Callback Exists: {self.external_update_callback is not None}")
|
||||
|
||||
if self.external_update_callback:
|
||||
logging.debug(f"--> Submitting YTM external_update_callback for title: {title} to executor")
|
||||
try:
|
||||
# Offload the callback to the executor
|
||||
self._callback_executor.submit(self.external_update_callback, data)
|
||||
except Exception as cb_ex:
|
||||
logging.error(f"Error submitting YTMClient external_update_callback to executor: {cb_ex}")
|
||||
|
||||
def load_config(self):
|
||||
default_url = "http://localhost:9863"
|
||||
self.base_url = default_url # Start with default
|
||||
|
||||
# Load base_url from main config.json
|
||||
if not os.path.exists(CONFIG_PATH):
|
||||
logging.warning(f"Main config file not found at {CONFIG_PATH}. Using default YTM URL: {self.base_url}")
|
||||
else:
|
||||
try:
|
||||
with open(CONFIG_PATH, 'r') as f:
|
||||
loaded_config = json.load(f)
|
||||
music_config = loaded_config.get("music", {})
|
||||
self.base_url = music_config.get("YTM_COMPANION_URL", default_url)
|
||||
if not self.base_url:
|
||||
logging.warning("YTM_COMPANION_URL missing or empty in config.json music section, using default.")
|
||||
self.base_url = default_url
|
||||
except json.JSONDecodeError:
|
||||
logging.error(f"Error decoding JSON from main config {CONFIG_PATH}. Using default YTM URL.")
|
||||
except Exception as e:
|
||||
logging.error(f"Error loading YTM_COMPANION_URL from main config {CONFIG_PATH}: {e}. Using default YTM URL.")
|
||||
|
||||
logging.debug(f"YTM Companion URL set to: {self.base_url}")
|
||||
|
||||
if self.base_url and self.base_url.startswith("ws://"):
|
||||
self.base_url = "http://" + self.base_url[5:]
|
||||
elif self.base_url and self.base_url.startswith("wss://"):
|
||||
self.base_url = "https://" + self.base_url[6:]
|
||||
|
||||
# Load ytm_token from ytm_auth.json
|
||||
self.ytm_token = None # Reset token before trying to load
|
||||
if os.path.exists(YTM_AUTH_CONFIG_PATH):
|
||||
try:
|
||||
with open(YTM_AUTH_CONFIG_PATH, 'r') as f:
|
||||
auth_data = json.load(f)
|
||||
self.ytm_token = auth_data.get("YTM_COMPANION_TOKEN")
|
||||
if self.ytm_token:
|
||||
logging.info(f"YTM Companion token loaded from {YTM_AUTH_CONFIG_PATH}.")
|
||||
else:
|
||||
logging.warning(f"YTM_COMPANION_TOKEN not found in {YTM_AUTH_CONFIG_PATH}. YTM features may be limited or disabled.")
|
||||
except json.JSONDecodeError:
|
||||
logging.error(f"Error decoding JSON from YTM auth file {YTM_AUTH_CONFIG_PATH}. YTM features may be limited or disabled.")
|
||||
except Exception as e:
|
||||
logging.error(f"Error loading YTM auth config {YTM_AUTH_CONFIG_PATH}: {e}. YTM features may be limited or disabled.")
|
||||
else:
|
||||
logging.warning(f"YTM auth file not found at {YTM_AUTH_CONFIG_PATH}. Run the authentication script to generate it. YTM features may be limited or disabled.")
|
||||
|
||||
def connect_client(self, timeout=10):
|
||||
if not self.ytm_token:
|
||||
logging.warning("No YTM token loaded. Cannot connect to YTM Socket.IO. Run authentication script.")
|
||||
self.is_connected = False
|
||||
return False
|
||||
|
||||
if self.is_connected:
|
||||
logging.debug("YTM client already connected.")
|
||||
return True
|
||||
|
||||
logging.info(f"Attempting to connect to YTM Socket.IO server: {self.base_url}")
|
||||
auth_payload = {"token": self.ytm_token}
|
||||
|
||||
try:
|
||||
self._connection_event.clear()
|
||||
self.sio.connect(
|
||||
self.base_url,
|
||||
transports=['websocket'],
|
||||
wait_timeout=timeout,
|
||||
namespaces=['/api/v1/realtime'],
|
||||
auth=auth_payload
|
||||
)
|
||||
event_wait_timeout = timeout + 5
|
||||
if not self._connection_event.wait(timeout=event_wait_timeout):
|
||||
logging.warning(f"YTM Socket.IO connection event not received within {event_wait_timeout}s (connect timeout was {timeout}s).")
|
||||
self.is_connected = False
|
||||
return False
|
||||
# Connection success/failure is logged by connect/connect_error events
|
||||
return self.is_connected
|
||||
except socketio.exceptions.ConnectionError as e:
|
||||
logging.error(f"YTM Socket.IO connection error: {e}")
|
||||
self.is_connected = False
|
||||
return False
|
||||
except Exception as e:
|
||||
logging.error(f"Unexpected error during YTM Socket.IO connection: {e}")
|
||||
self.is_connected = False
|
||||
return False
|
||||
|
||||
def is_available(self):
|
||||
if not self.ytm_token:
|
||||
return False
|
||||
return self.is_connected
|
||||
|
||||
def get_current_track(self):
|
||||
if not self.is_connected:
|
||||
return None
|
||||
|
||||
with self._data_lock:
|
||||
if self.last_known_track_data:
|
||||
return self.last_known_track_data
|
||||
else:
|
||||
return None
|
||||
|
||||
def disconnect_client(self):
|
||||
if self.is_connected:
|
||||
self.sio.disconnect()
|
||||
logging.info("YTM Socket.IO client disconnected.")
|
||||
self.is_connected = False
|
||||
else:
|
||||
logging.debug("YTM Socket.IO client already disconnected or not connected.")
|
||||
|
||||
def shutdown(self):
|
||||
"""Shuts down the callback executor."""
|
||||
logging.info("YTMClient: Shutting down callback executor...")
|
||||
if self._callback_executor:
|
||||
self._callback_executor.shutdown(wait=True) # Wait for pending tasks to complete
|
||||
self._callback_executor = None # Clear reference
|
||||
logging.info("YTMClient: Callback executor shut down.")
|
||||
else:
|
||||
logging.debug("YTMClient: Callback executor already None or not initialized.")
|
||||
|
||||
# Example Usage (for testing - needs to be adapted for Socket.IO async nature)
|
||||
# if __name__ == '__main__':
|
||||
# client = YTMClient()
|
||||
# if client.connect_client():
|
||||
# print("YTM Server is available (Socket.IO).")
|
||||
# try:
|
||||
# for _ in range(10): # Poll for a few seconds
|
||||
# track = client.get_current_track()
|
||||
# if track:
|
||||
# print(json.dumps(track, indent=2))
|
||||
# else:
|
||||
# print("No track currently playing or error fetching (Socket.IO).")
|
||||
# time.sleep(2)
|
||||
# finally:
|
||||
# client.disconnect_client()
|
||||
# else:
|
||||
# print(f"YTM Server not available at {client.base_url} (Socket.IO). Is YTMD running with companion server enabled and token generated?")
|
||||
Reference in New Issue
Block a user