#!/usr/bin/env python3 import json import socket import struct import threading import time import concurrent.futures from collections import OrderedDict import requests from flask import Flask, jsonify, render_template, request CTRL_PORT = 50742 DISCOVERY_PORT = 50744 BOARD_PORT = 50743 SCAN_TIMEOUT = 1.5 POLL_INTERVAL = 3 app = Flask(__name__) boards = OrderedDict() boards_lock = threading.Lock() offline_threshold = POLL_INTERVAL * 3 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_subnet(): ip = get_local_ip() parts = ip.split(".") return ".".join(parts[:3]) def check_port(ip, port, timeout=SCAN_TIMEOUT): try: s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.settimeout(timeout) s.connect((ip, port)) s.close() return ip except: return None def udp_listener(): sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) sock.settimeout(1) sock.bind(("0.0.0.0", DISCOVERY_PORT)) while True: try: data, addr = sock.recvfrom(2048) msg = json.loads(data) ip = msg.get("ip", addr[0]) port = msg.get("port", BOARD_PORT) with boards_lock: if ip not in boards: print(f" Discovered board: {msg.get('display_name', ip)} at {ip}:{port}") boards[ip] = { "display_name": msg.get("display_name", ""), "ip": ip, "port": port, "last_seen": time.time(), "online": True, "status": boards.get(ip, {}).get("status"), } except socket.timeout: pass except: pass def scan_subnet(): subnet = get_subnet() found = [] with concurrent.futures.ThreadPoolExecutor(max_workers=100) as pool: futures = {pool.submit(check_port, f"{subnet}.{i}", BOARD_PORT): i for i in range(1, 255)} for future in concurrent.futures.as_completed(futures): ip = future.result() if ip: found.append(ip) with boards_lock: for ip in found: if ip not in boards: boards[ip] = { "display_name": f"Board ({ip})", "ip": ip, "port": BOARD_PORT, "last_seen": time.time(), "online": True, "status": None, } print(f" Scanned board: {ip}") return found def refresh_board_cache(ip, port): try: r = requests.get(f"http://{ip}:{port}/api/status", timeout=3) if r.status_code == 200: data = r.json() with boards_lock: if ip in boards: boards[ip]["status"] = data boards[ip]["online"] = True boards[ip]["last_seen"] = time.time() if data.get("display_name"): boards[ip]["display_name"] = data["display_name"] except: pass def poll_boards(): while True: with boards_lock: snapshot = list(boards.items()) for ip, board in snapshot: try: r = requests.get(f"http://{ip}:{board['port']}/api/status", timeout=3) if r.status_code == 200: data = r.json() with boards_lock: if ip in boards: boards[ip]["status"] = data boards[ip]["online"] = True boards[ip]["last_seen"] = time.time() if data.get("display_name"): boards[ip]["display_name"] = data["display_name"] else: with boards_lock: if ip in boards: boards[ip]["online"] = False except: with boards_lock: if ip in boards: boards[ip]["online"] = False time.sleep(POLL_INTERVAL) threading.Thread(target=udp_listener, daemon=True).start() threading.Thread(target=poll_boards, daemon=True).start() @app.route("/") def index(): return render_template("index.html") @app.route("/api/boards") def api_boards(): now = time.time() with boards_lock: result = [] for ip, board in list(boards.items()): stale = (now - board["last_seen"]) > offline_threshold status = board.get("status") or {} scores = status.get("scores") or {} config_data = status.get("config") or {} result.append({ "display_name": board["display_name"], "ip": ip, "port": board["port"], "online": board["online"] and not stale, "scores": scores, "config": config_data, }) return jsonify(result) @app.route("/api/scan", methods=["POST"]) def api_scan(): def do_scan(): found = scan_subnet() print(f" Scan complete: {len(found)} boards found") threading.Thread(target=do_scan, daemon=True).start() return jsonify({"status": "scanning"}) @app.route("/api/boards//", methods=["GET", "POST", "PUT", "OPTIONS"]) def api_proxy(ip, endpoint): port = BOARD_PORT with boards_lock: if ip in boards: port = boards[ip]["port"] url = f"http://{ip}:{port}/{endpoint}" method = request.method headers = {"Content-Type": "application/json"} if request.is_json else {} try: if method == "GET": r = requests.get(url, timeout=5, headers=headers) elif method == "POST": r = requests.post(url, timeout=5, json=request.get_json(silent=True), headers=headers) elif method == "PUT": r = requests.put(url, timeout=5, json=request.get_json(silent=True), headers=headers) else: r = requests.options(url, timeout=5, headers=headers) resp = app.make_response((r.content, r.status_code, dict(r.headers))) resp.headers["Access-Control-Allow-Origin"] = "*" # Immediately refresh cached status after any score-modifying action if method in ("POST", "PUT"): threading.Thread(target=refresh_board_cache, args=(ip, port), daemon=True).start() return resp except Exception as e: return jsonify({"error": str(e)}), 502 @app.after_request def add_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 if __name__ == "__main__": print(f" CuteBoard Controller") print(f" URL: http://0.0.0.0:{CTRL_PORT}") app.run(host="0.0.0.0", port=CTRL_PORT, debug=False, threaded=True)