diff --git a/rpi/low_level.py b/rpi/low_level.py index 1c371be..fe1e810 100644 --- a/rpi/low_level.py +++ b/rpi/low_level.py @@ -1,4 +1,7 @@ -from rpi_ws281x import Color +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 diff --git a/rpi/main.py b/rpi/main.py index 2137ecd..4ab9082 100644 --- a/rpi/main.py +++ b/rpi/main.py @@ -10,7 +10,17 @@ import random import socket import sys -from rpi_ws281x import PixelStrip, ws +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,7 +40,16 @@ strip = PixelStrip( channel=0, strip_type=ws.WS2811_STRIP_GRB, ) -strip.begin() +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 _udp_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) @@ -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)), - ] - text, color = texts[bottom_text_state] - node = _add_text_node(text, color, 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_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}]}, + ] + 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) % 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() diff --git a/rpi/mock_rpi_ws281x.py b/rpi/mock_rpi_ws281x.py new file mode 100644 index 0000000..f1dfc79 --- /dev/null +++ b/rpi/mock_rpi_ws281x.py @@ -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 diff --git a/rpi/requirements.txt b/rpi/requirements.txt index e0e30f7..98ae52d 100644 --- a/rpi/requirements.txt +++ b/rpi/requirements.txt @@ -1,2 +1,3 @@ rpi_ws281x flask +flask-sock diff --git a/rpi/server.py b/rpi/server.py index 2fc12e3..e03adee 100644 --- a/rpi/server.py +++ b/rpi/server.py @@ -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 = """
@@ -46,9 +81,56 @@ INDEX_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 = """