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:
Chuck
2026-01-29 18:12:45 -05:00
committed by GitHub
parent ddd300a117
commit 14c50f316e
6 changed files with 599 additions and 5 deletions

View File

@@ -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"""

View File

@@ -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

View File

@@ -26,6 +26,56 @@
</form>
</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>
(function() {
'use strict';
@@ -145,4 +195,120 @@
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>