- Add reduced_styles config to switch between Tailwind CDN and inline CSS - Restore liquid wave animations in full-styles mode - Add score decrement endpoint and staged hold-to-reset (decrement/arm/reset) - Improve start.sh: prefer .venv, cleanup stale processes, X display fallback - Scope inline utility classes to body.reduced-styles
322 lines
10 KiB
Python
322 lines
10 KiB
Python
#!/usr/bin/env python3
|
|
import os
|
|
import sys
|
|
import json
|
|
import queue
|
|
import threading
|
|
import socket
|
|
import time
|
|
|
|
try:
|
|
import tomllib
|
|
except ModuleNotFoundError:
|
|
try:
|
|
import tomli as tomllib
|
|
except ModuleNotFoundError:
|
|
print("tomli/tomllib not found. Install with: pip install tomli")
|
|
sys.exit(1)
|
|
|
|
try:
|
|
import tomli_w
|
|
except ModuleNotFoundError:
|
|
tomli_w = None
|
|
|
|
from flask import Flask, render_template, jsonify, Response, request
|
|
|
|
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
CONFIG_PATH = os.path.join(BASE_DIR, "config.toml")
|
|
|
|
with open(CONFIG_PATH, "rb") as f:
|
|
config = tomllib.load(f)
|
|
|
|
PORT = 50743
|
|
|
|
app = Flask(__name__)
|
|
scores = {"left": 0, "right": 0}
|
|
|
|
subscribers = []
|
|
sub_lock = threading.Lock()
|
|
|
|
|
|
def get_local_ip():
|
|
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
try:
|
|
s.connect(("10.255.255.255", 1))
|
|
ip = s.getsockname()[0]
|
|
except:
|
|
ip = "127.0.0.1"
|
|
finally:
|
|
s.close()
|
|
return ip
|
|
|
|
|
|
def get_local_ips():
|
|
ips = set()
|
|
try:
|
|
_, _, ip_list = socket.gethostbyname_ex(socket.gethostname())
|
|
ips.update(ip_list)
|
|
except:
|
|
pass
|
|
try:
|
|
for info in socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET):
|
|
addr = info[4][0]
|
|
if addr != "127.0.0.1":
|
|
ips.add(addr)
|
|
except:
|
|
pass
|
|
try:
|
|
ips.add(get_local_ip())
|
|
except:
|
|
pass
|
|
return sorted(ips)
|
|
|
|
|
|
def udp_beacon():
|
|
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
|
|
while True:
|
|
try:
|
|
payload = json.dumps({
|
|
"display_id": config["display_id"],
|
|
"display_name": config.get("display_name", ""),
|
|
"ip": get_local_ip(),
|
|
"ips": get_local_ips(),
|
|
"port": PORT,
|
|
})
|
|
sock.sendto(payload.encode(), ("255.255.255.255", 50744))
|
|
except:
|
|
pass
|
|
time.sleep(10)
|
|
|
|
|
|
threading.Thread(target=udp_beacon, daemon=True).start()
|
|
|
|
|
|
def broadcast(data):
|
|
dead = []
|
|
with sub_lock:
|
|
for q in subscribers:
|
|
try:
|
|
q.put_nowait(json.dumps(data))
|
|
except:
|
|
dead.append(q)
|
|
for q in dead:
|
|
subscribers.remove(q)
|
|
|
|
|
|
def cors(resp):
|
|
resp.headers["Access-Control-Allow-Origin"] = "*"
|
|
resp.headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, DELETE, OPTIONS"
|
|
resp.headers["Access-Control-Allow-Headers"] = "Content-Type"
|
|
return resp
|
|
|
|
|
|
def save_config():
|
|
if tomli_w is None:
|
|
return
|
|
with open(CONFIG_PATH, "wb") as f:
|
|
tomli_w.dump(config, f)
|
|
|
|
|
|
@app.route("/")
|
|
def index():
|
|
return render_template(
|
|
"index.html",
|
|
display_id=config["display_id"],
|
|
reduced_styles=config.get("reduced_styles", True),
|
|
header=config.get("header", ""),
|
|
header_font_size=config.get("header_font_size", 0),
|
|
content_padding_top=config.get("content_padding_top", 0),
|
|
content_gap=config.get("content_gap", 1),
|
|
left_name=config["left"]["name"],
|
|
left_color=config["left"]["color"],
|
|
left_subtitle=config["left"].get("subtitle", ""),
|
|
left_name_font_size=config["left"].get("name_font_size", 0),
|
|
left_subtitle_font_size=config["left"].get("subtitle_font_size", 0),
|
|
left_score_font_size=config["left"].get("score_font_size", 0),
|
|
right_name=config["right"]["name"],
|
|
right_color=config["right"]["color"],
|
|
right_subtitle=config["right"].get("subtitle", ""),
|
|
right_name_font_size=config["right"].get("name_font_size", 0),
|
|
right_subtitle_font_size=config["right"].get("subtitle_font_size", 0),
|
|
right_score_font_size=config["right"].get("score_font_size", 0),
|
|
left_score=scores["left"],
|
|
right_score=scores["right"],
|
|
)
|
|
|
|
|
|
@app.route("/events")
|
|
def sse():
|
|
def stream():
|
|
q = queue.Queue()
|
|
with sub_lock:
|
|
subscribers.append(q)
|
|
try:
|
|
while True:
|
|
data = q.get()
|
|
yield f"data: {data}\n\n"
|
|
except GeneratorExit:
|
|
with sub_lock:
|
|
if q in subscribers:
|
|
subscribers.remove(q)
|
|
|
|
return Response(stream(), mimetype="text/event-stream")
|
|
|
|
|
|
@app.route("/api/status")
|
|
def api_status():
|
|
return cors(jsonify({
|
|
"display_id": config["display_id"],
|
|
"display_name": config.get("display_name", ""),
|
|
"header": config.get("header", ""),
|
|
"header_font_size": config.get("header_font_size", 0),
|
|
"content_padding_top": config.get("content_padding_top", 0),
|
|
"content_gap": config.get("content_gap", 1),
|
|
"reduced_styles": config.get("reduced_styles", True),
|
|
"ip": get_local_ip(),
|
|
"port": PORT,
|
|
"scores": dict(scores),
|
|
"config": {
|
|
"left": {
|
|
"name": config["left"]["name"],
|
|
"subtitle": config["left"].get("subtitle", ""),
|
|
"color": config["left"]["color"],
|
|
"name_font_size": config["left"].get("name_font_size", 0),
|
|
"subtitle_font_size": config["left"].get("subtitle_font_size", 0),
|
|
"score_font_size": config["left"].get("score_font_size", 0),
|
|
},
|
|
"right": {
|
|
"name": config["right"]["name"],
|
|
"subtitle": config["right"].get("subtitle", ""),
|
|
"color": config["right"]["color"],
|
|
"name_font_size": config["right"].get("name_font_size", 0),
|
|
"subtitle_font_size": config["right"].get("subtitle_font_size", 0),
|
|
"score_font_size": config["right"].get("score_font_size", 0),
|
|
},
|
|
},
|
|
}))
|
|
|
|
|
|
@app.route("/api/score/<side>", methods=["PUT", "OPTIONS"])
|
|
def api_set_score(side):
|
|
if request.method == "OPTIONS":
|
|
return cors(jsonify({}))
|
|
if side not in scores:
|
|
return cors((jsonify({"error": "invalid side"}), 400))
|
|
data = request.get_json(silent=True) or {}
|
|
value = data.get("value")
|
|
if value is None or not isinstance(value, int) or value < 0:
|
|
return cors((jsonify({"error": "value must be a non-negative integer"}), 400))
|
|
scores[side] = value
|
|
broadcast({"type": "score", "scores": dict(scores), "side": side})
|
|
return cors(jsonify(scores))
|
|
|
|
|
|
@app.route("/api/score/<side>/reset", methods=["POST", "OPTIONS"])
|
|
def api_reset_side(side):
|
|
if request.method == "OPTIONS":
|
|
return cors(jsonify({}))
|
|
if side in scores:
|
|
scores[side] = 0
|
|
broadcast({"type": "score", "scores": dict(scores), "side": side})
|
|
return cors(jsonify(scores))
|
|
|
|
|
|
@app.route("/api/score/reset", methods=["POST", "OPTIONS"])
|
|
def api_reset_all():
|
|
if request.method == "OPTIONS":
|
|
return cors(jsonify({}))
|
|
scores["left"] = 0
|
|
scores["right"] = 0
|
|
broadcast({"type": "score", "scores": dict(scores), "side": None})
|
|
return cors(jsonify(scores))
|
|
|
|
|
|
@app.route("/api/score/<side>/increment", methods=["POST", "OPTIONS"])
|
|
def api_increment(side):
|
|
if request.method == "OPTIONS":
|
|
return cors(jsonify({}))
|
|
if side in scores:
|
|
scores[side] += 1
|
|
broadcast({"type": "score", "scores": dict(scores), "side": side})
|
|
return cors(jsonify(scores))
|
|
|
|
|
|
@app.route("/api/score/<side>/decrement", methods=["POST", "OPTIONS"])
|
|
def api_decrement(side):
|
|
if request.method == "OPTIONS":
|
|
return cors(jsonify({}))
|
|
if side in scores and scores[side] > 0:
|
|
scores[side] -= 1
|
|
broadcast({"type": "score", "scores": dict(scores), "side": side})
|
|
return cors(jsonify(scores))
|
|
|
|
|
|
@app.route("/api/config", methods=["PUT", "OPTIONS"])
|
|
def api_update_config():
|
|
if request.method == "OPTIONS":
|
|
return cors(jsonify({}))
|
|
data = request.get_json(silent=True) or {}
|
|
if "display_id" in data:
|
|
did = data["display_id"]
|
|
if not isinstance(did, int) or did <= 0:
|
|
return cors((jsonify({"error": "display_id must be a positive integer"}), 400))
|
|
config["display_id"] = did
|
|
if "display_name" in data:
|
|
config["display_name"] = data["display_name"]
|
|
if "header" in data:
|
|
config["header"] = data["header"]
|
|
if "header_font_size" in data:
|
|
config["header_font_size"] = data["header_font_size"]
|
|
if "content_padding_top" in data:
|
|
config["content_padding_top"] = data["content_padding_top"]
|
|
if "content_gap" in data:
|
|
config["content_gap"] = data["content_gap"]
|
|
for side in ("left", "right"):
|
|
if side in data:
|
|
for key in ("name", "subtitle", "color", "name_font_size", "subtitle_font_size", "score_font_size"):
|
|
if key in data[side]:
|
|
config[side][key] = data[side][key]
|
|
save_config()
|
|
broadcast({"type": "config", "config": data})
|
|
return cors(jsonify({"status": "ok"}))
|
|
|
|
|
|
@app.route("/increment/<side>", methods=["GET", "POST"])
|
|
def increment(side):
|
|
if side in scores:
|
|
scores[side] += 1
|
|
broadcast({"type": "score", "scores": dict(scores), "side": side})
|
|
return jsonify(scores)
|
|
|
|
|
|
@app.route("/decrement/<side>", methods=["GET", "POST"])
|
|
def decrement(side):
|
|
if side in scores and scores[side] > 0:
|
|
scores[side] -= 1
|
|
broadcast({"type": "score", "scores": dict(scores), "side": side})
|
|
return jsonify(scores)
|
|
|
|
|
|
@app.route("/reset", methods=["GET", "POST"])
|
|
def reset():
|
|
scores["left"] = 0
|
|
scores["right"] = 0
|
|
broadcast({"type": "score", "scores": dict(scores), "side": None})
|
|
return jsonify(scores)
|
|
|
|
|
|
@app.route("/reset/<side>", methods=["GET", "POST"])
|
|
def reset_side(side):
|
|
if side in scores:
|
|
scores[side] = 0
|
|
broadcast({"type": "score", "scores": dict(scores), "side": side})
|
|
return jsonify(scores)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
print(f" CuteBoard Display #{config['display_id']}")
|
|
print(f" {config['left']['name']} ({config['left']['color']}) vs {config['right']['name']} ({config['right']['color']})")
|
|
print(f" URL: http://0.0.0.0:{PORT}")
|
|
app.run(host="0.0.0.0", port=PORT, debug=False, threaded=True)
|