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:
Chuck
2025-12-27 14:15:49 -05:00
committed by GitHub
parent 711482d59a
commit 7d71656cf1
647 changed files with 83039 additions and 1199386 deletions

8
src/__init__.py Normal file
View File

@@ -0,0 +1,8 @@
"""
LEDMatrix Display System
Core source package for the LED Matrix Display project.
"""
__version__ = "1.0.0"

View File

@@ -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.")

View File

@@ -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.")

View File

@@ -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,

View File

@@ -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:

View File

@@ -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}")

View File

@@ -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):

View File

@@ -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

View File

@@ -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
View 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
View 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
View 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
View 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
View 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
View 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
}

View File

@@ -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")

View File

@@ -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}")

View File

@@ -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
View 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
View 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
View 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
}

View 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
View 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
View 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

View 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
View 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
View 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
View 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

View 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
View 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
View 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
View 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

View File

@@ -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

View 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
View 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

View File

@@ -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
View 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
View 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)}

View File

@@ -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,

View File

@@ -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
View 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)

View File

@@ -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)

File diff suppressed because it is too large Load Diff

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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"
)

View File

@@ -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")

View File

@@ -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"
)

View File

@@ -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"
)

View File

@@ -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

View File

@@ -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")

View File

@@ -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")

View File

@@ -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

View File

@@ -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}")

View 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',
]

View 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)

View 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)

View 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)

View 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()

View 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

View 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

View 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}")

View 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)

View 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)

View 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)

View 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)

View 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']

View 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

View 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

View 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

File diff suppressed because it is too large Load Diff

View 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'
]

View 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 = []

View 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

View File

@@ -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
View 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})

View File

@@ -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

View File

@@ -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}")

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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}")

View 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

View 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
View 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

View 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
)

View 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('&', '&amp;')
text = text.replace('<', '&lt;')
text = text.replace('>', '&gt;')
text = text.replace('"', '&quot;')
text = text.replace("'", '&#x27;')
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

File diff suppressed because it is too large Load Diff

View File

@@ -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
)

View File

@@ -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()

View File

@@ -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?")