#!/usr/bin/env python3 import ipaddress import json import os import socket import struct import threading import time import concurrent.futures from collections import OrderedDict try: import tomllib except ModuleNotFoundError: import tomli as tomllib 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() # display_id -> board record boards_lock = threading.Lock() ip_index = {} # ip -> display_id ip_identity = {} # ip -> {"id": identity, "name": display_name} offline_threshold = POLL_INTERVAL * 3 conflicts = OrderedDict() # display_id -> conflict record conflicts_lock = threading.Lock() 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 s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) try: s.connect(("10.255.255.255", 1)) ips.add(s.getsockname()[0]) except: pass finally: s.close() return sorted(ips) BASE_DIR = os.path.dirname(os.path.abspath(__file__)) CONFIG_PATH = os.path.join(BASE_DIR, "config.toml") def load_scan_subnets(): try: with open(CONFIG_PATH, "rb") as f: data = tomllib.load(f) val = data.get("scan", {}).get("subnets") if isinstance(val, str): val = [val] return val except Exception: return None def resolve_subnets(patterns): if patterns is None: return None networks = [] for pat in patterns: pat = str(pat).strip() if not pat or pat.lower() == "all": return None try: if "*" in pat: prefix = 0 cidr_parts = [] for part in pat.split("."): if part == "*": break cidr_parts.append(part) prefix += 8 if prefix == 0: continue padded = cidr_parts + ["0"] * (4 - len(cidr_parts)) networks.append(ipaddress.ip_network( ".".join(padded) + "/" + str(prefix), strict=False)) elif "/" in pat: networks.append(ipaddress.ip_network(pat, strict=False)) else: networks.append(ipaddress.ip_network(pat + "/32", strict=False)) except ValueError: print(f" Ignoring invalid subnet pattern: {pat}") return networks _scan_networks_cache = None def get_scan_networks(): global _scan_networks_cache if _scan_networks_cache is None: _scan_networks_cache = resolve_subnets(load_scan_subnets()) return _scan_networks_cache def ip_allowed(ip): networks = get_scan_networks() if networks is None: return True try: addr = ipaddress.ip_address(ip) except ValueError: return False return any(addr in net for net in networks) def build_scan_targets(networks): targets = [] if networks is None: subnets = set() for ip in get_local_ips(): subnets.add(".".join(ip.split(".")[:3])) for subnet in sorted(subnets): for i in range(1, 255): targets.append(f"{subnet}.{i}") else: for net in networks: for ip in net.hosts(): targets.append(str(ip)) return targets 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 fetch_status(ip, port): try: r = requests.get(f"http://{ip}:{port}/api/status", timeout=3) if r.status_code == 200: return r.json() except: pass return None def board_identity(status): """Static identity of a board so multi-homed IPs can be told apart from distinct physical boards that share a display_id.""" if not status: return None cfg = status.get("config") or {} return ( status.get("display_id"), status.get("display_name"), status.get("header"), json.dumps(cfg.get("left"), sort_keys=True, default=str), json.dumps(cfg.get("right"), sort_keys=True, default=str), ) def record_conflict(did, ip, status, other_ips): """Record that multiple physical boards are using the same display_id.""" now = time.time() incoming = board_identity(status) with conflicts_lock: groups = {} for other in set(list(other_ips) + [ip]): info = ip_identity.get(other) ident = info["id"] if info else None name = info["name"] if info else "" if other == ip and incoming is not None: ident = incoming name = (status or {}).get("display_name", "") if ident is None: continue groups.setdefault(ident, []).append({"ip": other, "display_name": name}) if len(groups) < 2: conflicts.pop(did, None) return rec = {"display_id": did, "boards": [], "last_seen": now} for entries in groups.values(): for e in entries: rec["boards"].append({ "ip": e["ip"], "display_name": e["display_name"], "last_seen": now, }) conflicts[did] = rec def register_board(did, ip, port, display_name="", status=None): identity = board_identity(status) conflict_ips = None with boards_lock: now = time.time() prev_did = ip_index.get(ip) if prev_did is not None and prev_did != did and prev_did in boards: old = boards[prev_did] if ip in old["ips"]: old["ips"].remove(ip) if not old["ips"]: boards.pop(prev_did, None) if did in boards: board = boards[did] if identity is not None: for other in list(board["ips"]): other_info = ip_identity.get(other) if other_info and other_info["id"] != identity: conflict_ips = list(board["ips"]) break if ip not in board["ips"]: board["ips"].append(ip) print(f" Board {did} also reachable at {ip}:{port}") if display_name and not board["display_name"]: board["display_name"] = display_name board["port"] = port board["last_seen"] = now board["online"] = True board["active_ip"] = ip if status is not None: board["status"] = status if status.get("display_name"): board["display_name"] = status["display_name"] boards.move_to_end(did) else: boards[did] = { "display_id": did, "display_name": display_name or f"Board ({ip})", "ips": [ip], "port": port, "last_seen": now, "online": True, "status": status, "active_ip": ip, } print(f" Discovered board: {boards[did]['display_name']} at {ip}:{port}") if identity is not None: ip_identity[ip] = {"id": identity, "name": display_name or (status or {}).get("display_name", "")} ip_index[ip] = did if conflict_ips is not None: record_conflict(did, ip, status, conflict_ips) 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) port = msg.get("port", BOARD_PORT) did = msg.get("display_id") ips = msg.get("ips") or [] if not ips: ips = [msg.get("ip")] if msg.get("ip") else [addr[0]] for ip in ips: if not ip_allowed(ip): print(f" Ignoring {ip}: outside scan subnets") continue register_board(did or ip, ip, port, msg.get("display_name", "")) except socket.timeout: pass except: pass def scan_subnets(): patterns = load_scan_subnets() networks = resolve_subnets(patterns) targets = build_scan_targets(networks) if networks is not None: print(f" Scanning subnets: {', '.join(str(n) for n in networks)}") else: print(" Scanning local subnets (all)") print(f" Scanning {len(targets)} hosts") found = [] with concurrent.futures.ThreadPoolExecutor(max_workers=100) as pool: futures = {} for target in targets: futures[pool.submit(check_port, target, BOARD_PORT)] = target for future in concurrent.futures.as_completed(futures): ip = future.result() if ip: found.append(ip) print(f" Scan found IPs: {', '.join(sorted(found))}") for ip in found: status = fetch_status(ip, BOARD_PORT) if status and status.get("display_id"): did = status["display_id"] with boards_lock: known = did in boards if known: print(f" Deduplicated: {ip} -> display_id {did} (already registered)") register_board(did, ip, BOARD_PORT, status.get("display_name", ""), status) else: with boards_lock: known = ip in ip_index if known: print(f" Deduplicated: {ip} (already registered)") register_board(ip, ip, BOARD_PORT, f"Board ({ip})", status) return found def refresh_board_cache(ip, port): status = fetch_status(ip, port) if status is None: return did = status.get("display_id") or ip register_board(did, ip, port, status.get("display_name", ""), status) def refresh_conflicts(): """Rebuild conflict records from current ip -> identity data so conflicts disappear once display_ids are made unique.""" now = time.time() with boards_lock: by_did = {} for ip, did in list(ip_index.items()): info = ip_identity.get(ip) if info: by_did.setdefault(did, []).append((ip, info["id"], info["name"])) with conflicts_lock: for did in list(conflicts.keys()): if did not in by_did: conflicts.pop(did, None) for did, entries in by_did.items(): idents = {ident for _, ident, _ in entries} if len(idents) >= 2: conflicts[did] = { "display_id": did, "boards": [{"ip": ip, "display_name": name, "last_seen": now} for ip, _ident, name in entries], "last_seen": now, } else: conflicts.pop(did, None) def poll_boards(): while True: with boards_lock: snapshot = list(boards.values()) for board in snapshot: status = None active_ip = None for ip in list(board["ips"]): status = fetch_status(ip, board["port"]) if status is None: continue reported_did = status.get("display_id") if reported_did is not None and reported_did != board["display_id"]: with boards_lock: rec = boards.get(board["display_id"]) if rec and ip in rec["ips"]: rec["ips"].remove(ip) if rec and not rec["ips"]: boards.pop(board["display_id"], None) status = None continue active_ip = ip break with boards_lock: if board["display_id"] not in boards: continue rec = boards[board["display_id"]] if status is not None: rec["status"] = status rec["online"] = True rec["last_seen"] = time.time() rec["active_ip"] = active_ip if status.get("display_name"): rec["display_name"] = status["display_name"] if active_ip in rec["ips"]: rec["ips"].remove(active_ip) rec["ips"].insert(0, active_ip) else: rec["online"] = False with boards_lock: now = time.time() dead = [did for did, b in list(boards.items()) if not b.get("online") and (now - b["last_seen"]) > 600] for did in dead: boards.pop(did, None) for ip in [ip for ip, d in list(ip_index.items()) if d in dead]: ip_index.pop(ip, None) for ip in [ip for ip in list(ip_identity.keys()) if ip not in ip_index]: ip_identity.pop(ip, None) refresh_conflicts() 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 did, 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_id": did, "display_name": board["display_name"], "ip": board.get("active_ip") or (board["ips"][0] if board["ips"] else None), "ips": list(board["ips"]), "port": board["port"], "online": board["online"] and not stale, "scores": scores, "config": config_data, }) return jsonify(result) @app.route("/api/conflicts") def api_conflicts(): with conflicts_lock: return jsonify(list(conflicts.values())) @app.route("/api/scan", methods=["POST"]) def api_scan(): def do_scan(): found = scan_subnets() print(f" Scan complete: {len(found)} board IPs 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: did = ip_index.get(ip) if did is not None and did in boards: board = boards[did] port = board["port"] if board.get("active_ip"): ip = board["active_ip"] 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)