Files
cos 7d2fe96704 add mock mode, websocket display stream, and text editor
- mock_rpi_ws281x: stub for running without RPi hardware; auto-fallback
  on RuntimeError, forced via --mock flag or MOCK_LED env var
- mock mode runs web server on port 8080 instead of 80
- replace display polling with WebSocket stream (/ws/display); client
  sends start/stop messages, server pushes binary RGB frames
- force-button feedback: button shows waiting state and polls until
  the cycle clears the flag
- text editor UI: edit top and bottom texts with per-segment color
  pickers; saved to texts.json for persistence across restarts
- bottom and top texts read from server state instead of hardcoded
- bottom text switched to _add_multi_color_node to support multi-color
- fix bottom text invisible: split scroll_all_multi_color_texts into
  top (y<8) and bottom (y>=8) passes so fill_pixels does not wipe
  bottom nodes before they are drawn; this also fixes force-vehicles
  polling loop that stalled when the bottom node never expired

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 21:15:39 +02:00

610 lines
24 KiB
Python

import os
from flask import Flask, request, jsonify, Response
from flask_sock import Sock
import threading
import socket
import json
from config import DISPLAY_MAX_X, DISPLAY_MAX_Y
UDP_PORT = 4210
COLS = DISPLAY_MAX_X + 1 # 48
ROWS = DISPLAY_MAX_Y + 1 # 16
FRAME_SIZE = COLS * ROWS * 3 # 2304 bytes
TEXTS_FILE = os.path.join(os.path.dirname(__file__), 'texts.json')
DEFAULT_TEXTS = {
'top': [
{
'text': 'Witamy w PTI',
'colors': [
{'r': 40, 'g': 40, 'b': 40, 'from': 0},
{'r': 0, 'g': 0, 'b': 50, 'from': 9},
],
}
],
'bottom': [
{'text': 'Technik informatyk + ai, robotyka', 'colors': [{'r': 0, 'g': 0, 'b': 50, 'from': 0}]},
{'text': 'Technik programista', 'colors': [{'r': 50, 'g': 40, 'b': 0, 'from': 0}]},
{'text': 'Technik poligrafii i grafiki komputerowej', 'colors': [{'r': 0, 'g': 45, 'b': 0, 'from': 0}]},
{'text': 'Technik reklamy', 'colors': [{'r': 60, 'g': 20, 'b': 0, 'from': 0}]},
],
}
def _load_texts():
try:
with open(TEXTS_FILE) as f:
return json.load(f)
except Exception:
return DEFAULT_TEXTS
app = Flask(__name__)
_sock = Sock(app)
# Shared state — written by Flask thread, read by main loop
state = {
'brightness': 100,
'program_top_text_enabled': True,
'program_bottom_text_enabled': True,
'program_vehicles_enabled': True,
'force_vehicles': False,
'force_dvd': False,
'texts': _load_texts(),
}
_lock = threading.Lock()
_frame = b'\x00' * FRAME_SIZE
_frame_cond = threading.Condition()
def _udp_listener():
global _frame
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.bind(('127.0.0.1', UDP_PORT))
while True:
data, _ = sock.recvfrom(FRAME_SIZE + 1)
if len(data) == FRAME_SIZE:
with _frame_cond:
_frame = data
_frame_cond.notify_all()
INDEX_HTML = """<!DOCTYPE HTML><html>
<head>
<title>LED Panel Control</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: Arial, sans-serif; background: #1a1a2e; color: #eee; padding: 20px; }
h1 { text-align: center; margin-bottom: 20px; font-size: 1.5em; }
.container { max-width: 600px; margin: 0 auto; }
.display-wrapper { background: #0a0a0a; border-radius: 12px; padding: 12px; margin-bottom: 12px; overflow-x: auto; }
.display-grid { display: grid; grid-template-columns: repeat(48, 1fr); gap: 1px; width: 100%; aspect-ratio: 3/1; }
.dot { aspect-ratio: 1; border-radius: 50%; background: #000; }
/* Stream bar */
.stream-bar { background: #16213e; border-radius: 12px; padding: 12px 20px; margin-bottom: 12px;
display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.stream-status { display: flex; align-items: center; gap: 8px; font-size: 0.9em; }
.status-dot { width: 10px; height: 10px; border-radius: 50%; background: #444; flex-shrink: 0; }
.status-dot.connected { background: #f0a020; }
.status-dot.streaming { background: #4CAF50; animation: pulse 1.2s infinite; }
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.4} }
.stream-btns { display: flex; gap: 8px; }
.sbtn { padding: 8px 18px; border: none; border-radius: 8px; font-size: 0.9em; cursor: pointer; transition: 0.2s; }
.sbtn:disabled { opacity: 0.35; cursor: default; }
.sbtn-start { background: #4CAF50; color: #fff; }
.sbtn-start:not(:disabled):hover { background: #5dbf61; }
.sbtn-stop { background: #c0392b; color: #fff; }
.sbtn-stop:not(:disabled):hover { background: #d44; }
/* Text editor */
.editor-card { background: #16213e; border-radius: 12px; margin-bottom: 12px; overflow: hidden; }
.editor-header { padding: 14px 20px; cursor: pointer; display: flex; justify-content: space-between;
align-items: center; user-select: none; font-size: 1em; }
.editor-header:hover { background: #1e2a4e; }
.editor-body { padding: 16px 20px; border-top: 1px solid #253050; }
.editor-section { margin-bottom: 16px; }
.editor-section-title { font-size: 0.75em; text-transform: uppercase; color: #778; margin-bottom: 8px; letter-spacing: 0.06em; }
.entry-card { background: #1a2540; border-radius: 8px; padding: 12px; margin-bottom: 8px; }
.entry-top-row { display: flex; gap: 8px; align-items: center; margin-bottom: 10px; }
.text-input { flex: 1; background: #0d1b33; border: 1px solid #2a3a5e; border-radius: 6px;
color: #eee; padding: 7px 10px; font-size: 0.9em; width: 100%; }
.seg-label { font-size: 0.78em; color: #778; margin-bottom: 6px; }
.seg-row { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; font-size: 0.85em; }
.seg-color { width: 34px; height: 26px; padding: 1px 2px; border: none; border-radius: 4px; cursor: pointer; background: none; }
.seg-from { width: 52px; background: #0d1b33; border: 1px solid #2a3a5e; border-radius: 4px;
color: #eee; padding: 3px 6px; font-size: 0.9em; }
.rm-btn { background: #6b2020; color: #ddd; border: none; border-radius: 4px;
padding: 3px 8px; cursor: pointer; font-size: 0.8em; }
.rm-btn:hover { background: #9b3030; }
.add-seg-btn { background: #1e3050; color: #99b; border: 1px solid #2a4070; border-radius: 4px;
padding: 4px 10px; cursor: pointer; font-size: 0.8em; margin-top: 2px; }
.add-seg-btn:hover { background: #253a60; }
.add-entry-btn { width: 100%; padding: 8px; background: #1e3050; color: #99b;
border: 1px dashed #2a4070; border-radius: 8px; cursor: pointer; font-size: 0.85em; margin-top: 4px; }
.add-entry-btn:hover { background: #253a60; }
.save-row { display: flex; align-items: center; gap: 12px; padding-top: 4px; }
.btn-save { flex: 1; padding: 10px; background: #4CAF50; color: #fff; border: none;
border-radius: 8px; cursor: pointer; font-size: 0.95em; }
.btn-save:hover { background: #5dbf61; }
#save-status { font-size: 0.85em; min-width: 60px; }
/* Controls */
.controls { max-width: 400px; margin: 0 auto; }
.card { background: #16213e; border-radius: 12px; padding: 16px 20px; margin-bottom: 12px;
display: flex; align-items: center; justify-content: space-between; }
.card-label { font-size: 1em; }
.card-label small { display: block; color: #888; font-size: 0.75em; margin-top: 2px; }
.toggle { position: relative; width: 52px; height: 28px; flex-shrink: 0; }
.toggle input { opacity: 0; width: 0; height: 0; }
.toggle .slider { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0;
background: #444; border-radius: 28px; transition: 0.3s; }
.toggle .slider:before { content: ""; position: absolute; height: 22px; width: 22px;
left: 3px; bottom: 3px; background: #fff; border-radius: 50%; transition: 0.3s; }
.toggle input:checked + .slider { background: #4CAF50; }
.toggle input:checked + .slider:before { transform: translateX(24px); }
.brightness-card { background: #16213e; border-radius: 12px; padding: 16px 20px; margin-bottom: 12px; }
.brightness-card label { font-size: 1em; display: block; margin-bottom: 8px; }
.brightness-row { display: flex; align-items: center; gap: 12px; }
.brightness-row input[type=range] { flex: 1; accent-color: #4CAF50; }
.brightness-row span { min-width: 32px; text-align: right; }
.btn { display: block; width: 100%; padding: 14px; border: none; border-radius: 12px;
font-size: 1em; cursor: pointer; margin-bottom: 12px; transition: 0.2s; }
.btn-vehicles { background: #2a4a7f; color: #eee; }
.btn-vehicles:hover { background: #3a5a9f; }
.btn-vehicles:active { background: #1a3a6f; }
.btn-dvd { background: #6b2a7f; color: #eee; }
.btn-dvd:hover { background: #8b3a9f; }
.btn-dvd:active { background: #5b1a6f; }
</style>
</head>
<body>
<div class="container">
<h1>LED Panel Control</h1>
<div class="display-wrapper">
<div class="display-grid" id="grid"></div>
</div>
<div class="stream-bar">
<div class="stream-status">
<div class="status-dot" id="status-dot"></div>
<span id="status-text">Disconnected</span>
</div>
<div class="stream-btns">
<button class="sbtn sbtn-start" id="btn-start" onclick="startStream()">Start</button>
<button class="sbtn sbtn-stop" id="btn-stop" onclick="stopStream()" disabled>Stop</button>
</div>
</div>
<div class="editor-card">
<div class="editor-header" onclick="toggleEditor()">
<span>Text Editor</span>
<span id="editor-arrow">&#9660;</span>
</div>
<div id="editor-body" style="display:none" class="editor-body">
<div class="editor-section">
<div class="editor-section-title">Top Text</div>
<div id="top-entry"></div>
</div>
<div class="editor-section">
<div class="editor-section-title">Bottom Texts</div>
<div id="bottom-entries"></div>
<button class="add-entry-btn" onclick="_addBottom()">+ Add Bottom Text</button>
</div>
<div class="save-row">
<button class="btn-save" onclick="saveTexts()">Save Changes</button>
<span id="save-status"></span>
</div>
</div>
</div>
<div class="controls">
<div class="card">
<div class="card-label">Top Text<small>Witamy w PTI</small></div>
<label class="toggle"><input type="checkbox" id="top_text" checked onchange="toggle('top_text', this.checked)"><span class="slider"></span></label>
</div>
<div class="card">
<div class="card-label">Bottom Text<small>School programs</small></div>
<label class="toggle"><input type="checkbox" id="bottom_text" checked onchange="toggle('bottom_text', this.checked)"><span class="slider"></span></label>
</div>
<div class="card">
<div class="card-label">Vehicles<small>Vehicle interruptions</small></div>
<label class="toggle"><input type="checkbox" id="vehicles" checked onchange="toggle('vehicles', this.checked)"><span class="slider"></span></label>
</div>
<div class="brightness-card">
<label>Brightness</label>
<div class="brightness-row">
<input type="range" id="brightness" min="0" max="100" value="100" oninput="updateBrightnessLabel()" onchange="sendBrightness()">
<span id="brightnessValue">100</span>
</div>
</div>
<button class="btn btn-vehicles" onclick="forceVehicles()">Force Vehicles Next Cycle</button>
<button class="btn btn-dvd" onclick="forceDvd()">Force DVD Next Cycle</button>
</div>
</div>
<script>
/* ---- LED grid ---- */
var COLS = 48, ROWS = 16;
var dots = [];
(function() {
var grid = document.getElementById('grid');
for (var i = 0; i < ROWS * COLS; i++) {
var d = document.createElement('div');
d.className = 'dot';
grid.appendChild(d);
dots.push(d);
}
})();
/* ---- WebSocket stream ---- */
var ws = null;
function setStatus(state) {
var dot = document.getElementById('status-dot');
var text = document.getElementById('status-text');
var btnStart = document.getElementById('btn-start');
var btnStop = document.getElementById('btn-stop');
dot.className = 'status-dot';
if (state === 'streaming') {
dot.classList.add('streaming');
text.textContent = 'Streaming';
btnStart.disabled = true; btnStop.disabled = false;
} else if (state === 'paused') {
dot.classList.add('connected');
text.textContent = 'Paused';
btnStart.disabled = false; btnStop.disabled = true;
} else {
text.textContent = 'Disconnected';
btnStart.disabled = false; btnStop.disabled = true;
}
}
function startStream() {
if (ws && ws.readyState === WebSocket.OPEN) { ws.send('start'); setStatus('streaming'); return; }
var proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
ws = new WebSocket(proto + '//' + location.host + '/ws/display');
ws.binaryType = 'arraybuffer';
ws.onopen = function() { setStatus('streaming'); };
ws.onmessage = function(e) {
if (!(e.data instanceof ArrayBuffer)) return;
var data = new Uint8Array(e.data);
for (var i = 0; i < ROWS * COLS; i++) {
var r = data[i*3], g = data[i*3+1], b = data[i*3+2];
dots[i].style.background = (r|g|b) ? 'rgb('+r+','+g+','+b+')' : '#000';
}
};
ws.onclose = function() { ws = null; setStatus('disconnected'); };
ws.onerror = function() { ws = null; setStatus('disconnected'); };
}
function stopStream() {
if (!ws || ws.readyState !== WebSocket.OPEN) return;
ws.send('stop'); setStatus('paused');
}
/* ---- Text editor ---- */
var _ed = {top: [{text:'',colors:[{r:40,g:40,b:40,'from':0}]}], bottom: []};
function _hex(r,g,b) {
return '#'+[r,g,b].map(function(v){return ('0'+Math.max(0,Math.min(255,v|0)).toString(16)).slice(-2);}).join('');
}
function _rgb(hex) {
return {r:parseInt(hex.slice(1,3),16), g:parseInt(hex.slice(3,5),16), b:parseInt(hex.slice(5,7),16)};
}
function _esc(s) {
return (s||'').replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/</g,'&lt;');
}
function _segsHtml(segs, pid) {
var h = '<div class="seg-label">Color segments (from character index):</div>';
segs.forEach(function(s,i) {
h += '<div class="seg-row" id="'+pid+'-s'+i+'">'
+ '<input type="color" class="seg-color" value="'+_hex(s.r,s.g,s.b)+'">'
+ ' from char&nbsp;<input type="number" class="seg-from" min="0" value="'+(s['from']||0)+'">'
+ ' <button class="rm-btn" data-pid="'+pid+'" data-idx="'+i+'" onclick="handleRmSeg(this)">&#10005;</button>'
+ '</div>';
});
h += '<button class="add-seg-btn" data-pid="'+pid+'" onclick="handleAddSeg(this)">+ Add Segment</button>';
return h;
}
function _renderTop() {
var e = _ed.top[0] || {text:'',colors:[{r:40,g:40,b:40,'from':0}]};
document.getElementById('top-entry').innerHTML =
'<div class="entry-card">'
+ '<div class="entry-top-row"><input class="text-input" id="top-txt" value="'+_esc(e.text)+'" placeholder="Top text..."></div>'
+ '<div id="top-segs">'+_segsHtml(e.colors||[],'top')+'</div>'
+ '</div>';
}
function _renderBottom() {
var h = '';
(_ed.bottom||[]).forEach(function(e,i) {
h += '<div class="entry-card" id="be'+i+'">'
+ '<div class="entry-top-row">'
+ '<input class="text-input" id="bt'+i+'" value="'+_esc(e.text)+'" placeholder="Bottom text...">'
+ '<button class="rm-btn" data-idx="'+i+'" onclick="handleRmBottom(this)">&#10005; Remove</button>'
+ '</div>'
+ '<div id="bs'+i+'">'+_segsHtml(e.colors||[],'b'+i)+'</div>'
+ '</div>';
});
document.getElementById('bottom-entries').innerHTML = h;
}
function _render() { _renderTop(); _renderBottom(); }
function _readSegs(containerId) {
var segs = [];
document.querySelectorAll('#'+containerId+' .seg-row').forEach(function(row) {
var c = _rgb(row.querySelector('.seg-color').value);
segs.push({r:c.r,g:c.g,b:c.b,'from':parseInt(row.querySelector('.seg-from').value)||0});
});
return segs.length ? segs : [{r:50,g:50,b:50,'from':0}];
}
function _sync() {
_ed.top[0] = {text: document.getElementById('top-txt').value, colors: _readSegs('top-segs')};
(_ed.bottom||[]).forEach(function(_,i) {
_ed.bottom[i] = {text: document.getElementById('bt'+i).value, colors: _readSegs('bs'+i)};
});
}
function _addSeg(pid) {
_sync();
var segs = pid === 'top' ? _ed.top[0].colors : _ed.bottom[parseInt(pid.slice(1))].colors;
segs.push({r:50,g:50,b:50,'from':0});
_render();
}
function _rmSeg(pid, i) {
_sync();
var segs = pid === 'top' ? _ed.top[0].colors : _ed.bottom[parseInt(pid.slice(1))].colors;
segs.splice(i, 1);
if (!segs.length) segs.push({r:50,g:50,b:50,'from':0});
_render();
}
function handleRmSeg(btn) { _rmSeg(btn.getAttribute('data-pid'), parseInt(btn.getAttribute('data-idx'))); }
function handleAddSeg(btn) { _addSeg(btn.getAttribute('data-pid')); }
function handleRmBottom(btn) { _rmBottom(parseInt(btn.getAttribute('data-idx'))); }
function _addBottom() {
_sync();
_ed.bottom.push({text:'',colors:[{r:50,g:50,b:50,'from':0}]});
_render();
}
function _rmBottom(i) {
_sync();
_ed.bottom.splice(i,1);
_render();
}
function toggleEditor() {
var body = document.getElementById('editor-body');
var arrow = document.getElementById('editor-arrow');
var open = body.style.display === 'none';
body.style.display = open ? 'block' : 'none';
arrow.innerHTML = open ? '&#9650;' : '&#9660;';
}
function saveTexts() {
_sync();
fetch('/texts', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify(_ed)
}).then(function(r) {
var el = document.getElementById('save-status');
if (r.ok) { el.textContent = 'Saved!'; el.style.color = '#4CAF50'; }
else { el.textContent = 'Error!'; el.style.color = '#c0392b'; }
setTimeout(function(){ el.textContent=''; }, 2000);
});
}
function loadTexts() {
fetch('/texts').then(function(r){return r.json();}).then(function(d) {
_ed = d;
if (!_ed.top || !_ed.top.length) _ed.top = [{text:'',colors:[{r:40,g:40,b:40,'from':0}]}];
_render();
});
}
/* ---- Controls ---- */
var _forcePoll = {};
function _awaitForceCleared(flagKey, btn, label) {
clearInterval(_forcePoll[flagKey]);
_forcePoll[flagKey] = setInterval(function() {
fetch('/program-status').then(function(r){return r.json();}).then(function(s) {
if (!s[flagKey]) {
clearInterval(_forcePoll[flagKey]);
btn.textContent = 'Triggered!';
btn.style.opacity = '1';
setTimeout(function(){ btn.textContent = label; btn.disabled = false; }, 1500);
}
});
}, 250);
}
function forceVehicles() {
var btn = document.querySelector('.btn-vehicles');
btn.disabled = true; btn.style.opacity = '0.6';
btn.textContent = 'Waiting for cycle end…';
fetch('/force-vehicles', {method:'POST'}).then(function() {
_awaitForceCleared('force_vehicles', btn, 'Force Vehicles Next Cycle');
});
}
function forceDvd() {
var btn = document.querySelector('.btn-dvd');
btn.disabled = true; btn.style.opacity = '0.6';
btn.textContent = 'Waiting for cycle end…';
fetch('/force-dvd', {method:'POST'}).then(function() {
_awaitForceCleared('force_dvd', btn, 'Force DVD Next Cycle');
});
}
function toggle(program, enabled) {
var body = 'program='+program+'&enabled='+(enabled?'true':'false');
fetch('/toggle-program', {method:'POST', headers:{'Content-Type':'application/x-www-form-urlencoded'}, body:body});
}
function updateBrightnessLabel() {
document.getElementById('brightnessValue').innerText = document.getElementById('brightness').value;
}
function sendBrightness() {
fetch('/brightness', {method:'POST', headers:{'Content-Type':'application/x-www-form-urlencoded'},
body:'value='+document.getElementById('brightness').value});
}
function fetchStatus() {
fetch('/program-status').then(function(r){return r.json();}).then(function(s) {
document.getElementById('top_text').checked = s.top_text;
document.getElementById('bottom_text').checked = s.bottom_text;
document.getElementById('vehicles').checked = s.vehicles;
document.getElementById('brightness').value = s.brightness;
document.getElementById('brightnessValue').innerText = s.brightness;
});
}
window.onload = function() { fetchStatus(); loadTexts(); setStatus('disconnected'); };
</script>
</body>
</html>"""
@_sock.route('/ws/display')
def ws_display(ws):
streaming = [True]
def _reader():
try:
while True:
msg = ws.receive()
if msg == 'stop':
streaming[0] = False
elif msg == 'start':
streaming[0] = True
except Exception:
streaming[0] = False
t = threading.Thread(target=_reader, daemon=True)
t.start()
try:
while t.is_alive():
with _frame_cond:
_frame_cond.wait(timeout=0.5)
data = _frame
if streaming[0]:
ws.send(data)
except Exception:
pass
@app.route('/')
def index():
return Response(INDEX_HTML, mimetype='text/html')
@app.route('/display', methods=['GET'])
def handle_display():
with _frame_cond:
data = _frame
pixels = []
off = 0
for y in range(ROWS):
row = []
for x in range(COLS):
row.append([data[off], data[off + 1], data[off + 2]])
off += 3
pixels.append(row)
return Response(json.dumps(pixels), mimetype='application/json')
@app.route('/texts', methods=['GET'])
def handle_get_texts():
with _lock:
return jsonify(state['texts'])
@app.route('/texts', methods=['POST'])
def handle_post_texts():
data = request.get_json(silent=True)
if not isinstance(data, dict) or 'top' not in data or 'bottom' not in data:
return 'Invalid format', 400
try:
with open(TEXTS_FILE, 'w') as f:
json.dump(data, f, indent=2, ensure_ascii=False)
except Exception as e:
return str(e), 500
with _lock:
state['texts'] = data
return 'OK'
@app.route('/brightness', methods=['POST'])
def handle_brightness():
value = request.form.get('value')
if value is not None:
with _lock:
state['brightness'] = max(0, min(100, int(value)))
return 'OK'
@app.route('/toggle-program', methods=['POST'])
def handle_toggle():
program = request.form.get('program')
enabled_str = request.form.get('enabled')
if not program or enabled_str is None:
return 'Missing program or enabled parameter', 400
enabled = enabled_str.lower() == 'true'
key_map = {
'top_text': 'program_top_text_enabled',
'bottom_text': 'program_bottom_text_enabled',
'vehicles': 'program_vehicles_enabled',
}
if program not in key_map:
return 'Unknown program', 400
with _lock:
state[key_map[program]] = enabled
return 'OK'
@app.route('/force-vehicles', methods=['POST'])
def handle_force_vehicles():
with _lock:
state['force_vehicles'] = True
return 'OK'
@app.route('/force-dvd', methods=['POST'])
def handle_force_dvd():
with _lock:
state['force_dvd'] = True
return 'OK'
@app.route('/program-status', methods=['GET'])
def handle_status():
with _lock:
s = dict(state)
return jsonify({
'top_text': s['program_top_text_enabled'],
'bottom_text': s['program_bottom_text_enabled'],
'vehicles': s['program_vehicles_enabled'],
'brightness': s['brightness'],
'force_vehicles': s['force_vehicles'],
'force_dvd': s['force_dvd'],
})
def start_server(host='0.0.0.0', port=80):
threading.Thread(target=_udp_listener, daemon=True).start()
threading.Thread(target=lambda: app.run(host=host, port=port, use_reloader=False), daemon=True).start()