diff --git a/src/odds_ticker_manager.py b/src/odds_ticker_manager.py
index 89c8d105..ddcabb73 100644
--- a/src/odds_ticker_manager.py
+++ b/src/odds_ticker_manager.py
@@ -119,6 +119,7 @@ class OddsTickerManager:
self.current_game_index = 0
self.ticker_image = None # This will hold the single, wide image
self.last_display_time = 0
+ self._end_reached_logged = False # Track if we've already logged reaching the end
# Font setup
self.fonts = self._load_fonts()
@@ -1715,6 +1716,8 @@ class OddsTickerManager:
logger.debug(f"Reset/initialized display start time: {self._display_start_time}")
# Also reset scroll position for clean start
self.scroll_position = 0
+ # Reset the end reached logging flag
+ self._end_reached_logged = False
else:
# Check if the display start time is too old (more than 2x the dynamic duration)
current_time = time.time()
@@ -1723,6 +1726,8 @@ class OddsTickerManager:
logger.debug(f"Display start time is too old ({elapsed_time:.1f}s), resetting")
self._display_start_time = current_time
self.scroll_position = 0
+ # Reset the end reached logging flag
+ self._end_reached_logged = False
logger.debug(f"Number of games in data at start of display method: {len(self.games_data)}")
if not self.games_data:
@@ -1826,11 +1831,13 @@ class OddsTickerManager:
else:
# Stop scrolling when we reach the end
if self.scroll_position >= self.ticker_image.width - width:
- logger.info(f"Odds ticker reached end: scroll_position {self.scroll_position} >= {self.ticker_image.width - width}")
+ if not self._end_reached_logged:
+ logger.info(f"Odds ticker reached end: scroll_position {self.scroll_position} >= {self.ticker_image.width - width}")
+ logger.info("Odds ticker scrolling stopped - reached end of content")
+ self._end_reached_logged = True
self.scroll_position = self.ticker_image.width - width
# Signal that scrolling has stopped
self.display_manager.set_scrolling_state(False)
- logger.info("Odds ticker scrolling stopped - reached end of content")
# Check if we're at a natural break point for mode switching
# If we're near the end of the display duration and not at a clean break point,
diff --git a/templates/index_v2.html b/templates/index_v2.html
index c81ecfb3..a04d9955 100644
--- a/templates/index_v2.html
+++ b/templates/index_v2.html
@@ -992,36 +992,36 @@
-
+
Number of LED rows
-
+
Number of LED columns
-
+
Number of LED panels chained together
-
+
Number of parallel chains
-
-
LED brightness: {{ main_config.display.hardware.brightness }}%
+
+
LED brightness: {{ safe_config_get(main_config, 'display', 'hardware', 'brightness', default=95) }}%
Hardware mapping type
@@ -1029,32 +1029,32 @@
-
+
GPIO slowdown factor (0-5)
-
+
Scan mode for LED matrix (0-1)
-
+
PWM bits for brightness control (1-11)
-
+
PWM dither bits (0-4)
-
+
PWM LSB nanoseconds (50-500)
-
+
Limit refresh rate in Hz (1-1000)
@@ -1063,28 +1063,28 @@
Disable hardware pulsing
Inverse color display
Show refresh rate on display
Use short date format for display
diff --git a/test_config_display.py b/test_config_display.py
new file mode 100644
index 00000000..98222ce4
--- /dev/null
+++ b/test_config_display.py
@@ -0,0 +1,197 @@
+#!/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/web_interface_v2.py b/web_interface_v2.py
index a47b793c..913f91eb 100644
--- a/web_interface_v2.py
+++ b/web_interface_v2.py
@@ -38,6 +38,57 @@ import logging
app = Flask(__name__)
app.secret_key = os.urandom(24)
+
+# Custom Jinja2 filter for safe nested dictionary access
+@app.template_filter('safe_get')
+def safe_get(obj, key_path, default=''):
+ """Safely access nested dictionary values using dot notation.
+
+ Usage: {{ main_config|safe_get('display.hardware.brightness', 95) }}
+ """
+ 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
+
+# Template context processor to provide safe access methods
+@app.context_processor
+def inject_safe_access():
+ """Inject safe access methods into template context."""
+ 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
+
+ return dict(safe_config_get=safe_config_get)
# Prefer eventlet when available, but allow forcing threading via env for troubleshooting
force_threading = os.getenv('USE_THREADING', '0') == '1' or os.getenv('FORCE_THREADING', '0') == '1'
if force_threading:
@@ -83,6 +134,30 @@ class DictWrapper:
# 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 __getitem__(self, key):
# Support bracket notation
return getattr(self, key, DictWrapper({}))
@@ -95,24 +170,26 @@ class DictWrapper:
return {}.items()
def get(self, key, default=None):
- # Support .get() method
+ # Support .get() method like dictionaries
data = object.__getattribute__(self, '_data')
if data and key in data:
- value = data[key]
- if isinstance(value, dict):
- return DictWrapper(value)
- return value
+ return data[key]
return default
+ def has_key(self, key):
+ # Check if key exists
+ data = object.__getattribute__(self, '_data')
+ return data and key in data
+
def keys(self):
# Support .keys() method
data = object.__getattribute__(self, '_data')
- return data.keys() if data else [].keys()
+ return data.keys() if data else []
def values(self):
# Support .values() method
data = object.__getattribute__(self, '_data')
- return data.values() if data else [].values()
+ return data.values() if data else []
def __str__(self):
# Return empty string for missing values to avoid template errors