mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-04-10 21:03:01 +00:00
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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> --------- Co-authored-by: Chuck <chuck@example.com> Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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",
|
"timezone": "America/Chicago",
|
||||||
"location": {
|
"location": {
|
||||||
"city": "Dallas",
|
"city": "Dallas",
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from pathlib import Path
|
|||||||
from typing import Dict, Any, List, Optional
|
from typing import Dict, Any, List, Optional
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from concurrent.futures import ThreadPoolExecutor, as_completed # pylint: disable=no-name-in-module
|
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
|
# Core system imports only - all functionality now handled via plugins
|
||||||
from src.display_manager import DisplayManager
|
from src.display_manager import DisplayManager
|
||||||
@@ -334,7 +335,12 @@ class DisplayController:
|
|||||||
# Schedule management
|
# Schedule management
|
||||||
self.is_display_active = True
|
self.is_display_active = True
|
||||||
self._was_display_active = True # Track previous state for schedule change detection
|
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
|
# Publish initial on-demand state
|
||||||
try:
|
try:
|
||||||
self._publish_on_demand_state()
|
self._publish_on_demand_state()
|
||||||
@@ -445,8 +451,17 @@ class DisplayController:
|
|||||||
self._was_display_active = True # Track previous state for schedule change detection
|
self._was_display_active = True # Track previous state for schedule change detection
|
||||||
logger.debug("Schedule is disabled - display always active")
|
logger.debug("Schedule is disabled - display always active")
|
||||||
return
|
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_day = current_time.strftime('%A').lower() # Get day name (monday, tuesday, etc.)
|
||||||
current_time_only = current_time.time()
|
current_time_only = current_time.time()
|
||||||
|
|
||||||
@@ -523,11 +538,96 @@ class DisplayController:
|
|||||||
self._was_display_active = self.is_display_active
|
self._was_display_active = self.is_display_active
|
||||||
|
|
||||||
except ValueError as e:
|
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)
|
schedule_type, e, start_time_str, end_time_str)
|
||||||
self.is_display_active = True
|
self.is_display_active = True
|
||||||
self._was_display_active = True # Track previous state for schedule change detection
|
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):
|
def _update_modules(self):
|
||||||
"""Update all plugin modules."""
|
"""Update all plugin modules."""
|
||||||
if not self.plugin_manager:
|
if not self.plugin_manager:
|
||||||
@@ -1184,6 +1284,13 @@ class DisplayController:
|
|||||||
elif not self.on_demand_active and self.on_demand_schedule_override:
|
elif not self.on_demand_active and self.on_demand_schedule_override:
|
||||||
self.on_demand_schedule_override = False
|
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:
|
if not self.is_display_active:
|
||||||
# Clear display when schedule makes it inactive to ensure blank screen
|
# Clear display when schedule makes it inactive to ensure blank screen
|
||||||
# (not showing initialization screen)
|
# (not showing initialization screen)
|
||||||
|
|||||||
@@ -174,6 +174,57 @@ class DisplayManager:
|
|||||||
else:
|
else:
|
||||||
return 32 # Default fallback height
|
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):
|
def _draw_test_pattern(self):
|
||||||
"""Draw a test pattern to verify the display is working."""
|
"""Draw a test pattern to verify the display is working."""
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -353,6 +353,227 @@ def save_schedule_config():
|
|||||||
status_code=500
|
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'])
|
@api_v3.route('/config/main', methods=['POST'])
|
||||||
def save_main_config():
|
def save_main_config():
|
||||||
"""Save main configuration"""
|
"""Save main configuration"""
|
||||||
|
|||||||
@@ -140,8 +140,13 @@ def _load_schedule_partial():
|
|||||||
if pages_v3.config_manager:
|
if pages_v3.config_manager:
|
||||||
main_config = pages_v3.config_manager.load_config()
|
main_config = pages_v3.config_manager.load_config()
|
||||||
schedule_config = main_config.get('schedule', {})
|
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',
|
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:
|
except Exception as e:
|
||||||
return f"Error: {str(e)}", 500
|
return f"Error: {str(e)}", 500
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,56 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Dim Schedule Section -->
|
||||||
|
<div class="bg-white rounded-lg shadow p-6 mt-6">
|
||||||
|
<div class="border-b border-gray-200 pb-4 mb-6">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900">Dim Schedule Settings</h2>
|
||||||
|
<p class="mt-1 text-sm text-gray-600">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.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="dim_schedule_form"
|
||||||
|
hx-post="/api/v3/config/dim-schedule"
|
||||||
|
hx-ext="json-enc"
|
||||||
|
hx-headers='{"Content-Type": "application/json"}'
|
||||||
|
hx-swap="none"
|
||||||
|
hx-on:htmx:after-request="handleDimScheduleResponse(event)"
|
||||||
|
class="space-y-6">
|
||||||
|
|
||||||
|
<!-- Dim Brightness Level -->
|
||||||
|
<div class="bg-gray-50 rounded-lg p-4 mb-4">
|
||||||
|
<label for="dim_brightness" class="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Dim Brightness Level
|
||||||
|
</label>
|
||||||
|
<div class="flex items-center space-x-4">
|
||||||
|
<input type="range"
|
||||||
|
id="dim_brightness"
|
||||||
|
name="dim_brightness"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
value="{{ dim_schedule_config.dim_brightness | default(30) }}"
|
||||||
|
class="flex-1 h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-blue-600"
|
||||||
|
oninput="document.getElementById('dim_brightness_display').textContent = this.value + '%'">
|
||||||
|
<span id="dim_brightness_display" class="text-sm font-medium text-gray-700 w-12 text-right">
|
||||||
|
{{ dim_schedule_config.dim_brightness | default(30) }}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-xs text-gray-500">Current normal brightness: {{ normal_brightness }}%</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Dim Schedule Picker Widget Container -->
|
||||||
|
<div id="dim_schedule_picker_container"></div>
|
||||||
|
|
||||||
|
<!-- Submit Button -->
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<button type="submit"
|
||||||
|
class="btn bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md">
|
||||||
|
<i class="fas fa-save mr-2"></i>
|
||||||
|
Save Dim Schedule Settings
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
(function() {
|
(function() {
|
||||||
'use strict';
|
'use strict';
|
||||||
@@ -145,4 +195,120 @@
|
|||||||
setTimeout(initSchedulePicker, 50);
|
setTimeout(initSchedulePicker, 50);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
// Dim Schedule Picker initialization
|
||||||
|
(function() {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
function initDimSchedulePicker() {
|
||||||
|
const container = document.getElementById('dim_schedule_picker_container');
|
||||||
|
if (!container) {
|
||||||
|
console.error('[DimSchedule] Container not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if widget registry is available
|
||||||
|
if (typeof window.LEDMatrixWidgets === 'undefined') {
|
||||||
|
console.error('[DimSchedule] LEDMatrixWidgets registry not available');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const widget = window.LEDMatrixWidgets.get('schedule-picker');
|
||||||
|
if (!widget) {
|
||||||
|
console.error('[DimSchedule] schedule-picker widget not registered');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get dim schedule config from template data (injected by Jinja2)
|
||||||
|
const dimScheduleConfig = {{ dim_schedule_config | tojson | safe }} || {};
|
||||||
|
|
||||||
|
// Determine mode
|
||||||
|
let mode = 'global';
|
||||||
|
if (dimScheduleConfig.mode) {
|
||||||
|
mode = dimScheduleConfig.mode.replace('-', '_');
|
||||||
|
} else if (dimScheduleConfig.days) {
|
||||||
|
mode = 'per_day';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert config to widget format
|
||||||
|
const widgetValue = {
|
||||||
|
enabled: dimScheduleConfig.enabled || false,
|
||||||
|
mode: mode,
|
||||||
|
start_time: dimScheduleConfig.start_time || '20:00',
|
||||||
|
end_time: dimScheduleConfig.end_time || '07:00',
|
||||||
|
days: dimScheduleConfig.days || {}
|
||||||
|
};
|
||||||
|
|
||||||
|
// If no days config, initialize with defaults for dim schedule (20:00-07:00)
|
||||||
|
if (!dimScheduleConfig.days || Object.keys(dimScheduleConfig.days).length === 0) {
|
||||||
|
widgetValue.days = {};
|
||||||
|
const days = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'];
|
||||||
|
days.forEach(function(day) {
|
||||||
|
widgetValue.days[day] = {
|
||||||
|
enabled: true,
|
||||||
|
start_time: '20:00',
|
||||||
|
end_time: '07:00'
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render the widget with a different field ID
|
||||||
|
widget.render(container, {
|
||||||
|
'x-options': {
|
||||||
|
showModeToggle: true,
|
||||||
|
showEnableToggle: true,
|
||||||
|
compactMode: false
|
||||||
|
}
|
||||||
|
}, widgetValue, {
|
||||||
|
fieldId: 'dim_schedule'
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[DimSchedule] Dim schedule picker widget initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle dim schedule form submission response
|
||||||
|
window.handleDimScheduleResponse = function(event) {
|
||||||
|
const xhr = event.detail.xhr;
|
||||||
|
let response;
|
||||||
|
try {
|
||||||
|
response = JSON.parse(xhr.responseText);
|
||||||
|
} catch (e) {
|
||||||
|
response = { status: 'error', message: 'Invalid response from server' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = response.message || (response.status === 'success' ? 'Dim schedule settings saved' : 'Error saving dim schedule');
|
||||||
|
const type = response.status || 'info';
|
||||||
|
|
||||||
|
// Use global notification function if available
|
||||||
|
if (typeof window.showNotification === 'function') {
|
||||||
|
window.showNotification(message, type);
|
||||||
|
} else {
|
||||||
|
// Fallback notification
|
||||||
|
const colors = {
|
||||||
|
success: 'bg-green-500',
|
||||||
|
error: 'bg-red-500',
|
||||||
|
warning: 'bg-yellow-500',
|
||||||
|
info: 'bg-blue-500'
|
||||||
|
};
|
||||||
|
|
||||||
|
const notification = document.createElement('div');
|
||||||
|
notification.className = 'fixed top-4 right-4 px-6 py-3 rounded-lg shadow-lg z-50 ' + (colors[type] || colors.info) + ' text-white';
|
||||||
|
notification.textContent = message;
|
||||||
|
document.body.appendChild(notification);
|
||||||
|
|
||||||
|
setTimeout(function() {
|
||||||
|
notification.style.transition = 'opacity 0.5s';
|
||||||
|
notification.style.opacity = '0';
|
||||||
|
setTimeout(function() { notification.remove(); }, 500);
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize when DOM is ready
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', initDimSchedulePicker);
|
||||||
|
} else {
|
||||||
|
setTimeout(initDimSchedulePicker, 100);
|
||||||
|
}
|
||||||
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Reference in New Issue
Block a user