From b98231ae08b5d9cd502ab926bae21dcea5ad86e4 Mon Sep 17 00:00:00 2001 From: PitresAli Date: Wed, 26 Aug 2026 00:36:02 +0200 Subject: [PATCH] feat: WebGL 3D model viewer with smooth vertex normals, water caustics, soft cyan-slate material, floaty controls, and camera position mapping --- .gitignore | 6 + README.md | 124 ++++ app.py | 1219 ++++++++++++++++++++++++++++++++ battery_settings.json | 8 + docs/sub-control-interface.png | Bin 0 -> 50134 bytes requirements.txt | 4 + run.ps1 | 15 + web/app.js | 46 ++ web/index.html | 119 ++++ web/model-viewer.js | 518 ++++++++++++++ web/styles.css | 12 + web_ui.py | 346 +++++++++ 12 files changed, 2417 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 app.py create mode 100644 battery_settings.json create mode 100644 docs/sub-control-interface.png create mode 100644 requirements.txt create mode 100644 run.ps1 create mode 100644 web/app.js create mode 100644 web/index.html create mode 100644 web/model-viewer.js create mode 100644 web/styles.css create mode 100644 web_ui.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c7bf4b4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.venv/ +__pycache__/ +*.py[cod] +logs/ +model_settings.json +runtime/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..3c5c185 --- /dev/null +++ b/README.md @@ -0,0 +1,124 @@ +# SUB Control + +A deliberately small, low-bandwidth MAVLink controller for bench-testing an +ArduSub vehicle over a USB serial connection. It sends no parameter-list +request and no telemetry-stream request. + +![SUB Control interface](docs/sub-control-interface.png) + +The desktop window uses a local HTML/CSS/JavaScript interface rendered by the +Windows WebView. Python remains the only launcher and owns the MAVLink worker, +arming, logging, output limiting, and parameter writes. It does not run a web +server, open a browser tab, or require an internet connection. + +The WebView is frameless and uses the compact title bar drawn by the interface. +The vehicle rail includes a local STL viewer: choose **Load STL** to start with +an orthographic top-view silhouette, then drag to orbit or use the mouse wheel +to zoom. A cached STL copy is stored locally under the ignored `runtime/` +directory so the model can be restored on the next launch. +STEP/STP CAD solids must first be tessellated/exported as STL because the +embedded renderer intentionally does not include a full CAD kernel. +Binary and ASCII STL faces are preserved in full; large assemblies are not +rendered by skipping triangles. Imports are limited to 30 MB to keep startup +and interactive orbiting responsive. + +## Safety first + +- Remove propellers for initial tests and restrain the vehicle. +- Verify the ArduSub frame and `SERVOx_FUNCTION`, motor direction, ESC range, + leak/failsafe, and emergency-stop arrangements before arming. +- The application starts disarmed and limits pilot demand to 20% by default. + Once armed, control movement is sent directly with no additional gate. +- Closing or disconnecting sends neutral controls and a disarm request, but a + physical power cutoff is still required. + +The controls are pilot-demand axes, not direct PWM values. ArduSub performs +the frame mixing and generates the configured ESC outputs. For ArduSub, +`MANUAL_CONTROL.z = 500` is neutral vertical thrust. + +## Run on Windows + +1. Install Python 3.11 or newer. A virtual environment installs packages, but + it does not install Python itself. +2. Open PowerShell in this folder and run `./run.ps1` (or right-click it and + choose **Run with PowerShell**). +3. Select the autopilot's COM port, then click **Connect**. Baud and control-rate + settings are under the small `•••` link button (normally 57600 baud / 20 Hz). +4. Confirm `ArduSub` and a live heartbeat are shown. +5. Click **Set MANUAL**, then **ARM** only when the bench is safe. +6. Move the controls. Once armed, no additional enable button is needed. + +`run.ps1` automatically creates a private `.venv` on the first run, installs +the versions declared in `requirements.txt`, and starts SUB Control. Later runs +reuse the same environment and update missing dependencies when necessary. +The `.venv` directory is intentionally ignored by Git: virtual environments +contain machine-specific executables and can be recreated from +`requirements.txt`. + +## Repository layout + +The Gitea repository is named `argo`. `SUB Control` is a normal directory inside +that repository containing this application. A Gitea **Project** is a planning +board for issues and tasks; source code is not attached to a Project board. +Git tracks the folder automatically because its files are committed on the +repository's `main` branch. + +Keyboard controls for SimpleROV-3: W/S forward/back, A/D yaw left/right, +Q/E alternative yaw control, and R/F rise/dive. +Releasing movement keys eases their controls back to neutral. + +## Three-thruster limitation + +The common ArduSub `SimpleROV-3` arrangement controls forward, yaw, and depth. +It cannot independently control lateral, roll, or pitch. Accordingly, the main +pad is mapped to forward/yaw, while the vertical slider controls depth thrust. + +## Link behavior + +- MAVLink GCS heartbeat: 1 Hz +- `MANUAL_CONTROL`: selectable 10 or 20 Hz (default 20 Hz). Lower rates are + intentionally excluded because they make short return ramps become abrupt + steps at the flight controller. +- No full parameter download +- On connection, inherited bulk telemetry streams are stopped. Only + `SYS_STATUS` at 1 Hz and `SERVO_OUTPUT_RAW` / `RC_CHANNELS` at 2 Hz are + requested, in addition to the normal heartbeat and event-driven status text. +- Arm/disarm uses `MAV_CMD_COMPONENT_ARM_DISARM` and displays `COMMAND_ACK` +- Link health is based on received autopilot heartbeats +- Battery voltage is read from `SYS_STATUS` at 1 Hz and shown as pack voltage, + estimated per-cell voltage, and a voltage-only percentage. Cell count, + capacity, C rating, full/empty cell voltages, and warning percentage are + configurable and saved locally in `battery_settings.json`. Capacity consumed + cannot be measured without a current sensor. +- Servo configuration scans `SERVO1_FUNCTION` through `SERVO16_FUNCTION` and + ArduSub's `MOT_1_DIRECTION` through `MOT_12_DIRECTION` one at a time, waiting + for each response and retrying missed replies. A selected output can be + assigned to Motor 1–12 or set unassigned. Motor direction is toggled with the + corresponding `MOT_n_DIRECTION` value while disarmed; `SERVOx_REVERSED` is + intentionally not used for Motor functions. It never downloads the full + parameter list. + The diagnostic scan also reads `SERVO1`–`SERVO3` MIN/TRIM/MAX and ArduSub's + joystick gain values into the local session log; these values remain + read-only in the application. +- Every session creates a local text log under `logs/`, including operator + actions, outgoing manual-control values and commands, acknowledgements, + status text, and all received MAVLink messages. This does not use ArduPilot's + onboard logger or add any link traffic. + +The car-style virtual joystick maps only to forward/reverse and continuous +differential turning; it never sends lateral motion. The joystick and vertical +slider ease back to center over 400 ms after mouse/key release, so neither can +be left commanding motion. +Heartbeat loss, disconnect, and disarm remain immediate safety stops and are +intentionally not eased. + +In addition, transmitted pilot demands have a small 4% input deadband and a +0.8-second full-range slew limit. This makes both acceleration and deceleration +arrive at the flight controller as a sequence of stable intermediate values. +Forward and yaw are normalized as a SimpleROV-3 motor pair before the output +limit is applied, preventing their mixer contributions from adding beyond the +selected horizontal limit. + +ArduPilot may ignore manual input when `MAV_GCS_SYSID` does not match this +application's system ID (default 255), or when `MAV_OPTIONS` does not allow +MAVLink input. Those parameters must already be correctly configured. diff --git a/app.py b/app.py new file mode 100644 index 0000000..e4664ac --- /dev/null +++ b/app.py @@ -0,0 +1,1219 @@ +from __future__ import annotations + +import queue +import json +import os +import threading +import time +import tkinter as tk +import customtkinter as ctk +from collections import deque +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path +from tkinter import messagebox, ttk + +from pymavlink import mavutil +from serial.tools import list_ports + + +NEUTRAL_Z = 500 # ArduSub legacy MANUAL_CONTROL convention: 0..1000. +MOTOR_FUNCTIONS = { + **{33 + index: f"Motor {index + 1}" for index in range(8)}, + **{82 + index: f"Motor {index + 9}" for index in range(4)}, +} + + +def motor_number_for_function(function: int | None) -> int | None: + if function is None: + return None + if 33 <= function <= 40: + return function - 32 + if 82 <= function <= 85: + return function - 73 + return None + + +@dataclass +class Axes: + forward: float = 0.0 + lateral: float = 0.0 + vertical: float = 0.0 + yaw: float = 0.0 + + +class MavlinkWorker: + def __init__(self, events: queue.Queue): + self.events = events + self.connection = None + self.stop_event = threading.Event() + self.lock = threading.Lock() + self.thread = None + self.axes = Axes() + self.output_axes = Axes() + self.last_control_at = 0.0 + # Smooth ESC commands without adding a perceptible one-second lag. + # At 20 Hz this produces five small steps from neutral to full command. + self.ramp_seconds = 0.25 + self.gain = 0.20 + self.rate_hz = 20 + self.target_system = 0 + self.target_component = 0 + self.last_heartbeat = 0.0 + self.last_rx = 0.0 + self.armed = False + self.config_paused = False + self.param_scan = deque() + self.param_waiting = None + self.param_deadline = 0.0 + self.param_retries = 0 + self.param_types = {} + self.pending_sets = {} + self.log_lock = threading.Lock() + self.log_file = None + self.log_path = None + + def connect(self, port: str, baud: int): + if self.thread and self.thread.is_alive(): + return + self.stop_event.clear() + log_dir = Path(__file__).resolve().parent / "logs" + log_dir.mkdir(exist_ok=True) + self.log_path = log_dir / f"ardusub_{datetime.now():%Y%m%d_%H%M%S}.log" + self.log_file = self.log_path.open("a", encoding="utf-8", buffering=1) + self.log("SESSION", f"start port={port} baud={baud} control_hz={self.rate_hz}") + self.events.put(("log_path", str(self.log_path))) + self.thread = threading.Thread( + target=self._run, args=(port, baud), daemon=True + ) + self.thread.start() + + def _run(self, port: str, baud: int): + try: + conn = mavutil.mavlink_connection( + port, baud=baud, source_system=255, source_component=190, + autoreconnect=False, force_connected=True, + ) + self.connection = conn + self.events.put(("status", f"Port open; waiting for heartbeat at {baud} baud…")) + next_gcs_hb = 0.0 + next_control = 0.0 + while not self.stop_event.is_set(): + now = time.monotonic() + if now >= next_gcs_hb: + self.log("TX_HEARTBEAT", "GCS") + conn.mav.heartbeat_send( + mavutil.mavlink.MAV_TYPE_GCS, + mavutil.mavlink.MAV_AUTOPILOT_INVALID, + 0, 0, mavutil.mavlink.MAV_STATE_ACTIVE, + ) + next_gcs_hb = now + 1.0 + if self.target_system and now >= next_control: + self._send_control() + next_control = now + (1.0 / max(1, self.rate_hz)) + if self.target_system: + self._tick_param_scan(now) + + msg = conn.recv_match(blocking=False) + if msg: + self._handle_message(msg) + else: + time.sleep(0.01) + except Exception as exc: + self.events.put(("error", f"Connection error: {exc}")) + finally: + self._safe_stop() + if self.connection: + try: + self.connection.close() + except Exception: + pass + self.connection = None + self.target_system = 0 + self.armed = False + self.config_paused = False + self.log("SESSION", "disconnected") + with self.log_lock: + if self.log_file: + self.log_file.close() + self.log_file = None + self.events.put(("disconnected", "Disconnected")) + + def log(self, category: str, message: str): + timestamp = datetime.now().astimezone().isoformat(timespec="milliseconds") + with self.log_lock: + if self.log_file: + self.log_file.write(f"{timestamp} [{category}] {message}\n") + + def _handle_message(self, msg): + msg_type = msg.get_type() + if msg_type == "BAD_DATA": + self.log("RX_BAD_DATA", repr(getattr(msg, "data", b""))) + return + self.last_rx = time.monotonic() + try: + payload = json.dumps(msg.to_dict(), default=str, separators=(",", ":")) + except Exception: + payload = str(msg) + self.log("RX", payload) + if msg_type == "HEARTBEAT" and msg.get_srcComponent() == 1: + first = not self.target_system + self.target_system = msg.get_srcSystem() + self.target_component = msg.get_srcComponent() + self.last_heartbeat = time.monotonic() + armed = bool(msg.base_mode & mavutil.mavlink.MAV_MODE_FLAG_SAFETY_ARMED) + self.armed = armed + self.config_paused = armed + try: + mode = mavutil.mode_string_v10(msg) + except Exception: + mode = str(msg.custom_mode) + autopilot = mavutil.mavlink.enums["MAV_AUTOPILOT"].get(msg.autopilot) + ap_name = autopilot.name if autopilot else str(msg.autopilot) + self.events.put(("heartbeat", self.target_system, ap_name, mode, armed)) + if first: + self.events.put(("status", "Heartbeat received; controls are neutral.")) + self._configure_low_bandwidth_streams() + self._start_servo_scan() + elif msg_type == "COMMAND_ACK": + result = mavutil.mavlink.enums["MAV_RESULT"].get(msg.result) + result_name = result.name if result else str(msg.result) + self.events.put(("ack", int(msg.command), result_name)) + elif msg_type == "STATUSTEXT": + text = msg.text + if isinstance(text, bytes): + text = text.decode(errors="replace") + self.events.put(("statustext", text.rstrip("\x00"))) + elif msg_type == "SYS_STATUS": + voltage_mv = int(msg.voltage_battery) + if 0 < voltage_mv < 65535: + self.events.put(("battery", voltage_mv / 1000.0)) + elif msg_type == "PARAM_VALUE": + param_id = msg.param_id + if isinstance(param_id, bytes): + param_id = param_id.decode(errors="replace") + param_id = param_id.rstrip("\x00") + self.param_types[param_id] = msg.param_type + if param_id == self.param_waiting: + self.param_waiting = None + self.param_deadline = time.monotonic() + 0.10 + if param_id in self.pending_sets: + wanted = self.pending_sets[param_id] + if int(round(msg.param_value)) == wanted: + del self.pending_sets[param_id] + self.events.put(("param_saved", param_id, wanted)) + if param_id.startswith("SERVO") and param_id.endswith("_FUNCTION"): + self.events.put(("servo_param", param_id, int(round(msg.param_value)))) + elif param_id.startswith("MOT_") and param_id.endswith("_DIRECTION"): + parts = param_id.split("_") + if len(parts) == 3 and parts[1].isdigit(): + self.events.put(("motor_direction", int(parts[1]), + int(round(msg.param_value)))) + + def _start_servo_scan(self): + names = [f"SERVO{number}_FUNCTION" for number in range(1, 17)] + # ArduSub reverses mixer motors with MOT_n_DIRECTION (1 normal, + # -1 reversed). SERVOx_REVERSED does not reverse Motor functions. + names.extend(f"MOT_{number}_DIRECTION" for number in range(1, 13)) + # Capture the exact PWM scaling that affects the three SimpleROV-3 + # outputs, plus ArduSub's own joystick gain. These are read-only here + # and make direction asymmetry diagnosable from the session log. + names.extend( + f"SERVO{number}_{suffix}" + for number in range(1, 4) + for suffix in ("MIN", "TRIM", "MAX") + ) + names.extend(("JS_GAIN_MIN", "JS_GAIN_DEFAULT", "JS_GAIN_MAX")) + self.param_scan = deque(names) + self.param_waiting = None + self.param_deadline = time.monotonic() + self.param_retries = 0 + + def _configure_low_bandwidth_streams(self): + """Stop inherited high-rate streams and request only diagnostic essentials.""" + if not self.connection: + return + self.log("TX_STREAM_CONFIG", "stop MAV_DATA_STREAM_ALL") + self.connection.mav.request_data_stream_send( + self.target_system, self.target_component, + mavutil.mavlink.MAV_DATA_STREAM_ALL, 0, 0, + ) + # Heartbeat and STATUSTEXT do not need to be requested. Keep only + # output/input evidence needed to diagnose controls, at 2 Hz each. + for message_id, interval_us, name in ( + (mavutil.mavlink.MAVLINK_MSG_ID_SYS_STATUS, 1_000_000, "SYS_STATUS"), + (mavutil.mavlink.MAVLINK_MSG_ID_SERVO_OUTPUT_RAW, 500_000, "SERVO_OUTPUT_RAW"), + (mavutil.mavlink.MAVLINK_MSG_ID_RC_CHANNELS, 500_000, "RC_CHANNELS"), + ): + self.log("TX_STREAM_CONFIG", f"{name} interval_us={interval_us}") + self.connection.mav.command_long_send( + self.target_system, self.target_component, + mavutil.mavlink.MAV_CMD_SET_MESSAGE_INTERVAL, + 0, message_id, interval_us, 0, 0, 0, 0, 0, + ) + self.events.put(("status", "Low-bandwidth telemetry configured; reading servo setup…")) + + def _tick_param_scan(self, now): + if not self.connection or self.config_paused or now < self.param_deadline: + return + if self.param_waiting is not None: + if self.param_retries < 2: + self.param_retries += 1 + self._request_param(self.param_waiting) + self.param_deadline = now + 1.5 + return + self.events.put(("param_missing", self.param_waiting)) + self.param_waiting = None + if self.param_scan: + self.param_waiting = self.param_scan.popleft() + self.param_retries = 0 + self._request_param(self.param_waiting) + self.param_deadline = now + 1.5 + elif self.param_waiting is None: + self.events.put(("param_scan_complete",)) + self.param_deadline = float("inf") + + def _request_param(self, name: str): + self.log("TX_PARAM_REQUEST", name) + self.connection.mav.param_request_read_send( + self.target_system, self.target_component, name.encode("ascii"), -1 + ) + + def set_parameter(self, name: str, value: int): + if not self.connection or not self.target_system: + raise RuntimeError("No autopilot heartbeat") + if self.armed or self.config_paused: + raise RuntimeError("Disarm before changing motor configuration") + param_type = self.param_types.get(name) + if param_type is None: + raise RuntimeError(f"{name} has not been read from the autopilot") + self.pending_sets[name] = int(value) + self.log("TX_PARAM_SET", f"{name}={value} type={param_type}") + self.connection.mav.param_set_send( + self.target_system, self.target_component, name.encode("ascii"), + float(value), param_type, + ) + self.events.put(("status", f"Writing {name}={value}; waiting for confirmation…")) + + def _send_control(self, force_neutral: bool = False): + conn = self.connection + if not conn or not self.target_system: + return + with self.lock: + active = not force_neutral + target = self.axes if active else Axes() + gain = self.gain + now = time.monotonic() + if force_neutral: + self.output_axes = Axes() + else: + dt = (now - self.last_control_at) if self.last_control_at else (1.0 / max(1, self.rate_hz)) + max_change = min(1.0, dt / max(0.1, self.ramp_seconds)) + self.output_axes = Axes( + self._approach(self.output_axes.forward, target.forward, max_change), + self._approach(self.output_axes.lateral, target.lateral, max_change), + self._approach(self.output_axes.vertical, target.vertical, max_change), + self._approach(self.output_axes.yaw, target.yaw, max_change), + ) + self.last_control_at = now + axes = self.output_axes + # SimpleROV-3 mixes horizontal motors from forward +/- yaw. Normalize + # the pair so combining the two axes cannot exceed the selected limit. + horizontal_mix_peak = max( + 1.0, + abs(axes.forward + axes.yaw), + abs(axes.forward - axes.yaw), + ) + limited_forward = axes.forward / horizontal_mix_peak + limited_yaw = axes.yaw / horizontal_mix_peak + x = round(limited_forward * gain * 1000) + y = round(axes.lateral * gain * 1000) + r = round(limited_yaw * gain * 1000) + z = round(NEUTRAL_Z + axes.vertical * gain * 500) + self.log( + "TX_MANUAL_CONTROL", + f"x={x} y={y} z={z} r={r} active={active} " + f"target=({target.forward:.3f},{target.lateral:.3f},{target.vertical:.3f},{target.yaw:.3f})", + ) + conn.mav.manual_control_send(self.target_system, x, y, z, r, 0) + + @staticmethod + def _approach(current: float, target: float, max_change: float) -> float: + difference = target - current + if abs(difference) <= max_change: + return target + return current + (max_change if difference > 0 else -max_change) + + def command_arm(self, arm: bool): + conn = self.connection + if not conn or not self.target_system: + raise RuntimeError("No autopilot heartbeat") + if not arm: + self._send_control(force_neutral=True) + else: + # Give the ARM request priority over the low-rate parameter scan. + self.config_paused = True + self.log("TX_COMMAND", "ARM" if arm else "DISARM") + conn.mav.command_long_send( + self.target_system, self.target_component, + mavutil.mavlink.MAV_CMD_COMPONENT_ARM_DISARM, + 0, 1.0 if arm else 0.0, 0, 0, 0, 0, 0, 0, + ) + + def set_manual_mode(self): + conn = self.connection + if not conn or not self.target_system: + raise RuntimeError("No autopilot heartbeat") + modes = conn.mode_mapping() or {} + manual = modes.get("MANUAL") + if manual is None: + raise RuntimeError("MANUAL mode is not advertised by this firmware") + self.log("TX_MODE", f"MANUAL custom_mode={manual}") + conn.mav.set_mode_send( + self.target_system, + mavutil.mavlink.MAV_MODE_FLAG_CUSTOM_MODE_ENABLED, + manual, + ) + + def set_axes(self, axes: Axes): + def conditioned(value): + # Ignore tiny pointer/key return noise, then quantize to stable 1% steps. + if abs(value) < 0.04: + return 0.0 + return round(max(-1.0, min(1.0, value)), 2) + + with self.lock: + self.axes = Axes( + conditioned(axes.forward), conditioned(axes.lateral), + conditioned(axes.vertical), conditioned(axes.yaw), + ) + + def disconnect(self): + self._send_control(force_neutral=True) + if self.connection and self.target_system: + try: + self.command_arm(False) + except Exception: + pass + self.stop_event.set() + + def _safe_stop(self): + if self.connection and self.target_system: + try: + self._send_control(force_neutral=True) + self.connection.mav.command_long_send( + self.target_system, self.target_component, + mavutil.mavlink.MAV_CMD_COMPONENT_ARM_DISARM, + 0, 0, 0, 0, 0, 0, 0, 0, + ) + except Exception: + pass + + +class Joystick(tk.Canvas): + def __init__(self, master, callback, **kwargs): + self.size = 276 + self.center_point = self.size / 2 + self.radius = 108 + self.knob_radius = 24 + super().__init__(master, width=self.size, height=self.size, bg="#f5f5f2", + highlightthickness=0, bd=0, **kwargs) + self.callback = callback + self.value = (0.0, 0.0) + self.return_job = None + c, r, k = self.center_point, self.radius, self.knob_radius + self.create_oval(c-r, c-r, c+r, c+r, fill="#e9e9e5", outline="#d8d8d2", width=2) + self.create_oval(c-70, c-70, c+70, c+70, outline="#deded8", width=1) + self.create_line(c, c-r+12, c, c+r-12, fill="#d2d2cc", dash=(3, 6)) + self.create_line(c-r+12, c, c+r-12, c, fill="#d2d2cc", dash=(3, 6)) + self.create_text(c, 13, text="FORWARD", fill="#8a8a84", font=("Helvetica", 8, "bold")) + self.create_text(c, self.size-13, text="REVERSE", fill="#8a8a84", font=("Helvetica", 8, "bold")) + self.create_text(25, c, text="TURN\nLEFT", fill="#8a8a84", font=("Helvetica", 8, "bold")) + self.create_text(self.size-25, c, text="TURN\nRIGHT", fill="#8a8a84", font=("Helvetica", 8, "bold")) + self.knob = self.create_oval(c-k, c-k, c+k, c+k, + fill="#575c61", outline="#42474b", width=2) + self.bind("", self._move) + self.bind("", self._move) + self.bind("", self._release) + + def _move(self, event): + if self.return_job is not None: + self.after_cancel(self.return_job) + self.return_job = None + dx, dy = event.x - self.center_point, event.y - self.center_point + length = max(1.0, (dx * dx + dy * dy) ** 0.5) + if length > self.radius: + dx, dy = dx * self.radius / length, dy * self.radius / length + self.set_immediate(dx / self.radius, -dy / self.radius) + + def set_immediate(self, x: float, y: float): + if self.return_job is not None: + self.after_cancel(self.return_job) + self.return_job = None + self.value = (x, y) + c, k = self.center_point, self.knob_radius + dx, dy = x * self.radius, -y * self.radius + self.coords(self.knob, c-k+dx, c-k+dy, c+k+dx, c+k+dy) + self.callback() + + def _release(self, _event=None): + start_x, start_y = self.value + started = time.monotonic() + duration = 0.40 + + def ease_back(): + elapsed = time.monotonic() - started + progress = min(1.0, elapsed / duration) + # Smoothstep has zero slope at both ends, avoiding a motor kick. + eased = progress * progress * (3.0 - 2.0 * progress) + remaining = 1.0 - eased + x, y = start_x * remaining, start_y * remaining + self.value = (x, y) + c, k = self.center_point, self.knob_radius + dx, dy = x * self.radius, -y * self.radius + self.coords(self.knob, c-k+dx, c-k+dy, c+k+dx, c+k+dy) + self.callback() + if progress < 1.0: + self.return_job = self.after(20, ease_back) + else: + self.return_job = None + + if self.return_job is not None: + self.after_cancel(self.return_job) + ease_back() + + def center(self): + if self.return_job is not None: + self.after_cancel(self.return_job) + self.return_job = None + self.value = (0.0, 0.0) + c, k = self.center_point, self.knob_radius + self.coords(self.knob, c-k, c-k, c+k, c+k) + self.callback() + + +class SpringScale(ctk.CTkSlider): + """A Scale that smoothly returns to zero when the pointer is released.""" + + def __init__(self, master, **kwargs): + self.callback = kwargs.pop("command", None) + kwargs.pop("showvalue", None) + kwargs.pop("bg", None) + kwargs.pop("troughcolor", None) + kwargs.pop("activebackground", None) + kwargs.pop("highlightthickness", None) + kwargs.pop("bd", None) + kwargs.pop("sliderrelief", None) + if "orient" in kwargs: + kwargs["orientation"] = kwargs.pop("orient") + length = kwargs.pop("length", 200) + if kwargs.get("orientation", "horizontal") == "vertical": + kwargs.setdefault("height", length) + kwargs.setdefault("width", 18) + else: + kwargs.setdefault("width", length) + kwargs.setdefault("height", 18) + super().__init__(master, command=self._changed, corner_radius=10, + button_corner_radius=10, button_length=22, + fg_color="#dbe3ee", progress_color="#60a5fa", + button_color="#2563eb", button_hover_color="#1d4ed8", + **kwargs) + self.return_job = None + self.bind("", self._cancel_return, add="+") + self.bind("", self._released, add="+") + + def _changed(self, value): + if self.callback: + self.callback(value) + + def _set_and_notify(self, value): + self.set(value) + if self.callback: + self.callback(value) + + def _cancel_return(self, _event=None): + if self.return_job is not None: + self.after_cancel(self.return_job) + self.return_job = None + + def _released(self, _event=None): + # Wait until Tk's native Scale binding has applied its final value. + self.after_idle(self.spring_to_center) + + def set_immediate(self, value): + self._cancel_return() + self._set_and_notify(value) + + def spring_to_center(self): + self._cancel_return() + start = float(self.get()) + if abs(start) < 0.5: + self._set_and_notify(0) + return + started = time.monotonic() + duration = 0.40 + + def ease_back(): + progress = min(1.0, (time.monotonic() - started) / duration) + eased = progress * progress * (3.0 - 2.0 * progress) + self._set_and_notify(start * (1.0 - eased)) + if progress < 1.0: + self.return_job = self.after(20, ease_back) + else: + self._set_and_notify(0) + self.return_job = None + + ease_back() + + +class App(tk.Tk): + def __init__(self): + super().__init__() + self.title("SUB Control") + self.geometry("1040x800") + self.minsize(920, 720) + self.events = queue.Queue() + self.worker = MavlinkWorker(self.events) + self.keys = set() + self.keyboard_yaw = 0 + self.keyboard_vertical = 0 + self.keyboard_planar = (0, 0) + self.servo_functions = {} + self.motor_directions = {} + self.battery_config_path = Path(__file__).resolve().parent / "battery_settings.json" + self.battery_config = { + "cells": 4, + "capacity_mah": 5500, + "c_rating": 35, + "full_v_per_cell": 4.20, + "empty_v_per_cell": 3.30, + "warning_percent": 20, + } + self.battery_voltage = None + self.battery_voltage_smoothed = None + self._load_battery_settings() + self._configure_styles() + self.protocol("WM_DELETE_WINDOW", self.close) + self._build() + self.refresh_ports() + self.bind_all("", self._key_down) + self.bind_all("", self._key_up) + self.after(50, self._poll) + self.after(500, self._link_watchdog) + + def _configure_styles(self): + self.configure(bg="#e8e9e7") + style = ttk.Style(self) + try: + style.theme_use("clam") + except tk.TclError: + pass + style.configure("TFrame", background="#fbfbf9") + style.configure("TLabel", background="#fbfbf9", foreground="#202224", + font=("Helvetica", 9)) + style.configure("TLabelframe", background="#f4f6f8", bordercolor="#d8dee8", + borderwidth=1, relief="solid") + style.configure("TLabelframe.Label", background="#f4f6f8", foreground="#475569", + font=("Segoe UI", 9, "bold")) + style.configure("TButton", font=("Segoe UI", 9), padding=(10, 7), + background="#ffffff", foreground="#1e293b", bordercolor="#cbd5e1") + style.map("TButton", background=[("active", "#e8eef7")]) + style.configure("Primary.TButton", background="#2563eb", foreground="#ffffff", + bordercolor="#2563eb", font=("Segoe UI", 9, "bold")) + style.map("Primary.TButton", background=[("active", "#1d4ed8")]) + style.configure("Danger.TButton", background="#dc2626", foreground="#ffffff", + bordercolor="#dc2626", font=("Segoe UI", 9, "bold")) + style.map("Danger.TButton", background=[("active", "#b91c1c")]) + style.configure("Title.TLabel", font=("Segoe UI", 18, "bold"), foreground="#0f172a") + style.configure("Subtitle.TLabel", font=("Segoe UI", 9), foreground="#64748b") + style.configure("Status.TLabel", font=("Segoe UI", 9, "bold")) + style.configure("Footer.TLabel", background="#e9eef5", foreground="#475569", + font=("Segoe UI", 9)) + style.configure("Treeview", rowheight=28, background="#ffffff", fieldbackground="#ffffff", + foreground="#26282a", bordercolor="#e1e1dc", font=("Helvetica", 9)) + style.configure("Treeview.Heading", font=("Helvetica", 9, "bold"), padding=(6, 6), + background="#f0f0ed", foreground="#4c4f52") + style.map("Treeview", background=[("selected", "#dbeafe")], + foreground=[("selected", "#1e3a8a")]) + style.configure("Horizontal.TProgressbar", troughcolor="#e2e8f0", background="#22c55e", + bordercolor="#e2e8f0", lightcolor="#22c55e", darkcolor="#22c55e") + + def _console_button(self, master, text, command, kind="neutral", width=112): + colors = { + "neutral": ("#f4f4f1", "#e9e9e4", "#343739", "#deded8"), + "primary": ("#dcebd5", "#cde2c4", "#27401f", "#c4dcb9"), + "danger": ("#f7dfdc", "#f1cfcb", "#7b3029", "#edcec9"), + } + color, hover, text_color, border = colors[kind] + return ctk.CTkButton( + master, text=text, command=command, width=width, height=34, + corner_radius=11, fg_color=color, hover_color=hover, + text_color=text_color, border_color=border, border_width=1, + font=ctk.CTkFont("Helvetica", 12, "bold" if kind != "neutral" else "normal"), + bg_color="transparent", + ) + + def _console_combo(self, master, values, width): + return ctk.CTkComboBox( + master, values=[str(value) for value in values], width=width, height=34, + corner_radius=10, border_width=1, border_color="#cbd5e1", + fg_color="#ffffff", button_color="#e8eef7", + button_hover_color="#dbeafe", dropdown_fg_color="#ffffff", + dropdown_hover_color="#dbeafe", text_color="#1e293b", + bg_color="transparent", state="readonly", + ) + + def _build(self): + shell = ctk.CTkFrame(self, fg_color="#f8f8f6", corner_radius=24, + border_width=1, border_color="#d5d6d3") + shell.pack(fill="both", expand=True, padx=18, pady=18) + shell.grid_rowconfigure(0, weight=1) + shell.grid_columnconfigure(1, weight=1) + + rail = ctk.CTkFrame(shell, width=248, fg_color="#282d30", corner_radius=22) + rail.grid(row=0, column=0, sticky="nsew") + rail.grid_propagate(False) + workspace = ctk.CTkFrame(shell, fg_color="#fbfbf9", corner_radius=22) + workspace.grid(row=0, column=1, sticky="nsew") + + ctk.CTkLabel(rail, text="SUB CONTROL / 03", text_color="#f4f4f1", + font=ctk.CTkFont("Helvetica", 13, "bold")).pack( + anchor="w", padx=24, pady=(24, 0)) + ctk.CTkLabel(rail, text="ArduSub vehicle", text_color="#8f9699", + font=ctk.CTkFont("Helvetica", 10)).pack(anchor="w", padx=24, pady=(2, 14)) + + vehicle_art = tk.Canvas(rail, width=204, height=250, bg="#282d30", + highlightthickness=0, bd=0) + vehicle_art.pack(padx=20, pady=(5, 4)) + vehicle_art.create_oval(47, 27, 157, 223, fill="#353b3e", outline="#555c60", width=2) + vehicle_art.create_oval(68, 49, 136, 201, fill="#303538", outline="#444a4e") + vehicle_art.create_oval(80, 102, 124, 146, fill="#5e666a", outline="#788084", width=2) + vehicle_art.create_oval(21, 57, 62, 100, fill="#202426", outline="#62696d", width=2) + vehicle_art.create_oval(142, 57, 183, 100, fill="#202426", outline="#62696d", width=2) + vehicle_art.create_line(42, 79, 72, 93, fill="#757d81", width=3) + vehicle_art.create_line(162, 79, 132, 93, fill="#757d81", width=3) + vehicle_art.create_text(102, 236, text="TOP VIEW • 3 THRUSTERS", + fill="#7e8689", font=("Helvetica", 8, "bold")) + + ctk.CTkLabel(rail, text="Vehicle", text_color="#959c9f", + font=ctk.CTkFont("Helvetica", 10)).pack(anchor="w", padx=24, pady=(5, 0)) + self.vehicle_label = ctk.CTkLabel(rail, text="No heartbeat", text_color="#f4f4f1", + font=ctk.CTkFont("Helvetica", 14, "bold")) + self.vehicle_label.pack(anchor="w", padx=24) + + rail_bottom = ctk.CTkFrame(rail, fg_color="transparent") + rail_bottom.pack(side="bottom", fill="x", padx=24, pady=22) + self.battery_summary = ctk.CTkLabel( + rail_bottom, text="Battery —", text_color="#f4f4f1", + font=ctk.CTkFont("Helvetica", 13, "bold"), anchor="w" + ) + self.battery_summary.pack(fill="x") + self.battery_meta = ctk.CTkLabel( + rail_bottom, text="Waiting for voltage", text_color="#92999c", + font=ctk.CTkFont("Helvetica", 9), anchor="w" + ) + self.battery_meta.pack(fill="x", pady=(0, 5)) + self.battery_bar = ctk.CTkProgressBar( + rail_bottom, height=7, corner_radius=4, fg_color="#454b4e", + progress_color="#c4e46c" + ) + self.battery_bar.set(0) + self.battery_bar.pack(fill="x", pady=(0, 18)) + self.link_label = ctk.CTkLabel( + rail_bottom, text="● DISCONNECTED", text_color="#f08a82", + font=ctk.CTkFont("Helvetica", 10, "bold"), anchor="w" + ) + self.link_label.pack(fill="x") + + header = ctk.CTkFrame(workspace, fg_color="transparent") + header.pack(fill="x", padx=28, pady=(24, 14)) + title = ctk.CTkFrame(header, fg_color="transparent") + title.pack(side="left") + ctk.CTkLabel(title, text="Drive", text_color="#17191a", + font=ctk.CTkFont("Helvetica", 24, "bold")).pack(anchor="w") + ctk.CTkLabel(title, text="Differential steering • MANUAL control", + text_color="#8a8c8e", font=ctk.CTkFont("Helvetica", 10)).pack(anchor="w") + + top = ctk.CTkFrame(header, fg_color="transparent") + top.pack(side="right") + self.port = self._console_combo(top, [], 190) + self.port.grid(row=0, column=0, padx=4) + self._console_button(top, "↻", self.refresh_ports, width=38).grid(row=0, column=1, padx=4) + self.baud = self._console_combo(top, (9600, 19200, 38400, 57600, 115200), 105) + self.baud.set("57600") + self.rate = self._console_combo(top, (10, 20), 72) + self.rate.set("20") + self.link_settings_button = self._console_button( + top, "•••", self._toggle_link_settings, width=38 + ) + self.link_settings_button.grid(row=0, column=2, padx=4) + self.connect_button = self._console_button( + top, "Connect", self.toggle_connect, kind="primary", width=110 + ) + self.connect_button.grid(row=0, column=3, padx=(4, 0)) + + separator = ctk.CTkFrame(workspace, height=1, fg_color="#e7e7e3") + separator.pack(fill="x", padx=28) + + controls = ctk.CTkFrame(workspace, fg_color="transparent") + controls.pack(fill="both", expand=True, padx=28, pady=18) + controls.grid_rowconfigure(0, weight=1) + controls.grid_columnconfigure(0, weight=1) + controls.grid_columnconfigure(1, minsize=214) + + drive_card = ctk.CTkFrame(controls, fg_color="#f5f5f2", corner_radius=18) + drive_card.grid(row=0, column=0, sticky="nsew", padx=(0, 12)) + ctk.CTkLabel(drive_card, text="STEER + THROTTLE", text_color="#66696b", + font=ctk.CTkFont("Helvetica", 10, "bold")).pack(pady=(18, 0)) + self.joystick = Joystick(drive_card, self._axes_changed) + self.joystick.pack(expand=True, pady=(4, 0)) + ctk.CTkLabel(drive_card, text="W / S drive • A / D steer", + text_color="#969896", font=ctk.CTkFont("Helvetica", 9)).pack(pady=(0, 16)) + + heave_card = ctk.CTkFrame(controls, fg_color="#f5f5f2", corner_radius=18) + heave_card.grid(row=0, column=1, sticky="nsew") + ctk.CTkLabel(heave_card, text="VERTICAL", text_color="#66696b", + font=ctk.CTkFont("Helvetica", 10, "bold")).pack(pady=(18, 2)) + ctk.CTkLabel(heave_card, text="RISE", text_color="#969896", + font=ctk.CTkFont("Helvetica", 8, "bold")).pack() + self.vertical = SpringScale( + heave_card, from_=100, to=-100, orient="vertical", length=230, + command=lambda _: self._axes_changed() + ) + self.vertical.set(0) + self.vertical.pack(expand=True, pady=8) + ctk.CTkLabel(heave_card, text="DIVE", text_color="#969896", + font=ctk.CTkFont("Helvetica", 8, "bold")).pack() + ctk.CTkLabel(heave_card, text="R / F • springs to neutral", + text_color="#969896", font=ctk.CTkFont("Helvetica", 9)).pack(pady=(4, 16)) + + self.battery_cells = tk.StringVar(value=str(self.battery_config["cells"])) + self.battery_capacity = tk.StringVar(value=str(self.battery_config["capacity_mah"])) + self.battery_c_rating = tk.StringVar(value=str(self.battery_config["c_rating"])) + self.battery_full_cell = tk.StringVar(value=str(self.battery_config["full_v_per_cell"])) + self.battery_empty_cell = tk.StringVar(value=str(self.battery_config["empty_v_per_cell"])) + self.battery_warning = tk.StringVar(value=str(self.battery_config["warning_percent"])) + fields = ( + ("Cells (S)", self.battery_cells, 5), + ("Capacity mAh", self.battery_capacity, 8), + ("C rating", self.battery_c_rating, 5), + ("Full V/cell", self.battery_full_cell, 6), + ("Empty V/cell", self.battery_empty_cell, 6), + ("Warn %", self.battery_warning, 5), + ) + self.battery_settings_frame = ctk.CTkFrame(workspace, fg_color="#f0f0ed", corner_radius=14) + for column, (label, variable, width) in enumerate(fields): + group = ctk.CTkFrame(self.battery_settings_frame, fg_color="transparent") + group.grid(row=0, column=column, padx=(0, 8), sticky="w") + ctk.CTkLabel(group, text=label, text_color="#66696b", + font=ctk.CTkFont("Helvetica", 9)).pack(anchor="w") + ctk.CTkEntry(group, textvariable=variable, width=max(62, width * 9), height=30, + corner_radius=8, border_color="#d0d0cb").pack(anchor="w") + self._console_button(self.battery_settings_frame, "Apply", self._apply_battery_settings, + kind="primary", width=82).grid( + row=0, column=6, padx=(8, 0), sticky="sew" + ) + self.battery_settings_frame.pack_forget() + + actions = ctk.CTkFrame(workspace, fg_color="transparent") + actions.pack(fill="x", padx=28, pady=(0, 14)) + self._console_button(actions, "MANUAL", self.set_manual, width=94).pack(side="left", padx=3) + self._console_button(actions, "ARM", self.arm, kind="primary", width=82).pack(side="left", padx=3) + self._console_button(actions, "DISARM", self.disarm, kind="danger", width=92).pack(side="left", padx=3) + ctk.CTkLabel(actions, text="Output", text_color="#777a7c", + font=ctk.CTkFont("Helvetica", 10)).pack(side="left", padx=(18, 4)) + self.gain = ctk.CTkSlider( + actions, from_=5, to=50, number_of_steps=45, width=170, height=18, + command=self._gain_changed, corner_radius=10, button_corner_radius=10, + button_length=22, fg_color="#dbe3ee", progress_color="#60a5fa", + button_color="#2563eb", button_hover_color="#1d4ed8", + bg_color="transparent", + ) + self.gain.set(20) + self.gain.pack(side="left") + self.gain_text = ctk.CTkLabel(actions, text="20%", text_color="#55585a", + font=ctk.CTkFont("Helvetica", 10, "bold")) + self.gain_text.pack(side="left") + self._console_button(actions, "Motor setup", self._toggle_motor_setup, width=112).pack( + side="right", padx=3 + ) + self._console_button(actions, "Battery", self._toggle_battery_settings, width=88).pack( + side="right", padx=3 + ) + + assignments = ctk.CTkFrame(workspace, fg_color="#f0f0ed", corner_radius=14) + self.assignments_frame = assignments + self.servo_tree = ttk.Treeview( + assignments, columns=("output", "function", "reversed"), + show="headings", height=5, selectmode="browse", + ) + self.servo_tree.heading("output", text="Output") + self.servo_tree.heading("function", text="Function") + self.servo_tree.heading("reversed", text="Motor reversed") + self.servo_tree.column("output", width=90, anchor="center", stretch=False) + self.servo_tree.column("function", width=220, anchor="w") + self.servo_tree.column("reversed", width=90, anchor="center", stretch=False) + for number in range(1, 17): + self.servo_tree.insert("", "end", iid=str(number), + values=(f"SERVO{number}", "Waiting…", "Waiting…")) + self.servo_tree.pack(side="left", fill="x", expand=True) + scrollbar = ttk.Scrollbar(assignments, orient="vertical", command=self.servo_tree.yview) + scrollbar.pack(side="left", fill="y") + self.servo_tree.configure(yscrollcommand=scrollbar.set) + + edit = ctk.CTkFrame(assignments, fg_color="transparent") + edit.pack(side="right", fill="y") + ctk.CTkLabel(edit, text="Selected output function", text_color="#55585a").pack(anchor="w") + motor_choices = ["Unassigned (0)"] + [f"Motor {number}" for number in range(1, 13)] + self.motor_function = self._console_combo(edit, motor_choices, 165) + self.motor_function.set("Motor 1") + self.motor_function.pack(fill="x", pady=(2, 5)) + self._console_button(edit, "Apply function", self.apply_servo_function, width=165).pack(fill="x", pady=2) + self._console_button(edit, "Reverse motor", self.toggle_servo_reversed, width=165).pack(fill="x", pady=2) + ctk.CTkLabel(edit, text="Changes require DISARMED", text_color="#a16207").pack(pady=(8, 0)) + + log_frame = ctk.CTkFrame(workspace, fg_color="transparent") + log_frame.pack(fill="x", padx=28, pady=(0, 12)) + self.log_frame = log_frame + self.log_path_text = tk.StringVar(value="Session log starts when connected") + ctk.CTkLabel(log_frame, textvariable=self.log_path_text, anchor="w", text_color="#8b8d8f", + font=ctk.CTkFont("Helvetica", 9)).pack(side="left", fill="x", expand=True) + self._console_button(log_frame, "Open logs", self.open_logs_folder, width=96).pack(side="right") + + self.status_text = tk.StringVar(value="Ready. Controls are neutral; vehicle is not connected.") + ctk.CTkLabel(workspace, textvariable=self.status_text, anchor="w", height=34, + corner_radius=12, fg_color="#eeeeea", text_color="#65686a", + font=ctk.CTkFont("Helvetica", 9)).pack(fill="x", padx=28, pady=(0, 18)) + + def refresh_ports(self): + ports = [p.device for p in list_ports.comports()] + self.port.configure(values=ports) + if ports and self.port.get() not in ports: + self.port.set(ports[0]) + + def _toggle_battery_settings(self): + if self.battery_settings_frame.winfo_ismapped(): + self.battery_settings_frame.pack_forget() + else: + self.battery_settings_frame.pack( + fill="x", padx=28, pady=(0, 12), before=self.log_frame + ) + + def _toggle_link_settings(self): + if self.baud.winfo_ismapped(): + self.baud.grid_remove() + self.rate.grid_remove() + else: + self.baud.grid(row=1, column=0, sticky="e", padx=4, pady=(6, 0)) + self.rate.grid(row=1, column=1, sticky="w", padx=4, pady=(6, 0)) + + def _toggle_motor_setup(self): + if self.assignments_frame.winfo_ismapped(): + self.assignments_frame.pack_forget() + else: + self.assignments_frame.pack( + fill="x", padx=28, pady=(0, 12), before=self.log_frame + ) + + def _load_battery_settings(self): + try: + saved = json.loads(self.battery_config_path.read_text(encoding="utf-8")) + for key in self.battery_config: + if key in saved: + self.battery_config[key] = saved[key] + except (OSError, ValueError, TypeError): + pass + + def _apply_battery_settings(self): + try: + config = { + "cells": int(self.battery_cells.get()), + "capacity_mah": int(self.battery_capacity.get()), + "c_rating": float(self.battery_c_rating.get()), + "full_v_per_cell": float(self.battery_full_cell.get()), + "empty_v_per_cell": float(self.battery_empty_cell.get()), + "warning_percent": int(self.battery_warning.get()), + } + if not 1 <= config["cells"] <= 12: + raise ValueError("cell count must be between 1 and 12") + if config["capacity_mah"] <= 0 or config["c_rating"] <= 0: + raise ValueError("capacity and C rating must be positive") + if not config["full_v_per_cell"] > config["empty_v_per_cell"] > 0: + raise ValueError("full V/cell must be greater than empty V/cell") + if not 0 <= config["warning_percent"] <= 100: + raise ValueError("warning percentage must be between 0 and 100") + self.battery_config = config + self.battery_config_path.write_text( + json.dumps(config, indent=2) + "\n", encoding="utf-8" + ) + self._update_battery_display() + self.status_text.set("Battery settings saved") + except (ValueError, OSError) as exc: + messagebox.showerror("Invalid battery settings", str(exc)) + + def _battery_percent(self, voltage): + cells = self.battery_config["cells"] + per_cell = voltage / cells + empty = self.battery_config["empty_v_per_cell"] + full = self.battery_config["full_v_per_cell"] + return max(0.0, min(100.0, (per_cell - empty) * 100.0 / (full - empty))) + + def _update_battery_display(self): + voltage = self.battery_voltage_smoothed + if voltage is None: + self.battery_summary.configure(text="Battery —", text_color="#f4f4f1") + self.battery_meta.configure(text="Waiting for voltage") + self.battery_bar.set(0) + return + cells = self.battery_config["cells"] + percent = self._battery_percent(voltage) + per_cell = voltage / cells + warning = percent <= self.battery_config["warning_percent"] + self.battery_summary.configure( + text=f"Battery {percent:.0f}%", + text_color="#ffaaa2" if warning else "#f4f4f1", + ) + self.battery_meta.configure( + text=f"{voltage:.2f} V • {per_cell:.2f} V/cell • {cells}S" + + (" • LOW" if warning else "") + ) + self.battery_bar.configure(progress_color="#f08a82" if warning else "#c4e46c") + self.battery_bar.set(percent / 100.0) + + def toggle_connect(self): + if self.worker.thread and self.worker.thread.is_alive(): + self.worker.disconnect() + self.connect_button.configure(state="disabled") + return + if not self.port.get(): + messagebox.showerror("No port", "Select a USB serial COM port.") + return + self.worker.rate_hz = int(self.rate.get()) + self.worker.connect(self.port.get(), int(self.baud.get())) + self.connect_button.configure(text="Disconnect") + self.status_text.set("Opening serial port…") + + def _axes_changed(self): + turn, forward = self.joystick.value + self.worker.set_axes(Axes(forward, 0.0, + float(self.vertical.get()) / 100, + turn)) + + def _gain_changed(self, value): + value = int(float(value)) + self.worker.gain = value / 100 + self.gain_text.configure(text=f"{value}%") + + def _key_down(self, event): + key = event.keysym.lower() + if key in "wasdqerf": + self.keys.add(key) + self._apply_keys() + + def _key_up(self, event): + key = event.keysym.lower() + self.keys.discard(key) + self._apply_keys() + + def _apply_keys(self): + turn = (1 if ("d" in self.keys or "e" in self.keys) else 0) - ( + 1 if ("a" in self.keys or "q" in self.keys) else 0 + ) + forward = (1 if "w" in self.keys else 0) - (1 if "s" in self.keys else 0) + vertical = (1 if "r" in self.keys else 0) - (1 if "f" in self.keys else 0) + planar = (turn, forward) + if planar != self.keyboard_planar: + if turn or forward: + self.joystick.set_immediate(float(turn), float(forward)) + else: + self.joystick._release() + self.keyboard_planar = planar + if vertical != self.keyboard_vertical: + if vertical: + self.vertical.set_immediate(vertical * 100) + else: + self.vertical.spring_to_center() + self.keyboard_vertical = vertical + self._axes_changed() + + def arm(self): + if not messagebox.askyesno("Arm vehicle", "Propellers removed or vehicle securely restrained?\n\nArm now?"): + return + try: + self.worker.log("OPERATOR", "clicked ARM") + self.worker.command_arm(True) + self.status_text.set("Arm command sent; waiting for ACK/heartbeat…") + except Exception as exc: + messagebox.showerror("Cannot arm", str(exc)) + + def disarm(self): + try: + self.worker.log("OPERATOR", "clicked DISARM") + self.worker.command_arm(False) + self.status_text.set("Disarm command sent; neutral sent") + except Exception as exc: + messagebox.showerror("Cannot disarm", str(exc)) + + def set_manual(self): + try: + self.worker.log("OPERATOR", "clicked SET MANUAL") + self.worker.set_manual_mode() + self.status_text.set("MANUAL mode request sent") + except Exception as exc: + messagebox.showerror("Cannot set mode", str(exc)) + + def open_logs_folder(self): + log_dir = Path(__file__).resolve().parent / "logs" + log_dir.mkdir(exist_ok=True) + os.startfile(log_dir) + + def _selected_servo(self): + selection = self.servo_tree.selection() + if not selection: + raise RuntimeError("Select a SERVO output in the table first") + return int(selection[0]) + + def apply_servo_function(self): + try: + servo = self._selected_servo() + choice = self.motor_function.get() + if choice.startswith("Motor "): + motor = int(choice.split()[1]) + function = 32 + motor if motor <= 8 else 73 + motor + else: + function = 0 + description = MOTOR_FUNCTIONS.get(function, "Unassigned") + if not messagebox.askyesno( + "Change output function", + f"Set SERVO{servo}_FUNCTION to {description} ({function})?\n\n" + "This changes the autopilot configuration.", + ): + return + self.worker.set_parameter(f"SERVO{servo}_FUNCTION", function) + except Exception as exc: + messagebox.showerror("Cannot change function", str(exc)) + + def toggle_servo_reversed(self): + try: + servo = self._selected_servo() + function = self.servo_functions.get(servo) + motor = motor_number_for_function(function) + if motor is None: + raise RuntimeError( + f"SERVO{servo} is not assigned to an ArduSub Motor function" + ) + current = self.motor_directions.get(motor) + if current is None: + raise RuntimeError(f"MOT_{motor}_DIRECTION has not been read yet") + new_value = -1 if current == 1 else 1 + if not messagebox.askyesno( + "Reverse motor output", + f"SERVO{servo} is Motor {motor}.\n" + f"Set MOT_{motor}_DIRECTION to {new_value}?\n\n" + "Test direction with propellers removed after changing this.", + ): + return + self.worker.set_parameter(f"MOT_{motor}_DIRECTION", new_value) + except Exception as exc: + messagebox.showerror("Cannot reverse output", str(exc)) + + def _refresh_servo_row(self, number): + function = self.servo_functions.get(number) + motor = motor_number_for_function(function) + direction = self.motor_directions.get(motor) if motor is not None else None + if function is None: + function_text = "Waiting…" + else: + function_text = f"{MOTOR_FUNCTIONS.get(function, 'Other / unassigned')} ({function})" + if motor is None: + reversed_text = "—" + elif direction is None: + reversed_text = "Waiting…" + else: + reversed_text = "Yes" if direction == -1 else "No" + self.servo_tree.item(str(number), values=(f"SERVO{number}", function_text, reversed_text)) + + def _poll(self): + try: + while True: + event = self.events.get_nowait() + kind = event[0] + if kind == "heartbeat": + _, system, autopilot, mode, armed = event + self.vehicle_label.configure( + text=f"{mode} • {'ARMED' if armed else 'DISARMED'}" + ) + self.link_label.configure(text="● LINK OK", text_color="#c4e46c") + elif kind in ("status", "statustext"): + self.status_text.set(event[1]) + elif kind == "ack": + self.status_text.set(f"Command {event[1]} ACK: {event[2]}") + elif kind == "servo_param": + _, param_id, value = event + number = int(param_id[5:param_id.index("_")]) + self.servo_functions[number] = value + self._refresh_servo_row(number) + elif kind == "motor_direction": + _, motor, value = event + self.motor_directions[motor] = value + for number in range(1, 17): + if motor_number_for_function(self.servo_functions.get(number)) == motor: + self._refresh_servo_row(number) + elif kind == "battery": + self.battery_voltage = event[1] + if self.battery_voltage_smoothed is None: + self.battery_voltage_smoothed = self.battery_voltage + else: + self.battery_voltage_smoothed = ( + 0.75 * self.battery_voltage_smoothed + 0.25 * self.battery_voltage + ) + self._update_battery_display() + elif kind == "param_saved": + self.status_text.set(f"Confirmed: {event[1]}={event[2]}") + elif kind == "param_missing": + self.status_text.set(f"No response for {event[1]}; continuing scan…") + elif kind == "param_scan_complete": + count = sum(value in MOTOR_FUNCTIONS for value in self.servo_functions.values()) + self.status_text.set(f"Servo scan complete: {count} motor output(s) assigned") + elif kind == "log_path": + self.log_path_text.set(f"Log: {event[1]}") + elif kind == "error": + self.status_text.set(event[1]) + messagebox.showerror("MAVLink", event[1]) + elif kind == "disconnected": + self.connect_button.configure(text="Connect", state="normal") + self.link_label.configure(text="● DISCONNECTED", text_color="#f08a82") + self.vehicle_label.configure(text="No heartbeat") + self.battery_voltage = None + self.battery_voltage_smoothed = None + self._update_battery_display() + self.servo_functions.clear() + self.motor_directions.clear() + for number in range(1, 17): + self.servo_tree.item(str(number), values=(f"SERVO{number}", "Waiting…", "Waiting…")) + except queue.Empty: + pass + self.after(50, self._poll) + + def _link_watchdog(self): + if self.worker.target_system and time.monotonic() - self.worker.last_heartbeat > 5.0: + if time.monotonic() - self.worker.last_rx < 2.0: + text = "● DATA OK / HEARTBEAT DELAYED" + else: + text = "● ALL LINK DATA LOST" + self.link_label.configure(text=text, text_color="#f08a82") + self.worker._send_control(force_neutral=True) + self.after(500, self._link_watchdog) + + def close(self): + self.worker.disconnect() + self.after(150, self.destroy) + + +if __name__ == "__main__": + # The production interface is CSS/HTML rendered by the local WebView. + # The Tk classes remain in this module as a fallback and to keep the + # MAVLink worker import-compatible with older installations. + from web_ui import main as run_web_interface + + run_web_interface() diff --git a/battery_settings.json b/battery_settings.json new file mode 100644 index 0000000..f8db5a8 --- /dev/null +++ b/battery_settings.json @@ -0,0 +1,8 @@ +{ + "cells": 4, + "capacity_mah": 5500, + "c_rating": 35.0, + "full_v_per_cell": 4.2, + "empty_v_per_cell": 3.3, + "warning_percent": 20 +} diff --git a/docs/sub-control-interface.png b/docs/sub-control-interface.png new file mode 100644 index 0000000000000000000000000000000000000000..db24d5bf79055c54358d7daaa169b62df7c67854 GIT binary patch literal 50134 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 z�!AsUmvNvh9wT*RJ)*5Z38x81l~P$BgYG^LjvNiU5J|xR?u(uqG6%+sI3nkwHpA zjWzQ*VX=MA88zC@qtGNBW#*(I6iX7JL^$ldA`4uH0n{^dcCsyz3|i8kHW0Pk!DC|c zw4c3oTn{TH<|lOGy_JCMF<7$sHf&>LALPZpUFxzoeogIklb3 z8em))INV)UqG{2KNoPziLn2}z*kz$4ls_F= zShoZKb{i^B#}29hHLBJ<1M1h0W1*RKU6eolvs{P*)MK=AD)b=ow*}P27Hx5FqJ3b!cBWQv=s{*yfq$YrRE!zf zVpbTgYOGw~%u5wLS=o?~vgzfLBi@D1ofHwv$S%;$6UAcU`#V2Q(%`utWgyzju>4RZ zN#shDg(6SjF47p@tNsQY%1!|C-F0r6Sb0rz6{jI}*e2t5`*_BRiXtvm)#YMvQ(axr z#a8ZR-ToUmbsap+kcGZv68rEI&Qm}!JFe|$xW8!{RxNY}H`dj4p)Y~OK#0TPSzS9_ z{zf|t-d{LCXf2^KMY-J%?9HjL>b{q#OQoK%z_PRy5)QGM=pN=ue!d!nzzdZ!BPWL@xkURTz$R?yr@Aa`x-^hwVoGaRntppZ-`u8-aaB z&hqhjq@ylHA=(54s7VGyZf3#}YRP^;$+UJ$-HwN_Gw!0Be6z>w&x)M9c0%fwe}wOyIu37n-hA#I6QH}AIFa}TgAyUHpvpH;G9*3%`tb-LflGO+U&*W`GarWncSRc)uU`VS(SxOr0;z5TAu08V^kr{(u znxCqW6z4C6CNpw<^f(|j7@A)QBJGT2A4byB_sq}s=zL8t6GGZ$;$QU47)?ESt&Oh- z-$`OsM@7VLhwq=B!mdY}17AO!dXoA=74ldDVQDL@(%0?tW7=YWu!N zawe4{Ep_RBb4-1&Yt3d8U7&f;4bcBn?dy?NK##jr41KJQ5X-3_q`bVIzC7E+! zI{NK9usPwjL`po6o)Mg}JLV=8t)C3IP@jJ>kDz;TR@_o40l;`cZK=<7aGgQ%Hqf{Uf%9SBspB$cuU>NPUfYJPV_ zYvrXUv~x(hy{nir7&4}j*>^84^g(xEZIIo=w#klUj%CdTXTx*hjA)1(+1RYM?YX!B z_U_MxRwJ47s@yFpD#}i;JT@gRrpnntucbj8re zTUY^s`z!r~7H)Jronv1Ht-B(B7DsO&OQIrMf}ZQAv>k#aXOD{|&T(>h41Ci6Qo~p^ zlHV!8rtb=NM#d#v!mjJdoZ#!^;qh<&;jrAjkMw)DS;+1#Tyv&(9B%v;kOWETXp9qU zFSoJJ3U@}4+Vl)hh_B0#i!RxZ1=a=k?Jc37-J&kG^cX@-^|Vq6VM6b?&BukNQykR0 z{R>`%Ag99zSCh+|_I@?)fr^;jrae}2?}tR^s};XzcOPkDT^zM_-}~S&_yn<`lYT5* zb+mS9Y41Y|q*%QAlHd>U^H++sK(!R1QU{#sL;6Htnr>vJD85!P_R3ZJKX~llHpde^ z?L8nc4|uDda&2fD_M4$Wt)C79&r+5FWkp}V=>9um_hX!M8pA>|jbPu^Zmwz(4 z+O0=CKcM?yU73C`a-qGEAAyJnjoL`K6EJiaEuraYJtl-H!W7oDso`dy&Hmg4muha4ePz|1%DR5$7f^$Tg!m#Z$akWRDEh)&`Fi1&h5 z_>c!7@lpEqa0{y5UK9(}85S^M+<;T-8&xmFifhG?#0Jk5KcIvT=}r!ajv`c-+GM2n zXgiyxye%g57Q0c|4(mmWu_UGE!o24~I;8a*y9Sj6pUh~xZW68uf#Y-3vX*dygsp{5 z&e|c5HROvKN*)^r%XR8rJ}-7l9T$1 zMH8mySsJhQHo(N~GqfB+)H2AR5W$rY#f9OdhF9%OIUdvV7r;&%>pS?~oYg}4+=m7l zd==sjZ_ZJqr8>~3o>?Yz$oMCV88b+(WElqxNe+KUz{q$n|_8HC;2v=Pgn@|JHJ&U*F4GMT7Gm z3L3^S1EJ?us1eC8Dw!&=X`VL^HP#hMa}Qz((YkNT4!z`@ttpP}op)JDwMPl|im^3~ zlyoUYE?4}o@~%6oschZHaU4`e9aK8%T$K_aAW92}I68t52@;Bs0D>qG0)$Y7fH)$c z(i8${2%sb(1PIa+YEXl~0Ma2;K}sl*-izQ1VtaFE-M8*PcipvK^3QL7XP<9>Is0Uv zeZCsyAV7|PY|WVRyZW2oZprtqOXPXsr*h{2XHJDtk!4Ssn^56raN{Fh)y;zhhqRzx^;UggMc`5C}6lIEaC|$#W2y;1qxLh(%B?BW&8|tP{AOs4^r)U^C z4IgjegIO-=^8IEw91&Ecl~L)rN4&{jr#|Gi4!7pSO;fo@W%|2ipuQ-m!4;iwD%bI_ zs8_JflComjw2E|q2V98_TZ+j5UKmIFp=>d><-!7*475!AbyaokHFU-be$X!HBY?5` z;>n9pUV$F*@{FIalpW*Mn8bw@KKIuhRfGT!kHgDC_RWNZ>CF@`U$8tGK~B_6Us{r| z-c9z=SMtlUU=S5tuf;Uu7Z;{7b2N7gnMW{Rh^11z3wP(wwbhwo;6++d>T{w4W{PLoozE2Ua+&yk(AS6~qP(v&22k-^^RcaPbRO3fB;dgP<_CMJ?;`mG!oi7W}! z=hecIyQYUal~^AbXy`-PD{<;d>3U+d-7>5!0+%Cx!51jwW+~fXk-6Ys1{!Lqnto7F zU!Nq2l%pZf2br3=Li{Wz+-aj@vD21jags*1g*PgMHMw5H>1IUhnsfuODJFUTVUpel zL8g2HuzY8;SnLT0j}R*${3?|0WO4%q?GlJ9uE$+#O=cMsx%Ac4s#8jQkK2@A!PfN( z#g4AsP0h&!vp~|4n4j{H`FRbus`7l5m5DzT(sW`atSi0bATWUlN5;^iT&e0NacEe?D01lqrFF`Ghqbz_I;Qrokf1@!>iF$K3bB0 z^?2Waf)9c-b>_$ZS2aG#1qqF3cetZnN_yiPt%Dv8Zr$^H&zGJyPG=koK!?I>24*RI zddhj#)r?-f;|xaMQT0qE1ccj`)<_t%j)c#rH^+iWOQ%UO)kBK}G2KwJ*UqCD3znK{ z`@u=1>=Q0;TfmVoI{Y>@`Xk_n3Up4fNX5m@a|6AeZJJ(isDglT1DK0;#cEAShwGXo zMK+0dBduEL5sn)3@6z=}CyZQ39|2Mxl^C1}QKpcNM^5QJwsZ_RX6L{s2N5rhl{|MK zT!KuuEGk_edD#|SH|sQMU!V2h+5?|97oshnul{iSHdf7vl=QUYEtk8e#Zi*<=-CkEL-G{c?FRtUh_r`Tdh5^sMH!?0E?I}lu!>h{(d9Si+NPesk4l77f!@k5sxb8kd*2u$$dy!gg zg?RbTeXkcm3m%MW)zu-$$k)j3x>nDz(&jKvYK5~=HFi|3)qUw)(|DjnemUrbxvdGv zZr8aLXlYuTXAkTh)zpMS6{hQv67(b=Nk0qKLddy}iYa>CZ_F;J7&9r%N*OC`NuWr} zpwg!C#rS$nFOP=T?l!9)#E#m6NYPvG>zOys5!81-!jZ*<5bg4d*z|1L%6;;<@%-hMR79~WZ)5sgytB2ORML(R@ z@*vqIRb9H4CFfzhyCQp18eC+2Wv>(Rj4lrTq2>s;pVX&m(;IO0a*h2wdLguNYOixoOOO%~WDxG`yxTqSw5$t~PgDR)Y21Imh$D zhHD+}C!4)nh3E>iXiDO8az))%um~r#2I_hoHeh3}|I&e-FRGMe)o&d@nQDDn5+j!RN^S*)hIm>4O`ba{4;2Z5xvpx({9*)xC^ot z+-Ie2Iq**D@ST>K{R@2C?#&A&--}N(Bb0qPh(#(+3I}+udxYJ7?ZjU#sF+9_wAjXeXb9TSJdpaHXXZg@~C*Vs%ONm$zUW2FL!THO{nKL+e!ax z;py+W6}lEY<49$xW%%T5*OP%l@7sg(ZXb46BPt_k3p){Z54~&&|Ip8)3gzBfsR6y> zBvoD-w%7bnJXPAFwSH*u_xaUw;U-OIw2+D0&32lG01;js;~tK$J{^O$_m#}1V=3l) z=o|cK@ilm3g=L#;>-$$MS>Oa(tB!I+p*-zfv08-9O@`Gx#Yt3r>M_o@;qMDBnW@4zf*YVae*y%B02bU(2E^>|&`bTMJ5p0=ya52Wu9G zeD)4MR;k>Pl0TtHBcgrec=0k9^iMXxGYJS}{`zEE#LBG?LSD6gYr0`bh~5c!A%)&L zg8*Jd75o1{JLI5J26V6HZ6pT#uH1GjI2fm7!)*-bsJqS7$@nW+L#W$;A4q==?;WoGt;oSTu3)~EdwNbEc)WW^UD@vU8T^T+H`)U93K+{tADWrv zV(0P`QHE;V8kV6VzXSnIso_r%`k7Ph&%hAoXlh))1|+28t%a3Q1fsCUh!JaEe7_2G zy&20!Y0Ju6A$7JpKAltw+wFgxvQyd`Tz}aw_kjgZ?IebX3?deu(F$s6eE*oGLZdrc%jwO>9^ts3g9XbO4C=H~6CQW?>104~Z$JAc z&0ps&+~hSztUZU$hI!h_M;lboGZ#F3Rr4I2ejUkOu1UJc%eEf3#H1|vLFE*~sY6k7 zU^KQX@Cu2hVk4pE-I_JQ(HY>CV~O)s#q&z5qnwaKEePYI;6I2jvt22Dfn|>EB1NUww z_k8%f9$?!F3!wTZJo1>A2r0cZ{HFbJptBw^qR?b~J3EpsiCkbxvQH)PH5Ng|za%GTB1=oF_jq`E^I%77FUt z+#KKb;x?bp=y2!T`4`0{yKVTBwh$k~CqQvBYg2u)g^l%`K? zE6)}MnGO7tzp@x58+)KHvk&p6gJ7DblhL3i>XDNE5WGw(zPR!tZ6JE=*zg8P7x2Fc zPu;dBKY);;5>_LD3ejiKT)w|{xPq2^l1&sAQyy4KBdtctdZ(X+5$O1y+-jj8isuLV zrG=TN@2RP&x@9foVUjgCa%&bFYfFK~BiELlF<^U+`i-ogs=6AM`bt)#P}+UBrnnAk zw^|DO;|j%(Y_+vAKLP^QUZidGgfG4n!ez(3>EzR!6dP~PE+#W{jrJ=UL*=1Qa}*o zrh10`5x`vUYsDfqmX-oxZnc5cq0CL8`j%d73KiJ9F~wECzHv!w;;)^J%B5#*r#96` z1?y9j8x#<~2rL?xK!}@o@{jW4i-l$~6_pX{OfFFLwwLeOzf;O>`Z)pkcF4Tbg&Iy8y_S)Gugx1MG3c&UxRh)R6hE3jAr#nDYwDqb|21>(t*z-%-cByC9=&8>SpN2`yRL6x;2Pfx4|DTzYL|H@4LoLIBoRjjt5I^N2Dso zCcv&Ov8Bzkgok%+1w}HWkEb^W*}`h}$GOEsg~`8B4_#|`dldM?)Gr?azH86SGZ$Ms zhqgA>7RwFOj;HJ4KtMF9UsQ^Mvm)1XfrZ7Tso-}CxE5C6RIY0N#J z1PcND%jS>Us;P(L4jE=U>5@Z@$JJko*6fmR9ryq_Q_jnj8as|zvHzSY)wr1%sr}hY z@BR_jhucE#h&9M37`Pa?pFAruKiO1JtJ{KzepmzJS=JkaPR0CbzTG=ExRE*Zzv$f>%Q zI6ZO5`_tiiTz;~GE)yxWBL#;|HY;Qhks_6Sye%TwEruG;gm-tN{bmU>i-ZFLy$k-@ z<%TYyLNa(tLxl1y$`PE^-0$t-qQ!W>v%vGp5tiGkpZ0{jZ2d$dWf!X~Yq<{O;*e+C z>|XwQHgKVwIXwLm>lGqyCt_>*kGn>NRQaD0bze4-HEPfS7hCYWtUR3e>I&Js$}*AB z`m9?1k}6x|IU0?rX)3rwYN2ntv}9Ja!HQX3k*1RIAm`MPJEvwpU`uTdGtcuATxV7w zKfk&`YRnc24-`uISEqdfawt@npc2EBY5;;b{wPzu z0_O!;X&vxxD@5th0kSN?TJVrz0N<|bi~1I4U>hHvclwM?PXB`45h)11woEK6w>1_D zIrKcVyVu<4*>eyQr2^ekY2aMVr0f-hazm2dd7GDs|IthhIHxe&8A?1)C-r`XPtf;VO?8$_@5;fbU_E$iVKfF)+xu!HzRaH68=>iUf_P3K zSVo>GGI^2ZjdSqT=U%R7%INI&UGTF5!%t0I)b z$y~hNWx+|tLMzIQ5cj?Z@!cM!RutGrSig{WDO;WfxRlj z;jf2_X(052!RKaJ0|R1n(a#R=O1m%=6Rd+z9?x77=^jhmIdiY4#UoZW_Q<@i?y97k zNGWL!$54wNo;~I~kN$b7&M2J%x&cmKIG7H+fydGFqB{zr=RNp62t>3xxCq5HBY4fl zdMn$~)|zCzsg`XI(^?UpDfJ84r%#xq8ul5- z>I$k$Mb|3lMb}OA4rZ(0&zCktZkwi2nV1~XY0EH1r4X1E!95{#bKYUQ(@d#zeMT~xAKkFcnw|-3b zo0h&m-mjWM7JxKh745vjcC4^h6%R| zWtvTsyT_s-Sus&e%C5o@24c~5#PBtutTMf&j77Y`OF1fxb26&|137|1_&QRV@Ka7) zM8A8`1ELv_O3AP$d4%`6M^^g_Epkc%!Pn;aa!b7@X&R4WOK|i8jNsJO(_QUkf^*nuXiazq zmd7VmO&!sW!F2=@Z#pAu4mxUJFx9+JdrDGjs;elsAYKD#?ifr4w#eCC%^mVX5dNdJIeTA0I3o z@i7EFvRKg{`a#O}q$|v2!udAfae)v4Vc?!(LLz4-!1|Jb_kQ>SWn38DU7b8my9`qC zE-FP&dF5KV(b(%-y#LvY_sb6ZQv$=PZ*ed@GxT@z{QZyk7V+^-@?XBeujp%N!&k~? z{fF;AJP(E2dvd?0{PVT^g%_yvMZn8HMr}S$zMXLP8~lnk15f_)`SmaD&F0_D#Xr$L GcK#32rN;9B literal 0 HcmV?d00001 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..0843753 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +pymavlink>=2.4.49,<3 +pyserial>=3.5,<4 +customtkinter==5.2.2 +pywebview==6.2.1 diff --git a/run.ps1 b/run.ps1 new file mode 100644 index 0000000..104e4da --- /dev/null +++ b/run.ps1 @@ -0,0 +1,15 @@ +$ErrorActionPreference = "Stop" + +$projectRoot = Split-Path -Parent $MyInvocation.MyCommand.Path +Set-Location -LiteralPath $projectRoot + +if (-not (Get-Command py -ErrorAction SilentlyContinue)) { + throw "Python 3.11 or newer is required. Install Python from python.org, then run this script again." +} + +if (-not (Test-Path -LiteralPath ".venv\Scripts\python.exe")) { + & py -3 -m venv .venv +} + +& .venv\Scripts\python.exe -m pip install --disable-pip-version-check -r requirements.txt +& .venv\Scripts\python.exe app.py diff --git a/web/app.js b/web/app.js new file mode 100644 index 0000000..7178b94 --- /dev/null +++ b/web/app.js @@ -0,0 +1,46 @@ +const $=s=>document.querySelector(s), $$=s=>[...document.querySelectorAll(s)]; +const control={forward:0,turn:0,vertical:0}, keys=new Set(); let bridgeReady=false, connected=false, batteryConfig={}, servoSignature='', lastAxisSend=0, axisTimer=0; +const api=(name,...args)=>window.pywebview.api[name](...args); +function toast(message,error=false){const el=$('#toast');el.textContent=message;el.className=`toast show${error?' error':''}`;clearTimeout(toast.t);toast.t=setTimeout(()=>el.className='toast',2600)} +window.showToast=toast; +function populatePorts(ports){const p=$('#port'),old=p.value;p.innerHTML='';if(!ports.length)p.add(new Option('No COM ports',''));ports.forEach(x=>p.add(new Option(x,x)));if(ports.includes(old))p.value=old} +function sendAxes(){if(!bridgeReady)return;const transmit=()=>{lastAxisSend=performance.now();axisTimer=0;api('set_axes',control.forward,control.turn,control.vertical).catch(e=>toast(e.message,true))},wait=50-(performance.now()-lastAxisSend);if(wait<=0)transmit();else{clearTimeout(axisTimer);axisTimer=setTimeout(transmit,wait)}} +function updateReadouts(){ $('#surge-readout').textContent=Math.round(control.forward*100);$('#yaw-readout').textContent=Math.round(control.turn*100);$('#heave-readout').textContent=Math.round(control.vertical*100)} + +const joy=$('#joystick'),stick=$('#stick');let joyDrag=false,returnAnim=0; +function setJoy(x,y,send=true){const r=joy.clientWidth*.34,n=Math.hypot(x,y);if(n>1){x/=n;y/=n}control.turn=x;control.forward=-y;stick.style.transform=`translate(calc(-50% + ${x*r}px),calc(-50% + ${y*r}px))`;updateReadouts();if(send)sendAxes();window.rovModel?.setLiveControl?.(control.turn,control.forward,control.vertical)} +function joyEvent(e){const b=joy.getBoundingClientRect();setJoy((e.clientX-b.left-b.width/2)/(b.width*.34),(e.clientY-b.top-b.height/2)/(b.height*.34))} +joy.addEventListener('pointerdown',e=>{joyDrag=true;cancelAnimationFrame(returnAnim);joy.setPointerCapture(e.pointerId);joyEvent(e)});joy.addEventListener('pointermove',e=>{if(joyDrag)joyEvent(e)});joy.addEventListener('pointerup',()=>{joyDrag=false;easeJoy()}); +function easeJoy(){const sx=control.turn,sy=-control.forward,start=performance.now();function frame(now){const p=Math.min(1,(now-start)/400),smooth=p*p*(3-2*p);setJoy(sx*(1-smooth),sy*(1-smooth));if(p<1)returnAnim=requestAnimationFrame(frame)}returnAnim=requestAnimationFrame(frame)} + +const heave=$('#heave'),hknob=$('#heave-knob'),hfill=$('#heave-fill');let heaveDrag=false,heaveAnim=0; +function setHeave(v,send=true){v=Math.max(-1,Math.min(1,v));control.vertical=v;hknob.style.top=`${50-v*43}%`;hfill.style.top=v>=0?`${50-v*43}%`:'50%';hfill.style.height=`${Math.abs(v)*43}%`;updateReadouts();if(send)sendAxes();window.rovModel?.setLiveControl?.(control.turn,control.forward,control.vertical)} + +function heaveEvent(e){const b=heave.getBoundingClientRect();setHeave((b.top+b.height/2-e.clientY)/(b.height*.43))} +heave.addEventListener('pointerdown',e=>{heaveDrag=true;cancelAnimationFrame(heaveAnim);heave.setPointerCapture(e.pointerId);heaveEvent(e)});heave.addEventListener('pointermove',e=>{if(heaveDrag)heaveEvent(e)});heave.addEventListener('pointerup',()=>{heaveDrag=false;easeHeave()}); +function easeHeave(){const sv=control.vertical,start=performance.now();function frame(now){const p=Math.min(1,(now-start)/400),smooth=p*p*(3-2*p);setHeave(sv*(1-smooth));if(p<1)heaveAnim=requestAnimationFrame(frame)}heaveAnim=requestAnimationFrame(frame)} + +function applyKeys(){const turn=(keys.has('d')||keys.has('e')?1:0)-(keys.has('a')||keys.has('q')?1:0),forward=(keys.has('w')?1:0)-(keys.has('s')?1:0),vertical=(keys.has('r')?1:0)-(keys.has('f')?1:0);if(turn||forward){cancelAnimationFrame(returnAnim);setJoy(turn,-forward)}else if(!joyDrag)easeJoy();if(vertical){cancelAnimationFrame(heaveAnim);setHeave(vertical)}else if(!heaveDrag)easeHeave()} +addEventListener('keydown',e=>{if(/^(INPUT|SELECT)$/.test(e.target.tagName))return;const k=e.key.toLowerCase();if('wasdqerf'.includes(k)&&!keys.has(k)){keys.add(k);applyKeys()}});addEventListener('keyup',e=>{const k=e.key.toLowerCase();if(keys.delete(k))applyKeys()}); +addEventListener('blur',()=>{keys.clear();if(!joyDrag)easeJoy();if(!heaveDrag)easeHeave()}); + +function openModal(id){document.getElementById(id)?.showModal()}$$('[data-open]').forEach(b=>b.onclick=()=>openModal(b.dataset.open));$$('[data-close]').forEach(b=>b.onclick=()=>b.closest('dialog').close()); +$('#window-min').onclick=()=>api('minimize_window');$('#window-max').onclick=()=>api('toggle_maximize_window');$('#window-close').onclick=()=>api('close_window');$('.window-drag').ondblclick=()=>api('toggle_maximize_window'); +const modelInput=$('#model-input'),modelButton=$('#load-model'); +modelButton.onclick=()=>{modelInput.value='';modelInput.click()}; +function bytesToBase64(bytes){let value='';for(let i=0;i{const file=modelInput.files?.[0],old=modelButton.textContent;if(!file)return;try{if(!file.name.toLowerCase().endsWith('.stl'))throw new Error('Choose an STL mesh');if(file.size>30*1024*1024)throw new Error('The STL is larger than 30 MB; export a lighter mesh');modelButton.disabled=true;modelButton.textContent='Loading…';await new Promise(requestAnimationFrame);const buffer=await file.arrayBuffer(),result=window.rovModel.loadBuffer(buffer,file.name);modelButton.textContent='Replace STL';toast(`Loaded ${result.name} • ${result.triangles.toLocaleString()} triangles`);if(bridgeReady)cacheModel(file).then(()=>toast('STL saved for the next launch')).catch(error=>toast(`Model loaded, but could not be saved: ${error.message}`,true))}catch(error){toast(`STL import failed: ${error.message}`,true)}finally{modelButton.disabled=false;if(modelButton.textContent==='Loading…')modelButton.textContent=old}}; +$('#refresh').onclick=async()=>populatePorts(await api('refresh_ports'));$('#connect').onclick=async()=>{try{if(connected)await api('disconnect');else await api('connect',$('#port').value,+$('#baud').value,+$('#rate').value)}catch(e){toast(e.message,true)}}; +$('#manual').onclick=()=>api('command','manual').catch(e=>toast(e.message,true));$('#arm').onclick=()=>openModal('arm-dialog');$('#confirm-arm').onclick=()=>{api('command','arm').catch(e=>toast(e.message,true));$('#arm-dialog').close()};$('#disarm').onclick=()=>api('command','disarm').catch(e=>toast(e.message,true));$('#logs').onclick=()=>api('open_logs'); +$('#gain').oninput=e=>{$('#gain-value').textContent=`${e.target.value}%`;api('set_gain',+e.target.value)}; +$('#battery-form').onsubmit=async e=>{e.preventDefault();const data=Object.fromEntries(new FormData(e.target));try{batteryConfig=await api('save_battery',data);$('#battery-modal').close();toast('Battery settings saved')}catch(err){toast(err.message,true)}}; +function fillBatteryForm(){for(const [k,v] of Object.entries(batteryConfig)){const el=$(`#battery-form [name=${k}]`);if(el)el.value=v}} +function renderMotors(servos){const signature=JSON.stringify(servos);if(signature===servoSignature)return;servoSignature=signature;const list=$('#motor-list');list.innerHTML='';for(let i=1;i<=16;i++){const s=servos[String(i)]||{},row=document.createElement('div');row.className='motor-row';const opts=['',...Array.from({length:12},(_,n)=>``)].join('');row.innerHTML=`SERVO${i}${s.direction==null?'—':s.direction===-1?'Reversed':'Normal'}`;row.querySelector('select').onchange=e=>api('set_servo_function',i,+e.target.value).catch(x=>toast(x.message,true));row.querySelector('button').onclick=()=>api('reverse_motor',i).catch(x=>toast(x.message,true));list.append(row)}} +function render(s){connected=s.connected;$('#connect').textContent=connected?'Disconnect':'Connect';$('#link').textContent=s.link;$('.link-state').classList.toggle('online',s.link==='LINK OK');$('#vehicle').textContent=s.vehicle;$('#status').textContent=s.status;const b=s.battery;if(b){$('#battery-title').textContent=`${b.voltage.toFixed(2)} V`;$('#battery-percent').textContent=`${Math.round(b.percent)}% • ${b.cells}S`;$('#battery-fill').style.width=`${b.percent}%`;$('#battery-fill').style.background=b.low?'var(--red)':'var(--acid)'}else{$('#battery-title').textContent='—';$('#battery-percent').textContent='Waiting';$('#battery-fill').style.width='0'}renderMotors(s.servos)} +async function poll(){if(!bridgeReady)return;try{render(await api('poll'))}catch(e){}setTimeout(poll,250)} +let booted=false; +async function boot(){if(booted||!window.pywebview?.api)return;booted=true;bridgeReady=true;try{const init=await api('initial_state');batteryConfig=init.battery_config;populatePorts(init.ports);fillBatteryForm();render(init.state);const saved=await api('get_saved_model_info');if(saved){try{$('#load-model').textContent='Loading saved STL…';await loadCachedModel(saved);$('#load-model').textContent='Replace STL'}catch(error){$('#load-model').textContent='Load STL';toast(`Saved STL failed: ${error.message}`,true)}}poll();setInterval(async()=>{if(!connected)try{populatePorts(await api('refresh_ports'))}catch(_){}},2000)}catch(e){booted=false;bridgeReady=false;toast(`Python bridge: ${e.message}`,true);setTimeout(waitForBridge,500)}} +function waitForBridge(){if(window.pywebview?.api)boot();else setTimeout(waitForBridge,50)} +addEventListener('pywebviewready',boot);if(document.readyState==='loading')addEventListener('DOMContentLoaded',waitForBridge);else waitForBridge(); diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..c7a86fa --- /dev/null +++ b/web/index.html @@ -0,0 +1,119 @@ + + + + + + SUB Control + + + +
+
SSUB ControlBuilt-in vehicle
+
+ + + +
+
+
+ + +
+
+
PILOT CONSOLE

