From 8cbdef394972b7bee08c4bf0da073c040ea1eda1 Mon Sep 17 00:00:00 2001 From: Chuck <33324927+ChuckBuilds@users.noreply.github.com> Date: Mon, 22 Sep 2025 20:42:10 -0400 Subject: [PATCH] Fix/leaderboard timing improvements (#60) * Fix leaderboard timing issues with comprehensive improvements - Add maximum display time cap (120s) to prevent hanging - Implement dynamic scroll speed tracking with runtime measurements - Simplify complex timing logic that was causing hangs - Add enhanced progress tracking and logging - Add configurable safety buffer (10s) - Update config template with new timing options - Add comprehensive test suite for timing logic Fixes the 30-second hanging issue reported in PR #53 by providing multiple layers of protection against time overestimation. * Simplify leaderboard timing to use long timeout with exception-based ending - Remove complex dynamic duration calculations - Remove safety buffer complexity - Remove scroll speed tracking and measurements - Use simple 10-minute timeout (600s) for both display_duration and max_display_time - Let content determine when display is complete via existing StopIteration logic - Update display controller to use simplified duration approach - Clean up config template to remove unused timing settings This approach is much more reliable than trying to predict content duration and eliminates the hanging issues reported in PR #53. * Fix configuration structure to use centralized display_durations - Remove redundant display_duration from leaderboard section - Use main display_durations.leaderboard (300s) for fixed duration mode - Update leaderboard manager to read from centralized config - Increase leaderboard default duration from 60s to 300s for better content coverage - Maintain dynamic_duration option for user choice between fixed/dynamic modes - Add comprehensive scroll behavior analysis and testing This completes the leaderboard timing improvements with proper config structure. * scroll every frame to be smoother like the stock ticker instead of waiting per subsecond * leaderboard block api calls while scrolling * leaderboard debugging * added leaderboard fps logging * leaderboard frame control and optimizations * background update memory leak for scrolling text found and first solution applied * tuning scroll speeds * working display scrolls * revert scroll delay to 0.01 (about 100fps) * revert min duration of leaderboard * remove onetime test scripts --- .../ncaa_logos/{TAANDM.png => TA&M.png} | Bin config/config.template.json | 8 +- src/display_controller.py | 36 +- src/display_manager.py | 75 +++- src/leaderboard_manager.py | 364 +++++++----------- src/stock_news_manager.py | 2 +- test_config_display.py | 197 ---------- test_core_logic.py | 154 -------- 8 files changed, 231 insertions(+), 605 deletions(-) rename assets/sports/ncaa_logos/{TAANDM.png => TA&M.png} (100%) delete mode 100644 test_config_display.py delete mode 100644 test_core_logic.py diff --git a/assets/sports/ncaa_logos/TAANDM.png b/assets/sports/ncaa_logos/TA&M.png similarity index 100% rename from assets/sports/ncaa_logos/TAANDM.png rename to assets/sports/ncaa_logos/TA&M.png diff --git a/config/config.template.json b/config/config.template.json index 643dec3d..6f062984 100644 --- a/config/config.template.json +++ b/config/config.template.json @@ -39,7 +39,7 @@ "daily_forecast": 30, "stock_news": 20, "odds_ticker": 60, - "leaderboard": 60, + "leaderboard": 300, "nhl_live": 30, "nhl_recent": 30, "nhl_upcoming": 30, @@ -197,13 +197,11 @@ "update_interval": 3600, "scroll_speed": 1, "scroll_delay": 0.01, - "display_duration": 60, "loop": false, "request_timeout": 30, "dynamic_duration": true, - "min_duration": 45, - "max_duration": 600, - "duration_buffer": 0.1, + "min_duration": 30, + "max_display_time": 600, "background_service": { "enabled": true, "max_workers": 3, diff --git a/src/display_controller.py b/src/display_controller.py index e9811f78..ba2c4ea7 100644 --- a/src/display_controller.py +++ b/src/display_controller.py @@ -508,11 +508,15 @@ class DisplayController: if not hasattr(self, '_last_logged_duration') or self._last_logged_duration != dynamic_duration: logger.info(f"Using dynamic duration for stocks: {dynamic_duration} seconds") self._last_logged_duration = dynamic_duration + # Debug: Always log the current dynamic duration value + logger.debug(f"Stocks dynamic duration check: {dynamic_duration}s") return dynamic_duration except Exception as e: logger.error(f"Error getting dynamic duration for stocks: {e}") # Fall back to configured duration - return self.display_durations.get(mode_key, 60) + fallback_duration = self.display_durations.get(mode_key, 60) + logger.debug(f"Using fallback duration for stocks: {fallback_duration}s") + return fallback_duration # Handle dynamic duration for stock_news if mode_key == 'stock_news' and self.news: @@ -542,19 +546,20 @@ class DisplayController: # Fall back to configured duration return self.display_durations.get(mode_key, 60) - # Handle dynamic duration for leaderboard + # Handle leaderboard duration (user choice between fixed or dynamic) if mode_key == 'leaderboard' and self.leaderboard: try: - dynamic_duration = self.leaderboard.get_dynamic_duration() + duration = self.leaderboard.get_duration() + mode_type = "dynamic" if self.leaderboard.dynamic_duration else "fixed" # Only log if duration has changed or we haven't logged this duration yet - if not hasattr(self, '_last_logged_leaderboard_duration') or self._last_logged_leaderboard_duration != dynamic_duration: - logger.info(f"Using dynamic duration for leaderboard: {dynamic_duration} seconds") - self._last_logged_leaderboard_duration = dynamic_duration - return dynamic_duration + if not hasattr(self, '_last_logged_leaderboard_duration') or self._last_logged_leaderboard_duration != duration: + logger.info(f"Using leaderboard {mode_type} duration: {duration} seconds") + self._last_logged_leaderboard_duration = duration + return duration except Exception as e: - logger.error(f"Error getting dynamic duration for leaderboard: {e}") + logger.error(f"Error getting duration for leaderboard: {e}") # Fall back to configured duration - return self.display_durations.get(mode_key, 60) + return self.display_durations.get(mode_key, 600) # Simplify weather key handling if mode_key.startswith('weather_'): @@ -576,6 +581,8 @@ class DisplayController: # 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.leaderboard: + self.display_manager.defer_update(self.leaderboard.update, priority=1) if self.stocks: self.display_manager.defer_update(self.stocks.update_stock_data, priority=2) if self.news: @@ -1127,8 +1134,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() + # Process deferred updates less frequently when scrolling to improve performance + if not self.display_manager.is_currently_scrolling() or (current_time % 2.0 < 0.1): + self.display_manager.process_deferred_updates() # Update live modes in rotation if needed self._update_live_modes_in_rotation() @@ -1250,6 +1258,10 @@ class DisplayController: if hasattr(self, '_last_logged_duration'): delattr(self, '_last_logged_duration') elif current_time - self.last_switch >= self.get_current_duration() or self.force_change: + # Debug timing information + elapsed_time = current_time - self.last_switch + expected_duration = self.get_current_duration() + logger.debug(f"Mode switch triggered: {self.current_display_mode} - Elapsed: {elapsed_time:.1f}s, Expected: {expected_duration}s, Force: {self.force_change}") self.force_change = False if self.current_display_mode == 'calendar' and self.calendar: self.calendar.advance_event() @@ -1271,6 +1283,8 @@ class DisplayController: if needs_switch: self.force_clear = True self.last_switch = current_time + # Debug: Log when we set the switch time for a new mode + logger.debug(f"Mode switch completed: {self.current_display_mode} - Switch time set to {current_time}, Duration: {self.get_current_duration()}s") else: self.force_clear = False # Only set manager_to_display if it hasn't been set by live priority logic diff --git a/src/display_manager.py b/src/display_manager.py index 4ee9ad72..90ea31b3 100644 --- a/src/display_manager.py +++ b/src/display_manager.py @@ -40,7 +40,9 @@ class DisplayManager: 'is_scrolling': False, 'last_scroll_activity': 0, 'scroll_inactivity_threshold': 2.0, # seconds of inactivity before considering "not scrolling" - 'deferred_updates': [] + 'deferred_updates': [], + 'max_deferred_updates': 50, # Limit queue size to prevent memory issues + 'deferred_update_ttl': 300.0 # 5 minutes TTL for deferred updates } self._setup_matrix() @@ -677,13 +679,27 @@ class DisplayManager: update_func: Function to call when not scrolling priority: Priority level (lower numbers = higher priority) """ + current_time = time.time() + + # Clean up expired updates before adding new ones + self._cleanup_expired_deferred_updates(current_time) + + # Limit queue size to prevent memory issues + if len(self._scrolling_state['deferred_updates']) >= self._scrolling_state['max_deferred_updates']: + # Remove oldest update to make room + self._scrolling_state['deferred_updates'].pop(0) + logger.debug("Removed oldest deferred update due to queue size limit") + self._scrolling_state['deferred_updates'].append({ 'func': update_func, 'priority': priority, - 'timestamp': time.time() + 'timestamp': current_time }) - # Sort by priority (lower numbers first) - self._scrolling_state['deferred_updates'].sort(key=lambda x: x['priority']) + + # Only sort if we have a reasonable number of updates to avoid excessive sorting + if len(self._scrolling_state['deferred_updates']) <= 20: + 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): @@ -693,21 +709,56 @@ class DisplayManager: if not self._scrolling_state['deferred_updates']: return + + current_time = time.time() + + # Clean up expired updates first + self._cleanup_expired_deferred_updates(current_time) - # Process all deferred updates - updates_to_process = self._scrolling_state['deferred_updates'].copy() - self._scrolling_state['deferred_updates'].clear() + if not self._scrolling_state['deferred_updates']: + return + + # Process only a limited number of updates per call to avoid blocking + max_updates_per_call = min(5, len(self._scrolling_state['deferred_updates'])) + updates_to_process = self._scrolling_state['deferred_updates'][:max_updates_per_call] + self._scrolling_state['deferred_updates'] = self._scrolling_state['deferred_updates'][max_updates_per_call:] - logger.debug(f"Processing {len(updates_to_process)} deferred updates") + logger.debug(f"Processing {len(updates_to_process)} deferred updates (queue size: {len(self._scrolling_state['deferred_updates'])})") + failed_updates = [] for update_info in updates_to_process: try: + # Check if update is still valid (not too old) + if current_time - update_info['timestamp'] > self._scrolling_state['deferred_update_ttl']: + logger.debug("Skipping expired deferred update") + continue + 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) + # Only retry recent failures, and limit retries + if current_time - update_info['timestamp'] < 60.0: # Only retry for 1 minute + failed_updates.append(update_info) + + # Re-add failed updates to the end of the queue (not the beginning) + if failed_updates: + self._scrolling_state['deferred_updates'].extend(failed_updates) + + def _cleanup_expired_deferred_updates(self, current_time: float): + """Remove expired deferred updates to prevent memory leaks.""" + ttl = self._scrolling_state['deferred_update_ttl'] + initial_count = len(self._scrolling_state['deferred_updates']) + + # Filter out expired updates + self._scrolling_state['deferred_updates'] = [ + update for update in self._scrolling_state['deferred_updates'] + if current_time - update['timestamp'] <= ttl + ] + + removed_count = initial_count - len(self._scrolling_state['deferred_updates']) + if removed_count > 0: + logger.debug(f"Cleaned up {removed_count} expired deferred updates") def get_scrolling_stats(self) -> dict: """Get current scrolling statistics for debugging.""" @@ -715,7 +766,9 @@ class DisplayManager: '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'] + 'inactivity_threshold': self._scrolling_state['scroll_inactivity_threshold'], + 'max_deferred_updates': self._scrolling_state['max_deferred_updates'], + 'deferred_update_ttl': self._scrolling_state['deferred_update_ttl'] } def _write_snapshot_if_due(self) -> None: diff --git a/src/leaderboard_manager.py b/src/leaderboard_manager.py index 872b845e..82e97ae0 100644 --- a/src/leaderboard_manager.py +++ b/src/leaderboard_manager.py @@ -40,17 +40,15 @@ class LeaderboardManager: self.update_interval = self.leaderboard_config.get('update_interval', 3600) self.scroll_speed = self.leaderboard_config.get('scroll_speed', 1) self.scroll_delay = self.leaderboard_config.get('scroll_delay', 0.01) - self.display_duration = self.leaderboard_config.get('display_duration', 30) self.loop = self.leaderboard_config.get('loop', True) self.request_timeout = self.leaderboard_config.get('request_timeout', 30) self.time_over = 0 - # Dynamic duration settings - self.dynamic_duration_enabled = self.leaderboard_config.get('dynamic_duration', True) - self.min_duration = self.leaderboard_config.get('min_duration', 30) - self.max_duration = self.leaderboard_config.get('max_duration', 300) - self.duration_buffer = self.leaderboard_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 + + # Duration settings - user can choose between fixed or dynamic (exception-based) + self.dynamic_duration = self.leaderboard_config.get('dynamic_duration', True) + # Get duration from main display_durations section + self.display_duration = config.get('display', {}).get('display_durations', {}).get('leaderboard', 300) + self.max_display_time = self.leaderboard_config.get('max_display_time', 600) # 10 minutes maximum # Initialize managers self.cache_manager = CacheManager() @@ -80,6 +78,19 @@ class LeaderboardManager: self.leaderboard_image = None # This will hold the single, wide image self.last_display_time = 0 + # FPS tracking variables + self.frame_times = [] # Store last 30 frame times for averaging + self.last_frame_time = 0 + self.fps_log_interval = 10.0 # Log FPS every 10 seconds + self.last_fps_log_time = 0 + + # Performance optimization caches + self._cached_draw = None + self._last_visible_image = None + self._last_scroll_position = -1 + self._text_measurement_cache = {} # Cache for font measurements + self._logo_cache = {} # Cache for resized logos + # Font setup self.fonts = self._load_fonts() @@ -240,6 +251,19 @@ class LeaderboardManager: } return fonts + def _get_cached_text_bbox(self, text, font_name): + """Get cached text bounding box measurements.""" + cache_key = f"{text}_{font_name}" + if cache_key not in self._text_measurement_cache: + font = self.fonts[font_name] + bbox = font.getbbox(text) + self._text_measurement_cache[cache_key] = { + 'width': bbox[2] - bbox[0], + 'height': bbox[3] - bbox[1], + 'bbox': bbox + } + return self._text_measurement_cache[cache_key] + def _draw_text_with_outline(self, draw, text, position, font, fill=(255, 255, 255), outline_color=(0, 0, 0)): """Draw text with a black outline for better readability on LED matrix.""" x, y = position @@ -249,30 +273,35 @@ class LeaderboardManager: # Draw text draw.text((x, y), text, font=font, fill=fill) + def _get_cached_resized_logo(self, team_abbr: str, logo_dir: str, size: int, league: str = None, team_name: str = None) -> Optional[Image.Image]: + """Get cached resized team logo.""" + cache_key = f"{team_abbr}_{logo_dir}_{size}" + if cache_key not in self._logo_cache: + logo = self._get_team_logo(team_abbr, logo_dir, league, team_name) + if logo: + resized_logo = logo.resize((size, size), Image.Resampling.LANCZOS) + self._logo_cache[cache_key] = resized_logo + else: + self._logo_cache[cache_key] = None + return self._logo_cache[cache_key] + def _get_team_logo(self, team_abbr: str, logo_dir: str, league: str = None, team_name: str = None) -> Optional[Image.Image]: """Get team logo from the configured directory, downloading if missing.""" if not team_abbr or not logo_dir: - logger.debug("Cannot get team logo with missing team_abbr or logo_dir") return None try: logo_path = os.path.join(logo_dir, f"{team_abbr}.png") - logger.debug(f"Attempting to load logo from path: {logo_path}") if os.path.exists(logo_path): logo = Image.open(logo_path) - logger.debug(f"Successfully loaded logo for {team_abbr} from {logo_path}") return logo else: - logger.warning(f"Logo not found at path: {logo_path}") - # Try to download the missing logo if we have league information if league: - logger.info(f"Attempting to download missing logo for {team_abbr} in league {league}") success = download_missing_logo(team_abbr, league, team_name) if success: # Try to load the downloaded logo if os.path.exists(logo_path): logo = Image.open(logo_path) - logger.info(f"Successfully downloaded and loaded logo for {team_abbr}") return logo return None @@ -920,13 +949,13 @@ class LeaderboardManager: # For other leagues, show position number_text = f"{i+1}." - number_bbox = self.fonts['xlarge'].getbbox(number_text) - number_width = number_bbox[2] - number_bbox[0] + number_measurements = self._get_cached_text_bbox(number_text, 'xlarge') + number_width = number_measurements['width'] # Calculate width for team abbreviation (use large font like in drawing) team_text = team['abbreviation'] - text_bbox = self.fonts['large'].getbbox(team_text) - text_width = text_bbox[2] - text_bbox[0] + text_measurements = self._get_cached_text_bbox(team_text, 'large') + text_width = text_measurements['width'] # Total team width: bold number + spacing + logo + spacing + text + spacing team_width = number_width + 4 + logo_size + 4 + text_width + 12 # Spacing between teams @@ -996,18 +1025,16 @@ class LeaderboardManager: # For other leagues, show position number_text = f"{i+1}." - number_bbox = self.fonts['xlarge'].getbbox(number_text) - number_width = number_bbox[2] - number_bbox[0] - number_height = number_bbox[3] - number_bbox[1] + number_measurements = self._get_cached_text_bbox(number_text, 'xlarge') + number_width = number_measurements['width'] + number_height = number_measurements['height'] number_y = (height - number_height) // 2 self._draw_text_with_outline(draw, number_text, (team_x, number_y), self.fonts['xlarge'], fill=(255, 255, 0)) - # Draw team logo (95% of display height, centered vertically) - team_logo = self._get_team_logo(team['abbreviation'], league_config['logo_dir'], - league=league_key, team_name=team.get('name')) + # Draw team logo (cached and resized) + team_logo = self._get_cached_resized_logo(team['abbreviation'], league_config['logo_dir'], + logo_size, league=league_key, team_name=team.get('name')) if team_logo: - # Resize team logo to dynamic size (95% of display height) - team_logo = team_logo.resize((logo_size, logo_size), Image.Resampling.LANCZOS) # Paste team logo after the bold number (centered vertically) logo_x = team_x + number_width + 4 @@ -1016,9 +1043,9 @@ class LeaderboardManager: # Draw team abbreviation after the logo (centered vertically) team_text = team['abbreviation'] - text_bbox = self.fonts['large'].getbbox(team_text) - text_width = text_bbox[2] - text_bbox[0] - text_height = text_bbox[3] - text_bbox[1] + text_measurements = self._get_cached_text_bbox(team_text, 'large') + text_width = text_measurements['width'] + text_height = text_measurements['height'] text_x = logo_x + logo_size + 4 text_y = (height - text_height) // 2 self._draw_text_with_outline(draw, team_text, (text_x, text_y), self.fonts['large'], fill=(255, 255, 255)) @@ -1028,9 +1055,9 @@ class LeaderboardManager: else: # Fallback if no logo - draw team abbreviation after bold number (centered vertically) team_text = team['abbreviation'] - text_bbox = self.fonts['large'].getbbox(team_text) - text_width = text_bbox[2] - text_bbox[0] - text_height = text_bbox[3] - text_bbox[1] + text_measurements = self._get_cached_text_bbox(team_text, 'large') + text_width = text_measurements['width'] + text_height = text_measurements['height'] text_x = team_x + number_width + 4 text_y = (height - text_height) // 2 self._draw_text_with_outline(draw, team_text, (text_x, text_y), self.fonts['large'], fill=(255, 255, 255)) @@ -1081,11 +1108,11 @@ class LeaderboardManager: else: number_text = f"{j+1}." - number_bbox = self.fonts['xlarge'].getbbox(number_text) - number_width = number_bbox[2] - number_bbox[0] + number_measurements = self._get_cached_text_bbox(number_text, 'xlarge') + number_width = number_measurements['width'] team_text = team['abbreviation'] - text_bbox = self.fonts['large'].getbbox(team_text) - text_width = text_bbox[2] - text_bbox[0] + text_measurements = self._get_cached_text_bbox(team_text, 'large') + text_width = text_measurements['width'] team_width = number_width + 4 + logo_size + 4 + text_width + 12 teams_width += team_width @@ -1106,126 +1133,20 @@ class LeaderboardManager: logger.info(f"Total image width: {total_width}px, Display width: {height}px") - # Calculate dynamic duration using proper scroll-based calculation - if self.dynamic_duration_enabled: - self.calculate_dynamic_duration() logger.info(f"Created leaderboard image with width {total_width}") except Exception as e: logger.error(f"Error creating leaderboard image: {e}") self.leaderboard_image = None - def calculate_dynamic_duration(self): - """Calculate the exact time needed to display all leaderboard content""" - logger.info(f"Calculating dynamic duration - enabled: {self.dynamic_duration_enabled}, content width: {self.total_scroll_width}px") - - # If dynamic duration is disabled, use fixed duration from config - if not self.dynamic_duration_enabled: - self.dynamic_duration = self.leaderboard_config.get('display_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 - logger.debug(f"total_scroll_width is 0, using minimum duration: {self.min_duration}s") - return - - try: - # Get display width (assume full width of display) - display_width = getattr(self.display_manager, 'matrix', None) - if display_width: - display_width = display_width.width - else: - display_width = 128 # Default to 128 if not available - - # Calculate total scroll distance needed - # For looping content, we need to scroll the entire content width - # For non-looping content, we need content width minus display width (since last part shows fully) - if self.loop: - total_scroll_distance = self.total_scroll_width - else: - # For single pass, we need to scroll until the last content is fully visible - total_scroll_distance = max(0, self.total_scroll_width - display_width) - - # Calculate time based on scroll speed and delay - # scroll_speed = pixels per frame, scroll_delay = seconds per frame - # However, actual observed speed is slower than theoretical calculation - # Based on log analysis: 1950px in 36s = 54.2 px/s actual speed - # vs theoretical: 1px/0.01s = 100 px/s - # Use actual observed speed for more accurate timing - actual_scroll_speed = 54.2 # pixels per second (calculated from logs) - total_time = total_scroll_distance / actual_scroll_speed - - # Add buffer time for smooth cycling (configurable %) - buffer_time = total_time * self.duration_buffer - - # Calculate duration for single complete pass - if self.loop: - # For looping: set duration to exactly one loop cycle (no extra time to prevent multiple loops) - calculated_duration = int(total_time) - logger.debug(f"Looping enabled, duration set to exactly one loop cycle: {calculated_duration}s") - else: - # For single pass: precise calculation to show content exactly once - # Add buffer to prevent cutting off the last content - completion_buffer = total_time * 0.05 # 5% extra to ensure complete display - calculated_duration = int(total_time + buffer_time + completion_buffer) - logger.debug(f"Single pass mode, added {completion_buffer:.2f}s completion buffer for precise timing") - - # 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 - - # Additional safety check: if the calculated duration seems too short for the content, - # ensure we have enough time to display all content properly - if self.dynamic_duration < 45 and self.total_scroll_width > 200: - # If we have content but short duration, increase it - # Use a more generous calculation: at least 45s or 1s per 20px - self.dynamic_duration = max(45, int(self.total_scroll_width / 20)) - logger.debug(f"Adjusted duration for content: {self.dynamic_duration}s (content width: {self.total_scroll_width}px)") - - logger.info(f"Leaderboard dynamic duration calculation:") - logger.info(f" Display width: {display_width}px") - logger.info(f" Content width: {self.total_scroll_width}px") - logger.info(f" Total scroll distance: {total_scroll_distance}px") - logger.info(f" Configured scroll speed: {self.scroll_speed}px/frame") - logger.info(f" Configured scroll delay: {self.scroll_delay}s/frame") - logger.info(f" Actual observed scroll speed: {actual_scroll_speed}px/s (from log analysis)") - logger.info(f" Base time: {total_time:.2f}s") - logger.info(f" Buffer time: {buffer_time:.2f}s ({self.duration_buffer*100}%)") - logger.info(f" Looping enabled: {self.loop}") - logger.info(f" Calculated duration: {calculated_duration}s") - logger.info(f"Final calculated duration: {self.dynamic_duration}s") - - # Verify the duration makes sense for the content - expected_scroll_time = self.total_scroll_width / actual_scroll_speed - logger.info(f" Verification - Time to scroll content: {expected_scroll_time:.1f}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""" - # 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 - # Bypass the update interval check for duration calculation - self.update() - logger.debug(f"Force update completed, total_scroll_width: {self.total_scroll_width}px") - except Exception as e: - logger.error(f"Error updating leaderboard for dynamic duration: {e}") - - logger.debug(f"get_dynamic_duration called, returning: {self.dynamic_duration}s") - return self.dynamic_duration + def get_duration(self) -> int: + """Get the duration for display based on user preference""" + if self.dynamic_duration: + # Use long timeout and let content determine when done via StopIteration + return self.max_display_time + else: + # Use fixed duration from config + return self.display_duration def update(self) -> None: """Update leaderboard data.""" @@ -1277,67 +1198,74 @@ class LeaderboardManager: def display(self, force_clear: bool = False) -> None: """Display the leaderboard.""" - logger.debug("Entering leaderboard display method") - logger.debug(f"Leaderboard enabled: {self.is_enabled}") - logger.debug(f"Current scroll position: {self.scroll_position}") - logger.debug(f"Leaderboard image width: {self.leaderboard_image.width if self.leaderboard_image else 'None'}") - logger.debug(f"Using dynamic duration for leaderboard: {self.dynamic_duration}s") - if not self.is_enabled: - logger.debug("Leaderboard is disabled, exiting display method.") return # Reset display start time when force_clear is True or when starting fresh if force_clear or not hasattr(self, '_display_start_time'): self._display_start_time = time.time() - logger.debug(f"Reset/initialized display start time: {self._display_start_time}") # Also reset scroll position for clean start self.scroll_position = 0 - else: - # Check if the display start time is too old (more than 2x the dynamic duration) - current_time = time.time() - elapsed_time = current_time - self._display_start_time - if elapsed_time > (self.dynamic_duration * 2): - logger.debug(f"Display start time is too old ({elapsed_time:.1f}s), resetting") - self._display_start_time = current_time - self.scroll_position = 0 + # Initialize FPS tracking + self.last_frame_time = 0 + self.frame_times = [] + self.last_fps_log_time = time.time() + # Reset performance caches + self._cached_draw = None + self._last_visible_image = None + self._last_scroll_position = -1 + # Clear caches but limit their size to prevent memory leaks + if len(self._text_measurement_cache) > 100: + self._text_measurement_cache.clear() + if len(self._logo_cache) > 50: + self._logo_cache.clear() + logger.info("Leaderboard FPS tracking initialized") - logger.debug(f"Number of leagues in data at start of display method: {len(self.leaderboard_data)}") if not self.leaderboard_data: - logger.warning("Leaderboard has no data. Attempting to update...") self.update() if not self.leaderboard_data: - logger.warning("Still no data after update. Displaying fallback message.") self._display_fallback_message() return if self.leaderboard_image is None: - logger.warning("Leaderboard image is not available. Attempting to create it.") self._create_leaderboard_image() if self.leaderboard_image is None: - logger.error("Failed to create leaderboard image.") self._display_fallback_message() return try: current_time = time.time() - # Check if we should be scrolling - should_scroll = current_time - self.last_scroll_time >= self.scroll_delay + # FPS tracking only (no artificial throttling) + if self.last_frame_time > 0: + frame_time = current_time - self.last_frame_time + + # FPS tracking - use circular buffer to prevent memory growth + self.frame_times.append(frame_time) + if len(self.frame_times) > 30: # Keep buffer size reasonable + self.frame_times.pop(0) + + # Log FPS status every 10 seconds + if current_time - self.last_fps_log_time >= self.fps_log_interval: + if self.frame_times: + avg_frame_time = sum(self.frame_times) / len(self.frame_times) + current_fps = 1.0 / frame_time if frame_time > 0 else 0 + avg_fps = 1.0 / avg_frame_time if avg_frame_time > 0 else 0 + logger.info(f"Leaderboard FPS: Current={current_fps:.1f}, Average={avg_fps:.1f}, Frame Time={frame_time*1000:.1f}ms") + self.last_fps_log_time = current_time + + self.last_frame_time = current_time # 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() + self.display_manager.set_scrolling_state(True) - # Scroll the image - if should_scroll: - self.scroll_position += self.scroll_speed - self.last_scroll_time = current_time + # Scroll the image every frame for smooth animation + self.scroll_position += self.scroll_speed - # Calculate crop region + # Add scroll delay like other managers for consistent timing + time.sleep(self.scroll_delay) + + # Get display dimensions once width = self.display_manager.matrix.width height = self.display_manager.matrix.height @@ -1345,67 +1273,51 @@ class LeaderboardManager: if self.loop: # Reset position when we've scrolled past the end for a continuous loop if self.scroll_position >= self.leaderboard_image.width: - logger.info(f"Leaderboard loop reset: scroll_position {self.scroll_position} >= image width {self.leaderboard_image.width}") self.scroll_position = 0 - logger.info("Leaderboard starting new loop cycle") else: # Stop scrolling when we reach the end if self.scroll_position >= self.leaderboard_image.width - width: - logger.info(f"Leaderboard reached end: scroll_position {self.scroll_position} >= {self.leaderboard_image.width - width}") self.scroll_position = self.leaderboard_image.width - width # Signal that scrolling has stopped self.display_manager.set_scrolling_state(False) - logger.info("Leaderboard scrolling stopped - reached end of content") if self.time_over == 0: self.time_over = time.time() elif time.time() - self.time_over >= 2: self.time_over = 0 raise StopIteration - # Check if we're at a natural break point for mode switching + # Simple timeout check - prevent hanging beyond maximum display time elapsed_time = current_time - self._display_start_time - remaining_time = self.dynamic_duration - elapsed_time + if elapsed_time > self.max_display_time: + raise StopIteration("Maximum display time exceeded") - # Log scroll progress every 50 pixels to help debug (less verbose) - if self.scroll_position % 50 == 0 and self.scroll_position > 0: - logger.info(f"Leaderboard progress: elapsed={elapsed_time:.1f}s, remaining={remaining_time:.1f}s, scroll_pos={self.scroll_position}/{self.leaderboard_image.width}px") - - # If we have less than 2 seconds remaining, check if we can complete the content display - if remaining_time < 2.0 and self.scroll_position > 0: - # Calculate how much time we need to complete the current scroll position - # Use actual observed scroll speed (54.2 px/s) instead of theoretical calculation - actual_scroll_speed = 54.2 # pixels per second (calculated from logs) + # Optimize: Only create new visible image if scroll position changed significantly + # Use integer scroll position to reduce unnecessary crops + int_scroll_position = int(self.scroll_position) + if int_scroll_position != self._last_scroll_position: + # Ensure crop coordinates are within bounds + crop_left = max(0, int_scroll_position) + crop_right = min(self.leaderboard_image.width, int_scroll_position + width) - if self.loop: - # For looping, we need to complete one full cycle - distance_to_complete = self.leaderboard_image.width - self.scroll_position + if crop_right > crop_left: # Valid crop region + # Create the visible part of the image by cropping from the leaderboard_image + self._last_visible_image = self.leaderboard_image.crop(( + crop_left, + 0, + crop_right, + height + )) + self._last_scroll_position = int_scroll_position + + # Cache the draw object to avoid creating it every frame + self._cached_draw = ImageDraw.Draw(self._last_visible_image) else: - # For single pass, we need to reach the end (content width minus display width) - end_position = max(0, self.leaderboard_image.width - width) - distance_to_complete = end_position - self.scroll_position - - time_to_complete = distance_to_complete / actual_scroll_speed - - if time_to_complete <= remaining_time: - # We have enough time to complete the scroll, continue normally - logger.debug(f"Sufficient time remaining ({remaining_time:.1f}s) to complete scroll ({time_to_complete:.1f}s)") - else: - # Not enough time, reset to beginning for clean transition - logger.warning(f"Not enough time to complete content display - remaining: {remaining_time:.1f}s, needed: {time_to_complete:.1f}s") - logger.debug(f"Resetting scroll position for clean transition") - self.scroll_position = 0 - - # Create the visible part of the image by cropping from the leaderboard_image - visible_image = self.leaderboard_image.crop(( - self.scroll_position, - 0, - self.scroll_position + width, - height - )) + # Invalid crop region, skip this frame + return # Display the visible portion - self.display_manager.image = visible_image - self.display_manager.draw = ImageDraw.Draw(self.display_manager.image) + self.display_manager.image = self._last_visible_image + self.display_manager.draw = self._cached_draw self.display_manager.update_display() except StopIteration as e: diff --git a/src/stock_news_manager.py b/src/stock_news_manager.py index 6ea64299..0602f897 100644 --- a/src/stock_news_manager.py +++ b/src/stock_news_manager.py @@ -44,7 +44,7 @@ class StockNewsManager: # 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.005) # Default to 5ms for smoother scrolling + 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) diff --git a/test_config_display.py b/test_config_display.py deleted file mode 100644 index 98222ce4..00000000 --- a/test_config_display.py +++ /dev/null @@ -1,197 +0,0 @@ -#!/usr/bin/env python3 -""" -Test script to verify the safe_config_get function and template logic works correctly. -""" -import json -import sys -import os - -# Add the src directory to Python path -sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src')) - -class DictWrapper: - """Wrapper to make dictionary accessible via dot notation for Jinja2 templates.""" - def __init__(self, data=None): - # Store the original data - object.__setattr__(self, '_data', data if isinstance(data, dict) else {}) - - # Set attributes from the dictionary - if isinstance(data, dict): - for key, value in data.items(): - if isinstance(value, dict): - object.__setattr__(self, key, DictWrapper(value)) - elif isinstance(value, list): - object.__setattr__(self, key, value) - else: - object.__setattr__(self, key, value) - - def __getattr__(self, name): - # Return a new empty DictWrapper for missing attributes - # This allows chaining like main_config.display.hardware.rows - return DictWrapper({}) - - def __str__(self): - # Return empty string for missing values to avoid template errors - data = object.__getattribute__(self, '_data') - if not data: - return '' - return str(data) - - def __int__(self): - # Return 0 for missing numeric values - data = object.__getattribute__(self, '_data') - if not data: - return 0 - try: - return int(data) - except (ValueError, TypeError): - return 0 - - def __bool__(self): - # Return False for missing boolean values - data = object.__getattribute__(self, '_data') - if not data: - return False - return bool(data) - - def get(self, key, default=None): - # Support .get() method like dictionaries - data = object.__getattribute__(self, '_data') - if data and key in data: - return data[key] - return default - -def safe_config_get(config, *keys, default=''): - """Safely get nested config values with fallback.""" - try: - current = config - for key in keys: - if hasattr(current, key): - current = getattr(current, key) - # Check if we got an empty DictWrapper - if isinstance(current, DictWrapper): - data = object.__getattribute__(current, '_data') - if not data: # Empty DictWrapper means missing config - return default - elif isinstance(current, dict) and key in current: - current = current[key] - else: - return default - - # Final check for empty values - if current is None or (hasattr(current, '_data') and not object.__getattribute__(current, '_data')): - return default - return current - except (AttributeError, KeyError, TypeError): - return default - -def test_config_access(): - """Test the safe config access with actual config data.""" - print("Testing safe_config_get function...") - - # Load the actual config - try: - with open('config/config.json', 'r') as f: - config_data = json.load(f) - print("✓ Successfully loaded config.json") - except Exception as e: - print(f"✗ Failed to load config.json: {e}") - return False - - # Wrap the config - main_config = DictWrapper(config_data) - print("✓ Successfully wrapped config in DictWrapper") - - # Test critical configuration values - test_cases = [ - ('display.hardware.rows', 32), - ('display.hardware.cols', 64), - ('display.hardware.brightness', 95), - ('display.hardware.chain_length', 2), - ('display.hardware.parallel', 1), - ('display.hardware.hardware_mapping', 'adafruit-hat-pwm'), - ('display.runtime.gpio_slowdown', 3), - ('display.hardware.scan_mode', 0), - ('display.hardware.pwm_bits', 9), - ('display.hardware.pwm_dither_bits', 1), - ('display.hardware.pwm_lsb_nanoseconds', 130), - ('display.hardware.limit_refresh_rate_hz', 120), - ('display.hardware.disable_hardware_pulsing', False), - ('display.hardware.inverse_colors', False), - ('display.hardware.show_refresh_rate', False), - ('display.use_short_date_format', True), - ] - - print("\nTesting configuration value access:") - all_passed = True - - for key_path, expected_default in test_cases: - keys = key_path.split('.') - - # Test safe_config_get function - result = safe_config_get(main_config, *keys, default=expected_default) - - # Test direct access (old way) for comparison - try: - direct_result = main_config - for key in keys: - direct_result = getattr(direct_result, key) - direct_success = True - except AttributeError: - direct_result = None - direct_success = False - - status = "✓" if result is not None else "✗" - print(f" {status} {key_path}: {result} (direct: {direct_result if direct_success else 'FAILED'})") - - if result is None: - all_passed = False - - return all_passed - -def test_missing_config(): - """Test behavior with missing configuration sections.""" - print("\nTesting with missing configuration sections...") - - # Create a config with missing sections - incomplete_config = { - "timezone": "America/Chicago", - # Missing display section entirely - } - - main_config = DictWrapper(incomplete_config) - - # Test that safe_config_get returns defaults for missing sections - test_cases = [ - ('display.hardware.rows', 32), - ('display.hardware.cols', 64), - ('display.hardware.brightness', 95), - ] - - all_passed = True - for key_path, expected_default in test_cases: - keys = key_path.split('.') - result = safe_config_get(main_config, *keys, default=expected_default) - - status = "✓" if result == expected_default else "✗" - print(f" {status} {key_path}: {result} (expected default: {expected_default})") - - if result != expected_default: - all_passed = False - - return all_passed - -if __name__ == "__main__": - print("=" * 60) - print("Testing Web Interface Configuration Display") - print("=" * 60) - - success1 = test_config_access() - success2 = test_missing_config() - - print("\n" + "=" * 60) - if success1 and success2: - print("✓ ALL TESTS PASSED - Web interface should display config correctly!") - else: - print("✗ SOME TESTS FAILED - There may be issues with config display") - print("=" * 60) diff --git a/test_core_logic.py b/test_core_logic.py deleted file mode 100644 index 1a79d7a8..00000000 --- a/test_core_logic.py +++ /dev/null @@ -1,154 +0,0 @@ -#!/usr/bin/env python3 -""" -Test the core logic of the web interface without Flask dependencies. -""" -import json - -class DictWrapper: - """Wrapper to make dictionary accessible via dot notation for Jinja2 templates.""" - def __init__(self, data=None): - # Store the original data - object.__setattr__(self, '_data', data if isinstance(data, dict) else {}) - - # Set attributes from the dictionary - if isinstance(data, dict): - for key, value in data.items(): - if isinstance(value, dict): - object.__setattr__(self, key, DictWrapper(value)) - elif isinstance(value, list): - object.__setattr__(self, key, value) - else: - object.__setattr__(self, key, value) - - def __getattr__(self, name): - # Return a new empty DictWrapper for missing attributes - # This allows chaining like main_config.display.hardware.rows - return DictWrapper({}) - - def __str__(self): - # Return empty string for missing values to avoid template errors - data = object.__getattribute__(self, '_data') - if not data: - return '' - return str(data) - - def __int__(self): - # Return 0 for missing numeric values - data = object.__getattribute__(self, '_data') - if not data: - return 0 - try: - return int(data) - except (ValueError, TypeError): - return 0 - - def __bool__(self): - # Return False for missing boolean values - data = object.__getattribute__(self, '_data') - if not data: - return False - return bool(data) - - def get(self, key, default=None): - # Support .get() method like dictionaries - data = object.__getattribute__(self, '_data') - if data and key in data: - return data[key] - return default - -def safe_get(obj, key_path, default=''): - """Safely access nested dictionary values using dot notation.""" - try: - keys = key_path.split('.') - current = obj - for key in keys: - if hasattr(current, key): - current = getattr(current, key) - elif isinstance(current, dict) and key in current: - current = current[key] - else: - return default - return current if current is not None else default - except (AttributeError, KeyError, TypeError): - return default - -def safe_config_get(config, *keys, default=''): - """Safely get nested config values with fallback.""" - try: - current = config - for key in keys: - if hasattr(current, key): - current = getattr(current, key) - # Check if we got an empty DictWrapper - if isinstance(current, DictWrapper): - data = object.__getattribute__(current, '_data') - if not data: # Empty DictWrapper means missing config - return default - elif isinstance(current, dict) and key in current: - current = current[key] - else: - return default - - # Final check for empty values - if current is None or (hasattr(current, '_data') and not object.__getattribute__(current, '_data')): - return default - return current - except (AttributeError, KeyError, TypeError): - return default - -def simulate_template_rendering(): - """Simulate how the template would render configuration values.""" - print("Simulating template rendering with actual config...") - - # Load actual config - with open('config/config.json', 'r') as f: - config_data = json.load(f) - - main_config = DictWrapper(config_data) - - # Simulate template expressions that would be used - template_tests = [ - # Input field values - ("safe_config_get(main_config, 'display', 'hardware', 'rows', default=32)", 32), - ("safe_config_get(main_config, 'display', 'hardware', 'cols', default=64)", 64), - ("safe_config_get(main_config, 'display', 'hardware', 'brightness', default=95)", 95), - ("safe_config_get(main_config, 'display', 'hardware', 'chain_length', default=2)", 2), - ("safe_config_get(main_config, 'display', 'hardware', 'parallel', default=1)", 1), - ("safe_config_get(main_config, 'display', 'hardware', 'hardware_mapping', default='adafruit-hat-pwm')", 'adafruit-hat-pwm'), - - # Checkbox states - ("safe_config_get(main_config, 'display', 'hardware', 'disable_hardware_pulsing', default=False)", False), - ("safe_config_get(main_config, 'display', 'hardware', 'inverse_colors', default=False)", False), - ("safe_config_get(main_config, 'display', 'hardware', 'show_refresh_rate', default=False)", False), - ("safe_config_get(main_config, 'display', 'use_short_date_format', default=True)", True), - ] - - all_passed = True - for expression, expected in template_tests: - try: - result = eval(expression) - status = "✓" if result == expected else "✗" - print(f" {status} {expression.split('(')[0]}(...): {result} (expected: {expected})") - if result != expected: - all_passed = False - except Exception as e: - print(f" ✗ {expression}: ERROR - {e}") - all_passed = False - - return all_passed - -if __name__ == "__main__": - print("=" * 70) - print("Testing Core Web Interface Logic") - print("=" * 70) - - success = simulate_template_rendering() - - print("\n" + "=" * 70) - if success: - print("✓ ALL TEMPLATE SIMULATIONS PASSED!") - print("✓ The web interface should correctly display all config values!") - else: - print("✗ SOME TEMPLATE SIMULATIONS FAILED!") - print("✗ There may be issues with config display in the web interface!") - print("=" * 70)