From b20c3880b2b5d5d66e7b410f97bce83a557e619b Mon Sep 17 00:00:00 2001 From: Chuck <33324927+ChuckBuilds@users.noreply.github.com> Date: Mon, 15 Sep 2025 13:59:33 -0400 Subject: [PATCH] make sure web ui is pulling existing config options --- src/odds_ticker_manager.py | 11 ++- templates/index_v2.html | 40 ++++---- test_config_display.py | 197 +++++++++++++++++++++++++++++++++++++ web_interface_v2.py | 91 +++++++++++++++-- 4 files changed, 310 insertions(+), 29 deletions(-) create mode 100644 test_config_display.py 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