V2
This commit is contained in:
@@ -7,13 +7,16 @@ import threading
|
||||
import time
|
||||
import base64
|
||||
import shutil
|
||||
import uuid
|
||||
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:
|
||||
@@ -22,12 +25,19 @@ class ControlApi:
|
||||
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,
|
||||
"log_path": None,
|
||||
@@ -39,8 +49,6 @@ class ControlApi:
|
||||
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"
|
||||
self.model_uploads: dict[str, tuple[object, Path, str, int, int]] = {}
|
||||
self.model_lock = threading.Lock()
|
||||
# Leading underscore keeps the native WebView object out of js_api
|
||||
# serialization. Only the small public methods below are exposed.
|
||||
self._window = None
|
||||
@@ -57,14 +65,26 @@ class ControlApi:
|
||||
|
||||
def initial_state(self):
|
||||
return {
|
||||
"ports": [p.device for p in list_ports.comports()],
|
||||
"ports": self.refresh_ports(),
|
||||
"battery_config": self.battery_config,
|
||||
"saved_model": self.get_saved_model_info(),
|
||||
"state": self._snapshot(),
|
||||
"model_info": self.get_saved_model_info(),
|
||||
}
|
||||
|
||||
def refresh_ports(self):
|
||||
return [p.device for p in list_ports.comports()]
|
||||
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:
|
||||
@@ -86,62 +106,83 @@ class ControlApi:
|
||||
self._window.destroy()
|
||||
return True
|
||||
|
||||
def begin_model_save(self, name, size):
|
||||
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")
|
||||
size = int(size)
|
||||
if not 0 < size <= 30 * 1024 * 1024:
|
||||
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)
|
||||
token = uuid.uuid4().hex
|
||||
temp = self.model_cache.parent / f".{token}.upload"
|
||||
with self.model_lock:
|
||||
self.model_uploads[token] = (temp.open("wb"), temp, safe_name, size, 0)
|
||||
return token
|
||||
|
||||
def append_model_chunk(self, token, data):
|
||||
try:
|
||||
chunk = base64.b64decode(data, validate=True)
|
||||
except (ValueError, TypeError) as exc:
|
||||
raise ValueError("Invalid STL data chunk") from exc
|
||||
with self.model_lock:
|
||||
upload = self.model_uploads.get(str(token))
|
||||
if not upload:
|
||||
raise ValueError("Model upload expired")
|
||||
handle, temp, name, expected, received = upload
|
||||
if received + len(chunk) > expected:
|
||||
raise ValueError("Model upload is larger than expected")
|
||||
handle.write(chunk)
|
||||
self.model_uploads[str(token)] = (handle, temp, name, expected, received + len(chunk))
|
||||
return True
|
||||
|
||||
def finish_model_save(self, token):
|
||||
with self.model_lock:
|
||||
upload = self.model_uploads.pop(str(token), None)
|
||||
if not upload:
|
||||
raise ValueError("Model upload expired")
|
||||
handle, temp, name, expected, received = upload
|
||||
handle.close()
|
||||
if received != expected:
|
||||
temp.unlink(missing_ok=True)
|
||||
raise ValueError("Model upload was incomplete")
|
||||
os.replace(temp, self.model_cache)
|
||||
self.model_cache_name.write_text(name, encoding="utf-8")
|
||||
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 "vehicle_model.stl"
|
||||
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=1048576):
|
||||
offset, size = int(offset), min(int(size), 1048576)
|
||||
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:
|
||||
@@ -150,49 +191,114 @@ class ControlApi:
|
||||
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 self.model_cache.is_file():
|
||||
return
|
||||
default_asset = Path(__file__).resolve().parent / "assets" / "Main Assembly V1 (2).stl"
|
||||
if default_asset.is_file():
|
||||
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:
|
||||
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
|
||||
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:
|
||||
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 (OSError, ValueError, TypeError, KeyError):
|
||||
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.",
|
||||
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)
|
||||
self.worker.connect(str(port), int(baud))
|
||||
with self.lock:
|
||||
self.vehicle_state.disconnect()
|
||||
self.state.update(
|
||||
connected=True, link="CONNECTING",
|
||||
status=f"Opening {port} at {baud} baud…",
|
||||
)
|
||||
return True
|
||||
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.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, status="Demo mode disconnected")
|
||||
return True
|
||||
self.worker.disconnect()
|
||||
return True
|
||||
|
||||
def set_axes(self, forward, turn, vertical):
|
||||
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))),
|
||||
))
|
||||
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):
|
||||
@@ -200,18 +306,45 @@ class ControlApi:
|
||||
self.worker.gain = value / 100.0
|
||||
return value
|
||||
|
||||
def command(self, name):
|
||||
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()
|
||||
elif name == "arm":
|
||||
self.worker.log("OPERATOR", "clicked ARM")
|
||||
self.worker.command_arm(True)
|
||||
elif name == "disarm":
|
||||
self.worker.log("OPERATOR", "clicked DISARM")
|
||||
self.worker.command_arm(False)
|
||||
else:
|
||||
raise ValueError("Unknown command")
|
||||
raise ValueError(f"Unknown command: {name}")
|
||||
return True
|
||||
|
||||
def set_servo_function(self, servo, motor):
|
||||
@@ -219,6 +352,10 @@ class ControlApi:
|
||||
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
|
||||
|
||||
@@ -230,6 +367,10 @@ class ControlApi:
|
||||
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
|
||||
|
||||
@@ -259,13 +400,15 @@ class ControlApi:
|
||||
return True
|
||||
|
||||
def poll(self):
|
||||
while True:
|
||||
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 > 5:
|
||||
# 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"
|
||||
@@ -273,24 +416,49 @@ class ControlApi:
|
||||
)
|
||||
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:
|
||||
return json.loads(json.dumps(self.state))
|
||||
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
|
||||
_, 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
|
||||
@@ -316,9 +484,14 @@ class ControlApi:
|
||||
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",
|
||||
armed=False, battery=None, status="Disconnected")
|
||||
mode=None, status=status_msg)
|
||||
self.state["last_error"] = None
|
||||
|
||||
def _update_servo(self, servo):
|
||||
function = self.servo_functions.get(servo)
|
||||
@@ -334,23 +507,106 @@ class ControlApi:
|
||||
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():
|
||||
api = ControlApi()
|
||||
ui_path = Path(__file__).resolve().parent / "web" / "index.html"
|
||||
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=str(ui_path), js_api=api,
|
||||
width=1120, height=760, min_size=(900, 650),
|
||||
"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:
|
||||
webview.start(debug=False)
|
||||
webview.start(debug=True)
|
||||
finally:
|
||||
api.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user