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>
This commit is contained in:
@@ -1,4 +1,7 @@
|
||||
try:
|
||||
from rpi_ws281x import Color
|
||||
except (ImportError, RuntimeError):
|
||||
from mock_rpi_ws281x import Color
|
||||
from config import PANEL_PIXEL_COUNT, DISPLAY_MAX_X, DISPLAY_MAX_Y
|
||||
from fonts import FONT_7X5
|
||||
|
||||
|
||||
+56
-18
@@ -10,7 +10,17 @@ import random
|
||||
import socket
|
||||
import sys
|
||||
|
||||
import os
|
||||
|
||||
_mock = '--mock' in sys.argv or os.environ.get('MOCK_LED', '0') != '0'
|
||||
|
||||
if _mock:
|
||||
from mock_rpi_ws281x import PixelStrip, ws
|
||||
else:
|
||||
try:
|
||||
from rpi_ws281x import PixelStrip, ws
|
||||
except ImportError:
|
||||
from mock_rpi_ws281x import PixelStrip, ws
|
||||
|
||||
import config
|
||||
import low_level
|
||||
@@ -30,6 +40,15 @@ strip = PixelStrip(
|
||||
channel=0,
|
||||
strip_type=ws.WS2811_STRIP_GRB,
|
||||
)
|
||||
if not _mock:
|
||||
try:
|
||||
strip.begin()
|
||||
except RuntimeError as e:
|
||||
print(f'[warn] rpi_ws281x init failed ({e}), falling back to mock', file=sys.stderr)
|
||||
from mock_rpi_ws281x import PixelStrip as _MockStrip, ws as _mock_ws
|
||||
strip = _MockStrip(config.NUMPIXELS, config.PIN)
|
||||
_mock = True
|
||||
else:
|
||||
strip.begin()
|
||||
low_level.strip = strip
|
||||
|
||||
@@ -244,10 +263,12 @@ def scroll_all_texts(split_scroll_mode=False):
|
||||
for ch in node['content']:
|
||||
_draw_char(ch, node['char_height'], node['char_width'], r, g, b, cursor)
|
||||
|
||||
def scroll_all_multi_color_texts(split_scroll_mode=False):
|
||||
def scroll_all_multi_color_texts(split_scroll_mode=False, y_min=0, y_max=None):
|
||||
for node in multi_color_text_nodes:
|
||||
if node['disappear_time'] == 0:
|
||||
continue
|
||||
if node['pos_y'] < y_min or (y_max is not None and node['pos_y'] >= y_max):
|
||||
continue
|
||||
if node['is_scrolling']:
|
||||
if node['scroll_slowness'] > node['scroll_progress']:
|
||||
node['scroll_progress'] += 1
|
||||
@@ -292,8 +313,10 @@ def handle_disappear_timers():
|
||||
0, 0, 0
|
||||
)
|
||||
|
||||
def handle_multi_color_disappear_timers():
|
||||
def handle_multi_color_disappear_timers(y_min=0, y_max=None):
|
||||
for node in multi_color_text_nodes:
|
||||
if node['pos_y'] < y_min or (y_max is not None and node['pos_y'] >= y_max):
|
||||
continue
|
||||
if node['disappear_time'] > 0:
|
||||
node['disappear_time'] -= 1
|
||||
if node['disappear_time'] == 0:
|
||||
@@ -340,9 +363,12 @@ def handle_program1():
|
||||
elif not main_animation_started:
|
||||
if not _is_node_existing(0) and not _is_node_existing(1):
|
||||
main_animation_started = True
|
||||
_tc = server.state.get('texts', {}).get('top', [{}])
|
||||
_te = _tc[0] if _tc else {}
|
||||
_tclrs = [(_c['r'], _c['g'], _c['b'], _c['from']) for _c in _te.get('colors', [{'r':40,'g':40,'b':40,'from':0}, {'r':0,'g':0,'b':50,'from':9}])]
|
||||
node = _add_multi_color_node(
|
||||
"Witamy w PTI",
|
||||
[(40, 40, 40, 0), (0, 0, 50, 9)],
|
||||
_te.get('text', 'Witamy w PTI'),
|
||||
_tclrs,
|
||||
0, 0, 3, True, True, -1, False
|
||||
)
|
||||
if node:
|
||||
@@ -364,9 +390,12 @@ def handle_program1():
|
||||
return
|
||||
|
||||
# Re-add top text from right edge
|
||||
_tc = server.state.get('texts', {}).get('top', [{}])
|
||||
_te = _tc[0] if _tc else {}
|
||||
_tclrs = [(_c['r'], _c['g'], _c['b'], _c['from']) for _c in _te.get('colors', [{'r':40,'g':40,'b':40,'from':0}, {'r':0,'g':0,'b':50,'from':9}])]
|
||||
node = _add_multi_color_node(
|
||||
"Witamy w PTI",
|
||||
[(40, 40, 40, 0), (0, 0, 50, 9)],
|
||||
_te.get('text', 'Witamy w PTI'),
|
||||
_tclrs,
|
||||
config.DISPLAY_MAX_X, 0, 3, True, True, -1, False
|
||||
)
|
||||
if node:
|
||||
@@ -378,6 +407,9 @@ def handle_program1():
|
||||
for node in text_nodes:
|
||||
if node['disappear_time'] != 0:
|
||||
node['disappear_time'] = 1
|
||||
for node in multi_color_text_nodes:
|
||||
if node['global_id'] == scroll_node_global_id and node['disappear_time'] != 0:
|
||||
node['disappear_time'] = 1
|
||||
scroll_node_active = False
|
||||
if program2_active:
|
||||
program2_active = False
|
||||
@@ -404,18 +436,23 @@ def handle_program1():
|
||||
low_level.fill_pixels(0, 9, config.DISPLAY_MAX_X, config.DISPLAY_MAX_Y, 0, 0, 0)
|
||||
return
|
||||
|
||||
texts = [
|
||||
("Technik informatyk + ai, robotyka", (0, 0, 50)),
|
||||
("Technik programista", (50, 40, 0)),
|
||||
("Technik poligrafii i grafiki komputerowej", (0, 45, 0)),
|
||||
("Technik reklamy", (60, 20, 0)),
|
||||
_bottom_entries = server.state.get('texts', {}).get('bottom', [])
|
||||
if not _bottom_entries:
|
||||
_bottom_entries = [
|
||||
{'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}]},
|
||||
]
|
||||
text, color = texts[bottom_text_state]
|
||||
node = _add_text_node(text, color, config.DISPLAY_MAX_X, 9, 1, True, True, -1, False)
|
||||
if _bottom_entries:
|
||||
_idx = bottom_text_state % len(_bottom_entries)
|
||||
_be = _bottom_entries[_idx]
|
||||
_bclrs = [(_c['r'], _c['g'], _c['b'], _c['from']) for _c in _be.get('colors', [{'r': 50, 'g': 50, 'b': 50, 'from': 0}])]
|
||||
node = _add_multi_color_node(_be.get('text', ''), _bclrs, config.DISPLAY_MAX_X, 9, 1, True, True, -1, False)
|
||||
if node is not None:
|
||||
scroll_node_global_id = node['global_id']
|
||||
scroll_node_active = True
|
||||
bottom_text_state = (bottom_text_state + 1) % 4
|
||||
bottom_text_state = (bottom_text_state + 1) % len(_bottom_entries)
|
||||
|
||||
|
||||
def reset_dvd():
|
||||
@@ -529,7 +566,7 @@ def setup():
|
||||
for i in range(config.NUMPIXELS):
|
||||
strip.setPixelColor(i, 0)
|
||||
strip.show()
|
||||
server.start_server(port=80)
|
||||
server.start_server(port=8080 if _mock else 80)
|
||||
print("Server started. Running display loop.")
|
||||
|
||||
|
||||
@@ -553,9 +590,8 @@ def loop():
|
||||
_send_frame()
|
||||
return
|
||||
|
||||
if s['program_top_text_enabled']:
|
||||
scroll_all_multi_color_texts(split_scroll_mode=True)
|
||||
handle_multi_color_disappear_timers()
|
||||
scroll_all_multi_color_texts(split_scroll_mode=True, y_max=8)
|
||||
handle_multi_color_disappear_timers(y_max=8)
|
||||
|
||||
if not s['program_bottom_text_enabled']:
|
||||
if program2_active:
|
||||
@@ -573,6 +609,8 @@ def loop():
|
||||
low_level.fill_pixels(0, 9, config.DISPLAY_MAX_X, config.DISPLAY_MAX_Y, 0, 0, 0)
|
||||
scroll_all_texts(split_scroll_mode=True)
|
||||
handle_disappear_timers()
|
||||
scroll_all_multi_color_texts(split_scroll_mode=True, y_min=8)
|
||||
handle_multi_color_disappear_timers(y_min=8)
|
||||
|
||||
strip.show()
|
||||
_send_frame()
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
"""
|
||||
Stub replacement for rpi_ws281x that allows running without a Raspberry Pi.
|
||||
Pixel data is stored in memory; hardware calls are no-ops.
|
||||
"""
|
||||
|
||||
|
||||
def Color(r, g, b):
|
||||
return ((r & 0xFF) << 16) | ((g & 0xFF) << 8) | (b & 0xFF)
|
||||
|
||||
|
||||
class _ws:
|
||||
WS2811_STRIP_GRB = 0x00081000
|
||||
|
||||
|
||||
ws = _ws()
|
||||
|
||||
|
||||
class PixelStrip:
|
||||
def __init__(self, num, pin, freq_hz=800000, dma=5, invert=False,
|
||||
brightness=255, channel=0, strip_type=None, gamma=None):
|
||||
self._pixels = [0] * num
|
||||
|
||||
def begin(self):
|
||||
pass
|
||||
|
||||
def show(self):
|
||||
pass
|
||||
|
||||
def setBrightness(self, brightness):
|
||||
pass
|
||||
|
||||
def setPixelColor(self, n, color):
|
||||
if 0 <= n < len(self._pixels):
|
||||
self._pixels[n] = color
|
||||
|
||||
def getPixelColor(self, n):
|
||||
if 0 <= n < len(self._pixels):
|
||||
return self._pixels[n]
|
||||
return 0
|
||||
@@ -1,2 +1,3 @@
|
||||
rpi_ws281x
|
||||
flask
|
||||
flask-sock
|
||||
|
||||
+393
-53
@@ -1,4 +1,6 @@
|
||||
import os
|
||||
from flask import Flask, request, jsonify, Response
|
||||
from flask_sock import Sock
|
||||
import threading
|
||||
import socket
|
||||
import json
|
||||
@@ -10,7 +12,37 @@ 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 = {
|
||||
@@ -20,11 +52,12 @@ state = {
|
||||
'program_vehicles_enabled': True,
|
||||
'force_vehicles': False,
|
||||
'force_dvd': False,
|
||||
'texts': _load_texts(),
|
||||
}
|
||||
_lock = threading.Lock()
|
||||
|
||||
_frame = b'\x00' * FRAME_SIZE
|
||||
_frame_lock = threading.Lock()
|
||||
_frame_cond = threading.Condition()
|
||||
|
||||
|
||||
def _udp_listener():
|
||||
@@ -34,8 +67,10 @@ def _udp_listener():
|
||||
while True:
|
||||
data, _ = sock.recvfrom(FRAME_SIZE + 1)
|
||||
if len(data) == FRAME_SIZE:
|
||||
with _frame_lock:
|
||||
with _frame_cond:
|
||||
_frame = data
|
||||
_frame_cond.notify_all()
|
||||
|
||||
|
||||
INDEX_HTML = """<!DOCTYPE HTML><html>
|
||||
<head>
|
||||
@@ -46,9 +81,56 @@ INDEX_HTML = """<!DOCTYPE HTML><html>
|
||||
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-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; }
|
||||
@@ -80,9 +162,44 @@ INDEX_HTML = """<!DOCTYPE HTML><html>
|
||||
<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>
|
||||
@@ -107,89 +224,289 @@ INDEX_HTML = """<!DOCTYPE HTML><html>
|
||||
<button class="btn btn-dvd" onclick="forceDvd()">Force DVD Next Cycle</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
/* ---- LED grid ---- */
|
||||
var COLS = 48, ROWS = 16;
|
||||
var dots = [];
|
||||
var grid = document.getElementById("grid");
|
||||
(function() {
|
||||
var grid = document.getElementById('grid');
|
||||
for (var i = 0; i < ROWS * COLS; i++) {
|
||||
var d = document.createElement("div");
|
||||
d.className = "dot";
|
||||
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 = "#000";
|
||||
/* ---- 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 {
|
||||
dots[idx].style.background = "rgb(" + c[0] + "," + c[1] + "," + c[2] + ")";
|
||||
}
|
||||
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';
|
||||
}
|
||||
};
|
||||
xhr.send();
|
||||
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);
|
||||
}
|
||||
setInterval(pollDisplay, 100);
|
||||
|
||||
function forceVehicles() {
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open("POST", "/force-vehicles", true);
|
||||
xhr.send();
|
||||
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 xhr = new XMLHttpRequest();
|
||||
xhr.open("POST", "/force-dvd", true);
|
||||
xhr.send();
|
||||
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 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"));
|
||||
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;
|
||||
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);
|
||||
fetch('/brightness', {method:'POST', headers:{'Content-Type':'application/x-www-form-urlencoded'},
|
||||
body:'value='+document.getElementById('brightness').value});
|
||||
}
|
||||
|
||||
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;
|
||||
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;
|
||||
});
|
||||
}
|
||||
};
|
||||
xhr.send();
|
||||
}
|
||||
window.onload = function() { fetchStatus(); pollDisplay(); };
|
||||
|
||||
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')
|
||||
@@ -197,7 +514,7 @@ def index():
|
||||
|
||||
@app.route('/display', methods=['GET'])
|
||||
def handle_display():
|
||||
with _frame_lock:
|
||||
with _frame_cond:
|
||||
data = _frame
|
||||
pixels = []
|
||||
off = 0
|
||||
@@ -210,6 +527,27 @@ def handle_display():
|
||||
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')
|
||||
@@ -261,6 +599,8 @@ def handle_status():
|
||||
'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'],
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"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}]}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user