From a70f739ef70900c6bceb3bf650062c2f6eaf3e45 Mon Sep 17 00:00:00 2001 From: Nick Date: Tue, 11 Aug 2026 18:28:51 -0500 Subject: [PATCH] Add display_id conflict detection, editable board display_id, and multi-IP beaconing --- app.py | 28 +++ controller/app.py | 427 ++++++++++++++++++++++++++------ controller/config.toml | 14 ++ controller/static/js/main.js | 61 ++++- controller/templates/index.html | 14 ++ 5 files changed, 469 insertions(+), 75 deletions(-) create mode 100644 controller/config.toml diff --git a/app.py b/app.py index 0bcbb94..a1cc37e 100644 --- a/app.py +++ b/app.py @@ -50,14 +50,37 @@ def get_local_ip(): 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)) @@ -232,6 +255,11 @@ 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: diff --git a/controller/app.py b/controller/app.py index eb0983b..0eb7666 100644 --- a/controller/app.py +++ b/controller/app.py @@ -1,5 +1,7 @@ #!/usr/bin/env python3 +import ipaddress import json +import os import socket import struct import threading @@ -7,6 +9,11 @@ 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 @@ -18,27 +25,123 @@ POLL_INTERVAL = 3 app = Flask(__name__) -boards = OrderedDict() +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_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 s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) try: s.connect(("10.255.255.255", 1)) - ip = s.getsockname()[0] + ips.add(s.getsockname()[0]) except: - ip = "127.0.0.1" + pass finally: s.close() - return ip + return sorted(ips) -def get_subnet(): - ip = get_local_ip() - parts = ip.split(".") - return ".".join(parts[:3]) +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): @@ -52,6 +155,114 @@ def check_port(ip, port, timeout=SCAN_TIMEOUT): 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) @@ -61,89 +272,145 @@ def udp_listener(): 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"), - } + 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_subnet(): - subnet = get_subnet() +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 = {pool.submit(check_port, f"{subnet}.{i}", BOARD_PORT): i for i in range(1, 255)} + 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) - 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}") + 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): - 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 + 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.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() + 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: - 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"] + 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: - with boards_lock: - if ip in boards: - boards[ip]["online"] = False - except: - with boards_lock: - if ip in boards: - boards[ip]["online"] = False + 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) @@ -161,14 +428,16 @@ def api_boards(): now = time.time() with boards_lock: result = [] - for ip, board in list(boards.items()): + 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": ip, + "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, @@ -177,11 +446,17 @@ def api_boards(): 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_subnet() - print(f" Scan complete: {len(found)} boards found") + found = scan_subnets() + print(f" Scan complete: {len(found)} board IPs found") threading.Thread(target=do_scan, daemon=True).start() return jsonify({"status": "scanning"}) @@ -190,8 +465,12 @@ def api_scan(): def api_proxy(ip, endpoint): port = BOARD_PORT with boards_lock: - if ip in boards: - port = boards[ip]["port"] + 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 {} diff --git a/controller/config.toml b/controller/config.toml new file mode 100644 index 0000000..cd2a2fa --- /dev/null +++ b/controller/config.toml @@ -0,0 +1,14 @@ +# CuteBoard Controller scan configuration. +# +# subnets controls which subnets the "Scan Network" button searches. +# +# ["all"] scan every subnet this controller is attached to (default) +# ["192.168.*"] scan every 192.168.x.x subnet +# ["192.168.1.*"] scan a single 192.168.1.x subnet +# ["10.0.1.*"] scan a single 10.0.1.x subnet +# ["10.0.0.0/24"] scan a CIDR range +# ["192.168.*", "10.0.1.*"] scan multiple ranges +# +# NOTE: broad patterns like 192.168.* scan 65k+ hosts and take a while. +[scan] +subnets = ["10.0.1.*"] diff --git a/controller/static/js/main.js b/controller/static/js/main.js index 2b7490e..7f1a685 100644 --- a/controller/static/js/main.js +++ b/controller/static/js/main.js @@ -32,8 +32,14 @@ } emptyMsg.style.display = 'none'; + var sorted = boards.slice().sort(function (a, b) { + var na = (a.display_name || '').toLowerCase(); + var nb = (b.display_name || '').toLowerCase(); + return na.localeCompare(nb); + }); + var html = ''; - boards.forEach(function (board) { + sorted.forEach(function (board) { var statusClass = board.online ? 'online' : 'offline'; var badgeClass = board.online ? 'badge-online' : 'badge-offline'; var left = board.config && board.config.left || {}; @@ -183,6 +189,7 @@ .then(function (data) { var cfg = data.config || {}; document.getElementById('edit-display-name').value = data.display_name || ''; + document.getElementById('edit-display-id').value = data.display_id != null ? data.display_id : ''; document.getElementById('edit-header').value = data.header || ''; document.getElementById('edit-header-font-size').value = data.header_font_size || ''; document.getElementById('edit-content-padding-top').value = data.content_padding_top || ''; @@ -238,6 +245,9 @@ }, }; + var did = parseInt(document.getElementById('edit-display-id').value, 10); + if (!isNaN(did) && did > 0) payload.display_id = did; + var saveBtn = configForm.querySelector('.btn-save'); saveBtn.textContent = 'Saving...'; saveBtn.disabled = true; @@ -262,6 +272,55 @@ if (e.target === modalOverlay) closeModal(); }); + // ---- Conflicts (F9) ---- + function showConflicts() { + fetch('/api/conflicts') + .then(function (r) { return r.json(); }) + .then(function (list) { + var summary = document.getElementById('conflicts-summary'); + var listEl = document.getElementById('conflicts-list'); + listEl.innerHTML = ''; + if (!list || list.length === 0) { + summary.textContent = 'No display ID conflicts detected.'; + listEl.innerHTML = '

Every discovered board has a unique display_id.

'; + } else { + summary.textContent = list.length + ' conflicted display ID' + (list.length > 1 ? 's' : '') + + '. Give each board a unique display_id to fix (use Edit).'; + list.forEach(function (conflict) { + var box = document.createElement('div'); + box.className = 'card p-4 mb-3'; + var html = '
display_id ' + esc(String(conflict.display_id)) + '
'; + (conflict.boards || []).forEach(function (b) { + html += '
' + + '
' + esc(b.ip) + '' + + '' + esc(b.display_name || '?') + '
' + + '' + + '
'; + }); + box.innerHTML = html; + listEl.appendChild(box); + }); + } + document.getElementById('conflicts-modal').classList.add('open'); + }) + .catch(function () { alert('Could not fetch conflicts'); }); + } + + window.closeConflicts = function () { + document.getElementById('conflicts-modal').classList.remove('open'); + }; + + document.getElementById('conflicts-modal').addEventListener('click', function (e) { + if (e.target === document.getElementById('conflicts-modal')) window.closeConflicts(); + }); + + document.addEventListener('keydown', function (e) { + if (e.key === 'F9') { + e.preventDefault(); + showConflicts(); + } + }); + // ---- Polling ---- fetchBoards(); setInterval(fetchBoards, 3000); diff --git a/controller/templates/index.html b/controller/templates/index.html index 04e880a..be777c4 100644 --- a/controller/templates/index.html +++ b/controller/templates/index.html @@ -90,6 +90,8 @@ Technical Settings + +
@@ -124,6 +126,18 @@
+ + +