229 lines
7.1 KiB
Python
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)
|