CuteBoard/controller/app.py
2026-06-27 01:56:59 -05:00

229 lines
7.1 KiB
Python

#!/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/<ip>/<path:endpoint>", 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)