mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-04-10 13:02:59 +00:00
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
This commit is contained in:
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 3.6 KiB |
@@ -39,7 +39,7 @@
|
|||||||
"daily_forecast": 30,
|
"daily_forecast": 30,
|
||||||
"stock_news": 20,
|
"stock_news": 20,
|
||||||
"odds_ticker": 60,
|
"odds_ticker": 60,
|
||||||
"leaderboard": 60,
|
"leaderboard": 300,
|
||||||
"nhl_live": 30,
|
"nhl_live": 30,
|
||||||
"nhl_recent": 30,
|
"nhl_recent": 30,
|
||||||
"nhl_upcoming": 30,
|
"nhl_upcoming": 30,
|
||||||
@@ -197,13 +197,11 @@
|
|||||||
"update_interval": 3600,
|
"update_interval": 3600,
|
||||||
"scroll_speed": 1,
|
"scroll_speed": 1,
|
||||||
"scroll_delay": 0.01,
|
"scroll_delay": 0.01,
|
||||||
"display_duration": 60,
|
|
||||||
"loop": false,
|
"loop": false,
|
||||||
"request_timeout": 30,
|
"request_timeout": 30,
|
||||||
"dynamic_duration": true,
|
"dynamic_duration": true,
|
||||||
"min_duration": 45,
|
"min_duration": 30,
|
||||||
"max_duration": 600,
|
"max_display_time": 600,
|
||||||
"duration_buffer": 0.1,
|
|
||||||
"background_service": {
|
"background_service": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"max_workers": 3,
|
"max_workers": 3,
|
||||||
|
|||||||
@@ -508,11 +508,15 @@ class DisplayController:
|
|||||||
if not hasattr(self, '_last_logged_duration') or self._last_logged_duration != dynamic_duration:
|
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")
|
logger.info(f"Using dynamic duration for stocks: {dynamic_duration} seconds")
|
||||||
self._last_logged_duration = dynamic_duration
|
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
|
return dynamic_duration
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error getting dynamic duration for stocks: {e}")
|
logger.error(f"Error getting dynamic duration for stocks: {e}")
|
||||||
# Fall back to configured duration
|
# 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
|
# Handle dynamic duration for stock_news
|
||||||
if mode_key == 'stock_news' and self.news:
|
if mode_key == 'stock_news' and self.news:
|
||||||
@@ -542,19 +546,20 @@ class DisplayController:
|
|||||||
# Fall back to configured duration
|
# Fall back to configured duration
|
||||||
return self.display_durations.get(mode_key, 60)
|
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:
|
if mode_key == 'leaderboard' and self.leaderboard:
|
||||||
try:
|
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
|
# 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:
|
if not hasattr(self, '_last_logged_leaderboard_duration') or self._last_logged_leaderboard_duration != duration:
|
||||||
logger.info(f"Using dynamic duration for leaderboard: {dynamic_duration} seconds")
|
logger.info(f"Using leaderboard {mode_type} duration: {duration} seconds")
|
||||||
self._last_logged_leaderboard_duration = dynamic_duration
|
self._last_logged_leaderboard_duration = duration
|
||||||
return dynamic_duration
|
return duration
|
||||||
except Exception as e:
|
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
|
# 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
|
# Simplify weather key handling
|
||||||
if mode_key.startswith('weather_'):
|
if mode_key.startswith('weather_'):
|
||||||
@@ -576,6 +581,8 @@ class DisplayController:
|
|||||||
# Defer updates for modules that might cause lag during scrolling
|
# Defer updates for modules that might cause lag during scrolling
|
||||||
if self.odds_ticker:
|
if self.odds_ticker:
|
||||||
self.display_manager.defer_update(self.odds_ticker.update, priority=1)
|
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:
|
if self.stocks:
|
||||||
self.display_manager.defer_update(self.stocks.update_stock_data, priority=2)
|
self.display_manager.defer_update(self.stocks.update_stock_data, priority=2)
|
||||||
if self.news:
|
if self.news:
|
||||||
@@ -1127,8 +1134,9 @@ class DisplayController:
|
|||||||
# Update data for all modules first
|
# Update data for all modules first
|
||||||
self._update_modules()
|
self._update_modules()
|
||||||
|
|
||||||
# Process any deferred updates that may have accumulated
|
# Process deferred updates less frequently when scrolling to improve performance
|
||||||
self.display_manager.process_deferred_updates()
|
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
|
# Update live modes in rotation if needed
|
||||||
self._update_live_modes_in_rotation()
|
self._update_live_modes_in_rotation()
|
||||||
@@ -1250,6 +1258,10 @@ class DisplayController:
|
|||||||
if hasattr(self, '_last_logged_duration'):
|
if hasattr(self, '_last_logged_duration'):
|
||||||
delattr(self, '_last_logged_duration')
|
delattr(self, '_last_logged_duration')
|
||||||
elif current_time - self.last_switch >= self.get_current_duration() or self.force_change:
|
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
|
self.force_change = False
|
||||||
if self.current_display_mode == 'calendar' and self.calendar:
|
if self.current_display_mode == 'calendar' and self.calendar:
|
||||||
self.calendar.advance_event()
|
self.calendar.advance_event()
|
||||||
@@ -1271,6 +1283,8 @@ class DisplayController:
|
|||||||
if needs_switch:
|
if needs_switch:
|
||||||
self.force_clear = True
|
self.force_clear = True
|
||||||
self.last_switch = current_time
|
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:
|
else:
|
||||||
self.force_clear = False
|
self.force_clear = False
|
||||||
# Only set manager_to_display if it hasn't been set by live priority logic
|
# Only set manager_to_display if it hasn't been set by live priority logic
|
||||||
|
|||||||
@@ -40,7 +40,9 @@ class DisplayManager:
|
|||||||
'is_scrolling': False,
|
'is_scrolling': False,
|
||||||
'last_scroll_activity': 0,
|
'last_scroll_activity': 0,
|
||||||
'scroll_inactivity_threshold': 2.0, # seconds of inactivity before considering "not scrolling"
|
'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()
|
self._setup_matrix()
|
||||||
@@ -677,13 +679,27 @@ class DisplayManager:
|
|||||||
update_func: Function to call when not scrolling
|
update_func: Function to call when not scrolling
|
||||||
priority: Priority level (lower numbers = higher priority)
|
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({
|
self._scrolling_state['deferred_updates'].append({
|
||||||
'func': update_func,
|
'func': update_func,
|
||||||
'priority': priority,
|
'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'])}")
|
logger.debug(f"Deferred update added. Total deferred: {len(self._scrolling_state['deferred_updates'])}")
|
||||||
|
|
||||||
def process_deferred_updates(self):
|
def process_deferred_updates(self):
|
||||||
@@ -694,20 +710,55 @@ class DisplayManager:
|
|||||||
if not self._scrolling_state['deferred_updates']:
|
if not self._scrolling_state['deferred_updates']:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Process all deferred updates
|
current_time = time.time()
|
||||||
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")
|
# Clean up expired updates first
|
||||||
|
self._cleanup_expired_deferred_updates(current_time)
|
||||||
|
|
||||||
|
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 (queue size: {len(self._scrolling_state['deferred_updates'])})")
|
||||||
|
|
||||||
|
failed_updates = []
|
||||||
for update_info in updates_to_process:
|
for update_info in updates_to_process:
|
||||||
try:
|
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']()
|
update_info['func']()
|
||||||
logger.debug("Deferred update executed successfully")
|
logger.debug("Deferred update executed successfully")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error executing deferred update: {e}")
|
logger.error(f"Error executing deferred update: {e}")
|
||||||
# Re-add failed updates for retry
|
# Only retry recent failures, and limit retries
|
||||||
self._scrolling_state['deferred_updates'].append(update_info)
|
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:
|
def get_scrolling_stats(self) -> dict:
|
||||||
"""Get current scrolling statistics for debugging."""
|
"""Get current scrolling statistics for debugging."""
|
||||||
@@ -715,7 +766,9 @@ class DisplayManager:
|
|||||||
'is_scrolling': self._scrolling_state['is_scrolling'],
|
'is_scrolling': self._scrolling_state['is_scrolling'],
|
||||||
'last_activity': self._scrolling_state['last_scroll_activity'],
|
'last_activity': self._scrolling_state['last_scroll_activity'],
|
||||||
'deferred_count': len(self._scrolling_state['deferred_updates']),
|
'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:
|
def _write_snapshot_if_due(self) -> None:
|
||||||
|
|||||||
@@ -40,17 +40,15 @@ class LeaderboardManager:
|
|||||||
self.update_interval = self.leaderboard_config.get('update_interval', 3600)
|
self.update_interval = self.leaderboard_config.get('update_interval', 3600)
|
||||||
self.scroll_speed = self.leaderboard_config.get('scroll_speed', 1)
|
self.scroll_speed = self.leaderboard_config.get('scroll_speed', 1)
|
||||||
self.scroll_delay = self.leaderboard_config.get('scroll_delay', 0.01)
|
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.loop = self.leaderboard_config.get('loop', True)
|
||||||
self.request_timeout = self.leaderboard_config.get('request_timeout', 30)
|
self.request_timeout = self.leaderboard_config.get('request_timeout', 30)
|
||||||
self.time_over = 0
|
self.time_over = 0
|
||||||
# Dynamic duration settings
|
|
||||||
self.dynamic_duration_enabled = self.leaderboard_config.get('dynamic_duration', True)
|
# Duration settings - user can choose between fixed or dynamic (exception-based)
|
||||||
self.min_duration = self.leaderboard_config.get('min_duration', 30)
|
self.dynamic_duration = self.leaderboard_config.get('dynamic_duration', True)
|
||||||
self.max_duration = self.leaderboard_config.get('max_duration', 300)
|
# Get duration from main display_durations section
|
||||||
self.duration_buffer = self.leaderboard_config.get('duration_buffer', 0.1)
|
self.display_duration = config.get('display', {}).get('display_durations', {}).get('leaderboard', 300)
|
||||||
self.dynamic_duration = 60 # Default duration in seconds
|
self.max_display_time = self.leaderboard_config.get('max_display_time', 600) # 10 minutes maximum
|
||||||
self.total_scroll_width = 0 # Track total width for dynamic duration calculation
|
|
||||||
|
|
||||||
# Initialize managers
|
# Initialize managers
|
||||||
self.cache_manager = CacheManager()
|
self.cache_manager = CacheManager()
|
||||||
@@ -80,6 +78,19 @@ class LeaderboardManager:
|
|||||||
self.leaderboard_image = None # This will hold the single, wide image
|
self.leaderboard_image = None # This will hold the single, wide image
|
||||||
self.last_display_time = 0
|
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
|
# Font setup
|
||||||
self.fonts = self._load_fonts()
|
self.fonts = self._load_fonts()
|
||||||
|
|
||||||
@@ -240,6 +251,19 @@ class LeaderboardManager:
|
|||||||
}
|
}
|
||||||
return fonts
|
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)):
|
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."""
|
"""Draw text with a black outline for better readability on LED matrix."""
|
||||||
x, y = position
|
x, y = position
|
||||||
@@ -249,30 +273,35 @@ class LeaderboardManager:
|
|||||||
# Draw text
|
# Draw text
|
||||||
draw.text((x, y), text, font=font, fill=fill)
|
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]:
|
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."""
|
"""Get team logo from the configured directory, downloading if missing."""
|
||||||
if not team_abbr or not logo_dir:
|
if not team_abbr or not logo_dir:
|
||||||
logger.debug("Cannot get team logo with missing team_abbr or logo_dir")
|
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
logo_path = os.path.join(logo_dir, f"{team_abbr}.png")
|
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):
|
if os.path.exists(logo_path):
|
||||||
logo = Image.open(logo_path)
|
logo = Image.open(logo_path)
|
||||||
logger.debug(f"Successfully loaded logo for {team_abbr} from {logo_path}")
|
|
||||||
return logo
|
return logo
|
||||||
else:
|
else:
|
||||||
logger.warning(f"Logo not found at path: {logo_path}")
|
|
||||||
|
|
||||||
# Try to download the missing logo if we have league information
|
# Try to download the missing logo if we have league information
|
||||||
if league:
|
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)
|
success = download_missing_logo(team_abbr, league, team_name)
|
||||||
if success:
|
if success:
|
||||||
# Try to load the downloaded logo
|
# Try to load the downloaded logo
|
||||||
if os.path.exists(logo_path):
|
if os.path.exists(logo_path):
|
||||||
logo = Image.open(logo_path)
|
logo = Image.open(logo_path)
|
||||||
logger.info(f"Successfully downloaded and loaded logo for {team_abbr}")
|
|
||||||
return logo
|
return logo
|
||||||
|
|
||||||
return None
|
return None
|
||||||
@@ -920,13 +949,13 @@ class LeaderboardManager:
|
|||||||
# For other leagues, show position
|
# For other leagues, show position
|
||||||
number_text = f"{i+1}."
|
number_text = f"{i+1}."
|
||||||
|
|
||||||
number_bbox = self.fonts['xlarge'].getbbox(number_text)
|
number_measurements = self._get_cached_text_bbox(number_text, 'xlarge')
|
||||||
number_width = number_bbox[2] - number_bbox[0]
|
number_width = number_measurements['width']
|
||||||
|
|
||||||
# Calculate width for team abbreviation (use large font like in drawing)
|
# Calculate width for team abbreviation (use large font like in drawing)
|
||||||
team_text = team['abbreviation']
|
team_text = team['abbreviation']
|
||||||
text_bbox = self.fonts['large'].getbbox(team_text)
|
text_measurements = self._get_cached_text_bbox(team_text, 'large')
|
||||||
text_width = text_bbox[2] - text_bbox[0]
|
text_width = text_measurements['width']
|
||||||
|
|
||||||
# Total team width: bold number + spacing + logo + spacing + text + spacing
|
# Total team width: bold number + spacing + logo + spacing + text + spacing
|
||||||
team_width = number_width + 4 + logo_size + 4 + text_width + 12 # Spacing between teams
|
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
|
# For other leagues, show position
|
||||||
number_text = f"{i+1}."
|
number_text = f"{i+1}."
|
||||||
|
|
||||||
number_bbox = self.fonts['xlarge'].getbbox(number_text)
|
number_measurements = self._get_cached_text_bbox(number_text, 'xlarge')
|
||||||
number_width = number_bbox[2] - number_bbox[0]
|
number_width = number_measurements['width']
|
||||||
number_height = number_bbox[3] - number_bbox[1]
|
number_height = number_measurements['height']
|
||||||
number_y = (height - number_height) // 2
|
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))
|
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)
|
# Draw team logo (cached and resized)
|
||||||
team_logo = self._get_team_logo(team['abbreviation'], league_config['logo_dir'],
|
team_logo = self._get_cached_resized_logo(team['abbreviation'], league_config['logo_dir'],
|
||||||
league=league_key, team_name=team.get('name'))
|
logo_size, league=league_key, team_name=team.get('name'))
|
||||||
if team_logo:
|
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)
|
# Paste team logo after the bold number (centered vertically)
|
||||||
logo_x = team_x + number_width + 4
|
logo_x = team_x + number_width + 4
|
||||||
@@ -1016,9 +1043,9 @@ class LeaderboardManager:
|
|||||||
|
|
||||||
# Draw team abbreviation after the logo (centered vertically)
|
# Draw team abbreviation after the logo (centered vertically)
|
||||||
team_text = team['abbreviation']
|
team_text = team['abbreviation']
|
||||||
text_bbox = self.fonts['large'].getbbox(team_text)
|
text_measurements = self._get_cached_text_bbox(team_text, 'large')
|
||||||
text_width = text_bbox[2] - text_bbox[0]
|
text_width = text_measurements['width']
|
||||||
text_height = text_bbox[3] - text_bbox[1]
|
text_height = text_measurements['height']
|
||||||
text_x = logo_x + logo_size + 4
|
text_x = logo_x + logo_size + 4
|
||||||
text_y = (height - text_height) // 2
|
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))
|
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:
|
else:
|
||||||
# Fallback if no logo - draw team abbreviation after bold number (centered vertically)
|
# Fallback if no logo - draw team abbreviation after bold number (centered vertically)
|
||||||
team_text = team['abbreviation']
|
team_text = team['abbreviation']
|
||||||
text_bbox = self.fonts['large'].getbbox(team_text)
|
text_measurements = self._get_cached_text_bbox(team_text, 'large')
|
||||||
text_width = text_bbox[2] - text_bbox[0]
|
text_width = text_measurements['width']
|
||||||
text_height = text_bbox[3] - text_bbox[1]
|
text_height = text_measurements['height']
|
||||||
text_x = team_x + number_width + 4
|
text_x = team_x + number_width + 4
|
||||||
text_y = (height - text_height) // 2
|
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))
|
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:
|
else:
|
||||||
number_text = f"{j+1}."
|
number_text = f"{j+1}."
|
||||||
|
|
||||||
number_bbox = self.fonts['xlarge'].getbbox(number_text)
|
number_measurements = self._get_cached_text_bbox(number_text, 'xlarge')
|
||||||
number_width = number_bbox[2] - number_bbox[0]
|
number_width = number_measurements['width']
|
||||||
team_text = team['abbreviation']
|
team_text = team['abbreviation']
|
||||||
text_bbox = self.fonts['large'].getbbox(team_text)
|
text_measurements = self._get_cached_text_bbox(team_text, 'large')
|
||||||
text_width = text_bbox[2] - text_bbox[0]
|
text_width = text_measurements['width']
|
||||||
team_width = number_width + 4 + logo_size + 4 + text_width + 12
|
team_width = number_width + 4 + logo_size + 4 + text_width + 12
|
||||||
teams_width += team_width
|
teams_width += team_width
|
||||||
|
|
||||||
@@ -1106,126 +1133,20 @@ class LeaderboardManager:
|
|||||||
|
|
||||||
logger.info(f"Total image width: {total_width}px, Display width: {height}px")
|
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}")
|
logger.info(f"Created leaderboard image with width {total_width}")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error creating leaderboard image: {e}")
|
logger.error(f"Error creating leaderboard image: {e}")
|
||||||
self.leaderboard_image = None
|
self.leaderboard_image = None
|
||||||
|
|
||||||
def calculate_dynamic_duration(self):
|
def get_duration(self) -> int:
|
||||||
"""Calculate the exact time needed to display all leaderboard content"""
|
"""Get the duration for display based on user preference"""
|
||||||
logger.info(f"Calculating dynamic duration - enabled: {self.dynamic_duration_enabled}, content width: {self.total_scroll_width}px")
|
if self.dynamic_duration:
|
||||||
|
# Use long timeout and let content determine when done via StopIteration
|
||||||
# If dynamic duration is disabled, use fixed duration from config
|
return self.max_display_time
|
||||||
if not self.dynamic_duration_enabled:
|
else:
|
||||||
self.dynamic_duration = self.leaderboard_config.get('display_duration', 60)
|
# Use fixed duration from config
|
||||||
logger.debug(f"Dynamic duration disabled, using fixed duration: {self.dynamic_duration}s")
|
return self.display_duration
|
||||||
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 update(self) -> None:
|
def update(self) -> None:
|
||||||
"""Update leaderboard data."""
|
"""Update leaderboard data."""
|
||||||
@@ -1277,67 +1198,74 @@ class LeaderboardManager:
|
|||||||
|
|
||||||
def display(self, force_clear: bool = False) -> None:
|
def display(self, force_clear: bool = False) -> None:
|
||||||
"""Display the leaderboard."""
|
"""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:
|
if not self.is_enabled:
|
||||||
logger.debug("Leaderboard is disabled, exiting display method.")
|
|
||||||
return
|
return
|
||||||
|
|
||||||
# Reset display start time when force_clear is True or when starting fresh
|
# Reset display start time when force_clear is True or when starting fresh
|
||||||
if force_clear or not hasattr(self, '_display_start_time'):
|
if force_clear or not hasattr(self, '_display_start_time'):
|
||||||
self._display_start_time = time.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
|
# Also reset scroll position for clean start
|
||||||
self.scroll_position = 0
|
self.scroll_position = 0
|
||||||
else:
|
# Initialize FPS tracking
|
||||||
# Check if the display start time is too old (more than 2x the dynamic duration)
|
self.last_frame_time = 0
|
||||||
current_time = time.time()
|
self.frame_times = []
|
||||||
elapsed_time = current_time - self._display_start_time
|
self.last_fps_log_time = time.time()
|
||||||
if elapsed_time > (self.dynamic_duration * 2):
|
# Reset performance caches
|
||||||
logger.debug(f"Display start time is too old ({elapsed_time:.1f}s), resetting")
|
self._cached_draw = None
|
||||||
self._display_start_time = current_time
|
self._last_visible_image = None
|
||||||
self.scroll_position = 0
|
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:
|
if not self.leaderboard_data:
|
||||||
logger.warning("Leaderboard has no data. Attempting to update...")
|
|
||||||
self.update()
|
self.update()
|
||||||
if not self.leaderboard_data:
|
if not self.leaderboard_data:
|
||||||
logger.warning("Still no data after update. Displaying fallback message.")
|
|
||||||
self._display_fallback_message()
|
self._display_fallback_message()
|
||||||
return
|
return
|
||||||
|
|
||||||
if self.leaderboard_image is None:
|
if self.leaderboard_image is None:
|
||||||
logger.warning("Leaderboard image is not available. Attempting to create it.")
|
|
||||||
self._create_leaderboard_image()
|
self._create_leaderboard_image()
|
||||||
if self.leaderboard_image is None:
|
if self.leaderboard_image is None:
|
||||||
logger.error("Failed to create leaderboard image.")
|
|
||||||
self._display_fallback_message()
|
self._display_fallback_message()
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
current_time = time.time()
|
current_time = time.time()
|
||||||
|
|
||||||
# Check if we should be scrolling
|
# FPS tracking only (no artificial throttling)
|
||||||
should_scroll = current_time - self.last_scroll_time >= self.scroll_delay
|
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
|
# Signal scrolling state to display manager
|
||||||
if should_scroll:
|
self.display_manager.set_scrolling_state(True)
|
||||||
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
|
# Scroll the image every frame for smooth animation
|
||||||
if should_scroll:
|
self.scroll_position += self.scroll_speed
|
||||||
self.scroll_position += self.scroll_speed
|
|
||||||
self.last_scroll_time = current_time
|
|
||||||
|
|
||||||
# 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
|
width = self.display_manager.matrix.width
|
||||||
height = self.display_manager.matrix.height
|
height = self.display_manager.matrix.height
|
||||||
|
|
||||||
@@ -1345,67 +1273,51 @@ class LeaderboardManager:
|
|||||||
if self.loop:
|
if self.loop:
|
||||||
# Reset position when we've scrolled past the end for a continuous loop
|
# Reset position when we've scrolled past the end for a continuous loop
|
||||||
if self.scroll_position >= self.leaderboard_image.width:
|
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
|
self.scroll_position = 0
|
||||||
logger.info("Leaderboard starting new loop cycle")
|
|
||||||
else:
|
else:
|
||||||
# Stop scrolling when we reach the end
|
# Stop scrolling when we reach the end
|
||||||
if self.scroll_position >= self.leaderboard_image.width - width:
|
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
|
self.scroll_position = self.leaderboard_image.width - width
|
||||||
# Signal that scrolling has stopped
|
# Signal that scrolling has stopped
|
||||||
self.display_manager.set_scrolling_state(False)
|
self.display_manager.set_scrolling_state(False)
|
||||||
logger.info("Leaderboard scrolling stopped - reached end of content")
|
|
||||||
if self.time_over == 0:
|
if self.time_over == 0:
|
||||||
self.time_over = time.time()
|
self.time_over = time.time()
|
||||||
elif time.time() - self.time_over >= 2:
|
elif time.time() - self.time_over >= 2:
|
||||||
self.time_over = 0
|
self.time_over = 0
|
||||||
raise StopIteration
|
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
|
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)
|
# Optimize: Only create new visible image if scroll position changed significantly
|
||||||
if self.scroll_position % 50 == 0 and self.scroll_position > 0:
|
# Use integer scroll position to reduce unnecessary crops
|
||||||
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")
|
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 we have less than 2 seconds remaining, check if we can complete the content display
|
if crop_right > crop_left: # Valid crop region
|
||||||
if remaining_time < 2.0 and self.scroll_position > 0:
|
# Create the visible part of the image by cropping from the leaderboard_image
|
||||||
# Calculate how much time we need to complete the current scroll position
|
self._last_visible_image = self.leaderboard_image.crop((
|
||||||
# Use actual observed scroll speed (54.2 px/s) instead of theoretical calculation
|
crop_left,
|
||||||
actual_scroll_speed = 54.2 # pixels per second (calculated from logs)
|
0,
|
||||||
|
crop_right,
|
||||||
|
height
|
||||||
|
))
|
||||||
|
self._last_scroll_position = int_scroll_position
|
||||||
|
|
||||||
if self.loop:
|
# Cache the draw object to avoid creating it every frame
|
||||||
# For looping, we need to complete one full cycle
|
self._cached_draw = ImageDraw.Draw(self._last_visible_image)
|
||||||
distance_to_complete = self.leaderboard_image.width - self.scroll_position
|
|
||||||
else:
|
else:
|
||||||
# For single pass, we need to reach the end (content width minus display width)
|
# Invalid crop region, skip this frame
|
||||||
end_position = max(0, self.leaderboard_image.width - width)
|
return
|
||||||
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
|
|
||||||
))
|
|
||||||
|
|
||||||
# Display the visible portion
|
# Display the visible portion
|
||||||
self.display_manager.image = visible_image
|
self.display_manager.image = self._last_visible_image
|
||||||
self.display_manager.draw = ImageDraw.Draw(self.display_manager.image)
|
self.display_manager.draw = self._cached_draw
|
||||||
self.display_manager.update_display()
|
self.display_manager.update_display()
|
||||||
|
|
||||||
except StopIteration as e:
|
except StopIteration as e:
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ class StockNewsManager:
|
|||||||
|
|
||||||
# Get scroll settings from config with faster defaults
|
# Get scroll settings from config with faster defaults
|
||||||
self.scroll_speed = self.stock_news_config.get('scroll_speed', 1)
|
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
|
# Get headline settings from config
|
||||||
self.max_headlines_per_symbol = self.stock_news_config.get('max_headlines_per_symbol', 1)
|
self.max_headlines_per_symbol = self.stock_news_config.get('max_headlines_per_symbol', 1)
|
||||||
|
|||||||
@@ -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)
|
|
||||||
@@ -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)
|
|
||||||
Reference in New Issue
Block a user