7d2fe96704
- 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>
610 lines
24 KiB
Python
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">▼</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,'&').replace(/"/g,'"').replace(/</g,'<');
|
|
}
|
|
|
|
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 <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)">✕</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)">✕ 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 ? '▲' : '▼';
|
|
}
|
|
|
|
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()
|