From e63198dc494b067dd44e77c95305de90b95e29cf Mon Sep 17 00:00:00 2001 From: ChuckBuilds <33324927+ChuckBuilds@users.noreply.github.com> Date: Mon, 18 Aug 2025 15:21:39 -0500 Subject: [PATCH 01/83] scroll priority to determine data refreshes --- src/display_controller.py | 38 ++++++-- src/display_manager.py | 80 ++++++++++++++++ src/odds_ticker_manager.py | 24 ++++- src/stock_manager.py | 71 +++++++++----- src/stock_news_manager.py | 85 ++++++++++------- test_graceful_updates.py | 187 +++++++++++++++++++++++++++++++++++++ wiki/GRACEFUL_UPDATES.md | 146 +++++++++++++++++++++++++++++ 7 files changed, 565 insertions(+), 66 deletions(-) create mode 100644 test_graceful_updates.py create mode 100644 wiki/GRACEFUL_UPDATES.md diff --git a/src/display_controller.py b/src/display_controller.py index 2cf7de13..9c124011 100644 --- a/src/display_controller.py +++ b/src/display_controller.py @@ -530,14 +530,33 @@ class DisplayController: def _update_modules(self): """Call update methods on active managers.""" - if self.weather: self.weather.get_weather() - if self.stocks: self.stocks.update_stock_data() - if self.news: self.news.update_news_data() - if self.odds_ticker: self.odds_ticker.update() - if self.calendar: self.calendar.update(time.time()) - if self.youtube: self.youtube.update() - if self.text_display: self.text_display.update() - if self.of_the_day: self.of_the_day.update(time.time()) + # Check if we're currently scrolling and defer updates if so + if self.display_manager.is_currently_scrolling(): + logger.debug("Display is currently scrolling, deferring module updates") + # Defer updates for modules that might cause lag during scrolling + if self.odds_ticker: + self.display_manager.defer_update(self.odds_ticker.update, priority=1) + if self.stocks: + self.display_manager.defer_update(self.stocks.update_stock_data, priority=2) + if self.news: + self.display_manager.defer_update(self.news.update_news_data, priority=2) + # Continue with non-scrolling-sensitive updates + if self.weather: self.weather.get_weather() + if self.calendar: self.calendar.update(time.time()) + if self.youtube: self.youtube.update() + if self.text_display: self.text_display.update() + if self.of_the_day: self.of_the_day.update(time.time()) + else: + # Not scrolling, perform all updates normally + if self.weather: self.weather.get_weather() + if self.stocks: self.stocks.update_stock_data() + if self.news: self.news.update_news_data() + if self.odds_ticker: self.odds_ticker.update() + if self.calendar: self.calendar.update(time.time()) + if self.youtube: self.youtube.update() + if self.text_display: self.text_display.update() + if self.of_the_day: self.of_the_day.update(time.time()) + # News manager fetches data when displayed, not during updates # if self.news_manager: self.news_manager.fetch_news_data() @@ -940,6 +959,9 @@ class DisplayController: # Update data for all modules first self._update_modules() + # Process any deferred updates that may have accumulated + self.display_manager.process_deferred_updates() + # Update live modes in rotation if needed self._update_live_modes_in_rotation() diff --git a/src/display_manager.py b/src/display_manager.py index ba198bd6..84c515a0 100644 --- a/src/display_manager.py +++ b/src/display_manager.py @@ -30,6 +30,15 @@ class DisplayManager: self._snapshot_path = "/tmp/led_matrix_preview.png" self._snapshot_min_interval_sec = 0.2 # max ~5 fps self._last_snapshot_ts = 0.0 + + # Scrolling state tracking for graceful updates + self._scrolling_state = { + 'is_scrolling': False, + 'last_scroll_activity': 0, + 'scroll_inactivity_threshold': 2.0, # seconds of inactivity before considering "not scrolling" + 'deferred_updates': [] + } + self._setup_matrix() logger.info("Matrix setup completed in %.3f seconds", time.time() - start_time) @@ -634,6 +643,77 @@ class DisplayManager: return dt.strftime(f"%b %-d{suffix}") + def set_scrolling_state(self, is_scrolling: bool): + """Set the current scrolling state. Call this when a display starts/stops scrolling.""" + current_time = time.time() + self._scrolling_state['is_scrolling'] = is_scrolling + if is_scrolling: + self._scrolling_state['last_scroll_activity'] = current_time + logger.debug(f"Scrolling state set to: {is_scrolling}") + + def is_currently_scrolling(self) -> bool: + """Check if the display is currently in a scrolling state.""" + current_time = time.time() + + # If explicitly not scrolling, return False + if not self._scrolling_state['is_scrolling']: + return False + + # If we've been inactive for the threshold period, consider it not scrolling + if current_time - self._scrolling_state['last_scroll_activity'] > self._scrolling_state['scroll_inactivity_threshold']: + self._scrolling_state['is_scrolling'] = False + return False + + return True + + def defer_update(self, update_func, priority: int = 0): + """Defer an update function to be called when not scrolling. + + Args: + update_func: Function to call when not scrolling + priority: Priority level (lower numbers = higher priority) + """ + self._scrolling_state['deferred_updates'].append({ + 'func': update_func, + 'priority': priority, + 'timestamp': time.time() + }) + # Sort by priority (lower numbers first) + self._scrolling_state['deferred_updates'].sort(key=lambda x: x['priority']) + logger.debug(f"Deferred update added. Total deferred: {len(self._scrolling_state['deferred_updates'])}") + + def process_deferred_updates(self): + """Process any deferred updates if not currently scrolling.""" + if self.is_currently_scrolling(): + return + + if not self._scrolling_state['deferred_updates']: + return + + # Process all deferred updates + updates_to_process = self._scrolling_state['deferred_updates'].copy() + self._scrolling_state['deferred_updates'].clear() + + logger.debug(f"Processing {len(updates_to_process)} deferred updates") + + for update_info in updates_to_process: + try: + update_info['func']() + logger.debug("Deferred update executed successfully") + except Exception as e: + logger.error(f"Error executing deferred update: {e}") + # Re-add failed updates for retry + self._scrolling_state['deferred_updates'].append(update_info) + + def get_scrolling_stats(self) -> dict: + """Get current scrolling statistics for debugging.""" + return { + 'is_scrolling': self._scrolling_state['is_scrolling'], + 'last_activity': self._scrolling_state['last_scroll_activity'], + 'deferred_count': len(self._scrolling_state['deferred_updates']), + 'inactivity_threshold': self._scrolling_state['scroll_inactivity_threshold'] + } + def _write_snapshot_if_due(self) -> None: """Write the current image to a PNG snapshot file at a limited frequency.""" try: diff --git a/src/odds_ticker_manager.py b/src/odds_ticker_manager.py index e1a323c6..31696048 100644 --- a/src/odds_ticker_manager.py +++ b/src/odds_ticker_manager.py @@ -937,6 +937,16 @@ class OddsTickerManager: logger.debug("Odds ticker is disabled, skipping update") return + # Check if we're currently scrolling and defer the update if so + if self.display_manager.is_currently_scrolling(): + logger.debug("Odds ticker is currently scrolling, deferring update") + self.display_manager.defer_update(self._perform_update, priority=1) + return + + self._perform_update() + + def _perform_update(self): + """Internal method to perform the actual update.""" current_time = time.time() if current_time - self.last_update < self.update_interval: logger.debug(f"Odds ticker update interval not reached. Next update in {self.update_interval - (current_time - self.last_update)} seconds") @@ -992,8 +1002,18 @@ class OddsTickerManager: try: current_time = time.time() + # Check if we should be scrolling + should_scroll = current_time - self.last_scroll_time >= self.scroll_delay + + # Signal scrolling state to display manager + if should_scroll: + self.display_manager.set_scrolling_state(True) + else: + # If we're not scrolling, check if we should process deferred updates + self.display_manager.process_deferred_updates() + # Scroll the image - if current_time - self.last_scroll_time >= self.scroll_delay: + if should_scroll: self.scroll_position += self.scroll_speed self.last_scroll_time = current_time @@ -1010,6 +1030,8 @@ class OddsTickerManager: # Stop scrolling when we reach the end if self.scroll_position >= self.ticker_image.width - width: self.scroll_position = self.ticker_image.width - width + # Signal that scrolling has stopped + self.display_manager.set_scrolling_state(False) # Create the visible part of the image by pasting from the ticker_image visible_image = Image.new('RGB', (width, height)) diff --git a/src/stock_manager.py b/src/stock_manager.py index fb9833d5..2160323d 100644 --- a/src/stock_manager.py +++ b/src/stock_manager.py @@ -366,32 +366,52 @@ class StockManager: logger.info("Stock display settings changed, clearing cache") def update_stock_data(self): - """Update stock and crypto data for all configured symbols.""" + """Update stock data from API.""" current_time = time.time() - update_interval = self.stocks_config.get('update_interval', 300) + update_interval = self.stocks_config.get('update_interval', 600) - # Check if we need to update based on time - if current_time - self.last_update > update_interval: - stock_symbols = self.stocks_config.get('symbols', []) - crypto_symbols = self.crypto_config.get('symbols', []) if self.crypto_config.get('enabled', False) else [] + # 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 - if not stock_symbols and not crypto_symbols: - logger.warning("No stock or crypto symbols configured") + 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 - - # Update stocks - for symbol in stock_symbols: - data = self._fetch_stock_data(symbol, is_crypto=False) - if data: - self.stock_data[symbol] = data - - # Update crypto - for symbol in crypto_symbols: - data = self._fetch_stock_data(symbol, is_crypto=True) - if data: - self.stock_data[symbol] = data - + + # 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.""" @@ -696,6 +716,15 @@ class StockManager: 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 diff --git a/src/stock_news_manager.py b/src/stock_news_manager.py index ff6c9c7f..debe6eb8 100644 --- a/src/stock_news_manager.py +++ b/src/stock_news_manager.py @@ -169,50 +169,52 @@ class StockNewsManager: return [] def update_news_data(self): - """Update news data for all configured stock symbols.""" + """Update news data from API.""" current_time = time.time() - update_interval = self.stock_news_config.get('update_interval', 300) + update_interval = self.stock_news_config.get('update_interval', 3600) - # Check if we need to update based on time - if current_time - self.last_update > update_interval: + # 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 - - # Get cached data - cached_data = self.cache_manager.get('stock_news') - - # Update each symbol - new_data = {} - success = False - + + # Fetch news for each symbol for symbol in symbols: - # Check if data has changed before fetching - if cached_data and symbol in cached_data: - current_state = cached_data[symbol] - if not self.cache_manager.has_data_changed('stock_news', current_state): - logger.info(f"News data hasn't changed for {symbol}, using existing data") - new_data[symbol] = current_state - success = True - continue - - # Add a longer delay between requests to avoid rate limiting - time.sleep(random.uniform(1.0, 2.0)) # increased delay between requests - news_items = self._fetch_news(symbol) - if news_items: - new_data[symbol] = news_items - success = True + try: + news = self._fetch_news_for_symbol(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}") - if success: - # Cache the new data - self.cache_manager.update_cache('stock_news', new_data) - # Only update the displayed data when we have new data - self.news_data = new_data - self.last_update = current_time - logger.info(f"Updated news data for {len(new_data)} symbols") - else: - logger.error("Failed to fetch news for any configured stocks") + 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.""" @@ -405,12 +407,23 @@ class StockNewsManager: # 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: diff --git a/test_graceful_updates.py b/test_graceful_updates.py new file mode 100644 index 00000000..3014bb45 --- /dev/null +++ b/test_graceful_updates.py @@ -0,0 +1,187 @@ +#!/usr/bin/env python3 +""" +Test script to demonstrate the graceful update system for scrolling displays. +This script shows how updates are deferred during scrolling periods to prevent lag. +""" + +import time +import logging +import sys +import os + +# Add the project root directory to Python path +sys.path.append(os.path.dirname(os.path.abspath(__file__))) + +# Configure logging first +logging.basicConfig( + level=logging.DEBUG, + format='%(asctime)s.%(msecs)03d - %(levelname)s:%(name)s:%(message)s', + datefmt='%H:%M:%S', + stream=sys.stdout +) + +logger = logging.getLogger(__name__) + +# Mock rgbmatrix module for testing on non-Raspberry Pi systems +try: + from rgbmatrix import RGBMatrix, RGBMatrixOptions +except ImportError: + logger.info("rgbmatrix module not available, using mock for testing") + + class MockRGBMatrixOptions: + def __init__(self): + self.rows = 32 + self.cols = 64 + self.chain_length = 2 + self.parallel = 1 + self.hardware_mapping = 'adafruit-hat-pwm' + self.brightness = 90 + self.pwm_bits = 10 + self.pwm_lsb_nanoseconds = 150 + self.led_rgb_sequence = 'RGB' + self.pixel_mapper_config = '' + self.row_address_type = 0 + self.multiplexing = 0 + self.disable_hardware_pulsing = False + self.show_refresh_rate = False + self.limit_refresh_rate_hz = 90 + self.gpio_slowdown = 2 + + class MockRGBMatrix: + def __init__(self, options=None): + self.width = 128 # 64 * 2 chain length + self.height = 32 + + def CreateFrameCanvas(self): + return MockCanvas() + + def SwapOnVSync(self, canvas, dont_wait=False): + pass + + def Clear(self): + pass + + class MockCanvas: + def __init__(self): + self.width = 128 + self.height = 32 + + def SetImage(self, image): + pass + + def Clear(self): + pass + + RGBMatrix = MockRGBMatrix + RGBMatrixOptions = MockRGBMatrixOptions + +from src.display_manager import DisplayManager +from src.config_manager import ConfigManager + +def simulate_scrolling_display(display_manager, duration=10): + """Simulate a scrolling display for testing.""" + logger.info(f"Starting scrolling simulation for {duration} seconds") + + start_time = time.time() + while time.time() - start_time < duration: + # Signal that we're scrolling + display_manager.set_scrolling_state(True) + + # Simulate some scrolling work + time.sleep(0.1) + + # Every 2 seconds, try to defer an update + if int(time.time() - start_time) % 2 == 0: + logger.info("Attempting to defer an update during scrolling") + display_manager.defer_update( + lambda: logger.info("This update was deferred and executed later!"), + priority=1 + ) + + # Signal that scrolling has stopped + display_manager.set_scrolling_state(False) + logger.info("Scrolling simulation completed") + +def test_graceful_updates(): + """Test the graceful update system.""" + logger.info("Testing graceful update system") + + # Load config + config_manager = ConfigManager() + config = config_manager.load_config() + + # Initialize display manager + display_manager = DisplayManager(config, force_fallback=True) + + try: + # Test 1: Defer updates during scrolling + logger.info("=== Test 1: Defer updates during scrolling ===") + + # Add some deferred updates + display_manager.defer_update( + lambda: logger.info("Update 1: High priority update"), + priority=1 + ) + display_manager.defer_update( + lambda: logger.info("Update 2: Medium priority update"), + priority=2 + ) + display_manager.defer_update( + lambda: logger.info("Update 3: Low priority update"), + priority=3 + ) + + # Start scrolling simulation + simulate_scrolling_display(display_manager, duration=5) + + # Check scrolling stats + stats = display_manager.get_scrolling_stats() + logger.info(f"Scrolling stats: {stats}") + + # Test 2: Process deferred updates when not scrolling + logger.info("=== Test 2: Process deferred updates when not scrolling ===") + + # Process any remaining deferred updates + display_manager.process_deferred_updates() + + # Test 3: Test inactivity threshold + logger.info("=== Test 3: Test inactivity threshold ===") + + # Signal scrolling started + display_manager.set_scrolling_state(True) + logger.info(f"Is scrolling: {display_manager.is_currently_scrolling()}") + + # Wait longer than the inactivity threshold + time.sleep(3) + logger.info(f"Is scrolling after inactivity: {display_manager.is_currently_scrolling()}") + + # Test 4: Test priority ordering + logger.info("=== Test 4: Test priority ordering ===") + + # Add updates in reverse priority order + display_manager.defer_update( + lambda: logger.info("Priority 3 update"), + priority=3 + ) + display_manager.defer_update( + lambda: logger.info("Priority 1 update"), + priority=1 + ) + display_manager.defer_update( + lambda: logger.info("Priority 2 update"), + priority=2 + ) + + # Process them (should execute in priority order: 1, 2, 3) + display_manager.process_deferred_updates() + + logger.info("All tests completed successfully!") + + except Exception as e: + logger.error(f"Test failed: {e}", exc_info=True) + finally: + # Cleanup + display_manager.cleanup() + +if __name__ == "__main__": + test_graceful_updates() diff --git a/wiki/GRACEFUL_UPDATES.md b/wiki/GRACEFUL_UPDATES.md new file mode 100644 index 00000000..ec16d06f --- /dev/null +++ b/wiki/GRACEFUL_UPDATES.md @@ -0,0 +1,146 @@ +# Graceful Update System + +The LED Matrix project now includes a graceful update system that prevents lag during scrolling displays by deferring updates until the display is not actively scrolling. + +## Overview + +When displays like the odds ticker, stock ticker, or news ticker are actively scrolling, performing API updates or data fetching can cause visual lag or stuttering. The graceful update system solves this by: + +1. **Tracking scrolling state** - The system monitors when displays are actively scrolling +2. **Deferring updates** - Updates that might cause lag are deferred during scrolling periods +3. **Processing when safe** - Deferred updates are processed when scrolling stops or during non-scrolling periods +4. **Priority-based execution** - Updates are executed in priority order when processed + +## How It Works + +### Scrolling State Tracking + +The `DisplayManager` class now includes scrolling state tracking: + +```python +# Signal when scrolling starts +display_manager.set_scrolling_state(True) + +# Signal when scrolling stops +display_manager.set_scrolling_state(False) + +# Check if currently scrolling +if display_manager.is_currently_scrolling(): + # Defer updates + pass +``` + +### Deferred Updates + +Updates can be deferred using the `defer_update` method: + +```python +# Defer an update with priority +display_manager.defer_update( + lambda: self._perform_update(), + priority=1 # Lower numbers = higher priority +) +``` + +### Automatic Processing + +Deferred updates are automatically processed when: +- A display signals it's not scrolling +- The main loop processes updates during non-scrolling periods +- The inactivity threshold is reached (default: 2 seconds) + +## Implementation Details + +### Display Manager Changes + +The `DisplayManager` class now includes: + +- `set_scrolling_state(is_scrolling)` - Signal scrolling state changes +- `is_currently_scrolling()` - Check if display is currently scrolling +- `defer_update(update_func, priority)` - Defer an update function +- `process_deferred_updates()` - Process all pending deferred updates +- `get_scrolling_stats()` - Get current scrolling statistics + +### Manager Updates + +The following managers have been updated to use the graceful update system: + +#### Odds Ticker Manager +- Defers API updates during scrolling +- Signals scrolling state during display +- Processes deferred updates when not scrolling + +#### Stock Manager +- Defers stock data updates during scrolling +- Always signals scrolling state (continuous scrolling) +- Priority 2 for stock updates + +#### Stock News Manager +- Defers news data updates during scrolling +- Signals scrolling state during display +- Priority 2 for news updates + +### Display Controller Changes + +The main display controller now: +- Checks scrolling state before updating modules +- Defers scrolling-sensitive updates during scrolling periods +- Processes deferred updates in the main loop +- Continues non-scrolling-sensitive updates normally + +## Configuration + +The system uses these default settings: + +- **Inactivity threshold**: 2.0 seconds +- **Update priorities**: + - Priority 1: Odds ticker updates + - Priority 2: Stock and news updates + - Priority 3+: Other updates + +## Benefits + +1. **Smoother Scrolling** - No more lag during ticker scrolling +2. **Better User Experience** - Displays remain responsive during updates +3. **Efficient Resource Usage** - Updates happen when the system is idle +4. **Priority-Based** - Important updates are processed first +5. **Automatic** - No manual intervention required + +## Testing + +You can test the graceful update system using the provided test script: + +```bash +python test_graceful_updates.py +``` + +This script demonstrates: +- Deferring updates during scrolling +- Processing updates when not scrolling +- Priority-based execution +- Inactivity threshold behavior + +## Debugging + +To debug the graceful update system, enable debug logging: + +```python +import logging +logging.getLogger('src.display_manager').setLevel(logging.DEBUG) +``` + +The system will log: +- When scrolling state changes +- When updates are deferred +- When deferred updates are processed +- Current scrolling statistics + +## Future Enhancements + +Potential improvements to the system: + +1. **Configurable thresholds** - Allow users to adjust inactivity thresholds +2. **More granular priorities** - Add more priority levels for different update types +3. **Update batching** - Group similar updates to reduce processing overhead +4. **Performance metrics** - Track and report update deferral statistics +5. **Web interface integration** - Show deferred update status in the web UI From 68416d0293c40c42fbac0f2f683f269686a0669f Mon Sep 17 00:00:00 2001 From: ChuckBuilds <33324927+ChuckBuilds@users.noreply.github.com> Date: Mon, 18 Aug 2025 15:50:23 -0500 Subject: [PATCH 02/83] live games in odds ticker --- src/odds_ticker_manager.py | 351 +++++++++++++++++++++++++++++++--- test/test_odds_ticker_live.py | 173 +++++++++++++++++ 2 files changed, 499 insertions(+), 25 deletions(-) create mode 100644 test/test_odds_ticker_live.py diff --git a/src/odds_ticker_manager.py b/src/odds_ticker_manager.py index 31696048..4a31a90f 100644 --- a/src/odds_ticker_manager.py +++ b/src/odds_ticker_manager.py @@ -359,7 +359,7 @@ class OddsTickerManager: if request_date_obj < current_date_obj: ttl = 86400 * 30 # 30 days for past dates elif request_date_obj == current_date_obj: - ttl = 3600 # 1 hour for today + ttl = 300 # 5 minutes for today (shorter to catch live games) else: ttl = 43200 # 12 hours for future dates @@ -382,9 +382,15 @@ class OddsTickerManager: break game_id = event['id'] status = event['status']['type']['name'].lower() - if status in ['scheduled', 'pre-game', 'status_scheduled']: + status_state = event['status']['type']['state'].lower() + + # Include both scheduled and live games + if status in ['scheduled', 'pre-game', 'status_scheduled'] or status_state == 'in': game_time = datetime.fromisoformat(event['date'].replace('Z', '+00:00')) - if now <= game_time <= future_window: + + # For live games, include them regardless of time window + # For scheduled games, check if they're within the future window + if status_state == 'in' or (now <= game_time <= future_window): competitors = event['competitions'][0]['competitors'] home_team = next(c for c in competitors if c['homeAway'] == 'home') away_team = next(c for c in competitors if c['homeAway'] == 'away') @@ -437,7 +443,10 @@ class OddsTickerManager: # Dynamically set update interval based on game start time time_until_game = game_time - now - if time_until_game > timedelta(hours=48): + if status_state == 'in': + # Live games need more frequent updates + update_interval_seconds = 300 # 5 minutes for live games + elif time_until_game > timedelta(hours=48): update_interval_seconds = 86400 # 24 hours else: update_interval_seconds = 3600 # 1 hour @@ -461,6 +470,12 @@ class OddsTickerManager: has_odds = True if odds_data.get('over_under') is not None: has_odds = True + + # Extract live game information if the game is in progress + live_info = None + if status_state == 'in': + live_info = self._extract_live_game_info(event, sport) + game = { 'id': game_id, 'home_team': home_abbr, @@ -472,7 +487,10 @@ class OddsTickerManager: 'away_record': away_record, 'odds': odds_data if has_odds else None, 'broadcast_info': broadcast_info, - 'logo_dir': league_config.get('logo_dir', f'assets/sports/{league.lower()}_logos') + 'logo_dir': league_config.get('logo_dir', f'assets/sports/{league.lower()}_logos'), + 'status': status, + 'status_state': status_state, + 'live_info': live_info } all_games.append(game) games_found += 1 @@ -492,8 +510,145 @@ class OddsTickerManager: break return all_games + def _extract_live_game_info(self, event: Dict[str, Any], sport: str) -> Dict[str, Any]: + """Extract live game information from ESPN API event data.""" + try: + status = event['status'] + competitions = event['competitions'][0] + competitors = competitions['competitors'] + + # Get scores + home_score = next(c['score'] for c in competitors if c['homeAway'] == 'home') + away_score = next(c['score'] for c in competitors if c['homeAway'] == 'away') + + live_info = { + 'home_score': home_score, + 'away_score': away_score, + 'period': status.get('period', 1), + 'clock': status.get('displayClock', ''), + 'detail': status['type'].get('detail', ''), + 'short_detail': status['type'].get('shortDetail', '') + } + + # Sport-specific information + if sport == 'baseball': + # Extract inning information + situation = competitions.get('situation', {}) + count = situation.get('count', {}) + + live_info.update({ + 'inning': status.get('period', 1), + 'inning_half': 'top', # Default + 'balls': count.get('balls', 0), + 'strikes': count.get('strikes', 0), + 'outs': situation.get('outs', 0), + 'bases_occupied': [ + situation.get('onFirst', False), + situation.get('onSecond', False), + situation.get('onThird', False) + ] + }) + + # Determine inning half from status detail + status_detail = status['type'].get('detail', '').lower() + status_short = status['type'].get('shortDetail', '').lower() + + if 'bottom' in status_detail or 'bot' in status_detail or 'bottom' in status_short or 'bot' in status_short: + live_info['inning_half'] = 'bottom' + elif 'top' in status_detail or 'mid' in status_detail or 'top' in status_short or 'mid' in status_short: + live_info['inning_half'] = 'top' + + elif sport == 'football': + # Extract football-specific information + situation = competitions.get('situation', {}) + + live_info.update({ + 'quarter': status.get('period', 1), + 'down': situation.get('down', 0), + 'distance': situation.get('distance', 0), + 'yard_line': situation.get('yardLine', 0), + 'possession': situation.get('possession', '') + }) + + elif sport == 'basketball': + # Extract basketball-specific information + situation = competitions.get('situation', {}) + + live_info.update({ + 'quarter': status.get('period', 1), + 'time_remaining': status.get('displayClock', ''), + 'possession': situation.get('possession', '') + }) + + elif sport == 'hockey': + # Extract hockey-specific information + situation = competitions.get('situation', {}) + + live_info.update({ + 'period': status.get('period', 1), + 'time_remaining': status.get('displayClock', ''), + 'power_play': situation.get('powerPlay', False) + }) + + elif sport == 'soccer': + # Extract soccer-specific information + live_info.update({ + 'period': status.get('period', 1), + 'time_remaining': status.get('displayClock', ''), + 'extra_time': status.get('displayClock', '').endswith('+') + }) + + return live_info + + except Exception as e: + logger.error(f"Error extracting live game info: {e}") + return None + def _format_odds_text(self, game: Dict[str, Any]) -> str: """Format the odds text for display.""" + # Check if this is a live game + is_live = game.get('status_state') == 'in' + live_info = game.get('live_info') + + if is_live and live_info: + # Format live game information + home_score = live_info.get('home_score', 0) + away_score = live_info.get('away_score', 0) + + # Determine sport for sport-specific formatting + sport = None + for league_key, config in self.league_configs.items(): + if config.get('logo_dir') == game.get('logo_dir'): + sport = config.get('sport') + break + + if sport == 'baseball': + inning_half_indicator = "▲" if live_info.get('inning_half') == 'top' else "▼" + inning_text = f"{inning_half_indicator}{live_info.get('inning', 1)}" + count_text = f"{live_info.get('balls', 0)}-{live_info.get('strikes', 0)}" + outs_text = f"{live_info.get('outs', 0)} out" + return f"[LIVE] {game.get('away_team_name', game['away_team'])} {away_score} vs {game.get('home_team_name', game['home_team'])} {home_score} - {inning_text} {count_text} {outs_text}" + + elif sport == 'football': + quarter_text = f"Q{live_info.get('quarter', 1)}" + down_text = f"{live_info.get('down', 0)}&{live_info.get('distance', 0)}" + clock_text = live_info.get('clock', '') + return f"[LIVE] {game.get('away_team_name', game['away_team'])} {away_score} vs {game.get('home_team_name', game['home_team'])} {home_score} - {quarter_text} {down_text} {clock_text}" + + elif sport == 'basketball': + quarter_text = f"Q{live_info.get('quarter', 1)}" + clock_text = live_info.get('time_remaining', '') + return f"[LIVE] {game.get('away_team_name', game['away_team'])} {away_score} vs {game.get('home_team_name', game['home_team'])} {home_score} - {quarter_text} {clock_text}" + + elif sport == 'hockey': + period_text = f"P{live_info.get('period', 1)}" + clock_text = live_info.get('time_remaining', '') + return f"[LIVE] {game.get('away_team_name', game['away_team'])} {away_score} vs {game.get('home_team_name', game['home_team'])} {home_score} - {period_text} {clock_text}" + + else: + return f"[LIVE] {game.get('away_team_name', game['away_team'])} {away_score} vs {game.get('home_team_name', game['home_team'])} {home_score}" + + # Original odds formatting for non-live games odds = game.get('odds', {}) if not odds: # Show just the game info without odds @@ -657,10 +812,80 @@ class OddsTickerManager: game_time = game_time.replace(tzinfo=pytz.UTC) local_time = game_time.astimezone(tz) - # Capitalize full day name, e.g., 'Tuesday' - day_text = local_time.strftime("%A") - date_text = local_time.strftime("%-m/%d") - time_text = local_time.strftime("%I:%M%p").lstrip('0') + # Check if this is a live game + is_live = game.get('status_state') == 'in' + live_info = game.get('live_info') + + if is_live and live_info: + # Show live game information instead of date/time + sport = None + for league_key, config in self.league_configs.items(): + if config.get('logo_dir') == game.get('logo_dir'): + sport = config.get('sport') + break + + if sport == 'baseball': + # Baseball: Show inning and count + inning_half_indicator = "▲" if live_info.get('inning_half') == 'top' else "▼" + inning_text = f"{inning_half_indicator}{live_info.get('inning', 1)}" + count_text = f"{live_info.get('balls', 0)}-{live_info.get('strikes', 0)}" + outs_text = f"{live_info.get('outs', 0)} out" + + day_text = inning_text + date_text = count_text + time_text = outs_text + + elif sport == 'football': + # Football: Show quarter and down/distance + quarter_text = f"Q{live_info.get('quarter', 1)}" + down_text = f"{live_info.get('down', 0)}&{live_info.get('distance', 0)}" + clock_text = live_info.get('clock', '') + + day_text = quarter_text + date_text = down_text + time_text = clock_text + + elif sport == 'basketball': + # Basketball: Show quarter and time remaining + quarter_text = f"Q{live_info.get('quarter', 1)}" + clock_text = live_info.get('time_remaining', '') + possession_text = live_info.get('possession', '') + + day_text = quarter_text + date_text = clock_text + time_text = possession_text + + elif sport == 'hockey': + # Hockey: Show period and time remaining + period_text = f"P{live_info.get('period', 1)}" + clock_text = live_info.get('time_remaining', '') + power_play_text = "PP" if live_info.get('power_play') else "" + + day_text = period_text + date_text = clock_text + time_text = power_play_text + + elif sport == 'soccer': + # Soccer: Show period and time remaining + period_text = f"P{live_info.get('period', 1)}" + clock_text = live_info.get('time_remaining', '') + extra_time_text = "+" if live_info.get('extra_time') else "" + + day_text = period_text + date_text = clock_text + time_text = extra_time_text + + else: + # Fallback: Show generic live info + day_text = "LIVE" + date_text = f"{live_info.get('home_score', 0)}-{live_info.get('away_score', 0)}" + time_text = live_info.get('clock', '') + else: + # Show regular date/time for non-live games + # Capitalize full day name, e.g., 'Tuesday' + day_text = local_time.strftime("%A") + date_text = local_time.strftime("%-m/%d") + time_text = local_time.strftime("%I:%M%p").lstrip('0') # Datetime column width temp_draw = ImageDraw.Draw(Image.new('RGB', (1, 1))) @@ -677,6 +902,13 @@ class OddsTickerManager: away_team_text = f"{game.get('away_team_name', game.get('away_team', 'N/A'))} ({game.get('away_record', '') or 'N/A'})" home_team_text = f"{game.get('home_team_name', game.get('home_team', 'N/A'))} ({game.get('home_record', '') or 'N/A'})" + # For live games, show scores instead of records + if is_live and live_info: + away_score = live_info.get('away_score', 0) + home_score = live_info.get('home_score', 0) + away_team_text = f"{game.get('away_team_name', game.get('away_team', 'N/A'))} ({away_score})" + home_team_text = f"{game.get('home_team_name', game.get('home_team', 'N/A'))} ({home_score})" + away_team_width = int(temp_draw.textlength(away_team_text, font=team_font)) home_team_width = int(temp_draw.textlength(home_team_text, font=team_font)) team_info_width = max(away_team_width, home_team_width) @@ -707,17 +939,65 @@ class OddsTickerManager: away_odds_text = "" home_odds_text = "" - # Simplified odds placement logic - if home_favored: - home_odds_text = f"{home_spread}" - if over_under: - away_odds_text = f"O/U {over_under}" - elif away_favored: - away_odds_text = f"{away_spread}" - if over_under: + # For live games, show live status instead of odds + if is_live and live_info: + sport = None + for league_key, config in self.league_configs.items(): + if config.get('logo_dir') == game.get('logo_dir'): + sport = config.get('sport') + break + + if sport == 'baseball': + # Show bases occupied for baseball + bases = live_info.get('bases_occupied', [False, False, False]) + bases_text = "" + if bases[0]: bases_text += "1" + if bases[1]: bases_text += "2" + if bases[2]: bases_text += "3" + if not bases_text: bases_text = "---" + + away_odds_text = f"Bases: {bases_text}" + home_odds_text = f"Count: {live_info.get('balls', 0)}-{live_info.get('strikes', 0)}" + + elif sport == 'football': + # Show possession and yard line for football + possession = live_info.get('possession', '') + yard_line = live_info.get('yard_line', 0) + + away_odds_text = f"Ball: {possession}" + home_odds_text = f"Yard: {yard_line}" + + elif sport == 'basketball': + # Show possession for basketball + possession = live_info.get('possession', '') + + away_odds_text = f"Ball: {possession}" + home_odds_text = f"Time: {live_info.get('time_remaining', '')}" + + elif sport == 'hockey': + # Show power play status for hockey + power_play = live_info.get('power_play', False) + + away_odds_text = "Power Play" if power_play else "Even" + home_odds_text = f"Time: {live_info.get('time_remaining', '')}" + + else: + # Generic live status + away_odds_text = "LIVE" + home_odds_text = live_info.get('clock', '') + else: + # Show odds for non-live games + # Simplified odds placement logic + if home_favored: + home_odds_text = f"{home_spread}" + if over_under: + away_odds_text = f"O/U {over_under}" + elif away_favored: + away_odds_text = f"{away_spread}" + if over_under: + home_odds_text = f"O/U {over_under}" + elif over_under: home_odds_text = f"O/U {over_under}" - elif over_under: - home_odds_text = f"O/U {over_under}" away_odds_width = int(temp_draw.textlength(away_odds_text, font=odds_font)) home_odds_width = int(temp_draw.textlength(home_odds_text, font=odds_font)) @@ -753,7 +1033,13 @@ class OddsTickerManager: # "vs." y_pos = (height - vs_font.size) // 2 if hasattr(vs_font, 'size') else (height - 8) // 2 # Added fallback for default font - draw.text((current_x, y_pos), vs_text, font=vs_font, fill=(255, 255, 255)) + + # Use red color for live game "vs." text to make it stand out + vs_color = (255, 255, 255) # White for regular games + if is_live and live_info: + vs_color = (255, 0, 0) # Red for live games + + draw.text((current_x, y_pos), vs_text, font=vs_font, fill=vs_color) current_x += vs_width + h_padding # Home Logo @@ -766,8 +1052,14 @@ class OddsTickerManager: team_font_height = team_font.size if hasattr(team_font, 'size') else 8 away_y = 2 home_y = height - team_font_height - 2 - draw.text((current_x, away_y), away_team_text, font=team_font, fill=(255, 255, 255)) - draw.text((current_x, home_y), home_team_text, font=team_font, fill=(255, 255, 255)) + + # Use red color for live game scores to make them stand out + team_color = (255, 255, 255) # White for regular team info + if is_live and live_info: + team_color = (255, 0, 0) # Red for live games + + draw.text((current_x, away_y), away_team_text, font=team_font, fill=team_color) + draw.text((current_x, home_y), home_team_text, font=team_font, fill=team_color) current_x += team_info_width + h_padding # Odds (stacked) @@ -777,6 +1069,10 @@ class OddsTickerManager: # Use a consistent color for all odds text odds_color = (0, 255, 0) # Green + + # Use red color for live game information to make it stand out + if is_live and live_info: + odds_color = (255, 0, 0) # Red for live games draw.text((current_x, odds_y_away), away_odds_text, font=odds_font, fill=odds_color) draw.text((current_x, odds_y_home), home_odds_text, font=odds_font, fill=odds_color) @@ -804,9 +1100,14 @@ class OddsTickerManager: date_x = current_x + (datetime_col_width - date_text_width) // 2 time_x = current_x + (datetime_col_width - time_text_width) // 2 - draw.text((day_x, day_y), day_text, font=datetime_font, fill=(255, 255, 255)) - draw.text((date_x, date_y), date_text, font=datetime_font, fill=(255, 255, 255)) - draw.text((time_x, time_y), time_text, font=datetime_font, fill=(255, 255, 255)) + # Use red color for live game information to make it stand out + datetime_color = (255, 255, 255) # White for regular date/time + if is_live and live_info: + datetime_color = (255, 0, 0) # Red for live games + + draw.text((day_x, day_y), day_text, font=datetime_font, fill=datetime_color) + draw.text((date_x, date_y), date_text, font=datetime_font, fill=datetime_color) + draw.text((time_x, time_y), time_text, font=datetime_font, fill=datetime_color) current_x += datetime_col_width + h_padding # Add padding after datetime if broadcast_logo: diff --git a/test/test_odds_ticker_live.py b/test/test_odds_ticker_live.py new file mode 100644 index 00000000..b65ed0e2 --- /dev/null +++ b/test/test_odds_ticker_live.py @@ -0,0 +1,173 @@ +#!/usr/bin/env python3 +""" +Test script to verify odds ticker live game functionality. +""" + +import sys +import os +import json +import requests +from datetime import datetime, timezone + +# Add the src directory to the path +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src')) + +from odds_ticker_manager import OddsTickerManager +from display_manager import DisplayManager +from cache_manager import CacheManager +from config_manager import ConfigManager + +def test_live_game_detection(): + """Test that the odds ticker can detect live games.""" + print("Testing live game detection in odds ticker...") + + # Create a minimal config for testing + config = { + 'odds_ticker': { + 'enabled': True, + 'enabled_leagues': ['mlb', 'nfl', 'nba'], + 'show_favorite_teams_only': False, + 'max_games_per_league': 3, + 'show_odds_only': False, + 'update_interval': 300, + 'scroll_speed': 2, + 'scroll_delay': 0.05, + 'display_duration': 30, + 'future_fetch_days': 1, + 'loop': True, + 'show_channel_logos': True, + 'broadcast_logo_height_ratio': 0.8, + 'broadcast_logo_max_width_ratio': 0.8, + 'request_timeout': 30, + 'dynamic_duration': True, + 'min_duration': 30, + 'max_duration': 300, + 'duration_buffer': 0.1 + }, + 'timezone': 'UTC', + 'mlb': { + 'enabled': True, + 'favorite_teams': [] + }, + 'nfl_scoreboard': { + 'enabled': True, + 'favorite_teams': [] + }, + 'nba_scoreboard': { + 'enabled': True, + 'favorite_teams': [] + } + } + + # Create mock display manager + class MockDisplayManager: + def __init__(self): + self.matrix = MockMatrix() + self.image = None + self.draw = None + + def update_display(self): + pass + + def is_currently_scrolling(self): + return False + + def set_scrolling_state(self, state): + pass + + def defer_update(self, func, priority=0): + pass + + def process_deferred_updates(self): + pass + + class MockMatrix: + def __init__(self): + self.width = 128 + self.height = 32 + + # Create managers + display_manager = MockDisplayManager() + cache_manager = CacheManager() + config_manager = ConfigManager() + + # Create odds ticker manager + odds_ticker = OddsTickerManager(config, display_manager) + + # Test fetching games + print("Fetching games...") + games = odds_ticker._fetch_upcoming_games() + + print(f"Found {len(games)} total games") + + # Check for live games + live_games = [game for game in games if game.get('status_state') == 'in'] + scheduled_games = [game for game in games if game.get('status_state') != 'in'] + + print(f"Live games: {len(live_games)}") + print(f"Scheduled games: {len(scheduled_games)}") + + # Display live games + for i, game in enumerate(live_games[:3]): # Show first 3 live games + print(f"\nLive Game {i+1}:") + print(f" Teams: {game['away_team']} @ {game['home_team']}") + print(f" Status: {game.get('status')} (State: {game.get('status_state')})") + + live_info = game.get('live_info') + if live_info: + print(f" Score: {live_info.get('away_score', 0)} - {live_info.get('home_score', 0)}") + print(f" Period: {live_info.get('period', 'N/A')}") + print(f" Clock: {live_info.get('clock', 'N/A')}") + print(f" Detail: {live_info.get('detail', 'N/A')}") + + # Sport-specific info + sport = None + for league_key, league_config in odds_ticker.league_configs.items(): + if league_config.get('logo_dir') == game.get('logo_dir'): + sport = league_config.get('sport') + break + + if sport == 'baseball': + print(f" Inning: {live_info.get('inning_half', 'N/A')} {live_info.get('inning', 'N/A')}") + print(f" Count: {live_info.get('balls', 0)}-{live_info.get('strikes', 0)}") + print(f" Outs: {live_info.get('outs', 0)}") + print(f" Bases: {live_info.get('bases_occupied', [])}") + elif sport == 'football': + print(f" Quarter: {live_info.get('quarter', 'N/A')}") + print(f" Down: {live_info.get('down', 'N/A')} & {live_info.get('distance', 'N/A')}") + print(f" Yard Line: {live_info.get('yard_line', 'N/A')}") + print(f" Possession: {live_info.get('possession', 'N/A')}") + elif sport == 'basketball': + print(f" Quarter: {live_info.get('quarter', 'N/A')}") + print(f" Time: {live_info.get('time_remaining', 'N/A')}") + print(f" Possession: {live_info.get('possession', 'N/A')}") + elif sport == 'hockey': + print(f" Period: {live_info.get('period', 'N/A')}") + print(f" Time: {live_info.get('time_remaining', 'N/A')}") + print(f" Power Play: {live_info.get('power_play', False)}") + else: + print(" No live info available") + + # Test formatting + print("\nTesting text formatting...") + for game in live_games[:2]: # Test first 2 live games + formatted_text = odds_ticker._format_odds_text(game) + print(f"Formatted text: {formatted_text}") + + # Test image creation + print("\nTesting image creation...") + if games: + try: + odds_ticker.games_data = games[:3] # Use first 3 games + odds_ticker._create_ticker_image() + if odds_ticker.ticker_image: + print(f"Successfully created ticker image: {odds_ticker.ticker_image.size}") + else: + print("Failed to create ticker image") + except Exception as e: + print(f"Error creating ticker image: {e}") + + print("\nTest completed!") + +if __name__ == "__main__": + test_live_game_detection() From 9f00124fadec48e53ed347c89870101224bc691f Mon Sep 17 00:00:00 2001 From: ChuckBuilds <33324927+ChuckBuilds@users.noreply.github.com> Date: Mon, 18 Aug 2025 19:05:36 -0500 Subject: [PATCH 03/83] Fix odds ticker dynamic duration calculation - Fixed double-counting of display width in total_scroll_width calculation - Added detailed debug logging for dynamic duration calculation - Added debug logging for scrolling behavior and loop resets - Created test script for debugging dynamic duration issues - The issue was that total_scroll_width included display width, causing incorrect duration calculations that resulted in early cutoff --- src/odds_ticker_manager.py | 33 +++++- test/test_odds_ticker_dynamic_duration.py | 122 ++++++++++++++++++++++ 2 files changed, 152 insertions(+), 3 deletions(-) create mode 100644 test/test_odds_ticker_dynamic_duration.py diff --git a/src/odds_ticker_manager.py b/src/odds_ticker_manager.py index 4a31a90f..cdca88be 100644 --- a/src/odds_ticker_manager.py +++ b/src/odds_ticker_manager.py @@ -12,6 +12,14 @@ from src.cache_manager import CacheManager from src.config_manager import ConfigManager from src.odds_manager import OddsManager +# 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 logger = logging.getLogger(__name__) @@ -214,6 +222,9 @@ class OddsTickerManager: response.raise_for_status() data = response.json() + # Increment API counter for sports data + increment_api_counter('sports', 1) + # Different path for college sports records if league == 'college-football': record_items = data.get('team', {}).get('record', {}).get('items', []) @@ -371,6 +382,10 @@ class OddsTickerManager: response = requests.get(url, timeout=self.request_timeout) response.raise_for_status() data = response.json() + + # Increment API counter for sports data + increment_api_counter('sports', 1) + self.cache_manager.set(cache_key, data) logger.debug(f"Cached scoreboard for {league} on {date} with a TTL of {ttl} seconds.") else: @@ -1140,7 +1155,8 @@ class OddsTickerManager: gap_width = 24 # Reduced gap between games display_width = self.display_manager.matrix.width # Add display width of black space at start - total_width = display_width + sum(img.width for img in game_images) + gap_width * (len(game_images)) + content_width = sum(img.width for img in game_images) + gap_width * (len(game_images)) + total_width = display_width + content_width height = self.display_manager.matrix.height self.ticker_image = Image.new('RGB', (total_width, height), color=(0, 0, 0)) @@ -1156,8 +1172,14 @@ class OddsTickerManager: self.ticker_image.putpixel((bar_x, y), (255, 255, 255)) current_x += gap_width - # Calculate total scroll width for dynamic duration - self.total_scroll_width = total_width + # Calculate total scroll width for dynamic duration (only the content width, not including display width) + self.total_scroll_width = content_width + logger.debug(f"Odds ticker image creation:") + logger.debug(f" Display width: {display_width}px") + logger.debug(f" Content width: {content_width}px") + logger.debug(f" Total image width: {total_width}px") + logger.debug(f" Number of games: {len(game_images)}") + logger.debug(f" Gap width: {gap_width}px") self.calculate_dynamic_duration() def _draw_text_with_outline(self, draw: ImageDraw.Draw, text: str, position: tuple, font: ImageFont.FreeTypeFont, @@ -1278,6 +1300,9 @@ class OddsTickerManager: """Display the odds ticker.""" logger.debug("Entering display method") logger.debug(f"Odds ticker enabled: {self.is_enabled}") + logger.debug(f"Current scroll position: {self.scroll_position}") + logger.debug(f"Ticker image width: {self.ticker_image.width if self.ticker_image else 'None'}") + logger.debug(f"Dynamic duration: {self.dynamic_duration}s") if not self.is_enabled: logger.debug("Odds ticker is disabled, exiting display method.") @@ -1326,10 +1351,12 @@ class OddsTickerManager: if self.loop: # Reset position when we've scrolled past the end for a continuous loop if self.scroll_position >= self.ticker_image.width: + logger.debug(f"Odds ticker loop reset: scroll_position {self.scroll_position} >= image width {self.ticker_image.width}") self.scroll_position = 0 else: # Stop scrolling when we reach the end if self.scroll_position >= self.ticker_image.width - width: + logger.debug(f"Odds ticker reached end: scroll_position {self.scroll_position} >= {self.ticker_image.width - width}") self.scroll_position = self.ticker_image.width - width # Signal that scrolling has stopped self.display_manager.set_scrolling_state(False) diff --git a/test/test_odds_ticker_dynamic_duration.py b/test/test_odds_ticker_dynamic_duration.py new file mode 100644 index 00000000..76747c5d --- /dev/null +++ b/test/test_odds_ticker_dynamic_duration.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +""" +Test script for debugging OddsTickerManager dynamic duration calculation +""" + +import sys +import os +import time +import logging + +# Add the parent directory to the Python path so we can import from src +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) + +from src.display_manager import DisplayManager +from src.config_manager import ConfigManager +from src.odds_ticker_manager import OddsTickerManager + +# Configure logging to show debug information +logging.basicConfig( + level=logging.DEBUG, + format='%(asctime)s.%(msecs)03d - %(levelname)s:%(name)s:%(message)s', + datefmt='%H:%M:%S' +) + +def test_dynamic_duration(): + """Test the dynamic duration calculation for odds ticker.""" + print("Testing OddsTickerManager Dynamic Duration...") + + try: + # Load configuration + config_manager = ConfigManager() + config = config_manager.load_config() + + # Initialize display manager + display_manager = DisplayManager(config) + + # Initialize odds ticker + odds_ticker = OddsTickerManager(config, display_manager) + + print(f"Odds ticker enabled: {odds_ticker.is_enabled}") + print(f"Dynamic duration enabled: {odds_ticker.dynamic_duration_enabled}") + print(f"Min duration: {odds_ticker.min_duration}s") + print(f"Max duration: {odds_ticker.max_duration}s") + print(f"Duration buffer: {odds_ticker.duration_buffer}") + print(f"Scroll speed: {odds_ticker.scroll_speed}") + print(f"Scroll delay: {odds_ticker.scroll_delay}") + print(f"Display width: {display_manager.matrix.width}") + + if not odds_ticker.is_enabled: + print("Odds ticker is disabled in config. Enabling for test...") + odds_ticker.is_enabled = True + + # Temporarily disable favorite teams filter for testing + print("Temporarily disabling favorite teams filter to test display...") + original_show_favorite = odds_ticker.show_favorite_teams_only + odds_ticker.show_favorite_teams_only = False + + # Update odds ticker data + print("\nUpdating odds ticker data...") + odds_ticker.update() + + print(f"Found {len(odds_ticker.games_data)} games") + + if odds_ticker.games_data: + print("\nSample game data:") + for i, game in enumerate(odds_ticker.games_data[:3]): # Show first 3 games + print(f" Game {i+1}: {game['away_team']} @ {game['home_team']}") + print(f" Time: {game['start_time']}") + print(f" League: {game['league']}") + if game.get('odds'): + print(f" Has odds: Yes") + else: + print(f" Has odds: No") + print() + + # Check dynamic duration calculation + print("\nDynamic Duration Analysis:") + print(f"Total scroll width: {odds_ticker.total_scroll_width}px") + print(f"Calculated dynamic duration: {odds_ticker.dynamic_duration}s") + + # Calculate expected duration manually + display_width = display_manager.matrix.width + total_scroll_distance = display_width + odds_ticker.total_scroll_width + frames_needed = total_scroll_distance / odds_ticker.scroll_speed + total_time = frames_needed * odds_ticker.scroll_delay + buffer_time = total_time * odds_ticker.duration_buffer + calculated_duration = int(total_time + buffer_time) + + print(f"\nManual calculation:") + print(f" Display width: {display_width}px") + print(f" Content width: {odds_ticker.total_scroll_width}px") + print(f" Total scroll distance: {total_scroll_distance}px") + print(f" Frames needed: {frames_needed:.1f}") + print(f" Base time: {total_time:.2f}s") + print(f" Buffer time: {buffer_time:.2f}s ({odds_ticker.duration_buffer*100}%)") + print(f" Calculated duration: {calculated_duration}s") + + # Test display for a few iterations + print(f"\nTesting display for 10 iterations...") + for i in range(10): + print(f" Display iteration {i+1} starting...") + odds_ticker.display() + print(f" Display iteration {i+1} complete - scroll position: {odds_ticker.scroll_position}") + time.sleep(1) + + else: + print("No games found even with favorite teams filter disabled.") + + # Restore original setting + odds_ticker.show_favorite_teams_only = original_show_favorite + + # Cleanup + display_manager.cleanup() + print("\nTest completed successfully!") + + except Exception as e: + print(f"Error during test: {e}") + import traceback + traceback.print_exc() + +if __name__ == "__main__": + test_dynamic_duration() From e4b3adb867561bf1e640bf7a4c0789d3366e8b2c Mon Sep 17 00:00:00 2001 From: ChuckBuilds <33324927+ChuckBuilds@users.noreply.github.com> Date: Mon, 18 Aug 2025 19:09:50 -0500 Subject: [PATCH 04/83] Fix test script to handle missing game data keys - Added safe key access using .get() method with defaults - Added display of available keys in game data for debugging - Added sport field display to help identify data structure - This prevents KeyError when game data structure changes --- test/test_odds_ticker_dynamic_duration.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/test/test_odds_ticker_dynamic_duration.py b/test/test_odds_ticker_dynamic_duration.py index 76747c5d..f78daf58 100644 --- a/test/test_odds_ticker_dynamic_duration.py +++ b/test/test_odds_ticker_dynamic_duration.py @@ -64,13 +64,15 @@ def test_dynamic_duration(): if odds_ticker.games_data: print("\nSample game data:") for i, game in enumerate(odds_ticker.games_data[:3]): # Show first 3 games - print(f" Game {i+1}: {game['away_team']} @ {game['home_team']}") - print(f" Time: {game['start_time']}") - print(f" League: {game['league']}") + print(f" Game {i+1}: {game.get('away_team', 'Unknown')} @ {game.get('home_team', 'Unknown')}") + print(f" Time: {game.get('start_time', 'Unknown')}") + print(f" League: {game.get('league', 'Unknown')}") + print(f" Sport: {game.get('sport', 'Unknown')}") if game.get('odds'): print(f" Has odds: Yes") else: print(f" Has odds: No") + print(f" Available keys: {list(game.keys())}") print() # Check dynamic duration calculation From 3c1706d4e814962443bc255981d4b8439ad576eb Mon Sep 17 00:00:00 2001 From: ChuckBuilds <33324927+ChuckBuilds@users.noreply.github.com> Date: Mon, 18 Aug 2025 19:13:54 -0500 Subject: [PATCH 05/83] Fix odds ticker dynamic duration timing issue - Modified get_dynamic_duration() to trigger update when total_scroll_width is 0 - This ensures the dynamic duration is calculated with actual data before being used - Prevents fallback to minimum duration (30s) when odds ticker hasn't updated yet - Added debug logging to track when updates are triggered for duration calculation --- src/odds_ticker_manager.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/odds_ticker_manager.py b/src/odds_ticker_manager.py index cdca88be..3c7315d5 100644 --- a/src/odds_ticker_manager.py +++ b/src/odds_ticker_manager.py @@ -1251,6 +1251,17 @@ class OddsTickerManager: def get_dynamic_duration(self) -> int: """Get the calculated dynamic duration for display""" + # If we don't have a valid dynamic duration yet (total_scroll_width is 0), + # try to update the data first + if self.total_scroll_width == 0 and self.is_enabled: + logger.debug("get_dynamic_duration called but total_scroll_width is 0, attempting update...") + try: + # Force an update to get the data and calculate proper duration + self._perform_update() + except Exception as e: + logger.error(f"Error updating odds ticker for dynamic duration: {e}") + + logger.debug(f"get_dynamic_duration called, returning: {self.dynamic_duration}s") return self.dynamic_duration def update(self): From 2b93eafcdf8b657d9051523cd86f3fd52709b6e7 Mon Sep 17 00:00:00 2001 From: ChuckBuilds <33324927+ChuckBuilds@users.noreply.github.com> Date: Mon, 18 Aug 2025 19:23:20 -0500 Subject: [PATCH 06/83] improve odds ticker dynamic duration --- src/music_manager.py | 14 +++++++++++++- src/ncaam_basketball_managers.py | 11 +++++++++++ src/odds_manager.py | 22 +++++++++++++++++----- src/odds_ticker_manager.py | 10 +++++++++- src/soccer_managers.py | 15 +++++++++++++++ src/youtube_display.py | 12 ++++++++++++ templates/index_v2.html | 3 +++ web_interface_v2.py | 24 ++++++++++++++++++++++++ 8 files changed, 104 insertions(+), 7 deletions(-) diff --git a/src/music_manager.py b/src/music_manager.py index ab5a4929..4b24c269 100644 --- a/src/music_manager.py +++ b/src/music_manager.py @@ -6,7 +6,7 @@ import json import os from io import BytesIO import requests -from typing import Union +from typing import Union, Dict, Any, Optional from PIL import Image, ImageEnhance import queue # Added import @@ -15,6 +15,14 @@ from .spotify_client import SpotifyClient from .ytm_client import YTMClient # Removed: import config +# 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, format='%(asctime)s - %(levelname)s - %(message)s') logger = logging.getLogger(__name__) @@ -313,6 +321,10 @@ class MusicManager: try: response = requests.get(url, timeout=5) # 5-second timeout for image download response.raise_for_status() # Raise an exception for bad status codes + + # Increment API counter for music data + increment_api_counter('music', 1) + img_data = BytesIO(response.content) img = Image.open(img_data) diff --git a/src/ncaam_basketball_managers.py b/src/ncaam_basketball_managers.py index 5e7a79f9..d4dc967a 100644 --- a/src/ncaam_basketball_managers.py +++ b/src/ncaam_basketball_managers.py @@ -13,6 +13,14 @@ from src.config_manager import ConfigManager from src.odds_manager import OddsManager 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_NCAAMB_SCOREBOARD_URL = "https://site.api.espn.com/apis/site/v2/sports/basketball/mens-college-basketball/scoreboard" @@ -328,6 +336,9 @@ class BaseNCAAMBasketballManager: response.raise_for_status() data = response.json() + # Increment API counter for sports data + increment_api_counter('sports', 1) + if use_cache: self.cache_manager.set(cache_key, data) diff --git a/src/odds_manager.py b/src/odds_manager.py index b1a221d0..7b11b6b5 100644 --- a/src/odds_manager.py +++ b/src/odds_manager.py @@ -1,13 +1,22 @@ -import requests +import time import logging +import requests import json -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from src.cache_manager import CacheManager -from src.config_manager import ConfigManager -from typing import Optional, List, Dict, Any +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: ConfigManager): + def __init__(self, cache_manager: CacheManager, config_manager=None): self.cache_manager = cache_manager self.config_manager = config_manager self.logger = logging.getLogger(__name__) @@ -31,6 +40,9 @@ class OddsManager: 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) diff --git a/src/odds_ticker_manager.py b/src/odds_ticker_manager.py index 3c7315d5..ef3a9c43 100644 --- a/src/odds_ticker_manager.py +++ b/src/odds_ticker_manager.py @@ -1194,6 +1194,8 @@ class OddsTickerManager: def calculate_dynamic_duration(self): """Calculate the exact time needed to display all odds ticker content""" + logger.debug(f"calculate_dynamic_duration called - dynamic_duration_enabled: {self.dynamic_duration_enabled}, total_scroll_width: {self.total_scroll_width}") + # If dynamic duration is disabled, use fixed duration from config if not self.dynamic_duration_enabled: self.dynamic_duration = self.odds_ticker_config.get('display_duration', 60) @@ -1202,6 +1204,7 @@ class OddsTickerManager: if not self.total_scroll_width: self.dynamic_duration = self.min_duration # Use configured minimum + logger.debug(f"total_scroll_width is 0, using minimum duration: {self.min_duration}s") return try: @@ -1257,7 +1260,12 @@ class OddsTickerManager: logger.debug("get_dynamic_duration called but total_scroll_width is 0, attempting update...") try: # Force an update to get the data and calculate proper duration - self._perform_update() + # Bypass the update interval check for duration calculation + self.games_data = self._fetch_upcoming_games() + self.scroll_position = 0 + self.current_game_index = 0 + self._create_ticker_image() # Create the composite image + logger.debug(f"Force update completed, total_scroll_width: {self.total_scroll_width}px") except Exception as e: logger.error(f"Error updating odds ticker for dynamic duration: {e}") diff --git a/src/soccer_managers.py b/src/soccer_managers.py index 186177fe..66eb276b 100644 --- a/src/soccer_managers.py +++ b/src/soccer_managers.py @@ -14,6 +14,14 @@ from src.config_manager import ConfigManager from src.odds_manager import OddsManager 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_SOCCER_SCOREBOARD_URL = "https://site.api.espn.com/apis/site/v2/sports/soccer/scoreboards" # Old URL ESPN_SOCCER_LEAGUE_SCOREBOARD_URL_FORMAT = "http://site.api.espn.com/apis/site/v2/sports/soccer/{}/scoreboard" # New format string @@ -192,6 +200,9 @@ class BaseSoccerManager: response = requests.get(url, params=params, timeout=10) # Add timeout response.raise_for_status() data = response.json() + + # Increment API counter for sports data + increment_api_counter('sports', 1) cls.logger.debug(f"[Soccer Map Build] Fetched data for {league_slug}") for event in data.get("events", []): @@ -264,6 +275,10 @@ class BaseSoccerManager: response = requests.get(url, params=params) response.raise_for_status() data = response.json() + + # Increment API counter for sports data + increment_api_counter('sports', 1) + self.logger.info(f"[Soccer] Fetched data from ESPN API for {league_slug} on {fetch_date}") if use_cache: diff --git a/src/youtube_display.py b/src/youtube_display.py index d827c970..3299b499 100644 --- a/src/youtube_display.py +++ b/src/youtube_display.py @@ -8,6 +8,14 @@ from rgbmatrix import RGBMatrix, RGBMatrixOptions import os 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__) @@ -57,6 +65,10 @@ class YouTubeDisplay: 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 { diff --git a/templates/index_v2.html b/templates/index_v2.html index 60276621..1b2c71c5 100644 --- a/templates/index_v2.html +++ b/templates/index_v2.html @@ -1996,6 +1996,9 @@
Mbtk0_K`O8vczr}({7aLnYyyEC0
zk5%c=0Wga=-!9oU?C3f@K=zU#;SQbye;Q|GlVg58!ve7Odt8{6S)W`-&YBS%sGA
z{+w_ffhiSNa{)+b5(CdD;j`t-xXCTD)=E62Lrk9%cG
ze<1F@qS>1)+m}M@E9$k+O~j
4U5R=)Y4(J=uG!9Uo#7O{7+R<(F=>ZjqxD&ay+vaDo1vB&Rq`
zJVUCrGAICd;^iAGa5Q7k+0H$~jKO2@WXK0`@lLAW16DISwQ!FI9Fz)1VvKb1PG|M!|0`dwn?N
z84@M5fV*Plp@Ot^l>03-){N685agQ=JPbO5jI2(b5{nA}3>#sBb+;Dvd@6f)ibD?6
zo!N`7Wuw0c>Wmr~C@U}|Y)-;$rw^asEwXAJay%tn8aI^xYlxhWFAM}%fNbViT
<<||9OSaWHCWFm@w!e
z>D22C95FZ%6c>edq=BYTG}+3nWtmw9LLXe4-t=fG4kRN@rX8+z{~JQx16~_0NLbO`
zc#n
_t6+m(<Ama0jBMvA&5)Rok8c}-B!sc1t|?TRqgFVl8*)9X8Mz_lMn3aQ+{81eiVNXh3buG>QrBI#(EBYbPK(?NdktB
zH2u{-JtYVH9T>Ppa(S@S!w5?HWNk02se(9+DjT{lH$Hn6mSuH)^>Prm_p7w`u(=s^
zn=NasT%uww>RW1bW2W}UsW*9D;%y=_%}w)^+Q#!SObGPMTAJUMAb3Wr?~T+{?lBbn
z@UFqZr~I6wDnPDn>O$|!tHY37t|e2}+qKsco2$kdlQ8t^o;K#WHgfF313Y&(ZrPn3E2s&s(mIUqbEQuLaP{