From 227a048a0ae5faed3f28cacdbaf2292fb060bba9 Mon Sep 17 00:00:00 2001 From: Chris Date: Thu, 14 May 2026 20:42:52 -0500 Subject: [PATCH] Add schedules and quiet hours features (#32) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Schedules: - Time-based app/playlist switching with day-of-week and time window support - Overnight ranges supported (e.g. 22:00–07:00) - Fires at transition points only — manual changes persist until next boundary - GET/POST /schedules and POST /schedule_tick (immediate re-evaluation) routes - Schedules page in hamburger menu with add/edit/delete/toggle UI - Modal has X close button; saving triggers immediate tick so active schedules fire right away without waiting up to 60s - Sunday shown first in day pickers Quiet Hours: - Dedicated setting to stop the display during defined windows - Takes priority over schedules - Suppresses notification interrupts during quiet hours - Enable toggle, start/end time pickers, day checkboxes in Schedules page Co-Authored-By: Claude Sonnet 4.6 --- server/app.py | 171 +++++++++++++++++++++++++++++++++ server/static/app.js | 185 ++++++++++++++++++++++++++++++++++++ server/templates/index.html | 34 +++++++ 3 files changed, 390 insertions(+) diff --git a/server/app.py b/server/app.py index 1784c46..7bcd36c 100644 --- a/server/app.py +++ b/server/app.py @@ -122,6 +122,11 @@ def load_settings(): "notify_enabled": False, "notify_display_seconds": 10, "notify_sources": {}, + "schedules": [], + "quiet_hours_enabled": False, + "quiet_hours_start": "22:00", + "quiet_hours_end": "07:00", + "quiet_hours_days": ["sun","mon","tue","wed","thu","fri","sat"], "installed_apps": [ "time", "date", "weather", "stocks", "sports", "countdown", "world_clock", "crypto", "iss", "metro", "youtube", "yt_comments", @@ -1000,6 +1005,8 @@ def get_plugin_settings_config(): def _pop_notify(): """Return and remove the oldest non-expired notification, or None.""" + if _quiet_hours_active: + return None now = time.time() with _notify_lock: # Prune stale messages (not shown within 5 minutes) @@ -1141,6 +1148,134 @@ def _get_pages_for_app(app_key): return [] +# ============================================================ +# SCHEDULER + QUIET HOURS +# ============================================================ + +_active_schedule_id = None +_quiet_hours_active = False + + +def _in_time_window(start, end, t): + """Return True if time string t (HH:MM) is within [start, end). Supports overnight ranges.""" + if start <= end: + return start <= t < end + return t >= start or t < end # overnight e.g. 22:00–07:00 + + +def _is_quiet_hours(): + """Return True if quiet hours are currently active.""" + if not settings.get('quiet_hours_enabled', False): + return False + tz = pytz.timezone(settings.get('timezone', 'US/Eastern')) + now = datetime.now(tz) + day = ['mon','tue','wed','thu','fri','sat','sun'][now.weekday()] + if day not in settings.get('quiet_hours_days', []): + return False + t = now.strftime('%H:%M') + return _in_time_window(settings.get('quiet_hours_start', '22:00'), + settings.get('quiet_hours_end', '07:00'), t) + + +def _schedule_tick(): + global _active_schedule_id, _quiet_hours_active + global active_app, active_app_playlist, app_playlist_loop, app_playlist_name + global current_playlist, last_sent_page, loop_delay + + quiet = _is_quiet_hours() + + # Quiet hours transition: entering + if quiet and not _quiet_hours_active: + _quiet_hours_active = True + active_app = None + active_app_playlist = None + stop_event.set() + mqtt_publish_state() + logging.info("Quiet hours: display stopped") + return + + # Quiet hours transition: leaving + if not quiet and _quiet_hours_active: + _quiet_hours_active = False + logging.info("Quiet hours ended") + # Fall through to check schedules + + if quiet: + return # stay quiet, don't evaluate schedules + + # Evaluate schedules + tz = pytz.timezone(settings.get('timezone', 'US/Eastern')) + now = datetime.now(tz) + day = ['mon','tue','wed','thu','fri','sat','sun'][now.weekday()] + t = now.strftime('%H:%M') + + matched = None + for sched in settings.get('schedules', []): + if not sched.get('enabled', True): + continue + if day not in sched.get('days', []): + continue + if _in_time_window(sched.get('start_time', '00:00'), sched.get('end_time', '00:00'), t): + matched = sched + break + + new_id = matched['id'] if matched else None + if new_id == _active_schedule_id: + return # no change + + _active_schedule_id = new_id + if matched is None: + logging.info("Schedule: no active schedule") + return # schedule ended — don't force stop, let user's state persist + + action = matched.get('action', {}) + atype = action.get('type', 'off') + name = matched.get('name', '') + + if atype == 'off': + active_app = None + active_app_playlist = None + stop_event.set() + mqtt_publish_state() + logging.info(f"Schedule '{name}': display off") + + elif atype == 'app': + app_id = action.get('value', '') + if app_id in _plugin_registry: + manifest = _plugin_registry[app_id] + active_app = app_id + active_app_playlist = None + saved = settings.get(f'plugin_{app_id}_loop_delay', '') + loop_delay = float(saved) if saved else float(manifest.get('loop_delay', settings.get('global_loop_delay', 5))) + stop_event.set() + mqtt_publish_state() + logging.info(f"Schedule '{name}': started app {app_id}") + + elif atype == 'playlist': + pl_name = action.get('value', '') + playlists = settings.get('saved_app_playlists', {}) + if pl_name in playlists: + pl = playlists[pl_name] + active_app_playlist = pl.get('entries', []) + app_playlist_loop = pl.get('loop', True) + app_playlist_name = pl_name + active_app = None + current_playlist = [] + last_sent_page = None + stop_event.set() + mqtt_publish_state() + logging.info(f"Schedule '{name}': started playlist '{pl_name}'") + + +def _schedule_loop(): + while True: + time.sleep(60) + _schedule_tick() + + +threading.Thread(target=_schedule_loop, daemon=True).start() +threading.Thread(target=_schedule_tick, daemon=True).start() + def playlist_loop(): global current_playlist, loop_delay, last_sent_page, active_app global active_app_playlist, app_playlist_loop @@ -1622,6 +1757,42 @@ def delete_playlist(name): return jsonify(status="deleted") +# ============================================================ +# SCHEDULES + QUIET HOURS +# ============================================================ + +@app.route('/schedules', methods=['GET', 'POST']) +def schedules_route(): + if request.method == 'GET': + return jsonify(schedules=settings.get('schedules', []), + quiet_hours_enabled=settings.get('quiet_hours_enabled', False), + quiet_hours_start=settings.get('quiet_hours_start', '22:00'), + quiet_hours_end=settings.get('quiet_hours_end', '07:00'), + quiet_hours_days=settings.get('quiet_hours_days', ['mon','tue','wed','thu','fri','sat','sun'])) + data = request.json + if 'schedules' in data: + settings['schedules'] = data['schedules'] + if 'quiet_hours_enabled' in data: + settings['quiet_hours_enabled'] = bool(data['quiet_hours_enabled']) + if 'quiet_hours_start' in data: + settings['quiet_hours_start'] = data['quiet_hours_start'] + if 'quiet_hours_end' in data: + settings['quiet_hours_end'] = data['quiet_hours_end'] + if 'quiet_hours_days' in data: + settings['quiet_hours_days'] = data['quiet_hours_days'] + save_settings(settings) + return jsonify(status="saved") + + +@app.route('/schedule_tick', methods=['POST']) +def schedule_tick_route(): + """Force an immediate schedule evaluation (e.g. after saving schedules).""" + global _active_schedule_id + _active_schedule_id = None # reset so current window re-fires + threading.Thread(target=_schedule_tick, daemon=True).start() + return jsonify(status="ok") + + # ============================================================ # APP PLAYLISTS # ============================================================ diff --git a/server/static/app.js b/server/static/app.js index c22afcd..35eff60 100644 --- a/server/static/app.js +++ b/server/static/app.js @@ -302,6 +302,7 @@ function openMenuPage(name){ if(name==='calibration') loadSettingsData(); if(name==='settings') loadSettingsData(); if(name==='library') loadAppLibrary(); + if(name==='schedules') loadSchedules(); if(typeof lucide!=='undefined') lucide.createIcons(); } @@ -3083,6 +3084,190 @@ function saveHotspotConfig(){ .then(()=>showToast('Hotspot config saved')); } +// ============================================================ +// SCHEDULES + QUIET HOURS +// ============================================================ +const DAYS = ['sun','mon','tue','wed','thu','fri','sat']; +const DAY_LABELS = ['S','M','T','W','T','F','S']; +let _schedules = []; +let _schedulesDirty = false; + +function setSchedulesDirty(v){ _schedulesDirty = v; } + +function loadSchedules(){ + fetch('/schedules').then(r=>r.json()).then(data=>{ + _schedules = data.schedules || []; + document.getElementById('quietHoursEnabled').checked = !!data.quiet_hours_enabled; + document.getElementById('quietHoursStart').value = data.quiet_hours_start || '22:00'; + document.getElementById('quietHoursEnd').value = data.quiet_hours_end || '07:00'; + _renderQuietHoursDays(data.quiet_hours_days || DAYS); + _renderScheduleList(); + setSchedulesDirty(false); + if(typeof lucide!=='undefined') lucide.createIcons(); + }); +} + +function _renderQuietHoursDays(activeDays){ + const el = document.getElementById('quietHoursDays'); + if(!el) return; + el.innerHTML = ''; + DAYS.forEach((d, i) => { + const btn = document.createElement('button'); + btn.className = 'btn btn-sm'; + btn.style.cssText = `min-width:32px;padding:4px 6px;font-size:.8rem;background:${activeDays.includes(d)?'var(--accent)':'#333'};color:#fff;border:1px solid #555`; + btn.textContent = DAY_LABELS[i]; + btn.dataset.day = d; + btn.onclick = () => { + btn.style.background = btn.style.background.includes('accent') ? '#333' : 'var(--accent)'; + setSchedulesDirty(true); + }; + el.appendChild(btn); + }); +} + +function _getQuietHoursDays(){ + return Array.from(document.querySelectorAll('#quietHoursDays button')) + .filter(b => b.style.background.includes('accent')) + .map(b => b.dataset.day); +} + +function _renderScheduleList(){ + const el = document.getElementById('scheduleList'); + if(!el) return; + if(!_schedules.length){ + el.innerHTML='
No schedules yet.
'; + return; + } + el.innerHTML=''; + _schedules.forEach((s, i) => { + const row = document.createElement('div'); + row.style.cssText='display:flex;align-items:center;gap:8px;padding:10px 0;border-bottom:1px solid #2a2a2a'; + const days = s.days.map(d=>DAY_LABELS[DAYS.indexOf(d)]).join(''); + const action = s.action.type==='off' ? 'Off' : s.action.type==='app' ? `App: ${s.action.value}` : `Playlist: ${s.action.value}`; + row.innerHTML=` + +
+
${s.name||'Unnamed'}
+
${days} · ${s.start_time}–${s.end_time} · ${action}
+
+ + `; + el.appendChild(row); + }); +} + +function _toggleSchedule(idx, enabled){ + _schedules[idx].enabled = enabled; + fetch('/schedules',{method:'POST',headers:{'Content-Type':'application/json'}, + body:JSON.stringify({schedules:_schedules})}); +} + +function _deleteSchedule(idx){ + _schedules.splice(idx,1); + fetch('/schedules',{method:'POST',headers:{'Content-Type':'application/json'}, + body:JSON.stringify({schedules:_schedules})}) + .then(()=>_renderScheduleList()); +} + +function saveSchedules(){ + const data = { + schedules: _schedules, + quiet_hours_enabled: document.getElementById('quietHoursEnabled').checked, + quiet_hours_start: document.getElementById('quietHoursStart').value, + quiet_hours_end: document.getElementById('quietHoursEnd').value, + quiet_hours_days: _getQuietHoursDays(), + }; + fetch('/schedules',{method:'POST',headers:{'Content-Type':'application/json'}, + body:JSON.stringify(data)}) + .then(()=>{ showToast('Schedules saved'); setSchedulesDirty(false); }); +} + +function openAddSchedule(){ _openScheduleModal(null); } +function openEditSchedule(idx){ _openScheduleModal(idx); } + +function _openScheduleModal(idx){ + const isEdit = idx !== null; + const s = isEdit ? {..._schedules[idx], action:{..._schedules[idx].action}} : { + id: 'sched_'+Date.now(), name:'', enabled:true, + days:[...DAYS], start_time:'07:00', end_time:'09:00', + action:{type:'app', value:''} + }; + + const modal = document.createElement('div'); + modal.className='modal-overlay'; + modal.style.display='flex'; + modal.innerHTML=` + `; + document.body.appendChild(modal); + _updateSchedActionValue(s.action); + setTimeout(()=>document.getElementById('schedModalName').focus(),50); +} + +function _updateSchedActionValue(action){ + const el = document.getElementById('schedModalActionValue'); + if(!el) return; + const atype = (document.querySelector('[data-atype][style*="accent"]')||{}).dataset?.atype || (action?.type||'app'); + if(atype==='off'){ el.innerHTML=''; return; } + const val = action?.value||''; + if(atype==='app'){ + fetch('/installed_apps').then(r=>r.json()).then(data=>{ + const apps = (data.apps||[]); + el.innerHTML=``; + }); + } else { + fetch('/app_playlists').then(r=>r.json()).then(data=>{ + const names = Object.keys(data); + el.innerHTML=``; + }); + } +} + +function _saveScheduleModal(idx, id){ + const modal = document.querySelector('.modal-overlay'); + const name = document.getElementById('schedModalName').value.trim(); + const days = Array.from(document.querySelectorAll('#schedModalDays [data-day]')) + .filter(b=>b.style.background.includes('accent')).map(b=>b.dataset.day); + const start_time = document.getElementById('schedModalStart').value; + const end_time = document.getElementById('schedModalEnd').value; + const atype = (document.querySelector('[data-atype][style*="accent"]')||{}).dataset?.atype||'app'; + const valEl = document.getElementById('schedModalValue'); + const value = valEl ? valEl.value : ''; + const sched = {id, name, enabled:true, days, start_time, end_time, action:{type:atype, value}}; + if(idx===null || idx==='null') _schedules.push(sched); + else _schedules[idx] = sched; + fetch('/schedules',{method:'POST',headers:{'Content-Type':'application/json'}, + body:JSON.stringify({schedules:_schedules})}) + .then(()=>{ fetch('/schedule_tick',{method:'POST'}); _renderScheduleList(); modal.remove(); showToast('Schedule saved'); }); +} + // ============================================================ // SOFTWARE UPDATE // ============================================================ diff --git a/server/templates/index.html b/server/templates/index.html index 7fc2201..c95b6b0 100644 --- a/server/templates/index.html +++ b/server/templates/index.html @@ -117,6 +117,39 @@

🛠 Create Your Own App

+ +
+ + +
+
+

Schedules

+ +
+

Automatically switch apps or playlists at set times. Manual changes persist until the next schedule boundary.

+
No schedules yet.
+
+ +
+

Quiet Hours

+

Stop the display during these hours. Also suppresses notification interrupts.

+
+ + +
+
+
+
+
+
+
+ +
+
+ +
+
+
@@ -281,6 +314,7 @@

Backup & Restore

+