From 14c50f316e502d65c960c9983aa653aa4a423c3a Mon Sep 17 00:00:00 2001 From: Chuck <33324927+ChuckBuilds@users.noreply.github.com> Date: Thu, 29 Jan 2026 18:12:45 -0500 Subject: [PATCH] feat: add timezone support for schedules and dim schedule feature (#218) * feat: add timezone support for schedules and dim schedule feature - Fix timezone handling in _check_schedule() to use configured timezone instead of system time (addresses schedule offset issues) - Add dim schedule feature for automatic brightness dimming: - New dim_schedule config section with brightness level and time windows - Smart interaction: dim schedule won't turn display on if it's off - Supports both global and per-day modes like on/off schedule - Add set_brightness() and get_brightness() methods to DisplayManager for runtime brightness control - Add REST API endpoints: GET/POST /api/v3/config/dim-schedule - Add web UI for dim schedule configuration in schedule settings page Co-Authored-By: Claude Opus 4.5 * fix: normalize per-day mode and validate dim_brightness input - Normalize mode string in _check_dim_schedule to handle both "per-day" and "per_day" variants - Add try/except around dim_brightness int conversion to handle invalid input gracefully Co-Authored-By: Claude Opus 4.5 * refactor: improve error handling in brightness and dim schedule endpoints - display_manager.py: Add fail-fast input validation, catch specific exceptions (AttributeError, TypeError, ValueError), add [BRIGHTNESS] context tags, include stack traces in error logs - api_v3.py: Catch specific config exceptions (FileNotFoundError, JSONDecodeError, IOError), add [DIM SCHEDULE] context tags for Pi debugging, include stack traces Co-Authored-By: Claude Opus 4.5 --------- Co-authored-by: Chuck Co-authored-by: Claude Opus 4.5 --- config/config.template.json | 44 ++++ src/display_controller.py | 115 ++++++++- src/display_manager.py | 51 ++++ web_interface/blueprints/api_v3.py | 221 ++++++++++++++++++ web_interface/blueprints/pages_v3.py | 7 +- .../templates/v3/partials/schedule.html | 166 +++++++++++++ 6 files changed, 599 insertions(+), 5 deletions(-) diff --git a/config/config.template.json b/config/config.template.json index baf5757d..412ea933 100644 --- a/config/config.template.json +++ b/config/config.template.json @@ -43,6 +43,50 @@ } } }, + "dim_schedule": { + "enabled": false, + "dim_brightness": 30, + "mode": "global", + "start_time": "20:00", + "end_time": "07:00", + "days": { + "monday": { + "enabled": true, + "start_time": "20:00", + "end_time": "07:00" + }, + "tuesday": { + "enabled": true, + "start_time": "20:00", + "end_time": "07:00" + }, + "wednesday": { + "enabled": true, + "start_time": "20:00", + "end_time": "07:00" + }, + "thursday": { + "enabled": true, + "start_time": "20:00", + "end_time": "07:00" + }, + "friday": { + "enabled": true, + "start_time": "20:00", + "end_time": "07:00" + }, + "saturday": { + "enabled": true, + "start_time": "20:00", + "end_time": "07:00" + }, + "sunday": { + "enabled": true, + "start_time": "20:00", + "end_time": "07:00" + } + } + }, "timezone": "America/Chicago", "location": { "city": "Dallas", diff --git a/src/display_controller.py b/src/display_controller.py index 0c04e4de..0e9bedfb 100644 --- a/src/display_controller.py +++ b/src/display_controller.py @@ -7,6 +7,7 @@ from pathlib import Path from typing import Dict, Any, List, Optional from datetime import datetime from concurrent.futures import ThreadPoolExecutor, as_completed # pylint: disable=no-name-in-module +import pytz # Core system imports only - all functionality now handled via plugins from src.display_manager import DisplayManager @@ -334,7 +335,12 @@ class DisplayController: # Schedule management self.is_display_active = True self._was_display_active = True # Track previous state for schedule change detection - + + # Brightness state tracking for dim schedule + self.current_brightness = self.config.get('display', {}).get('hardware', {}).get('brightness', 90) + self.is_dimmed = False + self._was_dimmed = False + # Publish initial on-demand state try: self._publish_on_demand_state() @@ -445,8 +451,17 @@ class DisplayController: self._was_display_active = True # Track previous state for schedule change detection logger.debug("Schedule is disabled - display always active") return - - current_time = datetime.now() + + # Get configured timezone, default to UTC + timezone_str = self.config.get('timezone', 'UTC') + try: + tz = pytz.timezone(timezone_str) + except pytz.UnknownTimeZoneError: + logger.warning(f"Unknown timezone '{timezone_str}', using UTC") + tz = pytz.UTC + + # Use timezone-aware current time + current_time = datetime.now(tz) current_day = current_time.strftime('%A').lower() # Get day name (monday, tuesday, etc.) current_time_only = current_time.time() @@ -523,11 +538,96 @@ class DisplayController: self._was_display_active = self.is_display_active except ValueError as e: - logger.warning("Invalid schedule format for %s schedule: %s (start: %s, end: %s). Defaulting to active.", + logger.warning("Invalid schedule format for %s schedule: %s (start: %s, end: %s). Defaulting to active.", schedule_type, e, start_time_str, end_time_str) self.is_display_active = True self._was_display_active = True # Track previous state for schedule change detection + def _check_dim_schedule(self) -> int: + """ + Check if display should be dimmed based on dim schedule. + + Returns: + Target brightness level (dim_brightness if in dim period, + normal brightness otherwise) + """ + # Get normal brightness from config + normal_brightness = self.config.get('display', {}).get('hardware', {}).get('brightness', 90) + + # If display is OFF via schedule, don't process dim schedule + if not self.is_display_active: + self.is_dimmed = False + return normal_brightness + + dim_config = self.config.get('dim_schedule', {}) + + # If dim schedule doesn't exist or is disabled, use normal brightness + if not dim_config or not dim_config.get('enabled', False): + self.is_dimmed = False + return normal_brightness + + # Get configured timezone + timezone_str = self.config.get('timezone', 'UTC') + try: + tz = pytz.timezone(timezone_str) + except pytz.UnknownTimeZoneError: + logger.warning(f"Unknown timezone '{timezone_str}' in dim schedule, using UTC") + tz = pytz.UTC + + current_time = datetime.now(tz) + current_day = current_time.strftime('%A').lower() + current_time_only = current_time.time() + + # Determine if using per-day or global dim schedule + # Normalize mode to handle both "per-day" and "per_day" variants + mode = dim_config.get('mode', 'global') + mode_normalized = mode.replace('_', '-') if mode else 'global' + days_config = dim_config.get('days') + use_per_day = mode_normalized == 'per-day' and days_config and current_day in days_config + + if use_per_day: + day_config = days_config[current_day] + if not day_config.get('enabled', True): + self.is_dimmed = False + return normal_brightness + start_time_str = day_config.get('start_time', '20:00') + end_time_str = day_config.get('end_time', '07:00') + else: + start_time_str = dim_config.get('start_time', '20:00') + end_time_str = dim_config.get('end_time', '07:00') + + try: + start_time = datetime.strptime(start_time_str, '%H:%M').time() + end_time = datetime.strptime(end_time_str, '%H:%M').time() + + # Determine if currently in dim period + if start_time <= end_time: + # Same-day schedule (e.g., 10:00 to 18:00) + in_dim_period = start_time <= current_time_only <= end_time + else: + # Overnight schedule (e.g., 20:00 to 07:00) + in_dim_period = current_time_only >= start_time or current_time_only <= end_time + + if in_dim_period: + self.is_dimmed = True + target_brightness = dim_config.get('dim_brightness', 30) + else: + self.is_dimmed = False + target_brightness = normal_brightness + + # Log state changes + if self.is_dimmed and not self._was_dimmed: + logger.info(f"Dim schedule activated: brightness set to {target_brightness}%") + elif not self.is_dimmed and self._was_dimmed: + logger.info(f"Dim schedule deactivated: brightness restored to {target_brightness}%") + + self._was_dimmed = self.is_dimmed + return target_brightness + + except ValueError as e: + logger.warning(f"Invalid dim schedule time format: {e}") + return normal_brightness + def _update_modules(self): """Update all plugin modules.""" if not self.plugin_manager: @@ -1184,6 +1284,13 @@ class DisplayController: elif not self.on_demand_active and self.on_demand_schedule_override: self.on_demand_schedule_override = False + # Check dim schedule and apply brightness (only when display is active) + if self.is_display_active: + target_brightness = self._check_dim_schedule() + if target_brightness != self.current_brightness: + if self.display_manager.set_brightness(target_brightness): + self.current_brightness = target_brightness + if not self.is_display_active: # Clear display when schedule makes it inactive to ensure blank screen # (not showing initialization screen) diff --git a/src/display_manager.py b/src/display_manager.py index 5ee03703..b945f9b9 100644 --- a/src/display_manager.py +++ b/src/display_manager.py @@ -174,6 +174,57 @@ class DisplayManager: else: return 32 # Default fallback height + def set_brightness(self, brightness: int) -> bool: + """ + Set display brightness at runtime. + + Args: + brightness: Brightness level (0-100) + + Returns: + True if brightness was set successfully, False otherwise + """ + # Fail fast: validate input type + if not isinstance(brightness, (int, float)): + logger.error(f"[BRIGHTNESS] Invalid brightness type: {type(brightness).__name__}, expected int") + return False + + if self.matrix is None: + logger.warning("[BRIGHTNESS] Cannot set brightness in fallback mode") + return False + + # Clamp to valid range + brightness = max(0, min(100, int(brightness))) + + try: + # RGBMatrix accepts brightness as a property + self.matrix.brightness = brightness + logger.info(f"[BRIGHTNESS] Display brightness set to {brightness}%") + return True + except AttributeError as e: + logger.error(f"[BRIGHTNESS] Matrix does not support brightness property: {e}", exc_info=True) + return False + except (TypeError, ValueError) as e: + logger.error(f"[BRIGHTNESS] Invalid brightness value rejected by hardware: {e}", exc_info=True) + return False + + def get_brightness(self) -> int: + """ + Get current display brightness. + + Returns: + Current brightness level (0-100), or -1 if unavailable + """ + if self.matrix is None: + logger.debug("[BRIGHTNESS] Cannot get brightness in fallback mode") + return -1 + + try: + return self.matrix.brightness + except AttributeError as e: + logger.warning(f"[BRIGHTNESS] Matrix does not support brightness property: {e}", exc_info=True) + return -1 + def _draw_test_pattern(self): """Draw a test pattern to verify the display is working.""" try: diff --git a/web_interface/blueprints/api_v3.py b/web_interface/blueprints/api_v3.py index b6be6275..4f77e470 100644 --- a/web_interface/blueprints/api_v3.py +++ b/web_interface/blueprints/api_v3.py @@ -353,6 +353,227 @@ def save_schedule_config(): status_code=500 ) +@api_v3.route('/config/dim-schedule', methods=['GET']) +def get_dim_schedule_config(): + """Get current dim schedule configuration""" + import logging + import json + + if not api_v3.config_manager: + logging.error("[DIM SCHEDULE] Config manager not initialized") + return error_response( + ErrorCode.CONFIG_LOAD_FAILED, + 'Config manager not initialized', + status_code=500 + ) + + try: + config = api_v3.config_manager.load_config() + dim_schedule_config = config.get('dim_schedule', { + 'enabled': False, + 'dim_brightness': 30, + 'mode': 'global', + 'start_time': '20:00', + 'end_time': '07:00', + 'days': {} + }) + + return success_response(data=dim_schedule_config) + except FileNotFoundError as e: + logging.error(f"[DIM SCHEDULE] Config file not found: {e}", exc_info=True) + return error_response( + ErrorCode.CONFIG_LOAD_FAILED, + "Configuration file not found", + status_code=500 + ) + except json.JSONDecodeError as e: + logging.error(f"[DIM SCHEDULE] Invalid JSON in config file: {e}", exc_info=True) + return error_response( + ErrorCode.CONFIG_LOAD_FAILED, + "Configuration file contains invalid JSON", + status_code=500 + ) + except (IOError, OSError) as e: + logging.error(f"[DIM SCHEDULE] Error reading config file: {e}", exc_info=True) + return error_response( + ErrorCode.CONFIG_LOAD_FAILED, + f"Error reading configuration file: {str(e)}", + status_code=500 + ) + except Exception as e: + logging.error(f"[DIM SCHEDULE] Unexpected error loading config: {e}", exc_info=True) + return error_response( + ErrorCode.CONFIG_LOAD_FAILED, + f"Unexpected error loading dim schedule configuration: {str(e)}", + status_code=500 + ) + +@api_v3.route('/config/dim-schedule', methods=['POST']) +def save_dim_schedule_config(): + """Save dim schedule configuration""" + try: + if not api_v3.config_manager: + return jsonify({'status': 'error', 'message': 'Config manager not initialized'}), 500 + + data = request.get_json() + if not data: + return jsonify({'status': 'error', 'message': 'No data provided'}), 400 + + # Load current config + current_config = api_v3.config_manager.load_config() + + # Build dim schedule configuration + enabled_value = data.get('enabled', False) + if isinstance(enabled_value, str): + enabled_value = enabled_value.lower() in ('true', 'on', '1') + + # Validate and get dim_brightness + dim_brightness_raw = data.get('dim_brightness', 30) + try: + # Handle empty string or None + if dim_brightness_raw is None or dim_brightness_raw == '': + dim_brightness = 30 + else: + dim_brightness = int(dim_brightness_raw) + except (ValueError, TypeError): + return error_response( + ErrorCode.VALIDATION_ERROR, + "dim_brightness must be an integer between 0 and 100", + status_code=400 + ) + + if not 0 <= dim_brightness <= 100: + return error_response( + ErrorCode.VALIDATION_ERROR, + "dim_brightness must be between 0 and 100", + status_code=400 + ) + + dim_schedule_config = { + 'enabled': enabled_value, + 'dim_brightness': dim_brightness + } + + mode = data.get('mode', 'global') + dim_schedule_config['mode'] = mode + + if mode == 'global': + # Simple global schedule + start_time = data.get('start_time', '20:00') + end_time = data.get('end_time', '07:00') + + # Validate time formats + is_valid, error_msg = _validate_time_format(start_time) + if not is_valid: + return error_response( + ErrorCode.VALIDATION_ERROR, + error_msg, + status_code=400 + ) + + is_valid, error_msg = _validate_time_format(end_time) + if not is_valid: + return error_response( + ErrorCode.VALIDATION_ERROR, + error_msg, + status_code=400 + ) + + dim_schedule_config['start_time'] = start_time + dim_schedule_config['end_time'] = end_time + # Remove days config when switching to global mode + dim_schedule_config.pop('days', None) + else: + # Per-day schedule + dim_schedule_config['days'] = {} + # Remove global times when switching to per-day mode + dim_schedule_config.pop('start_time', None) + dim_schedule_config.pop('end_time', None) + days = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'] + enabled_days_count = 0 + + for day in days: + day_config = {} + enabled_key = f'{day}_enabled' + start_key = f'{day}_start' + end_key = f'{day}_end' + + # Check if day is enabled + if enabled_key in data: + enabled_val = data[enabled_key] + if isinstance(enabled_val, str): + day_config['enabled'] = enabled_val.lower() in ('true', 'on', '1') + else: + day_config['enabled'] = bool(enabled_val) + else: + day_config['enabled'] = True + + # Only add times if day is enabled + if day_config.get('enabled', True): + enabled_days_count += 1 + start_time = data.get(start_key) or '20:00' + end_time = data.get(end_key) or '07:00' + + # Validate time formats + is_valid, error_msg = _validate_time_format(start_time) + if not is_valid: + return error_response( + ErrorCode.VALIDATION_ERROR, + f"Invalid start time for {day}: {error_msg}", + status_code=400 + ) + + is_valid, error_msg = _validate_time_format(end_time) + if not is_valid: + return error_response( + ErrorCode.VALIDATION_ERROR, + f"Invalid end time for {day}: {error_msg}", + status_code=400 + ) + + day_config['start_time'] = start_time + day_config['end_time'] = end_time + + dim_schedule_config['days'][day] = day_config + + # Validate that at least one day is enabled in per-day mode + if enabled_days_count == 0: + return error_response( + ErrorCode.VALIDATION_ERROR, + "At least one day must be enabled in per-day dim schedule mode", + status_code=400 + ) + + # Update and save config using atomic save + current_config['dim_schedule'] = dim_schedule_config + success, error_msg = _save_config_atomic(api_v3.config_manager, current_config, create_backup=True) + if not success: + return error_response( + ErrorCode.CONFIG_SAVE_FAILED, + f"Failed to save dim schedule configuration: {error_msg}", + status_code=500 + ) + + # Invalidate cache on config change + try: + from web_interface.cache import invalidate_cache + invalidate_cache() + except ImportError: + pass + + return success_response(message='Dim schedule configuration saved successfully') + except Exception as e: + import logging + import traceback + error_msg = f"Error saving dim schedule config: {str(e)}\n{traceback.format_exc()}" + logging.error(error_msg) + return error_response( + ErrorCode.CONFIG_SAVE_FAILED, + f"Error saving dim schedule configuration: {str(e)}", + details=traceback.format_exc(), + status_code=500 + ) + @api_v3.route('/config/main', methods=['POST']) def save_main_config(): """Save main configuration""" diff --git a/web_interface/blueprints/pages_v3.py b/web_interface/blueprints/pages_v3.py index 43ce3324..9d53a523 100644 --- a/web_interface/blueprints/pages_v3.py +++ b/web_interface/blueprints/pages_v3.py @@ -140,8 +140,13 @@ def _load_schedule_partial(): if pages_v3.config_manager: main_config = pages_v3.config_manager.load_config() schedule_config = main_config.get('schedule', {}) + dim_schedule_config = main_config.get('dim_schedule', {}) + # Get normal brightness for display in dim schedule UI + normal_brightness = main_config.get('display', {}).get('hardware', {}).get('brightness', 90) return render_template('v3/partials/schedule.html', - schedule_config=schedule_config) + schedule_config=schedule_config, + dim_schedule_config=dim_schedule_config, + normal_brightness=normal_brightness) except Exception as e: return f"Error: {str(e)}", 500 diff --git a/web_interface/templates/v3/partials/schedule.html b/web_interface/templates/v3/partials/schedule.html index 5fd57991..839f6fa1 100644 --- a/web_interface/templates/v3/partials/schedule.html +++ b/web_interface/templates/v3/partials/schedule.html @@ -26,6 +26,56 @@ + +
+
+

Dim Schedule Settings

+

Configure when the display should automatically dim to a lower brightness. Dimming only applies when the display is ON (active per the schedule above). The dim schedule will not turn the display back on if it's off.

+
+ +
+ + +
+ +
+ + + {{ dim_schedule_config.dim_brightness | default(30) }}% + +
+

Current normal brightness: {{ normal_brightness }}%

+
+ + +
+ + +
+ +
+
+
+