Drive

+
+ + + + +
+
+ +
+
+
STEER + THROTTLEDifferential drive
W A S D
+
+
+ FORWARDREVERSE + TURN LEFTTURN RIGHT +
+
+
0% surge0% turn
+
+ +
+
VERTICALCenter thruster
R / F
+
+ RISE +
+ DIVE +
+
0% vertical
+
+
+ +
+
+ + + +
+ + + +
+

Ready. Select a USB port to begin.

+
+
+ +
+ + SAFETY CHECK

Arm the vehicle?

Remove propellers or securely restrain the ROV before enabling motor output.

+ CONNECTION

Link settings

20 Hz is recommended for smooth intermediate commands on this link.

+ POWER

Battery settings

+ CONFIGURATION

Motor outputs

Function and direction are read one parameter at a time to protect the low-bandwidth link.

OutputFunctionDirection
+ + + + + diff --git a/web/model-viewer.js b/web/model-viewer.js new file mode 100644 index 0000000..8636b7e --- /dev/null +++ b/web/model-viewer.js @@ -0,0 +1,518 @@ +(() => { + const canvas = document.querySelector('#model-canvas'); + const stage = document.querySelector('.rov-stage'); + const renderScale = Math.max(2.0, window.devicePixelRatio || 2.0); + const gl = canvas.getContext('webgl', { alpha: true, antialias: true, preserveDrawingBuffer: false }); + if (!gl) { window.rovModel = { loadBuffer() { throw new Error('WebGL is unavailable in this WebView'); } }; return; } + + // Pass 1: Geometry shader - renders view-space normals and depth to FBO (Hero-sized 0.88 dynamic projection) + const vertexSceneSource = ` + attribute vec3 aPosition; + attribute vec3 aNormal; + uniform float uYaw, uPitch, uZoom, uAspect; + varying vec3 vNormal; + varying vec3 vViewPos; + void main() { + float cy = cos(uYaw), sy = sin(uYaw); + vec3 p = vec3(aPosition.x * cy + aPosition.z * sy, aPosition.y, -aPosition.x * sy + aPosition.z * cy); + vec3 n = vec3(aNormal.x * cy + aNormal.z * sy, aNormal.y, -aNormal.x * sy + aNormal.z * cy); + float cp = cos(uPitch), sp = sin(uPitch); + p = vec3(p.x, p.y * cp - p.z * sp, p.y * sp + p.z * cp); + n = vec3(n.x, n.y * cp - n.z * sp, n.y * sp + n.z * cp); + + // Base scale (1.15 fills 95-100% of canvas frame without over-zooming out) + float baseScale = 1.15 * uZoom; + + // Dynamic pixel-perfect aspect projection: fills window canvas without distortion or shrinking + vec2 clipPos; + if (uAspect >= 1.0) { + clipPos = vec2(p.x * baseScale / uAspect, p.y * baseScale); + } else { + clipPos = vec2(p.x * baseScale, p.y * baseScale * uAspect); + } + + gl_Position = vec4(clipPos.x, clipPos.y, p.z * baseScale * .4, 1.); + vViewPos = vec3(clipPos.x, clipPos.y, p.z * baseScale); + vNormal = normalize(n); + } + `; + + const fragmentSceneSource = ` + precision mediump float; + varying vec3 vNormal; + varying vec3 vViewPos; + void main() { + vec3 encNormal = normalize(vNormal) * .5 + .5; + float encDepth = clamp(vViewPos.z * .4 + .5, .01, .99); + gl_FragColor = vec4(encNormal, encDepth); + } + `; + + // Pass 2: Quad shader - Vibrant Soft Cyan-Slate Material + Water Caustics + Argo Glare (Non-grey) + const vertexQuadSource = ` + attribute vec2 aQuadPos; + varying vec2 vTexCoord; + void main() { + vTexCoord = aQuadPos * .5 + .5; + gl_Position = vec4(aQuadPos, 0., 1.); + } + `; + + const fragmentQuadSource = ` + precision mediump float; + uniform sampler2D uTex; + uniform vec2 uTexSize; + uniform float uTime; + varying vec2 vTexCoord; + + // Animated water caustics shader + float waterCaustics(vec2 uv, float t) { + vec2 p = uv * 8.0; + vec2 i = vec2(p); + float c = 1.0; + float intent = 0.14; + for (int n = 0; n < 4; n++) { + float t_ang = t * (0.8 - (2.2 / float(n + 1))); + vec2 coord = i + vec2(cos(t_ang - i.x) + sin(t_ang + i.y), sin(t_ang - i.y) + cos(t_ang + i.x)); + c += 1.0 / length(vec2(i.x / (sin(coord.x + t_ang) / intent), i.y / (cos(coord.y + t_ang) / intent))); + i = coord; + } + c /= 4.0; + c = 1.15 - sqrt(c); + return clamp(pow(max(0.0, c), 2.2) * 1.4, 0.0, 1.4); + } + + void main() { + vec2 texel = 1. / uTexSize; + + // Subpixel 4-sample Anti-Aliasing for smooth geometry boundaries + vec4 s0 = texture2D(uTex, vTexCoord + vec2(-0.25, -0.25) * texel); + vec4 s1 = texture2D(uTex, vTexCoord + vec2( 0.25, -0.25) * texel); + vec4 s2 = texture2D(uTex, vTexCoord + vec2(-0.25, 0.25) * texel); + vec4 s3 = texture2D(uTex, vTexCoord + vec2( 0.25, 0.25) * texel); + vec4 center = (s0 + s1 + s2 + s3) * 0.25; + + float alphaCoverage = clamp((s0.a > .001 ? .25 : 0.) + (s1.a > .001 ? .25 : 0.) + (s2.a > .001 ? .25 : 0.) + (s3.a > .001 ? .25 : 0.), 0., 1.); + if (alphaCoverage <= .001) { + gl_FragColor = vec4(0., 0., 0., 0.); + return; + } + + vec3 N = normalize(center.rgb * 2. - 1.); + float centerDepth = center.a; + + // 1. Light Ambient Occlusion + float occlusion = 0.; + vec2 offsets[8]; + offsets[0] = vec2(1.5, 0.); offsets[1] = vec2(-1.5, 0.); + offsets[2] = vec2(0., 1.5); offsets[3] = vec2(0., -1.5); + offsets[4] = vec2(1.1, 1.1); offsets[5] = vec2(-1.1, 1.1); + offsets[6] = vec2(1.1, -1.1); offsets[7] = vec2(-1.1, -1.1); + + for (int i = 0; i < 8; i++) { + vec4 sampleVal = texture2D(uTex, vTexCoord + offsets[i] * texel); + if (sampleVal.a > .001) { + float dDiff = sampleVal.a - centerDepth; + if (dDiff < -.0005 && dDiff > -.12) { + occlusion += clamp(-dDiff * 14.0, 0., 1.); + } + } + } + float ao = 1. - clamp(occlusion / 8.0 * 0.36, 0., .20); + + // 2. Soft Luminous Cyan-Slate Material Shading (Vibrant, non-grey) + vec3 lightDir = normalize(vec3(.35, .85, .45)); + float diff = max(dot(N, lightDir), 0.0); + vec3 viewDir = vec3(0., 0., 1.); + vec3 halfDir = normalize(lightDir + viewDir); + float spec = pow(max(dot(N, halfDir), 0.0), 28.0); + + // Argo crisp luminous glare + vec3 argoGlare = vec3(0.98, 0.96, 0.92) * spec * 0.95; + + // Soft rim light + float fresnel = pow(1. - max(dot(N, viewDir), 0.), 2.0); + vec3 rimLight = vec3(0.65, 0.82, 0.95) * fresnel * 0.35; + + // Soft vibrant cyan-slate gradient (non-grey, soft luminous depth) + vec3 baseShadow = vec3(.22, .44, .58); + vec3 baseBody = vec3(.38, .62, .78); + vec3 baseTop = vec3(.88, .92, .96); + + float lFactor = clamp(diff * 0.7 + (N.y * 0.3 + 0.3), 0.0, 1.0); + vec3 bodyColor = mix(baseShadow, baseBody, smoothstep(0.0, 0.5, lFactor)); + bodyColor = mix(bodyColor, baseTop, smoothstep(0.5, 1.0, lFactor)); + + // 3. Luminous Water Caustics on top/front facing surfaces + float topFacing = clamp(N.y * 0.7 + 0.45, 0.0, 1.0); + float causticsVal = waterCaustics(vTexCoord + vec2(sin(uTime * 0.2) * 0.04, uTime * 0.025), uTime * 0.75) * topFacing; + vec3 causticColor = vec3(0.30, 0.85, 1.0) * causticsVal * 1.1; + + // Combine soft diffuse, vibrant cyan-slate body, rim light, glare, caustics, and AO + vec3 rawColor = (bodyColor + rimLight + argoGlare + causticColor) * ao; + + // Soft filmic highlight compression (preserves vibrant colors without white crushing) + vec3 finalColor = rawColor / (rawColor + vec3(0.18)) * 1.08; + + gl_FragColor = vec4(finalColor, alphaCoverage); + } + `; + + function compileShader(type, source) { + const s = gl.createShader(type); + gl.shaderSource(s, source); + gl.compileShader(s); + if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(s)); + return s; + } + + function createProgram(vSrc, fSrc) { + const p = gl.createProgram(); + gl.attachShader(p, compileShader(gl.VERTEX_SHADER, vSrc)); + gl.attachShader(p, compileShader(gl.FRAGMENT_SHADER, fSrc)); + gl.linkProgram(p); + if (!gl.getProgramParameter(p, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(p)); + return p; + } + + const programScene = createProgram(vertexSceneSource, fragmentSceneSource); + const programQuad = createProgram(vertexQuadSource, fragmentQuadSource); + + const locScene = { + aPosition: gl.getAttribLocation(programScene, 'aPosition'), + aNormal: gl.getAttribLocation(programScene, 'aNormal'), + uYaw: gl.getUniformLocation(programScene, 'uYaw'), + uPitch: gl.getUniformLocation(programScene, 'uPitch'), + uZoom: gl.getUniformLocation(programScene, 'uZoom'), + uAspect: gl.getUniformLocation(programScene, 'uAspect') + }; + + const locQuad = { + aQuadPos: gl.getAttribLocation(programQuad, 'aQuadPos'), + uTex: gl.getUniformLocation(programQuad, 'uTex'), + uTexSize: gl.getUniformLocation(programQuad, 'uTexSize'), + uTime: gl.getUniformLocation(programQuad, 'uTime') + }; + + const bufferScene = gl.createBuffer(); + const bufferQuad = gl.createBuffer(); + gl.bindBuffer(gl.ARRAY_BUFFER, bufferQuad); + gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]), gl.STATIC_DRAW); + + let fbo = null, fboTexture = null, fboDepth = null, fboWidth = 0, fboHeight = 0; + function updateFbo(w, h) { + if (fboWidth === w && fboHeight === h) return; + fboWidth = w; fboHeight = h; + if (fbo) { gl.deleteFramebuffer(fbo); gl.deleteTexture(fboTexture); gl.deleteRenderbuffer(fboDepth); } + fboTexture = gl.createTexture(); + gl.bindTexture(gl.TEXTURE_2D, fboTexture); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, w, h, 0, gl.RGBA, gl.UNSIGNED_BYTE, null); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + + fboDepth = gl.createRenderbuffer(); + gl.bindRenderbuffer(gl.RENDERBUFFER, fboDepth); + gl.renderbufferStorage(gl.RENDERBUFFER, gl.DEPTH_COMPONENT16, w, h); + + fbo = gl.createFramebuffer(); + gl.bindFramebuffer(gl.FRAMEBUFFER, fbo); + gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, fboTexture, 0); + gl.framebufferRenderbuffer(gl.FRAMEBUFFER, gl.DEPTH_ATTACHMENT, gl.RENDERBUFFER, fboDepth); + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + } + + let vertexCount = 0, frame = 0; + let yaw = 0, pitch = 0, zoom = 1; + let targetYaw = 0.68, targetPitch = 0.42, targetZoom = 1.0; + let yawVelocity = 0, pitchVelocity = 0; + let dragging = false, lastX = 0, lastY = 0; + let introAnimating = false, introStartTime = 0, startYaw = 0, startPitch = 0, startZoom = 1; + + // Live joystick & vertical knob control inputs + let liveTurn = 0, liveForward = 0, liveVertical = 0; + let targetLiveTurn = 0, targetLiveForward = 0, targetLiveVertical = 0; + + const timeStart = performance.now(); + + function normalize(data) { + let minX = Infinity, minY = Infinity, minZ = Infinity, maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity; + for (let i = 0; i < data.length; i += 6) { + const x = data[i], y = data[i + 1], z = data[i + 2]; + minX = Math.min(minX, x); maxX = Math.max(maxX, x); + minY = Math.min(minY, y); maxY = Math.max(maxY, y); + minZ = Math.min(minZ, z); maxZ = Math.max(maxZ, z); + } + const cx = (minX + maxX) / 2, cy = (minY + maxY) / 2, cz = (minZ + maxZ) / 2; + const scale = 2 / Math.max(maxX - minX, maxY - minY, maxZ - minZ, 1e-9); + for (let i = 0; i < data.length; i += 6) { + data[i] = (data[i] - cx) * scale; + data[i + 1] = (data[i + 1] - cy) * scale; + data[i + 2] = (data[i + 2] - cz) * scale; + } + return data; + } + + // Smooth vertex normal computation (welds shared vertices to eliminate sharp polygon facet lines) + function parseStl(binary) { + const view = new DataView(binary), count = binary.byteLength >= 84 ? view.getUint32(80, true) : 0; + let rawTriangles = []; + + if (count > 0 && 84 + count * 50 <= binary.byteLength) { + for (let t = 0; t < count; t++) { + const base = 84 + t * 50; + const v0x = view.getFloat32(base + 12, true), v0y = view.getFloat32(base + 16, true), v0z = view.getFloat32(base + 20, true); + const v1x = view.getFloat32(base + 24, true), v1y = view.getFloat32(base + 28, true), v1z = view.getFloat32(base + 32, true); + const v2x = view.getFloat32(base + 36, true), v2y = view.getFloat32(base + 40, true), v2z = view.getFloat32(base + 44, true); + rawTriangles.push(v0x, v0y, v0z, v1x, v1y, v1z, v2x, v2y, v2z); + } + } else { + const text = new TextDecoder().decode(binary), matches = [...text.matchAll(/vertex\s+([-+\deE.]+)\s+([-+\deE.]+)\s+([-+\deE.]+)/gi)]; + const triCount = Math.floor(matches.length / 3); + if (triCount < 1) throw new Error('This file does not contain a readable STL mesh'); + for (let i = 0; i + 2 < matches.length; i += 3) { + rawTriangles.push( + +matches[i][1], +matches[i][2], +matches[i][3], + +matches[i+1][1], +matches[i+1][2], +matches[i+1][3], + +matches[i+2][1], +matches[i+2][2], +matches[i+2][3] + ); + } + } + + const numTriangles = Math.floor(rawTriangles.length / 9); + const posMap = new Map(); + const getVertexIndex = (x, y, z) => { + const key = `${Math.round(x * 10000)},${Math.round(y * 10000)},${Math.round(z * 10000)}`; + let idx = posMap.get(key); + if (idx === undefined) { + idx = posMap.size; + posMap.set(key, idx); + } + return idx; + }; + + // First pass: Index vertices and accumulate smooth face normals + const triangleIndices = new Int32Array(numTriangles * 3); + for (let t = 0; t < numTriangles; t++) { + const b = t * 9; + triangleIndices[t * 3] = getVertexIndex(rawTriangles[b], rawTriangles[b+1], rawTriangles[b+2]); + triangleIndices[t * 3 + 1] = getVertexIndex(rawTriangles[b+3], rawTriangles[b+4], rawTriangles[b+5]); + triangleIndices[t * 3 + 2] = getVertexIndex(rawTriangles[b+6], rawTriangles[b+7], rawTriangles[b+8]); + } + + const numUnique = posMap.size; + const accumNormals = new Float32Array(numUnique * 3); + for (let t = 0; t < numTriangles; t++) { + const b = t * 9; + const v0x = rawTriangles[b], v0y = rawTriangles[b+1], v0z = rawTriangles[b+2]; + const v1x = rawTriangles[b+3], v1y = rawTriangles[b+4], v1z = rawTriangles[b+5]; + const v2x = rawTriangles[b+6], v2y = rawTriangles[b+7], v2z = rawTriangles[b+8]; + + const ax = v1x - v0x, ay = v1y - v0y, az = v1z - v0z; + const bx = v2x - v0x, by = v2y - v0y, bz = v2z - v0z; + const nx = ay * bz - az * by, ny = az * bx - ax * bz, nz = ax * by - ay * bx; + + const i0 = triangleIndices[t * 3], i1 = triangleIndices[t * 3 + 1], i2 = triangleIndices[t * 3 + 2]; + accumNormals[i0 * 3] += nx; accumNormals[i0 * 3 + 1] += ny; accumNormals[i0 * 3 + 2] += nz; + accumNormals[i1 * 3] += nx; accumNormals[i1 * 3 + 1] += ny; accumNormals[i1 * 3 + 2] += nz; + accumNormals[i2 * 3] += nx; accumNormals[i2 * 3 + 1] += ny; accumNormals[i2 * 3 + 2] += nz; + } + + // Second pass: Normalize smooth vertex normals + for (let i = 0; i < numUnique; i++) { + const b = i * 3; + const len = Math.hypot(accumNormals[b], accumNormals[b+1], accumNormals[b+2]) || 1; + accumNormals[b] /= len; accumNormals[b+1] /= len; accumNormals[b+2] /= len; + } + + // Third pass: Assemble final vertex array (6 floats per vertex) + const data = new Float32Array(numTriangles * 18); + let out = 0; + for (let t = 0; t < numTriangles; t++) { + const b = t * 9; + for (let v = 0; v < 3; v++) { + const vx = rawTriangles[b + v * 3], vy = rawTriangles[b + v * 3 + 1], vz = rawTriangles[b + v * 3 + 2]; + const idx = triangleIndices[t * 3 + v]; + data[out++] = vx; data[out++] = vy; data[out++] = vz; + data[out++] = accumNormals[idx * 3]; + data[out++] = accumNormals[idx * 3 + 1]; + data[out++] = accumNormals[idx * 3 + 2]; + } + } + + return normalize(data); + } + + function draw() { + frame = 0; + if (!vertexCount) return; + const rect = canvas.getBoundingClientRect(); + const width = Math.max(1, Math.round(rect.width * renderScale)); + const height = Math.max(1, Math.round(rect.height * renderScale)); + if (canvas.width !== width || canvas.height !== height) { + canvas.width = width; + canvas.height = height; + } + updateFbo(width, height); + + const now = performance.now(); + const currentTime = (now - timeStart) * 0.001; + + // Interpolate live control inputs (turn, forward, vertical) + liveTurn += (targetLiveTurn - liveTurn) * 0.15; + liveForward += (targetLiveForward - liveForward) * 0.15; + liveVertical += (targetLiveVertical - liveVertical) * 0.15; + + // Handle auto-center 3/4 hero camera transition & floaty physics + if (introAnimating && !dragging) { + const elapsed = (now - introStartTime) / 1200; + if (elapsed >= 1.0) { + introAnimating = false; + yaw = targetYaw; pitch = targetPitch; zoom = targetZoom; + } else { + const t = 1 - Math.pow(1 - elapsed, 3); + yaw = startYaw + (targetYaw - startYaw) * t; + pitch = startPitch + (targetPitch - startPitch) * t; + zoom = startZoom + (targetZoom - startZoom) * t; + } + } else { + yaw += yawVelocity; + pitch = Math.max(-1.45, Math.min(1.45, pitch + pitchVelocity)); + zoom += (targetZoom - zoom) * 0.1; + yawVelocity *= 0.92; + pitchVelocity *= 0.92; + } + + // Effective yaw & pitch including live joystick/heave control offsets + const effectiveYaw = yaw + liveTurn * 0.7; + const effectivePitch = Math.max(-1.45, Math.min(1.45, pitch + (liveVertical * 0.65 + liveForward * 0.4))); + + // Pass 1: Render Normals & Linear Depth to FBO + gl.bindFramebuffer(gl.FRAMEBUFFER, fbo); + gl.viewport(0, 0, width, height); + gl.clearColor(0, 0, 0, 0); + gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); + gl.enable(gl.DEPTH_TEST); + gl.depthFunc(gl.LEQUAL); + + gl.useProgram(programScene); + gl.bindBuffer(gl.ARRAY_BUFFER, bufferScene); + gl.enableVertexAttribArray(locScene.aPosition); + gl.vertexAttribPointer(locScene.aPosition, 3, gl.FLOAT, false, 24, 0); + gl.enableVertexAttribArray(locScene.aNormal); + gl.vertexAttribPointer(locScene.aNormal, 3, gl.FLOAT, false, 24, 12); + + gl.uniform1f(locScene.uYaw, effectiveYaw); + gl.uniform1f(locScene.uPitch, effectivePitch); + gl.uniform1f(locScene.uZoom, zoom); + gl.uniform1f(locScene.uAspect, width / height); + + gl.drawArrays(gl.TRIANGLES, 0, vertexCount); + + // Pass 2: Soft "Eve"-style Material Pass to Canvas + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + gl.viewport(0, 0, width, height); + gl.clearColor(0, 0, 0, 0); + gl.clear(gl.COLOR_BUFFER_BIT); + gl.disable(gl.DEPTH_TEST); + + gl.useProgram(programQuad); + gl.bindBuffer(gl.ARRAY_BUFFER, bufferQuad); + gl.enableVertexAttribArray(locQuad.aQuadPos); + gl.vertexAttribPointer(locQuad.aQuadPos, 2, gl.FLOAT, false, 0, 0); + + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, fboTexture); + gl.uniform1i(locQuad.uTex, 0); + gl.uniform2f(locQuad.uTexSize, width, height); + gl.uniform1f(locQuad.uTime, currentTime); + + gl.drawArrays(gl.TRIANGLES, 0, 6); + + // Keep animation active if floaty velocity or control interpolation is active + if (Math.abs(yawVelocity) > 1e-4 || Math.abs(pitchVelocity) > 1e-4 || + Math.abs(targetLiveTurn - liveTurn) > 1e-3 || Math.abs(targetLiveVertical - liveVertical) > 1e-3 || + introAnimating) { + requestDraw(); + } + } + + function requestDraw() { + if (!frame) frame = requestAnimationFrame(draw); + } + + canvas.addEventListener('pointerdown', event => { + dragging = true; + introAnimating = false; + lastX = event.clientX; + lastY = event.clientY; + yawVelocity = 0; + pitchVelocity = 0; + canvas.setPointerCapture(event.pointerId); + }); + + canvas.addEventListener('pointermove', event => { + if (!dragging) return; + const dx = event.clientX - lastX; + const dy = event.clientY - lastY; + yawVelocity = dx * 0.0012; + pitchVelocity = dy * 0.0012; + yaw += yawVelocity; + pitch = Math.max(-1.45, Math.min(1.45, pitch + pitchVelocity)); + lastX = event.clientX; + lastY = event.clientY; + requestDraw(); + }); + + canvas.addEventListener('pointerup', () => dragging = false); + canvas.addEventListener('pointercancel', () => dragging = false); + + canvas.addEventListener('wheel', event => { + event.preventDefault(); + introAnimating = false; + targetZoom = Math.max(.55, Math.min(2.2, targetZoom * (event.deltaY > 0 ? .9 : 1.1))); + requestDraw(); + }, { passive: false }); + + addEventListener('resize', requestDraw); + + window.rovModel = { + setLiveControl(turn, forward, vertical) { + targetLiveTurn = turn || 0; + targetLiveForward = forward || 0; + targetLiveVertical = vertical || 0; + requestDraw(); + }, + loadBuffer(data, name) { + const vertices = parseStl(data); + vertexCount = vertices.length / 6; + gl.bindBuffer(gl.ARRAY_BUFFER, bufferScene); + gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW); + stage.classList.add('model-loaded'); + document.querySelector('#model-file-name').textContent = name; + + // Auto-center camera transition to 3/4 corner hero shot upon load + yaw = 0; + pitch = 0; + zoom = 0.75; + targetYaw = 0.68; + targetPitch = 0.42; + targetZoom = 1.0; + yawVelocity = 0; + pitchVelocity = 0; + + setTimeout(() => { + introAnimating = true; + introStartTime = performance.now(); + startYaw = yaw; + startPitch = pitch; + startZoom = zoom; + requestDraw(); + }, 150); + + requestDraw(); + return { name, triangles: vertexCount / 3 }; + } + }; +})(); diff --git a/web/styles.css b/web/styles.css new file mode 100644 index 0000000..9304651 --- /dev/null +++ b/web/styles.css @@ -0,0 +1,12 @@ +:root{color-scheme:dark;--bg:#0d1113;--rail:#161c1f;--panel:#f1f3f0;--card:#fff;--ink:#141719;--muted:#747a7d;--line:#dfe3de;--acid:#c9f277;--cyan:#72d8e8;--amber:#f2c675;--red:#f0958b;--shadow:0 25px 70px #0007;font-family:Inter,"SF Pro Display","Helvetica Neue",Arial,sans-serif} +.window-bar{height:38px;display:flex;align-items:center;justify-content:space-between;background:#111719;color:#aeb6b8;border-bottom:1px solid #ffffff10;user-select:none}.window-drag{display:flex;align-items:center;align-self:stretch;flex:1;gap:9px;padding-left:13px}.window-drag strong{font-size:10px;letter-spacing:.04em;color:#d8dddc}.window-drag i{font-size:9px;font-style:normal;color:#667175}.mini-mark{display:grid;place-items:center;width:19px;height:19px;border-radius:5px;background:var(--acid);color:#1d2818;font-size:9px;font-weight:900}.window-actions{display:flex;height:100%}.window-actions button{width:46px;height:100%;border:0;border-radius:0;background:transparent;color:#9ca5a7;cursor:pointer;font-size:15px}.window-actions button:hover{background:#ffffff0c;color:#fff}.window-actions .window-close:hover{background:#c84c43;color:#fff} +*{box-sizing:border-box}html,body{height:100%;margin:0;overflow:hidden;background:#111719;font-size:14px}button,select,input{font:inherit}.app-shell{width:100%;height:calc(100% - 38px);margin:0;display:grid;grid-template-columns:270px 1fr;border:0;border-radius:0;overflow:hidden;background:var(--panel)} +.vehicle-rail{position:relative;display:flex;flex-direction:column;padding:25px 24px 22px;background:linear-gradient(155deg,#1c2428,#111618);color:#f4f6f3;overflow:hidden}.brand{display:flex;align-items:center;gap:10px;letter-spacing:.08em;font-size:12px;font-weight:700}.brand small{margin-left:auto;color:#758086}.brand-mark{display:grid;place-items:center;width:27px;height:27px;border-radius:9px;background:var(--acid);color:#1d2818;font-weight:900}.eyebrow{font-size:9px;letter-spacing:.16em;color:#899295;font-weight:750}.vehicle-rail>.eyebrow{margin-top:30px}.rov-stage{position:relative;height:min(41vh,330px);min-height:240px;display:grid;place-items:center}.rov-stage svg{position:relative;width:210px;height:270px;z-index:2}.water-ring{position:absolute;border:1px solid #78939d24;border-radius:50%;transform:scaleY(.5)}.water-ring.r1{width:230px;height:230px}.water-ring.r2{width:165px;height:165px;border-color:#9ec7d235}.vehicle-name{display:flex;flex-direction:column;gap:5px}.vehicle-name small,.battery-card small{font-size:9px;letter-spacing:.14em;color:#778186;font-style:normal}.vehicle-name strong{font-size:17px}.vehicle-name span{font-size:10px;color:#7d878b}.rail-bottom{margin-top:auto}.battery-card{display:grid;grid-template-columns:1fr auto;width:100%;padding:0 0 19px;text-align:left;color:inherit;border:0;border-bottom:1px solid #ffffff17;cursor:pointer}.battery-card>span{display:flex;flex-direction:column;gap:3px}.battery-card strong{font-size:19px}.battery-card i{align-self:end;color:#8c979b;font-size:11px;font-style:normal}.battery-card b{grid-column:1/-1;height:5px;margin-top:10px;background:#353d40;border-radius:20px;overflow:hidden}.battery-card em{display:block;width:0;height:100%;background:var(--acid);border-radius:inherit;transition:width .35s}.ghost{background:none}.link-state{display:flex;align-items:center;gap:9px;margin-top:17px;font-size:10px;letter-spacing:.08em;color:#a9b0b2}.link-state span{width:7px;height:7px;border-radius:50%;background:var(--red);box-shadow:0 0 0 4px #f0958b18}.link-state.online span{background:var(--acid);box-shadow:0 0 0 4px #c9f27718} +.workspace{min-width:0;display:flex;flex-direction:column;padding:26px 30px 18px;color:var(--ink);background:linear-gradient(145deg,#f7f8f6,#ecefeb)}.topbar{display:flex;align-items:center;justify-content:space-between;padding-bottom:20px;border-bottom:1px solid var(--line)}h1{margin:3px 0 0;font-size:30px;letter-spacing:-.04em}.connection{display:flex;align-items:center;gap:7px}select,input{border:1px solid #d7dbd6;background:#fff;color:#25292b;border-radius:11px;outline:0}.connection select{width:155px;height:38px;padding:0 12px}.button,.icon-button{height:38px;border:1px solid transparent;border-radius:12px;padding:0 16px;color:#28302a;background:#e9ece8;cursor:pointer;transition:transform .12s,background .12s,box-shadow .12s}.button:hover,.icon-button:hover{transform:translateY(-1px);box-shadow:0 5px 14px #28322c18}.icon-button{width:39px;padding:0;font-weight:700}.connect-button{background:#1d2421;color:white}.drive-grid{display:grid;grid-template-columns:minmax(380px,1fr) 230px;gap:16px;min-height:0;flex:1;padding:18px 0}.control-card{display:flex;flex-direction:column;min-height:0;padding:18px 20px 14px;border:1px solid #e0e3df;border-radius:22px;background:#fff;box-shadow:0 12px 34px #3039340b}.control-card header{display:flex;justify-content:space-between;align-items:start}.control-card header div{display:flex;flex-direction:column;gap:3px}.control-card small,.modal>small{font-size:9px;letter-spacing:.14em;color:#8b9190;font-weight:750}.control-card header strong{font-size:15px}.key-hint{padding:6px 9px;border-radius:8px;background:#f0f2ef;color:#838887;font-size:9px;font-weight:700;letter-spacing:.08em}.control-card footer{display:flex;justify-content:center;gap:25px;color:#7e8482;font-size:10px}.control-card footer span{display:flex;align-items:center;gap:4px}.control-card footer b{color:#353a38}.dot{width:6px;height:6px;border-radius:50%;background:var(--cyan)}.dot.yaw{background:var(--amber)}.dot.depth{background:#bc9feb} +.joystick{--size:min(36vh,300px);position:relative;width:var(--size);height:var(--size);min-width:220px;min-height:220px;margin:auto;border:1px solid #d5d9d5;border-radius:50%;background:radial-gradient(circle at 45% 38%,#f8f9f7 0 20%,#ecefeb 70%);box-shadow:inset 0 3px 8px #fff,inset 0 -8px 18px #2630280c;user-select:none;touch-action:none}.joystick:after{content:"";position:absolute;inset:23%;border:1px solid #dce0dc;border-radius:50%}.axis{position:absolute;background:#ccd1cc}.vertical-axis{top:8%;bottom:8%;left:50%;width:1px}.horizontal-axis{left:8%;right:8%;top:50%;height:1px}.axis-label{position:absolute;color:#979c99;font-size:8px;font-weight:750;letter-spacing:.12em}.forward{top:7px;left:50%;transform:translateX(-50%)}.reverse{bottom:7px;left:50%;transform:translateX(-50%)}.left{left:6px;top:50%;transform:translateY(-50%) rotate(-90deg)}.right{right:3px;top:50%;transform:translateY(-50%) rotate(90deg)}.stick-shadow{position:absolute;left:50%;top:50%;width:68px;height:68px;border-radius:50%;background:#21282422;filter:blur(9px);transform:translate(-45%,-35%)}.stick{position:absolute;z-index:3;left:50%;top:50%;width:62px;height:62px;border-radius:50%;transform:translate(-50%,-50%);background:linear-gradient(145deg,#4b5558,#252c2f);border:1px solid #111719;box-shadow:inset 0 2px 2px #ffffff38,0 10px 18px #18201d40;will-change:transform}.stick span{position:absolute;inset:18px;border-radius:50%;border:1px solid #a9b3b544} +.vertical-control{display:flex;flex:1;min-height:240px;flex-direction:column;align-items:center;gap:8px;padding:16px 0 12px;color:#989d9a;font-size:8px;font-weight:750;letter-spacing:.12em}.heave-track{position:relative;width:38px;flex:1;min-height:180px;border-radius:22px;background:linear-gradient(90deg,#e3e6e2,#f3f4f2 50%,#dfe3de);border:1px solid #d8dcd7;box-shadow:inset 0 3px 7px #9ca59f25;touch-action:none}.heave-center{position:absolute;left:7px;right:7px;top:50%;height:1px;background:#adb4af}.heave-fill{position:absolute;left:12px;right:12px;top:50%;height:0;background:#bc9feb88}.heave-knob{position:absolute;left:50%;top:50%;width:56px;height:35px;border-radius:18px;background:linear-gradient(145deg,#4b5558,#252c2f);border:1px solid #151a1d;box-shadow:0 8px 14px #1d252238,inset 0 1px #ffffff36;transform:translate(-50%,-50%);will-change:top} +.command-bar{display:flex;align-items:center;gap:10px;min-height:57px;padding:10px 12px;border:1px solid #dde1dc;border-radius:17px;background:#fff}.commands{display:flex;gap:7px}.subtle{background:#f0f2ef;border-color:#e4e7e2}.arm{background:#ddf1bd;color:#29371f}.danger{background:#f7dfdc;color:#7b3029}.output-limit{display:flex;flex:1;align-items:center;gap:12px;margin:0 10px;color:#747a77;font-size:10px}.output-limit span{white-space:nowrap}.output-limit b{color:#222724}.output-limit input{width:100%;accent-color:#27302b}.status-line{display:flex;align-items:center;gap:9px;min-height:27px;color:#747b78;font-size:10px}.status-line span{width:6px;height:6px;border-radius:50%;background:#96a09a}.status-line p{white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.modal{width:min(620px,calc(100vw - 40px));padding:27px;border:1px solid #ffffff24;border-radius:24px;background:#f7f8f5;color:#1c2021;box-shadow:0 35px 100px #000b}.modal::backdrop{background:#071012b5;backdrop-filter:blur(8px)}.modal h2{margin:5px 0 17px;font-size:25px}.modal-close{position:absolute;top:18px;right:18px;width:34px;height:34px;border:0;border-radius:50%;background:#e8ebe6;color:#555;cursor:pointer;font-size:20px}.form-grid{display:grid;gap:13px}.form-grid.two{grid-template-columns:1fr 1fr}.form-grid.three{grid-template-columns:repeat(3,1fr)}.form-grid label{display:flex;flex-direction:column;gap:6px;color:#737976;font-size:10px}.form-grid input,.form-grid select{width:100%;height:40px;padding:0 11px}.note{color:#7a807d;font-size:11px;line-height:1.5}.modal-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:22px}.modal-actions.wide{grid-column:1/-1}.motor-modal{width:min(760px,calc(100vw - 40px))}.motor-head,.motor-row{display:grid;grid-template-columns:70px 1fr 110px 94px;align-items:center;gap:9px}.motor-head{padding:8px 10px;color:#969b99;font-size:9px;letter-spacing:.1em}.motor-list{max-height:390px;overflow:auto}.motor-row{min-height:47px;padding:5px 10px;border-top:1px solid #e3e6e1}.motor-row select{height:34px;padding:0 9px}.motor-row .button{height:32px;padding:0 10px}.toast{position:fixed;z-index:20;left:50%;bottom:28px;padding:11px 16px;border-radius:12px;background:#202725;color:#fff;box-shadow:0 10px 35px #0006;transform:translate(-50%,25px);opacity:0;pointer-events:none;transition:.2s}.toast.show{transform:translate(-50%,0);opacity:1}.toast.error{background:#77352f} +@media(max-width:950px){.app-shell{grid-template-columns:225px minmax(0,1fr)}.vehicle-rail{padding-left:18px;padding-right:18px}.drive-grid{grid-template-columns:minmax(330px,1fr) 190px}.workspace{padding-left:22px;padding-right:22px}.connection select{width:120px}}@media(max-height:700px){.rov-stage{height:240px}.drive-grid{padding:12px 0}.joystick{--size:min(32vh,250px)}.workspace{padding-top:18px}} +.control-card{border-radius:14px}.command-bar{border-radius:12px}.button,.icon-button,.connection select,select,input{border-radius:9px}.modal{border-radius:16px}.brand-mark{border-radius:7px} +.rov-stage #model-canvas{display:none;position:absolute;inset:0;width:100%;height:100%;z-index:3;image-rendering:auto;cursor:grab;touch-action:none}.rov-stage #model-canvas:active{cursor:grabbing}.rov-stage.model-loaded #default-model{display:none}.rov-stage.model-loaded #model-canvas{display:block;filter:drop-shadow(0 0 1px rgba(8,15,17,.9)) drop-shadow(0 4px 5px rgba(0,0,0,.3))}.model-tools{position:absolute;z-index:5;left:50%;bottom:5px;display:flex;gap:5px;transform:translateX(-50%);opacity:.72;transition:opacity .18s}.rov-stage:hover .model-tools{opacity:1}.model-tools button{height:27px;padding:0 9px;border:1px solid #ffffff18;border-radius:7px;background:#20282bcc;color:#aab3b5;font-size:8px;letter-spacing:.06em;cursor:pointer;backdrop-filter:blur(7px)}.model-tools button:hover{color:#fff;background:#303a3e}.model-tools button:disabled{opacity:.35;cursor:default} diff --git a/web_ui.py b/web_ui.py new file mode 100644 index 0000000..a5e5517 --- /dev/null +++ b/web_ui.py @@ -0,0 +1,346 @@ +from __future__ import annotations + +import json +import os +import queue +import threading +import time +import base64 +import shutil +import uuid +from pathlib import Path + +import webview +from serial.tools import list_ports + +from app import Axes, MOTOR_FUNCTIONS, MavlinkWorker, motor_number_for_function + + +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.lock = threading.Lock() + self.state = { + "connected": False, + "link": "DISCONNECTED", + "vehicle": "No heartbeat", + "armed": False, + "status": "Ready. Select a USB port to begin.", + "battery": 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.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 + 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 + + def initial_state(self): + return { + "ports": [p.device for p in list_ports.comports()], + "battery_config": self.battery_config, + "state": self._snapshot(), + } + + def refresh_ports(self): + return [p.device for p in list_ports.comports()] + + 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 begin_model_save(self, name, size): + 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: + 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") + 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" + return {"name": name, "size": self.model_cache.stat().st_size} + except (OSError, ValueError): + pass + return None + + def read_saved_model_chunk(self, offset, size=196608): + offset, size = int(offset), min(int(size), 196608) + 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 self.model_cache.is_file(): + return + 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): + pass + + def connect(self, port, baud=57600, rate=20): + if not port: + raise ValueError("Select a USB serial port") + self.worker.rate_hz = int(rate) + self.worker.connect(str(port), int(baud)) + with self.lock: + self.state.update( + connected=True, link="CONNECTING", + status=f"Opening {port} at {baud} baud…", + ) + return True + + def disconnect(self): + 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))), + )) + 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): + 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") + 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) + 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") + 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_config = clean + self.battery_path.write_text(json.dumps(clean, indent=2) + "\n", encoding="utf-8") + return clean + + def open_logs(self): + path = Path(__file__).resolve().parent / "logs" + path.mkdir(exist_ok=True) + os.startfile(path) + 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: + 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 _snapshot(self): + with self.lock: + return json.loads(json.dumps(self.state)) + + def _consume(self, event): + kind = event[0] + with self.lock: + if kind == "heartbeat": + _, system, autopilot, mode, armed = event + self.state.update( + connected=True, link="LINK OK", vehicle=f"{mode} • {'ARMED' if armed else 'DISARMED'}", + armed=bool(armed), + ) + elif kind in ("status", "statustext"): + self.state["status"] = event[1] + elif kind == "ack": + self.state["status"] = f"Command {event[1]} ACK: {event[2]}" + elif kind == "battery": + 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 == "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] + elif kind == "disconnected": + self.state.update(connected=False, link="DISCONNECTED", vehicle="No heartbeat", + armed=False, battery=None, status="Disconnected") + + 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 main(): + api = ControlApi() + ui_path = Path(__file__).resolve().parent / "web" / "index.html" + window = webview.create_window( + "SUB Control", url=str(ui_path), js_api=api, + width=1120, height=760, min_size=(900, 650), + background_color="#111719", frameless=True, easy_drag=False, + shadow=True, + ) + api._window = window + window.events.maximized += lambda: setattr(api, "maximized", True) + window.events.restored += lambda: setattr(api, "maximized", False) + try: + webview.start(debug=False) + finally: + api.close() + + +if __name__ == "__main__": + main()