252 lines
9.3 KiB
Python
252 lines
9.3 KiB
Python
from flask import Flask, request, jsonify, Response
|
|
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
|
|
|
|
app = Flask(__name__)
|
|
|
|
# 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,
|
|
}
|
|
_lock = threading.Lock()
|
|
|
|
_frame = b'\x00' * FRAME_SIZE
|
|
_frame_lock = threading.Lock()
|
|
|
|
|
|
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_lock:
|
|
_frame = data
|
|
|
|
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: 20px; 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: #111; }
|
|
.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; }
|
|
</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="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>
|
|
</div>
|
|
</div>
|
|
<script>
|
|
var COLS = 48, ROWS = 16;
|
|
var dots = [];
|
|
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);
|
|
}
|
|
|
|
function pollDisplay() {
|
|
var xhr = new XMLHttpRequest();
|
|
xhr.open("GET", "/display", true);
|
|
xhr.onload = function() {
|
|
if (xhr.status === 200) {
|
|
var px = JSON.parse(xhr.responseText);
|
|
for (var y = 0; y < ROWS; y++) {
|
|
for (var x = 0; x < COLS; x++) {
|
|
var c = px[y][x];
|
|
var idx = y * COLS + x;
|
|
if (c[0] === 0 && c[1] === 0 && c[2] === 0) {
|
|
dots[idx].style.background = "#111";
|
|
} else {
|
|
dots[idx].style.background = "rgb(" + c[0] + "," + c[1] + "," + c[2] + ")";
|
|
}
|
|
}
|
|
}
|
|
}
|
|
};
|
|
xhr.send();
|
|
}
|
|
setInterval(pollDisplay, 100);
|
|
|
|
function forceVehicles() {
|
|
var xhr = new XMLHttpRequest();
|
|
xhr.open("POST", "/force-vehicles", true);
|
|
xhr.send();
|
|
}
|
|
|
|
function toggle(program, enabled) {
|
|
var xhr = new XMLHttpRequest();
|
|
xhr.open("POST", "/toggle-program", true);
|
|
xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");
|
|
xhr.send("program=" + program + "&enabled=" + (enabled ? "true" : "false"));
|
|
}
|
|
function updateBrightnessLabel() {
|
|
document.getElementById("brightnessValue").innerText = document.getElementById("brightness").value;
|
|
}
|
|
function sendBrightness() {
|
|
var val = document.getElementById("brightness").value;
|
|
var xhr = new XMLHttpRequest();
|
|
xhr.open("POST", "/brightness", true);
|
|
xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");
|
|
xhr.send("value=" + val);
|
|
}
|
|
function fetchStatus() {
|
|
var xhr = new XMLHttpRequest();
|
|
xhr.open("GET", "/program-status", true);
|
|
xhr.onload = function() {
|
|
if (xhr.status === 200) {
|
|
var s = JSON.parse(xhr.responseText);
|
|
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;
|
|
}
|
|
};
|
|
xhr.send();
|
|
}
|
|
window.onload = function() { fetchStatus(); pollDisplay(); };
|
|
</script>
|
|
</body>
|
|
</html>"""
|
|
|
|
|
|
@app.route('/')
|
|
def index():
|
|
return Response(INDEX_HTML, mimetype='text/html')
|
|
|
|
|
|
@app.route('/display', methods=['GET'])
|
|
def handle_display():
|
|
with _frame_lock:
|
|
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('/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('/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'],
|
|
})
|
|
|
|
|
|
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()
|