Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
171 changes: 171 additions & 0 deletions server/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
# ============================================================
Expand Down
185 changes: 185 additions & 0 deletions server/static/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}

Expand Down Expand Up @@ -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='<div style="color:#666;font-style:italic;font-size:.85rem">No schedules yet.</div>';
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=`
<label class="switch" style="flex-shrink:0"><input type="checkbox" ${s.enabled?'checked':''} onchange="_toggleSchedule(${i},this.checked)"><span class="slider"></span></label>
<div style="flex:1;min-width:0">
<div style="font-size:.9rem;font-weight:600;color:#fff">${s.name||'Unnamed'}</div>
<div style="font-size:.75rem;color:#888">${days} · ${s.start_time}–${s.end_time} · ${action}</div>
</div>
<button class="btn btn-secondary btn-sm" onclick="openEditSchedule(${i})">Edit</button>
<button class="btn-del btn-sm" style="padding:4px 8px;border-radius:4px" onclick="_deleteSchedule(${i})">✕</button>`;
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=`
<div class="modal-content" style="max-width:420px">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
<h3 style="color:var(--accent);margin:0">${isEdit?'Edit':'Add'} Schedule</h3>
<button style="background:none;border:none;color:#888;font-size:1.3rem;cursor:pointer;line-height:1" onclick="this.closest('.modal-overlay').remove()">✕</button>
</div>
<label class="field-label">Name</label>
<input type="text" id="schedModalName" value="${s.name||''}" placeholder="e.g. Morning weather" class="line-input" style="font-size:.9rem;margin-bottom:12px">
<label class="field-label">Days</label>
<div style="display:flex;gap:5px;margin-bottom:12px" id="schedModalDays">
${DAYS.map((d,i)=>`<button type="button" data-day="${d}" style="min-width:30px;padding:4px 5px;font-size:.8rem;border-radius:4px;border:1px solid #555;cursor:pointer;background:${s.days.includes(d)?'var(--accent)':'#333'};color:#fff" onclick="this.style.background=this.style.background.includes('accent')?'#333':'var(--accent)'">${DAY_LABELS[i]}</button>`).join('')}
</div>
<div style="display:flex;gap:12px;margin-bottom:12px">
<div style="flex:1"><label class="field-label">From</label><input type="time" id="schedModalStart" value="${s.start_time}" class="line-input" style="font-size:.9rem;margin:0"></div>
<div style="flex:1"><label class="field-label">To</label><input type="time" id="schedModalEnd" value="${s.end_time}" class="line-input" style="font-size:.9rem;margin:0"></div>
</div>
<label class="field-label">Action</label>
<div style="display:flex;gap:6px;margin-bottom:10px">
${['app','playlist','off'].map(t=>`<button type="button" class="btn btn-sm" data-atype="${t}" style="flex:1;background:${s.action.type===t?'var(--accent)':'#333'};color:#fff;border:1px solid #555" onclick="document.querySelectorAll('[data-atype]').forEach(b=>b.style.background='#333');this.style.background='var(--accent)';_updateSchedActionValue()">${t==='off'?'Off':t==='app'?'App':'Playlist'}</button>`).join('')}
</div>
<div id="schedModalActionValue" style="margin-bottom:14px"></div>
<div style="display:flex;gap:8px">
<button class="btn btn-success" style="flex:1" onclick="_saveScheduleModal(${isEdit?idx:'null'},'${s.id}')">Save</button>
<button class="btn" style="background:#333;color:#aaa;border:1px solid #555" onclick="this.closest('.modal-overlay').remove()">Cancel</button>
</div>
</div>`;
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=`<label class="field-label">App</label><select id="schedModalValue" class="line-input" style="font-size:.9rem;margin:0">
${apps.map(a=>`<option value="${a.plugin_id||a.key}"${(a.plugin_id||a.key)===val?' selected':''}>${a.name}</option>`).join('')}
</select>`;
});
} else {
fetch('/app_playlists').then(r=>r.json()).then(data=>{
const names = Object.keys(data);
el.innerHTML=`<label class="field-label">Playlist</label><select id="schedModalValue" class="line-input" style="font-size:.9rem;margin:0">
${names.map(n=>`<option value="${n}"${n===val?' selected':''}>${n}</option>`).join('')}
</select>`;
});
}
}

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
// ============================================================
Expand Down
Loading