Update UI layout, window controls, and vehicle styling
This commit is contained in:
+167
-17
@@ -6,6 +6,10 @@ import queue
|
||||
import threading
|
||||
import time
|
||||
import base64
|
||||
import shutil
|
||||
import http.server
|
||||
import socketserver
|
||||
import urllib.parse
|
||||
from pathlib import Path
|
||||
|
||||
import webview
|
||||
@@ -56,6 +60,7 @@ class ControlApi:
|
||||
"ports": [p.device for p in list_ports.comports()],
|
||||
"battery_config": self.battery_config,
|
||||
"state": self._snapshot(),
|
||||
"model_info": self.get_saved_model_info(),
|
||||
}
|
||||
|
||||
def refresh_ports(self):
|
||||
@@ -81,6 +86,56 @@ class ControlApi:
|
||||
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"):
|
||||
@@ -96,24 +151,55 @@ class ControlApi:
|
||||
self.model_cache_name.write_text(safe_name, encoding="utf-8")
|
||||
return True
|
||||
|
||||
def saved_model(self):
|
||||
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"
|
||||
return {"name": name, "data": base64.b64encode(self.model_cache.read_bytes()).decode("ascii")}
|
||||
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
|
||||
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:
|
||||
return self._model_payload(path)
|
||||
except (OSError, ValueError, TypeError, KeyError):
|
||||
pass
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _model_payload(path):
|
||||
return {"name": path.name, "data": base64.b64encode(path.read_bytes()).decode("ascii")}
|
||||
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):
|
||||
if not port:
|
||||
@@ -146,6 +232,14 @@ class ControlApi:
|
||||
return value
|
||||
|
||||
def command(self, name):
|
||||
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 == "manual":
|
||||
self.worker.log("OPERATOR", "clicked SET MANUAL")
|
||||
self.worker.set_manual_mode()
|
||||
@@ -156,7 +250,7 @@ class ControlApi:
|
||||
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):
|
||||
@@ -279,23 +373,79 @@ 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
|
||||
corner_pref = ctypes.c_int(2) # DWMWCP_ROUND
|
||||
ctypes.windll.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
|
||||
ctypes.windll.dwmapi.DwmSetWindowAttribute(
|
||||
ctypes.c_void_p(hwnd), 20, ctypes.byref(dark_mode), ctypes.sizeof(dark_mode)
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
class AppHTTPHandler(http.server.SimpleHTTPRequestHandler):
|
||||
def log_message(self, format, *args):
|
||||
pass # Suppress HTTP request logs in console
|
||||
|
||||
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.send_header("Cache-Control", "no-cache")
|
||||
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"
|
||||
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=1400, height=808, min_size=(1125, 690),
|
||||
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