mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-18 04:49:13 +00:00
feat(schedule): per-plugin schedules and hide-while-live
Users asked to turn off news and stocks on gameday so only scores show. Each plugin can now have its own schedule (global or per-day window in which it may show) plus hide_when_live, which hides it whenever any plugin reports a live game. A hidden plugin is skipped in the rotation, the live takeover and the Vegas ticker; it stays loaded, and on-demand still shows it. - src/plugin_schedule.py: the window rule, now shared with the display schedule (_check_schedule calls schedule_state) - core config key plugin_schedules; GET/POST /api/v3/config/plugin-schedules - Schedule page: Plugin Schedules card, one picker per enabled plugin Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -224,6 +224,105 @@ def get_refresh_rate():
|
||||
}})
|
||||
|
||||
|
||||
def _build_schedule(data):
|
||||
"""``(schedule, None)`` from a schedule picker's POST body, or ``(None, message)``.
|
||||
|
||||
The display schedule and each plugin schedule (``plugin_schedules``)
|
||||
share it. Times are validated; a per-day schedule that is on needs a day
|
||||
that is on.
|
||||
"""
|
||||
# Build schedule configuration
|
||||
# Handle enabled checkbox - can be True, False, or 'on'
|
||||
enabled_value = data.get('enabled', False)
|
||||
if isinstance(enabled_value, str):
|
||||
enabled_value = enabled_value.lower() in ('true', 'on', '1')
|
||||
schedule_config = {
|
||||
'enabled': enabled_value
|
||||
}
|
||||
|
||||
mode = data.get('mode', 'global')
|
||||
schedule_config['mode'] = mode
|
||||
|
||||
if mode == 'global':
|
||||
# Simple global schedule
|
||||
start_time = data.get('start_time', '07:00')
|
||||
end_time = data.get('end_time', '23:00')
|
||||
|
||||
# Validate time formats
|
||||
is_valid, error_msg = _validate_time_format(start_time)
|
||||
if not is_valid:
|
||||
return None, error_msg
|
||||
|
||||
is_valid, error_msg = _validate_time_format(end_time)
|
||||
if not is_valid:
|
||||
return None, error_msg
|
||||
|
||||
schedule_config['start_time'] = start_time
|
||||
schedule_config['end_time'] = end_time
|
||||
# Remove days config when switching to global mode
|
||||
schedule_config.pop('days', None)
|
||||
else:
|
||||
# Per-day schedule
|
||||
schedule_config['days'] = {}
|
||||
# Remove global times when switching to per-day mode
|
||||
schedule_config.pop('start_time', None)
|
||||
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
|
||||
has_enabled, enabled_val = _day_setting(data, day, enabled_key, 'enabled')
|
||||
if has_enabled:
|
||||
# Handle checkbox values that may come as 'on', True, or False
|
||||
if isinstance(enabled_val, str):
|
||||
day_config['enabled'] = enabled_val.lower() in ('true', 'on', '1')
|
||||
else:
|
||||
day_config['enabled'] = bool(enabled_val)
|
||||
else:
|
||||
# Default to enabled if not specified
|
||||
day_config['enabled'] = True
|
||||
|
||||
# Only add times if day is enabled
|
||||
if day_config.get('enabled', True):
|
||||
enabled_days_count += 1
|
||||
start_time = None
|
||||
end_time = None
|
||||
|
||||
start_time = _day_setting(data, day, start_key, 'start_time')[1] or '07:00'
|
||||
end_time = _day_setting(data, day, end_key, 'end_time')[1] or '23:00'
|
||||
|
||||
# Validate time formats
|
||||
is_valid, error_msg = _validate_time_format(start_time)
|
||||
if not is_valid:
|
||||
return None, f"Invalid start time for {day}: {error_msg}"
|
||||
|
||||
is_valid, error_msg = _validate_time_format(end_time)
|
||||
if not is_valid:
|
||||
return None, f"Invalid end time for {day}: {error_msg}"
|
||||
|
||||
day_config['start_time'] = start_time
|
||||
day_config['end_time'] = end_time
|
||||
else:
|
||||
day_config.update(_disabled_day_times(data, day, start_key, end_key))
|
||||
|
||||
schedule_config['days'][day] = day_config
|
||||
|
||||
# An enabled per-day schedule needs a day to be on. A disabled
|
||||
# one does not: every day off with the schedule off is what
|
||||
# config.template.json ships, so refusing it meant a fresh
|
||||
# install could not post back the schedule GET returned.
|
||||
if enabled_days_count == 0 and enabled_value:
|
||||
return None, "At least one day must be enabled in per-day schedule mode"
|
||||
|
||||
return schedule_config, None
|
||||
|
||||
|
||||
@api_v3.route('/config/schedule', methods=['GET'])
|
||||
def get_schedule_config():
|
||||
"""Get current schedule configuration"""
|
||||
@@ -261,114 +360,9 @@ def save_schedule_config():
|
||||
# Load current config
|
||||
current_config = api_v3.config_manager.load_config()
|
||||
|
||||
# Build schedule configuration
|
||||
# Handle enabled checkbox - can be True, False, or 'on'
|
||||
enabled_value = data.get('enabled', False)
|
||||
if isinstance(enabled_value, str):
|
||||
enabled_value = enabled_value.lower() in ('true', 'on', '1')
|
||||
schedule_config = {
|
||||
'enabled': enabled_value
|
||||
}
|
||||
|
||||
mode = data.get('mode', 'global')
|
||||
schedule_config['mode'] = mode
|
||||
|
||||
if mode == 'global':
|
||||
# Simple global schedule
|
||||
start_time = data.get('start_time', '07:00')
|
||||
end_time = data.get('end_time', '23: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
|
||||
)
|
||||
|
||||
schedule_config['start_time'] = start_time
|
||||
schedule_config['end_time'] = end_time
|
||||
# Remove days config when switching to global mode
|
||||
schedule_config.pop('days', None)
|
||||
else:
|
||||
# Per-day schedule
|
||||
schedule_config['days'] = {}
|
||||
# Remove global times when switching to per-day mode
|
||||
schedule_config.pop('start_time', None)
|
||||
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
|
||||
has_enabled, enabled_val = _day_setting(data, day, enabled_key, 'enabled')
|
||||
if has_enabled:
|
||||
# Handle checkbox values that may come as 'on', True, or False
|
||||
if isinstance(enabled_val, str):
|
||||
day_config['enabled'] = enabled_val.lower() in ('true', 'on', '1')
|
||||
else:
|
||||
day_config['enabled'] = bool(enabled_val)
|
||||
else:
|
||||
# Default to enabled if not specified
|
||||
day_config['enabled'] = True
|
||||
|
||||
# Only add times if day is enabled
|
||||
if day_config.get('enabled', True):
|
||||
enabled_days_count += 1
|
||||
start_time = None
|
||||
end_time = None
|
||||
|
||||
start_time = _day_setting(data, day, start_key, 'start_time')[1] or '07:00'
|
||||
end_time = _day_setting(data, day, end_key, 'end_time')[1] or '23: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
|
||||
else:
|
||||
day_config.update(_disabled_day_times(data, day, start_key, end_key))
|
||||
|
||||
schedule_config['days'][day] = day_config
|
||||
|
||||
# An enabled per-day schedule needs a day to be on. A disabled
|
||||
# one does not: every day off with the schedule off is what
|
||||
# config.template.json ships, so refusing it meant a fresh
|
||||
# install could not post back the schedule GET returned.
|
||||
if enabled_days_count == 0 and enabled_value:
|
||||
return error_response(
|
||||
ErrorCode.VALIDATION_ERROR,
|
||||
"At least one day must be enabled in per-day schedule mode",
|
||||
status_code=400
|
||||
)
|
||||
schedule_config, error_msg = _build_schedule(data)
|
||||
if error_msg:
|
||||
return error_response(ErrorCode.VALIDATION_ERROR, error_msg, status_code=400)
|
||||
|
||||
# Update and save config using atomic save
|
||||
current_config['schedule'] = schedule_config
|
||||
@@ -391,6 +385,67 @@ def save_schedule_config():
|
||||
|
||||
status_code=500, details=describe_exception(e)
|
||||
)
|
||||
@api_v3.route('/config/plugin-schedules', methods=['GET'])
|
||||
def get_plugin_schedules():
|
||||
"""Every plugin's schedule (config['plugin_schedules']), by plugin id."""
|
||||
if not api_v3.config_manager:
|
||||
return error_response(ErrorCode.CONFIG_LOAD_FAILED,
|
||||
'Config manager not initialized', status_code=500)
|
||||
config = api_v3.config_manager.load_config()
|
||||
return success_response(data=config.get('plugin_schedules') or {})
|
||||
|
||||
|
||||
@api_v3.route('/config/plugin-schedules', methods=['POST'])
|
||||
def save_plugin_schedule():
|
||||
"""Save one plugin's schedule (src/plugin_schedule.py).
|
||||
|
||||
The body is a schedule picker's fields plus ``plugin_id`` and
|
||||
``hide_when_live``. A schedule that is off and does not hide during live
|
||||
games changes nothing, so it is removed rather than stored.
|
||||
"""
|
||||
try:
|
||||
if not api_v3.config_manager:
|
||||
return error_response(ErrorCode.CONFIG_LOAD_FAILED,
|
||||
'Config manager not initialized', status_code=500)
|
||||
data = request.get_json(silent=True)
|
||||
if not isinstance(data, dict):
|
||||
return error_response(ErrorCode.VALIDATION_ERROR, 'No data provided', status_code=400)
|
||||
plugin_id = data.get('plugin_id')
|
||||
if not isinstance(plugin_id, str) or not plugin_id:
|
||||
return error_response(ErrorCode.VALIDATION_ERROR, 'plugin_id is required', status_code=400)
|
||||
not_plugin = _pkg._non_plugin_id_error(plugin_id)
|
||||
if not_plugin is not None:
|
||||
return not_plugin
|
||||
if plugin_id not in _pkg._discovered_plugin_manifests(plugin_id):
|
||||
return error_response(ErrorCode.PLUGIN_NOT_FOUND,
|
||||
f"Plugin '{plugin_id}' is not installed", status_code=404)
|
||||
|
||||
schedule, error_msg = _build_schedule(data)
|
||||
if error_msg:
|
||||
return error_response(ErrorCode.VALIDATION_ERROR, error_msg, status_code=400)
|
||||
schedule['hide_when_live'] = _coerce_to_bool(data.get('hide_when_live'))
|
||||
|
||||
current_config = api_v3.config_manager.load_config()
|
||||
schedules = dict(current_config.get('plugin_schedules') or {})
|
||||
if schedule['enabled'] or schedule['hide_when_live']:
|
||||
schedules[plugin_id] = schedule
|
||||
else:
|
||||
schedules.pop(plugin_id, None)
|
||||
current_config['plugin_schedules'] = schedules
|
||||
success, error_msg = _pkg._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 plugin schedule: {error_msg}", status_code=500)
|
||||
invalidate_cache()
|
||||
return success_response(message=f"Schedule for {plugin_id} saved")
|
||||
except Exception as e:
|
||||
logger.error("Error saving plugin schedule", exc_info=True)
|
||||
return error_response(ErrorCode.CONFIG_SAVE_FAILED,
|
||||
"An error occurred; see logs for details",
|
||||
status_code=500, details=describe_exception(e))
|
||||
|
||||
|
||||
@api_v3.route('/config/dim-schedule', methods=['GET'])
|
||||
def get_dim_schedule_config():
|
||||
"""Get current dim schedule configuration"""
|
||||
|
||||
@@ -615,7 +615,28 @@ def _load_schedule_partial():
|
||||
return render_template('v3/partials/schedule.html',
|
||||
schedule_config=schedule_config,
|
||||
dim_schedule_config=dim_schedule_config,
|
||||
normal_brightness=normal_brightness)
|
||||
normal_brightness=normal_brightness,
|
||||
plugin_schedule_rows=_plugin_schedule_rows(main_config))
|
||||
|
||||
|
||||
def _plugin_schedule_rows(main_config):
|
||||
"""One row per enabled plugin, or plugin with a saved schedule, for the
|
||||
Plugin Schedules card: ``{id, name, schedule}``, sorted by name."""
|
||||
schedules = main_config.get('plugin_schedules') or {}
|
||||
if not pages_v3.plugin_catalog:
|
||||
return []
|
||||
try:
|
||||
pages_v3.plugin_catalog.discover_plugins()
|
||||
infos = pages_v3.plugin_catalog.get_all_plugin_info()
|
||||
except Exception:
|
||||
logger.warning("schedule: could not enumerate plugins", exc_info=True)
|
||||
return []
|
||||
rows = [{'id': info['id'], 'name': info.get('name') or info['id'],
|
||||
'schedule': schedules.get(info['id']) or {}}
|
||||
for info in infos
|
||||
if info.get('id') and ((main_config.get(info['id']) or {}).get('enabled', False)
|
||||
or info['id'] in schedules)]
|
||||
return sorted(rows, key=lambda r: r['name'].lower())
|
||||
|
||||
|
||||
def _load_plugins_partial():
|
||||
|
||||
@@ -39,12 +39,26 @@ const PICKERS = [
|
||||
},
|
||||
];
|
||||
|
||||
// Each plugin's picker (the Plugin Schedules card): the window in which the
|
||||
// plugin may show. Unset, it offers the whole day.
|
||||
const PLUGIN_PICKER = { label: 'PluginSchedule', start: '00:00', end: '23:59', fillEmptyDays: true };
|
||||
|
||||
const FORMS = {
|
||||
schedule_form: { saved: 'Schedule settings saved', failed: 'Error saving schedule' },
|
||||
dim_schedule_form: { saved: 'Dim schedule settings saved', failed: 'Error saving dim schedule' },
|
||||
};
|
||||
const PLUGIN_FORM = { saved: 'Plugin schedule saved', failed: 'Error saving plugin schedule' };
|
||||
|
||||
/** The notification labels for a form on this page, or null. */
|
||||
function formLabels(form) {
|
||||
if (FORMS[form.id]) return FORMS[form.id];
|
||||
return form.classList.contains('plugin-schedule-form') ? PLUGIN_FORM : null;
|
||||
}
|
||||
|
||||
const WIDGET_OPTIONS = { showModeToggle: true, showEnableToggle: true, compactMode: false };
|
||||
const PLUGIN_WIDGET_OPTIONS = Object.assign({}, WIDGET_OPTIONS, {
|
||||
enableDescription: 'When enabled, this plugin only shows during the hours below.',
|
||||
});
|
||||
|
||||
// The mounted page, for the deprecated aliases (boot.js).
|
||||
let active = null;
|
||||
@@ -126,6 +140,11 @@ function drawPickers(root, ctx) {
|
||||
widget.render(container, { 'x-options': WIDGET_OPTIONS },
|
||||
widgetValue(readConfig(root, spec.data), spec), { fieldId: spec.fieldId });
|
||||
});
|
||||
root.querySelectorAll('[data-plugin-schedule-config]').forEach(function(container) {
|
||||
widget.render(container, { 'x-options': PLUGIN_WIDGET_OPTIONS },
|
||||
widgetValue(readConfig(container, 'data-plugin-schedule-config'), PLUGIN_PICKER),
|
||||
{ fieldId: container.id });
|
||||
});
|
||||
}
|
||||
attempt();
|
||||
}
|
||||
@@ -135,7 +154,7 @@ export function init(root, ctx) {
|
||||
|
||||
root.addEventListener('htmx:afterRequest', function(event) {
|
||||
const form = event.target && event.target.closest ? event.target.closest('form') : null;
|
||||
const labels = form && root.contains(form) ? FORMS[form.id] : null;
|
||||
const labels = form && root.contains(form) ? formLabels(form) : null;
|
||||
if (labels && event.detail && event.detail.xhr) report(ctx.notify, event.detail.xhr, labels);
|
||||
}, on);
|
||||
|
||||
|
||||
@@ -188,7 +188,7 @@
|
||||
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
|
||||
<span class="ml-2 text-sm font-medium text-gray-900">Enable Schedule</span>
|
||||
</label>
|
||||
<p class="mt-1 text-sm text-gray-600">When enabled, the display will only operate during specified hours.</p>
|
||||
<p class="mt-1 text-sm text-gray-600">${window.LEDEscape.html(xOptions.enableDescription || 'When enabled, the display will only operate during specified hours.')}</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -85,4 +85,51 @@
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Plugin Schedules Section (config plugin_schedules, src/plugin_schedule.py) -->
|
||||
<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">Plugin Schedules</h2>
|
||||
<p class="mt-1 text-sm text-gray-600">Choose when each plugin may show. Outside its schedule a plugin is skipped in the rotation and in Vegas mode; it stays loaded, and on-demand still shows it. "Hide while a game is live" skips it whenever any plugin has a live game, so on gameday you only see scores.</p>
|
||||
</div>
|
||||
|
||||
{% if plugin_schedule_rows %}
|
||||
<div class="space-y-3">
|
||||
{% for row in plugin_schedule_rows %}
|
||||
<details class="border border-gray-200 rounded-md">
|
||||
<summary class="cursor-pointer px-4 py-3 text-sm font-medium text-gray-900">
|
||||
{{ row.name }}
|
||||
{% if row.schedule.enabled or row.schedule.hide_when_live %}
|
||||
<span class="ml-2 text-xs text-blue-700">scheduled</span>
|
||||
{% endif %}
|
||||
</summary>
|
||||
<form class="plugin-schedule-form px-4 pb-4 space-y-4"
|
||||
hx-post="/api/v3/config/plugin-schedules"
|
||||
hx-ext="json-enc"
|
||||
hx-headers='{"Content-Type": "application/json"}'
|
||||
hx-swap="none"
|
||||
data-reports-result>
|
||||
<input type="hidden" name="plugin_id" value="{{ row.id }}">
|
||||
<label class="flex items-center text-sm text-gray-700">
|
||||
<input type="checkbox" name="hide_when_live" class="mr-2"
|
||||
{% if row.schedule.hide_when_live %}checked{% endif %}>
|
||||
Hide while a game is live
|
||||
</label>
|
||||
<div id="plugin_schedule_picker_{{ loop.index }}"
|
||||
data-plugin-schedule-config='{{ row.schedule | tojson }}'></div>
|
||||
<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 {{ row.name }} Schedule
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</details>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-sm text-gray-600">No enabled plugins.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user