Files
argo/web_ui.py
T

681 lines
29 KiB
Python

from __future__ import annotations
import json
import os
import queue
import threading
import time
import base64
import shutil
import http.server
import socketserver
import urllib.parse
from pathlib import Path
import webview
from serial.tools import list_ports
from app import Axes, MOTOR_FUNCTIONS, MavlinkWorker, motor_number_for_function
from vehicle_state import VehicleState
class ControlApi:
"""Small, local-only bridge between the CSS UI and the MAVLink worker."""
def __init__(self):
self.events: queue.Queue = queue.Queue()
self.worker = MavlinkWorker(self.events)
self.worker.require_input_lease = True
self.vehicle_state = VehicleState()
self.battery_received_at = None
self.demo_mode = False
self.lock = threading.Lock()
self._poll_lock = threading.Lock()
self.state = {
"connected": False,
"link": "DISCONNECTED",
"vehicle": "No heartbeat",
"armed": False,
"mode": None,
"app_state": "disconnected",
"status": "Ready. Select a USB port to begin.",
"battery": None,
"link_quality": None,
"heavy_quality": None,
"log_path": None,
"servos": {str(i): {"function": None, "direction": None} for i in range(1, 17)},
}
self.servo_functions: dict[int, int] = {}
self.motor_directions: dict[int, int] = {}
self.battery_path = Path(__file__).resolve().parent / "battery_settings.json"
self.link_path = Path(__file__).resolve().parent / "link_settings.json"
self.model_path = Path(__file__).resolve().parent / "model_settings.json"
self.model_cache = Path(__file__).resolve().parent / "runtime" / "vehicle_model.stl"
self.model_cache_name = Path(__file__).resolve().parent / "runtime" / "vehicle_model_name.txt"
# Leading underscore keeps the native WebView object out of js_api
# serialization. Only the small public methods below are exposed.
self._window = None
self.maximized = False
self.battery_config = {
"cells": 4, "capacity_mah": 5500, "c_rating": 35,
"full_v_per_cell": 4.2, "empty_v_per_cell": 3.3,
"warning_percent": 20,
}
try:
self.battery_config.update(json.loads(self.battery_path.read_text(encoding="utf-8")))
except (OSError, ValueError, TypeError):
pass
self.link_config = {
"baud": 57600, "control_hz": 20,
"ping_hz": 1, "system_hz": 1, "servo_hz": 2,
"rc_hz": 2, "esc_hz": 1,
}
try:
self.link_config.update(json.loads(self.link_path.read_text(encoding="utf-8")))
except (OSError, ValueError, TypeError):
pass
self._apply_link_config()
def initial_state(self):
return {
"ports": self.refresh_ports(),
"battery_config": self.battery_config,
"link_config": self.link_config,
"state": self._snapshot(),
"model_info": self.get_saved_model_info(),
}
def refresh_ports(self):
ports = []
# Add physical and virtual serial COM ports
for p in list_ports.comports():
desc = p.description or ""
if desc and desc != "n/a" and desc != p.device:
label = f"{p.device} ({desc})"
else:
label = p.device
ports.append({"device": p.device, "label": label, "type": "serial"})
# Built-in UI testing: no external process, port, or MAVLink socket.
ports.insert(0, {"device": "demo", "label": "Demo Mode (no hardware)", "type": "demo"})
return ports
def minimize_window(self):
if self._window:
self._window.minimize()
return True
def toggle_maximize_window(self):
if not self._window:
return False
if self.maximized:
self._window.restore()
else:
self._window.maximize()
self.maximized = not self.maximized
return self.maximized
def close_window(self):
if self._window:
self._window.destroy()
return True
def choose_model(self):
selected = None
# 1. Native Windows topmost open file dialog (instant, reliable)
try:
import tkinter as tk
from tkinter import filedialog
root = tk.Tk()
root.withdraw()
root.attributes("-topmost", True)
file_path = filedialog.askopenfilename(
title="Select 3D STL Vehicle Model",
initialdir=str(Path.home() / "Downloads"),
filetypes=[("STL 3D Meshes (*.stl)", "*.stl"), ("All files (*.*)", "*.*")],
)
root.destroy()
if file_path:
selected = (file_path,)
except Exception:
pass
# 2. Fallback to pywebview create_file_dialog
if not selected and self._window:
try:
selected = self._window.create_file_dialog(
webview.FileDialog.OPEN,
directory=str(Path.home()),
allow_multiple=False,
file_types=("STL mesh (*.stl)", "All files (*.*)"),
)
except Exception:
selected = None
if not selected:
return None
path = Path(selected[0])
if not path.is_file() or not path.suffix.lower().endswith(".stl"):
raise ValueError("Choose an STL mesh")
if path.stat().st_size > 30 * 1024 * 1024:
raise ValueError("The STL is larger than 30 MB; export a lighter mesh")
content = path.read_bytes()
self.model_cache.parent.mkdir(exist_ok=True)
self.model_cache.write_bytes(content)
self.model_cache_name.write_text(path.name, encoding="utf-8")
try:
self.model_path.write_text(json.dumps({"path": str(path)}, indent=2) + "\n", encoding="utf-8")
except Exception:
pass
return {"name": path.name, "data": base64.b64encode(content).decode("ascii")}
def save_model(self, name, data):
safe_name = Path(str(name)).name
if not safe_name.lower().endswith(".stl"):
raise ValueError("Choose an STL mesh")
try:
content = base64.b64decode(data, validate=True)
except (ValueError, TypeError) as exc:
raise ValueError("Invalid STL data") from exc
if not content or len(content) > 30 * 1024 * 1024:
raise ValueError("STL must be between 1 byte and 30 MB")
self.model_cache.parent.mkdir(exist_ok=True)
self.model_cache.write_bytes(content)
self.model_cache_name.write_text(safe_name, encoding="utf-8")
return True
def get_saved_model_info(self):
self._migrate_legacy_model()
try:
if self.model_cache.is_file() and self.model_cache.stat().st_size <= 30 * 1024 * 1024:
name = self.model_cache_name.read_text(encoding="utf-8").strip() or "Main Assembly V1 (2).stl"
return {"name": name, "size": self.model_cache.stat().st_size}
except (OSError, ValueError):
pass
return None
def read_saved_model_chunk(self, offset, size=262144):
offset, size = int(offset), min(int(size), 524288)
if offset < 0 or size < 1 or not self.model_cache.is_file():
raise ValueError("Saved model is unavailable")
with self.model_cache.open("rb") as stream:
stream.seek(offset)
content = stream.read(size)
return {"offset": offset, "data": base64.b64encode(content).decode("ascii"), "eof": offset + len(content) >= self.model_cache.stat().st_size}
def _migrate_legacy_model(self):
if not self.model_cache.is_file():
default_asset = Path(__file__).resolve().parent / "assets" / "Main Assembly V1 (2).stl"
if default_asset.is_file():
try:
self.model_cache.parent.mkdir(exist_ok=True)
shutil.copyfile(default_asset, self.model_cache)
self.model_cache_name.write_text(default_asset.name, encoding="utf-8")
return
except Exception:
pass
try:
if self.model_path.is_file():
path = Path(json.loads(self.model_path.read_text(encoding="utf-8"))["path"])
if path.is_file() and path.suffix.lower() == ".stl" and path.stat().st_size <= 30 * 1024 * 1024:
self.model_cache.parent.mkdir(exist_ok=True)
shutil.copyfile(path, self.model_cache)
self.model_cache_name.write_text(path.name, encoding="utf-8")
except Exception:
pass
def saved_model(self):
self._migrate_legacy_model()
try:
if self.model_cache.is_file() and self.model_cache.stat().st_size <= 30 * 1024 * 1024:
name = self.model_cache_name.read_text(encoding="utf-8").strip() or "Main Assembly V1 (2).stl"
return {"name": name, "data": base64.b64encode(self.model_cache.read_bytes()).decode("ascii")}
except (OSError, ValueError):
pass
return None
def connect(self, port, baud=57600, rate=20):
print(f"[web_ui] connect() called: port={port!r}, baud={baud!r}, rate={rate!r}")
if not port:
raise ValueError("Select a USB serial port")
if str(port).lower() in ("demo", "tcp:127.0.0.1:5760", "udpin:127.0.0.1:14550"):
self.demo_mode = True
self.servo_functions = {1: 33, 2: 34, 3: 35}
self.motor_directions = {1: 1, 2: 1, 3: 1}
with self.lock:
self.state.update(
connected=True, link="LINK OK", vehicle="DEMO • DISARMED",
armed=False, mode="MANUAL", status="Demo mode ready — no hardware connected.",
link_quality={"grade": "excellent", "latency_ms": 12.0,
"loss_percent": 0.0, "samples": 20,
"corrupt_frames_20s": 0, "heartbeat_age_s": 0.1},
battery={"voltage": 15.8, "per_cell": 3.95, "percent": 72,
"cells": 4, "low": False},
)
for servo in range(1, 17):
function = self.servo_functions.get(servo, 0)
motor = motor_number_for_function(function)
self.state["servos"][str(servo)] = {
"function": function, "label": MOTOR_FUNCTIONS.get(function, "Unassigned"),
"motor": motor, "direction": self.motor_directions.get(motor) if motor else None,
}
return self._snapshot()
self.worker.rate_hz = int(rate)
with self.lock:
self.vehicle_state.disconnect()
self.state.update(
connected=True, link="CONNECTING",
status=f"Opening {port} at {baud} baud…",
)
self.worker.connect(str(port), int(baud))
print(f"[web_ui] connect() dispatched to worker thread OK")
# Return only once the worker has either seen a heartbeat or reported a
# connection failure. This makes the button independent of the regular
# UI polling loop and prevents an indefinite visual "Connecting" state.
deadline = time.monotonic() + 8.0
while time.monotonic() < deadline:
snapshot = self.poll()
if snapshot["app_state"] != "connecting":
return snapshot
time.sleep(0.05)
self.worker.disconnect()
time.sleep(0.1)
self.poll()
raise TimeoutError(f"No MAVLink heartbeat received from {port} within 8 seconds")
def disconnect(self):
if self.worker.heavy_quality_test is not None:
self.worker.cancel_heavy_quality_test()
if self.demo_mode:
self.demo_mode = False
with self.lock:
self.state.update(connected=False, link="DISCONNECTED", vehicle="No heartbeat",
armed=False, mode=None, battery=None, link_quality=None,
status="Demo mode disconnected")
return True
self.worker.disconnect()
return True
def start_heavy_quality_test(self):
if self.demo_mode:
raise RuntimeError("The heavy link test requires a real flight-controller connection")
return self.worker.start_heavy_quality_test()
def cancel_heavy_quality_test(self):
return self.worker.cancel_heavy_quality_test()
def set_axes(self, forward, turn, vertical, sequence=None):
if self.demo_mode:
with self.lock:
self.state["status"] = (
f"Demo controls • surge {float(forward):+.2f} • "
f"yaw {float(turn):+.2f} • heave {float(vertical):+.2f}"
)
return True
with self.lock:
allowed = self.vehicle_state.snapshot()["controls_allowed"]
self.worker.set_axes(Axes(
max(-1.0, min(1.0, float(forward))), 0.0,
max(-1.0, min(1.0, float(vertical))),
max(-1.0, min(1.0, float(turn))),
) if allowed else Axes(), sequence)
return True
def stop_controls(self, sequence=None):
"""Invalidate queued input without claiming that the vehicle disarmed."""
with self.lock:
self.worker.set_axes(Axes(), sequence)
self.worker._send_control(force_neutral=True)
return True
def set_gain(self, value):
value = max(5, min(50, int(value)))
self.worker.gain = value / 100.0
return value
def command(self, name, sequence=None, expires_at=None):
if self.demo_mode:
if name not in ("manual", "arm", "disarm"):
raise ValueError(f"Unknown command: {name}")
armed = name == "arm" if name != "manual" else self.state.get("armed", False)
with self.lock:
self.state.update(armed=armed, mode="MANUAL",
vehicle=f"DEMO • {'ARMED' if armed else 'DISARMED'}",
status=f"Demo: {'Manual mode' if name == 'manual' else name.capitalize()} confirmed")
return True
if not self.worker.connection or not self.worker.target_system:
if name == "manual":
raise RuntimeError("Connect to vehicle to enable Manual mode")
elif name == "arm":
raise RuntimeError("Connect to vehicle before arming")
elif name == "disarm":
raise RuntimeError("Connect to vehicle before disarming")
raise RuntimeError("No autopilot heartbeat")
if name in ("arm", "disarm"):
self.poll()
with self.lock:
if name == "arm":
if expires_at is not None and time.time() * 1000 > expires_at:
raise RuntimeError("Engage request expired before it could be sent")
if sequence is not None and sequence <= self.worker.last_input_sequence:
raise RuntimeError("Engage canceled by a newer input reset")
self.vehicle_state.request(name)
self.worker.set_axes(Axes(), sequence)
try:
self.worker.command_arm(name == "arm")
except Exception as exc:
self.vehicle_state.fail(f"{name.capitalize()} send failed: {exc}")
raise
return self._snapshot()
if name == "manual":
self.worker.log("OPERATOR", "clicked SET MANUAL")
self.worker.set_manual_mode()
else:
raise ValueError(f"Unknown command: {name}")
return True
def set_servo_function(self, servo, motor):
servo, motor = int(servo), int(motor)
if not 1 <= servo <= 16 or not 0 <= motor <= 12:
raise ValueError("Invalid servo or motor")
function = 0 if motor == 0 else (32 + motor if motor <= 8 else 73 + motor)
if self.demo_mode:
self.servo_functions[servo] = function
self._update_servo(servo)
return True
self.worker.set_parameter(f"SERVO{servo}_FUNCTION", function)
return True
def reverse_motor(self, servo):
servo = int(servo)
motor = motor_number_for_function(self.servo_functions.get(servo))
if motor is None:
raise ValueError(f"SERVO{servo} is not assigned to a motor")
current = self.motor_directions.get(motor)
if current is None:
raise ValueError(f"MOT_{motor}_DIRECTION has not been read yet")
if self.demo_mode:
self.motor_directions[motor] = -1 if current == 1 else 1
self._update_servo(servo)
return True
self.worker.set_parameter(f"MOT_{motor}_DIRECTION", -1 if current == 1 else 1)
return True
def save_battery(self, config):
clean = {
"cells": int(config["cells"]),
"capacity_mah": int(config["capacity_mah"]),
"c_rating": float(config["c_rating"]),
"full_v_per_cell": float(config["full_v_per_cell"]),
"empty_v_per_cell": float(config["empty_v_per_cell"]),
"warning_percent": int(config["warning_percent"]),
}
if clean["cells"] < 1 or clean["capacity_mah"] <= 0 or clean["c_rating"] <= 0:
raise ValueError("Battery cells, capacity and C rating must be positive")
if not clean["full_v_per_cell"] > clean["empty_v_per_cell"] > 0:
raise ValueError("Full voltage per cell must be greater than empty voltage")
if not 0 <= clean["warning_percent"] <= 100:
raise ValueError("Warning percentage must be between 0 and 100")
self.battery_path.write_text(json.dumps(clean, indent=2) + "\n", encoding="utf-8")
self.battery_config = clean
return clean
def _apply_link_config(self):
self.worker.rate_hz = self.link_config["control_hz"]
self.worker.set_link_rates(self.link_config["ping_hz"], {
"SYS_STATUS": self.link_config["system_hz"],
"SERVO_OUTPUT_RAW": self.link_config["servo_hz"],
"RC_CHANNELS": self.link_config["rc_hz"],
"ESC_TELEMETRY_1_TO_4": self.link_config["esc_hz"],
})
def save_link_settings(self, config):
allowed = {0, 1, 2, 5, 10}
clean = {key: int(config.get(key, value)) for key, value in self.link_config.items()}
if clean["baud"] not in {9600, 19200, 38400, 57600, 115200}:
raise ValueError("Choose a supported baud rate")
if clean["control_hz"] not in {10, 20}:
raise ValueError("Control rate must be 10 or 20 Hz")
if clean["ping_hz"] not in {1, 2, 5, 10}:
raise ValueError("Link test rate must be 1, 2, 5 or 10 Hz")
if any(clean[key] not in allowed for key in ("system_hz", "servo_hz", "rc_hz", "esc_hz")):
raise ValueError("Telemetry rates must be Off, 1, 2, 5 or 10 Hz")
self.link_path.write_text(json.dumps(clean, indent=2) + "\n", encoding="utf-8")
self.link_config = clean
self._apply_link_config()
return clean
def open_logs(self):
path = Path(__file__).resolve().parent / "logs"
path.mkdir(exist_ok=True)
os.startfile(path)
return True
def read_console(self, limit=150):
"""Return a bounded, read-only view of the local session console."""
return self.worker.read_console(limit)
def poll(self):
# Bound a poll even if telemetry arrives continuously.
with self._poll_lock:
for _ in range(500):
try:
event = self.events.get_nowait()
except queue.Empty:
break
self._consume(event)
if self.worker.target_system and time.monotonic() - self.worker.last_heartbeat > 3:
with self.lock:
self.state["link"] = (
"DATA OK / HEARTBEAT DELAYED"
if time.monotonic() - self.worker.last_rx < 2 else "ALL LINK DATA LOST"
)
return self._snapshot()
def _derive_app_state(self) -> str:
if not self.state.get("connected"):
return "disconnected"
link = self.state.get("link", "")
if link == "CONNECTING":
return "connecting"
if link in ("DATA OK / HEARTBEAT DELAYED", "ALL LINK DATA LOST"):
return "link_degraded"
if self.state.get("armed"):
return "armed"
return "connected_disarmed"
def _snapshot(self):
with self.lock:
snap = json.loads(json.dumps(self.state))
snap["app_state"] = self._derive_app_state()
if not self.demo_mode:
snap.update(self.vehicle_state.snapshot())
if snap["connected"] and snap["app_state"] != "connecting" and not snap["armed_known"]:
snap["app_state"] = "link_degraded"
snap["battery_stale"] = (self.battery_received_at is None
or time.monotonic() - self.battery_received_at > 5)
return snap
def _consume(self, event):
kind = event[0]
with self.lock:
if kind == "heartbeat":
_, system, autopilot, mode, armed = event[:5]
self.vehicle_state.heartbeat(armed, event[5] if len(event) > 5 else None)
self.state.update(
connected=True, link="LINK OK", vehicle=f"{mode} • {'ARMED' if armed else 'DISARMED'}",
armed=bool(armed),
mode=str(mode).upper(),
)
elif kind in ("status", "statustext"):
self.state["status"] = event[1]
elif kind == "ack":
if event[1] == 400:
self.vehicle_state.ack(event[2], event[3] if len(event) > 3 else None)
self.state["status"] = f"Command {event[1]} ACK: {event[2]}"
elif kind == "battery":
self.battery_received_at = time.monotonic()
voltage = float(event[1])
cells = self.battery_config["cells"]
per_cell = voltage / cells
low, high = self.battery_config["empty_v_per_cell"], self.battery_config["full_v_per_cell"]
percent = max(0, min(100, (per_cell - low) * 100 / (high - low)))
self.state["battery"] = {
"voltage": voltage, "per_cell": per_cell, "percent": percent,
"cells": cells, "low": percent <= self.battery_config["warning_percent"],
}
elif kind == "link_quality":
self.state["link_quality"] = event[1]
elif kind == "heavy_quality":
self.state["heavy_quality"] = event[1]
elif kind == "servo_param":
_, param_id, value = event
servo = int(param_id[5:param_id.index("_")])
self.servo_functions[servo] = int(value)
self._update_servo(servo)
elif kind == "motor_direction":
_, motor, value = event
self.motor_directions[int(motor)] = int(value)
for servo in range(1, 17):
self._update_servo(servo)
elif kind == "param_saved":
self.state["status"] = f"Confirmed: {event[1]}={event[2]}"
elif kind == "log_path":
self.state["log_path"] = event[1]
elif kind == "error":
self.state["status"] = event[1]
self.state["last_error"] = event[1]
elif kind == "disconnected":
self.vehicle_state.disconnect()
last_err = self.state.get("last_error")
status_msg = last_err if last_err else "Disconnected"
self.state.update(connected=False, link="DISCONNECTED", vehicle="No heartbeat",
mode=None, link_quality=None, status=status_msg)
self.state["last_error"] = None
def _update_servo(self, servo):
function = self.servo_functions.get(servo)
motor = motor_number_for_function(function)
self.state["servos"][str(servo)] = {
"function": function,
"label": MOTOR_FUNCTIONS.get(function, "Unassigned") if function is not None else "Waiting…",
"motor": motor,
"direction": self.motor_directions.get(motor) if motor else None,
}
def close(self):
self.worker.disconnect()
def _apply_dwm_styling(window):
try:
hwnd = None
if hasattr(window, "native") and window.native:
hwnd = int(window.native.Handle.ToInt64())
if hwnd:
import ctypes
user32 = ctypes.windll.user32
dwmapi = ctypes.windll.dwmapi
corner_pref = ctypes.c_int(2) # DWMWCP_ROUND
dwmapi.DwmSetWindowAttribute(
ctypes.c_void_p(hwnd), 33, ctypes.byref(corner_pref), ctypes.sizeof(corner_pref)
)
dark_mode = ctypes.c_int(1) # DWMWA_USE_IMMERSIVE_DARK_MODE
dwmapi.DwmSetWindowAttribute(
ctypes.c_void_p(hwnd), 20, ctypes.byref(dark_mode), ctypes.sizeof(dark_mode)
)
user32.SetForegroundWindow(ctypes.c_void_p(hwnd))
except Exception:
pass
def _get_window_size() -> tuple[int, int, tuple[int, int]]:
target_w, target_h = 1100, 650
min_w, min_h = 960, 580
try:
import ctypes
user32 = ctypes.windll.user32
sw = user32.GetSystemMetrics(0)
sh = user32.GetSystemMetrics(1)
if sw > 0 and sh > 0:
target_w = min(target_w, max(min_w, int(sw * 0.92)))
target_h = min(target_h, max(min_h, int(sh * 0.90)))
min_w = min(min_w, target_w)
min_h = min(min_h, target_h)
except Exception:
pass
return target_w, target_h, (min_w, min_h)
class AppHTTPHandler(http.server.SimpleHTTPRequestHandler):
def log_message(self, format, *args):
pass # Suppress HTTP request logs in console
def end_headers(self):
self.send_header("Cache-Control", "no-cache, no-store, must-revalidate")
self.send_header("Pragma", "no-cache")
self.send_header("Expires", "0")
super().end_headers()
def do_GET(self):
unquoted = urllib.parse.unquote(self.path)
if self.path in ("/model", "/model.stl", "/runtime/vehicle_model.stl") or "Main Assembly" in unquoted or "vehicle_model" in unquoted:
project_root = Path(__file__).resolve().parent
model_file = project_root / "assets" / "Main Assembly V1 (2).stl"
if not model_file.is_file():
model_file = project_root / "runtime" / "vehicle_model.stl"
if model_file.is_file():
data = model_file.read_bytes()
self.send_response(200)
self.send_header("Content-Type", "model/stl")
self.send_header("Content-Length", str(len(data)))
self.send_header("Access-Control-Allow-Origin", "*")
self.end_headers()
self.wfile.write(data)
return
return super().do_GET()
def start_local_server(directory: Path) -> int:
handler = lambda *args, **kwargs: AppHTTPHandler(*args, directory=str(directory), **kwargs)
server = socketserver.TCPServer(("127.0.0.1", 0), handler)
port = server.server_address[1]
threading.Thread(target=server.serve_forever, daemon=True).start()
return port
def main():
if os.name == "nt":
# Keep the running window separate from generic Python taskbar groups.
import ctypes
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID("SUBControl.Desktop")
from desktop_shortcut import ensure_desktop_shortcut
ensure_desktop_shortcut()
api = ControlApi()
api._migrate_legacy_model()
project_root = Path(__file__).resolve().parent
port = start_local_server(project_root)
app_url = f"http://127.0.0.1:{port}/web/index.html?v={int(time.time())}"
win_w, win_h, min_dims = _get_window_size()
window = webview.create_window(
"SUB Control", url=app_url, js_api=api,
width=win_w, height=win_h, min_size=min_dims,
background_color="#111719", frameless=True, easy_drag=False,
shadow=True,
)
api._window = window
window.events.shown += lambda: _apply_dwm_styling(window)
window.events.maximized += lambda: setattr(api, "maximized", True)
window.events.restored += lambda: setattr(api, "maximized", False)
try:
icon_path = project_root / "assets" / "sub-control.ico"
webview.start(debug=True, icon=str(icon_path) if icon_path.is_file() else None)
finally:
api.close()
if __name__ == "__main__":
main()