commits
tags
import eventlet
eventlet.monkey_patch()
import os
import psutil
import subprocess
from pathlib import Path
from flask import Flask, render_template, jsonify, request, abort, session, redirect, url_for
from flask_socketio import SocketIO, emit
import db
import scheduler as sched
from auth import login_required
from addons.sleep import is_sleep_active as _is_sleep_active
from addons.wifi import wifi_status as _wifi_status
from addons.settings.routes import _apply_internal_display
from addon_loader import discover_and_load, get_loaded_addons
from utils.system import local_ip as _local_ip
from utils.display import (
sway_env as _sway_env,
apply_saved_audio as _apply_saved_audio,
apply_display_mode as _apply_display_mode,
)
# Load kiosk.conf (if present) into DB state so settings survive app restarts
def _load_kiosk_conf():
conf = Path(os.path.dirname(__file__)) / 'kiosk.conf'
if not conf.exists():
return
try:
vals = {}
for line in conf.read_text().splitlines():
line = line.strip()
if not line or line.startswith('#') or '=' not in line:
continue
k, v = line.split('=', 1)
vals[k.strip()] = v.strip().strip('"').strip("'")
if 'KIOSK_DISPLAY_MODE' in vals:
db.set_state('display_mode', vals['KIOSK_DISPLAY_MODE'])
if 'KIOSK_DISPLAY_OUTPUT' in vals:
db.set_state('display_output', vals['KIOSK_DISPLAY_OUTPUT'])
if 'KIOSK_AUDIO_PROFILE_INDEX' in vals:
db.set_state('audio_profile_index', vals['KIOSK_AUDIO_PROFILE_INDEX'])
if 'KIOSK_INTERNAL_DISPLAY' in vals:
db.set_state('internal_display', vals['KIOSK_INTERNAL_DISPLAY'])
except Exception:
pass
# load at import time
_load_kiosk_conf()
app = Flask(__name__)
def _get_secret_key():
"""Hämta en persistent SECRET_KEY från DB eller miljövariabel."""
env_key = os.environ.get('SECRET_KEY')
if env_key:
return env_key
# Spara i DB så att sessioner överlever omstart
import db as _db
_db.init_db()
key = _db.get_state().get('_secret_key')
if not key:
key = os.urandom(32).hex()
_db.set_state('_secret_key', key)
return key
app.config['SECRET_KEY'] = _get_secret_key()
socketio = SocketIO(app, async_mode='eventlet', cors_allowed_origins='*')
import extensions as _ext
_ext.socketio = socketio
# Ladda addons och starta scheduler direkt vid import, oavsett startmetod
db.init_db()
discover_and_load(app, socketio)
sched.init_scheduler(socketio)
KIOSK_USER = os.environ.get('KIOSK_USER', 'admin')
KIOSK_PASS = os.environ.get('KIOSK_PASS', 'changeme')
def _check_credentials(username, password):
"""Kontrollera inloggning — DB-hash har prioritet över env/default."""
from werkzeug.security import check_password_hash as _chk
state = db.get_state()
stored_hash = state.get('admin_password_hash')
stored_user = state.get('admin_username') or KIOSK_USER
if username != stored_user:
return False
if stored_hash:
return _chk(stored_hash, password)
return password == KIOSK_PASS
# --- Login ---
@app.route('/login', methods=['GET', 'POST'])
def login():
error = None
if request.method == 'POST':
if _check_credentials(request.form['username'], request.form['password']):
session['logged_in'] = True
return redirect(request.args.get('next') or url_for('admin'))
error = 'Fel användarnamn eller lösenord'
return render_template('login.html', error=error)
@app.route('/logout')
def logout():
session.clear()
return redirect(url_for('login'))
# --- Display page (no auth) ---
@app.route('/display')
def display():
overlays = [a['name'] for a in get_loaded_addons() if a.get('has_overlay')]
return render_template('display.html', local_ip=_local_ip(), active_overlays=overlays)
# --- Admin page ---
@app.route('/admin')
@login_required
def admin():
panels = [a for a in get_loaded_addons() if a.get('has_panel')]
return render_template('admin.html', addon_panels=panels)
# --- API: sysinfo ---
@app.route('/api/sysinfo')
@login_required
def sysinfo():
uptime = subprocess.check_output('uptime -p', shell=True).decode().strip()
state = db.get_state()
return jsonify({
'cpu': psutil.cpu_percent(interval=1),
'ram': psutil.virtual_memory().percent,
'disk': psutil.disk_usage('/').percent,
'uptime': uptime,
'active_type': state.get('current_type', '—'),
'active_source': state.get('current_source', '—'),
})
# --- API: jobs ---
@app.route('/api/jobs', methods=['GET'])
@login_required
def get_jobs():
return jsonify(db.get_all_jobs())
@app.route('/api/jobs', methods=['POST'])
@login_required
def add_job():
data = request.get_json()
if not data:
abort(400)
required = ('label', 'duration', 'type', 'source')
if not all(k in data for k in required):
abort(400)
job_id = db.add_job(
data['label'], float(data['duration']), data['type'], data['source'],
shuffle=bool(data.get('shuffle', False)),
show_chat=bool(data.get('show_chat', False)),
transition_type=str(data.get('transition_type', '')),
transition_duration=int(data.get('transition_duration', 0)),
)
sched.reload_jobs()
return jsonify({'id': job_id}), 201
@app.route('/api/jobs/<int:job_id>', methods=['DELETE'])
@login_required
def delete_job(job_id):
db.delete_job(job_id)
sched.reload_jobs()
return '', 204
@app.route('/api/jobs/<int:job_id>', methods=['PATCH'])
@login_required
def toggle_job(job_id):
# Allow partial update (edit fields) if JSON body provided, otherwise toggle enabled
data = request.get_json(silent=True)
if data:
allowed = {}
for k in ('label', 'duration', 'type', 'source', 'enabled', 'shuffle', 'show_chat'):
if k in data:
allowed[k] = data[k]
if 'duration' in allowed:
allowed['duration'] = float(allowed['duration'])
if 'shuffle' in allowed:
allowed['shuffle'] = 1 if allowed['shuffle'] else 0
if 'show_chat' in allowed:
allowed['show_chat'] = 1 if allowed['show_chat'] else 0
if 'transition_type' in allowed:
allowed['transition_type'] = str(allowed['transition_type'])
if 'transition_duration' in allowed:
allowed['transition_duration'] = int(allowed['transition_duration'] or 0)
db.update_job(job_id, **allowed)
sched.reload_jobs()
return '', 204
else:
result = db.toggle_job(job_id)
if result is None:
abort(404)
sched.reload_jobs()
return jsonify(result)
@app.route('/api/jobs/reorder', methods=['POST'])
@login_required
def reorder_jobs():
data = request.get_json()
if not data or 'order' not in data or not isinstance(data['order'], list):
abort(400)
ids = [int(x) for x in data['order']]
db.reorder_jobs(ids)
sched.reload_jobs()
return '', 204
# --- Kiosk Livechat --- (migrerat till addons/livechat/)
# --- WebSocket ---
# _is_sleep_active importeras från addons.sleep
@socketio.on('connect')
def on_connect():
state = db.get_state()
if state.get('current_type'):
emit('switch', {
'type': state['current_type'],
'source': state.get('current_source', ''),
'scheduled': False,
'shuffle': False,
'show_chat': True,
})
if state.get('sleep_enabled') == '1':
active = _is_sleep_active(
state.get('sleep_start', '22:00'),
state.get('sleep_end', '07:00'),
)
emit('sleep_toggle', {'active': active})
# Emittera alla overlay-inställningar direkt via socket
# så display.js inte behöver lyckas med HTTP-fetches för att initiera.
emit('ticker', {
'enabled': state.get('ticker_enabled', '0') == '1',
'source': state.get('ticker_source', 'text'),
'text': state.get('ticker_text', ''),
'rss_url': state.get('ticker_rss_url', ''),
'rss_interval': int(state.get('ticker_rss_interval', '10')),
'mode': state.get('ticker_mode', 'sequential'),
'separator': state.get('ticker_separator','◆'),
'position': state.get('ticker_position', 'bottom'),
'text_color': state.get('ticker_text_color','#ffffff'),
'font_family': state.get('ticker_font_family','sans-serif'),
'bar_color': state.get('ticker_bar_color', '#000000'),
'pps': int(state.get('ticker_pps', '150')),
})
emit('chat_settings', {
'kiosk_enabled': state.get('kiosk_chat_enabled', '0') == '1',
'kiosk_position': state.get('kiosk_chat_position', 'right'),
'kiosk_width_pct': int(state.get('kiosk_chat_width', '30')),
'kiosk_height': int(state.get('kiosk_chat_height','80')),
})
emit('yt_chat_settings', {
'enabled': state.get('yt_chat_enabled', '0') == '1',
'position': state.get('yt_chat_position', 'right'),
'width_pct': int(state.get('yt_chat_width_pct', '25')),
'height': int(state.get('yt_chat_height', '100')),
'bg_color': state.get('yt_chat_bg_color', '#000000'),
'bg_opacity': int(state.get('yt_chat_bg_opacity', '70')),
})
emit('clock_settings', {
'enabled': state.get('clock_enabled', '0') == '1',
'style': state.get('clock_style', 'digital'),
'show_date': state.get('clock_show_date', '0') == '1',
'position': state.get('clock_position', 'top-right'),
'size': float(state.get('clock_size', '2')),
'color': state.get('clock_color', '#ffffff'),
'opacity': int(state.get('clock_opacity', '90')),
'schedule_mode': state.get('clock_schedule_mode', 'always'),
})
emit('transition_config', {
'type': state.get('transition_type', 'fade'),
'duration': int(state.get('transition_duration', '600')),
})
@socketio.on('admin_switch')
def handle_switch(data):
type_ = data.get('type')
source = data.get('source', '')
db.set_current(type_, source)
socketio.emit('switch', {'type': type_, 'source': source, 'scheduled': False})
# --- File browser --- (migrerat till addons/filebrowser/)
# --- Settings --- (migrerat till addons/settings/)
# --- Söv-läge --- (migrerat till addons/sleep/)
# --- Ticker --- (migrerat till addons/ticker/)
# --- Chat overlay --- (migrerat till addons/livechat/)
# --- Clock overlay --- (migrerat till addons/clock/)
# --- Music --- (migrerat till addons/music/)
# --- Wi-Fi --- (migrerat till addons/wifi/)
# --- Bluetooth --- (migrerat till addons/bluetooth/)
# --- System + internal_display --- (migrerat till addons/system/ och addons/settings/)
# --- Startup ---
def _apply_settings_after_cage():
"""Background task: wait for sway's Wayland socket, then apply saved display + audio."""
xdg_runtime = os.environ.get('XDG_RUNTIME_DIR', f'/run/user/{os.getuid()}')
# Wait up to 30 s for any wayland socket to appear (sway may not be running yet)
for _ in range(30):
eventlet.sleep(1)
socks = [p for p in Path(xdg_runtime).glob('wayland-[0-9]*') if p.suffix != '.lock']
if socks:
break
state = db.get_state()
saved_mode = state.get('display_mode', '')
if saved_mode:
_apply_display_mode(saved_mode, target_output=state.get('display_output') or None)
_apply_saved_audio()
# Vänta ordentligt innan workspace-byte så att display-Chromium hinner
# ladda klart och WebSocket-anslutningen stabiliseras.
eventlet.sleep(15)
env = _sway_env()
if env:
internal_pref = state.get('internal_display', 'off')
wifi = _wifi_status()
should_enable = (not wifi['connected']) or (internal_pref == 'on')
if should_enable:
_apply_internal_display(True, env)
@app.route('/api/addons', methods=['GET'])
@login_required
def list_addons():
"""Lista alla kända addons med aktiverad/inaktiverad status."""
from addon_loader import get_all_addon_names
state = db.get_state()
result = []
for name in get_all_addon_names():
db_val = state.get(f'addon_{name}_enabled')
loaded = any(a['name'] == name for a in get_loaded_addons())
result.append({
'name': name,
'enabled': loaded,
'db_value': db_val,
})
return jsonify(result)
@app.route('/api/addons/<name>/toggle', methods=['POST'])
@login_required
def toggle_addon(name):
"""Aktivera/inaktivera ett addon. Kräver omstart för att träda i kraft."""
from addon_loader import get_all_addon_names
if name not in get_all_addon_names():
return jsonify({'error': 'Okänt addon'}), 404
state = db.get_state()
current_val = state.get(f'addon_{name}_enabled', '1')
new_val = '0' if current_val == '1' else '1'
db.set_state(f'addon_{name}_enabled', new_val)
socketio.emit('addons_changed', {'name': name, 'enabled': new_val == '1'})
return jsonify({'name': name, 'enabled': new_val == '1'})
@app.route('/api/transition', methods=['GET'])
def get_transition():
state = db.get_state()
return jsonify({
'type': state.get('transition_type', 'fade'),
'duration': int(state.get('transition_duration', '600')),
})
@app.route('/api/transition', methods=['POST'])
@login_required
def set_transition():
data = request.get_json() or {}
t = data.get('type', 'fade')
d = int(data.get('duration', 600))
db.set_state('transition_type', t)
db.set_state('transition_duration', str(d))
socketio.emit('transition_config', {'type': t, 'duration': d})
return jsonify({'type': t, 'duration': d})
if __name__ == '__main__':
# Apply audio immediately (PipeWire is independent of the Wayland compositor)
_apply_saved_audio()
# Apply display settings in background after sway/Wayland is up
eventlet.spawn(_apply_settings_after_cage)
socketio.run(app, host='0.0.0.0', port=5000)