Add display_id conflict detection, editable board display_id, and multi-IP beaconing
This commit is contained in:
parent
3dc3bc75ca
commit
a70f739ef7
28
app.py
28
app.py
@ -50,14 +50,37 @@ def get_local_ip():
|
|||||||
return 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():
|
def udp_beacon():
|
||||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
|
sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
payload = json.dumps({
|
payload = json.dumps({
|
||||||
|
"display_id": config["display_id"],
|
||||||
"display_name": config.get("display_name", ""),
|
"display_name": config.get("display_name", ""),
|
||||||
"ip": get_local_ip(),
|
"ip": get_local_ip(),
|
||||||
|
"ips": get_local_ips(),
|
||||||
"port": PORT,
|
"port": PORT,
|
||||||
})
|
})
|
||||||
sock.sendto(payload.encode(), ("255.255.255.255", 50744))
|
sock.sendto(payload.encode(), ("255.255.255.255", 50744))
|
||||||
@ -232,6 +255,11 @@ def api_update_config():
|
|||||||
if request.method == "OPTIONS":
|
if request.method == "OPTIONS":
|
||||||
return cors(jsonify({}))
|
return cors(jsonify({}))
|
||||||
data = request.get_json(silent=True) or {}
|
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:
|
if "display_name" in data:
|
||||||
config["display_name"] = data["display_name"]
|
config["display_name"] = data["display_name"]
|
||||||
if "header" in data:
|
if "header" in data:
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
|
import ipaddress
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import socket
|
import socket
|
||||||
import struct
|
import struct
|
||||||
import threading
|
import threading
|
||||||
@ -7,6 +9,11 @@ import time
|
|||||||
import concurrent.futures
|
import concurrent.futures
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
|
|
||||||
|
try:
|
||||||
|
import tomllib
|
||||||
|
except ModuleNotFoundError:
|
||||||
|
import tomli as tomllib
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from flask import Flask, jsonify, render_template, request
|
from flask import Flask, jsonify, render_template, request
|
||||||
|
|
||||||
@ -18,27 +25,123 @@ POLL_INTERVAL = 3
|
|||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
|
|
||||||
boards = OrderedDict()
|
boards = OrderedDict() # display_id -> board record
|
||||||
boards_lock = threading.Lock()
|
boards_lock = threading.Lock()
|
||||||
|
ip_index = {} # ip -> display_id
|
||||||
|
ip_identity = {} # ip -> {"id": identity, "name": display_name}
|
||||||
offline_threshold = POLL_INTERVAL * 3
|
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)
|
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
try:
|
try:
|
||||||
s.connect(("10.255.255.255", 1))
|
s.connect(("10.255.255.255", 1))
|
||||||
ip = s.getsockname()[0]
|
ips.add(s.getsockname()[0])
|
||||||
except:
|
except:
|
||||||
ip = "127.0.0.1"
|
pass
|
||||||
finally:
|
finally:
|
||||||
s.close()
|
s.close()
|
||||||
return ip
|
return sorted(ips)
|
||||||
|
|
||||||
|
|
||||||
def get_subnet():
|
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
ip = get_local_ip()
|
CONFIG_PATH = os.path.join(BASE_DIR, "config.toml")
|
||||||
parts = ip.split(".")
|
|
||||||
return ".".join(parts[:3])
|
|
||||||
|
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):
|
def check_port(ip, port, timeout=SCAN_TIMEOUT):
|
||||||
@ -52,6 +155,114 @@ def check_port(ip, port, timeout=SCAN_TIMEOUT):
|
|||||||
return None
|
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():
|
def udp_listener():
|
||||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||||
@ -61,89 +272,145 @@ def udp_listener():
|
|||||||
try:
|
try:
|
||||||
data, addr = sock.recvfrom(2048)
|
data, addr = sock.recvfrom(2048)
|
||||||
msg = json.loads(data)
|
msg = json.loads(data)
|
||||||
ip = msg.get("ip", addr[0])
|
|
||||||
port = msg.get("port", BOARD_PORT)
|
port = msg.get("port", BOARD_PORT)
|
||||||
with boards_lock:
|
did = msg.get("display_id")
|
||||||
if ip not in boards:
|
ips = msg.get("ips") or []
|
||||||
print(f" Discovered board: {msg.get('display_name', ip)} at {ip}:{port}")
|
if not ips:
|
||||||
boards[ip] = {
|
ips = [msg.get("ip")] if msg.get("ip") else [addr[0]]
|
||||||
"display_name": msg.get("display_name", ""),
|
for ip in ips:
|
||||||
"ip": ip,
|
if not ip_allowed(ip):
|
||||||
"port": port,
|
print(f" Ignoring {ip}: outside scan subnets")
|
||||||
"last_seen": time.time(),
|
continue
|
||||||
"online": True,
|
register_board(did or ip, ip, port, msg.get("display_name", ""))
|
||||||
"status": boards.get(ip, {}).get("status"),
|
|
||||||
}
|
|
||||||
except socket.timeout:
|
except socket.timeout:
|
||||||
pass
|
pass
|
||||||
except:
|
except:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
def scan_subnet():
|
def scan_subnets():
|
||||||
subnet = get_subnet()
|
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 = []
|
found = []
|
||||||
with concurrent.futures.ThreadPoolExecutor(max_workers=100) as pool:
|
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):
|
for future in concurrent.futures.as_completed(futures):
|
||||||
ip = future.result()
|
ip = future.result()
|
||||||
if ip:
|
if ip:
|
||||||
found.append(ip)
|
found.append(ip)
|
||||||
with boards_lock:
|
print(f" Scan found IPs: {', '.join(sorted(found))}")
|
||||||
for ip in found:
|
for ip in found:
|
||||||
if ip not in boards:
|
status = fetch_status(ip, BOARD_PORT)
|
||||||
boards[ip] = {
|
if status and status.get("display_id"):
|
||||||
"display_name": f"Board ({ip})",
|
did = status["display_id"]
|
||||||
"ip": ip,
|
with boards_lock:
|
||||||
"port": BOARD_PORT,
|
known = did in boards
|
||||||
"last_seen": time.time(),
|
if known:
|
||||||
"online": True,
|
print(f" Deduplicated: {ip} -> display_id {did} (already registered)")
|
||||||
"status": None,
|
register_board(did, ip, BOARD_PORT,
|
||||||
}
|
status.get("display_name", ""), status)
|
||||||
print(f" Scanned board: {ip}")
|
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
|
return found
|
||||||
|
|
||||||
|
|
||||||
def refresh_board_cache(ip, port):
|
def refresh_board_cache(ip, port):
|
||||||
try:
|
status = fetch_status(ip, port)
|
||||||
r = requests.get(f"http://{ip}:{port}/api/status", timeout=3)
|
if status is None:
|
||||||
if r.status_code == 200:
|
return
|
||||||
data = r.json()
|
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:
|
with boards_lock:
|
||||||
if ip in boards:
|
by_did = {}
|
||||||
boards[ip]["status"] = data
|
for ip, did in list(ip_index.items()):
|
||||||
boards[ip]["online"] = True
|
info = ip_identity.get(ip)
|
||||||
boards[ip]["last_seen"] = time.time()
|
if info:
|
||||||
if data.get("display_name"):
|
by_did.setdefault(did, []).append((ip, info["id"], info["name"]))
|
||||||
boards[ip]["display_name"] = data["display_name"]
|
with conflicts_lock:
|
||||||
except:
|
for did in list(conflicts.keys()):
|
||||||
pass
|
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():
|
def poll_boards():
|
||||||
while True:
|
while True:
|
||||||
with boards_lock:
|
with boards_lock:
|
||||||
snapshot = list(boards.items())
|
snapshot = list(boards.values())
|
||||||
for ip, board in snapshot:
|
for board in snapshot:
|
||||||
try:
|
status = None
|
||||||
r = requests.get(f"http://{ip}:{board['port']}/api/status", timeout=3)
|
active_ip = None
|
||||||
if r.status_code == 200:
|
for ip in list(board["ips"]):
|
||||||
data = r.json()
|
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:
|
with boards_lock:
|
||||||
if ip in boards:
|
rec = boards.get(board["display_id"])
|
||||||
boards[ip]["status"] = data
|
if rec and ip in rec["ips"]:
|
||||||
boards[ip]["online"] = True
|
rec["ips"].remove(ip)
|
||||||
boards[ip]["last_seen"] = time.time()
|
if rec and not rec["ips"]:
|
||||||
if data.get("display_name"):
|
boards.pop(board["display_id"], None)
|
||||||
boards[ip]["display_name"] = data["display_name"]
|
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:
|
else:
|
||||||
|
rec["online"] = False
|
||||||
with boards_lock:
|
with boards_lock:
|
||||||
if ip in boards:
|
now = time.time()
|
||||||
boards[ip]["online"] = False
|
dead = [did for did, b in list(boards.items())
|
||||||
except:
|
if not b.get("online") and (now - b["last_seen"]) > 600]
|
||||||
with boards_lock:
|
for did in dead:
|
||||||
if ip in boards:
|
boards.pop(did, None)
|
||||||
boards[ip]["online"] = False
|
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)
|
time.sleep(POLL_INTERVAL)
|
||||||
|
|
||||||
|
|
||||||
@ -161,14 +428,16 @@ def api_boards():
|
|||||||
now = time.time()
|
now = time.time()
|
||||||
with boards_lock:
|
with boards_lock:
|
||||||
result = []
|
result = []
|
||||||
for ip, board in list(boards.items()):
|
for did, board in list(boards.items()):
|
||||||
stale = (now - board["last_seen"]) > offline_threshold
|
stale = (now - board["last_seen"]) > offline_threshold
|
||||||
status = board.get("status") or {}
|
status = board.get("status") or {}
|
||||||
scores = status.get("scores") or {}
|
scores = status.get("scores") or {}
|
||||||
config_data = status.get("config") or {}
|
config_data = status.get("config") or {}
|
||||||
result.append({
|
result.append({
|
||||||
|
"display_id": did,
|
||||||
"display_name": board["display_name"],
|
"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"],
|
"port": board["port"],
|
||||||
"online": board["online"] and not stale,
|
"online": board["online"] and not stale,
|
||||||
"scores": scores,
|
"scores": scores,
|
||||||
@ -177,11 +446,17 @@ def api_boards():
|
|||||||
return jsonify(result)
|
return jsonify(result)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/conflicts")
|
||||||
|
def api_conflicts():
|
||||||
|
with conflicts_lock:
|
||||||
|
return jsonify(list(conflicts.values()))
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/scan", methods=["POST"])
|
@app.route("/api/scan", methods=["POST"])
|
||||||
def api_scan():
|
def api_scan():
|
||||||
def do_scan():
|
def do_scan():
|
||||||
found = scan_subnet()
|
found = scan_subnets()
|
||||||
print(f" Scan complete: {len(found)} boards found")
|
print(f" Scan complete: {len(found)} board IPs found")
|
||||||
threading.Thread(target=do_scan, daemon=True).start()
|
threading.Thread(target=do_scan, daemon=True).start()
|
||||||
return jsonify({"status": "scanning"})
|
return jsonify({"status": "scanning"})
|
||||||
|
|
||||||
@ -190,8 +465,12 @@ def api_scan():
|
|||||||
def api_proxy(ip, endpoint):
|
def api_proxy(ip, endpoint):
|
||||||
port = BOARD_PORT
|
port = BOARD_PORT
|
||||||
with boards_lock:
|
with boards_lock:
|
||||||
if ip in boards:
|
did = ip_index.get(ip)
|
||||||
port = boards[ip]["port"]
|
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}"
|
url = f"http://{ip}:{port}/{endpoint}"
|
||||||
method = request.method
|
method = request.method
|
||||||
headers = {"Content-Type": "application/json"} if request.is_json else {}
|
headers = {"Content-Type": "application/json"} if request.is_json else {}
|
||||||
|
|||||||
14
controller/config.toml
Normal file
14
controller/config.toml
Normal file
@ -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.*"]
|
||||||
@ -32,8 +32,14 @@
|
|||||||
}
|
}
|
||||||
emptyMsg.style.display = 'none';
|
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 = '';
|
var html = '';
|
||||||
boards.forEach(function (board) {
|
sorted.forEach(function (board) {
|
||||||
var statusClass = board.online ? 'online' : 'offline';
|
var statusClass = board.online ? 'online' : 'offline';
|
||||||
var badgeClass = board.online ? 'badge-online' : 'badge-offline';
|
var badgeClass = board.online ? 'badge-online' : 'badge-offline';
|
||||||
var left = board.config && board.config.left || {};
|
var left = board.config && board.config.left || {};
|
||||||
@ -183,6 +189,7 @@
|
|||||||
.then(function (data) {
|
.then(function (data) {
|
||||||
var cfg = data.config || {};
|
var cfg = data.config || {};
|
||||||
document.getElementById('edit-display-name').value = data.display_name || '';
|
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').value = data.header || '';
|
||||||
document.getElementById('edit-header-font-size').value = data.header_font_size || '';
|
document.getElementById('edit-header-font-size').value = data.header_font_size || '';
|
||||||
document.getElementById('edit-content-padding-top').value = data.content_padding_top || '';
|
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');
|
var saveBtn = configForm.querySelector('.btn-save');
|
||||||
saveBtn.textContent = 'Saving...';
|
saveBtn.textContent = 'Saving...';
|
||||||
saveBtn.disabled = true;
|
saveBtn.disabled = true;
|
||||||
@ -262,6 +272,55 @@
|
|||||||
if (e.target === modalOverlay) closeModal();
|
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 = '<p class="text-sm text-slate-500">Every discovered board has a unique display_id.</p>';
|
||||||
|
} 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 = '<div class="font-semibold mb-2">display_id ' + esc(String(conflict.display_id)) + '</div>';
|
||||||
|
(conflict.boards || []).forEach(function (b) {
|
||||||
|
html += '<div class="flex items-center justify-between py-1" style="border-top:1px solid #1e1e30;">' +
|
||||||
|
'<div><span class="text-xs text-slate-600">' + esc(b.ip) + '</span>' +
|
||||||
|
'<span class="ml-2">' + esc(b.display_name || '?') + '</span></div>' +
|
||||||
|
'<button class="btn btn-edit" onclick="window.openConfig(\'' + esc(b.ip) + '\')">⚙ Edit</button>' +
|
||||||
|
'</div>';
|
||||||
|
});
|
||||||
|
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 ----
|
// ---- Polling ----
|
||||||
fetchBoards();
|
fetchBoards();
|
||||||
setInterval(fetchBoards, 3000);
|
setInterval(fetchBoards, 3000);
|
||||||
|
|||||||
@ -90,6 +90,8 @@
|
|||||||
<summary style="cursor:pointer;font-size:0.8rem;color:#94a3b8;text-transform:uppercase;letter-spacing:0.1em;padding:0.5rem 0;user-select:none;">Technical Settings</summary>
|
<summary style="cursor:pointer;font-size:0.8rem;color:#94a3b8;text-transform:uppercase;letter-spacing:0.1em;padding:0.5rem 0;user-select:none;">Technical Settings</summary>
|
||||||
<label>Display Name</label>
|
<label>Display Name</label>
|
||||||
<input type="text" id="edit-display-name" placeholder="e.g. Main Scoreboard">
|
<input type="text" id="edit-display-name" placeholder="e.g. Main Scoreboard">
|
||||||
|
<label>Display ID</label>
|
||||||
|
<input type="number" id="edit-display-id" min="1" step="1" placeholder="Unique integer per board">
|
||||||
<label>Header Font Size (rem)</label>
|
<label>Header Font Size (rem)</label>
|
||||||
<input type="number" id="edit-header-font-size" min="0" step="0.25" placeholder="0 = auto-fit">
|
<input type="number" id="edit-header-font-size" min="0" step="0.25" placeholder="0 = auto-fit">
|
||||||
<div class="grid grid-cols-2 gap-4">
|
<div class="grid grid-cols-2 gap-4">
|
||||||
@ -124,6 +126,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Conflicts Modal (F9) -->
|
||||||
|
<div class="modal-overlay" id="conflicts-modal">
|
||||||
|
<div class="modal">
|
||||||
|
<h2>Display ID Conflicts</h2>
|
||||||
|
<p class="text-sm text-slate-500 mb-4" id="conflicts-summary"></p>
|
||||||
|
<div id="conflicts-list"></div>
|
||||||
|
<div class="flex gap-3 justify-end mt-6">
|
||||||
|
<button type="button" class="btn" style="background:#374151;color:white;padding:0.6rem 1.5rem;" onclick="window.closeConflicts()">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script src="{{ url_for('static', filename='js/main.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/main.js') }}"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user