Compare commits

...

5 Commits

29 changed files with 6537 additions and 1831 deletions
+52 -3
View File
@@ -109,11 +109,12 @@ The car-style virtual joystick maps only to forward/reverse and continuous
differential turning; it never sends lateral motion. The joystick and vertical 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 slider ease back to center over 400 ms after mouse/key release, so neither can
be left commanding motion. be left commanding motion.
Heartbeat loss, disconnect, and disarm remain immediate safety stops and are Focus loss, stale telemetry, disconnect, and disarm clear commanded inputs
intentionally not eased. without easing. This is a neutral-command request, **not proof that motors have
stopped** when the communication link is unavailable.
In addition, transmitted pilot demands have a small 4% input deadband and a 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 0.25-second full-range slew limit. This makes both acceleration and deceleration
arrive at the flight controller as a sequence of stable intermediate values. 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 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 limit is applied, preventing their mixer contributions from adding beyond the
@@ -122,3 +123,51 @@ selected horizontal limit.
ArduPilot may ignore manual input when `MAV_GCS_SYSID` does not match this 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 application's system ID (default 255), or when `MAV_OPTIONS` does not allow
MAVLink input. Those parameters must already be correctly configured. MAVLink input. Those parameters must already be correctly configured.
## Engage, confirmation, and interruption handling
The compact window opens at 1100 × 650. Empty sidebar areas drag the window;
the STL viewer, buttons, and sliders are separate interactive regions. Output
limit and Motor setup sit below the main controls. Recent activity retains the
last three events, oldest at the top and newest emphasized at the bottom.
Routine hold instructions are kept in tooltips;
pending confirmations and safety warnings still appear above the controls.
The full-width text control displays `engage` / `disengage`; disengage sends the
same immediate disarm request described below.
- Hold **ENGAGE** for 1.4 seconds (or hold F2). Early release, leaving the
button, interrupted touch, and focus loss cancel the hold without sending arm.
- **ENGAGING…** means a request is pending. A send result or accepted command
ACK alone does not prove that the vehicle armed: a fresh autopilot heartbeat
reporting the requested state is the final confirmation. A heartbeat can
confirm state even if the ACK packet was lost.
- **DISARM** is one tap or F3, with no long hold. While confirmation is pending,
**DISARMING…** stays red and driving is disabled. Disarm can also interrupt a
pending Engage request. No forced-arm/disarm bypass is used.
- Confirmation timeout: 5 seconds in the backend (6-second UI fallback).
Rejection and timeout remain visible. Late heartbeat updates still determine
the displayed state; the UI never substitutes an animation state for telemetry.
- A heartbeat older than 3 seconds is stale. A missing bridge reply after
1.5 seconds shows **STATE UNKNOWN**, preserving the last-known state without
claiming disarm. Battery readings older than 5 seconds are marked stale.
- The web frontend renews an input lease locally every 250 ms. If it stops for
1 second, the worker clears stored demands and sends neutral at its configured
control rate. This adds no MAVLink stream or parameter download. Focus loss
clears both captured touch pointers and queued input; reconnect never replays
the old joystick position. Normal release still eases back to center.
- Link loss cannot deliver a stop over a broken tether. Verify the vehicle's
own failsafe and use independent physical power isolation for bench tests.
### Regression checks (no hardware)
Run from this project folder after setting up the Python environment:
```powershell
.venv\Scripts\python.exe -m unittest discover -s tests -v
node --test tests/control_ui.test.cjs
```
Node is needed only for the frontend regression tests, not to run the app.
The tests use mocked transport and synthetic pointer events, including independent
joystick/vertical pointers. Validate physical multi-touch on the intended screen
and FC failsafes on a restrained, propeller-free setup before powered operation.
+52 -3
View File
@@ -109,11 +109,12 @@ The car-style virtual joystick maps only to forward/reverse and continuous
differential turning; it never sends lateral motion. The joystick and vertical 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 slider ease back to center over 400 ms after mouse/key release, so neither can
be left commanding motion. be left commanding motion.
Heartbeat loss, disconnect, and disarm remain immediate safety stops and are Focus loss, stale telemetry, disconnect, and disarm clear commanded inputs
intentionally not eased. without easing. This is a neutral-command request, **not proof that motors have
stopped** when the communication link is unavailable.
In addition, transmitted pilot demands have a small 4% input deadband and a 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 0.25-second full-range slew limit. This makes both acceleration and deceleration
arrive at the flight controller as a sequence of stable intermediate values. 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 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 limit is applied, preventing their mixer contributions from adding beyond the
@@ -122,3 +123,51 @@ selected horizontal limit.
ArduPilot may ignore manual input when `MAV_GCS_SYSID` does not match this 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 application's system ID (default 255), or when `MAV_OPTIONS` does not allow
MAVLink input. Those parameters must already be correctly configured. MAVLink input. Those parameters must already be correctly configured.
## Engage, confirmation, and interruption handling
The compact window opens at 1100 × 650. Empty sidebar areas drag the window;
the STL viewer, buttons, and sliders are separate interactive regions. Output
limit and Motor setup sit below the main controls. Recent activity retains the
last three events, oldest at the top and newest emphasized at the bottom.
Routine hold instructions are kept in tooltips;
pending confirmations and safety warnings still appear above the controls.
The full-width text control displays `engage` / `disengage`; disengage sends the
same immediate disarm request described below.
- Hold **ENGAGE** for 1.4 seconds (or hold F2). Early release, leaving the
button, interrupted touch, and focus loss cancel the hold without sending arm.
- **ENGAGING…** means a request is pending. A send result or accepted command
ACK alone does not prove that the vehicle armed: a fresh autopilot heartbeat
reporting the requested state is the final confirmation. A heartbeat can
confirm state even if the ACK packet was lost.
- **DISARM** is one tap or F3, with no long hold. While confirmation is pending,
**DISARMING…** stays red and driving is disabled. Disarm can also interrupt a
pending Engage request. No forced-arm/disarm bypass is used.
- Confirmation timeout: 5 seconds in the backend (6-second UI fallback).
Rejection and timeout remain visible. Late heartbeat updates still determine
the displayed state; the UI never substitutes an animation state for telemetry.
- A heartbeat older than 3 seconds is stale. A missing bridge reply after
1.5 seconds shows **STATE UNKNOWN**, preserving the last-known state without
claiming disarm. Battery readings older than 5 seconds are marked stale.
- The web frontend renews an input lease locally every 250 ms. If it stops for
1 second, the worker clears stored demands and sends neutral at its configured
control rate. This adds no MAVLink stream or parameter download. Focus loss
clears both captured touch pointers and queued input; reconnect never replays
the old joystick position. Normal release still eases back to center.
- Link loss cannot deliver a stop over a broken tether. Verify the vehicle's
own failsafe and use independent physical power isolation for bench tests.
### Regression checks (no hardware)
Run from this project folder after setting up the Python environment:
```powershell
.venv\Scripts\python.exe -m unittest discover -s tests -v
node --test tests/control_ui.test.cjs
```
Node is needed only for the frontend regression tests, not to run the app.
The tests use mocked transport and synthetic pointer events, including independent
joystick/vertical pointers. Validate physical multi-touch on the intended screen
and FC failsafes on a restrained, propeller-free setup before powered operation.
+81 -12
View File
@@ -42,6 +42,25 @@ class Axes:
yaw: float = 0.0 yaw: float = 0.0
def normalize_endpoint(port: str) -> str:
p = str(port).strip()
if not p:
return p
lower = p.lower()
if "sim" in lower or "5760" in lower or lower in ("tcp", "tcp:", "tcp:127.0.0.1", "127.0.0.1"):
return "tcp:127.0.0.1:5760"
if "14550" in lower or lower in ("udp", "udp:", "udpin", "udpin:"):
return "udpin:127.0.0.1:14550"
if p.startswith("127.0.0.1:") or p.startswith("localhost:"):
num = p.split(":")[1]
if num == "14550":
return f"udpin:{p}"
return f"tcp:{p}"
if ":" in p and not p.startswith(("tcp:", "tcpin:", "udp:", "udpin:", "udpout:")):
return f"tcp:{p}"
return p
class MavlinkWorker: class MavlinkWorker:
def __init__(self, events: queue.Queue): def __init__(self, events: queue.Queue):
self.events = events self.events = events
@@ -52,6 +71,11 @@ class MavlinkWorker:
self.axes = Axes() self.axes = Axes()
self.output_axes = Axes() self.output_axes = Axes()
self.last_control_at = 0.0 self.last_control_at = 0.0
# Enabled by the web bridge only; the legacy Tk frontend is unchanged.
self.require_input_lease = False
self.last_input_at = 0.0
self.last_input_sequence = -1
self.input_timeout = 1.0
# Smooth ESC commands without adding a perceptible one-second lag. # Smooth ESC commands without adding a perceptible one-second lag.
# At 20 Hz this produces five small steps from neutral to full command. # At 20 Hz this produces five small steps from neutral to full command.
self.ramp_seconds = 0.25 self.ramp_seconds = 0.25
@@ -74,29 +98,52 @@ class MavlinkWorker:
self.log_path = None self.log_path = None
def connect(self, port: str, baud: int): def connect(self, port: str, baud: int):
print(f"[MavlinkWorker] connect() called: port={port!r}, baud={baud}")
if self.thread and self.thread.is_alive(): if self.thread and self.thread.is_alive():
print(f"[MavlinkWorker] Thread already alive, ignoring connect()")
return return
self.stop_event.clear() self.stop_event.clear()
log_dir = Path(__file__).resolve().parent / "logs" log_dir = Path(__file__).resolve().parent / "logs"
log_dir.mkdir(exist_ok=True) log_dir.mkdir(exist_ok=True)
self.log_path = log_dir / f"ardusub_{datetime.now():%Y%m%d_%H%M%S}.log" 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_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}") normalized = normalize_endpoint(port)
print(f"[MavlinkWorker] normalized endpoint: {normalized!r}")
self.log("SESSION", f"start port={normalized} (input={port}) baud={baud} control_hz={self.rate_hz}")
self.events.put(("log_path", str(self.log_path))) self.events.put(("log_path", str(self.log_path)))
self.thread = threading.Thread( self.thread = threading.Thread(
target=self._run, args=(port, baud), daemon=True target=self._run, args=(normalized, baud), daemon=True
) )
self.thread.start() self.thread.start()
print(f"[MavlinkWorker] worker thread started")
def _run(self, port: str, baud: int): def _run(self, port: str, baud: int):
print(f"[MavlinkWorker._run] ENTERED: port={port!r}, baud={baud}")
try: try:
print(f"[MavlinkWorker._run] Creating mavlink_connection...")
conn = mavutil.mavlink_connection( conn = mavutil.mavlink_connection(
port, baud=baud, source_system=255, source_component=190, port, baud=baud, source_system=255, source_component=190,
autoreconnect=False, force_connected=True, # Fail a missing TCP simulator immediately. pymavlink otherwise
# retries the refused socket several times and leaves the UI in
# "Opening" long after the actual failure.
autoreconnect=False, retries=0, force_connected=True,
) )
print(f"[MavlinkWorker._run] mavlink_connection created: {type(conn).__name__}")
self.connection = conn self.connection = conn
self.events.put(("status", f"Port open; waiting for heartbeat at {baud} baud…")) if port.startswith("tcp:"):
next_gcs_hb = 0.0 self.events.put(("status", f"Connected to {port}; waiting for vehicle heartbeat…"))
else:
self.events.put(("status", f"Port {port} open; waiting for heartbeat at {baud} baud…"))
# Send initial GCS heartbeat immediately
self.log("TX_HEARTBEAT", "GCS initial")
conn.mav.heartbeat_send(
mavutil.mavlink.MAV_TYPE_GCS,
mavutil.mavlink.MAV_AUTOPILOT_INVALID,
0, 0, mavutil.mavlink.MAV_STATE_ACTIVE,
)
print(f"[MavlinkWorker._run] Initial heartbeat sent, entering recv loop")
next_gcs_hb = time.monotonic() + 1.0
next_control = 0.0 next_control = 0.0
while not self.stop_event.is_set(): while not self.stop_event.is_set():
now = time.monotonic() now = time.monotonic()
@@ -120,6 +167,7 @@ class MavlinkWorker:
else: else:
time.sleep(0.01) time.sleep(0.01)
except Exception as exc: except Exception as exc:
print(f"[MavlinkWorker._run] EXCEPTION: {exc}")
self.events.put(("error", f"Connection error: {exc}")) self.events.put(("error", f"Connection error: {exc}"))
finally: finally:
self._safe_stop() self._safe_stop()
@@ -156,7 +204,11 @@ class MavlinkWorker:
except Exception: except Exception:
payload = str(msg) payload = str(msg)
self.log("RX", payload) self.log("RX", payload)
if msg_type == "HEARTBEAT" and msg.get_srcComponent() == 1: if msg_type == "HEARTBEAT" and getattr(msg, "type", 0) != mavutil.mavlink.MAV_TYPE_GCS:
if getattr(msg, "autopilot", mavutil.mavlink.MAV_AUTOPILOT_INVALID) == mavutil.mavlink.MAV_AUTOPILOT_INVALID:
return
if self.target_system and (msg.get_srcSystem(), msg.get_srcComponent()) != (self.target_system, self.target_component):
return
first = not self.target_system first = not self.target_system
self.target_system = msg.get_srcSystem() self.target_system = msg.get_srcSystem()
self.target_component = msg.get_srcComponent() self.target_component = msg.get_srcComponent()
@@ -170,15 +222,17 @@ class MavlinkWorker:
mode = str(msg.custom_mode) mode = str(msg.custom_mode)
autopilot = mavutil.mavlink.enums["MAV_AUTOPILOT"].get(msg.autopilot) autopilot = mavutil.mavlink.enums["MAV_AUTOPILOT"].get(msg.autopilot)
ap_name = autopilot.name if autopilot else str(msg.autopilot) ap_name = autopilot.name if autopilot else str(msg.autopilot)
self.events.put(("heartbeat", self.target_system, ap_name, mode, armed)) self.events.put(("heartbeat", self.target_system, ap_name, mode, armed, self.last_heartbeat))
if first: if first:
self.events.put(("status", "Heartbeat received; controls are neutral.")) self.events.put(("status", "Heartbeat received; controls are neutral."))
self._configure_low_bandwidth_streams() self._configure_low_bandwidth_streams()
self._start_servo_scan() self._start_servo_scan()
elif msg_type == "COMMAND_ACK": elif msg_type == "COMMAND_ACK":
if (msg.get_srcSystem(), msg.get_srcComponent()) != (self.target_system, self.target_component):
return
result = mavutil.mavlink.enums["MAV_RESULT"].get(msg.result) result = mavutil.mavlink.enums["MAV_RESULT"].get(msg.result)
result_name = result.name if result else str(msg.result) result_name = result.name if result else str(msg.result)
self.events.put(("ack", int(msg.command), result_name)) self.events.put(("ack", int(msg.command), result_name, self.last_rx))
elif msg_type == "STATUSTEXT": elif msg_type == "STATUSTEXT":
text = msg.text text = msg.text
if isinstance(text, bytes): if isinstance(text, bytes):
@@ -300,10 +354,17 @@ class MavlinkWorker:
if not conn or not self.target_system: if not conn or not self.target_system:
return return
with self.lock: with self.lock:
active = not force_neutral now = time.monotonic()
expired = self.require_input_lease and (
not self.armed or now - self.last_heartbeat > 3.0
or now - self.last_input_at > self.input_timeout)
if expired or force_neutral:
# Never replay a stored command after telemetry/UI recovery.
self.axes = Axes()
self.output_axes = Axes()
active = not (force_neutral or expired)
target = self.axes if active else Axes() target = self.axes if active else Axes()
gain = self.gain gain = self.gain
now = time.monotonic()
if force_neutral: if force_neutral:
self.output_axes = Axes() self.output_axes = Axes()
else: else:
@@ -375,7 +436,7 @@ class MavlinkWorker:
manual, manual,
) )
def set_axes(self, axes: Axes): def set_axes(self, axes: Axes, sequence=None):
def conditioned(value): def conditioned(value):
# Ignore tiny pointer/key return noise, then quantize to stable 1% steps. # Ignore tiny pointer/key return noise, then quantize to stable 1% steps.
if abs(value) < 0.04: if abs(value) < 0.04:
@@ -383,6 +444,11 @@ class MavlinkWorker:
return round(max(-1.0, min(1.0, value)), 2) return round(max(-1.0, min(1.0, value)), 2)
with self.lock: with self.lock:
if sequence is not None:
if sequence <= self.last_input_sequence:
return
self.last_input_sequence = sequence
self.last_input_at = time.monotonic()
self.axes = Axes( self.axes = Axes(
conditioned(axes.forward), conditioned(axes.lateral), conditioned(axes.forward), conditioned(axes.lateral),
conditioned(axes.vertical), conditioned(axes.yaw), conditioned(axes.vertical), conditioned(axes.yaw),
@@ -887,6 +953,9 @@ class App(tk.Tk):
def refresh_ports(self): def refresh_ports(self):
ports = [p.device for p in list_ports.comports()] ports = [p.device for p in list_ports.comports()]
for sim in ("tcp:127.0.0.1:5760", "udpin:127.0.0.1:14550"):
if sim not in ports:
ports.append(sim)
self.port.configure(values=ports) self.port.configure(values=ports)
if ports and self.port.get() not in ports: if ports and self.port.get() not in ports:
self.port.set(ports[0]) self.port.set(ports[0])
@@ -1139,7 +1208,7 @@ class App(tk.Tk):
event = self.events.get_nowait() event = self.events.get_nowait()
kind = event[0] kind = event[0]
if kind == "heartbeat": if kind == "heartbeat":
_, system, autopilot, mode, armed = event _, system, autopilot, mode, armed = event[:5]
self.vehicle_label.configure( self.vehicle_label.configure(
text=f"{mode} • {'ARMED' if armed else 'DISARMED'}" text=f"{mode} • {'ARMED' if armed else 'DISARMED'}"
) )
+553
View File
@@ -0,0 +1,553 @@
"""ArduSub Flight Controller (FC) Spoofer & Simulator.
Emulates an ArduSub SimpleROV-3 vehicle over MAVLink (TCP, UDP, or Serial).
Supports full parameter scans, live parameter updates (servo functions / motor reversal),
arming/disarming commands, manual control processing, and realistic telemetry streaming
(battery voltage/current with load sag, live thruster PWM mixing, simulated attitude & depth).
"""
from __future__ import annotations
import argparse
import math
import os
import sys
import time
from collections import deque
from dataclasses import dataclass
from typing import Any
from pymavlink import mavutil
# Default SimpleROV-3 motor mappings and ArduSub parameters
DEFAULT_PARAMETERS: dict[str, tuple[float, int]] = {
# Servo functions: 33 = Motor 1 (Port), 34 = Motor 2 (Starboard), 35 = Motor 3 (Vertical)
"SERVO1_FUNCTION": (33.0, mavutil.mavlink.MAV_PARAM_TYPE_INT16),
"SERVO2_FUNCTION": (34.0, mavutil.mavlink.MAV_PARAM_TYPE_INT16),
"SERVO3_FUNCTION": (35.0, mavutil.mavlink.MAV_PARAM_TYPE_INT16),
# Remaining servos unassigned
**{f"SERVO{i}_FUNCTION": (0.0, mavutil.mavlink.MAV_PARAM_TYPE_INT16) for i in range(4, 17)},
# Motor directions: 1 = normal, -1 = reversed
**{f"MOT_{i}_DIRECTION": (1.0, mavutil.mavlink.MAV_PARAM_TYPE_INT8) for i in range(1, 13)},
# SimpleROV-3 thruster PWM limits
"SERVO1_MIN": (1100.0, mavutil.mavlink.MAV_PARAM_TYPE_REAL32),
"SERVO1_TRIM": (1500.0, mavutil.mavlink.MAV_PARAM_TYPE_REAL32),
"SERVO1_MAX": (1900.0, mavutil.mavlink.MAV_PARAM_TYPE_REAL32),
"SERVO2_MIN": (1100.0, mavutil.mavlink.MAV_PARAM_TYPE_REAL32),
"SERVO2_TRIM": (1500.0, mavutil.mavlink.MAV_PARAM_TYPE_REAL32),
"SERVO2_MAX": (1900.0, mavutil.mavlink.MAV_PARAM_TYPE_REAL32),
"SERVO3_MIN": (1100.0, mavutil.mavlink.MAV_PARAM_TYPE_REAL32),
"SERVO3_TRIM": (1500.0, mavutil.mavlink.MAV_PARAM_TYPE_REAL32),
"SERVO3_MAX": (1900.0, mavutil.mavlink.MAV_PARAM_TYPE_REAL32),
# Joystick gain parameters
"JS_GAIN_MIN": (0.10, mavutil.mavlink.MAV_PARAM_TYPE_REAL32),
"JS_GAIN_DEFAULT": (0.20, mavutil.mavlink.MAV_PARAM_TYPE_REAL32),
"JS_GAIN_MAX": (1.00, mavutil.mavlink.MAV_PARAM_TYPE_REAL32),
}
@dataclass
class ControlState:
x: float = 0.0 # Surge (forward/back) -1000..1000
y: float = 0.0 # Sway (lateral) -1000..1000
z: float = 500.0 # Heave (vertical) 0..1000 (500 = neutral)
r: float = 0.0 # Yaw (turn) -1000..1000
last_rx: float = 0.0
class FlightControllerSpoofer:
def __init__(
self,
endpoint: str = "tcpin:127.0.0.1:5760",
baud: int = 57600,
battery_cells: int = 4,
nominal_voltage: float = 16.0,
show_dashboard: bool = True,
):
self.endpoint = endpoint
self.baud = baud
self.battery_cells = battery_cells
self.battery_base_v = nominal_voltage
self.show_dashboard = show_dashboard
self.running = True
self.armed = False
self.mode = 19 # ArduSub MANUAL mode
self.system_status = mavutil.mavlink.MAV_STATE_ACTIVE
self.target_system = 255
self.target_component = 190
self.params = dict(DEFAULT_PARAMETERS)
self.controls = ControlState()
# Simulated physical state
self.depth_m = 1.5 # Positive depth under water
self.heading_deg = 0.0
self.pitch_deg = 0.0
self.roll_deg = 0.0
self.m1_pwm = 1500 # Port
self.m2_pwm = 1500 # Starboard
self.m3_pwm = 1500 # Vertical
self.current_a = 0.35
self.events: deque[str] = deque(maxlen=6)
self.client_connected = False
self.total_packets_rx = 0
self.total_packets_tx = 0
self.start_time = time.monotonic()
def log_event(self, text: str):
now_str = time.strftime("%H:%M:%S")
self.events.append(f"[{now_str}] {text}")
def run(self):
print(f"\n{'='*72}")
print(f" ARDUSUB SIMPLE-ROV FLIGHT CONTROLLER SPOOFER IS RUNNING")
print(f" Target Endpoint: {self.endpoint}")
print(f" --> In Sub Control app, select or connect to: {self.endpoint.replace('tcpin:', 'tcp:')}")
print(f"{'='*72}\n", flush=True)
self.log_event(f"Simulator starting on {self.endpoint}...")
while self.running:
try:
self._run_server_session()
except KeyboardInterrupt:
break
except OSError as exc:
if getattr(exc, "winerror", None) == 10048 or "already in use" in str(exc).lower():
print(f"\n[ERROR] Port {self.endpoint} is already in use by another program or previous spoofer instance.")
print("Please close any running python/spoofer processes and try again.\n")
break
self.log_event(f"Session reset: {exc}")
time.sleep(1.0)
except Exception as exc:
self.log_event(f"Session reset: {exc}")
time.sleep(1.0)
print("\nSpoofer stopped.")
def _run_server_session(self):
# Bind server / open connection
if self.endpoint.startswith("tcpin:"):
shown_endpoint = self.endpoint.replace("tcpin:", "tcp:", 1)
print(f"[READY] Listening for SUB Control on {shown_endpoint}", flush=True)
print(" Leave this window open, then click Connect in the app.\n", flush=True)
conn = mavutil.mavlink_connection(
self.endpoint,
baud=self.baud,
source_system=1,
source_component=1,
autoreconnect=True,
force_connected=True,
)
self.log_event(f"Client connected on {self.endpoint}")
next_heartbeat = 0.0
next_sys_status = 0.0
next_servos = 0.0
next_attitude = 0.0
next_dashboard = 0.0
# Send initial announcement statustext
initial_sent = False
while self.running:
now = time.monotonic()
elapsed = now - self.start_time
time_boot_ms = int(elapsed * 1000) & 0xFFFFFFFF
time_boot_us = int(elapsed * 1000000) & 0xFFFFFFFF
# 1. Heartbeat at 1.0 Hz
if now >= next_heartbeat:
base_mode = (
mavutil.mavlink.MAV_MODE_FLAG_CUSTOM_MODE_ENABLED
| mavutil.mavlink.MAV_MODE_FLAG_MANUAL_INPUT_ENABLED
| mavutil.mavlink.MAV_MODE_FLAG_STABILIZE_ENABLED
)
if self.armed:
base_mode |= mavutil.mavlink.MAV_MODE_FLAG_SAFETY_ARMED
conn.mav.heartbeat_send(
mavutil.mavlink.MAV_TYPE_SUBMARINE,
mavutil.mavlink.MAV_AUTOPILOT_ARDUPILOTMEGA,
base_mode,
self.mode,
self.system_status,
)
self.total_packets_tx += 1
next_heartbeat = now + 1.0
if not initial_sent and self.client_connected:
conn.mav.statustext_send(
mavutil.mavlink.MAV_SEVERITY_INFO,
b"ArduSub V4.1.0 (SimpleROV-3 Simulator)",
)
conn.mav.statustext_send(
mavutil.mavlink.MAV_SEVERITY_INFO,
b"EKF3 IMU0 initialised",
)
initial_sent = True
# 2. Physics & Motor Mixing step
self._update_physics(now)
# 3. SYS_STATUS at 1.0 Hz (Battery & Sensor Health)
if now >= next_sys_status:
# Voltage with slight noise + load sag
load_factor = (
abs(self.m1_pwm - 1500)
+ abs(self.m2_pwm - 1500)
+ abs(self.m3_pwm - 1500)
) / 1200.0 # 0.0 to 1.0
voltage_v = self.battery_base_v - (0.45 * load_factor) + (0.02 * math.sin(now * 1.5))
voltage_mv = int(max(10.0, voltage_v) * 1000)
# Current: 0.35A base + up to 12.5A at full throttle
self.current_a = 0.35 + (12.5 * load_factor)
current_ca = int(self.current_a * 100)
# Battery remaining percentage (e.g. 88% draining slowly)
remaining_pct = max(10, int(88 - (elapsed / 300.0)))
conn.mav.sys_status_send(
322042887, # onboard_control_sensors_present
320969735, # onboard_control_sensors_enabled
305241095, # onboard_control_sensors_health
175, # load (17.5%)
voltage_mv,
current_ca,
remaining_pct,
0, 0, 0, 0, 0, 0,
)
self.total_packets_tx += 1
next_sys_status = now + 1.0
# 4. SERVO_OUTPUT_RAW & RC_CHANNELS at 4.0 Hz
if now >= next_servos:
conn.mav.servo_output_raw_send(
time_boot_us,
0,
self.m1_pwm,
self.m2_pwm,
self.m3_pwm,
0, 0, 0, 0, 0,
)
conn.mav.rc_channels_send(
time_boot_ms,
8,
1500 + int(self.controls.r * 0.4), # Chan 1: Yaw
1500, # Chan 2: Roll
int(self.controls.z), # Chan 3: Throttle/Vertical
1500 + int(self.controls.x * 0.4), # Chan 4: Pitch/Forward
1500, 1500, 1500, 1500,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
)
self.total_packets_tx += 2
next_servos = now + 0.25
# 5. ATTITUDE & VFR_HUD at 4.0 Hz
if now >= next_attitude:
conn.mav.attitude_send(
time_boot_ms,
math.radians(self.roll_deg),
math.radians(self.pitch_deg),
math.radians(self.heading_deg),
0.0, 0.0, 0.0,
)
conn.mav.vfr_hud_send(
abs(self.m1_pwm - 1500) / 400.0, # airspeed
abs(self.m2_pwm - 1500) / 400.0, # groundspeed
int(self.heading_deg) % 360, # heading
int((self.controls.z - 500) / 5), # throttle %
-self.depth_m, # altitude (negative depth)
(self.controls.z - 500) / 500.0, # climb rate
)
self.total_packets_tx += 2
next_attitude = now + 0.25
# 6. Process incoming MAVLink packets from App
self._handle_incoming_messages(conn)
# 7. Render Console Dashboard
if self.show_dashboard and now >= next_dashboard:
self._render_dashboard()
next_dashboard = now + 0.15
time.sleep(0.01)
def _update_physics(self, now: float):
# If manual control packet hasn't arrived for > 1.5s, auto-neutralize
if now - self.controls.last_rx > 1.5:
self.controls.x = 0.0
self.controls.y = 0.0
self.controls.z = 500.0
self.controls.r = 0.0
if not self.armed:
self.m1_pwm = 1500
self.m2_pwm = 1500
self.m3_pwm = 1500
else:
# Read motor direction parameters
mot1_dir = int(self.params.get("MOT_1_DIRECTION", (1.0,))[0])
mot2_dir = int(self.params.get("MOT_2_DIRECTION", (1.0,))[0])
mot3_dir = int(self.params.get("MOT_3_DIRECTION", (1.0,))[0])
# Differential drive mixing for SimpleROV-3:
# Motor 1 (Port): Forward + Yaw
# Motor 2 (Starboard): Forward - Yaw
# Motor 3 (Vertical): Vertical (0..1000 with 500 center)
forward_cmd = (self.controls.x / 1000.0) * 400.0 # -400..+400
yaw_cmd = (self.controls.r / 1000.0) * 400.0 # -400..+400
vert_cmd = ((self.controls.z - 500.0) / 500.0) * 400.0 # -400..+400
m1_offset = (forward_cmd + yaw_cmd) * mot1_dir
m2_offset = (forward_cmd - yaw_cmd) * mot2_dir
m3_offset = vert_cmd * mot3_dir
self.m1_pwm = int(max(1100, min(1900, 1500 + m1_offset)))
self.m2_pwm = int(max(1100, min(1900, 1500 + m2_offset)))
self.m3_pwm = int(max(1100, min(1900, 1500 + m3_offset)))
# Simulate subtle underwater motion
self.roll_deg = 2.5 * math.sin(now * 0.8)
self.pitch_deg = (self.controls.x / 1000.0) * 5.0 + 1.2 * math.cos(now * 0.7)
if self.armed and abs(self.controls.r) > 10:
self.heading_deg = (self.heading_deg + (self.controls.r / 1000.0) * 2.0) % 360.0
if self.armed and abs(self.controls.z - 500) > 20:
vert_delta = ((self.controls.z - 500.0) / 500.0) * 0.02
self.depth_m = max(0.1, min(50.0, self.depth_m - vert_delta))
def _handle_incoming_messages(self, conn):
while True:
msg = conn.recv_match(blocking=False)
if not msg:
break
self.total_packets_rx += 1
if not self.client_connected:
self.client_connected = True
self.log_event(f"GCS Connected (SysID {msg.get_srcSystem()}, CompID {msg.get_srcComponent()})")
# Immediately send heartbeat so GCS locks on with zero delay
base_mode = (
mavutil.mavlink.MAV_MODE_FLAG_CUSTOM_MODE_ENABLED
| mavutil.mavlink.MAV_MODE_FLAG_MANUAL_INPUT_ENABLED
| mavutil.mavlink.MAV_MODE_FLAG_STABILIZE_ENABLED
)
if self.armed:
base_mode |= mavutil.mavlink.MAV_MODE_FLAG_SAFETY_ARMED
conn.mav.heartbeat_send(
mavutil.mavlink.MAV_TYPE_SUBMARINE,
mavutil.mavlink.MAV_AUTOPILOT_ARDUPILOTMEGA,
base_mode,
self.mode,
self.system_status,
)
mtype = msg.get_type()
if mtype == "HEARTBEAT":
self.target_system = msg.get_srcSystem()
self.target_component = msg.get_srcComponent()
elif mtype == "COMMAND_LONG":
cmd = int(msg.command)
if cmd == mavutil.mavlink.MAV_CMD_COMPONENT_ARM_DISARM:
should_arm = bool(msg.param1 == 1.0)
self.armed = should_arm
conn.mav.command_ack_send(cmd, mavutil.mavlink.MAV_RESULT_ACCEPTED)
status_str = "Motors ARMED" if self.armed else "Motors DISARMED"
self.log_event(f"Command: {status_str}")
conn.mav.statustext_send(
mavutil.mavlink.MAV_SEVERITY_INFO,
status_str.encode("ascii"),
)
elif cmd == mavutil.mavlink.MAV_CMD_DO_SET_MODE:
self.mode = int(msg.param2)
conn.mav.command_ack_send(cmd, mavutil.mavlink.MAV_RESULT_ACCEPTED)
self.log_event(f"Command: SET_MODE {self.mode}")
conn.mav.statustext_send(
mavutil.mavlink.MAV_SEVERITY_INFO,
b"Mode: MANUAL",
)
elif cmd == mavutil.mavlink.MAV_CMD_SET_MESSAGE_INTERVAL:
msg_id = int(msg.param1)
conn.mav.command_ack_send(cmd, mavutil.mavlink.MAV_RESULT_ACCEPTED)
self.log_event(f"Stream interval set for MsgID {msg_id}")
else:
conn.mav.command_ack_send(cmd, mavutil.mavlink.MAV_RESULT_ACCEPTED)
elif mtype == "SET_MODE":
self.mode = int(msg.custom_mode)
self.log_event(f"Set custom mode -> {self.mode}")
conn.mav.statustext_send(mavutil.mavlink.MAV_SEVERITY_INFO, b"Mode: MANUAL")
elif mtype == "REQUEST_DATA_STREAM":
pass # Acknowledged implicitly by sending streams
elif mtype == "PARAM_REQUEST_READ":
param_id = msg.param_id
if isinstance(param_id, bytes):
param_id = param_id.decode("ascii", errors="ignore").rstrip("\x00")
if param_id in self.params:
val, ptype = self.params[param_id]
else:
val, ptype = (0.0, mavutil.mavlink.MAV_PARAM_TYPE_REAL32)
conn.mav.param_value_send(
param_id.encode("ascii"),
float(val),
ptype,
len(self.params),
0,
)
self.log_event(f"Param read: {param_id} = {val}")
elif mtype == "PARAM_REQUEST_LIST":
self.log_event("Param list requested, streaming all...")
for idx, (p_name, (p_val, p_type)) in enumerate(self.params.items()):
conn.mav.param_value_send(
p_name.encode("ascii"),
float(p_val),
p_type,
len(self.params),
idx,
)
time.sleep(0.002)
elif mtype == "PARAM_SET":
param_id = msg.param_id
if isinstance(param_id, bytes):
param_id = param_id.decode("ascii", errors="ignore").rstrip("\x00")
new_val = float(msg.param_value)
ptype = int(msg.param_type)
self.params[param_id] = (new_val, ptype)
conn.mav.param_value_send(
param_id.encode("ascii"),
new_val,
ptype,
len(self.params),
0,
)
self.log_event(f"Param updated: {param_id} -> {int(new_val) if new_val.is_integer() else new_val}")
elif mtype == "MANUAL_CONTROL":
self.controls.x = float(msg.x)
self.controls.y = float(msg.y)
self.controls.z = float(msg.z)
self.controls.r = float(msg.r)
self.controls.last_rx = time.monotonic()
def _render_dashboard(self):
# Clear screen ANSI
out = sys.stdout
out.write("\033[H")
# Color codes
GREEN = "\033[92m"
RED = "\033[91m"
YELLOW = "\033[93m"
CYAN = "\033[96m"
BOLD = "\033[1m"
DIM = "\033[2m"
RESET = "\033[0m"
def bar(val: float, min_v: float, max_v: float, width: int = 14) -> str:
norm = max(0.0, min(1.0, (val - min_v) / (max_v - min_v)))
pos = int(norm * width)
return f"[{'=' * pos}{' ' * (width - pos)}]"
status_tag = f"{GREEN}{BOLD} ARMED {RESET}" if self.armed else f"{YELLOW}DISARMED{RESET}"
link_tag = f"{GREEN}ONLINE{RESET}" if self.client_connected else f"{RED}WAITING{RESET}"
lines = [
f"{BOLD}{CYAN}========================================================================{RESET}",
f"{BOLD} ArduSub SimpleROV-3 Flight Controller Spoofer & Telemetry Simulator {RESET}",
f"{BOLD}{CYAN}========================================================================{RESET}",
f" Endpoint: {BOLD}{self.endpoint}{RESET} Baud: {self.baud} Link: {link_tag} State: {status_tag} Mode: {BOLD}MANUAL{RESET}",
f"{CYAN}------------------------------------------------------------------------{RESET}",
f" {BOLD}BATTERY & POWER:{RESET}",
f" Voltage: {BOLD}{self.battery_base_v:.2f} V{RESET} ({self.battery_cells}S) Current: {BOLD}{self.current_a:.2f} A{RESET} Power: {BOLD}{self.battery_base_v * self.current_a:.1f} W{RESET}",
f" Depth: {BOLD}{self.depth_m:.2f} m{RESET} Heading: {BOLD}{self.heading_deg:.0f}°{RESET} Roll/Pitch: {self.roll_deg:+.1f}° / {self.pitch_deg:+.1f}°",
f"{CYAN}------------------------------------------------------------------------{RESET}",
f" {BOLD}INCOMING MANUAL CONTROLS (from Sub Control app):{RESET}",
f" Surge (Forward/Back): {self.controls.x:+5.0f} {bar(self.controls.x, -1000, 1000)}",
f" Yaw (Steer/Turn): {self.controls.r:+5.0f} {bar(self.controls.r, -1000, 1000)}",
f" Heave (Vertical): {self.controls.z:5.0f} {bar(self.controls.z, 0, 1000)}",
f"{CYAN}------------------------------------------------------------------------{RESET}",
f" {BOLD}SIMULATED THRUSTER PWM OUTPUTS:{RESET}",
f" Motor 1 (Port / Left): {self.m1_pwm:4d} µs {bar(self.m1_pwm, 1100, 1900)}",
f" Motor 2 (Starboard / Right): {self.m2_pwm:4d} µs {bar(self.m2_pwm, 1100, 1900)}",
f" Motor 3 (Vertical / Center): {self.m3_pwm:4d} µs {bar(self.m3_pwm, 1100, 1900)}",
f"{CYAN}------------------------------------------------------------------------{RESET}",
f" {BOLD}RECENT EVENT LOG:{RESET}",
]
if not self.events:
lines.append(f" {DIM}No events yet. Connect Sub Control app to begin.{RESET}")
else:
for ev in list(self.events)[-5:]:
lines.append(f" {ev}")
lines.append(f"{CYAN}------------------------------------------------------------------------{RESET}")
lines.append(f" Packets RX: {self.total_packets_rx:<8d} Packets TX: {self.total_packets_tx:<8d} (Press Ctrl+C to exit)")
out.write("\n".join(lines) + "\n")
out.flush()
def main():
parser = argparse.ArgumentParser(
description="ArduSub Flight Controller (FC) Spoofer & Telemetry Simulator",
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
)
parser.add_argument(
"--endpoint",
type=str,
default="tcpin:127.0.0.1:5760",
help="MAVLink connection string (e.g. 'tcpin:127.0.0.1:5760', 'udpin:127.0.0.1:14550', 'COM3')",
)
parser.add_argument(
"--baud",
type=int,
default=57600,
help="Baud rate (for serial COM ports)",
)
parser.add_argument(
"--cells",
type=int,
default=4,
help="Battery cell count (4S = 16.0V nominal)",
)
parser.add_argument(
"--voltage",
type=float,
default=15.8,
help="Nominal starting battery voltage (V)",
)
parser.add_argument(
"--no-dashboard",
action="store_true",
help="Disable interactive terminal dashboard and use plain output",
)
args = parser.parse_args()
# Enable ANSI escape sequences on Windows console
if os.name == "nt":
os.system("")
spoofer = FlightControllerSpoofer(
endpoint=args.endpoint,
baud=args.baud,
battery_cells=args.cells,
nominal_voltage=args.voltage,
show_dashboard=not args.no_dashboard,
)
spoofer.run()
if __name__ == "__main__":
main()
+9
View File
@@ -0,0 +1,9 @@
@echo off
title ArduSub FC Spoofer
cd /d "%~dp0"
if exist ".venv\Scripts\python.exe" (
".venv\Scripts\python.exe" -u fc_spoofer.py %*
) else (
python -u fc_spoofer.py %*
)
pause
+20
View File
@@ -0,0 +1,20 @@
$ErrorActionPreference = "Stop"
$projectRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
Set-Location -LiteralPath $projectRoot
$existingListener = Get-NetTCPConnection -LocalPort 5760 -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1
if ($existingListener) {
$existingProcess = Get-CimInstance Win32_Process -Filter "ProcessId=$($existingListener.OwningProcess)" -ErrorAction SilentlyContinue
Write-Host ""
Write-Host "The simulator is already LISTENING on tcp:127.0.0.1:5760." -ForegroundColor Green
Write-Host "Process: $($existingListener.OwningProcess) $($existingProcess.Name)"
Write-Host "Leave that simulator running and select 'Simulator (TCP 5760)' in SUB Control."
Write-Host ""
exit 0
}
if (Test-Path -LiteralPath ".venv\Scripts\python.exe") {
& .venv\Scripts\python.exe -u fc_spoofer.py @args
} else {
& python -u fc_spoofer.py @args
}
+207
View File
@@ -0,0 +1,207 @@
// Deterministic DOM/pointer harness. No browser, vehicle, or serial port involved.
const {test}=require('node:test');
const assert=require('node:assert/strict');
const fs=require('node:fs'),vm=require('node:vm'),path=require('node:path');
const {present}=require('../web/control-state.js');
const armed={connected:true,armed_known:true,armed:true,app_state:'armed',servos:{}};
test('native pywebview result wrapper can execute alongside app globals',()=>{
const h=harness();
// Window.evaluate_js declares these variables in the page's global scope.
// A global const/let collision prevents every Python reply from executing.
assert.doesNotThrow(()=>h.run(`var value; try { value = ['COM3','COM4']; }
catch(e) { value = {name:e.name}; var keys = Object.getOwnPropertyNames(e);
keys.forEach(function(key) { value[key] = e[key]; }); }
populatePorts(value);`));
assert.ok(h.el('#port').options.some(p=>p.value==='COM3'));
});
test('an empty pywebview API cannot permanently latch startup as completed',async()=>{
const h=harness();h.context.pywebview.api={};await h.run('boot()');
assert.equal(h.run('booted'),false);assert.equal(h.run('bridgeReady'),false);
});
test('desktop startup retries after failure and populates detected COM ports',async()=>{
const h=harness();let attempts=0;
h.run('loadDefaultModel=()=>Promise.resolve(false)');
h.context.pywebview.api.refresh_ports=()=>Promise.resolve([{device:'COM3',label:'COM3'},{device:'COM4',label:'COM4'}]);
h.context.pywebview.api.initial_state=()=>++attempts===1?Promise.reject(Error('Temporary startup failure')):Promise.resolve({ports:['COM3','COM4'],battery_config:{},state:{connected:false,armed:null,servos:{}}});
h.context.pywebview.api.poll=()=>Promise.resolve({connected:false,armed:null,servos:{}});
await h.run('boot()');assert.equal(h.run('booted'),false);
assert.ok(h.el('#port').options.some(p=>p.value==='COM3'),'COM discovery survives a configuration startup failure');
await h.run('boot()');assert.equal(h.run('booted'),true);
assert.ok(h.el('#port').options.some(p=>p.value==='COM3'));
assert.ok(h.el('#port').options.some(p=>p.value==='COM4'));
});
test('visual status changes cannot resize the header or percentage label',()=>{
const html=fs.readFileSync(path.join(__dirname,'../web/index.html'),'utf8');
const css=fs.readFileSync(path.join(__dirname,'../web/styles.css'),'utf8');
assert.doesNotMatch(html,/class="water-ring/);
assert.match(html,/class="engage-sheen-label"/);
assert.match(css,/padding-bottom:\.22em; margin-bottom:-\.22em/);
assert.match(css,/grid-template-rows:120px minmax\(0,1fr\) 52px/);
assert.match(css,/\.control-status \{ position:absolute/);
assert.match(css,/flex:0 0 128px; width:128px/);
assert.match(css,/\.vertical-card footer \{ justify-content:center/);
assert.match(css,/mask-image:linear-gradient\(to right,#000 calc\(var\(--engage-progress\) - 8%\),transparent var\(--engage-progress\)\)/);
});
test('activity renders oldest at top, latest above its footer; both control states sweep',()=>{
const html=fs.readFileSync(path.join(__dirname,'../web/index.html'),'utf8');
assert.ok(html.indexOf('id="activity-oldest"')<html.indexOf('id="activity-previous"'));
assert.ok(html.indexOf('id="activity-previous"')<html.indexOf('id="status-text"'));
assert.ok(html.indexOf('id="status-text"')<html.indexOf('class="activity-heading"'));
const css=fs.readFileSync(path.join(__dirname,'../web/styles.css'),'utf8');
assert.match(css,/#engage:is\(\.arm,\.danger\).*animation:engage-sweep/);
});
test('native drag surface has no interactive descendants; output limit is outside sidebar',()=>{
const html=fs.readFileSync(path.join(__dirname,'../web/index.html'),'utf8');
assert.match(html,/<aside class="sidebar-deck"/);
assert.match(html,/<div class="sidebar-drag-surface pywebview-drag-region" aria-hidden="true"><\/div>/);
assert.ok(html.indexOf('id="gain"')>html.indexOf('</aside>'));
});
function harness(){
let now=100,id=0;const timers=new Map(),elements=new Map(),listeners=new Map();
class Element{
constructor(name){this.name=name;this.dataset={};this.style={setProperty(k,v){this[k]=v}};this.classes=new Set();this.classList={add:(...n)=>n.forEach(x=>this.classes.add(x)),remove:(...n)=>n.forEach(x=>this.classes.delete(x)),contains:n=>this.classes.has(n),toggle:(n,b)=>b?this.classes.add(n):this.classes.delete(n)};this.handlers={};this.captured=new Set();this.value='demo';this.options=[];this.tagName='DIV';this.children=[];this.textContent='';}
addEventListener(n,fn){(this.handlers[n]??=[]).push(fn)}
removeEventListener(){}
emit(n,fields={}){const e={pointerId:1,button:0,pointerType:'touch',clientX:160,clientY:160,preventDefault(){},target:this,...fields};for(const fn of this.handlers[n]||[])fn(e);return e}
getBoundingClientRect(){return this.name==='#heave'?{left:0,top:0,width:48,height:300,right:48,bottom:300}:this.name==='#engage'?{left:0,top:0,width:180,height:44,right:180,bottom:44}:{left:0,top:0,width:320,height:320,right:320,bottom:320}}
setPointerCapture(p){this.captured.add(p)}
releasePointerCapture(p){if(this.captured.delete(p))this.emit('lostpointercapture',{pointerId:p})}
getContext(){return new Proxy({}, {get:()=>()=>{}})}
querySelector(s){return el(this.name+' '+s)}
setAttribute(k,v){this[k]=v}
append(n){this.children.push(n)}
add(n){this.options.push(n)}
closest(){return null}
click(){if(!this.disabled)this.onclick?.({target:this,currentTarget:this})}
}
function el(n){if(!elements.has(n))elements.set(n,new Element(n));return elements.get(n)}
const document={querySelector:el,querySelectorAll:()=>[],getElementById:n=>el('#'+n),createElement:n=>new Element(n),body:el('body'),readyState:'loading',hidden:false,hasFocus:()=>true,addEventListener(n,f){listeners.set('doc:'+n,f)}};
const schedule=(fn,ms,interval=0)=>{timers.set(++id,{fn,at:now+ms,interval});return id};
const calls=[];
const context=vm.createContext({console,document,devicePixelRatio:1,performance:{now:()=>now},setTimeout:(f,ms=0)=>schedule(f,ms),clearTimeout:i=>timers.delete(i),setInterval:(f,ms)=>schedule(f,ms,ms),requestAnimationFrame:f=>schedule(()=>f(now),16),cancelAnimationFrame:i=>timers.delete(i),addEventListener(n,f){listeners.set(n,f)},Option:function(label,value){this.label=label;this.value=value},fetch:()=>Promise.reject(Error('No network in unit tests'))});
context.window=context;
context.pywebview={api:{set_axes:(...a)=>{calls.push(['axes',...a]);return Promise.resolve()},stop_controls:(...a)=>{calls.push(['stop',...a]);return Promise.resolve()},command:(name)=>{calls.push(['command',name]);return Promise.resolve(true)}}};
for(const f of ['control-state.js','joystick-3d.js','app.js'])vm.runInContext(fs.readFileSync(path.join(__dirname,'../web',f),'utf8'),context,{filename:f});
function run(s){return vm.runInContext(s,context)}
function advance(ms){const end=now+ms;while(true){let next=[...timers].filter(([,t])=>t.at<=end).sort((a,b)=>a[1].at-b[1].at)[0];if(!next)break;const [i,t]=next;now=t.at;timers.delete(i);if(t.interval)timers.set(i,{...t,at:now+t.interval});t.fn()}now=end;}
function state(s=armed){context.snapshot=s;run('render(snapshot)')}
return {el,run,advance,state,calls,context,fire:(name,e={})=>listeners.get(name)?.(e)};
}
test('pending/timeout/unknown presentation never invents armed state',()=>{
let p=present({...armed,pending_command:'disarm'});
assert.equal(p.label,'DISARMING…');assert.equal(p.drive,false);
p=present({...armed,command_error:'Disarm confirmation timed out.'});
assert.equal(p.label,'DISARM');assert.match(p.message,/Vehicle armed/);
p=present({...armed,armed:false,pending_command:'arm'});
assert.equal(p.label,'ENGAGING…');assert.equal(p.drive,false);
p=present({...armed,armed:false,command_error:'Engage failed'});
assert.equal(p.label,'ENGAGE');assert.equal(p.action,'arm');
p=present({...armed,connected:false,armed_known:false});
assert.equal(p.label,'STATE UNKNOWN');assert.equal(p.drive,false);
});
test('activity history keeps three unique recent events, latest first',()=>{
const h=harness();h.run("recordActivity('One');recordActivity('Two');recordActivity('Three');recordActivity('Three');recordActivity('Four')");
assert.equal(h.el('#status-text').textContent,'Four');assert.equal(h.el('#activity-previous').textContent,'Three');assert.equal(h.el('#activity-oldest').textContent,'Two');
});
test('output limit drag is bounded, owns one pointer and does not recenter',()=>{
const h=harness(),slider=h.el('#gain');slider.min='5';slider.max='50';slider.value='20';
slider.emit('pointerdown',{pointerId:1,clientX:9});assert.equal(h.el('#gain-value').textContent,'5%');
slider.emit('pointermove',{pointerId:2,clientX:311});assert.equal(h.el('#gain-value').textContent,'5%');
slider.emit('pointermove',{pointerId:1,clientX:311});assert.equal(h.el('#gain-value').textContent,'50%');
slider.emit('pointerup');h.advance(500);assert.equal(h.el('#gain-value').textContent,'50%');
});
test('maximum travel, diagonal and smooth touch return',()=>{
const h=harness();h.state();const c=h.el('#joystick-canvas');
c.emit('pointerdown',{clientX:1000,clientY:160});
assert.equal(h.context.joystick3D.getState().x,1);
c.emit('pointermove',{clientX:1000,clientY:1000});
const s=h.context.joystick3D.getState();assert.ok(Math.abs(s.x-Math.SQRT1_2)<1e-6);assert.ok(Math.abs(s.y-Math.SQRT1_2)<1e-6);
c.emit('pointerup');h.advance(180);assert.ok(h.context.joystick3D.getState().x>0);h.advance(220);assert.equal(h.context.joystick3D.getState().x,0);
});
test('independent multi-touch: second joystick finger ignored; heave continues on joystick release',()=>{
const h=harness();h.state();const c=h.el('#joystick-canvas'),v=h.el('#heave');
c.emit('pointerdown',{pointerId:1,clientX:250});
v.emit('pointerdown',{pointerId:2,clientY:0});
const x=h.context.joystick3D.getState().x;
c.emit('pointerdown',{pointerId:3,clientX:0});c.emit('pointerup',{pointerId:3});
assert.equal(h.context.joystick3D.getState().x,x);
c.emit('pointerup',{pointerId:1});h.advance(400);
assert.equal(h.context.joystick3D.getState().x,0);assert.equal(h.context.verticalControl.getState().value,1);
v.emit('pointercancel',{pointerId:2});h.advance(450);assert.equal(h.context.verticalControl.getState().value,0);
});
test('mouse leaving joystick returns to center',()=>{
const h=harness();h.state();const c=h.el('#joystick-canvas');
c.emit('pointerdown',{pointerType:'mouse',clientX:300});c.emit('pointerleave',{pointerType:'mouse'});h.advance(400);
assert.equal(h.context.joystick3D.getState().x,0);
});
test('focus loss clears both captured pointers and prevents replay',()=>{
const h=harness();h.state();h.el('#joystick-canvas').emit('pointerdown',{clientX:300});h.el('#heave').emit('pointerdown',{pointerId:2,clientY:0});
h.fire('blur');h.advance(500);
assert.equal(h.context.joystick3D.getState().x,0);assert.equal(h.context.verticalControl.getState().value,0);
assert.equal(h.context.joystick3D.getState().active,false);assert.equal(h.context.verticalControl.getState().active,false);
assert.equal(h.run('view.drive'),false);h.fire('focus');assert.equal(h.run('control.forward+control.turn+control.vertical'),0);
});
test('lost connection during commanded joystick clears command and shows unknown',()=>{
const h=harness();h.state();h.el('#joystick-canvas').emit('pointerdown',{clientX:300});
h.state({...armed,connected:false,armed_known:false,app_state:'disconnected'});
assert.equal(h.context.joystick3D.getState().x,0);assert.equal(h.el('#engage-label').textContent,'state unknown');
h.state();assert.equal(h.run('control.turn'),0);
});
test('half hold cancels on touch interruption and focus loss; extra fingers cannot complete it',()=>{
const h=harness();h.state({...armed,armed:false,app_state:'connected_disarmed'});const b=h.el('#engage');
b.emit('pointerdown');h.advance(600);b.emit('pointerdown',{pointerId:2});b.emit('pointerup',{pointerId:2});
assert.equal(h.run('engagePointer'),1);b.emit('pointercancel');h.advance(1500);assert.equal(h.calls.length,0);
b.emit('pointerdown');h.advance(600);h.fire('blur');h.advance(1500);assert.equal(h.calls.length,0);
});
test('demo full hold waits for simulated confirmation; release cannot immediately disarm',()=>{
const h=harness();h.run('demoActive=true;demoState=makeDemoState();render(demoState)');const b=h.el('#engage');
b.emit('pointerdown');h.advance(1450);assert.equal(h.el('#engage-label').textContent,'engaging…');assert.equal(h.run('demoState.armed'),false);
b.emit('pointerup');b.click();h.advance(700);assert.equal(h.el('#engage-label').textContent,'disengage');
b.click();assert.equal(h.el('#engage-label').textContent,'disengaging…');assert.equal(h.run('demoState.armed'),true);
h.advance(700);assert.equal(h.el('#engage-label').textContent,'engage');
});
test('real send result and accepted ACK cannot optimistically arm; timeout is visible',async()=>{
const h=harness();h.state({...armed,armed:false,app_state:'connected_disarmed'});
h.run("requestControl('arm')");await Promise.resolve();assert.equal(h.el('#engage-label').textContent,'engaging…');
assert.equal(h.run('vehicleSnapshot.armed'),false);h.advance(6100);
assert.equal(h.el('#engage-label').textContent,'engage');assert.match(h.el('#control-status').textContent,/timed out/);
});
test('bridge freeze clears input even if armed snapshot has not changed',()=>{
const h=harness();h.state();h.el('#joystick-canvas').emit('pointerdown',{clientX:300});h.run('markBridgeStale()');
assert.equal(h.context.joystick3D.getState().x,0);assert.equal(h.el('#engage-label').textContent,'state unknown');
assert.match(h.el('#control-status').textContent,/Telemetry unavailable/);
});
test('hung bridge poll has an independent watchdog and cannot trap the controls',async()=>{
const h=harness();h.state();h.context.pywebview.api.poll=()=>new Promise(()=>{});
h.run('bridgeReady=true;lastBridgeAt=performance.now();poll()');
h.el('#joystick-canvas').emit('pointerdown',{clientX:300});
h.advance(1700);await Promise.resolve();
assert.equal(h.run('view.drive'),false);assert.equal(h.el('#engage-label').textContent,'state unknown');
assert.equal(h.context.joystick3D.getState().x,0);
});
test('holding through confirmation then releasing outside cannot swallow the next disarm tap',()=>{
const h=harness();h.run('demoActive=true;demoState=makeDemoState();render(demoState)');const b=h.el('#engage');
b.emit('pointerdown');h.advance(2300);assert.equal(h.el('#engage-label').textContent,'disengage');assert.ok(b.captured.has(1));
b.emit('pointerup',{clientX:500});h.advance(1);b.click();assert.equal(h.el('#engage-label').textContent,'disengaging…');
});
+158
View File
@@ -0,0 +1,158 @@
"""No serial port is opened by these tests."""
import queue
import unittest
from unittest.mock import Mock, patch
from app import Axes, MavlinkWorker, NEUTRAL_Z
from vehicle_state import VehicleState
from web_ui import ControlApi
class VehicleStateTests(unittest.TestCase):
def setUp(self):
self.now = 10.0
self.state = VehicleState(lambda: self.now)
self.state.heartbeat(False)
def test_ack_is_not_armed_confirmation(self):
self.state.request("arm")
self.now += .2
self.state.ack("MAV_RESULT_ACCEPTED")
self.assertFalse(self.state.snapshot()["armed"])
self.assertEqual(self.state.snapshot()["pending_command"], "arm")
self.state.heartbeat(True)
self.assertTrue(self.state.snapshot()["controls_allowed"])
def test_heartbeat_confirms_even_when_ack_packet_is_lost(self):
self.state.request("arm")
self.now += .5
self.state.heartbeat(True)
self.assertIsNone(self.state.pending)
self.assertTrue(self.state.armed)
def test_old_events_cannot_confirm_new_request(self):
self.state.request("arm")
self.state.ack("MAV_RESULT_DENIED", received_at=9)
self.state.heartbeat(True, received_at=9)
self.assertIsNotNone(self.state.pending)
self.assertFalse(self.state.armed)
def test_rejected_engage_stays_disarmed(self):
self.state.request("arm")
self.now += .1
self.state.ack("MAV_RESULT_DENIED")
self.assertIsNone(self.state.pending)
self.assertIn("rejected", self.state.error)
self.assertFalse(self.state.armed)
def test_engage_timeout_then_late_armed_heartbeat_is_truth(self):
self.state.request("arm")
self.now += 6
self.state.tick()
self.assertIn("timed out", self.state.error)
self.assertFalse(self.state.snapshot()["armed_known"])
self.state.heartbeat(True)
self.assertTrue(self.state.snapshot()["armed"])
def test_disarm_timeout_never_claims_disarmed(self):
self.state.heartbeat(True)
self.state.request("disarm")
self.now += 6
self.state.heartbeat(True)
snap = self.state.snapshot()
self.assertTrue(snap["armed"])
self.assertIn("timed out", snap["command_error"])
def test_disarm_ack_waits_for_disarmed_heartbeat(self):
self.state.heartbeat(True)
self.state.request("disarm")
self.now += .2
self.state.ack("MAV_RESULT_ACCEPTED")
self.assertTrue(self.state.snapshot()["armed"])
self.assertFalse(self.state.snapshot()["controls_allowed"])
self.state.heartbeat(False)
self.assertIsNone(self.state.pending)
self.assertFalse(self.state.armed)
def test_disconnect_preserves_last_known_armed_but_invalidates_it(self):
self.state.heartbeat(True)
self.state.disconnect()
snap = self.state.snapshot()
self.assertTrue(snap["armed"])
self.assertFalse(snap["armed_known"])
self.assertFalse(snap["controls_allowed"])
def test_frozen_telemetry_blocks_new_engage(self):
self.now += 3.1
self.assertFalse(self.state.snapshot()["armed_known"])
with self.assertRaises(RuntimeError):
self.state.request("arm")
class WorkerWatchdogTests(unittest.TestCase):
def setUp(self):
self.worker = MavlinkWorker(queue.Queue())
self.worker.require_input_lease = True
self.worker.connection = Mock()
self.worker.target_system = 1
self.worker.armed = True
self.worker.last_heartbeat = 10
def assertNeutral(self):
self.worker._send_control()
self.assertEqual(self.worker.connection.mav.manual_control_send.call_args.args,
(1, 0, 0, NEUTRAL_Z, 0, 0))
def test_ui_freeze_neutralizes_and_does_not_replay(self):
with patch("app.time.monotonic", return_value=10):
self.worker.set_axes(Axes(forward=1), 1)
self.worker._send_control()
self.assertGreater(self.worker.output_axes.forward, 0)
with patch("app.time.monotonic", return_value=11.1):
self.assertNeutral()
self.assertEqual(self.worker.axes, Axes())
def test_heartbeat_freeze_neutralizes_even_with_live_ui(self):
with patch("app.time.monotonic", return_value=13.1):
self.worker.set_axes(Axes(forward=1), 1)
self.assertNeutral()
def test_stale_queued_input_cannot_override_neutral(self):
self.worker.set_axes(Axes(), 20)
self.worker.set_axes(Axes(forward=1), 19)
self.assertEqual(self.worker.axes, Axes())
class BridgeTests(unittest.TestCase):
def test_delayed_arm_after_focus_reset_or_expiry_is_not_sent(self):
api = ControlApi()
api.worker.connection = Mock()
api.worker.target_system = 1
api._consume(("heartbeat", 1, "ARDUPILOT", "MANUAL", False))
api.worker.set_axes(Axes(), 20)
with self.assertRaisesRegex(RuntimeError, "newer input reset"):
api.command("arm", 19)
with self.assertRaisesRegex(RuntimeError, "expired"):
api.command("arm", 21, expires_at=0)
api.worker.connection.mav.command_long_send.assert_not_called()
def test_real_bridge_ack_pending_disconnect_and_stale_battery(self):
api = ControlApi()
api.worker.connection = Mock()
api.worker.target_system = 1
api._consume(("heartbeat", 1, "ARDUPILOT", "MANUAL", False))
pending = api.command("arm")
self.assertEqual(pending["pending_command"], "arm")
self.assertFalse(pending["armed"])
api._consume(("ack", 400, "MAV_RESULT_ACCEPTED"))
self.assertFalse(api._snapshot()["armed"])
api._consume(("heartbeat", 1, "ARDUPILOT", "MANUAL", True))
self.assertTrue(api._snapshot()["armed"])
api._consume(("disconnected", "Disconnected"))
self.assertTrue(api._snapshot()["armed"])
self.assertFalse(api._snapshot()["armed_known"])
self.assertTrue(api._snapshot()["battery_stale"])
if __name__ == "__main__":
unittest.main()
+75
View File
@@ -0,0 +1,75 @@
"""Vehicle truth and command transactions; independent of UI/animation clocks."""
import time
class VehicleState:
HEARTBEAT_TIMEOUT = 3.0
COMMAND_TIMEOUT = 5.0
def __init__(self, clock=time.monotonic):
self.clock = clock
self.connected = False
self.armed = None
self.heartbeat_at = None
self.pending = None
self.error = ""
def fresh(self):
return (self.connected and self.heartbeat_at is not None
and self.clock() - self.heartbeat_at <= self.HEARTBEAT_TIMEOUT)
def heartbeat(self, armed, received_at=None):
received_at = self.clock() if received_at is None else received_at
if self.heartbeat_at is not None and received_at < self.heartbeat_at:
return
self.connected = True
self.heartbeat_at = received_at
self.armed = bool(armed)
# A fresh heartbeat is the authoritative confirmation of actual state.
# ACK ACCEPTED alone only confirms that a command was accepted.
if (self.pending and received_at > self.pending["started"]
and self.armed == (self.pending["name"] == "arm")):
self.pending = None
def request(self, name):
self.tick()
if name == "arm" and (not self.fresh() or self.armed is not False):
raise RuntimeError("Fresh, disarmed vehicle telemetry is required to engage")
if self.pending and (name == "arm" or self.pending["name"] == name):
raise RuntimeError("A vehicle confirmation is already pending")
now = self.clock()
self.pending = {"name": name, "started": now,
"deadline": now + self.COMMAND_TIMEOUT, "ack": None}
self.error = ""
def ack(self, result, received_at=None):
received_at = self.clock() if received_at is None else received_at
if not self.pending or received_at <= self.pending["started"]:
return
self.pending["ack"] = result
if result not in ("MAV_RESULT_ACCEPTED", "MAV_RESULT_IN_PROGRESS"):
self.fail(f"{self.pending['name'].capitalize()} rejected: {result}")
def fail(self, message):
self.error = message
self.pending = None
def disconnect(self):
self.connected = False
if self.pending:
self.fail("Connection lost before vehicle confirmation. State unknown.")
def tick(self):
if self.pending and self.clock() >= self.pending["deadline"]:
name = self.pending["name"].capitalize()
self.fail(f"{name} confirmation timed out. Check current vehicle state.")
def snapshot(self):
self.tick()
fresh = self.fresh()
return {"armed": self.armed, "armed_known": fresh,
"heartbeat_age": None if self.heartbeat_at is None else self.clock() - self.heartbeat_at,
"pending_command": self.pending["name"] if self.pending else None,
"command_ack": self.pending["ack"] if self.pending else None,
"command_error": self.error,
"controls_allowed": fresh and self.armed is True and self.pending is None}
+387 -74
View File
@@ -1,26 +1,115 @@
const STATUS_LABELS = {
disconnected: 'Disconnected — select a port and connect',
connecting: 'Connecting…',
connected_disarmed: 'Connected · Disarmed',
armed: 'Connected · Armed',
link_degraded: 'Signal lost — check tether',
};
const $=s=>document.querySelector(s), $$=s=>[...document.querySelectorAll(s)]; 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 control={forward:0,turn:0,vertical:0}, pressedKeys=new Set(); let bridgeReady=false, appState='disconnected', batteryConfig={}, servoSignature='', lastAxisSend=0, axisTimer=0, demoActive=false, demoState=null;
const api=(name,...args)=>window.pywebview.api[name](...args); const api=(name,...args)=>window.pywebview.api[name](...args);
addEventListener('pointermove',e=>{const button=e.target.closest?.('button:not(.battery-card), .stick');if(!button)return;const rect=button.getBoundingClientRect();button.style.setProperty('--glow-x',`${e.clientX-rect.left}px`);button.style.setProperty('--glow-y',`${e.clientY-rect.top}px`)}); const activity=[];
let lastReportedStatus='';
function recordActivity(message){
if(!message||activity[0]===message)return;
activity.unshift(String(message));activity.length=Math.min(activity.length,3);
['#status-text','#activity-previous','#activity-oldest'].forEach((selector,i)=>{
const row=$(selector);if(row){row.textContent=activity[i]||'';row.hidden=!activity[i]}
});
}
function withTimeout(promise,ms=1500){return new Promise((resolve,reject)=>{const timer=setTimeout(()=>reject(new Error('Control bridge timed out')),ms);Promise.resolve(promise).then(value=>{clearTimeout(timer);resolve(value)},error=>{clearTimeout(timer);reject(error)})})}
let vehicleSnapshot={connected:false,armed:null}, view=ControlState.present(vehicleSnapshot), inputFocused=true,
bridgeStale=false,lastBridgeAt=performance.now(),axisBusy=false,inputSequence=Date.now(),
localRequest=null,commandError='',requestSerial=0,demoCommandTimer=0,lastSafetyTick=performance.now();
const canDrive=()=>view.drive&&performance.now()-lastSafetyTick<750;
window.canDrive=canDrive;
function makeDemoState(){const servos={};for(let i=1;i<=16;i++)servos[String(i)]={function:i<=3?32+i:0,label:i<=3?`Motor ${i}`:'Unassigned',motor:i<=3?i:null,direction:i<=3?1:null};return {connected:true,link:'LINK OK',vehicle:'DEMO • DISARMED',armed:false,armed_known:true,mode:'MANUAL',app_state:'connected_disarmed',status:'Demo mode ready — no hardware or spoofer.',battery:{voltage:15.8,per_cell:3.95,percent:72,cells:4,low:false},servos}}
function updateDemo(patch){Object.assign(demoState,patch);render(demoState)}
document.addEventListener('click',e=>{const btn=e.target.closest('.num-step');if(!btn)return;const pill=btn.closest('.number-pill');if(!pill)return;const input=pill.querySelector('input');if(!input)return;const step=+input.step||1,min=input.min!==''?+input.min:-Infinity,max=input.max!==''?+input.max:Infinity;let val=+input.value||0;if(btn.classList.contains('down'))val=Math.max(min,+(val-step).toFixed(4));else val=Math.min(max,+(val+step).toFixed(4));input.value=val;input.dispatchEvent(new Event('input',{bubbles:true}));input.dispatchEvent(new Event('change',{bubbles:true}))}); document.addEventListener('click',e=>{const btn=e.target.closest('.num-step');if(!btn)return;const pill=btn.closest('.number-pill');if(!pill)return;const input=pill.querySelector('input');if(!input)return;const step=+input.step||1,min=input.min!==''?+input.min:-Infinity,max=input.max!==''?+input.max:Infinity;let val=+input.value||0;if(btn.classList.contains('down'))val=Math.max(min,+(val-step).toFixed(4));else val=Math.min(max,+(val+step).toFixed(4));input.value=val;input.dispatchEvent(new Event('input',{bubbles:true}));input.dispatchEvent(new Event('change',{bubbles:true}))});
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)} 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; 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 populatePorts(ports){
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)}} const p=$('#port');
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)} if(!p)return;
const old=p.value||'';
p.innerHTML='';
window.setJoyControl=(x,y)=>{control.turn=x;control.forward=-y;updateReadouts();sendAxes()}; const allList=[];
const seen=new Set();
const addPort=item=>{
let dev=item, lbl=item;
if(item&&typeof item==='object'){
dev=item.device||'';
lbl=item.label||dev;
}
if(dev&&!seen.has(dev)){
seen.add(dev);
allList.push({device:dev, label:lbl});
}
};
if(Array.isArray(ports)) ports.forEach(addPort);
addPort({device:'demo', label:'Demo Mode (no hardware)'});
let matched=false;
allList.forEach(item=>{
const opt=new Option(item.label, item.device);
p.add(opt);
if(item.device===old){
p.value=item.device;
matched=true;
}
});
if(!matched&&allList.length>0){
p.value=allList[0].device;
}
}
function sendAxes(){
if(!canDrive()||demoActive||!bridgeReady||axisBusy)return;
const transmit=()=>{
axisTimer=0;
if(!canDrive()||demoActive||axisBusy)return;
lastAxisSend=performance.now();axisBusy=true;
withTimeout(api('set_axes',control.forward,control.turn,control.vertical,++inputSequence))
.catch(()=>markBridgeStale()).finally(()=>axisBusy=false);
};
const wait=50-(performance.now()-lastAxisSend);
if(wait<=0)transmit();else if(!axisTimer)axisTimer=setTimeout(transmit,wait);
}
// This is a local bridge lease, not extra MAVLink traffic. The worker retains
// the configured wire rate and neutralizes if the UI stops renewing its input.
setInterval(()=>{if(canDrive())sendAxes()},250);
function updateReadouts(){ if(!canDrive()){$('#surge-readout').textContent='—';$('#yaw-readout').textContent='—';$('#heave-readout').textContent='—';return;}$('#surge-readout').textContent=Math.round(control.forward*100);$('#yaw-readout').textContent=Math.round(control.turn*100);$('#heave-readout').textContent=Math.round(control.vertical*100);}
window.setJoyControl=(x,y)=>{if(!canDrive())return;control.turn=x;control.forward=-y;updateReadouts();sendAxes();};
function releaseHandle(el){el.classList.remove('pressed');el.classList.add('released');setTimeout(()=>el.classList.remove('released'),520)} function releaseHandle(el){el.classList.remove('pressed');el.classList.add('released');setTimeout(()=>el.classList.remove('released'),520)}
const heave=$('#heave'),hknob=$('#heave-knob'),hfill=$('#heave-fill');let heaveDrag=false,heaveAnim=0; const heave=$('#heave'),hknob=$('#heave-knob'),hfill=$('#heave-fill');let heavePointer=null,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()} function setHeave(v,send=true){if(!canDrive()&&v!==0)return;v=Math.max(-1,Math.min(1,v));control.vertical=v;heave.dataset.commanded=String(Math.abs(v)>.03);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&&canDrive())sendAxes();}
function heaveEvent(e){const b=heave.getBoundingClientRect();setHeave((b.top+b.height/2-e.clientY)/(b.height*.43))} function heaveEvent(e){const b=heave.getBoundingClientRect();setHeave((b.top+b.height/2-e.clientY)/(b.height*.43))}
heave.addEventListener('pointerdown',e=>{heaveDrag=true;hknob.classList.remove('released');hknob.classList.add('pressed');cancelAnimationFrame(heaveAnim);heave.setPointerCapture(e.pointerId);heaveEvent(e)});heave.addEventListener('pointermove',e=>{if(heaveDrag)heaveEvent(e)});heave.addEventListener('pointerup',()=>{heaveDrag=false;releaseHandle(hknob);easeHeave()});heave.addEventListener('pointercancel',()=>{heaveDrag=false;releaseHandle(hknob);easeHeave()}); function releaseHeave(e){if(heavePointer===null||(e?.pointerId!=null&&e.pointerId!==heavePointer))return;const pointer=heavePointer;heavePointer=null;try{heave.releasePointerCapture(pointer)}catch(_){}releaseHandle(hknob);easeHeave()}
heave.addEventListener('pointerdown',e=>{if(!canDrive()||heavePointer!==null||e.button>0)return;heavePointer=e.pointerId;hknob.classList.remove('released');hknob.classList.add('pressed');cancelAnimationFrame(heaveAnim);heave.setPointerCapture(e.pointerId);heaveEvent(e);e.preventDefault()});heave.addEventListener('pointermove',e=>{if(e.pointerId===heavePointer)heaveEvent(e)});heave.addEventListener('pointerup',releaseHeave);heave.addEventListener('pointercancel',releaseHeave);heave.addEventListener('lostpointercapture',e=>{if(e.pointerId===heavePointer)releaseHeave(e)});
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 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)}
window.verticalControl={setPos:value=>{cancelAnimationFrame(heaveAnim);setHeave(value)},easeBack:()=>{if(heavePointer===null)easeHeave()},getState:()=>({value:control.vertical,active:heavePointer!==null})};
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){window.joystick3D?.setPos(turn,-forward)}else{window.joystick3D?.easeBack()}if(vertical){cancelAnimationFrame(heaveAnim);setHeave(vertical)}else if(!heaveDrag)easeHeave()} function applyKeys(){if(!canDrive()){pressedKeys.clear();return;}const turn=(pressedKeys.has('d')||pressedKeys.has('e')?1:0)-(pressedKeys.has('a')||pressedKeys.has('q')?1:0),forward=(pressedKeys.has('w')?1:0)-(pressedKeys.has('s')?1:0),vertical=(pressedKeys.has('r')?1:0)-(pressedKeys.has('f')?1:0);if(!window.joystick3D?.getState().active){if(turn||forward)window.joystick3D?.setPos(turn,-forward);else window.joystick3D?.easeBack()}if(heavePointer===null){if(vertical){cancelAnimationFrame(heaveAnim);setHeave(vertical)}else easeHeave()}}
addEventListener('keydown',e=>{if(/^(INPUT|SELECT)$/.test(e.target.tagName))return;if(e.key==='F2'){e.preventDefault();if(!e.repeat)startArmHold();return}const shortcuts={F1:'#manual',F3:'#disarm'};if(shortcuts[e.key]){e.preventDefault();if(!e.repeat)$(shortcuts[e.key]).click();return}const k=e.key.toLowerCase();if('wasdqerf'.includes(k)&&!keys.has(k)){keys.add(k);applyKeys()}});addEventListener('keyup',e=>{if(e.key==='F2'){e.preventDefault();stopArmHold(true);return}const k=e.key.toLowerCase();if(keys.delete(k))applyKeys()}); addEventListener('keydown',e=>{if(e.key==='F3'){e.preventDefault();if(!e.repeat&&view.action==='disarm')requestControl('disarm');return;}if(/^(INPUT|SELECT|TEXTAREA)$/.test(e.target.tagName))return;if(e.key==='F2'){e.preventDefault();if(!e.repeat)startEngageHold();return;}const k=e.key.toLowerCase();if('wasdqerf'.includes(k)&&k.length===1){if(!canDrive())return;e.preventDefault();if(!pressedKeys.has(k)){pressedKeys.add(k);applyKeys();}}});addEventListener('keyup',e=>{if(e.key==='F2'){e.preventDefault();cancelEngageHold(true);return;}const k=e.key.toLowerCase();if(pressedKeys.delete(k))applyKeys();});
addEventListener('blur',()=>{keys.clear();window.joystick3D?.easeBack();if(!heaveDrag)easeHeave()}); function resetInputs(){
if(gainPointer!==null)releaseGain({pointerId:gainPointer});
clearTimeout(axisTimer);axisTimer=0;pressedKeys.clear();cancelEngageHold(false);
window.joystick3D?.reset();
const pointer=heavePointer;heavePointer=null;cancelAnimationFrame(heaveAnim);
if(pointer!==null)try{heave.releasePointerCapture(pointer)}catch(_){}
hknob.classList.remove('pressed','released');
control.forward=control.turn=control.vertical=0;setHeave(0,false);
if(bridgeReady&&!demoActive)api('stop_controls',++inputSequence).catch(()=>{});
}
function focusChanged(focused){inputFocused=focused;if(!focused)resetInputs();render(vehicleSnapshot)}
addEventListener('blur',()=>focusChanged(false));
addEventListener('focus',()=>focusChanged(!document.hidden));
document.addEventListener('visibilitychange',()=>focusChanged(!document.hidden&&document.hasFocus()));
addEventListener('pagehide',()=>focusChanged(false));
function openModal(id){ function openModal(id){
const el=typeof id==='string'?document.getElementById(id):id; const el=typeof id==='string'?document.getElementById(id):id;
@@ -51,7 +140,7 @@ $$('dialog').forEach(d=>{
if(e.clientX<r.left||e.clientX>r.right||e.clientY<r.top||e.clientY>r.bottom)closeModal(d); if(e.clientX<r.left||e.clientX>r.right||e.clientY<r.top||e.clientY>r.bottom)closeModal(d);
}); });
}); });
$('#window-min').onclick=()=>api('minimize_window');$('#window-max').onclick=async()=>{const max=await api('toggle_maximize_window');document.body.classList.toggle('maximized',!!max)};$('#window-close').onclick=()=>{if(document.body.classList.contains('window-exiting'))return;document.body.classList.add('window-exiting');setTimeout(()=>api('close_window'),360)};$('.topbar').ondblclick=async e=>{if(!e.target.closest('button,select,input')){const max=await api('toggle_maximize_window');document.body.classList.toggle('maximized',!!max)}}; $('#window-min').onclick=()=>api('minimize_window');$('#window-max').onclick=async()=>{const max=await api('toggle_maximize_window');document.body.classList.toggle('maximized',!!max)};$('#window-close').onclick=()=>{if(document.body.classList.contains('window-exiting'))return;document.body.classList.add('window-exiting');setTimeout(()=>api('close_window'),360)};const windowBar=$('.workspace-topbar');if(windowBar)windowBar.ondblclick=async e=>{if(!e.target.closest('button,select,input')){const max=await api('toggle_maximize_window');document.body.classList.toggle('maximized',!!max)}};
const modelInput=$('#model-input'), modelButton=$('#load-model'), rovStage=$('.rov-stage'); const modelInput=$('#model-input'), modelButton=$('#load-model'), rovStage=$('.rov-stage');
async function handleStlFile(file){ async function handleStlFile(file){
@@ -119,68 +208,279 @@ if(rovStage){
if(file)handleStlFile(file); if(file)handleStlFile(file);
}); });
} }
$('#refresh').onclick=async e=>{const button=e.currentTarget;button.classList.remove('spinning');void button.offsetWidth;button.classList.add('spinning');setTimeout(()=>button.classList.remove('spinning'),650);if(bridgeReady)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)}}; $('#refresh').onclick=async e=>{
$('#manual').onclick=async()=>{try{await api('command','manual');toast('Manual mode command sent')}catch(e){toast(e.message,true)}}; const button=e.currentTarget;button.classList.remove('spinning');void button.offsetWidth;button.classList.add('spinning');setTimeout(()=>button.classList.remove('spinning'),650);
const armButton=$('#arm'); try{
let armHoldTimer=null, armStart=0, armCharged=false; if(typeof window.pywebview?.api?.refresh_ports!=='function')throw new Error('Desktop connection is not ready. Open the app using run.ps1.');
const ARM_HOLD_MS=1000; populatePorts(await withTimeout(api('refresh_ports'),4000));
if(!booted)boot();
}catch(error){recordActivity(`Port refresh failed: ${error.message}`);toast(error.message,true)}
};
$('#connect').onclick=async()=>{
const btn=$('#connect');
const label=btn.querySelector('.btn-label')||btn;
const p=$('#port');
const port=p?p.value:'';
function updateArmProgress(){ if(port==='demo'||demoActive){
if(!armStart)return; resetInputs();clearTimeout(demoCommandTimer);requestSerial++;localRequest=null;commandError='';bridgeStale=false;
const elapsed=performance.now()-armStart; if(demoActive){demoActive=false;demoState=null;render({app_state:'disconnected',connected:false,link:'DISCONNECTED',vehicle:'No heartbeat',armed:false,mode:null,status:'Demo mode disconnected',battery:null,servos:{}});toast('Demo disconnected')}
const progress=Math.min(1,elapsed/ARM_HOLD_MS); else{demoActive=true;demoState=makeDemoState();render(demoState);toast('Demo connected — no hardware required')}
armButton.style.setProperty('--arm-progress',`${progress*100}%`);
if(progress>=1){
armCharged=true;
stopArmHold(false);
armButton.classList.add('charged');
setTimeout(()=>armButton.classList.remove('charged'),600);
api('command','arm').then(()=>toast('Vehicle armed')).catch(e=>toast(e.message,true));
return; return;
} }
armHoldTimer=requestAnimationFrame(updateArmProgress);
}
function startArmHold(e){ if(appState==='connecting'){
if(armStart||armCharged)return; label.textContent='Canceling…';
if(e&&e.button!==undefined&&e.button!==0)return; try{ await api('disconnect'); }catch(_){}
armStart=performance.now(); return;
armCharged=false;
armButton.classList.add('charging');
armButton.style.setProperty('--arm-progress','0%');
armHoldTimer=requestAnimationFrame(updateArmProgress);
}
function stopArmHold(wasCancelled=true){
if(!armStart)return;
const elapsed=performance.now()-armStart;
cancelAnimationFrame(armHoldTimer);
armHoldTimer=null;
armStart=0;
armButton.classList.remove('charging');
armButton.style.setProperty('--arm-progress','0%');
if(wasCancelled&&!armCharged&&elapsed<ARM_HOLD_MS&&elapsed>70){
toast('Hold for 1s to Arm vehicle');
} }
armCharged=false;
if(appState!=='disconnected'){
resetInputs();
label.textContent='Disconnecting…';
try{
await api('disconnect');
toast('Disconnected');
}catch(e){
toast(e.message,true);
}
}else{
if(!port){
toast('Select or enter a port to connect',true);
return;
}
btn.classList.add('is-connecting');
label.textContent='Connecting…';
const statusEl=$('#status-text');
recordActivity(`Connecting to ${port}…`);
toast(`Connecting to ${port}…`);
try{
const confirmedState=await api('connect',port,+$('#baud').value,+$('#rate').value);
if(confirmedState)render(confirmedState);
toast(confirmedState?.armed_known?'MAVLink connected':'Waiting for vehicle telemetry',!confirmedState?.armed_known);
}catch(e){
btn.classList.remove('is-connecting');
label.textContent='Connect';
toast(e.message,true);
recordActivity(`Connection error: ${e.message}`);
}
}
};
const customPortBtn=$('#custom-port-btn'), customPortInput=$('#custom-port-input');
if(customPortBtn&&customPortInput){
const applyCustomPort=()=>{
const val=customPortInput.value.trim();
if(!val)return;
const p=$('#port');
let exists=false;
for(const opt of p.options){
if(opt.value===val){opt.selected=true;exists=true;break;}
}
if(!exists){
p.add(new Option(`Custom (${val})`, val), 0);
p.value=val;
}
closeModal($('#link-modal'));
toast(`Selected port: ${val}`);
};
customPortBtn.onclick=applyCustomPort;
customPortInput.onkeydown=e=>{if(e.key==='Enter'){e.preventDefault();applyCustomPort();}};
}
const engageButton=$('#engage');
const engageProgressLabel=engageButton?.querySelector('.engage-progress-label');
let engageHoldTimer=null,engageStart=0,engagePointer=null,completedEngagePointer=null,suppressEngageClick=false,engageVisualState='',engageTransitionTimer=null;
const ENGAGE_HOLD_MS=1400;
function applyEngageVisual(next){
if(!engageButton)return;
const label=({'ENGAGE':'engage','DISARM':'disengage','ENGAGING…':'engaging…','DISARMING…':'disengaging…','DISARM · ?':'disengage · ?','STATE UNKNOWN':'state unknown'})[next.label]||next.label.toLowerCase();
// Interaction updates immediately; the short fade never gates emergency access.
engageButton.disabled=!next.action;
engageButton.setAttribute('aria-busy',String(next.pending));
engageButton.setAttribute('aria-label',label);
engageButton.title=next.action==='disarm'?'Request disarm immediately (F3)':next.action==='arm'?'Hold 1.4 seconds to engage (F2)':next.message;
const key=`${next.label}/${next.tone}`;
if(engageVisualState===key)return;
const previous=engageVisualState;engageVisualState=key;
clearTimeout(engageTransitionTimer);
engageButton.classList.remove('arm','danger','subtle','charged','pending');
engageButton.classList.add(next.tone==='danger'?'danger':next.tone==='pending'?'pending':'arm');
engageButton.style.setProperty('--engage-progress',next.pending?'100%':'0%');
$('#engage-label').textContent=label;
if(engageProgressLabel)engageProgressLabel.textContent=label;
const sheen=engageButton.querySelector('.engage-sheen-label');if(sheen)sheen.textContent=label;
if(previous){
engageButton.classList.remove('state-transition');void engageButton.offsetWidth;
engageButton.classList.add('state-transition');
engageTransitionTimer=setTimeout(()=>engageButton.classList.remove('state-transition'),240);
}
}
function completeEngage(){
const pointer=engagePointer;cancelAnimationFrame(engageHoldTimer);engageHoldTimer=null;engageStart=0;engagePointer=null;completedEngagePointer=typeof pointer==='number'?pointer:null;engageButton.classList.remove('charging');engageButton.classList.add('charged');engageButton.style.setProperty('--engage-progress','100%');
requestControl('arm');
}
async function requestControl(name){
if((name==='arm'&&view.action!=='arm')||(name==='disarm'&&view.action!=='disarm'))return;
const id=++requestSerial;
commandError='';localRequest={name,deadline:performance.now()+6000,sent:false};
recordActivity(name==='arm'?'Engage requested':'Disengage requested');
resetInputs();render(vehicleSnapshot);
try{
if(demoActive){
// Separate simulated request and simulated heartbeat, just like the real UI.
clearTimeout(demoCommandTimer);
updateDemo({pending_command:name,command_error:''});
demoCommandTimer=setTimeout(()=>{
if(!demoActive||id!==requestSerial)return;
localRequest=null;
updateDemo({pending_command:null,armed:name==='arm',armed_known:true,
app_state:name==='arm'?'armed':'connected_disarmed',
vehicle:`DEMO • ${name==='arm'?'ARMED':'DISARMED'}`,status:`Demo: ${name} confirmed`});
},650);
}else{
await api('command',name,++inputSequence,Date.now()+2000);
if(id===requestSerial&&localRequest)localRequest.sent=true;
// Only poll snapshots may confirm state; a send result is not confirmation.
}
}catch(error){
if(id!==requestSerial)return;
localRequest=null;commandError=`${name==='arm'?'Engage':'Disarm'} request failed: ${error.message}`;
render(vehicleSnapshot);toast(commandError,true);
}
}
function updateEngageProgress(now){
if(!engageStart||view.action!=='arm'||!inputFocused)return cancelEngageHold(false);
const progress=Math.min(1,(now-engageStart)/ENGAGE_HOLD_MS);engageButton.style.setProperty('--engage-progress',`${progress*100}%`);if(progress>=1)return void completeEngage();engageHoldTimer=requestAnimationFrame(updateEngageProgress);
}
function startEngageHold(e){
if(view.action!=='arm'||!inputFocused||engageStart)return;
if(e&&e.button!==undefined&&e.button!==0)return;
engagePointer=e?.pointerId??'keyboard';engageStart=performance.now();engageButton.classList.add('charging');engageButton.style.setProperty('--engage-progress','0%');
if(e?.pointerId!=null)engageButton.setPointerCapture(e.pointerId);
engageHoldTimer=requestAnimationFrame(updateEngageProgress);
}
function cancelEngageHold(showHint=true){
if(!engageStart)return;
const elapsed=performance.now()-engageStart,pointer=engagePointer;cancelAnimationFrame(engageHoldTimer);engageHoldTimer=null;engageStart=0;engagePointer=null;engageButton.classList.remove('charging');engageButton.classList.add('canceling');engageButton.style.setProperty('--engage-progress','0%');setTimeout(()=>engageButton.classList.remove('canceling'),280);if(typeof pointer==='number')try{engageButton.releasePointerCapture(pointer)}catch(_){}if(showHint&&elapsed>80)toast('Engagement canceled');
} }
armButton.addEventListener('pointerdown',startArmHold); if(engageButton){
armButton.addEventListener('pointerup',()=>stopArmHold(true)); engageButton.addEventListener('pointerdown',startEngageHold);
armButton.addEventListener('pointerleave',()=>stopArmHold(true)); engageButton.addEventListener('pointerup',e=>{if(e.pointerId===completedEngagePointer){completedEngagePointer=null;try{engageButton.releasePointerCapture(e.pointerId)}catch(_){}suppressEngageClick=true;setTimeout(()=>suppressEngageClick=false,0);return}if(e.pointerId===engagePointer)cancelEngageHold(true)});
armButton.addEventListener('pointercancel',()=>stopArmHold(true)); engageButton.addEventListener('pointerleave',e=>{if(e.pointerId===engagePointer)cancelEngageHold(true)});
armButton.addEventListener('contextmenu',e=>e.preventDefault()); engageButton.addEventListener('pointercancel',e=>{if(e.pointerId===completedEngagePointer)completedEngagePointer=null;if(e.pointerId===engagePointer)cancelEngageHold(false)});
engageButton.addEventListener('lostpointercapture',e=>{if(e.pointerId===engagePointer)cancelEngageHold(false);if(e.pointerId===completedEngagePointer)completedEngagePointer=null});
engageButton.addEventListener('pointermove',e=>{if(e.pointerId!==engagePointer)return;const r=engageButton.getBoundingClientRect();if(e.clientX<r.left||e.clientX>r.right||e.clientY<r.top||e.clientY>r.bottom)cancelEngageHold(false)});
engageButton.addEventListener('contextmenu',e=>e.preventDefault());
engageButton.onclick=()=>{
if(suppressEngageClick){suppressEngageClick=false;return}
if(view.action==='disarm')requestControl('disarm');
};
}
$('#disarm').onclick=async()=>{try{await api('command','disarm');toast('Disarm command sent')}catch(e){toast(e.message,true)}};
$('#logs').onclick=()=>api('open_logs'); $('#logs').onclick=()=>api('open_logs');
function setGain(value){const gain=$('#gain');gain.value=Math.max(+gain.min,Math.min(+gain.max,value));$('#gain-value').textContent=`${gain.value}%`;if(bridgeReady)api('set_gain',+gain.value)} function setGain(value){const gain=$('#gain');gain.value=Math.max(+gain.min,Math.min(+gain.max,value));$('#gain-value').textContent=`${gain.value}%`;if(bridgeReady)api('set_gain',+gain.value)}
$('#gain').oninput=e=>setGain(+e.target.value);$('#gain-down').onclick=()=>setGain(+$('#gain').value-5);$('#gain-up').onclick=()=>setGain(+$('#gain').value+5); $('#gain').oninput=e=>setGain(+e.target.value);$('#gain-down').onclick=()=>setGain(+$('#gain').value-5);$('#gain-up').onclick=()=>setGain(+$('#gain').value+5);
// Explicit pointer ownership makes the full range rail a reliable touch target
// in the embedded window, without depending on native range drag defaults.
const gainSlider=$('#gain');let gainPointer=null;
function gainFromPointer(e){const r=gainSlider.getBoundingClientRect(),fraction=Math.max(0,Math.min(1,(e.clientX-r.left-9)/Math.max(1,r.width-18)));setGain(Math.round(+gainSlider.min+fraction*(+gainSlider.max-+gainSlider.min)))}
function releaseGain(e){if(e.pointerId!==gainPointer)return;gainPointer=null;try{gainSlider.releasePointerCapture(e.pointerId)}catch(_){}}
gainSlider.addEventListener('pointerdown',e=>{if(gainPointer!==null||e.button>0)return;gainPointer=e.pointerId;gainSlider.setPointerCapture(e.pointerId);gainFromPointer(e);e.preventDefault()});
gainSlider.addEventListener('pointermove',e=>{if(e.pointerId===gainPointer)gainFromPointer(e)});
['pointerup','pointercancel','lostpointercapture'].forEach(type=>gainSlider.addEventListener(type,releaseGain));
gainSlider.addEventListener('keydown',e=>{const delta={ArrowRight:1,ArrowUp:1,ArrowLeft:-1,ArrowDown:-1}[e.key];if(delta){e.preventDefault();setGain(+gainSlider.value+delta)}else if(e.key==='Home'||e.key==='End'){e.preventDefault();setGain(+(e.key==='Home'?gainSlider.min:gainSlider.max))}});
$('#battery-form').onsubmit=async e=>{e.preventDefault();const data=Object.fromEntries(new FormData(e.target));try{batteryConfig=await api('save_battery',data);closeModal($('#battery-modal'));toast('Battery settings saved')}catch(err){toast(err.message,true)}}; $('#battery-form').onsubmit=async e=>{e.preventDefault();const data=Object.fromEntries(new FormData(e.target));try{batteryConfig=await api('save_battery',data);closeModal($('#battery-modal'));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 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=['<option value="0">Unassigned</option>',...Array.from({length:12},(_,n)=>`<option value="${n+1}" ${s.motor===n+1?'selected':''}>Motor ${n+1}</option>`)].join('');row.innerHTML=`<strong>SERVO${i}</strong><select aria-label="SERVO${i} function">${opts}</select><span>${s.direction==null?'—':s.direction===-1?'Reversed':'Normal'}</span><button class="button subtle">Reverse</button>`;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 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=['<option value="0">Unassigned</option>',...Array.from({length:12},(_,n)=>`<option value="${n+1}" ${s.motor===n+1?'selected':''}>Motor ${n+1}</option>`)].join('');row.innerHTML=`<strong>SERVO${i}</strong><select aria-label="SERVO${i} function">${opts}</select><span>${s.direction==null?'—':s.direction===-1?'Reversed':'Normal'}</span><button class="button subtle">Reverse</button>`;row.querySelector('select').onchange=e=>{if(demoActive){const motor=+e.target.value;demoState.servos[String(i)]={function:motor?32+motor:0,label:motor?`Motor ${motor}`:'Unassigned',motor:motor||null,direction:motor?1:null};servoSignature='';renderMotors(demoState.servos);toast(`Demo SERVO${i} updated`)}else api('set_servo_function',i,+e.target.value).catch(x=>toast(x.message,true))};row.querySelector('button').onclick=()=>{if(demoActive){if(s.motor){s.direction=s.direction===-1?1:-1;servoSignature='';renderMotors(demoState.servos);toast(`Demo Motor ${s.motor} reversed`)}else toast(`SERVO${i} is unassigned`,true)}else api('reverse_motor',i).catch(x=>toast(x.message,true))};list.append(row)}}
function render(s){connected=s.connected;$('#connect').textContent=connected?'Disconnect':'Connect';const linkText=s.link==='LINK OK'?'connected':(s.link?s.link.toLowerCase():'disconnected');$('#link').textContent=linkText;$('.link-state').classList.toggle('online',s.link==='LINK OK'||s.link==='connected');$('#vehicle').textContent=s.vehicle?s.vehicle.toLowerCase():'no heartbeat';$('#status').textContent=s.status?s.status.toLowerCase():'ready';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)} function render(s,confirmed=false){
vehicleSnapshot=s;
if(confirmed&&localRequest?.sent && (s.pending_command===localRequest.name || s.command_error ||
(s.armed_known&&s.armed===(localRequest.name==='arm')))) localRequest=null;
const previous=view;
view=ControlState.present(s,{stale:bridgeStale,focused:inputFocused,pending:localRequest?.name,error:commandError});
if((previous.drive&&!view.drive)||(previous.action==='arm'&&view.action!=='arm'))resetInputs();
appState=bridgeStale&&s.connected?'link_degraded':s.app_state||'disconnected';
document.body.dataset.appState=appState;
document.body.dataset.controlsEnabled=String(view.drive);
document.body.dataset.telemetryStale=String(!view.known||!!s.battery_stale);
$('#port').disabled=!!s.connected;
const isOnline=(appState==='connected_disarmed'||appState==='armed');
const connectBtn=$('#connect');
const connectLabel=connectBtn?connectBtn.querySelector('.btn-label'):null;
if(connectBtn){
if(appState==='connecting'){
if(connectLabel)connectLabel.textContent='Connecting…';
else connectBtn.textContent='Connecting…';
connectBtn.classList.add('is-connecting');
connectBtn.classList.remove('is-connected');
}else if(appState!=='disconnected'){
if(connectLabel)connectLabel.textContent='Disconnect';
else connectBtn.textContent='Disconnect';
connectBtn.classList.remove('is-connecting');
connectBtn.classList.add('is-connected');
}else{
if(connectLabel)connectLabel.textContent='Connect';
else connectBtn.textContent='Connect';
connectBtn.classList.remove('is-connecting', 'is-connected');
}
}
const engageBtn=$('#engage');
const engageLabel=$('#engage-label');
if(engageBtn&&engageLabel){
applyEngageVisual(view);
}
const controlStatus=$('#control-status');
controlStatus.textContent=view.message;
controlStatus.classList.toggle('is-error',view.error);
controlStatus.hidden=!(view.pending||view.error||(view.known&&s.armed&&!inputFocused));
const motorBtn=$('[data-open="motors-modal"]');
if(motorBtn)motorBtn.disabled=(!isOnline);
async function poll(){if(!bridgeReady)return;try{render(await api('poll'))}catch(e){}setTimeout(poll,250)} const confirmedCommand=previous.pending&&!view.pending&&view.known&&!view.error;
const reported=commandError||s.command_error||((!view.known&&s.connected&&appState!=='connecting')?'Telemetry stale — vehicle state unknown':s.status);
if(reported&&reported!==lastReportedStatus){lastReportedStatus=reported;if(!confirmedCommand)recordActivity(reported)}
if(confirmedCommand){
recordActivity(s.armed?'Engage confirmed':'Disengage confirmed');
}
updateReadouts();
const b=s.battery;
if(b){
$('#battery-title').textContent=`${b.voltage.toFixed(2)} V`;
$('#battery-percent').textContent=`${Math.round(b.percent)}% • ${b.cells}s${!view.known||s.battery_stale?' · stale':''}`;
$('#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||{});
}
function markBridgeStale(){
if(demoActive||bridgeStale)return;
bridgeStale=true;resetInputs();render(vehicleSnapshot);
}
setInterval(()=>{
const now=performance.now();
if(now-lastSafetyTick>750)resetInputs();
lastSafetyTick=now;
if(!demoActive&&bridgeReady&&vehicleSnapshot.connected&&performance.now()-lastBridgeAt>1500)markBridgeStale();
if(localRequest&&performance.now()>localRequest.deadline){
commandError=`${localRequest.name==='arm'?'Engage':'Disarm'} confirmation timed out. Check the vehicle state.`;
localRequest=null;render(vehicleSnapshot);
}
},100);
async function poll(){
if(!bridgeReady)return;
if(!demoActive)try{
const snapshot=await withTimeout(api('poll'));
if(!demoActive){lastBridgeAt=performance.now();bridgeStale=false;render(snapshot,true)}
}catch(e){markBridgeStale()}
setTimeout(poll,250);
}
let modelLoadPromise=null; let modelLoadPromise=null;
async function loadDefaultModel(){ async function loadDefaultModel(){
if(modelLoadPromise)return modelLoadPromise; if(modelLoadPromise)return modelLoadPromise;
@@ -254,33 +554,46 @@ async function loadDefaultModel(){
return modelLoadPromise; return modelLoadPromise;
} }
let booted=false; let booted=false,booting=false,bootRetry=0,portRefreshTimer=0;
function hasDesktopApi(){return ['initial_state','refresh_ports','poll'].every(name=>typeof window.pywebview?.api?.[name]==='function')}
async function boot(){ async function boot(){
if(booted||!window.pywebview?.api)return; if(booted||booting||!hasDesktopApi())return;
booted=true; booting=true;clearTimeout(bootRetry);
bridgeReady=true;
loadDefaultModel();
try{ try{
const init=await api('initial_state'); // OS port discovery must not depend on model/config initialization succeeding.
batteryConfig=init.battery_config; populatePorts(await withTimeout(api('refresh_ports'),4000));
const init=await withTimeout(api('initial_state'),4000);
batteryConfig=init.battery_config||{};
populatePorts(init.ports); populatePorts(init.ports);
fillBatteryForm(); fillBatteryForm();
render(init.state); bridgeReady=true;booted=true;lastBridgeAt=performance.now();
if(!demoActive)render(init.state);
poll(); poll();
setInterval(async()=>{ // Populate the serial selector before starting the optional model load.
if(!connected){ // A large cached mesh must never delay an operator seeing USB COM ports.
try{populatePorts(await api('refresh_ports'))}catch(_){} loadDefaultModel();
if(!portRefreshTimer)portRefreshTimer=setInterval(async()=>{
if(appState==='disconnected'||demoActive){
try{populatePorts(await withTimeout(api('refresh_ports'),1500))}
catch(error){recordActivity(`Port scan failed: ${error.message}`)}
} }
},2000); },2000);
}catch(e){ }catch(e){
booted=false;bridgeReady=false;
recordActivity(`Desktop startup failed; retrying: ${e.message}`);
toast(`Python bridge: ${e.message}`,true); toast(`Python bridge: ${e.message}`,true);
bootRetry=setTimeout(boot,1000);
}finally{
booting=false;
} }
} }
function waitForBridge(){ function waitForBridge(){
if(window.pywebview?.api)boot(); if(booted)return;
else setTimeout(waitForBridge,50); if(hasDesktopApi())boot();
setTimeout(waitForBridge,250);
} }
addEventListener('pywebviewready',boot); addEventListener('pywebviewready',boot);
render(vehicleSnapshot);
if(document.readyState==='loading'){ if(document.readyState==='loading'){
addEventListener('DOMContentLoaded',()=>{loadDefaultModel();waitForBridge()}); addEventListener('DOMContentLoaded',()=>{loadDefaultModel();waitForBridge()});
}else{ }else{
+32
View File
@@ -0,0 +1,32 @@
/* Presentation derived from telemetry. No animation is allowed to set armed. */
((root) => {
function present(state, {stale=false, focused=true, pending=null, error=''}={}) {
const known = state.armed_known === true && !stale;
const online = !!state.connected && !stale;
const request = pending || state.pending_command;
const uncertain = !known && (state.armed !== null && state.armed !== undefined || !!request || state.heartbeat_age != null);
let label='ENGAGE', tone='idle', action=null, message='Connect to receive vehicle state.';
if (request === 'disarm') {
label='DISARMING…'; tone='danger'; message='Awaiting vehicle confirmation. Not yet confirmed disarmed.';
} else if (request === 'arm') {
label='ENGAGING…'; tone='pending'; action=online?'disarm':null;
message='Awaiting vehicle confirmation. Tap to request disarm.';
} else if (known && state.armed) {
label='DISARM'; tone='danger'; action=online?'disarm':null;
message=focused?'Armed · tap or F3 to disarm.':'Armed · controls neutral while the app is unfocused.';
} else if (uncertain) {
label=online?'DISARM · ?':'STATE UNKNOWN'; tone='danger'; action=online?'disarm':null;
message='Vehicle state unknown. Inputs cleared; disarm is not confirmed.';
} else if (known && online) {
action=focused?'arm':null; message='Disarmed · hold 1.4 seconds to engage.';
}
if (stale) message='Telemetry unavailable. Inputs cleared; vehicle state unknown.';
const failure=error || state.command_error;
return {label,tone,action,pending:!!request,known,
drive:known && online && state.armed === true && !request && focused,
message:failure ? `${failure} ${known ? (state.armed?'Vehicle armed.':'Vehicle disarmed.') : 'Vehicle state unknown.'}` : message,
error:!!(error || state.command_error || uncertain || stale)};
}
root.ControlState={present};
if(typeof module!=='undefined') module.exports=root.ControlState;
})(typeof window!=='undefined'?window:globalThis);
+109 -47
View File
@@ -4,17 +4,37 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="viewport" content="width=device-width,initial-scale=1">
<title>Sub Control</title> <title>Sub Control</title>
<link rel="stylesheet" href="styles.css"> <link rel="stylesheet" href="styles.css?v=20260827-bridge-v5">
</head> </head>
<body> <body>
<main class="app-shell"> <main class="app-shell">
<aside class="vehicle-rail">
<header class="brand pywebview-drag-region"> <!-- ── DECK (Connection, 3D Model, Battery, Deck Controls, Status) ── -->
<span>Sub Control</span> <aside class="sidebar-deck" aria-label="Vehicle Deck and Status">
</header> <div class="sidebar-drag-surface pywebview-drag-region" aria-hidden="true"></div>
<div class="eyebrow">live vehicle</div> <!-- Connection Controls -->
<div class="deck-connection">
<div class="port-row">
<select id="port" aria-label="Serial port">
<option value="demo" selected>Demo Mode (no hardware)</option>
</select>
<button id="refresh" class="icon-button" title="Refresh ports" aria-label="Refresh ports">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M20 7v5h-5M4 17v-5h5M18.5 9A7 7 0 0 0 6.8 6.8L4 10m16 4-2.8 3.2A7 7 0 0 1 5.5 15"/></svg>
</button>
<button class="icon-button" data-open="link-modal" title="Link settings" aria-label="Link settings">
<svg class="dots-icon" viewBox="0 0 24 24" aria-hidden="true"><circle cx="5" cy="12" r="1.25"/><circle cx="12" cy="12" r="1.25"/><circle cx="19" cy="12" r="1.25"/></svg>
</button>
</div>
<button id="connect" class="button connect-button" aria-label="Toggle connection">
<span class="btn-spinner" aria-hidden="true"></span>
<span class="btn-label">Connect</span>
</button>
</div>
<!-- Telemetry / Status Zone -->
<section class="telemetry-zone" aria-label="Vehicle Telemetry and Status">
<div class="deck-card model-card">
<div class="rov-stage" aria-label="Top view of three-thruster ROV"> <div class="rov-stage" aria-label="Top view of three-thruster ROV">
<div class="water-ring r1"></div><div class="water-ring r2"></div>
<canvas id="model-canvas" aria-label="Interactive STL vehicle model"></canvas> <canvas id="model-canvas" aria-label="Interactive STL vehicle model"></canvas>
<div id="model-status" class="model-status">Initializing…</div> <div id="model-status" class="model-status">Initializing…</div>
<div class="model-tools"> <div class="model-tools">
@@ -22,75 +42,117 @@
<input id="model-input" type="file" accept=".stl,.STL" hidden> <input id="model-input" type="file" accept=".stl,.STL" hidden>
</div> </div>
</div> </div>
<div class="vehicle-name"><small>vehicle</small><strong id="vehicle">no heartbeat</strong><span>SimpleROV-3 • differential drive</span></div> </div>
<div class="rail-bottom">
<!-- Battery Status -->
<button class="battery-card ghost" data-open="battery-modal"> <button class="battery-card ghost" data-open="battery-modal">
<span><small>Battery</small><strong id="battery-title">—</strong></span><i id="battery-percent">waiting</i> <span><small>Battery</small><strong id="battery-title">—</strong></span><i id="battery-percent">waiting</i>
<b><em id="battery-fill"></em></b> <b><em id="battery-fill"></em></b>
</button> </button>
<div class="link-state"><span id="link-dot"></span><strong id="link">disconnected</strong></div>
<div class="deck-status-bar">
<ol class="activity-history" aria-label="Last three events" aria-live="polite">
<li id="activity-oldest" hidden></li>
<li id="activity-previous" hidden></li>
<li id="status-text">Not connected</li>
</ol>
<header class="activity-heading"><span>Recent activity</span><a id="logs" class="logs-link" title="Open session logs" aria-label="Open session logs">Logs</a></header>
</div> </div>
</section>
</aside> </aside>
<section class="workspace"> <!-- ── PRIMARY CONTROL ZONE ── -->
<header class="topbar"> <section class="controls-workspace" aria-label="Primary Vehicle Controls">
<div class="connection"> <!-- Frameless Drag & Window Controls Header -->
<select id="port" aria-label="Serial port"><option value="">Select COM port…</option></select> <div class="workspace-topbar">
<button id="refresh" class="icon-button" title="Refresh ports" aria-label="Refresh ports"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M20 7v5h-5M4 17v-5h5M18.5 9A7 7 0 0 0 6.8 6.8L4 10m16 4-2.8 3.2A7 7 0 0 1 5.5 15"/></svg></button> <div class="workspace-drag-area pywebview-drag-region"></div>
<button class="icon-button" data-open="link-modal" title="Link settings" aria-label="Link settings"><svg class="dots-icon" viewBox="0 0 24 24" aria-hidden="true"><circle cx="5" cy="12" r="1.25"/><circle cx="12" cy="12" r="1.25"/><circle cx="19" cy="12" r="1.25"/></svg></button>
<button id="connect" class="button connect-button">Connect</button>
</div>
<div class="topbar-spacer pywebview-drag-region"></div>
<div class="window-actions"> <div class="window-actions">
<button id="window-min" aria-label="Minimize" title="Minimize"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12h14"/></svg></button> <button id="window-min" aria-label="Minimize" title="Minimize"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12h14"/></svg></button>
<button id="window-max" aria-label="Maximize" title="Maximize"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="5" y="5" width="14" height="14" rx="1.5"/></svg></button> <button id="window-max" aria-label="Maximize" title="Maximize"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="5" y="5" width="14" height="14" rx="1.5"/></svg></button>
<button id="window-close" class="window-close" aria-label="Close" title="Close"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 6 12 12M18 6 6 18"/></svg></button> <button id="window-close" class="window-close" aria-label="Close" title="Close"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 6 12 12M18 6 6 18"/></svg></button>
</div> </div>
</header>
<section class="drive-grid">
<article class="control-card steer-card">
<header><div><small>steer + throttle</small><strong>differential drive</strong></div><span class="key-hint">w a s d</span></header>
<div id="joystick" class="joystick" touch-action="none">
<canvas id="joystick-canvas" aria-label="3D Interactive Spherical Joystick"></canvas>
</div> </div>
<footer><span class="steer-readout-left">surge <b id="surge-readout">0</b></span><span class="steer-readout-right"><b id="yaw-readout">0</b> turn</span></footer>
<section class="primary-control-zone" aria-label="Primary Control Zone">
<!-- Reserved Engage / Disarm row -->
<div class="engage-header-zone">
<button id="engage" class="button subtle engage-button" aria-label="Engage" aria-describedby="control-status" title="Engage (F2)">
<span class="engage-face">
<span id="engage-label" class="engage-label">engage</span>
<span class="engage-sheen-label" aria-hidden="true">engage</span>
<span class="engage-progress-label" aria-hidden="true">engage</span>
</span>
</button>
<p id="control-status" class="control-status" role="status" aria-live="polite">Connect to receive vehicle state.</p>
</div>
<!-- Main Joysticks & Thrust Control Grid -->
<div class="controls-grid">
<!-- Steering / Throttle Joystick -->
<article class="control-card steer-card">
<header>
<div><small>steer + throttle</small></div>
<span class="key-hint">w a s d</span>
</header>
<div class="joystick-pad">
<div id="joystick" class="joystick" touch-action="none">
<canvas id="joystick-canvas" aria-label="Steering and throttle joystick; W A S D keys"></canvas>
</div>
</div>
<footer>
<span class="steer-readout-left">surge <b id="surge-readout">—</b></span>
<span class="steer-readout-right"><b id="yaw-readout">—</b> turn</span>
</footer>
</article> </article>
<!-- Vertical Thruster Slider -->
<article class="control-card vertical-card"> <article class="control-card vertical-card">
<header><div><small>vertical</small><strong>center thruster</strong></div><span class="key-hint">r / f</span></header> <header>
<div><small>vertical</small></div>
<span class="key-hint">r / f</span>
</header>
<div class="heave-pad">
<div class="vertical-control"> <div class="vertical-control">
<span>rise</span> <span>rise</span>
<div id="heave" class="heave-track" touch-action="none"><div class="heave-center"></div><div id="heave-fill" class="heave-fill"></div><div id="heave-knob" class="heave-knob"></div></div> <div id="heave" class="heave-track" touch-action="none">
<div class="heave-center"></div>
<div id="heave-fill" class="heave-fill"></div>
<div id="heave-knob" class="heave-knob"></div>
</div>
<span>dive</span> <span>dive</span>
</div> </div>
<footer><span><b id="heave-readout">0</b> vertical</span></footer>
</article>
</section>
<section class="bottom-actions">
<section class="command-bar">
<div class="commands">
<button id="manual" class="button subtle"><span>Manual</span></button>
<button id="arm" class="button arm" aria-label="Hold to arm" title="Hold to arm (F2)"><span class="arm-copy"><span class="arm-label">Arm</span><span class="arm-holding-label">Holding…</span></span></button>
<button id="disarm" class="button danger"><span>Disarm</span></button>
</div> </div>
<label class="output-limit"><span>output limit <b id="gain-value">20%</b></span><div class="range-pill"><button id="gain-down" type="button" aria-label="Decrease output limit">‹</button><input id="gain" type="range" min="5" max="50" value="20"><button id="gain-up" type="button" aria-label="Increase output limit">›</button></div></label> <footer>
<button class="button subtle" data-open="motors-modal">Motor Setup</button> <span><b id="heave-readout">—</b> vertical</span>
</footer>
</article>
</div>
<div class="control-toolbar">
<label class="output-limit">
<span>Output limit <b id="gain-value">20%</b></span>
<div class="range-pill">
<button id="gain-down" type="button" aria-label="Decrease output limit">‹</button>
<input id="gain" aria-label="Output limit" type="range" min="5" max="50" value="20">
<button id="gain-up" type="button" aria-label="Increase output limit">›</button>
</div>
</label>
<button class="button subtle config-btn" data-open="motors-modal">Motor setup</button>
</div>
</section> </section>
</section> </section>
<div class="status-line"><span></span><p id="status">ready. select a USB port to begin.</p><a id="logs" class="logs-link" title="Open session logs" aria-label="Open session logs">logs</a></div>
</section>
</main> </main>
<div id="toast" class="toast"></div> <div id="toast" class="toast"></div>
<dialog id="link-modal" class="modal"><button class="modal-close" data-close>×</button><small>connection</small><h2>Link Settings</h2><div class="form-grid two"><label><span>Baud rate</span><select id="baud"><option>9600</option><option>19200</option><option>38400</option><option selected>57600</option><option>115200</option></select></label><label><span>Control rate</span><select id="rate"><option>10</option><option selected>20</option></select></label></div><p class="note">20 Hz is recommended for smooth intermediate commands on this link.</p></dialog> <!-- Modals -->
<dialog id="link-modal" class="modal"><button class="modal-close" data-close>×</button><small>connection</small><h2>Link Settings</h2><div class="form-grid two"><label><span>Baud rate</span><select id="baud"><option>9600</option><option>19200</option><option>38400</option><option selected>57600</option><option>115200</option></select></label><label><span>Control rate</span><select id="rate"><option>10</option><option selected>20</option></select></label></div><div style="margin-top:14px;"><label style="display:flex;flex-direction:column;gap:6px;"><span style="font-size:11px;font-weight:500;color:var(--muted)">Custom Port / Endpoint</span><div style="display:flex;gap:8px;"><input id="custom-port-input" placeholder="e.g. COM3 or tcp:127.0.0.1:5760" style="flex:1;height:32px;padding:0 12px;font-size:11px;"><button type="button" id="custom-port-btn" class="button subtle" style="white-space:nowrap;">Use Port</button></div></label></div><p class="note" style="margin-top:14px;">20 Hz is recommended for smooth intermediate commands on this link.</p></dialog>
<dialog id="battery-modal" class="modal"><button class="modal-close" data-close>×</button><small>Power</small><h2>Battery Settings</h2><form id="battery-form" class="form-grid three"><label><span>Cells (S)</span><div class="number-pill"><button type="button" class="num-step down" tabindex="-1">‹</button><input name="cells" type="number" min="1" step="1"><button type="button" class="num-step up" tabindex="-1">›</button></div></label><label><span>Capacity (mAh)</span><div class="number-pill"><button type="button" class="num-step down" tabindex="-1">‹</button><input name="capacity_mah" type="number" min="1" step="50"><button type="button" class="num-step up" tabindex="-1">›</button></div></label><label><span>C rating</span><div class="number-pill"><button type="button" class="num-step down" tabindex="-1">‹</button><input name="c_rating" type="number" min="1" step=".1"><button type="button" class="num-step up" tabindex="-1">›</button></div></label><label><span>Full V / cell</span><div class="number-pill"><button type="button" class="num-step down" tabindex="-1">‹</button><input name="full_v_per_cell" type="number" step=".01"><button type="button" class="num-step up" tabindex="-1">›</button></div></label><label><span>Empty V / cell</span><div class="number-pill"><button type="button" class="num-step down" tabindex="-1">‹</button><input name="empty_v_per_cell" type="number" step=".01"><button type="button" class="num-step up" tabindex="-1">›</button></div></label><label><span>Warning %</span><div class="number-pill"><button type="button" class="num-step down" tabindex="-1">‹</button><input name="warning_percent" type="number" min="0" max="100" step="1"><button type="button" class="num-step up" tabindex="-1">›</button></div></label><div class="modal-actions wide"><button type="button" class="button subtle" data-close>Cancel</button><button class="button connect-button">Save Settings</button></div></form></dialog> <dialog id="battery-modal" class="modal"><button class="modal-close" data-close>×</button><small>Power</small><h2>Battery Settings</h2><form id="battery-form" class="form-grid three"><label><span>Cells (S)</span><div class="number-pill"><button type="button" class="num-step down" tabindex="-1">‹</button><input name="cells" type="number" min="1" step="1"><button type="button" class="num-step up" tabindex="-1">›</button></div></label><label><span>Capacity (mAh)</span><div class="number-pill"><button type="button" class="num-step down" tabindex="-1">‹</button><input name="capacity_mah" type="number" min="1" step="50"><button type="button" class="num-step up" tabindex="-1">›</button></div></label><label><span>C rating</span><div class="number-pill"><button type="button" class="num-step down" tabindex="-1">‹</button><input name="c_rating" type="number" min="1" step=".1"><button type="button" class="num-step up" tabindex="-1">›</button></div></label><label><span>Full V / cell</span><div class="number-pill"><button type="button" class="num-step down" tabindex="-1">‹</button><input name="full_v_per_cell" type="number" step=".01"><button type="button" class="num-step up" tabindex="-1">›</button></div></label><label><span>Empty V / cell</span><div class="number-pill"><button type="button" class="num-step down" tabindex="-1">‹</button><input name="empty_v_per_cell" type="number" step=".01"><button type="button" class="num-step up" tabindex="-1">›</button></div></label><label><span>Warning %</span><div class="number-pill"><button type="button" class="num-step down" tabindex="-1">‹</button><input name="warning_percent" type="number" min="0" max="100" step="1"><button type="button" class="num-step up" tabindex="-1">›</button></div></label><div class="modal-actions wide"><button type="button" class="button subtle" data-close>Cancel</button><button class="button connect-button">Save Settings</button></div></form></dialog>
<dialog id="motors-modal" class="modal motor-modal"><button class="modal-close" data-close>×</button><small>configuration</small><h2>Motor Outputs</h2><p class="note">Function and direction are read one parameter at a time to protect the low-bandwidth link.</p><div class="motor-head"><span>Output</span><span>Function</span><span>Direction</span><span></span></div><div id="motor-list" class="motor-list"></div></dialog> <dialog id="motors-modal" class="modal motor-modal"><button class="modal-close" data-close>×</button><small>configuration</small><h2>Motor Outputs</h2><p class="note">Function and direction are read one parameter at a time to protect the low-bandwidth link.</p><div class="motor-head"><span>Output</span><span>Function</span><span>Direction</span><span></span></div><div id="motor-list" class="motor-list"></div></dialog>
<script src="model-viewer.js"></script> <script src="model-viewer.js?v=20260826-controls-v2"></script>
<script src="joystick-3d.js"></script> <script src="joystick-3d.js?v=20260827-polish-v3"></script>
<script src="app.js"></script> <script src="control-state.js?v=20260827-state-v1"></script>
<script src="app.js?v=20260827-bridge-v6"></script>
</body> </body>
</html> </html>
+32 -307
View File
@@ -1,317 +1,42 @@
/* joystick-3d.js – Clean Canvas joystick with tactile press animation, /* Visual joystick. Control transport remains in app.js through setJoyControl. */
expanded soft red boundary glow, clean lowercase curved labels, and 20% larger knob. */
(() => { (() => {
'use strict'; 'use strict';
const host=document.getElementById('joystick'),canvas=document.getElementById('joystick-canvas');
if(!host||!canvas)return;
const ctx=canvas.getContext('2d');
let width=0,height=0,dpr=1,x=0,y=0,activePointer=null,returnFrame=0;
const host = document.getElementById('joystick'); function geometry(){
if (!host) return; const radius=Math.min(width,height)*.43;
return {cx:width/2,cy:height/2,radius,travel:radius*.67,knob:Math.max(25,Math.min(34,radius*.22))};
let canvas = document.getElementById('joystick-canvas');
if (!canvas) {
canvas = document.createElement('canvas');
canvas.id = 'joystick-canvas';
host.appendChild(canvas);
} }
function resize(){const rect=host.getBoundingClientRect();width=rect.width;height=rect.height;dpr=Math.min(devicePixelRatio||1,2);canvas.width=Math.round(width*dpr);canvas.height=Math.round(height*dpr);canvas.style.width=`${width}px`;canvas.style.height=`${height}px`;draw()}
function draw(){
if(!width||!height)return;
const {cx,cy,radius,travel,knob}=geometry(),kx=cx+x*travel,ky=cy+y*travel,amount=Math.min(1,Math.hypot(x,y));
ctx.setTransform(dpr,0,0,dpr,0,0);ctx.clearRect(0,0,width,height);
canvas.style.cssText = ctx.beginPath();ctx.arc(cx,cy,radius,0,Math.PI*2);ctx.fillStyle='#eee';ctx.fill();ctx.strokeStyle='#d3d3d3';ctx.lineWidth=1.5;ctx.stroke();
'position:absolute;inset:0;width:100%;height:100%;z-index:5;touch-action:none;cursor:grab;'; ctx.strokeStyle='rgba(80,92,87,.14)';ctx.lineWidth=1;ctx.beginPath();ctx.moveTo(cx-radius*.76,cy);ctx.lineTo(cx+radius*.76,cy);ctx.moveTo(cx,cy-radius*.76);ctx.lineTo(cx,cy+radius*.76);ctx.stroke();
const ctx = canvas.getContext('2d'); ctx.beginPath();ctx.arc(cx,cy,8,0,Math.PI*2);ctx.fillStyle=amount<.03?'#77817d':'#c8ceca';ctx.fill();
if(amount>.025){ctx.beginPath();ctx.moveTo(cx,cy);ctx.lineTo(kx,ky);ctx.strokeStyle='rgba(78,91,85,.24)';ctx.lineWidth=3;ctx.lineCap='round';ctx.stroke()}
let W = 0, H = 0, dpr = 1; ctx.save();ctx.shadowColor='rgba(17,25,21,.22)';ctx.shadowBlur=activePointer===null?8:12;ctx.shadowOffsetY=3;
let nx = 0, ny = 0; // normalised knob position: -1 to +1 ctx.beginPath();ctx.arc(kx,ky,knob,0,Math.PI*2);ctx.fillStyle=activePointer===null?'#555':'#353535';ctx.fill();ctx.restore();
let dragging = false; ctx.beginPath();ctx.arc(kx,ky,knob,0,Math.PI*2);ctx.strokeStyle='rgba(255,255,255,.42)';ctx.lineWidth=1;ctx.stroke();
let animId = 0; ctx.beginPath();ctx.arc(kx,ky,4,0,Math.PI*2);ctx.fillStyle='#dfe5e1';ctx.fill();
let pressAnimId = 0;
let pressScale = 1.0; // smooth press scale: 1.0 down to 0.93
function resize() {
const r = host.getBoundingClientRect();
dpr = Math.min(window.devicePixelRatio || 1, 2);
W = r.width;
H = r.height;
canvas.width = Math.round(W * dpr);
canvas.height = Math.round(H * dpr);
draw();
} }
window.addEventListener('resize', resize); function publish(){window.setJoyControl?.(x,y)}
function setPosition(nextX,nextY,notify=true){const length=Math.hypot(nextX,nextY);if(length>1){nextX/=length;nextY/=length}x=nextX;y=nextY;draw();if(notify)publish()}
function positionFromEvent(event){const rect=canvas.getBoundingClientRect(),g=geometry();setPosition((event.clientX-rect.left-g.cx)/g.travel,(event.clientY-rect.top-g.cy)/g.travel)}
function returnToCenter(){cancelAnimationFrame(returnFrame);const startX=x,startY=y,start=performance.now();const tick=now=>{const p=Math.min(1,(now-start)/360),smooth=p*p*(3-2*p);setPosition(startX*(1-smooth),startY*(1-smooth));if(p<1)returnFrame=requestAnimationFrame(tick)};returnFrame=requestAnimationFrame(tick)}
function release(event){if(activePointer===null||(event?.pointerId!=null&&event.pointerId!==activePointer))return;const pointer=activePointer;activePointer=null;try{canvas.releasePointerCapture(pointer)}catch(_){}canvas.classList.remove('is-active');draw();returnToCenter()}
const TAU = Math.PI * 2; canvas.addEventListener('pointerdown',event=>{if(activePointer!==null||!window.canDrive?.()||event.button>0)return;activePointer=event.pointerId;cancelAnimationFrame(returnFrame);canvas.setPointerCapture(event.pointerId);canvas.classList.add('is-active');positionFromEvent(event);event.preventDefault()});
canvas.addEventListener('pointermove',event=>{if(event.pointerId===activePointer)positionFromEvent(event)});
function drawCurvedText(text, cx, cy, radius, centerAngle, flip = false) { canvas.addEventListener('pointerup',release);canvas.addEventListener('pointercancel',release);canvas.addEventListener('lostpointercapture',event=>{if(event.pointerId===activePointer)release(event)});
ctx.save(); canvas.addEventListener('pointerleave',event=>{if(event.pointerType==='mouse'&&event.pointerId===activePointer)release(event)});
ctx.font = '300 11.5px Inter, "SF Pro Display", "Helvetica Neue", Arial, sans-serif'; addEventListener('resize',resize);resize();
ctx.fillStyle = '#656f6c'; window.joystick3D={setPos(nextX,nextY){cancelAnimationFrame(returnFrame);setPosition(nextX,nextY)},easeBack(){if(activePointer===null)returnToCenter()},reset(){const pointer=activePointer;activePointer=null;cancelAnimationFrame(returnFrame);if(pointer!==null)try{canvas.releasePointerCapture(pointer)}catch(_){}canvas.classList.remove('is-active');setPosition(0,0,false)},getState(){return {x,y,active:activePointer!==null}}};
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
const charWidths = [];
let totalWidth = 0;
for (let i = 0; i < text.length; i++) {
const w = ctx.measureText(text[i]).width + 1.8;
charWidths.push(w);
totalWidth += w;
}
const totalAngle = totalWidth / radius;
if (!flip) {
let currentAngle = centerAngle - totalAngle / 2;
for (let i = 0; i < text.length; i++) {
const charAngle = charWidths[i] / radius;
const midAngle = currentAngle + charAngle / 2;
const x = cx + radius * Math.cos(midAngle);
const y = cy + radius * Math.sin(midAngle);
ctx.save();
ctx.translate(x, y);
ctx.rotate(midAngle + Math.PI / 2);
ctx.fillText(text[i], 0, 0);
ctx.restore();
currentAngle += charAngle;
}
} else {
let currentAngle = centerAngle + totalAngle / 2;
for (let i = 0; i < text.length; i++) {
const charAngle = charWidths[i] / radius;
const midAngle = currentAngle - charAngle / 2;
const x = cx + radius * Math.cos(midAngle);
const y = cy + radius * Math.sin(midAngle);
ctx.save();
ctx.translate(x, y);
ctx.rotate(midAngle - Math.PI / 2);
ctx.fillText(text[i], 0, 0);
ctx.restore();
currentAngle -= charAngle;
}
}
ctx.restore();
}
function draw() {
if (!W || !H) return;
ctx.save();
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, W, H);
const cx = W / 2;
const cy = H / 2;
const R = Math.min(W, H) * 0.45;
const baseKnobR = R * 0.22; // 20% larger knob
const knobR = baseKnobR * pressScale;
const travel = R * 0.58;
const dist = Math.hypot(nx, ny);
/* ── 1. SPHERICAL BOWL BASE ── */
ctx.save();
ctx.beginPath();
ctx.arc(cx, cy, R, 0, TAU);
ctx.clip();
// Base smooth light background (clean, no blue edge gradient)
const bg = ctx.createRadialGradient(cx, cy - R * 0.1, R * 0.1, cx, cy, R);
bg.addColorStop(0, '#ffffff');
bg.addColorStop(0.55, '#f7f9f8');
bg.addColorStop(0.85, '#edf1f0');
bg.addColorStop(1, '#e3e8e7');
ctx.fillStyle = bg;
ctx.fillRect(cx - R, cy - R, R * 2, R * 2);
// 30% larger, 20% lighter transparent red boundary glow when pulled near edge (dist > 0.65)
if (dist > 0.65) {
const redFactor = Math.min(1, (dist - 0.65) / 0.35);
const angle = Math.atan2(ny, nx);
const edgeX = cx + R * Math.cos(angle);
const edgeY = cy + R * Math.sin(angle);
const redGrad = ctx.createRadialGradient(edgeX, edgeY, 0, edgeX, edgeY, R * 0.85);
redGrad.addColorStop(0, `rgba(240, 110, 95, ${0.14 * redFactor})`);
redGrad.addColorStop(0.5, `rgba(240, 110, 95, ${0.045 * redFactor})`);
redGrad.addColorStop(1, 'rgba(240, 110, 95, 0)');
ctx.fillStyle = redGrad;
ctx.fillRect(cx - R, cy - R, R * 2, R * 2);
}
// Straight vertical and horizontal axis lines
ctx.strokeStyle = 'rgba(155, 175, 178, 0.32)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(cx, cy - R * 0.85);
ctx.lineTo(cx, cy + R * 0.85);
ctx.moveTo(cx - R * 0.85, cy);
ctx.lineTo(cx + R * 0.85, cy);
ctx.stroke();
// ── Curved Lowercase Directional Labels ──
const labelR = R * 0.84;
drawCurvedText('forward', cx, cy, labelR, -Math.PI / 2, false);
drawCurvedText('reverse', cx, cy, labelR, Math.PI / 2, true);
drawCurvedText('turn left', cx, cy, labelR, Math.PI, false);
drawCurvedText('turn right', cx, cy, labelR, 0, false);
ctx.restore(); // un-clip
// Outer rim border
ctx.beginPath();
ctx.arc(cx, cy, R, 0, TAU);
ctx.strokeStyle = '#cdd6d4';
ctx.lineWidth = 1.5;
ctx.stroke();
/* ── 2. SOFT KNOB SHADOW ── */
const kx = cx + nx * travel;
const ky = cy + ny * travel;
const shadowY = ky + 2.5;
const sg = ctx.createRadialGradient(kx, shadowY, knobR * 0.3, kx, shadowY, knobR * 1.25);
sg.addColorStop(0, 'rgba(18, 28, 24, 0.15)');
sg.addColorStop(0.55, 'rgba(18, 28, 24, 0.05)');
sg.addColorStop(1, 'rgba(0, 0, 0, 0)');
ctx.beginPath();
ctx.arc(kx, shadowY, knobR * 1.25, 0, TAU);
ctx.fillStyle = sg;
ctx.fill();
/* ── 3. SINGLE 20% LARGER LIGHTER KNOB ── */
ctx.save();
ctx.translate(kx, ky);
// Lighter, clean metallic/slate gradient
const kg = ctx.createLinearGradient(0, -knobR, 0, knobR);
kg.addColorStop(0, '#687271');
kg.addColorStop(0.45, '#515958');
kg.addColorStop(1, '#3c4443');
ctx.beginPath();
ctx.arc(0, 0, knobR, 0, TAU);
ctx.fillStyle = kg;
ctx.fill();
// Clean 1px top highlight bevel
const bevel = ctx.createLinearGradient(0, -knobR, 0, knobR);
bevel.addColorStop(0, 'rgba(255, 255, 255, 0.55)');
bevel.addColorStop(0.4, 'rgba(255, 255, 255, 0.05)');
bevel.addColorStop(0.7, 'rgba(0, 0, 0, 0.1)');
bevel.addColorStop(1, 'rgba(0, 0, 0, 0.40)');
ctx.strokeStyle = bevel;
ctx.lineWidth = 1;
ctx.stroke();
// Outer light edge border (matching vertical knob)
ctx.strokeStyle = '#d7dedc';
ctx.lineWidth = 1;
ctx.stroke();
// Subtle cyan light glow ONLY when actively holding/dragging
if (dragging) {
const gg = ctx.createRadialGradient(0, 0, 0, 0, 0, knobR * 1.15);
gg.addColorStop(0, 'rgba(255, 255, 255, 0.32)');
gg.addColorStop(0.4, 'rgba(200, 245, 255, 0.18)');
gg.addColorStop(0.75, 'rgba(114, 216, 232, 0.10)');
gg.addColorStop(1, 'rgba(0, 0, 0, 0)');
ctx.beginPath();
ctx.arc(0, 0, knobR, 0, TAU);
ctx.fillStyle = gg;
ctx.fill();
}
ctx.restore(); // un-knob translate
ctx.restore(); // top-level save
}
function setPos(x, y) {
const len = Math.hypot(x, y);
if (len > 1) { x /= len; y /= len; }
nx = x;
ny = y;
draw();
if (window.setJoyControl) window.setJoyControl(nx, ny);
}
function pointerToNorm(e) {
const r = canvas.getBoundingClientRect();
const cx = r.width / 2;
const cy = r.height / 2;
const travel = Math.min(r.width, r.height) * 0.45 * 0.58;
const px = e.clientX - r.left;
const py = e.clientY - r.top;
setPos((px - cx) / travel, (py - cy) / travel);
}
/* ── Tactile Press Animation (matching vertical knob) ── */
function animatePress(toPressed) {
cancelAnimationFrame(pressAnimId);
const startScale = pressScale;
const targetScale = toPressed ? 0.93 : 1.0;
const duration = toPressed ? 110 : 450;
const startTime = performance.now();
function step(now) {
const p = Math.min(1, (now - startTime) / duration);
const curve = toPressed
? p * (2 - p)
: 1 + Math.sin((p - 1) * Math.PI * 2.5) * Math.pow(1 - p, 2) * 0.35;
pressScale = startScale + (targetScale - startScale) * (toPressed ? curve : p);
if (!toPressed) {
pressScale = 1.0 + (startScale - 1.0) * Math.pow(1 - p, 2) + Math.sin(p * Math.PI) * 0.02 * (1 - p);
}
draw();
if (p < 1) {
pressAnimId = requestAnimationFrame(step);
} else {
pressScale = targetScale;
draw();
}
}
pressAnimId = requestAnimationFrame(step);
}
function easeBack() {
cancelAnimationFrame(animId);
const sx = nx, sy = ny, t0 = performance.now();
(function step(now) {
const p = Math.min(1, (now - t0) / 320);
const s = p * p * (3 - 2 * p);
nx = sx * (1 - s);
ny = sy * (1 - s);
draw();
if (window.setJoyControl) window.setJoyControl(nx, ny);
if (p < 1) animId = requestAnimationFrame(step);
})(t0);
}
canvas.addEventListener('pointerdown', e => {
dragging = true;
canvas.style.cursor = 'grabbing';
cancelAnimationFrame(animId);
animatePress(true);
canvas.setPointerCapture(e.pointerId);
pointerToNorm(e);
});
canvas.addEventListener('pointermove', e => {
if (dragging) pointerToNorm(e);
});
canvas.addEventListener('pointerup', () => {
dragging = false;
canvas.style.cursor = 'grab';
animatePress(false);
easeBack();
});
canvas.addEventListener('pointercancel', () => {
dragging = false;
canvas.style.cursor = 'grab';
animatePress(false);
easeBack();
});
resize();
window.joystick3D = {
setPos(x, y) { cancelAnimationFrame(animId); setPos(x, y); },
easeBack() { if (!dragging) easeBack(); }
};
})(); })();
+694 -646
View File
File diff suppressed because it is too large Load Diff
+187 -26
View File
@@ -16,6 +16,7 @@ import webview
from serial.tools import list_ports from serial.tools import list_ports
from app import Axes, MOTOR_FUNCTIONS, MavlinkWorker, motor_number_for_function from app import Axes, MOTOR_FUNCTIONS, MavlinkWorker, motor_number_for_function
from vehicle_state import VehicleState
class ControlApi: class ControlApi:
@@ -24,12 +25,19 @@ class ControlApi:
def __init__(self): def __init__(self):
self.events: queue.Queue = queue.Queue() self.events: queue.Queue = queue.Queue()
self.worker = MavlinkWorker(self.events) self.worker = MavlinkWorker(self.events)
self.worker.require_input_lease = True
self.vehicle_state = VehicleState()
self.battery_received_at = None
self.demo_mode = False
self.lock = threading.Lock() self.lock = threading.Lock()
self._poll_lock = threading.Lock()
self.state = { self.state = {
"connected": False, "connected": False,
"link": "DISCONNECTED", "link": "DISCONNECTED",
"vehicle": "No heartbeat", "vehicle": "No heartbeat",
"armed": False, "armed": False,
"mode": None,
"app_state": "disconnected",
"status": "Ready. Select a USB port to begin.", "status": "Ready. Select a USB port to begin.",
"battery": None, "battery": None,
"log_path": None, "log_path": None,
@@ -57,14 +65,26 @@ class ControlApi:
def initial_state(self): def initial_state(self):
return { return {
"ports": [p.device for p in list_ports.comports()], "ports": self.refresh_ports(),
"battery_config": self.battery_config, "battery_config": self.battery_config,
"state": self._snapshot(), "state": self._snapshot(),
"model_info": self.get_saved_model_info(), "model_info": self.get_saved_model_info(),
} }
def refresh_ports(self): def refresh_ports(self):
return [p.device for p in list_ports.comports()] ports = []
# Add physical and virtual serial COM ports
for p in list_ports.comports():
desc = p.description or ""
if desc and desc != "n/a" and desc != p.device:
label = f"{p.device} ({desc})"
else:
label = p.device
ports.append({"device": p.device, "label": label, "type": "serial"})
# Built-in UI testing: no external process, port, or MAVLink socket.
ports.insert(0, {"device": "demo", "label": "Demo Mode (no hardware)", "type": "demo"})
return ports
def minimize_window(self): def minimize_window(self):
if self._window: if self._window:
@@ -202,28 +222,83 @@ class ControlApi:
return None return None
def connect(self, port, baud=57600, rate=20): def connect(self, port, baud=57600, rate=20):
print(f"[web_ui] connect() called: port={port!r}, baud={baud!r}, rate={rate!r}")
if not port: if not port:
raise ValueError("Select a USB serial port") raise ValueError("Select a USB serial port")
self.worker.rate_hz = int(rate) if str(port).lower() in ("demo", "tcp:127.0.0.1:5760", "udpin:127.0.0.1:14550"):
self.worker.connect(str(port), int(baud)) self.demo_mode = True
self.servo_functions = {1: 33, 2: 34, 3: 35}
self.motor_directions = {1: 1, 2: 1, 3: 1}
with self.lock: with self.lock:
self.state.update(
connected=True, link="LINK OK", vehicle="DEMO • DISARMED",
armed=False, mode="MANUAL", status="Demo mode ready — no hardware connected.",
battery={"voltage": 15.8, "per_cell": 3.95, "percent": 72,
"cells": 4, "low": False},
)
for servo in range(1, 17):
function = self.servo_functions.get(servo, 0)
motor = motor_number_for_function(function)
self.state["servos"][str(servo)] = {
"function": function, "label": MOTOR_FUNCTIONS.get(function, "Unassigned"),
"motor": motor, "direction": self.motor_directions.get(motor) if motor else None,
}
return self._snapshot()
self.worker.rate_hz = int(rate)
with self.lock:
self.vehicle_state.disconnect()
self.state.update( self.state.update(
connected=True, link="CONNECTING", connected=True, link="CONNECTING",
status=f"Opening {port} at {baud} baud…", status=f"Opening {port} at {baud} baud…",
) )
return True self.worker.connect(str(port), int(baud))
print(f"[web_ui] connect() dispatched to worker thread OK")
# Return only once the worker has either seen a heartbeat or reported a
# connection failure. This makes the button independent of the regular
# UI polling loop and prevents an indefinite visual "Connecting" state.
deadline = time.monotonic() + 8.0
while time.monotonic() < deadline:
snapshot = self.poll()
if snapshot["app_state"] != "connecting":
return snapshot
time.sleep(0.05)
self.worker.disconnect()
time.sleep(0.1)
self.poll()
raise TimeoutError(f"No MAVLink heartbeat received from {port} within 8 seconds")
def disconnect(self): def disconnect(self):
if self.demo_mode:
self.demo_mode = False
with self.lock:
self.state.update(connected=False, link="DISCONNECTED", vehicle="No heartbeat",
armed=False, mode=None, battery=None, status="Demo mode disconnected")
return True
self.worker.disconnect() self.worker.disconnect()
return True return True
def set_axes(self, forward, turn, vertical): def set_axes(self, forward, turn, vertical, sequence=None):
if self.demo_mode:
with self.lock:
self.state["status"] = (
f"Demo controls • surge {float(forward):+.2f} • "
f"yaw {float(turn):+.2f} • heave {float(vertical):+.2f}"
)
return True
with self.lock:
allowed = self.vehicle_state.snapshot()["controls_allowed"]
self.worker.set_axes(Axes( self.worker.set_axes(Axes(
max(-1.0, min(1.0, float(forward))), max(-1.0, min(1.0, float(forward))), 0.0,
0.0,
max(-1.0, min(1.0, float(vertical))), max(-1.0, min(1.0, float(vertical))),
max(-1.0, min(1.0, float(turn))), max(-1.0, min(1.0, float(turn))),
)) ) if allowed else Axes(), sequence)
return True
def stop_controls(self, sequence=None):
"""Invalidate queued input without claiming that the vehicle disarmed."""
with self.lock:
self.worker.set_axes(Axes(), sequence)
self.worker._send_control(force_neutral=True)
return True return True
def set_gain(self, value): def set_gain(self, value):
@@ -231,7 +306,16 @@ class ControlApi:
self.worker.gain = value / 100.0 self.worker.gain = value / 100.0
return value return value
def command(self, name): def command(self, name, sequence=None, expires_at=None):
if self.demo_mode:
if name not in ("manual", "arm", "disarm"):
raise ValueError(f"Unknown command: {name}")
armed = name == "arm" if name != "manual" else self.state.get("armed", False)
with self.lock:
self.state.update(armed=armed, mode="MANUAL",
vehicle=f"DEMO • {'ARMED' if armed else 'DISARMED'}",
status=f"Demo: {'Manual mode' if name == 'manual' else name.capitalize()} confirmed")
return True
if not self.worker.connection or not self.worker.target_system: if not self.worker.connection or not self.worker.target_system:
if name == "manual": if name == "manual":
raise RuntimeError("Connect to vehicle to enable Manual mode") raise RuntimeError("Connect to vehicle to enable Manual mode")
@@ -240,15 +324,25 @@ class ControlApi:
elif name == "disarm": elif name == "disarm":
raise RuntimeError("Connect to vehicle before disarming") raise RuntimeError("Connect to vehicle before disarming")
raise RuntimeError("No autopilot heartbeat") raise RuntimeError("No autopilot heartbeat")
if name in ("arm", "disarm"):
self.poll()
with self.lock:
if name == "arm":
if expires_at is not None and time.time() * 1000 > expires_at:
raise RuntimeError("Engage request expired before it could be sent")
if sequence is not None and sequence <= self.worker.last_input_sequence:
raise RuntimeError("Engage canceled by a newer input reset")
self.vehicle_state.request(name)
self.worker.set_axes(Axes(), sequence)
try:
self.worker.command_arm(name == "arm")
except Exception as exc:
self.vehicle_state.fail(f"{name.capitalize()} send failed: {exc}")
raise
return self._snapshot()
if name == "manual": if name == "manual":
self.worker.log("OPERATOR", "clicked SET MANUAL") self.worker.log("OPERATOR", "clicked SET MANUAL")
self.worker.set_manual_mode() 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: else:
raise ValueError(f"Unknown command: {name}") raise ValueError(f"Unknown command: {name}")
return True return True
@@ -258,6 +352,10 @@ class ControlApi:
if not 1 <= servo <= 16 or not 0 <= motor <= 12: if not 1 <= servo <= 16 or not 0 <= motor <= 12:
raise ValueError("Invalid servo or motor") raise ValueError("Invalid servo or motor")
function = 0 if motor == 0 else (32 + motor if motor <= 8 else 73 + motor) function = 0 if motor == 0 else (32 + motor if motor <= 8 else 73 + motor)
if self.demo_mode:
self.servo_functions[servo] = function
self._update_servo(servo)
return True
self.worker.set_parameter(f"SERVO{servo}_FUNCTION", function) self.worker.set_parameter(f"SERVO{servo}_FUNCTION", function)
return True return True
@@ -269,6 +367,10 @@ class ControlApi:
current = self.motor_directions.get(motor) current = self.motor_directions.get(motor)
if current is None: if current is None:
raise ValueError(f"MOT_{motor}_DIRECTION has not been read yet") raise ValueError(f"MOT_{motor}_DIRECTION has not been read yet")
if self.demo_mode:
self.motor_directions[motor] = -1 if current == 1 else 1
self._update_servo(servo)
return True
self.worker.set_parameter(f"MOT_{motor}_DIRECTION", -1 if current == 1 else 1) self.worker.set_parameter(f"MOT_{motor}_DIRECTION", -1 if current == 1 else 1)
return True return True
@@ -298,13 +400,15 @@ class ControlApi:
return True return True
def poll(self): def poll(self):
while True: # Bound a poll even if telemetry arrives continuously.
with self._poll_lock:
for _ in range(500):
try: try:
event = self.events.get_nowait() event = self.events.get_nowait()
except queue.Empty: except queue.Empty:
break break
self._consume(event) self._consume(event)
if self.worker.target_system and time.monotonic() - self.worker.last_heartbeat > 5: if self.worker.target_system and time.monotonic() - self.worker.last_heartbeat > 3:
with self.lock: with self.lock:
self.state["link"] = ( self.state["link"] = (
"DATA OK / HEARTBEAT DELAYED" "DATA OK / HEARTBEAT DELAYED"
@@ -312,24 +416,49 @@ class ControlApi:
) )
return self._snapshot() return self._snapshot()
def _derive_app_state(self) -> str:
if not self.state.get("connected"):
return "disconnected"
link = self.state.get("link", "")
if link == "CONNECTING":
return "connecting"
if link in ("DATA OK / HEARTBEAT DELAYED", "ALL LINK DATA LOST"):
return "link_degraded"
if self.state.get("armed"):
return "armed"
return "connected_disarmed"
def _snapshot(self): def _snapshot(self):
with self.lock: with self.lock:
return json.loads(json.dumps(self.state)) snap = json.loads(json.dumps(self.state))
snap["app_state"] = self._derive_app_state()
if not self.demo_mode:
snap.update(self.vehicle_state.snapshot())
if snap["connected"] and snap["app_state"] != "connecting" and not snap["armed_known"]:
snap["app_state"] = "link_degraded"
snap["battery_stale"] = (self.battery_received_at is None
or time.monotonic() - self.battery_received_at > 5)
return snap
def _consume(self, event): def _consume(self, event):
kind = event[0] kind = event[0]
with self.lock: with self.lock:
if kind == "heartbeat": if kind == "heartbeat":
_, system, autopilot, mode, armed = event _, system, autopilot, mode, armed = event[:5]
self.vehicle_state.heartbeat(armed, event[5] if len(event) > 5 else None)
self.state.update( self.state.update(
connected=True, link="LINK OK", vehicle=f"{mode} • {'ARMED' if armed else 'DISARMED'}", connected=True, link="LINK OK", vehicle=f"{mode} • {'ARMED' if armed else 'DISARMED'}",
armed=bool(armed), armed=bool(armed),
mode=str(mode).upper(),
) )
elif kind in ("status", "statustext"): elif kind in ("status", "statustext"):
self.state["status"] = event[1] self.state["status"] = event[1]
elif kind == "ack": elif kind == "ack":
if event[1] == 400:
self.vehicle_state.ack(event[2], event[3] if len(event) > 3 else None)
self.state["status"] = f"Command {event[1]} ACK: {event[2]}" self.state["status"] = f"Command {event[1]} ACK: {event[2]}"
elif kind == "battery": elif kind == "battery":
self.battery_received_at = time.monotonic()
voltage = float(event[1]) voltage = float(event[1])
cells = self.battery_config["cells"] cells = self.battery_config["cells"]
per_cell = voltage / cells per_cell = voltage / cells
@@ -355,9 +484,14 @@ class ControlApi:
self.state["log_path"] = event[1] self.state["log_path"] = event[1]
elif kind == "error": elif kind == "error":
self.state["status"] = event[1] self.state["status"] = event[1]
self.state["last_error"] = event[1]
elif kind == "disconnected": elif kind == "disconnected":
self.vehicle_state.disconnect()
last_err = self.state.get("last_error")
status_msg = last_err if last_err else "Disconnected"
self.state.update(connected=False, link="DISCONNECTED", vehicle="No heartbeat", self.state.update(connected=False, link="DISCONNECTED", vehicle="No heartbeat",
armed=False, battery=None, status="Disconnected") mode=None, status=status_msg)
self.state["last_error"] = None
def _update_servo(self, servo): def _update_servo(self, servo):
function = self.servo_functions.get(servo) function = self.servo_functions.get(servo)
@@ -380,22 +514,49 @@ def _apply_dwm_styling(window):
hwnd = int(window.native.Handle.ToInt64()) hwnd = int(window.native.Handle.ToInt64())
if hwnd: if hwnd:
import ctypes import ctypes
user32 = ctypes.windll.user32
dwmapi = ctypes.windll.dwmapi
corner_pref = ctypes.c_int(2) # DWMWCP_ROUND corner_pref = ctypes.c_int(2) # DWMWCP_ROUND
ctypes.windll.dwmapi.DwmSetWindowAttribute( dwmapi.DwmSetWindowAttribute(
ctypes.c_void_p(hwnd), 33, ctypes.byref(corner_pref), ctypes.sizeof(corner_pref) ctypes.c_void_p(hwnd), 33, ctypes.byref(corner_pref), ctypes.sizeof(corner_pref)
) )
dark_mode = ctypes.c_int(1) # DWMWA_USE_IMMERSIVE_DARK_MODE dark_mode = ctypes.c_int(1) # DWMWA_USE_IMMERSIVE_DARK_MODE
ctypes.windll.dwmapi.DwmSetWindowAttribute( dwmapi.DwmSetWindowAttribute(
ctypes.c_void_p(hwnd), 20, ctypes.byref(dark_mode), ctypes.sizeof(dark_mode) ctypes.c_void_p(hwnd), 20, ctypes.byref(dark_mode), ctypes.sizeof(dark_mode)
) )
user32.SetForegroundWindow(ctypes.c_void_p(hwnd))
except Exception: except Exception:
pass pass
def _get_window_size() -> tuple[int, int, tuple[int, int]]:
target_w, target_h = 1100, 650
min_w, min_h = 960, 580
try:
import ctypes
user32 = ctypes.windll.user32
sw = user32.GetSystemMetrics(0)
sh = user32.GetSystemMetrics(1)
if sw > 0 and sh > 0:
target_w = min(target_w, max(min_w, int(sw * 0.92)))
target_h = min(target_h, max(min_h, int(sh * 0.90)))
min_w = min(min_w, target_w)
min_h = min(min_h, target_h)
except Exception:
pass
return target_w, target_h, (min_w, min_h)
class AppHTTPHandler(http.server.SimpleHTTPRequestHandler): class AppHTTPHandler(http.server.SimpleHTTPRequestHandler):
def log_message(self, format, *args): def log_message(self, format, *args):
pass # Suppress HTTP request logs in console pass # Suppress HTTP request logs in console
def end_headers(self):
self.send_header("Cache-Control", "no-cache, no-store, must-revalidate")
self.send_header("Pragma", "no-cache")
self.send_header("Expires", "0")
super().end_headers()
def do_GET(self): def do_GET(self):
unquoted = urllib.parse.unquote(self.path) unquoted = urllib.parse.unquote(self.path)
if self.path in ("/model", "/model.stl", "/runtime/vehicle_model.stl") or "Main Assembly" in unquoted or "vehicle_model" in unquoted: if self.path in ("/model", "/model.stl", "/runtime/vehicle_model.stl") or "Main Assembly" in unquoted or "vehicle_model" in unquoted:
@@ -409,7 +570,6 @@ class AppHTTPHandler(http.server.SimpleHTTPRequestHandler):
self.send_header("Content-Type", "model/stl") self.send_header("Content-Type", "model/stl")
self.send_header("Content-Length", str(len(data))) self.send_header("Content-Length", str(len(data)))
self.send_header("Access-Control-Allow-Origin", "*") self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Cache-Control", "no-cache")
self.end_headers() self.end_headers()
self.wfile.write(data) self.wfile.write(data)
return return
@@ -429,10 +589,11 @@ def main():
api._migrate_legacy_model() api._migrate_legacy_model()
project_root = Path(__file__).resolve().parent project_root = Path(__file__).resolve().parent
port = start_local_server(project_root) port = start_local_server(project_root)
app_url = f"http://127.0.0.1:{port}/web/index.html" app_url = f"http://127.0.0.1:{port}/web/index.html?v={int(time.time())}"
win_w, win_h, min_dims = _get_window_size()
window = webview.create_window( window = webview.create_window(
"SUB Control", url=app_url, js_api=api, "SUB Control", url=app_url, js_api=api,
width=1400, height=808, min_size=(1125, 690), width=win_w, height=win_h, min_size=min_dims,
background_color="#111719", frameless=True, easy_drag=False, background_color="#111719", frameless=True, easy_drag=False,
shadow=True, shadow=True,
) )
+81 -12
View File
@@ -42,6 +42,25 @@ class Axes:
yaw: float = 0.0 yaw: float = 0.0
def normalize_endpoint(port: str) -> str:
p = str(port).strip()
if not p:
return p
lower = p.lower()
if "sim" in lower or "5760" in lower or lower in ("tcp", "tcp:", "tcp:127.0.0.1", "127.0.0.1"):
return "tcp:127.0.0.1:5760"
if "14550" in lower or lower in ("udp", "udp:", "udpin", "udpin:"):
return "udpin:127.0.0.1:14550"
if p.startswith("127.0.0.1:") or p.startswith("localhost:"):
num = p.split(":")[1]
if num == "14550":
return f"udpin:{p}"
return f"tcp:{p}"
if ":" in p and not p.startswith(("tcp:", "tcpin:", "udp:", "udpin:", "udpout:")):
return f"tcp:{p}"
return p
class MavlinkWorker: class MavlinkWorker:
def __init__(self, events: queue.Queue): def __init__(self, events: queue.Queue):
self.events = events self.events = events
@@ -52,6 +71,11 @@ class MavlinkWorker:
self.axes = Axes() self.axes = Axes()
self.output_axes = Axes() self.output_axes = Axes()
self.last_control_at = 0.0 self.last_control_at = 0.0
# Enabled by the web bridge only; the legacy Tk frontend is unchanged.
self.require_input_lease = False
self.last_input_at = 0.0
self.last_input_sequence = -1
self.input_timeout = 1.0
# Smooth ESC commands without adding a perceptible one-second lag. # Smooth ESC commands without adding a perceptible one-second lag.
# At 20 Hz this produces five small steps from neutral to full command. # At 20 Hz this produces five small steps from neutral to full command.
self.ramp_seconds = 0.25 self.ramp_seconds = 0.25
@@ -74,29 +98,52 @@ class MavlinkWorker:
self.log_path = None self.log_path = None
def connect(self, port: str, baud: int): def connect(self, port: str, baud: int):
print(f"[MavlinkWorker] connect() called: port={port!r}, baud={baud}")
if self.thread and self.thread.is_alive(): if self.thread and self.thread.is_alive():
print(f"[MavlinkWorker] Thread already alive, ignoring connect()")
return return
self.stop_event.clear() self.stop_event.clear()
log_dir = Path(__file__).resolve().parent / "logs" log_dir = Path(__file__).resolve().parent / "logs"
log_dir.mkdir(exist_ok=True) log_dir.mkdir(exist_ok=True)
self.log_path = log_dir / f"ardusub_{datetime.now():%Y%m%d_%H%M%S}.log" 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_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}") normalized = normalize_endpoint(port)
print(f"[MavlinkWorker] normalized endpoint: {normalized!r}")
self.log("SESSION", f"start port={normalized} (input={port}) baud={baud} control_hz={self.rate_hz}")
self.events.put(("log_path", str(self.log_path))) self.events.put(("log_path", str(self.log_path)))
self.thread = threading.Thread( self.thread = threading.Thread(
target=self._run, args=(port, baud), daemon=True target=self._run, args=(normalized, baud), daemon=True
) )
self.thread.start() self.thread.start()
print(f"[MavlinkWorker] worker thread started")
def _run(self, port: str, baud: int): def _run(self, port: str, baud: int):
print(f"[MavlinkWorker._run] ENTERED: port={port!r}, baud={baud}")
try: try:
print(f"[MavlinkWorker._run] Creating mavlink_connection...")
conn = mavutil.mavlink_connection( conn = mavutil.mavlink_connection(
port, baud=baud, source_system=255, source_component=190, port, baud=baud, source_system=255, source_component=190,
autoreconnect=False, force_connected=True, # Fail a missing TCP simulator immediately. pymavlink otherwise
# retries the refused socket several times and leaves the UI in
# "Opening" long after the actual failure.
autoreconnect=False, retries=0, force_connected=True,
) )
print(f"[MavlinkWorker._run] mavlink_connection created: {type(conn).__name__}")
self.connection = conn self.connection = conn
self.events.put(("status", f"Port open; waiting for heartbeat at {baud} baud…")) if port.startswith("tcp:"):
next_gcs_hb = 0.0 self.events.put(("status", f"Connected to {port}; waiting for vehicle heartbeat…"))
else:
self.events.put(("status", f"Port {port} open; waiting for heartbeat at {baud} baud…"))
# Send initial GCS heartbeat immediately
self.log("TX_HEARTBEAT", "GCS initial")
conn.mav.heartbeat_send(
mavutil.mavlink.MAV_TYPE_GCS,
mavutil.mavlink.MAV_AUTOPILOT_INVALID,
0, 0, mavutil.mavlink.MAV_STATE_ACTIVE,
)
print(f"[MavlinkWorker._run] Initial heartbeat sent, entering recv loop")
next_gcs_hb = time.monotonic() + 1.0
next_control = 0.0 next_control = 0.0
while not self.stop_event.is_set(): while not self.stop_event.is_set():
now = time.monotonic() now = time.monotonic()
@@ -120,6 +167,7 @@ class MavlinkWorker:
else: else:
time.sleep(0.01) time.sleep(0.01)
except Exception as exc: except Exception as exc:
print(f"[MavlinkWorker._run] EXCEPTION: {exc}")
self.events.put(("error", f"Connection error: {exc}")) self.events.put(("error", f"Connection error: {exc}"))
finally: finally:
self._safe_stop() self._safe_stop()
@@ -156,7 +204,11 @@ class MavlinkWorker:
except Exception: except Exception:
payload = str(msg) payload = str(msg)
self.log("RX", payload) self.log("RX", payload)
if msg_type == "HEARTBEAT" and msg.get_srcComponent() == 1: if msg_type == "HEARTBEAT" and getattr(msg, "type", 0) != mavutil.mavlink.MAV_TYPE_GCS:
if getattr(msg, "autopilot", mavutil.mavlink.MAV_AUTOPILOT_INVALID) == mavutil.mavlink.MAV_AUTOPILOT_INVALID:
return
if self.target_system and (msg.get_srcSystem(), msg.get_srcComponent()) != (self.target_system, self.target_component):
return
first = not self.target_system first = not self.target_system
self.target_system = msg.get_srcSystem() self.target_system = msg.get_srcSystem()
self.target_component = msg.get_srcComponent() self.target_component = msg.get_srcComponent()
@@ -170,15 +222,17 @@ class MavlinkWorker:
mode = str(msg.custom_mode) mode = str(msg.custom_mode)
autopilot = mavutil.mavlink.enums["MAV_AUTOPILOT"].get(msg.autopilot) autopilot = mavutil.mavlink.enums["MAV_AUTOPILOT"].get(msg.autopilot)
ap_name = autopilot.name if autopilot else str(msg.autopilot) ap_name = autopilot.name if autopilot else str(msg.autopilot)
self.events.put(("heartbeat", self.target_system, ap_name, mode, armed)) self.events.put(("heartbeat", self.target_system, ap_name, mode, armed, self.last_heartbeat))
if first: if first:
self.events.put(("status", "Heartbeat received; controls are neutral.")) self.events.put(("status", "Heartbeat received; controls are neutral."))
self._configure_low_bandwidth_streams() self._configure_low_bandwidth_streams()
self._start_servo_scan() self._start_servo_scan()
elif msg_type == "COMMAND_ACK": elif msg_type == "COMMAND_ACK":
if (msg.get_srcSystem(), msg.get_srcComponent()) != (self.target_system, self.target_component):
return
result = mavutil.mavlink.enums["MAV_RESULT"].get(msg.result) result = mavutil.mavlink.enums["MAV_RESULT"].get(msg.result)
result_name = result.name if result else str(msg.result) result_name = result.name if result else str(msg.result)
self.events.put(("ack", int(msg.command), result_name)) self.events.put(("ack", int(msg.command), result_name, self.last_rx))
elif msg_type == "STATUSTEXT": elif msg_type == "STATUSTEXT":
text = msg.text text = msg.text
if isinstance(text, bytes): if isinstance(text, bytes):
@@ -300,10 +354,17 @@ class MavlinkWorker:
if not conn or not self.target_system: if not conn or not self.target_system:
return return
with self.lock: with self.lock:
active = not force_neutral now = time.monotonic()
expired = self.require_input_lease and (
not self.armed or now - self.last_heartbeat > 3.0
or now - self.last_input_at > self.input_timeout)
if expired or force_neutral:
# Never replay a stored command after telemetry/UI recovery.
self.axes = Axes()
self.output_axes = Axes()
active = not (force_neutral or expired)
target = self.axes if active else Axes() target = self.axes if active else Axes()
gain = self.gain gain = self.gain
now = time.monotonic()
if force_neutral: if force_neutral:
self.output_axes = Axes() self.output_axes = Axes()
else: else:
@@ -375,7 +436,7 @@ class MavlinkWorker:
manual, manual,
) )
def set_axes(self, axes: Axes): def set_axes(self, axes: Axes, sequence=None):
def conditioned(value): def conditioned(value):
# Ignore tiny pointer/key return noise, then quantize to stable 1% steps. # Ignore tiny pointer/key return noise, then quantize to stable 1% steps.
if abs(value) < 0.04: if abs(value) < 0.04:
@@ -383,6 +444,11 @@ class MavlinkWorker:
return round(max(-1.0, min(1.0, value)), 2) return round(max(-1.0, min(1.0, value)), 2)
with self.lock: with self.lock:
if sequence is not None:
if sequence <= self.last_input_sequence:
return
self.last_input_sequence = sequence
self.last_input_at = time.monotonic()
self.axes = Axes( self.axes = Axes(
conditioned(axes.forward), conditioned(axes.lateral), conditioned(axes.forward), conditioned(axes.lateral),
conditioned(axes.vertical), conditioned(axes.yaw), conditioned(axes.vertical), conditioned(axes.yaw),
@@ -887,6 +953,9 @@ class App(tk.Tk):
def refresh_ports(self): def refresh_ports(self):
ports = [p.device for p in list_ports.comports()] ports = [p.device for p in list_ports.comports()]
for sim in ("tcp:127.0.0.1:5760", "udpin:127.0.0.1:14550"):
if sim not in ports:
ports.append(sim)
self.port.configure(values=ports) self.port.configure(values=ports)
if ports and self.port.get() not in ports: if ports and self.port.get() not in ports:
self.port.set(ports[0]) self.port.set(ports[0])
@@ -1139,7 +1208,7 @@ class App(tk.Tk):
event = self.events.get_nowait() event = self.events.get_nowait()
kind = event[0] kind = event[0]
if kind == "heartbeat": if kind == "heartbeat":
_, system, autopilot, mode, armed = event _, system, autopilot, mode, armed = event[:5]
self.vehicle_label.configure( self.vehicle_label.configure(
text=f"{mode} • {'ARMED' if armed else 'DISARMED'}" text=f"{mode} • {'ARMED' if armed else 'DISARMED'}"
) )
+553
View File
@@ -0,0 +1,553 @@
"""ArduSub Flight Controller (FC) Spoofer & Simulator.
Emulates an ArduSub SimpleROV-3 vehicle over MAVLink (TCP, UDP, or Serial).
Supports full parameter scans, live parameter updates (servo functions / motor reversal),
arming/disarming commands, manual control processing, and realistic telemetry streaming
(battery voltage/current with load sag, live thruster PWM mixing, simulated attitude & depth).
"""
from __future__ import annotations
import argparse
import math
import os
import sys
import time
from collections import deque
from dataclasses import dataclass
from typing import Any
from pymavlink import mavutil
# Default SimpleROV-3 motor mappings and ArduSub parameters
DEFAULT_PARAMETERS: dict[str, tuple[float, int]] = {
# Servo functions: 33 = Motor 1 (Port), 34 = Motor 2 (Starboard), 35 = Motor 3 (Vertical)
"SERVO1_FUNCTION": (33.0, mavutil.mavlink.MAV_PARAM_TYPE_INT16),
"SERVO2_FUNCTION": (34.0, mavutil.mavlink.MAV_PARAM_TYPE_INT16),
"SERVO3_FUNCTION": (35.0, mavutil.mavlink.MAV_PARAM_TYPE_INT16),
# Remaining servos unassigned
**{f"SERVO{i}_FUNCTION": (0.0, mavutil.mavlink.MAV_PARAM_TYPE_INT16) for i in range(4, 17)},
# Motor directions: 1 = normal, -1 = reversed
**{f"MOT_{i}_DIRECTION": (1.0, mavutil.mavlink.MAV_PARAM_TYPE_INT8) for i in range(1, 13)},
# SimpleROV-3 thruster PWM limits
"SERVO1_MIN": (1100.0, mavutil.mavlink.MAV_PARAM_TYPE_REAL32),
"SERVO1_TRIM": (1500.0, mavutil.mavlink.MAV_PARAM_TYPE_REAL32),
"SERVO1_MAX": (1900.0, mavutil.mavlink.MAV_PARAM_TYPE_REAL32),
"SERVO2_MIN": (1100.0, mavutil.mavlink.MAV_PARAM_TYPE_REAL32),
"SERVO2_TRIM": (1500.0, mavutil.mavlink.MAV_PARAM_TYPE_REAL32),
"SERVO2_MAX": (1900.0, mavutil.mavlink.MAV_PARAM_TYPE_REAL32),
"SERVO3_MIN": (1100.0, mavutil.mavlink.MAV_PARAM_TYPE_REAL32),
"SERVO3_TRIM": (1500.0, mavutil.mavlink.MAV_PARAM_TYPE_REAL32),
"SERVO3_MAX": (1900.0, mavutil.mavlink.MAV_PARAM_TYPE_REAL32),
# Joystick gain parameters
"JS_GAIN_MIN": (0.10, mavutil.mavlink.MAV_PARAM_TYPE_REAL32),
"JS_GAIN_DEFAULT": (0.20, mavutil.mavlink.MAV_PARAM_TYPE_REAL32),
"JS_GAIN_MAX": (1.00, mavutil.mavlink.MAV_PARAM_TYPE_REAL32),
}
@dataclass
class ControlState:
x: float = 0.0 # Surge (forward/back) -1000..1000
y: float = 0.0 # Sway (lateral) -1000..1000
z: float = 500.0 # Heave (vertical) 0..1000 (500 = neutral)
r: float = 0.0 # Yaw (turn) -1000..1000
last_rx: float = 0.0
class FlightControllerSpoofer:
def __init__(
self,
endpoint: str = "tcpin:127.0.0.1:5760",
baud: int = 57600,
battery_cells: int = 4,
nominal_voltage: float = 16.0,
show_dashboard: bool = True,
):
self.endpoint = endpoint
self.baud = baud
self.battery_cells = battery_cells
self.battery_base_v = nominal_voltage
self.show_dashboard = show_dashboard
self.running = True
self.armed = False
self.mode = 19 # ArduSub MANUAL mode
self.system_status = mavutil.mavlink.MAV_STATE_ACTIVE
self.target_system = 255
self.target_component = 190
self.params = dict(DEFAULT_PARAMETERS)
self.controls = ControlState()
# Simulated physical state
self.depth_m = 1.5 # Positive depth under water
self.heading_deg = 0.0
self.pitch_deg = 0.0
self.roll_deg = 0.0
self.m1_pwm = 1500 # Port
self.m2_pwm = 1500 # Starboard
self.m3_pwm = 1500 # Vertical
self.current_a = 0.35
self.events: deque[str] = deque(maxlen=6)
self.client_connected = False
self.total_packets_rx = 0
self.total_packets_tx = 0
self.start_time = time.monotonic()
def log_event(self, text: str):
now_str = time.strftime("%H:%M:%S")
self.events.append(f"[{now_str}] {text}")
def run(self):
print(f"\n{'='*72}")
print(f" ARDUSUB SIMPLE-ROV FLIGHT CONTROLLER SPOOFER IS RUNNING")
print(f" Target Endpoint: {self.endpoint}")
print(f" --> In Sub Control app, select or connect to: {self.endpoint.replace('tcpin:', 'tcp:')}")
print(f"{'='*72}\n", flush=True)
self.log_event(f"Simulator starting on {self.endpoint}...")
while self.running:
try:
self._run_server_session()
except KeyboardInterrupt:
break
except OSError as exc:
if getattr(exc, "winerror", None) == 10048 or "already in use" in str(exc).lower():
print(f"\n[ERROR] Port {self.endpoint} is already in use by another program or previous spoofer instance.")
print("Please close any running python/spoofer processes and try again.\n")
break
self.log_event(f"Session reset: {exc}")
time.sleep(1.0)
except Exception as exc:
self.log_event(f"Session reset: {exc}")
time.sleep(1.0)
print("\nSpoofer stopped.")
def _run_server_session(self):
# Bind server / open connection
if self.endpoint.startswith("tcpin:"):
shown_endpoint = self.endpoint.replace("tcpin:", "tcp:", 1)
print(f"[READY] Listening for SUB Control on {shown_endpoint}", flush=True)
print(" Leave this window open, then click Connect in the app.\n", flush=True)
conn = mavutil.mavlink_connection(
self.endpoint,
baud=self.baud,
source_system=1,
source_component=1,
autoreconnect=True,
force_connected=True,
)
self.log_event(f"Client connected on {self.endpoint}")
next_heartbeat = 0.0
next_sys_status = 0.0
next_servos = 0.0
next_attitude = 0.0
next_dashboard = 0.0
# Send initial announcement statustext
initial_sent = False
while self.running:
now = time.monotonic()
elapsed = now - self.start_time
time_boot_ms = int(elapsed * 1000) & 0xFFFFFFFF
time_boot_us = int(elapsed * 1000000) & 0xFFFFFFFF
# 1. Heartbeat at 1.0 Hz
if now >= next_heartbeat:
base_mode = (
mavutil.mavlink.MAV_MODE_FLAG_CUSTOM_MODE_ENABLED
| mavutil.mavlink.MAV_MODE_FLAG_MANUAL_INPUT_ENABLED
| mavutil.mavlink.MAV_MODE_FLAG_STABILIZE_ENABLED
)
if self.armed:
base_mode |= mavutil.mavlink.MAV_MODE_FLAG_SAFETY_ARMED
conn.mav.heartbeat_send(
mavutil.mavlink.MAV_TYPE_SUBMARINE,
mavutil.mavlink.MAV_AUTOPILOT_ARDUPILOTMEGA,
base_mode,
self.mode,
self.system_status,
)
self.total_packets_tx += 1
next_heartbeat = now + 1.0
if not initial_sent and self.client_connected:
conn.mav.statustext_send(
mavutil.mavlink.MAV_SEVERITY_INFO,
b"ArduSub V4.1.0 (SimpleROV-3 Simulator)",
)
conn.mav.statustext_send(
mavutil.mavlink.MAV_SEVERITY_INFO,
b"EKF3 IMU0 initialised",
)
initial_sent = True
# 2. Physics & Motor Mixing step
self._update_physics(now)
# 3. SYS_STATUS at 1.0 Hz (Battery & Sensor Health)
if now >= next_sys_status:
# Voltage with slight noise + load sag
load_factor = (
abs(self.m1_pwm - 1500)
+ abs(self.m2_pwm - 1500)
+ abs(self.m3_pwm - 1500)
) / 1200.0 # 0.0 to 1.0
voltage_v = self.battery_base_v - (0.45 * load_factor) + (0.02 * math.sin(now * 1.5))
voltage_mv = int(max(10.0, voltage_v) * 1000)
# Current: 0.35A base + up to 12.5A at full throttle
self.current_a = 0.35 + (12.5 * load_factor)
current_ca = int(self.current_a * 100)
# Battery remaining percentage (e.g. 88% draining slowly)
remaining_pct = max(10, int(88 - (elapsed / 300.0)))
conn.mav.sys_status_send(
322042887, # onboard_control_sensors_present
320969735, # onboard_control_sensors_enabled
305241095, # onboard_control_sensors_health
175, # load (17.5%)
voltage_mv,
current_ca,
remaining_pct,
0, 0, 0, 0, 0, 0,
)
self.total_packets_tx += 1
next_sys_status = now + 1.0
# 4. SERVO_OUTPUT_RAW & RC_CHANNELS at 4.0 Hz
if now >= next_servos:
conn.mav.servo_output_raw_send(
time_boot_us,
0,
self.m1_pwm,
self.m2_pwm,
self.m3_pwm,
0, 0, 0, 0, 0,
)
conn.mav.rc_channels_send(
time_boot_ms,
8,
1500 + int(self.controls.r * 0.4), # Chan 1: Yaw
1500, # Chan 2: Roll
int(self.controls.z), # Chan 3: Throttle/Vertical
1500 + int(self.controls.x * 0.4), # Chan 4: Pitch/Forward
1500, 1500, 1500, 1500,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
)
self.total_packets_tx += 2
next_servos = now + 0.25
# 5. ATTITUDE & VFR_HUD at 4.0 Hz
if now >= next_attitude:
conn.mav.attitude_send(
time_boot_ms,
math.radians(self.roll_deg),
math.radians(self.pitch_deg),
math.radians(self.heading_deg),
0.0, 0.0, 0.0,
)
conn.mav.vfr_hud_send(
abs(self.m1_pwm - 1500) / 400.0, # airspeed
abs(self.m2_pwm - 1500) / 400.0, # groundspeed
int(self.heading_deg) % 360, # heading
int((self.controls.z - 500) / 5), # throttle %
-self.depth_m, # altitude (negative depth)
(self.controls.z - 500) / 500.0, # climb rate
)
self.total_packets_tx += 2
next_attitude = now + 0.25
# 6. Process incoming MAVLink packets from App
self._handle_incoming_messages(conn)
# 7. Render Console Dashboard
if self.show_dashboard and now >= next_dashboard:
self._render_dashboard()
next_dashboard = now + 0.15
time.sleep(0.01)
def _update_physics(self, now: float):
# If manual control packet hasn't arrived for > 1.5s, auto-neutralize
if now - self.controls.last_rx > 1.5:
self.controls.x = 0.0
self.controls.y = 0.0
self.controls.z = 500.0
self.controls.r = 0.0
if not self.armed:
self.m1_pwm = 1500
self.m2_pwm = 1500
self.m3_pwm = 1500
else:
# Read motor direction parameters
mot1_dir = int(self.params.get("MOT_1_DIRECTION", (1.0,))[0])
mot2_dir = int(self.params.get("MOT_2_DIRECTION", (1.0,))[0])
mot3_dir = int(self.params.get("MOT_3_DIRECTION", (1.0,))[0])
# Differential drive mixing for SimpleROV-3:
# Motor 1 (Port): Forward + Yaw
# Motor 2 (Starboard): Forward - Yaw
# Motor 3 (Vertical): Vertical (0..1000 with 500 center)
forward_cmd = (self.controls.x / 1000.0) * 400.0 # -400..+400
yaw_cmd = (self.controls.r / 1000.0) * 400.0 # -400..+400
vert_cmd = ((self.controls.z - 500.0) / 500.0) * 400.0 # -400..+400
m1_offset = (forward_cmd + yaw_cmd) * mot1_dir
m2_offset = (forward_cmd - yaw_cmd) * mot2_dir
m3_offset = vert_cmd * mot3_dir
self.m1_pwm = int(max(1100, min(1900, 1500 + m1_offset)))
self.m2_pwm = int(max(1100, min(1900, 1500 + m2_offset)))
self.m3_pwm = int(max(1100, min(1900, 1500 + m3_offset)))
# Simulate subtle underwater motion
self.roll_deg = 2.5 * math.sin(now * 0.8)
self.pitch_deg = (self.controls.x / 1000.0) * 5.0 + 1.2 * math.cos(now * 0.7)
if self.armed and abs(self.controls.r) > 10:
self.heading_deg = (self.heading_deg + (self.controls.r / 1000.0) * 2.0) % 360.0
if self.armed and abs(self.controls.z - 500) > 20:
vert_delta = ((self.controls.z - 500.0) / 500.0) * 0.02
self.depth_m = max(0.1, min(50.0, self.depth_m - vert_delta))
def _handle_incoming_messages(self, conn):
while True:
msg = conn.recv_match(blocking=False)
if not msg:
break
self.total_packets_rx += 1
if not self.client_connected:
self.client_connected = True
self.log_event(f"GCS Connected (SysID {msg.get_srcSystem()}, CompID {msg.get_srcComponent()})")
# Immediately send heartbeat so GCS locks on with zero delay
base_mode = (
mavutil.mavlink.MAV_MODE_FLAG_CUSTOM_MODE_ENABLED
| mavutil.mavlink.MAV_MODE_FLAG_MANUAL_INPUT_ENABLED
| mavutil.mavlink.MAV_MODE_FLAG_STABILIZE_ENABLED
)
if self.armed:
base_mode |= mavutil.mavlink.MAV_MODE_FLAG_SAFETY_ARMED
conn.mav.heartbeat_send(
mavutil.mavlink.MAV_TYPE_SUBMARINE,
mavutil.mavlink.MAV_AUTOPILOT_ARDUPILOTMEGA,
base_mode,
self.mode,
self.system_status,
)
mtype = msg.get_type()
if mtype == "HEARTBEAT":
self.target_system = msg.get_srcSystem()
self.target_component = msg.get_srcComponent()
elif mtype == "COMMAND_LONG":
cmd = int(msg.command)
if cmd == mavutil.mavlink.MAV_CMD_COMPONENT_ARM_DISARM:
should_arm = bool(msg.param1 == 1.0)
self.armed = should_arm
conn.mav.command_ack_send(cmd, mavutil.mavlink.MAV_RESULT_ACCEPTED)
status_str = "Motors ARMED" if self.armed else "Motors DISARMED"
self.log_event(f"Command: {status_str}")
conn.mav.statustext_send(
mavutil.mavlink.MAV_SEVERITY_INFO,
status_str.encode("ascii"),
)
elif cmd == mavutil.mavlink.MAV_CMD_DO_SET_MODE:
self.mode = int(msg.param2)
conn.mav.command_ack_send(cmd, mavutil.mavlink.MAV_RESULT_ACCEPTED)
self.log_event(f"Command: SET_MODE {self.mode}")
conn.mav.statustext_send(
mavutil.mavlink.MAV_SEVERITY_INFO,
b"Mode: MANUAL",
)
elif cmd == mavutil.mavlink.MAV_CMD_SET_MESSAGE_INTERVAL:
msg_id = int(msg.param1)
conn.mav.command_ack_send(cmd, mavutil.mavlink.MAV_RESULT_ACCEPTED)
self.log_event(f"Stream interval set for MsgID {msg_id}")
else:
conn.mav.command_ack_send(cmd, mavutil.mavlink.MAV_RESULT_ACCEPTED)
elif mtype == "SET_MODE":
self.mode = int(msg.custom_mode)
self.log_event(f"Set custom mode -> {self.mode}")
conn.mav.statustext_send(mavutil.mavlink.MAV_SEVERITY_INFO, b"Mode: MANUAL")
elif mtype == "REQUEST_DATA_STREAM":
pass # Acknowledged implicitly by sending streams
elif mtype == "PARAM_REQUEST_READ":
param_id = msg.param_id
if isinstance(param_id, bytes):
param_id = param_id.decode("ascii", errors="ignore").rstrip("\x00")
if param_id in self.params:
val, ptype = self.params[param_id]
else:
val, ptype = (0.0, mavutil.mavlink.MAV_PARAM_TYPE_REAL32)
conn.mav.param_value_send(
param_id.encode("ascii"),
float(val),
ptype,
len(self.params),
0,
)
self.log_event(f"Param read: {param_id} = {val}")
elif mtype == "PARAM_REQUEST_LIST":
self.log_event("Param list requested, streaming all...")
for idx, (p_name, (p_val, p_type)) in enumerate(self.params.items()):
conn.mav.param_value_send(
p_name.encode("ascii"),
float(p_val),
p_type,
len(self.params),
idx,
)
time.sleep(0.002)
elif mtype == "PARAM_SET":
param_id = msg.param_id
if isinstance(param_id, bytes):
param_id = param_id.decode("ascii", errors="ignore").rstrip("\x00")
new_val = float(msg.param_value)
ptype = int(msg.param_type)
self.params[param_id] = (new_val, ptype)
conn.mav.param_value_send(
param_id.encode("ascii"),
new_val,
ptype,
len(self.params),
0,
)
self.log_event(f"Param updated: {param_id} -> {int(new_val) if new_val.is_integer() else new_val}")
elif mtype == "MANUAL_CONTROL":
self.controls.x = float(msg.x)
self.controls.y = float(msg.y)
self.controls.z = float(msg.z)
self.controls.r = float(msg.r)
self.controls.last_rx = time.monotonic()
def _render_dashboard(self):
# Clear screen ANSI
out = sys.stdout
out.write("\033[H")
# Color codes
GREEN = "\033[92m"
RED = "\033[91m"
YELLOW = "\033[93m"
CYAN = "\033[96m"
BOLD = "\033[1m"
DIM = "\033[2m"
RESET = "\033[0m"
def bar(val: float, min_v: float, max_v: float, width: int = 14) -> str:
norm = max(0.0, min(1.0, (val - min_v) / (max_v - min_v)))
pos = int(norm * width)
return f"[{'=' * pos}{' ' * (width - pos)}]"
status_tag = f"{GREEN}{BOLD} ARMED {RESET}" if self.armed else f"{YELLOW}DISARMED{RESET}"
link_tag = f"{GREEN}ONLINE{RESET}" if self.client_connected else f"{RED}WAITING{RESET}"
lines = [
f"{BOLD}{CYAN}========================================================================{RESET}",
f"{BOLD} ArduSub SimpleROV-3 Flight Controller Spoofer & Telemetry Simulator {RESET}",
f"{BOLD}{CYAN}========================================================================{RESET}",
f" Endpoint: {BOLD}{self.endpoint}{RESET} Baud: {self.baud} Link: {link_tag} State: {status_tag} Mode: {BOLD}MANUAL{RESET}",
f"{CYAN}------------------------------------------------------------------------{RESET}",
f" {BOLD}BATTERY & POWER:{RESET}",
f" Voltage: {BOLD}{self.battery_base_v:.2f} V{RESET} ({self.battery_cells}S) Current: {BOLD}{self.current_a:.2f} A{RESET} Power: {BOLD}{self.battery_base_v * self.current_a:.1f} W{RESET}",
f" Depth: {BOLD}{self.depth_m:.2f} m{RESET} Heading: {BOLD}{self.heading_deg:.0f}°{RESET} Roll/Pitch: {self.roll_deg:+.1f}° / {self.pitch_deg:+.1f}°",
f"{CYAN}------------------------------------------------------------------------{RESET}",
f" {BOLD}INCOMING MANUAL CONTROLS (from Sub Control app):{RESET}",
f" Surge (Forward/Back): {self.controls.x:+5.0f} {bar(self.controls.x, -1000, 1000)}",
f" Yaw (Steer/Turn): {self.controls.r:+5.0f} {bar(self.controls.r, -1000, 1000)}",
f" Heave (Vertical): {self.controls.z:5.0f} {bar(self.controls.z, 0, 1000)}",
f"{CYAN}------------------------------------------------------------------------{RESET}",
f" {BOLD}SIMULATED THRUSTER PWM OUTPUTS:{RESET}",
f" Motor 1 (Port / Left): {self.m1_pwm:4d} µs {bar(self.m1_pwm, 1100, 1900)}",
f" Motor 2 (Starboard / Right): {self.m2_pwm:4d} µs {bar(self.m2_pwm, 1100, 1900)}",
f" Motor 3 (Vertical / Center): {self.m3_pwm:4d} µs {bar(self.m3_pwm, 1100, 1900)}",
f"{CYAN}------------------------------------------------------------------------{RESET}",
f" {BOLD}RECENT EVENT LOG:{RESET}",
]
if not self.events:
lines.append(f" {DIM}No events yet. Connect Sub Control app to begin.{RESET}")
else:
for ev in list(self.events)[-5:]:
lines.append(f" {ev}")
lines.append(f"{CYAN}------------------------------------------------------------------------{RESET}")
lines.append(f" Packets RX: {self.total_packets_rx:<8d} Packets TX: {self.total_packets_tx:<8d} (Press Ctrl+C to exit)")
out.write("\n".join(lines) + "\n")
out.flush()
def main():
parser = argparse.ArgumentParser(
description="ArduSub Flight Controller (FC) Spoofer & Telemetry Simulator",
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
)
parser.add_argument(
"--endpoint",
type=str,
default="tcpin:127.0.0.1:5760",
help="MAVLink connection string (e.g. 'tcpin:127.0.0.1:5760', 'udpin:127.0.0.1:14550', 'COM3')",
)
parser.add_argument(
"--baud",
type=int,
default=57600,
help="Baud rate (for serial COM ports)",
)
parser.add_argument(
"--cells",
type=int,
default=4,
help="Battery cell count (4S = 16.0V nominal)",
)
parser.add_argument(
"--voltage",
type=float,
default=15.8,
help="Nominal starting battery voltage (V)",
)
parser.add_argument(
"--no-dashboard",
action="store_true",
help="Disable interactive terminal dashboard and use plain output",
)
args = parser.parse_args()
# Enable ANSI escape sequences on Windows console
if os.name == "nt":
os.system("")
spoofer = FlightControllerSpoofer(
endpoint=args.endpoint,
baud=args.baud,
battery_cells=args.cells,
nominal_voltage=args.voltage,
show_dashboard=not args.no_dashboard,
)
spoofer.run()
if __name__ == "__main__":
main()
+9
View File
@@ -0,0 +1,9 @@
@echo off
title ArduSub FC Spoofer
cd /d "%~dp0"
if exist ".venv\Scripts\python.exe" (
".venv\Scripts\python.exe" -u fc_spoofer.py %*
) else (
python -u fc_spoofer.py %*
)
pause
+20
View File
@@ -0,0 +1,20 @@
$ErrorActionPreference = "Stop"
$projectRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
Set-Location -LiteralPath $projectRoot
$existingListener = Get-NetTCPConnection -LocalPort 5760 -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1
if ($existingListener) {
$existingProcess = Get-CimInstance Win32_Process -Filter "ProcessId=$($existingListener.OwningProcess)" -ErrorAction SilentlyContinue
Write-Host ""
Write-Host "The simulator is already LISTENING on tcp:127.0.0.1:5760." -ForegroundColor Green
Write-Host "Process: $($existingListener.OwningProcess) $($existingProcess.Name)"
Write-Host "Leave that simulator running and select 'Simulator (TCP 5760)' in SUB Control."
Write-Host ""
exit 0
}
if (Test-Path -LiteralPath ".venv\Scripts\python.exe") {
& .venv\Scripts\python.exe -u fc_spoofer.py @args
} else {
& python -u fc_spoofer.py @args
}
+207
View File
@@ -0,0 +1,207 @@
// Deterministic DOM/pointer harness. No browser, vehicle, or serial port involved.
const {test}=require('node:test');
const assert=require('node:assert/strict');
const fs=require('node:fs'),vm=require('node:vm'),path=require('node:path');
const {present}=require('../web/control-state.js');
const armed={connected:true,armed_known:true,armed:true,app_state:'armed',servos:{}};
test('native pywebview result wrapper can execute alongside app globals',()=>{
const h=harness();
// Window.evaluate_js declares these variables in the page's global scope.
// A global const/let collision prevents every Python reply from executing.
assert.doesNotThrow(()=>h.run(`var value; try { value = ['COM3','COM4']; }
catch(e) { value = {name:e.name}; var keys = Object.getOwnPropertyNames(e);
keys.forEach(function(key) { value[key] = e[key]; }); }
populatePorts(value);`));
assert.ok(h.el('#port').options.some(p=>p.value==='COM3'));
});
test('an empty pywebview API cannot permanently latch startup as completed',async()=>{
const h=harness();h.context.pywebview.api={};await h.run('boot()');
assert.equal(h.run('booted'),false);assert.equal(h.run('bridgeReady'),false);
});
test('desktop startup retries after failure and populates detected COM ports',async()=>{
const h=harness();let attempts=0;
h.run('loadDefaultModel=()=>Promise.resolve(false)');
h.context.pywebview.api.refresh_ports=()=>Promise.resolve([{device:'COM3',label:'COM3'},{device:'COM4',label:'COM4'}]);
h.context.pywebview.api.initial_state=()=>++attempts===1?Promise.reject(Error('Temporary startup failure')):Promise.resolve({ports:['COM3','COM4'],battery_config:{},state:{connected:false,armed:null,servos:{}}});
h.context.pywebview.api.poll=()=>Promise.resolve({connected:false,armed:null,servos:{}});
await h.run('boot()');assert.equal(h.run('booted'),false);
assert.ok(h.el('#port').options.some(p=>p.value==='COM3'),'COM discovery survives a configuration startup failure');
await h.run('boot()');assert.equal(h.run('booted'),true);
assert.ok(h.el('#port').options.some(p=>p.value==='COM3'));
assert.ok(h.el('#port').options.some(p=>p.value==='COM4'));
});
test('visual status changes cannot resize the header or percentage label',()=>{
const html=fs.readFileSync(path.join(__dirname,'../web/index.html'),'utf8');
const css=fs.readFileSync(path.join(__dirname,'../web/styles.css'),'utf8');
assert.doesNotMatch(html,/class="water-ring/);
assert.match(html,/class="engage-sheen-label"/);
assert.match(css,/padding-bottom:\.22em; margin-bottom:-\.22em/);
assert.match(css,/grid-template-rows:120px minmax\(0,1fr\) 52px/);
assert.match(css,/\.control-status \{ position:absolute/);
assert.match(css,/flex:0 0 128px; width:128px/);
assert.match(css,/\.vertical-card footer \{ justify-content:center/);
assert.match(css,/mask-image:linear-gradient\(to right,#000 calc\(var\(--engage-progress\) - 8%\),transparent var\(--engage-progress\)\)/);
});
test('activity renders oldest at top, latest above its footer; both control states sweep',()=>{
const html=fs.readFileSync(path.join(__dirname,'../web/index.html'),'utf8');
assert.ok(html.indexOf('id="activity-oldest"')<html.indexOf('id="activity-previous"'));
assert.ok(html.indexOf('id="activity-previous"')<html.indexOf('id="status-text"'));
assert.ok(html.indexOf('id="status-text"')<html.indexOf('class="activity-heading"'));
const css=fs.readFileSync(path.join(__dirname,'../web/styles.css'),'utf8');
assert.match(css,/#engage:is\(\.arm,\.danger\).*animation:engage-sweep/);
});
test('native drag surface has no interactive descendants; output limit is outside sidebar',()=>{
const html=fs.readFileSync(path.join(__dirname,'../web/index.html'),'utf8');
assert.match(html,/<aside class="sidebar-deck"/);
assert.match(html,/<div class="sidebar-drag-surface pywebview-drag-region" aria-hidden="true"><\/div>/);
assert.ok(html.indexOf('id="gain"')>html.indexOf('</aside>'));
});
function harness(){
let now=100,id=0;const timers=new Map(),elements=new Map(),listeners=new Map();
class Element{
constructor(name){this.name=name;this.dataset={};this.style={setProperty(k,v){this[k]=v}};this.classes=new Set();this.classList={add:(...n)=>n.forEach(x=>this.classes.add(x)),remove:(...n)=>n.forEach(x=>this.classes.delete(x)),contains:n=>this.classes.has(n),toggle:(n,b)=>b?this.classes.add(n):this.classes.delete(n)};this.handlers={};this.captured=new Set();this.value='demo';this.options=[];this.tagName='DIV';this.children=[];this.textContent='';}
addEventListener(n,fn){(this.handlers[n]??=[]).push(fn)}
removeEventListener(){}
emit(n,fields={}){const e={pointerId:1,button:0,pointerType:'touch',clientX:160,clientY:160,preventDefault(){},target:this,...fields};for(const fn of this.handlers[n]||[])fn(e);return e}
getBoundingClientRect(){return this.name==='#heave'?{left:0,top:0,width:48,height:300,right:48,bottom:300}:this.name==='#engage'?{left:0,top:0,width:180,height:44,right:180,bottom:44}:{left:0,top:0,width:320,height:320,right:320,bottom:320}}
setPointerCapture(p){this.captured.add(p)}
releasePointerCapture(p){if(this.captured.delete(p))this.emit('lostpointercapture',{pointerId:p})}
getContext(){return new Proxy({}, {get:()=>()=>{}})}
querySelector(s){return el(this.name+' '+s)}
setAttribute(k,v){this[k]=v}
append(n){this.children.push(n)}
add(n){this.options.push(n)}
closest(){return null}
click(){if(!this.disabled)this.onclick?.({target:this,currentTarget:this})}
}
function el(n){if(!elements.has(n))elements.set(n,new Element(n));return elements.get(n)}
const document={querySelector:el,querySelectorAll:()=>[],getElementById:n=>el('#'+n),createElement:n=>new Element(n),body:el('body'),readyState:'loading',hidden:false,hasFocus:()=>true,addEventListener(n,f){listeners.set('doc:'+n,f)}};
const schedule=(fn,ms,interval=0)=>{timers.set(++id,{fn,at:now+ms,interval});return id};
const calls=[];
const context=vm.createContext({console,document,devicePixelRatio:1,performance:{now:()=>now},setTimeout:(f,ms=0)=>schedule(f,ms),clearTimeout:i=>timers.delete(i),setInterval:(f,ms)=>schedule(f,ms,ms),requestAnimationFrame:f=>schedule(()=>f(now),16),cancelAnimationFrame:i=>timers.delete(i),addEventListener(n,f){listeners.set(n,f)},Option:function(label,value){this.label=label;this.value=value},fetch:()=>Promise.reject(Error('No network in unit tests'))});
context.window=context;
context.pywebview={api:{set_axes:(...a)=>{calls.push(['axes',...a]);return Promise.resolve()},stop_controls:(...a)=>{calls.push(['stop',...a]);return Promise.resolve()},command:(name)=>{calls.push(['command',name]);return Promise.resolve(true)}}};
for(const f of ['control-state.js','joystick-3d.js','app.js'])vm.runInContext(fs.readFileSync(path.join(__dirname,'../web',f),'utf8'),context,{filename:f});
function run(s){return vm.runInContext(s,context)}
function advance(ms){const end=now+ms;while(true){let next=[...timers].filter(([,t])=>t.at<=end).sort((a,b)=>a[1].at-b[1].at)[0];if(!next)break;const [i,t]=next;now=t.at;timers.delete(i);if(t.interval)timers.set(i,{...t,at:now+t.interval});t.fn()}now=end;}
function state(s=armed){context.snapshot=s;run('render(snapshot)')}
return {el,run,advance,state,calls,context,fire:(name,e={})=>listeners.get(name)?.(e)};
}
test('pending/timeout/unknown presentation never invents armed state',()=>{
let p=present({...armed,pending_command:'disarm'});
assert.equal(p.label,'DISARMING…');assert.equal(p.drive,false);
p=present({...armed,command_error:'Disarm confirmation timed out.'});
assert.equal(p.label,'DISARM');assert.match(p.message,/Vehicle armed/);
p=present({...armed,armed:false,pending_command:'arm'});
assert.equal(p.label,'ENGAGING…');assert.equal(p.drive,false);
p=present({...armed,armed:false,command_error:'Engage failed'});
assert.equal(p.label,'ENGAGE');assert.equal(p.action,'arm');
p=present({...armed,connected:false,armed_known:false});
assert.equal(p.label,'STATE UNKNOWN');assert.equal(p.drive,false);
});
test('activity history keeps three unique recent events, latest first',()=>{
const h=harness();h.run("recordActivity('One');recordActivity('Two');recordActivity('Three');recordActivity('Three');recordActivity('Four')");
assert.equal(h.el('#status-text').textContent,'Four');assert.equal(h.el('#activity-previous').textContent,'Three');assert.equal(h.el('#activity-oldest').textContent,'Two');
});
test('output limit drag is bounded, owns one pointer and does not recenter',()=>{
const h=harness(),slider=h.el('#gain');slider.min='5';slider.max='50';slider.value='20';
slider.emit('pointerdown',{pointerId:1,clientX:9});assert.equal(h.el('#gain-value').textContent,'5%');
slider.emit('pointermove',{pointerId:2,clientX:311});assert.equal(h.el('#gain-value').textContent,'5%');
slider.emit('pointermove',{pointerId:1,clientX:311});assert.equal(h.el('#gain-value').textContent,'50%');
slider.emit('pointerup');h.advance(500);assert.equal(h.el('#gain-value').textContent,'50%');
});
test('maximum travel, diagonal and smooth touch return',()=>{
const h=harness();h.state();const c=h.el('#joystick-canvas');
c.emit('pointerdown',{clientX:1000,clientY:160});
assert.equal(h.context.joystick3D.getState().x,1);
c.emit('pointermove',{clientX:1000,clientY:1000});
const s=h.context.joystick3D.getState();assert.ok(Math.abs(s.x-Math.SQRT1_2)<1e-6);assert.ok(Math.abs(s.y-Math.SQRT1_2)<1e-6);
c.emit('pointerup');h.advance(180);assert.ok(h.context.joystick3D.getState().x>0);h.advance(220);assert.equal(h.context.joystick3D.getState().x,0);
});
test('independent multi-touch: second joystick finger ignored; heave continues on joystick release',()=>{
const h=harness();h.state();const c=h.el('#joystick-canvas'),v=h.el('#heave');
c.emit('pointerdown',{pointerId:1,clientX:250});
v.emit('pointerdown',{pointerId:2,clientY:0});
const x=h.context.joystick3D.getState().x;
c.emit('pointerdown',{pointerId:3,clientX:0});c.emit('pointerup',{pointerId:3});
assert.equal(h.context.joystick3D.getState().x,x);
c.emit('pointerup',{pointerId:1});h.advance(400);
assert.equal(h.context.joystick3D.getState().x,0);assert.equal(h.context.verticalControl.getState().value,1);
v.emit('pointercancel',{pointerId:2});h.advance(450);assert.equal(h.context.verticalControl.getState().value,0);
});
test('mouse leaving joystick returns to center',()=>{
const h=harness();h.state();const c=h.el('#joystick-canvas');
c.emit('pointerdown',{pointerType:'mouse',clientX:300});c.emit('pointerleave',{pointerType:'mouse'});h.advance(400);
assert.equal(h.context.joystick3D.getState().x,0);
});
test('focus loss clears both captured pointers and prevents replay',()=>{
const h=harness();h.state();h.el('#joystick-canvas').emit('pointerdown',{clientX:300});h.el('#heave').emit('pointerdown',{pointerId:2,clientY:0});
h.fire('blur');h.advance(500);
assert.equal(h.context.joystick3D.getState().x,0);assert.equal(h.context.verticalControl.getState().value,0);
assert.equal(h.context.joystick3D.getState().active,false);assert.equal(h.context.verticalControl.getState().active,false);
assert.equal(h.run('view.drive'),false);h.fire('focus');assert.equal(h.run('control.forward+control.turn+control.vertical'),0);
});
test('lost connection during commanded joystick clears command and shows unknown',()=>{
const h=harness();h.state();h.el('#joystick-canvas').emit('pointerdown',{clientX:300});
h.state({...armed,connected:false,armed_known:false,app_state:'disconnected'});
assert.equal(h.context.joystick3D.getState().x,0);assert.equal(h.el('#engage-label').textContent,'state unknown');
h.state();assert.equal(h.run('control.turn'),0);
});
test('half hold cancels on touch interruption and focus loss; extra fingers cannot complete it',()=>{
const h=harness();h.state({...armed,armed:false,app_state:'connected_disarmed'});const b=h.el('#engage');
b.emit('pointerdown');h.advance(600);b.emit('pointerdown',{pointerId:2});b.emit('pointerup',{pointerId:2});
assert.equal(h.run('engagePointer'),1);b.emit('pointercancel');h.advance(1500);assert.equal(h.calls.length,0);
b.emit('pointerdown');h.advance(600);h.fire('blur');h.advance(1500);assert.equal(h.calls.length,0);
});
test('demo full hold waits for simulated confirmation; release cannot immediately disarm',()=>{
const h=harness();h.run('demoActive=true;demoState=makeDemoState();render(demoState)');const b=h.el('#engage');
b.emit('pointerdown');h.advance(1450);assert.equal(h.el('#engage-label').textContent,'engaging…');assert.equal(h.run('demoState.armed'),false);
b.emit('pointerup');b.click();h.advance(700);assert.equal(h.el('#engage-label').textContent,'disengage');
b.click();assert.equal(h.el('#engage-label').textContent,'disengaging…');assert.equal(h.run('demoState.armed'),true);
h.advance(700);assert.equal(h.el('#engage-label').textContent,'engage');
});
test('real send result and accepted ACK cannot optimistically arm; timeout is visible',async()=>{
const h=harness();h.state({...armed,armed:false,app_state:'connected_disarmed'});
h.run("requestControl('arm')");await Promise.resolve();assert.equal(h.el('#engage-label').textContent,'engaging…');
assert.equal(h.run('vehicleSnapshot.armed'),false);h.advance(6100);
assert.equal(h.el('#engage-label').textContent,'engage');assert.match(h.el('#control-status').textContent,/timed out/);
});
test('bridge freeze clears input even if armed snapshot has not changed',()=>{
const h=harness();h.state();h.el('#joystick-canvas').emit('pointerdown',{clientX:300});h.run('markBridgeStale()');
assert.equal(h.context.joystick3D.getState().x,0);assert.equal(h.el('#engage-label').textContent,'state unknown');
assert.match(h.el('#control-status').textContent,/Telemetry unavailable/);
});
test('hung bridge poll has an independent watchdog and cannot trap the controls',async()=>{
const h=harness();h.state();h.context.pywebview.api.poll=()=>new Promise(()=>{});
h.run('bridgeReady=true;lastBridgeAt=performance.now();poll()');
h.el('#joystick-canvas').emit('pointerdown',{clientX:300});
h.advance(1700);await Promise.resolve();
assert.equal(h.run('view.drive'),false);assert.equal(h.el('#engage-label').textContent,'state unknown');
assert.equal(h.context.joystick3D.getState().x,0);
});
test('holding through confirmation then releasing outside cannot swallow the next disarm tap',()=>{
const h=harness();h.run('demoActive=true;demoState=makeDemoState();render(demoState)');const b=h.el('#engage');
b.emit('pointerdown');h.advance(2300);assert.equal(h.el('#engage-label').textContent,'disengage');assert.ok(b.captured.has(1));
b.emit('pointerup',{clientX:500});h.advance(1);b.click();assert.equal(h.el('#engage-label').textContent,'disengaging…');
});
+158
View File
@@ -0,0 +1,158 @@
"""No serial port is opened by these tests."""
import queue
import unittest
from unittest.mock import Mock, patch
from app import Axes, MavlinkWorker, NEUTRAL_Z
from vehicle_state import VehicleState
from web_ui import ControlApi
class VehicleStateTests(unittest.TestCase):
def setUp(self):
self.now = 10.0
self.state = VehicleState(lambda: self.now)
self.state.heartbeat(False)
def test_ack_is_not_armed_confirmation(self):
self.state.request("arm")
self.now += .2
self.state.ack("MAV_RESULT_ACCEPTED")
self.assertFalse(self.state.snapshot()["armed"])
self.assertEqual(self.state.snapshot()["pending_command"], "arm")
self.state.heartbeat(True)
self.assertTrue(self.state.snapshot()["controls_allowed"])
def test_heartbeat_confirms_even_when_ack_packet_is_lost(self):
self.state.request("arm")
self.now += .5
self.state.heartbeat(True)
self.assertIsNone(self.state.pending)
self.assertTrue(self.state.armed)
def test_old_events_cannot_confirm_new_request(self):
self.state.request("arm")
self.state.ack("MAV_RESULT_DENIED", received_at=9)
self.state.heartbeat(True, received_at=9)
self.assertIsNotNone(self.state.pending)
self.assertFalse(self.state.armed)
def test_rejected_engage_stays_disarmed(self):
self.state.request("arm")
self.now += .1
self.state.ack("MAV_RESULT_DENIED")
self.assertIsNone(self.state.pending)
self.assertIn("rejected", self.state.error)
self.assertFalse(self.state.armed)
def test_engage_timeout_then_late_armed_heartbeat_is_truth(self):
self.state.request("arm")
self.now += 6
self.state.tick()
self.assertIn("timed out", self.state.error)
self.assertFalse(self.state.snapshot()["armed_known"])
self.state.heartbeat(True)
self.assertTrue(self.state.snapshot()["armed"])
def test_disarm_timeout_never_claims_disarmed(self):
self.state.heartbeat(True)
self.state.request("disarm")
self.now += 6
self.state.heartbeat(True)
snap = self.state.snapshot()
self.assertTrue(snap["armed"])
self.assertIn("timed out", snap["command_error"])
def test_disarm_ack_waits_for_disarmed_heartbeat(self):
self.state.heartbeat(True)
self.state.request("disarm")
self.now += .2
self.state.ack("MAV_RESULT_ACCEPTED")
self.assertTrue(self.state.snapshot()["armed"])
self.assertFalse(self.state.snapshot()["controls_allowed"])
self.state.heartbeat(False)
self.assertIsNone(self.state.pending)
self.assertFalse(self.state.armed)
def test_disconnect_preserves_last_known_armed_but_invalidates_it(self):
self.state.heartbeat(True)
self.state.disconnect()
snap = self.state.snapshot()
self.assertTrue(snap["armed"])
self.assertFalse(snap["armed_known"])
self.assertFalse(snap["controls_allowed"])
def test_frozen_telemetry_blocks_new_engage(self):
self.now += 3.1
self.assertFalse(self.state.snapshot()["armed_known"])
with self.assertRaises(RuntimeError):
self.state.request("arm")
class WorkerWatchdogTests(unittest.TestCase):
def setUp(self):
self.worker = MavlinkWorker(queue.Queue())
self.worker.require_input_lease = True
self.worker.connection = Mock()
self.worker.target_system = 1
self.worker.armed = True
self.worker.last_heartbeat = 10
def assertNeutral(self):
self.worker._send_control()
self.assertEqual(self.worker.connection.mav.manual_control_send.call_args.args,
(1, 0, 0, NEUTRAL_Z, 0, 0))
def test_ui_freeze_neutralizes_and_does_not_replay(self):
with patch("app.time.monotonic", return_value=10):
self.worker.set_axes(Axes(forward=1), 1)
self.worker._send_control()
self.assertGreater(self.worker.output_axes.forward, 0)
with patch("app.time.monotonic", return_value=11.1):
self.assertNeutral()
self.assertEqual(self.worker.axes, Axes())
def test_heartbeat_freeze_neutralizes_even_with_live_ui(self):
with patch("app.time.monotonic", return_value=13.1):
self.worker.set_axes(Axes(forward=1), 1)
self.assertNeutral()
def test_stale_queued_input_cannot_override_neutral(self):
self.worker.set_axes(Axes(), 20)
self.worker.set_axes(Axes(forward=1), 19)
self.assertEqual(self.worker.axes, Axes())
class BridgeTests(unittest.TestCase):
def test_delayed_arm_after_focus_reset_or_expiry_is_not_sent(self):
api = ControlApi()
api.worker.connection = Mock()
api.worker.target_system = 1
api._consume(("heartbeat", 1, "ARDUPILOT", "MANUAL", False))
api.worker.set_axes(Axes(), 20)
with self.assertRaisesRegex(RuntimeError, "newer input reset"):
api.command("arm", 19)
with self.assertRaisesRegex(RuntimeError, "expired"):
api.command("arm", 21, expires_at=0)
api.worker.connection.mav.command_long_send.assert_not_called()
def test_real_bridge_ack_pending_disconnect_and_stale_battery(self):
api = ControlApi()
api.worker.connection = Mock()
api.worker.target_system = 1
api._consume(("heartbeat", 1, "ARDUPILOT", "MANUAL", False))
pending = api.command("arm")
self.assertEqual(pending["pending_command"], "arm")
self.assertFalse(pending["armed"])
api._consume(("ack", 400, "MAV_RESULT_ACCEPTED"))
self.assertFalse(api._snapshot()["armed"])
api._consume(("heartbeat", 1, "ARDUPILOT", "MANUAL", True))
self.assertTrue(api._snapshot()["armed"])
api._consume(("disconnected", "Disconnected"))
self.assertTrue(api._snapshot()["armed"])
self.assertFalse(api._snapshot()["armed_known"])
self.assertTrue(api._snapshot()["battery_stale"])
if __name__ == "__main__":
unittest.main()
+75
View File
@@ -0,0 +1,75 @@
"""Vehicle truth and command transactions; independent of UI/animation clocks."""
import time
class VehicleState:
HEARTBEAT_TIMEOUT = 3.0
COMMAND_TIMEOUT = 5.0
def __init__(self, clock=time.monotonic):
self.clock = clock
self.connected = False
self.armed = None
self.heartbeat_at = None
self.pending = None
self.error = ""
def fresh(self):
return (self.connected and self.heartbeat_at is not None
and self.clock() - self.heartbeat_at <= self.HEARTBEAT_TIMEOUT)
def heartbeat(self, armed, received_at=None):
received_at = self.clock() if received_at is None else received_at
if self.heartbeat_at is not None and received_at < self.heartbeat_at:
return
self.connected = True
self.heartbeat_at = received_at
self.armed = bool(armed)
# A fresh heartbeat is the authoritative confirmation of actual state.
# ACK ACCEPTED alone only confirms that a command was accepted.
if (self.pending and received_at > self.pending["started"]
and self.armed == (self.pending["name"] == "arm")):
self.pending = None
def request(self, name):
self.tick()
if name == "arm" and (not self.fresh() or self.armed is not False):
raise RuntimeError("Fresh, disarmed vehicle telemetry is required to engage")
if self.pending and (name == "arm" or self.pending["name"] == name):
raise RuntimeError("A vehicle confirmation is already pending")
now = self.clock()
self.pending = {"name": name, "started": now,
"deadline": now + self.COMMAND_TIMEOUT, "ack": None}
self.error = ""
def ack(self, result, received_at=None):
received_at = self.clock() if received_at is None else received_at
if not self.pending or received_at <= self.pending["started"]:
return
self.pending["ack"] = result
if result not in ("MAV_RESULT_ACCEPTED", "MAV_RESULT_IN_PROGRESS"):
self.fail(f"{self.pending['name'].capitalize()} rejected: {result}")
def fail(self, message):
self.error = message
self.pending = None
def disconnect(self):
self.connected = False
if self.pending:
self.fail("Connection lost before vehicle confirmation. State unknown.")
def tick(self):
if self.pending and self.clock() >= self.pending["deadline"]:
name = self.pending["name"].capitalize()
self.fail(f"{name} confirmation timed out. Check current vehicle state.")
def snapshot(self):
self.tick()
fresh = self.fresh()
return {"armed": self.armed, "armed_known": fresh,
"heartbeat_age": None if self.heartbeat_at is None else self.clock() - self.heartbeat_at,
"pending_command": self.pending["name"] if self.pending else None,
"command_ack": self.pending["ack"] if self.pending else None,
"command_error": self.error,
"controls_allowed": fresh and self.armed is True and self.pending is None}
+590 -34
View File
@@ -1,46 +1,602 @@
const STATUS_LABELS = {
disconnected: 'Disconnected — select a port and connect',
connecting: 'Connecting…',
connected_disarmed: 'Connected · Disarmed',
armed: 'Connected · Armed',
link_degraded: 'Signal lost — check tether',
};
const $=s=>document.querySelector(s), $$=s=>[...document.querySelectorAll(s)]; 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 control={forward:0,turn:0,vertical:0}, pressedKeys=new Set(); let bridgeReady=false, appState='disconnected', batteryConfig={}, servoSignature='', lastAxisSend=0, axisTimer=0, demoActive=false, demoState=null;
const api=(name,...args)=>window.pywebview.api[name](...args); const api=(name,...args)=>window.pywebview.api[name](...args);
const activity=[];
let lastReportedStatus='';
function recordActivity(message){
if(!message||activity[0]===message)return;
activity.unshift(String(message));activity.length=Math.min(activity.length,3);
['#status-text','#activity-previous','#activity-oldest'].forEach((selector,i)=>{
const row=$(selector);if(row){row.textContent=activity[i]||'';row.hidden=!activity[i]}
});
}
function withTimeout(promise,ms=1500){return new Promise((resolve,reject)=>{const timer=setTimeout(()=>reject(new Error('Control bridge timed out')),ms);Promise.resolve(promise).then(value=>{clearTimeout(timer);resolve(value)},error=>{clearTimeout(timer);reject(error)})})}
let vehicleSnapshot={connected:false,armed:null}, view=ControlState.present(vehicleSnapshot), inputFocused=true,
bridgeStale=false,lastBridgeAt=performance.now(),axisBusy=false,inputSequence=Date.now(),
localRequest=null,commandError='',requestSerial=0,demoCommandTimer=0,lastSafetyTick=performance.now();
const canDrive=()=>view.drive&&performance.now()-lastSafetyTick<750;
window.canDrive=canDrive;
function makeDemoState(){const servos={};for(let i=1;i<=16;i++)servos[String(i)]={function:i<=3?32+i:0,label:i<=3?`Motor ${i}`:'Unassigned',motor:i<=3?i:null,direction:i<=3?1:null};return {connected:true,link:'LINK OK',vehicle:'DEMO • DISARMED',armed:false,armed_known:true,mode:'MANUAL',app_state:'connected_disarmed',status:'Demo mode ready — no hardware or spoofer.',battery:{voltage:15.8,per_cell:3.95,percent:72,cells:4,low:false},servos}}
function updateDemo(patch){Object.assign(demoState,patch);render(demoState)}
document.addEventListener('click',e=>{const btn=e.target.closest('.num-step');if(!btn)return;const pill=btn.closest('.number-pill');if(!pill)return;const input=pill.querySelector('input');if(!input)return;const step=+input.step||1,min=input.min!==''?+input.min:-Infinity,max=input.max!==''?+input.max:Infinity;let val=+input.value||0;if(btn.classList.contains('down'))val=Math.max(min,+(val-step).toFixed(4));else val=Math.min(max,+(val+step).toFixed(4));input.value=val;input.dispatchEvent(new Event('input',{bubbles:true}));input.dispatchEvent(new Event('change',{bubbles:true}))});
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)} 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; 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 populatePorts(ports){
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)}} const p=$('#port');
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)} if(!p)return;
const old=p.value||'';
p.innerHTML='';
const joy=$('#joystick'),stick=$('#stick');let joyDrag=false,returnAnim=0; const allList=[];
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)} const seen=new Set();
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; const addPort=item=>{
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)} let dev=item, lbl=item;
if(item&&typeof item==='object'){
dev=item.device||'';
lbl=item.label||dev;
}
if(dev&&!seen.has(dev)){
seen.add(dev);
allList.push({device:dev, label:lbl});
}
};
if(Array.isArray(ports)) ports.forEach(addPort);
addPort({device:'demo', label:'Demo Mode (no hardware)'});
let matched=false;
allList.forEach(item=>{
const opt=new Option(item.label, item.device);
p.add(opt);
if(item.device===old){
p.value=item.device;
matched=true;
}
});
if(!matched&&allList.length>0){
p.value=allList[0].device;
}
}
function sendAxes(){
if(!canDrive()||demoActive||!bridgeReady||axisBusy)return;
const transmit=()=>{
axisTimer=0;
if(!canDrive()||demoActive||axisBusy)return;
lastAxisSend=performance.now();axisBusy=true;
withTimeout(api('set_axes',control.forward,control.turn,control.vertical,++inputSequence))
.catch(()=>markBridgeStale()).finally(()=>axisBusy=false);
};
const wait=50-(performance.now()-lastAxisSend);
if(wait<=0)transmit();else if(!axisTimer)axisTimer=setTimeout(transmit,wait);
}
// This is a local bridge lease, not extra MAVLink traffic. The worker retains
// the configured wire rate and neutralizes if the UI stops renewing its input.
setInterval(()=>{if(canDrive())sendAxes()},250);
function updateReadouts(){ if(!canDrive()){$('#surge-readout').textContent='—';$('#yaw-readout').textContent='—';$('#heave-readout').textContent='—';return;}$('#surge-readout').textContent=Math.round(control.forward*100);$('#yaw-readout').textContent=Math.round(control.turn*100);$('#heave-readout').textContent=Math.round(control.vertical*100);}
window.setJoyControl=(x,y)=>{if(!canDrive())return;control.turn=x;control.forward=-y;updateReadouts();sendAxes();};
function releaseHandle(el){el.classList.remove('pressed');el.classList.add('released');setTimeout(()=>el.classList.remove('released'),520)}
const heave=$('#heave'),hknob=$('#heave-knob'),hfill=$('#heave-fill');let heavePointer=null,heaveAnim=0;
function setHeave(v,send=true){if(!canDrive()&&v!==0)return;v=Math.max(-1,Math.min(1,v));control.vertical=v;heave.dataset.commanded=String(Math.abs(v)>.03);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&&canDrive())sendAxes();}
function heaveEvent(e){const b=heave.getBoundingClientRect();setHeave((b.top+b.height/2-e.clientY)/(b.height*.43))} 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 releaseHeave(e){if(heavePointer===null||(e?.pointerId!=null&&e.pointerId!==heavePointer))return;const pointer=heavePointer;heavePointer=null;try{heave.releasePointerCapture(pointer)}catch(_){}releaseHandle(hknob);easeHeave()}
heave.addEventListener('pointerdown',e=>{if(!canDrive()||heavePointer!==null||e.button>0)return;heavePointer=e.pointerId;hknob.classList.remove('released');hknob.classList.add('pressed');cancelAnimationFrame(heaveAnim);heave.setPointerCapture(e.pointerId);heaveEvent(e);e.preventDefault()});heave.addEventListener('pointermove',e=>{if(e.pointerId===heavePointer)heaveEvent(e)});heave.addEventListener('pointerup',releaseHeave);heave.addEventListener('pointercancel',releaseHeave);heave.addEventListener('lostpointercapture',e=>{if(e.pointerId===heavePointer)releaseHeave(e)});
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 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)}
window.verticalControl={setPos:value=>{cancelAnimationFrame(heaveAnim);setHeave(value)},easeBack:()=>{if(heavePointer===null)easeHeave()},getState:()=>({value:control.vertical,active:heavePointer!==null})};
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()} function applyKeys(){if(!canDrive()){pressedKeys.clear();return;}const turn=(pressedKeys.has('d')||pressedKeys.has('e')?1:0)-(pressedKeys.has('a')||pressedKeys.has('q')?1:0),forward=(pressedKeys.has('w')?1:0)-(pressedKeys.has('s')?1:0),vertical=(pressedKeys.has('r')?1:0)-(pressedKeys.has('f')?1:0);if(!window.joystick3D?.getState().active){if(turn||forward)window.joystick3D?.setPos(turn,-forward);else window.joystick3D?.easeBack()}if(heavePointer===null){if(vertical){cancelAnimationFrame(heaveAnim);setHeave(vertical)}else 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('keydown',e=>{if(e.key==='F3'){e.preventDefault();if(!e.repeat&&view.action==='disarm')requestControl('disarm');return;}if(/^(INPUT|SELECT|TEXTAREA)$/.test(e.target.tagName))return;if(e.key==='F2'){e.preventDefault();if(!e.repeat)startEngageHold();return;}const k=e.key.toLowerCase();if('wasdqerf'.includes(k)&&k.length===1){if(!canDrive())return;e.preventDefault();if(!pressedKeys.has(k)){pressedKeys.add(k);applyKeys();}}});addEventListener('keyup',e=>{if(e.key==='F2'){e.preventDefault();cancelEngageHold(true);return;}const k=e.key.toLowerCase();if(pressedKeys.delete(k))applyKeys();});
addEventListener('blur',()=>{keys.clear();if(!joyDrag)easeJoy();if(!heaveDrag)easeHeave()}); function resetInputs(){
if(gainPointer!==null)releaseGain({pointerId:gainPointer});
clearTimeout(axisTimer);axisTimer=0;pressedKeys.clear();cancelEngageHold(false);
window.joystick3D?.reset();
const pointer=heavePointer;heavePointer=null;cancelAnimationFrame(heaveAnim);
if(pointer!==null)try{heave.releasePointerCapture(pointer)}catch(_){}
hknob.classList.remove('pressed','released');
control.forward=control.turn=control.vertical=0;setHeave(0,false);
if(bridgeReady&&!demoActive)api('stop_controls',++inputSequence).catch(()=>{});
}
function focusChanged(focused){inputFocused=focused;if(!focused)resetInputs();render(vehicleSnapshot)}
addEventListener('blur',()=>focusChanged(false));
addEventListener('focus',()=>focusChanged(!document.hidden));
document.addEventListener('visibilitychange',()=>focusChanged(!document.hidden&&document.hasFocus()));
addEventListener('pagehide',()=>focusChanged(false));
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()); function openModal(id){
$('#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 el=typeof id==='string'?document.getElementById(id):id;
const modelInput=$('#model-input'),modelButton=$('#load-model'); if(!el)return;
modelButton.onclick=()=>{modelInput.value='';modelInput.click()}; el.classList.remove('closing');
function bytesToBase64(bytes){let value='';for(let i=0;i<bytes.length;i+=0x8000)value+=String.fromCharCode(...bytes.subarray(i,i+0x8000));return btoa(value)} if(!el.open)el.showModal();
async function cacheModel(file){const token=await api('begin_model_save',file.name,file.size),chunk=1048576;for(let offset=0;offset<file.size;offset+=chunk){const bytes=new Uint8Array(await file.slice(offset,offset+chunk).arrayBuffer());await api('append_model_chunk',token,bytesToBase64(bytes));await new Promise(r=>setTimeout(r,0))}await api('finish_model_save',token)} }
async function loadCachedModel(info){const bytes=new Uint8Array(info.size),chunk=1048576;for(let offset=0;offset<info.size;offset+=chunk){const response=await api('read_saved_model_chunk',offset,chunk),part=atob(response.data);for(let i=0;i<part.length;i++)bytes[offset+i]=part.charCodeAt(i);await new Promise(r=>setTimeout(r,0))}return window.rovModel.loadBuffer(bytes.buffer,info.name)} function closeModal(dialog){
modelInput.onchange=async()=>{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}}; if(!dialog||!dialog.open||dialog.classList.contains('closing'))return;
$('#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)}}; dialog.classList.add('closing');
$('#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'); let done=false;
$('#gain').oninput=e=>{$('#gain-value').textContent=`${e.target.value}%`;api('set_gain',+e.target.value)}; const finish=()=>{
$('#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)}}; if(done)return;
done=true;
dialog.removeEventListener('animationend',finish);
dialog.classList.remove('closing');
dialog.close();
};
dialog.addEventListener('animationend',finish,{once:true});
setTimeout(finish,300);
}
$$('[data-open]').forEach(b=>b.onclick=()=>openModal(b.dataset.open));
$$('[data-close]').forEach(b=>b.onclick=()=>closeModal(b.closest('dialog')));
$$('dialog').forEach(d=>{
d.addEventListener('cancel',e=>{e.preventDefault();closeModal(d)});
d.addEventListener('click',e=>{
const r=d.getBoundingClientRect();
if(e.clientX<r.left||e.clientX>r.right||e.clientY<r.top||e.clientY>r.bottom)closeModal(d);
});
});
$('#window-min').onclick=()=>api('minimize_window');$('#window-max').onclick=async()=>{const max=await api('toggle_maximize_window');document.body.classList.toggle('maximized',!!max)};$('#window-close').onclick=()=>{if(document.body.classList.contains('window-exiting'))return;document.body.classList.add('window-exiting');setTimeout(()=>api('close_window'),360)};const windowBar=$('.workspace-topbar');if(windowBar)windowBar.ondblclick=async e=>{if(!e.target.closest('button,select,input')){const max=await api('toggle_maximize_window');document.body.classList.toggle('maximized',!!max)}};
const modelInput=$('#model-input'), modelButton=$('#load-model'), rovStage=$('.rov-stage');
async function handleStlFile(file){
if(!file)return;
try{
if(!file.name.toLowerCase().endsWith('.stl'))throw new Error('Choose an STL mesh (*.stl)');
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…';
const buffer=await file.arrayBuffer();
const result=window.rovModel.loadBuffer(buffer,file.name);
modelButton.textContent='Replace STL';
toast(`Loaded ${result.name} • ${result.triangles.toLocaleString()} triangles`);
if(bridgeReady&&window.pywebview?.api?.save_model){
const dataUrl=await new Promise((resolve,reject)=>{
const reader=new FileReader();
reader.onload=()=>resolve(reader.result);
reader.onerror=()=>reject(reader.error);
reader.readAsDataURL(file);
});
api('save_model',file.name,String(dataUrl).split(',')[1]).catch(e=>console.warn(e));
}
}catch(error){
toast(`STL import failed: ${error.message}`,true);
}finally{
modelButton.disabled=false;
if(modelButton.textContent==='Loading…')modelButton.textContent='Load STL';
}
}
modelButton.onclick=async()=>{
let loaded=false;
if(window.pywebview?.api?.choose_model){
try{
const model=await api('choose_model');
if(model&&model.data){
const res=window.rovModel.load(model);
modelButton.textContent='Replace STL';
toast(`Loaded ${res.name} • ${res.triangles.toLocaleString()} triangles`);
loaded=true;
}
}catch(err){
console.warn('choose_model error:', err);
}
}
if(!loaded&&modelInput){
modelInput.value='';
modelInput.click();
}
};
if(modelInput){
modelInput.onchange=()=>{
const file=modelInput.files?.[0];
if(file)handleStlFile(file);
};
}
if(rovStage){
rovStage.addEventListener('dragover',e=>{e.preventDefault();e.stopPropagation();rovStage.classList.add('drag-over')});
rovStage.addEventListener('dragleave',e=>{e.preventDefault();e.stopPropagation();rovStage.classList.remove('drag-over')});
rovStage.addEventListener('drop',e=>{
e.preventDefault();e.stopPropagation();rovStage.classList.remove('drag-over');
const file=e.dataTransfer?.files?.[0];
if(file)handleStlFile(file);
});
}
$('#refresh').onclick=async e=>{
const button=e.currentTarget;button.classList.remove('spinning');void button.offsetWidth;button.classList.add('spinning');setTimeout(()=>button.classList.remove('spinning'),650);
try{
if(typeof window.pywebview?.api?.refresh_ports!=='function')throw new Error('Desktop connection is not ready. Open the app using run.ps1.');
populatePorts(await withTimeout(api('refresh_ports'),4000));
if(!booted)boot();
}catch(error){recordActivity(`Port refresh failed: ${error.message}`);toast(error.message,true)}
};
$('#connect').onclick=async()=>{
const btn=$('#connect');
const label=btn.querySelector('.btn-label')||btn;
const p=$('#port');
const port=p?p.value:'';
if(port==='demo'||demoActive){
resetInputs();clearTimeout(demoCommandTimer);requestSerial++;localRequest=null;commandError='';bridgeStale=false;
if(demoActive){demoActive=false;demoState=null;render({app_state:'disconnected',connected:false,link:'DISCONNECTED',vehicle:'No heartbeat',armed:false,mode:null,status:'Demo mode disconnected',battery:null,servos:{}});toast('Demo disconnected')}
else{demoActive=true;demoState=makeDemoState();render(demoState);toast('Demo connected — no hardware required')}
return;
}
if(appState==='connecting'){
label.textContent='Canceling…';
try{ await api('disconnect'); }catch(_){}
return;
}
if(appState!=='disconnected'){
resetInputs();
label.textContent='Disconnecting…';
try{
await api('disconnect');
toast('Disconnected');
}catch(e){
toast(e.message,true);
}
}else{
if(!port){
toast('Select or enter a port to connect',true);
return;
}
btn.classList.add('is-connecting');
label.textContent='Connecting…';
const statusEl=$('#status-text');
recordActivity(`Connecting to ${port}…`);
toast(`Connecting to ${port}…`);
try{
const confirmedState=await api('connect',port,+$('#baud').value,+$('#rate').value);
if(confirmedState)render(confirmedState);
toast(confirmedState?.armed_known?'MAVLink connected':'Waiting for vehicle telemetry',!confirmedState?.armed_known);
}catch(e){
btn.classList.remove('is-connecting');
label.textContent='Connect';
toast(e.message,true);
recordActivity(`Connection error: ${e.message}`);
}
}
};
const customPortBtn=$('#custom-port-btn'), customPortInput=$('#custom-port-input');
if(customPortBtn&&customPortInput){
const applyCustomPort=()=>{
const val=customPortInput.value.trim();
if(!val)return;
const p=$('#port');
let exists=false;
for(const opt of p.options){
if(opt.value===val){opt.selected=true;exists=true;break;}
}
if(!exists){
p.add(new Option(`Custom (${val})`, val), 0);
p.value=val;
}
closeModal($('#link-modal'));
toast(`Selected port: ${val}`);
};
customPortBtn.onclick=applyCustomPort;
customPortInput.onkeydown=e=>{if(e.key==='Enter'){e.preventDefault();applyCustomPort();}};
}
const engageButton=$('#engage');
const engageProgressLabel=engageButton?.querySelector('.engage-progress-label');
let engageHoldTimer=null,engageStart=0,engagePointer=null,completedEngagePointer=null,suppressEngageClick=false,engageVisualState='',engageTransitionTimer=null;
const ENGAGE_HOLD_MS=1400;
function applyEngageVisual(next){
if(!engageButton)return;
const label=({'ENGAGE':'engage','DISARM':'disengage','ENGAGING…':'engaging…','DISARMING…':'disengaging…','DISARM · ?':'disengage · ?','STATE UNKNOWN':'state unknown'})[next.label]||next.label.toLowerCase();
// Interaction updates immediately; the short fade never gates emergency access.
engageButton.disabled=!next.action;
engageButton.setAttribute('aria-busy',String(next.pending));
engageButton.setAttribute('aria-label',label);
engageButton.title=next.action==='disarm'?'Request disarm immediately (F3)':next.action==='arm'?'Hold 1.4 seconds to engage (F2)':next.message;
const key=`${next.label}/${next.tone}`;
if(engageVisualState===key)return;
const previous=engageVisualState;engageVisualState=key;
clearTimeout(engageTransitionTimer);
engageButton.classList.remove('arm','danger','subtle','charged','pending');
engageButton.classList.add(next.tone==='danger'?'danger':next.tone==='pending'?'pending':'arm');
engageButton.style.setProperty('--engage-progress',next.pending?'100%':'0%');
$('#engage-label').textContent=label;
if(engageProgressLabel)engageProgressLabel.textContent=label;
const sheen=engageButton.querySelector('.engage-sheen-label');if(sheen)sheen.textContent=label;
if(previous){
engageButton.classList.remove('state-transition');void engageButton.offsetWidth;
engageButton.classList.add('state-transition');
engageTransitionTimer=setTimeout(()=>engageButton.classList.remove('state-transition'),240);
}
}
function completeEngage(){
const pointer=engagePointer;cancelAnimationFrame(engageHoldTimer);engageHoldTimer=null;engageStart=0;engagePointer=null;completedEngagePointer=typeof pointer==='number'?pointer:null;engageButton.classList.remove('charging');engageButton.classList.add('charged');engageButton.style.setProperty('--engage-progress','100%');
requestControl('arm');
}
async function requestControl(name){
if((name==='arm'&&view.action!=='arm')||(name==='disarm'&&view.action!=='disarm'))return;
const id=++requestSerial;
commandError='';localRequest={name,deadline:performance.now()+6000,sent:false};
recordActivity(name==='arm'?'Engage requested':'Disengage requested');
resetInputs();render(vehicleSnapshot);
try{
if(demoActive){
// Separate simulated request and simulated heartbeat, just like the real UI.
clearTimeout(demoCommandTimer);
updateDemo({pending_command:name,command_error:''});
demoCommandTimer=setTimeout(()=>{
if(!demoActive||id!==requestSerial)return;
localRequest=null;
updateDemo({pending_command:null,armed:name==='arm',armed_known:true,
app_state:name==='arm'?'armed':'connected_disarmed',
vehicle:`DEMO • ${name==='arm'?'ARMED':'DISARMED'}`,status:`Demo: ${name} confirmed`});
},650);
}else{
await api('command',name,++inputSequence,Date.now()+2000);
if(id===requestSerial&&localRequest)localRequest.sent=true;
// Only poll snapshots may confirm state; a send result is not confirmation.
}
}catch(error){
if(id!==requestSerial)return;
localRequest=null;commandError=`${name==='arm'?'Engage':'Disarm'} request failed: ${error.message}`;
render(vehicleSnapshot);toast(commandError,true);
}
}
function updateEngageProgress(now){
if(!engageStart||view.action!=='arm'||!inputFocused)return cancelEngageHold(false);
const progress=Math.min(1,(now-engageStart)/ENGAGE_HOLD_MS);engageButton.style.setProperty('--engage-progress',`${progress*100}%`);if(progress>=1)return void completeEngage();engageHoldTimer=requestAnimationFrame(updateEngageProgress);
}
function startEngageHold(e){
if(view.action!=='arm'||!inputFocused||engageStart)return;
if(e&&e.button!==undefined&&e.button!==0)return;
engagePointer=e?.pointerId??'keyboard';engageStart=performance.now();engageButton.classList.add('charging');engageButton.style.setProperty('--engage-progress','0%');
if(e?.pointerId!=null)engageButton.setPointerCapture(e.pointerId);
engageHoldTimer=requestAnimationFrame(updateEngageProgress);
}
function cancelEngageHold(showHint=true){
if(!engageStart)return;
const elapsed=performance.now()-engageStart,pointer=engagePointer;cancelAnimationFrame(engageHoldTimer);engageHoldTimer=null;engageStart=0;engagePointer=null;engageButton.classList.remove('charging');engageButton.classList.add('canceling');engageButton.style.setProperty('--engage-progress','0%');setTimeout(()=>engageButton.classList.remove('canceling'),280);if(typeof pointer==='number')try{engageButton.releasePointerCapture(pointer)}catch(_){}if(showHint&&elapsed>80)toast('Engagement canceled');
}
if(engageButton){
engageButton.addEventListener('pointerdown',startEngageHold);
engageButton.addEventListener('pointerup',e=>{if(e.pointerId===completedEngagePointer){completedEngagePointer=null;try{engageButton.releasePointerCapture(e.pointerId)}catch(_){}suppressEngageClick=true;setTimeout(()=>suppressEngageClick=false,0);return}if(e.pointerId===engagePointer)cancelEngageHold(true)});
engageButton.addEventListener('pointerleave',e=>{if(e.pointerId===engagePointer)cancelEngageHold(true)});
engageButton.addEventListener('pointercancel',e=>{if(e.pointerId===completedEngagePointer)completedEngagePointer=null;if(e.pointerId===engagePointer)cancelEngageHold(false)});
engageButton.addEventListener('lostpointercapture',e=>{if(e.pointerId===engagePointer)cancelEngageHold(false);if(e.pointerId===completedEngagePointer)completedEngagePointer=null});
engageButton.addEventListener('pointermove',e=>{if(e.pointerId!==engagePointer)return;const r=engageButton.getBoundingClientRect();if(e.clientX<r.left||e.clientX>r.right||e.clientY<r.top||e.clientY>r.bottom)cancelEngageHold(false)});
engageButton.addEventListener('contextmenu',e=>e.preventDefault());
engageButton.onclick=()=>{
if(suppressEngageClick){suppressEngageClick=false;return}
if(view.action==='disarm')requestControl('disarm');
};
}
$('#logs').onclick=()=>api('open_logs');
function setGain(value){const gain=$('#gain');gain.value=Math.max(+gain.min,Math.min(+gain.max,value));$('#gain-value').textContent=`${gain.value}%`;if(bridgeReady)api('set_gain',+gain.value)}
$('#gain').oninput=e=>setGain(+e.target.value);$('#gain-down').onclick=()=>setGain(+$('#gain').value-5);$('#gain-up').onclick=()=>setGain(+$('#gain').value+5);
// Explicit pointer ownership makes the full range rail a reliable touch target
// in the embedded window, without depending on native range drag defaults.
const gainSlider=$('#gain');let gainPointer=null;
function gainFromPointer(e){const r=gainSlider.getBoundingClientRect(),fraction=Math.max(0,Math.min(1,(e.clientX-r.left-9)/Math.max(1,r.width-18)));setGain(Math.round(+gainSlider.min+fraction*(+gainSlider.max-+gainSlider.min)))}
function releaseGain(e){if(e.pointerId!==gainPointer)return;gainPointer=null;try{gainSlider.releasePointerCapture(e.pointerId)}catch(_){}}
gainSlider.addEventListener('pointerdown',e=>{if(gainPointer!==null||e.button>0)return;gainPointer=e.pointerId;gainSlider.setPointerCapture(e.pointerId);gainFromPointer(e);e.preventDefault()});
gainSlider.addEventListener('pointermove',e=>{if(e.pointerId===gainPointer)gainFromPointer(e)});
['pointerup','pointercancel','lostpointercapture'].forEach(type=>gainSlider.addEventListener(type,releaseGain));
gainSlider.addEventListener('keydown',e=>{const delta={ArrowRight:1,ArrowUp:1,ArrowLeft:-1,ArrowDown:-1}[e.key];if(delta){e.preventDefault();setGain(+gainSlider.value+delta)}else if(e.key==='Home'||e.key==='End'){e.preventDefault();setGain(+(e.key==='Home'?gainSlider.min:gainSlider.max))}});
$('#battery-form').onsubmit=async e=>{e.preventDefault();const data=Object.fromEntries(new FormData(e.target));try{batteryConfig=await api('save_battery',data);closeModal($('#battery-modal'));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 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=['<option value="0">Unassigned</option>',...Array.from({length:12},(_,n)=>`<option value="${n+1}" ${s.motor===n+1?'selected':''}>Motor ${n+1}</option>`)].join('');row.innerHTML=`<strong>SERVO${i}</strong><select aria-label="SERVO${i} function">${opts}</select><span>${s.direction==null?'—':s.direction===-1?'Reversed':'Normal'}</span><button class="button subtle">Reverse</button>`;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 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=['<option value="0">Unassigned</option>',...Array.from({length:12},(_,n)=>`<option value="${n+1}" ${s.motor===n+1?'selected':''}>Motor ${n+1}</option>`)].join('');row.innerHTML=`<strong>SERVO${i}</strong><select aria-label="SERVO${i} function">${opts}</select><span>${s.direction==null?'—':s.direction===-1?'Reversed':'Normal'}</span><button class="button subtle">Reverse</button>`;row.querySelector('select').onchange=e=>{if(demoActive){const motor=+e.target.value;demoState.servos[String(i)]={function:motor?32+motor:0,label:motor?`Motor ${motor}`:'Unassigned',motor:motor||null,direction:motor?1:null};servoSignature='';renderMotors(demoState.servos);toast(`Demo SERVO${i} updated`)}else api('set_servo_function',i,+e.target.value).catch(x=>toast(x.message,true))};row.querySelector('button').onclick=()=>{if(demoActive){if(s.motor){s.direction=s.direction===-1?1:-1;servoSignature='';renderMotors(demoState.servos);toast(`Demo Motor ${s.motor} reversed`)}else toast(`SERVO${i} is unassigned`,true)}else 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)} function render(s,confirmed=false){
async function poll(){if(!bridgeReady)return;try{render(await api('poll'))}catch(e){}setTimeout(poll,250)} vehicleSnapshot=s;
let booted=false; if(confirmed&&localRequest?.sent && (s.pending_command===localRequest.name || s.command_error ||
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=init.saved_model||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)}} (s.armed_known&&s.armed===(localRequest.name==='arm')))) localRequest=null;
function waitForBridge(){if(window.pywebview?.api)boot();else setTimeout(waitForBridge,50)} const previous=view;
addEventListener('pywebviewready',boot);if(document.readyState==='loading')addEventListener('DOMContentLoaded',waitForBridge);else waitForBridge(); view=ControlState.present(s,{stale:bridgeStale,focused:inputFocused,pending:localRequest?.name,error:commandError});
if((previous.drive&&!view.drive)||(previous.action==='arm'&&view.action!=='arm'))resetInputs();
appState=bridgeStale&&s.connected?'link_degraded':s.app_state||'disconnected';
document.body.dataset.appState=appState;
document.body.dataset.controlsEnabled=String(view.drive);
document.body.dataset.telemetryStale=String(!view.known||!!s.battery_stale);
$('#port').disabled=!!s.connected;
const isOnline=(appState==='connected_disarmed'||appState==='armed');
const connectBtn=$('#connect');
const connectLabel=connectBtn?connectBtn.querySelector('.btn-label'):null;
if(connectBtn){
if(appState==='connecting'){
if(connectLabel)connectLabel.textContent='Connecting…';
else connectBtn.textContent='Connecting…';
connectBtn.classList.add('is-connecting');
connectBtn.classList.remove('is-connected');
}else if(appState!=='disconnected'){
if(connectLabel)connectLabel.textContent='Disconnect';
else connectBtn.textContent='Disconnect';
connectBtn.classList.remove('is-connecting');
connectBtn.classList.add('is-connected');
}else{
if(connectLabel)connectLabel.textContent='Connect';
else connectBtn.textContent='Connect';
connectBtn.classList.remove('is-connecting', 'is-connected');
}
}
const engageBtn=$('#engage');
const engageLabel=$('#engage-label');
if(engageBtn&&engageLabel){
applyEngageVisual(view);
}
const controlStatus=$('#control-status');
controlStatus.textContent=view.message;
controlStatus.classList.toggle('is-error',view.error);
controlStatus.hidden=!(view.pending||view.error||(view.known&&s.armed&&!inputFocused));
const motorBtn=$('[data-open="motors-modal"]');
if(motorBtn)motorBtn.disabled=(!isOnline);
const confirmedCommand=previous.pending&&!view.pending&&view.known&&!view.error;
const reported=commandError||s.command_error||((!view.known&&s.connected&&appState!=='connecting')?'Telemetry stale — vehicle state unknown':s.status);
if(reported&&reported!==lastReportedStatus){lastReportedStatus=reported;if(!confirmedCommand)recordActivity(reported)}
if(confirmedCommand){
recordActivity(s.armed?'Engage confirmed':'Disengage confirmed');
}
updateReadouts();
const b=s.battery;
if(b){
$('#battery-title').textContent=`${b.voltage.toFixed(2)} V`;
$('#battery-percent').textContent=`${Math.round(b.percent)}% • ${b.cells}s${!view.known||s.battery_stale?' · stale':''}`;
$('#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||{});
}
function markBridgeStale(){
if(demoActive||bridgeStale)return;
bridgeStale=true;resetInputs();render(vehicleSnapshot);
}
setInterval(()=>{
const now=performance.now();
if(now-lastSafetyTick>750)resetInputs();
lastSafetyTick=now;
if(!demoActive&&bridgeReady&&vehicleSnapshot.connected&&performance.now()-lastBridgeAt>1500)markBridgeStale();
if(localRequest&&performance.now()>localRequest.deadline){
commandError=`${localRequest.name==='arm'?'Engage':'Disarm'} confirmation timed out. Check the vehicle state.`;
localRequest=null;render(vehicleSnapshot);
}
},100);
async function poll(){
if(!bridgeReady)return;
if(!demoActive)try{
const snapshot=await withTimeout(api('poll'));
if(!demoActive){lastBridgeAt=performance.now();bridgeStale=false;render(snapshot,true)}
}catch(e){markBridgeStale()}
setTimeout(poll,250);
}
let modelLoadPromise=null;
async function loadDefaultModel(){
if(modelLoadPromise)return modelLoadPromise;
modelLoadPromise=(async()=>{
const statusEl=$('#model-status');
// 1. Direct high-speed HTTP fetch (instant, ~20ms)
for(const url of [
'/model.stl',
'/model',
'/runtime/vehicle_model.stl',
'/assets/Main%20Assembly%20V1%20(2).stl',
'../runtime/vehicle_model.stl',
'../assets/Main%20Assembly%20V1%20(2).stl'
]){
try{
const res=await fetch(url);
if(res.ok){
if(statusEl)statusEl.textContent='Loading mesh…';
const buf=await res.arrayBuffer();
if(window.rovModel){
const result=window.rovModel.loadBuffer(buf,'Main Assembly V1 (2).stl');
$('#load-model').textContent='Replace STL';
console.log('[Direct Fetch] Model loaded:', result);
return true;
}
}
}catch(err){
console.warn(`[Direct Fetch] ${url} failed:`, err);
}
}
// 2. Python Bridge Chunked Stream (fallback)
if(window.pywebview?.api?.get_saved_model_info){
try{
const info=await api('get_saved_model_info');
if(info&&info.size){
const total=info.size, chunk=262144;
const bytes=new Uint8Array(total);
let loaded=0;
if(statusEl){
statusEl.className='model-status';
statusEl.textContent=`Streaming ${info.name} (0%)…`;
}
while(loaded<total){
const reqSize=Math.min(chunk,total-loaded);
const resp=await api('read_saved_model_chunk',loaded,reqSize);
const part=atob(resp.data);
for(let i=0;i<part.length;i++)bytes[loaded+i]=part.charCodeAt(i);
loaded+=part.length;
const pct=Math.round((loaded/total)*100);
if(statusEl)statusEl.textContent=`Streaming ${pct}% (${(loaded/(1024*1024)).toFixed(1)}MB)…`;
if(loaded%(chunk*4)===0)await new Promise(r=>setTimeout(r,0));
}
if(window.rovModel){
const res=window.rovModel.loadBuffer(bytes.buffer,info.name);
$('#load-model').textContent='Replace STL';
return true;
}
}
}catch(e){
console.warn('[Bridge Stream] Failed:', e);
if(statusEl){
statusEl.className='model-status error';
statusEl.textContent=`Bridge Error: ${e.message}`;
}
}
}
return false;
})();
return modelLoadPromise;
}
let booted=false,booting=false,bootRetry=0,portRefreshTimer=0;
function hasDesktopApi(){return ['initial_state','refresh_ports','poll'].every(name=>typeof window.pywebview?.api?.[name]==='function')}
async function boot(){
if(booted||booting||!hasDesktopApi())return;
booting=true;clearTimeout(bootRetry);
try{
// OS port discovery must not depend on model/config initialization succeeding.
populatePorts(await withTimeout(api('refresh_ports'),4000));
const init=await withTimeout(api('initial_state'),4000);
batteryConfig=init.battery_config||{};
populatePorts(init.ports);
fillBatteryForm();
bridgeReady=true;booted=true;lastBridgeAt=performance.now();
if(!demoActive)render(init.state);
poll();
// Populate the serial selector before starting the optional model load.
// A large cached mesh must never delay an operator seeing USB COM ports.
loadDefaultModel();
if(!portRefreshTimer)portRefreshTimer=setInterval(async()=>{
if(appState==='disconnected'||demoActive){
try{populatePorts(await withTimeout(api('refresh_ports'),1500))}
catch(error){recordActivity(`Port scan failed: ${error.message}`)}
}
},2000);
}catch(e){
booted=false;bridgeReady=false;
recordActivity(`Desktop startup failed; retrying: ${e.message}`);
toast(`Python bridge: ${e.message}`,true);
bootRetry=setTimeout(boot,1000);
}finally{
booting=false;
}
}
function waitForBridge(){
if(booted)return;
if(hasDesktopApi())boot();
setTimeout(waitForBridge,250);
}
addEventListener('pywebviewready',boot);
render(vehicleSnapshot);
if(document.readyState==='loading'){
addEventListener('DOMContentLoaded',()=>{loadDefaultModel();waitForBridge()});
}else{
loadDefaultModel();
waitForBridge();
}
+32
View File
@@ -0,0 +1,32 @@
/* Presentation derived from telemetry. No animation is allowed to set armed. */
((root) => {
function present(state, {stale=false, focused=true, pending=null, error=''}={}) {
const known = state.armed_known === true && !stale;
const online = !!state.connected && !stale;
const request = pending || state.pending_command;
const uncertain = !known && (state.armed !== null && state.armed !== undefined || !!request || state.heartbeat_age != null);
let label='ENGAGE', tone='idle', action=null, message='Connect to receive vehicle state.';
if (request === 'disarm') {
label='DISARMING…'; tone='danger'; message='Awaiting vehicle confirmation. Not yet confirmed disarmed.';
} else if (request === 'arm') {
label='ENGAGING…'; tone='pending'; action=online?'disarm':null;
message='Awaiting vehicle confirmation. Tap to request disarm.';
} else if (known && state.armed) {
label='DISARM'; tone='danger'; action=online?'disarm':null;
message=focused?'Armed · tap or F3 to disarm.':'Armed · controls neutral while the app is unfocused.';
} else if (uncertain) {
label=online?'DISARM · ?':'STATE UNKNOWN'; tone='danger'; action=online?'disarm':null;
message='Vehicle state unknown. Inputs cleared; disarm is not confirmed.';
} else if (known && online) {
action=focused?'arm':null; message='Disarmed · hold 1.4 seconds to engage.';
}
if (stale) message='Telemetry unavailable. Inputs cleared; vehicle state unknown.';
const failure=error || state.command_error;
return {label,tone,action,pending:!!request,known,
drive:known && online && state.armed === true && !request && focused,
message:failure ? `${failure} ${known ? (state.armed?'Vehicle armed.':'Vehicle disarmed.') : 'Vehicle state unknown.'}` : message,
error:!!(error || state.command_error || uncertain || stale)};
}
root.ControlState={present};
if(typeof module!=='undefined') module.exports=root.ControlState;
})(typeof window!=='undefined'?window:globalThis);
+117 -78
View File
@@ -3,117 +3,156 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="viewport" content="width=device-width,initial-scale=1">
<title>SUB Control</title> <title>Sub Control</title>
<link rel="stylesheet" href="styles.css"> <link rel="stylesheet" href="styles.css?v=20260827-bridge-v5">
</head> </head>
<body> <body>
<header class="window-bar">
<div class="window-drag pywebview-drag-region"><span class="mini-mark">S</span><strong>SUB Control</strong><i id="model-file-name">Built-in vehicle</i></div>
<div class="window-actions">
<button id="window-min" aria-label="Minimize">—</button>
<button id="window-max" aria-label="Maximize">□</button>
<button id="window-close" class="window-close" aria-label="Close">×</button>
</div>
</header>
<main class="app-shell"> <main class="app-shell">
<aside class="vehicle-rail">
<header class="brand"><span class="brand-mark">S</span><span>SUB CONTROL</span><small>03</small></header> <!-- ── DECK (Connection, 3D Model, Battery, Deck Controls, Status) ── -->
<div class="eyebrow">LIVE VEHICLE</div> <aside class="sidebar-deck" aria-label="Vehicle Deck and Status">
<div class="sidebar-drag-surface pywebview-drag-region" aria-hidden="true"></div>
<!-- Connection Controls -->
<div class="deck-connection">
<div class="port-row">
<select id="port" aria-label="Serial port">
<option value="demo" selected>Demo Mode (no hardware)</option>
</select>
<button id="refresh" class="icon-button" title="Refresh ports" aria-label="Refresh ports">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M20 7v5h-5M4 17v-5h5M18.5 9A7 7 0 0 0 6.8 6.8L4 10m16 4-2.8 3.2A7 7 0 0 1 5.5 15"/></svg>
</button>
<button class="icon-button" data-open="link-modal" title="Link settings" aria-label="Link settings">
<svg class="dots-icon" viewBox="0 0 24 24" aria-hidden="true"><circle cx="5" cy="12" r="1.25"/><circle cx="12" cy="12" r="1.25"/><circle cx="19" cy="12" r="1.25"/></svg>
</button>
</div>
<button id="connect" class="button connect-button" aria-label="Toggle connection">
<span class="btn-spinner" aria-hidden="true"></span>
<span class="btn-label">Connect</span>
</button>
</div>
<!-- Telemetry / Status Zone -->
<section class="telemetry-zone" aria-label="Vehicle Telemetry and Status">
<div class="deck-card model-card">
<div class="rov-stage" aria-label="Top view of three-thruster ROV"> <div class="rov-stage" aria-label="Top view of three-thruster ROV">
<div class="water-ring r1"></div><div class="water-ring r2"></div>
<svg id="default-model" viewBox="0 0 260 310" role="img">
<defs>
<linearGradient id="body" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#566168"/><stop offset="1" stop-color="#22292d"/>
</linearGradient>
<filter id="shadow"><feDropShadow dx="0" dy="18" stdDeviation="13" flood-opacity=".45"/></filter>
</defs>
<g filter="url(#shadow)">
<rect x="73" y="34" width="114" height="226" rx="55" fill="url(#body)" stroke="#707b81" stroke-width="2"/>
<rect x="93" y="60" width="74" height="168" rx="35" fill="#30383c" stroke="#49545a"/>
<circle cx="130" cy="145" r="28" fill="#7d898f" stroke="#a3adb1" stroke-width="3"/>
<circle cx="130" cy="145" r="12" fill="#d9e0df"/>
<path d="M78 98 48 80M182 98l30-18" stroke="#7e8a90" stroke-width="9" stroke-linecap="round"/>
<g fill="#191f22" stroke="#7b878c" stroke-width="3">
<ellipse cx="38" cy="74" rx="27" ry="34"/><ellipse cx="222" cy="74" rx="27" ry="34"/>
</g>
<g stroke="#59656a" stroke-width="2">
<path d="M22 54l32 40M54 54 22 94M206 54l32 40M238 54l-32 40"/>
</g>
</g>
</svg>
<canvas id="model-canvas" aria-label="Interactive STL vehicle model"></canvas> <canvas id="model-canvas" aria-label="Interactive STL vehicle model"></canvas>
<div id="model-status" class="model-status">Initializing…</div>
<div class="model-tools"> <div class="model-tools">
<button id="load-model">Load STL</button> <button id="load-model">Load STL</button>
<input id="model-input" type="file" accept=".stl,model/stl,application/sla" hidden> <input id="model-input" type="file" accept=".stl,.STL" hidden>
</div> </div>
</div> </div>
<div class="vehicle-name"><small>VEHICLE</small><strong id="vehicle">No heartbeat</strong><span>SimpleROV-3 • differential drive</span></div> </div>
<div class="rail-bottom">
<!-- Battery Status -->
<button class="battery-card ghost" data-open="battery-modal"> <button class="battery-card ghost" data-open="battery-modal">
<span><small>BATTERY</small><strong id="battery-title">—</strong></span><i id="battery-percent">Waiting</i> <span><small>Battery</small><strong id="battery-title">—</strong></span><i id="battery-percent">waiting</i>
<b><em id="battery-fill"></em></b> <b><em id="battery-fill"></em></b>
</button> </button>
<div class="link-state"><span id="link-dot"></span><strong id="link">DISCONNECTED</strong></div>
<div class="deck-status-bar">
<ol class="activity-history" aria-label="Last three events" aria-live="polite">
<li id="activity-oldest" hidden></li>
<li id="activity-previous" hidden></li>
<li id="status-text">Not connected</li>
</ol>
<header class="activity-heading"><span>Recent activity</span><a id="logs" class="logs-link" title="Open session logs" aria-label="Open session logs">Logs</a></header>
</div> </div>
</section>
</aside> </aside>
<section class="workspace"> <!-- ── PRIMARY CONTROL ZONE ── -->
<header class="topbar"> <section class="controls-workspace" aria-label="Primary Vehicle Controls">
<div><div class="eyebrow">PILOT CONSOLE</div><h1>Drive</h1></div> <!-- Frameless Drag & Window Controls Header -->
<div class="connection"> <div class="workspace-topbar">
<select id="port" aria-label="Serial port"><option value="">Select COM port…</option></select> <div class="workspace-drag-area pywebview-drag-region"></div>
<button id="refresh" class="icon-button" title="Refresh ports">↻</button> <div class="window-actions">
<button class="icon-button" data-open="link-modal" title="Link settings">•••</button> <button id="window-min" aria-label="Minimize" title="Minimize"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12h14"/></svg></button>
<button id="connect" class="button connect-button">Connect</button> <button id="window-max" aria-label="Maximize" title="Maximize"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="5" y="5" width="14" height="14" rx="1.5"/></svg></button>
<button id="window-close" class="window-close" aria-label="Close" title="Close"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 6 12 12M18 6 6 18"/></svg></button>
</div>
</div> </div>
</header>
<section class="drive-grid"> <section class="primary-control-zone" aria-label="Primary Control Zone">
<!-- Reserved Engage / Disarm row -->
<div class="engage-header-zone">
<button id="engage" class="button subtle engage-button" aria-label="Engage" aria-describedby="control-status" title="Engage (F2)">
<span class="engage-face">
<span id="engage-label" class="engage-label">engage</span>
<span class="engage-sheen-label" aria-hidden="true">engage</span>
<span class="engage-progress-label" aria-hidden="true">engage</span>
</span>
</button>
<p id="control-status" class="control-status" role="status" aria-live="polite">Connect to receive vehicle state.</p>
</div>
<!-- Main Joysticks & Thrust Control Grid -->
<div class="controls-grid">
<!-- Steering / Throttle Joystick -->
<article class="control-card steer-card"> <article class="control-card steer-card">
<header><div><small>STEER + THROTTLE</small><strong>Differential drive</strong></div><span class="key-hint">W A S D</span></header> <header>
<div><small>steer + throttle</small></div>
<span class="key-hint">w a s d</span>
</header>
<div class="joystick-pad">
<div id="joystick" class="joystick" touch-action="none"> <div id="joystick" class="joystick" touch-action="none">
<div class="axis vertical-axis"></div><div class="axis horizontal-axis"></div> <canvas id="joystick-canvas" aria-label="Steering and throttle joystick; W A S D keys"></canvas>
<span class="axis-label forward">FORWARD</span><span class="axis-label reverse">REVERSE</span>
<span class="axis-label left">TURN LEFT</span><span class="axis-label right">TURN RIGHT</span>
<div class="stick-shadow"></div><div id="stick" class="stick"><span></span></div>
</div> </div>
<footer><span><i class="dot surge"></i><b id="surge-readout">0</b>% surge</span><span><i class="dot yaw"></i><b id="yaw-readout">0</b>% turn</span></footer> </div>
<footer>
<span class="steer-readout-left">surge <b id="surge-readout">—</b></span>
<span class="steer-readout-right"><b id="yaw-readout">—</b> turn</span>
</footer>
</article> </article>
<!-- Vertical Thruster Slider -->
<article class="control-card vertical-card"> <article class="control-card vertical-card">
<header><div><small>VERTICAL</small><strong>Center thruster</strong></div><span class="key-hint">R / F</span></header> <header>
<div><small>vertical</small></div>
<span class="key-hint">r / f</span>
</header>
<div class="heave-pad">
<div class="vertical-control"> <div class="vertical-control">
<span>RISE</span> <span>rise</span>
<div id="heave" class="heave-track" touch-action="none"><div class="heave-center"></div><div id="heave-fill" class="heave-fill"></div><div id="heave-knob" class="heave-knob"></div></div> <div id="heave" class="heave-track" touch-action="none">
<span>DIVE</span> <div class="heave-center"></div>
<div id="heave-fill" class="heave-fill"></div>
<div id="heave-knob" class="heave-knob"></div>
</div> </div>
<footer><span><i class="dot depth"></i><b id="heave-readout">0</b>% vertical</span></footer> <span>dive</span>
</div>
</div>
<footer>
<span><b id="heave-readout">—</b> vertical</span>
</footer>
</article> </article>
</section>
<section class="command-bar">
<div class="commands">
<button id="manual" class="button subtle">Manual</button>
<button id="arm" class="button arm">Arm</button>
<button id="disarm" class="button danger">Disarm</button>
</div> </div>
<label class="output-limit"><span>Output limit <b id="gain-value">20%</b></span><input id="gain" type="range" min="5" max="50" value="20"></label> <div class="control-toolbar">
<button class="button subtle" data-open="motors-modal">Motor setup</button> <label class="output-limit">
<button id="logs" class="icon-button" title="Open session logs">⌁</button> <span>Output limit <b id="gain-value">20%</b></span>
<div class="range-pill">
<button id="gain-down" type="button" aria-label="Decrease output limit">‹</button>
<input id="gain" aria-label="Output limit" type="range" min="5" max="50" value="20">
<button id="gain-up" type="button" aria-label="Increase output limit">›</button>
</div>
</label>
<button class="button subtle config-btn" data-open="motors-modal">Motor setup</button>
</div>
</section> </section>
<div class="status-line"><span></span><p id="status">Ready. Select a USB port to begin.</p></div>
</section> </section>
</main> </main>
<div id="toast" class="toast"></div> <div id="toast" class="toast"></div>
<dialog id="arm-dialog" class="modal confirm-modal"><button class="modal-close" data-close>×</button><small>SAFETY CHECK</small><h2>Arm the vehicle?</h2><p>Remove propellers or securely restrain the ROV before enabling motor output.</p><div class="modal-actions"><button class="button subtle" data-close>Cancel</button><button id="confirm-arm" class="button arm">Confirm arm</button></div></dialog> <!-- Modals -->
<dialog id="link-modal" class="modal"><button class="modal-close" data-close>×</button><small>CONNECTION</small><h2>Link settings</h2><div class="form-grid two"><label>Baud rate<select id="baud"><option>9600</option><option>19200</option><option>38400</option><option selected>57600</option><option>115200</option></select></label><label>Control rate<select id="rate"><option>10</option><option selected>20</option></select></label></div><p class="note">20 Hz is recommended for smooth intermediate commands on this link.</p></dialog> <dialog id="link-modal" class="modal"><button class="modal-close" data-close>×</button><small>connection</small><h2>Link Settings</h2><div class="form-grid two"><label><span>Baud rate</span><select id="baud"><option>9600</option><option>19200</option><option>38400</option><option selected>57600</option><option>115200</option></select></label><label><span>Control rate</span><select id="rate"><option>10</option><option selected>20</option></select></label></div><div style="margin-top:14px;"><label style="display:flex;flex-direction:column;gap:6px;"><span style="font-size:11px;font-weight:500;color:var(--muted)">Custom Port / Endpoint</span><div style="display:flex;gap:8px;"><input id="custom-port-input" placeholder="e.g. COM3 or tcp:127.0.0.1:5760" style="flex:1;height:32px;padding:0 12px;font-size:11px;"><button type="button" id="custom-port-btn" class="button subtle" style="white-space:nowrap;">Use Port</button></div></label></div><p class="note" style="margin-top:14px;">20 Hz is recommended for smooth intermediate commands on this link.</p></dialog>
<dialog id="battery-modal" class="modal"><button class="modal-close" data-close>×</button><small>POWER</small><h2>Battery settings</h2><form id="battery-form" class="form-grid three"><label>Cells (S)<input name="cells" type="number" min="1"></label><label>Capacity (mAh)<input name="capacity_mah" type="number" min="1"></label><label>C rating<input name="c_rating" type="number" min="1" step=".1"></label><label>Full V / cell<input name="full_v_per_cell" type="number" step=".01"></label><label>Empty V / cell<input name="empty_v_per_cell" type="number" step=".01"></label><label>Warning %<input name="warning_percent" type="number" min="0" max="100"></label><div class="modal-actions wide"><button type="button" class="button subtle" data-close>Cancel</button><button class="button connect-button">Save settings</button></div></form></dialog> <dialog id="battery-modal" class="modal"><button class="modal-close" data-close>×</button><small>Power</small><h2>Battery Settings</h2><form id="battery-form" class="form-grid three"><label><span>Cells (S)</span><div class="number-pill"><button type="button" class="num-step down" tabindex="-1">‹</button><input name="cells" type="number" min="1" step="1"><button type="button" class="num-step up" tabindex="-1">›</button></div></label><label><span>Capacity (mAh)</span><div class="number-pill"><button type="button" class="num-step down" tabindex="-1">‹</button><input name="capacity_mah" type="number" min="1" step="50"><button type="button" class="num-step up" tabindex="-1">›</button></div></label><label><span>C rating</span><div class="number-pill"><button type="button" class="num-step down" tabindex="-1">‹</button><input name="c_rating" type="number" min="1" step=".1"><button type="button" class="num-step up" tabindex="-1">›</button></div></label><label><span>Full V / cell</span><div class="number-pill"><button type="button" class="num-step down" tabindex="-1">‹</button><input name="full_v_per_cell" type="number" step=".01"><button type="button" class="num-step up" tabindex="-1">›</button></div></label><label><span>Empty V / cell</span><div class="number-pill"><button type="button" class="num-step down" tabindex="-1">‹</button><input name="empty_v_per_cell" type="number" step=".01"><button type="button" class="num-step up" tabindex="-1">›</button></div></label><label><span>Warning %</span><div class="number-pill"><button type="button" class="num-step down" tabindex="-1">‹</button><input name="warning_percent" type="number" min="0" max="100" step="1"><button type="button" class="num-step up" tabindex="-1">›</button></div></label><div class="modal-actions wide"><button type="button" class="button subtle" data-close>Cancel</button><button class="button connect-button">Save Settings</button></div></form></dialog>
<dialog id="motors-modal" class="modal motor-modal"><button class="modal-close" data-close>×</button><small>CONFIGURATION</small><h2>Motor outputs</h2><p class="note">Function and direction are read one parameter at a time to protect the low-bandwidth link.</p><div class="motor-head"><span>Output</span><span>Function</span><span>Direction</span><span></span></div><div id="motor-list" class="motor-list"></div></dialog> <dialog id="motors-modal" class="modal motor-modal"><button class="modal-close" data-close>×</button><small>configuration</small><h2>Motor Outputs</h2><p class="note">Function and direction are read one parameter at a time to protect the low-bandwidth link.</p><div class="motor-head"><span>Output</span><span>Function</span><span>Direction</span><span></span></div><div id="motor-list" class="motor-list"></div></dialog>
<script src="model-viewer.js"></script> <script src="model-viewer.js?v=20260826-controls-v2"></script>
<script src="app.js"></script> <script src="joystick-3d.js?v=20260827-polish-v3"></script>
<script src="control-state.js?v=20260827-state-v1"></script>
<script src="app.js?v=20260827-bridge-v6"></script>
</body> </body>
</html> </html>
+42
View File
@@ -0,0 +1,42 @@
/* Visual joystick. Control transport remains in app.js through setJoyControl. */
(() => {
'use strict';
const host=document.getElementById('joystick'),canvas=document.getElementById('joystick-canvas');
if(!host||!canvas)return;
const ctx=canvas.getContext('2d');
let width=0,height=0,dpr=1,x=0,y=0,activePointer=null,returnFrame=0;
function geometry(){
const radius=Math.min(width,height)*.43;
return {cx:width/2,cy:height/2,radius,travel:radius*.67,knob:Math.max(25,Math.min(34,radius*.22))};
}
function resize(){const rect=host.getBoundingClientRect();width=rect.width;height=rect.height;dpr=Math.min(devicePixelRatio||1,2);canvas.width=Math.round(width*dpr);canvas.height=Math.round(height*dpr);canvas.style.width=`${width}px`;canvas.style.height=`${height}px`;draw()}
function draw(){
if(!width||!height)return;
const {cx,cy,radius,travel,knob}=geometry(),kx=cx+x*travel,ky=cy+y*travel,amount=Math.min(1,Math.hypot(x,y));
ctx.setTransform(dpr,0,0,dpr,0,0);ctx.clearRect(0,0,width,height);
ctx.beginPath();ctx.arc(cx,cy,radius,0,Math.PI*2);ctx.fillStyle='#eee';ctx.fill();ctx.strokeStyle='#d3d3d3';ctx.lineWidth=1.5;ctx.stroke();
ctx.strokeStyle='rgba(80,92,87,.14)';ctx.lineWidth=1;ctx.beginPath();ctx.moveTo(cx-radius*.76,cy);ctx.lineTo(cx+radius*.76,cy);ctx.moveTo(cx,cy-radius*.76);ctx.lineTo(cx,cy+radius*.76);ctx.stroke();
ctx.beginPath();ctx.arc(cx,cy,8,0,Math.PI*2);ctx.fillStyle=amount<.03?'#77817d':'#c8ceca';ctx.fill();
if(amount>.025){ctx.beginPath();ctx.moveTo(cx,cy);ctx.lineTo(kx,ky);ctx.strokeStyle='rgba(78,91,85,.24)';ctx.lineWidth=3;ctx.lineCap='round';ctx.stroke()}
ctx.save();ctx.shadowColor='rgba(17,25,21,.22)';ctx.shadowBlur=activePointer===null?8:12;ctx.shadowOffsetY=3;
ctx.beginPath();ctx.arc(kx,ky,knob,0,Math.PI*2);ctx.fillStyle=activePointer===null?'#555':'#353535';ctx.fill();ctx.restore();
ctx.beginPath();ctx.arc(kx,ky,knob,0,Math.PI*2);ctx.strokeStyle='rgba(255,255,255,.42)';ctx.lineWidth=1;ctx.stroke();
ctx.beginPath();ctx.arc(kx,ky,4,0,Math.PI*2);ctx.fillStyle='#dfe5e1';ctx.fill();
}
function publish(){window.setJoyControl?.(x,y)}
function setPosition(nextX,nextY,notify=true){const length=Math.hypot(nextX,nextY);if(length>1){nextX/=length;nextY/=length}x=nextX;y=nextY;draw();if(notify)publish()}
function positionFromEvent(event){const rect=canvas.getBoundingClientRect(),g=geometry();setPosition((event.clientX-rect.left-g.cx)/g.travel,(event.clientY-rect.top-g.cy)/g.travel)}
function returnToCenter(){cancelAnimationFrame(returnFrame);const startX=x,startY=y,start=performance.now();const tick=now=>{const p=Math.min(1,(now-start)/360),smooth=p*p*(3-2*p);setPosition(startX*(1-smooth),startY*(1-smooth));if(p<1)returnFrame=requestAnimationFrame(tick)};returnFrame=requestAnimationFrame(tick)}
function release(event){if(activePointer===null||(event?.pointerId!=null&&event.pointerId!==activePointer))return;const pointer=activePointer;activePointer=null;try{canvas.releasePointerCapture(pointer)}catch(_){}canvas.classList.remove('is-active');draw();returnToCenter()}
canvas.addEventListener('pointerdown',event=>{if(activePointer!==null||!window.canDrive?.()||event.button>0)return;activePointer=event.pointerId;cancelAnimationFrame(returnFrame);canvas.setPointerCapture(event.pointerId);canvas.classList.add('is-active');positionFromEvent(event);event.preventDefault()});
canvas.addEventListener('pointermove',event=>{if(event.pointerId===activePointer)positionFromEvent(event)});
canvas.addEventListener('pointerup',release);canvas.addEventListener('pointercancel',release);canvas.addEventListener('lostpointercapture',event=>{if(event.pointerId===activePointer)release(event)});
canvas.addEventListener('pointerleave',event=>{if(event.pointerType==='mouse'&&event.pointerId===activePointer)release(event)});
addEventListener('resize',resize);resize();
window.joystick3D={setPos(nextX,nextY){cancelAnimationFrame(returnFrame);setPosition(nextX,nextY)},easeBack(){if(activePointer===null)returnToCenter()},reset(){const pointer=activePointer;activePointer=null;cancelAnimationFrame(returnFrame);if(pointer!==null)try{canvas.releasePointerCapture(pointer)}catch(_){}canvas.classList.remove('is-active');setPosition(0,0,false)},getState(){return {x,y,active:activePointer!==null}}};
})();
+491 -436
View File
File diff suppressed because it is too large Load Diff
+1117 -12
View File
File diff suppressed because it is too large Load Diff
+329 -73
View File
@@ -7,13 +7,16 @@ import threading
import time import time
import base64 import base64
import shutil import shutil
import uuid import http.server
import socketserver
import urllib.parse
from pathlib import Path from pathlib import Path
import webview import webview
from serial.tools import list_ports from serial.tools import list_ports
from app import Axes, MOTOR_FUNCTIONS, MavlinkWorker, motor_number_for_function from app import Axes, MOTOR_FUNCTIONS, MavlinkWorker, motor_number_for_function
from vehicle_state import VehicleState
class ControlApi: class ControlApi:
@@ -22,12 +25,19 @@ class ControlApi:
def __init__(self): def __init__(self):
self.events: queue.Queue = queue.Queue() self.events: queue.Queue = queue.Queue()
self.worker = MavlinkWorker(self.events) self.worker = MavlinkWorker(self.events)
self.worker.require_input_lease = True
self.vehicle_state = VehicleState()
self.battery_received_at = None
self.demo_mode = False
self.lock = threading.Lock() self.lock = threading.Lock()
self._poll_lock = threading.Lock()
self.state = { self.state = {
"connected": False, "connected": False,
"link": "DISCONNECTED", "link": "DISCONNECTED",
"vehicle": "No heartbeat", "vehicle": "No heartbeat",
"armed": False, "armed": False,
"mode": None,
"app_state": "disconnected",
"status": "Ready. Select a USB port to begin.", "status": "Ready. Select a USB port to begin.",
"battery": None, "battery": None,
"log_path": None, "log_path": None,
@@ -39,8 +49,6 @@ class ControlApi:
self.model_path = Path(__file__).resolve().parent / "model_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 = Path(__file__).resolve().parent / "runtime" / "vehicle_model.stl"
self.model_cache_name = Path(__file__).resolve().parent / "runtime" / "vehicle_model_name.txt" 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 # Leading underscore keeps the native WebView object out of js_api
# serialization. Only the small public methods below are exposed. # serialization. Only the small public methods below are exposed.
self._window = None self._window = None
@@ -57,14 +65,26 @@ class ControlApi:
def initial_state(self): def initial_state(self):
return { return {
"ports": [p.device for p in list_ports.comports()], "ports": self.refresh_ports(),
"battery_config": self.battery_config, "battery_config": self.battery_config,
"saved_model": self.get_saved_model_info(),
"state": self._snapshot(), "state": self._snapshot(),
"model_info": self.get_saved_model_info(),
} }
def refresh_ports(self): def refresh_ports(self):
return [p.device for p in list_ports.comports()] ports = []
# Add physical and virtual serial COM ports
for p in list_ports.comports():
desc = p.description or ""
if desc and desc != "n/a" and desc != p.device:
label = f"{p.device} ({desc})"
else:
label = p.device
ports.append({"device": p.device, "label": label, "type": "serial"})
# Built-in UI testing: no external process, port, or MAVLink socket.
ports.insert(0, {"device": "demo", "label": "Demo Mode (no hardware)", "type": "demo"})
return ports
def minimize_window(self): def minimize_window(self):
if self._window: if self._window:
@@ -86,62 +106,83 @@ class ControlApi:
self._window.destroy() self._window.destroy()
return True return True
def begin_model_save(self, name, size): def choose_model(self):
selected = None
# 1. Native Windows topmost open file dialog (instant, reliable)
try:
import tkinter as tk
from tkinter import filedialog
root = tk.Tk()
root.withdraw()
root.attributes("-topmost", True)
file_path = filedialog.askopenfilename(
title="Select 3D STL Vehicle Model",
initialdir=str(Path.home() / "Downloads"),
filetypes=[("STL 3D Meshes (*.stl)", "*.stl"), ("All files (*.*)", "*.*")],
)
root.destroy()
if file_path:
selected = (file_path,)
except Exception:
pass
# 2. Fallback to pywebview create_file_dialog
if not selected and self._window:
try:
selected = self._window.create_file_dialog(
webview.FileDialog.OPEN,
directory=str(Path.home()),
allow_multiple=False,
file_types=("STL mesh (*.stl)", "All files (*.*)"),
)
except Exception:
selected = None
if not selected:
return None
path = Path(selected[0])
if not path.is_file() or not path.suffix.lower().endswith(".stl"):
raise ValueError("Choose an STL mesh")
if path.stat().st_size > 30 * 1024 * 1024:
raise ValueError("The STL is larger than 30 MB; export a lighter mesh")
content = path.read_bytes()
self.model_cache.parent.mkdir(exist_ok=True)
self.model_cache.write_bytes(content)
self.model_cache_name.write_text(path.name, encoding="utf-8")
try:
self.model_path.write_text(json.dumps({"path": str(path)}, indent=2) + "\n", encoding="utf-8")
except Exception:
pass
return {"name": path.name, "data": base64.b64encode(content).decode("ascii")}
def save_model(self, name, data):
safe_name = Path(str(name)).name safe_name = Path(str(name)).name
if not safe_name.lower().endswith(".stl"): if not safe_name.lower().endswith(".stl"):
raise ValueError("Choose an STL mesh") raise ValueError("Choose an STL mesh")
size = int(size) try:
if not 0 < size <= 30 * 1024 * 1024: content = base64.b64decode(data, validate=True)
except (ValueError, TypeError) as exc:
raise ValueError("Invalid STL data") from exc
if not content or len(content) > 30 * 1024 * 1024:
raise ValueError("STL must be between 1 byte and 30 MB") raise ValueError("STL must be between 1 byte and 30 MB")
self.model_cache.parent.mkdir(exist_ok=True) self.model_cache.parent.mkdir(exist_ok=True)
token = uuid.uuid4().hex self.model_cache.write_bytes(content)
temp = self.model_cache.parent / f".{token}.upload" self.model_cache_name.write_text(safe_name, encoding="utf-8")
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 return True
def get_saved_model_info(self): def get_saved_model_info(self):
self._migrate_legacy_model() self._migrate_legacy_model()
try: try:
if self.model_cache.is_file() and self.model_cache.stat().st_size <= 30 * 1024 * 1024: if self.model_cache.is_file() and self.model_cache.stat().st_size <= 30 * 1024 * 1024:
name = self.model_cache_name.read_text(encoding="utf-8").strip() or "vehicle_model.stl" name = self.model_cache_name.read_text(encoding="utf-8").strip() or "Main Assembly V1 (2).stl"
return {"name": name, "size": self.model_cache.stat().st_size} return {"name": name, "size": self.model_cache.stat().st_size}
except (OSError, ValueError): except (OSError, ValueError):
pass pass
return None return None
def read_saved_model_chunk(self, offset, size=1048576): def read_saved_model_chunk(self, offset, size=262144):
offset, size = int(offset), min(int(size), 1048576) offset, size = int(offset), min(int(size), 524288)
if offset < 0 or size < 1 or not self.model_cache.is_file(): if offset < 0 or size < 1 or not self.model_cache.is_file():
raise ValueError("Saved model is unavailable") raise ValueError("Saved model is unavailable")
with self.model_cache.open("rb") as stream: with self.model_cache.open("rb") as stream:
@@ -150,8 +191,7 @@ class ControlApi:
return {"offset": offset, "data": base64.b64encode(content).decode("ascii"), "eof": offset + len(content) >= self.model_cache.stat().st_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): def _migrate_legacy_model(self):
if self.model_cache.is_file(): if not self.model_cache.is_file():
return
default_asset = Path(__file__).resolve().parent / "assets" / "Main Assembly V1 (2).stl" default_asset = Path(__file__).resolve().parent / "assets" / "Main Assembly V1 (2).stl"
if default_asset.is_file(): if default_asset.is_file():
try: try:
@@ -162,37 +202,103 @@ class ControlApi:
except Exception: except Exception:
pass pass
try: try:
if self.model_path.is_file():
path = Path(json.loads(self.model_path.read_text(encoding="utf-8"))["path"]) 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: 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) self.model_cache.parent.mkdir(exist_ok=True)
shutil.copyfile(path, self.model_cache) shutil.copyfile(path, self.model_cache)
self.model_cache_name.write_text(path.name, encoding="utf-8") self.model_cache_name.write_text(path.name, encoding="utf-8")
except (OSError, ValueError, TypeError, KeyError): except Exception:
pass pass
def saved_model(self):
self._migrate_legacy_model()
try:
if self.model_cache.is_file() and self.model_cache.stat().st_size <= 30 * 1024 * 1024:
name = self.model_cache_name.read_text(encoding="utf-8").strip() or "Main Assembly V1 (2).stl"
return {"name": name, "data": base64.b64encode(self.model_cache.read_bytes()).decode("ascii")}
except (OSError, ValueError):
pass
return None
def connect(self, port, baud=57600, rate=20): def connect(self, port, baud=57600, rate=20):
print(f"[web_ui] connect() called: port={port!r}, baud={baud!r}, rate={rate!r}")
if not port: if not port:
raise ValueError("Select a USB serial port") raise ValueError("Select a USB serial port")
self.worker.rate_hz = int(rate) if str(port).lower() in ("demo", "tcp:127.0.0.1:5760", "udpin:127.0.0.1:14550"):
self.worker.connect(str(port), int(baud)) self.demo_mode = True
self.servo_functions = {1: 33, 2: 34, 3: 35}
self.motor_directions = {1: 1, 2: 1, 3: 1}
with self.lock: with self.lock:
self.state.update(
connected=True, link="LINK OK", vehicle="DEMO • DISARMED",
armed=False, mode="MANUAL", status="Demo mode ready — no hardware connected.",
battery={"voltage": 15.8, "per_cell": 3.95, "percent": 72,
"cells": 4, "low": False},
)
for servo in range(1, 17):
function = self.servo_functions.get(servo, 0)
motor = motor_number_for_function(function)
self.state["servos"][str(servo)] = {
"function": function, "label": MOTOR_FUNCTIONS.get(function, "Unassigned"),
"motor": motor, "direction": self.motor_directions.get(motor) if motor else None,
}
return self._snapshot()
self.worker.rate_hz = int(rate)
with self.lock:
self.vehicle_state.disconnect()
self.state.update( self.state.update(
connected=True, link="CONNECTING", connected=True, link="CONNECTING",
status=f"Opening {port} at {baud} baud…", status=f"Opening {port} at {baud} baud…",
) )
return True self.worker.connect(str(port), int(baud))
print(f"[web_ui] connect() dispatched to worker thread OK")
# Return only once the worker has either seen a heartbeat or reported a
# connection failure. This makes the button independent of the regular
# UI polling loop and prevents an indefinite visual "Connecting" state.
deadline = time.monotonic() + 8.0
while time.monotonic() < deadline:
snapshot = self.poll()
if snapshot["app_state"] != "connecting":
return snapshot
time.sleep(0.05)
self.worker.disconnect()
time.sleep(0.1)
self.poll()
raise TimeoutError(f"No MAVLink heartbeat received from {port} within 8 seconds")
def disconnect(self): def disconnect(self):
if self.demo_mode:
self.demo_mode = False
with self.lock:
self.state.update(connected=False, link="DISCONNECTED", vehicle="No heartbeat",
armed=False, mode=None, battery=None, status="Demo mode disconnected")
return True
self.worker.disconnect() self.worker.disconnect()
return True return True
def set_axes(self, forward, turn, vertical): def set_axes(self, forward, turn, vertical, sequence=None):
if self.demo_mode:
with self.lock:
self.state["status"] = (
f"Demo controls • surge {float(forward):+.2f} • "
f"yaw {float(turn):+.2f} • heave {float(vertical):+.2f}"
)
return True
with self.lock:
allowed = self.vehicle_state.snapshot()["controls_allowed"]
self.worker.set_axes(Axes( self.worker.set_axes(Axes(
max(-1.0, min(1.0, float(forward))), max(-1.0, min(1.0, float(forward))), 0.0,
0.0,
max(-1.0, min(1.0, float(vertical))), max(-1.0, min(1.0, float(vertical))),
max(-1.0, min(1.0, float(turn))), max(-1.0, min(1.0, float(turn))),
)) ) if allowed else Axes(), sequence)
return True
def stop_controls(self, sequence=None):
"""Invalidate queued input without claiming that the vehicle disarmed."""
with self.lock:
self.worker.set_axes(Axes(), sequence)
self.worker._send_control(force_neutral=True)
return True return True
def set_gain(self, value): def set_gain(self, value):
@@ -200,18 +306,45 @@ class ControlApi:
self.worker.gain = value / 100.0 self.worker.gain = value / 100.0
return value return value
def command(self, name): def command(self, name, sequence=None, expires_at=None):
if self.demo_mode:
if name not in ("manual", "arm", "disarm"):
raise ValueError(f"Unknown command: {name}")
armed = name == "arm" if name != "manual" else self.state.get("armed", False)
with self.lock:
self.state.update(armed=armed, mode="MANUAL",
vehicle=f"DEMO • {'ARMED' if armed else 'DISARMED'}",
status=f"Demo: {'Manual mode' if name == 'manual' else name.capitalize()} confirmed")
return True
if not self.worker.connection or not self.worker.target_system:
if name == "manual":
raise RuntimeError("Connect to vehicle to enable Manual mode")
elif name == "arm":
raise RuntimeError("Connect to vehicle before arming")
elif name == "disarm":
raise RuntimeError("Connect to vehicle before disarming")
raise RuntimeError("No autopilot heartbeat")
if name in ("arm", "disarm"):
self.poll()
with self.lock:
if name == "arm":
if expires_at is not None and time.time() * 1000 > expires_at:
raise RuntimeError("Engage request expired before it could be sent")
if sequence is not None and sequence <= self.worker.last_input_sequence:
raise RuntimeError("Engage canceled by a newer input reset")
self.vehicle_state.request(name)
self.worker.set_axes(Axes(), sequence)
try:
self.worker.command_arm(name == "arm")
except Exception as exc:
self.vehicle_state.fail(f"{name.capitalize()} send failed: {exc}")
raise
return self._snapshot()
if name == "manual": if name == "manual":
self.worker.log("OPERATOR", "clicked SET MANUAL") self.worker.log("OPERATOR", "clicked SET MANUAL")
self.worker.set_manual_mode() 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: else:
raise ValueError("Unknown command") raise ValueError(f"Unknown command: {name}")
return True return True
def set_servo_function(self, servo, motor): def set_servo_function(self, servo, motor):
@@ -219,6 +352,10 @@ class ControlApi:
if not 1 <= servo <= 16 or not 0 <= motor <= 12: if not 1 <= servo <= 16 or not 0 <= motor <= 12:
raise ValueError("Invalid servo or motor") raise ValueError("Invalid servo or motor")
function = 0 if motor == 0 else (32 + motor if motor <= 8 else 73 + motor) function = 0 if motor == 0 else (32 + motor if motor <= 8 else 73 + motor)
if self.demo_mode:
self.servo_functions[servo] = function
self._update_servo(servo)
return True
self.worker.set_parameter(f"SERVO{servo}_FUNCTION", function) self.worker.set_parameter(f"SERVO{servo}_FUNCTION", function)
return True return True
@@ -230,6 +367,10 @@ class ControlApi:
current = self.motor_directions.get(motor) current = self.motor_directions.get(motor)
if current is None: if current is None:
raise ValueError(f"MOT_{motor}_DIRECTION has not been read yet") raise ValueError(f"MOT_{motor}_DIRECTION has not been read yet")
if self.demo_mode:
self.motor_directions[motor] = -1 if current == 1 else 1
self._update_servo(servo)
return True
self.worker.set_parameter(f"MOT_{motor}_DIRECTION", -1 if current == 1 else 1) self.worker.set_parameter(f"MOT_{motor}_DIRECTION", -1 if current == 1 else 1)
return True return True
@@ -259,13 +400,15 @@ class ControlApi:
return True return True
def poll(self): def poll(self):
while True: # Bound a poll even if telemetry arrives continuously.
with self._poll_lock:
for _ in range(500):
try: try:
event = self.events.get_nowait() event = self.events.get_nowait()
except queue.Empty: except queue.Empty:
break break
self._consume(event) self._consume(event)
if self.worker.target_system and time.monotonic() - self.worker.last_heartbeat > 5: if self.worker.target_system and time.monotonic() - self.worker.last_heartbeat > 3:
with self.lock: with self.lock:
self.state["link"] = ( self.state["link"] = (
"DATA OK / HEARTBEAT DELAYED" "DATA OK / HEARTBEAT DELAYED"
@@ -273,24 +416,49 @@ class ControlApi:
) )
return self._snapshot() return self._snapshot()
def _derive_app_state(self) -> str:
if not self.state.get("connected"):
return "disconnected"
link = self.state.get("link", "")
if link == "CONNECTING":
return "connecting"
if link in ("DATA OK / HEARTBEAT DELAYED", "ALL LINK DATA LOST"):
return "link_degraded"
if self.state.get("armed"):
return "armed"
return "connected_disarmed"
def _snapshot(self): def _snapshot(self):
with self.lock: with self.lock:
return json.loads(json.dumps(self.state)) snap = json.loads(json.dumps(self.state))
snap["app_state"] = self._derive_app_state()
if not self.demo_mode:
snap.update(self.vehicle_state.snapshot())
if snap["connected"] and snap["app_state"] != "connecting" and not snap["armed_known"]:
snap["app_state"] = "link_degraded"
snap["battery_stale"] = (self.battery_received_at is None
or time.monotonic() - self.battery_received_at > 5)
return snap
def _consume(self, event): def _consume(self, event):
kind = event[0] kind = event[0]
with self.lock: with self.lock:
if kind == "heartbeat": if kind == "heartbeat":
_, system, autopilot, mode, armed = event _, system, autopilot, mode, armed = event[:5]
self.vehicle_state.heartbeat(armed, event[5] if len(event) > 5 else None)
self.state.update( self.state.update(
connected=True, link="LINK OK", vehicle=f"{mode} • {'ARMED' if armed else 'DISARMED'}", connected=True, link="LINK OK", vehicle=f"{mode} • {'ARMED' if armed else 'DISARMED'}",
armed=bool(armed), armed=bool(armed),
mode=str(mode).upper(),
) )
elif kind in ("status", "statustext"): elif kind in ("status", "statustext"):
self.state["status"] = event[1] self.state["status"] = event[1]
elif kind == "ack": elif kind == "ack":
if event[1] == 400:
self.vehicle_state.ack(event[2], event[3] if len(event) > 3 else None)
self.state["status"] = f"Command {event[1]} ACK: {event[2]}" self.state["status"] = f"Command {event[1]} ACK: {event[2]}"
elif kind == "battery": elif kind == "battery":
self.battery_received_at = time.monotonic()
voltage = float(event[1]) voltage = float(event[1])
cells = self.battery_config["cells"] cells = self.battery_config["cells"]
per_cell = voltage / cells per_cell = voltage / cells
@@ -316,9 +484,14 @@ class ControlApi:
self.state["log_path"] = event[1] self.state["log_path"] = event[1]
elif kind == "error": elif kind == "error":
self.state["status"] = event[1] self.state["status"] = event[1]
self.state["last_error"] = event[1]
elif kind == "disconnected": elif kind == "disconnected":
self.vehicle_state.disconnect()
last_err = self.state.get("last_error")
status_msg = last_err if last_err else "Disconnected"
self.state.update(connected=False, link="DISCONNECTED", vehicle="No heartbeat", self.state.update(connected=False, link="DISCONNECTED", vehicle="No heartbeat",
armed=False, battery=None, status="Disconnected") mode=None, status=status_msg)
self.state["last_error"] = None
def _update_servo(self, servo): def _update_servo(self, servo):
function = self.servo_functions.get(servo) function = self.servo_functions.get(servo)
@@ -334,23 +507,106 @@ class ControlApi:
self.worker.disconnect() self.worker.disconnect()
def _apply_dwm_styling(window):
try:
hwnd = None
if hasattr(window, "native") and window.native:
hwnd = int(window.native.Handle.ToInt64())
if hwnd:
import ctypes
user32 = ctypes.windll.user32
dwmapi = ctypes.windll.dwmapi
corner_pref = ctypes.c_int(2) # DWMWCP_ROUND
dwmapi.DwmSetWindowAttribute(
ctypes.c_void_p(hwnd), 33, ctypes.byref(corner_pref), ctypes.sizeof(corner_pref)
)
dark_mode = ctypes.c_int(1) # DWMWA_USE_IMMERSIVE_DARK_MODE
dwmapi.DwmSetWindowAttribute(
ctypes.c_void_p(hwnd), 20, ctypes.byref(dark_mode), ctypes.sizeof(dark_mode)
)
user32.SetForegroundWindow(ctypes.c_void_p(hwnd))
except Exception:
pass
def _get_window_size() -> tuple[int, int, tuple[int, int]]:
target_w, target_h = 1100, 650
min_w, min_h = 960, 580
try:
import ctypes
user32 = ctypes.windll.user32
sw = user32.GetSystemMetrics(0)
sh = user32.GetSystemMetrics(1)
if sw > 0 and sh > 0:
target_w = min(target_w, max(min_w, int(sw * 0.92)))
target_h = min(target_h, max(min_h, int(sh * 0.90)))
min_w = min(min_w, target_w)
min_h = min(min_h, target_h)
except Exception:
pass
return target_w, target_h, (min_w, min_h)
class AppHTTPHandler(http.server.SimpleHTTPRequestHandler):
def log_message(self, format, *args):
pass # Suppress HTTP request logs in console
def end_headers(self):
self.send_header("Cache-Control", "no-cache, no-store, must-revalidate")
self.send_header("Pragma", "no-cache")
self.send_header("Expires", "0")
super().end_headers()
def do_GET(self):
unquoted = urllib.parse.unquote(self.path)
if self.path in ("/model", "/model.stl", "/runtime/vehicle_model.stl") or "Main Assembly" in unquoted or "vehicle_model" in unquoted:
project_root = Path(__file__).resolve().parent
model_file = project_root / "assets" / "Main Assembly V1 (2).stl"
if not model_file.is_file():
model_file = project_root / "runtime" / "vehicle_model.stl"
if model_file.is_file():
data = model_file.read_bytes()
self.send_response(200)
self.send_header("Content-Type", "model/stl")
self.send_header("Content-Length", str(len(data)))
self.send_header("Access-Control-Allow-Origin", "*")
self.end_headers()
self.wfile.write(data)
return
return super().do_GET()
def start_local_server(directory: Path) -> int:
handler = lambda *args, **kwargs: AppHTTPHandler(*args, directory=str(directory), **kwargs)
server = socketserver.TCPServer(("127.0.0.1", 0), handler)
port = server.server_address[1]
threading.Thread(target=server.serve_forever, daemon=True).start()
return port
def main(): def main():
api = ControlApi() api = ControlApi()
ui_path = Path(__file__).resolve().parent / "web" / "index.html" api._migrate_legacy_model()
project_root = Path(__file__).resolve().parent
port = start_local_server(project_root)
app_url = f"http://127.0.0.1:{port}/web/index.html?v={int(time.time())}"
win_w, win_h, min_dims = _get_window_size()
window = webview.create_window( window = webview.create_window(
"SUB Control", url=str(ui_path), js_api=api, "SUB Control", url=app_url, js_api=api,
width=1120, height=760, min_size=(900, 650), width=win_w, height=win_h, min_size=min_dims,
background_color="#111719", frameless=True, easy_drag=False, background_color="#111719", frameless=True, easy_drag=False,
shadow=True, shadow=True,
) )
api._window = window api._window = window
window.events.shown += lambda: _apply_dwm_styling(window)
window.events.maximized += lambda: setattr(api, "maximized", True) window.events.maximized += lambda: setattr(api, "maximized", True)
window.events.restored += lambda: setattr(api, "maximized", False) window.events.restored += lambda: setattr(api, "maximized", False)
try: try:
webview.start(debug=False) webview.start(debug=True)
finally: finally:
api.close() api.close()
if __name__ == "__main__": if __name__ == "__main__":
main() main()